检索质量评估指标详解:从概念到代码实战
搞懂 Precision@K、MRR、NDCG,看这一篇就够了
写在前面
如果你在做 RAG、搜索引擎或推荐系统,一定被问过这些问题:
- "你的检索效果怎么样?"
- "怎么证明你的系统比别人的好?"
- "优化之后效果提升了多少?"
这时候,检索质量评估指标就派上用场了。
本文带你彻底搞懂 5 个核心指标:Precision@K、Recall@K、AP、MRR、NDCG,每个指标都包含:
- 📖 概念解释(说人话)
- 📐 理论公式
- 📝 计算实例
- 💻 代码实现
一、核心概念总览
1.1 什么是检索质量评估?
简单说,就是评判你的检索系统返回的结果好不好。
用户查询:"Python 怎么读取 JSON 文件?"
↓
检索系统返回 10 篇文档
↓
怎么评价这 10 篇文档的质量?← 评估指标登场
1.2 五个核心指标
| 指标 | 全称 | 关注点 | 难度 |
|---|---|---|---|
| Precision@K | 查准率 | 前 K 个有多准 | ⭐ |
| Recall@K | 召回率 | 覆盖了多少 | ⭐ |
| AP | 平均精度 | 综合精度 | ⭐⭐ |
| MRR | 平均倒数排名 | 第一个正确答案排第几 | ⭐⭐ |
| NDCG | 归一化折损累计增益 | 整体排序质量 | ⭐⭐⭐ |
1.3 基础概念:相关性与相关性标注
在计算任何指标之前,我们需要知道哪些文档是相关的。
检索结果:[文档 A, 文档 B, 文档 C, 文档 D, 文档 E]
相关性标注:[✅, ❌, ✅, ✅, ❌ ]
相关性标注通常由人工完成,或者基于用户行为(点击、停留时间等)推断。
二、Precision@K(查准率)
2.1 概念解释
Precision@K 就是:前 K 个结果里,有多少是相关的?
生活例子:
你去相亲,媒人给你介绍了 5 个对象(K=5)
其中 3 个你觉得合适,2 个不合适
Precision@5 = 3/5 = 0.6
2.2 理论公式
Precision@K = 前 K 个结果中相关的文档数 / K
2.3 计算实例
场景:用户搜索 "Python JSON 教程"
| 排名 | 文档 | 是否相关 |
|---|---|---|
| 1 | "Python JSON 完全指南" | ✅ |
| 2 | "Java 入门教程" | ❌ |
| 3 | "Python 解析 JSON 实战" | ✅ |
| 4 | "Python 网络爬虫" | ❌ |
| 5 | "JSON 数据格式详解" | ✅ |
Precision@5 = 3 / 5 = 0.6
2.4 代码实现
python
def precision_at_k(results: list, k: int) -> float:
"""
计算 Precision@K
Args:
results: 检索结果相关性列表,[True, False, True, ...]
True 表示相关,False 表示不相关
k: 前 K 个结果
Returns:
Precision@K 分数
"""
if len(results) < k:
# 如果结果不足 K 个,补齐 False
results = results + [False] * (k - len(results))
return sum(results[:k]) / k
# 使用示例
results = [True, False, True, False, True, False]
print(f"Precision@5 = {precision_at_k(results, 5):.3f}") # 0.600
print(f"Precision@3 = {precision_at_k(results, 3):.3f}") # 0.667
2.5 特点总结
| 优点 | 缺点 |
|---|---|
| ✅ 简单直观,容易解释 | ❌ 不考虑排序质量 |
| ✅ 计算快速 | ❌ 不考虑 K 之后的结果 |
| ✅ 不需要知道所有相关文档 | ❌ K 的选择有任意性 |
三、Recall@K(召回率)
2.1 概念解释
Recall@K 就是:所有相关文档中,前 K 个结果里召回了多少?
生活例子:
仓库里有 100 个合格零件(所有相关文档)
你随机抓了 20 个,里面有 15 个合格
Recall@20 = 15/100 = 0.15
2.2 理论公式
Recall@K = 前 K 个结果中相关的文档数 / 所有相关文档总数
2.3 计算实例
场景 :知识库中关于 "Python JSON" 共有 20 篇 相关文档
检索结果前 10 个中有 6 篇 相关:
Recall@10 = 6 / 20 = 0.3
这意味着:虽然你检出了 6 篇相关文档,但还有 14 篇相关的没找出来。
2.4 代码实现
python
def recall_at_k(results: list, total_relevant: int) -> float:
"""
计算 Recall@K
Args:
results: 检索结果相关性列表,[True, False, True, ...]
total_relevant: 所有相关文档的总数
Returns:
Recall@K 分数
"""
relevant_in_top_k = sum(results)
if total_relevant == 0:
return 0
return relevant_in_top_k / total_relevant
# 使用示例
results = [True, False, True, True, False, True] # 前 6 个中有 4 个相关
total_relevant = 20 # 总共有 20 篇相关文档
print(f"Recall@6 = {recall_at_k(results, total_relevant):.3f}") # 0.200
2.5 特点总结
| 优点 | 缺点 |
|---|---|
| ✅ 衡量检索覆盖度 | ❌ 需要知道所有相关文档(难获取) |
| ✅ 适合评估检索广度 | ❌ 不考虑排序质量 |
| ✅ 与 Precision 互补 | ❌ 可能鼓励"宁可错杀一千" |
四、AP(Average Precision,平均精度)
4.1 概念解释
AP 就是:在每个相关文档的位置,计算 Precision,然后求平均。
生活例子:
钓鱼比赛,你钓上了 5 条鱼(相关文档)
第 1 条鱼:前 1 竿钓上来的,Precision@1 = 1/1 = 1.0
第 2 条鱼:前 3 竿钓上来的,Precision@3 = 2/3 = 0.67
第 3 条鱼:前 5 竿钓上来的,Precision@5 = 3/5 = 0.6
...
AP = 这些 Precision 的平均值
4.2 理论公式
AP = (Σ Precision@kᵢ) / 相关文档总数
其中 kᵢ 是第 i 个相关文档的位置
4.3 计算实例
检索结果(10 篇) :[✅, ❌, ✅, ❌, ❌, ✅, ❌, ❌, ✅, ❌]
相关文档位置:1, 3, 6, 9(共 4 篇相关)
| 相关文档 | 位置 | 累计相关数 | Precision@位置 |
|---|---|---|---|
| 第 1 篇 | 1 | 1 | 1/1 = 1.0 |
| 第 2 篇 | 3 | 2 | 2/3 ≈ 0.67 |
| 第 3 篇 | 6 | 3 | 3/6 = 0.5 |
| 第 4 篇 | 9 | 4 | 4/9 ≈ 0.44 |
AP = (1.0 + 0.67 + 0.5 + 0.44) / 4 = 0.65
4.4 代码实现
python
def average_precision(results: list) -> float:
"""
计算 AP(Average Precision)
Args:
results: 检索结果相关性列表,[True, False, True, ...]
Returns:
AP 分数
"""
precision_sum = 0
relevant_count = 0
for i, is_relevant in enumerate(results):
if is_relevant:
relevant_count += 1
# 计算当前位置的 Precision
precision_at_i = relevant_count / (i + 1)
precision_sum += precision_at_i
if relevant_count == 0:
return 0
return precision_sum / relevant_count
# 使用示例
results = [True, False, True, False, False, True, False, False, True, False]
ap = average_precision(results)
print(f"AP = {ap:.3f}") # 0.652
4.5 特点总结
| 优点 | 缺点 |
|---|---|
| ✅ 综合考虑精度和召回 | ❌ 需要知道所有相关文档 |
| ✅ 考虑排序质量(靠前的相关文档贡献更大) | ❌ 计算相对复杂 |
| ✅ 学术界常用指标 | ❌ 对单个查询的评估可能不稳定 |
五、MAP(Mean Average Precision)
5.1 概念解释
MAP 就是:多个查询的 AP 的平均值。
AP 是单个查询的指标,MAP 是多个查询的综合指标。
5.2 理论公式
MAP = (AP₁ + AP₂ + ... + APₙ) / n
5.3 计算实例
| 查询 | AP |
|---|---|
| 查询 1 | 0.65 |
| 查询 2 | 0.80 |
| 查询 3 | 0.45 |
| 查询 4 | 0.70 |
MAP = (0.65 + 0.80 + 0.45 + 0.70) / 4 = 0.65
5.4 代码实现
python
def mean_average_precision(all_results: list) -> float:
"""
计算 MAP(Mean Average Precision)
Args:
all_results: 多个查询的结果列表
[[True, False, True...], [False, True...], ...]
Returns:
MAP 分数
"""
ap_scores = [average_precision(results) for results in all_results]
if not ap_scores:
return 0
return sum(ap_scores) / len(ap_scores)
# 使用示例
all_results = [
[True, False, True, False, False, True], # 查询 1
[True, True, False, True, False], # 查询 2
[False, True, True, False], # 查询 3
]
map_score = mean_average_precision(all_results)
print(f"MAP = {map_score:.3f}")
六、MRR(Mean Reciprocal Rank,平均倒数排名)
6.1 概念解释
MRR 就是:第一个正确答案排第几名?
生活例子:
考试单选题,选项有 A、B、C、D
正确答案是 B,排在第 2 个
RR = 1/2 = 0.5
如果是多选题,MRR 只看第一个正确选项排第几。
6.2 理论公式
对于单个查询:
Reciprocal Rank (RR) = 1 / 第一个正确答案的排名
(如果没找到正确答案,RR = 0)
对于多个查询:
MRR = (RR₁ + RR₂ + ... + RRₙ) / n
6.3 计算实例
5 个测试查询:
| 查询 | 第一个正确答案排名 | RR = 1/排名 |
|---|---|---|
| 查询 1 | 第 1 名 | 1/1 = 1.0 |
| 查询 2 | 第 3 名 | 1/3 ≈ 0.333 |
| 查询 3 | 第 2 名 | 1/2 = 0.5 |
| 查询 4 | 前 10 名都没有 | 0 |
| 查询 5 | 第 1 名 | 1/1 = 1.0 |
MRR = (1.0 + 0.333 + 0.5 + 0 + 1.0) / 5 = 0.566
6.4 代码实现
python
def reciprocal_rank(results: list) -> float:
"""
计算单个查询的 RR(Reciprocal Rank)
"""
for i, is_relevant in enumerate(results):
if is_relevant:
return 1 / (i + 1)
return 0 # 没找到正确答案
def mean_reciprocal_rank(all_results: list) -> float:
"""
计算 MRR(Mean Reciprocal Rank)
Args:
all_results: 多个查询的结果列表
[[True, False, True...], [False, True...], ...]
Returns:
MRR 分数
"""
rr_scores = [reciprocal_rank(results) for results in all_results]
if not rr_scores:
return 0
return sum(rr_scores) / len(rr_scores)
# 使用示例
all_results = [
[True, False, False, False], # 第 1 名正确,RR=1.0
[False, False, True, False], # 第 3 名正确,RR=0.333
[False, True, True, False], # 第 2 名正确,RR=0.5
[False, False, False, False], # 没正确,RR=0
[True, False, False, False], # 第 1 名正确,RR=1.0
]
mrr_score = mean_reciprocal_rank(all_results)
print(f"MRR = {mrr_score:.3f}") # 0.566
6.5 特点总结
| 优点 | 缺点 |
|---|---|
| ✅ 计算简单,易于理解 | ❌ 只关注第一个正确答案 |
| ✅ 适合问答系统(只需一个正确答案) | ❌ 忽略多个正确答案的情况 |
| ✅ 不需要知道所有相关文档 | ❌ 不适合搜索场景(用户会看多条结果) |
七、NDCG(归一化折损累计增益)
7.1 概念解释
NDCG 是检索评估的"黄金指标",它同时考虑两件事:
- 相关性有等级(0-3 分),不是非黑即白
- 排名越靠后,价值越低(折损)
生活例子:
老师批改试卷,不是简单的"对/错",而是:
- 完全正确:3 分
- 基本正确:2 分
- 沾点边:1 分
- 完全错误:0 分
而且,排在前面的答案更重要!
7.2 为什么需要 NDCG?
MRR 和 Precision 的局限性:
场景:用户搜 "Python 教程"(会看前 5-10 条结果)
结果 A: [✅, ❌, ❌, ❌, ❌] 第一个正确在第 1 名
结果 B: [✅, ✅, ✅, ❌, ❌] 第一个正确在第 1 名
用 MRR 评估:两者一样(都只关心第一个)
用 Precision@5 评估:两者一样(3/5 vs 1/5... 不对,这里 Precision 不同)
但用户明显更喜欢结果 B!
NDCG 能区分这种情况,因为它考虑所有结果的相关性。
7.3 核心概念
7.3.1 相关性等级
| 等级 | 分数 | 说明 |
|---|---|---|
| 完全无关 | 0 | 完全不相关 |
| 有点相关 | 1 | 沾点边 |
| 相关 | 2 | 比较相关 |
| 非常相关 | 3 | 完美匹配 |
7.3.2 折损因子
排名越靠后,价值越低:
| 排名 | 折损因子 1/log₂(排名 +1) | 含义 |
|---|---|---|
| 1 | 1/log₂(2) = 1.0 | 100% 价值 |
| 2 | 1/log₂(3) ≈ 0.63 | 63% 价值 |
| 3 | 1/log₂(4) = 0.5 | 50% 价值 |
| 4 | 1/log₂(5) ≈ 0.43 | 43% 价值 |
| 5 | 1/log₂(6) ≈ 0.39 | 39% 价值 |
7.4 理论公式
CG@K = Σ relᵢ (累计增益)
DCG@K = Σ (relᵢ / log₂(i + 1)) (折损累计增益)
IDCG@K = 最优排序下的 DCG (理想折损累计增益)
NDCG@K = DCG@K / IDCG@K (归一化)
7.5 完整计算实例
检索结果及人工标注:
| 排名 | 文档 | 相关性分数 |
|---|---|---|
| 1 | "机器学习完全指南" | 3(非常相关) |
| 2 | "深度学习教程" | 2(相关) |
| 3 | "Python 编程基础" | 1(有点相关) |
| 4 | "机器学习实战项目" | 3(非常相关) |
| 5 | "Java 入门教程" | 0(无关) |
步骤 1:计算 DCG@5
DCG@5 = 3/1.0 + 2/0.63 + 1/0.5 + 3/0.43 + 0/0.39
= 3.0 + 3.17 + 2.0 + 6.98 + 0
= 15.15
步骤 2:计算 IDCG@5
最优排序应该是相关性从高到低:[3, 3, 2, 1, 0]
IDCG@5 = 3/1.0 + 3/0.63 + 2/0.5 + 1/0.43 + 0/0.39
= 3.0 + 4.76 + 4.0 + 2.33 + 0
= 14.09
步骤 3:计算 NDCG@5
NDCG@5 = DCG@5 / IDCG@5
= 15.15 / 14.09
≈ 1.08(>1 说明计算有误,通常 NDCG ≤ 1)
注:实际应用中 NDCG 应在 0-1 之间。上面例子用于演示计算过程,实际数据应保证 IDCG ≥ DCG。
7.6 代码实现
python
import numpy as np
def dcg(relevances: list) -> float:
"""
计算 DCG(Discounted Cumulative Gain)
公式:DCG = Σ (2^rel - 1) / log₂(i + 2)
"""
dcg_score = 0
for i, rel in enumerate(relevances):
dcg_score += (2 ** rel - 1) / np.log2(i + 2)
return dcg_score
def ndcg(relevances: list) -> float:
"""
计算 NDCG(Normalized DCG)
Args:
relevances: 相关性分数列表 [3, 2, 3, 1, 0]
Returns:
NDCG 分数(0-1 之间)
"""
# 计算实际 DCG
dcg_score = dcg(relevances)
# 计算理想 DCG(最优排序:相关性从高到低)
ideal_relevances = sorted(relevances, reverse=True)
idcg_score = dcg(ideal_relevances)
if idcg_score == 0:
return 0
return dcg_score / idcg_score
# 使用示例
relevances = [3, 2, 1, 3, 0] # 5 个结果的相关性分数
ndcg_score = ndcg(relevances)
print(f"NDCG@5 = {ndcg_score:.3f}")
# 对比两种排序
good_ranking = [3, 3, 2, 1, 0] # 好的排序:高分在前
bad_ranking = [0, 1, 2, 3, 3] # 差的排序:高分在后
print(f"好的排序 NDCG = {ndcg(good_ranking):.3f}") # ≈ 1.0
print(f"差的排序 NDCG = {ndcg(bad_ranking):.3f}") # < 1.0
7.7 特点总结
| 优点 | 缺点 |
|---|---|
| ✅ 考虑多个正确答案 | ❌ 计算复杂 |
| ✅ 考虑相关性等级(不是非黑即白) | ❌ 需要人工标注相关性 |
| ✅ 考虑排序质量(靠前的更重要) | ❌ 对标注一致性敏感 |
| ✅ 结果在 0-1 之间,易于比较 |
八、五大指标对比总结
8.1 对比表
| 指标 | 关注点 | 是否考虑排序 | 需要全部标注 | 计算复杂度 | 适用场景 |
|---|---|---|---|---|---|
| Precision@K | 前 K 个有多准 | ❌ | ❌ | O(K) | 快速评估 |
| Recall@K | 覆盖率 | ❌ | ✅ | O(K) | 评估召回能力 |
| AP/MAP | 综合精度 | ✅ | ✅ | O(n) | 学术评估 |
| MRR | 第一个正确答案 | ✅ | ❌ | O(n) | 问答系统 |
| NDCG | 整体排序质量 | ✅ | ✅ | O(n log n) | 搜索/推荐 |
8.2 选择建议
你的场景是什么?
│
├─ 只需一个正确答案(问答系统)
│ └─→ MRR
│
├─ 用户会看多条结果(搜索引擎)
│ └─→ NDCG
│
├─ 快速评估效果
│ └─→ Precision@K
│
├─ 需要知道召回覆盖率
│ └─→ Recall@K
│
└─ 学术论文/综合评估
└─→ MAP + NDCG
8.3 记忆口诀
Precision 看前 K,Recall 看覆盖
AP 综合算平均,MRR 只看第一名
NDCG 最全面,相关性 + 排序都考虑
九、完整实战代码
python
import numpy as np
from typing import List
class RetrievalEvaluator:
"""检索质量评估器"""
def precision_at_k(self, results: List[bool], k: int) -> float:
"""计算 Precision@K"""
if len(results) < k:
results = results + [False] * (k - len(results))
return sum(results[:k]) / k
def recall_at_k(self, results: List[bool], total_relevant: int) -> float:
"""计算 Recall@K"""
if total_relevant == 0:
return 0
return sum(results) / total_relevant
def average_precision(self, results: List[bool]) -> float:
"""计算 AP"""
precision_sum = 0
relevant_count = 0
for i, is_relevant in enumerate(results):
if is_relevant:
relevant_count += 1
precision_at_i = relevant_count / (i + 1)
precision_sum += precision_at_i
return precision_sum / relevant_count if relevant_count > 0 else 0
def mean_average_precision(self, all_results: List[List[bool]]) -> float:
"""计算 MAP"""
ap_scores = [self.average_precision(r) for r in all_results]
return np.mean(ap_scores) if ap_scores else 0
def reciprocal_rank(self, results: List[bool]) -> float:
"""计算单个查询的 RR"""
for i, is_relevant in enumerate(results):
if is_relevant:
return 1 / (i + 1)
return 0
def mean_reciprocal_rank(self, all_results: List[List[bool]]) -> float:
"""计算 MRR"""
rr_scores = [self.reciprocal_rank(r) for r in all_results]
return np.mean(rr_scores) if rr_scores else 0
def dcg(self, relevances: List[float]) -> float:
"""计算 DCG"""
return sum((2 ** rel - 1) / np.log2(i + 2)
for i, rel in enumerate(relevances))
def ndcg(self, relevances: List[float]) -> float:
"""计算 NDCG"""
dcg_score = self.dcg(relevances)
ideal_relevances = sorted(relevances, reverse=True)
idcg_score = self.dcg(ideal_relevances)
return dcg_score / idcg_score if idcg_score > 0 else 0
# ==================== 使用示例 ====================
if __name__ == "__main__":
evaluator = RetrievalEvaluator()
print("=" * 60)
print("检索质量评估示例")
print("=" * 60)
# 单个查询的检索结果
results = [True, False, True, True, False, True, False, False]
print(f"\n检索结果:{results}")
print(f"Precision@5 = {evaluator.precision_at_k(results, 5):.3f}")
print(f"Recall (总相关=10) = {evaluator.recall_at_k(results, 10):.3f}")
print(f"AP = {evaluator.average_precision(results):.3f}")
# 多查询评估
all_results = [
[True, False, True, False],
[True, True, False, False],
[False, True, True, False],
]
print(f"\nMAP = {evaluator.mean_average_precision(all_results):.3f}")
print(f"MRR = {evaluator.mean_reciprocal_rank(all_results):.3f}")
# NDCG 评估
relevances = [3, 2, 3, 1, 0]
print(f"\nNDCG@5 = {evaluator.ndcg(relevances):.3f}")
print("=" * 60)
十、结语
检索质量评估指标是衡量和优化检索系统的基础工具。
- Precision@K 和 Recall@K 简单直观,适合快速评估
- AP/MAP 综合考虑精度和召回,学术界常用
- MRR 专注于第一个正确答案,适合问答系统
- NDCG 最全面,是工业界的黄金指标
实际项目中,我推荐:
- 快速迭代阶段:用 Precision@K 监控
- 正式评估阶段:用 NDCG + MRR 综合评估
- 学术论文:用 MAP + NDCG@K 多指标报告
记住:没有完美的指标,只有适合的指标。选择哪个指标,取决于你的业务场景和用户需求。
本文代码已测试,可直接运行。欢迎收藏备用!
文档整理日期:2026-04-15