做 RAG 和搜索的朋友应该都有体会:召回模型把候选文档捞回来之后,重排才是决定用户体验的关键。我最初做知识库问答时,Reranker 这个概念还没有现在这么普及,向量召回 Top 5 里经常混着几篇“看起来相关、其实答非所问”的文档,后来才意识到问题不在召回,而在缺少一个足够精细的排序环节。Reranker 就是干这件事的,而 Cross-Encoder 架构的 Reranker,配上 Hard Negative 训练数据,是目前效果最直接、复现成本最低的方案。这篇文章把我从零跑通的最小复现流程完整记录下来,覆盖 Cross-Encoder 原理、Hard Negative 挖掘、训练与评估,以及我自己踩过的几个坑,适合刚接触 RAG 排序环节的开发者,或者想快速给检索系统加一道精排的工程师参考。
1. Reranker 在检索链路中的定位:为什么非要单独加一道排序
1.1 两阶段检索:海选和面试的分工
先交代背景。一个完整的检索系统通常分成两段:召回(Retrieval)和重排(Rerank)。召回阶段的任务是从几百万甚至上千万条文档里,以毫秒级速度捞回几百条候选。这个阶段只能快,不能做太复杂的计算,所以主流方案是 BM25 这类字面匹配,或者双塔向量召回这类可以用 ANN 索引加速的模型。召回的目标是“高召回”,宁可捞回一堆不相关的,也不能漏掉正确答案。
重排阶段就反过来,候选只有几百条了,计算成本可以放宽一点,目标变成“高精确”:把最可能满足 query 的文档排在前面。早期很多系统直接把召回的分数当排序依据,结果就是开头说的那种尴尬:向量相似度高、内容根本不对题。原因在于,召回模型为了能做 ANN 检索,必须把文档压缩成一个固定向量,这个压缩过程会丢失大量细节,尤其是“文档里有没有正面回答 query 问题”这种细粒度信号。Cross-Encoder 重排器就是为了补上这个细节而生的。
打个比方,召回是 HR 筛简历,只看学历和关键词;重排是部门主管面试,会认真考察候选人和岗位的匹配度。筛简历可以用规则和关键词,但最终拍板一定要靠面试。Reranker 就是这场面试,它需要有能力判断“这个人看着经历丰富,但到底能不能干这个活”,而不是停留在“他学历不错”的表面信息上。两段各司其职,系统才能既快又准。
1.2 召回阶段精度的瓶颈在哪里
很多人一开始不理解:向量模型不也能算语义相似度吗,为什么还要多此一举?这里的关键是“全局相似”和“精确匹配”的区别。
双塔向量模型训练时,是让 query 的向量和正样本文档的向量靠近、和负样本文档的向量远离。它学到的是“语义相近”这个整体信号。但检索场景里最难的错误案例,恰恰是“语义确实相关,但没有回答问题”。比如 query 是“iPhone 15 的价格”,一篇讲“iPhone 15 的发布会回顾”的文档,向量相似度很可能很高,因为它同样包含 iPhone、手机、发布会这些关键词,但它根本没有价格信息。这种细粒度判断,向量模型很难学会,因为向量是整篇文档的平均语义,细节早被平均掉了。
Cross-Encoder 不存在这个问题,因为它是把 query 和文档拼接成一句话,让 Transformer 的每一层注意力都同时作用在两者的 token 上。这等于让模型逐词核对“文档里到底有没有回应 query 的诉求”,而不是凭整体印象猜测。所以重排阶段的精度提升,不是靠调参调出来的,是架构本身决定的。
1.3 最小复现的边界:我们到底要复现什么
在动手之前先明确目标。这篇讲的最小复现,不是复现一个工业级重排服务,而是复现一条完整、可验证、可扩展的 Reranker 训练链路,包括三件事:用 Cross-Encoder 架构训练一个排序模型;用 Hard Negative 挖掘让这个模型真正学会区分“高度相似但不相关”的样本;用标准指标验证效果。
我不会讲分布式训练、模型量化、缓存设计这些生产环境的东西,那会把你淹没在细节里。当你把这条链路走通之后,再往里加工程优化,会轻松很多,因为你知道每个环节的输入输出是什么、瓶颈可能在哪里。最小复现的价值就在这里:用最小的成本把整条技术主线摸清楚。
2. Cross-Encoder 的核心机制:把“理解”交给注意力
2.1 从双塔到交叉:输入方式决定了交互上限
先简单回顾双塔模型。Bi-Encoder 对 query 和 doc 分别用一个编码器,得到两个向量,然后用余弦相似度或者点积打分。因为 doc 向量可以提前算好并建索引,所以检索很快。但也正因为这个设计,query 和 doc 的交互只发生在最后的向量计算阶段,中间没有任何细粒度交互,两个向量被“各自压缩”之后再比较,信息损失已经在压缩那一刻注定了。
Cross-Encoder 直接把 query 和 doc 拼成一个序列:[CLS] query [SEP] doc [SEP],喂给同一个 Transformer。每一层自注意力不只是让 query 内部的 token 互看,也让 query 的 token 和 doc 的 token 互看。这意味着模型在做打分的时候,可以参考“query 里的‘价格’这个词,在文档里有没有对应的内容”,而不是只凭整篇文档的全局向量猜。
这个差异在直觉上很容易理解:一个面试官只能看候选人的简历摘要,和一个能面对面逐句交流的面试官,哪一个判断更准?显然是后者。代价就是每次打分都要做一次完整的 Transformer 前向,没法预计算索引,只能实时算。这也是为什么 Cross-Encoder 只适合做重排——候选集合已经很小,实时计算成本可以接受。
2.2 一句话讲清楚 Cross-Encoder 的模型结构
用代码说更直接。下面是一个最朴素的实现,它本质上就是一个文本分类模型:
from transformers import AutoTokenizer, AutoModelForSequenceClassification tokenizer = AutoTokenizer.from_pretrained('BAAI/bge-reranker-base') model = AutoModelForSequenceClassification.from_pretrained( 'BAAI/bge-reranker-base', num_labels=1 ) pairs = [ ("什么是重排模型", "重排模型是在召回之后对候选文档再次打分排序的模型"), ("什么是重排模型", "重排是页面布局的一种方式"), ] inputs = tokenizer( [p[0] for p in pairs], [p[1] for p in pairs], padding=True, truncation=True, max_length=512, return_tensors='pt' ) logits = model(**inputs).logits # shape [batch, 1]你看到它本质上就是AutoModelForSequenceClassification,把一对文本拼接成两个输入序列,最终在[CLS]位置输出一个 logit。训练的时候,正样本让 logit 接近 1,负样本让 logit 接近 0,用 BCE 损失就能跑。这就是 Cross-Encoder 的全部秘密,没有更复杂的结构。
如果觉得还不够直观,可以再换一个角度理解:它和你平时做文本二分类没有任何区别,只是把“两句话是否语义等价”换成了“这篇文档是否回答了这个问题”。很多人第一次接触 Cross-Encoder 时会被名字吓到,拆开之后会发现,久经考验的就是一个带序列分类头的 Transformer。
2.3 最小复现怎么选模型,别一上来就上大模型
模型选型这件事,我的建议非常明确:中文场景首选 BAAI 的 bge-reranker 系列,英文场景可以用 cross-encoder/ms-marco-MiniLM-L-6-v2。
bge-reranker 是专门为 rerank 任务训练过的 Cross-Encoder,base 版本参数量不大,普通显存就能跑,效果却明显好过自己从零训练。v2-m3 支持多语言,对中文、英文混合的语料尤其友好。最小复现阶段,我强烈建议直接用BAAI/bge-reranker-base做底座,而不是自己从 RoBERTa 开始训练。原因有两个:一是预训练参数本身就携带大量语义知识,二是 bge 系列已经经历过 rerank 任务的对齐,你只需要少量领域数据做微调就能看到效果。
如果你有自己的小众领域,比如法律、医疗文档,可以在底座上继续训练,把领域数据灌进去。整个过程就是这套流程,换模型名字就行。从工程角度说,base 级别的推理延迟也能控制在几十毫秒内,完全够一个中小型知识库系统使用。
3. Hard Negative:决定排序模型上限的隐藏变量
3.1 为什么随机负例撑不起一个排序模型
很多第一次训练排序模型的朋友会问:负样本不就是随机从语料里抽吗?我最初也这么干,结果模型在训练集上 loss 很低,一上线就被打回原形。
原因很简单。随机负例基本都是“今天天气不错”这种跟 query 八竿子打不着的文本,模型只需要学会计算“语义整体相似度”就能轻松区分正负。可线上真正的难例,全是那些用词高度相似、句法也对、就是没回答问题的文档。你没在训练里给模型出过这种难题,它自然学不会处理。
Hard Negative 的定义就是:和 query 语义相似度高,但确实不是正确答案的文档。这类负例是排序模型的“附加题”,也是决定精排能力上限的关键变量。我举个例子你就明白:训练一个判断“苹果”相关性的模型,如果你只给它“苹果是一种水果,今天天气不错”这种负例,它永远学不会区分“苹果公司发布了新手机”和“苹果是一种富含维生素的水果”。只有把后一种样本作为 Hard Negative 喂进去,模型才会被迫理解上下文层面的差异。
3.2 四种挖掘策略,从简单到进阶
第一招,BM25 负例。字面匹配本身就是天然的难例来源。对每个 query 用 BM25 检索,取分数最高但不在正确答案集合里的文档。这些文档和 query 有很强的词面重合,能逼着模型别只靠关键词判断。这是成本最低的一招,我建议任何项目都从它开始,十分钟就能跑通。
第二招,向量召回负例。用现成的 embedding 模型检索出语义相似度高的文档,过滤掉正确答案后当负例。这类负例比 BM25 更难,因为词面不一定重合,但语义相近,模型必须学会理解深层关系才能区分。
第三招,Batch 内负例。训练时把同一个 batch 里其他样本的文档,当作当前 query 的负例。这一招在对比学习里很常见,好处是不用额外挖掘,计算上“免费”;坏处是质量不稳定,如果 batch 里两个文档恰好都算相关,就会引入噪声。所以它更适合作为补充,而不是唯一来源。
第四招,模型负例。用当前训练中的模型对候选文档打分,把预测分数高但实际无关的文档挖出来,加入下一轮训练。这是动态难例挖掘,效果最好,但实现复杂,容易让模型在难例上过度自信然后崩掉,属于进阶玩法。最小复现阶段,前两招足够喂饱一个不错的模型。
3.3 负例比例与数据清洗的实操经验
比例上,我实测下来正负比 1:2 到 1:4 最稳。负例太少模型欠拟合,多了又会让模型变得过度保守,什么都不敢判为正。另外强烈建议在 Hard Negative 里掺 20% 左右的随机负例,这样做是给模型留一些“简单题”,防止它被难题带偏,梯度更新也更平稳。这种混合策略,本质上相当于考试卷里既有基础题又有压轴题,模型才能在不同难度上都保持稳定的判断力。
清洗环节有个特别容易踩的坑:挖掘负例时没做去重,导致同一个文档既是正例又是负例。模型看到同一个输入既输出 1 又输出 0,训练逻辑直接乱套,loss 会异常波动。另一个坑是正确答案集合不完整,挖掘时只过滤了已知正例,但语料里还藏着其他表述方式相同的正确文档,被当成 Hard Negative 喂给模型,等于教模型“正确答案也是错误答案”。
这些小问题单个看起来不大,但都会在排序结果上放大。我的习惯是每轮挖掘后都打印几条 Hard Negative 人工抽查一下,确认没有明显的标注污染,再进训练。
4. 最小复现完整代码:从数据到评估
4.1 环境准备与数据结构
先把依赖装好:
pip install sentence-transformers transformers torch datasets rank-bm25 jieba数据用最简单的 CSV 组织,三列:query、doc、label。label 为 1 表示相关,0 表示不相关。如果你现在手头只有“问题-答案”对,没有负例,那就先跑一遍 BM25 挖出负例,再把正负例合并成 CSV。下面的代码会带你走完全流程。
一个极小的数据样例长这样:
query,doc,label 什么是重排模型,重排模型是在召回之后对候选文档再次打分排序的模型,1 什么是重排模型,重排是页面布局的一种方式,指重新排列元素,0 苹果发布了新手机,苹果公司发布了新一代智能手机,1 苹果发布了新手机,苹果是一种富含维生素的水果,0真实场景下每个 query 建议备 1 条正例加 2 到 4 条负例,200 个 query 起步,这个量级已经能看到明显的排序提升。数据集不用追求大,先把链路跑通,再谈规模。
4.2 用 BM25 挖 Hard Negative 的完整代码
假设你现在只有一批 query 和对应的正确答案,没有负例。用 BM25 在候选文档库里挖,注意 jieba 分词是中文场景必需的预处理,英文场景可以换成单词切分:
import jieba from rank_bm25 import BM25Okapi # corpus: 你的候选文档列表,每个元素是一篇文档的文本 corpus = [ "重排模型是在召回之后对候选文档再次打分排序的模型", "重排是页面布局的一种方式,指重新排列元素", "苹果公司发布了新一代智能手机", "苹果是一种富含维生素的水果", ] tokenized_corpus = [list(jieba.cut(doc)) for doc in corpus] bm25 = BM25Okapi(tokenized_corpus) def mine_hard_negatives(query, gold_docs, top_n=5): """ query: 用户问题 gold_docs: 正确答案集合,用于过滤 top_n: 取 BM25 分数最高的前 n 个候选取负例 """ q_tokens = list(jieba.cut(query)) scores = bm25.get_scores(q_tokens) ranked = sorted(zip(corpus, scores), key=lambda x: x[1], reverse=True) negatives = [] for doc, score in ranked[:top_n]: if doc in gold_docs: continue negatives.append(doc) return negatives # 示例 query = "什么是重排模型" gold_docs = {"重排模型是在召回之后对候选文档再次打分排序的模型"} for neg in mine_hard_negatives(query, gold_docs): print("hard neg:", neg)关键点在于if doc in gold_docs: continue这一行,它把正确答案过滤掉,避免把答案本身挖成负例。这个过滤条件在真实项目中一定要写扎实,前面已经说过了,它是数据污染的源头之一。
4.3 训练 Cross-Encoder Reranker
用 sentence-transformers 能把训练代码压缩到最短:
from sentence_transformers import CrossEncoder, InputExample from torch.utils.data import DataLoader import csv # 读取上一步生成的 CSV def load_pairs(path): pairs = [] with open(path, encoding='utf-8') as f: for row in csv.DictReader(f): pairs.append((row['query'], row['doc'], int(row['label']))) return pairs train_pairs = load_pairs('train.csv') # 加载 bge-reranker-base 作为初始模型 model = CrossEncoder('BAAI/bge-reranker-base', num_labels=1, max_length=512) samples = [ InputExample(texts=[q, d], label=float(label)) for q, d, label in train_pairs ] loader = DataLoader(samples, batch_size=16, shuffle=True) model.fit( train_objectives=[(loader, model.loss)], epochs=5, warmup_steps=int(len(loader) * 0.1), optimizer_params={'lr': 2e-5}, show_progress_bar=True, ) model.save('reranker_minimal')解释几个关键选择。num_labels=1表示模型输出一个实数分数,配合 BCE 损失做二分类;BAAI/bge-reranker-base是预训练底座,预训练权重让冷启动非常快;max_length=512是输入序列上限,query 和 doc 拼起来超长会被截断,对长文档场景建议自己控制分段或者选支持更长序列的模型。
关于model.loss多说一句:CrossEncoder 会根据num_labels自动选择损失函数,num_labels=1时是 BCEWithLogitsLoss。它直接对 logit 做二分类监督,等价于下面这段更显式的 transformers 手写训练循环。如果你想完全掌控训练过程,可以这样写:
from transformers import AutoTokenizer, AutoModelForSequenceClassification from torch.nn import BCEWithLogitsLoss from torch.utils.data import DataLoader import torch tokenizer = AutoTokenizer.from_pretrained('BAAI/bge-reranker-base') model = AutoModelForSequenceClassification.from_pretrained('BAAI/bge-reranker-base', num_labels=1) optimizer = torch.optim.AdamW(model.parameters(), lr=2e-5) loss_fn = BCEWithLogitsLoss() # train_pairs 是 (query, doc, label) 元组列表 def collate_fn(batch): queries, docs, labels = zip(*batch) enc = tokenizer(list(queries), list(docs), padding=True, truncation=True, max_length=512, return_tensors='pt') return enc, torch.tensor(list(labels), dtype=torch.float) loader = DataLoader(train_pairs, batch_size=16, shuffle=True, collate_fn=collate_fn) model.train() for epoch in range(5): total_loss = 0.0 for enc, labels in loader: logits = model(**enc).logits.view(-1) loss = loss_fn(logits, labels) optimizer.zero_grad() loss.backward() optimizer.step() total_loss += loss.item() print(f"epoch {epoch} loss {total_loss / len(loader):.4f}")两条路效果没有本质区别,选你顺手的那条。sentence-transformers 封装更省事,手写循环更能看清每一步在做什么。
4.4 推理排序与 MRR 评估
推理接口很简单,给一批 (query, doc) 对,返回模型打分:
reranker = CrossEncoder('reranker_minimal') pairs = [ ("什么是重排模型", "重排模型是在召回之后对候选文档再次打分排序的模型"), ("什么是重排模型", "重排是页面布局的一种方式"), ] scores = reranker.predict(pairs) print(scores) # 得分越高越相关评估我用 MRR(Mean Reciprocal Rank),这也是排序任务最常用的指标之一。对每个 query,把正确答案混进若干负例里,让模型排序,看正确答案排在第几位,取倒数,再对所有 query 求平均。代码很简单:
def mrr_at_k(sorted_docs, gold, k=10): for rank, doc in enumerate(sorted_docs[:k], start=1): if doc == gold: return 1.0 / rank return 0.0 eval_items = [ { "query": "什么是重排模型", "gold": "重排模型是在召回之后对候选文档再次打分排序的模型", "candidates": [ "重排模型是在召回之后对候选文档再次打分排序的模型", "重排是页面布局的一种方式", "重排元素可以让界面更美观", ], }, # 更多评估样本... ] total = 0.0 for item in eval_items: pairs = [(item["query"], doc) for doc in item["candidates"]] scores = reranker.predict(pairs) sorted_docs = [doc for _, doc in sorted(zip(scores, item["candidates"]), reverse=True)] total += mrr_at_k(sorted_docs, item["gold"]) print("MRR@10:", total / len(eval_items))如果评估集更大,可以再加 Recall@k 和 NDCG。NDCG 的好处是支持多级相关性,比 MRR 更细,核心思想是“排在前面的相关文档权重更高,且按位置做折损”。最小复现阶段,MRR 一个人就够了,它能直观反映正确答案平均排在第几位。
5. 避坑指南:我踩过的五个坑
5.1 常见问题速查表
训练 Reranker 的过程里,我至少五次怀疑过人生,最后发现全是细节问题。整理成一张速查表,你遇到类似现象可以直接对照:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| Loss 不降或剧烈震荡 | 学习率太高、数据标签混乱 | 降到 1e-5,检查 CSV 标签和去重逻辑 |
| 训练集效果不错,线上没提升 | Hard Negative 不够难,模型只会粗粒度判断 | 加大 BM25 负例比例,或换成向量召回负例 |
| 显存不足 | max_length 太长、batch_size 太大 | 先设 256 和 8,跑通再加 |
| 排序结果几乎不变 | 微调数据太少或 epoch 不够 | 增加 query 数,epoch 提到 5 到 8 |
| 同一个 doc 既当正例又当负例 | 挖掘时没过滤正确答案集合 | 在挖掘代码里严格做 set 过滤,训练前再随机抽查 |
这里还想单独提醒一点:不要迷信损失函数。有人一看到排序效果不好,就想着换一个更复杂的 loss,比如 ListNet、LambdaRank。大多数情况下,问题出在数据质量而不是损失函数。我见过很多项目把大量精力花在换 loss 上,结果 Hard Negative 挖掘做得一塌糊涂,换什么 loss 都救不回来。先把数据和负例质量做扎实,再看是不是真的需要更高级的损失函数。
5.2 热知识回应:DeepSeek 有 Reranker 模型吗
最近总在群里看到有人搜“DeepSeek 有 reranker 模型吗”。先说结论:据我对开源生态的了解,DeepSeek 官方目前主要发布的是生成式大语言模型,并没有像 BAAI/bge-reranker 这样的专用 reranker 模型系列。如果你想要一个开源、可本地部署的中文 Reranker,直接选 bge-reranker-base 或者 bge-reranker-v2-m3 就好,不需要等一个“官方 reranker”。
再说深层一点的逻辑。Reranker 这个角色和生成式大模型本来就是两回事。生成式大模型擅长的是“根据 prompt 生成文本”,如果让它给文档打分,要么靠 prompt 硬问,要么靠 logprob 推断,成本高、延迟高、效果还不一定稳定。而 Reranker 需要一个专门的判别式模型,也就是 Cross-Encoder,它的核心能力是“给一对文本的关系打分”,参数规模通常在 1 亿左右,推理快、部署轻。
所以哪怕某个大模型厂商出了很强的生成模型,和“有没有 reranker”也是两个维度的问题。最小复现的方案里,用专门的小模型永远是最理性的选择。你不必关心哪个大模型带不带重排功能,只需要选一个像 bge-reranker 这样训练好的 Cross-Encoder,然后在自己的数据上微调,就能得到足够好用的精排模型。
最后再分享一点个人心得。这套最小复现跑通之后,我对检索这件事最大的改观是:别再指望一个模型解决所有问题。召回负责广撒网,重排负责精挑细选,各司其职,系统才能又稳又准。Hard Negative 挖掘这个环节,以前觉得是苦力活,现在回头看,它才是整个 Reranker 项目里性价比最高的投资——数据质量上去了,模型效果自然跟着上去,调参的时间反而能省下来。
如果你也准备在自己的数据集上复现,我的建议是:先别贪多,拿一两百个 query 跑通全链路,看看 MRR 有没有变化,再决定要不要扩大数据规模。这一步走稳了,后续接 RAG、接搜索、接推荐,都是顺水推舟的事。