目录
一、粗排与精排的分工
上一篇结束时,两路检索已经融合成了一个还算靠谱的候选集。但候选集里仍然混着不少「相关但不解决问题」的东西。
先看一个具体的失败样本。用户问「入职 3 年能休多少天年假」,粗排交回 Top 5:
[1] "员工入职满一年后,可享受带薪年假10天。" ✓ 直接回答
[2] "年假需提前3天申请,经直属领导批准。" ✗ 相关,但不回答问题
[3] "入职满五年后,年假增至15天。" ✓ 部分回答
[4] "年假不可跨年累积,未使用的年假将清零。" ✗ 相关,但不回答问题
[5] "病假需提供医院证明,3天以内由直属领导批准。" ✗ 不相关
注意 [2] 和 [4]:它们确实是讲年假的,词面上和查询高度重叠(都出现了「年假」「3」),所以 BM25 给高分完全合理。问题在于「相关」和「能回答这个问题」是两件事。 检索器的目标函数是「这段文字和查询像不像」,而不是「这段文字能不能回答这个问题」——后者需要理解查询的意图,也就是「3 年」对应的是哪条规则。
这活儿得换个更贵的方式来做。招聘能类比得很贴切:
- 粗排:HR 拿 1000 份简历按硬条件筛(有 Java、3 年经验、本科),几秒钟筛出 50 份。快,但粗糙。
- 精排:HR 逐份细读这 50 份,看项目经历和技术栈是否真的匹配。慢,但能把最合适的 3 份挑出来。
没有人会一上来就细读 1000 份简历。 两阶段的真正意义在于:把昂贵但精准的判断,限制在一个足够小的候选集上。
graph LR
Q["用户查询"] --> C["粗排<br/>BM25 + 向量 + RRF"]
C -->|"Top 20<br/>快,保召回"| R["精排<br/>Cross-Encoder"]
R -->|"Top 3<br/>慢,保准确"| F["最终结果"]
style C fill:#e6f3ff
style R fill:#e6ffe6
二、Bi-Encoder 与 Cross-Encoder
这是整篇最值得花时间理解的一节,因为它解释的不是「Rerank 好用」,而是为什么粗排阶段不能用更好的模型。
2.1 双塔(Bi-Encoder)
查询和文档各自过同一个编码器,得到两个向量,然后用余弦相似度比较:
Query → Encoder → Query 向量 ┐
├→ 相似度 = cosine(Query 向量, Doc 向量)
Doc → Encoder → Doc 向量 ┘
关键在于两者是分别编码的,编码时谁也不知道对方的存在。所以文档向量可以离线预先算好、存进向量库——这是它能在一百万篇文档上秒级检索的全部原因。
代价是:查询和文档之间的细粒度交互(哪个词对应哪个词、条件是否匹配)永远没有机会被模型看到。两个向量比完之后才知道结果,太晚了。
2.2 交叉编码器(Cross-Encoder)
把查询和文档拼成一个序列,一起送进模型,直接输出一个相关性分数:
[Query, Doc] → Encoder → 相关性分数
因为两者在同一个注意力计算里,模型能直接建模「这个查询的哪个部分」和「这个文档的哪个部分」之间的对应关系。前面那个「3 年」匹配不到「满一年」的问题,在这里就有机会被纠正——模型能同时看到两边,从而理解「满一年」是「3 年」的条件。
代价是致命的:分数无法预计算。 每一对(查询,文档)都要跑一次完整的前向计算。文档有 100 万篇,就得为这一次查询跑 100 万次模型推理。
一句话总结这个结构性差异:
| Bi-Encoder | Cross-Encoder | |
|---|---|---|
| 编码方式 | 查询、文档各自独立编码 | 拼接后一起编码 |
| 能否预计算 | 能,文档向量离线算好 | 不能,必须查询时现算 |
| 单次查询代价 | 1 次编码 + N 次向量点积 | N 次完整模型推理 |
| 精度 | 较低 | 明显更高 |
| 适合的位置 | 粗排,全库检索 | 精排,小候选集 |
这份表格也直接回答了一个常见追问:为什么不全用 Cross-Encoder? 因为复杂度从「N 次点积」变成了「N 次推理」,在 N 等于全库规模时不成立。粗排-精排不是效果上的妥协,而是代价结构决定的必然分工。
三、三种 Rerank 方案
| 方案 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 托管 Rerank API | 调用 Cohere 等厂商的 rerank 接口 | 无需 GPU,开箱即用,效果稳 | 按调用量付费,数据出网 |
| 本地 Cross-Encoder | 自己部署 BGE-Reranker 等模型 | 免费、数据不出内网、可定制微调 | 需要 GPU 或接受 CPU 上的延迟 |
| LLM Rerank | 用现成的大模型判断相关性 | 无需额外模型,能理解复杂意图 | 单次调用成本高,延迟大,大批量排序不划算 |
成本量级值得心里有数:托管 API 按「每千次查询」计费,本地模型是一次性硬件投入换固定延迟,LLM Rerank 则是每个候选文档一次大模型调用——用它排 20 个候选,等于把整个 RAG 的大模型开销乘以 20。所以 LLM Rerank 的合理位置是:候选很少(比如 5 条以内),或者需要判断「文档能否回答问题」这类 Cross-Encoder 也不擅长的语义任务。
这一篇先落地托管 API(Cohere),代码里同时保留 LLM Rerank 作为可切换的备选分支。
四、完整代码
4.1 添加依赖
pom.xml
<!-- OkHttp(用于调用 Cohere API) -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
4.2 RerankService
RerankService.java
package com.rag.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.rag.model.Document;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.*;
import java.util.stream.Collectors;
/**
* Rerank 重排序服务
*
* 职责:对粗排结果进行精排,提高最终结果的相关性
*
* 类比:论文审稿人的"二次评审"
* - 第一轮评审(粗排):快速筛选出可能相关的论文
* - 第二轮评审(精排):仔细阅读每篇论文,给出精确评分
*/
@Slf4j
@Service
public class RerankService {
@Value("${rag.rerank.enabled:false}")
private boolean rerankEnabled;
@Value("${rag.rerank.provider:cohere}")
private String rerankProvider;
@Value("${rag.rerank.cohere.api-key:}")
private String cohereApiKey;
@Value("${rag.rerank.cohere.model:rerank-v3.5}")
private String cohereModel;
@Value("${rag.rerank.top-n:3}")
private int rerankTopN;
private final ObjectMapper objectMapper = new ObjectMapper();
/**
* 对文档列表进行重排序
*
* @param query 用户查询
* @param documents 粗排结果
* @return 精排后的文档列表
*/
public List<Document> rerank(String query, List<Document> documents) {
if (!rerankEnabled) {
log.info("Rerank 未启用,返回原始结果");
return documents.subList(0, Math.min(rerankTopN, documents.size()));
}
if (documents.isEmpty()) {
return documents;
}
log.info("开始 Rerank,查询: {}, 文档数量: {}", query, documents.size());
switch (rerankProvider.toLowerCase()) {
case "cohere":
return rerankWithCohere(query, documents);
case "llm":
return rerankWithLlm(query, documents);
default:
log.warn("未知的 Rerank 提供商: {}, 使用默认排序", rerankProvider);
return documents.subList(0, Math.min(rerankTopN, documents.size()));
}
}
/**
* 使用 Cohere API 进行重排序
*
* Cohere Rerank API 文档:
* https://docs.cohere.com/docs/reranking
*/
private List<Document> rerankWithCohere(String query, List<Document> documents) {
try {
// 构建请求体
Map<String, Object> requestBody = new HashMap<>();
requestBody.put("model", cohereModel);
requestBody.put("query", query);
requestBody.put("documents", documents.stream()
.map(Document::getContent)
.collect(Collectors.toList()));
requestBody.put("top_n", rerankTopN);
// 调用 Cohere API
String responseJson = callCohereApi(requestBody);
// 解析响应
CohereResponse response = objectMapper.readValue(responseJson, CohereResponse.class);
// 根据 Rerank 分数重新排序文档
List<Document> rerankedDocs = new ArrayList<>();
for (CohereResult result : response.getResults()) {
int index = result.getIndex();
if (index >= 0 && index < documents.size()) {
Document doc = documents.get(index);
// 可以把 Rerank 分数存到 metadata 里
doc.getMetadata().put("rerank_score", result.getRelevanceScore());
rerankedDocs.add(doc);
}
}
log.info("Cohere Rerank 完成,返回 {} 个文档", rerankedDocs.size());
return rerankedDocs;
} catch (Exception e) {
log.error("Cohere Rerank 失败", e);
// 失败时返回原始结果
return documents.subList(0, Math.min(rerankTopN, documents.size()));
}
}
/**
* 调用 Cohere API
*/
private String callCohereApi(Map<String, Object> requestBody) throws Exception {
okhttp3.OkHttpClient client = new okhttp3.OkHttpClient();
String json = objectMapper.writeValueAsString(requestBody);
okhttp3.Request request = new okhttp3.Request.Builder()
.url("https://api.cohere.ai/v1/rerank")
.addHeader("Authorization", "Bearer " + cohereApiKey)
.addHeader("Content-Type", "application/json")
.post(okhttp3.RequestBody.create(json, okhttp3.MediaType.parse("application/json")))
.build();
try (okhttp3.Response response = client.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new RuntimeException("Cohere API 调用失败: " + response.code());
}
return response.body().string();
}
}
/**
* 使用 LLM 进行重排序(备选方案)
*
* 思路:让 LLM 判断每个文档和查询的相关性,给出分数
*
* 优点:不需要额外的 API
* 缺点:成本高,速度慢
*/
private List<Document> rerankWithLlm(String query, List<Document> documents) {
// 这里简化实现,实际可以调用 LLM
// 提示:可以让 LLM 对每个文档打分(1-10分),然后按分数排序
log.info("LLM Rerank 暂未实现,返回原始结果");
return documents.subList(0, Math.min(rerankTopN, documents.size()));
}
// ========== Cohere API 响应类 ==========
@Data
public static class CohereResponse {
private List<CohereResult> results;
}
@Data
public static class CohereResult {
private int index;
private double relevanceScore;
}
}
几个设计上值得留意的地方:
降级是显式设计的,不是碰巧。 整条链路被 try-catch 包住,任何异常(API 超时、Key 失效、额度耗尽)都会退回到「取粗排的前 N 条」。这意味着 Rerank 挂掉时系统不会整个瘫掉,只是退化成上一篇的效果。 对外的检索服务都应该有这种降级路径——外部依赖的失败是常态,不是意外。
备选分支的切换靠配置。 provider 决定走 Cohere 还是走 LLM,两者产出同样的数据结构,上层无感知。
OkHttpClient 是在方法内部 new 出来的。 这是个应该修的问题:OkHttp 的客户端持有连接池和线程池,设计上就是单例复用的。每次调用都新建一个,等于每次请求都丢弃一个连接池——高频调用下会表现为连接无法复用、线程数上涨、偶发的资源泄漏。正确做法是把它声明为 Bean 注入进来,全局一个。这个坑放在误区里细说。
4.3 配置
application.yml
rag:
chunk-size: 500
chunk-overlap: 50
top-k: 10 # 粗排返回更多文档,给 Rerank 留余量
rerank:
enabled: true # 启用 Rerank
provider: cohere # 使用 Cohere
top-n: 3 # Rerank 后返回 3 个文档
cohere:
api-key: ${COHERE_API_KEY:} # Cohere API Key
model: rerank-v3.5 # Cohere Rerank 模型
注意 top-k: 10 和 top-n: 3 是两个不同的旋钮:
top-k是粗排交回多少条给精排。这个值决定了精排的候选池,也直接决定了 Rerank 的成本和延迟(候选越多,要算的 pair 越多)。top-n是精排之后留多少条塞进 Prompt。这个值影响回答质量和 token 成本。
把它们调成同一个值是常见的错误——那样就白做精排了:粗排留 3 条,精排只是把这 3 条重新排个序,一个都没换掉。精排的价值恰恰在于「从 10 条里淘汰掉 7 条」,所以 top-k 必须显著大于 top-n。
4.4 接入 RagService
在 RagService 中注入 RerankService
// 在 RagService 中注入 RerankService
private final RerankService rerankService;
// 修改 ask 方法
public String ask(String question) {
log.info("收到用户问题: {}", question);
// 第一步:混合检索(粗排)
log.info("步骤 1: 混合检索(粗排)...");
List<Document> coarseResults = hybridRetrievalService.search(question, topK * 2);
if (coarseResults.isEmpty()) {
log.warn("未找到相关文档");
return "抱歉,知识库中没有找到相关信息。";
}
// 第二步:Rerank(精排)
log.info("步骤 2: Rerank(精排)...");
List<Document> fineResults = rerankService.rerank(question, coarseResults);
log.info("找到 {} 个相关文档", fineResults.size());
// 第三步:组装 Prompt
log.info("步骤 3: 组装 Prompt...");
String prompt = buildPrompt(question, fineResults);
// 第四步:调用大模型
log.info("步骤 4: 调用大模型生成回答...");
return callLlm(prompt);
}
4.5 新增接口
在 RagController 中添加 Rerank 接口
/**
* Rerank 测试接口
*/
@PostMapping("/rerank-test")
public ResponseEntity<Map<String, Object>> rerankTest(@RequestBody AskRequest request) {
log.info("Rerank 测试: {}", request.getQuestion());
try {
// 粗排
List<Document> coarseResults = hybridRetrievalService.search(request.getQuestion(), 10);
// 精排
List<Document> fineResults = rerankService.rerank(request.getQuestion(), coarseResults);
return ResponseEntity.ok(Map.of(
"success", true,
"coarseResults", coarseResults.size(),
"fineResults", fineResults.stream().map(doc -> Map.of(
"id", doc.getId(),
"content", doc.getContent(),
"rerank_score", doc.getMetadata().getOrDefault("rerank_score", "N/A")
)).collect(Collectors.toList())
));
} catch (Exception e) {
log.error("Rerank 测试失败", e);
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "测试失败: " + e.getMessage()
));
}
}
五、对比测试
5.1 获取 Cohere API Key
- 访问 https://cohere.com/
- 注册账号
- 创建 API Key(免费额度够做对比实验)
5.2 测试 Rerank
# 设置环境变量
export COHERE_API_KEY=xxx
# 测试 Rerank
curl -X POST http://localhost:8080/api/rag/rerank-test \
-H "Content-Type: application/json" \
-d '{
"question": "入职3年能休多少天年假"
}'
5.3 对比效果
# 不启用 Rerank(在配置中设置 enabled: false)
curl -X POST http://localhost:8080/api/rag/ask \
-H "Content-Type: application/json" \
-d '{"question": "入职3年能休多少天年假"}'
# 启用 Rerank
curl -X POST http://localhost:8080/api/rag/ask \
-H "Content-Type: application/json" \
-d '{"question": "入职3年能休多少天年假"}'
对比实验的做法是:同一批查询,跑两遍(enabled: false 和 enabled: true),然后逐条对比 Top 3 的差异。
这里必须说一句:不要用「感觉变好了」来下结论。 逐个查询肉眼比对能帮你发现具体案例(比如那个「3 年 vs 满一年」的例子是否被纠正),但没法回答「整体上好了多少」。这个问题只有评估集能回答——也就是下一篇。
六、常见误区
❌「加了 Rerank 就一定更准」 Rerank 只会对粗排给的候选池重排序,它无法把粗排没召回的东西捞回来。如果正确文档压根不在 Top 10 里,加多少个 Rerank 都没用。这就是为什么召回率要由粗排负责——精排的能力上限是粗排给的候选集。两者是串联的,短板在任何一端都会立刻显形。
❌「每次请求 new 一个 OkHttpClient 没关系」
有关系,而且是典型的慢性病。OkHttp 的 OkHttpClient 内部持有连接池(默认 5 个空闲连接、5 分钟保活)和任务分发用的线程池——它的设计意图就是全局一个实例,所有请求共享。每次调用新建一个,连接复用彻底失效(每次都要重新 TCP + TLS 握手),请求量上来之后线程和 socket 数量都会持续膨胀。这类问题在压测时才会暴露,单机手测完全看不出来。
❌「Rerank 的延迟可以忽略」
一次跨公网的 Rerank API 调用,延迟在几十到几百毫秒量级,具体取决于候选数量和网络路径——这个数字应该由你自己测,而不是照抄任何文章里的值。它叠加在原本的「检索 + 生成」之上,如果整个问答的延迟预算是 2 秒,Rerank 占了 300ms 就是个必须正视的决策。控制手段是调小 top-k,或者对延迟敏感的场景只对 Top 5 做精排。
❌「Cross-Encoder 一定比 LLM 判断得准」 两者擅长的事不同。Cross-Encoder 是在「相关性排序」这个具体任务上专门训练的,对「这段和这句像不像」的判断又快又稳。但让它判断「这段材料能不能回答这个问题」,它并不比一个大模型强。选型看任务,不看谁更「高级」。
❌「Rerank 分数据可以拿来当阈值过滤」 Cohere 等 API 返回的相关性分数是模型内部的 logit 或归一化值,不同模型之间不可比,同一个模型跨批次也未必稳定。把它当排序依据是合理的,当「低于 0.3 就丢弃」这类硬阈值用则很容易踩坑——阈值该在评估集上测出来,而不是凭数值直觉定。
七、自检清单
- 能说清 Bi-Encoder 和 Cross-Encoder 在编码阶段的差别
- 说得清为什么 Cross-Encoder 的分数无法预计算
- 知道 Cross-Encoder 的复杂度从「N 次点积」变成了「N 次推理」
- 说得清粗排-精排为什么是串联关系,短板在哪一端都会显形
- 知道
top-k和top-n各自控制什么,以及为什么不能相等 - 知道 Rerank 服务失败时的降级路径会退化成什么
- 说得清
OkHttpClient为什么要全局复用 - 知道 Rerank 分数不能当硬阈值用,以及原因
附:延伸阅读
相关资料
| 资源 | 说明 |
|---|---|
| Cohere: Reranking | 接口语义、top_n 与返回分数的含义 |
| Passage Re-ranking with BERT (Nogueira & Cho, 2019) | monoBERT,Cross-Encoder 做精排的开山之作 |
| sentence-transformers: Cross-Encoders | 本地 Cross-Encoder 的实际用法与性能特征 |
| FlagEmbedding / BGE-Reranker | 中文场景常用的开源 Reranker |
| MS MARCO Passage Ranking | 精排模型的经典评测集,理解指标口径 |
| OkHttp: Recipes | 客户端复用与超时配置的官方说明 |
关联笔记
- RAG 实战(三):混合检索与 RRF 融合 —— 精排的输入来自这一篇的融合结果
- RAG 实战(五):给 RAG 做量化评估 —— 这一篇留下的「到底好了多少」由它回答
最后更新:2026-09-27