LangChain4j RAG重排序实践:从粗检到精排,让大模型真正引用对内容 如果你已经把自己的 RAG 管线跑起来过大概率经历过这种尴尬问题丢进去向量检索也把 top-5 段落返回来了结果大模型偏偏没引用到最关键的哪一段。别急着换 embedding 模型也别一门心思调大 top-K我更推荐在检索和生成之间再加一道“重排序re-ranking”。这半年我在维护一个基于 LangChain4j 的知识库问答服务把原来“检索完直接拼 Prompt”的流程改成“粗检索 精重排 再生成”之后相关段落的命中率提升非常明显回答也终于不靠“碰运气”了。这篇就围绕 LangChain4j 的 RAG 重排序实践讲讲原理、装配方式、代码链路以及我实际踩进去过的坑。1. 为什么 top-K 检索总是跑偏重排序要解决的两层问题1.1 相似度不等于相关度这是所有向量检索的通病做 RAG 的人习惯性认为只要向量检索打分够高这段内容就一定对回答有用。实际并不是。Embedding 模型把句子压进一个高维语义空间检索时算的是“语义相近程度”而 LLM 生成时需要的却是“这段内容是否包含解决当前问题所需的信息”。这俩不完全是同一件事。我举一个实际的场景文档里写着“生产环境禁止使用某账号执行部署操作如需临时变更必须走审批流程”另一段写着“某账号的日常使用规范包括权限申请、密码轮换和操作审计”。你问“部署变更要经过什么步骤”前一段才是答案但后一段在语义上和“账号使用”更贴近向量相似度可能更高。原始 top-5 检索结果里有用段落被挤到第 7、第 8 位是常有的事。这个问题靠调大 top-K 也解决不了。top-K 调大到 10相关段固然能进来但无关段落也会混进来一大堆。LLM 的上下文窗口被垃圾信息占据回答质量反而下降top-K 调小召回率又跟不上。传统的做法是在“召回率”和“精确率”之间二选一而重排序的思路是跳出这个选择题。1.2 粗召回负责广撒网精排序负责临门一脚重排序的做法说起来很简单先让廉价的向量检索多取一些候选回来再用一个更强的模型对这些候选重新打分排序最后只把得分最高的几段交给 LLM。整套链路分成了两个阶段这也是业界的标准叫法——两阶段检索。打个比方初筛就像 HR 筛简历根据关键词和学历快速刷掉明显不符合的人留下一个比较大的候选池重排序就像技术面试对候选池里的每个人单独出一道题问“你这段经历到底能不能解决我们现在的这个技术问题”给一个更可信的评价。初筛要求的是广覆盖和低成本重排要求的是单点判断准确。两阶段的好处在于初筛阶段可以使用便宜、快速的 embedding 模型和内存型向量库top-K 从 5 调到 20 甚至 30成本增长可控重排阶段哪怕慢一点、贵一点因为只处理 20 个候选、只跑一次总延迟也远小于把 20 段全塞给大模型去阅读。这个架构几乎是目前 RAG 生产落地里回报率最高的优化点没有之一。2. LangChain4j 的 RAG 装配方式重排序器应该插在哪一环2.1 从 ContentRetriever 到 ContentAggregator 的分层设计LangChain4j 在 Java 生态里做 RAG 的一个好处是它没有把 RAG 逻辑写死成一个“调库函数”而是拆成了几个可以自由组合的接口。先记住三个关键角色第一个是ContentRetriever负责从向量库、搜索引擎或者其他来源里取文档片段。内置的EmbeddingStoreContentRetriever就是最常见的实现传入EmbeddingStore、EmbeddingModel、maxResults这些参数就能得到一个标准的检索器。第二个是ContentAggregator它负责把检索回来的内容做“合并、过滤、去重、排序”。RAG 里很多脏活其实都该在这个环节做只是很多人没注意到它的存在。LangChain4j 提供的ReRankingContentAggregator就是专门用来做重排序的默认实现它会拿一个ReRankingModel对检索器返回的候选重新打分再按分数从高到低返回。第三个是ReRankingModel这是抽象出来的“精排序模型”接口。也就是说重排序并不是把逻辑硬编码在某个组件里你只要按接口提供一个能做打分排序的模型实现ReRankingContentAggregator就会自动调用它。这种设计对替换模型非常友好今天接一个云端 API明天换成本地推理改动很小。串联起来之后默认的 RAG 流程是这样的输入 Query - ContentRetriever 粗粒度检索向量相似度 - ContentAggregator 精排序ReRankingModel 重新打分 - 拼接为 Prompt 上下文 - ChatLanguageModel 生成回答2.2 目前可用的重排序实现云端 API 与本地模型接手这个环节之前我建议先盘点一下手头能用的重排序模型。LangChain4j 生态里常见的接入方式有几种CohereReRankingModel调用国外云计算厂商的重排序接口支持多语言文本对中文场景也比较友好是很多团队优先考虑的方案。JinaReRankingModel同样是云端 API接入简单和向量检索配合起来效果比较稳定。本地交叉编码器如果数据敏感、完全不能出内网可以用本地推理框架加载一个 cross-encoder 模型再写一个ReRankingModel的实现包一层。直接让大模型排序用一个通用 LLM 对候选段落做“对号入座”式的排序效果理论上不错但成本高、延迟大一般只用在候选数量很少的场景。这些实现互不冲突关键看你的隐私要求、预算和延迟指标。后面我会专门讲选型参数这里先把架构位置弄清楚。3. 动手复现把一份文档库从朴素检索改成完整重排序链路3.1 初始依赖与文档切分先说环境。我用的是 Maven 项目核心依赖大致如下dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-embeddings/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-cohere/artifactId version0.35.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.35.0/version /dependency注意LangChain4j 的版本迭代速度很快某些类的包路径会有调整但总体组装思路是不变的。文档加载和切分我会习惯性地单独做。先加载几份 markdown 文档再用DocumentSplitters.recursive切成长度适中的片段ListDocument documents FileDocumentLoader.loadDocuments( Paths.get(/data/docs), new TextDocumentParser() ); DocumentSplitter splitter DocumentSplitters.recursive(400, 50); ListTextSegment segments splitter.splitAll(documents);这里有两个容易被忽视的点。第一切分长度不要太大400 到 600 字比较合适。太长的片段塞进重排序模型里模型对“哪一小句才是关键答案”的识别能力会下降太短又容易丢失上下文。第二重叠区要留一点比如 50 字防止关键句正好被截断在片段边界上。3.2 嵌入、存储与初版检索切分完成后用本地 embedding 模型做向量化存进内存向量库。本地模型在这一步的优势是不需要外部 API调试链路时特别省心EmbeddingModel embeddingModel new AllMiniLmL6V2EmbeddingModel(); ListEmbedding embeddings embeddingModel .embedAll(segments.stream().map(TextSegment::text).toList()) .content(); EmbeddingStoreTextSegment store new InMemoryEmbeddingStore(); store.addAll(embeddings, segments);如果到了生产环境把InMemoryEmbeddingStore换成 Qdrant、pgvector 或 Elasticsearch 的 EmbeddingStore 实现即可接口基本一致。接下来是关键的第一步构造一个EmbeddingStoreContentRetriever注意这里的maxResults我会故意调大ContentRetriever retriever EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(10) .minScore(0.5) .build();初检阶段取 10 个候选是因为我们后面还要做精排序这一步宁可多召回也不要因为 top-5 太紧把正确答案漏掉。minScore也别设太高0.5 或 0.6 就够只是用来去掉明显无关的段落。3.3 接入 ReRankingContentAggregator让链路自动精排序重排序模型我选了一个云端服务因为它的多语言模型在中文文档上的表现比较稳定ReRankingModel reRankingModel CohereReRankingModel.builder() .apiKey(System.getenv(RERANK_API_KEY)) .modelName(rerank-multilingual-v3.0) .build(); ReRankingContentAggregator aggregator ReRankingContentAggregator.builder() .model(reRankingModel) .build();然后把检索器和聚合器一起交给DefaultRetrievalAugmentorRetrievalAugmentor augmentor DefaultRetrievalAugmentor.builder() .contentRetriever(retriever) .contentAggregator(aggregator) .build();最后用 AI Service 的方式组装问答服务interface Assistant { String chat(dev.langchain4j.service.UserMessage String userMessage); } Assistant assistant AiServices.builder(Assistant.class) .chatLanguageModel(chatLanguageModel) .retrievalAugmentor(augmentor) .build(); String answer assistant.chat(部署变更需要经过什么审批步骤);这套写法和普通 RAG 唯一的不同就是组装RetrievalAugmentor时多塞了一个contentAggregator。代码改动非常小但行为差异很大ContentRetriever返回的 10 个片段会先经过重排序模型重新打分最后聚合器只会把最能回答当前问题的前 3 到 4 段拼到 Prompt 里。3.4 手动模式不依赖 AI Service 的重排序调用方式有些项目没有走AiServices那一套而是自己拼 Prompt。这时候直接调重排序模型反而更直观Embedding queryEmbedding embeddingModel.embed(部署变更需要经过什么审批步骤).content(); EmbeddingSearchResultTextSegment searchResult store.search( EmbeddingSearchRequest.builder() .queryEmbedding(queryEmbedding) .maxResults(10) .build() ); ListString candidateTexts searchResult.matches().stream() .map(match - match.embedded().text()) .toList(); ReRankingRequest request ReRankingRequest.builder() .query(部署变更需要经过什么审批步骤) .documents(candidateTexts) .build(); ReRankingResponse response reRankingModel.reRank(request); ListReRankedDocument topDocuments response.rerankedDocuments().stream() .filter(doc - doc.score() 0.3) .limit(3) .toList(); String context topDocuments.stream() .map(doc - doc.document().text()) .collect(Collectors.joining(\n\n)); String prompt 请仅根据以下资料回答问题\n context \n问题 query;这种写法的好处是可控性极强。你可以清楚看到每一段原始候选的分数变化也方便做日志记录和链路追踪。我日常调试重排序效果时基本都是先用这段手动模式把输入输出打出来确认没问题后再切回AiServices自动装配。有一点需要提醒ReRankingResult里的分数是模型给出的相对分数不同模型之间的分数区间差异可能很大。有的模型打分集中在 0.0~0.3有的集中在 0.4~0.9所以“0.3 以上才保留”这样的阈值必须针对具体模型调不能照搬别人的配置。4. 重排序模型怎么选云端 API、本地模型与关键参数4.1 不同方案的行为差异我把常见的几种重排序方案放在一起比较过结论不复杂但每类方案适用场景差距很大。方案延迟单次成本中文表现数据隐私适合场景云端重排序 API100~300ms较低较好数据出内网一般业务场景快速验证本地 cross-encoder50~200ms无看模型完全可控隐私敏感、离线环境通用 LLM 排序1~5s高好取决于部署候选极少准确性要求极高的场景云端 API 最大的好处是省事不用自己部署模型也不用考虑推理服务的稳定性。交叉编码器模型最大的优势则是“对相关性判断更准”因为它会把查询和每个文档片段拼在一起送入模型而不是把它们各自编码后再算相似度判断粒度更细。通用 LLM 排序在我实测中最容易受 Prompt 影响如果候选段落文字相近LLM 经常给出“都很相关”的模糊结果反而不如专门的交叉编码器干净。4.2 三个必调参数初检数量、最终截断数、分数阈值参数调优这件事我可以给出三个优先级最高的点。第一个是初检索的maxResults。我一般取最终截断数的 3 到 5 倍。如果希望最后只给 LLM 3 段上下文初检就取 10 到 15。这样既不会让重排序模型疲于处理大量候选也给了初检足够的召回空间。第二个是重排后到底取多少段。这个数字取决于两个因素Prompt 里能容纳多少 token以及你的文档片段本身多长。400 字的片段取 3 段就是 1200 字左右对大多数模型来说已经够用。取太多反而会让 LLM 在多个正确候选之间“纠结”生成时出现上下文重复引用。第三个是阈值。这里的建议是用一批你手工标注过答案的测试问题把候选文档按重排分数排序观察“正确答案”和“无关答案”在分数上的分界点。分数分界点会随文档主题变化我在自己的文档集上测试时正确答案的分数普遍在 0.5 以上但换一个领域后分界点就掉到了 0.3所以阈值最好来自你自己的评估集而不是拍脑袋。5. 效果验证与线上排坑实录5.1 重排序前后到底差多少我的一次对照实验改造完链路后我自己做了一个小规模对照实验。测试集是 30 个真实业务问题每个问题预先标注了 1~2 个绝对应该被引用的文档段落。然后分别跑两条链路A 链路初检索 top-5直接拼接 Prompt。B 链路初检索 top-10重排序后取 top-3再拼接 Prompt。结果很有意思。A 链路里有 11 个问题没有把正确答案召回进 top-5占了三分之一B 链路把初检扩大到 top-10 后正确答案基本都能进候选池再经过重排最终进入 Prompt 的命中率达到 26/30。这 4 个漏掉的全部是文档切分本身出了问题——关键信息被切得七零八落属于另一个层面的坑。除了命中率我同时观察了生成回答的质量。B 链路产出的回答里引用文档原文的次数明显变多而且回答更精炼。原因是 Prompt 里的干扰项少了模型不再被无关段落带偏。5.2 三个我反复踩过的坑坑一切分重叠区太大导致重复片段。我把重叠区从 50 改成 100 之后同一句话在相邻两段里反复出现重排序模型对重复内容会给出接近的分数最终取出的 3 段里有两段高度重复。这会让 LLM 的答案冗余。建议切分之后先把相邻片段做一下文本去重或者重叠区尽量控制在单个句子长度以内。坑二只靠重排分数过滤把正确答案也滤掉了。我遇到过一次阈值设太高结果某段被模型打了 0.28 分刚好低于 0.3 的过滤线但那段恰恰是答案所在。后来检查发现重排模型往往会因为段落里夹杂了大段背景介绍给整段内容打低分。这个问题的解法不是调阈值而是调整切分策略把“背景铺垫”和“核心结论”尽量切成两个独立片段让重排模型更容易抓到要点。坑三初检的召回能力不足重排再强也救不回来。有一类查询包含很多关键词和缩写embedding 模型对这种稀疏词汇不敏感top-10 候选里根本没有正确答案。这时候重排只是“在矮子里拔将军”。比较务实的方案是走混合检索用关键词/BM25 检索和向量检索各返回一批合并后再重排。LangChain4j 的ContentRetriever支持配置多个检索器把这两种结果合并交给聚合器能补足纯向量检索的召回盲区。最后再分享一点个人体会。重排序不是包治百病的特效药它解决的问题是“候选里本来有正确答案但没被选中”而不是“候选里压根没有正确答案”。所以我现在的习惯是上线 RAG 时先不管重排序把切分、索引、初检这一层做到召回过关再引入重排序精排。地基打稳之后重排序的收益才会真正放大。这个顺序千万不要反过来。