RAG 评测指标体系:准确率之外,还要看召回率和延迟分布
RAG 评测指标体系准确率之外还要看召回率和延迟分布一、老板问准确率多少你答 85%但用户投诉说搜出来的东西不相关准确率是最容易理解的指标但也是最不全面的。我们团队上线知识库问答系统时踩过这个坑。老板问准确率多少我跑了一批测试集答了 85%老板满意地点头。上线第一周就收到用户投诉搜出来的文档完全不对。排查后发现85% 的准确率是指生成的答案和参考答案匹配但用户问的问题在评测集里根本没有覆盖。更严重的是检索召回率只有 42%——超过一半的相关文档根本没被检索到模型在用错误的上下文生成答案偶尔蒙对了就算准确。一个 RAG 系统的质量涉及多个维度只看准确率是盲人摸象。回答准确但检索延迟 8 秒用户等不了。检索结果相关但答案不忠实于原文这是幻觉——模型用自己的知识补全了检索结果中没有的信息。看起来答对了但引用的来源是错的合规场景下这比答错还危险。RAG 评测需要至少五个维度的指标。准确率看答案对不对。召回率看相关文档有没有被找到。忠实度看答案是否依据了检索到的文档。相关性看检索结果和问题的匹配度。延迟看用户体验能不能接受。五个维度缺一个都可能在生产环境中翻车。二、RAG 评测指标体系全景RAG 评测分三大类检索质量、生成质量、系统性能。检索质量是 RAG 的地基。如果检索阶段就没找到正确的文档后面的生成再好也是在错误的基础上编故事。检索质量看四个指标RecallK前 K 个结果中包含了多少比例的相关文档、PrecisionK前 K 个结果中有多少是相关的、MRR第一个相关文档的排名倒数反映用户需要翻几页才能找到答案、NDCG考虑位置权重的相关性评分排名越靠前权重越大。我们实践中最看重 Recall5因为大多数 RAG 系统只取 top-5 文档喂给模型如果相关文档不在这 5 个里面模型根本看不到。生成质量是 RAG 的上层建筑。忠实度Faithfulness是最关键的指标——答案中的每个声明是否都能在检索到的上下文中找到支撑。忠实度低意味着模型在自由发挥这在企业知识库场景是不可接受的。相关性Relevance看回答是否切题。幻觉率Hallucination Rate是忠实度的反面衡量答案中无法被上下文证实的比例。系统性能决定用户体验。P50/P95/P99 延迟分布比平均延迟更有意义——平均延迟 2 秒可能掩盖了 P99 延迟 10 秒的问题。首 Token 延迟TTFT影响用户感知的开始响应速度。Token 生成速率影响长答案的等待时间。端到端耗时是用户从提问到收到完整答案的总时间。flowchart TB A[RAG 评测] -- B[检索质量] A -- C[生成质量] A -- D[系统性能] B -- B1[召回率 RecallK] B -- B2[精确率 PrecisionK] B -- B3[MRR 平均倒数排名] B -- B4[NDCG 归一化折损累计增益] C -- C1[答案准确率] C -- C2[忠实度 Faithfulness] C -- C3[相关性 Relevance] C -- C4[幻觉率 Hallucination Rate] D -- D1[P50/P95/P99 延迟] D -- D2[首 Token 延迟 TTFT] D -- D3[Token 生成速率] D -- D4[端到端耗时]三、评测框架的 Go 实现下面是我们生产环境使用的 RAG 评测框架核心实现用 Go 编写。选择 Go 而非 Python 的原因是评测任务需要并发跑大量测试用例Go 的 goroutine 天然适合。package main import ( fmt sort strings time ) // TestCase 单条评测用例 type TestCase struct { Query string // 用户问题 ExpectedAnswer string // 参考答案 RelevantDocIDs []string // 标注的相关文档ID Category string // 问题分类 } // RetrievalResult 检索结果 type RetrievalResult struct { DocIDs []string Scores []float64 LatencyMs float64 } // GenerationResult 生成结果 type GenerationResult struct { Answer string Contexts []string TokenCount int LatencyMs float64 } // RAGEvaluator RAG 评测器 type RAGEvaluator struct{} func NewRAGEvaluator() *RAGEvaluator { return RAGEvaluator{} } // EvaluateRetrieval 评估检索质量 func (e *RAGEvaluator) EvaluateRetrieval(tc TestCase, ret RetrievalResult) map[string]float64 { relevant : make(map[string]bool) for _, id : range tc.RelevantDocIDs { relevant[id] true } metrics : make(map[string]float64) // RecallK 和 PrecisionK for _, k : range []int{1, 3, 5, 10} { if k len(ret.DocIDs) { k len(ret.DocIDs) } topK : ret.DocIDs[:k] hits : 0 for _, id : range topK { if relevant[id] { hits } } recall : float64(hits) / float64(len(tc.RelevantDocIDs)) precision : float64(hits) / float64(k) metrics[fmt.Sprintf(recall%d, k)] recall metrics[fmt.Sprintf(precision%d, k)] precision } // MRR: 第一个相关文档的排名倒数 for rank, id : range ret.DocIDs { if relevant[id] { metrics[mrr] 1.0 / float64(rank1) break } } if _, ok : metrics[mrr]; !ok { metrics[mrr] 0.0 } metrics[retrieval_latency_ms] ret.LatencyMs return metrics } // EvaluateFaithfulness 评估答案忠实度 // 检查答案中的关键声明是否有上下文支撑 func (e *RAGEvaluator) EvaluateFaithfulness(answer string, contexts []string) float64 { if len(contexts) 0 || answer { return 0.0 } sentences : strings.Split(answer, 。) var validSentences []string for _, s : range sentences { s strings.TrimSpace(s) if len(s) 0 { validSentences append(validSentences, s) } } if len(validSentences) 0 { return 1.0 } contextText : strings.ToLower(strings.Join(contexts, )) faithfulCount : 0 for _, sentence : range validSentences { words : strings.Fields(sentence) if len(words) 3 { faithfulCount continue } // 检查句子中至少 50% 的关键词在上下文中 keyTerms : []string{} for _, w : range words { if len([]rune(w)) 1 { keyTerms append(keyTerms, strings.ToLower(w)) } } if len(keyTerms) 0 { faithfulCount continue } matches : 0 for _, term : range keyTerms { if strings.Contains(contextText, term) { matches } } if float64(matches)/float64(len(keyTerms)) 0.5 { faithfulCount } } return float64(faithfulCount) / float64(len(validSentences)) } // EvaluateRelevance 评估回答相关性 func (e *RAGEvaluator) EvaluateRelevance(answer, query string) float64 { queryTerms : strings.Fields(strings.ToLower(query)) answerLower : strings.ToLower(answer) if len(queryTerms) 0 { return 0.0 } overlap : 0 for _, term : range queryTerms { if strings.Contains(answerLower, term) { overlap } } return float64(overlap) / float64(len(queryTerms)) } // LatencyDistribution 延迟分布 type LatencyDistribution struct { P50 float64 P90 float64 P99 float64 } func CalcLatencyDistribution(latencies []float64) LatencyDistribution { if len(latencies) 0 { return LatencyDistribution{} } sort.Float64s(latencies) n : len(latencies) return LatencyDistribution{ P50: latencies[n*50/100], P90: latencies[n*90/100], P99: latencies[min(n*99/100, n-1)], } } func min(a, b int) int { if a b { return a } return b } // EvalSummary 评测汇总 type EvalSummary struct { RecallAt5 float64 MRR float64 Faithfulness float64 Relevance float64 AnswerCorrect float64 Latency LatencyDistribution TotalCases int } // RunEvaluation 运行完整评测 func (e *RAGEvaluator) RunEvaluation( testCases []TestCase, retrieverFn func(string) RetrievalResult, generatorFn func(string, []string) GenerationResult, ) EvalSummary { var recalls, mrrs, faithfuls, relevances []float64 var correctCount int var latencies []float64 for i, tc : range testCases { fmt.Printf(评测 %d/%d: %s\n, i1, len(testCases), tc.Query[:min(50, len(tc.Query))]) t0 : time.Now() ret : retrieverFn(tc.Query) ret.LatencyMs float64(time.Since(t0).Milliseconds()) t0 time.Now() gen : generatorFn(tc.Query, ret.DocIDs) gen.LatencyMs float64(time.Since(t0).Milliseconds()) retMetrics : e.EvaluateRetrieval(tc, ret) faith : e.EvaluateFaithfulness(gen.Answer, gen.Contexts) relev : e.EvaluateRelevance(gen.Answer, tc.Query) // 简化准确性判断 correct : strings.Contains( strings.ToLower(gen.Answer), strings.ToLower(tc.ExpectedAnswer), ) if correct { correctCount } recalls append(recalls, retMetrics[recall5]) mrrs append(mrrs, retMetrics[mrr]) faithfuls append(faithfuls, faith) relevances append(relevances, relev) latencies append(latencies, ret.LatencyMsgen.LatencyMs) } n : float64(len(testCases)) return EvalSummary{ RecallAt5: avg(recalls) / n, MRR: avg(mrrs) / n, Faithfulness: avg(faithfuls) / n, Relevance: avg(relevances) / n, AnswerCorrect: float64(correctCount) / n, Latency: CalcLatencyDistribution(latencies), TotalCases: len(testCases), } } func avg(vals []float64) float64 { sum : 0.0 for _, v : range vals { sum v } return sum } func main() { evaluator : NewRAGEvaluator() // 示例测试集生产环境应从标注文件加载 testCases : []TestCase{ { Query: Go 语言的 GMP 调度模型是什么, ExpectedAnswer: GMP 是 Go 运行时的调度模型, RelevantDocIDs: []string{doc-001, doc-005}, Category: 技术, }, { Query: RAG 系统如何评估检索质量, ExpectedAnswer: 通过 RecallK 和 MRR 评估, RelevantDocIDs: []string{doc-010, doc-012, doc-015}, Category: AI, }, } // 模拟检索和生成函数 retriever : func(query string) RetrievalResult { return RetrievalResult{ DocIDs: []string{doc-001, doc-003, doc-005}, Scores: []float64{0.95, 0.82, 0.78}, } } generator : func(query string, docIDs []string) GenerationResult { return GenerationResult{ Answer: GMP 是 Go 运行时的调度模型G 代表 goroutine。, Contexts: []string{GMP 调度模型是 Go 运行时的核心机制}, TokenCount: 50, } } summary : evaluator.RunEvaluation(testCases, retriever, generator) fmt.Printf(\n RAG 评测报告 \n) fmt.Printf(测试用例: %d 条\n, summary.TotalCases) fmt.Printf(Recall5: %.1f%%\n, summary.RecallAt5*100) fmt.Printf(MRR: %.3f\n, summary.MRR) fmt.Printf(忠实度: %.1f%%\n, summary.Faithfulness*100) fmt.Printf(相关性: %.1f%%\n, summary.Relevance*100) fmt.Printf(准确率: %.1f%%\n, summary.AnswerCorrect*100) fmt.Printf(延迟 P50: %.0fms\n, summary.Latency.P50) fmt.Printf(延迟 P90: %.0fms\n, summary.Latency.P90) fmt.Printf(延迟 P99: %.0fms\n, summary.Latency.P99) }四、评测数据集的构建评测数据集是 RAG 评测的基石没有好的数据集评测结果毫无意义。评测集至少需要 50 条问答对来源是用户真实提问加人工标注相关文档。我们从生产环境的搜索日志里筛出高频问题人工标注每个问题对应的相关文档。标注成本约 15 分钟/条50 条约需 2 人天。这是一次性投资但价值极高。每次 Prompt 或检索策略改动跑一次评测就能量化效果变化。构建评测集时要注意类别覆盖。我们的评测集按问题类型分为四类事实型公司的退款政策是什么、推理型A 方案和 B 方案哪个更适合 100 人团队、对比型Go 和 Rust 在并发处理上的区别、开放型如何设计一个高可用架构。不同类型的检索和生成难度不同混在一起看平均值会掩盖某些类型的严重问题。踩坑提醒评测集不要只用简单问题。我们最初的 50 条评测集里有 35 条是事实型问题系统准确率 90%。后来加了 20 条推理型问题准确率直接跌到 65%。简单问题的好成绩是假象。五、总结RAG 评测需要多维度指标体系单一准确率指标会严重误导决策。检索质量看 RecallK 和 MRRRecall5 是最关键的——相关文档不在 top-5 里模型就看不到。生成质量看 Faithfulness 和 Relevance忠实度低意味着模型在幻觉。系统性能看 P50/P90/P99 延迟分布平均延迟掩盖长尾问题。评测数据集至少 50 条标注的问答对覆盖事实型、推理型、对比型、开放型四类问题。每个版本改动后都应跑全量评测用数据驱动优化决策而非凭感觉调参。没有评测体系的 RAG 优化是盲人摸象。先花 2 人天建评测集之后每次改动的效果都可量化、可对比、可追溯。这笔投入的 ROI 远高于任何单一优化技巧。