改了分块、换了模型、调了权重之后,判断"效果到底变好还是变差"的方式通常只有一种:抽几条 query 手动搜一下,觉得"好像准了"。这种判断的方差比改动本身带来的收益还大。评测集不是学术仪式,它的工程价值是把"好像准了"换成"能进入 CI 的通过/不通过"。
1. 评测要回答什么
一个字:变。评测集是测量工具,回答的是"这次改动相比基线是涨是跌",而不是"这个系统绝对有多好"。由此推出三条组织原则:
| 原则 | 含义 | 反例 |
|---|---|---|
| 同集对比 | 所有版本跑同一套标注 | 换版本时顺手换评测集 |
| 单变量 | 一次只改一个因素 | 同时换模型 + 改分块 + 调权重 |
| 看分布 | 报区间与失败样本,不报单点 | 只贴一个平均分 |
2. 四个指标的适用面
Recall@k = |相关 ∩ top-k| / |全部相关|
Precision@k = |相关 ∩ top-k| / k
MRR = mean( 1 / rank of first relevant )
NDCG@k = DCG@k / IDCG@k , DCG@k = Σ_i (2^rel_i - 1) / log2(i + 1)
| 指标 | 关心顺序 | 关心多级相关度 | 分块/召回场景 | 适用面 |
|---|---|---|---|---|
| Recall@k | 否 | 否 | 最适合 | 判断"该召回的是否进池" |
| Precision@k | 否 | 否 | 一般 | 判断前 k 条噪声比例 |
| MRR | 是(只看第一条相关) | 否 | 一般 | 只有一个正解(问答、单跳检索) |
| NDCG@k | 是 | 是 | 适合 | 有多级标注(完全相关/部分相关) |
选择规则:RAG 检索首先看 Recall@k——进不了上下文窗口的文档,重排再强也无意义;再看 NDCG 判断顺序;MRR 适合"唯一答案"型 query。同时报两个指标,避免单指标掩盖:Precision 高而 Recall 低,典型是只召回好召回的那部分。
3. 标注集怎么建
{"qid":"Q-0001","query":"怎么取消自动续费","mode":"semantic",
"candidates":[
{"doc_id":"D-1024","chunk":3,"rel":3,"note":"直接说明关闭扣款步骤"},
{"doc_id":"D-2077","chunk":1,"rel":1,"note":"提到订阅管理,未讲取消"},
{"doc_id":"D-3310","chunk":5,"rel":0,"note":"无关:账单发票"}]}
| query 来源 | 优点 | 偏差风险 |
|---|---|---|
| 检索日志真实 query | 分布真实,含长尾 | 冷启动无数据;日志里已有系统偏差 |
| 业务方整理的问题集 | 覆盖关键意图 | 偏向"能答上来的问题" |
| 失败样本回捞 | 直接针对痛点 | 过度拟合已知失败 |
| 人工构造变体 | 覆盖同义改写、跨语言 | 写法过于"像考试题" |
相关性分级(3 级最常用):0 无关、1 部分相关(同主题不解决问题)、2 完全相关(能直接回答问题)。分级标准必须先写成一页文档,否则不同标注人对"部分相关"的判定会漂移。
候选池怎么来:用当前多个版本(BM25、向量、混合各自的 top-N)并集去重,再人工判定。只用单版本 top-N 会漏掉"新版本能召回、旧版本不能"的文档,评测自然看不出改进。
4. 标注量:够用的判断方式
| 维度 | 典型量级 | 说明 |
|---|---|---|
| query 条数 | 每条覆盖 10–20 个候选,总量几十到几百条起步 | 按需要区分的改动幅度反推 |
| 每 query 相关文档数 | ≥1 正解,最好 2–5 条 | 只有 1 条正解时 Recall 与 MRR 几乎等价 |
| 分层比例 | 简单/困难各占一部分 | 全标简单 query 会让所有改动都"涨" |
| 标注人 | ≥2 人有重叠样本 | 算一致率,低于阈值就重议标准 |
够不够用看改动能否被区分:用基线版本自比(同版本跑两次,结果应完全一致),再看目标改动带来的差异是否大于标注波动。如果差异落在波动区间内,加标注量比换模型更划算。
5. A/B 纪律
| 变量 | 必须固定 | 常见违规 |
|---|---|---|
| 评测集 | 同一份 qid→doc_id→rel | 顺手把新发现的失败样本加进"新集"再对比 |
| 分块 | 同一份 chunk | 评测 A 用旧分块、评测 B 用新分块 |
| 模型与维度 | 同一次 embedding 产物 | 换模型后复用旧向量索引 |
| 参数 | 一次只改一个 | 同时调权重与 num_candidates |
| 指标口径 | 同一 k、同一实现 | 一个版本报 NDCG@10、另一个报 @100 |
"看起来涨了"最常见的来源不是模型,而是评测实现不同:排除了空结果 query 的版本、去重逻辑不同、把 rel>=1 与 rel>=2 当成正解混用。评测脚本要放进版本控制,与检索代码同仓库。
6. 离线涨了、线上没涨:四种原因
| 原因 | 表现 | 排查手段 |
|---|---|---|
| 评测集与线上分布不同 | 离线标注 query 偏短、偏干净 | 抽线上真实 query 做影子评测 |
| 离线只评了检索,线上还看生成 | 检索 NDCG 涨但答案差 | 分层看检索指标与答案指标 |
| 候选池偏差 | 新版本召回了评测集里没有的文档,被记为"错" | 重新做候选池并集 |
| 线上受过滤/权限/截断影响 | 离线无 filter,线上带租户过滤 | 影子流量按生产 filter 重放 |
判断顺序:先确认评测集与线上同分布,再确认指标口径一致,最后才怀疑"模型在真实数据上没那么强"。跳过前两步直接换模型,是评测里最贵的浪费。
7. RAG 特有的评测分层
| 失败形态 | 现象 | 该测哪一层 |
|---|---|---|
| 检索命中但答案不对 | 上下文里有答案,生成没用上 | 生成忠实度(faithfulness) |
| 答案对但引用错 | 结论正确,引用了无关片段 | 引用准确率(citation precision) |
| 上下文过长忽略中段 | 长拼接后中段文档被忽略 | 上下文利用率、位置敏感性 |
| 该弃答却硬答 | 无相关文档仍生成答案 | 弃答率 / 无答案 query 集 |
| 分块切断答案 | 关键句子被切成两块,都命中但都不完整 | 分块级 Recall + 父块回表命中 |
RAG 的检索评测要额外记录父块命中率:子块检索命中、父块回表可取到完整答案,才算真正可用。只报子块 Recall 会高估效果。
8. 把评测做成 CI 的一步
# eval/run_eval.py(示意结构)
import json, sys
def recall_at_k(rels, k): # rels: [{"doc_id":..., "rel":...}] 已按预测顺序排列
hit = sum(1 for d in rels[:k] if d["rel"] >= 2)
total = sum(1 for d in rels if d["rel"] >= 2)
return hit / total if total else 0.0
def mrr(rels):
for i, d in enumerate(rels, 1):
if d["rel"] >= 2:
return 1.0 / i
return 0.0
cur = {q["qid"]: {"recall@10": recall_at_k(q["rels"], 10), "mrr": mrr(q["rels"])}
for q in predict_all(load_qids("eval/queries.jsonl"))}
base = json.load(open("eval/baseline.json"))
base = {k: v for k, v in base.items() if not k.startswith("_")}
delta = sum(cur[q]["recall@10"] - base["per_query"][q]["recall@10"] for q in cur) / len(cur)
print(f"recall@10 delta = {delta:+.4f}")
sys.exit(0 if delta >= -json.load(open("eval/thresholds.json"))["recall@10_tolerance"] else 1)
# CI 里怎么跑、怎么看退化样本
python eval/run_eval.py --queries eval/queries.jsonl --topk 10 --out eval/run-$(git rev-parse --short HEAD).json
python - <<'PY'
import json
cur, base = json.load(open("eval/run-latest.json")), json.load(open("eval/baseline.json"))
rows = [(q, base["per_query"][q]["recall@10"] - cur[q]["recall@10"]) for q in cur]
for q, d in sorted(rows, key=lambda r: -r[1])[:10]: # 退化最严重的 10 条
print(f"{q} delta={d:+.3f}")
PY
{
"_comment": "阈值按当前评测集规模核对后填写,不要照抄",
"recall@10_tolerance": 0.0,
"mrr_min": 0.0,
"note": "数字按本集基线分布填写,先跑一次基线再定",
"fail_on_regression": true
}
CI 集成的三条纪律:基线文件随代码提交(否则无从比较)、允许的回归容差写进阈值文件(有意的取舍要留痕)、失败时打印具体退化 query(否则无法定位)。每次合并前跑一次小集(快),每晚跑一次全集(慢)。
9. 人工评估的抽样
| 抽样方式 | 适用 | 注意 |
|---|---|---|
| 随机抽样 | 估计整体水平 | 样本量不足时看不出差异 |
| 分层抽样(按意图/难度) | 比较特定能力 | 各层比例要固定,便于跨版本对比 |
| 失败样本全量 | 定位问题 | 会严重低估整体水平,不能当线上指标 |
| 固定集合(回归集) | 跨版本对比 | 长期勿改,改了需重新建基线 |
人工评估的评分表要与自动指标分开维护:人评看"能不能解决问题",自动指标看"排在第几位",两者结论冲突时,以人评为准并回头改自动指标的定义。
10. 排错
| 现象 | 原因 | 处理 |
|---|---|---|
| 指标高得反常 | 用了训练集/被索引过的语料当评测集 | 换独立语料,确认 query 未出现在训练数据里 |
| 两个标注人分数差很大 | 分级标准未书面化 | 先对齐"部分相关"的定义,再重标重叠样本 |
| 版本升级指标全涨 | k 值或去重逻辑变了 | 锁定口径,把评测脚本纳入 code review |
| 指标涨但用户仍抱怨 | 只看了平均分 | 看失败样本清单与分意图指标 |
| 某 query 一直是 0 | 该 query 无相关文档(标注缺失) | 补标或从分母中排除并记录 |
| 改动后个别 query 暴跌被平均掉 | 只看均值 | 增加"最差 N 条 query 的退化"作为独立门禁 |
11. 边界
| 场景 | 更适合的做法 | 理由 |
|---|---|---|
| 冷启动无日志 | 业务方问题集 + 人工构造变体 | 标注快,先建立基线 |
| 亿级语料、标注不可行 | 抽样池 + 弱监督 + 人工抽检 | 全量标注不现实 |
| 强时效内容 | 滚动更新评测集,标注带时间戳 | 旧标注会过期 |
| 只有唯一正解 | MRR + 命中率 | Recall 退化无意义 |
| 需要线上真实反馈 | 影子流量 + 点击/留存指标 | 离线只是代理指标 |
一句话:评测集是资产,不是一次性任务。它要跟着语料、模型、分块一起演进,版本化保存;否则半年后没人说得清"当时那个 0.8 是怎么来的"。
12. 自测
- 为什么 RAG 检索场景的第一指标通常是 Recall@k 而不是 Precision@k?
- MRR 与 NDCG 的区别体现在哪两个维度上?
- 只用单版本 top-N 构造候选池,会漏掉什么样的改进?
- "离线涨、线上不涨"的四种原因里,先排查哪一个?
- RAG 场景下"检索命中但答案不对"应该测哪一层?
- 评测脚本纳入 CI 时,基线文件与容差阈值为什么都必须随代码提交?