检索评测指标与离线评测#
一句话答案#
检索评测用标注好的「query → 相关文档」集合(qrels),对系统返回的 top-k 算 Recall@k / Precision@k / Hit Rate(找没找到)、MRR(第一个对的排多前)、NDCG(分级相关性加位置折扣);RAG 里要分层评测:召回层、精排层、生成层和端到端分开看,才能定位是哪一层的问题。
核心要点
ES写入链路与搜索质量 列过这几个指标的一句话定义,Agent评测平台设计 讲了数据集版本化和发布门禁,本篇讲指标的计算细节、各自的盲区、标注集怎么建,以及离线指标和线上效果为什么对不上。
1. 指标定义与各自的盲区#
设某个 query 的相关文档集合为 R,系统返回的前 k 条为 S_k:
| 指标 | 公式 | 回答什么问题 | 盲区 |
|---|---|---|---|
| Hit Rate@k | 1 如果 ` | R ∩ S_k | ≥ 1`,否则 0,对 query 取平均 |
| Recall@k | ` | R ∩ S_k | / |
| Precision@k | ` | R ∩ S_k | / k` |
| MRR | mean(1 / 第一个相关文档的名次),k 内没有则记 0 | 第一条对的排得多靠前 | 只看第一条,后面的全不管 |
| MAP | 每个相关文档命中位置的 Precision 求和再除以 | R | (没召回的相关文档记 0),再对 query 平均 |
| NDCG@k | DCG@k / IDCG@k | 分级相关性 + 位置折扣的综合排序质量 | 依赖增益映射,档位定义不同结果不可比 |
RAG 场景里,Hit Rate@k 和 Recall@k 通常最重要:LLM 读的是整个 top-k,答案所在的 chunk 在第 1 位还是第 3 位,对生成的影响往往没有「在不在 top-k 里」大;但 k 较大时位置仍有影响,长上下文中段的信息容易被忽略(见 长上下文处理)。排序指标(MRR、NDCG)更适合评 reranker,或者 top-k 很小的场景。
2. NDCG:分级相关性怎么算#
DCG@k = Σ_{i=1}^{k} (2^{rel_i} − 1) / log2(i + 1) (指数增益,也有直接用 rel_i 的线性增益版本)
IDCG@k = 把所有已标注文档按 rel 从高到低理想排序后的 DCG@k
NDCG@k = DCG@k / IDCG@k ∈ [0, 1]plaintext- 位置折扣
1/log2(i+1):第 1 位折扣为 1,第 2 位约 0.63,第 3 位 0.5,越往后越不值钱。 - 分级相关性:以电商的 ESCI 标注为例,E(Exact 完全匹配)、S(Substitute 替代品)、C(Complement 配件)、I(Irrelevant 无关)四档,可以映射成 3/2/1/0。指数增益会拉大高档位的权重。
- IDCG 要用全部已标注的相关文档算,不能只用系统召回到的那些,否则系统漏召回反而不扣分,这是常见的实现 bug。
- 档位定义本身会影响结论:比如每个 query 只有一个 E、指数增益又很陡时,NDCG 主要由这个 E 的位置决定,接近 MRR,S 和 C 的相对顺序对分数的影响被明显压低。
import math
def recall_at_k(ranked: list[str], relevant: set[str], k: int) -> float:
return len(set(ranked[:k]) & relevant) / len(relevant) if relevant else 0.0
def hit_at_k(ranked: list[str], relevant: set[str], k: int) -> float:
return float(any(d in relevant for d in ranked[:k]))
def reciprocal_rank(ranked: list[str], relevant: set[str], k: int) -> float:
for i, d in enumerate(ranked[:k], start=1):
if d in relevant:
return 1.0 / i
return 0.0
def ndcg_at_k(ranked: list[str], grades: dict[str, int], k: int) -> float:
"""grades: 该 query 全部已标注文档的档位,如 {"d1": 3, "d7": 1};未标注视为 0"""
dcg = sum((2 ** grades.get(d, 0) - 1) / math.log2(i + 1) for i, d in enumerate(ranked[:k], start=1))
ideal = sorted(grades.values(), reverse=True)[:k] # 用全部标注算 IDCG
idcg = sum((2 ** g - 1) / math.log2(i + 1) for i, g in enumerate(ideal, start=1))
return dcg / idcg if idcg > 0 else 0.0python计算好的逐 query 分数要保留,不要只存平均值:做版本对比、找 bad case、算置信区间都要用到。
3. 分层评测:定位问题出在哪一层#
| 层 | 评什么 | 输入 | 指标 |
|---|---|---|---|
| 召回层 | 各路召回和融合后的候选池 | query → 候选池 top-N | Recall@N(N 取进 rerank 的条数),分路单独算 |
| 精排层 | reranker 在固定候选池上的排序 | 同一批候选 | NDCG@k、MRR;同时报告候选池的 Recall@N,作为精排的上限 |
| 生成层 | 给定正确上下文时 LLM 答得对不对 | query + 标准上下文 | 答案正确率、Faithfulness |
| 端到端 | 用户实际拿到的答案 | query | 答案正确率、Context Recall/Precision、任务完成率 |
- 归因:召回到了但答错 → 生成层问题;没召回到 → 检索层问题;召回池里有但被 rerank 挤出 top-k → 精排层问题。逐 query 打标签后按类别统计,就知道下一步该改哪一层。
- 端到端和生成层的评测方法(LLM-as-Judge、RAGAS 的 Faithfulness / Context Precision 等)见 LLM评测方法。
- 离线评测和线上检索的执行方式要一致:离线常用暴力精确检索,线上是 ANN 近似检索;编码时的池化方式、归一化、截断长度只要有一处不一致,query 和文档就不在同一个向量空间里,不会报错,只是效果变差。
4. 标注集怎么建#
| 来源 | 做法 | 风险 |
|---|---|---|
| 人工标注 | 写标注规范(每一档的判定标准和例子),多人标注算一致性(如 Cohen’s Kappa) | 贵、慢;标注员对领域理解不一致 |
| 合成 query | 让 LLM 读一个 chunk 生成它能回答的问题,这个 chunk 就是正例 | 生成的问题和原文措辞高度重合,偏向 BM25,比真实 query 简单;一个问题可能多个 chunk 都能回答,但只标了一个 |
| 线上日志 | 用点击、采纳、复制作为弱标签 | 位置偏差(排在前面的更容易被点);只能看到系统展示过的文档 |
| 公开分级数据集 | 如 ESCI(电商搜索 E/S/C/I 四档)、MS MARCO、BEIR | 领域和自有语料不同,需要和自有文档对齐(如按商品 ID join) |
合成 query 的质量控制:
- 要求 LLM 改写措辞、生成不同难度(直接问、换说法、多跳),不要照抄原文。
- 往返过滤:用生成的 query 检索,原 chunk 不在 top-N 里的,要么丢弃,要么人工复核,因为可能是 query 本身有问题。
- 分层抽样人工复核,按 query 类型(事实型、比较型、无答案型)保持比例。
- 一定要放一部分无答案 query,否则测不出系统在应该拒答时会不会乱答。
标注不全(incomplete judgments):大库里绝大多数文档没人标过,未标注的文档一律按不相关计算。后果是:
- 新系统找到了没标过的相关文档,却被算作错误,所以离线指标对「和标注时的系统不同的新系统」不公平。TREC 的 pooling 做法是把多个系统的 top-k 合并起来送标,降低这种偏差。
- 标注覆盖率很低时,小 k 的 Recall 基本量不出差异。可以加大 k,或者只在有标注的子集上评测,先确认这把尺子能区分出好坏。
5. 离线指标涨了,线上为什么没涨#
| 原因 | 机制 |
|---|---|
| 指标和实际用途不一致 | 离线量排序(NDCG),线上却用分数做阈值判别,或者 LLM 对 top-k 内部顺序不敏感 |
| query 分布不同 | 评测集是合成的或旧的,线上是口语化、多轮上下文、带错别字的 query |
| 标注不全或过时 | 新系统找到的新相关文档被当成错误;文档更新后标注没更新 |
| 执行方式不同 | 离线用暴力检索,线上用 ANN;离线不带 filter,线上带 filter;编码配置不一致 |
| 下游还有其他环节 | 召回提升被后面的 rerank、去重、配额、截断吃掉 |
| 样本量太小 | 几十条 query 的端到端 A/B 噪声很大,涨跌落在置信区间内就只能说「没测出差异」 |
| 延迟和超时 | 新方案更慢,线上超时降级,实际跑的是降级路径 |
对比两个版本时,用成对的统计方法:同一批 query 上逐条算差值,用 bootstrap 或配对 t 检验给置信区间,而不是只比较两个平均数。
面试回答(2分钟版)
检索评测先要有一份标注集,也就是每个 query 对应哪些文档相关,最好是分级的。指标上,Hit Rate@k 看 top-k 里有没有至少一条对的,Recall@k 看相关文档找回了多少,Precision@k 看 top-k 里有多少是对的,MRR 看第一条对的排第几,NDCG 同时考虑分级相关性和位置折扣:DCG 是每个位置的增益
2^rel − 1除以log2(i+1)再求和,除以理想排序的 IDCG 得到 0 到 1 的分数。注意 IDCG 要用全部已标注文档算,否则漏召回不扣分。RAG 里我最看重召回层的 Recall 和 Hit Rate,因为 LLM 读的是整个 top-k;排序指标主要用来评 reranker。评测要分层:召回层看候选池的 Recall@N,精排层在固定候选池上看 NDCG,生成层给正确上下文看 LLM 答得对不对,最后看端到端,这样一条 bad case 能归因到具体哪一层。标注集可以人工标、用 LLM 从 chunk 合成 query、或者用 ESCI 这类公开分级数据。合成 query 容易和原文措辞重合、偏简单,要做往返过滤和人工抽检。离线涨了线上没涨,常见原因是 query 分布不同、标注不全、离线暴力检索和线上 ANN 不一致,或者指标和线上用途根本不是一回事。结合项目时可以讲:评测集怎么来、先怎么确认这把尺子能区分好坏(比如标注覆盖率低时加大 k),以及离线涨了为什么没上线或上线后怎么验证。
追问与易错
追问方向:
- “Recall@k 和 Hit Rate@k 有什么区别,RAG 里看哪个?” → Hit Rate 只看 top-k 里是否至少有一条相关,Recall 看相关文档找回的比例。答案集中在一个 chunk 时两者差不多;需要多个 chunk 拼出答案时要看 Recall。
- “MRR 和 NDCG 各适合什么场景?” → MRR 只关心第一条相关文档的位置,适合「只要一个答案」的场景(如 FAQ 匹配);NDCG 支持分级相关性并考虑所有位置,适合评整个排序列表、评 reranker。
- “NDCG 的 IDCG 怎么算?常见 bug 是什么?” → 用该 query 全部已标注文档按档位从高到低排序后算 DCG@k。常见 bug 是只用系统召回到的文档算 IDCG,这样漏召回的相关文档不扣分,分数虚高。
- “指数增益
2^rel − 1和线性增益有什么区别?” → 指数增益拉大高档位的权重,一条高档位文档的位置变化对分数影响更大;线性增益各档之间差距均匀。两种算出的数不可比,报告时要写清楚用的是哪种。 - “合成评测集有什么问题,怎么控制质量?” → LLM 容易照抄原文措辞,偏向 BM25、比真实 query 简单,而且一个问题可能有多个 chunk 能答但只标了一个。做法是要求改写和分难度生成、做往返检索过滤、按类型分层抽样人工复核,并加入无答案 query。
- “标注不全对评测有什么影响?” → 未标注文档按不相关计算,新系统找到的未标注相关文档会被当成错误;覆盖率很低时小 k 的指标区分不出好坏。可以用 pooling 把多个系统的 top-k 合并送标、加大 k,或者只在有标注的子集上评。
- “怎么判断两个版本的差异是不是真的?” → 在同一批 query 上逐条算差值,用 paired bootstrap 或配对 t 检验给置信区间;区间包含 0 就只能说没测出差异,这时要扩大评测集,而不是直接下结论。
- “离线指标涨了线上没涨,你会怎么排查?” → 先确认线上怎么用这一层的输出(排序还是阈值判别);再对比离线和线上的执行方式(ANN、filter、编码配置);然后看线上 query 分布和评测集差多少,把线上 bad case 回灌到评测集。
- “ESCI 的四档怎么用在训练和评测里?” → E 是完全匹配、S 是替代品、C 是配件、I 是无关。评测时映射成分级增益算 NDCG;训练时 E 当正例,S、C 可以当难负例或中间档。具体怎么映射取决于业务把「替代品」和「配件」看成相关还是不相关。
易错点:
- ❌ “指标越多越好” → 先定线上实际怎么用这一层的输出,再选对应的指标;和用途不一致的指标涨了也没用。
- ❌ “只看平均值” → 要保留逐 query 分数,按 query 类型拆分,做配对统计。
- ❌ “离线评测用暴力检索,结论可以直接搬到线上 ANN” → 两者结果不同,编码配置不一致时不会报错,只会让效果变差。