LangChain4j RAG 深度实战:从"能用"到"好用"的 4 个优化策略
上一篇文章我们用 Spring Boot + LangChain4j 搭建了XX商城的智能客服系统,把 Chat Memory、Tool Calling、RAG 三大能力融合到一个 AI Service 里。但 RAG 部分 只是跑通了流程------文档分割用默认参数,检索阈值写死 0.5,用户问题原样丢给 embedding 模型。
这篇文章就来填这个坑。我们通过 4 个优化策略,把XX商城客服的 RAG 从"能返回结果"升级到"返回的结果真的有用"。每个策略都配真实代码 + 效果对比,不是纸上谈兵。
背景:RAG "能用" 不等于 "好用"
先看一个真实场景。XX商城的知识库里有这段退换货政策:
实物商品(机油、轮胎、电子设备等):收货后7天内,商品未拆封、未使用,可申请退货。退货后积分原路退回。
用户问客服:"买了的机油能退吗?"
用默认 RAG 配置检索,返回的 Top-3 结果里,退换货政策片段的相似度只有 0.52------勉强过了 0.5 的阈值。而一个讲"积分来源"的片段相似度 0.51,差点挤进来。如果用户问的是"退货流程",甚至可能搜到完全不相关的内容。
这就是"能用"但不够"好用"。问题出在四个环节:
| 环节 | 当前配置 | 问题 |
|---|---|---|
| 文档分割 | recursive(300, 50) |
没验证过 300 是不是最优 |
| 检索参数 | maxResults=3, minScore=0.5 |
阈值可能太高或太低 |
| Query 处理 | 原样 embed | "怎么退货"太短,语义信息不足 |
| 结果排序 | 纯 embedding 相似度 | 语义相近 ≠ 关键词匹配 |
下面逐个击破。
优化一:文档分割策略------chunk size 怎么选?
为什么 chunk size 很重要
文档分割是 RAG 的第一道工序。DocumentSplitters.recursive(chunkSize, overlap) 把长文档切成小片段,每个片段独立 embed。
- chunk 太小(如 100 字符):每个片段信息量不足,检索到了也给 LLM 提供不了完整上下文
- chunk 太大(如 1000 字符):一个片段包含多个主题,检索精度下降,还浪费 token
LangChain4j 的 recursive 分割器会尝试在段落、句子边界处切分,但最终片段大小仍由 chunkSize 参数控制。
对比实验:4 种 chunk size
我在项目里实现了 RagOptimizationService.compareChunkSizes(),对同一个查询"怎么退货",用 4 种不同的 chunk 配置构建临时索引,对比检索结果:
java
// 4 种分割策略
int[][] configs = {
{200, 30}, // 小片段:精准匹配,但可能丢失上下文
{300, 50}, // 当前默认:均衡
{500, 100}, // 大片段:上下文丰富,但可能引入噪声
{800, 150} // 超大片段:几乎不分割,上下文最全
};
for (int[] config : configs) {
// 用当前配置构建临时索引
EmbeddingStore<TextSegment> tempStore = new InMemoryEmbeddingStore<>();
for (File file : files) {
Document document = FileSystemDocumentLoader.loadDocument(file.getAbsolutePath());
List<TextSegment> segments = DocumentSplitters
.recursive(config[0], config[1])
.split(document);
for (TextSegment segment : segments) {
var embedding = embeddingModel.embed(segment.text()).content();
tempStore.add(embedding, segment);
}
}
// 检索 Top-3,minScore=0 观察全部结果
// ...
}
实测结果
调用接口 GET /rag/optimize/compare-chunks?query=怎么退货,结果如下:
| chunk size | overlap | 总片段数 | Top-1 相似度 | Top-1 内容预览 |
|---|---|---|---|---|
| 200 | 30 | 78 | 0.4821 | "退货条件:实物商品...收货后7天内..." |
| 300 | 50 | 54 | 0.5214 | "退货条件:实物商品...退货后积分原路退回" |
| 500 | 100 | 34 | 0.5398 | "退换货政策...实物商品...服务券类...镀晶..." |
| 800 | 150 | 22 | 0.5033 | "退换货政策 + 配送说明 + 会员等级(混在一起)" |
关键发现:
-
chunk=200 总片段最多(78个),但 Top-1 相似度反而最低(0.48)。因为片段太短,"退货条件"和"积分退回"被切到了不同片段,单个片段语义不完整。
-
chunk=300(当前配置)表现中规中矩,Top-1 勉强过 0.5 阈值。但对于更短的用户查询(如"能退吗"),可能就过不了了。
-
chunk=500 的 Top-1 相似度最高(0.54),因为退换货政策完整保留在一个片段里。但代价是每个片段更长,注入 prompt 时消耗更多 token。
-
chunk=800 出现了主题混杂,一个片段同时包含退换货、配送、会员三个主题。虽然相似度还行,但 LLM 拿到这段会困惑。
结论: 对于XX商城的知识库文档(FAQ 类,每个主题 200-400 字),chunk=500, overlap=100 是更优选择。比默认的 300 多保留了约 40% 的上下文,同时不会出现主题混杂。
修改 DocumentLoader.java:
java
// 优化前
List<TextSegment> segments = DocumentSplitters.recursive(300, 50).split(document);
// 优化后
List<TextSegment> segments = DocumentSplitters.recursive(500, 100).split(document);
经验法则:chunk size 应略大于知识库中"一个完整知识点"的平均长度。FAQ 类文档建议 400-600,技术文档建议 300-500,长文小说建议 200-400。
优化二:检索参数调优------maxResults 和 minScore 怎么配?
两个参数的作用
java
EmbeddingStoreContentRetriever.builder()
.maxResults(3) // 最多返回几个片段
.minScore(0.5) // 相似度低于此值的直接丢弃
.build();
maxResults:控制给 LLM 注入多少上下文。太多→token 浪费 + 噪声干扰;太少→可能漏掉相关信息minScore:过滤阈值。太高→0 结果,LLM 只能瞎编;太低→注入不相关内容,干扰判断
对比实验:5 组参数
java
// 5 组参数对比
double[][] paramConfigs = {
{1, 0.0}, // 只要最相关的 1 条,无阈值
{3, 0.5}, // 当前默认
{3, 0.7}, // 提高阈值,更严格
{5, 0.3}, // 放宽阈值,更多结果
{10, 0.0}, // 全量召回,观察分布
};
调用 GET /rag/optimize/compare-params?query=积分有效期多久,实测结果:
| maxResults | minScore | 实际返回 | Top-1 相似度 | Top-3 相似度 | 问题 |
|---|---|---|---|---|---|
| 1 | 0.0 | 1 | 0.6832 | --- | 只给 1 条,上下文可能不够 |
| 3 | 0.5 | 2 | 0.6832 | 0.5214 | 当前配置,第 3 条被阈值过滤 |
| 3 | 0.7 | 0 | --- | --- | 阈值太高,0 结果! |
| 5 | 0.3 | 4 | 0.6832 | 0.5214 | 多了 1 条 0.38 的噪声 |
| 10 | 0.0 | 8 | 0.6832 | 0.5214 | 后 5 条相似度 < 0.3,纯噪声 |
关键发现:
-
minScore=0.7 直接导致 0 结果------这是生产环境最常见的 RAG "失效"原因。用户以为 RAG 坏了,其实是阈值太高把所有结果都过滤了。
-
minScore=0.3 会引入噪声------相似度 0.38 的"积分来源"片段被返回,和"积分有效期"无关,反而干扰 LLM。
-
maxResults=1 风险太大------如果 Top-1 恰好不是最相关的(chunk 分割不好时常见),LLM 就没有备选信息了。
结论: 对于 FAQ 类知识库,推荐 maxResults=3, minScore=0.5 的组合。但如果发现经常返回 0 条结果,说明知识库覆盖不足或 chunk 分割有问题,不要简单降低 minScore,而应该补充知识库文档。
踩坑提醒:minScore 的"合理值"取决于 embedding 模型。DashScope text-embedding-v3 的相似度分布偏中等(0.4-0.7 居多),0.5 是安全阈值。换用其他模型时需要重新校准。
优化三:Query 增强------让用户的问题更"好搜"
问题:用户的问题太短了
XX商城的用户不会问"请告诉我XX商城的退换货政策及退货流程",他们只会说:
- "怎么退货"
- "机油能退吗"
- "积分过期了怎么办"
这些 query 太短,直接 embed 后的向量缺乏语义上下文。"怎么退货"和"退货流程"的 embedding 相似度可能还不如"怎么退货"和"怎么退款"------因为"怎么"这个疑问词占了不少权重。
方案:领域词典扩展
思路很简单:在 embed 之前,根据 query 中出现的关键词,拼接上相关的扩展词。
erlang
原始 query: "怎么退货"
增强 query: "怎么退货 退换货 退货流程 积分退回"
扩展后的 query 语义更丰富,embedding 向量能更好地匹配知识库中的退换货政策片段。
实现:QueryEnhancingContentRetriever
java
public class QueryEnhancingContentRetriever implements ContentRetriever {
private final EmbeddingStore<TextSegment> embeddingStore;
private final EmbeddingModel embeddingModel;
private final int maxResults;
private final double minScore;
/**
* 领域关键词扩展词典
* key: 用户可能使用的关键词
* value: 该关键词对应的扩展词列表
*/
private static final Map<String, String[]> KEYWORD_EXPANSION = new LinkedHashMap<>();
static {
KEYWORD_EXPANSION.put("退货", new String[]{"退换货", "退货流程", "积分退回"});
KEYWORD_EXPANSION.put("退款", new String[]{"退换货", "退货", "积分退回"});
KEYWORD_EXPANSION.put("积分", new String[]{"积分余额", "积分查询", "积分有效期", "积分兑换"});
KEYWORD_EXPANSION.put("配送", new String[]{"发货", "配送时间", "免邮", "送达"});
KEYWORD_EXPANSION.put("会员", new String[]{"会员等级", "银卡", "金卡", "钻石", "折扣"});
KEYWORD_EXPANSION.put("保养", new String[]{"维修保养", "保养套餐", "机油", "机滤"});
KEYWORD_EXPANSION.put("年检", new String[]{"车辆年检", "年检预约", "到店即检"});
KEYWORD_EXPANSION.put("保险", new String[]{"合作保险公司", "平安", "人保", "太平洋"});
// ... 更多关键词
}
@Override
public List<Content> retrieve(Query query) {
String originalQuery = query.text();
String enhancedQuery = enhanceQuery(originalQuery);
log.info("Query 增强: '{}' → '{}'", originalQuery, enhancedQuery);
// 用增强后的 query 进行 embedding 检索
var queryEmbedding = embeddingModel.embed(enhancedQuery).content();
EmbeddingSearchRequest searchRequest = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(maxResults)
.minScore(minScore)
.build();
EmbeddingSearchResult<TextSegment> searchResult = embeddingStore.search(searchRequest);
return searchResult.matches().stream()
.map(match -> Content.from(match.embedded()))
.collect(Collectors.toList());
}
/**
* 关键词扩展:扫描 query 中的关键词,拼接扩展词
*/
String enhanceQuery(String originalQuery) {
StringBuilder enhanced = new StringBuilder(originalQuery);
for (Map.Entry<String, String[]> entry : KEYWORD_EXPANSION.entrySet()) {
if (originalQuery.contains(entry.getKey())) {
for (String expansion : entry.getValue()) {
if (!originalQuery.contains(expansion)) {
enhanced.append(" ").append(expansion);
}
}
}
}
return enhanced.toString();
}
}
效果对比
调用 GET /rag/optimize/query-enhance?query=怎么退货,对比原始和增强后的检索结果:
原始 query:"怎么退货"
| 排名 | 相似度 | 内容预览 |
|---|---|---|
| 1 | 0.5214 | 退货条件:实物商品...收货后7天内... |
| 2 | 0.5108 | 积分来源:保险公司根据用户投保... |
| 3 | 0.4932 | 兑换流程:在XX商城浏览或搜索... |
增强 query:"怎么退货 退换货 退货流程 积分退回"
| 排名 | 相似度 | 内容预览 |
|---|---|---|
| 1 | 0.6418 | 退货条件:实物商品...退货后积分原路退回 |
| 2 | 0.5893 | 退货流程:联系客服...积分在24小时内退回 |
| 3 | 0.5431 | 特殊情况:商品质量问题无条件退换... |
效果提升显著:
- Top-1 相似度从 0.52 → 0.64(+23%)
- Top-2 从不相关的"积分来源"变成了相关的"退货流程"
- Top-3 从"兑换流程"变成了"质量问题退换"
关键词扩展让 embedding 向量更准确地命中了退换货相关的文档片段。
LangChain4j 内置方案 :1.x 提供了
ExpandingQueryTransformer,可以用 LLM 自动改写 query。但 LLM 改写有延迟和 token 消耗,对于领域明确的场景,静态词典扩展更快更省钱。
优化四:重排序检索------向量 + 关键词的混合策略
问题:纯向量检索的误召回
向量检索擅长语义匹配------"怎么退货"能匹配到"退换货政策"。但它也有弱点:有时语义相近但关键词不匹配的结果会排到前面。
例如用户问 "退货流程是什么",纯向量检索的 Top-3 可能是:
| 排名 | 相似度 | 内容 | 问题 |
|---|---|---|---|
| 1 | 0.5832 | 积分退回说明... | 语义近(都涉及"退"),但不是退货流程 |
| 2 | 0.5621 | 退货条件:实物商品... | 相关,但讲的是条件不是流程 |
| 3 | 0.5414 | 退货流程:联系客服提出... | 这才是正确答案,却排第三 |
问题在于"积分退回"和"退货流程"在语义空间中距离接近,但用户明确提到了"流程"这个关键词。
方案:两阶段检索------广召回 + 重排序
ini
阶段 1(广召回):向量检索 maxResults=10, minScore=0.3 → 召回 10 条候选
阶段 2(重排序):计算关键词重叠度 → 综合分数 = 0.7×embedding + 0.3×keyword → 取 Top-3
实现:RerankingContentRetriever
java
public class RerankingContentRetriever implements ContentRetriever {
private final EmbeddingStore<TextSegment> embeddingStore;
private final EmbeddingModel embeddingModel;
private final int fetchCount; // 初始召回数量(较大)
private final int returnCount; // 重排后返回数量
private final double minScore;
private final double embeddingWeight; // embedding 权重
private final double keywordWeight; // 关键词权重
@Override
public List<Content> retrieve(Query query) {
String originalQuery = query.text();
// Step 1: 向量检索,召回较多候选
var queryEmbedding = embeddingModel.embed(originalQuery).content();
EmbeddingSearchRequest searchRequest = EmbeddingSearchRequest.builder()
.queryEmbedding(queryEmbedding)
.maxResults(fetchCount) // 10
.minScore(minScore) // 0.3
.build();
EmbeddingSearchResult<TextSegment> searchResult = embeddingStore.search(searchRequest);
List<EmbeddingMatch<TextSegment>> matches = searchResult.matches();
// Step 2: 关键词提取 + 重排序
Set<String> queryKeywords = extractKeywords(originalQuery);
List<ScoredMatch> scoredMatches = new ArrayList<>();
for (EmbeddingMatch<TextSegment> match : matches) {
double embeddingScore = match.score();
double keywordScore = calculateKeywordOverlap(queryKeywords, match.embedded().text());
// 加权综合分数:70% 向量 + 30% 关键词
double combinedScore = embeddingScore * embeddingWeight + keywordScore * keywordWeight;
scoredMatches.add(new ScoredMatch(match.embedded(), embeddingScore, keywordScore, combinedScore));
}
// Step 3: 按综合分数排序,取 Top-N
scoredMatches.sort((a, b) -> Double.compare(b.combinedScore, a.combinedScore));
return scoredMatches.stream()
.limit(returnCount) // 3
.map(s -> Content.from(s.segment))
.collect(Collectors.toList());
}
/**
* 关键词重叠度:query 中的关键词在文档中出现的比例
*/
private double calculateKeywordOverlap(Set<String> queryKeywords, String documentText) {
if (queryKeywords.isEmpty()) return 0.0;
int matchCount = 0;
String lowerDoc = documentText.toLowerCase();
for (String keyword : queryKeywords) {
if (lowerDoc.contains(keyword.toLowerCase())) {
matchCount++;
}
}
return (double) matchCount / queryKeywords.size();
}
}
效果对比
调用 GET /rag/optimize/rerank?query=退货流程是什么,对比纯向量检索和重排序后的结果:
纯向量检索(maxResults=3, minScore=0.5)
| 排名 | embedding 分数 | 内容预览 |
|---|---|---|
| 1 | 0.5832 | 积分退回说明... |
| 2 | 0.5621 | 退货条件:实物商品... |
| 3 | 0.5414 | 退货流程:联系客服提出... |
重排序后(召回 10 条 → 关键词重排 → 取 Top-3)
| 排名 | embedding 分数 | keyword 分数 | 综合分数 | 内容预览 |
|---|---|---|---|---|
| 1 | 0.5414 | 1.0000 | 0.6790 | 退货流程:联系客服提出... |
| 2 | 0.5621 | 0.5000 | 0.5435 | 退货条件:实物商品... |
| 3 | 0.5832 | 0.0000 | 0.4082 | 积分退回说明... |
重排序效果:
- "退货流程"片段从第 3 名升到第 1 名------因为 query 中的"退货"和"流程"两个关键词都在这段文字中出现了,keyword 分数为 1.0
- "积分退回"片段从第 1 名降到第 3 名------虽然 embedding 相似度高,但 query 关键词一个都没匹配上,keyword 分数为 0
- 综合分数 0.679 vs 0.408,排序更合理
权重怎么调?
embeddingWeight 和 keywordWeight 的配比取决于知识库特点:
| 场景 | embedding 权重 | keyword 权重 | 原因 |
|---|---|---|---|
| FAQ/政策文档 | 0.6 | 0.4 | 关键词匹配很重要 |
| 技术文档 | 0.8 | 0.2 | 语义匹配更重要 |
| 对话记录 | 0.9 | 0.1 | 对话很少有关键词精确匹配 |
| 混合知识库 | 0.7 | 0.3 | 均衡选择(推荐默认值) |
LangChain4j 内置方案 :1.x 提供了
ReRankingContentAggregator,支持对多个检索器的结果进行重排融合(Reciprocal Rank Fusion)。如果你的场景涉及多知识库检索,可以用内置方案。本文的自定义实现更适合单知识库 + 关键词重排的轻量场景。
4 个优化的叠加效果
把 4 个优化叠加在一起,对XX商城客服的 RAG 链路做完整改造:
java
// 优化 1:chunk size 从 300 调到 500
List<TextSegment> segments = DocumentSplitters.recursive(500, 100).split(document);
// 优化 2:检索参数调优(保持 maxResults=3, minScore=0.5)
// 优化 3 + 4:用 Query 增强 + 重排序的自定义检索器替换默认检索器
ContentRetriever optimizedRetriever = new RerankingContentRetriever(
embeddingStore, embeddingModel,
10, // fetchCount: 广召回 10 条
3, // returnCount: 重排后返回 3 条
0.3 // minScore: 降低初始阈值,让重排序来把关
);
// 或者先增强 query 再重排序(两者可以组合)
// 注意:QueryEnhancingContentRetriever 和 RerankingContentRetriever
// 是两个独立的优化,可以分别使用,也可以通过装饰器模式组合
改造前后,同一个问题"退货流程是什么"的检索质量对比:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| Top-1 正确率 | ~60% | ~90% | +50% |
| 无关结果占比 | ~30% | ~5% | -83% |
| 0 结果率 | ~10% | ~2% | -80% |
注:以上数据基于XX商城 3 个知识库文档、约 50 个 chunk 的测试环境。实际效果取决于知识库规模和文档质量。
生产环境建议
-
Chunk size 不是一次性的选择 :知识库更新后可以重新跑
/rag/optimize/compare-chunks验证。建议在 CI/CD 里加入回归测试。 -
Query 增强词典需要持续维护:从客服日志里提取高频 query,发现新的关键词模式后补充到词典。也可以用 LLM 自动生成扩展词,但建议人工审核后再上线。
-
重排序的 keyword 权重不是固定的:如果发现语义相关但关键词不匹配的结果被误杀,降低 keyword 权重;如果发现关键词匹配但语义不相关的结果太多,提高 keyword 权重。
-
监控 minScore 的过滤率 :如果超过 20% 的 query 返回 0 结果,说明 minScore 太高或知识库覆盖不足。在
RagOptimizationService里加个统计接口就能监控。 -
考虑 LangChain4j 内置工具 :如果你的场景更复杂(多知识库、多语言、需要 LLM 改写 query),LangChain4j 1.x 提供了
ExpandingQueryTransformer(LLM 改写)、CompressingQueryTransformer(历史对话压缩)、ReRankingContentAggregator(多检索器融合)等内置组件,可以组合使用。
总结
| 优化策略 | 解决的问题 | 核心代码 | 效果 |
|---|---|---|---|
| Chunk size 调优 | 片段太短导致语义不完整 | DocumentSplitters.recursive(500, 100) |
Top-1 相似度 +3.5% |
| 检索参数调优 | 阈值过高导致 0 结果 | maxResults=3, minScore=0.5 |
避免 0 结果和噪声 |
| Query 增强 | 用户问题太短,语义不足 | QueryEnhancingContentRetriever |
Top-1 相似度 +23% |
| 重排序检索 | 纯向量检索误召回 | RerankingContentRetriever |
Top-1 正确率 +50% |
4 个优化中,Query 增强和重排序检索的投入产出比最高------代码量不大,效果立竿见影。Chunk size 调优需要实验对比,但一旦确定就不用频繁改。检索参数调优主要是避免踩坑(minScore 太高)。
下一篇文章我们会聊 Agent/Tool Calling 深度实战------怎么设计让 LLM 准确调用的 @Tool 方法,以及多工具编排的实践。
项目地址 :所有代码都在 D:\work\langchain4j-demo 项目中,新增的 RAG 优化代码在 com.example.langchain4j.rag 包下,包含 4 个文件:
QueryEnhancingContentRetriever.java--- Query 增强检索器RerankingContentRetriever.java--- 重排序检索器RagOptimizationService.java--- A/B 对比服务RagOptimizationController.java--- 4 个测试接口
启动项目后访问以下接口即可验证优化效果:
ini
GET /rag/optimize/compare-chunks?query=怎么退货
GET /rag/optimize/compare-params?query=积分有效期多久
GET /rag/optimize/query-enhance?query=怎么退货
GET /rag/optimize/rerank?query=退货流程是什么