ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

图像相似度计算与向量检索:商品同款和以图搜图工程实践

图像相似度计算与向量检索:商品同款和以图搜图工程实践

做图像检索这一块,最常被问到的一句话是“给我算一下这两张图有多像”。听着简单,但真落到项目里,这个问题会立刻分化成十几个子问题:是判断两张图是不是同一件商品,还是判断它们风格接近?是给一个0到1的分数,还是给一个排序列表?是交互式的一次性比对,还是百万级图库里的实时检索?我最早接手的一个需求是用户上传一张商品主图,系统要在几百万张历史图片里找出同款和近似款,顺便做重复铺货的识别。当时第一版用了感知哈希加颜色直方图,效果在一个小测试集上看着还行,一上真实数据就崩了——白底图换成场景图直接失效,同款不同角度全部漏召。后来整个方案推倒重来,走深度学习特征加向量检索的路线,前后折腾了小半年,踩的坑足够写一篇长文。这篇就把这套东西从业务定义、选型思路、数据构造、损失函数配置、阈值标定、检索上线到实际翻车现场,完整捋一遍,适合正在做图片查重、商品同款检索、以图搜图、内容去重这类需求的同学参考,也适合已经跑通 Demo 但线上效果不达预期的朋友对照排查。

1. "相似"这个词在业务里的三种含义,先对齐再动手

动手写第一行代码之前,有件事必须先做:把需求方嘴里的“相似”翻译成可计算、可验收的定义。这一步偷懒,后面所有工作都会返工。我在项目里见过太多次这种对话——产品说“找出相似的图片”,工程这边吭哧吭哧训了个模型,上线后产品说“不对啊,我要的是同款,你给我推了一堆同类目但不同款的东西”。这不是模型不行,是定义没对齐。

1.1 像素近似、实例相同、语义相近是三件完全不同的事

把“相似”拆开看,至少有三种层次,对应三种完全不同的技术路线和评价标准。

第一种是像素级近似,也就是两张图在视觉呈现上几乎一样,可能只是压缩、缩放、加了个水印、裁了个边。这类需求本质上是去重,用感知哈希(aHash、dHash、pHash)配合汉明距离就能解决大部分场景,速度快、成本低、不需要 GPU。我个人的经验是,只要业务能接受“允许一定误杀”,感知哈希在纯重复图片上的表现比深度模型还稳,因为它对全局结构极其敏感,而深度特征反而会主动忽略这些细节。

第二种是实例级相同,也就是同一个物体、同一件商品,但拍摄角度、光照、背景、模特姿势全都变了。这才是深度学习真正的主场,也是绝大多数“以图搜图”需求的真实含义。它要求模型学到的是“这是不是同一个东西”,而不是“这两张图看起来像不像”。这两个目标在数学上是有冲突的——同一件商品的两张图,像素差异可能巨大;而两件不同商品,可能因为都是白底正面图而特征极其接近。模型必须被显式地训练出这种区分能力。

第三种是语义级相近,比如“这两张图都是日系小清新风格”“这两张都是猫”。这类需求不需要精确到实例,反而需要模型具备较强的泛化能力和语义抽象能力,通常用在大规模预训练模型上做零样本检索效果最好,不需要自己训练。

三种相似度不能混在一个模型里解决。如果业务同时要“同款”和“同类”,最省事的做法是训一个实例级模型做主召回,再叠一层类目分类模型做过滤,而不是指望一个 embedding 同时表达两个粒度。

我见过一个团队试图用一个模型同时满足“查重复铺货”和“找相似风格推荐”,结果两头都不讨好。查重场景下,模型对背景太敏感,同款换背景就漏;推荐场景下,模型又太死板,换个颜色就判为不同。最后拆成两个模型、两条链路,各自指标都上去了。

1.2 把需求翻译成任务类型:检索、验证、聚类各有各的打法

定义清楚相似度层次之后,第二步是确定任务形态。这个决定了你训练时怎么组织数据、评测时看什么指标、上线时用什么架构。

任务形态典型场景输出形式关键指标
图像检索(Retrieval)以图搜图、同款查找Top-K 排序列表Recall@K、mAP
图像验证(Verification)两图比对、是否同款二分类或相似度分数准确率、AUC、误召率
图像聚类(Clustering)自动归组、去重分桶分组结果纯度、轮廓系数
图像分类式度量商品类目识别类别概率分类准确率

这四种形态在模型层面其实可以共用一套特征提取器,差别在于训练目标和后处理。检索和验证是最常见的两种,而且它们对模型的要求有微妙差异:验证任务关心的是“阈值附近能不能分对”,检索任务关心的是“正样本能不能排进前 K”。如果你的业务是检索,但训练时只优化二分类交叉熵,经常会出现 AUC 很高但 Recall@10 上不去的情况——因为模型把精力花在了容易的样本上,而排序是靠最难的那批样本决定的。

我一般建议:只要业务最终呈现是“给一串结果”,就按检索任务来设计和评测,训练时用度量学习损失,评测时直接算 Recall@K 和 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 张图(比如 P=16、K=4,batch size 64),然后在这个 batch 内部为每个锚点找最难的正样本和最难的负样本。这样不需要额外的采样器,实现简单,效果也不错。代价是 batch 要足够大才有足够的挖掘空间,显存消耗会上去。我一般从 P=8、K=4 开始试,显存够就往上加。

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正负样本对中,易塌缩简单验证任务margin
Triplet类别标签中,依赖采样实例级检索margin、采样策略
ArcFace 类类别标签高类别明确的检索margin、scale
InfoNCE只需正样本对高无类别标注场景温度系数 τ

我自己的选择顺序是:有明确类别标签优先用 ArcFace 类,没有类别标签就用 InfoNCE,三元组损失留着做补充微调或者做难样本的二次训练。

4. 训练数据构造:决定模型上限的那一半

模型和损失函数决定的是下限,数据决定上限。这句话在图片相似度任务上体现得淋漓尽致。我见过同一个模型、同一套超参,只因为数据构造方式不同,Recall@10 差出二十个点。

4.1 正负样本的四种来源与实际可用性

正样本的来源大致有四种,按可靠性从高到低排:

第一是业务主键,比如同一个商品 ID 下的所有图片。这是最可靠的,因为它是业务系统里的强约束。但要注意,有些商品 ID 下挂的图可能根本是不同的东西(运营传错了、供应商乱传),我在实际数据里见过大概 3% 到 5% 的噪声。所以最好再叠一层人工抽检或者自动过滤,比如把同一 ID 下特征差异过大的图剔掉。

第二是人工标注,找标注团队标“这两张图是不是同一个东西”。质量最高,成本也最高,一般只用来做小规模的验证集和测试集,或者用来校准业务主键的噪声率。

第三是数据增强生成,对一张图做随机裁剪、颜色抖动、旋转、模糊,生成它的正样本。这是自监督学习里的标准做法,好处是零成本,坏处是学到的特征可能过度关注这些低层变换的不变性,而对真正的实例差异不敏感。我一般把增强样本作为正样本的一部分(不超过 50%),不能全用。

第四是时间或位置邻近,比如同一家店连续上传的一批图。这个可靠性最差,容易引入错误正样本,我基本不用。

负样本的来源比较简单,随机采样就行,因为随机采到的绝大多数都是真正的负样本。难点在于难负样本。

4.2 难负样本挖掘的具体做法与风险控制

随机负样本对模型来说太容易区分了,训到后期几乎不产生梯度。真正让模型进步的是那些“看起来很像但不是同一个东西”的样本——同类目不同款、同款不同色、白底图和场景图的组合。

挖掘流程一般是这样的:先用一个初步训练的模型把所有底库图片编码,然后对每张图搜索它的 Top-K 近邻,把近邻里标签不同的样本作为候选难负样本,人工抽检一部分或者用规则过滤后加入训练集。

这个流程有个陷阱:如果初步模型本身有偏差,挖出来的难负样本也是有偏的。比如模型对颜色特别敏感,那挖出来的就全是颜色相近但实际不同的样本,训练后模型会对颜色更敏感,形成正反馈。我的做法是训练两到三轮,每一轮用上一轮模型挖难负样本,同时保留一定比例的随机负样本(大概 30%),避免分布过度偏移。

还有一个更隐蔽的问题:假负样本。库里的两张图标注为不同商品,但实际上可能是同一件东西挂了两个 ID(这个在电商数据里极其常见)。这些样本被当作难负样本喂进去,会严重损害模型。过滤办法是用多模型投票或者人工审核,把高置信度的假负样本从负样本池里剔除,或者直接转成正样本。

难负样本不是越多越好。我一般把难负样本控制在总负样本的 50% 左右,剩下的用随机负样本。全用难负样本会导致模型对边界样本过拟合,泛化到新数据上反而变差。

4.3 数据泄漏与评测污染:最隐蔽的翻车点

这是我踩过最惨的一个坑。有一版模型离线 Recall@10 到了 0.92,上线后只有 0.6 出头。排查了两周,最后发现是训练集和测试集有重叠。

具体怎么发生的:我按商品 ID 划分训练集和测试集,看起来没问题。但数据增强阶段,我对训练集图片做了随机裁剪,其中一些裁剪后的图和测试集里的图几乎一样。更糟的是,有些商品被错误地挂了两个 ID,一个在训练集一个在测试集,模型等于在测试集上训过了。

划分数据集的时候有几个硬性检查必须做:

第一,按实体划分,不能按图片划分。同一个商品的所有图片必须全在训练集或全在测试集,绝不能跨集。

第二,去重再划分。用感知哈希先跑一遍全量数据,把汉明距离极小的图归为一组,整组划分到一起。

第三,测试集要覆盖时间维度。如果业务是持续新增数据,测试集最好取最近一段时间的数据,而不是随机抽样。因为随机抽样会让测试集和训练集的分布过于接近,高估模型效果。

第四,建立一套固定的 baseline 和回归测试集。每次模型迭代都在这套固定集上测,避免因为测试集变动导致指标虚高。

我现在的习惯是准备三套集:训练集、验证集(调参用)、测试集(只在最终评估用,绝不参与任何决策)。测试集的标签要严格人工审核过,宁可小一点,比如五千对,也要保证干净。

5. 阈值标定与评测:离线指标怎么对上线上体感

模型训完了,怎么判断它够不够用?怎么看离线指标能不能反映线上效果?这块比训练更容易出问题。

5.1 Recall@K、mAP 与业务指标之间的换算

检索任务的标准指标是 Recall@K 和 mAP,但业务方听不懂这两个词,他们关心的是“前十个结果里有几个是对的”“用户会不会觉得不准”。

先说 Recall@K。对每个查询图,在库中检索出 Top-K 结果,如果真实的正样本出现在 Top-K 里,记一次命中。Recall@K 是所有查询的命中率。这个指标适合评估“能不能找到”,不关心排在第几位。

mAP(平均精度均值)更严格,它考虑了正样本在排序中的位置,排得越靠前分数越高。当库里有多个正样本时,mAP 比 Recall@K 更能反映排序质量。

换算到业务口径,我一般这样对齐:如果业务场景是“给用户展示 Top-10 相似结果”,那就看 Precision@10,也就是前十个里平均有几个是真正相似的,这个数字业务方最好理解。如果业务场景是“判断库里有没有同款”,那就看 Recall@50 配合一个阈值过滤,看最终能不能捞出来。

这里有个经验值供参考:商品同款检索场景,Recall@10 到 0.85 以上,线上体感就基本可用了;Recall@50 到 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 到 50ms
500 万到 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 倍以上,Recall@10 的损失可以控制在 1 个百分点以内。这个代价完全可以接受。

重排的候选数量不是越多越好。取 Top-200 和 Top-500,最终 Recall@10 差别很小,但延迟会差一倍多。我的经验是取 K 的 10 到 20 倍作为重排候选数,比如最终要 Top-10,粗筛取 Top-100 到 Top-200 就够。

6.3 增量更新与模型版本管理

线上系统每天都有新图进来,索引必须支持增量。暴力搜索和 HNSW 天然支持增量插入,IVF 需要往对应的簇里加向量,如果新数据分布变了,簇的划分会逐渐失准,隔一段时间需要重建索引。

重建索引的代价不小,百万级库大概要几十分钟到几小时。我的做法是双索引热切换:后台用全量数据重建新索引,建好后通过原子操作切换线上读写指针,旧索引延迟一段时间再释放。这样切换过程对线上无感。

更麻烦的是模型版本管理。一旦编码器模型更新,所有历史 embedding 都失效,必须全量重编码。这个操作成本很高,我做过的最大一次是全库 800 万张图重编码,用了 6 张卡跑了将近 4 小时。

所以模型迭代要谨慎,不要小版本频繁上线。我的策略是:小改动先在影子链路验证,确认收益足够大(比如 Recall@10 提升 3 个点以上)才触发全量重编码;同时新旧模型并存一段时间,新图用新模型编码,查询时两种模型各查一遍,结果合并,等老图自然淘汰得差不多了再彻底切到新模型。

7. 踩坑记录:几个我真实遇到过的翻车现场

前面讲的偏方法论,这一节讲具体的翻车过程,因为这些东西在文档里查不到。

7.1 离线指标漂亮但线上拉胯:排查链路复盘

那次线上 Recall@10 只有离线的三分之二,我花了大概两周排查。完整链路是这样的:

第一步,先确认线上和离线的输入是否一致。把线上请求的原始图片 dump 下来,用离线脚本重新跑一遍,结果发现离线跑出来的分数和线上不一样。这就定位到编码阶段。

第二步,检查预处理差异。线上服务用的是 OpenCV 读图,离线脚本用的是 PIL。两者对 EXIF 旋转的处理不同,导致一部分竖图在线上被横着编码了。这个 bug 只影响约 8% 的图片,但足以让整体指标掉一大截。

第三步,修完预处理之后,指标还是差。继续查,发现线上索引里混进了一批老模型编码的向量。原因是增量更新时的模型版本号没对齐,有一批新图用的是旧模型编码入库的,和查询向量的空间不一致。

第四步,这两处修完,指标基本对齐了,但还差 2 个点。最后发现是评测集口径不同——线上统计 Recall@10 时,把用户重复点击的查询也计入分母,而离线是去重后的。这个属于统计口径问题,不是模型问题。

这类问题排查有个通用方法:逐层对齐。把线上链路拆成预处理、编码、检索、后处理四段,每一段都 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, P=16, K=4): 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, margin=0.3): # emb 已做 L2 归一化 dist = torch.cdist(emb, emb, p=2) # [B, B] same = labels.unsqueeze(0) == labels.unsqueeze(1) eye = torch.eye(len(labels), dtype=torch.bool, device=emb.device) pos_mask = same & ~eye neg_mask = ~same # 最难正样本:同类里距离最大的 pos_dist = dist.masked_fill(~pos_mask, -1e4).max(dim=1).values # 最难负样本:异类里距离最小的 neg_dist = dist.masked_fill(~neg_mask, 1e4).min(dim=1).values loss = F.relu(pos_dist - neg_dist + margin) # 过滤掉没有有效正样本的锚点 valid = pos_mask.any(dim=1) 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_ivf=True): 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, topk=20): 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,记录每个取值下的 Recall@10,画成曲线,选一个召回已经饱和但延迟还acceptable 的点。这个曲线一般会有一个明显的拐点,拐点之后再加 nprobe 收益很小。

最后提一句工程上的收尾:这套链路要有完整的监控。至少监控四个东西——查询 QPS 和延迟分位数、Top-1 相似度分数的分布、索引的增量写入量、编码服务的心跳。分数分布的监控尤其重要,它是模型效果漂移的第一预警信号,一旦中位数或分位数出现明显偏移,往往意味着线上数据分布变了,或者某个预处理环节出了 bug。我在实际项目里就是靠这条监控提前发现了图片格式变更导致的编码异常,比用户投诉早了两天。

RELATED READING

延伸阅读

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