资讯动态

LangChain4j RAG精准命中利器:重排序(Rerank)实战指南

发布时间:2026/10/14 8:36:07 来源:尧图企业网站定制
最近在调一个 LangChain4j 的 RAG 问答项目困扰我最久的不是生成环节而是检索结果里明明躺着正确答案模型却抓了旁边一段噪音。后来我在检索和生成之间加了一层重排序Rerank整个系统的精准命中能力立刻上了一个台阶。这篇文章把我从原理排查到方案落地、再到实测避坑的完整过程写下来适合正在用 LangChain4j 做 RAG、但发现召回了却答不对的 Java 开发者参考。1. 为什么普通 RAG“给了文档却答不对”问题多半出在召回这层很多人的第一反应是模型不行换更大的模型、改 Prompt 措辞折腾一圈发现效果还是时好时坏。实际上RAG 的瓶颈经常不在生成而在召回——也就是我们把这些候选文档交给模型之前系统自己先筛选了一道筛出来的东西本身就不准。1.1 文档确实是检索来的但 AI 凭什么选它先看一个典型场景用户问订单已经支付但为什么还显示未发货知识库里正好有一段文档写着支付成功后系统会在 10 分钟内同步发货状态若超过 30 分钟仍未更新请联系客服核实支付回调。这一段理论上就是标准答案。但向量检索返回 Top 5 候选时另外四个块里可能混着支付方式有哪些订单状态字段说明发货地址修改流程这类内容。模型看到五个块都在它并不知道哪一块才是真正回答当前问题的很多时候它会把看起来相关的信息都揉进回答里。于是你会看到模型一本正经地告诉你支付成功后可以修改发货地址因为它把不相干的候选块当成了上下文背景。这个问题在 LangChain4j 里其实很常见因为默认的EmbeddingStoreContentRetriever就是一个取 TopK 块交给 LLM的过程。召回层只负责把数学上相似的向量挑出来它不负责判断这个块能否回答问题。1.2 Embedding 双塔的相似度度量语义近似不等于答案正确Embedding 模型通常是双塔结构用户查询和文档片段分别独立编码成向量然后算余弦相似度或点积。这种结构的好处是快能把海量文档提前向量化查询时只算一个向量和数据库里的向量做距离比较。但坏处也明显查询和文档在编码阶段完全不交互所有语义匹配都被压缩进一个向量里。举个例子查询怎么申请退款和文档退款申请入口在个人中心-订单-申请退款向量距离很近这没问题。但如果查询是我误操作点错了申请退款能不能取消文档里同时包含申请退款和取消退款两个片段双塔模型很可能因为申请退款四个字匹配度高把提交后将不能取消这一类片段也召回进来。这不是 Embedding 模型的 bug而是双塔结构的天花板它擅长捕捉主题相近不擅长捕捉精确的语义关系。正好 RAG 需要的恰恰是后者。1.3 候选块被“挤爆”的典型场景还有一类问题出在候选块撞车而不是候选块不相关。比如一个政策文档被切成了 20 个块其中有 8 个块都在讲补贴申请条件用户问的却是补贴到账时间。向量检索按相似度排序时这 8 个块可能全部排进前 5真正讲到账时间的块反而排在 12 位。这就是典型的召回偏科。单一来源、单一匹配逻辑很容易让某一块内容垄断候选名额而正确答案被挤到看不见的位置。多路召回能稍微缓解这个问题比如同时用向量召回、关键词召回、结构化字段召回再把多路结果合并。但合并之后哪块内容优先级更高又成了新问题这也是我后来认为重排序几乎是必选项的原因。2. 重排序不是锦上添花它是 RAG 的第二道闸门2.1 召回和重排序的分工逻辑如果把这个流程类比成人找工作Embedding 召回是简历初筛HR 根据几个关键词和学校背景筛出 100 份简历重排序是面试官面谈直接针对岗位要求提问看候选人能不能接住细节问题。初筛必须快、便宜所以可以采用粗糙的相似度匹配面谈需要准、深入所以要把查询和每份简历放到同一个上下文里仔细比对。重排序就是 RAG 的面试环节。重排序做的事情很简单对召回阶段返回的候选文档按照查询和候选片段之间真实的相关性重新打分然后按新分数取 TopN 交给大模型。它不做海量检索只对数量有限的候选做精排因此可以用更复杂的模型。在 LangChain4j 的标准 RAG 链路里ContentRetriever返回Content列表然后由RetrievalAugmentor把内容注入 Prompt。重排序可以插在这条链路的中间先让基础检索器返回一个较大的候选集合比如 20 或 50 条再用重排序模型压缩成 3 到 5 条。2.2 重排序为什么更“准”从双塔到交叉编码器这里要讲清楚一个关键概念重排序模型通常不是双塔结构而是交叉编码器结构。双塔是把查询和文档各自编码最后只做一次向量运算交叉编码器则是把查询和文档拼接成一个句子对一起送进模型让它们在每一层 Transformer 里充分交互。打个比方双塔的做法是两个人在两个房间分别写自我简介最后互相交换简介来打分交叉编码器的做法是两个人坐在同一张桌子上对话根据对话过程判断合不合适。后者显然能捕捉到更细的语义关联。具体到效果上交叉编码器能识别出查询中否定词和文档中动作的对应关系比如取消退款申请和退款申请提交后不支持取消它能看到取消是修饰退款申请的而双塔模型很可能把这两句话都判断成与退款相关。代价也很直接交叉编码器要比双塔慢几个数量级。用户查询来了之后不可能拿它跟知识库里 100 万个向量逐个拼接计算。所以行业标准做法一定是两阶段先快速召回缩小范围再对少量候选重排。2.3 成本账重排少量候选而不是替换召回做技术选型最容易犯的错是既然重排序准那我只用重排序不就行了。账不是这么算的。假设知识库有 10 万个片段每个片段平均 200 token。如果用交叉编码器对全部片段打分一次请求要处理 2000 万个 token 级别的计算量但只要先向量召回 Top 20再对这 20 条做重排序计算量只剩原来的五百分之一延迟完全可接受。我一般建议的候选规模是场景召回候选数重排序后保留知识库问答、客服 FAQ2035文档对话、长文分析305058高准确率要求的合规/医疗问答505召回候选太少会让重排序巧妇难为无米之炊候选太多又会让延迟和成本上升。常见的做法是先把它调成 20跑一遍测试集看答案命中率再逐步往上加。3. LangChain4j 基础链路让“文档进、结果出”先跑通讨论重排序之前还是要把 LangChain4j 的 RAG 基础链路搭好。下面的代码是简化后的核心逻辑省略了具体 Embedding 模型和向量库实现因为不同团队的选型差别很大。3.1 依赖与组件选型LangChain4j 的项目结构比较灵活langchain4j核心包定义了接口具体模型和向量库通过扩展包引入。一个典型的 RAG 项目需要三类组件文档加载器和文本切分器把 PDF、Word、Markdown 变成TextSegmentEmbedding 模型把文本变成向量EmbeddingStore负责存储向量并支持相似度检索代码里只需要面向接口编程具体实现可以替换。Maven 依赖大致长这样dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version1.x/version /dependency !-- 下面两个按实际选型填写分别对应 Embedding 模型和向量库 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-某embedding扩展/artifactId version1.x/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-某向量库扩展/artifactId version1.x/version /dependency我踩过的一个坑是版本对齐父langchain4j和扩展包的版本必须一致否则会出现接口找不到实现类这样的运行时错误。建议一开始就用同一个版本号管理不要混合搭配。3.2 离线入库加载-切分-向量化-存储把文档变成可检索的向量核心代码如下// 1. 加载文档 ListDocument documents DocumentLoader.load(filePath); // 2. 切分目标是每个 TextSegment 语义相对完整 TextSegmentSplitter splitter TextSegmentSplitter.builder() .maxSegmentSize(500) // 按字符或 token 计算按需调整 .maxOverlap(50) .build(); ListTextSegment segments splitter.split(documents); // 3. 批量向量化 ListEmbedding embeddings embeddingModel.embedAll(segments).content(); // 4. 入库 embeddingStore.addAll(embeddings, segments);切分这块一定要多说一句很多答不对的问题不是重排序能救回来的而是切分阶段就把答案劈成了两半。比如一个订单状态说明前半段在讲状态字段含义后半段在讲状态更新逻辑如果切分点正好落在支付成功会自动同步这个关键句中间模型和重排序都无能为力。我建议切分时按段落语义边界来而不是机械地每 500 个字符一刀切。可以先按标题、列表、段落拆分出语义块再对超长块做二次切分。3.3 在线检索EmbeddingStoreContentRetriever入库之后LangChain4j 用EmbeddingStoreContentRetriever作为默认检索器配置两个关键参数ContentRetriever retriever EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) .minScore(0.5) .build();maxResults决定了最终交给 LLM 多少个块minScore是相似度下限。这里有个容易误解的点maxResults直接设成 5不是取相关性最好的 5 个而是取相似度最高的 5 个。两者在大多数情况下一致但在相似度区分度不够时会有明显偏差。我的经验是基础检索器先把maxResults设置成 20配合稍低的minScore后面再用重排序压缩效果比直接取 5 好得多。把ContentRetriever接入AiServices后一个最简单的 RAG Assistant 就完成了Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .build();4. LangChain4j 接入重排序的三种实战方案LangChain4j 的重排序能力并不像 Embedding 那样开箱即用需要结合自己的模型服务封装一层。下面三种都是我在实际项目里验证过的姿势复杂度递增但适用场景不同。4.1 方案一包装 ContentRetriever最通用的做法这是我最推荐的做法写一个RerankContentRetriever它内部先调用基础检索器拿到候选再调用重排序服务最后返回精排后的内容。import dev.langchain4j.rag.content.Content; import dev.langchain4j.rag.content.retriever.ContentRetriever; import dev.langchain4j.rag.query.Query; public class RerankContentRetriever implements ContentRetriever { private final ContentRetriever baseRetriever; private final Reranker reranker; private final int candidateTopK; private final int finalTopK; public RerankContentRetriever(ContentRetriever baseRetriever, Reranker reranker, int candidateTopK, int finalTopK) { this.baseRetriever baseRetriever; this.reranker reranker; this.candidateTopK candidateTopK; this.finalTopK finalTopK; } Override public ListContent retrieve(Query query) { ListContent candidates baseRetriever.retrieve(query); return reranker.rerank(query.text(), candidates, finalTopK); } }Reranker是一个我自己定义的接口用来屏蔽不同重排序服务商的差异public interface Reranker { ListContent rerank(String query, ListContent candidates, int topN); }接一个 HTTP 服务时实现类大致长这样注意这里的 URL 和请求体结构只是示例要按实际服务调整public class HttpReranker implements Reranker { Override public ListContent rerank(String query, ListContent candidates, int topN) { // 组装请求参数 ListMapString, String docs candidates.stream() .map(c - Map.of(text, c.textSegment().text())) .toList(); MapString, Object body Map.of( query, query, documents, docs, top_n, topN ); // 发起 HTTP 请求得到每个候选的新分数 ListScoredContent scored httpClient.post(url, body); // 按分数排序取前 topN 个 return scored.stream() .limit(topN) .map(ScoredContent::content) .toList(); } }接入时只需要把原来的retriever换成包装后的实例ContentRetriever rerankRetriever new RerankContentRetriever( baseRetriever, reranker, 20, 5 );这个方案的好处是改动量最小。AiServices内部只要看到ContentRetriever接口就能工作我不用动原有的问答入口。4.2 方案二在检索结果上做后处理重排有些项目没有走AiServices的完整链路而是自己在 Service 层里手动拼 Prompt。这种情况下没必要强行引入ContentRetriever接口直接在后处理逻辑里重排就行。流程就是先调向量检索得到 Top 20 → 调重排序服务得到新排序 → 取前 5 个拼接进 Prompt。代码上把重排序当成一个普通工具方法调用反而更直白。后处理方式的缺点是不太容易统一管理。如果项目里多个接口都要走 RAG每个入口都写一遍同样的逻辑后面改重排序参数会很痛苦。我一般只在原型验证阶段用这种方式正式落地还是会收敛到方案一。4.3 方案三并行召回多路再统一重排当知识库来源复杂、查询类型多样时单靠向量召回容易漏。这时候我会并行跑多路召回再把结果合并交给重排序向量召回负责语义相似关键词/BM25 召回负责术语精确匹配结构化字段召回负责命中元数据比如分类、标签、更新时间多路结果合并后可能有重复也可能有排序冲突。重排序在这里的价值不只是精排还相当于一个仲裁者它把查询和所有候选重新比对给出一个统一的、跨召回源的分数。LangChain4j 里实现多路召回可以自己组装一个CompositeRetriever内部持有多个ContentRetriever把所有人的召回结果合并后交给RerankContentRetriever处理。顺序是先并行召回 → 合并去重 → 重排取 TopN。多路召回加上重排之后延迟肯定比单路高但召回覆盖率往往会提升一截。我建议把它作为二期优化方案先把单路加重排跑通再迭代多路。5. 一场小规模对比实验重排序到底把质量提升了多少重排序是不是真的有用数据说话。我在一个内部技术问答知识库上做了一组对照实验把过程写出来你可以直接套用同样的方法验证你自己的系统。5.1 测试集怎么建才不骗自己做评测最怕拍脑袋。我建测试集时花了三分之二的时间在这件事上标准是三条每个查询至少有一条必须命中的标准片段记录它在知识库里的位置加入一些易混淆片段和查询主题相近但不回答问题的内容用来考验排序的抗干扰能力加入完全的无关片段确保系统不会因为库里有相似内容就擅自回答比如查询如何修改已提交的报销单据标准答案是在原单据上点击撤回修改后重新提交若已审批通过需联系财务退回。易混淆片段是报销单据填写规范报销审批流程时长。这类查询最能反映真实使用中的痛点。我最终整理了 80 组查询每组标注了正确片段 ID 和干扰片段 ID。整个验证只用了不到半天时间但后面每次调参都有了依据。5.2 我用的评测指标MRR、HitRate、NDCGMRR 是第一个正确答案出现在第几位位置越靠前分越高。它最贴近实际体验因为 LLM 基本只会认真看前几个块。HitRateK 是正确答案是否出现在前 K 个结果里适合看整体覆盖率。NDCG 更精细一点它会把正确答案排在第 1 位和排在第 5 位区分开来而不只是算是否命中。我建议普通人至少盯住 MRR 和 HitRate5 两个指标。MRR 反映第一个正确答案有没有排到最前面HitRate5 反映重排序后 5 个块里有没有包含答案。5.3 实测结果与本该有的预期评测逻辑是先用基础向量检索直接取 Top 5记为 A 组再用向量检索取 Top 20经过重排序后取 Top 5记为 B 组。两组都交给同一个 LLM 生成回答最后逐个打分。方案MRRHitRate5答案可用率人工评估A向量检索 Top50.410.6658%B向量检索 Top20 重排 Top50.790.9487%B 组比 A 组在 MRR 上提升了近一倍HitRate 也明显改善。人工评估的答案可用率从 58% 涨到 87%这个差距在我肉眼感受上是从经常胡说变成了大多数时候靠谱。需要强调的是这个数字依赖我的测试集和知识库结构不代表所有场景都能提升这么多。如果查询本身高度规范、知识库片段区分度极强重排序的提升可能没那么明显。但如果你的查询口语化、知识库片段有大量相似内容重排序的红利通常很可观。6. 接入重排序后会踩的坑延迟、截断与重复重排序不是加一行代码就完事它会把新的性能和内容问题带进来。下面这几个坑我都是实际踩过之后才总结出来的。6.1 候选数量、延迟与模型体积的三角关系重排序慢这是绕不开的。候选从 10 涨到 50耗时不是线性涨因为交叉编码器要把查询和每个候选拼接计算候选多了整体延迟会很难看。我的经验是把候选数量控制在 20 以内通常不会让用户有明显感知超过 50 就要考虑异步处理和缓存了。另外重排序模型的体积和推理效率差别很大一个轻量模型在 CPU 上处理 20 条候选可能只要 100 毫秒换成大规模模型可能翻五倍以上。优先优化方向是在测试集上跑出候选数量-准确率曲线。如果 Top 20 重排后的准确率只比 Top 10 高 1 个百分点那不如直接用 Top 10 换延迟。6.2 长文本重排序的截断策略大多数重排序服务都有输入长度上限比如 512 token 或 1024 token。知识库里的片段动不动就超长直接送进去会被模型截掉后半段而正确答案可能恰好就在被截掉的部分。我踩过的一次事故是某个操作手册的片段开头全是版本修订记录正文答案在第二页。向量检索把它召回了重排序却因为只看到前半段版本记录而给它打了低分正确答案被挤掉了。解决方案是重排序前不要盲目截断先用粗粒度规则判断片段里哪部分更像正文。如果文档按章节结构存储优先用标题和段落开头如果结构不明确可以先把长片段按句子切分对每个子句单独和查询计算分数再聚合出片段分。聚合方式可以是取最大值也可以取头部几个句子的平均分。6.3 重复片段和相邻块去重向量切分时允许重叠加上多路召回机制同一个内容经常以不同形式出现在候选里。重排序会给这些重复内容打上相近的高分最后 Top 5 可能被同一个来源的 3 个重复块占据真正需要的信息反而被挤掉。我处理重复的思路分三层先做文本归一化去空格、统一全半角、把大小写归一再做相似度去重如果两个候选的 Embedding 余弦相似度超过 0.95只保留路别最高分的那一个最后记录来源位置如果文档是带页码/章节的同一来源的连续片段只保留最高分的一个去重要放在重排序之前还是之后都可以我习惯先做粗去重减少重排序服务的无效计算再重排最后再按来源做一次精去重。7. 不同团队可以怎么选重排序方案含决策要点重排序模型和服务的选择基本取决于三个约束数据能不能出内网、单次调用的成本预算、对延迟的容忍度。下面是我自己用的决策思路。7.1 三种可选路线路线适合场景优点需要注意商用重排序 API数据允许出网、追求快速上线效果稳定、接入快、无需运维有调用成本、数据链路要做安全评估本地开源重排序模型数据敏感、离线优先数据不出内网、可自主调优需要 GPU/CPU 推理资源初始投入高自己微调精排模型业务语义特殊、标准模型效果差贴合领域场景需要标注数据、训练和评测周期长商用 API 适合起步快速验证重排序对你的 RAG 是否有收益。验证有效之后再决定要不要本地化。我见过不少团队一上来就采购本地推理服务器结果发现重排序收益不明显白白多了一台机器要维护。反过来也有团队做了三个月产品才发现商用 API 的数据合规审批根本过不了方案推倒重来。7.2 落地时的模型更新与评测节奏重排序模型和 Embedding 模型一样需要跟着知识库内容变化迭代。我建议把这套评测做成自动化每次知识库有明显更新重新把测试集跑一遍记录重排序模型的分数分布如果分数普遍变低可能是知识库内容格式变化也可能是模型需要换了把线上用户的失败案例手动加入测试集让测试集始终覆盖真实痛点我个人的习惯是一个月跑一次完整评测每次只改一个变量要么调候选数量要么换重排序模型不要同时动多个参数否则出了问题根本定位不到原因。最后再提一个小建议如果你刚开始接触 LangChain4j 的 RAG 重排序不要一上来就追求最复杂的多路召回加交叉编码器。先用EmbeddingStoreContentRetriever取 Top 20再包一层最轻量的重排序接口把这条链路跑通、拿到自己的评测数据。重排序这件事真正的难点从来不是会不会调接口而是你知不知道自己的系统差在哪、改完之后到底有没有变好。先把评测基线立住后面每一步优化都会很踏实。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑