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.86. 评估的陷阱
- 评估数据集太小:10 个测试用例的评估结果没有统计意义——至少 50-100 个
- 过拟合评估集:不断调参直到评估分数好看,但实际用户体验没变好——需要保留独立的测试集
- 只看平均分数:平均 Recall 0.8 可能意味着 80% 的问题 Recall 1.0,20% 的问题 Recall 0——要看分布
- LLM-as-Judge 的偏差:LLM 评估器本身有偏好——对自己的风格更友好
- 忽略延迟和成本:检索质量提升了 5%,但延迟翻倍、成本翻三倍——需要综合评估
总结
回顾这一节的要点:
- RAG 评估分两个维度:检索质量 + 生成质量
- 检索指标:Recall@K(覆盖率)、MRR(排名)、NDCG(排序质量)、Hit Rate(兜底率)
- 生成指标:忠实度(不编造)、相关性(不答偏)、完整性(不遗漏)
- 评估数据集是基础——人工标注最准,LLM 辅助生成成本低
- RAGAS 框架整合了常用指标
- 持续评估比一次性评估更重要——集成到 CI/CD 定期跑
- 评估的陷阱:数据集太小、过拟合、只看平均、忽略延迟成本
下一篇讲 RAG 接口设计——把前面所有的组件串起来,设计成 API。