LangChain4j RAG重排序实战:让AI精准命中相关文档
搞RAG的Java开发者十有八九都遇到过这个问题文档切好了向量库建好了AiServices也跑起来了结果问它一个稍微带点限定词的问题回你的答案里经常掺着一段不太相关的段落甚至直接答非所问。排在前面的几条“相关文档”细看之下只是关键词撞上了语义上完全不在一个频道上。这就是我写这篇LangChain4j RAG检索重排序实战的动机——把检索链路里最值得投入的一环也就是重排序拆开讲清楚让AI真正精准命中相关文档。这篇内容适合用LangChain4j做RAG落地、但总觉得回答质量不够稳的Java开发者也适合想弄明白“为什么向量检索明明召回了答案却不准确”的朋友。看完你就能自己接上重排序而不是一直靠调Prompt碰运气。1. 先说清楚一次向量检索其实远远不够1.1 向量召回为什么“以为相关其实不相关”很多朋友第一次做RAG时都会兴奋地觉得把文档切成块、转成向量、塞进向量数据库就万事大吉了。但真正跑起来才发现向量相似度和“语义相关”根本是两码事。Embedding模型做的事情是把一段文本映射到高维空间里的一个点然后靠余弦相似度去衡量两个点靠得近不近。问题是这个“近”并不总是等于“符合用户意图”。我举一个真实工作里让我印象深刻的例子。知识库里有一堆产品文档和技术文章用户问“系统支持哪种身份认证方式”结果向量检索把一篇讲“认证中心架构设计”的文章排在前面因为里面“认证”这个词出现次数够多。但用户真正想要的是登录方式的清单那篇文章的侧重点完全不在登录方式上。这就是典型的“词面相关、语义不相关”。Embedding模型擅长捕捉主题层面的相似但面对需要精确逻辑匹配、限定条件筛选、多条件组合的问题它经常把权重分错位置。更麻烦的是向量检索阶段为了保证速度通常不会做太精细的交互计算它本质上是先“广撒网”把看起来沾边的候选全部捞回来。这一步如果追求精度速度会慢到没法用如果追求速度精度就一定会打折扣。你没法要求一辆卡车既能在市区灵活穿梭又能拉一百吨货。所以把全部希望寄托在向量检索的一次排序上是RAG效果不稳定的根源之一。1.2 两阶段架构召回控制候选集重排控制精度既然一次检索搞不定精度业界的通用解法就是“两阶段检索”这也是搜索引擎这么多年用下来的成熟方案。第一阶段叫召回Recall目标是快哪怕捞回一些噪音也尽量保证真正相关的文档不会漏掉所以通常向量检索、BM25关键词检索可以多路并行每一路各捞一批合并成一个大候选集。第二阶段叫重排序Rerank目标只有一个精。用更强、更有判别力的模型对候选集逐条打分把真正符合用户意图的排到最前面。用生活化的类比这就是海选和决赛的关系。海选时评委没时间仔细看每一个选手只能靠初印象快速筛一波先让一百个人进复试决赛时才一个个上台细看这才选出真正有实力的。RAG里的向量检索就是海选重排序就是决赛。如果你跳过了决赛直接让海选结果决定胜负那最后LLM拿到的上下文大概率是混着噪音的。落回LangChain4j这套两阶段架构其实很容易实现因为ContentRetriever这个接口就是天然的插入点。在标准的RAG流程里用户问题先进来然后由ContentRetriever负责从EmbeddingStore里捞出文档片段把这些片段拼成上下文交给LLM。我们只要在这个接口的实现里先调用原始检索器做召回再调用一个重排序模型对结果重新打分就能把“决赛”的环节加进去而且对整条链路的侵入非常小。1.3 什么场景下必须加重排当然不是所有RAG项目都值得立刻上重排序它也有成本和延迟。按我自己的项目经验下面这几种情况重排几乎是刚需。第一种知识库主题交叉很多时。比如你的知识库既有产品操作手册又有技术架构文档还有老版本的变更记录同一批专业术语在不同文档里指向完全不同的东西。这时向量检索很容易把主题相似但答案不同的内容混在一起重排可以帮LLM把正确答案提到前面。第二种问题里带限定条件时。比如“2023年以后发布的功能里哪个配置项控制日志级别”这句话里“2023年以后”“配置项”“日志级别”几个条件是并列的向量检索很容易只抓住“日志级别”这个主词把几年前的旧文档也招进来。重排序模型在做查询和文档的交互计算时会关注条件的组合匹配比单纯的向量距离精准得多。第三种多轮对话里隐含指代时。用户上一句说“刚才那个备份任务”这一句问“它失败的原因是什么”重排模型能把“备份任务”“失败原因”这两个信息从历史对话中结合到一起把相关文档捞得更准。反过来如果你只是一个小型知识库所有文档都是同一个主题用户问题也都是直来直去的关键词查询那一次向量检索可能已经够好用了重排带来的收益不明显反而增加接口延迟。所以别盲目上重排先判断自己的场景痛点在哪。2. 动手前的准备把RAG链路的关键位置标清楚2.1 LangChain4j里RAG请求到底走哪几步在写代码之前我建议先把LangChain4j的RAG数据流捋一遍不然很容易把重排序插到错误的位置。用户问题进来后先经过AiServices提供的入口这一步LLM的接口方法会被代理。AiServices内部会做几件事把问题包装成Query交给配置好的ContentRetrieverContentRetriever去EmbeddingStore里做向量搜索拿到一批Content然后这些Content会被拼进Prompt连同原始问题一起发给LLM。有的版本还会经过ContentAggregator做上下文重排和压缩再进入LLM。这里最值得注意的一点是ContentRetriever拿到的Query其实已经包含了对话历史。也就是说在重排序阶段我们不只能看到用户当前说的那一句话还能拿到前文几轮的内容这对处理多轮指代问题非常有帮助。重排序的正确插入点就是在ContentRetriever的返回值上再做一次“精加工”后返回给下一步不要去动AiServices内部的Prompt拼接逻辑。我见过有朋友试图靠修改System Prompt来弥补检索不准的问题比如在提示词里写“请只依据最相关的资料回答”。这个思路方向对但效果有限。Prompt能约束LLM的注意力却没法改变喂给它的材料本身。如果相关文档排在第五位而前四位都是噪音LLM很容易被前四位的长篇内容带偏。所以问题的关键还是得从检索源头解决重排序就是干的这个事。2.2 切分策略重排救不了胡说八道的切分强调一句大实话重排序能“重新排序”但它不能凭空召回原本就不在候选集里的信息。如果文档切分切得稀烂真正的答案被切碎、截断、甚至和无关内容混在一个块里那重排再准也无能为力。切分这个前置步骤我建议按“语义块”切而不是按固定字符数硬切。很多同学图省事直接调一个DocumentByCharacterSplitter(500, 50)结果一个完整的表格被切成两半一段“操作步骤”开头和结尾被拆到两个块里。这种切法到了重排阶段特别吃亏因为重排模型打分时看的是块的整体内容一个残缺块很难被判断为“高度相关”。我自己的习惯是能按段落切就按段落切段落太长时再按句子边界或者标题层级做二次切分。切分参数上中文场景下每个块保持在200到500字左右比较合适重叠区域控制在50到100字。太小了上下文不完整太大了重排模型处理起来又费时。更重要的是切分的时候尽量保证每个块都是一个自包含的信息单元讲的是一个完整的事。切完之后建议人工抽查十几条看看是不是每一条读起来都像一句完整的话而不是硬生生截断出来的碎片。2.3 找对重排序模型能力、成本、延迟三角平衡重排序不是随便拿个Embedding模型再算一遍相似度就行。严格意义上的重排序模型走的是“交互式编码”的路子把用户的问题和每一条候选文档拼接成一个序列喂给模型做精细的注意力计算模型会去捕捉问题和文档之间细微的语义对齐关系。相比之下Embedding模型的路线是“分别编码再算距离”问题和文档各过一遍编码器没有交互过程精细度就差了一截。所以选型时要分清你要的是“重排序模型”还是“再算一次向量相似度”。前者在效果上更值得信赖但代价是计算量更大。在LangChain4j里接重排序常见有两条路线一条是调一个现成的重排序API服务把问题和候选文档POST过去返回分数实现成本低但每个请求都有网络开销延迟会上去一些另一条是本地跑一个开源的重排序模型部署好后用HTTP接口自己调用省去按次计费的成本但需要自己维护推理服务。选型三角是“能力、成本、延迟”三个同时做到基本不可能。如果知识库以中文为主优先选中文语料训练出来的重排序模型英文模型对中文长句的语义对齐能力一般差不少。还要注意模型的上下文窗口因为你要把问题和文档拼在一起送进去窗口太小的模型面对长文档会部分截断影响打分效果。我落地时的做法是先用候选集规模控制在20条以内每条文档控制在500字左右这样大多数重排序模型的窗口都放得下同时也给后端的打分服务留出余量。3. 完整实操把重排序接进LangChain4j3.1 引入依赖与基础配置假设你现在已经有一个可以跑的LangChain4j工程Embedding模型、向量库都配好了。要接重排序我们需要加两个东西一个是HTTP客户端依赖用来调重排序服务另一个是LangChain4j本身的RerankModel接口如果你的项目版本里已经有这个接口就可以直接用没有的话自己定义一个也不难。Maven的pom.xml里至少要保证有langchain4j、langchain4j-embeddings这些核心依赖版本尽量对齐。HTTP客户端我用的是普通的Java HttpClient不需要额外引第三方库代码写起来也不啰嗦。dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version替换为你项目实际使用的版本/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-embeddings/artifactId version替换为你项目实际使用的版本/version /dependency这里关键不是堆依赖而是确认你的ContentRetriever接口能正常拿到结果。如果你现在用的是EmbeddingStoreContentRetriever那说明链路是通的下面加重排就是水到渠成的事。3.2 自定义一个RerankModel实现我建议先定义接口再把具体实现类细节写死。接口定义成下面这个样子就够了参数就是“一个查询文本”加“一组待重排文档”返回值是带分数的排序结果。public interface RerankModel { ListScoredText rerank(String query, ListString documents); } public record ScoredText(String text, double score) {}然后写HTTP实现类。核心逻辑是组装请求、调用远程服务、解析返回的分数。这里我以某个常见的重排序API为例你需要把它换成你自己选型服务的地址和鉴权方式。public class HttpRerankModel implements RerankModel { private final String endpoint; private final String apiKey; private final HttpClient httpClient; public HttpRerankModel(String endpoint, String apiKey) { this.endpoint endpoint; this.apiKey apiKey; this.httpClient HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); } Override public ListScoredText rerank(String query, ListString documents) { // 1. 构造请求体 MapString, Object body new HashMap(); body.put(query, query); body.put(documents, documents); body.put(top_n, documents.size()); // 2. 发送POST请求 HttpRequest request HttpRequest.newBuilder() .uri(URI.create(endpoint)) .header(Content-Type, application/json) .header(Authorization, Bearer apiKey) .POST(BodyPublishers.ofString(toJson(body))) .timeout(Duration.ofSeconds(15)) .build(); // 3. 解析响应组装成ListScoredText返回 HttpResponseString response; try { response httpClient.send(request, HttpResponse.BodyHandlers.ofString()); } catch (Exception e) { throw new RuntimeException(重排序服务调用失败, e); } return parseResponse(response.body()); } }这一步有几个细节值得注意。第一top_n参数最好设成和候选文档数一样让服务把每条文档的分数都返回回来我们自己再截断这样灵活度最高。第二超时时间不能设太短重排模型单条打分不算快文档数量一多耗时很容易超过5秒我用的15秒是比较稳的。第三如果服务返回的排序分数不带归一化我们自己做解析时也别去额外处理直接按原始数值降序排就行因为重排只看相对顺序不看绝对分数。3.3 把重排序装进ContentRetriever有了RerankModel接下来就是把它和原来的ContentRetriever组合在一起。做法是新建一个实现类内部持有原始检索器、重排序模型、候选集大小candidateTopK和最终保留条数topN。public class RerankContentRetriever implements ContentRetriever { private final ContentRetriever delegate; private final RerankModel rerankModel; private final int candidateTopK; private final int topN; public RerankContentRetriever(ContentRetriever delegate, RerankModel rerankModel, int candidateTopK, int topN) { this.delegate delegate; this.rerankModel rerankModel; this.candidateTopK candidateTopK; this.topN topN; } Override public ListContent retrieve(Query query) { // 第一步原始检索器负责召回多捞一些候选 ListContent candidates delegate.retrieve(query); if (candidates.size() topN) { return candidates; } // 第二步把候选内容转成文本交给重排序模型打分 ListString texts candidates.stream() .map(Content::text) .collect(Collectors.toList()); ListScoredText scored rerankModel.rerank(query.text(), texts); // 第三步按分数降序截取topN并映射回Content return scored.stream() .sorted(Comparator.comparingDouble(ScoredText::score).reversed()) .limit(topN) .map(scoredText - candidates.get(texts.indexOf(scoredText.text()))) .collect(Collectors.toList()); } }这段代码看着不长但它在链路里干的活很关键先让原始检索器放开手脚召回再让重排模型坐下来精挑细选。注意我加了if (candidates.size() topN)这个短路条件如果原始检索器本来就没捞够topN那就没必要再做重排省一次网络请求是小事省下几十毫秒的延迟在线上体验上却很值。再把Content和ScoredText来回映射时我直接用字符串做匹配简单但有点脆弱。如果重排服务返回的结果里对文本做了截断或格式化indexOf可能找不到原对象。更稳妥的办法是给每条候选内容带上一个序号把这个序号传给重排服务返回时按序号映射回Content。这也是我在实际项目中踩过坑之后改掉了。3.4 通过AiServices把整条链路串起来重排序器写好了最后就是组装。一个大致的完整流程是这样先建向量库和嵌入模型把文档切好导入然后创建普通的EmbeddingStoreContentRetriever作为原始检索器再把这个原始检索器包进RerankContentRetriever最后在AiServices里指定这个包装后的检索器。// 1. 创建嵌入模型和向量库 EmbeddingModel embeddingModel new YourEmbeddingModel(); EmbeddingStoreTextSegment store new YourEmbeddingStore(); EmbeddingStoreContentRetriever baseRetriever EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(20) // 召回阶段多捞一些给重排留空间 .build(); // 2. 创建重排序模型 RerankModel rerankModel new HttpRerankModel(https://your-rerank-api.example.com/rerank, your-api-key); // 3. 组装重排序检索器 ContentRetriever retriever new RerankContentRetriever(baseRetriever, rerankModel, 20, 5); // 4. 构建Agent服务 Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .contentRetriever(retriever) .build();这里有两个参数需要前后配合。EmbeddingStoreContentRetriever里的maxResults是召回阶段的数量我设成20给重排充足的候选空间RerankContentRetriever里的candidateTopK要小于等于这个maxResultstopN则是最终RAG喂给LLM的文档数按经验设成5左右比较合适。这样组合起来召回阶段去掉不相关的重排阶段负责把最精准的留下来各司其职。如果你是用问答接口而不是AiServices那思路也完全一样只要在拿到检索结果之后、拼接Prompt之前插入rerank调用就行。关键是记住重排动作永远发生在“召回之后、生成之前”位置定死了谁调用都差不多。3.5 这么调一下TopN效果立刻不一样有朋友问过我重排后保留几条最合适这真不是玄学而是跟你的任务类型直接挂钩。任务目标如果是“定位一个具体事实”比如“这个系统的默认密码策略是什么”那topN设成3到4就够太多内容反而模糊焦点。任务目标是“综述一段内容”比如“总结一下异步消息机制的实现原理”那LLM需要更多上下文来组织答案topN可以放到6到8。我试过在同一个知识库上把topN从5改成10在事实型问题上回答反而变差了一点因为末尾几条相关性并不高白白占了Prompt空间。在总结型问题上topN从5改成8则有可见的质量提升说明信息量确实不够。所以最好的做法是先按“事实定位型”场景把topN压到5跑通全链路然后拿一组验证问题列表来回测逐步调整上下限。不要指望一个参数走天下也不要想着一口气调到完美重排参数是伴随知识库增长需要持续微调的变量。4. 踩坑实录这些坑每一个我都填过4.1 重排后答案反而变差了问题出在哪先说一个最打击人的情况高高兴兴接了重排结果发现回答质量还不如以前。我在项目里查过好多次这种回归原因基本可以归成三类。第一类是候选集太小。原始检索器只召回5条重排模型只在5条里倒来倒去本质上没有提供新的选择空间。重排再怎么厉害也不能把5条里全是垃圾的结果变成好结果。这时候要把原始检索的召回量放大我建议至少到15到20条重排才有发挥的余地。第二类是重排序模型的领域和知识库不太匹配。如果你用的是通用英文重排模型来处理中文技术文档打分结果失真得很厉害甚至会出现把正确文档打到末尾的情况。解决办法是换一个对中文更友好的模型或者用一小批人工标注好的“问题-正确答案”样例先跑一遍对比再决定是否采用。第三类是重排后只保留了1条结果。有些朋友对重排期望特别高topN设成1想着把最好的留给LLM就行。结果LLM只有孤零零一段上下文遇到需要交叉验证的信息时直接抓瞎回答反而变得片面。我的建议是至少保留3条让LLM有多来源信息可参考同时也留点容错空间。4.2 重排接口慢得像乌龟整个对话都在等重排模型本身计算量大服务端处理一批20条文档常常要一两秒如果再叠加上网络传输用户端的体感就很糟糕。碰到这个问题我从三个方向做了优化。第一个方向是缩减候选集规模。召回从20条减到12条甚至10条对重排质量影响不大但等待时间能少一大截。毕竟网友那些相关性差的候选重排模型也是要逐条过一遍的少一条就快一点。第二个方向是做好超时降级。重排服务一旦超时我不让整个请求直接失败而是在retrieve方法里捕获异常把原始检索器的结果原样返回也就是降级回到“没有重排”的老状态。虽然效果差一些但系统至少还能正常工作用户不会得到“服务不可用”的体验。第三个方向是缓存。同一个问题短期内被重复问到的概率不小我在查询和重排结果之间建立了一个简单的内存缓存命中就直接返回完全不走后端服务。这个优化在内部知识库场景里收益很明显因为大家翻来覆去问的就是那么些问题。public ListContent retrieve(Query query) { String cacheKey query.text() | candidateTopK | topN; if (cache.containsKey(cacheKey)) { return cache.get(cacheKey); } ListContent contents; try { contents doRetrieveWithRerank(query); } catch (Exception e) { contents delegate.retrieve(query); } cache.put(cacheKey, contents); return contents; }4.3 中文语境下的分字分词问题中文Embedding在召回阶段有一个老毛病某些模型对中文的编码方式不友好遇到人名、产品名、版本号这种命名实体时容易把“GPT”和“生成式预训练”这种语义关联打散导致真正该被召回的文档压根没进候选集。这个问题不是重排序阶段能挽回的因为它发生在更早的召回环节。我的解决办法是“多路召回”在向量检索之外再加一个关键词检索的路子。比如用BM25算法或者简单的基础查询把按词面匹配召回的结果也放进候选集和向量召回的结果合并再去重。这样即使向量召回遗漏了包含确切关键词的文档关键词召回也能把它捞回来然后交给重排模型做精细打分。重排模型对交互语义的把握更强能在两路结果混合的候选集里把真正相关的抬上来。这个思路在实践里非常有效也是检索系统里“多路召回”思想的具体落地。4.4 常见问题速查表现象可能原因排查方向解决办法重排后答案明显变差召回候选集太小或重排模型与领域不匹配看召回数量和模型评分分布扩大召回量到15条以上换成中文领域模型请求耗时明显增加候选文档偏多、模型推理慢、网络延迟检查候选集数量和单次重排耗时降低候选集到10到12条加超时降级重排结果里分数都差不多候选文档本身区分度不高检查文档切分是否合理优化切分策略让每个块信息更聚焦个别问题仍然答非所问相关文档没进候选集查一下召回阶段是否漏召增加关键词检索做多路召回重排服务持续超时或报错服务端限流或文档数量过大看服务端日志和响应码缩小单次请求文档条数批量分片4.5 重排对象里别掺入太长的文档块另一个容易被忽略的坑是重排模型对超长文本的注意力会稀释。你送进去一条1200字的文档模型很难准确抓住其中和问题相关的那几十个字打分难免被无关内容带偏。所以在切分时就要管好块长度如果块确实长可以在重排前先做一次轻量截断只取每块里和查询关键词重合度最高的部分参与打分。我见过效果最好的方案是把块控制在400字左右重排模型的关注力才能聚焦。同时要注意重排模型打分的输入格式是“查询文档”拼接成的序列不同模型的拼接规则有差异。有的模型要求“查询”在前有的要求“文档”在前这个顺序会影响注意力权重分配。接新模型时一定要先做小批量测试而不是直接上线。我在项目里就遇到过换了模型后因为拼接顺序反了导致前几名几乎固定成同一篇文档的情况排查了半天才发现是顺序问题。写在最后的一点体会重排序不是花架子也不是锦上添花的步骤它在RAG链路里扮演的是“把关人”的角色。我做过的项目里一次向量检索的命中率可能只有六七成接上重排之后能稳定提升到九成以上尤其对中文长文本、多条件限定问题和多轮对话场景改善非常明显。而且重排模型这层抽象很值得保留以后想换更便宜或更强的服务只需要替换HttpRerankModel这一个实现类就行对上层完全透明。再往后做你还可以在重排分数里叠加时间衰减、来源可信度这些业务因子让检索结果更贴合实际业务优先级。我个人建议在花大力气调Prompt之前先确认检索链路里有没有这道关卡它往往比提示词对整个问答质量的影响大得多。