做图像检索这一块最常被问到的一句话是“给我算一下这两张图有多像”。听着简单但真落到项目里这个问题会立刻分化成十几个子问题是判断两张图是不是同一件商品还是判断它们风格接近是给一个0到1的分数还是给一个排序列表是交互式的一次性比对还是百万级图库里的实时检索我最早接手的一个需求是用户上传一张商品主图系统要在几百万张历史图片里找出同款和近似款顺便做重复铺货的识别。当时第一版用了感知哈希加颜色直方图效果在一个小测试集上看着还行一上真实数据就崩了——白底图换成场景图直接失效同款不同角度全部漏召。后来整个方案推倒重来走深度学习特征加向量检索的路线前后折腾了小半年踩的坑足够写一篇长文。这篇就把这套东西从业务定义、选型思路、数据构造、损失函数配置、阈值标定、检索上线到实际翻车现场完整捋一遍适合正在做图片查重、商品同款检索、以图搜图、内容去重这类需求的同学参考也适合已经跑通 Demo 但线上效果不达预期的朋友对照排查。1. 相似这个词在业务里的三种含义先对齐再动手动手写第一行代码之前有件事必须先做把需求方嘴里的“相似”翻译成可计算、可验收的定义。这一步偷懒后面所有工作都会返工。我在项目里见过太多次这种对话——产品说“找出相似的图片”工程这边吭哧吭哧训了个模型上线后产品说“不对啊我要的是同款你给我推了一堆同类目但不同款的东西”。这不是模型不行是定义没对齐。1.1 像素近似、实例相同、语义相近是三件完全不同的事把“相似”拆开看至少有三种层次对应三种完全不同的技术路线和评价标准。第一种是像素级近似也就是两张图在视觉呈现上几乎一样可能只是压缩、缩放、加了个水印、裁了个边。这类需求本质上是去重用感知哈希aHash、dHash、pHash配合汉明距离就能解决大部分场景速度快、成本低、不需要 GPU。我个人的经验是只要业务能接受“允许一定误杀”感知哈希在纯重复图片上的表现比深度模型还稳因为它对全局结构极其敏感而深度特征反而会主动忽略这些细节。第二种是实例级相同也就是同一个物体、同一件商品但拍摄角度、光照、背景、模特姿势全都变了。这才是深度学习真正的主场也是绝大多数“以图搜图”需求的真实含义。它要求模型学到的是“这是不是同一个东西”而不是“这两张图看起来像不像”。这两个目标在数学上是有冲突的——同一件商品的两张图像素差异可能巨大而两件不同商品可能因为都是白底正面图而特征极其接近。模型必须被显式地训练出这种区分能力。第三种是语义级相近比如“这两张图都是日系小清新风格”“这两张都是猫”。这类需求不需要精确到实例反而需要模型具备较强的泛化能力和语义抽象能力通常用在大规模预训练模型上做零样本检索效果最好不需要自己训练。三种相似度不能混在一个模型里解决。如果业务同时要“同款”和“同类”最省事的做法是训一个实例级模型做主召回再叠一层类目分类模型做过滤而不是指望一个 embedding 同时表达两个粒度。我见过一个团队试图用一个模型同时满足“查重复铺货”和“找相似风格推荐”结果两头都不讨好。查重场景下模型对背景太敏感同款换背景就漏推荐场景下模型又太死板换个颜色就判为不同。最后拆成两个模型、两条链路各自指标都上去了。1.2 把需求翻译成任务类型检索、验证、聚类各有各的打法定义清楚相似度层次之后第二步是确定任务形态。这个决定了你训练时怎么组织数据、评测时看什么指标、上线时用什么架构。任务形态典型场景输出形式关键指标图像检索Retrieval以图搜图、同款查找Top-K 排序列表RecallK、mAP图像验证Verification两图比对、是否同款二分类或相似度分数准确率、AUC、误召率图像聚类Clustering自动归组、去重分桶分组结果纯度、轮廓系数图像分类式度量商品类目识别类别概率分类准确率这四种形态在模型层面其实可以共用一套特征提取器差别在于训练目标和后处理。检索和验证是最常见的两种而且它们对模型的要求有微妙差异验证任务关心的是“阈值附近能不能分对”检索任务关心的是“正样本能不能排进前 K”。如果你的业务是检索但训练时只优化二分类交叉熵经常会出现 AUC 很高但 Recall10 上不去的情况——因为模型把精力花在了容易的样本上而排序是靠最难的那批样本决定的。我一般建议只要业务最终呈现是“给一串结果”就按检索任务来设计和评测训练时用度量学习损失评测时直接算 RecallK 和 mAP别用分类准确率自欺欺人。2. 为什么我放弃了端到端相似度回归改走特征加距离这条路第一版深度学习方案我想得挺简单既然要输出相似度分数那就搭个双塔网络两张图各过一个 backbone拼接特征后接几层全连接直接回归一个 0 到 1 的分数用 MSE 或对比损失训练。这个思路在论文里很常见落地却问题一堆。2.1 直接回归相似度分数的三个硬伤第一个硬伤是推理成本不可接受。端到端双塔意味着每次查询都要把查询图和库里的每一张图过一遍网络。百万级图库单次查询就是百万次前向就算用 batch 加速也得上百秒。这个架构在交互式场景下直接判死刑。而“特征加距离”的方案是离线把库里的图全部编码成向量存起来线上只编码查询图然后做向量距离计算百万级库的查询可以压到几十毫秒。第二个硬伤是分数没有可比性。回归出来的分数是个绝对值但它的分布会随着数据分布漂移。今天库里全是白底图模型给的相似度普遍偏高明天进来一批场景图分数整体偏低。你没法用一个固定的阈值去卡。而向量空间里的余弦相似度虽然也有漂移问题但至少它的几何意义清晰可以通过统计分数分布来动态标定阈值也方便做归一化和校准。第三个硬伤是分数不满足三角不等式检索时无法用索引加速。任意两个分数之间没有距离度量的性质就没法用 KD 树、倒排索引、HNSW 这类结构做近似最近邻搜索只能暴力比对。这个限制是架构级的改不了。判断一个相似度方案能不能落地先问一句这个分数能不能支持近似最近邻检索如果不能规模一上去就得推倒重来。所以后来我把架构改成了标准的“离线建库 在线查询”两层结构离线阶段用一个 encoder 把所有底库图片编码成定长向量写进向量索引在线阶段只编码查询图然后在索引里做近似最近邻搜索拿到 Top-K 候选再按需做重排序。这个结构的好处是模型迭代和索引重建解耦编码器升级只需要重跑一遍离线任务线上服务几乎不用改。2.2 Embedding 检索架构的分层设计具体拆开是四层每一层都有独立优化的空间这也是我喜欢这个架构的原因——出问题时能快速定位是哪一层的问题。第一层是预处理包括缩放、裁剪、去白边、方向校正。别小看这一层我遇到过因为 EXIF 方向没处理导致竖图被横着编码的情况特征全乱。还有商品图里大面积留白的直接 resize 会让主体占比过小建议先做主体检测再裁剪主体占比控制在画面 60% 以上效果最好。第二层是编码器也就是深度学习模型部分负责把图片映射成 embedding 向量。这是本文的重点后面几节展开。第三层是向量索引负责高效搜索。常见选择有倒排文件索引IVF、分层可导航小世界图HNSW、乘积量化PQ及其组合。索引选型主要看数据规模、内存预算和延迟要求。第四层是后处理与重排包括阈值过滤、类目约束、同店去重、业务规则打分等。这一层经常被忽略但实际业务里往往靠它把准确率从 80% 拉到 90% 以上。2.3 骨干网络与预训练权重的选择依据编码器选型这块我给一个比较务实的判断顺序先看数据量再看延迟预算最后才看精度上限。如果自有标注数据只有几千到几万对别想着从零训一定要用预训练权重。用 ImageNet 预训练的 ResNet50 做初始化比随机初始化的收敛速度快一个数量级而且在小数据集上泛化好得多。如果数据量在十万对以上可以考虑更深的骨干或者 ViT 系列。如果是纯语义相似不是实例级大规模图文预训练模型直接拿来做零样本特征效果往往比你自己训的还好因为它的语义空间已经被对齐过了。延迟预算这块要算清楚。ResNet50 在单张 GPU 上处理 224×224 输入batch 32 的情况下大概几毫秒一张编码百万张图在单卡上大概是小时级别可以接受。但如果换成 ViT-L 这种量级编码时间会翻十几倍离线建库成本就上去了。线上查询侧的延迟反而没那么紧张因为一次只编码一张图。至于 embedding 维度我一般用 512 或 768。维度不是越高越好512 维在大多数实例级检索任务上已经够用再往上收益递减明显反而增加索引内存和查询耗时。有个粗略的经验百万级库512 维 float32 向量占 2GB 内存如果维度翻倍到 1024内存也翻倍加上索引开销很容易把单机内存吃满。3. 度量学习的三条路线与损失函数配置模型结构定了之后真正决定效果的是损失函数。这块是图片相似度计算的核心也是最容易配错的地方。我按实际使用频率从高到低说三条路线。3.1 孪生网络与对比损失最简单但最容易塌缩孪生网络Siamese的思路最直观两个共享权重的分支各处理一张图得到两个 embedding然后计算它们的距离用对比损失Contrastive Loss拉近正样本对、推远负样本对。对比损失的标准形式是正样本对的距离直接最小化负样本对的距离在超过 margin 后不再产生梯度。写成公式大致是L y * d² (1-y) * max(0, m - d)²其中 y 为 1 表示正样本对d 是欧氏距离m 是 margin。这条路线的优点是实现简单、训练稳定缺点是对数据配比极其敏感而且容易塌缩。所谓塌缩就是模型发现把所有图片都映射到同一个点附近可以让损失很小——因为正样本对的距离确实变近了而负样本对只要 margin 设得不够大推远的梯度就消失了。我最早训的一个模型就塌缩了所有 embedding 两两余弦相似度都在 0.95 以上检索时结果是随机的。避免塌缩有几个实用手段第一margin 不要设太小欧氏距离下 margin 取 1.0 到 2.0 比较常见具体要看 embedding 是否归一化第二正负样本比例控制在 1:1 到 1:3 之间负样本太多会让模型只学“推远”不学“拉近”第三监控 embedding 的方差如果所有向量的标准差低于某个阈值基本可以判定塌缩了赶紧停下来调参。3.2 Triplet Loss 的采样与 margin效果上限高但采样是命门三元组损失是我在实例级检索任务上用得最多的。每个样本由锚点anchor、正样本positive、负样本negative组成目标是让锚点到正样本的距离小于锚点到负样本的距离并且至少小一个 margin。公式是L max(0, d(a,p) - d(a,n) m)。这里的 margin 是关键超参。用归一化后的余弦距离时margin 通常取 0.2 到 0.4用未归一化的欧氏距离时margin 取值会大一些。三元组损失的真正难点不在公式在采样。随机采样的三元组里绝大多数满足d(a,p) m d(a,n)损失为 0不产生梯度训练效率极低。真正有用的是所谓“半难”semi-hard三元组负样本比正样本远但距离差距小于 margin。这类样本处在决策边界附近梯度信息最丰富。工程上的做法是批内挖掘batch-hard mining一个 batch 里取 P 个类别、每类 K 张图比如 P16、K4batch size 64然后在这个 batch 内部为每个锚点找最难的正样本和最难的负样本。这样不需要额外的采样器实现简单效果也不错。代价是 batch 要足够大才有足够的挖掘空间显存消耗会上去。我一般从 P8、K4 开始试显存够就往上加。batch-hard 挖掘有个副作用训练早期模型还很差batch 里最难的负样本可能是标注噪声或者极端困难样本会导致训练不稳定。缓解办法是训练前几个 epoch 用全量随机三元组等 loss 降下来再切到 batch-hard。还有一个细节容易被忽略正样本的定义粒度。同一个商品的不同角度算正样本没问题但“同款不同颜色”算不算正样本这个必须在数据构造阶段就定死。我的经验是如果业务上要求“同款不同色也要召回”那就把不同颜色当正样本如果业务要求区分颜色就必须当负样本。模型没有能力自己猜你的意图。3.3 分类式度量学习与 InfoNCE我更推荐的两种做法三元组损失调起来比较费劲我后来更倾向于两种替代方案。第一种是分类式度量学习代表方法是 ArcFace、CosFace 这类加性角度间隔的 softmax 变体。思路很巧妙把每个类别每个商品 ID当成一个分类任务来训但在 softmax 之前对角度加一个 margin 惩罚强迫同类样本在超球面上聚得更紧、类间分得更开。训练完成后丢掉最后的分类层用倒数第二层的输出作为 embedding。这条路线的优点非常明显训练稳定收敛快不需要精心设计三元组采样而且效果通常比三元组损失更好。缺点是对类别数有要求每个类别至少要有几张图类别太少比如只有几十个类时 margin 加不上去。我在商品同款检索任务上用它类别数在几千到几十万这个量级效果都很稳。第二种是InfoNCE 对比损失就是 SimCLR、MoCo 那一套。它把 batch 内其他样本都当作负样本配合温度系数 τ 控制分布的锐利程度。公式大致是L -log(exp(sim(a,p)/τ) / Σ exp(sim(a,n_i)/τ))。温度系数 τ 的经验值在 0.07 到 0.2 之间。τ 越小模型越关注最难的负样本特征分布越锐利但也越容易过拟合噪声τ 越大分布越平滑学到的特征更均匀但区分度下降。我一般从 0.1 开始调数据噪声大的时候往 0.15 到 0.2 走。这条路线的优势是不需要标注类别只需要正样本对非常适合那种“知道哪些图是同一件东西但不知道总共多少个类别”的场景。而且它对 batch size 很敏感batch 越大负样本越多效果越好这也是为什么原论文要用很大的 batch 配合动量编码器。损失函数数据要求训练稳定性适用场景关键超参Contrastive正负样本对中易塌缩简单验证任务marginTriplet类别标签中依赖采样实例级检索margin、采样策略ArcFace 类类别标签高类别明确的检索margin、scaleInfoNCE只需正样本对高无类别标注场景温度系数 τ我自己的选择顺序是有明确类别标签优先用 ArcFace 类没有类别标签就用 InfoNCE三元组损失留着做补充微调或者做难样本的二次训练。4. 训练数据构造决定模型上限的那一半模型和损失函数决定的是下限数据决定上限。这句话在图片相似度任务上体现得淋漓尽致。我见过同一个模型、同一套超参只因为数据构造方式不同Recall10 差出二十个点。4.1 正负样本的四种来源与实际可用性正样本的来源大致有四种按可靠性从高到低排第一是业务主键比如同一个商品 ID 下的所有图片。这是最可靠的因为它是业务系统里的强约束。但要注意有些商品 ID 下挂的图可能根本是不同的东西运营传错了、供应商乱传我在实际数据里见过大概 3% 到 5% 的噪声。所以最好再叠一层人工抽检或者自动过滤比如把同一 ID 下特征差异过大的图剔掉。第二是人工标注找标注团队标“这两张图是不是同一个东西”。质量最高成本也最高一般只用来做小规模的验证集和测试集或者用来校准业务主键的噪声率。第三是数据增强生成对一张图做随机裁剪、颜色抖动、旋转、模糊生成它的正样本。这是自监督学习里的标准做法好处是零成本坏处是学到的特征可能过度关注这些低层变换的不变性而对真正的实例差异不敏感。我一般把增强样本作为正样本的一部分不超过 50%不能全用。第四是时间或位置邻近比如同一家店连续上传的一批图。这个可靠性最差容易引入错误正样本我基本不用。负样本的来源比较简单随机采样就行因为随机采到的绝大多数都是真正的负样本。难点在于难负样本。4.2 难负样本挖掘的具体做法与风险控制随机负样本对模型来说太容易区分了训到后期几乎不产生梯度。真正让模型进步的是那些“看起来很像但不是同一个东西”的样本——同类目不同款、同款不同色、白底图和场景图的组合。挖掘流程一般是这样的先用一个初步训练的模型把所有底库图片编码然后对每张图搜索它的 Top-K 近邻把近邻里标签不同的样本作为候选难负样本人工抽检一部分或者用规则过滤后加入训练集。这个流程有个陷阱如果初步模型本身有偏差挖出来的难负样本也是有偏的。比如模型对颜色特别敏感那挖出来的就全是颜色相近但实际不同的样本训练后模型会对颜色更敏感形成正反馈。我的做法是训练两到三轮每一轮用上一轮模型挖难负样本同时保留一定比例的随机负样本大概 30%避免分布过度偏移。还有一个更隐蔽的问题假负样本。库里的两张图标注为不同商品但实际上可能是同一件东西挂了两个 ID这个在电商数据里极其常见。这些样本被当作难负样本喂进去会严重损害模型。过滤办法是用多模型投票或者人工审核把高置信度的假负样本从负样本池里剔除或者直接转成正样本。难负样本不是越多越好。我一般把难负样本控制在总负样本的 50% 左右剩下的用随机负样本。全用难负样本会导致模型对边界样本过拟合泛化到新数据上反而变差。4.3 数据泄漏与评测污染最隐蔽的翻车点这是我踩过最惨的一个坑。有一版模型离线 Recall10 到了 0.92上线后只有 0.6 出头。排查了两周最后发现是训练集和测试集有重叠。具体怎么发生的我按商品 ID 划分训练集和测试集看起来没问题。但数据增强阶段我对训练集图片做了随机裁剪其中一些裁剪后的图和测试集里的图几乎一样。更糟的是有些商品被错误地挂了两个 ID一个在训练集一个在测试集模型等于在测试集上训过了。划分数据集的时候有几个硬性检查必须做第一按实体划分不能按图片划分。同一个商品的所有图片必须全在训练集或全在测试集绝不能跨集。第二去重再划分。用感知哈希先跑一遍全量数据把汉明距离极小的图归为一组整组划分到一起。第三测试集要覆盖时间维度。如果业务是持续新增数据测试集最好取最近一段时间的数据而不是随机抽样。因为随机抽样会让测试集和训练集的分布过于接近高估模型效果。第四建立一套固定的 baseline 和回归测试集。每次模型迭代都在这套固定集上测避免因为测试集变动导致指标虚高。我现在的习惯是准备三套集训练集、验证集调参用、测试集只在最终评估用绝不参与任何决策。测试集的标签要严格人工审核过宁可小一点比如五千对也要保证干净。5. 阈值标定与评测离线指标怎么对上线上体感模型训完了怎么判断它够不够用怎么看离线指标能不能反映线上效果这块比训练更容易出问题。5.1 RecallK、mAP 与业务指标之间的换算检索任务的标准指标是 RecallK 和 mAP但业务方听不懂这两个词他们关心的是“前十个结果里有几个是对的”“用户会不会觉得不准”。先说 RecallK。对每个查询图在库中检索出 Top-K 结果如果真实的正样本出现在 Top-K 里记一次命中。RecallK 是所有查询的命中率。这个指标适合评估“能不能找到”不关心排在第几位。mAP平均精度均值更严格它考虑了正样本在排序中的位置排得越靠前分数越高。当库里有多个正样本时mAP 比 RecallK 更能反映排序质量。换算到业务口径我一般这样对齐如果业务场景是“给用户展示 Top-10 相似结果”那就看 Precision10也就是前十个里平均有几个是真正相似的这个数字业务方最好理解。如果业务场景是“判断库里有没有同款”那就看 Recall50 配合一个阈值过滤看最终能不能捞出来。这里有个经验值供参考商品同款检索场景Recall10 到 0.85 以上线上体感就基本可用了Recall50 到 0.95 以上配合后处理可以做到不错的体验。低于 0.7 的话用户会明显感觉到“找不到”。5.2 分数分布与阈值选择不要用均值要看双峰如果业务需要输出“是否相似”的二值判断比如查重、判重就必须定一个阈值。这个阈值怎么来正确的做法是统计分数分布而不是简单取均值或中位数。把验证集里所有正样本对的相似度和负样本对的相似度分别算出来画成两条分布曲线你会发现它们基本是两个峰。阈值应该取在两个峰的谷底附近或者更精确地说取在能同时满足业务精度要求的位置。具体操作把候选阈值从 0.5 到 0.99 遍历一遍对每个阈值计算查准率和查全率然后根据业务侧重选点。查重场景通常要求高查准宁可漏不可误杀阈值往高走召回场景要求高查全阈值往低走。有个细节值得说正负样本的分数分布会随数据分布变化。今天库里以白底商品图为主负样本分数普遍在 0.6 到 0.7明天进来一批实拍图负样本分数可能升到 0.75。所以固定阈值是有风险的。我的做法是每隔一段时间比如一周重新采样一批正负样本重算一次分布阈值做动态调整同时对线上分数做监控一旦分布漂移超过预设范围就报警。5.3 余弦相似度与欧氏距离归一化之后是一回事这个细节很多人搞不清楚值得展开说一下。如果我们把 embedding 都做 L2 归一化那么余弦相似度和欧氏距离之间存在严格的对应关系。推导一下设两个归一化向量 a、b它们的欧氏距离平方是||a-b||² ||a||² ||b||² - 2a·b 1 1 - 2a·b 2 - 2a·b。而余弦相似度cos a·b因为已经归一化。所以||a-b||² 2 - 2cos两者完全等价排序结果一模一样。这意味着什么意味着只要做了归一化你用余弦还是用欧氏距离检索结果不会有任何差别阈值也可以互相换算。但如果不做归一化两者就不同了——欧氏距离会受到向量模长的影响而模长本身可能携带了“图片信息量”这类无关信息反而干扰检索。所以我强烈建议embedding 一律做 L2 归一化再入库。这不仅是让度量方式等价更重要的是让向量分布更均匀索引构建更稳定。用 Quadruplet 或者带模长约束的损失训出来的向量可能模长差异很大不归一化的话检索时会出现某些图因为模长大而永远排在前面。6. 向量检索上线索引选型、归一化与增量更新模型产出的 embedding 只是原料真正的检索能力要靠向量索引。这块的工程细节很多选错了索引前面模型训得再好也白搭。6.1 数据规模决定索引类型选索引之前先算清楚三个数底库规模 N、向量维度 D、可用内存 M。规模推荐索引内存估算512维 float32单次查询延迟10 万以内平坦索引暴力搜索约 200MB10ms 以内10 万到 500 万IVF PQ几十到几百 MB量化后10ms 到 50ms500 万到 5000 万HNSW 或 IVF-PQ 组合GB 级20ms 到 100ms亿级以上分片 多级索引需要分布式100ms 级10 万以内我建议直接用暴力搜索也就是把向量全存内存里逐个算距离。这个规模下暴力搜索的延迟完全可接受而且召回率是 100%不需要调任何参数。很多人一上来就上复杂索引结果为了调 nprobe 和 efSearch 折腾好几天得不偿失。超过 10 万就要考虑 IVF 了。IVF 的思路是先对向量做聚类把库分成 nlist 个簇查询时只在最近的 nprobe 个簇里搜索。nlist 的经验值是4 * sqrt(N)左右比如 100 万条数据nlist 取 4000 左右。nprobe 是查询时访问的簇数取值越大召回越高但越慢一般从 nlist 的 1% 开始调也就是 40 左右然后根据召回率曲线调整。如果内存吃紧就在 IVF 基础上加 PQ 量化。PQ 把向量切成 m 段每段单独做 k-means 量化用码本索引替代原始浮点数。512 维的 float32 向量是 2048 字节切成 64 段、每段用 8 bit 表示压缩后只有 64 字节压缩比 32 倍。代价是精度损失召回率会掉几个点需要用重排来补偿。HNSW 是图索引构建慢、内存占用大但查询速度快、召回高适合对延迟敏感的场景。它的关键参数是 M每个节点的连接数一般取 16 到 64和 efConstruction构建时的搜索广度取 100 到 400查询时用 efSearch 控制精度和速度的权衡。6.2 归一化与量化损失的补偿方案前面说 embedding 要归一化这里补充一个量化时的注意点PQ 量化后的向量不再归一化用它算出来的距离和原始距离有偏差。标准做法是先用 PQ 做粗筛取出 Top-100 或 Top-200 候选然后用原始 float32 向量做精确重排。这个重排步骤能把量化带来的精度损失基本补回来代价是多存一份原始向量或者存在磁盘上按需读取。我实测下来的经验是IVF-PQ 粗筛加精确重排相比纯暴力搜索延迟降低 20 倍以上Recall10 的损失可以控制在 1 个百分点以内。这个代价完全可以接受。重排的候选数量不是越多越好。取 Top-200 和 Top-500最终 Recall10 差别很小但延迟会差一倍多。我的经验是取 K 的 10 到 20 倍作为重排候选数比如最终要 Top-10粗筛取 Top-100 到 Top-200 就够。6.3 增量更新与模型版本管理线上系统每天都有新图进来索引必须支持增量。暴力搜索和 HNSW 天然支持增量插入IVF 需要往对应的簇里加向量如果新数据分布变了簇的划分会逐渐失准隔一段时间需要重建索引。重建索引的代价不小百万级库大概要几十分钟到几小时。我的做法是双索引热切换后台用全量数据重建新索引建好后通过原子操作切换线上读写指针旧索引延迟一段时间再释放。这样切换过程对线上无感。更麻烦的是模型版本管理。一旦编码器模型更新所有历史 embedding 都失效必须全量重编码。这个操作成本很高我做过的最大一次是全库 800 万张图重编码用了 6 张卡跑了将近 4 小时。所以模型迭代要谨慎不要小版本频繁上线。我的策略是小改动先在影子链路验证确认收益足够大比如 Recall10 提升 3 个点以上才触发全量重编码同时新旧模型并存一段时间新图用新模型编码查询时两种模型各查一遍结果合并等老图自然淘汰得差不多了再彻底切到新模型。7. 踩坑记录几个我真实遇到过的翻车现场前面讲的偏方法论这一节讲具体的翻车过程因为这些东西在文档里查不到。7.1 离线指标漂亮但线上拉胯排查链路复盘那次线上 Recall10 只有离线的三分之二我花了大概两周排查。完整链路是这样的第一步先确认线上和离线的输入是否一致。把线上请求的原始图片 dump 下来用离线脚本重新跑一遍结果发现离线跑出来的分数和线上不一样。这就定位到编码阶段。第二步检查预处理差异。线上服务用的是 OpenCV 读图离线脚本用的是 PIL。两者对 EXIF 旋转的处理不同导致一部分竖图在线上被横着编码了。这个 bug 只影响约 8% 的图片但足以让整体指标掉一大截。第三步修完预处理之后指标还是差。继续查发现线上索引里混进了一批老模型编码的向量。原因是增量更新时的模型版本号没对齐有一批新图用的是旧模型编码入库的和查询向量的空间不一致。第四步这两处修完指标基本对齐了但还差 2 个点。最后发现是评测集口径不同——线上统计 Recall10 时把用户重复点击的查询也计入分母而离线是去重后的。这个属于统计口径问题不是模型问题。这类问题排查有个通用方法逐层对齐。把线上链路拆成预处理、编码、检索、后处理四段每一段都 dump 中间结果和离线逐段对比第一段对不上的地方就是问题所在。别一上来就怀疑模型模型往往是最稳定的部分。7.2 跨域失效换个拍摄场景模型就瞎了有次模型在 A 类目上表现很好扩到 B 类目之后召回率掉到 0.5。分析发现B 类目全是实景拍摄背景复杂、光照不均而训练集里 90% 是白底图。模型实际上学的是“白底图之间的相似性”一换背景就失效。解决思路有三条我最后是组合使用一是数据层面补足场景图。想尽办法凑了一批实拍图哪怕是弱标注的用同类目同款做近似正样本也比没有强。同时把白底图和场景图的配对作为正样本对加进去强迫模型学到“白底图和实拍图是同一件东西”。二是训练层面加背景扰动增强。对白底图做随机背景替换用一些通用背景图粘贴生成伪场景图。这个做法成本低效果不错但要注意替换后的图不能太假否则模型学到的是“假背景的不变性”没用。三是后处理层面加类目路由。如果业务上能确定查询图属于哪个类目就只在对应类目的子库里检索避免跨类目干扰。这个办法简单粗暴但很有效前提是类目判断本身够准。修完之后 B 类目的召回率回到 0.82虽然还不如 A 类目的 0.89但已经可用了。7.3 长尾、重复图与脏数据被低估的工程债真实数据里的脏东西比想象中多得多。我列几个遇到的真实情况重复图泛滥。同一个商品图被压缩成不同尺寸上传了十几遍感知哈希能识别大部分但有些经过二次编辑的识别不了进了库就是一堆近似重复的向量检索时 Top-10 全是同一个东西的不同版本用户看不到多样性。解决办法是入库前做一次聚类去重把相似度超过 0.98 的归为一组只保留一张代表图或者标记为一组返回。长尾类目数据极少。大牌商品动辄上千张图小众商品可能只有一两张。类别极度不均衡会让模型偏向头部类目。应对办法是对长尾类目做重采样或者用分类式度量学习时给不同类别不同的 margin长尾类目用小 margin。低质图。模糊、过曝、带大面积水印的图片编码出来的特征本身就不可靠。这类图应该在建库时就过滤掉或者打上低质量标记检索时降权。判断方法可以用拉普拉斯方差算清晰度、用亮度直方图判断过曝阈值根据业务调。标注噪声。前面提过业务主键的错误率在 3% 到 5%这个必须靠清洗流程控制。我的做法是定期用当前模型跑一遍全库把同一 ID 下相似度极低的图挑出来人工复核把不同 ID 下相似度极高的图也挑出来复核。这两个方向能覆盖大部分标注错误。8. 一份能跑的最小实现骨架理论讲完给一份可以直接改的骨架。这套代码我简化过去掉了业务特有的部分保留核心逻辑。8.1 数据组织与采样器import torch from torch.utils.data import Dataset, Sampler import random class PairDataset(Dataset): def __init__(self, samples, transform): # samples: [(img_path, label_id), ...] self.samples samples self.transform transform self.label_to_indices {} for idx, (_, lab) in enumerate(samples): self.label_to_indices.setdefault(lab, []).append(idx) def __len__(self): return len(self.samples) def __getitem__(self, idx): path, lab self.samples[idx] img load_image(path) # 自行实现注意 EXIF 方向 return self.transform(img), lab class PKBatchSampler(Sampler): 每个 batch 采 P 个类别每类 K 张保证 batch-hard 挖掘有足够素材 def __init__(self, label_to_indices, P16, K4): self.l2i label_to_indices self.P, self.K P, K self.labels [l for l, ids in l2i.items() if len(ids) 2] def __iter__(self): random.shuffle(self.labels) for i in range(0, len(self.labels) - self.P 1, self.P): batch [] for lab in self.labels[i:i self.P]: ids self.l2i[lab] pick random.sample(ids, min(self.K, len(ids))) while len(pick) self.K: # 不足 K 张时有放回补足 pick.append(random.choice(ids)) batch.extend(pick) yield batch def __len__(self): return len(self.labels) // self.P这段代码有两个容易写错的地方。第一PKBatchSampler里必须过滤掉样本数少于 2 的类别否则正样本凑不出来三元组损失无法计算。第二__len__返回的是 batch 数而不是样本数写错了会导致训练轮次计算错误。8.2 训练循环的关键片段import torch.nn.functional as F def batch_hard_triplet_loss(emb, labels, margin0.3): # emb 已做 L2 归一化 dist torch.cdist(emb, emb, p2) # [B, B] same labels.unsqueeze(0) labels.unsqueeze(1) eye torch.eye(len(labels), dtypetorch.bool, deviceemb.device) pos_mask same ~eye neg_mask ~same # 最难正样本同类里距离最大的 pos_dist dist.masked_fill(~pos_mask, -1e4).max(dim1).values # 最难负样本异类里距离最小的 neg_dist dist.masked_fill(~neg_mask, 1e4).min(dim1).values loss F.relu(pos_dist - neg_dist margin) # 过滤掉没有有效正样本的锚点 valid pos_mask.any(dim1) return loss[valid].mean() if valid.any() else emb.sum() * 0.0训练循环里还有几个细节优化器用 AdamW学习率从 1e-4 开始backbone 用预训练权重时学习率再降一个量级1e-5避免把预训练特征破坏掉。学习率调度用余弦退火warmup 3 到 5 个 epoch。AMP 混合精度能省一半显存对 batch-hard 这种吃显存的策略很有必要。监控指标除了 loss一定要看正负样本对的平均距离差。如果这个差值长期不涨说明模型没在学区分性多半是塌缩或者采样有问题。8.3 建库与查询import faiss import numpy as np def build_index(embeddings, use_ivfTrue): emb embeddings.astype(np.float32) faiss.normalize_L2(emb) # 关键入库前归一化 d emb.shape[1] if not use_ivf or len(emb) 100000: index faiss.IndexFlatIP(d) # 归一化后内积等价余弦 index.add(emb) return index nlist int(4 * np.sqrt(len(emb))) quantizer faiss.IndexFlatIP(d) index faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT) index.train(emb) index.add(emb) index.nprobe max(1, nlist // 100) # 从 1% 起步按召回率调 return index def search(index, query_emb, topk20): q query_emb.astype(np.float32).reshape(1, -1) faiss.normalize_L2(q) scores, ids index.search(q, topk) return scores[0], ids[0]用内积IP而不是 L2 距离是因为归一化之后内积就等于余弦相似度分数范围在 -1 到 1 之间比距离更好解释也方便和业务方沟通阈值。如果坚持用 L2记得把结果按距离升序排别和相似度搞混了正负方向。nprobe的调法准备一个带标注的查询集比如 500 条固定 nprobe 从 1 跑到 nlist记录每个取值下的 Recall10画成曲线选一个召回已经饱和但延迟还acceptable 的点。这个曲线一般会有一个明显的拐点拐点之后再加 nprobe 收益很小。最后提一句工程上的收尾这套链路要有完整的监控。至少监控四个东西——查询 QPS 和延迟分位数、Top-1 相似度分数的分布、索引的增量写入量、编码服务的心跳。分数分布的监控尤其重要它是模型效果漂移的第一预警信号一旦中位数或分位数出现明显偏移往往意味着线上数据分布变了或者某个预处理环节出了 bug。我在实际项目里就是靠这条监控提前发现了图片格式变更导致的编码异常比用户投诉早了两天。
阅读完成 · 觉得有帮助?