上一篇用 RRF 将 BM25 和向量检索的结果合并为一个排名。但 RRF 的融合依据只有排名位置——它不知道候选文档和查询的实际匹配程度。一篇排在 BM25 第 2 名的文档可能因为标题恰好包含查询词而得分高,但正文和查询主题完全无关。

本篇引入重排序模型(reranker),对 RRF 返回的 Top-N 候选逐一做精细评分,找出真正最相关的文档。

二阶段检索

为什么不用 reranker 做全量搜索

Cross-encoder reranker 对每个 (query, document) pair 做联合编码——query 和 document 的每个 token 之间做 full attention。这比 bi-encoder(第 18 篇)精确得多,但也慢得多。

方法 1 万文档耗时 10 万文档耗时
BM25 ~5 ms ~20 ms
向量 HNSW ~5 ms ~10 ms
Cross-encoder ~5 s ~50 s

Cross-encoder 对每个候选都要过一遍完整的 Transformer 前向传播。对 1 万文档逐个打分需要 5 秒——完全不可能用于在线搜索。

所以采用二阶段架构:

  1. 召回(第 18-20 篇):BM25 + 向量 + RRF,从全量文档中快速选出 Top-100 候选
  2. 重排序(本篇):cross-encoder 对 Top-100 候选精排

Bi-Encoder vs Cross-Encoder

Bi-Encoder Cross-Encoder
编码方式 query 和 doc 分别编码为向量 query + doc 拼接后联合编码
token 交互 无——最后算点积 每层都有 full attention
doc 向量 可预计算,查询时只算 query 不可预计算,每次查询重新算
速度 慢 100-1000 倍
精度 一般
适用场景 召回(百万→百) 重排序(百→十)

Bi-encoder 的根本限制:query 和 doc 在编码时互相看不到对方。它们各自被压缩成一个固定长度的向量,细粒度的匹配信息(如"query 中的某个短语恰好出现在 doc 的第三段")丢失了。

Cross-encoder 让 query 和 doc 的每个 token 互相做 attention,可以捕获这种细粒度匹配。代价是不能预计算——每个 (query, doc) pair 都要实时计算。

Qwen3-Reranker-0.6B

模型概况

属性
参数量 0.6B
输入 (query, document) pair
输出 相关性分数
最大输入长度 8192 token
运行环境 GPU 推荐,CPU 可行

HTTP 服务封装

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
record RerankRequest(String query, List<String> documents, int topN) {}
record RerankResult(int index, double score) {}
record RerankResponse(List<RerankResult> results) {}

class RerankerClient {
private final String endpoint;
private final HttpClient client = HttpClient.newHttpClient();

List<RerankResult> rerank(String query, List<String> documents,
int topN, long timeoutMs) throws Exception {
String json = toJson(new RerankRequest(query, documents, topN));
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(endpoint + "/rerank"))
.header("Content-Type", "application/json")
.timeout(Duration.ofMillis(timeoutMs))
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();

HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
return parseResponse(response.body()).results();
}
}

批推理

逐个发送 (query, doc) pair 到模型服务效率低——每次 HTTP 往返有固定开销。批量发送所有候选:

1
2
3
4
5
6
7
8
9
10
11
12
List<DocScore> rerankCandidates(String query, List<DocScore> candidates, int topN) {
List<String> docTexts = candidates.stream()
.map(c -> loadDocumentText(c.docId()))
.toList();

List<RerankResult> reranked = rerankerClient.rerank(
query, docTexts, topN, 3000);

return reranked.stream()
.map(r -> new DocScore(candidates.get(r.index()).docId(), r.score()))
.toList();
}

一次 HTTP 调用传入 50 个文档,模型内部做 batch inference,效率远高于 50 次单独调用。

候选数量的权衡

传给 reranker 的候选数量(N)直接影响质量和延迟:

候选数 N Reranker 延迟 覆盖范围
20 ~50 ms 可能遗漏 RRF 排名 21-100 中的好文档
50 ~120 ms 平衡点
100 ~250 ms 覆盖全
200 ~500 ms 收益递减

在开发集上实验不同 N 的 nDCG@10:

1
2
3
4
5
6
7
8
9
void tuneCandidateCount(QuerySet devSet) {
int[] counts = {20, 50, 100, 200};
for (int n : counts) {
double ndcg = evaluateWithReranker(devSet, n);
double avgLatency = measureRerankLatency(devSet, n);
System.out.printf("N=%3d → nDCG@10=%.4f latency=%.0fms\n",
n, ndcg, avgLatency);
}
}

经验法则:如果 N=50 和 N=100 的 nDCG 差距 < 0.01,选 N=50(延迟更低)。

长度预算

问题

Reranker 的输入是 query + document 的拼接。如果文档超过模型最大长度:

  • query 通常很短(< 50 token),不需要截断
  • document 可能很长,需要截断

截断策略

1
2
3
4
5
6
7
8
String prepareDocumentForRerank(String docText, String query, int maxTokens) {
int queryTokens = estimateTokens(query);
int budgetForDoc = maxTokens - queryTokens - 10;
if (estimateTokens(docText) > budgetForDoc) {
docText = truncateToTokens(docText, budgetForDoc);
}
return docText;
}

截断保留文档开头——标题和摘要通常在最前面,包含最重要的信息。

对于 chunk 级别的候选(来自向量搜索),chunk 本身已经控制在模型长度限制内,通常不需要截断。

超时与回退

模型服务不可用

Reranker 是外部 HTTP 服务。可能因为 GPU 负载、服务重启、网络问题等原因不可用。搜索服务必须有回退:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
List<DocScore> searchWithReranker(String query, float[] queryVec, int topN) {
List<DocScore> rrfResults = hybridSearch(query, queryVec, 100);

try {
List<DocScore> reranked = rerankCandidates(query, rrfResults, topN);
return reranked;
} catch (TimeoutException e) {
log.warn("Reranker 超时 ({}ms),回退到 RRF 排序", RERANK_TIMEOUT_MS);
return rrfResults.subList(0, Math.min(topN, rrfResults.size()));
} catch (Exception e) {
log.warn("Reranker 错误: {},回退到 RRF 排序", e.getMessage());
return rrfResults.subList(0, Math.min(topN, rrfResults.size()));
}
}

回退到 RRF 排序意味着搜索质量下降,但服务仍然可用。用户不会看到错误页面。

超时预算

整个搜索流程的延迟预算分配:

阶段 预算
BM25 召回 20 ms
向量召回 20 ms
RRF 融合 5 ms
Reranker 200 ms
其他(网络、序列化) 55 ms
总计 300 ms

Reranker 占了最大的延迟预算。如果 200ms 内没返回,直接放弃重排序。

质量评估

逐阶段对比

在开发集上记录每个阶段的搜索质量:

1
2
3
4
5
6
7
void evaluatePipeline(QuerySet devSet) {
System.out.println("== 逐阶段 nDCG@10 ==");
System.out.printf("BM25-only: %.4f\n", evaluate(devSet, "bm25"));
System.out.printf("Dense-only: %.4f\n", evaluate(devSet, "dense"));
System.out.printf("RRF: %.4f\n", evaluate(devSet, "rrf"));
System.out.printf("RRF + Reranker: %.4f\n", evaluate(devSet, "rrf+rerank"));
}

预期:每个阶段都比前一个好,但提升幅度递减。

逐查询类型分析

1
2
3
4
5
6
7
== 逐查询类型 nDCG@10 ==
BM25 Dense RRF RRF+Rerank
精确标识符: 0.xx 0.xx 0.xx 0.xx
概念查询: 0.xx 0.xx 0.xx 0.xx
中英混合: 0.xx 0.xx 0.xx 0.xx
同义改写: 0.xx 0.xx 0.xx 0.xx
多条件: 0.xx 0.xx 0.xx 0.xx

Reranker 在哪些查询类型上提升最大?通常是概念查询和多条件查询——这些查询需要细粒度的语义匹配判断。

失败案例分析

找出 reranker 让结果变差的查询,分析原因:

1
2
3
4
5
6
7
8
9
10
void findRegressions(QuerySet devSet) {
for (AnnotatedQuery q : devSet.queries()) {
double rrfNdcg = computeNdcg(rrfSearch(q), q.relevance(), 10);
double rerankNdcg = computeNdcg(rerankSearch(q), q.relevance(), 10);
if (rerankNdcg < rrfNdcg - 0.05) {
System.out.printf("回归: '%s' RRF=%.4f → Rerank=%.4f\n",
q.text(), rrfNdcg, rerankNdcg);
}
}
}

常见的回归原因:

  • 文档被截断,reranker 看到的内容不包含关键信息
  • reranker 对某些特殊格式(代码块、表格)的理解不如 BM25 的精确匹配
  • 向量召回阶段遗漏了某个相关文档,reranker 无法补回

验证

场景 操作 预期
正常重排 搜索常见查询 Reranker 返回结果,质量 ≥ RRF
模型超时 设 timeout=1ms 回退到 RRF,搜索仍可用
模型停机 关闭 reranker 服务 回退到 RRF,无错误页面
不同候选数 N=20 vs N=100 记录 nDCG 和延迟差异
长文档 5000 字文档 截断后 reranker 仍正常
整体延迟 端到端搜索 < 300 ms(含 reranker)

当前局限

  • Reranker 在 CPU 上很慢——50 个候选可能需要 1-2 秒
  • 候选数固定——理想情况下应该根据查询难度动态调整
  • 没有处理多样性——reranker 可能让排名前列的结果都来自同一篇文档的不同 chunk
  • 没有做分数校准——reranker 的分数和 BM25/向量分数不在同一尺度
  • 回退策略是简单的降级——更好的方案是缓存最近的 reranker 结果

练习

  1. 对 5 个查询分别记录 RRF 和 RRF+Reranker 的 Top-5 结果,比较差异
  2. 将 reranker 候选数从 20 调到 100,记录 nDCG@10 和延迟
  3. 模拟 reranker 超时(设极短 timeout),验证回退是否正常
  4. 找到一个 reranker 让结果变差的查询,分析原因
  5. 测量整个搜索流程的端到端延迟,画出各阶段耗时分布

延伸阅读

  • Nogueira & Cho, “Passage Re-ranking with BERT”, 2019
  • Qwen3-Reranker 模型文档
  • Lin et al., “Pretrained Transformers for Text Ranking: BERT and Beyond”, 2021