14.17-RAG评估指标

要点

  • RAG 评估分两个维度:检索质量和生成质量——需要分别评估
  • 检索质量指标:Recall@K、MRR、NDCG、Hit Rate
  • 生成质量指标:忠实度(Faithfulness)、相关性(Relevance)、完整性(Completeness)
  • 评估数据集是基础——没有标注数据就没有可靠的评估
  • 自动化评估用 LLM-as-Judge 或 embedding 相似度——但都有局限
  • 持续评估比一次性评估更重要——RAG 系统会随数据变化而退化

1. 为什么需要评估

RAG 系统有很多可调参数:

  • 切分策略:chunk size、overlap
  • Embedding 模型选择
  • 向量数据库参数:topK、相似度阈值
  • Rerank 模型
  • 上下文拼接方式
  • Prompt 设计

不评估的话,不知道改进了还是退化了。

改了一个切分策略,chunk size 从 1000 改成 500。
「应该更好吧?更细粒度的切分应该检索更精确。」

但实际上:
- 切得太小,上下文丢失,检索反而找不到完整信息
- 回答质量下降了 15%

没有评估的优化是盲猜。

2. 检索质量指标

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 场景最常用的指标。

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
}

MRR(Mean Reciprocal Rank)

第一个正确结果的位置的倒数。

MRR = 1 / |Q| × Σ (1 / rank_i)

其中 rank_i 是第 i 个问题的第一个正确结果的排名
问题 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 关注的是「正确答案排在第几位」——用户通常只看前几个结果。

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)  // rank 从 1 开始
    }
  }
 
  return sum / queries.length
}

NDCG(Normalized Discounted Cumulative Gain)

考虑结果排名的加权指标——正确答案排在越前面,分数越高。

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

其中:
- rel_i:第 i 个结果的相关性分数(0 或 1,也可以是多级)
- IDCG:理想排序下的 DCG(所有相关文档排在最前面)
function ndcgAtK(
  retrieved: string[],
  relevant: string[],
  k: number
): number {
  const topK = retrieved.slice(0, k)
 
  // DCG
  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)  // i+2 因为 log2(1) = 0
  }
 
  // IDCG(理想排序)
  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
}

Hit Rate

至少找到一个相关文档的查询比例。

Hit Rate = |{至少找到一个相关文档的查询}| / |{总查询数}|
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
}

指标选择

指标关注点适用场景
Recall@K找到了多少相关文档RAG 检索,关注覆盖率
MRR第一个正确答案的排名问答系统,用户只看第一个
NDCG@K整体排序质量搜索结果排序
Hit Rate是否找到了任何相关文档兜底率,能不能答上

RAG 场景最关注 Recall@K 和 Hit Rate——检索阶段的目标是尽可能多地找到相关文档。

3. 生成质量指标

忠实度(Faithfulness)

LLM 的回答是否基于检索到的上下文——有没有编造信息。

忠实度高:回答中的每个事实都能在上下文中找到依据
忠实度低:回答中包含了上下文中没有的信息(幻觉)
async function evaluateFaithfulness(
  answer: string,
  contexts: string[]
): Promise<number> {
  // 用 LLM-as-Judge 评估
  const response = await llm.chat({
    messages: [
      {
        role: 'system',
        content: `评估以下回答是否完全基于提供的上下文。
给出 0-1 的评分:
- 1.0:所有信息都能在上下文中找到
- 0.5:大部分信息有依据,但有些细节是编造的
- 0.0:大部分信息是编造的
 
上下文:
${contexts.join('\n---\n')}
 
回答:${answer}
 
只输出数字评分,不要解释。`,
      },
    ],
  })
 
  return parseFloat(response.content)
}

相关性(Relevance)

LLM 的回答是否和用户问题相关——有没有答非所问。

async function evaluateRelevance(
  question: string,
  answer: string
): Promise<number> {
  const response = await llm.chat({
    messages: [
      {
        role: 'system',
        content: `评估以下回答和问题的相关性。
给出 0-1 的评分:
- 1.0:完全回答了问题
- 0.5:部分相关,但没有直接回答
- 0.0:完全无关
 
问题:${question}
回答:${answer}
 
只输出数字评分。`,
      },
    ],
  })
 
  return parseFloat(response.content)
}

完整性(Completeness)

LLM 的回答是否涵盖了问题的所有方面。

async function evaluateCompleteness(
  question: string,
  answer: string,
  contexts: string[]
): Promise<number> {
  const response = await llm.chat({
    messages: [
      {
        role: 'system',
        content: `基于上下文,评估回答的完整性。
给出 0-1 的评分:
- 1.0:完整回答了问题涉及的所有方面
- 0.5:回答了部分方面
- 0.0:遗漏了关键信息
 
问题:${question}
上下文:${contexts.join('\n')}
回答:${answer}
 
只输出数字评分。`,
      },
    ],
  })
 
  return parseFloat(response.content)
}

RAGAS 框架

RAGAS 是一个专门的 RAG 评估框架,把上述指标整合在一起:

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

4. 评估数据集

评估的基础是标注数据——每个问题需要有:

  • 相关文档:这个问题应该检索到哪些 chunk
  • 标准答案(可选):这个问题的正确答案是什么
type EvaluationCase = {
  question: string
  relevantChunkIds: string[]     // 应该检索到的 chunk ID
  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: '退款',
  },
  // ...
]

构建评估数据集

方法一:人工标注——最准确,但成本高

方法二:LLM 辅助生成

async function generateEvalDataset(documents: Document[]): Promise<EvaluationCase[]> {
  const cases: EvaluationCase[] = []
 
  for (const doc of documents) {
    // 让 LLM 根据文档内容生成问题和答案
    const response = await llm.chat({
      messages: [
        {
          role: 'system',
          content: `根据以下文档内容,生成 3-5 个用户可能会问的问题,
以及每个问题的答案。格式:
Q: 问题
A: 答案
 
文档内容:
${doc.content}`,
        },
      ],
    })
 
    // 解析 LLM 生成的 QA 对
    const qaPairs = parseQAPairs(response.content)
 
    for (const qa of qaPairs) {
      cases.push({
        question: qa.question,
        relevantChunkIds: [doc.id],  // 简化——实际需要匹配到具体 chunk
        groundTruthAnswer: qa.answer,
      })
    }
  }
 
  return cases
}

5. 持续评估

RAG 系统不是一次部署就完事了。数据在变化,模型在更新——需要持续评估。

评估流水线

// src/services/rag/eval-pipeline.ts
 
export class EvalPipeline {
  async run(config: {
    dataset: EvaluationCase[]
    searchConfig: SearchConfig
    promptTemplate: string
  }): Promise<EvalReport> {
    const { dataset, searchConfig, promptTemplate } = config
    const results: EvalResult[] = []
 
    for (const testCase of dataset) {
      // 1. 检索
      const queryVector = await embed(testCase.question)
      const searchResults = await vectorDB.search(queryVector, searchConfig)
 
      // 2. 检索质量指标
      const retrievedIds = searchResults.map((r) => r.id)
      const recall = recallAtK(retrievedIds, testCase.relevantChunkIds, 5)
 
      // 3. 生成回答
      const context = buildContext(searchResults)
      const answer = await llm.chat({
        messages: [
          { role: 'system', content: promptTemplate.replace('{{context}}', context) },
          { role: 'user', content: testCase.question },
        ],
      })
 
      // 4. 生成质量指标
      const faithfulness = await evaluateFaithfulness(answer, searchResults.map((r) => r.content))
      const relevance = await evaluateRelevance(testCase.question, answer)
 
      results.push({
        question: testCase.question,
        category: testCase.category,
        recall,
        faithfulness,
        relevance,
        retrievedIds,
        answer,
      })
    }
 
    return this.generateReport(results)
  }
 
  private generateReport(results: EvalResult[]): EvalReport {
    const avgRecall = results.reduce((sum, r) => sum + r.recall, 0) / results.length
    const avgFaithfulness = results.reduce((sum, r) => sum + r.faithfulness, 0) / results.length
    const avgRelevance = results.reduce((sum, r) => sum + r.relevance, 0) / results.length
 
    // 按类别分析
    const byCategory = groupBy(results, (r) => r.category ?? 'default')
    const categoryMetrics = Object.entries(byCategory).map(([category, cases]) => ({
      category,
      count: cases.length,
      avgRecall: cases.reduce((sum, r) => sum + r.recall, 0) / cases.length,
      avgFaithfulness: cases.reduce((sum, r) => sum + r.faithfulness, 0) / cases.length,
    }))
 
    return {
      summary: {
        totalCases: results.length,
        avgRecall,
        avgFaithfulness,
        avgRelevance,
      },
      byCategory: categoryMetrics,
      failures: results.filter((r) => r.recall < 0.5 || r.faithfulness < 0.7),
    }
  }
}

集成到 CI/CD

# .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 metrics threshold
        run: |
          # 如果 Recall 低于 0.8,构建失败
          node scripts/check-eval-threshold.js --metric recall --threshold 0.8

6. 评估的陷阱

  1. 评估数据集太小:10 个测试用例的评估结果没有统计意义——至少 50-100 个
  2. 过拟合评估集:不断调参直到评估分数好看,但实际用户体验没变好——需要保留独立的测试集
  3. 只看平均分数:平均 Recall 0.8 可能意味着 80% 的问题 Recall 1.0,20% 的问题 Recall 0——要看分布
  4. LLM-as-Judge 的偏差:LLM 评估器本身有偏好——对自己的风格更友好
  5. 忽略延迟和成本:检索质量提升了 5%,但延迟翻倍、成本翻三倍——需要综合评估

总结

回顾这一节的要点:

  • RAG 评估分两个维度:检索质量 + 生成质量
  • 检索指标:Recall@K(覆盖率)、MRR(排名)、NDCG(排序质量)、Hit Rate(兜底率)
  • 生成指标:忠实度(不编造)、相关性(不答偏)、完整性(不遗漏)
  • 评估数据集是基础——人工标注最准,LLM 辅助生成成本低
  • RAGAS 框架整合了常用指标
  • 持续评估比一次性评估更重要——集成到 CI/CD 定期跑
  • 评估的陷阱:数据集太小、过拟合、只看平均、忽略延迟成本

下一篇讲 RAG 接口设计——把前面所有的组件串起来,设计成 API。