Skip to content

评测体系与回归:没有评测集的 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,NONE

5.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.txt
eval/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. 生产避坑 ​

  1. 评测集里没有「无答案」样本,是最常见也最危险的疏漏。后果是拒答机制从未被验证,上线后遇到无答案查询就编造——而这恰恰是 RAG 最容易出事的地方。建评测集的第一天就要把无答案样本写进去。
  2. LLM 判分要要求输出理由,并定期人工抽查。判分模型本身会犯错,尤其是参考答案写得不完整时。做法:随机抽 10 条人工复核,如果判分一致率低于 85%,说明参考答案质量有问题,要修评测集而不是修系统。
  3. CI 阈值别设太严。LLM 判分和检索本身都有波动,阈值设 1pt 会导致 CI 频繁误报,最后被人关掉(这就回到没有回归保护的状态)。经验值:3~5 个百分点。

8. 延伸与锚点 ​

  • 思考题:召回率和成本常常冲突(召回更多 = 上下文更长 = 更贵)。怎么量化这个权衡?(答案在下一课时)
  • 代码锚点:git checkout ch02-17-evaluation
  • 下一课时:02-18 成本治理与降级
  • 对应课件:L02-17 评测体系