RAG 上級講座 #6 RAG 評価パイプラインを作る
第1回でゴールデンセットとベースラインの二つの数字から始め、毎回その数字で改善を判断してきました。今回はその間に合わせの測定を、きちんとした評価パイプラインに育てます。検索と生成をそれぞれの指標で測り、キーワードマッチングの限界を LLM 判定者で乗り越え、ハルシネーション率まで一度に測定する体系です。一度作っておけば、チャンキングでもモデルでもプロンプトでも、何を変えても同じ試験用紙で検証できます。
検索評価 — recall@k と MRR #
第1回の検索ヒット率は「上位 k 件の中に正解の文書があるか」でした。これがまさに標準指標の recall@k(リコール・アット・ケー、上位 k 件の結果に正解が含まれる割合)です。ここにもう一つ加えるとよいです。正解が何位で検索されたかを反映する MRR(Mean Reciprocal Rank、正解順位の逆数の平均)です。
def eval_retrieval(golden: list, top_k: int = 5) -> dict:
recall_hits, rr_sum = 0, 0.0
for case in golden:
chunks = search(case["question"], top_k=top_k)
rank = next(
(i for i, c in enumerate(chunks, 1) if c["metadata"]["source"] == case["source_doc"]),
None,
)
if rank is not None:
recall_hits += 1
rr_sum += 1 / rank
n = len(golden)
return {"recall@k": recall_hits / n, "mrr": rr_sum / n}二つの指標は役割が違います。recall@k が同じでも MRR が上がれば、正解がより上に来たという意味です。これは第4回のリランキングのように、順序を磨く改善の効果を捉えます。検索だけを別に評価できる点も重要です。生成まで回さないので速くて安く、検索側の実験はこの指標だけで何十回でも繰り返せます。
生成評価 — キーワードマッチングの限界と LLM 判定者 #
第1回では、答えにキーワードが入っているかで採点しました。速いものの、限界がはっきりしています。「手数料は10%です」と「手数料は10%ではありません」を区別できません。そこで生成評価には LLM 判定者(LLM-as-a-judge、モデルで答えの品質を採点する方法)を使います。LLM アプリ開発 第12回で紹介した方法を RAG の採点に合わせて組むと、こうなります。
import json
JUDGE_SYSTEM = """あなたは Q&A システムの採点者です。質問、模範解答、システムの答えを見て JSON で採点してください。
- correct: システムの答えが模範解答と事実関係で一致していれば true
- grounded: 答えが提供された文書チャンクの内容の範囲内にあれば true(チャンクにない内容を断定していれば false)
- reason: 判定理由を一文で
"""
def judge(case: dict, answer: str, chunks: list) -> dict:
context = "\n---\n".join(c["text"] for c in chunks)
response = client.messages.create(
model="claude-opus-4-8",
max_tokens=300,
system=JUDGE_SYSTEM,
messages=[{
"role": "user",
"content": (
f"質問: {case['question']}\n"
f"模範解答: {case['reference_answer']}\n"
f"文書チャンク:\n{context}\n"
f"システムの答え: {answer}\n"
'JSON のみ出力: {"correct": bool, "grounded": bool, "reason": str}'
),
}],
)
text = next(b.text for b in response.content if b.type == "text")
return json.loads(text)ゴールデンセットには、キーワードの代わりに模範解答(reference_answer)を書いておきます。判定項目を二つに分けた点に注目してください。correct は正確さを、grounded は根拠への忠実さを測ります。grounded が false の割合がそのままハルシネーション率です。第5回の引用ゲートがリアルタイムの防衛線だとすれば、この指標は変更のたびにハルシネーションの傾向を測る定期健診です。
LLM 判定者自体もモデルなので、検証が必要です。導入初期に判定結果を20〜30件自分で読んでみて、人の判断とずれるパターンがあれば判定プロンプトを直します。判定者を一度信頼できるものにしておけば、それ以降のすべての評価が自動化されます。
一度に回す評価スクリプト #
検索指標と生成指標を束ねれば、評価パイプラインの完成です。
def evaluate(golden: list, top_k: int = 5) -> dict:
retrieval = eval_retrieval(golden, top_k=top_k)
correct, grounded = 0, 0
for case in golden:
chunks = search(case["question"], top_k=top_k)
answer = rag_answer(case["question"], chunks)
verdict = judge(case, answer, chunks)
correct += verdict["correct"]
grounded += verdict["grounded"]
n = len(golden)
return {
**retrieval,
"accuracy": correct / n,
"hallucination_rate": 1 - grounded / n,
}
print(evaluate(GOLDEN))
# {'recall@k': 0.87, 'mrr': 0.71, 'accuracy': 0.80, 'hallucination_rate': 0.03}この四つの数字が RAG の健康診断表です。recall@k が低ければ第2〜4回(チャンキング・検索)へ、recall は高いのに accuracy が低ければ第5回(生成)へ、hallucination_rate が上がればプロンプトと引用ゲートに戻ればよいわけです。どこを直すかが数字からそのまま読み取れます。
回帰テストとして運用する #
評価パイプラインの本当の価値は反復にあります。運用のコツを三つ挙げます。
- 変更のたびに回します。 チャンキング、モデルのバージョン、プロンプト、top_k など、何を変えても前後を比較します。コードにおける回帰テストと同じ位置づけです。
- 結果を記録します。 日付、変更内容、四つの指標を一行ずつためておくと、どの変更が効いたかの歴史になります。
- ゴールデンセットを育てます。 運用中に見つけた失敗事例をゴールデンセットに追加します。一度間違えた質問が再び間違えないかを、永久に監視できるようになります。
費用の心配は、規模を見れば解けます。ゴールデンセット30件の評価にかかる呼び出しは判定込みで60回ほどなので、キャッシュと小さな判定モデルを使えば、一回あたりの費用はわずかなものです。間違った変更をデプロイして払う費用と比べれば、なおさらです。
よくつまずく場所 #
- 判定者を検証なしに信じる — LLM 判定者も間違えます。初期に人の判定と突き合わせて補正し、判定理由(
reason)も一緒に受け取って、ずれた判定を追跡します。 - 評価の直前にゴールデンセットを直す — 測定対象に合わせて試験用紙を変えると、比較が無意味になります。ゴールデンセットの変更とパイプラインの変更は別々に行います。
- 平均だけを見る — 全体の平均が同じでも、質問グループごとには上下があります。第3回でやったように質問タイプ別に分けて見れば、隠れた回帰を捕まえられます。
まとめ #
今回は RAG の評価を体系化しました。
- 検索は recall@k と MRR で別に、速く評価します。順序の改善は MRR が捉えます。
- 生成は LLM 判定者で正確さと根拠への忠実さを採点し、根拠への忠実さの裏返しがハルシネーション率です。
- 四つの指標を一つのスクリプトに束ねてすべての変更の回帰テストとして回し、失敗事例をゴールデンセットに追加し続けます。
道具と測定がすべてそろいました。最終回の「RAG 上級講座 #7 実践プロジェクト:ドキュメント Q&A ボットのアップグレード」では、LLM アプリ開発 第13回のボットをこのシリーズの技法で段階的に改善しながら、指標が実際にどう動くかを確認してシリーズを締めくくります。