本节摘要:三把尺子,各盯一种使用场景。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 章,本节聚焦代码检索场景的用法。
阅读完本节,你应当能够:
metrics.py,说清 threshold 与分级金标的关系;指标算之前,先回答"什么算命中"。二值金标(相关/不相关)对代码检索太粗:目标是 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 至少随符号与区间移动)。
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 = |前 K 结果中的相关文档| / |全部相关文档|
适用:RAG/问答——LLM 见到就能用,见不到就没有;K 取上下文预算能装下的条数(第 7 章的 top_k)。注意分母是金标里全部相关文档数,不是返回列表里的相关数——这就是 metrics.py 里 n_relevant 要显式传参的原因。
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 同列),单指标优化是把地图当成了地形。
n_relevant 显式传参),从返回列表数分母是最常见的错;尺子有了,量什么?下一节造被测物:从真实查询日志、访谈与 git 历史里,攒出一份带金标的评测集。