☰
LangChain4j RAG重排序实战:让AI精准命中相关文档
2026/10/17 13:33:42 网站建设 项目流程

搞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,交给配置好的ContentRetriever;ContentRetriever去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. 完整实操:把重排序接进LangChain4j

3.1 引入依赖与基础配置

假设你现在已经有一个可以跑的LangChain4j工程,Embedding模型、向量库都配好了。要接重排序,我们需要加两个东西:一个是HTTP客户端依赖,用来调重排序服务;另一个是LangChain4j本身的RerankModel接口,如果你的项目版本里已经有这个接口就可以直接用,没有的话自己定义一个也不难。

Maven的pom.xml里至少要保证有langchain4j、langchain4j-embeddings这些核心依赖,版本尽量对齐。HTTP客户端我用的是普通的Java HttpClient,不需要额外引第三方库,代码写起来也不啰嗦。

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>替换为你项目实际使用的版本</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-embeddings</artifactId> <version>替换为你项目实际使用的版本</version> </dependency>

这里关键不是堆依赖,而是确认你的ContentRetriever接口能正常拿到结果。如果你现在用的是EmbeddingStoreContentRetriever,那说明链路是通的,下面加重排就是水到渠成的事。

3.2 自定义一个RerankModel实现

我建议先定义接口,再把具体实现类细节写死。接口定义成下面这个样子就够了,参数就是“一个查询文本”加“一组待重排文档”,返回值是带分数的排序结果。

public interface RerankModel { List<ScoredText> rerank(String query, List<String> 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 List<ScoredText> rerank(String query, List<String> documents) { // 1. 构造请求体 Map<String, 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. 解析响应,组装成List<ScoredText>返回 HttpResponse<String> 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 List<Content> retrieve(Query query) { // 第一步:原始检索器负责召回,多捞一些候选 List<Content> candidates = delegate.retrieve(query); if (candidates.size() <= topN) { return candidates; } // 第二步:把候选内容转成文本,交给重排序模型打分 List<String> texts = candidates.stream() .map(Content::text) .collect(Collectors.toList()); List<ScoredText> 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(); EmbeddingStore<TextSegment> 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要小于等于这个maxResults,topN则是最终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 List<Content> retrieve(Query query) { String cacheKey = query.text() + "|" + candidateTopK + "|" + topN; if (cache.containsKey(cacheKey)) { return cache.get(cacheKey); } List<Content> 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之前,先确认检索链路里有没有这道关卡,它往往比提示词对整个问答质量的影响大得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询