一:先来解释一下RAG检索判定指标
1. Recall@K(召回率)
一句话
在前K个结果中,找到了多少比例的相关文档。
公式
Recall@K = 命中数 / 期望文档总数
示例
期望文档:[A, B, C, D, E](5个相关文档)
实际结果:[X, A, Y, B, Z](Top5)
命中:A(第2位), B(第4位) → 2个
Recall@5 = 2/5 = 0.4(40%
意义
- 越高越好,最高 1.0
- 召回率高 = 不漏相关文档
- RAG 里最怕召回低:相关文档没捞到,大模型就瞎编
简单来说,我们测试问“Mysql工作原理”,我们有关的RAG文档总共有5篇,但是我们这里检索后只返回了3篇,那么Recall=3/5=0.6,该指标的核心作用就是判断检索返回是否全面,Recall越高,检索越全面
2. MRR(平均倒数排名)
一句话
第一个相关文档出现得有多早,越早越好。侧重第一条有效结果的位置好不好
公式
MRR = 1 / 第一个相关文档的排名
如果未命中 = 0
示例
查询1:第一个相关文档排第1位 → MRR = 1/1 = 1.0
查询2:第一个相关文档排第2位 → MRR = 1/2 = 0.5
查询3:第一个相关文档排第4位 → MRR = 1/4 = 0.25
查询4:未命中 → MRR = 0
平均MRR = (1.0 + 0.5 + 0.25 + 0) / 4 = 0.4375
意义
- 越接近 1 越好
- MRR 高 = 相关结果排在最前面,用户一眼就能看到
- 适合问答、精准检索场景
场景
- 用户只看第一个结果 :MRR最重要
- 搜索引擎 :第一个结果决定用户体验
该指标的作用就是判断检索返回的第一条与用户问题相关的文档在总文档总的排名,比如第三条文档才真正与用户问题相关,那么mrr=1/3=0.33,排名越靠后,说明整体检索的相关文档都排在后面,而最先给LLM都是不想关的文档,导致LLM输出可能有误
3.为什么有@Recall指标了还需要MRR指标
核心一句话
Recall 只看「找没找全」,不看「排得好不好」;MRR 只看「最相关的那条排得够不够靠前」,两者互补,缺一个都不准。
1. 先记住各自的短板
Recall@K 缺点
只统计:TopK 里包含了多少个真实相关文档
- 只关心 有没有
- 完全不关心排序位置
- 相关文档全堆在最后几名,Recall 照样满分
MRR 缺点
只关心:第一个相关文档排在第几位
- 不关心后面还有没有其他相关文档
- 只抓首条体验
2. 举个最直观的例子(一眼懂)
假设:真实相关文档:A、B、C取 Top5
场景一
返回结果顺序:无关、无关、A、B、C
- Recall@5:3/3 = 1.0 满分
- 第一个相关 A 在第 3 位 → MRR = 0.33
Recall 满分,但用户体验极差,好东西都排在后面。
场景二
返回结果顺序:A、无关、无关、无关、无关
- Recall@5:只有 A 命中 → 1/3 = 0.33 很低
- 第一个相关 A 在第 1 位 → MRR = 1.0 满分
MRR 满分,但漏了很多相关文档,召回很差
3. 结论:为什么两个都要?
-
Recall 管「全不全」 保证不该漏的文档别漏,RAG 不瞎编。
-
MRR 管「准不准、靠前不靠」保证最相关的结果排在最前面,用户 / 大模型第一眼拿到最优答案。
-
只看一个会被欺骗
- 只看 Recall:容易出现「全找到了,但全在末尾」
- 只看 MRR:容易出现「第一条很准,但后面漏一大堆」
4. nDCG@K(归一化折损累积增益)
一句话
考虑排序质量的指标,相关文档排得越前越好。
为什么需要nDCG?
问题:Recall和MRR只看"有没有",不看"排得好不好"
例子:
期望文档:[A, B]
结果1:[A, B, X, Y, Z] → A排第1,B排第2 ✓ 很好
结果2:[X, A, B, Y, Z] → A排第2,B排第3 △ 一般
结果3:[X, Y, A, B, Z] → A排第3,B排第4 ✗ 较差
Recall@5都是1.0(都找到了)
但排序质量明显不同!
公式
DCG@K = Σ(相关度 / log2(排名+1))
nDCG@K = DCG@K / IDCG@K
IDCG:理想排序下的DCG(完美情况)
-
DCG:算你当前排序的得分
-
IDCG:算理论最好的得分(比如相关文档有A,B,那么就算A,B排在返回文档的第1,2个位置,即为完美状态)
-
nDCG:把你的结果和完美结果比一下 → 0~1(归一化,将分数转化为0-1范围内)
上面提到的公式都是可以直接复用的,都是经过大量RAG测试验证后的,我闷这种新人小白直接套用别人的公式就可以了
计算示例
期望文档:[A, B, C](假设都同等重要,相关度=1)
结果:[X, A, B, Y, Z]
排名: A=2, B=3
DCG@5 = 1/log2(3) + 1/log2(4)
= 1/1.585 + 1/2
= 0.63 + 0.5
= 1.13
理想排序:[A, B, C, X, Y]
IDCG@5 = 1/log2(2) + 1/log2(3) + 1/log2(4)
= 1/1 + 1/1.585 + 1/2
= 1.0 + 0.63 + 0.5
= 2.13
nDCG@5 = 1.13 / 2.13 = 0.53
| 核心特性 | 含义说明 |
|---|---|
| 位置敏感 | 排得越靠前,单篇文档贡献分值越大;越靠后折损越多、贡献越小 |
| 排得越前,贡献越大 | 利用对数分母做位置惩罚,靠前位置权重更高 |
| 累积增益 | 把前 K 个文档的相关得分逐篇累加,整体评估批次排序质量 |
| 多个相关文档累加 | 不只是看单个文档,支持多篇相关文档分数合并统计 |
| 归一化 | 用实际 DCG ÷ 理想 IDCG,消除查询难易差异 |
| 除以理想值,范围 0~1 | 最终结果压缩在 0~1 之间,方便横向对比不同查询、不同模型效果 |
折损函数
排名 log2(rank+1) 权重
1 log2(2)=1 1.0/1 = 1.00 ← 最高
2 log2(3)=1.58 1.0/1.58=0.63
3 log2(4)=2 1.0/2=0.50
4 log2(5)=2.32 1.0/2.32=0.43
5 log2(6)=2.58 1.0/2.58=0.39
排第1位是第5位的2.5倍价值!
5. 总结
| 指标 | 核心关注什么 | 优点 | 缺点 |
|---|---|---|---|
| Recall@K | 相关文档找全了吗 | 简单直观、衡量覆盖能力 | 完全不看排序好坏 |
| MRR | 第一个相关结果排得靠前吗 | 聚焦头部、贴合用户体验 | 只看首个,不关心后续文档 |
| nDCG@K | 整体排序质量好不好 | 兼顾相关度 + 位置,评估最全面 | 计算逻辑相对复杂 |
实际应用
评测结果:
Recall@5 = 0.8 → 找到了80%的相关文档 ✓
MRR = 0.6 → 第一个相关文档平均排第1.67位 △
nDCG@5 = 0.75 → 整体排序质量不错 ✓
结论:
- 召回能力不错
- 但第一个结果还可以更靠前
- 建议优化重排序策略
二:指标评测类RetrievalEvalMetrics
我们项目是把所有指标全都抽取出来,封装在这一个指标评测类了
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class RetrievalEvalMetrics {
/**
* 评估的前K个结果。
* <p>
* 表示在计算指标时考虑检索结果的前多少个文档。
* 通常设置为 5 或 10。
*/
private int topK;
/**
* 期望文档总数。
* <p>
* 来自 RetrievalEvalCase 的 expectedDocIds 或 expectedSources 的数量。
* 作为计算 Recall@K 的分母。
*/
private int expectedCount;
/**
* 命中期望文档的数量。
* <p>
* 在前 topK 个结果中,有多少个是期望的相关文档。
* 作为计算 Recall@K 的分子。
*/
private int hitCount;
/**
* 第一个相关文档的排名(1-based)。
* <p>
* 第一个命中的期望文档在结果列表中的位置。
* 用于计算 MRR,-1 表示未命中。
*/
private int firstHitRank;
/**
* 前K个是否至少命中一个。
* <p>
* 布尔值,表示在前 topK 个结果中是否有至少一个期望文档。
* 用于计算 Hit Rate(命中率)。
*/
private boolean hitAtK;
/**
* Recall@K(召回率)。
* <p>
* 公式:hitCount / expectedCount
* <p>
* 表示在前K个结果中找到了多少比例的期望文档。
* 取值范围 0.0 - 1.0,越接近 1.0 越好。
*/
private double recallAtK;
/**
* MRR(平均倒数排名)。
* <p>
* 公式:1 / firstHitRank(如果未命中则为 0)
* <p>
* 衡量第一个相关文档出现得有多早。
* 取值范围 0.0 - 1.0,越接近 1.0 越好。
*/
private double mrr;
/**
* nDCG@K(归一化折损累积增益)。
* <p>
* 公式:DCG / IDCG
* <p>
* 衡量排序质量,考虑相关文档的位置(排得越前越好)。
* 取值范围 0.0 - 1.0,越接近 1.0 越好。
*/
private double ndcgAtK;
/**
* 检索耗时(毫秒)。
* <p>
* 从执行检索到返回结果的总耗时。
* 用于评估系统性能。
*/
private long latencyMs;
}
具体结构如下
┌─────────────────────────────────────────┐
│ RetrievalEvalMetrics │
│ (RAG检索评测指标数据类) │
├─────────────────────────────────────────┤
│ 基础信息 │
│ ├── topK: int │
│ ├── expectedCount: int │
│ ├── hitCount: int │
│ ├── firstHitRank: int │
│ ├── hitAtK: boolean │
│ └── latencyMs: long │
├─────────────────────────────────────────┤
│ 核心指标(计算得出) │
│ ├── recallAtK: double │
│ ├── mrr: double │
│ └── ndcgAtK: double │
└─────────────────────────────────────────┘
基础信息就是来记录总共返回的文档数目,真正的命中数目,期望的命中数目,检索总耗时等等,核心指标的数据来自基础信息,但是核心指标才是我们整个检测的核心
使用流程
评测执行
│
├──→ 收集原始数据
│ ├── hitCount = 统计命中数
│ ├── firstHitRank = 找第一个命中位置
│ └── latencyMs = 结束时间 - 开始时间
│
├──→ 计算指标
│ ├── recallAtK = hitCount / expectedCount
│ ├── mrr = 1 / firstHitRank
│ └── ndcgAtK = computeDCG() / computeIDCG()
│
└──→ 封装到 RetrievalEvalMetrics
│
└──→ 存入 RetrievalEvalResult
四:创建一个自动配置与启动器类RetrievalEvaluationAutoConfiguration
简要讲述一下,我们该类的作用是进行指标评测前的一些前置处理
(1)讲解一下变量设定
/**
* 检索质量过滤器。
* <p>
* 用于执行完整的检索后处理流程(过滤、重排、多样性等)。
*/
private final RetrievalQualityFilter qualityFilter;
/**
* 向量存储。
* <p>
* 用于执行向量相似度检索。
*/
private final VectorStore vectorStore;
/**
* 评测运行器。
* <p>
* 负责加载用例、批量执行评测、计算指标、生成报告。
*/
private final RetrievalEvaluationRunner evaluationRunner;
/**
* 评测配置属性。
* <p>
* 从 application.yml 加载的评测相关配置。
*/
private final RetrievalEvaluationProperties properties;
- 我们注入的
RetrievalQualityFilter,底层就是去执行向量检索后的策略执行链,进行检索的文本过滤 vectorStore就是我们注入的向量数据库,用于做相似度检索RetrievalEvaluationRunner是底层的评测类,里面封装了各个指标的评测方法RetrievalEvaluationProperties是配置文件的配置类,用于读取评测数据的配置
这里我们讲解一下RetrievalEvaluationProperties
@Data
@ConfigurationProperties(prefix = "rag.evaluate")
public class RetrievalEvaluationProperties {
private boolean enabled = false;
private String resourceLocation = "classpath:retrieval-eval/golden-set.json";
private String outputDir = "target/retrieval-eval";
private String reportPrefix = "retrieval-eval";
private boolean writeMarkdown = true;
private boolean writeJson = true;
}
具体的评测配置形式,用于控制报告的一些格式输入输出,目录存放位置以及一些标准测试集的位置
(2)具体的自动评测启动运行器
/**
* 评测启动运行器。
* <p>
* 【实现 ApplicationRunner】
* Spring Boot 应用启动完成后自动执行 run() 方法。
* <p>
* 【执行流程】
* 1. 从配置的资源位置加载评测用例
* 2. 如果未加载到用例则跳过
* 3. 调用 evaluationRunner.evaluate() 批量执行评测
* 4. 将报告写入指定目录
* 5. 在日志中输出 Markdown 报告
*/
private class EvaluationStartupRunner implements ApplicationRunner {
@Override
public void run(ApplicationArguments args) {
log.info("[RetrievalEval] 离线评测启动 - 资源路径: {}", properties.getResourceLocation());
// 加载评测用例
List<RetrievalEvalCase> cases = evaluationRunner.loadCases(properties.getResourceLocation());
if (cases.isEmpty()) {
log.warn("[RetrievalEval] 评测跳过 - 未加载到用例, 资源路径: {}",
properties.getResourceLocation());
return;
}
log.info("[RetrievalEval] 评测配置 - 用例数={}, 输出目录={}, Markdown={}, JSON={}",
cases.size(), properties.getOutputDir(),
properties.isWriteMarkdown(), properties.isWriteJson());
long evalStartTime = System.currentTimeMillis();
// 批量执行评测
List<cn.bugstack.ai.domain.agent.service.rag.filter.retrieval.model.RetrievalEvalResult> results =
evaluationRunner.evaluate(cases, retrievalEvalExecutor());
long evalTime = System.currentTimeMillis() - evalStartTime;
// 写入报告文件
Path outputDir = Paths.get(properties.getOutputDir());
evaluationRunner.writeReports(results, outputDir, properties.getReportPrefix(), // 文件名前缀,如 "retrieval-eval"
properties.isWriteMarkdown(),// 是否写 Markdown 报告
properties.isWriteJson());//// 是否写 JSON 报告
// 日志输出 Markdown 报告
String markdown = evaluationRunner.toMarkdownReport(results);
log.info("[RetrievalEval] 评测全部完成 - 总耗时={}ms\n{}", evalTime, markdown);
}
}
可以看见我们这个方法还是对指标评测前的一些参数判断与评测后的一些收尾工作,内部调用核心的评测类evaluateRunner去实现核心评测的方法,同时他实现了ApplicationRunner接口,重写了run方法,保证在容器启动完成后执行该方法,实现了自动评测的逻辑
@Configuration
@EnableConfigurationProperties(RetrievalEvaluationProperties.class)
@ConditionalOnProperty(prefix = "rag.eval", name = "enabled", havingValue = "true")
@RequiredArgsConstructor
public class RetrievalEvaluationAutoConfiguration {
因为我们设定了条件,只有我们在配置文件配置了“enabled:true”,他才会继续我们的自动评测工作,这样搭配可以随意控制我们整个评测机制的启动与关闭
(3)执行单个评测用例的方法
private RetrievalEvalResult.RetrievalFilterResult execute(RetrievalEvalCase evalCase) {
// 参数校验
if (evalCase == null || !StringUtils.hasText(evalCase.getQuery())) {
log.warn("[RetrievalEval] 用例执行跳过 - query为空");
return RetrievalEvalResult.RetrievalFilterResult.builder().build();
}
String query = evalCase.getQuery();
String knowledgeTag = StringUtils.hasText(evalCase.getKnowledgeTag())
? evalCase.getKnowledgeTag().trim()
: null;
log.info("[RetrievalEval] 开始执行用例 - caseId={}, query长度={}, knowledgeTag={}",
evalCase.getId(), query.length(), knowledgeTag != null ? knowledgeTag : "null");
// 构建 knowledgeTag 过滤表达式
Filter.Expression filterExpression = null;
if (StringUtils.hasText(knowledgeTag)) {
// 转义单引号防止注入
String escapedTag = knowledgeTag.replace("'", "\'");
filterExpression = new FilterExpressionTextParser()
.parse("knowledge == '" + escapedTag + "'");
}
// 获取该知识库的相似度阈值
double similarityThreshold = qualityFilter.resolvePolicy(knowledgeTag)
.getSimilarityThreshold();
// 构建向量检索请求
SearchRequest searchRequest = SearchRequest.builder()
.query(query)
.topK(Math.max(1, evalCase.getTopK()))
.similarityThreshold(similarityThreshold)
.filterExpression(filterExpression)
.build();
// 执行向量检索
List<Document> rawDocuments = vectorStore.similaritySearch(searchRequest);
log.info("[RetrievalEval] 向量检索完成 - caseId={}, 召回文档数={}", evalCase.getId(), rawDocuments.size());
// 执行完整的检索后处理流程(过滤、重排、多样性等)
RetrievalEvalResult.RetrievalFilterResult result = qualityFilter.filterRetrievalResultsDetailed(rawDocuments, query, knowledgeTag);
log.info("[RetrievalEval] 后处理完成 - caseId={}, 原始文档={}, 最终文档={}, 是否回退={}",
evalCase.getId(), result.getOriginalCount(), result.getFinalCount(), result.isFallbackUsed());
return result;
}
我们这里的
RetrievalEvalResult封装的是整个评测结果,它里面封装了评测指标类RetrievalEvalMetrics,它里面封装了我们的各项指标(MRR,@Recall等等),检索过滤结果的封装类RetrievalFilterResult,它里面封装经过向量检索,重排序,bm25等过滤策略过滤后的最终结果,测试用例的封装类RetrievalEvalCase,里面封装了测试用例,比如用户问题,期望文档id等等。我们将整个评测流程的结果都封装在这一个类中,后续便于通过该类查看整个评测流程的情况,比如MRR评测指标低了,那就查看RetrievalFilterResult到底存放了那些过滤文本
public class RetrievalEvalResult {
// 评测用例定义。
private RetrievalEvalCase evalCase;
//检索后处理结果。
private RetrievalFilterResult retrievalResult;
//评测指标结果。
private RetrievalEvalMetrics metrics;
│ RetrievalEvalResult │
│ (评测结果 - 聚合根) │
├─────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 第一层:输入(Input) │ │
│ │ ─────────────────── │ │
│ │ RetrievalEvalCase evalCase │ │
│ │ ├── id(用例ID) │ │
│ │ ├── query(查询文本) │ │
│ │ ├── knowledgeTag(知识库标签) │ │
│ │ ├── expectedDocIds(期望文档ID) │ │
│ │ └── expectedSources(期望来源) │ │
│ │ │ │
│ │ 💡 作用:记录"测了什么" │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 第二层:输出(Output) │ │
│ │ ─────────────────── │ │
│ │ RetrievalFilterResult retrievalResult │ │
│ │ ├── finalDocuments(最终文档列表) │ │
│ │ ├── originalCount(原始文档数) │ │
│ │ ├── finalCount(最终文档数) │ │
│ │ └── fallbackUsed(是否回退) │ │
│ │ │ │
│ │ 💡 作用:记录"得到了什么" │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ 第三层:评估(Evaluation) │ │
│ │ ───────────────────── │ │
│ │ RetrievalEvalMetrics metrics │ │
│ │ ├── recallAtK(召回率) │ │
│ │ ├── mrr(平均倒数排名) │ │
│ │ ├── ndcgAtK(排序质量) │ │
│ │ ├── hitAtK(是否命中) │ │
│ │ └── latencyMs(耗时) │ │
│ │ │ │
│ │ 💡 作用:记录"测得怎么样" │ │
│ └─────────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────┘
//具体使用
问题:为什么这个用例的 Recall@5 只有 0.4?
通过三层结构可以追溯:
├─ evalCase.query = "八千代为什么孤独"
├─ evalCase.expectedDocIds = [doc-001, doc-002, doc-003]
│
├─ retrievalResult.finalDocuments = [doc-005, doc-001, doc-007]
│ └─ 只命中了 doc-001!
│
└─ metrics.hitCount = 1
metrics.expectedCount = 3
metrics.recallAtK = 1/3 = 0.33
结论:向量检索漏掉了 doc-002 和 doc-003
>上面只是评测结果类的一部分,也是最主要的部分
五:创建具体指标评测类RetrievalEvaluationRunner
这是 RAG 检索后处理的离线评测框架核心类,负责加载用例、批量执行、计算指标、生成报告。
(1) 核心职责(四大功能)
┌─────────────────────────────────────────────────────────────┐
│ RetrievalEvaluationRunner │
│ (评测运行器 - 核心) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 加载评测用例(loadCases) │
│ ├── 支持 classpath / 文件系统 │
│ ├── 支持数组格式 / 对象格式 │
│ └── 返回 List<RetrievalEvalCase> │
│ │
│ 2. 批量执行评测(evaluate)← 核心! │
│ ├── 遍历每个用例 │
│ ├── 调用 executor.execute() 执行检索 │
│ ├── 计算延迟 │
│ └── 构建 RetrievalEvalResult │
│ │
│ 3. 计算评测指标(buildResult) │
│ ├── Recall@K(召回率) │
│ ├── MRR(平均倒数排名) │
│ ├── nDCG@K(排序质量) │
│ └── Hit@K / Latency │
│ │
│ 4. 生成评测报告 │
│ ├── toMarkdownReport() → Markdown 表格 │
│ ├── toJsonReport() → JSON 数据 │
│ └── writeReports() → 写入文件 │
│ │
└─────────────────────────────────────────────────────────────┘
在架构中的位置
┌─────────────────────────────────────────────────────────────┐
│ 使用层 │
│ RetrievalEvaluationAutoConfiguration │
│ (自动配置、启动触发) │
│ │ │
│ └── 调用 runner.evaluate(cases, executor) │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 核心层 ← 你在这里 │
│ RetrievalEvaluationRunner │
│ (评测运行器 - 本类) │
│ │ │
│ ├── loadCases() 加载用例 │
│ ├── evaluate() 批量执行 ← 核心方法 │
│ ├── buildResult() 计算指标 │
│ └── writeReports() 生成报告 │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ 执行层 │
│ RetrievalEvalExecutor(接口) │
│ 具体实现:vectorStore + qualityFilter │
└─────────────────────────────────────────────────────────────┘
这里可以看见,我们上面提及的
RetrievalEvaluationAutoConfiguration,调用他的execute方法,本质就会启动RetrievalEvaluationRunner核心的评测流程
这里我们只讲一下核心的部分
(2)evaluate
public List<RetrievalEvalResult> evaluate(List<RetrievalEvalCase> cases, RetrievalEvalExecutor executor) {
if (cases == null || cases.isEmpty() || executor == null) {
return List.of();
}
logger.info("[RetrievalEvaluation] 开始离线评测 - 用例数: " + cases.size());
List<RetrievalEvalResult> results = new ArrayList<>(cases.size());
int successCount = 0;
int failCount = 0;
for (int i = 0; i < cases.size(); i++) {
RetrievalEvalCase evalCase = cases.get(i);
long startMs = System.currentTimeMillis();
try {
//执行评测用例,获取检索后处理结果,底层调用的是RetrievalAutoConfiguration中的RetrievalQualityFilter的execute方法,
//会重新走一遍完整的检索后过滤流程,包括bm25,rerank,diversity等步骤
RetrievalEvalResult.RetrievalFilterResult retrievalResult = executor.execute(evalCase);
//计算评测的耗时
long latencyMs = System.currentTimeMillis() - startMs;
//构建整个离线评测结果,包括@Recall, @MRR, @NDCG@K, @hitRate, @avgLatencyMs等指标
RetrievalEvalResult result = buildResult(evalCase, retrievalResult, latencyMs);
results.add(result);
successCount++;
// 记录每个用例的关键指标
RetrievalEvalMetrics metrics = result.getMetrics();
logger.info("[RetrievalEvaluation] 用例执行完成 [" + (i + 1) + "/" + cases.size() + "] - " +
"caseId=" + evalCase.getId() + ", " +
"Recall@" + metrics.getTopK() + "=" + String.format("%.2f", metrics.getRecallAtK()) + ", " +
"MRR=" + String.format("%.2f", metrics.getMrr()) + ", " +
"latency=" + latencyMs + "ms");
} catch (Exception e) {
failCount++;
logger.warning("[RetrievalEvaluation] 用例执行失败 [" + (i + 1) + "/" + cases.size() + "] - " +
"caseId=" + (evalCase != null ? evalCase.getId() : "null") + ", error=" + e.getMessage());
}
}
// 计算并输出整体统计
if (!results.isEmpty()) {
Map<String, Double> summary = summarize(results);
logger.info("[RetrievalEvaluation] 离线评测完成 - 成功: " + successCount + ", 失败: " + failCount + ", " +
"avgRecall=" + String.format("%.2f", summary.getOrDefault("avgRecallAtK", 0D)) + ", " +
"avgMRR=" + String.format("%.2f", summary.getOrDefault("avgMrr", 0D)) + ", " +
"hitRate=" + String.format("%.2f", summary.getOrDefault("hitRate", 0D)) + ", " +
"avgLatency=" + String.format("%.0f", summary.getOrDefault("avgLatencyMs", 0D)) + "ms");
}
return results;
}
核心流程图
┌─────────────────────────────────────────────────────────────┐
│ evaluate(cases, executor) │
├─────────────────────────────────────────────────────────────┤
│ │
│ 1. 参数校验 │
│ └── cases为空 或 executor为null? → 返回空列表 │
│ │
│ 2. 初始化统计 │
│ ├── results = new ArrayList<>() // 存储结果 │
│ ├── successCount = 0 // 成功计数 │
│ └── failCount = 0 // 失败计数 │
│ │
│ 3. 遍历执行每个用例 ← 核心循环 │
│ for (i = 0; i < cases.size(); i++) │
│ │ │
│ ├── 记录开始时间 startMs │
│ │ │
│ ├── 执行检索 executor.execute(evalCase) │
│ │ └── 返回 RetrievalFilterResult │
│ │ ├── finalDocuments(最终文档) │
│ │ └── originalCount(原始数量) │
│ │ │
│ ├── 计算耗时 latencyMs = current - startMs │
│ │ │
│ ├── 构建结果 buildResult(...) │
│ │ └── 计算 Recall@K, MRR, nDCG@K │
│ │ │
│ ├── 记录成功日志 │
│ │ │
│ └── 异常处理 → 记录失败日志 │
│ │
│ 4. 汇总统计 summarize(results) │
│ └── 计算平均指标 │
│ │
│ 5. 返回结果列表 │
│ └── List<RetrievalEvalResult> │
│ │
└─────────────────────────────────────────────────────────────┘
这里他会读取我们配置文件目录下的评测用例(loadCase这部分跳过解释了),并且去遍历每一个评测用例,每一个用例都会去执行execute(实际调用的是我们RetrievalEvaluationRunner里的execute方法,他会去对我们的用例执行过滤,调用策略执行链),将过滤结果封装在RetrievalEvalResult.RetrievalFilterResult,并将过滤结果传递给buildResult(内部封装了具体的评测指标实现),最后将评测结果打印一份在控制台
[RetrievalEvaluation] 开始离线评测 - 用例数: 10
[RetrievalEvaluation] 用例执行完成 [1/10] - caseId=case-001, Recall@5=0.67, MRR=0.50, latency=45ms
[RetrievalEvaluation] 用例执行完成 [2/10] - caseId=case-002, Recall@5=1.00, MRR=1.00, latency=38ms
[RetrievalEvaluation] 用例执行完成 [3/10] - caseId=case-003, Recall@5=0.33, MRR=0.33, latency=52ms
[RetrievalEvaluation] 用例执行完成 [4/10] - caseId=case-004, Recall@5=0.80, MRR=0.67, latency=41ms
[RetrievalEvaluation] 用例执行完成 [5/10] - caseId=case-005, Recall@5=1.00, MRR=1.00, latency=35ms
[RetrievalEvaluation] 用例执行完成 [6/10] - caseId=case-006, Recall@5=0.75, MRR=0.80, latency=48ms
[RetrievalEvaluation] 用例执行完成 [7/10] - caseId=case-007, Recall@5=0.50, MRR=0.50, latency=44ms
[RetrievalEvaluation] 用例执行完成 [8/10] - caseId=case-008, Recall@5=0.90, MRR=0.83, latency=39ms
[RetrievalEvaluation] 用例执行完成 [9/10] - caseId=case-009, Recall@5=0.67, MRR=0.67, latency=46ms
[RetrievalEvaluation] 用例执行完成 [10/10] - caseId=case-010, Recall@5=1.00, MRR=1.00, latency=37ms
[RetrievalEvaluation] 离线评测完成 - 成功: 10, 失败: 0, avgRecall=0.75, avgMRR=0.83, hitRate=0.90, avgLatency=42ms
最终会打印出类似的日志信息,用来统计测试用例的评测情况
(3)buildResult
它是用来执行单个测试用例的评测结果
private RetrievalEvalResult buildResult(RetrievalEvalCase evalCase, RetrievalEvalResult.RetrievalFilterResult retrievalResult, long latencyMs) {
// 【提取最终文档列表】检索后处理完成后的文档(已过滤、重排、多样性处理)
List<Document> finalDocuments = retrievalResult == null ? List.of() : retrievalResult.getFinalDocuments();
// 【初始化结果收集容器】
List<String> rankedDocIds = new ArrayList<>(finalDocuments.size()); // 实际排序的文档ID(前topK个)
List<String> matchedDocIds = new ArrayList<>(); // 命中的期望文档ID
List<Integer> hitRanks = new ArrayList<>(); // 命中文档的排名位置(1-based)
// 【解析期望集合】将列表转为Set提高查找效率
//evalCase.getExpectedDocIds() 期望返回的文档ID列表
//evalCase.getExpectedSources() 期望返回的文档来源列表
Set<String> expectedDocIds = normalizeSet(evalCase == null ? null : evalCase.getExpectedDocIds());
Set<String> expectedSources = normalizeSet(evalCase == null ? null : evalCase.getExpectedSources());
// 【确定评估窗口】取配置topK,默认5
int topK = Math.max(1, evalCase == null ? 5 : evalCase.getTopK());
// 【遍历前topK个文档,判断是否相关】
int limit = Math.min(topK, finalDocuments.size()); // 实际可遍历数量(文档可能不足topK)
for (int i = 0; i < limit; i++) {
Document document = finalDocuments.get(i);
String docId = safeDocumentId(document);
rankedDocIds.add(docId); // 记录实际排序
// 判断文档是否相关(ID匹配 或 来源匹配)
boolean relevant = isRelevant(document, expectedDocIds, expectedSources);
if (relevant) {
matchedDocIds.add(docId); // 记录命中的文档ID
hitRanks.add(i + 1); // 记录命中排名(从1开始),如果第三个文档是相关文档,记录为i=2,那么mrr=1/3,mrr越低表示整个检索质量越差
}
}
// 【计算期望文档总数】优先使用expectedDocIds,否则使用expectedSources
int expectedCount = !expectedDocIds.isEmpty() ? expectedDocIds.size() : expectedSources.size();
// 【构建评测指标】调用各指标计算方法
RetrievalEvalMetrics metrics = RetrievalEvalMetrics.builder()
.topK(topK) // 评估窗口大小
.expectedCount(expectedCount) // 期望文档总数
.hitCount(matchedDocIds.size()) // 命中数量
.firstHitRank(hitRanks.isEmpty() ? -1 : hitRanks.get(0)) // 第一个命中位置(-1表示未命中)
.hitAtK(!matchedDocIds.isEmpty()) // 前K个是否至少命中一个
.recallAtK(computeRecallAtK(matchedDocIds.size(), expectedCount)) // 召回率(RetrievalEvalMetrics中的@Recall指标,衡量检索结果中包含期望文档的比例,主要是判断检索是否全面,而不是检索是否准确)
.mrr(computeMrr(hitRanks)) // 平均倒数排名(mrr指标,衡量检索质量如何,检索是否准确,即检索的第一个相关文档出现得有多早,越早说明检索质量越高)
.ndcgAtK(computeNdcgAtK(hitRanks, expectedCount, topK)) // 归一化折损累积增益(nadc指标)
.latencyMs(latencyMs) // 执行耗时
.build();
// 【构建完整评测结果】聚合所有信息
return RetrievalEvalResult.builder()
.evalCase(evalCase) // 原始评测用例
.retrievalResult(retrievalResult) // 检索后处理结果
.metrics(metrics) // 评测指标
.rankedDocIds(rankedDocIds) // 实际排序的文档ID列表
.matchedDocIds(matchedDocIds) // 命中的期望文档ID列表
.build();
}
输入参数
- evalCase:当前要遍历的测试用例文本(内部包含具体的用户问题,期望文本id等等)
- retrievalResult:当前用例测试文本的相关检索后的文本(已经执行过过滤)
- latencyMs:用来计算每个文本的评测耗时
在这之前,我先展示一下我们的测试用例文本格式,便于理解
{
"id": "case-product-function",
"query": "产品功能",
"knowledgeTag": "product-manual",
"expectedDocIds": ["期望命中的文档ID"],//希望返回的文本id
"expectedSources": ["期望命中的来源文件"],//希望返回的文本来源文件(“比如员工守则文本”来自“公司规章制度”这个文件)
"topK": 3,//限制返回的文本数
"note": "产品功能说明文档应该排在前面"
}
我们每一个测试用例都是指定了我们期望会命中的文本id,可以理解为用户询问该测试用例问题(“比如产品功能”),我们的检测标准是返回所有的期望id的文档,如果实际运用中用户提出了类似于测试用例的问题,并且正确返回了所有指定的文本,那么就说明检索很成功,如果一个文本没有命中,就需要人工查询和调整。我们的测试用例就是手动去模拟用户可能提出的问题,并且指定希望返回的文本,去测试检索效果,从而不断的去调试检索流程,让他能正确检索我们想要的文本
理解了上面这一点,下面的这个流程图就比较好解释了
┌─────────────────────────────────────────────────────────────────┐
│ buildResult(evalCase, retrievalResult, latencyMs) │
├─────────────────────────────────────────────────────────────────┤
│ │
│ 1. 提取最终文档列表 │
│ └── finalDocuments = retrievalResult.getFinalDocuments() │
│ │
│ 2. 初始化收集容器 │
│ ├── rankedDocIds = [] // 实际排序的文档ID │
│ ├── matchedDocIds = [] // 命中的文档ID │
│ └── hitRanks = [] // 命中排名位置 │
│ │
│ 3. 解析期望集合 │
│ ├── expectedDocIds = Set<doc-001, doc-002, doc-003> │
│ └── expectedSources = Set<source-a, source-b> │
│ │
│ 4. 确定评估窗口 │
│ └── topK = 5(默认)或 evalCase.getTopK() │
│ │
│ 5. 遍历前 topK 个文档 ← 核心循环 │
│ for (i = 0; i < limit; i++) │
│ │ │
│ ├── docId = finalDocuments[i].id │
│ ├── rankedDocIds.add(docId) // 记录实际排序 │
│ │ │
│ ├── relevant = isRelevant(doc, expectedDocIds, │
│ │ expectedSources) │
│ │ ├── ID 匹配? │
│ │ └── 来源匹配? │
│ │ │
│ └── if (relevant) │
│ ├── matchedDocIds.add(docId) // 记录命中ID │
│ └── hitRanks.add(i + 1) // 记录排名 │
│ │
│ 示例结果: │
│ rankedDocIds = [doc-X, doc-A, doc-Y, doc-B, doc-Z] │
│ matchedDocIds = [doc-A, doc-B] │
│ hitRanks = [2, 4] // A排第2,B排第4 │
│ │
│ 6. 计算期望文档总数 │
│ └── expectedCount = expectedDocIds.size() 或 │
│ expectedSources.size() │
│ │
│ 7. 计算三大指标 ← 核心! │
│ ├── recallAtK = hitCount / expectedCount // 召回率 │
│ ├── mrr = 1 / firstHitRank // 倒数排名 │
│ └── ndcgAtK = DCG / IDCG // 排序质量 │
│ │
│ 8. 构建指标对象 │
│ └── RetrievalEvalMetrics.builder()... │
│ │
│ 9. 构建完整结果 │
│ └── RetrievalEvalResult.builder()... │
│ ├── evalCase // 输入 │
│ ├── retrievalResult // 输出 │
│ ├── metrics // 评估 │
│ ├── rankedDocIds // 实际排序 │
│ └── matchedDocIds // 命中文档 │
│ │
└─────────────────────────────────────────────────────────────────┘
(1)解释一下rankedDocIds = []
我们过滤后返回的文档会按照次序放在这个集合中,他就代表我们实际存放的顺序
期望文档:[doc-001, doc-002, doc-003]
实际检索结果(经过过滤、重排、多样性后):
┌────────┬─────────┐
│ 排名 │ 文档ID │
├────────┼─────────┤
│ 第1位 │ doc-X │ ← 不相关
│ 第2位 │ doc-001 │ ← ✓ 相关
│ 第3位 │ doc-Y │ ← 不相关
│ 第4位 │ doc-002 │ ← ✓ 相关
│ 第5位 │ doc-Z │ ← 不相关
└────────┴─────────┘
rankedDocIds = ["doc-X", "doc-001", "doc-Y", "doc-002", "doc-Z"]
↑ ↑ ↑ ↑ ↑
第1位 第2位 第3位 第4位 第5位
(2)matchedDocIds = []
记录在检索结果中,哪些文档是符合期望的(相关的)。换句话说我们测试用例中指定了期望文档id,就看我们当前遍历到的过滤后的文档id是否在期望文档id中,如果在那就说明该文档于与用户问题是相关连的
实际检索结果:
┌────────┬─────────┬─────────┐
│ 排名 │ 文档ID │ 是否命中 │
├────────┼─────────┼─────────┤
│ 第1位 │ doc-X │ ✗ │
│ 第2位 │ doc-001 │ ✓ │ ← 命中!
│ 第3位 │ doc-Y │ ✗ │
│ 第4位 │ doc-002 │ ✓ │ ← 命中!
│ 第5位 │ doc-Z │ ✗ │
└────────┴─────────┴─────────┘
matchedDocIds = ["doc-001", "doc-002"]
后续的计算 Recall@K hitCount = matchedDocIds.size(),就需要他来判断命中的文档数目是否完全
(3)hitRanks = []
记录每个命中文档排在第几位(从1开始)。
实际检索结果:
┌────────┬─────────┬─────────┐
│ 排名 │ 文档ID │ 是否命中 │
├────────┼─────────┼─────────┤
│ 第1位 │ doc-X │ ✗ │
│ 第2位 │ doc-001 │ ✓ │ ← 命中,排名=2
│ 第3位 │ doc-Y │ ✗ │
│ 第4位 │ doc-002 │ ✓ │ ← 命中,排名=4
│ 第5位 │ doc-Z │ ✗ │
└────────┴─────────┴─────────┘
hitRanks = [2, 4]
↑ ↑
doc-001 排第2位
doc-002 排第4位
计算 MRR:mrr = 1 / hitRanks.get(0), 计算 nDCG@K:dcg = Σ(1 / log2(rank+1)),以及分析排序质量(命中文档排得靠前还是靠后)都需要靠他
(4)三个容器的比较
| 字段 | 类型 | 含义 | 示例 |
|---|---|---|---|
| rankedDocIds | List | 所有文档 ID(完整全局排序列表) | ["doc-X", "doc-001", "doc-Y", "doc-002", "doc-Z"] |
| matchedDocIds | List | 检索命中的文档 ID 列表 | ["doc-001", "doc-002"] |
| hitRanks | List | 命中文档在全局排序里的排名位置 | [2, 4] |
(5)Set<String> expectedDocIds
读取测试用例文本中的期望文本id,并且转化为一个Set集合,利用Set的无序,唯一,底层运用HashMap(HashSet)的特性快速判断文档id是否在期望id集合中,很契合我们的需求
(6)isRelevant
他是我们自定义的一个方法,用来判断当前遍历到的文本与我们测试用例的问题是否相关,只有返回true,该文档才会加入
matchedDocIds,hitRanks = []
/**
* 判断文档是否相关。
* <p>
* 【匹配规则】
* 1. 文档 ID 在 expectedDocIds 中
* 2. 或文档来源在 expectedSources 中
*
* @param document 文档
* @param expectedDocIds 期望文档ID集合
* @param expectedSources 期望来源集合
* @return 是否相关
*/
private boolean isRelevant(Document document, Set<String> expectedDocIds, Set<String> expectedSources) {
if (document == null) {
return false;
}
String docId = safeDocumentId(document);//提取当前过滤后文本的id
if (StringUtils.hasText(docId) && expectedDocIds.contains(docId)) {//判断该id是否在期望id列表内
return true;//如果是,就表明当前文本与用户问题是相关的
}
return false;
}
核心逻辑已经讲解完了,下面讲解一下具体指标计算
(4)指标计算方法
RetrievalEvalMetrics metrics = RetrievalEvalMetrics.builder()
.topK(topK) // 评估窗口大小
.expectedCount(expectedCount) // 期望文档总数
.hitCount(matchedDocIds.size()) // 命中数量
.firstHitRank(hitRanks.isEmpty() ? -1 : hitRanks.get(0)) // 第一个命中位置(-1表示未命中)
.hitAtK(!matchedDocIds.isEmpty()) // 前K个是否至少命中一个
.recallAtK(computeRecallAtK(matchedDocIds.size(), expectedCount)) // 召回率(RetrievalEvalMetrics中的@Recall指标,衡量检索结果中包含期望文档的比例,主要是判断检索是否全面,而不是检索是否准确)
.mrr(computeMrr(hitRanks)) // 平均倒数排名(mrr指标,衡量检索质量如何,检索是否准确,即检索的第一个相关文档出现得有多早,越早说明检索质量越高)
.ndcgAtK(computeNdcgAtK(hitRanks, expectedCount, topK)) // 归一化折损累积增益(nadc指标)
.latencyMs(latencyMs) // 执行耗时
.build();
上面我们在构建指标评测结果类时,对于@Recall,Mrr,Ndcg是分别调用
computeRecallAtK,computeMrr,computeNdcgAtK,下面具体讲解一下他们内部,其实不难
1.computeRecallAtK
private double computeRecallAtK(int hitCount, int expectedCount) {
if (expectedCount <= 0) {
return 0D;
}
return Math.min(hitCount, expectedCount) / (double) expectedCount;
}
hitCount就是命中的文档数,exepectedCount就是期望的文档总数,公式:
Recall@K = hitCount / expectedCount
2.computeRecallAtK
private double computeMrr(List<Integer> hitRanks) {
if (hitRanks == null || hitRanks.isEmpty()) {
return 0D;
}
return 1D / hitRanks.get(0);
}
hitRank也是我们上面提及到的,里面记录了我们所有相关文档在原文档中的位置,符合公式
MRR = 1 / 第一个相关文档的排名,衡量第一个相关文档出现得有多早。
3. computeNdcgAtK
private double computeNdcgAtK(List<Integer> hitRanks, int expectedCount, int topK) {
if (expectedCount <= 0 || hitRanks == null || hitRanks.isEmpty()) {
return 0D;
}
// 计算 DCG
double dcg = 0D;
for (int rank : hitRanks) {
if (rank > topK) {
continue;
}
dcg += 1D / (Math.log(rank + 1D) / Math.log(2D));
}
// 计算 IDCG(理想情况)
int idealHits = Math.min(expectedCount, topK);
double idcg = 0D;
for (int i = 1; i <= idealHits; i++) {
idcg += 1D / (Math.log(i + 1D) / Math.log(2D));
}
return idcg <= 0D ? 0D : dcg / idcg;
}
这个其实也不难理解
核心概念
DCG = Σ(1 / log2(rank+1)) // 实际排序的得分
IDCG = 理想排序下的 DCG // 满分情况
nDCG@K = DCG / IDCG // 归一化到 0~1
我们就拿hitRank的模拟数据举个例子
期望文档:[A, B, C](3个)
实际结果:[X, A, B, Y, Z]
命中:A排第2,B排第3,C未命中
hitRanks = [2, 3]
1. DCG@5 = 1/log2(3) + 1/log2(4)
= 0.631 + 0.500
= 1.131
2. IDCG@5 = 1/log2(2) + 1/log2(3) + 1/log2(4)
= 1.000 + 0.631 + 0.500
= 2.131
3. nDCG@5 = 1.131 / 2.131 = 0.531
(5)数据展示(markdown)
这一部分说实在的,不好讲解,因为数据展示的方式各种各样,可以直接丢给AI,博主的就是,核心就是将我们之前的全部结果封装类
RetrievalFilterResult的数据展示出来即可,最主要的是确保数据都采集成功,博主就是统一放在RetrievalFilterResult,取出里面的数据填充到markdown中
### 1. case-001
- Query: `八千代为什么孤独`
- Knowledge tag: `八千代的孤独守望与时空闭环`
- TopK: 5
- Note: 测试孤独原因的理解
- Expected doc ids: doc-001, doc-002, doc-003
- Expected sources: source-a
| metric | value |
| --- | ---: |
| hit@K | true |
| recall@K | 0.6667 |
| mrr | 0.5000 |
| nDCG@K | 0.5310 |
| latencyMs | 45 |
| originalCount | 10 |
| finalCount | 4 |
| fallbackUsed | false |
#### Filter Stats
| stage | removed |
| --- | ---: |
| hardFilter | 2 |
| diversity | 4 |
#### Ranked Documents
| rank | docId | source | title | bm25 | rerank | matched | reason |
| ---: | --- | --- | --- | ---: | ---: | --- | --- |
| 1 | doc-X | source-b | 标题X | 5.2 | 0.85 | no | 向量相似度高 |
| 2 | doc-001 | source-a | 标题A | 3.1 | 0.92 | yes | BM25匹配 |
| 3 | doc-Y | source-c | 标题Y | 4.5 | 0.78 | no | - |
| 4 | doc-002 | source-a | 标题B | 2.8 | 0.88 | yes | 查询覆盖度高 |
#### Matched Docs
doc-001, doc-002
#### Final Ranked Doc IDs
doc-X, doc-001, doc-Y, doc-002
预期展示图
六:测试用例
先指定我们的测试集
开启我们的评测配置
可以看见启动项目时,日志已经打印
指定目录下确实有json/markdown两种形式