☰
RAG检索重排序实战:用LangChain4j提升知识库问答准确率
2026/10/14 8:37:41 网站建设 项目流程

做 RAG 的人,应该都有过这么一瞬间:文档明明在知识库里,检索也返回了,可大模型就是抓不住重点,回答得像没听过这回事。问题通常不是大模型不行,而是你喂给它的上下文里,真正有用的段落被淹没在了一堆“看着相关”的段落里。

我前段时间帮某团队的售后知识库做了一次体检,发现最典型的场景就是:用户问“保修期内换屏要多少钱”,向量检索返回的Top 5里,前两名是营销活动页和旧版价目表,第三条才真正提到保修换屏政策。如果这时候直接把Top 5全部打包给大模型,它会倾向于把前几条里的信息也揉进回答,幻觉就这么来了。

这次我基于 LangChain4j 做了一版带“检索重排序”的 RAG 管线,核心思路很简单:先用向量召回一批候选段落,再用一个专门的排序模型对候选段落重新打分,最后只把高分段落交给大模型。整套改动下来,我们这边售后问答的命中率从大约 65% 提到了 90% 上下,答案的稳定性也明显变好。这篇文章我会把完整代码、参数调法、踩过的坑全写出来,适合已经跑通基础 RAG、但对检索质量不满意的 Java 技术栈同学。

1. 先搞清楚重排序在RAG里的位置

1.1 标准RAG流程

RAG 的完整链路大家应该不陌生:文档切分,段落向量化,写入向量库;用户提问时把问题向量化,从向量库里按相似度召回 Top K 个段落;这些段落拼进 Prompt,连同问题一起交给大模型生成答案。

这个链路里,真正决定答案质量的不是最后那一步生成,而是召回。如果召回回来的段落本身不对,你再怎么优化 Prompt,大模型也只能在错误的上下文里“编”。重排序要干预的,就是“召回”和“生成”之间那段夹缝。

1.2 向量召回的两个典型翻车现场

向量检索擅长的是“语义相近”,但它有个老毛病:它衡量的是 Query 和文档在向量空间里的距离,不是“这句话是不是真的能回答这个问题”。我实际排查时发现了两类高频翻车。

第一类是词面重叠误导。比如用户问“苹果保修政策”,文档里有一整段讲“苹果种植基地的病虫害防治”,因为都出现了“苹果”和“政策”这些词,它的向量距离可能意外地近,于是被排进了 Top 5。大模型拿到之后,不知道哪段才是用户想要的“苹果”,不产生幻觉才怪。

第二类是长文档里多主题混排。假设一页产品手册里,先写规格参数,再写故障排查,最后写售后条款。按段落切分后,如果切分粒度太大,一个段落里塞了三个主题,向量化出来的向量是个“四不像”,跟真实问题匹配时总差那么点意思,排名忽高忽低,很不稳定。

1.3 两阶段检索:粗召回加精排序

重排序本质上就是搜索引擎玩了很多年的两阶段思路。第一阶段用廉价手段,从海量文档里快速捞一批“可能相关”的候选;第二阶段用一个更强的模型,对这批候选做细粒度的相关性判断,把真正有用的排到最前面。

第一阶段用的通常是双塔结构,Query 和 Document 各自过一遍编码器,算向量夹角。速度快,能提前把候选范围缩小,但对词与词之间的深层交互关系建模不够。

第二阶段的交叉编码器则是把 Query 和 Document 拼成一个序列,一次性喂进模型,让每个 token 都能互相看到对方。这种深度交互能捕捉“这俩到底在不在说同一件事”,准确率高不少,但没法对全库文档一个个跑,太贵了,所以只能排在第二阶段用。

我做项目时给团队翻译成人话:向量召回是 HR 筛简历,按关键词和职位描述打个粗分;重排序是部门主管面试,聊几句才知道这人到底行不行。两道关卡各干各的活。

2. 项目设计与技术选型

2.1 演示场景与预期效果

这次落地的场景是某团队的产品售后知识库,混合了几类材料:产品说明书、FAQ、内部工单处理记录、售后政策公告。文档总数大概两千份,每份长短不一,全部转成纯文本之后开始做分块。

改造前的管线比较“质朴”:向量召回 Top 5,直接拼 Prompt。实测问题集大概 100 条,人工标注“应该在哪个文档里能找到答案”,Top 5 命中率 65%,也就是有三分之一的问题,正确文档根本不在候选里,后续怎么优化 Prompt 都白搭。

加了重排序之后,我把候选召回数放大到 20,再用重排序器取 Top 5,Top 5 命中率提升到 90%,Top 3 也稳定在 80% 左右。更关键的变化是,正确文档从原来的“排在第八九位”被捞回到了第一位,大模型拿到的上下文质量完全不同,回答里胡说八道的情况少了很多。

2.2 技术栈约定

先说版本约定,后面代码都基于这个环境:

组件选择说明
语言Java 17用了 HttpClient 和 Stream,更老版本要换写法
构建Maven常规配置,无特殊插件
RAG 框架LangChain4j 1.x以 1.x 系列的 API 为准
嵌入模型你接入的 Embedding 服务云端或私有化部署均可,代码里通过接口对接
向量库先用 InMemoryEmbeddingStore演示和数据量不大时够用;生产量级再换外部向量库
重排序自建 HTTP 接口或本地模型服务见 2.3 的三种接入方式

这里稍微多说一句向量库。InMemoryEmbeddingStore 启动简单、零运维,适合验证流程和写 Demo。但文档量上来之后,内存里的线性比对会越来越慢,建议换用支持索引加速的外部向量数据库。好在 LangChain4j 把 EmbeddingStore 抽象成了接口,上层代码不用改,换一个实现类就行。

2.3 重排序器的三种接入方式

重排序模型怎么接入,是第一步要想清楚的。我整理了三种常用方式,各有取舍。

方式优点缺点适合场景
SaaS 重排序 API接入最快,效果有保障,不用管模型部署数据和查询要发到外部,延迟受网络影响,按量计费原型验证、数据不敏感、团队没有模型部署能力
私有化部署开源交叉编码模型数据不出内网,可自定义部署规格,延迟可控要维护推理服务,机器成本在那业务数据敏感,或调用量很大
本地推理库直接加载模型极低延迟,离线可用对 Java 生态和机器配置要求高,调优复杂模型小、单机可担的场景

团队当时赶进度,先用第一种方式验证效果,验证通过后把模型换成了私有化部署的推理服务,接口协议保持一致,代码层面几乎没动。这也是我的建议:先用简单方式跑通流程,确认重排序真的带来收益,再考虑数据合规和成本问题。

3. 核心代码落地:在LangChain4j里接上重排序

3.1 依赖与配置

Maven 依赖里核心是 langchain4j 主包,以及我用的 JSON 解析库。嵌入模型和对话模型对应的接入模块,用哪个服务就加哪个,我这边的代码面向接口,不绑死具体实现。

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>1.0.0</version> </dependency> <dependency> <groupId>com.fasterxml.jackson.core</groupId> <artifactId>jackson-databind</artifactId> <version>2.17.2</version> </dependency>

配置项我用 environment 注入,避免密钥写死在代码里。YAML 风格如下:

app: embedding-api: url: ${EMBEDDING_API_URL} api-key: ${EMBEDDING_API_KEY} rerank-api: url: ${RERANK_API_URL} api-key: ${RERANK_API_KEY} top-n: 5 min-score: 0.35 batch-size: 10

重排序的 top-n 是最终要保留几条,min-score 是相关性下限,batch-size 是单次请求最多传多少条候选文档,后面代码里会用到。

3.2 文档切分与向量化入库

先把原始文档读进来,按段落切分。LangChain4j 里可以用 DocumentByParagraphSplitter,我给的参数是单个段落最大长度 600 个字符、重叠 80 个字符。字符数和 token 数不是一个概念,中文场景下 600 字符大约对应 200 到 300 个 token,后面重排序模型通常也卡在这个量级。

Document document = Document.from(text); DocumentSplitter splitter = new DocumentByParagraphSplitter(600, 80); List<TextSegment> segments = splitter.split(document);

为什么必须做重叠?段落一旦被切分,边界上的句子很容易被硬生生切断。比如“保修期自激活之日起计算”,如果“自激活之日起计算”被切到下一段,前一段的“保修期”就成了无主信息。重叠 80 个字符能保证前后段落都保留这句话的完整上下文,代价是相邻段落会有重复内容,检索时可能出现两条重复命中,这在重排序阶段会被分数排序自然处理掉。

接下来建嵌入模型、向量库,把段落全部向量化入库:

EmbeddingModel embeddingModel = createEmbeddingModel(); EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); store.addAll(embeddings, segments);

createEmbeddingModel 这个方法需要你自己对接嵌入服务。LangChain4j 的官方模块里有各家的 EmbeddingModel 实现,挑一个合适的用即可。嵌入模型建议固定版本,不要频繁更换,因为不同模型的向量空间不一致,换模型之后全库向量都得重新生成,代价不小。

3.3 自定义HTTP重排序器

这是整个改动的核心。我实现了一个 HttpClient 调用重排序接口的组件,候选文档传过去,服务端返回每个文档与 Query 的相关性分数。

先定义一个承载打分结果的对象:

record ScoredContent(int index, double score, Content content) { }

index 是候选列表里的原始位置,score 是相关性分数,content 是原始的 Content 对象。记录 index 很关键,因为服务端返回的结果是乱序索引,我们得靠它找回原始段落。

核心逻辑如下:

public class HttpReranker { private final HttpClient httpClient; private final String apiUrl; private final String apiKey; private final int topN; private final double minScore; private final int batchSize; private final ObjectMapper objectMapper = new ObjectMapper(); public HttpReranker(String apiUrl, String apiKey, int topN, double minScore, int batchSize) { this.httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(3)) .build(); this.apiUrl = apiUrl; this.apiKey = apiKey; this.topN = topN; this.minScore = minScore; this.batchSize = batchSize; } public List<Content> rerank(String query, List<Content> candidates) { if (candidates == null || candidates.isEmpty()) { return List.of(); } List<String> documents = candidates.stream() .map(content -> content.textSegment().text()) .toList(); List<ScoredContent> scored = new ArrayList<>(); // 分批调用,避免单次请求体过大 for (int batchStart = 0; batchStart < documents.size(); batchStart += batchSize) { List<String> batch = documents.subList(batchStart, Math.min(batchStart + batchSize, documents.size())); List<ScoredContent> batchScored = callRerankApi(query, batch, batchStart); scored.addAll(batchScored); } // 先按分数过滤,再按分数倒序取 topN List<ScoredContent> filtered = scored.stream() .filter(s -> s.score() >= minScore) .sorted(Comparator.comparingDouble(ScoredContent::score).reversed()) .limit(topN) .toList(); return filtered.stream() .sorted(Comparator.comparingInt(ScoredContent::index)) .map(ScoredContent::content) .toList(); } private List<ScoredContent> callRerankApi(String query, List<String> batch, int offset) { try { Map<String, Object> payload = new LinkedHashMap<>(); payload.put("query", query); payload.put("documents", batch); String body = objectMapper.writeValueAsString(payload); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(apiUrl)) .timeout(Duration.ofSeconds(5)) .header("Authorization", "Bearer " + apiKey) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) .build(); HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); if (response.statusCode() != 200) { throw new RuntimeException("Rerank API error, status=" + response.statusCode() + ", body=" + response.body()); } return parseResponse(response.body(), offset); } catch (IOException | InterruptedException e) { Thread.currentThread().interrupt(); throw new RuntimeException("Rerank API call failed", e); } } private List<ScoredContent> parseResponse(String responseBody, int offset) throws JsonProcessingException { JsonNode root = objectMapper.readTree(responseBody); JsonNode results = root.path("results"); List<ScoredContent> list = new ArrayList<>(); for (JsonNode item : results) { int idx = item.path("index").asInt(); double score = item.path("relevance_score").asDouble(); list.add(new ScoredContent(offset + idx, score, null)); } return list; } }

这段代码有几个细节值得展开说。

第一,parseResponse 里索引偏移量 offset 的处理。因为我把候选文档分批传了,第二批的 index 是从 0 开始的,所以要把 offset 加上,才能映射到完整的候选列表。我第一次写的时候漏了它,结果重排后的文档张冠李戴,检索记录一片混乱。

第二,注意请求超时。检索链路是同步调用,一旦重排序服务响应慢,整个问答接口都会被拖垮。我设置了连接超时 3 秒、请求超时 5 秒,线上环境根据实际情况再调。

第三,批次大小 batchSize 不要拉太大。有些重排序服务对单次请求的文档数量有限制,保守一点设 10 条,候选召回 20 条时也就是两次 HTTP 请求,成本可以接受。

回传 Content 对象的恢复逻辑在这段代码里省略了,因为 HttpReranker 只接收 Content,而回调里拿到的是候选列表,需要在主流程中根据 index 映射。我在 3.4 里的包装层会处理这一步,把分数和原始 Content 成对绑定。

3.4 包装ContentRetriever并接入AiServices

有了重排序器,下一步是把“召回候选”和“重排序截断”两个动作串起来。这里我直接包装 ContentRetriever,比修改链路内部更直观,也不容易被版本升级影响。

ContentRetriever baseRetriever = EmbeddingContentRetriever.builder() .embeddingModel(embeddingModel) .embeddingStore(store) .maxResults(20) .build(); HttpReranker reranker = new HttpReranker( rerankApiUrl, rerankApiKey, 5, // topN 0.35, // minScore 10 // batchSize ); ContentRetriever rerankedRetriever = query -> { List<Content> candidates = baseRetriever.retrieve(query); if (candidates.isEmpty()) { return List.of(); } List<scoredContent> scored = reranker.rerankWithScore(query.text(), candidates); return scored.stream() .filter(s -> s.score() >= reranker.getMinScore()) .limit(5) .map(s -> candidates.get(s.index())) .toList(); };

注意两点。

第一,baseRetriever 的 maxResults 从原来的 5 调大到了 20。这是重排策略的关键:给第二阶段喂更多候选,让好内容有机会从“本来排不进前五”的位置被捞回来。如果召回窗口还是 5,重排序就没有操作空间。

第二,最终返回给 Prompt 的段落顺序,我会按原始召回顺序或重排后的分数顺序都行。实践下来,按重排分数倒序组装 Prompt,对模型更友好;但也有人习惯保持原文逻辑顺序,担心乱序影响阅读。我建议做个小实验,两组各测一次,哪个答案稳用哪个。

接着把包装好的 retriever 挂到 RetrievalAugmentor 上。我先加了多轮对话压缩,因为售后场景里用户极爱用代词:“它坏了”“这个怎么弄”。如果不先处理,向量检索拿到的 query 是“它坏了”,神仙模型也召回不了正确文档。

ChatModel chatModel = createChatModel(); QueryTransformer queryTransformer = CompressingQueryTransformer.builder() .chatModel(chatModel) .build(); RetrievalAugmentor augmentor = RetrievalAugmentor.builder() .queryTransformer(queryTransformer) .contentRetriever(rerankedRetriever) .build(); Assistant assistant = AiServices.builder(Assistant.class) .chatModel(chatModel) .retrievalAugmentor(augmentor) .build(); String answer = assistant.answer("你好,我的机器开不了机,请问怎么排查?");

Assistant 接口定义很简单:

interface Assistant { String answer(String message); }

到这一步,整个 RAG 链路已经变成:问题进来,先压缩多轮对话,再向量召回 20 条候选,紧接着重排序器打一遍分,过滤掉低分段落,最后按分数取前 5 条进 Prompt。大模型看到的上下文,是经过精排的高质量段落,回答质量自然不一样。

3.5 验证重排序是否真的生效

接入之后别急着上线,先打日志做对比。我在包装层里加了两行日志:

List<Content> candidates = baseRetriever.retrieve(query); log.info("召回Top{}: {}", candidates.size(), candidates.stream() .map(c -> c.textSegment().text().substring(0, Math.min(50, c.textSegment().text().length()))) .toList()); List<Content> finalContents = rerankedRetriever.retrieve(query); log.info("重排后Top{}: {}", finalContents.size(), finalContents.stream() .map(c -> c.textSegment().text().substring(0, Math.min(50, c.textSegment().text().length()))) .toList());

我用几条有代表性的问题做对照测试,包括:明确的问法、口语化问法、带错别字的问法、含代词的追问。每跑一条,人工看一下重排前后的顺序变化。

我整理一次典型的验证结果:

测试问题未重排 Top5 第一条重排后 Top5 第一条答案质量
保修期内换屏多少钱产品介绍页面保修换屏政策段落明显改善
设备无法开机怎么办参数规格故障排查步骤明显改善
退货运费谁出商城活动描述售后物流政策明显改善

日志里如果发现重排后的第一条还不如原来,别急着怀疑模型,先检查是不是候选数量太少、分段过大、或者多轮对话没压缩好。这些问题在第 5 节展开。

4. 参数调优与效果评估

4.1 召回窗口和保留数怎么定

重排序不是魔法,它的天花板由第一阶段召回质量决定。如果正确内容压根没被召回,重排序再准也捞不回来。

我建议把 baseRetriever 的 maxResults 从默认 5 提到 20 或 30。理论上窗口越大越好,但窗口越大,重排序请求的文档数越多,延迟和费用都会上升。实测下来,文档类型比较杂的知识库,20 条比 10 条有明显收益;再往上到 30 条,收益增长就明显变缓,甚至没有。

最终保留下来的条数,也就是 topN,我强烈建议不要超过 5。给大模型塞 8 条、10 条相关段落,看起来信息丰富,实际上会把真正核心的答案淹没在冗余文字里,模型反而更容易跑偏。

参数组合建议用一个简单矩阵:

阶段推荐值依据
向量召回条数20 起步太少没得重排,太多延迟高
重排序保留数3 到 5取够答主需要的核心段落即可
分数阈值0.3 到 0.5 之间试见 4.2
单文档最大字符600 左右匹配重排序模型输入上限

4.2 分数阈值怎么定

重排序返回的分数不是“概率”,不同模型分数分布差异很大。有的模型给分很松,普遍 0.8 以上;有的模型给分很紧,最高才 0.5。所以不要照搬别人的阈值,一定要看你自己的实际分数分布。

我的做法是跑一遍 100 条测试问题,把重排后的分数全部收集起来,按“正确答案是否排在第一”分两组,统计两组分数范围。阈值可以取“正确答案能覆盖住、噪声段落被挡在门外”的那个折中点。

阈值设太高还有个风险:用户问题太偏的时候,所有候选段落分数都低于阈值,最后没有内容可注入。我建议做一层兜底:如果过滤后为空,就把阈值放宽到最低值的 70%,或者直接返回“未找到相关资料”。宁可不答,也不要硬塞不相关内容让它编。

4.3 快速评测方法和指标

聊效果不能靠感觉。我平时用一套轻量评估法:准备 20 到 50 条真实用户问题,人工标注“应该在哪个文档/段落里能找到答案”,然后写个小脚本跑一遍检索,分别记录重排前和重排后的 Top K 命中情况。

主要看三个指标:

指标含义怎么算
Recall@K正确答案是否在 Top K 里命中的问题数 / 总问题数
MRR第一个正确答案排多靠前依次求和 1/排名 再平均
答案质量生成回答是否准确完整人工打分或模型打分辅助

我最关注的是 MRR。假设正确文档在重排前排在第八,MRR 只贡献 0.125;重排后提到第一,MRR 贡献变成 1。MRR 对“提位”非常敏感,正好能反映重排序器的价值。

如果你想让评估更自动,可以让大模型做裁判,给生成回答打准确度分,再和人工标注的命中结果交叉验证。注意模型打分只能做辅助参考,最终上线前还是得人工抽几条看一遍。

5. 常见问题与避坑指南

5.1 重排序之后答案反而变差

我遇到过一个特别典型的案例:重排后第一条文档确实和问题高度相关,但它只是一个碎片化的小段落,缺少前后语境。大模型看到这段孤立文本后,虽然有相关词,却没有足够条件推导出完整结论。

后来我在分块时把每个段落所属的“文档标题 + 二级标题”拼进段落最前面,作为头部元数据。比如“《售后政策》> 保修期规定:保修期为一年……”,这样重排序模型在打分时能看到更多上下文,大模型拿到的信息也更完整。这招对说明书类的层级文档尤其有效。

5.2 响应变慢,线上超时

重排序是在向量检索之后又加了一次模型推理,整体增加 100 到 300 毫秒延迟,如果服务部署在外网,可能更高。排查时先不要急着降级,按顺序做三件事。

第一,压缩召回窗口,从 30 降到 20,观察命中率是否有明显下降。第二,给重排序请求加超时和熔断,超时就跳过重排,直接用向量召回结果兜底。第三,针对高频相似问题做结果缓存,命中了就直接用上次的重排结果,不再重新计算。缓存要注意维护失效策略,文档更新后要把相关缓存清掉。

5.3 多轮对话代词导致召回失败

“它坏了”“这个怎么弄”“那运费呢”这类问法,向量检索基本必死。CompressingQueryTransformer 会结合历史消息把当前问题重写成一个独立、完整的查询。比如“那运费呢”可能会被重写成“退货时运费由谁承担”,这样检索才能命中。

如果不想依赖大模型做压缩,也可以用启发式方案:把最近两轮用户消息拼起来作为检索 Query。简单,省 token,效果在多数场景下够用。但遇到跨了好几轮才追问的场景,简单的拼接就容易失效,所以我目前还是倾向用模型压缩的方案。

5.4 版本和依赖坑

LangChain4j 版本迭代挺活跃的,RAG 相关接口在几个大版本间调整过不止一次。我第一次参考旧博客写代码时,ContentAggregator 的接口签名就和本版对不上,编译直接挂。建议项目里锁定一个具体版本,升级时先查官方迁移说明,别盲目升。

依赖冲突方面,最常见的是 Jackson 版本打架。嵌入服务 SDK、重排序 SDK、主框架各自传递依赖了不同版本的 Jackson,可能导致启动时 JsonMappingException 之类的诡异问题。用 mvn dependency:tree 查一下,把重复的传递依赖用 exclusions 去掉。

5.5 重排序模型长度限制与分块矛盾

交叉编码器模型的输入长度一般有限制,常见的是 512 个 token 左右。如果你把段落切到 1000 字符,传给重排序模型时可能会被截断,那么模型看的其实是残缺的文本,打出来的分数可信度会打折扣。

解决思路可以从两个方向走:一是把分块大小压到与模型输入长度匹配;二是保留超长段落,但重排序时只取每个段落的前 200 个字和元数据做打分,等确定入选后再把完整段落传给大模型。方案二精度上有点风险,但能省事,适合时间紧的时候先用。

5.6 成本控制与数据隐私

重排序是按调用量产生费用的,候选数量越大成本越高。召回 20 条,每次问答就是一批重排序请求,量大了不是个小数目。控制批次大小、加缓存、对长尾低价值问题跳过重排序,都是在成本和质量之间切一刀。

数据隐私方面,如果团队文档里有合同、工单、客户信息这类敏感内容,先把数据脱敏再对外调用重排序服务。条件允许的话,优先把模型部署到内网。我当时就是验证完效果后,立刻切到了私有化部署,接口协议不变,只是把 API 地址和密钥换了一下。

最后说几句个人体会

重排序不是银弹,它解决的是“召回管道最后一公里的排序精度”问题。如果切分粗糙、Embedding 模型选得随意、文档本身质量差,重排序只能救回来一点点,救不了全部。我的建议是先把分块、元数据、检索窗口这些基础工夫做扎实,再上重排序,效果才会明显。

调试重排序那段时间,我养成了习惯:每次改参数,都把“召回前 20 条 + 重排后 5 条 + 用户原始问题 + 最终答案”整理成一份测试报告,存成 Markdown,方便回看对比。这样做的好处是,过两周回头看改动时,不会凭感觉说“好像变好了”,而是有数据摆在眼前。

如果后续想继续扩展,这条路也值得试:把重排序分数沉淀成“文档优先级”信号,结合真实用户反馈做增量学习,让检索系统越用越准。总之,先把今天这套重排序跑通,再谈优化,方向不会错。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询