RAG 能跑≠跑得好。以下问题没有评估就无法回答:
- 检索是否召回了正确文档?
- 分块大小取多少合适?
- topK 应该设为几?
没有评估,调参就是盲猜。回答质量由检索和生成共同决定,检索错了,LLM 再强也救不回来。
1. 检索质量指标
| 指标 | 含义 | 公式 |
|---|---|---|
| Hit Rate | topK 里有没有命中 | 命中数 / 总用例数 |
| MRR | 命中结果排第几 | Σ(1/rank) / 总用例数 |
- Hit Rate 只看"有没有":第一名和第五名都算命中
- MRR 还看"排得好不好":第一名得 1 分、第二名 0.5 分、第五名 0.2 分
为什么两个都要看? 正确结果从第 1 掉到第 2,MRR 从 1→0.5 直接腰斩,Hit Rate 却纹丝不动。单看命中率会漏掉排序劣化的问题。
1.1 多少算合理
没有绝对标准,跟评估集质量、topK 设定、领域都强相关。topK=5 的常见场景下,有一组粗略经验区间可以拿来对照:
| 区间 | Hit Rate | MRR | 含义 |
|---|---|---|---|
| 不可用 | < 0.6 | < 0.4 | 检索基本没工作,先查文档摄入和分块逻辑 |
| 凑合 | 0.6 ~ 0.8 | 0.4 ~ 0.6 | 能用但 LLM 经常拿到不相关上下文,回答质量不稳定 |
| 可上线 | 0.8 ~ 0.9 | 0.6 ~ 0.8 | 大部分问题能找到正确文档且排在前列 |
| 优秀 | > 0.9 | > 0.8 | 检索质量到顶,剩下的优化空间在 Prompt 和生成 |
两条经验:
- topK 越大、Hit Rate 越容易高:topK=10 拿到 0.9 没什么稀奇,同 topK 下的 MRR 才更有信息量
- MRR 受 Hit Rate 上限约束:MRR 不可能超过 Hit Rate,所以 Hit Rate 还在 0.6 时去追 0.8 的 MRR 是徒劳,先把召回拉上去再说
2. 评估框架
src/main/java/com/albertstack/rag/evaluation/EvalCase.java
package com.albertstack.rag.evaluation;
public record EvalCase(
String question, // 评估用例的提问
String expectedContent // 期望检索结果中包含的文本片段
) {}src/main/java/com/albertstack/rag/evaluation/EvalResult.java
package com.albertstack.rag.evaluation;
public record EvalResult(
double hitRate, // 命中率:命中数 / 总用例数
double mrr, // 平均倒数排名:Σ(1 / 命中位置) / 总用例数
int totalCases,
int hits
) {
@Override
public String toString() {
return "EvalResult{hitRate=%.2f%%, mrr=%.4f, hits=%d/%d}"
.formatted(hitRate * 100, mrr, hits, totalCases);
}
}src/main/java/com/albertstack/rag/evaluation/RetrievalEvaluator.java
package com.albertstack.rag.evaluation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Component;
import java.util.List;
@Slf4j
@Component
@RequiredArgsConstructor
public class RetrievalEvaluator {
private final VectorStore vectorStore;
public EvalResult evaluate(List<EvalCase> cases, int topK) {
int hits = 0;
double mrrSum = 0;
for (var evalCase : cases) {
var results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(evalCase.question())
.topK(topK)
.build()
);
// 用 contains 而不是精确匹配,符合"找到相关段落即算命中"的直觉
for (int i = 0; i < results.size(); i++) {
if (results.get(i).getText().contains(evalCase.expectedContent())) {
hits++;
// i 从 0 起,加 1 后第一名得 1 分、第二名得 0.5 分
mrrSum += 1.0 / (i + 1);
break; // 一个用例只算排名最高的命中,避免重复计分
}
}
}
var result = new EvalResult(
(double) hits / cases.size(),
mrrSum / cases.size(),
cases.size(),
hits
);
log.info("评估结果: {}", result);
return result;
}
}3. 跑一次评估
评估用例必须基于你实际摄入的文档来编写,不能凭空想象。
src/test/java/com/albertstack/rag/evaluation/RetrievalEvaluatorTest.java
package com.albertstack.rag.evaluation;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
@Slf4j
@SpringBootTest
class RetrievalEvaluatorTest {
@Autowired
RetrievalEvaluator evaluator;
@Test
void shouldEvaluateRetrievalQuality() {
var cases = List.of(
new EvalCase("Spring AI 支持哪些向量数据库?", "Qdrant"),
new EvalCase("Ollama 怎么管理模型?", "ollama"),
new EvalCase("RAG 的核心思想是什么?", "检索"),
new EvalCase("ChatClient 怎么使用?", "ChatClient"),
new EvalCase("Embedding 是什么?", "向量")
// 实际项目里建议准备 20-50 个用例以获得统计意义
);
var result = evaluator.evaluate(cases, 5);
log.info("命中率={}, MRR={}", result.hitRate(), result.mrr());
}
}4. 对比不同参数
分块大小、topK、similarityThreshold 都用同一套方法做对比实验:固定其他参数,遍历目标参数的几个候选值,看 Hit Rate 和 MRR 怎么变。最简单的做法是用 @ParameterizedTest + @ValueSource 把候选值喂给同一个评估方法,每次的结果 log 出来对比。
不要去抄网上的"最佳分块大小"。中文技术文档、英文论文、对话日志的甜点都不一样,必须用自己的数据跑一遍才知道。可以根据调参顺序,影响大的先调:
分块大小 -> topK -> similarityThreshold -> Prompt 模板
5. LLM-as-Judge
检索是基础,回答质量还受生成环节影响。最可靠的评估是人工打分,但不可扩展。折中方案是用另一个 LLM 当评审。
src/main/java/com/albertstack/rag/evaluation/AnswerEvaluator.java
package com.albertstack.rag.evaluation;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.stereotype.Component;
@Slf4j
@Component
@RequiredArgsConstructor
public class AnswerEvaluator {
private final ChatClient chatClient;
public double scoreAnswer(String question, String answer, String expectedPoints) {
// 在 prompt 里明确给出 5 档评分标准,避免 LLM 用自己的标准随意打分
String prompt = """
你是一个严格的评估专家。请评估以下 RAG 系统的回答质量。
评分标准(1-5 分):
- 5 分:完全正确,覆盖所有要点,没有幻觉
- 4 分:基本正确,覆盖主要要点
- 3 分:部分正确,遗漏了一些要点
- 2 分:大部分不正确或不相关
- 1 分:完全错误或答非所问
问题:%s
期望要点:%s
实际回答:%s
只返回一个数字(1-5),不要任何解释。
""".formatted(question, expectedPoints, answer);
String score = chatClient.prompt().user(prompt).call().content();
try {
return Double.parseDouble(score.trim());
} catch (NumberFormatException e) {
// LLM 偶尔不听话返回长篇解释,给个中性分而不是抛异常打断流程
log.warn("LLM 返回了非数字评分: {},默认给 3 分", score);
return 3.0;
}
}
}用一个明显正确的回答和一个明显错误的回答各跑一次,对比 LLM 评审给出的分数。
src/test/java/com/albertstack/rag/evaluation/AnswerEvaluatorTest.java
package com.albertstack.rag.evaluation;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
@Slf4j
@SpringBootTest
class AnswerEvaluatorTest {
@Autowired
AnswerEvaluator answerEvaluator;
@Test
void shouldScoreAnswerQuality() {
String question = "Spring AI 支持哪些向量数据库?";
String expectedPoints = "Qdrant、Pinecone、Milvus、PGVector 等主流向量数据库,通过统一的 VectorStore 抽象屏蔽差异";
// 用一对反差明显的答案做对照:好答案应该接近 5 分,差答案应该接近 1 分
String goodAnswer = "Spring AI 通过 VectorStore 抽象支持 Qdrant、Pinecone、Milvus、PGVector 等主流向量数据库,切换实现只需改配置。";
String badAnswer = "Spring AI 是一个前端可视化框架,主要用来画图表。";
log.info("好答案: {}", goodAnswer);
double goodScore = answerEvaluator.scoreAnswer(question, goodAnswer, expectedPoints);
log.info("好答案得分: {}", goodScore);
log.info("差答案: {}", badAnswer);
double badScore = answerEvaluator.scoreAnswer(question, badAnswer, expectedPoints);
log.info("差答案得分: {}", badScore);
}
}跑这个测试时重点看两件事:好答案和差答案的得分差距是不是足够大;同样的输入跑几次,分数会不会大幅波动。差距小或波动大,都说明 prompt 还需要再约束。
6. 问题定位
回答质量不好时,先看检索结果再判断问题出在哪一环:
var results = vectorStore.similaritySearch(
SearchRequest.builder().query("你的测试问题").topK(5).build()
);
results.forEach(doc -> log.info("score={}, text={}",
doc.getScore(), doc.getText().substring(0, Math.min(100, doc.getText().length()))));- 检索结果里 有 正确文档 -> 问题在生成环节,调 Prompt 模板,强调 "仅基于上下文回答"
- 检索结果里 没有 正确文档 -> 问题在检索环节,按上面的调参顺序排查
7. 本章小结
搭起一套最小可用的评估框架:用 Hit Rate 和 MRR 量化检索质量,用对比实验找最优参数,用 LLM-as-Judge 兜底端到端答案质量。核心原则是先建评估集,再调参数,用数据说话。
| 知识点 | 说明 |
|---|---|
| Hit Rate / MRR | 前者看"有没有",后者看"排第几",配合使用 |
RetrievalEvaluator |
包装 vectorStore.similaritySearch,遍历用例统计命中和排名 |
| 对比实验 | 固定其他参数,遍历目标参数候选值,记录 Hit Rate 和 MRR 变化 |
| LLM-as-Judge | 用 ChatClient 给答案打 1-5 分,开发阶段快速验证端到端质量 |
| 调参顺序 | 分块大小 -> topK -> 阈值 -> Prompt,影响大的先调 |
接下来回顾整个专栏的路径,梳理架构全景图,并展望进阶方向。