最近我在做内部知识库问答系统时,被一个问题折磨了好几天:向量检索召回的内容明明“意思上”很接近,但结果排序就是不对味。问“数据库连接池耗尽怎么排查”,排在最前面的文档却是讲线程池参数的,真正讲排查步骤的那篇反而被挤到了第七位。把这样的结果喂给大模型,它自然是答非所问。后来我引入了一条关键链路——在 RAG 召回之后叠加一层检索重排序(Rerank),用 LangChain4j 把它接进现有流程,效果可以说是立竿见影:Top3 命中率从勉强及格提升到了九成以上。
这篇文章就围绕这条链路来写。我会从“为什么向量召回不够用”讲起,再拆解重排序到底在做什么,然后给出基于 LangChain4j 的完整落地代码,最后把我调参时的实测数据和一些容易踩的坑一并整理出来。适合正在用 Java / Spring 生态做 RAG 应用、或者被“检索结果不精准”折磨的工程实践者参考。
1. 召回阶段埋下的“雷”:为什么 TopK 结果总是感觉不对味
1.1 TopK 定多少都难两全
刚开始做这个问答系统时,我把代码写得“看起来很标准”:文档按段落切片,送入嵌入模型转成向量,存进向量库,然后用余弦相似度召回 Top4,直接把结果塞给大模型。跑通倒是很快,可用性却一言难尽。查了几次日志,我意识到最别扭的其实是TopK 这个数字怎么定都不对。
TopK 太小,比如取 4,正确文档经常压根不在候选集里,召回率太低;TopK 放大到 10~20,相关文档倒是进来了,但噪声也成倍增长,大模型面对一堆“沾边但不直接回答”的片段,会把多个无关信息混在一起形成幻觉。我遇到一个典型案例:某次检索“连接池耗尽怎么排查”,正确答案其实排在第 13 位,但它是全文最匹配的一篇。不放大 TopK,它根本不会被模型看到;放大了 TopK,前几位又全是干扰项。
在 RAG 流程里,召回(Retrieval)阶段的首要目标不是“给出精确答案”,而是“别错过正确答案”,所以 TopK 偏大是常态。但召回率上来了,精确率就保不住。这是检索系统一个天然矛盾,也是重排序出场的前提。
1.2 向量相似度分数并不具有可比性
一开始我还试过用最低相似度分数来过滤噪声,比如只保留 0.6 分以上的片段。结果发现不同 Query 之间的得分分布差异非常大。
有的 Query 很具体,比如“如何修改 Nginx 的请求超时时间”,它和某些文档片段的余弦相似度能到 0.75 以上;有的 Query 描述比较抽象,比如“系统最近经常出现性能瓶颈”,几乎所有候选文档都挤在 0.35~0.55 这一小段区间里。如果死守一个固定阈值,要么把好文档全过滤掉,要么放进一堆低质量内容。
更坑的是,向量相似度得分本身不能跨文档比较。长文档和短文档、不同来源的文本,投影到向量空间后分布特性完全不同。0.62 分在这个 Query 下可能是全场最高,在另一个 Query 下可能就是垫底水平。所以“看分数绝对值”这条路基本走不通,只能做相对排序。
1.3 检索阶段和生成阶段之间的“断层”
纯粹从向量检索角度看,“语义相近”不一定等于“能支持答题”。比如一份运维手册里,“连接池参数配置”和“连接池耗尽排查”是两篇相邻章节,在语义上有大量重叠词汇。对嵌入模型来说,这两篇都挺相关,但对大模型来说,前者只是背景知识,后者才是直接解决问题的证据。
这就是检索与生成之间常常被忽略的断层:检索看重“相关性”,生成更看重“可支撑性”。用户问的是“怎么排查”,你就得给“排查步骤”相关的片段,而不是给“配置说明”。向量召回这个粗排阶段无法充分建模 Query 和文档之间词级别的交互关系,所以大量“语义相似但不是答案”的片段被推到了前排。
我当时尝试过调整切片大小、换过不同嵌入模型,问题并没有彻底解决。后来才想明白:粗排负责圈范围,精排才负责“优中选优”,两者缺一不可。
2. 重排序的底层逻辑:精排为什么能纠正粗排的错误
2.1 双塔模型和交叉编码器的本质差异
要理解重排序,先得看清两类算法的路线差异。向量检索用的嵌入模型通常是双塔(Bi-Encoder)结构:Query 和 Document 分别走两个编码器,各自变成一个向量,然后算余弦相似度。这种设计的优点是文档向量可以提前算好、提前建索引,查询时只对 Query 做一次编码,再和库里所有向量做相似度计算,速度快、能支撑海量候选。
缺点是:Query 和 Document 只在最后一刻通过一个相似度函数“碰面”,两者之间细粒度的词级交互(比如“哪个词和哪个词真正形成语义呼应”)被压缩成了单个向量的点积。想象一下,候选人先提交一份简历,HR 只根据简历关键词快速过滤,两个人最终连面都没见——这就是粗排。
交叉编码器(Cross-Encoder)则完全换了一种思路:它把 Query 和 Document 拼成一段文本,一起送进编码器,用自注意力机制让两者进行充分交互。模型能看到“问题里的‘耗尽’到底对应文档里的哪个句子”,也能判断“讲配置的那段虽然词面很接近,但并没有回答问题”。打个比方,这就是 HR 把候选人约到现场,围绕岗位要求当面深聊一轮,识别能力完全不是一个量级。
不过交叉编码器的代价也很明显:每一条 Query 和候补文档都要单独拼起来过一遍模型,算力开销远超双塔模式。所以它不适合直接对全量向量库扫一遍,只能作用于“已经粗筛过的小范围候选集”上。
2.2 重排序在 RAG 链路中的定位
一个标准的重排序 RAG 流程是这样:先走向量检索(或结合 BM25 等关键词召回)取回 Top20 到 Top50 的候选文档,称之为“粗排候选集”;然后把每个候选片段和原始 Query 交给重排序模型,逐条打分;最后按新分数重新排序,只取前几名交给大模型。
很多人会困惑:“既然重排序更准,为什么不干脆用它做第一轮检索?”答案就是成本。交叉编码模型对每条候选都要实时前向计算,如果候选范围是全量文档库,系统根本响应不过来。而粗排的意义恰恰在于用很小的代价把候选范围收缩到一个“大致靠谱”的集合,把后续宝贵的精排算力集中在真正可能命中的片段上。
2.3 延迟、成本与场景取舍
实测下来,重排序增加的耗时通常在小几百毫秒到两三秒之间,具体取决于候选数量、模型大小、服务端并发。这个延迟对大部分知识库问答、客服辅助类场景完全可接受,但对毫秒级实时接口来说就要谨慎。
我的判断标准是:只要你的应用允许增加 20% 左右的延迟,同时要求回答准确率优先,那重排序几乎稳赚不赔。相反,如果产品形态是超高频的简单查表问答、对延迟极其敏感、答案错误容忍度也不高不低,那可以先用粗排顶着,不必上来就加重排。
3. LangChain4j 落地重排序:接口适配与完整实现
3.1 ContentRetriever 是替换重排序的正确位置
LangChain4j 从 4.x 开始提供了清晰的检索抽象,核心就是ContentRetriever接口。默认情况下,EmbeddingStoreContentRetriever负责向量库检索并返回得分最高的 TopK 相关内容,这相当于粗排器。
我一开始在 Assistant 里只配置了EmbeddingStoreContentRetriever,后来发现要在不破坏框架内聚的前提下插入重排序,最优雅的方式就是自己实现一个ContentRetriever,内部先委托给粗排器,再叠加一层重排序模型。LangChain4j 对ContentRetriever的使用非常统一,不管底层是向量库、搜索引擎还是混合检索,上层对话流程根本不用改。
3.2 定义你自己的 Rerank 模型抽象
LangChain4j 虽然已经支持多种在线模型,但我更推荐自己定义一个薄薄的接口,把“如何调用重排序服务”这件事隔离出来。这样做的好处很明显:你可以自由切换在线 API、本地部署的开源 Rerank 模型,或者内网自研服务,而业务代码完全无感。
public interface RerankModel { /** * 对候选内容按与 query 的相关性重新打分,返回按分数降序排列的结果。 */ List<RerankResult> rerank(List<Content> contents, String query); } public record RerankResult(Content content, double score) { }如果对接某个在线 Rerank API,实现也很直接。核心是把查询文本和候选文档文本拼成请求体,批量发送,再把返回的分数映射回Content:
public class HttpRerankModel implements RerankModel { private final HttpClient httpClient; private final String endpoint; private final String apiKey; public HttpRerankModel(String endpoint, String apiKey) { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(5)) .build(); this.endpoint = endpoint; this.apiKey = apiKey; } @Override public List<RerankResult> rerank(List<Content> contents, String query) { if (contents.isEmpty()) { return List.of(); } // 构造请求体:把候选文档原文和 query 一起发给重排序服务 List<Map<String, String>> inputs = contents.stream() .map(content -> Map.of("text", content.textSegment().text())) .toList(); Map<String, Object> requestBody = new HashMap<>(); requestBody.put("query", query); requestBody.put("inputs", inputs); try { HttpResponse<String> response = httpClient.send( HttpRequest.newBuilder() .uri(URI.create(endpoint)) .header("Content-Type", "application/json") .header("Authorization", "Bearer " + apiKey) .POST(BodyPublishers.ofString(toJson(requestBody))) .build(), BodyHandlers.ofString()); // 假设服务返回的结构是 [{"score": 0.82}, {"score": 0.61}, ...] List<Double> scores = parseScores(response.body()); List<RerankResult> results = new ArrayList<>(); for (int i = 0; i < contents.size(); i++) { results.add(new RerankResult(contents.get(i), scores.get(i))); } results.sort(Comparator.comparingDouble(RerankResult::score).reversed()); return results; } catch (IOException | InterruptedException e) { throw new RuntimeException("调用重排序服务失败", e); } } }这里有个实现细节要提醒:在线重排序接口的输入长度通常有限,超长文档可能被服务端截断或直接拒绝。所以候选文档切片尽量控制在模型要求的 token 范围内;如果切片太长,宁可丢掉开头结尾,也要保证 query 能够完整传入。
3.3 自定义 RerankingContentRetriever 的完整代码
有了RerankModel,下一步就是把重排序逻辑嵌入ContentRetriever。我实现了一个RerankingContentRetriever,它先调用底层向量检索拿到较宽的候选集,再调用重排序模型精排,最后过滤掉低分文档并截断到目标数量:
public class RerankingContentRetriever implements ContentRetriever { private final ContentRetriever delegate; private final RerankModel rerankModel; private final int finalMaxResults; private final double minScore; public RerankingContentRetriever(ContentRetriever delegate, RerankModel rerankModel, int finalMaxResults, double minScore) { this.delegate = delegate; this.rerankModel = rerankModel; this.finalMaxResults = finalMaxResults; this.minScore = minScore; } @Override public List<Content> retrieve(Query query) { // 第一阶段:粗排召回,候选集尽量宽一点 List<Content> candidates = delegate.retrieve(query); if (candidates.isEmpty()) { return List.of(); } // 第二阶段:重排序模型对候选集精排 List<RerankResult> reranked = rerankModel.rerank(candidates, query.text()); return reranked.stream() .filter(r -> r.score() >= minScore) .limit(finalMaxResults) .map(RerankResult::content) .toList(); } }这段代码有几个设计点值得说清楚。
第一,delegate可以是任何ContentRetriever,包括 LangChain4j 自带的向量检索器,也可以是组合多个数据源的聚合检索器。你完全可以做到“向量召回 + 关键词召回 + 数据库查询”多路召回,然后把所有候选混在一起交给重排序统一融合。这种“多路召回 + 重排序精排”是我在实际项目里最推荐的做法,能显著提升鲁棒性。
第二,minScore的默认值不要拍脑袋定。我一开始设 0.5,结果大量相关文档被过滤掉;后来把日志拉出来看分数分布,才发现好文档的分数集中在 0.3~0.7 之间,而且不同 Query 差异极大。所以最佳实践是先用低阈值跑一段时间,观察真实分布,再回头定阈值。
第三,很多重排序模型存在“分数压缩”现象——最高分和最低分差距往往很小,比如范围只在 0.02~0.55 之间。这种时候可以不用绝对阈值,而只看相对排序,直接取 TopK 即可。我最后在生产环境基本放弃了绝对阈值,只保留“重排后取前 N 条”的策略,效果更稳定。
3.4 在对话流程里配置重排序检索器
在整个 LangChain4j 应用里接入也很简单。先构建一个向量存储,然后配置EmbeddingStoreContentRetriever作为粗排器,再包上我们的重排序检索器,最后通过RetrievalAugmentor注入AiServices:
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("某个嵌入模型") .build(); EmbeddingStore<TextSegment> embeddingStore = InMemoryEmbeddingStore.fromFile("/data/kb.store"); // 粗排:先从向量库取回 Top 20 候选 EmbeddingStoreContentRetriever embeddingRetriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(20) .build(); // 精排:Rerank 后再取 Top 4 RerankingContentRetriever rerankingRetriever = new RerankingContentRetriever( embeddingRetriever, new HttpRerankModel("https://your-rerank-service/api/rerank", "token"), 4, 0.0 ); RetrievalAugmentor augmentor = RetrievalAugmentor.builder() .contentRetriever(rerankingRetriever) .build(); AiServices<Assistant> aiServices = AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .retrievalAugmentor(augmentor) .build(); Assistant assistant = aiServices.build();这样配置之后,对话时系统只会携带重排序后得分最高的 4 个片段给大模型。实际生成质量和我之前“直接把 Top4 粗排结果丢过去”相比,有明显提升——至少 AI 不再把“连接池配置调优”当成“连接池耗尽排查”的答案来引用了。
4. 参数调优与实测对比:从“能跑”到“精准命中”
4.1 三个关键参数的配合:候选数、保留数、阈值
重排序流程里真正需要琢磨的参数就三个:粗排候选数(Candidate TopK)、重排后保留数(Final TopK)、分数阈值(minScore)。
我觉得候选数是在“召回率”和“开销”之间找平衡。候选太少,可能正确答案还没进来,重排序再准也白搭;候选太多,每次精排的耗时和成本线性上升。实测下来,当最终保留数为 4~5 时,候选数取 20~30 通常比较合适。候选少于 10 时,重排序对结果提升幅度会明显变小——它没有足够的选择空间。
保留数本质上取决于大模型上下文窗口和任务类型。对于问答场景,3~5 个高质量片段已经足够;对于总结类任务,可以放宽到 6~8 个。但别贪多,LLM 的注意力会被无关信息稀释,塞 10 个片段不仅浪费 token,还会增加幻觉风险。
阈值这块前面已经说过,建议先放开跑数据,关键看分数分布。这里放一张我当时某一组 Query 的观测数据作参考:
| 重排后名次 | 分数 | 文档内容属性 | 是否可用 |
|---|---|---|---|
| 1 | 0.68 | 直接描述问题原因和排查路径 | 高相关 |
| 2 | 0.57 | 给出类似场景的故障案例 | 相关 |
| 3 | 0.41 | 提到相关关键词但无具体步骤 | 边缘 |
| 4 | 0.33 | 与 Query 概念相近但属于另一主题 | 弱相关 |
| 5 | 0.18 | 明显无关内容混入候选集 | 无关 |
这组数据里,如果阈值设在 0.5,我只能拿到前两个片段,对有些偏复杂的问答可能不够用;如果设在 0.3,会把边缘内容也加进来。所以我最后采用的方式是“阈值只过滤明显负分项,主控靠保留数”。
4.2 重排顺序变化的实测演示
我用一个内部故障排查知识库做了组对照测试,Query 是“MySQL 连接池耗尽怎么排查”。粗排阶段 Top5 和重排后 Top5 的结果对比如下:
| 序号 | 重排前(粗排原始顺序) | 重排后(最终喂给 LLM) |
|---|---|---|
| 1 | 常见 SQL 索引失效原因与优化案例 | MySQL 连接池耗尽排查指南与参数调优 |
| 2 | Java 线程池拒绝策略与线程泄漏分析 | 数据库连接超时与网络问题排查手册 |
| 3 | MySQL 连接池耗尽排查指南与参数调优 | Java 线程池拒绝策略与线程泄漏分析 |
| 4 | 数据库连接超时与网络问题排查手册 | 常见 SQL 索引失效原因与优化案例 |
| 5 | Nginx 上游超时时间配置说明 | Nginx 上游超时时间配置说明 |
可以看到,粗排阶段真正能回答 Query 的文档排在第 3 位,前面被两个“词汇相近但主题跑偏”的文档占着。重排后,直接相关的指南被拉到了第一位,排查实操类的文档紧随其后,索引失效那篇被压到了第四。大模型在这种输入结构下,回答明显更聚焦,不再把索引优化和连接池耗尽混为一谈。
4.3 在线重排序模型与本地部署模型的选型
我同时试过在线 Rerank API 和本地部署的开源 Rerank 模型,各有取舍,用一张表总结适用性:
| 维度 | 在线 Rerank API | 本地部署 Rerank 模型 |
|---|---|---|
| 效果 | 模型较大,通用场景表现稳定 | 需要自己挑选和微调,垂直领域潜力高 |
| 延迟 | 有网络开销,波动取决于服务负载 | 局域网内调用,延迟低且稳定 |
| 数据出域 | 文档内容会传给第三方服务,要评估是否允许 | 数据不离开内网,企业私有知识库友好 |
| 运维成本 | 零运维,按量计费 | 需要 GPU 或强 CPU 实例,自行维护模型服务 |
如果是个人项目或原型验证,在线 API 最省事;如果是企业私有知识库、客户数据不能出域,本地部署几乎是必然选择。我在本地部署时用了一个开源中文 Rerank 模型,效果虽然略逊于更大规模的在线服务,但已经足够支撑业务,而且是私有化网络环境下最稳妥的方案。
4.4 缓存、批量调用与降级策略
最后聊几个让重排序进入生产环境时容易忽略的细节。
线上对话经常出现相似问题被反复询问的情况,每一轮都调用一次重排序 API 很烧钱。我给重排序加了一个按 Query 内容哈希的缓存,命中的直接返回上次结果。用 Caffeine 就能实现,缓存过期时间设 10~30 分钟即可。注意不要缓存太长时间,因为知识库更新后旧结果会过期。
批量调用方面,在线 Rerank API 一般支持一次请求传多条候选文本。每次对话只跑一次 HTTP 调用,别一条条循环发,否则会慢得让人崩溃。请求体尽量精简,控制总字符数,太长会被服务端限流。
降级策略必须提前想好。有一次我依赖的重排序服务短时不可用,整个问答流程直接报错。后来我加了try-catch降级逻辑:重排序失败时,自动返回粗排器的原始结果,保证服务可用性。准确率稍降,但不会让用户直接得到“系统错误”的体验。
另外要提一个容易被忽略的问题:重排序救不了切片策略的缺陷。如果文档切片把上下文割裂得很碎,很多片段语义不完整,Rerank 分数普遍偏低,怎么排都排不出理想结果。我自己踩过一次坑:某份 PDF 手册一句话一个切片,重排后分数全在 0.2 以下,后来重新按章节段落切分,效果立刻上来了。所以切片粒度一定要先优化,再谈重排序。
我在这个项目里最大的体会是:RAG 的准确率从来不是靠某一个环节“一步到位”,而是靠召回、切片、精排、生成层层协作堆出来的。重排序不是银弹,但它是在不更换大模型、不大改结构的前提下,性价比最高的优化手段。如果你也在用 LangChain4j 做知识库问答,建议先按这里的方法跑一版对比数据——大概率会发现,原来 TopK 里那些“差一点命中”的文档,只是缺了一次认真审视的机会。