FessのAI検索モードで会話履歴の最適化

FessのAI検索モードでは、セッション内の会話履歴をLLMに渡すことで文脈を維持した回答を生成しています。しかし、会話が長くなるとアシスタントの応答が大きくなり、コンテキストウィンドウを圧迫するという課題がありました。そこで、会話履歴のコンテキストの扱いを見直し、smart_summaryモードの導入やターンベースの履歴パッキングなどの改善を行いました。今後も調整していく可能性はありますが、現時点での変更内容を紹介します。

背景

AI検索モードでは、ユーザーとアシスタントの過去のやり取りを会話履歴としてLLMに渡しています。これにより「さっきの検索結果について詳しく教えて」といった文脈依存の質問に対応できます。

従来は、アシスタントの応答を履歴に含める際のデフォルトモードとしてsource_titles(参照したドキュメントのタイトルのみを残す)を使用していました。しかし、タイトルだけでは前の応答の内容を十分に把握できず、文脈が途切れるケースがありました。

smart_summaryモードの導入

新しいデフォルトモードとしてsmart_summaryを導入しました。このモードでは、長いアシスタント応答の先頭60%(直接的な回答部分)と末尾40%(まとめ部分)を保持し、中間部分を省略します。

[先頭60%: 直接的な回答]
...[omitted]...
[末尾40%: まとめ・結論]
[Referenced documents: ドキュメントタイトル1, タイトル2]

LLMの応答は一般的に、冒頭に質問への直接的な回答があり、末尾にまとめや結論が来る構成になっています。中間部分は詳細な説明や補足情報であることが多いため、省略しても文脈の維持に必要な情報は残りやすいという考え方です。

なお、応答が短い場合はそのまま全文が保持されます。

ターンベースの履歴パッキング

履歴をLLMに渡す際の詰め込み方式も改善しました。従来はメッセージ単位で個別に予算に収まるかを判定していたため、ユーザーの質問だけが入ってアシスタントの応答が入らない、といった不自然な切れ方が起きる可能性がありました。

新しい方式では、ユーザーメッセージとアシスタント応答をペア(ターン)として扱い、ターン単位で予算内に収まるかを判定します。これにより、質問と回答の対応関係が維持されます。

履歴設定のLlmClientへの移動

これまでfess_config.propertiesで静的に設定していた履歴関連のパラメータを、LlmClientインターフェースのメソッドに移動しました。

パラメータ説明デフォルト値
getHistoryAssistantMaxChars()アシスタントメッセージの最大文字数800
getHistoryAssistantSummaryMaxChars()サマリーの最大文字数800
getIntentHistoryMaxMessages()インテント検出用の最大メッセージ数6
getIntentHistoryMaxChars()インテント検出用の最大文字数3000

これにより、fess-llm-openai、fess-llm-ollama、fess-llm-geminiなどのLLMプラグインが、それぞれのモデルの特性に合わせてパラメータをオーバーライドできるようになりました。

その他の変更

  • 会話履歴の最大メッセージ数のデフォルトを20から30に増加
  • インテント検出用の履歴メッセージ数のデフォルトを4から6に増加し、文字数による予算制御も追加
  • 削除された設定プロパティ: rag.chat.history.max.charsrag.chat.history.assistant.max.charsrag.chat.history.assistant.summary.max.charsrag.chat.intent.history.max.messages

まとめ

今回の変更により、AI検索モードでの会話履歴の扱いがより効率的になりました。smart_summaryモードによって、限られたコンテキストウィンドウの中でも会話の文脈を維持しやすくなっています。また、LLMプラグインごとにパラメータを調整できるようにしたことで、モデルの特性に合わせた最適化が可能になりました。今後も実際の利用状況を見ながら調整を続けていく予定です。

詳細はPR #3084を参照してください。

FessのRAGチャットでMarkdownレンダリングに対応

FessのRAGチャット機能で、LLMからの応答をMarkdownとしてレンダリングできるようにしました。これにより、見出しやテーブル、コードブロックなどが整形された状態で表示されるようになります。また、ストリーミング中の途中の応答も適切にMarkdownとして表示されます。

背景

これまでFessのチャット画面では、LLMからの応答をプレーンテキストとして表示していました。LLMの応答にはMarkdown形式で見出しやリスト、コードブロックなどが含まれることが多いため、そのまま表示すると読みにくい状態でした。特にストリーミング応答中は、Markdownのソースがそのまま見えてしまう問題がありました。

変更内容

marked.jsとDOMPurifyの導入

クライアントサイドでMarkdownをHTMLに変換するために、marked.js(v17.0.4)を導入しました。また、XSS対策としてDOMPurifyを組み合わせて使用しています。

サニタイズポリシーは、サーバーサイドのMarkdownRenderer(OWASPサニタイザー)と同等の設定にしています。

  • 許可するHTMLタグを限定(見出し、リスト、テーブル、コードブロックなど)
  • リンクにはrel="nofollow"を自動付与
  • class属性はcodeprespandiv要素のみに制限
  • data-*属性は不許可
  • URIはhttps?://のみ許可

ストリーミング対応

ストリーミング応答では、受信中のテキストに対してrenderMarkdown()を呼び出すことで、途中の応答もMarkdownとしてレンダリングされます。従来の.text()による表示を.html(renderMarkdown())に変更し、リアルタイムにMarkdown変換を行っています。

CSSスタイルの追加

レンダリングされたMarkdown要素に対して、チャットUIに適したスタイルを追加しました。

  • 見出し(h1〜h6)のフォントサイズとマージン
  • テーブルのボーダーとパディング
  • 引用ブロックの左ボーダー
  • リンクの色とホバースタイル

フォールバック

marked.jsやDOMPurifyの読み込みに失敗した場合は、従来どおりHTMLエスケープしたプレーンテキストとして表示するフォールバック処理を実装しています。

関連リンク

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解決のおかげでコードも簡素化でき、クローラーの堅牢性も向上しました。