RAG 评估我一般分成两大部分:检索阶段评估和生成阶段评估。
检索阶段主要看四个指标:Recall@K、Hit Rate@K、MRR 和 NDCG@K;生成阶段主要看三个维度:Context Relevancy、Faithfulness 和 Answer Relevancy。
一、检索阶段
第一个是 Recall@K。
Recall@K 主要衡量的是:真实相关文档有没有被召回,以及召回了多少。
公式是:
Recall@K = Top-K 中召回的相关文档数量 / 该 query 全部相关文档数量。
比如一个问题有 3 篇真实相关文档,Top5 找到了其中 2 篇,那么 Recall@5 就是 2/3。
它的特点是关注召回覆盖率,但是不关心相关文档排在第几位。
第二个是 Hit Rate@K。
Hit Rate 更简单,它是站在 query 维度看的。
对于一个 query,只要 Top-K 里面至少有一篇相关文档,就记为 1;一篇都没有就记为 0;最后对所有 query 求平均。
所以可以简单记成:
Recall 看召回了多少,Hit Rate 看有没有至少召回一个。
比如 100 个 query,有 80 个 query 的 Top5 至少包含一篇相关文档,那么 Hit Rate@5 就是 0.8。
第三个是 MRR,也就是 Mean Reciprocal Rank。
MRR 主要关注:第一个相关文档排得靠不靠前。
对于每个 query,找到第一个 relevant document 的排名 r,然后计算 1/r,最后对所有 query 求平均。
比如第一个相关文档排在第 1 位,得分就是 1;排在第 3 位,就是 1/3;如果 Top-K 中没有相关文档,就是 0。
所以:
MRR 只关注第一个相关结果的位置。
它比较适合用户通常只关注前几个结果的场景。
第四个是 NDCG@K。
NDCG 是更加精细的排序指标。
它不仅考虑文档的位置,还可以考虑文档的相关性等级。
例如:
- 3 分:高度相关
- 2 分:比较相关
- 1 分:弱相关
- 0 分:不相关
排名越靠后,相关性得分的贡献会通过折损函数降低。
首先计算实际排序的 DCG,然后计算理想排序情况下的 IDCG,最后:
NDCG = DCG / IDCG。
因此 NDCG 一般在 0 到 1 之间,越接近 1 说明排序越接近理想排序。
所以 Recall 和 Hit Rate 更关注有没有召回,MRR 关注第一个相关结果排得多靠前,而 NDCG 关注整个排序列表的质量,并且支持多等级相关性。
二、生成阶段
生成阶段我一般关注三个指标:
Context Relevancy、Faithfulness 和 Answer Relevancy。
这三个可以分别理解成:
检索的上下文相关不相关、答案有没有编、答案有没有回答到问题。
第一个是 Context Relevancy。
它评估的是:
检索出来的 context/chunk 和用户问题是否相关。
如果用户问的是“公司的退款政策”,结果检索出来大量和“公司历史、招聘、新闻”相关的 chunk,那么 Context Relevancy 就比较低。
所以它主要是在评估检索出来的材料质量。
第二个是 Faithfulness,也就是忠实度。
它评估:
模型生成的答案是否能够被提供给它的上下文支持。
核心是防止幻觉。
例如 Context 里面只说“产品支持 7 天退款”,但是模型回答成“产品支持 30 天无理由退款”,那么这个信息没有上下文支持,就属于 Faithfulness 问题。
所以可以记:
Faithfulness = Answer VS Context。
重点看模型有没有编造上下文中不存在的信息。
第三个是 Answer Relevancy。
它评估:
最终答案有没有真正回答用户的问题。
比如用户问“怎么申请退款”,模型却花大量篇幅介绍公司的发展历史,即使这些内容都来自 Context,没有产生幻觉,Answer Relevancy 仍然可能比较低。
所以:
Answer Relevancy = Answer VS Question。
重点看有没有答非所问。
三、三个生成指标最容易混淆的地方
我会用下面这句话来区分:
Context Relevancy:问题 → 检索出来的材料对不对。
Faithfulness:材料 → 模型有没有瞎编。
Answer Relevancy:问题 → 最终有没有答到点上。
也可以记成:
| 指标 | 对比什么 | 主要发现什么问题 |
|---|---|---|
| Context Relevancy | Query ↔ Context | 检索噪音 |
| Faithfulness | Context ↔ Answer | 幻觉/编造 |
| Answer Relevancy | Query ↔ Answer | 答非所问 |
四、工程上怎么组合使用
实际做 RAG Evaluation 时,我不会只看一个指标。
检索阶段可以先看 Recall@K,判断关键资料有没有进入候选集;然后看 MRR/NDCG,判断相关文档是不是排在比较合理的位置;Hit Rate 可以作为一个比较直观的 query-level 指标。
生成阶段则可以一起看:
Context Relevancy → Faithfulness → Answer Relevancy
也就是:
先看检索材料对不对,再看模型有没有基于材料回答,最后看有没有真正回答用户的问题。
五、面试高频追问
Q:MRR 和 NDCG 有什么区别?
MRR 只关注第一个相关文档的位置;NDCG 会考虑整个 Top-K 列表,而且可以考虑不同文档的相关性等级。
所以 MRR 更关注“第一个正确结果什么时候出现”,NDCG 更关注“整体排序是不是合理”。
Q:Recall 和 Hit Rate 有什么区别?
Recall 是看一个 query 的相关文档召回了多少;Hit Rate 是看这个 query 的 Top-K 中有没有至少一个相关文档。
比如真实相关文档有 4 个:
Top-K 找到 1 个和找到 4 个,Hit Rate 都是 1,但 Recall 分别是 0.25 和 1。
Q:Faithfulness 和 Answer Relevancy 怎么区分?
Faithfulness 看的是:
答案有没有超出 Context,重点是幻觉。
Answer Relevancy 看的是:
答案有没有回答 Query,重点是答非所问。
Q:Recall@K 很低,通常可能是什么问题?
首先排查召回链路,例如 chunk 切分策略、embedding 模型、query 改写、向量检索参数、混合检索策略以及 Top-K 设置。
如果真实相关文档根本没有进入候选集,那么后面的 reranker 和 LLM 再好也救不回来。
最后可以用一句话收尾:
RAG 评估可以拆成“检索”和“生成”两部分:检索侧用 Recall、Hit Rate、MRR、NDCG 判断有没有召回以及排序好不好;生成侧用 Context Relevancy、Faithfulness、Answer Relevancy 分别判断检索上下文是否相关、答案是否忠实于上下文,以及最终是否回答了用户的问题。
可以。核心思路是:不要提前手工构造 retrieved_relevances,而是让 NDCG 函数根据 retrieved_ids 去 ground_truth 里查每个文档的 relevance。
这样更接近真实 RAG 评估流程。
1. 先定义 Ground Truth
例如一个 query 的真实标注是:
ground_truth = {
101: 3, # 高度相关
205: 2, # 比较相关
309: 1, # 弱相关
}
检索系统返回:
retrieved_ids = [999, 205, 101, 888, 309]
那么程序自动得到:
999 -> 0
205 -> 2
101 -> 3
888 -> 0
309 -> 1
也就是:
retrieved_relevances = [0, 2, 3, 0, 1]
然后再计算 DCG。
2. 推荐实现
import math
from typing import Dict, List
def dcg_at_k(relevances: List[int], k: int) -> float:
"""
计算 DCG@K
relevances:
按检索结果排名顺序排列的相关性分数
例如 [0, 2, 3, 0, 1]
k:
只计算 Top-K
"""
relevances = relevances[:k]
dcg = 0.0
for rank, rel in enumerate(relevances, start=1):
dcg += rel / math.log2(rank + 1)
return dcg
def ndcg_at_k(
ground_truth: Dict[int, int],
retrieved_ids: List[int],
k: int
) -> float:
"""
NDCG@K
ground_truth:
{doc_id: relevance}
例如:
{
101: 3,
205: 2,
309: 1
}
retrieved_ids:
检索系统返回的文档 ID,已经按照检索排名排序
例如:
[999, 205, 101, 888, 309]
k:
Top-K
relevance 不在 ground_truth 中的文档默认认为是 0。
"""
# =========================
# 1. 根据 retrieved_ids 获取实际相关性
# =========================
retrieved_relevances = [
ground_truth.get(doc_id, 0)
for doc_id in retrieved_ids[:k]
]
# =========================
# 2. 计算实际 DCG
# =========================
dcg = dcg_at_k(retrieved_relevances, k)
# =========================
# 3. 构造理想排序
# =========================
# ground_truth 中的 relevance 从高到低排序
ideal_relevances = sorted(
ground_truth.values(),
reverse=True
)[:k]
# =========================
# 4. 计算 IDCG
# =========================
idcg = dcg_at_k(ideal_relevances, k)
# =========================
# 5. 计算 NDCG
# =========================
if idcg == 0:
return 0.0
return dcg / idcg
# ==========================
# Demo
# ==========================
if __name__ == "__main__":
ground_truth = {
101: 3,
205: 2,
309: 1,
}
retrieved_ids = [
999, # 不相关 -> 0
205, # relevance = 2
101, # relevance = 3
888, # 不相关 -> 0
309, # relevance = 1
]
score = ndcg_at_k(
ground_truth=ground_truth,
retrieved_ids=retrieved_ids,
k=5
)
print(f"NDCG@5 = {score:.4f}")
这个版本的好处是数据结构非常自然:
ground_truth = {
doc_id: relevance
}
而检索系统只负责返回:
retrieved_ids = [
doc_id1,
doc_id2,
doc_id3,
...
]
NDCG 函数自己完成:
retrieved_ids
↓
查 ground_truth
↓
得到 relevance
↓
计算 DCG
↓
计算理想排序 IDCG
↓
NDCG = DCG / IDCG
3. 手算一下这个例子
我们的 Ground Truth:
{
101: 3,
205: 2,
309: 1
}
所以理想排序一定是:
101(3), 205(2), 309(1)
即:
ideal_relevances = [3, 2, 1]
实际检索结果:
Rank 1: 999 → 0
Rank 2: 205 → 2
Rank 3: 101 → 3
Rank 4: 888 → 0
Rank 5: 309 → 1
因此:
retrieved_relevances = [0, 2, 3, 0, 1]
DCG:
0 / log2(2)
+ 2 / log2(3)
+ 3 / log2(4)
+ 0 / log2(5)
+ 1 / log2(6)
而 IDCG:
3 / log2(2)
+ 2 / log2(3)
+ 1 / log2(4)
最后:
NDCG = DCG / IDCG
所以这个检索结果虽然召回了所有 3 篇相关文档,Recall@5 = 1,但 NDCG 不会是 1,因为排序并不是理想排序。
这正好体现了 Recall 和 NDCG 的区别:
Recall:我有没有把正确的东西找回来?
NDCG:我找回来的东西,排序是不是合理?
4. 如果是二分类,其实也可以这么写
如果你的 RAG 数据集只有:
相关 = 1
不相关 = 0
那么:
ground_truth = {
101: 1,
205: 1,
309: 1,
}
检索:
retrieved_ids = [999, 205, 101, 888, 309]
程序自动转换成:
[0, 1, 1, 0, 1]
这时候 NDCG 仍然成立。
所以你面试时可以说:
NDCG 的 ground truth 最好设计成
doc_id -> relevance,检索结果只提供有序的doc_id列表。评估函数根据 doc_id 查出对应的 relevance,未标注的文档默认 relevance=0,然后分别计算实际 DCG 和理想情况下的 IDCG。这样数据结构和真实检索系统的输出更匹配。
还有一个很重要的工程细节
如果你真的要把这套指标用于 RAG benchmark,我建议把四个检索指标统一成同一种数据结构:
ground_truth_ids = [101, 205, 309]
ground_truth_relevance = {
101: 3,
205: 2,
309: 1,
}
retrieved_ids = [999, 205, 101, 888, 309]
这样:
Recall@K→ 用ground_truth_idsHitRate@K→ 用ground_truth_idsMRR→ 用ground_truth_idsNDCG@K→ 用ground_truth_relevance
会比你原来把 gt_rel 和 ret_rel 都作为参数传进去更不容易出错。