LangChain4j RAG 深度实战:从"能用"到"好用"的 4 个优化策略

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 "退换货政策 + 配送说明 + 会员等级(混在一起)"

关键发现:

  1. chunk=200 总片段最多(78个),但 Top-1 相似度反而最低(0.48)。因为片段太短,"退货条件"和"积分退回"被切到了不同片段,单个片段语义不完整。

  2. chunk=300(当前配置)表现中规中矩,Top-1 勉强过 0.5 阈值。但对于更短的用户查询(如"能退吗"),可能就过不了了。

  3. chunk=500 的 Top-1 相似度最高(0.54),因为退换货政策完整保留在一个片段里。但代价是每个片段更长,注入 prompt 时消耗更多 token。

  4. 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,纯噪声

关键发现:

  1. minScore=0.7 直接导致 0 结果------这是生产环境最常见的 RAG "失效"原因。用户以为 RAG 坏了,其实是阈值太高把所有结果都过滤了。

  2. minScore=0.3 会引入噪声------相似度 0.38 的"积分来源"片段被返回,和"积分有效期"无关,反而干扰 LLM。

  3. 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,排序更合理

权重怎么调?

embeddingWeightkeywordWeight 的配比取决于知识库特点:

场景 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 的测试环境。实际效果取决于知识库规模和文档质量。


生产环境建议

  1. Chunk size 不是一次性的选择 :知识库更新后可以重新跑 /rag/optimize/compare-chunks 验证。建议在 CI/CD 里加入回归测试。

  2. Query 增强词典需要持续维护:从客服日志里提取高频 query,发现新的关键词模式后补充到词典。也可以用 LLM 自动生成扩展词,但建议人工审核后再上线。

  3. 重排序的 keyword 权重不是固定的:如果发现语义相关但关键词不匹配的结果被误杀,降低 keyword 权重;如果发现关键词匹配但语义不相关的结果太多,提高 keyword 权重。

  4. 监控 minScore 的过滤率 :如果超过 20% 的 query 返回 0 结果,说明 minScore 太高或知识库覆盖不足。在 RagOptimizationService 里加个统计接口就能监控。

  5. 考虑 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=退货流程是什么
相关推荐
俊哥V1 小时前
每日 AI 研究简报 · 2026-08-07
人工智能·ai
调试到凌晨1 小时前
免费配音工具前置验证价值分析:音色选型、参数调优、克隆测试的零成本方案
人工智能·经验分享·实时音视频
badhope1 小时前
配了三天Agent开发环境踩了18个坑,我总结了一份零冲突配置指南
人工智能·agent
武子康1 小时前
Code Mode 什么时候更省:别只数工具调用,要数模型往返
人工智能·llm·agent
HIT_Weston1 小时前
165、【Agent】【OpenCode】TuiThreadCmd(代理 Fetch 实现)
人工智能·agent·opencode
骄阳如火1 小时前
论文撰写SKILLS实测三|PaperSpine:每个阶段都是带硬关卡的 gate,审计能直接 BLOCK 你
人工智能
badhope1 小时前
用RAG做了个智能客服,上线第一天就被用户骂了——我的7天实战复盘
人工智能·langchain
dunge20262 小时前
2026年8月更新:ChatGPT与Codex开发实践——从工具使用到AI工程工作流,开发者如何建立长期生产力体系(GPT-5.6技术分享)
人工智能·gpt·chatgpt
硅基流动2 小时前
OPC 南川:超级个体的疯狂探索|开发者说
人工智能