Appearance
课 1 · 评测集与检索指标
本课目标
建立标注问答数据集,用 Hit Rate、MRR、Recall@K 量化检索质量。课后你会有一个可重复运行的检索评测脚本。
关键理解:评测集是"已知答案应该在哪里",检索评测是看系统能不能找到那里。
为什么需要评测集
没有评测集的 RAG 优化是在黑箱里猜拳:
- 改了切块策略,感觉好多了?怎么知道没把其他问题弄差?
- 加了 Rerank,效果提升了?提升多少?
- 模型换了,召回质量是变好还是变差?
评测集把这些问题变成可量化的对比。
评测集结构
每条数据包含:问题 + 标准答案应该来自哪些文档片段(ground truth)。
typescript
// datasets/eval/questions.jsonl
// 每行一条 JSON
{"id":"q001","question":"什么是 RAG?","groundTruth":["chunk-001","chunk-002"]}
{"id":"q002","question":"向量检索的余弦相似度如何计算?","groundTruth":["chunk-045"]}
{"id":"q003","question":"BM25 算法的核心参数是什么?","groundTruth":["chunk-023","chunk-024"]}groundTruth 里存的是应该被召回的 chunk ID,需要人工标注(或者借助 LLM 辅助生成后人工验证)。
怎么标注(LLM 辅助 + 人工审核)
手工逐条标注太慢,用 LLM 辅助生成初版,人工审核即可:
typescript
// scripts/generate-eval-dataset.ts
import { generateObject } from 'ai'
import { z } from 'zod'
const qaSchema = z.object({
questions: z.array(
z.object({
question: z.string(),
groundTruth: z.array(z.string()), // chunk IDs
})
),
})
async function generateQAFromChunk(chunk: { id: string; content: string }) {
const { object } = await generateObject({
model: getModel(),
schema: qaSchema,
prompt: `
根据以下文档片段(ID: ${chunk.id}),生成 2-3 个典型问题,这些问题的答案应该主要来自这个片段。
文档片段:
${chunk.content}
要求:问题要自然,像真实用户会问的。
`.trim(),
})
return object.questions.map((q) => ({
...q,
groundTruth: [chunk.id], // 这个问题的标准答案来自这个 chunk
}))
}检索指标
Hit Rate(命中率)
只要 Top-K 里有一个 ground truth chunk,就算命中。是最宽松的指标。
MRR(Mean Reciprocal Rank)
Recall@K
在 Top-K 里找到了多少比例的 ground truth。
评测脚本
typescript
// packages/eval-core/src/retrieval-eval.ts
import type { IngestJob } from '@shared/types/job'
interface EvalQuestion {
id: string
question: string
groundTruth: string[] // chunk IDs
}
interface RetrievalMetrics {
hitRate: number
mrr: number
recallAtK: Record<number, number>
}
export async function evaluateRetrieval(
questions: EvalQuestion[],
retrieveFn: (q: string, topK: number) => Promise<Array<{ id: string }>>,
ks = [1, 3, 5],
): Promise<RetrievalMetrics> {
const maxK = Math.max(...ks)
let hits = 0
let reciprocalRankSum = 0
const recallSums: Record<number, number> = Object.fromEntries(ks.map((k) => [k, 0]))
for (const q of questions) {
const results = await retrieveFn(q.question, maxK)
const resultIds = results.map((r) => r.id)
const groundTruthSet = new Set(q.groundTruth)
// Hit Rate
const hasHit = resultIds.some((id) => groundTruthSet.has(id))
if (hasHit) hits++
// MRR
const firstHitIndex = resultIds.findIndex((id) => groundTruthSet.has(id))
if (firstHitIndex >= 0) reciprocalRankSum += 1 / (firstHitIndex + 1)
// Recall@K
for (const k of ks) {
const topK = resultIds.slice(0, k)
const recalled = topK.filter((id) => groundTruthSet.has(id)).length
recallSums[k] += recalled / q.groundTruth.length
}
}
const n = questions.length
return {
hitRate: hits / n,
mrr: reciprocalRankSum / n,
recallAtK: Object.fromEntries(ks.map((k) => [k, recallSums[k] / n])),
}
}运行评测
typescript
// scripts/run-eval.ts
import { readFileSync } from 'node:fs'
import { evaluate } from '../packages/eval-core/src/retrieval-eval'
import { retrieve } from '../packages/rag-core/src/retrieval'
const questions = readFileSync('datasets/eval/questions.jsonl', 'utf-8')
.trim()
.split('\n')
.map(JSON.parse)
const metrics = await evaluate(questions, (q, k) => retrieve(q, k))
console.log('=== 检索评测结果 ===')
console.log(`Hit Rate: ${(metrics.hitRate * 100).toFixed(1)}%`)
console.log(`MRR: ${metrics.mrr.toFixed(3)}`)
console.log(`Recall@1: ${(metrics.recallAtK[1] * 100).toFixed(1)}%`)
console.log(`Recall@3: ${(metrics.recallAtK[3] * 100).toFixed(1)}%`)
console.log(`Recall@5: ${(metrics.recallAtK[5] * 100).toFixed(1)}%`)本节产物
datasets/eval/
questions.jsonl # 标注问答集
scripts/
generate-eval-dataset.ts
run-eval.ts
packages/eval-core/src/
retrieval-eval.ts # 检索指标计算面试追问
MRR 和 Recall@K 分别衡量什么?
MRR 关注"第一个正确答案出现在第几位",衡量排序质量,适合用户只看第一个结果的场景(搜索引擎)。Recall@K 关注"Top-K 里覆盖了多少正确答案",衡量覆盖率,适合 RAG 这种需要多个相关片段的场景。进阶项目更关注 Recall@5,因为 LLM 会综合多个 chunk 生成答案。
评测集多大才够用?
100-200 条对进阶课足够,关键是要覆盖典型场景(精确词匹配、语义相关、多跳推理、无答案问题)。太少的评测集会让指标有较大方差,很难看出改动的真实效果。