FessクローラーのURL処理をjava.net.URIからjava.net.URLに戻した

FessのクローラーでURL処理に使っていたjava.net.URLjava.net.URIに置き換える対応を以前行いましたが、URIでは対応できない文字が多く問題が発生したため、java.net.URLに戻しました。

背景

Javaではjava.net.URLjava.net.URIの2つのURLを扱うクラスがあります。java.net.URLは古くからあるクラスで、java.net.URIはRFC 2396に準拠したより厳密なクラスです。一般的にはURIの使用が推奨されていますが、Webクローラーの用途では事情が異なります。

Webの世界には、RFCに準拠していない非標準的なURLが数多く存在します。URI.create()はこれらの非標準URLをIllegalArgumentExceptionで拒否してしまいますが、java.net.URLはより寛容に処理できます。

変更内容

PR #3066で、FessXpathTransformerProtocolHelperの2つのクラスを中心に、URIからURLへの変更を行いました。

FessXpathTransformer

主な変更点は以下の通りです。

  • java.net.URI/URISyntaxExceptionjava.net.URL/MalformedURLExceptionに変更
  • getURI()メソッドをgetURL()に、getBaseUri()getBaseUrl()にリネーム
  • addChildUrlFromTagAttribute()の引数の型をURIからURLに変更
  • 相対URL解決をURI.resolve()からnew URL(base, spec)コンストラクタに変更

new URL(base, spec)コンストラクタは相対URLの解決をネイティブに処理できるため、URI使用時に必要だった手動でのフォールバック処理(//で始まるURL、?#で始まるURL、相対パスなど)を大幅に簡素化できました。

// 変更前: URI.resolve()を使用
final URI childUri = uri.resolve(resolveTarget);
u = encodeUrl(normalizeUrl(childUri.toString()), encoding);

// 変更後: new URL(base, spec)を使用
final URL childUrl = new URL(url, urlValue.startsWith(":") ? url.getProtocol() + urlValue : urlValue);
String childUrlStr = childUrl.toExternalForm();

一方で、/../で始まるパスの正規化処理は引き続き必要なため、その部分は残しています。

ProtocolHelper

ProtocolHelperでは、リソースのプロトコルチェックで不要なURI変換を削除し、URL.getProtocol()を直接使用するようにしました。URIへの変換はFileコンストラクタがURIを要求する箇所でのみ残しています。

URIで問題が起きた理由

java.net.URIはRFC 2396に厳密に準拠しているため、以下のような文字を含むURLを処理できません。

  • ブラケット([]
  • 一部のUnicode文字
  • パーセント記号の不正なエンコーディング
  • HTMLエンティティを含むURL

Webクローラーは様々なサイトのHTMLを解析するため、このような非標準的なURLに頻繁に遭遇します。URI.create()IllegalArgumentExceptionを投げると、そのURLはクロール対象から外れてしまい、取得漏れの原因になります。

テストの追加

今回の変更に合わせて、特殊文字を含むURLに対するテストも追加しました。

  • FessXpathTransformerTest: getBaseUrl()のテストをリネームし、URL APIに合わせてアサーションを更新
  • CrawlingInfoHelperIndexExportJobDocumentUtilにブラケット、パーセント記号、Unicode、HTMLエンティティを含むURLのテストを追加

まとめ

java.net.URIはRFC準拠の厳密なURL処理には適していますが、Webクローラーのように非標準的なURLを扱う必要がある場面ではjava.net.URLの方が実用的です。今回の変更により、new URL(base, spec)による相対URL解決のおかげでコードも簡素化でき、クローラーの堅牢性も向上しました。

FessのAI検索モードでコンテンツプロキシ対応のリンクを生成

FessのAI検索モードで、検索結果のソースリンクがコンテンツプロキシ(/go/ URL)を利用していない問題がありました。通常の検索では、検索結果のリンクは /go/ URLを経由することで、クリックトラッキングやアクセス制御のログ記録が行われますが、AI検索モードではこの仕組みが使われていませんでした。今回、通常の検索と同様に /go/ URLを生成するように修正しました。

問題

通常の検索結果では、各結果のリンクが /go/?doc.id=...&rt=...&qt=... のような形式で、コンテンツプロキシを経由します。これにより、以下のことが可能になります。

  • クリックトラッキング(どの検索結果がクリックされたか)
  • アクセス制御のログ記録
  • URLのリダイレクト管理

しかし、AI検索モードのソースカードでは、生のURLがそのまま使われていたため、これらの機能が働いていませんでした。

変更内容

ChatClient.java

主な変更は ChatClient.java に集中しています。

  • ChatSearchResult ラッパークラスを導入し、検索結果のドキュメントと一緒に queryIdrequestedTime を保持できるようにしました
  • buildGoUrl() メソッドを追加し、URLエンコードされた /go/ リンクを構築します
  • addSourcesToMessage() メソッドで、ソース情報の付与を一箇所に集約しました
  • resolveContextPath() で、SSE処理の前にHTTPコンテキストパスを取得します(SSE処理中はリクエストコンテキストが利用できなくなる可能性があるため)

ThreadLocalを使って searchDocuments メソッドからメタデータを伝播させることで、protectedなAPIのシグネチャを変更せずに済んでいます。

ChatMessage.java

ChatSource クラスに urlLinkgoUrl フィールドを追加し、ドキュメントマップから url_link を読み取れるようにしました。

chat.js

ソースカードのリンクを goUrl → urlLink → url の優先順位で解決するように変更しました。goUrl が利用できない場合でも後方互換性を保っています。

テスト

ChatClientTest.java に以下のユニットテストが追加されています。

  • buildGoUrl の基本的な動作
  • コンテキストパスがある場合の動作
  • docIdやqueryIdがnullの場合の動作
  • 特殊文字のURLエンコード
  • ChatSource のフィールドアクセサ

関連リンク

FessのAI検索モードのログレベルを整理

FessのAI検索モード(RAG/LLM機能)では、各コンポーネントのログ出力が統一されておらず、運用時のトラブルシューティングやログ分析がしにくい状況でした。今回、ログレベルとプレフィックスを整理して、運用しやすくしました。

変更の背景

AI検索モードでは、チャットリクエストの処理、LLMクライアントの管理、意図検出、検索結果の評価など、複数のコンポーネントが連携して動作します。しかし、これまでのログ出力にはいくつかの問題がありました。

  • ログメッセージにプレフィックスが付いていないものがあり、RAG/LLM関連のログをフィルタリングしにくい
  • チャット完了時のログがdebugレベルだったため、本番環境でレイテンシやトークン使用量を把握するにはdebugログを有効にする必要があった
  • エラーログでLlmExceptionと予期しない例外が区別されていなかった
  • LLMクライアントのライフサイクルイベント(初期化・シャットダウン)がログに記録されていなかった

ログプレフィックスの統一

すべてのRAG/LLM関連のログメッセージに、以下のプレフィックスを付けるようにしました。

プレフィックス用途
[RAG]チャット処理全般(リクエスト、レスポンス、エラー)
[LLM]LLMクライアントの管理(初期化、シャットダウン、同時実行制御)
[RAG:INTENT]意図検出(ユーザーの質問の意図を判定する処理)
[RAG:EVAL]検索結果の評価(検索結果の関連性を判定する処理)

これにより、grep '[RAG]'grep '[LLM]'でログを簡単にフィルタリングできるようになりました。

ログレベルの見直し

debug → info への昇格

チャット完了時のログをdebugからinfoレベルに変更しました。これにより、本番環境でもレイテンシやソース数などの重要なメトリクスを確認できます。

[RAG] Chat completed. sessionId=xxx, sourcesCount=5, elapsedTime=1234ms
[RAG] Stream chat completed. sessionId=xxx, sourcesCount=3, elapsedTime=2345ms
[RAG] Enhanced chat completed. sessionId=xxx, intent=SEARCH, sourcesCount=5, responseLength=500, elapsedTime=3456ms

意図検出や検索結果評価の完了ログもinfoレベルで出力するようにしました。

[RAG:INTENT] Intent detected. intent=SEARCH, query=xxx, elapsedTime=500ms
[RAG:EVAL] Evaluation completed. hasRelevant=true, relevantCount=3, totalResults=10, elapsedTime=300ms

LLMクライアントのライフサイクルログの追加

LLMクライアントの初期化・シャットダウン時にinfoレベルでログを出力するようにしました。

[LLM] OpenAI initialized. model=gpt-4, timeout=30000ms, maxConcurrent=5
[LLM] OpenAI shutting down.

同時実行制限とリクエスト中断のwarnログ追加

同時実行制限の超過やリクエスト中断時にwarnレベルでログを出力するようにしました。

[LLM] Concurrency limit exceeded. name=OpenAI, maxConcurrent=5, waitTimeout=10000ms
[LLM] Request interrupted while waiting for concurrency permit. name=OpenAI

エラーログの改善

LlmExceptionと予期しない例外を区別してログ出力するようにしました。

  • LlmException: LLMサービス側の既知のエラー。スタックトレースなしでメッセージのみ出力(warnレベル)
  • 予期しない例外: プログラムのバグなど。スタックトレース付きで出力(warnレベル)
if (e instanceof LlmException) {
    logger.warn("[RAG] LLM error during chat. sessionId={}, error={}", session.getSessionId(), e.getMessage());
} else {
    logger.warn("[RAG] Unexpected error during chat. sessionId={}, error={}", session.getSessionId(), e.getMessage(), e);
}

LLMの既知のエラーではスタックトレースを省略することで、ログの可読性が向上します。

まとめ

今回の変更は、ログ出力の整理のみで動作の変更はありません。運用時に以下のメリットがあります。

  • プレフィックスベースのフィルタリングで、RAG/LLM関連のログを素早く抽出可能
  • debugログを有効にしなくても、チャット処理のレイテンシやソース数などのメトリクスを確認可能
  • LLMクライアントのライフサイクルイベントを追跡可能
  • エラーの種類に応じた適切なログ出力で、トラブルシューティングが効率化