引子:去年我搭第一个知识库时,犯过一个典型错误------召回阶段只靠向量相似度,取 top-5 直接喂给大模型。结果经常是:用户问"病假怎么申请",排在第一的却是年假政策(因为都含"申请""天数"这类高频词),真正相关的病假流程文档反而排第三。大模型基于错误文档生成,回答自然跑偏。后来加了一道重排(reranker),top-1 准确率肉眼可见地提上来了。这篇把"为什么需要重排、三种排序范式怎么选、bge-reranker-v2-m3 凭什么成为默认"讲透,并给一份能直接跑的代码。
0. 先说结论,省你时间
- 只用向量召回不够:bi-encoder 把 query 和 doc 各自编码,丢失了两者之间的细粒度交互,top-k 排序常常"大致相关但不精确"。
- 重排是 RAG 性价比最高的一步优化:论文和工程实践都证明,在已经跑通向量检索的管线上加一道 cross-encoder 重排,通常能换来 15%--30% 的精度提升,代价只是几十毫秒延迟。
- 三种范式:无重排(bi-encoder 直通)/ bi-encoder 召回 + cross-encoder 重排(两段式,主流)/ 纯 cross-encoder(太慢,不适合大规模)。
- bge-reranker-v2-m3 是当前开源多语言 RAG 的默认选择:568M 参数、Apache-2.0 可商用、100+ 语言、支持 FP16,社区基准(MTEB reranking)约 59.7 分,距离商业 API(Cohere Rerank 3.5 约 63.4)只差 3--4 分,但完全自托管。
下面展开,每一节都尽量给可验证的事实和可落地的代码。
1. 为什么要重排:两段式检索的动机
RAG 的检索本质是个"大海捞针"问题。理想的检索器要同时满足两件事:
- 召回率高:相关文档别漏掉(宁可多召回一些无关的)。
- 排序准:最相关的排在 top-1、top-3,大模型才好用。
但这两件事对同一个模型是矛盾的。向量检索(bi-encoder)擅长"广撒网"------在一个巨大向量空间里用近似最近邻(ANN)快速捞回一批候选,但它对"query 和 doc 到底哪儿相关"判断粗糙。把它既当召回又当精排,效果会打折。
两段式检索(retrieve-then-rerank)就是为此而生:
- 第一阶段(召回):用便宜、快速的 bi-encoder 从百万级文档里捞回 top-20 或 top-100 候选。这一步允许"宁可错杀不可放过"。
- 第二阶段(重排):用贵但准的 cross-encoder,只对这 20--100 个候选做精细相关性打分,重新排序,保留 top-8 或 top-10 喂给大模型。
这个范式不是拍脑袋。它直接对应信息检索里"召回"与"精排"分离的工程常识,也被后面要讲的论文从实验上证实有效。
2. 三种排序范式:架构上的根本差异
理解重排,先理解 bi-encoder 和 cross-encoder 在架构上的分叉。这是整篇文章的骨架。
2.1 Bi-Encoder(双塔)
query 和 document 分别进两个(或共享权重的)编码器,各自产出一个固定维度的向量。相关性 = 两个向量的余弦相似度(或内积)。
text
query ──> [Encoder_Q] ──> vec_q
│
├── 余弦相似度 ──> score
│
document ──> [Encoder_D] ──> vec_d
优点:document 的向量可以离线预计算、建索引,线上查询时只需编码一次 query,然后用 ANN 检索。这正是 Sentence-BERT 论文的核心卖点。
缺点:query 和 document 在编码阶段互不看见,无法建模它们之间的词级、句级交互。一个问"如何撤销已提交的报销单",另一个文档讲"报销单提交后的审批流程"------两者语义高度相关,但字面重叠少,bi-encoder 容易判低分。
2.2 Cross-Encoder(交叉编码器)
query 和 document 拼在一起(通常是 CLS query SEP document SEP),过一个完整的 Transformer,用 CLS 位置的输出直接预测一个相关性分数。query 和 doc 在每一层注意力里都互相看对方。
text
[CLS] query [SEP] document [SEP] ──> [Full Transformer, 全注意力] ──> relevance score
优点:能捕捉 query-doc 之间最细微的语义交互,排序精度远高于 bi-encoder。
缺点:不能预计算。每判断一个 (query, doc) 对,就要跑一次完整前向。如果有 100 万文档,就要跑 100 万次------这正是它只能做"重排"(对少量候选打分)而不能做"召回"(对全库打分)的原因。
2.3 无重排(bi-encoder 直通)
最简单:bi-encoder 召回 top-k 直接交差。没有第二阶段。成本低、延迟小,但排序精度天花板就是 bi-encoder 的上限。适合候选池本来就很小(< 10 篇)或查询极简单的场景。
2.4 一张图看懂三段关系
text
全库 (1M docs)
│
┌────────────┴────────────┐
│ 第一阶段:召回 │
│ bi-encoder + ANN │
│ 快、可预计算、召回率高 │
└────────────┬────────────┘
│ top-20 ~ top-100 候选
┌────────────┴────────────┐
│ 第二阶段:重排 │
│ cross-encoder │
│ 慢、不可预计算、排序准 │
└────────────┬────────────┘
│ top-8 ~ top-10
▼
交给 LLM 生成
3. 两篇奠基论文:把"快慢之争"钉死在数据上
下面两篇论文不是凑数,它们正好从两个方向把 bi-encoder 和 cross-encoder 的取舍讲清楚了。
3.1 Sentence-BERT(arXiv:1908.10084,Reimers & Gurevych,EMNLP 2019)
这篇论文的出发点恰恰是"原生 BERT 做相似度搜索太慢"。BERT 原版要做句子对任务,得把两个句子拼一起过网络,这导致:
在一组 10,000 个句子里找最相似的一对,用原生 BERT/ RoBERTa 需要约 5000 万次推理计算,耗时约 65 小时。
作者提出 Sentence-BERT,用孪生(siamese)网络结构把句子独立编码成向量,再用余弦相似度比较。结果:
把"找最相似对"的耗时从 65 小时降到约 5 秒,同时基本保持了 BERT 的精度。
这就是 bi-encoder 存在的根本理由------为了速度,牺牲 query-doc 交互。SBERT 解决的是"能不能用"的问题,但它在摘要里也明说了:它是用余弦相似度这种"廉价但粗糙"的方式比较的。换句话说,SBERT 自己就承认了它在精细排序上的天花板。
3.2 Passage Re-ranking with BERT(arXiv:1901.04085,Nogueira & Cho,2019)
这篇是重排的奠基工作,方向相反:它把"拼一起过 BERT"这个慢但准的做法,专门用在"重排"这一步。
做法:第一阶段用 BM25 等传统检索召回候选,第二阶段用 BERT cross-encoder 对 top-k 候选重新打分排序。结果:
在 MS MARCO passage retrieval 任务上,MRR@10 相对此前的 state-of-the-art 提升了 27%,登顶当时排行榜;在 TREC-CAR 上同样达到 SOTA。
27% 的相对提升,是一个极其震撼的数字。它说明:在召回已经不错的前提下,一道 cross-encoder 重排能带来的收益,往往比"换一个更好的 embedding 模型"大得多。这也是为什么业界常说------重排是 RAG 管线里 ROI 最高的一步优化。
3.3 两篇论文合起来的结论
- SBERT(bi-encoder):解决"大规模召回的速度问题",代价是排序粗糙。
- Nogueira & Cho(cross-encoder 重排):解决"精细排序的精度问题",代价是不能用于大规模。
两者不是二选一,而是串联:用 bi-encoder 做召回,用 cross-encoder 做重排。现代 RAG 的标准做法,就是这两篇论文在 2019 年共同铺好的路。
4. 主流重排器横向对比
有了方法论,看具体模型。下面这张表把常见的重排器放在一个维度上比,数据来自各模型卡与 MTEB 排行榜(社区基准,非单篇论文,数值会随榜单更新浮动,给出的是 2026 年中的典型值)。
| 模型 | 架构 | 参数量 | 语言 | MTEB reranking | 延迟(50 docs, GPU) | 自托管 | 许可 |
|---|---|---|---|---|---|---|---|
| 无重排(bi-encoder 直通) | --- | --- | 取决于 embedding | 基准线 | ~0ms | 是 | --- |
| ms-marco-MiniLM-L-6-v2 | cross-encoder | ~22M | 英语 | ~43.1 | 40--70ms | 是 | Apache-2.0 |
| ms-marco-MiniLM-L-12-v2 | cross-encoder | ~33M | 英语 | ~47.3 | 70--110ms | 是 | Apache-2.0 |
| bge-reranker-large | cross-encoder | ~335M | 中英为主 | ~60-64 | 85ms | 是 | MIT |
| bge-reranker-v2-m3 | cross-encoder | 568M | 100+ 语言 | ~59.7 | 90--130ms | 是 | Apache-2.0 |
| bge-reranker-v2-gemma | cross-encoder | 2.51B | 多语言 | ~62.1 | 180--250ms | 是 | Gemma 许可 |
| Cohere Rerank 3.5 | cross-encoder(API) | 未公开 | 多语言 | ~63.4 | 80--120ms(API) | 否 | 商业 |
怎么读这张表:
- 延迟敏感、纯英文:MiniLM 系列最快(40--70ms),但 MTEB 只有 40 分出头,且只支持英文。适合候选集本来就排得不错、只需在头部做微调的场景。
- 中英混合、要精度:bge-reranker-large 是经典选择,但语言覆盖窄。
- 多语言生产环境:bge-reranker-v2-m3 是共识上的默认。它 568M 参数、2.27GB、Apache-2.0 可商用,100+ 语言,距离商业 API 只差 3--4 分却完全自托管。
- 精度优先、GPU 充裕:v2-gemma(2.51B)再高 2--3 分,但延迟翻倍、显存 10GB,性价比不如 v2-m3。
- Cohere 3.5:分数最高(~63.4),但走 API、数据出域、按量计费。对数据主权敏感的场景(比如企业内网知识库)直接排除。
一个常被忽略的点:重排器对"候选集质量"很敏感。它擅长在"已经大致相关的列表头部"做精细区分,不擅长"从一堆垃圾里捞金子"。所以重排的前提是召回阶段不能太差------这又回到前面"混合检索提升召回"的话题(bge-m3 的稠密+稀疏双路正是为此)。
5. bge-reranker-v2-m3 深挖
选它当默认答案,不是因为它分数最高,而是综合维度最均衡。逐条看规格(来自 BAAI 模型卡与社区资料):
- 架构:cross-encoder,基于 XLM-RoBERTa backbone。query 和 doc 拼接后过完整 Transformer,用 CLS 输出经线性层得到 raw logits,越高越相关。
- 参数量与体积:568M 参数,磁盘约 2.27GB。相比 v2-gemma 的 2.51B / 10GB,轻得多,消费级显卡也能跑。
- 许可:Apache-2.0。这是企业最看重的------可商用、无传染性、可改可分发。
- 语言:继承 bge-m3 的多语言能力,覆盖 100+ 语言,对代码切换(query 一半英文一半中文、文档是另一种语言)的处理明显好于纯英文 cross-encoder。对中文知识库是硬需求。
- 上下文长度:最大 8192 token,但实际推理常用 512 token(足够覆盖一个 chunk + query)。长文档若要整篇过 reranker,需要注意截断策略。
- 精度:支持 FP16 和 BF16,推理更快、显存更省,精度仅有轻微下降。自托管务必开 FP16。
评测方式上,BAAI 的设定是:用强召回器先捞 top-100,再用 v2-m3 重排,在 BEIR(英文)、C-MTEB-retrieval(中文)、MIRACL(多语言)上验证。它在这些基准上都显著超过前代 reranker。这恰恰印证了第 1 节说的"两段式"设定------reranker 本来就是配合召回器工作的。
6. 可运行代码:用 FlagEmbedding 跑 bge-reranker-v2-m3
下面是一份端到端可跑的代码。依赖就一个:
text
pip install FlagEmbedding
核心用法非常简洁:
python
from FlagEmbedding import FlagReranker
# 加载重排器,本地推理开 fp16
reranker = FlagReranker(
"BAAI/bge-reranker-v2-m3",
use_fp16=True, # 开 fp16,速度翻倍、显存减半
)
# 构造 (query, document) 对
pairs = [
("病假申请流程", "员工病假需通过 OA 系统提交,附就诊证明,3 天内补交。"),
("病假申请流程", "年假需提前 7 天在系统申请,主管审批后生效。"),
("病假申请流程", "公司提供补充医疗保险,覆盖门诊与住院费用。"),
]
# 批量打分,normalize=True 把 logits 映射到 [0,1]
scores = reranker.compute_score(pairs, normalize=True)
print(scores)
# 输出类似 [0.91, 0.32, 0.18],第一个明显最相关
注意两点:第一,compute_score 的 normalize=True 会把原始 logits 过 sigmoid 压到 0--1,方便直接当阈值用;第二,批量传 pairs 比循环快很多,GPU 上尤其明显。
6.1 接进完整 RAG 两段式管线
下面把召回和重排串起来。这里用伪召回器占位,你实际可以替换成任何向量库(FAISS / Milvus / pgvector 都行):
python
from FlagEmbedding import FlagReranker
# ---------- 第一段:召回(bi-encoder,这里用占位示意) ----------
def retrieve_topk(query, corpus, k=20):
# 实际中:用 bge-m3 等 bi-encoder 编码 query 和 corpus,
# 建 ANN 索引,返回 top-k 候选 (doc_id, text)
# 此处省略向量检索实现
return [(i, corpus[i]) for i in range(k)] # 占位
# ---------- 第二段:重排(cross-encoder) ----------
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
def rerank(query, candidates, keep_top_n=8, score_threshold=0.3):
pairs = [(query, text) for _, text in candidates]
scores = reranker.compute_score(pairs, normalize=True)
ranked = sorted(
zip(candidates, scores),
key=lambda x: x[1],
reverse=True,
)
# 保留分数高于阈值的 top-n
out = []
for (cand, score) in ranked:
if score < score_threshold:
continue
if len(out) >= keep_top_n:
break
out.append((cand, score))
return out
# ---------- 串联 ----------
query = "病假申请需要哪些材料"
corpus = ["..."] * 1000 # 你的文档集
candidates = retrieve_topk(query, corpus, k=20)
final = rerank(query, candidates, keep_top_n=8, score_threshold=0.3)
for (doc_id, text), score in final:
print(f"score={score:.3f} doc_id={doc_id} {text[:40]}")
这段代码里有两个关键旋钮:keep_top_n(重排后保留几条喂给 LLM)和 score_threshold(低于此分直接丢弃)。下一节细讲它们怎么调。
6.2 几个生产环境要注意的点
- batch_size:候选多时(比如 top-100 重排),把 pairs 一次性传入 compute_score 让它内部 batch,比自己循环快数倍。
- max_length:默认 512。如果你把整个父 chunk(可能 1000+ token)喂进去,会被截断。要么调大 max_length(更慢),要么控制输入长度(推荐,重排只需 query + 关键片段)。
- 第一次加载会下载模型(约 2.27GB),之后走本地缓存。生产环境建议预下载并指定本地路径,避免冷启动拉模型。
- CPU 也能跑,但 50 docs 约 400ms+;上 GPU 后降到 90--130ms。量大的话 GPU 是刚需。
7. 参数调优表:每个旋钮调大调小会怎样
把重排阶段的关键参数列成一张表,方便你按场景调:
| 参数 | 论文/社区默认 | 调大 | 调小 |
|---|---|---|---|
| 召回 TopK(第一阶段) | 20--100 | 召回更全,但重排耗时线性增加 | 可能漏掉相关文档 |
| 保留 TopN(重排后) | 8--10 | 喂给 LLM 的上下文更丰富,token 成本升 | 更聚焦,但可能丢边缘相关信息 |
| score 阈值 | 0.3 左右 | 过滤更严,列表更干净 | 保留更多,可能引入噪声 |
| fp16 | 开 | --- | 关则精度略升、速度显存变差 |
| batch_size | 自动 | 吞吐更高 | 显存更省 |
| max_length | 512 | 能处理更长 doc,但更慢 | 更快,但长 doc 被截断 |
经验法则:
- 候选集质量一般(召回器较弱)→ 把召回 TopK 调大(如 50--100),让重排器有更多"原材料"挑。
- 查询复杂、多条件(比如"2024 年 Q3 华东区销售额超目标的产品")→ 保留 TopN 调到 10,重排器在头部做精细区分。
- 简单事实查询、候选本来就少(< 10 篇)→ 可以干脆不重排,省那几十毫秒。
8. 重排阶段的检索链路(ASCII 流程图)
把整条链路画出来,方便你对照自己的管线:
text
用户 query
│
▼
[bi-encoder 编码 query] ── 全库 doc 向量(预计算)
│ │
└──── 余弦/内积 + ANN ──────┘
│
▼
top-20 候选 (召回,可含 BM25 混合)
│
▼
[cross-encoder 逐对打分] query+doc 拼接过 Transformer
│
▼
按 score 降序 + 阈值过滤
│
▼
top-8 精排结果 ──> 回取原文/父块 ──> LLM 生成
这条链路里,重排器是个"精准过滤器":它不负责从全库找候选,只负责把已经召回的 20 条排好序、剔掉不相关的。角色清晰,所以延迟可控。
9. 对照 CoreNote:它为什么这么设计
下面这段基于 CoreNote 公开的 README 事实,不脑补。CoreNote 的检索链路正好就是上面这套两段式:
- 重排器用的是 bge-reranker-v2-m3:和本文第 5 节推荐一致------多语言、Apache-2.0 可商用、轻量自托管,对中文个人知识库是合理默认。
- 检索 Top20 去重后重排 Top8:对应本文第 7 节"召回 TopK=20、保留 TopN=8"的典型配置。先召回 20 条保证不漏,再重排到 8 条控制喂给模型的上下文量。
- 父子分块 + parent_chunk_id 回取:重排命中的是子块(200--300 token),但通过 parent_chunk_id 回取父块(800--1200 token)喂给模型。这解决了一个重排的副作用------子块被截断后语义不完整,回取父块才给模型完整上下文。
- 不强制云端:OPENAI_BASE_URL 可指向 Ollama 等本地端点,reranker 也可本地部署。对数据不出域的隐私诉求是硬支撑。
- embedding 用 bge-m3 稠密+稀疏双路(维度 1024):正是第 4 节说的"重排前提------召回不能太差"的工程落地,混合检索先把候选集质量抬起来,reranker 才能在头部精细排序。
诚实的边界:CoreNote 用的是父子分块,不是 RAPTOR 树状摘要(我们前一篇讲过 RAPTOR)。它的重排是标准两段式,没有用 listwise(考虑候选间相对关系)那种更前沿的设定。这对个人知识库足够,且实现成本和本地部署负担更低------RAPTOR 要额外跑 LLM 摘要建树,对个人频繁更新的库不划算。所以"选更轻的方案"是刻意的工程权衡,不是能力缺失。
10. 什么时候不该加重排
重排不是银弹,下面几种情况加了反而亏:
- 候选池本来就很小(< 10 篇):重排器在头部做精细区分才有价值,候选都差不多时它帮不上忙,还白加延迟。
- 要求亚 100ms 总延迟:cross-encoder 单批就加 50--200ms,实时性极强的场景要权衡。
- 向量检索本身精度已经很高:干净语料上的简单事实查询,bi-encoder 直通可能就够。
- 显存极度紧张且不能上 GPU:CPU 跑 v2-m3 一批 400ms+,吞吐起不来。
判断标准很简单:你的 top-1 命中率不够高、或者用户常问复杂多跳问题,就加重排;否则先别加。
11. 小结
- bi-encoder 和 cross-encoder 不是对立,是串联:前者解决"快",后者解决"准"。
- 两篇 2019 年的论文把这件事钉死:SBERT 证明 bi-encoder 把 65 小时压到 5 秒;Nogueira & Cho 证明 cross-encoder 重排让 MS MARCO 的 MRR@10 相对涨 27%。
- 落地选 bge-reranker-v2-m3:568M、Apache-2.0、100+ 语言、FP16,社区基准距离商业 API 仅 3--4 分却完全自托管,是中文/多语言 RAG 的默认答案。
- 参数就盯两个:召回 TopK(20--100)和保留 TopN(8--10),再加一个 score 阈值过滤噪声。
- CoreNote 的"Top20 去重重排 Top8 + bge-reranker-v2-m3 + 父子分块回取"是这套方法论的标准工程实现,且刻意选了比 RAPTOR 更轻的方案以适配个人库。
重排是 RAG 里最该先做的那步优化------它便宜、可自托管、收益确定。先把这一段跑通,再考虑更复杂的检索结构。
参考
- Reimers, N., Gurevych, I. (2019). Sentence-BERT: Sentence Embeddings using Siamese BERT-Networks. EMNLP 2019. arXiv:1908.10084
- Nogueira, R., Cho, K. (2019). Passage Re-ranking with BERT. arXiv:1901.04085
- Chen, J., et al. (2024). M3-Embedding: Multi-Linguality, Multi-Functionality, Multi-Granularity Text Embeddings (BGE-M3). arXiv:2402.03216
- BAAI. bge-reranker-v2-m3 model card. HuggingFace: BAAI/bge-reranker-v2-m3
- FlagEmbedding 文档与示例(BAAI/FlagEmbedding)
- CoreNote 项目:github.com/zhangjianxin477/CoreNote (检索与重排配置参见其 README)