2922 字
约 9 分钟
13
S13-X12 · 检索评测:召回率与相关性标注

改了分块、换了模型、调了权重之后,判断"效果到底变好还是变差"的方式通常只有一种:抽几条 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. 自测

  1. 为什么 RAG 检索场景的第一指标通常是 Recall@k 而不是 Precision@k?
  2. MRR 与 NDCG 的区别体现在哪两个维度上?
  3. 只用单版本 top-N 构造候选池,会漏掉什么样的改进?
  4. "离线涨、线上不涨"的四种原因里,先排查哪一个?
  5. RAG 场景下"检索命中但答案不对"应该测哪一层?
  6. 评测脚本纳入 CI 时,基线文件与容差阈值为什么都必须随代码提交?
S13-X12 · 检索评测:召回率与相关性标注
https://www.clxhxhhr.top/posts/5851/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。