
先说个结论如果检索链路里只有向量召回一个环节最终结果里总会混着几条“看着像、其实不对”的内容。cross-encoder交叉编码器就是专门治这种问题的精排模型——它把 query 和候选文档拼成一段文本让模型在 token 级别充分交互之后打个分数。这篇笔记是我在做一个知识库语义搜索项目时整理的包含了原理解读、训练数据构造、微调细节、推理优化还有几个实实在在踩过的坑。适合正在搭 RAG 链路、做站内搜索、做语义匹配的同学参考。1. 先说清楚 cross-encoder 是什么以及它凭什么比向量检索更准1.1 从双塔到交叉两种交互方式的本质差异大多数人对语义检索的第一印象是把文本编码成一串向量然后算余弦相似度。这条路线对应的模型结构叫 bi-encoder双编码器也就是常说的“双塔模型”。它的工作方式很直接query 单独过一遍编码器文档单独过一遍编码器各自产出向量离线把几百万文档的向量全部算好建索引。线上来一个 query只要算一次 query 向量然后去向量库里做 ANN近似最近邻搜索。这套方案的优势非常明显快、可扩展、适合大规模召回。但缺陷也藏在结构里——query 和文档在编码过程中完全没见过对方所有“交互”都发生在最后那一次余弦相似度计算上。这意味着模型只能捕捉两个文本各自独立表达出来的语义很难发现那种“字面上不匹配、但结合上下文才成立”的相关性。cross-encoder 的做法完全不同它把 query 和文档拼成一个完整的输入序列一起送进编码器。Transformer 的每一层自注意力都会让 query 的每个 token 与文档的每个 token 直接交互所以模型可以看到“这个词在对方语境里到底是什么意思”。最后用一个特殊的分类头输出标量分数本质是把相关性判别当成分类或回归任务来做。用生活化的类比bi-encoder 像看简历批量筛人快速排除明显不合格的候选人cross-encoder 像约面试只有真正进入这个环节的人才会被逐字逐句考察。简历筛选便宜面试判断准。一个成熟检索系统通常两者都要——先大规模召回再精排。1.2 输入输出到底长什么样cross-encoder 的输入并不是两个独立的序列而是一个长序列。以典型的预训练语言模型为例tokenize 之后的组织方式通常是[CLS] query token 序列 [SEP] doc token 序列 [SEP]实际代码里就是一个 tokenizer 同时吃两个字符串inputs tokenizer( query, doc, truncationTrue, max_length256, paddingmax_length, return_tensorspt )输出侧也很简单——看 [CLS] 位置的最终隐层向量。这个向量经过一个线性层或一层 MLP变成 1 个 logit。如果做相似度二分类就过 sigmoid 变成 0~1 之间的概率也可以直接拿 logit 当排序分数。有一个点常被忽略cross-encoder 根本没有办法预先算好文档的表示。因为每次打分都是把 query 和 doc 拼在一起现场推理score(query, doc1) 和 score(query, doc2) 是两次完全独立的 forward。所以它只能用于小规模精排不可能直接做全库检索。2. 训练数据才是重排模型效果的分水岭2.1 样本结构query, doc, label 三元组怎么来cross-encoder 微调最核心的原料是成对的 query, document, label 样本。label 通常是 0 或 1表示这对组合是否相关。听起来简单真正动手时第一个问题就是这些标注从哪来常见来源有三种。一种是业务日志。搜索框里用户点击过的结果天然是正样本但也要小心位置偏差——排第一的文档被点很可能只是因为它在第一。所以日志样本最好经过清洗至少要去掉那些点击后马上跳出、停留时间极短的记录。第二种是人工标注。找业务方或标注团队按“与 query 是否相关、相关到什么程度”打分。人工标注最可靠但成本高适合先标一批种子数据后面再不断迭代。第三种是模型辅助构造。尤其是 RAG 场景下很多团队会让大语言模型LLM对检索候选打分或生成“伪标签”。LLM 的判断不完全可靠但用来批量扩充训练集效率很高缺点是需要做一轮质量抽检。2.2 难负样本的挖掘策略从 BM25 到模型动态负采样样本里最容易出问题的是负样本的选择。如果负样本都是随机抽的网页或文档模型很快就学会了“只要字面重叠少就是负样本”一旦遇到真正需要语义理解的 case 就原形毕露。难负样本hard negative才是让模型真正变强的关键。什么是难负样本就是那些跟 query 在字面上很相近、主题上也沾边但严格来说不相关的文档。最经典的挖掘方法有两种用 BM25 把 querys 跑一遍从召回结果里挑那些分数靠前、但标注为不相关的文档当负样本。BM25 找出来的是“词面相似但语义并不同”的典型样本。用当前已经训练了一部分的模型去给候选打负样本难度分选出得分高但标签为负的样本把它们加入下一轮训练。这种迭代式挖掘被称为“在线难负样本”效果通常比一次性挖掘更好。我的经验是混着来。随机负样本放一点难负样本放一点基本比例可以做到 1:2 到 1:3。全上难负样本会让训练很快不收敛全上随机负样本又会让模型学不到细粒度判别这个平衡必须靠实验调。2.3 样本配比里容易被忽略的坑样本配比不只是“正负各多少”。还有一个很容易踩的坑query 的种类分布。如果训练集里 70% 的 query 是“这是什么产品的价格”这类购物意图剩下的长尾 query 样本稀少模型在线上遇到长尾 query 时分数就会失真。解决方式是给低频 query 类型做上采样或者按业务分组做均衡采样。另外正负样本比例我个人不会用 1:1实际训练里 1:2 到 1:3 会更稳。正样本太多会让模型变成“什么都觉得相关”的老好人正样本太少模型又会倾向于把候选全部打低分。具体比例还是要用自己的验证集调。3. 从零微调一个 cross-encoder基座选型、损失函数与训练细节3.1 基座模型怎么选体积、语种、领域匹配三件事选基座模型时同一个 BERT 类架构下也有大把选择核心权衡就三个体积、语种和领域匹配度。体积主要影响推理延迟和显存占用。百 MB 级的小模型做精排单次打分的延迟通常在几十毫秒以内而 XL 量级的大模型虽然更准但同样的候选集要重排线上延迟可能直接翻好几倍。少量候选比如 50 条之内可以考虑大模型候选一旦到 100 条以上还是老老实实用小模型或者做蒸馏。语种维度要看训练数据是否覆盖你的业务语言。中文场景就尽量用中文预训练过的基座否则模型对中文 token 的表征能力先天不足多语种场景则优先选多语种基座再拿你的数据微调。领域匹配度是我最看重的一条。通用预训练模型对财经、法律、医疗这些专业术语的理解往往是“字面对上了、内涵没对上”。如果业务高度垂直哪怕你只有几千条标注数据也值得做一轮领域语料继续预训练或者直接微调效果会比硬套通用模型好一截。3.2 损失函数对照BCE、Triplet、Contrastive 分别适合什么场景训练 cross-encoder 最常用的损失函数主要有三种我整理了它们的使用场景。损失函数样本结构适用场景特点BCEWithLogitsLoss(query, doc, label 0/1)标注数据充足、有明确相关标签训练稳定分数可解释适合直接输出相关性概率Triplet / MarginRankingLoss(query, pos_doc, neg_doc)只有成对偏好、没有绝对分数让正样本分数至少比负样本高一个 marginContrastive / InfoNCEbatch 内互为负样本大 batch、希望利用 batch 内负例配合 in-batch negative 能凭空扩出大量负样本从我实测的感受看如果标签质量高、量也够BCE 是最省心的。输入一对文本输出 0~1损失曲线非常直观。Triplet 更适合那种“我只有哪些文档比哪些文档好”的相对标注但要仔细调 margin太大训练难收敛太小模型又拉不开正负差距。Contrastive 在大 batch 下有奇效但显存也被迫吃紧因为 batch 内所有组合都可能被当成负例矩阵规模随 batch size 平方级膨胀。我这里要提醒一句不要为了上 Contrastive 盲目加大 batch先算清楚显存预算。3.3 训练参数与收敛判断下面这套参数是我多次实验后觉得比较稳的起点微调预训练语言模型时可以直接参考学习率2e-5 到 5e-5建议先用小学习率跑 1 个 epoch 看看 loss 的下降趋势warmup ratio0.1前面 10% 的步数做线性预热batch size能塞进显存就尽量大32 起步条件允许再往上提max_length根据 doc 实际长度定我一般用 256文档特别长再提到 512训练轮数1~3 轮小数据集上超 3 轮几乎必过拟合。收敛判断不要只看 loss更关键的是验证集上 MRR 或 NDCG 的变化。我习惯每个 epoch 结束都跑一遍验证集保存效果最好的那版而不是用最后一轮。预训练模型微调很容易在最后一轮开始过拟合训练集验证指标反而下降。训练循环的关键代码并不复杂核心就是拿到 logits 之后选择合适的 losslogits model(**inputs).logits # shape: [batch, 1] loss bce_loss(logits.view(-1), labels) # labels 为 0/14. 上线推理如何把 cross-encoder 塞进真实检索链路4.1 两段式流程召回 top-100 再精排cross-encoder 不能单独构成检索系统它的定位永远是精排而不是召回。我目前验证下来最稳的链路是第一级召回用 BM25 向量检索的混合召回各取 top-50合并去重后凑成约 100 条候选。第二级精排把每一条候选文档和 query 拼对过一遍 cross-encoder得到 100 个分数。截断输出按分数排序取 top-10 或 top-20 返回给下游任务。这个方案在延迟上很可控。100 个候选如果组成一个 batch 一次性 forwardGPU 上通常百毫秒内能完成如果是 CPU 上的小模型一两百毫秒也是能接受的范围。相比直接把全部文档交给大模型重排成本和稳定性都合理很多。4.2 批量打分与精度优化很多同学第一次上手时会一条条去调模型循环 100 次前向传播结果慢得离谱。正确的做法是拼 batch。把 100 个 query, doc 对分别 tokenize 后叠成一个 tensor一次 forward 出 100 个分数inputs tokenizer( [query] * len(docs), docs, truncationTrue, max_length256, paddingTrue, return_tensorspt ) with torch.no_grad(): logits model(**inputs).logits scores torch.sigmoid(logits).view(-1)有个细节要注意paddingTrue 时一个 batch 内会按最长的样本填充如果 99 个样本都很短、只有 1 个超长整批都按超长算白白浪费计算量。办法是手动把候选按长度分桶长度接近的放同一批再分别推理。精度优化我常用两招。第一招是半精度推理显存和速度都能直接受益第二招是导出成通用推理格式比如 ONNX在 CPU 部署场景下配合量化延迟能再降一档。量化时优先试动态量化对精排这种短序列任务影响通常较小。4.3 分数校准为什么排序可以但阈值会漂cross-encoder 训练时的标签是 0/1按理说推理分数应该可以当概率用。但实际不是这样。原因主要有两个。一是训练数据的分布跟线上不一致线上 query 是真实的、带噪声的训练样本是“刻意挑出来”的正负对模型见过的最难负样本比线上真实负样本难一大截于是给所有候选打得都偏低。二是基座模型本身有自己的输出偏好一些 token 组合天生更容易产出高分。结果就是你精心定下的 0.5 阈值线上几乎肯定不适用。分数可能整体集中在 0.2~0.6 之间也可能都堆在 0.01 附近。我的经验是如果只做排序分数漂移无所谓排位顺序依然可信但如果你想拿这个分数去截断或者作为最终置信度输出给用户一定不要用训练时的 0.5而是应该拿一批线上真实样本来重新标定阈值。简单方案是在验证集上画出分数分布选一个能让正样本保留率尽量高的切点更稳的方案是先把分数做 z-score 标准化再定阈值。5. 这几个坑我是真金白银踩出来的5.1 训练与推理的预处理不一致导致分数整体偏移这是我入坑时犯的第一个低级错误。训练 pipeline 里文本先做了一遍小写化、全角转半角、去重复空格推理服务里为了省事直接用了原始字符串。结果同一个样本训练时得分 0.9线上复测只有 0.6。模型对 token 的拼写和格式非常敏感哪怕只差一个空格[CLS] 位置的表示也会跟着变动。后来我把文本清洗逻辑抽成了一个公共函数训练和推理都调用它并专门写了一个单测保证清洗函数对同一输入永远输出同一结果。做这一件小事的收益比调十个超参数都大。5.2 max_length 截断策略失当文档常常远超过 512 token。我曾经图省事把所有文档一股脑截断到前 512 token结果业务里大量文档的有效信息堆在后半段——比如一篇产品说明前面全是版权声明和目录真正讲参数的部分在后面。精排模型根本没看到关键段落分数自然不对。后来处理方式是分段。先把文档分成若干语义块每块单独跟 query 组成对分别取分再对分数做 max-pooling。这个做法的代价是推理次数变多但相关性判断的准确率上了一个台阶。如果你的文档普遍较长强烈建议做分段交互而不是简单截断。5.3 蒸馏场景中的“软标签”温度不匹配用大模型蒸馏小模型做 cross-encoder 时最容易忽略的是软标签的温度设置。教师模型输出的概率分布通常很尖锐如果直接在原温度下取软标签学生模型学到的是“直接贴近这个概率”而不是“理解为什么不同类别之间差距这么大”。把温度调高之后教师输出分布被拉平中间地带的信息才真正暴露给学生。我实际调蒸馏参数时会把温度从 1 逐步升到 5 左右看学生模型在验证集上的表现。温度太低学生什么都学不到温度太高分布太平等于让学生去猜。这个值和数据集本身的难度强相关没有万能参数。5.4 显存失控batch 暴涨的现场有次跑蒸馏训练我把 batch size 从 32 调到 64以为显存只是线性增长。结果 loss 计算那里用了 contrastive 逻辑负样本矩阵一下涨到 64×64 的规模加上梯度传播显存直接爆掉。后来我把负样本计算的梯度断开detach只把它当作打分工具不参与反向传播优化显存才压下来。如果你的显存实在吃紧还有两个实用手段梯度累积gradient accumulation和梯度检查点gradient checkpointing。前者用时间换显存后者在前向传播时丢掉中间激活值、反向传播时重新计算。对于 cross-encoder 这种 12 层左右的模型梯度检查点效果尤其明显。6. 什么时候该用 cross-encoder什么时候不该用6.1 适合的场景cross-encoder 最舒服的位置是 RAG 系统的精排环节。召回阶段把候选从十万级压到百级精排阶段用 cross-encoder 在这百级里挑出最相关的几条既保证了精度又控制了延迟。企业知识库、站内搜索、FAQ 匹配这类任务都典型适用。尤其当你的文档有大量“同义改写”和“隐式相关”场景时cross-encoder 能抓住那些两段文本字面上几乎不重叠、但语义确实关联的样本。这一点是向量召回很难做到的。小规模数据集上做零样本重排也合适。用通用预训练模型直接对候选打分就算不做任何微调排序质量也往往比纯 BM25 好一截很多场景下可以作为快速垫底方案。6.2 不建议场景和替代方案如果文档库非常小比如只有几千条而且查询模式很固定直接上规则匹配或关键词权重就可能解决问题cross-encoder 反而是过度设计。如果全库都要打分比如离线做大规模文档两两去重cross-encoder 的计算量会爆炸。这时候要么用 bi-encoder 做粗筛、用 cross-encoder 只验证候选对要么换用延迟交互类模型——这类模型把双编码器的“提前算好文档表示”和交叉编码器的“细粒度交互”做了折中既能离线建索引又能保留一部分 token 级别的交互。还有一类情况建议谨慎系统延迟非常苛刻比如十毫秒级 SLA。cross-encoder 在 CPU 上处理几十个候选如果不做量化优化延迟很容易超标。我的建议是先压候选数、再用小模型、最后考虑量化实在不行就退回双塔加规则修正。最后分享一点心得把 cross-encoder 真正做成一个线上稳定工作的部件我总结下来就是三件事样本质量大于模型技巧难负样本的正确挖掘往往带来最大的指标提升训练和推理的预处理必须完全一致这是所有分数漂移问题里最廉价也最容易解决的一环别指望单一模型解决所有问题召回、精排、规则兜底各司其职系统才稳定。后面如果再有人跟我讨论重排我一般都会先问一句你的难负样本是怎么挖的如果能脱口而出效果基本都差不到哪去。