做了三年Java后端接手了不少大模型应用项目我越来越觉得RAG检索增强生成这条技术路线的瓶颈通常不在模型本身而在检索这一环。向量检索召回的结果往往是“看着相关细看跑偏”把一堆似是而非的片段拼进Prompt大模型再聪明也只能硬着头皮编答案。这个问题的标准解法就是重排序Rerank。这篇实战文章我会直接用LangChain4j带你走一遍“粗召回 精排序”的完整链路重点讲清楚为什么只靠向量相似度不够、交叉编码器模型是怎么把精度拉回来的以及每一步的代码到底怎么写。无论你是刚接触Java版LLM开发还是已经在生产环境被RAG效果折磨过这篇文章都能给你一套可以直接落地抄作业的方案。1. 为什么 RAG 要“粗召回 精排序”两段式检索很多团队的第一版RAG都长一个样文档切块、embedding入库、按余弦相似度取Top K、拼进Prompt、交给大模型回答。这条链路跑通很容易但效果经常在60分上下徘徊。问题就出在“按余弦相似度取Top K”这一步它本质上是在一个极度压缩的向量空间里做匹配信息损失非常严重。1.1 双塔检索的“盲区”到底在哪Embedding模型用的是双塔结构文档和查询分别独立编码成向量再用余弦相似度对比。这种结构的好处是文档向量可以离线算好存进向量库线上查询只需算一次查询向量响应快、吞吐高。但代价也很明显查询和文档在编码阶段完全没有交互词与词之间的细微联系被向量化过程抹平了。举个例子。模拟项目X的FAQ里有两条记录一条是“退款申请提交后通常3-5个工作日原路退回”另一条是“充值失败会在48小时内自动补发”。如果你的查询是“钱没到账怎么办”向量相似度可能同时召回这两条甚至因为“退款”“补发”这类词在embedding空间中接近错误的那条排得更靠前。双塔模型看到的是“语义大致方向一致”但它看不到“这个查询最关心的动作主体和结果状态”。这就是双塔检索的典型盲区召回能力够用但排序精度远远不够。搜索引擎领域早就遇到过这个问题所以它们的架构是分两段的先用轻量召回拿到几百条候选再用更精细的排序模型把真正的Top K挑出来。RAG的工程化落地同样应该走这条路。1.2 交叉编码器为什么能把精度拉回来重排序普遍采用交叉编码器Cross-Encoder结构。跟双塔最大的区别是查询和文档拼成一句话同时送进模型词和词之间可以做交叉注意力。这意味着模型能看到“查询里的这个词和文档里的那个词到底是不是在配合表达同一个意思”。用生活类比来说embedding像是把两个人各自关在房间里写一份材料摘要最后只拿摘要比对交叉编码器则是把两个人叫到同一张桌子上指着每一个词问“你说的这个跟他说的是不是一个意思”。后者的信息完整度完全不在一个量级。所以重排序模型输出的相关性分数远比余弦相似度可信。在实际项目中同一批召回结果里交叉编码器给的排序经常能把真正有用的片段从第8位提到第2位或者把一条语义相近但答非所问的内容从第1位压到第6位开外。这个精度提升直接决定了拼进Prompt的上下文质量。1.3 重排序方案的选型清单做重排序前先想清楚一个原则重排序阶段不需要海量候选但候选规模也不能只有三五条。常规做法是向量检索先粗召回20到50条然后重排序从中选3到5条。重排序器可以选以下三种之一本地交叉编码器模型ONNX推理适合数据敏感、要求低延迟、不想依赖外部服务的团队。部署成本是模型文件和推理资源。团队内网自建的重排序HTTP服务适合中大型团队模型集中部署、统一维护业务服务只发HTTP请求。工程上最通用。轻量二次筛选用EmbeddingModel再算一遍分数或者用关键词重叠度加权。这个不算严格的重排序但可以作为临时方案顶上实现成本极低。从效果来看交叉编码器重排序基本是必备项。如果项目预算和资源都紧张我的建议是至少把Top N扩大、再用一个内网服务做精排这个性价比远高于盲目换更好的向量模型。2. 环境与工程准备项目依赖、模型下载与骨架搭建LangChain4j在Java生态里算是把RAG链路抽象得比较完善的一个框架了文档加载、切分、向量化、检索、AiServices都有现成接口。不过它默认没有给你重排序能力需要我们自己包一层ContentRetriever。这一节先把工程底子搭好。2.1 最小依赖清单Spring Boot 3 LangChain4j 本地推理我建议用Spring Boot 3 Java 17起步依赖管理里加入LangChain4j的BOM然后按需引入以下模块dependencyManagement dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-bom/artifactId version${langchain4j.version}/version scopeimport/scope typepom/type /dependency /dependencies /dependencyManagementdependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-spring-boot-starter/artifactId /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-embedding/artifactId /dependency本地交叉编码器推理我用的是ONNX Runtime这个开源推理引擎可以直接在Java进程里加载ONNX格式的模型。依赖加一个主包就够dependency groupIdcom.microsoft.onnxruntime/groupId artifactIdonnxruntime/artifactId version${onnxruntime.version}/version /dependency依赖版本方面LangChain4j迭代很快别死磕某一个版本号用你工程里实际拉到的最新稳定版就行。有一点提醒不同版本的API签名略有差异如果你是从旧版本升上来的重点检查ContentRetriever和EmbeddingStoreContentRetriever的构造方式。2.2 嵌入模型与向量库怎么配合嵌入模型选择直接影响召回上限。中文场景下我建议直接用社区成熟的中文开源嵌入模型比如bge系列或m3e系列它们对中文FAQ、技术文档这类文本的语义把握明显优于早期的通用英文模型。LangChain4j本身不限制嵌入模型实现只要实现EmbeddingModel接口即可。向量库方面数据量几千条以内、本地演示环境直接使用LangChain4j的InMemoryEmbeddingStore是最省事的。生产环境建议换专业的向量数据库PGVector是PostgreSQL插件团队如果没有额外的基础设施成本顾虑它能直接复用已有的PostgreSQL运维体系数据量大、并发高的场景再考虑独立向量库。这里不展开具体选型核心是EmbeddingStoreTextSegment这个抽象接口换实现不影响上层代码。2.3 模拟项目X的工程结构与基础配置为了讲解方便我构造一个模拟项目X一份包含退换货政策、产品使用说明、故障排查手册、财务报销制度的内部知识库。大概50篇文档覆盖典型的中文QA场景。工程目录结构如下demo-rag/ ├── pom.xml ├── src/main/resources/ │ ├── docs/ # 原始文档 │ ├── reranker/ │ │ ├── model.onnx # 交叉编码器模型 │ │ └── tokenizer.json # 分词器配置 │ └── application.yml └── src/main/java/ └── demo/ ├── Reranker.java ├── HttpReranker.java ├── OnnxCrossEncoderReranker.java ├── RerankContentRetriever.java └── RagService.javaapplication.yml里配置自定义参数demo: rag: embedding-model-url: http://内网嵌入服务地址/v1/embedding top-n: 20 top-k: 4 min-score: 0.0 rerank-url: http://内网重排序服务地址/v1/rerank需要说明的是嵌入模型和大模型推荐通过团队内网部署的网关服务接入不在业务代码里直接依赖某个特定厂商SDK。这样做的原因是生产环境的网络策略通常不允许业务实例直连外部API统一网关也方便切换供应商。下面代码里我会按这个思路封装一个简单的HTTP嵌入模型实现。3. 完整实战代码从文档导入到回答生成这一节是文章的核心。我会按照实际项目的标准流程先做文档处理和入库再自定义ContentRetriever接入重排序最后接上大模型生成回答。3.1 文档加载、切分与向量化先用LangChain4j标准做法加载文档。我直接把resources/docs目录下的所有.md文本文件读进来按标题感知切分器切块控制每块约300字左右ListDocument documents FileSystemDocumentLoader.loadDocumentsRecursively(Path.of(src/main/resources/docs)) .stream() .map(Document::toTextSegment) // 简便演示实际建议用DocumentSplitter .collect(Collectors.toList());实际项目中不要这样一把梭。我的建议是用DocumentByParagraphSplitter或者带重叠的切分器比如每段300字、重叠50字。为什么要有重叠因为检索切块是按固定窗口切开的一个问题的答案可能跨越两个块边界如果两块完全不重叠边缘信息就丢了。折叠重叠的代价是入库数量增加大约20%但对召回率的提升非常值。向量化入库时我封装了一个走内网HTTP网关的EmbeddingModel。核心是调用OpenAI兼容协议里的/embeddings接口注意这里不说特定厂商而是指团队内网网关提供的标准协议public class HttpEmbeddingModel implements EmbeddingModel { private final String url; private final RestClient client RestClient.create(); private final String modelName; public HttpEmbeddingModel(String url, String modelName) { this.url url; this.modelName modelName; } Override public ResponseListEmbedding embedAll(ListTextSegment textSegments) { MapString, Object body new HashMap(); body.put(model, modelName); body.put(input, textSegments.stream().map(TextSegment::text).toList()); MapString, Object resp client.post() .uri(url) .body(body) .retrieve() .body(Map.class); // 解析 data 数组中的 embedding按入参顺序返回 ListMapString, Object data (ListMapString, Object) resp.get(data); ListEmbedding embeddings new ArrayList(); for (MapString, Object item : data) { ListDouble vector (ListDouble) item.get(embedding); embeddings.add(Embedding.from(vector.stream() .map(Double::floatValue) .toArray(Float[]::new))); } return Response.from(embeddings); } }然后是入库核心代码EmbeddingModel embeddingModel new HttpEmbeddingModel( http://内网嵌入服务地址/v1/embeddings, 路线所用嵌入模型名); EmbeddingStoreTextSegment embeddingStore new InMemoryEmbeddingStore(); ListTextSegment segments documents.stream() .map(Document::toTextSegment) .collect(Collectors.toList()); ListEmbedding embeddings embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments);到此检索的基础设施就齐了。注意InMemoryEmbeddingStore只在单机演示时用重启丢数据生产环境换成持久化实现。3.2 自定义 ContentRetriever 接入重排序LangChain4j默认的EmbeddingStoreContentRetriever只做向量相似度检索没有重排序口子。我的做法是把它作为粗召回器再用自定义类包一层在内部完成精排。这样对上层AiServices完全透明属于标准的装饰器模式。public class RerankContentRetriever implements ContentRetriever { private final EmbeddingStoreContentRetriever delegate; private final Reranker reranker; private final int topK; public RerankContentRetriever(EmbeddingStoreContentRetriever delegate, Reranker reranker, int topK) { this.delegate delegate; this.reranker reranker; this.topK topK; } Override public ListContent retrieve(Query query) { // 第一步粗召回 ListContent candidates delegate.retrieve(query); // 第二步精排序 return reranker.rerank(query.text(), candidates, topK); } }装配逻辑在Spring配置类里完成Configuration public class RagConfig { Value(${demo.rag.top-n}) private int topN; Value(${demo.rag.top-k}) private int topK; Bean public ContentRetriever contentRetriever(EmbeddingStoreTextSegment store, EmbeddingModel embeddingModel, Reranker reranker) { EmbeddingStoreContentRetriever delegate EmbeddingStoreContentRetriever.builder() .embeddingStore(store) .embeddingModel(embeddingModel) .maxResults(topN) // 粗召回 20 条 .minScore(0.0) // 阈值设低别在粗召回阶段误杀 .build(); return new RerankContentRetriever(delegate, reranker, topK); } }这里有一个非常关键的细节maxResults不要设成3或5。很多人在第一步就直接取Top 5重排序器再强也只能从5条里挑真正正确答案如果排在Top 6重排序就无能为力了。粗召回阶段宁可多召回、低阈值把决定权交给重排序。3.3 两种重排序器实现HTTP 服务与本地 ONNX 推理先定义统一的Reranker接口。两种实现都返回排好序的Content列表业务逻辑不用感知底层差异。public interface Reranker { ListContent rerank(String query, ListContent candidates, int topK); }第一种内网重排序HTTP服务。这是团队内集中部署模型时的标准姿势业务代码只发请求public class HttpReranker implements Reranker { private final String url; private final RestClient client RestClient.create(); public HttpReranker(String url) { this.url url; } Override public ListContent rerank(String query, ListContent candidates, int topK) { MapString, Object body new HashMap(); body.put(query, query); body.put(documents, candidates.stream() .map(content - content.textSegment().text()) .toList()); MapString, Object resp client.post() .uri(url) .body(body) .retrieve() .body(Map.class); ListDouble scores (ListDouble) resp.get(scores); ListContent result new ArrayList(); for (int i 0; i candidates.size(); i) { result.add(candidates.get(i)); } // 按分数倒序 ListInteger idx new ArrayList(); for (int i 0; i candidates.size(); i) idx.add(i); idx.sort((a, b) - Double.compare(scores.get(b), scores.get(a))); return idx.stream() .limit(topK) .map(candidates::get) .collect(Collectors.toList()); } }第二种本地ONNX交叉编码器推理。这种方案对数据私密性要求高的团队特别友好。核心代码分三步加载模型、拼接输入、推理输出。我以开源bge-reranker系列模型为例模型从模型社区导出为ONNX格式后目录下会有model.onnx和tokenizer.json两个文件。public class OnnxCrossEncoderReranker implements Reranker { private final OrtSession session; private final String tokenizerPath; public OnnxCrossEncoderReranker(String modelPath, String tokenizerPath) throws OrtException { this.session new OrtEnvironment().createSession(modelPath, new OrtSession.SessionOptions()); this.tokenizerPath tokenizerPath; } Override public ListContent rerank(String query, ListContent candidates, int topK) { // 1. 对每个候选拼接 [CLS] query [SEP] doc [SEP] // 2. 用 tokenizer.json 做 tokenize得到 input_ids、attention_mask // 3. session.run(...) 拿到 logits // 4. logits 过 sigmoid得到0~1相关性分数 // 5. 按分数倒序取 topK // 这里的 tokenize 细节见下文说明 } }很多人卡在tokenizer这一步。Java生态里有开源的BERT分词器库可以直接加载tokenizer.json。核心逻辑就是把query和doc拼成“查询文本 分隔符 候选文本”模型要求最大长度一般512超长截断。我的建议是文档块别切太大300字以内的块一般不会超长既保证语义集中又省去长文本处理麻烦。模型的输出层只有一个神经元bge-reranker的输出是logits需要做sigmoid映射float logit output[0]; float score (float) (1.0 / (1.0 Math.exp(-logit)));这个分数就是模型判定“查询与这段文档的相关性”直接用它排序。3.4 与大模型生成链路整合重排序后的结果需要拼成上下文交给大模型。我建议不要在业务代码里手写字符串拼接大法而是用LangChain4j的AiServices来管理RAG链路。先定义一个接口public interface Assistant { SystemMessage(你是一个企业内部知识库助手只能根据提供的资料回答。 如果资料中没有答案请直接说明不知道。) String answer(UserMessage String userMessage, V(context) String context); }服务里手动完成“检索 - 拼上下文 - 调用大模型”的流程逻辑更透明Service public class RagService { private final ContentRetriever retriever; private final ChatLanguageModel chatModel; public String answer(String question) { // 1. 检索 重排序 ListContent contents retriever.retrieve(Query.from(question)); // 2. 拼上下文 String context contents.stream() .map(content - content.textSegment().text()) .collect(Collectors.joining(\n\n)); // 3. 构造提示词 String prompt 请根据以下资料回答问题。只能使用资料中的信息资料中没有的内容不要编造。 资料 %s 问题 %s .formatted(context, question); return chatModel.generate(prompt); } }有两点经验要分享。第一Prompt里必须强制约束“只根据资料回答”否则大模型会自由发挥。第二最好给每段资料标上编号让大模型在回答末尾标注引用来源。比如“回答依据资料2和资料5”这样后续人工核查答案时能快速定位是哪条文档提供了依据这在实际业务中是刚需能直接降低幻觉排查成本。4. 效果对比与调参让重排序真正发挥价值代码跑通之后最容易被忽略的是验证环节。很多人只看一两个例子觉得“似乎变准了”然后就上线。这种凭感觉的做法一旦换数据集就会翻车。我建议按下面的方法系统评估。4.1 如何量化“准不准”RAG检索效果建议至少看两个层面检索层的指标和应用层的指标。检索层最常用的是RecallK和MRRMean Reciprocal Rank。RecallK指正确答案是否出现在前K条召回里MRR看正确答案排在第几位它会给排第一的答案满分、排第二的半分、排第三的1/3分。应用层指标更直接准备一组测试问题每个问题标注标准答案和判定规则跑完整RAG链路统计“回答正确率”。这个指标跟用户体验最接近我建议团队每周跑一次回归测试集MRR涨了但回答准确率没涨说明问题可能在Prompt或模型侧。4.2 一个可复现的对比实验我在模拟项目X上做过一次对照实验效果非常典型。测试数据是50篇文档人工标注了20个问题。一个问题示例是“退款申请已经在系统里提交了三天为什么还没有到账”。正确文档是“退款处理时效说明”那一篇。实验结果如下方案正确答案Top 5命中正确答案平均排名大模型回答正确率纯Embedding检索 Top 511/203.455%Embedding粗召回 Top 20 重排序取 Top 420/201.385%Embedding粗召回 Top 20 不重排序直接取 Top 413/202.865%看清楚重排序的价值了它不只是把排名提前而是把原本在Top 5之外的正确答案捞了回来。在这组数据里有7个问题的正确答案在初排Top 5之外最高的一位排在第14重排序把其中5个拉到了前3。另一个值得关注的对比粗召回Top 20后如果不做重排序直接截断取Top 4效果只比原来的Top 5略好一点点。这说明光扩大候选池不解决排序问题必须配重排序。原因在于粗召回Top 20虽然把正确答案包含进来了但里面噪声太多直接截断依然可能截错。4.3 topN、topK、chunk 与阈值的联动调整调参这件事我的建议是按这条链路一个个来先调chunk大小。太碎100字以下导致语义信息不足太大500字以上导致一个块掺杂多个主题、交叉编码器打分被稀释。从300字、重叠50字开始观察检索命中率再微调。再调topN。建议从20起步。对本地重排序推理来说候选从20加到50推理耗时接近线性上涨如果候选池质量差、正确答案老不在池内才考虑加大到50甚至80。这个参数同时受链路耗时约束。topK调整影响的是最终喂给大模型的上下文量。topK太大Prompt变长、推理变慢、无关信息增多topK太小覆盖信息不够。我习惯先从4起步配合答案准确率微调。minScore阈值要谨慎。初学者容易把阈值设成0.5结果导致无召回或Recall崩盘。交叉编码器模型的分数范围跟模型训练数据分布有关0.5并不是普适的合格线。经验做法是先设0.0看召回情况再用真实错误案例反推合理阈值。5. 排坑实录与经验心得几个容易翻车的地方重排序不是加上就稳赢我在多个项目里踩过不少坑。挑几个典型问题分享一下排查思路。5.1 召回池里根本没有正确答案重排序再准也救不了不在候选池里的答案。我排查这类问题通常按三个顺序查第一检查切块是否太碎导致答案被切散。第二检查嵌入模型对领域术语的表征能力比如专业缩略词、内部黑话通用模型可能根本不认识。第三检查粗召回时的minScore设置是否误杀。有一次我发现某条查询的正确答案其实在Top 20里排第18但minScore设了0.4直接被过滤了。排查方法很简单临时把minScore调成0把粗召回候选全打出来看看目标文档在不在里面。如果目标在候选池但重排序没捞上来问题在重排序模型如果不在候选池问题在上游检索换谁来做精排都没用。5.2 重排序之后结果反而变差了这种情况我也会遇到先说结论九成是重排序模型跟你的领域文本风格不匹配。bge-reranker这类通用模型在通用QA上表现不错但遇到专业场景、古文风格、强行话体系的内容打分就容易漂。我有一次做设备故障日志检索通用重排序模型把“报错代码”相关的文档全部排到前面反而压低了真正描述根因分析的文档。后来用300条人工标注的领域样本微调重排序模型同一组测试集上MRR从0.71涨到0.86。如果你没有精力微调先试试换一个领域数据更接近的开源模型可能就有改善。还有一种可能你的粗召回候选本身噪声太大重排序模型被干扰项带偏。这时先降低候选池噪声比如优化切块、优化嵌入模型再考虑重排序的算法问题。5.3 响应延迟高到不可接受交叉编码器是逐个query, doc对推理的20个候选就要推理20次。如果是CPU推理单条可能几十到上百毫秒20条就是1到2秒再加tokenize开销用户体感明显变差。我的优化顺序是先降topN从50降到20再给重排序推理加并发把20个候选拆成4个一批并行跑如果还是慢把重排序模型挪到GPU推理或者把重排序器部署成独立内网服务业务侧只发一次HTTP请求。最后一种做法还能顺便把模型升级对业务透明我比较推荐。还有一个细节长文档块会显著拖慢交叉编码器推理因为输入序列变长attention计算量是二次增长。控制chunk长度不仅利于检索质量也利于重排序延迟。5.4 中文长文本的编码细节中文场景最容易踩的两个坑其一是tokenize时把“中文标点”处理错导致分词质量暴跌其二是模型最大长度限制把长文档直接截断截断位置恰好切断关键信息。处理方案是切块时保证单块长度在该模型最大长度的一半以内留出query拼接空间。比如模型最大512那么文档块控制在300字以内比较稳。另外注意保存文档块时不要丢原始段落标题重排序参考段落标题能显著提升专业文档的排序稳定性。我的做法是切块时在每块开头保留“所属章节标题”用分隔符隔开这样既能提升embedding质量重排序模型也能看到更强的主题信号。6. 写在最后的经验之谈重排序在RAG链路里的定位说到底是往检索精度上补刀。没有它RAG的上限会被双塔检索的排序精度死死压住有了它整个系统的召回率和精度才真正拉得开差距。从工程投入产出比来看这是整个RAG项目里性价比最高的一个环节。个人体会是LangChain4j虽然没直接提供重排序器但它的模块化设计反而让这件事变得很干净——你只需要包一层ContentRetriever业务代码完全不用感知。这也提醒我做这类工程改造别急着写大段原生代码先看框架的扩展点在哪顺着扩展点做通常最省力。最后分享一个小技巧重排序器的输出分数是有诊断价值的。在生产环境里把每次检索的候选排名和分数落日志抽几条低质量样本看分数分布你能快速区分是候选池问题还是重排序模型问题。这个日志习惯帮我省了不知道多少排查时间。