ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

OpenClaw跨模态检索器训练全解:双塔结构、对比学习与工程实践

OpenClaw跨模态检索器训练全解:双塔结构、对比学习与工程实践

前两天有位同行问我:OpenClaw 做图搜文,检索器是怎么训练的?我发现不少人对"跨模态检索"的理解还停留在"让模型看懂图片、再写一句描述"——这其实是个根本性的误会。图搜文要的不是生成文字,而是从几十万条候选文本里,把和图片语义最匹配的那一条捞到最前面。它本质上是学一个"图文共享的向量空间",让图片向量和对应文本向量靠得足够近,让不相关的内容拉得足够远。

这篇文章我就用 OpenClaw 项目为例,把跨模态检索器的训练思路、数据准备、损失函数、分阶段管线、评测方法和踩过的坑完整梳理一遍。内容偏实操,适合正在做多模态检索、想做图文互相搜索、或者只是对对比学习感兴趣的读者。我会尽量把每个"为什么这么设计"都讲透,而不是丢一堆公式完事。

1. 检索器不是"看图说话"模型:OpenClaw 真正学的是向量空间

1.1 先想明白:图搜文到底在搜什么

我们先做一个简单的对照。假设产品给用户的入口是"上传一张图片,返回一段匹配的文字描述",后端实际发生的事可以拆成两条路:

  • 如果是生成式方案,模型需要把图片像素解码成句子,一次只输出一个结果,没法快速处理几十万候选。
  • 如果是检索式方案,系统提前把全部文本编码成向量建好索引,用户上传图片后,只需把图片也编码成一个向量,然后去向量索引里做最近邻搜索。

OpenClaw 走的是第二条路。这条路的好处是:文本库再大,也能在几十毫秒内返回结果,而且召回和精排可以分阶段做。第一次召回粗一点没关系,后面可以加重排模型。但代价是——整套系统的上限完全取决于那个"图文共享向量空间"的质量。这就是检索器训练的终极目标。

1.2 双塔结构:图像塔与文本塔

OpenClaw 的检索器核心是两个编码器,经典的双塔结构:

  • 图像塔:把图片处理成固定维度的向量。可以用纯 ViT,也可以用 CNN 骨架后接一个池化层,再经过一个 projection head 映射到共享空间。
  • 文本塔:把句子编码成同样维度的向量。常见的是 BERT 类模型的 [CLS] 输出,再接 projection head。

两个塔的最后一层输出维度保持一致,比如 512 或 1024 维。训练的时候,我们只约束两边输出的向量在余弦距离或点积距离下满足相似度关系。两个塔内部权重各自独立,不需要跨模态交互,这也是它能高效做大规模召回的根本原因。

1.3 训练的本质:让匹配对近、不匹配对远

检索器训练可以抽象成一句话:让匹配图文对在向量空间里的距离很近,让不匹配图文对的距离很远。听起来简单,但难点在于"不匹配对"的选择和"多远算远"的定义。

我见过不少第一次接触这个方向的同学,上来就想着用分类思路做:把"文本类别"当标签,让图片去分类。这在纯文本检索里有一定道理,但在图文检索里行不通——文本是开放域、长尾的,没法穷举成固定类别。分类把问题简化成了"给图片打离散标签",而检索需要的是连续语义空间里的相对距离。所以 OpenClaw 从一开始就放弃分类目标,改用度量学习/对比学习的方式来训练。

2. 训练数据是胜负手:图文对、负样本和难例挖掘

2.1 数据形态:一张图配若干条文本

OpenClaw 最早期的实验数据长这样:每张图片附带多条人工或半自动生成的描述。少的只有一条,多的有十几条。训练时,我们随机抽一条作为正样本,同 batch 里的其他文本都视为负样本。

这里有一个很容易踩的坑:如果某张图有 10 条描述,batch 里恰好出现两张相似图片,它们的描述互相之间其实也算"正相关",但 InfoNCE 阶段会把对方当作负样本去推开。这会产生噪声梯度,严重时会让模型在相似样本之间摇摆。解决办法主要有两个:一是构造 batch 时做同图去重,确保一个 batch 内不出现同图的不同描述;二是给 loss 加一个"同图掩码",把同一图片的文本从负样本集合里拿掉。

2.2 负样本的三种来源:随机负例、batch 内负例、硬负例

负样本决定模型能学到什么样的边界。只给简单负样本,模型学到的是"图片和完全无关文本的区别";给硬负样本,模型才能学到"图片和易混淆文本的细粒度差异"。

按成本从低到高排序:

  • 随机负样本:从整个文本库里随机抽样,实现最简单,但训练后期基本没有梯度。
  • batch 内负样本:利用当前 batch 里的其他文本充当负样本,这也是对比学习最经典的做法。好处是负样本来自当前真实分布,成本几乎为零。
  • 硬负样本:从检索结果里把"相似但不匹配"的文本捞出来,专门构造困难样本。效果最好,但需要额外维护一个难例队列,或定期用当前模型做一轮检索挖掘。

OpenClaw 最终采用的是"batch 内负样本 + 周期性硬负样本挖掘"的组合。batch 内负样本保证训练高效,每训练若干个 epoch 后,跑一次全量检索,把 top-k 里错误结果抽出来,当作额外的负样本喂回去。这个迭代对指标提升非常明显,尤其是在相似场景、相似物体、相似属性上。

2.3 数据清洗比模型结构更影响最终效果

我有一次在实验报告里写过一句很直白的话:"换模型提 1 个点,换数据清洗提 5 个点。"这不是夸张。

OpenClaw 的训练文本里经常出现三类脏数据:

  • 纯标签式文本:比如"猫、树、天空",这种描述没有完整语义。
  • 重复模板:文本库存在大量"这是一张关于某某的图片"这种句式,模型容易偷懒学句式。
  • 图文对应弱:图片是"雨天街道",文本却是"晴天郊外散步",人工标注出错。

清洗流程我们一般分四步:长度过滤、重复文本去重、图文相关性打分过滤、人工抽检。其中相关性打分可以用一个"现成的强模型"做弱监督过滤,比如拿通用图文匹配模型给数据打分,分数过低直接丢弃。这步能显著提高整体样本质量,也能让模型更专注在语义关系上。

2.4 图文对不够怎么办:合成数据与标题扩展

真实图文对永远是稀缺的。OpenClaw 在冷启动阶段用过两种很实用的方法。

第一种是搜关键字组合出合成文本。"雨天 + 红色汽车 + 街道"这种短语可以自由拼接,再配上一套 prompts 模板,比如"在{场景}里,有{物体},天气是{天气}"。合成文本的好处是能快速扩大覆盖范围,坏处是句式单一,所以只用来做预训练,不能用来做最终微调。

第二种是弱标注扩展。从搜索引擎或公开数据集的相邻数据里,把同源文本拿来当弱标签。这会让数据噪声上升,但配合批次内大负样本数量,模型其实能扛住挺大比例的低质正样本。

3. 三种损失函数怎么选:InfoNCE、Triplet 和辅助分类 Loss

3.1 InfoNCE:OpenClaw 默认的选择

InfoNCE 是当前对比学习的事实标准。它的形式是:

L = -log [ exp(sim(q,p+) / τ) / Σ exp(sim(q, pi) / τ) ]

其中 q 是查询向量(图片),p+ 是匹配的文本向量,pi 遍历 batch 内所有正负样本,τ 是温度系数。

从直觉上说,InfoNCE 做的事情是把"正确答案在 softmax 里的占比"最大化。它天然支持一个 query 对多个负样本,而且梯度包含整个 batch 内的对比信息,所以训练非常稳定。

在 OpenClaw 里,sim 用的是余弦相似度,τ 初始设 0.07,batch size 越大,τ 可以适当调小。τ 越小,模型越倾向于拉大所有负样本的间距,容易过拟合;τ 越大,梯度越平缓,训练会变慢。我们在少量数据集上调参时发现,0.05 到 0.1 之间结果差别不大,但超出 0.2 后效果明显退化。

3.2 Triplet Loss 的适用场景和坑

Triplet Loss 是一个 query、一个正例、一个负例,目标是让正例距离小于负例距离加上 margin:

L = max(0, sim(q, p-) - sim(q, p+) + margin)

它和 InfoNCE 的区别在于:Triplet 只要求"正负的距离差大于 margin",要求更低,所以在一个 batch 里能利用的信息量更少。我们做实验的结果是:只用 Triplet 训练 OpenClaw,收敛速度明显慢于 InfoNCE,最终 recall@10 也低 3 到 5 个点。

Triplet 真正好用的场景是在精调阶段:当基础对齐已经做完了,你只需要小步调整某一类难例,Triplet 会更可控,不会像 InfoNCE 那样"牵一发动全身"。另外,如果内存里实在放不下大 batch,Triplet 是最省显存的选择。

3.3 辅助 Loss:图文匹配分类

只用对比 Loss 会出现一个问题:向量空间只在"相对距离"上有意义,没有直接学"是否匹配"的二分类边界。有的实现会给双塔加一个额外头,把特征拼接后过一个 MLP 做图文匹配二分类(ITM),参与联合训练。

这个辅助 Loss 表面上让模型多了一个目标,实际作用是在特征里保留"可判别的匹配信息",对后续精排阶段有好处。OpenClaw 在微调阶段会打开这个分支,预训练阶段反而关掉——预训练数据太脏,硬让模型做严格二分类会引入大量噪声梯度。

3.4 超参组合怎么定

我做了一个小表,记录 OpenClaw 在 50 万图文对上的对比实验:

损失组合温度/超参Recall@5备注
仅 InfoNCEτ=0.070.682基线
InfoNCE + 同图掩码τ=0.070.694消除同图互怼噪声
Triplet onlymargin=0.20.621收敛慢
InfoNCE + ITMτ=0.07, λ=0.20.701精调后效果更好
InfoNCE + 硬负例τ=0.05, 负例=2560.718挖掘难例后

结论是:预训练用纯 InfoNCE 就够了,微调阶段再开 ITM 和硬负例,别一上来就把组合拉满。

4. OpenClaw 三阶段训练管线:预训练、对齐微调、检索精排

4.1 阶段一:大规模弱相关图文对预训练

第一阶段的目标是让双塔形成"大致能对齐"的语义空间,不需要数据集太干净。我们用了几百万到上千万规模的弱相关图文对,来源包括网页图文混合数据、公开数据集等。这一阶段 train batch size 尽量拉大,OpenClaw 在 8 卡 A100 上跑过 batch size 4096。

大 batch 对对比学习至关重要。batch 内负样本数量由 batch size 决定,batch 越大,同一个 query 能见过的负样本越多,模型学到的决策边界越准确。这可以说是整个对比学习方法的"命门"。

预训练阶段的损失只有 InfoNCE,学习率用 Transformer 常用的线性预热 + 余弦衰减。图像塔直接用预训练好的 ViT 权重初始化,文本塔用预训练好的 Bert 权重初始化,这样能明显缩短收敛时间。

4.2 阶段二:小规模精标数据微调

第二阶段的任务是"纠偏"。预训练数据里的图文关系是"弱相关"的,很多描述并不精确。OpenClaw 会人工标注一批高质量的图文对,数量控制在 10 万左右,覆盖高频搜索场景和易混淆场景。

微调时我们通常冻结图像塔和文本塔的前几层,只更新后几层和 projection head。原因很简单:底层特征(轮廓、颜色、基础语义)是通用的,不需要为了检索任务重新学一遍;高层语义对齐才需要按任务调整。

微调阶段可以引入从硬负例挖掘得到的负样本,并开启 ITM 辅助头。学习率要比预训练小一个数量级,否则会出现严重的灾难性遗忘。

4.3 阶段三:重排序与蒸馏

双塔检索器的上限在于它没有做图文 token 级交互。你说它快,它就快在双塔各自独立编码;但"快"的代价是有些需要联合推理的语义关系学不好。比如"左边的猫压住了右边的书"这种空间关系,双塔只看全局向量,很容易把图和描述匹配错。

OpenClaw 的处理方式是给系统加一个重排阶段:

  • 第一路:双塔召回 top 100。
  • 第二路:用一个更重的跨模态编码模型(图文一起进 Transformer)对 top 100 逐对打分。
  • 第三路:把分数融合,输出最终排序。

这里有个非常值得讲的小技巧:重排模型可以用蒸馏方式来训练。先用跨模态大模型在 batch 内给图文对打软标签,然后用这个软标签去训练一个轻量级重排模型。蒸馏能在保持精度的前提下大幅降低线上成本。

4.4 训练时怎么构建 batch 内负样本:代码级示例

最后给一段简化版的 PyTorch 风格代码,展示 OpenClaw 在单卡单 batch 里构造 InfoNCE 损失的方式:

import torch import torch.nn.functional as F def contrastive_loss(image_embeds, text_embeds, temperature=0.07): # image_embeds / text_embeds: (batch_size, embed_dim),已经 L2 归一化过了 # 相似度矩阵:第 i 行第 j 列表示 第 i 张图片和第 j 条文本的余弦相似度 logits = image_embeds @ text_embeds.t() / temperature # (batch_size, batch_size) labels = torch.arange(logits.shape[0]).to(logits.device) # 图片到文本方向 loss_i2t = F.cross_entropy(logits, labels) # 文本到图片方向 loss_t2i = F.cross_entropy(logits.t(), labels) return (loss_i2t + loss_t2i) / 2

这段代码是"对称 InfoNCE",也就是既做图搜文,也做文搜图。很多跨模态检索任务把这两个方向当成两个独立任务对待,但实际用对称损失训练出来的向量空间更均匀,不会偏向某一个模态。

在分布式环境里,这段代码需要改一下:每个 GPU 先用 all_gather 把所有塔的输出汇总到一起,再计算全局的相似度矩阵。否则每个 GPU 各自算 local batch 的 InfoNCE,负样本数量就会被切成若干份,效果明显变差。这是我在训练 OpenClaw 过程中踩过的大坑之一,后面会单独说。

5. 评测指标与 bad case:为什么 Recall@k 会骗人

5.1 标准指标:Recall@k、MRR 和 NDCG

OpenClaw 的离线评测主要看三个指标:

  • Recall@k:对每个 query 取前 k 个结果,看正确结果在不在里面。最常用的有 Recall@1、Recall@5、Recall@10。
  • MRR:正确结果的倒数排名的均值。它比 Recall 更敏感,比如同一个 query 的正确结果从第 3 名移到第 1 名,MRR 会立刻提升。
  • NDCG:考虑结果列表里可能有多个相关项,按位置衰减折算收益。

三个指标都在看,但上线前真正拍板的是 Recall@5 加上人工 bad case 抽检。原因是 Recall 只关心"有没有在 top k",不关心"错误结果有多离谱",而人工 review 能捕捉到很多指标看不出来的问题。

5.2 评估集划分最容易犯的坑:泄漏

做跨模态检索评估,最大的坑是数据集泄漏。我有一次发现 OpenClaw 的 Recall@10 从 0.73 涨到 0.81,特别兴奋,排查之后才发现问题出在评估集里混入了训练集的"近似文本"——文本库里有重复修改过的句子,模型其实是在背文本,不是理解语义。

正确的划分原则是:按图片做 group 划分,同一组语义接近的图片和文本要么全在训练集,要么全在评估集,不能被拆开。另外文本库里的重复片段要去重,或者至少把包含相同高频短语的文本归到 same group,避免泄漏。

5.3 Bad case 三类,每个都能追溯到训练设计

OpenClaw 的 bad case 大致分三型:

第一型是"属性丢失":图上明明是"红色汽车在雪地里",模型找回的是"汽车在雪地里"。原因是训练数据里带颜色属性描述的文本占比不够高,模型没把"红色"这种细粒度属性学到极致。

第二型是"概念混淆":图片上是柯基,检索结果出现柴犬的描述。这通常发生在底层的视觉相似度过强、高层语义区隔不足时。对策是加强硬负例挖掘,专门构造"相似犬种 + 不同品种名"的难例。

第三型是"逻辑关系错误":图片是"一个女孩站在桥上看着夕阳",检索结果变成"一个女孩在桥上看风景",漏掉了"夕阳"。这类问题双塔很难解决,我们是靠重排阶段的跨模态模型来兜底。

建议每个做跨模态检索的人都在训练过程中保留一个固定的 bad case 集,每次实验完看一批。你会发现很多指标涨了但体验变差的情况,靠 bad case 才能定位到真实原因。

5.4 召回池大小会影响指标

还有一点要提醒:评估时的召回池大小(candidate pool size)必须提前定好,并且线上保持一致。OpenClaw 早期在 1 万候选池上测评指标很高,一上线上变成 50 万候选池,Recall@5 掉得非常快。小候选池里前 5 名的竞争压力小,看不出长尾区分能力。正确的做法是评估和线上的召回池规模保持一致,或者在发布前用同等量级的池子做一次全量评测。

6. 训练中的那些坑:精度、批量大小和分布式负样本

6.1 混合精度训练里的梯度消失

OpenClaw 初期用 fp16 混合精度训练,loss 常常在下降一段时间后突然变成 NaN,或者长时间不动。排查下来是两个原因:

  • InfoNCE 里的 exp 在低精度下很容易溢出,特别是相似度接近正负极限时。
  • 温度系数偏小会让 logits 数值变大,fp16 的表示范围更吃力。

解决办法有两个:一是用 bf16 代替 fp16,数值稳定性会好很多;二是把损失函数里的 softmax 改成稳定版本,比如先减掉 logits 的最大值再做 exp。代码层面改动不大,但对训练稳定性影响非常大。

6.2 大 batch 与显存的矛盾

对比学习最理想的情况是 batch 越大越好,但显存是物理约束。OpenClaw 的图像塔和文本塔各自都很大,batch size 拉高后显存直接爆掉。

我们做过两个取舍:

  • 用 gradient checkpointing 换取更大 batch,训练速度慢 15%,但 batch 可以提高一倍。
  • 把投影头的输出维度从 1024 降到 512,这样相似度矩阵的显存占用直接降为原来的四分之一。

大 batch 带来的收益不是线性的,从 256 提到 512 有明显提升,但从 1024 提到 2048 收益就开始放缓。在显存和效率之间找平衡,建议先用 512 起步,再逐步往上试。

6.3 分布式训练时负样本数量被"切碎"的坑

这是 OpenClaw 早期最不值得踩但最容易踩的坑。多卡训练时,如果每个 GPU 只拿自己的 batch 算 InfoNCE,负样本数量就等于本地 batch size,而不是全局 batch size。8 卡每卡 128,全局是 1024,但每卡实际只用了 127 个负样本,白白浪费了一半以上数据。

标准解法是"跨卡 all-gather":每个 step 先把所有卡上的图像向量和文本向量收集起来,组成全局矩阵,再算 loss。我当时用 PyTorch 分布式训练时写了一个小封装,把所有卡上的特征做 all_gather 后再进入对比损失函数,效果立竿见影,负样本数量直接从 127 变成 1023。

6.4 Loss 不降怎么办:从数据、温度、初始化三处下手

如果你第一次训练跨模态检索器,loss 不动是很常见的。我自己的排查顺序是:

  1. 先看 loss 是否在 sanity check 阶段就符合预期。一个快速 sanity check 是只取 16 个样本在小模型上过拟合,如果 loss 能降到接近 0,说明模型和数据管线没问题。
  2. 检查温度系数。τ 太大梯度平缓,先调小到 0.05 附近试试。
  3. 检查投影头输出是否做了 L2 归一化。很多人在这里漏掉归一化,导致余弦相似度和 dot 相似度混用,训练目标不一致。
  4. 最后再看数据质量。如果 batch 里全是低质量图文对,模型很容易学到一个"高相似度区域"而没有任何区分度,这时候要动数据,不要动模型。

写在最后:一些个人体会

做 OpenClaw 这套检索器这一年多,我最大的一个感受是:跨模态检索的训练门槛不在模型结构,而在数据组织和对"距离关系"的理解。很多人一开始执着于换更强的编码器,但同样一批数据,只是把负样本从随机改成硬负例,把温度系数调对,效果提升就远超换一个更大的 backbone。

另一个体会是:图搜文永远不要只盯着离线指标。指标只能告诉你"结果有没有排到前面",不能告诉你"用户为什么觉得这个结果很怪"。我们后期很大一部分精力都花在看 bad case、拆解错误模式、针对性补数据上。这个过程很磨人,但效果稳定且持久。

如果现在让我给刚接触这个方向的人一个建议,我会说:别急着追论文里的新 loss,先用最简单的双塔加 InfoNCE,把你手头数据的负样本结构搞清楚。这一步做扎实了,后面所有优化都会顺利很多。OpenClaw 的下一轮迭代,我们计划把长尾概念和组合语义再深挖一层,到时候再专门写一篇数据侧的经验分享。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进