你的 RAG 知识库问答上线后,产品经理问"效果到底好不好",你只能回一句"感觉还行"。但"感觉"当不了指标------检索结果对不对、答案有没有编造,都得量化。这篇文章用 Spring AI 2.0 手写一套评估框架:先用召回率(Recall@K)、平均倒数排名(MRR)、命中率(Hit Rate)三个指标量化检索层,再用 LLM 当裁判给答案的忠实度打分。代码完整可运行,读完就能给自己的 RAG 系统建一套评测集。
一、这个问题到底是什么
RAG 系统上线前,必须有一个量化的评估手段,否则"效果好不好"永远是一笔糊涂账。
先说清楚 RAG 是什么。RAG(检索增强生成)是一套流程:用户问一个问题,系统先去知识库里检索几篇相关资料,再把资料拼进提示词,让大模型"对着资料作答"。它解决的核心痛点是大模型爱编造------你给它一份内部文档,它就能老老实实照着文档答,而不是凭空瞎编。
这套流程分成两个环节,每个环节都可能翻车,所以评估也必须拆成两层:
- 检索层:用户问"怎么退款",系统有没有把《退款规则》这篇文档捞上来?如果捞上来的是《积分规则》,后面大模型再厉害也答不对。
- 生成层:资料捞上来了,大模型有没有照着资料说?还是无视资料继续编?
大部分团队只盯着"最终答案对不对",但只看最终答案没法定位问题。答案错了,到底是检索没捞到对的东西,还是捞到了但大模型没用对?分不清,就没法针对性改进。所以正确的做法是:检索层用客观指标量化,生成层用 LLM 当裁判打分。
这篇文章要交付三样东西:
- 三个检索指标:Recall@K(该找的找回来多少)、MRR(正确的排在第几位)、Hit Rate(至少命中一个的查询占比)。
- 一个 LLM 裁判:让大模型自己评估"答案有没有忠于资料"(忠实度)。
- 一套完整可跑的代码:评测集 + 指标计算 + 检索评测 + LLM 裁判,全程只用内存向量库,不装任何外部数据库。
一个类比帮你记住:检索评估像批改"开卷考试",检查你有没有翻到正确的那一页;LLM 裁判像批改"有没有照着书抄对答案"。 两件事分开评,才能知道该修哪头。
二、底层原理到底怎么回事
要搞懂这三个指标,先得明白"评测集(golden set)"是什么。
评测集就是一份标准答案表。 每一行是一个测试问题(query),旁边标着"这个问题应该检索到哪几篇文档"(用文档的唯一 ID 表示)。比如:
| 问题 | 应该检索到的文档 ID |
|---|---|
| Java 里有哪些大模型开发框架? | doc-1, doc-2 |
| RAG 怎么减少大模型胡编乱造? | doc-4 |
有了这份标准答案,检索结果对不对就能对账了:系统实际返回的文档 ID,和标准答案里的 ID 做交集。
指标一:Recall@K(召回率)
Recall 回答的问题是"该找的,找回来了多少"。它不看排名,只看"在不在前 K 个结果里"。
公式:Recall@K = 前K个结果里命中的相关文档数 / 该问题总共应有的相关文档数
举个例子:某问题应该有 2 篇相关文档(doc-1、doc-2),系统返回前 3 个结果是 doc-1, doc-5, doc-3。命中了 doc-1,没命中 doc-2,所以 Recall@3 = 1/2 = 0.5。
这个指标适合回答"漏检"问题------知识库里明明有答案,但没被捞出来,用户的体验就是"它不会"。
指标二:MRR(Mean Reciprocal Rank,平均倒数排名)
MRR 回答的问题是"第一个对的结果,排在第几位"。位置越靠前分越高,因为它关心用户第一眼看到的东西对不对。
公式:先算每个问题的"倒数排名"(第一个相关文档排第 1 位得 1 分,第 2 位得 1/2,第 3 位得 1/3,一个都没找到得 0 分),再对所有问题求平均。
举个例子:某问题第一个相关文档排在第 2 位,那么这一个问题的 RR(倒数排名)就是 1/2 = 0.5。把一堆问题的 RR 平均起来,就是 MRR。
这个指标对"答案拼接场景"特别重要------RAG 里排在最前面的文档往往权重最高,如果对的文档总被挤到第 5、第 6 位,生成质量就上不去。
指标三:Hit Rate@K(命中率)
Hit Rate 回答的问题是"前 K 个结果里,至少捞到一个对的没有"。它是最宽松的指标------不看捞回来几个、不看排名,只要有就行。
公式:Hit Rate@K = 至少命中一个相关文档的问题数 / 总问题数
举例:10 个问题里,有 8 个问题的前 3 个结果里至少有一个相关文档,那 Hit Rate@3 = 80%。
这三个指标是从三个角度给检索打分,没有任何一个能单独说明问题,必须一起看:Hit Rate 高但 MRR 低,说明"能捞到但排得靠后";Recall 低说明"漏检严重"。
接下来是 LLM 裁判(LLM-as-Judge)的原理。
生成层的质量没法用上面那种"对 ID"的方式量化,因为"答案好不好"是语义判断,得靠人来读。但人工评测太贵、太慢,于是就有了"让大模型当裁判":把问题、检索到的资料、系统生成的答案三段一起喂给大模型,让它打分。
裁判的核心是忠实度(Faithfulness)------答案里说的每一句话,是不是都能在"资料"里找到依据。如果答案是"根据资料,退款需要 3 个工作日",而资料里写的是"退款 7 个工作日",那这个答案就是编造,忠实度低。
为什么用忠实度而不是"答案对不对"?因为"对不对"需要你有一份标准正确答案,成本极高;而"忠实度"只需要有"检索到的资料"(系统本来就有),裁判只需要比对"答案 vs 资料"即可,可以全自动跑。另外一个常用维度是相关性(Relevance)------答案有没有正面回答用户的问题,这个也能量化,后面会提。
一句话总结原理:检索层用"对 ID"的数学指标量化,生成层用"大模型读答案"来量化,两层分开,才能精准定位 RAG 的短板。
三、实战:手把手写代码
下面用 Spring Boot 4.1.1 + Spring AI 2.0.1(均为当前最新 GA 版)+ Java 21。全程用内存向量库 SimpleVectorStore,不依赖 Redis、Milvus 等外部服务,你只要填一个 OpenAI API Key 就能跑通。
3.1 依赖:pom.xml
关键点:用 spring-ai-bom 统一管理 Spring AI 版本,避免各子模块版本对不齐;starter 用 spring-ai-starter-model-openai,它一次性带来聊天模型(用于 LLM 裁判)和 Embedding 模型(用于向量化)。
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>rag-eval</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>rag-eval</name>
<description>RAG 检索质量评估 + LLM 裁判实战</description>
<properties>
<java.version>21</java.version>
<spring-ai.version>2.0.1</spring-ai.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<!-- Spring AI 2.0 的 OpenAI starter:同时提供 ChatModel 和 EmbeddingModel -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>${spring-ai.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3.2 配置:application.yml
配置 OpenAI 的 API Key、聊天模型(当裁判)和 Embedding 模型(做向量化)。
yaml
spring:
application:
name: rag-eval
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini
embedding:
options:
model: text-embedding-3-small
3.3 示例一:指标计算器(纯 Java,不依赖框架)
先写一个纯 Java 的指标计算类。它只吃两样东西:系统实际返回的文档 ID 列表,和标准答案里的相关 ID 集合。把指标计算独立出来,方便你写单元测试、到处复用,也方便理解每个公式到底在算什么。
java
package com.example.rageval;
import java.util.List;
import java.util.Set;
/**
* RAG 检索质量指标计算器。
* 输入:检索返回的文档 ID 列表 + 标准答案(该问题应有的相关文档 ID 集合)。
* 输出:Recall@K、倒数排名 RR、命中率 Hit@K。
*/
public final class RagMetrics {
private RagMetrics() {
}
/**
* 计算 Recall@K(召回率)。
* 含义:前 K 个结果里,命中了多少个"应该命中的文档",除以应该命中的文档总数。
* 值域 0~1,越接近 1 说明"该找的都找回来了",漏检越少。
*/
public static double recallAtK(List<String> retrievedIds, Set<String> relevantIds, int k) {
if (relevantIds.isEmpty()) {
return 0.0;
}
int limit = Math.min(k, retrievedIds.size());
Set<String> topK = Set.copyOf(retrievedIds.subList(0, limit));
long hitCount = topK.stream().filter(relevantIds::contains).count();
return (double) hitCount / relevantIds.size();
}
/**
* 计算单个问题的倒数排名 RR(Reciprocal Rank)。
* 含义:第一个相关文档排在第几位。第 1 位得 1 分,第 2 位得 1/2,第 3 位得 1/3,没找到得 0。
* MRR 就是对一堆问题的 RR 求平均。
*/
public static double reciprocalRank(List<String> retrievedIds, Set<String> relevantIds) {
for (int i = 0; i < retrievedIds.size(); i++) {
if (relevantIds.contains(retrievedIds.get(i))) {
return 1.0 / (i + 1);
}
}
return 0.0;
}
/**
* 计算 Hit Rate@K(命中率,单条)。
* 含义:前 K 个结果里,是否至少命中一个相关文档。
* 汇总时就是"命中的问题数 / 总问题数"。
*/
public static boolean hitAtK(List<String> retrievedIds, Set<String> relevantIds, int k) {
int limit = Math.min(k, retrievedIds.size());
Set<String> topK = Set.copyOf(retrievedIds.subList(0, limit));
return topK.stream().anyMatch(relevantIds::contains);
}
}
这段代码的关键行为解释:三个方法都只在"前 K 个结果"这个窗口内计算,窗口大小由 Math.min(k, retrievedIds.size()) 保证不会越界。Set.copyOf 会把前 K 个 ID 去重放进一个集合,这样"命中判断"就是一次集合查找,代码短、也够快------评测集通常就几十上百条,性能不是瓶颈。
3.4 示例二:检索评测服务(用内存向量库跑指标)
这个服务干三件事:把测试知识库灌进 SimpleVectorStore、定义评测集、跑检索并算出三个指标。注意一个细节:每篇文档的 metadata 里都带一个 doc_id ,评测时用这个字段对账,而不是用向量库自己生成的内部 ID------因为内部 ID 你控制不了,换个库、重启一次就可能变,而 doc_id 是你自己定的、稳定可控。
java
package com.example.rageval;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* 检索层评测服务:灌数据 -> 定义评测集 -> 跑检索 -> 算指标。
*/
@Service
public class RetrievalEvaluator {
private final VectorStore vectorStore;
public RetrievalEvaluator(EmbeddingModel embeddingModel) {
// 用内存向量库,无需安装任何外部数据库,开箱即跑
this.vectorStore = SimpleVectorStore.builder(embeddingModel).build();
ingest();
}
/**
* 灌入测试知识库。每篇文档用 metadata 里的 doc_id 作为唯一标识。
*/
private void ingest() {
List<Document> docs = List.of(
new Document("Spring AI 是 Spring 官方推出的 AI 应用开发框架,提供 ChatClient、EmbeddingModel、VectorStore 等核心组件,帮助 Java 开发者快速集成大模型。",
Map.of("doc_id", "doc-1")),
new Document("LangChain4j 是 Java 生态的 LLM 应用框架,提供对话、RAG、Agent 等能力,与 Spring Boot 集成良好。",
Map.of("doc_id", "doc-2")),
new Document("向量检索的核心是把文本转成 Embedding 向量,再用余弦相似度计算语义距离,距离越近表示含义越接近。",
Map.of("doc_id", "doc-3")),
new Document("RAG(检索增强生成)先检索相关资料,再把资料拼进提示词让大模型作答,能有效减少大模型胡编乱造。",
Map.of("doc_id", "doc-4")),
new Document("Redis 是一个高性能的内存数据库,常用于缓存、分布式锁、消息队列等场景。",
Map.of("doc_id", "doc-5"))
);
vectorStore.add(docs);
}
/**
* 评测集(golden set):每个问题对应"应该被检索到的文档 ID 集合"。
* 真实项目里这个集合至少要 50~100 条才有统计意义,这里为了演示放 4 条。
*/
public List<TestCase> goldenSet() {
return List.of(
new TestCase("Java 里有哪些大模型开发框架?", Set.of("doc-1", "doc-2")),
new TestCase("RAG 是怎么减少大模型胡编乱造的?", Set.of("doc-4")),
new TestCase("什么是向量检索?", Set.of("doc-3")),
new TestCase("Redis 能用来干什么?", Set.of("doc-5"))
);
}
/**
* 跑一遍完整评测,返回每个问题的明细 + 三个汇总指标。
*/
public EvalResult evaluate(int topK) {
List<TestCase> cases = goldenSet();
List<CaseResult> caseResults = new ArrayList<>();
double recallSum = 0.0;
double rrSum = 0.0;
int hitCount = 0;
for (TestCase tc : cases) {
List<Document> hits = vectorStore.similaritySearch(
SearchRequest.builder().query(tc.query()).topK(topK).build());
// 把检索结果转成我们自己定义的 doc_id 列表,才能和标准答案对账
List<String> retrievedIds = hits.stream()
.map(d -> (String) d.getMetadata().get("doc_id"))
.toList();
double recall = RagMetrics.recallAtK(retrievedIds, tc.relevantIds(), topK);
double rr = RagMetrics.reciprocalRank(retrievedIds, tc.relevantIds());
boolean hit = RagMetrics.hitAtK(retrievedIds, tc.relevantIds(), topK);
recallSum += recall;
rrSum += rr;
if (hit) {
hitCount++;
}
caseResults.add(new CaseResult(tc.query(), retrievedIds, recall, rr, hit));
}
int n = cases.size();
return new EvalResult(
caseResults,
recallSum / n, // 平均 Recall@K
rrSum / n, // MRR
(double) hitCount / n // Hit Rate@K
);
}
// ---- 内部数据结构(record) ----
public record TestCase(String query, Set<String> relevantIds) {
}
public record CaseResult(String query, List<String> retrievedDocIds,
double recall, double reciprocalRank, boolean hit) {
}
public record EvalResult(List<CaseResult> cases,
double meanRecall, double mrr, double hitRate) {
}
}
这段代码的关键行为解释:SimpleVectorStore.builder(embeddingModel).build() 构造一个内存向量库,add(docs) 时它内部会调用 Embedding 模型把每篇文档向量化再存起来。similaritySearch 则把查询向量化后做余弦相似度排序,返回按相似度降序的文档列表。因为 SimpleVectorStore 默认就是余弦相似度、默认返回的就是从高到低排序,所以我们直接取前 topK 个即可,符合"靠前的更相关"这个评测前提。
3.5 示例三:LLM 裁判评估答案忠实度
生成层的评估用 ChatClient 实现。核心思路:把"问题 + 检索到的资料 + 系统答案"三段拼进提示词,让大模型只输出一个 0~1 的小数当分数。温度要低(这里用默认的模型配置即可,生产建议设 0),prompt 要严格限定"只输出数字"。
java
package com.example.rageval;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Service;
/**
* LLM 裁判:用大模型评估答案质量(忠实度 Faithfulness)。
*/
@Service
public class LLMJudgeService {
private final ChatClient chatClient;
public LLMJudgeService(ChatClient.Builder chatClientBuilder) {
this.chatClient = chatClientBuilder.build();
}
/**
* 评估忠实度:答案是否完全基于"资料",有没有编造资料里不存在的内容。
* 返回 0~1 的分数:1.0 = 完全忠实,0.0 = 严重编造。
*/
public double judgeFaithfulness(String question, String context, String answer) {
String prompt = """
你是一个严格的评测裁判。请判断下面的"答案"是否完全基于"资料"得出,有没有编造资料里不存在的内容。
问题:%s
资料:
%s
答案:
%s
请只输出一个 0 到 1 之间的小数,表示忠实度分数:
- 1.0 表示答案完全基于资料,没有任何编造
- 0.0 表示答案与资料完全无关或严重编造
- 中间值表示部分基于资料
只输出数字,不要输出任何其他文字。
""".formatted(question, context, answer);
String content = chatClient.prompt()
.user(prompt)
.call()
.content();
try {
return Double.parseDouble(content.trim());
} catch (NumberFormatException e) {
// 模型偶尔不听话,返回原始输出便于排查,分数记 0
System.err.println("LLM 裁判输出无法解析为数字:" + content);
return 0.0;
}
}
}
这段代码的关键行为解释:Double.parseDouble(content.trim()) 前先 trim() 去掉模型可能多输出的换行和空格。catch 兜底是必须的------大模型即便被要求"只输出数字",偶尔也会补一句"好的,0.8",这时直接解析会抛异常。这里记 0 分并打印原始输出,方便你回头查是哪条 prompt 把模型带偏了。
3.6 入口类:把评测跑起来
最后写启动类,在 CommandLineRunner 里依次跑检索评测和 LLM 裁判,把结果打印到控制台。
java
package com.example.rageval;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
@SpringBootApplication
public class RagEvalApplication {
public static void main(String[] args) {
SpringApplication.run(RagEvalApplication.class, args);
}
@Bean
CommandLineRunner runEval(RetrievalEvaluator evaluator, LLMJudgeService judge) {
return args -> {
System.out.println("========== 检索质量评测(topK=3) ==========");
RetrievalEvaluator.EvalResult result = evaluator.evaluate(3);
for (RetrievalEvaluator.CaseResult c : result.cases()) {
System.out.printf("问题:%s%n", c.query());
System.out.printf(" 检索到:%s%n", c.retrievedDocIds());
System.out.printf(" Recall@3=%.2f RR=%.2f 命中=%s%n",
c.recall(), c.reciprocalRank(), c.hit());
}
System.out.printf("汇总:平均 Recall@3=%.2f | MRR=%.2f | HitRate@3=%.2f%n",
result.meanRecall(), result.mrr(), result.hitRate());
System.out.println();
System.out.println("========== LLM 裁判评估(忠实度) ==========");
double faithfulness = judge.judgeFaithfulness(
"RAG 是怎么减少大模型胡编乱造的?",
"RAG(检索增强生成)先检索相关资料,再把资料拼进提示词让大模型作答,能有效减少大模型胡编乱造。",
"RAG 会先检索资料再让大模型作答,所以能减少大模型凭空编造。");
System.out.printf("答案忠实度分数:%.2f(满分 1.0)%n", faithfulness);
};
}
}
跑起来的命令:
bash
export OPENAI_API_KEY=你的密钥
mvn spring-boot:run
你会在控制台看到每个问题的检索明细和三个汇总指标,最后是 LLM 裁判给答案打的忠实度分数。
四、踩坑经验和最佳实践
这一节是我在实际做 RAG 评估时踩过的坑,照着做能少走很多弯路。
坑一:评测集和开发数据混在一起,指标虚高。 如果你拿"已经调优过的那批文档"当评测集,指标当然好看,但那叫自欺欺人。正确做法是评测集要独立、要覆盖真实用户会问的问题,最好从线上日志里抽样真实 query,再人工标注标准答案。
坑二:靠向量库内部 ID 对账,结果不稳定。 向量库给你分配的 ID(比如 UUID)会随存储重启、重新导入而变化,评测结果就没法复现。所以一定要像本文这样,在 metadata 里放一个自己定义的 doc_id,用它对账。
坑三:topK 不固定,指标不可比。 Recall@3、Recall@5、Recall@10 是完全不同的三个数。两次评测如果 topK 都不一样,比出来的"进步"就是假的。每次评测前先定死 topK,并且和你线上真实取用的 topK 保持一致。
坑四:LLM 裁判不稳定,同一份答案两次打分可能不同。 大模型有随机性。缓解办法:把 temperature 设成 0,固定裁判用的 prompt,同一个问题跑 3 次取平均。如果分数波动仍然很大,说明这个裁判 prompt 本身写得不够明确,先修 prompt 再谈分数。
坑五:只看总分,不看明细。 汇总指标是平均值,会把问题藏起来。评测结果一定要保留每个问题的明细(本文的 CaseResult 就是干这个的),否则你只知道"MRR 降了",却不知道是哪类 query 掉下去了。
最佳实践清单:
- 评测集至少 50~100 条,才有统计意义;4 条只够演示。
- 检索层三个指标(Recall@K、MRR、Hit Rate)一起看,不单独下结论。
- 生成层至少评两个维度:忠实度(答案有没有编造)+ 相关性(答案有没有答非所问)。
- 换了 Embedding 模型、改了分块策略、换了向量库,都要重新跑一遍评测集。
- 把评测做成 CI 的一环,每次改检索逻辑自动出指标,防止"改好了 A 指标、改坏了 B 指标"。
五、性能对比和技术选型
评估方案本身也有选型问题,这里给几个实用结论。
检索层:客观指标优先,人工只做兜底。 Recall@K、MRR、Hit Rate 都是纯数学计算,零成本、可复现、毫秒级出结果,应该作为主力。人工评测只适合在"语义等价但 ID 不同"的边缘 case 上做抽查------比如两篇文档内容几乎一样,系统捞到了 A,标准答案写的是 B,指标会误判为漏检,这种才需要人看一眼。
生成层:LLM 裁判优先,人工抽检。 用大模型评估答案忠实度,成本远低于人工,且能全自动覆盖大批量答案。但 LLM 裁判本身有随机性和偏见(比如偏爱更长、更流畅的答案),所以建议每月人工抽检 5%~10% 的样本,校准 LLM 裁判的分数是否靠谱。
别用 BLEU/ROUGE 这类机器翻译指标评 RAG。 BLEU/ROUGE 是算"字面重合度"的,而 RAG 答案换一种说法表达同一个意思,字面可能完全对不上,用它们评会严重低估真实质量。语义层面的忠实度、相关性,交给 LLM 裁判更合适。
选型小结: 检索层用三指标(零成本、可复现),生成层用 LLM 裁判(低成本、可扩展),人工只在两头做校准和兜底。这是当前性价比最高的组合,也是业界主流做法。
六、总结
RAG 系统"效果好不好",不能靠感觉,必须靠量化评估。评估拆成两层:检索层用数学指标,生成层用 LLM 裁判,两层分开才能精准定位问题出在"没捞到"还是"没用对"。
检索层三个指标各有分工:Recall@K 看"该找的找回来多少",MRR 看"对的排在第几位",Hit Rate 看"至少捞到一个没有"。三个一起看,漏检、排序、空手而归三种毛病都能暴露出来。生成层用 LLM 当裁判评忠实度------答案有没有照着资料说、有没有编造,这是防止大模型"一本正经地胡说"的最后一道闸。
几个关键动作再强调一遍:评测集要独立、要够大(50 条以上);对账用自己定义的 doc_id,别用向量库内部 ID;topK 每次固定;LLM 裁判要低温、固定 prompt、必要时跑多次取平均。
全文代码基于 Spring Boot 4.1.1 + Spring AI 2.0.1 + Java 21,只用内存向量库,填一个 OpenAI API Key 就能跑通复现。把这套评估框架接进你的 RAG 项目,下次产品再问"效果怎么样",你可以直接甩三个数字加一个裁判分数过去,而不是一句"感觉还行"。