AutoHyDE と一般的なクエリ拡張・クエリ分解を比較する

目次

Thumnail

はじめに

AI Integration Unit の山崎です。RAG の検索コスト(LLM の呼び出し回数や入力トークン数、ベクトル検索回数)と検索精度のバランスを探っている中で、HyDE 系の手法である AutoHyDE を知りました。そこで、本記事では、AutoHyDE と一般的なクエリ拡張・クエリ分解の比較を行い、その特徴を整理します。

なぜクエリ単体では recall が伸びないのか

RAGを運用していると、「関連しているはずの文書が検索の上位に来ない」という recall 不足の問題に遭遇します。これは、埋め込みベースの検索には、クエリ側に次のような構造的な弱点があるためです。

  1. クエリと文書の長さ・形式のギャップ。クエリは数語〜一文と短く、文書は段落〜章単位で長いことが多い。同じトピックでもベクトル空間での距離が遠くなりやすい
  2. 同義語・別表記・略語の問題。「LP」「ランディングページ」「ランディング・ページ」などコーパス側の表記揺れに弱い
  3. 複合的な観点を含む質問。「現状の生成 AI が抱える課題」のように、内部に複数のサブトピックを持つクエリは、単一ベクトルでは焦点が拡散する

これらに対応するのがクエリ側の強化アプローチで、ここでは3つの手法を紹介します。

  • クエリ拡張(Query Expansion)
  • クエリ分解(Query Decomposition)
  • HyDE(Hypothetical Document Embeddings)系。(本記事ではその発展形である AutoHyDE を取り上げます)

本記事では各手法の概念を整理したうえで、特に AutoHyDE の処理フローを掘り下げ、3つの手法の特徴と使い分けを比較します。クエリ分解とクエリ拡張の特徴や実装例については、参考情報に挙げたQiitaの私の記事も参考にしてください。

本記事は概念比較が中心で、実装例やベンチマークは含みません。AutoHyDE の処理フローは、参考情報に挙げた note の記事に基づきます。

参考情報

3つの手法の概念整理

クエリ拡張(Query Expansion)

検索クエリに関連する用語や同義語クエリを生成し、それらを検索クエリに追加することで検索範囲を適切に広げる手法です。

種別 クエリ例
もとの検索クエリ 「現状の生成 AI が抱える課題にはどのようなものがあるか」
拡張後のクエリ 「生成 AI 規制 法規制 ガイドライン AI 規制法 ハルシネーション 著作権 プライバシー 倫理 エネルギー消費」
「生成 AI 情報リテラシー リテラシー格差 倫理 著作権 セキュリティ 透明性」

主に「同義語・別表記」の問題に効きます。各クエリで検索した結果を統合して回答を生成することで、検索の recall 向上が期待できます。ただし、拡張クエリが増えるほど無関係な文書も混入しやすく、precision とはトレードオフになります。

クエリ分解(Query Decomposition)

複雑なクエリを、それ単体で回答可能な複数の簡単なサブクエリに分解し、それぞれで検索した結果を統合する手法です。

種別 クエリ例
もとの検索クエリ 「現状の生成 AI が抱える課題にはどのようなものがあるか」
分解後のクエリ 「生成 AI の開発・運用にかかるコストや経済的課題は何か」「生成 AI の環境負荷や持続可能性に関する課題は何か」「生成 AI のデータセキュリティと個人情報の保護に関する課題は何か」

主に「複合的な観点を含む質問」に効きます。各サブクエリで独立に検索した結果を統合して回答を生成します。

AutoHyDE

HyDE(Hypothetical Document Embeddings)は、クエリに対して LLM に仮想的な回答文書を生成させ、その仮想文書の埋め込みを検索クエリベクトルとして用いる手法です。「短いクエリ」と「長い文書」のギャップを埋めるのに有効で、ゼロショット検索でも recall を改善できることが知られています(Gao et al., ACL 2023)。

AutoHyDE はその発展形で、LangChain の HypotheticalDocumentEmbedder をサブクラス化した実装です。従来の HyDE が「固定のプロンプトで 1 個の仮想文書を生成」するのに対し、AutoHyDE は次の追加要素を持ちます。

  • クエリからキーワードを自動抽出
  • 初期ベクトル検索で幅広く取得した文書のうち、下位にランクされた文書から抽出キーワードを含む「見逃された関連文書」を抽出
  • 抽出した文書を再埋め込みし、HDBSCAN でクラスタリングして多様な観点を自動発見
  • 検索クエリと各クラスタの文書をフューショット例に使い、コーパスの文書スタイルに合わせた仮想文書を複数生成
  • 複数の仮想文書の埋め込みを平均して最終クエリベクトルに集約
  • 集約した最終クエリベクトルで全コーパスを再検索する

つまり「コーパスから観点を学習して、コーパスの文体に寄せた仮想文書を作る」という発想で、HyDE をコーパス適応型に進化させた手法と言えます。

AutoHyDE の処理フロー

embed_query メソッドが 7 ステップを統括します。

def embed_query(self, text: str, db: VectorStore, hypo_params: dict) -> List[float]:
    keywords = self.extract_keywords(text, hypo_params)                                # Step 1
    init_docs = self.do_init_retrieval(db, text, hypo_params)                           # Step 2
    remaining_docs = self.get_remaining_docs_with_keywords(text, init_docs, keywords, hypo_params)  # Step 3
    cat_dict = self.cluster_docs(remaining_docs, hypo_params)                           # Step 4
    hypo_docs = self.generate_hypo_docs(text, cat_dict, hypo_params)                    # Step 5
    embeddings = self.embed_documents(hypo_docs, hypo_params)                           # Step 6
    combined_embeddings = self.combine_embeddings(embeddings, hypo_params)              # Step 7
    return combined_embeddings

各ステップの役割は次のとおりです。

Step 役割 検索対象
1 キーワード抽出(LLM に 1〜5 個の名詞を抽出させる)
2 初期検索(baseline_k × exploration_multiplier 件、デフォルト 100 件) 全コーパス
3 上位 baseline_k(既定 20)より下の文書から、抽出キーワードを含むものをフィルタ
4 残った文書を再埋め込みし、HDBSCAN でクラスタリング
5 クラスタごとにフューショット例として与え、仮想文書を 1 個ずつ生成
6 仮想文書群を埋め込み
7 埋め込みを要素ごとに平均して 1 本のベクトルに集約
最終(外側) 統合ベクトルで再度コーパスを KNN 検索 全コーパス

このフローで注目する点は次の 3 つです。

下位にランクされた文書に隠れた関連パターンを見出す

Step 3 で上位 baseline_k 件より下の文書だけを対象にしています。上位はすでにベクトル類似度で高スコアを取れている良い候補なので、そこを使っても従来 HyDE と変わりません。AutoHyDE の価値は「ベクトル類似度では下位だが、キーワードでマッチする文書」に隠れた関連パターンを引き出すことにあります。

クラスタリングで観点を分離する

下位から拾った文書には複数の観点が混在しがちです。HDBSCAN で密度ベースにクラスタリングし、クラスタごとに 1 個の仮想文書を作ることで、多様な関連パターンをカバーできます。

フューショットでコーパスの文体に寄せる

Step 5 のプロンプトでは、クラスタ内の文書を「同じ著者が書いた仮想文書の例」として提示します。実際にはそれらは検索クエリへの仮想文書ではなく、単にベクトル類似度とキーワードで拾われた実在の文書ですが、LLM はこの仮定を受け入れて「このクエリにはこういうスタイル・トーン・長さで答えるのだ」と学習し、コーパスに馴染む仮想文書を生成します。これが埋め込み空間でクエリベクトルをコーパス側に寄せる効果を生みます。

全体像を図示すると次のようになります。

[全コーパス(ベクトルストア)]
       │
       │ Step 2: 初期検索 100 件取得
       ↓
 上位 20 件(baseline_k)━━━ この 20 件は使わない(既に良い候補とみなす)
 下位 80 件
       │
       │ Step 3: キーワードフィルタ
       ↓
 残った文書(残余文書) 15 件(例)
       │
       │ Step 4: クラスタリング
       ↓
 クラスタ A, B, C
       │
       │ Step 5: 各クラスタをフューショット例に使って仮想文書を生成
       ↓
 仮想文書 A, B, C(3 個)
       │
       │ Step 6-7: 埋め込み → 平均化
       ↓
 統合ベクトル(1 本)
       │
       │ AutoHyDE 処理はここで終了
       ↓
 呼び出し側での最終検索(AutoHyDE の外)
 統合ベクトル → 全コーパスを KNN 検索
              → 最終的なトップ k 件

なお Step 4 の「再埋め込み」は、ベクトルストアの検索 API が文書本文と距離スコアしか返さない(元の埋め込みベクトル自体を返さない)場合に必要です。Amazon S3 Vectors のように returnData=true で埋め込みを取り出せる API を使えば、この再埋め込みコストは回避できます。

比較表で整理する

3つの手法の特徴を一覧で比較します。

観点 クエリ拡張 クエリ分解 AutoHyDE
対象となるテキスト 検索クエリの同義語・関連語による拡張クエリ 検索クエリのサブクエリ 検索クエリに対する仮想的な回答文書
LLM 生成物の性質 検索用のクエリ(キーワードや短文) 検索用のサブクエリ(短文) 答えそのものを模した文書(長文)
ベクトル検索回数 N 回(拡張クエリ数) M 回(サブクエリ数) 2 回(初期検索 + 最終検索)
検索結果の統合方法 各検索結果の和集合 → 重複排除・ランキング 各検索結果の和集合 → 重複排除・ランキング 初期検索後に埋め込みを平均し、最終検索は 1 回
recall への寄与 大(複数クエリで広く拾う) 大(多角的な観点で広く拾う) 中〜大(平均ベクトルの拡張に依存)
precision への寄与 中(ノイズも増える) 高(サブクエリごとに焦点が絞れる) 中(平均化で個別の精度は薄まる)
クエリと文書の意味的ギャップへの対処 限定的(短いクエリ同士の類似性に依存) 限定的(同左) 強い(仮想文書がコーパスの文書形式に寄る)
弱点・苦手なケース 複合的な観点を含む質問には対応しにくい(基本は同義語展開のため) 逐次依存型の質問(前の検索結果が次の検索の前提になる)には弱い 固有名詞・ID には弱い(LLM が仮想文書に組み込めない)
LLM 呼び出し回数(目安) 1 回(拡張生成) 1 回(分解生成) 1 + C 回(C はクラスタ数、3〜5 程度。キーワード抽出 1 回 + 仮想文書生成 C 回)

クエリ拡張とクエリ分解は「クエリ側に複数のテキストを生成して検索を複数回実行する」点で似ています。違いは生成物の性質(同義語の言い換えか/論理的なサブクエリか)と、それが効く弱点(同義語・別表記か/複合観点か)です。

AutoHyDE は「クエリ側にコーパスの文書形式を模した長文を作り、それらの埋め込みを 1 本のベクトルに集約してから最終検索する」点で特徴的です。複数の検索結果を統合するのではなく、埋め込み段階で統合する手法と整理できます。

どれをいつ使うか

ざっくりした判断軸は次のとおりです。

手法 適したケース
クエリ拡張 ドメイン固有の略語・別表記が多いコーパス。同義語辞書では拾いきれない言い換えを LLM に任せたい場合
クエリ分解 複数の独立した観点を含む複合クエリ。回答に複数の文書を統合する必要があるユースケース
AutoHyDE 短いクエリと長いドキュメントの意味的ギャップが大きい場合。特にゼロショット設定や、コーパスが特徴的な文体(議事録、技術記事、論文抄録など)を持つ場合

実運用では、どれを選ぶかを「クエリの典型的な性質」と「コーパスの性質」の両軸で考えると判断しやすくなります。

クエリの性質 \ コーパスの性質 一般的な文書 特徴的な文体・長文
短く曖昧 クエリ拡張 AutoHyDE
複合観点を含む クエリ分解 クエリ分解 + AutoHyDE
同義語・別表記が問題 クエリ拡張 クエリ拡張 + AutoHyDE

コストと実装上の注意

LLM 呼び出し回数は次のように見積もれます(最終的な回答生成は除く)。

手法 LLM 呼び出し 埋め込み API
クエリ拡張 1 回(拡張生成) N 回(拡張クエリ数)
クエリ分解 1 回(分解生成) M 回(サブクエリ数)
AutoHyDE 1 + C 回(キーワード抽出 + 仮想文書生成) 2 バッチ(残余文書の再埋め込み + 仮想文書の埋め込み)

C を 3〜5 とすると、AutoHyDE のクエリ側コストは 4〜6 回の LLM 呼び出しと 2 バッチの埋め込みになります。クエリ拡張・分解(いずれも 1 回)と比べると LLM 呼び出しは重めですが、ベクトル検索回数は初期検索と最終検索の 2 回で固定のため、N や M に比例するクエリ拡張・分解と比べてベクトルストア側のコストは抑えやすくなります。

実装上の注意点もいくつかあります。

  • 再埋め込みコスト
    Step 4 で残余文書を埋め込み直すコストは無視できない場合があります。Amazon S3 Vectors の returnData=true のように埋め込みベクトルを返せる API を使えば回避可能です
  • 日本語キーワード抽出の品質
    Step 1 のプロンプトは英語前提で書かれており、日本語クエリでは抽出語が安定しないことがあります。プロンプトを日本語化する、英訳してから処理するなどの調整が必要です
  • 固有名詞・ID への弱さ
    LLM が仮想文書を生成する以上、未知の固有名詞や ID 文字列はカバーできません。これらが重要なコーパスでは、別途キーワード検索(BM25 など)と組み合わせる方が無難です
  • フューショット例のトークン長
    長い記事や議事録のように 1 件あたり数千文字あるコーパスでは、フューショットで複数文書を並べると入力トークンが膨らみます。冒頭 200〜300 字にトリミングする、抜粋するなどの工夫が必要です
  • クラスタが形成されないケース
    残余文書が少ない、または HDBSCAN がクラスタを作れない場合のフォールバックを用意しておく必要があります(仮想文書 1 個生成に戻すなど)

まとめ

3つの手法の使い分けを整理すると次のようになります。

  • クエリ拡張は「同義語・別表記」の問題に、複数の言い換えクエリで対処する
  • クエリ分解は「複合観点」の問題に、独立したサブクエリへの分解で対処する
  • AutoHyDE は「短いクエリと長文書のギャップ」に、コーパスの文体に寄せた仮想文書で対処する

これらは排他的ではなく、組み合わせても使えます。クエリ拡張で作った N 個の拡張クエリをそれぞれ HyDE で仮想文書化して平均する、複合クエリを分解した各サブクエリに HyDE を適用する、AutoHyDE と BM25 を並行させて複数の検索結果のランキングを統合し、固有名詞・ID への弱さを補う、といった構成が考えられます。どの組み合わせが効くかはコーパスとクエリの性質に依存するため、評価セットを用意して定量的に比較するのが望ましいです。

RAG の改善はクエリ側・文書側・ランキング側のいずれでも余地があり、本記事はそのうちのクエリ側を扱いました。文書側(チャンク戦略、メタデータ)やランキング側(リランカー、ハイブリッド検索の重み)と組み合わせることで、recall と precision の両立がより現実的になります。AutoHyDE は LLM 呼び出しコストが大きい手法ですが、コーパスから観点を学習して仮想文書を生成するという発想自体は応用が利く面白い手法です。

「生成AI / LLM」の関連記事

ブログ一覧へ