作为一名常年跟检索系统打交道的算法工程师我对 cross-encoder 的感情一直很复杂。爱它是因为在精排阶段它通常能把双塔模型按在地上摩擦给最终效果带来肉眼可见的提升恨它是因为它推理起来实在太重了稍微没处理好延迟线上就得爆炸。最近我把几个真实检索场景里用 cross-encoder 做精排的笔记重新整理了一遍从数据准备、负样本挖掘、模型训练到部署加速都有不少踩坑记录这篇就当是给自己留个技术存档也希望能给刚开始碰精排的同学一些能直接上手的参考。这篇文章不会堆概念重点是我实际怎么做的、为什么这么做以及哪些地方最容易翻车。1. 为什么召回靠双塔精排却绕不开 cross-encoder先把这个东西放在整个检索链路里看一遍。典型的搜索或召回系统基本都是两段式:第一段用轻量级模型从海量候选里捞出几百个第二段用更强的模型对这几百个做精细排序。第一段常见的是双塔架构第二段则越来越多地落到 cross-encoder 头上。它俩的本质区别一句话就能讲清双塔模型把 query 和 document 分别编码成两个向量交互发生在最后一步的点积或余弦相似度计算里cross-encoder 则是把 query 和 document 拼成一个完整的序列一起送进编码器让注意力机制在两者的词元之间充分地交换信息。1.1 交互深度才是精度差异的根源我见过不少人以为 cross-encoder 精度高是单纯因为模型参数大这个理解其实不太对。精度差距主要来自交互方式。你可以想象这样一个场景双塔模型就像两个人各自在家写完简历最后由一个只负责查分数的中间人决定他们匹配不匹配中间人只看到两个分数看不到简历里那些彼此呼应的细节。cross-encoder 则像让两个人面对面坐下来深聊每一个词都能跟对方的每一个词直接对上话这种全交互的信息交换量级完全不同。具体到 Transformer 内部当 query 和 document 拼接成一个序列后query 里的苹果能直接 attend 到 document 里的手机苹果手机和苹果水果这种细微差异就能被区分开。但在双塔结构里苹果的向量表达永远只有一个它的上下文歧义要让一个固定向量去扛这非常勉强。实际项目里双塔召回的 top100 候选里真正让人头疼的往往就是这种语义细节上有微妙差别的样本而 cross-encoder 正好擅长处理这些。1.2 双塔和 cross-encoder 不是替代关系有些人会问既然 cross-encoder 精度这么高为什么不直接用它做召回我也曾经天真过后来被埋了。原因很直接计算复杂度完全不在一个量级。双塔模型是子线性或者近似独立的编码过程query 编码一次、document 编码完成后建立索引真正线上的计算只是一个向量距离百万级候选也扛得住。cross-encoder 需要对每个候选都完整算一遍候选规模一大延迟立刻爆炸。用一个最直观的对比表格维度双塔模型cross-encoder输入方式query 和 document 分别编码query 与 document 拼接后编码交互方式最后一层向量点积/余弦注意力机制全词元交互单条延迟毫秒级甚至更低相对高取决于模型层数和序列长度排序精度中等对细节歧义不敏感高能捕捉语义细节和上下文重匹配适用阶段召回、粗排、大规模候选过滤精排、小规模候选重排序训练难度容易做大规模负样本挖掘难负样本要求高训练更敏感部署成本低可构建向量索引高需要大量计算资源或加速方案所以在实际工程里一定要让两者分工召回用双塔精排用 cross-encoder 对召回出来的几百条重新打分用精度换回关键的头部几名的排序质量。这一点理解错了后面全盘都是歪的。2. 训练数据与负样本精排效果的最关键入口我见过太多团队模型结构、训练代码全部照搬开源项目最后效果依然上不去问题大概率出在训练数据和负样本上。cross-encoder 的训练数据门槛比双塔高很多不是简单地扔给它一堆相关/不相关样本就能出活。为什么因为它要学的不是大概相关的程度而是对细微差别的精确判断。数据里如果全是显而易见的样本训练完的模型一上真实场景就露馅。2.1 数据格式与标签设计先说最基础但最容易犯错的标签设计。很多人习惯用二分类标签0 表示不相关1 表示相关这能用但精度上限不够高。更推荐的是把相关性做成多级比如 0、1、2 三档或者直接用 0 到 3 的四档分级。多级标注的优点很朴素它让模型理解完全不相关和部分相关是两个不同的概念而不是把所有非完全匹配的样本混在一起。我做过一次对比同一个模型三分类标签训练后在真实测试集上的 NDCG 通常比二分类高出 2 到 3 个点并且遇到模糊样本时的稳定性更好。数据描述一条样本的逻辑很简单query 文本、document 文本、相关性标签。不同业务有不同的相关性定义所以接数据的同学一定要跟业务方对齐什么算相关。比如搜索苹果手机壳一个介绍苹果公司财报的页面和一个卖手机壳的商品页面在通用语义里可能都跟苹果相关但在购物场景里后者才算真正相关。这种 label 标准不统一的问题在跨团队协作时经常出现值得提前开会明确。2.2 负样本挖掘难负例是真正的分水岭如果说标签设计决定了下限那负样本挖掘就直接决定了精排模型的上限。随机采样一些明显不相关的 query-文档对当负样本模型收敛很快准确率也很高但线上效果会很差。原因很扎心召回阶段已经替你过滤掉了大部分明显不相关的文档能进入精排候选池的基本都是看着有点像、但细节上不够匹配的难样本。如果你的模型在训练时没见过这类难样本上线就直接懵。我自己的实践准则是负样本里随机负例和难负例的比例大概控制在 1:3 到 1:4。随机负例保证模型具备基础的判别能力难负例则训练它处理模糊数据。难负例怎么来常见做法有几类上一版召回/粗排模型返回的 top20 里、但被人工标注为不相关的结果。双塔模型召回的近似样本即向量距离很近但真实不匹配的样本。同一 query 下部分相关与高度相关文档之间的互相负例。在 batch 内把其他样本的 document 当作负例形成 in-batch negative。这里有一个容易被忽略的操作细节难负例挖得太难也会出问题。如果训练负样本全部是那种命题作文式的刁钻样本模型会过拟合到特定难分布上遇到稍微普通一点的误召回反而束手无策。我的经验是难负例的难度应该循序渐进先让模型在随机负例上充分收敛再逐步引入难负例继续训练或者直接把不同难度负样本混合但比例要控制。2.3 损失函数怎么选pointwise、pairwise 还是 listwise训练目标的选取是另一个经常被讨论的话题。我用过比较有效的几种简单总结一下pointwise 的 BCE 损失适合二分类标签实现简单但对排序的相对关系建模比较弱。pairwise 的 MarginRankingLoss核心是给正样本对和负样本对之间加一个 margin让模型学会把相关的排在前面。这个对排序任务的拟合度比 pointwise 好也比较好调。listwise 的 Softmax 交叉熵把一批候选当作一个列表预测相关文档在这一列表里的位置概率。这个跟最终评价指标最贴合但实现和采样都要更细致。我个人的经验是如果你有相对可靠的成对偏好数据或者分级标注pairwise 是性价比最高的选择要是你手头已经有类似搜索引擎的点击日志能够构造一个 query 对应多个候选的结构化列表那 listwise 的收益会更高。不要一上来就搞太复杂的损失函数先从一个 pair 的排序正确率开始调效果稳定后再说。3. 训练过程实操参数、代码与评估数据搞定后训练环节本身看起来很简单无非是加载预训练模型、加一个分类头、开始跑。但真正动手后你会发现在参数和评估上有太多细节稍不注意就是训练好了但落地没用的状态。这一章我会把我觉得最值得记录的参数和代码过程放出来。3.1 预训练模型怎么挑cross-encoder 通常要在一个预训练语言模型上加一个分类头所以底座的选择直接关系到效果。通用领域可以直接用常见的中文预训练模型层数从 6 层到 12 层都有具体看你的计算资源和延迟预算。领域场景更推荐用领域相关的预训练底座比如金融、医疗、代码等领域有专门的预训练模型在领域数据上继续训练效果提升非常明显。如果你没有条件训练领域底座一个替代方案是先在领域语料上做一段简单的继续预训练也就是 domain-adaptive pretraining这个操作 1000 条训练样本都能带来肉眼可见的收益。模型层数这块我一直跟团队说不要盲目追求大。精排阶段面对的只是几百个候选排序差异主要体现在对头部几名的区分上12 层模型未必比 6 层模型的排序效果翻倍但推理延迟一定翻倍。如果是一次全新的项目我会先拿一个 6 层中文底座把链路跑通确认效果达标后再考虑升级到更大的版本。先用小模型验证整套方案是现代检索项目里很省心的一个原则。3.2 训练参数速查表我把一套在实践中稳定跑通的参数列在下面这套参数适合 base 级别的预训练模型参数名推荐值备注learning rate1e-5 到 3e-5精排任务学得快学习率太大会震荡batch size32 到 64受限于 GPU 显存可配合梯度累积max sequence length128 到 256同时覆盖 query 和 documentepoch3 到 5建议按验证集早停warmup ratio0.1前 10% 步数线性热身weight decay0.01常规 L2 正则优化器AdamW配合线性衰减学习率具体数值要结合你的数据量与模型规模微调。尤其注意 max sequence length我在项目里见过有人把长度设到 512理由是怕丢掉信息但实际上大部分检索场景的核心信息都集中在前面长度拉长不仅推理变慢对排序精度的贡献也很小。建议先做一次序列长度分布的统计分析把长度设置到能覆盖 90% 以上样本的量级。3.3 最小可运行训练代码示例训练代码本身不复杂真正要花心思的是数据加载和采样逻辑。下面给一段基于常见框架的简化代码适合快速验证你的数据管线是否正常。import torch from torch.utils.data import DataLoader, Dataset from transformers import AutoTokenizer, AutoModelForSequenceClassification class RerankDataset(Dataset): def __init__(self, samples, tokenizer, max_len128): self.samples samples self.tokenizer tokenizer self.max_len max_len def __len__(self): return len(self.samples) def __getitem__(self, idx): query, doc, label self.samples[idx] encoded self.tokenizer( query, doc, truncationTrue, paddingmax_length, max_lengthself.max_len, ) return { input_ids: torch.tensor(encoded[input_ids]), attention_mask: torch.tensor(encoded[attention_mask]), labels: torch.tensor(label, dtypetorch.long), } tokenizer AutoTokenizer.from_pretrained(your_pretrained_model) model AutoModelForSequenceClassification.from_pretrained( your_pretrained_model, num_labels3 ) train_dataset RerankDataset(train_samples, tokenizer) train_loader DataLoader(train_dataset, batch_size32, shuffleTrue) optimizer torch.optim.AdamW(model.parameters(), lr2e-5) loss_fn torch.nn.CrossEntropyLoss() model.train() for epoch in range(3): for step, batch in enumerate(train_loader): outputs model( input_idsbatch[input_ids], attention_maskbatch[attention_mask], ) loss loss_fn(outputs.logits, batch[labels]) loss.backward() optimizer.step() optimizer.zero_grad() if step % 100 0: print(fepoch {epoch}, step {step}, loss {loss.item():.4f})这个示例只把核心逻辑写出来实际项目里要加梯度累积、混合精度、评估回调、模型保存。特别提醒一个细节跨 batch 的 in-batch negative 如果想实现朴素做法是把 batch 打乱后再让 document 彼此充当负例这种采样逻辑写起来简单但要小心标签错位别把正样本在构造负例时又当成负样本造成数据泄露。3.4 评估指标别死盯着准确率cross-encoder 的训练集里正负样本比例往往不是 1:1真要拿准确率当评估标准完全会被带偏。检索场景真正的评估指标应该是排序类的常见的有MRR平均倒数排名、RecallK前 K 个结果里相关文档的召回率、NDCGK折损累计增益。其中 NDCG 因为考虑了位置和分级相关性在精排任务里最常用。一个容易被忽略的问题是离线评估必须跟线上分布对齐。理想情况是评估集的候选集合来自召回模型的输出而不是你自己随机构造的池子。这样离线指标才基本能反映上线后的表现。我在项目里常常会做一次候选分布对齐检查把评估集里的 document 分布跟线上真实 top100 做对比如果差别太大宁可重采评估集。此外自动指标提升不代表用户体感提升。我一般会在每个训练迭代后抽样几百个 query人工看一遍 top10 的排序质量很多 badcase 在指标上是体现不出来的。这一步听着费人力实际上能省下后面调 bug 的无数时间。4. 部署与在线推理把几十毫秒压到极致cross-encoder 在离线评估里指标漂漂亮亮一上线就容易翻车原因无它慢。如果不做任何优化一个 12 层模型对单条 query-文档对推理典型延迟在几十毫秒到上百毫秒之间如果精排要处理几百个候选那一次搜索请求光精排就得几秒钟产品根本没法用。这一章就是讲怎么压延迟。4.1 延迟瓶颈在计算量不在模型单次速度先算一笔账假设精排候选有 200 个每个候选单独送进模型单条延迟 30 毫秒那就需要 200 × 30ms 6000ms也就是 6 秒。这个数字大家都懂但实际操作中很多人没意识到真正的突破口是减少模型前向次数而不是单纯压单次延迟。所以最划算的优化是先把候选集从几百降到几十比如用双塔粗排直接截断 top50再用 cross-encoder 精排这 50 条。五十条乘以单条 30ms也还有 1.5 秒所以要继续往下压。batch 推理是必须的。GPU 并行计算时把多条拼接数据一起送进去能大幅摊薄单条推理成本。通常一个 batch 32 条候选同时推理整体耗时可能只是单条的 3 到 5 倍而不是 32 倍。工程实现上注意两个细节第一padding 策略别把整批数据都 pad 到 128而是每个 batch 内按最长序列动态 pad第二query 本身在同一个请求内是固定的可以提前编码并复用不过 cross-encoder 因为拼接输入这个复用收益有限简化方式是先把 query 归一化成一个固定长度前缀。4.2 模型压缩与加速手段除了减少前向次数单次推理的优化空间也很大。常见方案包括模型蒸馏、量化、以及转换到更适合推理的格式。蒸馏的思路很简单用一个大的 cross-encoder 当老师让一个小模型去学习它的排序输出分布小模型在线跑大模型离线生成软标签。这样做的好处是你能把 12 层模型蒸馏到 6 层甚至更小精度损失通常能控制在可接受范围内但推理速度提升非常显著。量化是另一条实用路径。把模型从 FP16 量化到 INT8常见推理框架都能直接支持模型体积缩小四倍推理速度能再提一截。我建议在做任何量化之前先备一套标准的离线测试集量化前后各跑一遍计算指标差异不要因为大家都说量化没问题就直接上生产。另外如果你的服务是 CPU 场景一定要考虑换到 GPU 或者至少用支持底层优化的推理后端。GPU 对这类密集型 Transformer 计算的加速效果非常明显很多时候一个简单切换就能让延迟从不可用变成可用。记忆里我第一次把 cross-encoder 从 CPU 挪到 GPU同样的代码延迟直接降了一个数量级当时整个人都松了一口气。4.3 蒸馏一个双塔模型来承接精排信号这里分享一个我后来用得越来越顺手的折中方案与其让 cross-encoder 永远扛着线上重排的任务不如让它定期把排序知识蒸馏回双塔模型中。具体做法是用训练好的 cross-encoder 对一批历史 query-文档对打分把生成的软标签用来微调双塔模型。双塔模型在检索阶段排序时会自然地带上一点精排意见这样有些场景甚至可以让精排环节缩减候选集的数量延迟压力进一步降低。这个方案听起来有点绕但在资源紧张的中型团队里非常实用。它等于把 cross-encoder 的推理成本从每次请求转移到了异步的离线批量任务中线上只跑低成本的双塔。当然它不能完全替代 online 精排因为双塔毕竟没法精确建模交叉特征但对于不过分追求极致精度的产品这是一个性价比极高的组合。5. 踩过的坑与线上问题排查实录最后这部分我集中写一些真实遇到过的坑。很多东西不是看论文能看出来的而是要在项目里撞了南墙才记得住。5.1 数据泄露负样本构造时的隐性 Bug最坑的一次是团队同学把同一篇文档的不同段落同时放进了训练集的正样本池和负样本池。模型在训练时看到的信号变成这段文字既相关又不相关直接学乱了线下指标虚高线上效果暴跌。排查这种问题很费劲因为我们一开始根本没想到是数据问题。规避方法其实不复杂构建样本时强制按文档唯一 ID去保证同一个文档在同一个 query 下只出现在一个标签里划分训练集和验证集时也必须以文档为单位做划分而不是以样本对为单位。这个原则适用于所有检索模型训练值得写进团队的数据规范里。5.2 过拟合的信号与早停策略cross-encoder 训练非常容易发生过拟合尤其当你把难负例反复喂进去之后。典型信号是训练集 loss 一路下降准确率接近 100%但在验证集上的 NDCG 不升反降。这时候不要犹豫马上回滚到验证指标峰值时的 checkpoint同时把难负例的重复使用次数降下来。我踩过的具体场景是为了追求指标好看把训练数据里同一个难负例在多个 epoch 里反复出现结果模型把特定负例的措辞背下来了而不是学到通用的语义匹配信号。这个问题的解法是定期检查训练集里样本的重复度或者限制难负例在一个 epoch 里只出现一次。简单有效。5.3 线上与离线效果不一致多半是预处理不一致如果你发现离线测试指标很漂亮但线上 A/B 实验效果拉胯那第一嫌疑不是模型本身而是在线和离线的预处理流程不一致。最常见的几个点离线评估用全文本线上用的文本被截断过。离线做的是单条样本推理在线却用 batch 方式padding mask 没处理好。query 归一化方式不同比如离线用了小写转换和全角半角转换线上没做。标签打分尺度在线上被下游模块二次修改导致排序结果变化。想当然地认为用的同一个模型结果自然一样这是大忌。我养成的习惯是每次上线之前用线上真实请求日志里抽出来的 formatted 输入去跑一遍模型跟离线 pipeline 的输出做逐条比对。这个工作虽繁琐但能过滤掉九成所谓的玄学问题。5.4 正负样本不均衡的一个实操经验很多业务场景里正样本远少于负样本比例可能 1:10 甚至更低。如果不做任何处理模型会倾向把所有样本都判成负例排序结果自然是一团乱麻。处理手段有两类一类是在采样时按比例上采样正样本保持每个 batch 里正负比在 1:2 到 1:5 之间另一类是调整损失权重给正样本更高的权重。我自己更推荐第一种因为采样法能让每个 batch 内的分布保持稳定训练过程更平稳。这里也要小心过度上采样正样本会让模型遇到真实场景时误判率升高因为真实请求里正样本的比例其实很低。这又是一个需要权衡的地方基本靠离线验证集来定参数。这个内容做完之后我最大的体会是cross-encoder 的精度优势不是白来的它需要你付出数倍的数据和工程代价来换取。如果你刚开始接触精排建议别一上来就挑战最复杂的模型先拿一个小底座加上一套结构合理的难负样本把整个链路跑通这个版本的效果通常已经能超过纯双塔的一整版。我个人还有一个很土但很实用的习惯每次迭代都从真实 query 日志里抽一批 badcase 人工看一遍看多了以后你对数据分布和模型短板的理解会远比看论文来得快。精排这条路没有银弹但把基础环节做扎实效果一定不会差。