RAG評価をCIに入れる — 決定的シンセティックコーパスとrecall@kゲート
LLMもDBも使わずに検索品質の回帰を検出する評価ハーネスの実装
Key Takeaways
- 検索品質の回帰は型エラーにならない。CIに決定的な評価ゲートを置くことがretrieval層の変更可能性を守る
- 評価対象を検索に絞ればLLM不要。recall@k / MRR / nDCG / MAP / precisionの並列出力で劣化の方向が見える
- シード固定のシンセティックコーパスは絶対品質でなく差分検出のための道具
- CIゲートはrecall@10 >= 0.6の1条件のみ。多条件ゲートは形骸化を招く
- retrieve関数を差し込む設計で、単段/2段階/RAPTORを同じ物差しで比較できる
課題: 検索の変更は「動くけど悪化した」が起きる
検索基盤の変更は型エラーにもユニットテストの失敗にもなりません。バイナリ量子化の符号規約を1つ間違えても、検索は「動き」ます — ただ静かに悪い結果を返すだけです。このサイレントな品質回帰を検出する仕組みがないと、retrieval層のリファクタリングは事実上凍結されます。
よくある答えはLLM-as-a-judge(LLMに回答品質を採点させる)ですが、CIゲートとしては3つの欠点があります。遅い(分単位)、高い(評価のたびに課金)、そして非決定的(同じ変更でも通ったり落ちたりする)。Marianの評価ハーネスは逆の性質から設計しました。速く、無料で、決定的。
設計: 評価対象を「検索」に絞る
RAGの品質は検索(retrieval)と生成(generation)に分解できます。生成の評価はLLMなしでは難しい一方、検索の評価は正解集合つきのクエリさえあれば古典的なIR指標で計測できます。そしてRAGの障害の大半は検索の劣化です。ハーネスのインターフェースは検索関数そのものを差し込む形になっています。
// lib/marian-quality/retrieval-eval.ts
export async function evaluateRetrieval<Q>(
cases: readonly EvalCase<Q>[], // { id, query, relevant: 正解id集合 }
retrieve: (query: Q) => string[] | Promise<string[]>,
options: EvaluateOptions = {}, // k: 既定10
): Promise<EvalReport>
interface EvalReport {
k: number
meanRecall: number // recall@k の平均
meanPrecision: number // precision@k
mrr: number // Mean Reciprocal Rank
map: number // Mean Average Precision
meanNdcg: number // nDCG@k
cases: CaseResult[] // ケース別の内訳(デバッグ用)
}retrieveが引数なので、同じケース集合に対して「単段IVFFlat」「2段階量子化」「RAPTOR込み」を同じ物差しで比較できます。新しい検索方式を足すたびに評価コードを書き直す必要はありません。
指標の実装: 5つで十分
各指標は教科書どおりの素直な実装です。例えばrecall@kとnDCG@k:
export function recallAtK(retrieved: readonly string[], relevant: Iterable<string>, k: number) {
const rel = relevantSet(relevant)
if (rel.size === 0) return 0
let hits = 0
for (const id of retrieved.slice(0, k)) if (rel.has(id)) hits++
return hits / rel.size
}
export function ndcgAtK(retrieved: readonly string[], relevant: Iterable<string>, k: number) {
const rel = relevantSet(relevant)
let dcg = 0
retrieved.slice(0, k).forEach((id, i) => {
if (rel.has(id)) dcg += 1 / Math.log2(i + 2) // 順位i(0-based)の減衰
})
let idcg = 0
for (let i = 0; i < Math.min(k, rel.size); i++) idcg += 1 / Math.log2(i + 2)
return idcg === 0 ? 0 : dcg / idcg
}使い分けの直観はこうです。recall@kは「正解を取りこぼしていないか」(RAGでは最重要 — コンテキストに入らなかった正解は存在しないのと同じ)、MRRは「最初の正解が何位に出るか」、nDCGは「正解が上位に固まっているか」、MAPはその平均的な総合点、precision@kは「ノイズ率」。単一指標に潰すと劣化の方向が見えなくなるため、5つを並列で出します。
シンセティックコーパス: 正解集合を作る問題を回避する
IR評価の本当のコストは指標ではなく正解ラベル付きデータの構築です。人手でクエリと正解文書のペアを作るのは高く、実データは私的なノートなのでCIに置けません。Marianはコーパスごと合成します。
// scripts/eval-rag.ts
const corpus = generateSyntheticCorpus({
topics: 24, // トピック=クラスタ中心
chunksPerTopic: 10, // 各トピック周りに240チャンク
queriesPerTopic: 5, // 計120クエリ
dims: 96, // 評価用の小さい埋め込み次元
noise: 0.2, // 成分ごとのジッタ
seed: 42, // mulberry32による決定的乱数
})トピック中心ベクトルを乱数で置き、チャンクとクエリはその中心+ノイズとして生成します。クエリの正解集合は「同じトピックのチャンク」と定義により自明です。ノイズ0.2はトピック間の分離を保ちつつ、量子化誤差程度の劣化がスコアに現れる感度を持たせる値です。
乱数はMath.random()ではなくmulberry32(シード42)。これで全実行が同一のコーパスを見るため、スコアの変動はコードの変更だけを反映します。「seedを変えたら通った」のようなフレーキーさは構造的に発生しません。
評価データの合成は「現実の難しさ」を犠牲にして「比較の正確さ」を買う取引。CIゲートに必要なのは絶対品質の測定ではなく、変更前後の差分検出である。
CIゲート: しきい値は1つだけ
CIで強制するのはrecall@10 >= 0.6の1条件だけです(--min-recallで調整可)。他の指標はレポートには出ますがゲートにはしません。
しきい値を複数にすると、無関係な指標の自然な揺らぎでCIが落ち始め、やがて誰もが赤いCIを無視するようになります。「コンテキストに正解が入るか」を直接表すrecallを単独ゲートにし、nDCGやMRRの劣化はレビューで人間が見る、という役割分担です。--jsonフラグで機械可読レポートも出るため、指標の推移を別途追跡できます。
このハーネスが測らないもの
正直な限界も書いておきます。シンセティックコーパスは語彙の曖昧性・多言語・長文書の構造といった実データの難しさを持ちません。embedding品質そのものも測れません(コーパスがembedding空間で直接生成されるため)。これは意図的な割り切りで、ハーネスの守備範囲は検索アルゴリズム層(量子化・融合・ツリー)の回帰検出です。実データでの品質はステージング環境の手動評価が補完します。
まとめ
RAG評価ハーネスの設計は、(1)評価対象を検索に絞りLLMを排除、(2)retrieve関数を差し込むインターフェースで方式間比較を可能に、(3)recall/MRR/nDCG/MAP/precisionを並列で出し劣化の方向を保存、(4)シード固定のシンセティックコーパスで決定性を確保、(5)CIゲートはrecall@10 >= 0.6の1条件のみ、です。数秒で終わる決定的な評価がCIにあることで、検索基盤は「怖くて触れないコード」になることを免れています。
FAQ
- なぜLLM-as-a-judgeをCIゲートに使わないのか?
- 遅い(分単位)・高い(毎実行課金)・非決定的(同じコードで通ったり落ちたり)という3点でCIゲートに不適だからです。RAGの障害の大半は検索層の劣化なので、検索だけを古典的IR指標(recall@k等)で決定的に評価すれば、数秒・無料・再現可能なゲートになります。生成品質の評価はステージングでの手動評価に分離しています。
- シンセティックコーパスで実際の検索品質が保証できるのか?
- 絶対品質は保証できません。保証するのは「変更前後の差分」です。シード固定(mulberry32, seed=42)の24トピック×240チャンク×120クエリに対するスコア変動は、コード変更だけを反映します。量子化の符号ミスやRRF融合のバグのような検索アルゴリズム層の回帰はこれで検出でき、語彙の曖昧性など実データ固有の難しさは守備範囲外と割り切っています。
- CIのしきい値がrecall@10だけなのはなぜか?
- RAGではコンテキストに入らなかった正解は存在しないのと同じであり、recallが最も直接的な品質指標だからです。複数指標をゲートにすると自然な揺らぎでCIが落ち、赤いCIが常態化して形骸化します。MRR・nDCG・MAP・precisionはレポートに出して人間のレビュー判断に使い、機械的な強制はrecall@10 >= 0.6の1条件に絞っています。