上一篇用 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 秒——完全不可能用于在线搜索。
所以采用二阶段架构:
- 召回(第 18-20 篇):BM25 + 向量 + RRF,从全量文档中快速选出 Top-100 候选
- 重排序(本篇):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 结果
练习
- 对 5 个查询分别记录 RRF 和 RRF+Reranker 的 Top-5 结果,比较差异
- 将 reranker 候选数从 20 调到 100,记录 nDCG@10 和延迟
- 模拟 reranker 超时(设极短 timeout),验证回退是否正常
- 找到一个 reranker 让结果变差的查询,分析原因
- 测量整个搜索流程的端到端延迟,画出各阶段耗时分布
延伸阅读
- Nogueira & Cho, “Passage Re-ranking with BERT”, 2019
- Qwen3-Reranker 模型文档
- Lin et al., “Pretrained Transformers for Text Ranking: BERT and Beyond”, 2021