9.1 检索指标:MRR、nDCG 与 Recall


9.1 检索指标:MRR、nDCG 与 Recall

本节摘要:三把尺子,各盯一种使用场景。MRR(Mean Reciprocal Rank,平均倒数排名):每次查询取"第一个相关结果"的排名倒数再平均——排第 1 得 1.0、第 2 得 0.5、没出现得 0;它只关心第一枪打没打中,是 Agent 单步导航的主指标(下一步动作由第一个结果决定)。Recall@K:前 K 个结果覆盖了多少比例的相关文档——它只关心漏没漏,是"把上下文塞给 LLM"场景的主指标(漏了就答不出,排前后反而次要)。nDCG(normalized Discounted Cumulative Gain,折损累计增益归一化):把分级相关度(三级金标:精确命中 3、同文件 2、相关模块 1)按位置折损累加,再除以理想排序的上限——它让"排第几"与"有多相关"同时计价,适合人工浏览长列表的场景。本节给每个指标的手算示例与纯 Python 实现 metrics.py,末尾是场景—指标选择表。指标公式与通用 IR 评测的系统讲解详见 《利用 Zvec 实现完整的 RAG 技术》第 7 章,本节聚焦代码检索场景的用法。

学习目标

阅读完本节,你应当能够:

  1. 手算 MRR、Recall@K、nDCG(给定排序与金标);
  2. 运行并改造 metrics.py,说清 threshold 与分级金标的关系;
  3. 按"消费者怎么看结果"为系统选主指标;
  4. 解释三级相关度为什么比二值金标更贴近代码检索的真实。

一、先定义"相关":三级金标

指标算之前,先回答"什么算命中"。二值金标(相关/不相关)对代码检索太粗:目标是 kill_session 函数时,同文件的 revoke_refresh_token 也很有用,同模块的常量定义勉强算沾边。本书用三级(经验分级,可按团队改):

级别 含义
3 查询所找的实体本身 kill_session 函数定义
2 同文件强相关(被调用/配套) session.py 里的 revoke_refresh_token 调用处
1 同模块/同主题弱相关 auth 包里的 SESSION_TTL 常量
0 无关 上传重试逻辑

MRR/Recall 用阈值(默认 rel≥2 算"相关")把分级折成二值;nDCG 直接吃分级。金标指向 4.2 的稳定 chunk ID(不是行号——行号随编辑漂移,ID 至少随符号与区间移动)。

二、MRR:第一枪准不准

RR(q) = 1 / rank_第一个相关结果 (没有相关结果则为 0) MRR = mean(RR) (对全部查询平均)

手算:三次查询的排序相关度 [0, 3, 1]、[3, 0, 0]、[0, 0, 3](threshold=2)→ RR 分别 1/2、1/1、1/3 → MRR = (0.5 + 1.0 + 0.333) / 3 ≈ 0.611

适用:Agent 导航(第一个结果决定下一步 read 什么)、IDE 跳转。不适用:上下文组装——top5 全对但第一位略有争议时,MRR 区分不出"五个都对"和"只有第一个对"。

三、Recall@K:漏没漏

Recall@K = |前 K 结果中的相关文档| / |全部相关文档|

适用:RAG/问答——LLM 见到就能用,见不到就没有;K 取上下文预算能装下的条数(第 7 章的 top_k)。注意分母是金标里全部相关文档数,不是返回列表里的相关数——这就是 metrics.pyn_relevant 要显式传参的原因。

四、nDCG:位置与等级同时计价

DCG = Σ rel_i / log2(i + 1) i 从 1 起(线性增益版,另有 2^rel-1 指数版) nDCG = DCG / IDCG IDCG = 金标按等级降序排的 DCG(上限)

手算:排序 [3, 0, 1, 2] → DCG = 3/1 + 0 + 1/2 + 2/log2(5) ≈ 3 + 0.5 + 0.861 = 4.361;理想 [3, 2, 1, 0] → IDCG = 3 + 2/1.585 + 0.5 ≈ 4.762 → nDCG ≈ 0.916

读法:等级高的必须排前面,排后面会被 log 折损惩罚;nDCG=1 当且仅当排序就是理想排序。适用:人工浏览长结果列表(人会把前几条当摘要看)。

五、实现:metrics.py

# metrics.py — 三个检索指标的纯 Python 实现(第 9 章的仪表盘) import math def reciprocal_rank(ranked_rels: list[int], threshold: int = 2) -> float: """ranked_rels:按系统排序的每条结果相关度(金标三级 0~3)。""" for i, rel in enumerate(ranked_rels, start=1): if rel >= threshold: return 1.0 / i return 0.0 def mrr(per_query_rels: list[list[int]], threshold: int = 2) -> float: if not per_query_rels: return 0.0 return sum(reciprocal_rank(r, threshold) for r in per_query_rels) / len(per_query_rels) def recall_at_k(ranked_rels: list[int], k: int, n_relevant: int, threshold: int = 2) -> float: """n_relevant 由金标集合给出(分母),不从返回列表数(那是 Recall 的常见错)。""" if n_relevant == 0: return 1.0 # 无金标:不奖不罚 hits = sum(1 for r in ranked_rels[:k] if r >= threshold) return min(hits, n_relevant) / n_relevant def ndcg(ranked_rels: list[int]) -> float: """线性增益版 DCG,除以理想 DCG 归一。分级金标直接参与。""" dcg = sum(rel / math.log2(i + 1) for i, rel in enumerate(ranked_rels, start=1) if rel > 0) ideal = sorted(ranked_rels, reverse=True) idcg = sum(rel / math.log2(i + 1) for i, rel in enumerate(ideal, start=1) if rel > 0) return dcg / idcg if idcg > 0 else 1.0 if __name__ == "__main__": demo = [[0, 3, 1], [3, 0, 0], [0, 0, 3]] # 三次查询的排序相关度 print(f"MRR = {mrr(demo):.3f}") # 期望 0.611 for r in demo: n_rel = sum(1 for x in r if x >= 2) print(f"Recall@2 = {recall_at_k(r, 2, n_rel):.3f} nDCG = {ndcg(r):.3f}")

python metrics.py 核对手算:MRR 0.611;第三条查询 Recall@2 = 0(相关文档排第 3,前 2 没接住)而 MRR 里它贡献 1/3——同一个系统,两个指标讲两件事,这正是要三把尺子的原因。

六、场景—指标选择表

消费场景 结果怎么被消费 主指标 辅指标
Agent 单步导航(6.3 工具) 第一个结果决定下一步动作 MRR Recall@5
上下文组装(7.3 assemble) top-K 全部进提示词 Recall@K nDCG
人工浏览结果列表 人扫前几条找线索 nDCG MRR

⚠️ 指标 ≠ 目标。MRR 涨 2 个点但延迟翻倍,对交互式检索是净伤害——指标永远成组汇报(9.3 的 report 里 MRR/Recall/nDCG 与 latency 同列),单指标优化是把地图当成了地形。

本节要点回顾

  1. 三把尺子:MRR 盯第一枪(Agent 导航)、Recall@K 盯漏(上下文组装)、nDCG 同时计价位置与等级(人工浏览);
  2. 三级金标(3/2/1)比二值贴近真实;MRR/Recall 用 threshold 折二值,nDCG 吃分级;
  3. Recall 的分母来自金标全集(n_relevant 显式传参),从返回列表数分母是最常见的错;
  4. 指标成组汇报、与延迟并列——单指标优化是把地图当地形。

尺子有了,量什么?下一节造被测物:从真实查询日志、访谈与 git 历史里,攒出一份带金标的评测集。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U