- Published on
RAGが的外れな答えを返すとき — 検索の失敗と生成の失敗を切り分けるデバッグ手順
- Authors

- Name
- Youngju Kim
- @fjvbn20031
はじめに — 答えが間違っています。どこから見ますか
RAGシステムが文書に明らかに書いてある内容について的外れな答えを返すと、ほとんどのチームはプロンプトを直します。「提供された文脈のみに基づいてください」をより強い語調で書き直します。そしてたいてい良くなりません。
当然です。モデルは指示に背いたのではなく、受け取った文脈の中に正解がなかっただけ、という場合が多いからです。RAGは検索と生成が直列につながったパイプラインであり、前段が間違っていれば後段には手をつける場所がありません。
この記事では順序を守るデバッグ手順を整理します。まず原因を切り分け、最もよくある犯人から確認し、最後に測定体系を立てます。順序を守らないとプロンプトだけを6か月直し続けることになります。
第1段階 — 検索の失敗か、生成の失敗か
まず最初にやるべきことは、検索されたチャンクを目で見ることです。この段階を飛ばして進めるデバッグはすべて推測です。ところが驚くことに、多くのパイプラインは検索結果をログに残していません。
ログがあるなら、次の実験一つで原因が分かれます。
def diagnose(question, gold_passage, retriever, generate):
"""正解チャンクを直接入れた場合と検索結果を入れた場合を比較する。"""
retrieved = retriever(question, k=5)
ctx_retrieved = "\n\n".join(c.text for c in retrieved)
ans_normal = generate(question, ctx_retrieved)
ans_oracle = generate(question, gold_passage) # 検索を飛ばす
gold_in_ctx = gold_passage[:200] in ctx_retrieved
if not gold_in_ctx and is_correct(ans_oracle):
return "검색 실패" # 正解だけ渡せば当てる → 前段の問題
if gold_in_ctx and not is_correct(ans_normal):
return "생성 실패" # 正解を渡しても間違える → 後段の問題
if not is_correct(ans_oracle):
return "생성 실패 (근본)" # 正解文書だけ渡しても解けない
return "정상"
この診断をゴールデンセット全体に回すと、二つの失敗の比率が出ます。私の経験では初期のパイプラインでは検索の失敗のほうがはるかに多く、その中でもほとんどがチャンキングから来ます。
生成の失敗と判定された場合はさらに三つに分かれます。文脈に答えがあるのに無視して事前知識で答えた場合、複数のチャンクに散らばった情報を統合できなかった場合、そして文脈同士が矛盾するときに誤ったほうを選んだ場合です。三つとも処方が違うので、ひとまとめにしてはいけません。
「分からなければ分からないと言ってください」の限界
生成の失敗への対応として最初に試すのが、根拠のない答えを止める指示です。
SYSTEM = """제공된 문맥만 근거로 답하십시오.
문맥에 답이 없으면 정확히 "문서에서 찾을 수 없습니다"라고만 출력하십시오.
각 주장 끝에 근거 청크 번호를 대괄호로 표기하십시오."""
この指示には効果があります。ただし二つのことを知ったうえで使う必要があります。
第一に、棄権を強く要求すると、答えられる質問でも棄権する比率が一緒に上がります。これは適合率と再現率の交換であって純粋な改善ではありません。ですから「ハルシネーション率が下がった」だけを報告せず、「回答率がどれだけ下がったか」も併せて見る必要があります。
第二に、根拠の表記を要求しても、モデルが書いた番号が実際の根拠と一致する保証はありません。引用番号は検証可能な形式にすぎず、検証は皆さんのコードが行うべきものです。引用されたチャンクにその主張が実際にあるかを別途確認する段階を付けて、初めて信頼できる信号になります。
チャンキングが原因の半分です
検索の失敗と判定されたなら、埋め込みモデルを変える前にチャンクを先に見てください。ほとんどはそこで終わります。
典型的な破壊の例はこうです。表が途中で切れてヘッダーとデータが別のチャンクに分かれます。コードブロックが関数の途中で切れます。「上記の条件に該当する場合」で始まる段落が前の段落と分離され、何を指しているのか分からなくなります。条項番号だけの行と本文が別々になります。
固定長の分割はこうした構造をまったく知りません。ですから最初の改善はほぼ常に、文書構造を尊重する分割です。マークダウンなら見出しの境界、HTMLならセクションタグ、PDFならレイアウト解析の結果を優先境界として使い、その中で長さが溢れるときにだけ切ります。表とコードブロックは丸ごと一つのチャンクとして残し、長さ制限の例外として扱います。
サイズと重なりはその次の問題です。計算自体は単純です。
import math
def chunk_stats(doc_tokens, size, overlap):
stride = size - overlap
n = 1 if doc_tokens <= size else math.ceil((doc_tokens - overlap) / stride)
stored = n * size
return n, stored / doc_tokens
for size, ov in [(256, 32), (512, 64), (1024, 128), (512, 256)]:
n, infl = chunk_stats(50_000, size, ov)
print(f"size={size:4} overlap={ov:3} → 청크 {n:3}개, 저장 토큰 {infl:.2f}배")
# size= 256 overlap= 32 → 청크 224개, 저장 토큰 1.15배
# size= 512 overlap= 64 → 청크 112개, 저장 토큰 1.15배
# size=1024 overlap=128 → 청크 56개, 저장 토큰 1.15배
# size= 512 overlap=256 → 청크 195개, 저장 토큰 2.00배
重なりをサイズの半分に取ると保存量が二倍になります。ベクトルの保存コスト自体は大したことがありません。チャンク112個に1536次元fp32の埋め込みなら690KB程度です。本当の代償は、候補リストがほぼ同じ内容で埋まってしまうことです。上位5個を取ったのに4個が同じ段落の重なった断片なら、実効的な文脈は一つだけです。重なりはサイズの10〜15パーセントで足りる場合がほとんどです。
サイズに正解はありません。ただし方向はあります。小さいチャンクは検索の精度が高く文脈が不足し、大きいチャンクはその逆です。質問が単一の事実照会型なら小さく、手順や説明を求めるものなら大きくするほうが妥当です。一つに決められないなら、小さく検索して隣接チャンクを一緒に広げて渡す方式が実用的な折衷です。
埋め込みの類似度は意味の類似度ではありません
埋め込み検索が失敗する典型的なパターンがあります。
クエリに正確な識別子が入っている場合です。エラーコード、製品番号、関数名、社員番号のようなトークンは、埋め込み空間では似た形の別の識別子とほとんど区別できません。ベクトルは意味を圧縮する装置であり、こうしたトークンの価値はまさにその文字列そのものにあるからです。
否定や条件が反転している場合です。「返金が可能な条件」と「返金が不可能な条件」はコサイン類似度が非常に高くなります。埋め込みは主題を捉えますが論理は捉えません。
クエリと文書の表現形式が違う場合です。短い質問と長い叙述的な段落は、そもそも分布が違います。クエリ用と文書用のプレフィックスを別々に要求する埋め込みモデルがあるのはこのためで、そのプレフィックスを落とすと性能が目に見えて下がります。
一つ目と三つ目は語彙検索を混ぜればかなりの部分が解決します。BM25は正確な文字列一致に強く、まれなトークンに高い重みを与えます。二つの結果を合わせる最も無難な方法は、スコアを正規化せず順位だけを使う逆順位融合です。
def rrf(rankings, k=60, top_n=10):
"""rankings: 検索器ごとの文書IDリスト(順位順)。スコアのスケールを揃える必要がない。"""
scores = {}
for ranking in rankings:
for rank, doc_id in enumerate(ranking, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return sorted(scores, key=scores.get, reverse=True)[:top_n]
hits = rrf([
bm25_search(query, k=50), # 語彙
vector_search(query, k=50), # 意味
])
定数60は原論文で提案された値で、実務でそのまま使ってもおおむね無難です。この方式の長所は調整する値が事実上一つしかないことで、短所はスコアの大きさを捨てるため、ある検索器が圧倒的に確信している場合を反映できないことです。
ハイブリッドが常に勝つとは言いません。ドメインに固有名詞と識別子が少なく、文が叙述中心なら純粋なベクトル検索のほうが良いこともあります。ただし二つの検索器を同時に回すコストは低く実装も半日なので、まず試して測るほうが合理的な順序です。
リランキング — いつ値打ちを出すのか
リランキングは一次検索で候補を多めに取り、そのうち上位の一部を精密なモデルで並べ直す段階です。クロスエンコーダはクエリと文書を一度にエンコードして相互作用を直接見るので、それぞれ別々にベクトルを作る方式より正確です。
代償は計算量です。ベクトル検索はクエリを一度エンコードすれば済みますが、クロスエンコーダは候補の数だけ順伝播を回します。
def rerank_budget(n_candidates, ms_per_pair, retrieval_ms):
total = retrieval_ms + n_candidates * ms_per_pair
return total, total / retrieval_ms
for n in (20, 50, 100):
total, ratio = rerank_budget(n, ms_per_pair=1.2, retrieval_ms=15)
print(f"후보 {n:3}개 → {total:5.0f}ms (검색만 대비 {ratio:.1f}배)")
# 후보 20개 → 39ms (검색만 대비 2.6배)
# 후보 50개 → 75ms (검색만 대비 5.0배)
# 후보 100개 → 135ms (검색만 대비 9.0배)
ms_per_pairはモデルサイズ、チャンク長、ハードウェア、バッチ処理の有無によって大きく変わります。上の1.2ミリ秒は説明用に入れた値なので、必ずご自身の環境で測定して変えてください。
判断基準はこうです。リランキングは一次検索が正解を候補の中に入れてくれているのに順位が低い場合にだけ値打ちを出します。つまりrecall@50は高いのにrecall@5が低い状況です。逆にrecall@50そのものが低ければリランキングは何もできません。ないものを上に上げることはできないからです。ですからリランキングを検討する前にこの二つの数字を先に測る必要があり、この順序を守ればいらないレイテンシの追加を避けられます。
全文脈をLLMに渡す前の最後のフィルタとして使う場合も有効です。関連度の低いチャンクを捨てれば入力トークンが減り、モデルが無関係な文脈に引きずられる確率も減ります。
チャンクに文脈を付ける
埋め込まれるテキストがチャンク本文だけなら、そのチャンクは自分がどの文書のどの部分なのかを知らない状態で検索されます。「3項の例外は次のとおりである」という段落は、それ自体では検索されようがありません。
解法は単純です。埋め込み対象のテキストの前に位置情報を付けます。
def contextualize(chunk, doc):
header = " > ".join(filter(None, [doc.title, chunk.section, chunk.subsection]))
return f"[{header}]\n{chunk.text}"
# 埋め込みとBM25の索引の両方をこのテキストを対象に作る。
# ただし、LLMに渡す文脈にも同じヘッダーを維持しないと出典表記ができない。
効果が大きい理由は二つです。検索の面では、文書タイトルとセクション名がチャンクごとに繰り返されることで主題の信号が補強されます。生成の面では、モデルが複数の文書を区別できるようになり、「A製品のポリシー」と「B製品のポリシー」を混ぜる間違いが減ります。
もう一歩進めると、チャンクごとに文書全体の文脈を要約した一、二文をLLMであらかじめ生成して付ける方法もあります。索引作成時点で文書あたり一度コストを払う代わりに検索品質を上げる取引です。ただしこのコストは文書数に比例して実際に出ていくお金なので、先ほどの単純なヘッダー付与を先に試して、足りないときに検討するほうが順序としては妥当です。
メタデータは検索対象であると同時にフィルタです。日付、バージョン、部署、文書種別をフィールドとして保存しておけば、「昨年のポリシーが検索される」種類の失敗をフィルタで切り落とせます。これは順位の調整ではうまくいかず、フィルタでのみ確実になります。
ゴールデンセットとrecall@k — 測定なしに改善はありません
ここまでのすべての変更は元に戻せる必要があり、そのためには数字が要ります。必要なのは質問と正解根拠チャンクIDの対です。
作り方は実際のユーザー質問ログから取るのが最も良いです。ログがなければ文書からLLMで質問を生成しますが、必ず人が検収してください。生成された質問は原文の表現をそのまま写す傾向があるので、そういう質問だけで作った評価セットは語彙検索に有利な方向へ偏ります。表現を変えた質問、複数のチャンクを統合しなければならない質問、そして文書に答えがない質問を必ず混ぜてください。最後の種類が棄権能力を測る唯一の方法です。
def recall_at_k(dataset, retriever, k):
"""正解チャンクが上位k個に入った質問の比率"""
hit = 0
for q, gold_ids in dataset:
got = [c.id for c in retriever(q, k=k)]
if any(g in got for g in gold_ids):
hit += 1
return hit / len(dataset)
def mrr(dataset, retriever, k):
"""最初の正解の順位の逆数の平均。順位が重要なときにrecallと併せて見る。"""
total = 0.0
for q, gold_ids in dataset:
got = [c.id for c in retriever(q, k=k)]
for rank, cid in enumerate(got, start=1):
if cid in gold_ids:
total += 1.0 / rank
break
return total / len(dataset)
for k in (1, 5, 20, 50):
print(f"recall@{k:2} = {recall_at_k(golden, hybrid_retriever, k):.3f}")
この二つの数字をkごとに出しておくと診断が機械的になります。recall@50が低ければ索引とチャンキングの問題、recall@50は高いのにrecall@5が低ければ順位の問題、どちらも高いのに答えが間違っていれば生成の問題です。
| 症状 | 疑われる原因 | 確認方法 | 優先対応 |
|---|---|---|---|
| 答えに根拠がまったくない | 検索の失敗 | 正解チャンク注入実験 | チャンキング、ハイブリッド |
| 正解チャンクが上位にない | 順位の問題 | recall@50とrecall@5の比較 | リランキング |
| 一つの文書だけを繰り返し引用 | 重なりが過多 | 上位kチャンクの重複率 | 重なりの縮小、文書単位の多様化 |
| 古いバージョンの内容を引用 | フィルタの不在 | 引用チャンクの日付フィールド | メタデータフィルタ |
| 表の中の数字が違う | 表の分割 | チャンク原文の直接確認 | 表を丸ごと一つのチャンクに |
| 答えられる質問に棄権 | 棄権指示の過剰 | 回答率と精度の同時測定 | 指示の緩和、閾値の調整 |
注意点が一つ。この評価セットのサイズが小さければ、そこで出た差はほとんどが雑音です。30個の質問でrecallが0.70から0.73に上がったという結果でデプロイの判断を下してはいけません。標本サイズと有意性の扱い方は勘でやらないLLM評価の回にまとめました。
おわりに — まず検索結果を見てください
RAGデバッグの半分は「検索されたチャンクを実際に読んでみる」で終わります。ところがこの段階が最もよく省略されます。パイプラインが文脈をログに残さないように書かれているか、プロンプトを直すほうが速く見えるからです。
ですから今日やることは二つで足ります。リクエストごとに検索されたチャンクIDと原文をログに残すこと、そして質問50個のゴールデンセットを作ってrecall@5とrecall@50を測っておくことです。この二つがなければ以後のすべての改善は前が見えない状態での修理であり、あれば原因はたいてい一時間以内に絞り込めます。