前阵子维护一个基于 LangChain4j 的内部知识库问答机器人遇到的第一个尴尬不是模型不会说而是它“引错书”。用户问“数据库连接池的 maxLifetime 设置不合理会有哪些隐患”系统从 500 多篇技术文档里找回 4 篇结果第一篇讲的是连接池参数大全第二篇讲的是慢 SQL 排查真正把 maxLifetime 和孤儿连接讲清楚的那篇排在第三。LLM 只能拿着前两篇开始组织答案结果自然是答非所问。后来解决方案其实不复杂给 LangChain4j 的 RAG 流程加了一级重排序Rerank。这篇文章把整个改造过程、核心原理、代码写法和踩坑记录都整理出来Java 技术栈做 RAG 的同学可以直接抄作业。1. 为什么“召回准”不等于“命中准”1.1 两段式检索召回是海选重排才是决赛普通 RAG 的默认流程很直观拿用户 query 做向量化去向量库取 TopK 文档拼进 Prompt 丢给 LLM。大多数教程里 K 取 4 或 5文章也只讲到这里。但真实项目跑久了就会发现问题恰恰出在“这个 K 到底怎么取”。向量检索的设计目标从来不是“排序”而是“召回”。它用双塔结构把 query 和文档分别编码成向量再做余弦相似度或 ANN 近似搜索目的是在百万级候选里快速圈出“可能有关系”的几十条。信息检索领域把这种策略叫作“先召回、再精排”向量检索只负责前半段后半段精排才是 RAG 里真正决定答案质量的部分。我用一个类比来理解这件事海选的任务是不漏掉有潜力的人而不是直接决定冠亚季军。向量检索在线上花几十毫秒把库里可能相关的文档找回来但它没能力从“相似”里再判断“哪个更相关”。这是两塔结构的天然限制不是调个 embedding 模型就能解决的。在 LangChain4j 里默认的EmbeddingStoreContentRetriever干的就是海选的活。如果没人告诉它要精排它就拿着一堆“看起来相似”的文档去喂 LLM结果自然不可控。1.2 典型场景向量分数高答案却跑偏我实际遇到过三类典型问题。第一类叫“语义重叠但意图偏离”。用户问“退款需要提交哪些材料”知识库里有《退货与退款操作手册》两个文本的向量相似度非常高。但手册的大篇幅都在讲物流单号、快递面单真正回答问题的只有最后一小节。向量检索只做全局语义对齐不会把“问题意图”和“文档重点”做细粒度匹配。第二类叫“长文档稀释”。一份 8000 字的手册向量是整个文本的平均语义。用户问的是手册第 6000 字附近才出现的细节全篇向量的相似度反而不如另一篇泛泛而谈的概念性文章。这种场景在大规模产品手册、技术规范文档里非常常见。第三类叫“关键词幻觉”。query 里出现一个专有名词比如Graceful Shutdown知识库里恰好有篇讲 Kubernetes Pod 终止流程的文档向量分数被这个词顶得很高。但实际上用户问的是 Spring Boot 的优雅停机词汇命中不等于语义相关。这三类问题靠换 embedding 模型、调切分粒度能缓解但很难根治。关键思路是把“相似度”换成“相关性”判断这正是 Cross-Encoder 这类重排模型擅长的。1.3 没有评估指标的优化都是耍流氓动手优化之前得先定义什么叫“命中准”。我习惯盯三个指标。RecallK看真实相关文档在 TopK 里出现的比例。K10 这个数如果只有 60%说明文档在召回阶段就被过滤掉了重排救不回来要先回去调切分粒度、embedding 模型或索引结构。MRRMean Reciprocal Rank看第一条相关文档排名的倒数能直接反映“第一条是不是就对了”。LLM 对排序最敏感的其实就是前几个文档第一条对了答案质量基本有保证。NDCG适合标注了“完全相关 / 部分相关 / 不相关”的评估集能区分排序质量而不只是“有没有出现”。我当时写了 50 条测试 query人工标注相关文档跑出来的基线 MRR 只有 0.38大量 query 的第一条就是错的。这个数据证明问题不在代码逻辑而在排序策略这才决定认真做重排。2. 重排序的原理从 Bi-Encoder 到 Cross-Encoder2.1 Bi-Encoder 的“偷懒”本质向量检索用的双塔结构就是 Bi-Encoderquery 过一个编码器document 过另一个编码器两者在整个编码过程中互不知道对方的存在最后只在向量比较的瞬间碰一次面。好处很突出文档向量可以离线预计算在线只需要算 query 向量再走一次 ANN 索引就能拿到结果毫秒级响应。坏处也恰恰在这里query 和 document 的唯一交互就是最后算那一下距离好像两个人分别在电话里描述自己对某件事的理解再由第三方判断谁说得更接近。细节、否定关系、核心实体所在的上下文都在两个固定长度向量做余弦距离时被压扁了。更现实的问题是 embedding 向量一般是 768 或 1536 维这个维度看着不小但要容纳一篇几百 token 文档的全部语义其实非常紧张。像“maxLifetime 设置过大”这种含义可能只在段落尾部占十几个 token很容易被其他高频信息淹没。2.2 Cross-Encoder 的逐字对话Cross-Encoder 的思路完全不同。它把 query 和 document 拼成一个 token 序列[CLS] query [SEP] document [SEP]整个序列一起过 Transformer。模型内部的注意力机制能让 query 里的每个词直接观察 document 里每一个词判断这个文本到底是否真正回应了问题。比如查询“maxLifetime 设置过大会导致连接什么时候被回收”Cross-Encoder 在计算“过大”和文档中“连接耗尽后回收线程需要遍历活跃连接并等待空闲超时”这些词之间的关联时会明显比另一篇只讲连接池参数的文件打更高的分。这是逐 token 交互带来的能力不是最终向量那一次比较能比的。代价也很明确没法预计算。每个候选文档都要当场和 query 拼起来过一次模型候选多了耗时线性增长。典型的模型是 BERT-base 结构输入限制 512 token单个候选毫秒级30 个候选大概几十到几百毫秒。这也是为什么 RAG 流水线不能直接全部用 Cross-Encoder必须先让 Bi-Encoder 把候选缩小到合理规模。生活里的类比Bi-Encoder 是看照片认人快但容易脸盲Cross-Encoder 是当面问几个问题再判断慢一点但很难认错。2.3 本地模型还是 API 服务重排模型/服务的选型很影响落地方式我把自己调研过的几个方案整理成一张表。模型/服务类型语言精度参考延迟表现部署成本BGE-Reranker-v2-m3Cross-Encoder中英等多语言高中文友好中GPU 推理几十毫秒需要 GPU/ONNX 推理服务ms-marco-MiniLM-L-6-v2Cross-Encoder英文为主中低CPU 几十毫秒内模型几十 MB成本极低Cohere RerankAPI多语言高主要看网络 RTT按量付费零运维Jina Reranker APIAPI多语言高主要看网络 RTT按量付费零运维基于 LLM 的 Listwise 排序LLM 推理多语言很高但贵高成本很高中小项目慎选我的选型建议分三档。第一档是验证可行性阶段用 MiniLM 本地模型或者对应 API 的免费额度先确认“重排是否真的有效”避免一上来就花大钱。第二档是生产环境中文场景本地 GPU 跑 BGE-Reranker v2/m3或者改用 API 省运维。第三档是延迟敏感场景用本地 ONNX 量化版 MiniLM控制在几十毫秒内。表格里的“精度参考”只是相对的真实项目里差距要看数据集。我自己最终选的是 API 方案因为内部知识库查询量不大按月付费和零运维对我来说比自建 GPU 服务更划算。3. LangChain4j 接线把 Rerank 嵌进 RAG 流水线3.1 ContentRetriever、ContentAggregator、ReRankingModel 三者的关系LangChain4j 把 RAG 拆成了几个可插拔的组件完整路径是QueryTransformer - ContentRetriever - ContentAggregator - LLM每个环节只做一件事。QueryTransformer负责改写或扩展用户问题ContentRetriever负责召回可以一个也可以多个返回一批ContentContentAggregator拿到所有候选之后做聚合、过滤、排序最终输出给 LLM 的就是它的结果ReRankingModel是独立的小接口对候选文档和 query 一起打分重排。所以正确的接线方式不是去硬改检索器而是在ContentAggregator这一层插入重排。LangChain4j 社区提供了一个现成的ReRankingContentAggregator内部逻辑就是合并所有 retriever 的候选 - 调用ReRankingModel.rerank- 按分数过滤和截断 - 输出。注意一点老版本里出现过ReRankingContentRetriever这种写法它是把重排包在检索器外层。新版改成ReRankingContentAggregator之后语义更准确旧版项目如果看到前者不用慌核心逻辑一样只是摆放位置从“检索阶段”挪到了“聚合阶段”。3.2 手写一个可用的 ReRankingModelHTTP API 实现先看接口长什么样public interface ReRankingModel { ReRankingResult rerank(ListTextSegment segments, String query); }TextSegment就是候选文档片段query是检索问题返回结果里带每个候选的原始索引、重排后的顺序和分数。这个接口非常克制所以各种实现都不难写。下面是一个调用 Jina Reranker API 的实现我在生产环境跑过。换 Cohere 只需要改请求体结构用 Java 原生HttpClient就可以不需要额外引 SDK。public class JinaReRankingModel implements ReRankingModel { private static final String API_URL https://api.jina.ai/v1/rerank; private final String apiKey; private final HttpClient client HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(10)) .build(); public JinaReRankingModel(String apiKey) { this.apiKey apiKey; } Override public ReRankingResult rerank(ListTextSegment segments, String query) { // 候选超过模型上限时按原顺序截断避免请求失败 ListTextSegment candidates segments.stream() .limit(32) .toList(); String payload buildPayload(query, candidates); String response post(payload); return parse(response, candidates); } private String buildPayload(String query, ListTextSegment candidates) { ListString documents candidates.stream() .map(TextSegment::text) .toList(); // 这里用 JSON 序列化库拼请求体 // 生产环境用 Jackson/Gson 生成即可 return { model: jina-reranker-v2-base-multilingual, query: %s, documents: %s, top_n: %d } .formatted( toJsonString(query), toJsonString(documents), candidates.size()); } private String post(String payload) { HttpRequest request HttpRequest.newBuilder() .uri(URI.create(API_URL)) .header(Authorization, Bearer apiKey) .header(Content-Type, application/json) .timeout(Duration.ofSeconds(30)) .POST(HttpRequest.BodyPublishers.ofString(payload)) .build(); try { HttpResponseString response client.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() / 100 ! 2) { throw new RuntimeException( rerank failed, status response.statusCode()); } return response.body(); } catch (Exception e) { throw new RuntimeException(rerank exception, e); } } }解析响应时最容易踩的坑是index字段。API 返回的results数组里每个元素带index这个index是候选数组的原始下标。如果你在解析前对segments做了排序或过滤再拿index去查就会错位。我的做法是解析时先按 index 还原再统一排序private ReRankingResult parse(String responseBody, ListTextSegment candidates) { try { JsonNode root MAPPER.readTree(responseBody); JsonNode results root.path(results); ListReRankedContent ranked new ArrayList(); for (JsonNode node : results) { int index node.path(index).asInt(); double score node.path(relevance_score).asDouble(); ranked.add(new ReRankedContent(index, score, candidates.get(index))); } ranked.sort(Comparator.comparingDouble( ReRankedContent::score).reversed()); return new ReRankingResult(ranked); } catch (Exception e) { throw new RuntimeException(parse rerank response failed, e); } }注意ReRankedContent的构造方法在不同版本里可能有差异重点理解index和score的处理逻辑然后按你用的版本 Adjust。top_n我直接设成候选数量让服务端把所有候选都返回再由自己的聚合器做阈值过滤和截断。3.3 本地 ONNX 版重排的工程实现思路如果不想依赖外部 API也可以在本地跑 ONNX Runtime加载 MiniLM 或 BGE Reranker 的 ONNX 模型。核心步骤有四步从 Hugging Face 下载模型仓库用optimum-cli export onnx转成 ONNX 格式。准备 Tokenizer通常找一个 Java 端可用的分词实现或者直接调transformers的 tokenizer API 做一次转换。对每个候选构造[CLS] query [SEP] doc [SEP]的input_ids、attention_mask。交给 ONNX Session 推理取logits[0]作为该候选的相关性分数。这一步的工程细节比 API 版本多不少但好处是完全本地、无外部依赖、延迟可控。无论本地还是 APIReRankingModel接口都是一样的切换实现不影响上层代码。3.4 组装进 AiServices 的完整 RAG 链最关键的改造发生在ContentRetriever的maxResults和ContentAggregator的配置上。先看完整代码EmbeddingStoreTextSegment store new InMemoryEmbeddingStore(); EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .apiKey(embeddingApiKey) .build(); ContentRetriever retriever EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(20) // 重点先扩大召回交给重排精挑 .build(); ReRankingModel rerankingModel new JinaReRankingModel(rerankApiKey); ReRankingContentAggregator aggregator ReRankingContentAggregator.builder() .reRankingModel(rerankingModel) .maxResults(5) .minScore(0.3) .build(); Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(chatModel) .rag(RagConfiguration.builder() .contentRetriever(retriever) .contentAggregator(aggregator) .build()) .build();这里最值得注意的变化是retriever.maxResults(20)。很多教程默认写成 4-5因为没有重排不敢多召回。接上重排之后召回量要主动加大让ReRankingContentAggregator来承担精排职责。向量检索负责把真正的相关文档圈进候选池Cross-Encoder 负责把候选池里真正能回答问题的文档顶到前面。4. 实测案例内部知识库问答从“能用”到“好用”4.1 数据准备与 baseline我在一个项目里做了完整实验。数据源是 500 多篇 Markdown 技术文档先用DocumentSplitters.recursive(500, 100)切分得到大约 8000 个文本块。embedding 用多语言模型生成向量存InMemoryEmbeddingStore。这个存储方案演示够用生产环境换成 Qdrant、Milvus 或 Pgvector 都行。无重排基线配置retriever 只取 Top4不接 aggregator。先用 30 条手工标注 query 跑评测MRR 只有 0.40Top1 命中率 33%。这个数据反映在真实对话里的感觉就是答案经常引用无关段落或者逻辑有明显拼凑痕迹。4.2 接入重排后的直观变化把 retriever 的maxResults调到 20接上重排maxResults4minScore0.3。同样 30 条 query 上MRR 从 0.40 升到 0.72Top1 命中率从 33% 升到 60%。最典型的一个 query 是“数据库连接池 maxLifetime 设置过大会导致连接什么时候被回收”无重排时Top3 大概是这样第一篇是《连接池参数大全》里面对initialSize、maxWait讲得很全但没回答 maxLifetime 过大带来的孤儿连接问题第二篇是《生产环境慢 SQL 排查》相关性更弱真正包含答案的《连接泄漏排查记录》排到第三位。重排之后《连接泄漏排查记录》直接跳到 Top1。原因就是向量检索只看全局语义相似而 Cross-Encoder 能判断这个上下文里是否真的在回答“maxLifetime 过大”这件事。另一个间接收益是 LLM 的拒答率下降了。之前模型经常把两个不相关段落拼在一起一本正经地回答重排之后相关段落集中在靠前位置答案的引用链明显干净很多。4.3 分数阈值怎么定minScore这个参数我没有照搬网上常见的 0.5。不同 Rerank API/模型的分数分布差异极大照搬阈值会踩大坑。Jina 的 v2 模型在真实场景里分数普遍偏高但“相关文档”的分数也经常落在 0.3 到 0.5 之间。我跑了几天日志统计出正确被引用文档的最低分是 0.27错误文档的最高分是 0.61。固定阈值 0.5 会把不少真正相关的文档直接过滤掉0.3 是根据分位数试出来的。更稳的做法是不设死阈值只取 TopN。如果候选整体分数都很低比如最高分低于 0.15就让系统返回“无相关上下文”在 Prompt 里告诉 LLM 不要硬答。这比硬塞一个低质量文档进去安全得多。生产环境我还会在 aggregator 外层加一层兜底逻辑重排后 top1 分数低于阈值就返回空内容。LangChain4j 的 RAG 对这个场景是支持的关键在于触发空结果的时候Prompt 模板里要给 LLM 一个明确的“无可信依据”信号。5. 工程决策候选数量、阈值、缓存与降级5.1 为什么我坚持“先多召回、再重排”有一种偷懒做法是把向量检索 TopK 从 4 缩小到 2觉得这样噪音就少了。实测这种方式经常误伤。很多真正相关的文档语义距离并不近向量排序靠后提前截断就直接漏掉了。正确思路是反过来的。向量检索宁可多召回一些“可能相关”的候选把判断交给更聪明的 Cross-Encoder。我测试过不同候选池大小的效果召回数量重排后 Top1 命中率重排总耗时API545%约 150ms1050%约 300ms2060%约 600ms5062%约 1.3s候选从 5 扩到 20收益非常明显再往上到 50收益增速变缓延迟却线性上升。20-30 是大多数场景的甜点区间。5.2 多路召回 重排的统一融合向量检索只代表“语义相似”BM25 或全文检索则能覆盖字面匹配场景。现在我的检索层是双路向量召回 15 条全文检索召回 15 条合并后去重统一交给同一个 Rerank。Rerank 天然是打分融合器它把两套量纲完全不同的分数全部抛弃只按自己的相关性判断重排这省掉了为“向量分和 BM25 分怎么加权”写一堆调参逻辑的麻烦。实测对比单路向量 Rerank 和双路召回 RerankRecall10 大约涨 7 个百分点MRR 涨 5 个点。对于内部知识库这种文档量级这个收益非常值得。5.3 缓存、限流、降级三件套重排服务是外部依赖必须做容错。我维护的 Java 服务里做了三件事。缓存以(query, docId)为 key存 Rerank 分数。热点问题一天内被反复咨询LRU 缓存可以省掉大量重复调用。但要先对 query 做归一化去标点、小写、去掉常见口头语否则缓存命中率会很难看。限流用固定线程池加有界队列。线程数压到 5-10宁可队首等待也不要把上游 API 打挂。队列满时走降级逻辑不要无限堆积。降级是必须的重排服务不可用时自动回到“候选按原顺序截断”的策略。实现方式是把ReRankingContentAggregator包装成带兜底的ContentAggregator捕获异常后直接返回原候选并截断到 Top4。public class FallbackReRankingAggregator implements ContentAggregator { private final ContentAggregator delegate; private final ContentAggregator fallback; Override public ListContent aggregate(ListContent contents, String query) { try { return delegate.aggregate(contents, query); } catch (Exception e) { log.warn(rerank unavailable, fallback to truncate, e); return fallback.aggregate(contents, query); } } }5.4 延迟预算怎么控制线上版本 P95 响应时间要求 4 秒以内。向量召回一般在 50-200msRerank 是主要新增开销。不同部署形态的耗时不一样本地 MiniLM CPU单文档 2-5ms30 个候选约 100ms。BGE-Reranker GPU单文档 1-3ms30 个候选约 50ms。API 服务网络 RTT 200-500ms推理时间另算30 个候选可能到 1-2 秒。所以 API 版重排一定要配合缓存和候选截断并把重排单独设置超时。我的重排超时是 3 秒超时直接按降级处理不回滚整个请求。6. 踩坑记录高分错误结果与分数归一化陷阱6.1 一次被“高分错误结果”支配的排查有几天线上数据很奇怪Rerank 分数很高、明显不该排前面的文档却稳居第一。我按下面的链路排查打印聚合前后的完整候选列表确认 Rerank 分数确实把错误文档排到第一。单独用 curl 把同一个 query 和 document 发给 API分数又恢复正常。对比两边文本发现在 Java 端发送的 documents 数组里出现了大量重复段落。最终定位到根因知识库切分时模板页脚被重复切进多个文本块embedding 存储里重复向量太多检索召回的候选一堆内容几乎相同。Rerank 对同一内容的重复文档反复打分互相干扰。修复方式是在索引前对文档块做一次 MD5 去重保留第一份同时给切分器加页面元数据过滤把页脚模板剥掉。这个教训很典型重排只能把现有候选排好如果候选池本身被垃圾污染效果再好也救不回来。数据层面的治理永远是 RAG 项目绕不开的功课。6.2 分数归一化的陷阱不同 Rerank 模型/API 的分数分布差异极大不能直接跨模型做阈值或对比。MiniLM 的原始输出是 logit范围大概是 [-5, 5]Jina 的relevance_score是 sigmoid 后的 0-1实际集中在 0.3-0.8。把 MiniLM 的 3.2 分和 Jina 的 0.72 分直接划等号是没法比较的。建议只做“同模型内的相对排序”。如果要把分数存下来分析先对当前候选集做一次 min-max 归一化double min scores.stream().min().orElse(0); double max scores.stream().max().orElse(1); double normalized (score - min) / (max - min);注意这个归一化只对当下这一批候选有意义不能跨请求使用。两个请求之间没有可比性因为候选集不同。6.3 Query 改写与重排的顺序关系LangChain4j 的QueryTransformer可以在检索前改写用户问题比如压缩对话历史、把口语改成更正式的检索式表达。但如果你开了 QueryTransformerRerank 拿到的 query 也会是改写后的版本。我测试过几种组合改写 query 同时给 retriever 和 rerank改写 query 只给 retriever 而 rerank 用原 query原 query 直接给两端。差别不小。在我这个数据集上rerank 用原 query 的效果更好因为 Cross-Encoder 自己有能力处理口语化表达而改写后的 query 往往会丢掉一些原词细节。这不是普适结论但值得你做个 A/B。关键提醒是重排用的 query 不要悄然变成“改写后形态”要明确测试一下再决定。6.4 调试日志与对照实验RAG 优化工作中日志是最容易被低估的环节。我习惯在ContentAggregator前后各打一行结构化日志前一行记录检索原始分数和排名后一行记录 Rerank 后的分数和排名。这样任何排序异常都能很快定位到是召回问题、Rerank 问题还是阈值问题。更稳妥的做法是在配置里保留一个开关随时切回“无重排基线”。很多优化效果不明显往往是因为没有做严格对照实验。开了重排之后回头把召回量调大、阈值调好、日志打足你才能从日志和指标里看到这套水磨工夫的真实价值。我把这个开关做成ContentAggregator finalAggregator useRerank ? new FallbackReRankingAggregator(rerankAggregator, truncateAggregator) : truncateAggregator;功能上线后两套策略跑同一批线上 query日志和指标一对谁优谁劣一目了然。最后分享两个小建议第一做重排实验不要一上来就花大钱接最贵的 API 服务。先用本地 MiniLM 这种几十 MB 的模型把“重排是否有效”验证清楚再决定要不要上更强模型。我见过不少项目第一步就接最贵的 Rerank API效果提升有限于是得出“重排没用”的结论。其实问题往往出在候选召回质量、切分粒度、阈值都没调好换再强的模型也救不了基础问题。第二重排不是银弹。embedding 模型决定“能不能找到”重排模型决定“找得准不准”但两者都救不了“知识库本身乱”。先花时间把文档去重、去模板、切分合理再接重排你会看到组件的价值被放大很多。重排是我在 RAG 项目里做过成本最低、收益最明显的一次优化如果只推荐一个改动我会推荐先试试这个。
阅读完成 · 觉得有帮助?