这事得从三个月前说起。
公司内部有个知识库系统,存了几千篇技术文档、产品手册、运维手册。领导说:"你不是在搞AI吗?搞个智能问答,让员工直接用自然语言搜文档。"
我想着这不是RAG的典型场景嘛,Spring AI + 向量库,搭起来不复杂。第一版三天就上线了。
然后就被打脸了。
准确率大概只有60%左右——十个问题里有四个答非所问或者胡编乱造。有个同事问"数据库连接池怎么配",AI回答了一堆数据库索引优化的内容。还有一个同事问"线上Redis挂了怎么办",AI给出了一个Redis安装教程。
更尴尬的是,领导亲自试了一下,问了"本季度技术部的OKR是什么",AI回答:"我是一个AI语言模型,无法获取公司OKR信息。"领导当时的表情我现在想起来都觉得尴尬。
接下来两个月,我几乎把所有精力都放在了RAG优化上。准确率从60%提到了95%。下面是真正有效的6个优化手段,每一个都是踩了坑才想明白的。
优化1:切块策略——不是越小越好
第一版我用的是固定长度切块,每个块500 token。这是网上大部分教程的默认做法。
问题是文档被切得稀碎。比如一篇讲"数据库连接池配置"的文档,内容是这样的:
## 连接池配置
连接池最大连接数建议设置为CPU核心数的2-3倍。
公式:maxPoolSize = cpuCores * 2
## 索引优化
对于高频查询字段,建议添加B+树索引...
500 token的切块刚好把"连接池配置"和"索引优化"切成了一块。用户问"连接池怎么配",检索到这个块,但里面一半内容是索引优化的,AI就被干扰了。
改用按标题切分:
// 自定义切分策略:按Markdown标题切分
public class HeadingBasedSplitter implements DocumentTransformer {
@Override
public List<Document> apply(List<Document> documents) {
List<Document> result = new ArrayList<>();
for (Document doc : documents) {
String text = doc.getText();
// 按一级和二级标题切分
String[] sections = text.split("(?=^#{1,2} )", -1);
for (String section : sections) {
if (section.trim().length() > 50) {
result.add(new Document(section.trim(), doc.getMetadata()));
}
}
}
return result;
}
}
按语义边界切分后,"连接池配置"和"索引优化"就各自独立了。单这一项改动,准确率就提升了8个百分点。
经验:切块策略是最容易被忽视的,但它的影响远大于模型选择和prompt工程。不同类型的文档需要不同的切法——Markdown按标题切,PDF按段落切,代码按函数切。一刀切的固定长度是最差的方案。
优化2:检索阶段加Rerank——效果立竿见影
第一版的流程是:用户提问 → 向量检索top 5 → 直接丢给大模型。
问题在于向量检索的排序质量参差不齐。有时候top 1就是最相关的,有时候最相关的排在top 4或5。而且向量相似度高不代表语义相关——有些文档只是"用了相似的词"但"讲的不是一回事"。
加一层Rerank(重排序)模型:
@Service
public class RagService {
private final VectorStore vectorStore;
private final ChatClient chatClient;
private final EmbeddingModel embeddingModel;
public String ask(String question) {
// Step 1: 向量检索,多召回一些(top 20)
var candidates = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(20)
.similarityThreshold(0.6)
.build()
);
// Step 2: Rerank——用交叉编码器重新打分
var reranked = rerank(question, candidates, 5); // 取top 5
// Step 3: 拼接上下文,调用大模型
String context = reranked.stream()
.map(Document::getText)
.collect(Collectors.joining("\n\n---\n\n"));
return chatClient.prompt()
.system("""
基于以下参考资料回答问题。要求:
1. 只使用参考资料中的信息
2. 如果资料中没有答案,说"文档中没有相关信息"
3. 不要编造答案
""")
.user("参考资料:\n" + context + "\n\n问题:" + question)
.call()
.content();
}
// Rerank:用更精确的交叉编码器重新打分
private List<Document> rerank(String query, List<Document> docs, int topK) {
return docs.stream()
.map(doc -> Map.entry(doc, computeRelevanceScore(query, doc.getText())))
.sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
.limit(topK)
.map(Map.Entry::getKey)
.toList();
}
private double computeRelevanceScore(String query, String docText) {
// 用交叉编码器模型计算query和document的相关性
// 比向量余弦相似度更精确
return crossEncoderModel.score(query, docText);
}
}
Rerank用的模型是bge-reranker-v2-m3,本地部署的,延迟在30-50ms,完全可接受。
这一步改动让准确率从68%直接跳到79%。是我所有优化里投入产出比最高的一个。
优化3:Hybrid检索——向量不够,关键词来凑
纯向量检索有个问题:对专有名词和精确匹配不敏感。
比如用户问"ZK集群怎么扩容",向量检索可能返回一堆讲"Zookeeper原理"的文档,但用户要的是"扩容操作步骤"。因为向量关注的是语义相似度,而不是精确的关键词匹配。
解决方案是Hybrid检索——向量检索 + BM25关键词检索,融合两者的结果:
public List<Document> hybridSearch(String query) {
// 向量检索
var vectorResults = vectorStore.similaritySearch(
SearchRequest.builder().query(query).topK(20).build()
);
// BM25关键词检索(用Elasticsearch)
var bm25Results = elasticsearchClient.search(s -> s
.query(q -> q.match(m -> m.field("content").query(query)))
.size(20),
Document.class
).hits().hits().stream().map(h -> h.source()).toList();
// Reciprocal Rank Fusion(RRF)融合
return reciprocalRankFusion(vectorResults, bm25Results, 5);
}
// RRF算法:综合两路检索的排名
private List<Document> reciprocalRankFusion(
List<Document> vectorResults,
List<Document> bm25Results,
int topK) {
Map<String, Double> scores = new HashMap<>();
Map<String, Document> docMap = new HashMap<>();
// RRF公式:score = 1 / (k + rank)
int k = 60; // 经验值
for (int i = 0; i < vectorResults.size(); i++) {
String id = vectorResults.get(i).getId();
scores.merge(id, 1.0 / (k + i + 1), Double::sum);
docMap.put(id, vectorResults.get(i));
}
for (int i = 0; i < bm25Results.size(); i++) {
String id = bm25Results.get(i).getId();
scores.merge(id, 1.0 / (k + i + 1), Double::sum);
docMap.putIfAbsent(id, bm25Results.get(i));
}
return scores.entrySet().stream()
.sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
.limit(topK)
.map(e -> docMap.get(e.getKey()))
.toList();
}
Hybrid检索让专有名词查询的准确率提升了很多。从79%到86%。
优化4:元数据过滤——别什么都往context里塞
这个问题是这样的:用户问"Java服务的日志在哪看",向量检索返回了"Java服务日志配置"、"Python服务日志配置"、"运维日志查看指南"等文档。用户只要Java的,但检索结果里混进了一堆不相关的。
解决方案是在检索时加元数据过滤。每个文档入库时打上标签:
// 入库时打标签
public void importDocument(String content, Map<String, String> metadata) {
Document doc = new Document(content, metadata);
vectorStore.add(List.of(doc));
}
// 检索时按元数据过滤
var results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(10)
.filterExpression("language == 'java' && type == 'guide'") // 只查Java指南类文档
.build()
);
但这里有个鸡生蛋蛋生鸡的问题——用户的原始提问里不一定包含过滤信息。用户就是问"日志在哪看",你得先判断他想查哪个语言的。
我用了一个两阶段的方法:先用LLM提取查询意图和过滤条件,再带着过滤条件做检索:
public String ask(String question) {
// Phase 1: 让LLM分析问题,提取检索策略
var queryAnalysis = chatClient.prompt()
.system("""
分析用户问题,提取以下信息(JSON格式):
- searchQuery: 优化后的检索关键词
- filters: 元数据过滤条件(如果有)
- intent: 用户意图分类(配置/操作/排障/概念)
""")
.user(question)
.call()
.content();
// 解析LLM返回的检索策略
QueryStrategy strategy = parseStrategy(queryAnalysis);
// Phase 2: 带策略检索
var docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(strategy.searchQuery())
.topK(20)
.filterExpression(strategy.filterExpression())
.build()
);
// Phase 3: Rerank + 生成
// ...
}
加了一步LLM意图分析后,检索准确率从86%到91%。代价是多了一次LLM调用,延迟增加了300ms左右。对我们来说可以接受。
优化5:回答溯源——让AI说清楚它从哪看到的
这一步不是提升准确率的,而是提升可信度的。
RAG最大的问题是用户不知道AI的回答从哪来的。AI说"连接池最大连接数建议设为CPU核心数的2-3倍",用户怎么知道这是文档里写的还是AI编的?
给每个回答加上来源引用:
// 在prompt里要求AI标注来源
return chatClient.prompt()
.system("""
基于以下参考资料回答问题。
回答时必须在每个观点后标注来源编号,如[1]、[2]。
参考资料按编号排列。
如果资料中没有答案,说"文档中没有相关信息"。
""")
.user("参考资料:\n" + formatContextWithIds(reranked)
+ "\n\n问题:" + question)
.call()
.content();
// 输出示例:
// 连接池最大连接数建议设为CPU核心数的2-3倍[1]。
// 公式为:maxPoolSize = cpuCores * 2[1]。
// 如果连接数不够,会出现"ConnectionTimeout"异常[3]。
然后在前端把[1]、[2]渲染成可点击的链接,点击跳转到对应文档。
这一步虽然不影响准确率数字,但对用户体验的提升很大。用户能验证AI说的是不是真的,出了问题也知道去哪个文档查。
优化6:建立评测集——没有度量就没有优化
最后这个优化不是技术手段,是方法论。
之前我说"准确率从60%到95%",这个数字怎么来的?凭感觉吗?
不是。我花了一周时间,整理了200个问答对作为评测集。这些问题来自真实用户提问、客服记录、以及我自己编的边界case。每个问题都有标准答案。
每次改了RAG的任何环节(切块策略、检索参数、prompt),就跑一遍这200题,看准确率变化:
// 评测脚本
public class RagEvaluator {
public EvaluationResult evaluate(List<TestCase> testCases) {
int correct = 0;
int partial = 0;
int wrong = 0;
List<String> failures = new ArrayList<>();
for (TestCase tc : testCases) {
String answer = ragService.ask(tc.question());
String grade = gradeAnswer(answer, tc.expectedAnswer());
switch (grade) {
case "correct" -> correct++;
case "partial" -> partial++;
case "wrong" -> {
wrong++;
failures.add(String.format("Q: %s\nExpected: %s\nGot: %s",
tc.question(), tc.expectedAnswer(), answer));
}
}
}
return new EvaluationResult(correct, partial, wrong, failures);
}
// 用LLM做自动评分(节省人工)
private String gradeAnswer(String actual, String expected) {
return chatClient.prompt()
.system("""
判断AI回答是否正确。标准答案和AI回答如下。
评分标准:
- correct: 回答包含了标准答案的核心信息
- partial: 部分正确,但有遗漏或不精确
- wrong: 完全错误或编造了信息
只返回一个词:correct/partial/wrong
""")
.user("标准答案:" + expected + "\nAI回答:" + actual)
.call()
.content();
}
}
有了评测集之后,每次优化都能量化效果。之前是"感觉好了一点",现在是"准确率从83%到88%,主要改善在专有名词查询场景"。
这一步是最重要的。 没有评测集,你所有的优化都是盲人摸象。
最终的准确率变化曲线
第一版(固定切块 + 向量检索) 60%
+ 按标题切分 68%
+ Rerank重排序 79%
+ Hybrid检索(向量+BM25) 86%
+ 元数据过滤+意图分析 91%
+ 答案溯源(不影响准确率,提升信任度) 91%
+ 评测集驱动迭代 95%
95%够用吗?对我们来说够了。剩下的5%是那些知识库里确实没有答案的问题,AI正确地说了"文档中没有相关信息"——这本身就是正确行为。
我的一点体会
搞了两个月RAG,最大的感悟是:RAG的瓶颈不在模型,在工程。
用GPT-4还是DeepSeek、用哪个embedding模型,这些当然有影响,但都是几个百分点的差异。真正的差距来自切块策略、检索质量、过滤精度这些工程细节。
很多人一上来就折腾prompt工程,调temperature、改system prompt。但如果你喂给模型的上下文本身就是垃圾,prompt写得再花哨也没用。
Garbage in, garbage out. 这句话放在RAG里再合适不过了。