Recall@3が1.50になった。今日、同じ文書のIDを3回並べた最小ケースをPythonで作っていて、正解文書を拾うたびに加算するコードが、150%を返した。
RAGの検索器を比べる前に、評価コードの重複カウントを潰したい。チャンクを細かくしただけで成績が上がる集計を使うと、改善の方向まで間違えるんだよね。
Recall@3の分子と分母を文書にそろえる
今回は、検索結果を元の文書IDへ変換して評価する。正解は manual と faq の2文書。検索結果は順位順に manual, manual, manual, faq, terms とする。先頭3件は、同じマニュアルから切り出した別チャンクという想定だ。
ここで測る文書単位のRecallは「正解文書のうち、上位kチャンクに何文書含まれるか」。上位k件に含まれる正解文書IDの種類数 ÷ 正解文書IDの種類数で計算する。
先頭3件に正解のIDが3回あるから、素朴な加算では3÷2で1.50。実際に見つかった正解文書は manual だけなので、文書単位なら1÷2で0.50になる。
manual の3チャンクがどれほど有用かは、まだ測っていない。
Pythonで再現する
以下を recall_check.py に保存し、python3 recall_check.py で実行する。外部ライブラリは不要。手元ではPython 3.14.8で、出力とassertを確認した。
hits = ["manual", "manual", "manual", "faq", "terms"]
relevant = {"manual", "faq"}
def doc_recall(hits, relevant, k):
if k < 1:
raise ValueError("k must be positive")
if not relevant:
return None
return len(set(hits[:k]) & relevant) / len(relevant)
unique_hits = list(dict.fromkeys(hits))
naive = sum(x in relevant for x in hits[:3]) / len(relevant)
print(f"naive: {naive:.2f}")
print(f"chunk_top3: {doc_recall(hits, relevant, 3):.2f}")
print(f"doc_top3: {doc_recall(unique_hits, relevant, 3):.2f}")
assert doc_recall(["manual"] * 3, {"manual"}, 3) == 1
assert doc_recall(["terms"], relevant, 3) == 0
assert doc_recall([], relevant, 3) == 0
assert doc_recall(hits, set(), 3) is None
出力はこうなった。
naive: 1.50
chunk_top3: 0.50
doc_top3: 1.00
正解IDはsetで渡す契約にした。検索結果側も、先頭k件を切ってからsetへ変換する。これで、同じ文書が何チャンクあっても分子への加算は1回になる。正解があるのに検索結果が空なら0、正解IDの集合が空なら None を返す。
正解が空の設問はRecallの分母が0になる。この実装では平均から除外し、除外件数を別に残す方針だ。正解未付与の設問と、本当に回答できない設問はデータ側で区別しておきたい。後者の「検索しない判断」は別途評価する。
重複除去を先にすると、別の上位3件になる
出力の doc_top3 は1.00。同じ重複除去でも、順番を変えると数字が違う。
dict.fromkeys で文書IDの初出順を残すと、候補は manual, faq, terms になる。その先頭3文書を評価するため、正解2文書が両方入る。元の検索順位では4件目だった faq まで拾っている。
| 計算順序 | 評価する対象 | Recall |
|---|---|---|
| 先頭3件を切ってから重複除去 | 上位3チャンク内の文書 | 0.50 |
| 候補全体を重複除去してから先頭3件 | 初出順の上位3文書 | 1.00 |
LLMへ3チャンクだけ渡す実装なら、前者がその入力に対応する。後者を使うなら、検索側も文書をまとめ、実際に渡すコンテキストを組み直す必要がある。評価時だけ4件目を繰り上げると、LLMが読めなかった文書を成績に含めてしまう。
実データでは、チャンクIDと元文書IDをログに残す。同名の別文書を混ぜないよう、文書IDは検索インデックス側のIDにそろえる。文書の版も評価データと合わせる。
検索の改善に持ち帰るもの
自分は、チャンク数を変える比較では、この重複ケースを評価コードの確認に残したい。まず、実際にLLMへ渡した上位k件を固定して文書単位のRecallを計算する。その数字だけでは、回答に必要な段落が入ったかまでは分からないので、正解チャンクへの到達も別に見る。チャンク分割を細かくするほど、文書の網羅率と必要な段落への到達率が違う動きをするケースは増えると見ている。今回の5件だけでも、同じ検索結果から0.50と1.00が出る理由は切り分けられた。