Appearance
评测体系与回归:没有评测集的 RAG 等于闭眼开车
1. 本节产出
一个可跑的评测框架:50~100 条评测集(含无答案查询)、四个指标自动计算、纳入 CI 做回归。改任何参数后能在 5 分钟内知道「变好还是变差」。
2. 前置依赖
3. 为什么评测集是 RAG 项目的分水岭
没有评测集时,改动的决策过程是这样的:
改了切分策略 → 试了几个问题 → 「感觉还行」 → 上线
↑
这个「感觉」不可靠三个致命问题:
| 问题 | 后果 |
|---|---|
| 样本偏差 | 你试的那几个问题恰好是简单的 |
| 无法归因 | 不知道是切分变好还是运气好 |
| 无法回滚判断 | 上线后效果变差,不知道是哪次改动引入的 |
有评测集后:改一次跑一次,5 分钟得到数字,涨跌一目了然,还能进 CI 自动拦截退化。
一个真实经验:评测集建起来之后,团队第一次跑基线发现召回率只有 61%——而大家之前一直觉得「效果挺好的」。主观感受和客观数字之间常常有很大的差距。
4. 核心原理
4.1 四个指标
| 指标 | 计算 | 说明 |
|---|---|---|
| 召回率@k | 检索到的 k 个片段中有正确答案的比例 | 最核心,衡量「搜没搜到」 |
| MRR | 正确答案排名的倒数均值 | 衡量「排得够不够前」 |
| 答案正确率 | 最终答案是否正确(人工或 LLM 判分) | 端到端指标 |
| 拒答准确率 | 无答案查询被正确拒答的比例 | 常被忽略但极重要 |
拒答准确率必须单独测。很多团队只测「能答对的」,导致拒答机制从未被验证——上线后遇到无答案查询就开始编造。
4.2 评测集怎么建
字段:
question 用户真实问题
expected_doc_ids 包含答案的片段 sourceId(可多个)
expected_answer 参考答案(用于判分)
answerable true/false(资料里到底有没有答案)
category 查询类别(语义/精确/指代/无答案)规模:50 条能看出趋势,100 条能做可靠决策。
来源优先级:
| 来源 | 价值 |
|---|---|
| 真实用户查询日志 | 最高,代表真实分布 |
| 业务方提供的 FAQ | 高 |
| 自己构造 | 低(容易构造得过于简单) |
关键:必须包含 20%~30% 的 answerable=false 样本,否则拒答能力永远测不到。
4.3 答案判分:三种方式
| 方式 | 做法 | 成本 | 可靠性 |
|---|---|---|---|
| 人工判分 | 人看答案打分 | 高 | 最高 |
| 规则匹配 | 关键字符串是否出现 | 低 | 低(表述不同就误判) |
| LLM 判分 | 让模型比对参考答案打分 | 中 | 中高,推荐 |
LLM 判分的 prompt 要点:给出参考答案,要求判断「候选答案是否与参考答案语义一致,是否遗漏关键点」,输出 0-5 分和理由。要求输出理由能显著提高判分质量,也便于抽查。
4.4 纳入 CI 做回归
每次 PR → 跑评测集 → 对比基线
召回率下降 > 3pt → 阻断合并
拒答准确率下降 → 阻断合并
token 成本上升 > 20% → 告警阈值要设得宽松一点(3pt 而不是 1pt),因为 LLM 判分本身有波动。设太严会导致 CI 频繁误报,最后被人关掉。
5. 代码走查
5.1 评测集结构
csv
# eval/dataset.csv
question,expected_doc_ids,expected_answer,answerable,category
"年假怎么算","handbook#3.2","按司龄计算,满1年不满5年5天",true,SEMANTIC
"SO20260915001 的状态","orders#SO20260915001","已发货",true,EXACT
"它支持动态调整吗","gateway#limit","支持,通过配置中心热更新",true,COREF
"远程办公有补贴吗","","资料中无此规定",false,NONE5.2 评测执行器
java
// src/test/java/com/example/rag/eval/RetrievalEvaluationTest.java
@Testcontainers
@SpringBootTest
class RetrievalEvaluationTest {
@Autowired RagChain chain;
@Autowired VectorStore store;
@Test
void 评测集回归() throws IOException {
List<EvalCase> cases = Csv.load(Path.of("eval/dataset.csv"));
int recallHit = 0, rejectedOk = 0, answerableCount = 0, noneCount = 0;
double mrrSum = 0;
for (EvalCase c : cases) {
List<RetrievalResult> hits = pipeline.retrieve(c.question(), 5);
Set<String> hitIds = hits.stream().map(RetrievalResult::sourceId())
.collect(Collectors.toSet());
if (c.answerable()) {
answerableCount++;
// 召回:命中任一期望文档
if (c.expectedDocIds().stream().anyMatch(hitIds::contains)) {
recallHit++;
mrrSum += 1.0 / (rankOf(hits, c.expectedDocIds()) + 1);
}
} else {
noneCount++;
Answer a = chain.answer(c.question(), tenantId, acl);
if (a.isRejected()) rejectedOk++; // 正确拒答
}
}
double recall = recallHit / (double) answerableCount;
double mrr = mrrSum / answerableCount;
double rejectAcc = rejectedOk / (double) noneCount;
// 输出报告
System.out.printf("召回率@5=%.3f MRR=%.3f 拒答准确率=%.3f (n=%d)%n",
recall, mrr, rejectAcc, cases.size());
// CI 断言:低于基线阈值则失败
assertThat(recall).isGreaterThan(0.75);
assertThat(rejectAcc).isGreaterThan(0.85);
}
}5.3 LLM 判分
java
// src/test/java/com/example/rag/eval/LlmJudge.java
public final class LlmJudge {
public static Score judge(ChatClient judge, String question,
String expected, String actual) {
String raw = judge.prompt()
.system("""
你是评分员。判断【候选答案】是否回答了【问题】,
且与【参考答案】语义一致、未遗漏关键点。
输出 JSON:{"score":0-5,"reason":"..."}
4 分以上视为正确。不要因为表述不同就扣分。
""")
.user(u -> u.text("问题:{q}\n参考答案:{e}\n候选答案:{a}")
.param("q", question).param("e", expected).param("a", actual))
.options(OpenAiChatOptions.builder().temperature(0.0).build())
.call().content();
return Json.decode(clean(raw), Score.class);
}
public record Score(int score, String reason) {}
}5.4 保存基线
bash
# 每次评测结果落盘,形成趋势
mvn test -Dtest=RetrievalEvaluationTest | tee eval/history/2026-10-03.txteval/history/
2026-09-20.txt 召回率@5=0.71 MRR=0.62 拒答准确率=0.80
2026-09-28.txt 召回率@5=0.79 MRR=0.70 拒答准确率=0.88 ← 优化切分
2026-10-03.txt 召回率@5=0.85 MRR=0.76 拒答准确率=0.92 ← 加混合检索+Rerank这份历史记录是给客户的交付物,它证明了每一次优化的真实收益。
6. 跑起来
bash
git checkout ch02-17-evaluation
mvn -q test -Dtest=RetrievalEvaluationTest期望输出:
=== 评测集 92 条(可答 68 / 无答案 24)===
召回率@5 0.853
MRR 0.761
拒答准确率 0.917
平均延迟 412ms
单次成本 0.0051 元
分类明细:
SEMANTIC 召回 0.89
EXACT 召回 0.81 ← 混合检索的贡献
COREF 召回 0.83 ← 查询改写的贡献
NONE 拒答 0.92| 检查项 | 通过标准 |
|---|---|
| 评测集规模 | ≥ 50 条 |
| 含无答案样本 | 占比 20%~30% |
| CI 断言 | 召回率与拒答率有下限 |
| 分类可见 | 能按 category 看明细,便于归因 |
| 趋势可查 | 结果落盘,能对比历史 |
7. 生产避坑
- 评测集里没有「无答案」样本,是最常见也最危险的疏漏。后果是拒答机制从未被验证,上线后遇到无答案查询就编造——而这恰恰是 RAG 最容易出事的地方。建评测集的第一天就要把无答案样本写进去。
- LLM 判分要要求输出理由,并定期人工抽查。判分模型本身会犯错,尤其是参考答案写得不完整时。做法:随机抽 10 条人工复核,如果判分一致率低于 85%,说明参考答案质量有问题,要修评测集而不是修系统。
- CI 阈值别设太严。LLM 判分和检索本身都有波动,阈值设 1pt 会导致 CI 频繁误报,最后被人关掉(这就回到没有回归保护的状态)。经验值:3~5 个百分点。
8. 延伸与锚点
- 思考题:召回率和成本常常冲突(召回更多 = 上下文更长 = 更贵)。怎么量化这个权衡?(答案在下一课时)
- 代码锚点:
git checkout ch02-17-evaluation - 下一课时:02-18 成本治理与降级
- 对应课件:L02-17 评测体系