RAGで検索件数を5件から20件に増やしても、回答精度が上がるとは限りません。
正解の文書を取得できても、重複した文書に埋もれ、回答生成時に参照されない場合があるためです。
問題は検索件数そのものではなく、必要な根拠が何位にあり、どの文書と一緒にモデルへ渡されるかです。
Pythonで検索結果の重複率と根拠順位を測り、件数を増やす前に改善できる部分を切り分けます。
top_kを増やしても回答が改善しない理由
RAGでは、質問に関連する文書を検索し、その結果をLLMのコンテキストに追加します。
検索件数を指定するtop_kは、取得候補の数を制御するパラメータです。
たとえば、社内規程から経費精算の期限を回答するRAGを考えます。
質問は「交通費の申請期限はいつですか?」です。
検索対象には次のような文書があります。
- 交通費申請の手順を説明する文書
- 交通費申請の画面操作を説明する文書
- 古い申請手順のコピー
- 現行の申請期限を定めた規程
- 出張精算全般を説明する文書
検索結果の上位に画面操作の説明が集中すると、申請期限を定めた規程が下位に回ることがあります。
top_k=5では正解根拠を取得できず、top_k=20では取得できるかもしれません。
ただし、ここには2つの異なる問題があります。
1つ目は、検索によって正解根拠を取得できるかという問題です。
2つ目は、取得した正解根拠をLLMが回答生成時に利用できるかという問題です。
前者は検索評価で測れますが、後者は検索結果だけでは判断できません。
特に、検索結果に同じ内容の文書が繰り返し含まれると、入力トークン数が増える一方で、モデルに渡す情報の種類はほとんど増えません。
また、長いコンテキストの途中にある情報を利用しにくくなる現象は、論文「Lost in the Middle」で報告されています。
ただし、すべてのモデルやタスクで同じ現象が発生するわけではありません。
検索で見つかったことと、回答で使われたことは別の評価対象です。
この2つを分けないと、検索件数を増やした結果を正しく評価できません。
検索件数を変えて根拠の順位を測る
まず、LLMを呼び出さずに検索結果の構成だけを測ります。
ここではPython標準ライブラリのdifflib.SequenceMatcherを使い、文字列の類似度で文書を並べます。
実際のRAGで使う埋め込み検索とは異なりますが、検索件数と重複率、正解根拠の順位の関係を確認するには十分です。
再現コード
実行環境はPython 3.14です。外部ライブラリは使用しません。
from difflib import SequenceMatcher
question = "交通費の申請期限"
docs = [
("guide1", "交通費の申請画面の操作方法"),
("guide2", "交通費の申請画面の操作方法"),
("guide3", "交通費の申請画面の操作方法"),
("guide4", "交通費の申請画面の操作方法"),
("guide5", "交通費の申請画面の操作方法"),
("rule", "交通費の申請期限は翌月5日"),
("travel", "出張費用の精算手順"),
("receipt", "領収書の添付方法"),
("approval", "申請内容の承認方法"),
("account", "経費の勘定科目一覧"),
("faq", "交通費の申請に関する質問"),
("manual", "交通費の申請マニュアル"),
("history", "過去の交通費申請履歴"),
("policy", "経費精算の社内規程"),
("form", "交通費の入力フォーム"),
("status", "交通費の申請状況"),
("cancel", "交通費の申請取消方法"),
("update", "交通費の申請内容変更"),
("export", "交通費の申請データ出力"),
("archive", "交通費の申請データ保存"),
]
def score(text):
return SequenceMatcher(None, question, text).ratio()
ranked = sorted(docs, key=lambda d: score(d[1]), reverse=True)
def evaluate(items):
texts = [text for _, text in items]
duplicate_rate = 1 - len(set(texts)) / len(texts)
ids = [doc_id for doc_id, _ in items]
rank = ids.index("rule") + 1 if "rule" in ids else None
return duplicate_rate, rank
for k in (3, 5, 10, 20):
selected = ranked[:k]
duplicate_rate, rank = evaluate(selected)
print(
f"k={k:2} "
f"recall={int(rank is not None)} "
f"duplicate={duplicate_rate:.2f} "
f"rank={rank}"
)
このコードでは、文書IDがruleの文書を正解根拠としています。
評価指標は次の3つです。
-
recall:正解根拠が取得結果に含まれるか -
duplicate:取得文書のうち完全重複が占める割合 -
rank:正解根拠が取得結果の何位にあるか
正解根拠が1件なので、ここでのrecallはRecall@kを0または1で表したものです。
一般的なRecall@kでは、複数の正解根拠がある場合、取得できた正解根拠の数を全正解根拠数で割ります。
重複率は次の式で求めています。
重複率 = 1 - ユニークな本文数 / 取得文書数
今回は完全一致だけを重複としています。
実際の業務文書には、改訂日だけが異なる文書や、一部の文章を共有する文書もあります。そのため、完全一致だけでは重複を過小評価する可能性があります。
結果の読み方
この実験では、top_kを増やすほど取得対象の文書が増えます。
固定された検索順位に対して、上位k件を切り出しているため、Recall@kは減少しません。
しかし、正解根拠を取得できたとしても、その順位が自動的に改善するわけではありません。
また、同じ文書が複数含まれる場合、検索件数の増加に比例して新しい情報が増えるわけでもありません。
ここで注意したいのは、Recall@kが上がったことだけでは、回答品質の改善を証明できないという点です。
検索結果をそのままLLMへ渡す構成では、重複した文章も入力トークンを消費します。
実際の入力トークン数は、使用するモデルのトークナイザーで測る必要があります。Pythonのlen()や文字数だけでは正確なトークン数になりません。
重複除去とrerankingを比較する
検索件数を増やす前に試したいのが、重複除去とrerankingです。
この2つは似ていますが、役割が異なります。
重複除去は、同じ情報を繰り返し渡さないための処理です。
rerankingは、取得済みの候補を質問との関連性に基づいて並べ直す処理です。
Sentence TransformersのRetrieve & Re-Rankでは、最初に検索で候補を取得し、その候補をCross-Encoderで再評価する構成が示されています。
最初の検索で広めに候補を取得し、回答生成に渡す前に候補を絞る設計です。
重複除去を追加する
先ほどのコードの末尾に、次の処理を追加します。
def deduplicate(items):
seen = set()
result = []
for doc_id, text in items:
if text in seen:
continue
seen.add(text)
result.append((doc_id, text))
return result
candidates = ranked[:20]
unique = deduplicate(candidates)
print("before:", len(candidates), evaluate(candidates))
print("after: ", len(unique), evaluate(unique))
これで完全重複の文書が取り除かれます。
ただし、重複除去によって必ず回答品質が改善するわけではありません。
複数の文書に同じ規則が書かれていること自体が、情報の信頼性を判断する材料になる場合もあります。
また、本文が同じでも、出典や適用期間が異なる文書を単純に統合してはいけません。
業務システムでは、本文だけでなく文書ID、版、適用開始日などを保持したうえで、重複判定の単位を決める必要があります。
rerankingを追加する
続いて、正解根拠の順位を改善できるか確認します。
ここでは仕組みを分離して確認するため、質問に含まれる「期限」という語を優先する簡単な再順位付けを使います。
def rerank(items):
return sorted(
items,
key=lambda d: (
"期限" in d[1],
score(d[1]),
),
reverse=True,
)
before = unique[:5]
after = rerank(unique)[:5]
print("before:", evaluate(before))
print("after: ", evaluate(after))
print("before IDs:", [doc_id for doc_id, _ in before])
print("after IDs: ", [doc_id for doc_id, _ in after])
これは学習済みrerankerではなく、特定の質問に合わせたルールベースの実験です。
目的は、検索結果の候補集合を変えずに順位だけを変更したとき、評価指標がどう変化するかを確認することです。
このコードでは、ranked[:20]に正解根拠が含まれている場合に限り、rerankingによって上位に移動させられます。
検索段階で取得できなかった正解根拠を、rerankingだけで復活させることはできません。
ここが重要な境界です。
候補集合のRecallが低ければ、まず検索方式や検索対象の分割方法を見直す必要があります。
候補集合には正解根拠が含まれているのに、最終的な上位文書に残らない場合は、rerankingを改善する余地があります。
また、今回のように質問に合わせて作ったルールは、別の質問に対して有効とは限りません。実用上の評価では、正解根拠を直接知るルールではなく、質問と候補文書から関連性を推定するrerankerを使います。
検索件数と回答品質を混同しない
実際のRAGでは、検索結果の評価と回答生成の評価を別々に記録するのがよいと考えています。
比較対象は、たとえば次の3構成です。
-
top_k=5で検索し、そのままLLMへ渡す -
top_k=20で検索し、そのままLLMへ渡す -
top_k=20で検索し、重複除去とrerankingを行って上位5件を渡す
この3構成を、同じ質問と正解根拠のセットで比較します。
検索側ではRecall@k、重複率、正解根拠の順位を記録します。
生成側では入力トークン数と、回答が正解根拠に一致しているかを記録します。
ここでいう根拠一致率は、回答の各主張が提示された文書によって裏付けられている割合として定義できます。
ただし、これはRecall@kのように検索結果だけから機械的に計算できる指標ではありません。
回答の主張を分割し、各主張がどの文書に支持されているかを判定する処理が必要です。
LLMを評価器として利用する場合も、判定結果を無条件に正解として扱うべきではありません。人手で確認した評価データとの一致を検証する必要があります。
実運用で注意する点
今回のコードは、検索結果の構成を調べるための最小実験です。
SequenceMatcherは文字列の類似度を計算しますが、意味的な関連性を理解する埋め込みモデルではありません。
そのため、実際のベクトル検索と同じ順位になるとは限りません。
また、重複除去とrerankingにはそれぞれ制約があります。
重複除去では、完全一致だけでなく類似文書の扱いを決める必要があります。
rerankingでは、候補文書を再評価する計算コストが発生します。Cross-Encoderを使う場合は、候補件数が増えるほど評価する質問と文書の組も増えます。
さらに、LLMに渡す文書を5件に固定しても、各文書の長さが異なれば入力トークン数は一定になりません。
比較実験では、文書件数だけでなく、実際に渡したトークン数も記録したいところです。
検索候補数、最終採用件数、入力トークン数は、それぞれ独立したパラメータとして扱う方が原因を特定しやすくなります。
今回の検証で分かるのは、検索結果に含まれる根拠と重複の状態までです。回答精度が実際に改善するかは、同じ質問セット、同じモデル、同じ生成条件で比較する必要があります。特に、検索対象が頻繁に更新される場合や、複数文書を組み合わせないと回答できない場合は、単一の正解根拠を前提とした評価では不十分です。次に確かめるべきなのは、候補集合のRecallを維持したまま、最終的な入力トークン数と根拠一致率をどこまで改善できるかです。

