ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

最近在调一个 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 条做重排序,计算量只剩原来的五百分之一,延迟完全可接受。

我一般建议的候选规模是:

场景召回候选数重排序后保留
知识库问答、客服 FAQ203~5
文档对话、长文分析30~505~8
高准确率要求的合规/医疗问答505

召回候选太少会让重排序巧妇难为无米之炊,候选太多又会让延迟和成本上升。常见的做法是先把它调成 20,跑一遍测试集看答案命中率,再逐步往上加。

3. LangChain4j 基础链路:让“文档进、结果出”先跑通

讨论重排序之前,还是要把 LangChain4j 的 RAG 基础链路搭好。下面的代码是简化后的核心逻辑,省略了具体 Embedding 模型和向量库实现,因为不同团队的选型差别很大。

3.1 依赖与组件选型

LangChain4j 的项目结构比较灵活,langchain4j核心包定义了接口,具体模型和向量库通过扩展包引入。一个典型的 RAG 项目需要三类组件:

  • 文档加载器和文本切分器,把 PDF、Word、Markdown 变成TextSegment
  • Embedding 模型,把文本变成向量
  • EmbeddingStore,负责存储向量并支持相似度检索

代码里只需要面向接口编程,具体实现可以替换。Maven 依赖大致长这样:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>1.x</version> </dependency> <!-- 下面两个按实际选型填写,分别对应 Embedding 模型和向量库 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-某embedding扩展</artifactId> <version>1.x</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-某向量库扩展</artifactId> <version>1.x</version> </dependency>

我踩过的一个坑是版本对齐:父langchain4j和扩展包的版本必须一致,否则会出现接口找不到实现类这样的运行时错误。建议一开始就用同一个版本号管理,不要混合搭配。

3.2 离线入库:加载-切分-向量化-存储

把文档变成可检索的向量,核心代码如下:

// 1. 加载文档 List<Document> documents = DocumentLoader.load(filePath); // 2. 切分,目标是每个 TextSegment 语义相对完整 TextSegmentSplitter splitter = TextSegmentSplitter.builder() .maxSegmentSize(500) // 按字符或 token 计算,按需调整 .maxOverlap(50) .build(); List<TextSegment> segments = splitter.split(documents); // 3. 批量向量化 List<Embedding> 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 List<Content> retrieve(Query query) { List<Content> candidates = baseRetriever.retrieve(query); return reranker.rerank(query.text(), candidates, finalTopK); } }

Reranker是一个我自己定义的接口,用来屏蔽不同重排序服务商的差异:

public interface Reranker { List<Content> rerank(String query, List<Content> candidates, int topN); }

接一个 HTTP 服务时,实现类大致长这样,注意这里的 URL 和请求体结构只是示例,要按实际服务调整:

public class HttpReranker implements Reranker { @Override public List<Content> rerank(String query, List<Content> candidates, int topN) { // 组装请求参数 List<Map<String, String>> docs = candidates.stream() .map(c -> Map.of("text", c.textSegment().text())) .toList(); Map<String, Object> body = Map.of( "query", query, "documents", docs, "top_n", topN ); // 发起 HTTP 请求,得到每个候选的新分数 List<ScoredContent> 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、NDCG

  • MRR 是"第一个正确答案出现在第几位",位置越靠前分越高。它最贴近实际体验,因为 LLM 基本只会认真看前几个块。
  • HitRate@K 是"正确答案是否出现在前 K 个结果里",适合看整体覆盖率。
  • NDCG 更精细一点,它会把"正确答案排在第 1 位"和"排在第 5 位"区分开来,而不只是算是否命中。

我建议普通人至少盯住 MRR 和 HitRate@5 两个指标。MRR 反映"第一个正确答案有没有排到最前面",HitRate@5 反映"重排序后 5 个块里有没有包含答案"。

5.3 实测结果与本该有的预期

评测逻辑是:先用基础向量检索直接取 Top 5,记为 A 组;再用向量检索取 Top 20,经过重排序后取 Top 5,记为 B 组。两组都交给同一个 LLM 生成回答,最后逐个打分。

方案MRRHitRate@5答案可用率(人工评估)
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,再包一层最轻量的重排序接口,把这条链路跑通、拿到自己的评测数据。重排序这件事,真正的难点从来不是"会不会调接口",而是"你知不知道自己的系统差在哪、改完之后到底有没有变好"。先把评测基线立住,后面每一步优化都会很踏实。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进