Logo
Home|Blog|Speaker Deck
LanguageEnglish

本番運用のRAGを6つのステージで考える

2026-06-072026-06-07
Icon of ITITIcon of AIAIIcon of LLMLLM
OGP 本番運用のRAGを6つのステージで考える

ナイーブなRAGの仕組みは、説明するだけなら簡単です。ユーザーの質問を埋め込み、似ているチャンクを上位k件取得し、それをプロンプトに入れて回答させる。デモではこの方法でもうまく動きます。しかし、実際のコーパスと実際のユーザー、つまり曖昧な質問、略語、専門用語、固有名詞に向き合うと、すぐに限界が見えてきます。本当に必要なチャンクが6位にあり、1位のチャンクはまったく別の話をしている、ということが起こります。

この問題への定番の対処法は、ひとつの賢いトリックではありません。小さな処理を段階的に積み重ねるワークフローです。各ステージには役割があり、よくある失敗パターンを補うために存在します。これは特定のフレームワークに依存する話ではありません。ここで扱うのは検索技術であり、中にはLLMよりもずっと前から使われているものもあります。この記事では、そのワークフローをステージごとに見ていきます。

ここで扱うのは、RAGのクエリ時の処理だけです。ドキュメントをどうチャンク化し、埋め込み、同期し続けるかは、それだけで大きなテーマなので、別の記事で扱うべき内容です。ここでは、すでにインデックス済みのコーパスがあるものとして話を進めます。以下で引用するコードとログは、各ステージを試すために私が書いた小さなPython実装のものですが、そこで見られる挙動は一般的なものです。これらの技術は、どのフレームワークにも、あるいはフレームワークなしでも応用できます。

ステージ1 — クエリ変換#

ユーザーは検索システムのためではなく、人間に伝えるためにクエリを書きます。代名詞を使い、略語を使い、最初に思いついた言い回しで質問します。そして、それらがドキュメント側の語彙と一致するとは限りません。そこで、インデックスに検索をかける前に、LLMでクエリを書き換えます。目的は、検索の再現率を上げることです。略語を展開し、ありそうな同義語を追加します。

図のフォールバック矢印は、単なる飾りではありません。クエリ処理にLLMを挟む場合、LLMは空文字や壊れた出力を返すことがあります。そのため、すべてのLLMステップには、安全にフォールバックできる経路が必要です。実際に起こり得る例として、推論モデルが出力トークン予算を思考に使い切ってしまい、可視テキストをまったく返さないケースがあります。その場合、このステージでは空文字で検索するのではなく、元のクエリで検索するようにフォールバックします。

もうひとつ重要なのは、これはステージ1だけの話ではなく、ワークフロー全体に関わる原則だということです。書き換え後のクエリは、あくまで検索にだけ使います。ステージ4とステージ6では、意図的に元のクエリへ戻します。検索範囲を広げる段階では書き換え後のクエリを使い、候補を絞り込む段階ではユーザーが実際に入力したクエリを使います。

この失敗パターンへの対処は、クエリの書き換えだけではありません。代表的な代替案であるRAG-Fusionは、クエリを書き換えるのではなく、複数のクエリに増やします。それによって後続ステージの形も変わるため、記事の最後のおまけセクションで取り上げます。

ステージ2 — ハイブリッド検索:レキシカル + セマンティック#

ひとつの検索手法だけで十分そうに見えるのに、なぜ2種類の検索を走らせるのでしょうか。理由は、それぞれの弱点が補完関係にあるからです。

レキシカル検索(BM25は何十年も前から使われていますが、今でも得意な領域では非常に強力です)は、語の完全一致を見ます。ID、エラーコード、専門用語、希少なトークンにはこの方法が効きます。一方で、意味の一致はほとんど扱えません。「electricity」と検索しても、「power」とだけ書かれているチャンクは見つけられません。密ベクトルによるセマンティック検索はその逆です。言い換えや概念の一致は得意ですが、正確な部品番号を見逃すことがあります。埋め込みによって、その文字列が似たような文字列の曖昧な近傍に押し込まれてしまうからです。

そのため、このワークフローでは、同じチャンクストアに対して、同じtop-kで、同じ書き換え済みクエリを使い、両方の検索を実行します。その結果、2つのランキングリストが得られます。

運用上、事前に知っておきたい点があります。レキシカル検索はチャンクのテキストに対して動作します。つまり、クエリ時には埋め込みだけでなく、ドキュメントストアも利用できる必要があります。ベクトルだけを保持しているストレージ構成では、ハイブリッド検索は実質的に実装できません。

ステージ3 — Reciprocal Rank Fusionでマージする#

ここで2つのランキングリストが得られました。しかし、ひとつ問題があります。BM25スコアとコサイン類似度は、まったく異なるスケールの値です。単純に平均したり、比較したり、正規化したりすると、別のコーパスでは破綻する前提を置くことになります。

Reciprocal Rank Fusion(Cormack, Clarke & Büttcher, 2009)は、スコアを完全に無視することでこの問題を避けます。各チャンクは、それが現れた各リストについて 1 / (k + rank) の点数を得ます。つまり、両方の検索結果に現れたチャンクは両方から点数を得るため、上位に上がりやすくなります。

この処理自体は、数行の普通の算術で実装できます。モデルもAPI呼び出しもフレームワークも不要です。

python

def reciprocal_rank_fusion(result_lists, k=60):
    scores = defaultdict(float)
    for results in result_lists:
        for rank, chunk_id in enumerate(results):
            scores[chunk_id] += 1.0 / (k + rank + 1)
    return sorted(scores, key=lambda c: scores[c], reverse=True)

これはワークフローの中で最も安価なステージです。また、その重要な性質はテストとして簡単に表現できます。両方のリストで低順位だったとしても、両方に現れたチャンクは、それぞれの検索だけで単独1位だったチャンクに勝つことがあります。定数 k=60 は元論文で使われている標準的な値です。この値は、1位が2位に対してどれだけ強く優位になるかを抑える役割を持ちます。つまり、ひとつの検索手法のトップ候補が、複数の検索手法による合意を押しつぶさないようにします。

ステージ4 — クロスエンコーダでリランクする#

ここまでの処理は、すべて再現率を高めるためのものでした。広く候補を集め、ゆるくマージする。ステージ4では、今度は適合率を高めます。

リランカーを理解するうえでは、バイエンコーダとクロスエンコーダの対比が分かりやすいです。通常の検索では、クエリと各ドキュメントを別々に埋め込みます。だからこそ、コーパス全体に対して高速に検索できます。一方で、モデルはクエリとドキュメントを一緒に見ているわけではないため、あくまで近似になります。クロスエンコーダのリランカーはその逆です。各(クエリ, ドキュメント)ペアをまとめてスコアリングします。精度は高くなりますが、コーパス全体に対して実行するには遅すぎます。ただし、融合後の候補リストは十分に短いため、その候補すべてをスコアリングしても現実的なコストに収まります。

チャットモデルに候補を並べ替えさせることもできます。多くのフレームワークにはLLM-as-rerankerのようなモードがあります。ただし、専用のリランクモデルのほうが速く、安く(検索1回あたりおおよそ4分の1セント程度)、さらにチャットモデルが想定外の形式で返してパースに失敗する、といった問題も避けられます。リランクAPIは通常のHTTP呼び出しです。使っているスタックにプロバイダー向けのコネクタがなくても、自分で書くのは30行程度で済みます。

ここで、ステージ1で触れた話に戻ります。リランカーは、候補を元のクエリに対してスコアリングします。書き換えは、同義語でクエリを広げて検索範囲を広げるための処理でした。その広げたクエリをそのまま適合率を高めるステージへ渡すと、ユーザーが一度も入力していないノイズに一致したチャンクまで高く評価してしまう可能性があります。検索範囲を広げる段階では書き換え後のクエリを使い、候補を絞り込む段階ではユーザーの元の言葉を使います。

ステージ5 — 近傍チャンクで文脈を補う#

チャンク化には、クエリ時にひとつのトレードオフが残ります。小さなチャンクは検索には向いていますが、読むには文脈が足りないことがあります。たとえば、リランクで選ばれたチャンクに「風力アレイが冬季負荷の大半を担っている」と書かれていても、その風が止まったときに何が起きるかを説明する文は、隣のチャンクにあるかもしれません。

この問題には、小さく検索してから文脈を戻す、という方法で対処します。この技術には、近傍拡張、ウィンドウ拡張、small-to-big、親ドキュメント検索など、いくつかの呼び方があります。リランク後に残った候補について、それぞれの隣接チャンクや親セクションをドキュメントストアから取得します。

この処理に追加のAPI呼び出しは必要ありません。必要なのは、コーパスをインデックスした時点でチャンクの隣接関係を記録しておくことだけです。そうしておけば、拡張時にはその関係をたどるだけで済みます。これは、より大きな原則の一例でもあります。インデックス時の設計は、クエリ時のワークフローでできることの上限を決めます。

ステージ6 — 合成する#

最後のステージはシンプルです。そして、それが重要です。このワークフローの工夫の多くは上流にあります。LLMに回答を書かせる時点では、小さく、高品質で、文脈も補われたチャンク集合が手元にあります。あとはそれを読ませて答えさせるだけです。

リランカーと同じく、合成も元の質問に対して実行します。ユーザーが受け取るべきなのは、ユーザーが実際に尋ねたことへの答えだからです。

ここで、実際にかなりデバッグ時間を使った設定上の落とし穴を紹介します。特定のプロバイダーに限らない話です。合成に使えるプロンプトの予算は context window − max output tokens です。クライアントライブラリがコンテキストウィンドウを小さな値にデフォルト設定していて、さらに出力予算を大きく取った場合、たとえば推論モデルに十分な出力余地を与えようとした場合、この引き算がになることがあります。その結果、実際の原因とは違う場所を示すようなエラーメッセージが出ることがあります。実際に使うモデルについて、両方の数値を確認しておくべきです。

オブザーバビリティを設計に組み込む#

すべてのステージを、ひとつの小さなヘルパー経由で実行するようにします。そのヘルパーは、ステージごとにチャンクのログを1行ずつ出します。ステージ名、残った各チャンクのスコア、スニペットを記録します。

plain text

2b. vector (semantic) -> 5 chunk(s)
  score=0.4177 | The 300 kW wind array carries most of the winter load, because the solar...
  score=0.4040 | Unlike Kestrel Station it has no wind turbines: the camp runs on portabl...
  ...

これは単なるデバッグ用の後付けに見えるかもしれません。しかし実際には、プログラム全体のデバッグしやすさを大きく左右する設計です。RAGワークフローはファネルです。今後ほぼ必ず出てくる「なぜ回答がバッテリーバンクを見落としたのか」「なぜ無関係なチャンクがコンテキストに入ったのか」という問いは、実際には「どのステージが間違った候補を通したのか、あるいは正しい候補を落としたのか」という問いです。ステージごとのログがあれば、あるチャンクが2aで入り、RRFマージを通過し、リランクで上位に来て、ステージ5で近傍チャンクを拾う様子を追えます。ログがなければ、最終回答だけを眺めながら、6つのステージのどこに原因があるのかを推測するしかありません。

検索品質には、一般的なプログラムのようなデバッガはありません。ステージごとのロギングがデバッガの役割を果たします。そして、ステージ間の差分を見ることが、top-k、チャンクサイズ、リランクのカットオフを、勘ではなく証拠に基づいて調整する方法になります。

結論#

ワークフロー全体を並べると複雑に見えますが、見た目のためだけに入っているステージはありません。それぞれが、特定の失敗パターンを補う役割を持っています。

ステージ補うもの
クエリ変換検索システムではなく人間に向けて書かれたクエリ
ハイブリッド検索各検索手法の弱点
RRF比較できないスコアスケール
リランクバイエンコーダによる近似
拡張小さく検索することと、十分な文脈で読むことのトレードオフ
(RAG-Fusion)単一のクエリが単一の視点しか持たないこと

この考え方は、導入戦略としても使えます。各ステージは独立して追加できるため、初日からすべてを作る必要はありません。まずはナイーブな構成から始めれば十分です。ただし、最初からすべてのステージをログしてください。そこはほぼ無料で始められます。そのうえで、対応する失敗パターンがあなたのログに現れたときに、そのステージを追加すればよいのです。ブログ記事(この記事も含めて)がそう言っているから、ではありません。

おまけ — RAG-Fusion:クエリを書き換えるのではなく、増やす#

ステージ1で触れたRAG-Fusionの話です。RAG-Fusion(faviconRackauckas, 2024)は、元のクエリが検索キーとして弱いことがある、という同じ問題意識から出発します。ただし、アプローチが異なります。どれだけうまくクエリを書き換えても、ひとつのクエリはひとつの視点しか持ちません。そこで、クエリを書き換えるのではなく、LLMに複数の別表現を生成させます。より具体的なもの、より一般的なもの、同義語を含むもの、関連するサブ質問などです。

意図的に曖昧な入力に対しては、次のようになります。これは私のテストコーパスに対する実行結果です。テストコーパスは、南極の架空の前哨基地についての架空のドキュメントです。したがって、正しい答えはモデルの学習データからではなく、検索結果からしか得られません。

plain text

original query: Tell me about the outpost's energy situation.
generated     : Describe the outpost's power and energy supply status
generated     : Assess the outpost's energy resources, consumption, and backup systems
generated     : What is the outpost energy situation and infrastructure status?
generated     : How reliable is the outpost's electricity generation and fuel availability?

これは、ここまで見てきたワークフローの一部の形を変えます。差分を明確にすると、次のようになります。

  • ステージ2はファンアウト検索になります。 検索手法ごとに1回検索するのではなく、クエリごとに1回検索します。元のクエリは必ず残すため、N+1個のランキングリストになります。
  • ステージ3のアルゴリズムは変わりませんが、意味づけが変わります。 Reciprocal Rank Fusionは、リストがどこから来たかを気にしません。ハイブリッドワークフローでは、両方の検索手法で見つかったチャンクに高い点を与えます。RAG-Fusionでは、複数の言い換えで上位に出てきたチャンクに高い点を与えます。同じ計算ですが、意味が違います。この入力元に依存しない性質が、RRFの扱いやすいところです。
  • ステージ4〜6は変わりません。 リランクと合成では、引き続き元の質問を使います。生成されたクエリは、あくまで検索のためのものです。

この2つの技術は組み合わせることもできます。生成された各クエリを、両方の検索手法に対してファンアウトできます。そしてRRFは、すべての(クエリ × 検索手法)のランキングリストをまとめてマージします。安全性の考え方もステージ1と同じです。クエリ生成が空で返ってきた場合は、壊れるのではなく、通常の単一クエリRAGへフォールバックします。

Profile IconIkuma Yamashita

Rust が好きです。仕事ではインフラエンジニア、趣味ではアプリケーションエンジニアです。イラストなどを嗜む。