Skip to content

14.17-RAG评估指标

要点

  • RAG 系统有十几个可调参数,没有指标就没有优化方向——改了一个参数,你不知道系统是变好了还是变差了
  • 评估分两个独立维度:检索质量(找到了对的文件没有)和生成质量(回答得好不好)——两者互不替代
  • 检索指标各有侧重:Recall@K 看覆盖率,MRR 看首位命中,NDCG 看排序质量,Hit Rate 看兜底能力
  • 生成指标看三件事:忠实度(不编造)、相关性(不答偏)、完整性(不遗漏)
  • LLM-as-Judge 是当前最实用的生成评估方案,但有位置偏差、自我偏好和提示敏感三个系统性局限
  • 从零搭建评估体系只需五步:建数据集 → 跑检索 → 跑生成 → 算指标 → 集成到 CI

1. 改了一个参数,然后呢

RAG 系统的可调参数比你想象的多:chunk size、overlap、embedding 模型、topK、相似度阈值、rerank 策略、上下文拼接方式、Prompt 模板。每个参数都可能影响最终回答质量,但影响方向不确定

举个例子。你把 chunk size 从 1000 改成 500,直觉上更细粒度的切分应该检索更精确。实际跑下来,回答质量下降了 15%——切得太小,上下文丢失,检索反而找不到完整信息。

如果你没有评估体系,这个发现不会发生。你只会觉得「更细粒度更好」,然后带着一个退化的系统上线。

没有指标就没有优化方向。 这一篇解决两个问题:用什么指标衡量 RAG 质量,以及怎么从零搭建评估体系。

RAG 评估分两个独立维度——检索质量和生成质量。检索阶段要回答「找到了正确的文档没有」,生成阶段要回答「回答得好不好」。两个维度需要分别评估,因为检索满分不代表生成满分,反之亦然。

2. 检索质量:四个指标各自回答什么问题

检索评估的核心是拿「实际检索到的文档」和「应该检索到的文档」做对比。后者需要你提前标注——这在第 5 章讲。现在先理解每个指标的直觉含义和适用场景。

2.1 Recall@K:找到了多少正确答案

Recall@K 回答的问题是:在所有相关文档中,你找到了几个?

Recall@K = |{相关文档} ∩ {检索到的 topK}| / |{相关文档}|

假设用户问题有 3 篇相关文档 [A, B, C],检索返回 top5 [A, C, D, E, F]:

Recall@5 = |{A, C}| / |{A, B, C}| = 2/3 ≈ 0.67

Recall@K 关注覆盖率。RAG 的检索阶段目标是尽量多找到相关文档——漏掉关键信息,后面的 LLM 再生成也补不回来。这是 RAG 场景最核心的检索指标。

typescript
function recallAtK(
  retrieved: string[],
  relevant: string[],
  k: number
): number {
  const topK = retrieved.slice(0, k)
  const found = topK.filter((id) => relevant.includes(id)).length
  return found / relevant.length
}

2.2 MRR:正确答案排在第几位

MRR 回答的问题是:用户看到的第一个结果是对的吗?

它取每个查询中第一个正确结果的排名倒数,然后求平均。正确答案排第 1 位得 1.0,排第 3 位得 0.33,排不到得 0。

问题 1:第一个正确结果在第 1 位 → 1/1 = 1.0
问题 2:第一个正确结果在第 3 位 → 1/3 ≈ 0.33
问题 3:第一个正确结果在第 2 位 → 1/2 = 0.5

MRR = (1.0 + 0.33 + 0.5) / 3 ≈ 0.61

MRR 适合用户只看前几个结果的场景——问答系统、搜索建议。如果你的用户习惯翻好几页,MRR 的信息量就不够,应该看 NDCG。

typescript
function mrr(queries: Array<{ retrieved: string[]; relevant: string[] }>): number {
  let sum = 0
  for (const { retrieved, relevant } of queries) {
    const firstCorrect = retrieved.findIndex((id) => relevant.includes(id))
    if (firstCorrect >= 0) {
      sum += 1 / (firstCorrect + 1)
    }
  }
  return sum / queries.length
}

2.3 NDCG:排序质量的整体度量

NDCG 回答的问题是:相关文档是不是都排在前面?

和 Recall 只看「有没有找到」不同,NDCG 给每个位置加了对数衰减——排在第 1 位的相关文档贡献最大,越往后贡献越小。同时支持多级相关性(0/1/2),可以区分「部分相关」和「完全相关」。

DCG@K = Σ (rel_i / log2(i + 1))
NDCG@K = DCG@K / IDCG@K

其中 IDCG 是理想排序下的 DCG,用于归一化到 [0, 1]

NDCG 适合对排序质量要求高的场景——搜索结果页、文档推荐。计算比 Recall 复杂,但在排序敏感的场景更有区分度。

typescript
function ndcgAtK(
  retrieved: string[],
  relevant: string[],
  k: number
): number {
  const topK = retrieved.slice(0, k)

  let dcg = 0
  for (let i = 0; i < topK.length; i++) {
    const rel = relevant.includes(topK[i]) ? 1 : 0
    dcg += rel / Math.log2(i + 2)
  }

  const idealRelevant = Math.min(relevant.length, k)
  let idcg = 0
  for (let i = 0; i < idealRelevant; i++) {
    idcg += 1 / Math.log2(i + 2)
  }

  return idcg > 0 ? dcg / idcg : 0
}

2.4 Hit Rate:能不能答上

Hit Rate 回答的问题是:有多少查询至少找到了一个相关文档?

它不关心找到了几个,只关心「有没有」。一个查询只要 topK 里有至少一个相关文档就算命中。

typescript
function hitRate(
  queries: Array<{ retrieved: string[]; relevant: string[] }>
): number {
  const hits = queries.filter(({ retrieved, relevant }) =>
    retrieved.some((id) => relevant.includes(id))
  ).length
  return hits / queries.length
}

Hit Rate 衡量的是系统的兜底能力——有多少问题你根本答不上来。如果你的业务场景对「答错」的容忍度远低于「答不上」,Hit Rate 是最直接的指标。

2.5 怎么选

指标核心问题最佳场景局限
Recall@K找到了多少相关文档RAG 检索,关注覆盖率不关心排序
MRR第一个正确答案在哪问答系统,用户只看前几个忽略第二个及之后的正确结果
NDCG@K整体排序质量搜索排序,排序敏感计算复杂,需要多级相关性标注
Hit Rate有没有找到任何相关文档兜底率评估不区分找到 1 个还是 10 个

RAG 场景的推荐组合:Recall@K + Hit Rate。 Recall 看覆盖率,Hit Rate 看兜底能力。如果你的检索结果有排序要求(比如展示前 3 条),加上 MRR 或 NDCG。

指标选好了,检索质量可以度量了。但检索找到了正确的文档,不代表 LLM 就能生成好的回答——接下来看生成质量怎么评估。

3. 生成质量:忠实度、相关性、完整性

生成质量评估的是 LLM 拿到检索上下文后产出的回答。它和检索质量是独立的——检索 Recall 满分,LLM 仍然可能编造信息、答非所问或遗漏关键内容。

3.1 忠实度:回答有没有编造

忠实度(Faithfulness)回答:回答中的每个事实,都能在检索到的上下文中找到依据吗?

这是 RAG 场景最重要的生成指标。忠实度低意味着幻觉——LLM 在回答里加入了上下文中不存在的信息。对于企业知识库场景,幻觉的代价往往比「答不上来」更高。

评估忠实度最直接的方式是 LLM-as-Judge:用另一个 LLM 检查回答中的事实是否能从上下文中推导出来。

3.2 相关性:有没有答非所问

相关性(Relevance)回答:回答和用户的原始问题匹配吗?

LLM 有时候会「答偏」——问题问的是退款流程,回答讲的是退款政策历史。相关性评估不关心事实是否来自上下文,只关心回答是否直接回应了用户的问题。

3.3 完整性:有没有遗漏关键信息

完整性(Completeness)回答:回答覆盖了问题的所有方面吗?

一个问题可能有多个维度。比如「退款怎么申请,多久到账」包含两个子问题——完整性评估会检查回答是否两方面都覆盖了。

三个维度的代码实现思路相似,核心都是 LLM-as-Judge 打分,区别在于 Prompt 的评估标准。统一实现留到第 4 章。

三个维度正交:一个回答可以忠实但不完整(只说了一部分但没编造),相关但不忠实(回答了问题但编造了细节),完整但不相关(信息全面但答非所问)。 分开评估才能定位具体问题出在哪个环节。

这三个指标都依赖 LLM 来做评估。这个方案可行吗?有什么坑?

4. LLM-as-Judge:方案与局限

用 LLM 评估 LLM 的输出,是当前 RAG 评估最实用的方案。思路很直接:把上下文、问题和回答拼在一起,让一个评估模型按指定标准打分。

4.1 统一实现

三个生成指标可以用同一个评估函数实现,区别只在评估标准和 Prompt:

typescript
type EvalDimension = 'faithfulness' | 'relevance' | 'completeness'

const prompts: Record<EvalDimension, string> = {
  faithfulness: `评估回答是否完全基于提供的上下文。
- 1.0:所有事实都能在上下文中找到依据
- 0.5:大部分有依据,但有些细节是推断的
- 0.0:大部分信息在上下文中没有依据`,

  relevance: `评估回答和用户问题的相关性。
- 1.0:直接且完整地回答了问题
- 0.5:部分相关,但没有直接回答
- 0.0:完全无关或答非所问`,

  completeness: `基于上下文,评估回答是否覆盖了问题的所有方面。
- 1.0:完整覆盖了问题涉及的所有方面
- 0.5:回答了部分方面
- 0.0:遗漏了关键信息`,
}

async function evaluateGeneration(
  question: string,
  answer: string,
  contexts: string[],
  dimension: EvalDimension
): Promise<number> {
  const input = dimension === 'faithfulness'
    ? `上下文:\n${contexts.join('\n---\n')}\n\n回答:${answer}`
    : dimension === 'completeness'
    ? `问题:${question}\n上下文:${contexts.join('\n')}\n回答:${answer}`
    : `问题:${question}\n回答:${answer}`

  const response = await llm.chat({
    messages: [
      {
        role: 'system',
        content: `${prompts[dimension]}\n\n${input}\n\n只输出 0-1 的数字评分。`,
      },
    ],
  })

  return parseFloat(response.content)
}

4.2 LLM-as-Judge 的三个系统性偏差

LLM 评估器不是客观裁判——它自己也是一个 LLM,继承了 LLM 的所有偏差。

位置偏差(Position Bias)。 当评估器比较多个候选回答时,它倾向于给排在前面或后面的回答更高分。中间位置的回答容易被低估。

自我偏好(Self-Enhancement Bias)。 评估器倾向于给与自己风格相似的回答更高分。如果你用 GPT-4 生成回答,又用 GPT-4 评估,评分会系统偏高——不是因为回答更好,而是因为风格一致。

提示敏感(Prompt Sensitivity)。 改一下评估 Prompt 的措辞,评分可能波动 0.1-0.2。评估器对评估标准的理解和你的意图不一定一致。

4.3 四种改进方向

偏差没法消除,但可以缓解:

  1. 使用不同的模型做生成和评估。 用 Claude 生成、GPT-4 评估,或反过来。打破自我偏好。
  2. 多次评估取平均。 同一个回答评估 3 次,取中位数。降低随机波动。
  3. 明确的评分标准加 few-shot 示例。 在评估 Prompt 里给 2-3 个标注好的示例,锚定评估器的理解。
  4. 人工抽检校准。 每 100 个自动评估结果抽 10 个人工复核,检查评估器和人工的一致性。如果偏差超过阈值,调整 Prompt 或换评估模型。

4.4 RAGAS:开箱即用的整合方案

RAGAS 是一个专门为 RAG 设计的评估框架,把忠实度、相关性、上下文精确率、上下文召回率整合在一起,不需要你自己写评估 Prompt:

typescript
import { evaluate } from 'ragas'

const results = await evaluate({
  question: ['退款多久到账?', '如何修改密码?'],
  answer: ['退款一般在 3-5 个工作日到账。', '在设置页面可以修改密码。'],
  contexts: [
    ['退款一般在 3-5 个工作日到账,退回原支付账户。'],
    ['在「设置」>「账户安全」页面可以修改密码。'],
  ],
  ground_truth: [
    '退款一般在 3-5 个工作日到账。',
    '在设置页面的账户安全选项中可以修改密码。',
  ],
  metrics: ['faithfulness', 'answer_relevancy', 'context_precision', 'context_recall'],
})
// { faithfulness: 0.92, answer_relevancy: 0.88, context_precision: 0.85, context_recall: 0.78 }

RAGAS 的价值在于标准化——你不用自己定义评估 Prompt,社区已经验证过。但它的底层仍然是 LLM-as-Judge,第 4.2 节的偏差同样存在。

有了评估方法和工具,下一步是准备评估的基础设施——标注数据。

5. 评估数据集:标注与生成

所有指标的计算都需要一个前提:你知道每个问题的「正确答案」是什么。对于检索指标,你需要知道每个问题应该检索到哪些 chunk;对于生成指标,你需要知道标准答案。

5.1 数据结构

typescript
type EvaluationCase = {
  question: string
  relevantChunkIds: string[]     // 检索评估用:应该检索到的 chunk
  groundTruthAnswer?: string     // 生成评估用:标准答案
  category?: string              // 用于按类别分析薄弱环节
}

const evalDataset: EvaluationCase[] = [
  {
    question: '退款多久到账?',
    relevantChunkIds: ['doc-refund-chunk-2'],
    groundTruthAnswer: '退款一般在 3-5 个工作日到账。',
    category: '退款',
  },
  {
    question: '如何申请退款?',
    relevantChunkIds: ['doc-refund-chunk-0', 'doc-refund-chunk-1'],
    groundTruthAnswer: '在订单页面点击「申请退款」,填写退款原因后提交。',
    category: '退款',
  },
]

5.2 两种构建方式

人工标注——最准确,但成本最高。适合核心业务场景、高频问题。标注人员需要同时理解业务和文档结构。

LLM 辅助生成——成本低,速度快,适合快速起量。让 LLM 根据文档内容生成 QA 对,然后人工抽检:

typescript
async function generateEvalDataset(documents: Document[]): Promise<EvaluationCase[]> {
  const cases: EvaluationCase[] = []

  for (const doc of documents) {
    const response = await llm.chat({
      messages: [{
        role: 'system',
        content: `根据以下文档内容,生成 3-5 个用户可能会问的问题及答案。格式:
Q: 问题
A: 答案

文档内容:
${doc.content}`,
      }],
    })

    const qaPairs = parseQAPairs(response.content)
    for (const qa of qaPairs) {
      cases.push({
        question: qa.question,
        relevantChunkIds: [doc.id],
        groundTruthAnswer: qa.answer,
      })
    }
  }
  return cases
}

最低数量建议:50-100 个测试用例。 低于 50 个,评估结果的统计意义不足——10 个用例的平均分波动太大,无法可靠区分改进和退化。如果资源有限,宁可少做几个参数对比,也要保证评估集的规模。

5.3 标注质量检查

标注完之后做一轮交叉验证:随机抽 20% 的用例,让另一个人(或另一个 LLM)重新标注,计算标注一致性(Cohen's Kappa > 0.7 算合格)。一致性低说明标注标准不清晰,需要重新校准。

数据集建好了,接下来把所有东西串起来。

6. 从零搭建评估体系

不需要一步到位,按这五步走。

6.1 第一步:收集评估用例

从真实用户对话、客服工单或 FAQ 中提取 50-100 个 QA 对。按业务类别分组(退款、账户、产品使用……),每组至少 10 个。用 LLM 辅助生成补充用例,人工抽检标注质量。

6.2 第二步:跑检索,记录指标

对每个用例,记录检索到的 topK 文档 ID,计算 Recall@K 和 Hit Rate:

typescript
for (const testCase of dataset) {
  const queryVector = await embed(testCase.question)
  const results = await vectorDB.search(queryVector, { topK: 5 })
  const retrievedIds = results.map((r) => r.id)

  const recall = recallAtK(retrievedIds, testCase.relevantChunkIds, 5)
  const hit = retrievedIds.some((id) => testCase.relevantChunkIds.includes(id))
}

6.3 第三步:跑生成,记录指标

用检索到的上下文拼接 Prompt,让 LLM 生成回答,再评估忠实度和相关性:

typescript
const context = results.map((r) => r.content).join('\n---\n')
const answer = await generateAnswer(testCase.question, context)

const faithfulness = await evaluateGeneration(
  testCase.question, answer, results.map((r) => r.content), 'faithfulness'
)
const relevance = await evaluateGeneration(
  testCase.question, answer, [], 'relevance'
)

6.4 第四步:看分布,不要只看平均

平均 Recall 0.8 可能是两种情况:80% 的问题 Recall 1.0 + 20% 的问题 Recall 0,或者所有问题都在 0.7-0.9 之间。前者说明有一类问题完全检索不到——需要按类别分析,找到薄弱环节。

typescript
const byCategory = groupBy(results, (r) => r.category)
for (const [category, cases] of Object.entries(byCategory)) {
  const avgRecall = cases.reduce((sum, r) => sum + r.recall, 0) / cases.length
  console.log(`${category}: n=${cases.length}, recall=${avgRecall.toFixed(2)}`)
}
// 退款: n=25, recall=0.92
// 账户: n=20, recall=0.65  ← 薄弱环节

6.5 第五步:集成到 CI

评估不是一次性的——数据在更新,模型在更换,需要定期跑。集成到 CI/CD,设阈值告警:

yaml
# .github/workflows/rag-eval.yml
name: RAG Evaluation
on:
  schedule:
    - cron: '0 2 * * 1'  # 每周一凌晨 2 点
  workflow_dispatch:

jobs:
  evaluate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - run: pnpm install
      - run: pnpm run rag:eval
      - name: Check thresholds
        run: |
          node scripts/check-eval.js --metric recall --threshold 0.8
          node scripts/check-eval.js --metric faithfulness --threshold 0.85

阈值怎么定?先跑一轮基线,把当前分数记录下来。然后设阈值为基线的 90%——低于基线 90% 就告警,说明有退化。随着优化推进,逐步提高阈值。

7. 指标权衡与评估陷阱

7.1 指标之间的权衡

Recall 和延迟的权衡。 topK 越大,Recall 越高,但检索延迟和 LLM 上下文拼接成本也越高。topK=5 到 topK=10 可能只提升 3% 的 Recall,但延迟和成本几乎翻倍。找到边际收益递减的拐点。

Recall 和精确率的权衡。 检索更多文档提高了覆盖率,但也引入了更多噪声。噪声上下文会干扰 LLM 生成——模型需要在无关信息中定位关键内容,忠实度可能反而下降。

评估成本和频率的权衡。 每次完整评估需要大量 LLM 调用(生成回答 + 评估回答)。100 个用例 × 3 个评估维度 = 300 次 LLM 调用。如果每天跑一次,月度成本不低。折中方案:核心指标每天跑,完整评估每周跑。

7.2 五个常见陷阱

评估数据集太小。 10 个用例的评估没有统计意义。指标波动本身就是噪声,你无法区分「改进」和「随机波动」。最低 50 个用例。

过拟合评估集。 反复调参直到评估分数好看,但实际用户体验没改善。和机器学习过拟合一模一样的问题。解法:保留一个独立的测试集,调参过程中不看,只在最终验证时用一次。

只看平均分数。 前面说过,平均掩盖分布。一定要按类别看,找到薄弱类别。

LLM-as-Judge 当绝对标准。 评估器本身有偏差(第 4.2 节)。它给出 0.9 的忠实度分数不代表真的没有幻觉——只是评估器没检测到。人工抽检是必要的校准手段。

忽略延迟和成本。 检索质量提升了 5%,但延迟翻倍、成本翻三倍。这不是改进,是倒退。评估体系应该同时记录延迟和成本,做综合判断。

7.3 指标的边界条件

评估指标也有不适用的时候。

文档质量本身就差。 如果知识库里充斥着过时、矛盾或不完整的文档,检索指标再高也没用——找到了正确的 chunk,但 chunk 本身就是错的。评估指标度量的是 RAG 管道的技术质量,不是知识库的内容质量。

问题超出系统能力范围。 用户问了一个知识库根本没覆盖的问题,系统回答「我不知道」。这在忠实度上可能得高分(没编造),但在相关性上得低分(没回答问题)。标注数据集时应该包含「无法回答」类型的问题,并为评估标准做特殊处理。

指标之间的冲突无法完全消除。 没有一个数字能概括 RAG 系统的全部质量。评估体系的价值不在于给你一个满分答案,而在于让你知道改了一个参数之后,哪些方面变好了,哪些方面变差了,以及你是否接受这个取舍。

到这里,你已经知道怎么度量 RAG 系统的质量了。下一步是把这些组件串成 API——检索、生成、评估,怎么设计成可调用的接口,这是第 18 篇的内容。

基于 MIT 协议开源