评估指标详解

0 阅读9分钟

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 RelevancyQuery ↔ Context检索噪音
FaithfulnessContext ↔ Answer幻觉/编造
Answer RelevancyQuery ↔ 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_idsground_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_ids
  • HitRate@K → 用 ground_truth_ids
  • MRR → 用 ground_truth_ids
  • NDCG@K → 用 ground_truth_relevance

会比你原来把 gt_relret_rel 都作为参数传进去更不容易出错