検索拡張生成(RAG)
検索拡張生成(RAG) (P. Lewis et al. 2020)は、大規模言語モデルを本番環境へ配備するうえで、実務的な影響が特に大きい技術の1つとなっています。RAGは、学習時にモデルの重みへエンコードされた知識だけに頼るのではなく、動的に更新できる外部メモリをLLMに与えます。これにより、幅広い知識集約型タスクで、正確でグラウンディングされ、検証可能な応答を実現します。
動機と問題設定
Important
LLMに外部知識が必要な理由
大規模言語モデルは知識をパラメトリックに保存します。学習中に数十億の重みへ圧縮するのです。これにより、3つの根本的な制限が生じます。
ハルシネーション: 信頼できる知識の境界を超えて質問されると、事実としては誤っているがもっともらしく聞こえる文を、モデルは自信を持って生成します。
知識の陳腐化: 学習データにはカットオフ日があるため、学習後に起きた出来事、発表された論文、製品アップデートをモデルは知ることができません。
ドメイン固有性: 汎用モデルは、独自コードベース、社内文書、専門的な規制、企業データについて深い知識を持っていません。
パラメトリック知識とノンパラメトリック知識
2つの知識源の違いは、次のように形式化できます。 \(\mathcal{M}_\theta\) はパラメーター \(\theta\)、\(\mathcal{D} = \{d_1, d_2, \ldots, d_N\}\) は外部文書コーパスです。パラダイムごとに回答 \(a\) をクエリ \(q\) に対する生成確率は次のとおりです。
\[ \begin{aligned} P_{\text{parametric}}(a \mid q) &= P_{\mathcal{M}_\theta}(a \mid q) \\[6pt] P_{\text{RAG}}(a \mid q, \mathcal{D}) &= \sum_{d \in \mathcal{D}} P_{\mathcal{M}_\theta}(a \mid q, d), P_{\text{ret}}(d \mid q, \mathcal{D}) \end{aligned} \]
ここで \(P_{\text{ret}}(d \mid q, \mathcal{D})\) は文書に対する検索分布です。RAGは検索された証拠について周辺化し、ノンパラメトリック知識に基づいて生成をグラウンディングします。
Tip
図書館のたとえ
パラメトリックLLMを、巨大な図書館を暗記したものの、卒業してから何年も経った学者だと考えてみましょう。RAGはその学者に図書館カードを与えます。学者はリアルタイムで調べ、出典を引用し、記憶から推測するのではなく参照を確認する必要があると認められます。
RAG、ファインチューニング、ロングコンテキストの使い分け
| 基準 | RAG | ファインチューニング | ロングコンテキスト | RAG + FT |
|---|---|---|---|---|
| 知識が頻繁に更新される | \(\times\) | \(\times\) | ||
| 引用/グラウンディングが必要 | \(\times\) | |||
| 大規模な独自コーパス | \(\times\) | \(\times\) | ||
| スタイル/形式を適応させる | \(\times\) | \(\times\) | ||
| 新しい推論スキルを教える | \(\times\) | \(\times\) | ||
| コーパスがコンテキストウィンドウに収まる | \(\times\) | \(\times\) | \(\times\) | |
| 低レイテンシーが必要 | \(\times\) | \(\times\) | \(\times\) |
意思決定ガイド:RAG、ファインチューニング、ロングコンテキスト
Warning
よくある誤解
RAGはファインチューニングの代替ではありません。ファインチューニングはモデルにどのように推論し応答するかを教え、RAGは何について推論するかを与えます。両者は相補的です。指示に適切に従うようファインチューニングされたモデルは、ベースモデルより検索コンテキストを効果的に利用します。
RAGの基本アーキテクチャ
標準的なRAGシステムは2つのフェーズからなります。文書を処理して保存する オフラインインデックス作成パイプライン と、クエリに応答する オンライン検索・生成パイプライン です。
パイプライン全体の図
インデックス作成パイプライン
文書の読み込み
文書はさまざまな形式(PDF、HTML、Markdown、DOCX、コード)で届きます。ローダーはクリーンなテキストを抽出し、メタデータ(ソースURL、ページ番号、節タイトル、タイムスタンプ)を保持します。このメタデータはフィルタリングと引用のため、埋め込みとともに保存されます。
チャンク分割
長い文書は、埋め込みモデルのコンテキストウィンドウ(通常512トークン)に収まり、意味的に一貫したチャンクへ分割しなければなりません。チャンク分割戦略は、RAGシステム設計で最も影響の大きい判断の1つです(節2.4を参照)。
埋め込み
各チャンク \(c_i\) は密なベクトル \(\mathbf{e}_i = f_\phi(c_i) \in \mathbb{R}^d\) を埋め込みモデル \(f_\phi\) を使ってエンコードします。これらのベクトルは元のテキストとメタデータとともにベクトルデータベースへ保存されます。
検索
クエリ \(q\)について、検索ステップは \(\mathbf{q} = f_\phi(q)\) としてエンコードし、\(k\) 個の最も類似するチャンクをコサイン類似度で見つけます。
\[ \text{sim}(\mathbf{q}, \mathbf{e}_i) = \frac{\mathbf{q} \cdot \mathbf{e}_i}{|\mathbf{q}|,|\mathbf{e}_i|} \]
上位 \(k\) チャンク\(\mathcal{C}_k = \{c_{(1)}, \ldots, c_{(k)}\}\)をコンテキストとして返します。
生成
検索されたチャンクをプロンプトテンプレートへ注入します。
SYSTEM_PROMPT = """You are a helpful assistant. Answer the question using ONLY
the provided context. If the context does not contain enough information,
say so explicitly. Cite your sources using [Doc N] notation."""
def build_rag_prompt(query: str, chunks: list[dict]) -> str:
context_str = "\n\n".join(
f"[Doc {i+1}] (Source: {c['source']}, Page: {c.get('page','N/A')})\n{c['text']}"
for i, c in enumerate(chunks)
)
return f"""{SYSTEM_PROMPT}
Context:
{context_str}
Question: {query}
Answer:"""
検索手法
スパース検索:BM25とTF-IDF
スパース検索方式では、文書とクエリを語彙上の高次元スパースベクトルとして表します。古典的なBM25スコアリング関数 (Robertson and Zaragoza 2009) は文書 \(d\) に対するクエリ \(q\) と項目 \(t_1, \ldots, t_n\) について次のように定義されます。
\[ \text{BM25}(d, q) = \sum_{i=1}^{n} \text{IDF}(t_i) \cdot \frac{f(t_i, d) \cdot (k_1 + 1)}{f(t_i, d) + k_1 \cdot \left(1 - b + b \cdot \frac{|d|}{\text{avgdl}}\right)} \]
ここで \(f(t_i, d)\) は項頻度、\(\vert d\vert\) は文書長、\(\text{avgdl}\) は平均文書長であり、\(k_1 \in [1.2, 2.0]\), \(b = 0.75\) は調整パラメーターです。
Important
スパース検索がなお優位な場合
正確なキーワード一致: 製品コード、エラーコード、固有名詞、希少な用語
低リソースのドメイン: 密なモデルを学習するためのデータが不足している場合
解釈可能性: なぜ文書が検索されたかを簡単にデバッグできる
速度: GPUが不要で、転置インデックスによって数十億文書へ拡張できる
語彙外の用語: 埋め込み学習時に見ていなかった新しい用語
密ベクトル検索:DPR
Dense Passage Retrieval(DPR) (Karpukhin et al. 2020)は、2つの別個のBERTベースエンコーダー、すなわちクエリエンコーダー \(E_Q\) とパッセージエンコーダー \(E_P\)を使います。対照損失で学習し、関連するクエリとパッセージのペアを埋め込み空間で近くに配置します。
バイエンコーダーのアーキテクチャ
\[ \text{sim}(q, p) = E_Q(q)^\top E_P(p) \]
バッチ内負例を使った学習
バッチ \(B\) 個のクエリ・パッセージペア \(\{(q_i, p_i^+)\}_{i=1}^B\) に対して、対照損失はバッチ内の他のすべてのパッセージを負例として扱います。
\[ \mathcal{L}_{\text{DPR}} = -\frac{1}{B} \sum_{i=1}^{B} \log \frac{\exp!\left(E_Q(q_i)^\top E_P(p_i^+) / \tau\right)} {\sum_{j=1}^{B} \exp!\left(E_Q(q_i)^\top E_P(p_j) / \tau\right)} \]
ここで \(\tau\) は温度ハイパーパラメーターです。ハード負例(語彙的には似ているが意味的には無関係なパッセージ)は、強力な検索器の学習に不可欠です。
近似最近傍探索
大規模になると、数百万の埋め込みを総当たりで検索するのは現実的ではありません。FAISS (Johnson et al. 2021)(Facebook AI Similarity Search)は、次の方法で効率的な近似最近傍(ANN)探索を提供します。
-
IVF(転置ファイルインデックス): ベクトルをVoronoiセルへクラスタリングし、近傍セルだけを検索する
-
HNSW(Hierarchical Navigable Small World): (Malkov and Yashunin 2020) グラフベースのインデックスで、\(O(\log N)\) 検索を行う
-
PQ(Product Quantization): ベクトルを圧縮してメモリ使用量を削減する
Reciprocal Rank Fusionによるハイブリッド検索
ハイブリッド検索はスパーススコアと密なスコアを組み合わせます。単純な線形結合は次のとおりです。
\[ s_{\text{hybrid}}(d, q) = \alpha \cdot s_{\text{dense}}(d, q) + (1-\alpha) \cdot s_{\text{sparse}}(d, q) \]
しかし、異なるシステムのスコアは直接比較できません。 Reciprocal Rank Fusion(RRF) (Cormack et al. 2009)は、スコアではなく順位を扱うことでこの問題を避けます。
\[ \text{RRF}(d) = \sum_{r \in \mathcal{R}} \frac{1}{k + \text{rank}_r(d)} \label{eq:rrf} \]
ここで \(\mathcal{R}\) は順位付きリストの集合(例:BM25の順位リストと密ベクトル検索の順位リスト)、\(\text{rank}_r(d)\) は文書 \(d\) のリスト \(r\) における順位であり、\(k = 60\) は非常に高い順位の文書の影響を抑える平滑化定数です。
Note
RRFの計算
BM25が文書 \(d\) を3位、密ベクトル検索が7位にランク付けしたとします。\(k = 60\) のとき、 \[ \text{RRF}(d) = \frac{1}{60 + 3} + \frac{1}{60 + 7} = \frac{1}{63} + \frac{1}{67} \approx 0.0159 + 0.0149 = 0.0308 \] 両方のリストで1位の文書のスコアは \(\frac{1}{61} + \frac{1}{61} \approx 0.0328\). です。
学習済みスパース検索:SPLADEとSPLADEv2
Tip
SPLADEとは何か
従来のスパース検索(BM25)は正確な語彙一致に依存するため、クエリが「car」と言っているのに文書が「automobile」と言っている場合に失敗します。密ベクトル検索(DPR)は意味を捉えますが、解釈可能性を失い、クエリ時にGPUを必要とし、大きなインデックスを生成します。 SPLADE は両方の長所を得ます。BM25のように高速な転置インデックス検索ができるスパースベクトルと、密なモデルのように同義語や関連概念を扱う学習済み意味拡張を組み合わせます。
SPLADE(v1)— 基本概念
SPLADE(Sparse Lexical and Expansion Model) (Formal, Piwowarski, et al. 2021)は、事前学習済みのマスク言語モデル(例:BERT/DistilBERT)を使い、各文書またはクエリについて語彙全体にわたるスパースベクトルを生成します。重要な洞察は、MLMヘッドがテキスト中の各位置と意味的に関連する語をすでに知っていることです。SPLADEはこの知識を項目重要度の重みとして再利用します。
アーキテクチャ
入力テキスト \(x = [x_1, \ldots, x_n]\) について、
-
トランスフォーマーエンコーダーに通してコンテキスト表現 \(\mathbf{H} \in \mathbb{R}^{n \times \vert \mathcal{V}\vert }\) をMLMヘッドから得る
-
位置全体を集約し、飽和活性化を適用する。
\[ w_t(x) = \log!\left(1 + \text{ReLU}!\left(\max_{i \in [1,n]} \mathbf{H}_i[t]\right)\right) \]
ここで \(\mathbf{H}_i[t]\) は語彙トークン \(t\) の入力位置 \(i\) におけるMLMロジットです。
-
\(\log(1 + \cdot)\) の飽和により、単一の項目が支配的になるのを防ぐ(BM25のTF飽和と同様)
-
ReLUによりスパース性が保証され、大半の語彙項目の重みがゼロになる
-
\(\max\) による位置全体のプーリングで、テキスト内のどの位置からでも各項目の最も強い信号を捉える
-
拡張: 元のテキストに存在しないトークンでも非ゼロの重みを得られる(例:「neural networks」に関する文書が「deep learning」「AI」「backpropagation」に重みを持つことがある)
スコアリング
クエリと文書はそれぞれスパースベクトル \(\mathbf{w}^q, \mathbf{w}^d \in \mathbb{R}^{\vert \mathcal{V}\vert }\) へ写像されます。関連度スコアは単純な内積です。
\[ s(q, d) = \sum_{t \in \mathcal{V}} w_t^q \cdot w_t^d \]
両方のベクトルはスパースです(3万語彙のうち通常20〜200個の非ゼロエントリ)。そのため標準的な転置インデックス(Lucene、Anserini)を使って効率よく計算でき、クエリ時にGPUは必要ありません。
学習
SPLADEは、対照学習(バッチ内負例+ハード負例)に2つの正則化項を加えて学習します。
\[ \mathcal{L} = \mathcal{L}_{\text{contrastive}} + \lambda_q |\mathbf{w}^q|_1 + \lambda_d |\mathbf{w}^d|_1 \]
クエリと文書の表現に対する \(L_1\) ペナルティはスパース性を促します。これがなければ、モデルは目的に反する密な表現を学習します。
SPLADEv2 — 主な改善
SPLADEv2 (Formal, Lassance, et al. 2021)は、効率と有効性を大幅に改善する複数の改良を導入します。
- クロスエンコーダーからの蒸留: 二値の関連度ラベルだけで学習する代わりに、SPLADEv2はクロスエンコーダー教師(例:MonoT5 (Nogueira et al. 2020))を使ってソフトな関連度スコアを与えます。これにより、より豊かな学習信号が得られます。
\[ \mathcal{L}_{\text{distill}} = \text{KL}!\left(\sigma(s_{\text{student}}) ,|, \sigma(s_{\text{teacher}})\right) \]
- クエリ/文書エンコーダーの分離: SPLADEv2は、クエリと文書で異なるスパース性の目標を使います。クエリはよりスパースに(検索を高速に)し、文書はやや密でもよいものとします(オフラインで事前計算するためです)。
\[ \lambda_q > \lambda_d \quad \text{(e.g., } \lambda_q = 3 \times 10^{-4},; \lambda_d = 1 \times 10^{-4}\text{)} \]
-
FLOPS正則化: 単純な \(L_1\) の代わりに、SPLADEv2は予想される検索コストを直接罰するFLOPS対応正則化項を導入します。
\[ \mathcal{L}_{\text{FLOPS}} = \sum_{t \in \mathcal{V}} \left(\overline{a}_t^q\right)^2 + \sum_{t \in \mathcal{V}} \left(\overline{a}_t^d\right)^2 \]
ここで \(\overline{a}_t\) は項目 \(t\) のバッチ全体における平均活性値です。多くの文書で非ゼロになる項目(ポスティングリストが長い=検索が遅い項目)にペナルティを与えます。
-
効率的なバックボーン: BERT-base(1億1,000万パラメーター)の代わりにDistilBERT(6,600万パラメーター)を使い、品質をほとんど損なわずにエンコード時間を半減させます。
Important
SPLADEとSPLADEv2の比較
Tip
SPLADEを使う場合
SPLADE/v2を使う場合: クエリ時にGPUなしで意味検索を行いたい場合、インフラにすでに転置インデックス(Elasticsearch、Lucene)がある場合、または解釈可能な関連度スコアが必要な場合(どの拡張項目が一致したかを調べられます)。
密ベクトル検索を優先する場合: クエリエンコード用のGPU予算がある場合、多言語対応が必要な場合(密なモデルのほうが転移しやすい)、またはクエリが非常に短い場合(1〜2語では拡張の効果が小さいため)。
ベストプラクティス: 第1段階の検索器としてSPLADEv2を使い、上位 \(k\) 件にはクロスエンコーダーの再ランキング器を適用します。これにより、密ベクトル検索パイプラインと同等以上の性能を、より低いレイテンシーで実現できます。
ColBERT:レイトインタラクション
ColBERT (Khattab and Zaharia 2020)は、クエリと文書をトークンレベル埋め込みの集合へエンコードし、スコアリングにMaxSim演算子を使います。
\[ s(q, d) = \sum_{i \in |\mathbf{q}|} \max_{j \in |\mathbf{d}|} \mathbf{q}_i^\top \mathbf{d}_j \label{eq:colbert} \]
このレイトインタラクション機構は、単一ベクトルのバイエンコーダーより表現力が高く、文書埋め込みをオフラインで事前計算できるため、クロスエンコーダーよりはるかに高速です。
アーキテクチャ
クエリエンコーダー \(E_Q\) と文書エンコーダー \(E_D\) はどちらもBERTベースのモデルで、単一の [CLS] ベクトルではなく、トークンごとの埋め込みを生成します。各トークン埋め込みは線形層によって、通常128次元の低い次元へ射影されます。
\[ \begin{aligned} \mathbf{q}_i &= \text{Linear}(E_Q(q)_i) \in \mathbb{R}^{128}, \quad i = 1, \ldots, |q| \\ \mathbf{d}_j &= \text{Linear}(E_D(d)_j) \in \mathbb{R}^{128}, \quad j = 1, \ldots, |d| \end{aligned} \]
学習
ColBERTは、正例パッセージと負例パッセージに対するペアワイズsoftmax交差エントロピー損失で学習します。クエリ \(q\)、正例パッセージ \(d^+\)、負例パッセージの集合 \(\{d^-_1, \ldots, d^-_N\}\) があるとします。
\[ \mathcal{L}_{\text{ColBERT}} = -\log \frac{\exp(s(q, d^+))}{\exp(s(q, d^+)) + \sum_{k=1}^{N} \exp(s(q, d^-_k))} \]
ここで \(s(q, d)\) は式[eq:colbert]のMaxSimスコアです。負例は次から取得します。
-
バッチ内負例: 同じ学習バッチに含まれる他のパッセージ(無料で豊富)
-
ハード負例: BM25で検索された、語彙的には似ているが意味的には無関係なパッセージ(品質への影響が最も大きい)
-
蒸留負例 (ColBERTv2 (Santhanam et al. 2022):クロスエンコーダーの教師を使って最も難しい負例を発掘し、そのスコアをColBERTへ蒸留する)
インデックス作成とサービス提供
インデックス作成時には、すべての文書トークン埋め込みを事前計算して保存します(ColBERTv2では残差量子化による圧縮も可能です)。クエリ時にはクエリトークンだけをその場でエンコードし、保存済みの文書埋め込みに対してMaxSimを計算します。この分離により、次が可能になります。
-
オフライン文書エンコード: 一度エンコードすれば、多数のクエリに提供できる
-
PLAIDインデックス作成 (Santhanam et al. 2022):文書埋め込みをクラスタリングし、重心を使って候補を初期検索してから、候補に対してだけ正確なMaxSimを計算することで、レイテンシーを5〜10\(\times\)削減
-
インデックスサイズ: \(\vert d\vert \times 128\) 個の浮動小数点数(単一ベクトル方式より大きいものの、量子化により\(\sim\)2バイト/次元へ圧縮可能)
検索方式の比較
| 方式 | レイテンシー | 精度 | インデックスサイズ | GPU | 適する用途 |
|---|---|---|---|---|---|
| TF-IDF (Sparck Jones 1972) | 非常に低い | 低い | 小 | なし | ベースライン、完全一致 |
| BM25 (Robertson and Zaragoza 2009) | 非常に低い | 中 | 小 | なし | キーワード検索、希少語 |
| DPR/バイエンコーダー (Karpukhin et al. 2020) | 低い | 高い | 大 | あり | 意味的類似性 |
| SPLADE (Formal, Piwowarski, et al. 2021) | 低い | 高い | 中 | あり | 精度と速度の両立 |
| ColBERT (Khattab and Zaharia 2020) | 中 | 非常に高い | 非常に大 | あり | 高精度検索 |
| クロスエンコーダー (Nogueira and Cho 2019) | 高い | 最高 | 該当なし | あり | 上位 \(k\) 件の再ランキング |
| ハイブリッド(RRF) (Cormack et al. 2009) | 低い | 非常に高い | 大 | あり | 本番システム |
主要な側面における検索方式の比較 {#tab:retrieval_methods}
チャンク分割戦略
チャンク分割とは、文書を、(1) 埋め込みモデルのコンテキストウィンドウに収まるほど小さく、(2) 意味的に一貫し、(3) 単独で検索されたときにも役立つだけのコンテキストを含むセグメントへ分割するプロセスです。
オーバーラップ付き固定サイズチャンク分割
最も単純な戦略は、\(W\) トークンごとに分割し、連続するチャンク間に \(O\) トークンのオーバーラップを設けることです。
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512, # tokens per chunk
chunk_overlap=64, # overlap to preserve context across boundaries
length_function=len,
separators=["\n\n", "\n", ". ", " ", ""]
)
chunks = splitter.split_documents(documents)
オーバーラップの式: 長さ \(L\) トークンの文書におけるチャンク数は次のとおりです。
\[ N_{\text{chunks}} = \left\lceil \frac{L - O}{W - O} \right\rceil \]
意味チャンク分割
固定間隔で分割するのではなく、意味チャンク分割では、連続する文の埋め込み類似度を測定して検出したトピックの境界で分割します。
from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings
chunker = SemanticChunker(
embeddings=OpenAIEmbeddings(),
breakpoint_threshold_type="percentile", # or "standard_deviation"
breakpoint_threshold_amount=95, # split at top 5% dissimilarity
)
chunks = chunker.split_documents(documents)
文書構造を考慮したチャンク分割
構造化文書(Markdown、HTML、コード)では、自然な境界で分割します。
-
Markdown:
##見出しで分割し、節のコンテキストを保持する -
HTML:
<section>、<article>、<p>タグで分割する -
コード: 関数/クラス定義で分割し、各チャンクにインポートを保持する
-
表: 表全体を1つのチャンクとして保持し、行の途中では決して分割しない
親子チャンク分割
検索の粒度と生成コンテキストを切り離す強力なパターンです。
-
正確な検索のために小さな子チャンク(例:128トークン)をインデックス化する
-
より豊かなコンテキストをLLMへ渡すために大きな親チャンク(例:512トークン)を返す
from langchain.retrievers import ParentDocumentRetriever
from langchain.storage import InMemoryStore
from langchain.text_splitter import RecursiveCharacterTextSplitter
parent_splitter = RecursiveCharacterTextSplitter(chunk_size=2000)
child_splitter = RecursiveCharacterTextSplitter(chunk_size=400)
retriever = ParentDocumentRetriever(
vectorstore=vectorstore,
docstore=InMemoryStore(),
child_splitter=child_splitter,
parent_splitter=parent_splitter,
)
retriever.add_documents(documents)
チャンクサイズの経験則
| 用途 | 推奨チャンクサイズ | オーバーラップ |
|---|---|---|
| 事実確認QA(正確な事実) | 128〜256トークン | 20〜32トークン |
| 要約/統合 | 512〜1024トークン | 64〜128トークン |
| コード検索 | 関数全体 | なし |
| 法律/規制文書 | 段落単位 | 1文 |
| 会話/チャット | 256〜512トークン | 32〜64トークン |
用途別のチャンクサイズ推奨値
高度なRAGパターン
クエリ変換
ユーザーの生のクエリは、曖昧であったり、短すぎたり、文書の言語と十分に一致しなかったりすることがよくあります。クエリ変換技術は、検索前の検索品質を改善します。
HyDE(仮想文書埋め込み) (Luyu Gao et al. 2023)
クエリを直接埋め込む代わりに、仮想的な回答を生成して埋め込みます。
\[ \hat{d} = \text{LLM}(q), \quad \mathbf{e}_{\text{query}} = f_\phi(\hat{d}) \]
直感的には、仮想的な回答は実際の文書と同じ言語的レジスターにあるため、クエリと文書の分布の隔たりが小さくなります。
ステップバック・プロンプティング
具体的な質問では、まずより一般的な「ステップバック」質問を生成し、両方を検索してコンテキストを統合します。例:「エタノールの2気圧での沸点は?」 \(\to\) ステップバック:「液体の沸点に影響する要因は何か?」
マルチクエリ生成
クエリの多様な再定式化を \(M\) 個生成し、それぞれを検索して結果を和集合にします。
from langchain.retrievers.multi_query import MultiQueryRetriever
from langchain_openai import ChatOpenAI
retriever = MultiQueryRetriever.from_llm(
retriever=vectorstore.as_retriever(search_kwargs={"k": 5}),
llm=ChatOpenAI(temperature=0.7),
include_original=True, # also retrieve for original query
)
# Internally generates 3 query variants, retrieves for each, deduplicates
docs = retriever.get_relevant_documents(query)
再ランキング
初期検索で上位 \(k\) 件の候補を得た後、クロスエンコーダーの再ランキング器は各クエリと文書のペアを同時に評価し(両方へ同時に注意を向け)、より正確な関連度スコアを生成します。ただし、レイテンシーは高くなります。
\[ s_{\text{cross}}(q, d) = \text{CrossEncoder}([q; d]) \]
クロスエンコーダーは第1段階の検索には使えません(文書埋め込みを事前計算できないため)が、小さな候補集合(通常 \(k = 20\)〜\(100\))の再ランキングには理想的です。
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-large")
def rerank(query: str, docs: list[str], top_n: int = 5) -> list[str]:
pairs = [(query, doc) for doc in docs]
scores = reranker.predict(pairs)
ranked = sorted(zip(scores, docs), reverse=True)
return [doc for _, doc in ranked[:top_n]]
コンテキスト圧縮
検索されたチャンクには、関連する箇所の周囲に無関係な文が含まれていることがよくあります。コンテキスト圧縮では、LLMを使って関連部分だけを抽出します。
from langchain.retrievers import ContextualCompressionRetriever
from langchain.retrievers.document_compressors import LLMChainExtractor
compressor = LLMChainExtractor.from_llm(llm)
compression_retriever = ContextualCompressionRetriever(
base_compressor=compressor,
base_retriever=vectorstore.as_retriever()
)
compressed_docs = compression_retriever.get_relevant_documents(query)
Self-RAG
Self-RAG (Asai et al. 2023)は、単一のモデルを学習して、(1) 検索するかどうかを判断し、(2) 検索あり/なしで生成し、(3) 特別なリフレクショントークンを使って出力を自ら批評できるようにします。
-
[Retrieve]:モデルは追加のパッセージを検索すべきか -
[IsRel]:検索されたパッセージはクエリに関連しているか -
[IsSup]:生成された主張は検索されたパッセージから導かれているか -
[IsUse]:全体的な応答は役に立つか
モデルは応答と並行してこれらのトークンを予測するようエンドツーエンドで学習され、検索と自己評価をきめ細かく制御できるようになります。
CRAG:修正型RAG
CRAG (Yan et al. 2024)は、検索された文書を評価し、修正措置を起動する検索評価器を追加します。
-
上位 \(k\) 件の文書を検索する
-
各文書を評価する: 正しい / 曖昧 / 誤り
-
すべての文書が誤りまたは曖昧な場合は\(\to\)ウェブ検索へフォールバックする
-
一部の文書が正しい場合は\(\to\)知識の精緻化(無関係な文を除去する)を使う
-
精緻化したコンテキストから回答を生成する
Adaptive RAG
Adaptive RAG (Jeong et al. 2024)は、予測した複雑さに基づいてクエリを異なる検索戦略へ振り分けます。
-
検索なし: モデルがパラメーターから回答できる単純な事実確認クエリ
-
1ステップRAG: 中程度のクエリに対する標準的な検索・生成
-
マルチステップRAG: 複雑なマルチホップ質問に対する反復検索
クエリの複雑さのラベルで学習した軽量な分類器が、到着する各クエリを振り分けます。
Graph RAG
MicrosoftのGraph RAG (Edge et al. 2024)は、文書コーパスから知識グラフを構築し、コミュニティ検出を使って階層的な要約を生成します。
-
エンティティ抽出: LLMが各チャンクからエンティティと関係を抽出する
-
グラフ構築: グラフ \(G = (V, E)\) を構築する。ノードはエンティティ、エッジは関係を表す
-
コミュニティ検出: Leidenアルゴリズムを適用し、複数の解像度でコミュニティを見つける
-
コミュニティ要約: LLMが各コミュニティの要約を生成する
-
クエリ: グローバルクエリにはコミュニティ要約に対してmap-reduceを行い、ローカルクエリには標準的なベクトル検索を使う
Important
Graph RAGを使う場合
Graph RAGは、多数の文書にまたがる情報を統合する必要があるグローバルクエリ(「このコーパスの主なテーマは何か?」など)で優れていますが、構築と保守にコストがかかります。標準RAGは、ローカルクエリ(「文書XはトピックYについて何と述べているか?」など)に適しています。
RAG-Fusion
RAG-Fusion (Rackauckas 2024)は、元のクエリから複数の検索クエリを生成し、それぞれを検索して、RRF(式[eq:rrf])で順位リストを融合します。
def reciprocal_rank_fusion(ranked_lists: list[list[str]], k: int = 60) -> list[str]:
"""Fuse multiple ranked document lists using RRF."""
scores: dict[str, float] = {}
for ranked in ranked_lists:
for rank, doc_id in enumerate(ranked, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)
def rag_fusion(query: str, retriever, llm, n_queries: int = 4) -> str:
# Step 1: Generate query variants
variants = generate_query_variants(query, llm, n=n_queries)
# Step 2: Retrieve for each variant
all_ranked = [retriever.retrieve(q) for q in [query] + variants]
# Step 3: Fuse with RRF
fused_docs = reciprocal_rank_fusion(all_ranked)
# Step 4: Generate answer
return generate_answer(query, fused_docs[:5], llm)
効率的なRAGデコーディング:REFRAG
RAGにおける実用上のボトルネックはデコーディングレイテンシーです。LLMコンテキストへ連結された検索パッセージは長いのに関連性がまばらなことが多く、初回トークンまでの時間(TTFT)とKVキャッシュのメモリを膨らませます。REFRAG (X. Lin et al. 2025)は、検索パッセージが(多様化や再ランキング時の重複排除によって)独立に取得されるため、その注意パターンがブロック対角になる、つまりパッセージ間の大半の注意がほぼゼロになることに着目します。このスパース性により、デコーディング中にRAGコンテキストへ行う計算の大部分が不要になります。
Compress–Sense–Expandフレームワーク
REFRAGは、3段階のデコーディング戦略によってこの構造を利用します。
-
圧縮: 検索パッセージの完全なKV表現をコンパクトな要約(例:パッセージブロックごとの平均プール済みキー/値)に置き換え、メモリを大幅に削減する。
-
検知: 各デコーディングステップで、圧縮表現に対する軽量な注意を使い、現在のトークンに関連するパッセージブロックを特定する。
-
展開: 選択したブロックだけで完全なKVエントリを再構成し、スパースなアクティブ集合に対して正確な注意を実行する。
結果
LLaMAベースのモデルでは、REFRAGは最大 \(30.85\times\) のTTFT高速化(\(3.75\times\) の改善)を達成し、パープレキシティを損なわない。さらに、有効コンテキスト長を\(16\times\) 固定メモリ予算下で拡張します。これらの向上は、RAG、マルチターン対話、長文書要約タスク全体で得られます。
Tip
エージェント型RAGにREFRAGが重要な理由
エージェント型RAG(節2.7)では、クエリごとに複数回の検索が必要となり、レイテンシーが積み重なります。REFRAGのような効率的なデコーディング手法は不可欠な基盤です。各ラウンドのデコーディングコストをコンテキスト長に対して劣線形にすることで、反復的な検索・推論・生成ループを大規模でも実用的にします。
エージェント型RAG
動機:静的RAGの限界
標準的なRAGは、固定された検索してから生成するパターンに従います。これは次の場合に失敗します。
-
マルチホップ質問: 「2023年にOpenAIの主な競合相手を買収した会社を創業したのは誰か?」には、複数回の検索を連鎖させる必要がある
-
曖昧なクエリ: 適切な検索戦略は、何が見つかったかによって異なる
-
異種ソース: 異なるサブクエリには、異なる知識ベースが必要になる
-
反復的な改善: 初期検索によって、別のクエリが必要だと判明することがある
Tip
マルコフ決定過程としてのRAG
エージェント型RAGでは、検索を逐次的な意思決定問題として捉えます。状態は現在のコンテキスト(クエリと、ここまでに検索した文書)であり、行動には検索、推論、生成、停止が含まれます。報酬は回答の正しさです。エージェントは、いつ何を検索するかの方策を学習します。
エージェント型RAGのアーキテクチャ
マルチソース・ルーティング
エージェント型RAGシステムは、サブクエリを専門的な知識ソースへ振り分けられます。核心となる洞察は、質問の種類ごとに異なる検索バックエンドが必要であり、すべてに優れた単一のインデックスは存在しないということです。
なぜルーティングするのか
4つのクエリを処理する金融アナリストのアシスタントを考えてみましょう。
-
「自社のPTOポリシーは何か?」 \(\rightarrow\) ベクトルDB (社内文書)
-
「FRBは昨日何を発表したか?」 \(\rightarrow\) ウェブ検索 (リアルタイム)
-
「地域別に第3四半期の売上高を表示して」 \(\rightarrow\) SQLデータベース (構造化データ)
-
「自社の認証ミドルウェアはどのようにトークンを検証するか?」 \(\rightarrow\) コードインデックス (コードベース)
単一のインデックスから平坦に検索する方法では、回答を見失うか、無関係なパッセージを返してしまいます。ルーティングは検索開始前に、各サブクエリに適したツールを選びます。
ルーティング戦略
洗練度が上がる順に、主なアプローチは3つあります。
-
ルールベース・ルーティング: キーワードトリガー(例:SQLキーワード \(\rightarrow\) データベース、URLパターン \(\rightarrow\) ウェブ)。高速で解釈しやすい一方、曖昧なクエリには脆弱です。
-
分類器ベース・ルーティング: 軽量なモデル(例:ファインチューニングしたBERT分類器、またはクエリ埋め込みに対するロジスティック回帰)が最適なソースを予測します。低レイテンシー(\(<\)10ms)でルーティングログから学習できますが、ラベル付きデータが必要です。
-
LLMベース・ルーティング: LLM自身が構造化出力の呼び出しでソースを決定します(下のリストを参照)。最も柔軟で、新しいクエリの種類に対応し、推論を説明できますが、LLM呼び出し1回分のレイテンシーが加わります。
Tip
学習される方策としてのルーター
マルチソース・ルーティングは、最も単純には分類問題であり、最も高度には計画問題です。状態をクエリと会話履歴、行動をソースの選択(および任意のクエリ書き換え)、報酬を下流の回答品質とするRL方策として扱えば、ルーターを方策勾配手法によってエンドツーエンドで最適化できます(章5)。
実運用上の考慮事項
-
フォールバックチェーン: 主ソースが信頼度の低い結果を返したら、次に適したソースを試す
-
並列ファンアウト: 曖昧なクエリでは複数ソースから同時に検索し、Reciprocal Rank Fusionで結果を統合する(表2.1)。
-
コスト意識: ウェブ検索とAPI呼び出しには金銭的コストやレート制限があるため、ルーターはこれらを考慮すべきである
-
可観測性: 推論とともにすべてのルーティング判断を記録する。デバッグと再学習に不可欠である
from enum import Enum
from pydantic import BaseModel
class KnowledgeSource(str, Enum):
VECTOR_DB = "vector_db" # internal documents
WEB_SEARCH = "web_search" # real-time web
SQL_DB = "sql_db" # structured data
CODE_INDEX = "code_index" # codebase
API = "api" # external APIs
class RouteDecision(BaseModel):
source: KnowledgeSource
refined_query: str
reasoning: str
def route_query(query: str, llm) -> RouteDecision:
"""Use LLM to decide which knowledge source to query."""
prompt = f"""Given the query: "{query}"
Decide which knowledge source to use:
- vector_db: for internal documents, policies, past reports
- web_search: for current events, recent information
- sql_db: for numerical data, statistics, structured records
- code_index: for code examples, API documentation
- api: for real-time data (weather, stock prices, etc.)
Return a JSON with: source, refined_query, reasoning."""
return llm.with_structured_output(RouteDecision).invoke(prompt)
エージェント型RAGの完全実装
前節までで、ルーティング、検索、評価という個別のコンポーネントを紹介しました。完全なエージェント型RAGシステムは、これらを状態を持つノードのグラフとしてオーケストレーションします。つまり、制御フローが中間結果に応じて変化します。以下の実装ではLangGraphを使い、4つのノードをループへ接続します。
-
計画: ユーザークエリをサブクエリへ分解する(情報ニーズごとに1つ)。
-
検索: 各サブクエリを適切なソースへ振り分け、文書を取得する。
-
評価: 蓄積したコンテキストが元のクエリに回答するのに十分か判断する。
-
生成: 検索した文書から引用付きの最終回答を統合する。
重要な設計パターンは条件付きループです。評価後、エージェントは(コンテキストが十分、または反復予算を使い果たした場合)生成へ進むか、精緻化したサブクエリで検索へ戻ります。これは情報収集行動上で動作するRLエージェントの、検知・行動・評価サイクルに対応します。
from typing import TypedDict, Annotated
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
import operator
class AgentState(TypedDict):
query: str
sub_queries: list[str]
retrieved_docs: Annotated[list[dict], operator.add]
context_sufficient: bool
answer: str
iterations: int
max_iterations: int
def plan_node(state: AgentState) -> AgentState:
"""Decompose query into sub-queries."""
sub_queries = decompose_query(state["query"])
return {**state, "sub_queries": sub_queries, "iterations": 0}
def retrieve_node(state: AgentState) -> AgentState:
"""Retrieve documents for current sub-queries."""
new_docs = []
for sq in state["sub_queries"]:
source = route_query(sq)
docs = retrieve_from_source(sq, source)
new_docs.extend(docs)
return {**state, "retrieved_docs": new_docs,
"iterations": state["iterations"] + 1}
def evaluate_node(state: AgentState) -> AgentState:
"""Evaluate whether retrieved context is sufficient."""
sufficient = evaluate_context_sufficiency(
query=state["query"],
docs=state["retrieved_docs"]
)
return {**state, "context_sufficient": sufficient}
def generate_node(state: AgentState) -> AgentState:
"""Generate answer from retrieved context."""
answer = generate_with_citations(
query=state["query"],
docs=state["retrieved_docs"]
)
return {**state, "answer": answer}
def should_retrieve(state: AgentState) -> str:
if state["context_sufficient"]:
return "generate"
if state["iterations"] >= state["max_iterations"]:
return "generate" # give up and generate with what we have
return "retrieve"
# Build the graph
workflow = StateGraph(AgentState)
workflow.add_node("plan", plan_node)
workflow.add_node("retrieve", retrieve_node)
workflow.add_node("evaluate", evaluate_node)
workflow.add_node("generate", generate_node)
workflow.set_entry_point("plan")
workflow.add_edge("plan", "retrieve")
workflow.add_edge("retrieve", "evaluate")
workflow.add_conditional_edges("evaluate", should_retrieve,
{"retrieve": "retrieve", "generate": "generate"})
workflow.add_edge("generate", END)
agent = workflow.compile()
# Run
result = agent.invoke({
"query": "What were the main causes of the 2023 banking crisis?",
"max_iterations": 3,
"retrieved_docs": [],
"iterations": 0,
})
ツール拡張RAG
エージェント型RAGは検索と計算ツールを組み合わせられます。
from langchain.agents import create_tool_calling_agent, AgentExecutor
from langchain.tools import tool
@tool
def search_documents(query: str) -> str:
"""Search internal document knowledge base."""
docs = vectorstore.similarity_search(query, k=5)
return "\n\n".join(d.page_content for d in docs)
@tool
def query_database(sql: str) -> str:
"""Execute SQL query on the analytics database."""
return db.run(sql)
@tool
def web_search(query: str) -> str:
"""Search the web for current information."""
return tavily_client.search(query)
@tool
def execute_python(code: str) -> str:
"""Execute Python code for calculations."""
return python_repl.run(code)
tools = [search_documents, query_database, web_search, execute_python]
agent = create_tool_calling_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools, verbose=True)
Search-R1:RL学習されたエージェント型RAG
上記のエージェント型RAGアプローチは、プロンプトエンジニアリングされたオーケストレーションに依存しています。エージェントの検索動作は指示によって制御され、学習によって獲得されたものではありません。 Search-R1 (Jin et al. 2025)は根本的に異なる方法を取り、LLMを強化学習によって学習させ、推論プロセスの一部としていつ、何を、何回検索するかを学習します。
基本概念
Search-R1は、検索エンジンへのクエリをRL学習ループ内の 行動 として扱うことで、DeepSeek-R1 (DeepSeek-AI et al. 2025)の推論フレームワークを拡張します。連鎖思考の生成中、モデルは特別なトークン <search>query</search> を出力して検索エンジンからリアルタイムに検索できます。検索結果は推論コンテキストへ挿入され、モデルは生成を続けます。
形式的設定
モデルは検索行動を織り交ぜた推論トレースを生成します。
\[
\underbrace{\text{think}_1}_{\text{reasoning}} \to \underbrace{\texttt{
軌跡全体(推論+検索+最終回答)は、終端報酬によってスコアリングされます。報酬は、正解ラベルに対する最終回答の正しさです。
学習アルゴリズム
Search-R1はGRPO(Group Relative Policy Optimization)を使います。
-
質問ごとに \(N\) 本の軌跡をサンプリングする :各軌跡には0〜5回の検索呼び出しが含まれうる
-
検索をリアルタイムで実行する: 環境が実際の検索エンジン結果を返す
-
終端回答の正しさをスコアリングする: 正解に対する完全一致またはF1
-
グループ相対アドバンテージを計算する: \(\hat{A}_i = (R_i - \mu_G) / \sigma_G\)
-
方策を更新する: GRPOのクリップ付き目的関数により、効果的に検索した軌跡を強化する
モデルは次を学習します。
-
不確かなときに検索する: すでに持っている知識に対する不要な検索を避ける
-
効果的なクエリを組み立てる: 関連する結果を返すクエリの表現を学習する
-
複数回検索する: 初期結果に基づいてクエリを反復的に改善する
-
検索コンテキストを統合する: 検索結果を使って推論を裏付けたり、修正したりする
Search-R1とプロンプトベースのエージェント型RAGの違い
| 側面 | プロンプトベースのエージェント型RAG | Search-R1 |
|---|---|---|
| 検索の判断 | プロンプト/ヒューリスティック | RLで学習 |
| クエリの組み立て | プロンプトで指示(「クエリを書き換える」) | エンドツーエンドで学習 |
| 検索回数 | 推論時に固定またはLLMが決定 | 最適な回数を学習 |
| 学習信号 | なし(モデルを凍結) | 正しさの報酬 |
| 検索の統合 | コンテキストへ追加 | CoTに織り交ぜる |
| 失敗からの回復 | 再試行ヒューリスティック | 学習したバックオフ/再定式化 |
| 推論時のオーバーヘッド | フレームワークのオーバーヘッド(LangGraph) | モデル本来の動作 |
プロンプトベースのエージェント型RAGとSearch-R1(RL学習)の比較
結果
オープンドメインQAベンチマーク(NQ (Kwiatkowski et al. 2019)、TriviaQA (Joshi et al. 2017)、HotpotQA (Yang et al. 2018))では、7BモデルのSearch-R1は次を上回ります。
-
標準RAG(単一検索)を15〜20%の精度で上回る
-
プロンプト型エージェントRAG(ReAct形式)を8〜12%の精度で上回る
-
標準RAGを使う、はるかに大きなモデル(70B)の性能に近づく
重要な洞察は、 いつどのように検索するかを学習することは、より多くを知る大きなモデルを持つことより価値がある ということです。うまく検索する小さなモデルは、検索しない大きなモデルに勝ります。
Tip
Search-R1:パラダイムシフト
従来のRAGはこう尋ねます。「このクエリに対して、何を検索すべきか?」(生成前に行うパイプライン上の判断)。
Search-R1はこう尋ねます。「ここまで推論した内容から、さらに情報が必要か?必要なら、このギャップを埋める具体的な質問は何か?」(生成中に行う学習された判断)。
これは、試験の前に教科書を調べる学生と、問題の途中で行き詰まったと気づいたときに参考文献を調べる学生の違いです。後者のほうが効率的で、的を絞っています。
評価
RAGシステムの評価は、検索や生成を個別に評価するより難しくなります。エラーはパイプラインのどの段階でも発生し得て、しかも積み重なるためです。完全な生成器でも無関係な検索結果を補えず、完全な検索器でも生成器がハルシネーションを起こしたりコンテキストを無視したりすれば無駄になります。
したがって、効果的なRAG評価は 3つのレベル で行います。
-
検索品質: 検索器は正しいパッセージを見つけたか?(再現率、適合率、MRR、NDCG)
-
生成品質: 回答は正しく、検索されたコンテキストに忠実で、完全か?(正しさ、忠実性、回答関連度)
-
エンドツーエンド品質: システム全体はユーザーを満足させるか?(人間の選好、タスク成功率、レイテンシー調整済み効用)
よくある失敗は、1つのレベルだけを最適化することです。たとえば大きな \(K\) でRecall@\(K\)を最大化すると、周辺的にしか関連しないパッセージでコンテキストが埋まり、実際には生成品質が低下します。以下の指標は検索と生成の両方を対象とし、どの段階がボトルネックかを診断できるようにします。
検索指標
\(\mathcal{R}_k\) はランク \(k\) で検索された文書の集合で、\(\mathcal{R}^*\) は関連文書の集合とします。
Recall@K
\[ \text{Recall@}K = \frac{|\mathcal{R}_K \cap \mathcal{R}^*|}{|\mathcal{R}^*|} \]
Precision@K
\[ \text{Precision@}K = \frac{|\mathcal{R}_K \cap \mathcal{R}^*|}{K} \]
平均逆順位(MRR)
\[ \text{MRR} = \frac{1}{|Q|} \sum_{i=1}^{|Q|} \frac{1}{\text{rank}_i} \]
ここで \(\text{rank}_i\) は、クエリ \(i\) の最初の関連文書の順位です。
正規化割引累積利得(NDCG@K)
\[ \text{NDCG@}K = \frac{\text{DCG@}K}{\text{IDCG@}K}, \quad \text{DCG@}K = \sum_{i=1}^{K} \frac{\text{rel}_i}{\log_2(i+1)} \]
ここで \(\text{rel}_i \in \{0, 1, 2, \ldots\}\) は第\(i\) 件の結果の段階的関連度で、IDCGは理想(完全な)DCGです。
生成指標
忠実性
生成された回答が検索されたコンテキストにグラウンディングされているか、つまり回答中のすべての主張を検索された文書に帰属できるかを測定します。LLMの評価者によって評価します。
\[ \text{Faithfulness} = \frac{\text{# claims supported by context}}{\text{# total claims in answer}} \]
回答関連度
回答が質問に答えているかを測定します。回答から質問を生成し、元のクエリとの類似度を測定して計算します。
\[ \text{AnswerRelevance} = \frac{1}{N} \sum_{i=1}^{N} \cos!\left(E(q), E(\hat{q}_i)\right) \]
ここで \(\hat{q}_i\) は回答から生成された質問です。
コンテキストの適合率と再現率
\[ \begin{aligned} \text{ContextPrecision@}K &= \frac{1}{K} \sum_{k=1}^{K} \text{Precision@}k \cdot \mathbf{1}[\text{doc}_k \text{ is relevant}] \\ \text{ContextRecall} &= \frac{\text{# ground-truth claims attributable to context}}{\text{# total ground-truth claims}} \end{aligned} \]
RAGAsフレームワーク
RAGAs(Retrieval Augmented Generation Assessment) (Es et al. 2023)は、LLM評価者を使う参照不要の評価フレームワークを提供します。
from ragas import evaluate
from ragas.metrics import (
faithfulness,
answer_relevancy,
context_precision,
context_recall,
answer_correctness,
)
from datasets import Dataset
eval_dataset = Dataset.from_dict({
"question": questions,
"answer": generated_answers,
"contexts": retrieved_contexts, # list of lists
"ground_truth": reference_answers,
})
results = evaluate(
dataset=eval_dataset,
metrics=[
faithfulness,
answer_relevancy,
context_precision,
context_recall,
answer_correctness,
],
)
print(results.to_pandas())
よくある失敗モード
Warning
監視すべきRAGの失敗モード
検索漏れ: 関連文書はコーパスに存在するが検索されない。原因:不適切なチャンク分割、埋め込みモデルの不一致、クエリと文書の語彙の隔たり。
コンテキスト汚染: 検索された文書に誤解を招く、または矛盾する情報が含まれ、モデルが誤った回答を生成する。
Lost-in-the-Middle: LLMは長いコンテキストの先頭と末尾に強く注意を向けるため、中央の関連情報が無視されることがある (N. F. Liu et al. 2024b)。
過剰検索: 検索されたチャンクが多すぎると、関連信号が薄まり、レイテンシーとコストが増加する。
検索してもハルシネーション: 特にコンテキストが学習データと矛盾すると、モデルが検索コンテキストを無視してパラメトリック記憶から生成する。
引用の捏造: モデルが、主張を裏付けていない文書にその主張を帰属させる。
本番運用上の考慮事項
埋め込みモデルの選定
埋め込みモデルはRAGシステムで最も影響の大きいコンポーネント選択です。検索品質の上限を決めるためです。この分野は急速に進歩しており、表2.2にコストと品質のスペクトル全体にわたる現在の選択肢をまとめます。
| モデル | 次元数 | 最大トークン数 | MTEB平均 | アクセス | 注記 |
|---|---|---|---|---|---|
| APIベース(マネージド) | |||||
Voyage voyage-4-large | 1024* | 32K | — | API | 最良の検索品質 |
OpenAI text-embedding-3-large | 3072 | 8191 | 64.6 | API | Matryoshka次元 |
Cohere embed-english-v3.0 | 1024 | 512 | 64.5 | API | int8/バイナリ対応 |
Google text-embedding-005 | 768 | 2048 | — | API | Vertex AI統合 |
| オープンウェイト(セルフホスト) | |||||
nvidia/NV-Embed-v2 (Lee et al. 2024) | 4096 | 32K | 72.3 | 無料 | MTEB第1位(2024年9月) |
Alibaba-NLP/gte-Qwen2-7B (Z. Li et al. 2023) | 3584 | 32K | 70.2 | 無料 | Apache-2.0、多言語 |
BAAI/bge-m3 (J. Chen et al. 2024) | 1024 | 8192 | 65.0 | 無料 | 密ベクトル検索+スパース検索+マルチベクトル |
jinaai/jina-embeddings-v3 | 1024 | 8192 | 66.0 | 無料 | 多言語、LoRAアダプター |
BAAI/bge-large-en-v1.5 (S. Xiao et al. 2023) | 1024 | 512 | 64.2 | 無料 | 成熟しており、十分にサポートされている |
本番RAG向けの埋め込みモデル(2026年時点)。MTEBスコアは検索、分類、クラスタリング、STSタスク全体の平均値です。{#tab:embedding_models}
選定基準
-
ドメイン適合: 特化モデル(例:コード用の
voyage-code-3、金融用のvoyage-finance-2)は、ドメインタスクで汎用モデルを5〜15%上回ることがある。 -
コンテキスト長: 32Kトークンのコンテキストを持つモデル(Voyage-4、NV-Embed-v2)は、チャンク分割せず文書全体を埋め込めるため、パイプラインを簡素化できる。
-
Matryoshka埋め込み: 柔軟な出力次元(256〜4096)に対応するモデルでは、再エンコードせずにサービス提供時の品質とストレージ/レイテンシーをトレードオフできる。
-
量子化対応: モデルレベルのint8またはバイナリ量子化(Cohere、Voyage)により、再現率をほとんど損なわずインデックスサイズを4–32\(\times\) 削減する。
-
多言語: 英語以外または言語横断RAGでは、多言語で明示的に学習されたモデル(BGE-M3、Jina-v3、Voyage-4)を優先する。
ベクトルデータベースの比較
| データベース | ホスティング | 規模 | フィルタリング | ハイブリッド | 適する用途 |
|---|---|---|---|---|---|
| FAISS1 | セルフホスト | 数十億 | 限定的 | なし | 研究、オフライン |
| Pinecone2 | マネージド | 数十億 | あり | あり | サーバーレス、簡単なセットアップ |
| Weaviate3 | どちらも | 数十億 | あり | あり | GraphQL、マルチモーダル |
| Chroma4 | セルフホスト | 数百万 | あり | なし | ローカル開発、プロトタイピング |
| Qdrant5 | どちらも | 数十億 | あり | あり | 高性能 |
| Milvus6 | どちらも | 数十億 | あり | あり | エンタープライズ、GPU高速化 |
| pgvector7 | セルフホスト | 数百万 | あり | あり | 既存のPostgresユーザー |
本番RAGシステム向けのベクトルデータベース比較
5https://qdrant.tech 6https://milvus.io 7https://github.com/pgvector/pgvector
レイテンシーの最適化
-
事前フィルタリング: メタデータフィルター(期間、カテゴリ、ソース)を使い、ANN検索前に検索空間を狭める
-
近似最近傍: 正確な検索の代わりにHNSWまたはIVFインデックスを使い、\(\sim\)1% の再現率低下を受け入れて\(10\times\) の高速化を得る
-
埋め込みキャッシュ: 頻繁に繰り返されるクエリの埋め込みをキャッシュする
-
非同期検索: 複数ソースから並列に検索する
-
ストリーミング生成: 検索の完了中にLLM出力をストリームする
-
量子化: 埋め込みにint8またはバイナリ量子化を使い、メモリを削減してスループットを向上させる
非同期並列検索
上記の手法(3)と(4)は自然に組み合わせられます。クエリ埋め込みをキャッシュしてから、複数のバックエンドへ検索リクエストを同時にファンアウトします。マルチソースRAGシステム(節2.7)では、ユーザークエリがベクトルデータベース、キーワードインデックス、ウェブAPIから結果を必要とすることがあります。逐次検索ではレイテンシーが加わりますが、並列検索では最も遅いソースのコストだけを支払います。リスト[lst:async_retrieve]は、Pythonのasyncioを使ってこのパターンを示します。lru_cacheデコレーターによって繰り返しクエリでは埋め込みモデルを完全にスキップでき、asyncio.gatherによってすべてのソースクエリを同時に送出します。
import asyncio
from functools import lru_cache
@lru_cache(maxsize=1024)
def get_cached_embedding(text: str) -> list[float]:
return embedding_model.embed_query(text)
async def parallel_retrieve(
query: str,
sources: list[str],
k: int = 5
) -> list[dict]:
"""Retrieve from multiple sources in parallel."""
tasks = [
asyncio.create_task(retrieve_from_source_async(query, src, k))
for src in sources
]
results = await asyncio.gather(*tasks, return_exceptions=True)
# Flatten and deduplicate
all_docs = []
for r in results:
if not isinstance(r, Exception):
all_docs.extend(r)
return deduplicate_by_content(all_docs)
増分インデックス作成とバージョン管理
本番環境では文書コーパスが静的になることはありません。ポリシーは改訂され、新しいレポートは毎日追加され、非推奨コンテンツは削除する必要があります。完全な再インデックス作成(再チャンク分割、再埋め込み、再アップロード)は高コストで、ダウンタイムを引き起こします。増分インデックス作成は、文書レベルで変更を適用してこの問題を解決します。
基本操作
-
アップサート: 文書が作成または更新されたら、その
doc_idに対する既存の全チャンクを削除し、新しい内容を再チャンク分割して埋め込み、挿入する。これにより古い断片が残らないことが保証される。 -
削除/期限切れ: 文書IDでチャンクを削除する(明示的な削除)か、TTLで削除する(ニュースや市場データなど時間に敏感なソースの自動ガベージコレクション)。
-
バージョン追跡: チャンクのメタデータに
versionとindexed_atのタイムスタンプを保存する。これによりロールバック(ソースから以前のバージョンを復元)と監査可能性(「モデルはどのバージョンを見たか?」)が可能になる。
一貫性に関する課題
-
埋め込みモデルのドリフト: 埋め込みモデルをアップグレードすると、古いベクトルと新しいベクトルに互換性がなくなる。解決策:(a) モデルバージョンごとに別インデックスを維持してバックグラウンドで移行する、または (b) 次元の切り詰めによって互換性を保つMatryoshka対応モデルを使う。
-
チャンク境界の変化: チャンク分割戦略を変えると既存の全チャンクが無効になる。バージョンメタデータによって影響を受ける文書を特定し、選択的に再インデックス作成できる。
-
結果整合性: 分散ベクトルデータベースでは、アップサートしたばかりのベクトルがすぐに検索可能にならないことがある。短いインデックス作成遅延(通常は数秒〜数分)を許容できるようパイプラインを設計する。
実装
リスト[lst:incremental_index]は、アップサートと期限切れ処理をカプセル化した最小限の RAGIndexManager クラスを示します。メタデータフィルタリングに対応する任意のベクトルストアをラップするのに適しています。
class RAGIndexManager:
def __init__(self, vectorstore, metadata_store, chunker, embedder):
self.vs = vectorstore
self.meta = metadata_store
self.chunker = chunker
self.embedder = embedder
def upsert_document(self, doc_id: str, content: str,
metadata: dict) -> None:
"""Add or update a document, replacing old chunks."""
# Delete existing chunks for this document
self.vs.delete(filter={"doc_id": doc_id})
# Chunk new version (vectorstore embeds internally)
chunks = self.chunker.split_text(content)
self.vs.add_texts(
texts=chunks,
metadatas=[{**metadata, "doc_id": doc_id,
"version": metadata.get("version", 1),
"indexed_at": datetime.utcnow().isoformat()}
for _ in chunks],
)
def expire_old_documents(self, ttl_days: int = 365) -> int:
"""Remove documents older than TTL."""
cutoff = (datetime.utcnow() - timedelta(days=ttl_days)).isoformat()
return self.vs.delete(filter={"indexed_at": {"$lt": cutoff}})
RAGとファインチューニングの相乗効果
RAGとファインチューニングを組み合わせる場合
ファインチューニングとRAGは、相補的な弱点に対処します。
-
ファインチューニングだけ: モデルはスタイルと形式を学習するが、事実をハルシネーションすることがある
-
RAGだけ: モデルは事実へアクセスできるが、それを最適に使う方法を知らないことがある
-
組み合わせ: 検索したコンテキストをうまく使うようモデルをファインチューニングする。出典を引用し、不確実性を認め、無関係なコンテキストを無視する
RAFT:検索拡張ファインチューニング
RAFT (T. Zhang et al. 2024)は、関連文書と妨害文書を混ぜて与えられた質問に回答するようモデルを学習させ、関連するコンテキストだけを識別して使うことを教えます。
-
各学習例 \((q, a, d^*)\) について、\(k-1\) 個の妨害文書 \(\{d_i^-\}\)
-
次でファインチューニングする:
[q,\(d^*\),\(d_1^-\), …,\(d_{k-1}^-\)]\(\to\)[chain-of-thought + a] -
連鎖思考は \(d^*\) から明示的に引用し、モデルに回答をグラウンディングする方法を教える
\[ \mathcal{L}_{\text{RAFT}} = -\mathbb{E}_{(q,a,d^*,\{d_i^-\})} \left[ \log P_\theta!\left(\text{CoT}(d^*) \oplus a ;\middle|; q, d^*, \{d_i^-\}\right) \right] \]
検索器と生成器の共同学習
最大性能を得るには、検索器と生成器を共同で学習できます。REALM (Guu et al. 2020)とRAG (P. Lewis et al. 2020)の論文は、検索ステップを通じて勾配を流すエンドツーエンド学習を提案しています。
\[ \nabla_\theta \mathcal{L} = \nabla_\theta \left[ -\log \sum_{d \in \mathcal{D}} P_\theta(a \mid q, d) \cdot P_\phi(d \mid q) \right] \]
検索器のパラメーター \(\phi\) は、REINFORCE推定量を使うか、\(P_\phi(d \mid q)\) を文書に対する微分可能な注意として扱うことで更新されます。
Warning
共同学習の課題
検索器と生成器の共同学習は強力ですが複雑です。(1) 文書インデックスは\(\phi\) が変化するたびに定期的に更新する必要があります(非同期インデックス更新)。(2) 学習信号はスパースです(上位\(k\) 文書だけが寄与します)。(3) 事前学習済み検索器から慎重に初期化しなければ、学習は不安定です。
RAGアプローチの包括的な比較
| アプローチ | 精度 | レイテンシー | 複雑さ | コスト | 適する用途 |
|---|---|---|---|---|---|
| Naive RAG (P. Lewis et al. 2020) | 中 | 低 | 低 | 低 | プロトタイピング、単純なQA |
| RAG + 再ランキング (Nogueira and Cho 2019) | 高 | 中 | 中 | 中 | 本番QAシステム |
| HyDE (Luyu Gao et al. 2023) | 高 | 中 | 低 | 中 | 意味の不一致があるドメイン |
| マルチクエリRAG | 高 | 中 | 中 | 中 | 曖昧なクエリ |
| RAG-Fusion (Rackauckas 2024) | 高 | 中 | 中 | 中 | 多様なクエリ種別 |
| Self-RAG (Asai et al. 2023) | 高 | 中 | 高 | 中 | 選択的検索 |
| CRAG (Yan et al. 2024) | 高 | 中 | 高 | 高 | 信頼性の低いコーパス |
| Adaptive RAG (Jeong et al. 2024) | 高 | 低〜高 | 高 | 中 | 混在するクエリの複雑さ |
| Graph RAG (Edge et al. 2024) | 非常に高 | 高 | 非常に高 | 高 | グローバル統合クエリ |
| エージェント型RAG | 非常に高 | 高 | 非常に高 | 高 | マルチホップ推論 |
| RAFT (T. Zhang et al. 2024) | 非常に高 | 低 | 非常に高 | 非常に高 | ドメイン固有の配備 |
主要な側面におけるRAGアプローチ
Note
RAGシステムの主要な設計上の問い
本番向けRAGシステムを設計する際は、次を検討します。
クエリ分布はどのようなものか? 事実確認、分析、マルチホップのクエリには、それぞれ異なる検索戦略が必要です。
コーパスの規模と動的性はどの程度か? 頻繁に更新される数百万の文書には、増分インデックス作成が可能なマネージド・ベクトルデータベースが適しています。
レイテンシー要件は何か? 100ms未満の応答では再ランキングやエージェントループは使えません。バッチまたは非同期のユースケースならこれらを許容できます。
グラウンディングはどれほど重要か? 医療、法律、金融などの高リスク領域では、忠実性の評価と引用の検証が必要です。
語彙は専門的か? ドメイン固有の用語には、ハイブリッド検索やドメイン適応済みの埋め込みモデルが必要になることがあります。
Important
RAGのベストプラクティスまとめ
シンプルに始める: 優れたチャンク分割による素朴なRAGは、粗悪なチャンク分割による複雑なシステムを上回ることが多い
検索を個別に評価する: 生成を最適化する前に検索を修正する
ハイブリッド検索を使う: BM25+密ベクトル検索とRRFは堅実なデフォルトである
再ランキングを加える: 上位20候補へのクロスエンコーダー再ランキング器は投資対効果が高い
忠実性を監視する: LLM評価者を使い、本番環境でハルシネーション率を追跡する
積極的にキャッシュする: 文書は一度だけ埋め込み、頻繁なクエリの埋め込みをキャッシュする
オーバーラップ付きでチャンク分割する: 10〜15%のオーバーラップで境界における情報損失を防ぐ
豊富なメタデータを保存する: ソース、日付、節、文書種別によって強力な事前フィルタリングが可能になり、精度が大幅に向上する