目录
一、为什么得先有尺子
前四篇里,每一次优化都是这样验证的:跑几个查询,看看结果,觉得「好像准了点」。
这个做法有个致命缺陷——它无法区分「真的变好了」和「我看到的这几个例子恰好变好了」。 更糟的是,当你投入了半天时间实现 Rerank,你的判断天然倾向于确认它有效。这不是不诚实,是人正常的认知偏差。
把评估做起来,本质上是把「我觉得不错」替换成「在一组固定的问题上,指标从 A 变成了 B」。它带来三件具体的好处:
- 优化有方向:知道是召回不够还是排序不准,才不会瞎调参数。
- 改动有护栏:调 Prompt 修好了这个问题,有没有弄坏另一个问题,回归测试会告诉你。
- 结论可复现:换个人、过一个月,跑同一套评估得到同一组数字。
评估要分成两层来做,因为它们的失败模式完全不同:
| 层面 | 评的是 | 典型的失败 | 衡量方式 |
|---|---|---|---|
| 检索 | 相关文档有没有被捞进 Top-K | 正确文档压根没召回,模型无机可用 | Recall@K、Precision@K、MRR |
| 生成 | 回答是否忠于材料、是否切题 | 材料在手,模型还是编 | Faithfulness、Answer Relevancy |
分层是必须的。 如果只有一个笼统的「准确率」,当它下降时你完全不知道是哪一层坏了——是检索退化了,还是模型变懒了。
二、检索指标
检索指标的好处是可以精确计算,不依赖任何模型判断。因为「哪些文档是相关的」这件事,在测试集里是人工标注好的。
2.1 Recall@K
Recall@K = |Top-K 结果 ∩ 相关文档| / |全部相关文档|
它回答的问题是:该找的,找回来多少。
打个捕鱼的比方:水里有 10 条你想要的目标鱼,一网下去捞上来 8 条,召回率就是 0.8。注意分母是「水里总共有多少条鱼」,而不是「你捞上来多少条」。
这里有个容易踩的坑:Recall@K 的分母是全部相关文档数,所以当 K 小于相关文档总数时,Recall@K 存在一个无法突破的上限。
相关文档共 10 篇,只看 Top-5:
最多只能捞到 5 篇 → Recall@5 ≤ 5/10 = 0.5
也就是说,看到 Recall@5 = 0.67 而相关文档有 10 篇时,可以直接断定这个数字算错了——它在数学上不可能出现。这不是吹毛求疵,评估代码里分母取错(比如误用 K 当分母)是常见 bug,而它会静默地给你一个偏低的分数,让你去优化一个根本不存在的问题。
2.2 Precision@K
Precision@K = |Top-K 结果 ∩ 相关文档| / K
它回答的问题是:捞上来的,有多少是真正要的。
分母是 K——你交出去的结果条数。这次捞上 8 条鱼加 2 只虾,精确率就是 0.8。
Recall 和 Precision 天然互相拉扯:想提高召回,就把 K 调大,但精确率会掉;想提高精确率,就把门槛抬高,召回又会掉。
在 RAG 里,两者不对称。 漏掉相关文档(低 Recall)模型就失去了回答依据,后果严重且不可挽回;多进来几条不相关文档(低 Precision)还有精排和 Prompt 指令兜底。所以优先保 Recall——这也正是粗排-精排架构成立的理由:粗排放宽保召回,精排收紧保精确。
2.3 MRR
RR = 1 / 第一篇相关文档的排名
MRR = 所有查询 RR 的平均值
它衡量的是:第一篇有用的结果排在第几位。
相关文档排第 1 位 → RR = 1/1 = 1.0
相关文档排第 2 位 → RR = 1/2 = 0.5
相关文档排第 5 位 → RR = 1/5 = 0.2
只看第一篇命中,后面的不管——这不是偷懒,而是因为 RAG 的实际消费方式是「把 Top-K 塞进 Prompt」。 排在第 1 位和第 5 位,对模型的注意力影响是真实存在的(这也是第五篇延伸阅读里那篇 Lost in the Middle 的主题)。MRR 用一个小数概括了「好东西来得够不够早」。
MRR 的局限也要清楚:它完全忽略第二篇及之后的相关文档。如果你的场景是「需要综合多篇材料回答」,MRR 会低估实际效果,这时更适合 NDCG 或 MAP。
三、生成指标
检索指标能精确算,生成指标不行。
原因在于「回答是否忠于材料」这个判断,本质上是自然语言理解任务——它没有可以代入公式的答案。业界主流做法是用 LLM 当裁判:把回答拆成若干条独立陈述,逐条判断能否由检索到的材料支持,能支持的占比就是 Faithfulness。
先说清楚一件事:本节的示例代码里,这两个生成指标是简化实现,不是 RAGAS,也不是 LLM 裁判。 它们的定位是「把评估流程先搭通」,不是「拿去汇报的数字」。下面逐个说明它们在做什么、以及为什么不能当真。
3.1 Faithfulness(忠实度)
真正的 Faithfulness 回答的是:回答里的每一句话,是否都能在检索到的材料里找到依据。
问题:"年假有多少天?"
材料:"员工入职满一年后,年假 10 天。"
回答 A:"年假是 10 天。" → 忠实,完全有依据
回答 B:"年假是 15 天。" → 不忠实,数字与材料冲突
回答 C:"年假是 10 天,并且公司提供免费午餐。" → 部分不忠实,后半句是编的
示例代码里的实现是:抽取回答中出现的所有数字,检查这些数字是否也出现在检索材料中,用命中比例作为分数;如果回答里一个数字都没有,直接返回硬编码的 0.5。
这个实现能抓住「回答 B」这类数字冲突——这是它唯一的用处,而且确实覆盖了 RAG 里最常见的幻觉形态(乱报数字)。但它有三个明显的空洞:
- 只查数字。 回答 C 里那句「提供免费午餐」,一个数字都不涉及,完全不会被发现。
- 无数字的回答一律 0.5。 一个纯文字编造的回答,和白卷一样拿到 0.5。这个值不代表任何含义。
- 一个数字命中就是 1.0。 回答里其余全是编的,只要那个数字对,忠实度满分。
结论:这个分数只能用作「有没有数字级冲突」的信号,绝不能作为 Faithfulness 对外引用。 要拿真指标,得接 RAGAS 或自己写一个 LLM 裁判。
3.2 Answer Relevancy(回答相关性)
RAGAS 里的 Answer Relevancy 是这么算的:让 LLM 根据生成的回答反推「这段话大概在回答什么问题」,再算这个反推问题跟原始问题的相似度。 回答越切题,反推出来的问题就越接近原问题。
而示例代码里实现的是另一个东西:回答与「期望答案」之间的相似度。这更接近「答案正确性」或「与参考答案的一致度」,而不是「回答切不切题」。名字和实际语义对不上,是看这类评估代码时最容易误解的地方。
更要注意的是它的相似度算法——按空白字符切词,然后算 Jaccard 相似度:
Set<String> expectedWords = new HashSet<>(Arrays.asList(expected.split("\\s+")));
Set<String> actualWords = new HashSet<>(Arrays.asList(actual.split("\\s+")));
对中文来说这个切分是失效的,因为中文句子内部没有空格。"年假是10天" 经 split("\\s+") 之后得到的是一个只含整句话的单元素集合,而 "年假为10天" 得到的是另一个单元素集合。两个集合的交集为空,Jaccard = 0。
也就是说,这个指标对中文实际上退化成二值判断:两句话一字不差得 1,否则得 0。 它在英文文本上还能勉强工作,在中文上基本没有分辨力。
这不是在挑刺——它恰恰是「评估代码本身也需要被验证」的典型案例。一个指标算错了不会有任何报错,它只会安静地给出一个看起来像那么回事的数字,然后你把优化方向建在这个数字上。
四、测试集怎么建
测试集是评估的地基,它的质量决定了所有指标的可信度。每个用例需要三样东西:问题、期望答案、以及相关文档的 ID 列表。
最后一项是关键,也是最容易被省掉的。没有它,检索指标就算不出来——Recall 的分母、MRR 的排名,全都依赖「哪些文档算是相关的」这个人工标注。
标注相关文档时有几个实际经验:
- 相关性是分级的。 一篇直接回答了问题的文档,和一篇只是提到了相关概念的文档,不该被同等对待。哪怕先只标「强相关」和「弱相关」两档,也比一刀切好。
- 相关文档往往不止一篇。 只标一篇会导致 Recall 的分母偏小、指标虚高。把材料里所有能支撑答案的片段都标上。
- 一定要包含「材料里没有答案」的用例。 这类问题的正确行为是承认「资料不足」。如果测试集里全是能答上来的问题,你永远测不出模型会不会编。
覆盖面上,至少要包含这四种:
| 类型 | 例子 | 主要考察 |
|---|---|---|
| 直接事实 | 入职 3 年年假多少天 | 基础链路是否通 |
| 换个说法 | 带薪休假能休几天 | 语义召回能力 |
| 专有名词 | GPT-4 的上下文窗口多大 | 关键词召回能力(第三篇的靶子) |
| 材料无答案 | 公司年会在哪里办 | 会不会编(Faithfulness 的靶子) |
规模上,几十条就能看出趋势,不必追求上千条。但要注意:样本量小的时候,单条用例的翻转对均值的扰动很大。所以评估结果要连同「原始逐条结果」一起看,不要只看汇总的均值和总分。
五、完整代码
5.1 评估模型
EvaluationResult.java
package com.rag.evaluation;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* 评估结果
*/
@Data
@Builder
public class EvaluationResult {
/**
* 查询问题
*/
private String question;
/**
* 期望的答案
*/
private String expectedAnswer;
/**
* 实际生成的答案
*/
private String actualAnswer;
/**
* 检索到的文档 ID 列表
*/
private List<Long> retrievedDocIds;
/**
* 相关的文档 ID 列表(标准答案)
*/
private List<Long> relevantDocIds;
/**
* 检索指标
*/
private RetrievalMetrics retrievalMetrics;
/**
* 生成指标
*/
private GenerationMetrics generationMetrics;
}
/**
* 检索指标
*/
@Data
@Builder
public class RetrievalMetrics {
/**
* Recall@K
*/
private double recallAtK;
/**
* Precision@K
*/
private double precisionAtK;
/**
* MRR(平均倒数排名)
*/
private double mrr;
}
/**
* 生成指标
*/
@Data
@Builder
public class GenerationMetrics {
/**
* Faithfulness(忠实度)
* 回答是否基于检索到的文档
*/
private double faithfulness;
/**
* Answer Relevancy(回答相关性)
* 回答是否回答了问题
*/
private double answerRelevancy;
}
5.2 评估服务
EvaluationService.java
package com.rag.evaluation;
import com.rag.model.Document;
import com.rag.service.EmbeddingService;
import com.rag.service.RagService;
import com.rag.service.VectorStoreService;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* RAG 评估服务
*
* 职责:自动化评估 RAG 系统的检索和生成质量
*
* 类比:考试阅卷老师
* - 准备标准答案(测试集)
* - 让学生答题(RAG 系统回答)
* - 对比答案,打分
*/
@Slf4j
@Service
public class EvaluationService {
private final RagService ragService;
private final VectorStoreService vectorStoreService;
private final EmbeddingService embeddingService;
public EvaluationService(RagService ragService,
VectorStoreService vectorStoreService,
EmbeddingService embeddingService) {
this.ragService = ragService;
this.vectorStoreService = vectorStoreService;
this.embeddingService = embeddingService;
}
/**
* 运行评估
*
* @param testCases 测试用例
* @return 评估结果列表
*/
public List<EvaluationResult> evaluate(List<TestCase> testCases) {
log.info("开始评估,测试用例数量: {}", testCases.size());
List<EvaluationResult> results = new ArrayList<>();
for (TestCase testCase : testCases) {
try {
EvaluationResult result = evaluateSingle(testCase);
results.add(result);
log.info("评估完成: {} - Recall@5: {}, Precision@5: {}",
testCase.getQuestion(),
result.getRetrievalMetrics().getRecallAtK(),
result.getRetrievalMetrics().getPrecisionAtK());
} catch (Exception e) {
log.error("评估失败: {}", testCase.getQuestion(), e);
}
}
log.info("评估完成,成功: {}/{}", results.size(), testCases.size());
return results;
}
/**
* 评估单个测试用例
*/
private EvaluationResult evaluateSingle(TestCase testCase) {
// 第一步:获取 RAG 系统的回答
String actualAnswer = ragService.ask(testCase.getQuestion());
// 第二步:获取检索到的文档
float[] queryEmbedding = embeddingService.embed(testCase.getQuestion());
List<Document> retrievedDocs = vectorStoreService.searchByVector(queryEmbedding, 5);
List<Long> retrievedDocIds = retrievedDocs.stream()
.map(Document::getId)
.collect(Collectors.toList());
// 第三步:计算检索指标
RetrievalMetrics retrievalMetrics = calculateRetrievalMetrics(
retrievedDocIds, testCase.getRelevantDocIds());
// 第四步:计算生成指标
GenerationMetrics generationMetrics = calculateGenerationMetrics(
testCase.getQuestion(), testCase.getExpectedAnswer(),
actualAnswer, retrievedDocs);
return EvaluationResult.builder()
.question(testCase.getQuestion())
.expectedAnswer(testCase.getExpectedAnswer())
.actualAnswer(actualAnswer)
.retrievedDocIds(retrievedDocIds)
.relevantDocIds(testCase.getRelevantDocIds())
.retrievalMetrics(retrievalMetrics)
.generationMetrics(generationMetrics)
.build();
}
/**
* 计算检索指标
*/
private RetrievalMetrics calculateRetrievalMetrics(List<Long> retrievedIds,
List<Long> relevantIds) {
// 转换为 Set,方便查找
Set<Long> retrievedSet = new HashSet<>(retrievedIds);
Set<Long> relevantSet = new HashSet<>(relevantIds);
// 计算 Recall@K
// Recall = 检索到的相关文档数 / 总相关文档数
long hitCount = retrievedSet.stream()
.filter(relevantSet::contains)
.count();
double recall = relevantSet.isEmpty() ? 0 : (double) hitCount / relevantSet.size();
// 计算 Precision@K
// Precision = 检索到的相关文档数 / 检索到的文档总数
double precision = retrievedSet.isEmpty() ? 0 : (double) hitCount / retrievedSet.size();
// 计算 MRR
// MRR = 1 / 第一个相关文档的排名
double mrr = 0;
for (int i = 0; i < retrievedIds.size(); i++) {
if (relevantSet.contains(retrievedIds.get(i))) {
mrr = 1.0 / (i + 1);
break;
}
}
return RetrievalMetrics.builder()
.recallAtK(recall)
.precisionAtK(precision)
.mrr(mrr)
.build();
}
/**
* 计算生成指标
*
* 简化实现:使用字符串相似度作为近似
* 生产环境建议使用 LLM 进行更精确的评估
*/
private GenerationMetrics calculateGenerationMetrics(String question,
String expectedAnswer,
String actualAnswer,
List<Document> retrievedDocs) {
// 计算 Faithfulness(简化版)
// 检查回答是否包含检索到的文档中的关键信息
double faithfulness = calculateFaithfulness(actualAnswer, retrievedDocs);
// 计算 Answer Relevancy(简化版)
// 检查回答和期望答案的相似度
double answerRelevancy = calculateAnswerRelevancy(expectedAnswer, actualAnswer);
return GenerationMetrics.builder()
.faithfulness(faithfulness)
.answerRelevancy(answerRelevancy)
.build();
}
/**
* 计算 Faithfulness(忠实度)
*
* 简化实现:检查回答中的关键信息是否出现在检索到的文档中
*/
private double calculateFaithfulness(String answer, List<Document> retrievedDocs) {
if (answer == null || answer.isEmpty()) {
return 0;
}
// 合并所有检索到的文档内容
String allContent = retrievedDocs.stream()
.map(Document::getContent)
.collect(Collectors.joining(" "));
// 简单实现:检查回答中的数字是否出现在文档中
// 生产环境应该使用 LLM 进行更精确的判断
List<String> numbersInAnswer = extractNumbers(answer);
List<String> numbersInDocs = extractNumbers(allContent);
if (numbersInAnswer.isEmpty()) {
return 0.5; // 如果回答中没有数字,给一个中间分数
}
long matchCount = numbersInAnswer.stream()
.filter(numbersInDocs::contains)
.count();
return (double) matchCount / numbersInAnswer.size();
}
/**
* 计算 Answer Relevancy(回答相关性)
*
* 简化实现:使用字符串相似度
*/
private double calculateAnswerRelevancy(String expected, String actual) {
if (expected == null || actual == null) {
return 0;
}
// 简单的 Jaccard 相似度
Set<String> expectedWords = new HashSet<>(Arrays.asList(expected.split("\\s+")));
Set<String> actualWords = new HashSet<>(Arrays.asList(actual.split("\\s+")));
Set<String> intersection = new HashSet<>(expectedWords);
intersection.retainAll(actualWords);
Set<String> union = new HashSet<>(expectedWords);
union.addAll(actualWords);
return union.isEmpty() ? 0 : (double) intersection.size() / union.size();
}
/**
* 提取字符串中的数字
*/
private List<String> extractNumbers(String text) {
List<String> numbers = new ArrayList<>();
java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("\\d+").matcher(text);
while (matcher.find()) {
numbers.add(matcher.group());
}
return numbers;
}
/**
* 计算汇总统计
*/
public EvaluationSummary summarize(List<EvaluationResult> results) {
if (results.isEmpty()) {
return EvaluationSummary.builder().build();
}
double avgRecall = results.stream()
.mapToDouble(r -> r.getRetrievalMetrics().getRecallAtK())
.average().orElse(0);
double avgPrecision = results.stream()
.mapToDouble(r -> r.getRetrievalMetrics().getPrecisionAtK())
.average().orElse(0);
double avgMrr = results.stream()
.mapToDouble(r -> r.getRetrievalMetrics().getMrr())
.average().orElse(0);
double avgFaithfulness = results.stream()
.mapToDouble(r -> r.getGenerationMetrics().getFaithfulness())
.average().orElse(0);
double avgAnswerRelevancy = results.stream()
.mapToDouble(r -> r.getGenerationMetrics().getAnswerRelevancy())
.average().orElse(0);
return EvaluationSummary.builder()
.totalQuestions(results.size())
.avgRecallAtK(avgRecall)
.avgPrecisionAtK(avgPrecision)
.avgMrr(avgMrr)
.avgFaithfulness(avgFaithfulness)
.avgAnswerRelevancy(avgAnswerRelevancy)
.build();
}
// ========== 内部类 ==========
@Data
public static class TestCase {
private String question;
private String expectedAnswer;
private List<Long> relevantDocIds;
}
@Data
@Builder
public static class EvaluationSummary {
@Builder.Default
private int totalQuestions = 0;
@Builder.Default
private double avgRecallAtK = 0;
@Builder.Default
private double avgPrecisionAtK = 0;
@Builder.Default
private double avgMrr = 0;
@Builder.Default
private double avgFaithfulness = 0;
@Builder.Default
private double avgAnswerRelevancy = 0;
}
}
这个类是整篇的核心。读它的时候请对照上面两节的说明:calculateRetrievalMetrics 是精确的集合运算,可以信任;calculateGenerationMetrics 及其下游两个方法是占位实现,理解流程即可,不要引用它的数值。
还有一处实现细节值得称赞:代码里明确写了「生产环境应该使用 LLM 进行更精确的判断」。把简化实现和它的适用边界写在注释里,是比假装它很准更负责的做法——接手的人不会误用。
5.3 评估控制器
EvaluationController.java
package com.rag.evaluation;
import com.rag.evaluation.EvaluationService.EvaluationSummary;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 评估 API 控制器
*/
@Slf4j
@RestController
@RequestMapping("/api/evaluation")
public class EvaluationController {
private final EvaluationService evaluationService;
public EvaluationController(EvaluationService evaluationService) {
this.evaluationService = evaluationService;
}
/**
* 运行评估
*/
@PostMapping("/run")
public ResponseEntity<Map<String, Object>> runEvaluation(@RequestBody EvaluationRequest request) {
log.info("收到评估请求,测试用例数量: {}", request.getTestCases().size());
try {
// 运行评估
List<EvaluationResult> results = evaluationService.evaluate(request.getTestCases());
// 计算汇总
EvaluationSummary summary = evaluationService.summarize(results);
// 返回结果
return ResponseEntity.ok(Map.of(
"success", true,
"summary", summary,
"details", results.stream().map(r -> Map.of(
"question", r.getQuestion(),
"expectedAnswer", r.getExpectedAnswer(),
"actualAnswer", r.getActualAnswer(),
"recall", r.getRetrievalMetrics().getRecallAtK(),
"precision", r.getRetrievalMetrics().getPrecisionAtK(),
"faithfulness", r.getGenerationMetrics().getFaithfulness()
)).collect(Collectors.toList())
));
} catch (Exception e) {
log.error("评估失败", e);
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "评估失败: " + e.getMessage()
));
}
}
@Data
public static class EvaluationRequest {
private List<EvaluationService.TestCase> testCases;
}
}
六、跑一次评估
6.1 准备测试集
测试集 JSON 格式
[
{
"question": "入职3年能休多少天年假?",
"expected_answer": "入职满一年后,年假10天。",
"relevant_doc_ids": [1, 3]
},
{
"question": "病假需要什么手续?",
"expected_answer": "病假需提供医院证明,3天以内由直属领导批准。",
"relevant_doc_ids": [5]
}
]
一份可用的测试集
{
"testCases": [
{
"question": "入职3年能休多少天年假?",
"expectedAnswer": "入职满一年后,可享受带薪年假10天。",
"relevantDocIds": [1]
},
{
"question": "病假需要什么手续?",
"expectedAnswer": "病假需提供医院证明,3天以内由直属领导批准。",
"relevantDocIds": [2]
},
{
"question": "年假可以累积到明年吗?",
"expectedAnswer": "年假不可跨年累积,未使用的年假将在年底清零。",
"relevantDocIds": [3]
}
]
}
6.2 运行评估
# 运行评估
curl -X POST http://localhost:8080/api/evaluation/run \
-H "Content-Type: application/json" \
-d @test-cases.json
6.3 分析结果
输出示例
{
"success": true,
"summary": {
"totalQuestions": 3,
"avgRecallAtK": 0.67,
"avgPrecisionAtK": 0.33,
"avgMrr": 0.83,
"avgFaithfulness": 0.85,
"avgAnswerRelevancy": 0.72
},
"details": [...]
}
下面这段解读里的数字只是示例输出,不是任何一次真实运行的结果,请勿引用:
Recall@5 = 0.67:Top-5 命中的相关文档占全部相关文档的 67%
Precision@5 = 0.33:Top-5 里有约 1.7 条是相关的
MRR = 0.83:第一篇相关文档平均排在第 1.2 位
Faithfulness = 0.85:简化的数字比对实现给出的分数(见 3.1,不可当真)
Answer Relevancy = 0.72:简化的 Jaccard 实现给出的分数(见 3.2,中文下无效)
注意这份示例数据本身是自相矛盾的:它标注的相关文档有 10 篇,却给出 Recall@5 = 0.67。按 2.1 推导,相关文档 10 篇、K = 5 时 Recall@5 最大只能到 0.5,0.67 不可能出现。这正好说明为什么要把「K 与相关文档数的关系」在脑子里过一遍——评估代码算错了不会报错,只会给你一个错误的优化方向。
拿到结果的正确读法,是去看那些「翻转的样本」:
- Recall 低 → 检索层的问题。先看是分词没生效(第三篇),还是 Embedding 对这类查询不敏感,还是切分把答案切碎了。
- Recall 高但 MRR 低 → 找得到但排得后。这正是 Rerank 该解决的问题(第四篇)。
- Recall 高、MRR 高,但 Faithfulness 低 → 检索没问题,是 Prompt 或模型的问题。回去检查 system 指令里有没有写「资料不足就说不知道」。
指标是分诊工具,不是考试分数。 它的用途是把你指向正确的那一层,而不是告诉你「你考了 85 分」。
七、常见误区
❌「评估器写好了,指标就可信了」 评估代码自己就是个程序,它也会算错,而且不会抛异常。分母取错、除零保护返回 0 被误读成「效果差」、中文分词失效——这些都会静默地产出一个数字。所以评估体系上线前,必须拿几个手工算得出答案的用例去校验评估器本身:两个已知完全无关的答案,相似度指标应该接近 0;三个已知的 Top-5 结果,Recall 应该能手算对得上。今天这篇里那两个生成指标的问题,就是靠这种方式才能发现的。
❌「Recall@K 可以跨 K 值比较」 Recall@5 和 Recall@10 不是同一个指标,前者天然更低(因为分母里那些没进 Top-5 的文档注定算不中)。报告指标时必须带上 K,说「Recall 是 0.8」而不说是 @5 还是 @10,等于没说。这也意味着「把 K 从 5 调到 10,Recall 涨了」不是优化成果,只是换了刻度。
❌「只用 LLM 当裁判,不用人工看」 LLM 裁判有明确的系统性偏差:倾向给冗长的回答高分、倾向给和自己风格接近的回答高分、对同一份材料换个顺序可能给出不同判断。 它的正确用法是处理规模(人工看 500 条不现实),但必须定期抽查——尤其是指标突然大幅变动的那一批样本,逐条人工看一眼。指标是仪表盘,不是最终判决。
❌「测试集建一次就够了」 测试集会过期。文档更新了、切分策略改了、Prompt 模板换了,旧测试集里的期望答案和相关文档标注可能全部失效,但指标照算不误,还会给你一种「系统很稳定」的错觉。文档一改,测试集必须跟着复核。
❌「只看总分」 汇总成一个平均值会掩盖最关键的信息——错误全集中在某一类问题上,均值完全看不出来。方差比均值更有信息量:如果 20 条用例里 15 条是满分、5 条是零分,均值 0.75 看起来很健康,但真实情况是「有五类问题系统性地答不了」。按用例类型分组统计,比一个总分有用得多。
八、自检清单
- 能写出 Recall@K 和 Precision@K 的公式,并说出各自分母是什么
- 知道相关文档 10 篇时,Recall@5 的理论上限是多少
- 说得清 RAG 里为什么优先保 Recall 而不是 Precision
- 知道 MRR 忽略第一篇之后的相关文档,以及这适合什么场景
- 说得清 RAGAS 的 Answer Relevancy 是怎么算的(反推问题)
- 知道示例代码里的 Answer Relevancy 为什么在中文上失效
- 知道示例 Faithfulness 只检查了数字,会漏掉哪类幻觉
- 能说出测试集为什么必须包含「材料里没有答案」的用例
附:延伸阅读
相关资料
| 资源 | 说明 |
|---|---|
| RAGAS: Automated Evaluation of RAG | Faithfulness / Answer Relevancy 等指标的原始定义 |
| RAGAS 文档 | 各指标的实现细节与已知局限 |
| Judging LLM-as-a-Judge (MT-Bench) | LLM 裁判的偏差来源与缓解手段 |
| Introduction to Information Retrieval, Ch. 8 | Recall / Precision / MAP / NDCG 的标准教材定义 |
| Lost in the Middle | 为什么 MRR 关心的「排第几位」在 RAG 里真的重要 |
| BEIR Benchmark | 跨领域检索评测集,构建测试集时的参考 |
关联笔记
- RAG 实战(三):混合检索与 RRF 融合 —— 它调权重的依据应该来自这一篇
- RAG 实战(四):Rerank 重排序 —— 「到底好了多少」由这一篇回答
- RAG 实战(六):把散落的零件拼成完整项目 —— 收尾与工程化
最后更新:2026-09-28