鞋服以图搜款工程实践:SPU/SKU对齐与HNSW重排序 简介「阿迪达斯」是一份基于 TypeScript 与 React 脚手架的前端项目源码包面向正在学习前端工程化、希望深入理解 create-react-app 构建流程与配置细节的开发者。项目自带完整的 npm 脚本体系覆盖开发模式启动、交互式测试、生产构建与配置弹出等环节可帮助读者快速跑通一个可运行、可调试、可打包的 React 应用。压缩包共 1080 个文件以 622 个 js 脚本、151 个 md 文档、108 个 json 配置、28 个 ts 与 14 个 tsx 源码为主另含 scss 样式、yml 流程配置和 license 授权文本整体约 4.79MB目录层次清晰便于按模块检索工程配置与依赖组织。目前已有 336 人学习下载。对于想弄清 Babel、webpack、ESLint 如何协同工作的读者包内提供了可直接对照的配置文件和依赖清单也可作为二次开发或搭建同类脚手架时的参考底本。1. 鞋服以图搜款真正难的不是模型是“同款”这两个字做过通用图像检索的人第一次接手鞋服类目往往会低估难度。跑鞋的主图、模特图、鞋底图、细节图放在一起同一个款换个视角像素差异就很大而两个只差一代的跑鞋轮廓、配色、鞋面织纹几乎一模一样。业务方要的是“这双鞋属于哪个款、哪个配色”用户传上来的却常常是一张模糊实拍、半只脚、还带着地板。鞋服类目里的以图搜款本质是把视觉相似度对齐到 SPU、SKU 的编号体系上模型只是其中一环数据口径、属性结构、向量索引和重排序缺一不可。下面把这套在运动鞋服类目上跑通过的做法拆开讲从数据底座一路讲到线上排查。2. 数据底座把“款-色-码”和图片分桶先对齐2.1 鞋服 SPU/SKU 的四层结构怎么定做以图搜款最容易被跳过、又最容易返工的一步是确定检索结果落到哪个粒度。运动鞋服的业务结构天然是四层类目跑鞋、板鞋、卫衣、紧身裤→ 款SPU同一设计同一代→ 配色SKU同款不同色→ 尺码码。检索粒度定错后面所有指标都是错的。我的经验是召回做到 SKU 层展示做到 SPU 层。原因是用户拍一张图上来最想确认的是“这是不是那双白红配色的”颜色是决策变量但详情页只能落到 SPUSKU 是页面内的选项。如果一开始就把向量库建成 SPU 粒度同款不同色会被强行合成一个点颜色区分能力直接损失掉反过来只做 SKU 粒度又会导致同一个款在结果列表里刷屏前十条全是同款换色。落到表结构上至少要有这么几个字段字段类型说明是否参与检索过滤spu_idstring款编号同设计同代共用展示去重用sku_idstring款配色检索主键是category_l1/l2string鞋/服 → 跑步/篮球/训练是强过滤color_familystring归一后的色系如 黑/白/红是兜底过滤img_roleenummain / side / sole / detail / model / scene是索引分层seasonstring上市季节否辅助排序series_idstring款族跨代归组是评估集划分用series_id这个字段看起来跟检索无关实际上是后面做评估集划分和难负样本挖掘的关键很多团队栽在没存它等到要评估时只能靠人工重标。2.2 用 pHash 做图像去重与近重复清洗采集进来的图第一件事是去重。同一张官方主图会被不同渠道反复搬运尺寸、水印、压缩质量各不相同如果不去重向量库里同一个点会有几十份副本HNSW 的邻居图会被这些副本占满检索结果前二十条全是同款同角度用户体验极差。常见做法是感知哈希做粗筛再在桶内做精判。pHash 对缩放、亮度、轻微裁剪不敏感正好对应“同图不同渠道”的场景。import json from pathlib import Path import imagehash from PIL import Image def build_phash_buckets(img_root, hash_size16): 遍历目录给每张图算 pHash按 spu_id 分桶返回。 hash_size16 - 256 bit 指纹比默认 8 更抗误判。 buckets {} for p in Path(img_root).rglob(*.jpg): # 目录约定: root/spu_id/sku_id/img_role_xxx.jpg spu_id p.parts[-3] try: with Image.open(p) as im: h imagehash.phash(im.convert(RGB), hash_sizehash_size) except Exception as e: print(f[skip] {p} - {e}) continue buckets.setdefault(spu_id, []).append({path: str(p), hash: str(h)}) return buckets def dedup_within_bucket(buckets, dist_thresh8): 只在同一个 spu_id 内比对把 O(N^2) 降到可接受量级。 dist_thresh8 (256bit) 偏保守宁可漏合并也不要误合并。 kept, dropped [], [] for spu_id, items in buckets.items(): seen [] for it in items: h imagehash.hex_to_hash(it[hash]) dup next((s for s in seen if h - s[h] dist_thresh), None) if dup: dropped.append({path: it[path], dup_of: dup[path]}) else: seen.append({h: h, path: it[path]}) kept.append(it) return kept, droppedhash_size从默认的 8 提到 16是把指纹从 64 bit 拉到 256 bit。8 位的时候纯色背景的鞋类图特别容易撞桶一双白鞋和另一双白鞋的哈希距离可能只有 10 出头阈值稍一放松就误删。改成 16 位后汉明距离的判别力会明显好一些代价是计算量上涨但对百万级图库完全可接受。dist_thresh的取法没什么玄学拿 200 对人工标注的“同图/不同图”跑一遍画个距离分布取两条分布曲线的交叉点偏保守一侧即可。鞋服类目我一般落在 610 之间服装平铺图更宽松鞋子因为背景干净反而可以严一点。去重之后还有一步是同款多视角归组同一个 SKU 下的 main、side、sole、detail 要能互相找到。这一步靠img_role字段和文件名约定完成不要指望聚类聚类在鞋类上经常把左脚和右脚分成两簇。2.3 抠图、去模特、统一尺寸背景归一的三条约束背景是以图搜款里最典型的域偏移来源。官方主图是纯白棚拍用户 query 是地板、地毯、水泥地、床单。不做背景归一模型学到的一半是背景纹理。常规流程是抠图、裁 bbox、按长边做等比缩放并补边到正方形。三条约束值得写死第一抠图不能当唯一输入。抠图会损失材质信息半透明鞋面、白色鞋带、网眼织物的 alpha 常常被吃掉一半。稳妥做法是双路抠图后的图抽一路特征原图缩放到同尺寸再抽一路两路特征做加权拼接或分别入库。第二pad_ratio 必须留余量。bbox 紧贴边缘时后续 resize 会把鞋头、鞋跟切掉几个像素换成 224 或 518 输入后这几像素会被放大成明显形变。我一般四周留 6%10% 的白边。第三统一到模型的原生输入尺寸不要为了省显存强行缩小。ViT 类骨干对 patch 网格敏感518 训的模型喂 224 会掉好几个点。from PIL import Image import numpy as np from rembg import remove, new_session SESSION new_session(isnet-general-use) # 对细结构比 u2net 稳一些 def normalize_one(src_path, dst_path, size518, pad_ratio0.08): img Image.open(src_path).convert(RGB) cut remove(img, sessionSESSION, alpha_mattingFalse) alpha np.array(cut.split()[-1]) mask alpha 32 # 阈值放宽保住半透明鞋面 ys, xs np.where(mask) if len(xs) 0: cut img # 抠图失败就退回原图别丢样本 box (0, 0, img.width, img.height) else: box (xs.min(), ys.min(), xs.max(), ys.max()) w, h box[2] - box[0], box[3] - box[1] pad int(max(w, h) * pad_ratio) side max(w, h) 2 * pad canvas Image.new(RGB, (side, side), (255, 255, 255)) canvas.paste(cut.crop(box), ((side - w) // 2, (side - h) // 2)) canvas.resize((size, size), Image.BICUBIC).save(dst_path, quality95)alpha 32这个阈值是血泪经验默认的 0 阈值下白色鞋带和浅色网面经常整块被判成背景抠完只剩鞋底模型拿到的输入本身就残缺。调高到 32 左右相当于“只要有一点点前景概率就保留”代价是边缘会带一圈淡淡的地板色但这圈噪声对特征的影响远小于丢掉鞋带。new_session建议在进程启动时只初始化一次别放进循环里。抠图是整条流水线最慢的一环单张 CPU 大约几百毫秒百万级图库要上 GPU batching否则数据准备就能拖两周。3. 特征提取骨干网选型与向量入库的最小闭环3.1 鞋类更依赖局部纹理服装更依赖整体廓形选骨干网之前先想清楚类目的视觉特性。运动鞋的判别信息高度集中在局部鞋侧的品牌条、鞋底纹路、鞋面织法、鞋带孔排布这些在整图上占比很小。服装相反版型、长度比例、肩线位置这些全局信息更有区分度局部纹理反而容易受印花干扰。这就导致同一个骨干在两个类目上的表现不一样选型要分开看骨干维度鞋类 Recall10服装 Recall10单图推理(518)备注ResNet50 (IN1k)2048基线基线最快全局池化丢局部鞋类吃亏CLIP ViT-B/32512中较好快语义强、细粒度弱patch 太大DINOv2 ViT-S/14384较好较好中patch 14 分辨率高自监督纹理表达好DINOv2 ViT-B/14768最好最好慢显存和索引成本翻倍如果只能选一个我一般从 DINOv2 ViT-S/14 起步它的 patch size 是 14518 输入下能得到 37×37 的 patch token 网格做后面的切片重排序时天然有素材。CLIP 的 patch 32 在 224 输入下只有 7×7局部信息太粗鞋带孔这种级别的细节基本没了。这一步不需要微调。鞋服类目的 SKU 更新极快一周上新几百个款微调模型永远追不上数据分布的变化冷启动阶段直接拿自监督特征做零样本检索先跑通链路再谈优化。3.2 用 timm 批量抽特征并落成 parquet npy特征落盘的关键是可复现模型名、输入尺寸、预处理参数、归一化方式必须和向量一起存下来否则换个人接手重跑一遍特征空间对不上索引直接作废。import numpy as np, pandas as pd, timm, torch from pathlib import Path from PIL import Image MODEL_NAME vit_small_patch14_dinov2.lvd142m device cuda if torch.cuda.is_available() else cpu model timm.create_model(MODEL_NAME, pretrainedTrue, num_classes0) model.eval().to(device) cfg timm.data.resolve_data_config(model.pretrained_cfg) transform timm.data.create_transform(**cfg, is_trainingFalse) def extract(paths, batch_size64): feats, metas [], [] for i in range(0, len(paths), batch_size): chunk paths[i:i batch_size] tensors [] for p in chunk: try: tensors.append(transform(Image.open(p).convert(RGB))) except Exception: tensors.append(torch.zeros(3, cfg[input_size][1], cfg[input_size][2])) x torch.stack(tensors).to(device) with torch.no_grad(), torch.autocast(cuda, dtypetorch.float16): f model(x) # L2 归一化之后内积等价于余弦相似度 f torch.nn.functional.normalize(f.float(), dim-1) feats.append(f.cpu().numpy()) metas.extend(chunk) return np.concatenate(feats), metas feat, meta extract([str(p) for p in Path(data/norm).rglob(*.jpg)]) np.save(index/feat_dinov2s_518.npy, feat.astype(float32)) pd.DataFrame({path: meta}).to_parquet(index/meta.parquet)几个参数值的说明。num_classes0让模型输出池化后的 embedding 而不是分类 logits这是 timm 的约定。autocast用 fp16 推理ViT-S 在 518 输入下显存占用从约 2.4G 降到 1.2G 左右单卡 batch 64 够用如果换 ViT-Bbatch 要降到 24 以下。torch.nn.functional.normalize这一步不能省。后面用 Faiss 的内积索引只有向量在单位球面上内积才等于余弦相似度忘了归一化检索结果会被模长大的向量主导表现为“总是返回某几类图”。meta.parquet和feat.npy的顺序必须严格一致建议在 parquet 里再存一列row_idx读的时候 assert 一下df.row_idx.max() feat.shape[0] - 1这个 assert 能省掉后面无数次对不上的排查。3.3 Faiss HNSW 建索引M、efConstruction、efSearch 怎么定向量规模在百万级以内时HNSW 基本是默认选择召回高、延迟低、支持增量添加。三个参数各有分工别混着调参数作用阶段影响起始值调整方向M建索引每个节点的邻居数控制图连通性32召回不够先加到 48内存线性涨efConstruction建索引建图时的搜索宽度控制索引质量200加到 400 通常再涨 1~2 个点建库时间翻倍efSearch查询查询时的候选宽度控制精度/延迟64先调这个加倍的代价是延迟线性涨import faiss, numpy as np d feat.shape[1] # DINOv2 ViT-S - 384 index faiss.IndexHNSWFlat(d, 32, faiss.METRIC_INNER_PRODUCT) index.hnsw.efConstruction 200 index.add(feat) # feat 已 L2 归一化 index.hnsw.efSearch 64 D, I index.search(query_feat, 50) # 取 50 供重排序用别直接取 10METRIC_INNER_PRODUCT和归一化向量配套使用如果误用METRIC_L2排序结果理论上等价单位向量上 L2 距离与余弦单调对应但数值精度会差一点且在后续融合打分时容易搞混量纲。检索的时候k取重排序需求的两到三倍。比如前端展示 20 条就问 50 条因为后面颜色过滤会砍掉一批同款不同色的结果切片重排序又会重排留足冗余。这个冗余度的教训很直接早期直接k20颜色过滤一开返回条数掉到 7、8 条前端页面空一半。还有一个常被忽略的点是分桶。鞋和服的向量分布差异很大混在一个 HNSW 图里查询鞋的时候邻居里混着大量服装节点图的有效连通性下降。常规做法是按category_l1建多个小索引查询时按预分类路由或者建索引前先用类目做一次粗过滤。4. 检索质量颜色正交特征、多视角融合与切片重排序4.1 同款不同色是最强干扰项Lab 直方图做二次过滤所有干扰项里同款不同色的杀伤力最大。同一双跑鞋换个配色几何结构完全一致深度特征在语义空间的余弦相似度经常到 0.95 以上比“不同款但同色系”高得多。用户拍一张红色款返回一堆同款的黑白配色体验直接崩。纯靠深度特征解决不了这个问题因为颜色在自监督训练里被当成了一种不影响语义的增强手段。可行的办法是把颜色当成一个正交特征单独提用一个轻量的颜色直方图做二次打分。import cv2, numpy as np def lab_hist(bgr, bins(8, 8, 8)): OpenCV 的 Lab 通道范围是 0-255 编码不用做偏移。 lab cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB) hist cv2.calcHist([lab], [0, 1, 2], None, bins, [0, 256, 0, 256, 0, 256]) hist cv2.normalize(hist, hist).flatten() return hist.astype(float32) def fuse_score(cos_feat, cos_color, w_color0.25): 颜色权重别超过 0.35否则会压过结构信息搜出鞋型完全不对的结果。 return cos_feat w_color * cos_colorbins(8,8,8)是 512 维对鞋服够用。调太细比如 16³会变敏感同款不同批次拍摄的色差就能把分数拉开调太粗4³又区分不了黑和深灰。w_color的取值要按类目分。鞋类结构稳定、颜色区分明确0.250.3 比较合适服装里印花款颜色占比大但版型更关键一般压到 0.15 左右。这个权重没什么理论最优值拿线上检索日志做人工抽检盯着“同色命中率”这一个指标调比调综合指标更直接。还有个细节颜色直方图要在抠图后的图上算不要用原图。原图带地板、带模特衣服会把直方图污染得很厉害一张白鞋放在木地板上颜色特征看起来像棕色的。4.2 主图、鞋底、细节图的三视角加权融合同一个 SKU 往往有多张图主图、侧面、鞋底、细节、模特上身。这些视角的检索价值不一样主图最通用鞋底辨识度极高但用户很少拍鞋底细节图鞋面织法、鞋带结构对同款不同代的区分最有用。最简单的融合是加权平均后再归一化import numpy as np ROLE_WEIGHT {main: 0.50, side: 0.25, sole: 0.10, detail: 0.15} def fuse_views(view_feats: dict): view_feats: {role: (d,) 已归一化的特征} acc, total np.zeros_like(next(iter(view_feats.values()))), 0.0 for role, f in view_feats.items(): w ROLE_WEIGHT.get(role, 0.05) acc w * f total w acc / max(total, 1e-8) return acc / np.linalg.norm(acc)权重我给的是经验值主图 0.5 打底保证通用性细节图给 0.15 是因为它在“同系列不同代”这种最难区分的情况下贡献最大。如果只做主图融合跨代检索的 Top1 准确率会明显掉。这里有个反直觉的结论把所有视角拼成一个长向量比如 4×3841536 维再去建索引效果反而不如加权平均。原因是拼接后不同视角占据了不同的子空间余弦相似度会被“哪个视角更匹配”这件事主导而用户 query 通常只有一张图、对应不了具体视角。加权平均相当于把所有视角压到一个公共空间里鲁棒性更好。如果某个 SKU 只有主图怎么办按 total 归一化就行上面的代码已经处理了。这种情况在长尾款和上新初期特别多别让缺失视角把特征变成零向量。4.3 切片式 late interaction 重排序精排阶段可以用上更贵的手段因为只对 Top-50 做。切片式 late interaction 的思路是把图切成网格块每块单独提特征然后用 patch 之间的最大相似度做匹配对局部形变和视角变化更宽容。DINOv2 直接输出的 patch token 就是现成的素材不用再切图重跑import torch, torch.nn.functional as F torch.no_grad() def patch_tokens(model, x, grid24): 取 patch token 并做 L2 归一化。ViT-S/14 518 - 37x37。 t model.forward_features(x) # (B, 1N, D)第 0 位是 cls t t[:, 1:, :] t F.normalize(t.float(), dim-1) t F.adaptive_avg_pool1d(t.transpose(1, 2), grid * grid).transpose(1, 2) return F.normalize(t, dim-1) def late_score(q_tok, c_tok): 对每个 query patch 取候选中最大相似度再求均值。 sim q_tok c_tok.T # (Nq, Nc) return sim.max(dim-1).values.mean().item()grid24是把 patch 网格池化到 24×24576 个块对应原来 37×37 的 1369 个降一半左右计算量可控且保留足够局部性。重排序的最终分数用0.7 * 全局余弦 0.3 * late_score混合纯用 late_score 会因为局部偶然匹配而把完全不相关的图排上来。这套重排序对“同款不同代”的区分有效因为换代会改变鞋侧线条和鞋底纹路的走向patch 级匹配能抓住这些差异。但它对模糊 query 不友好——用户拍糊了patch 全是噪声最大相似度会被随机匹配拉高。稳妥做法是加个质量门控用 query 的拉普拉斯方差判断清晰度低于阈值的直接跳过重排序只用全局特征。5. 避坑排查离线 Recall10 到线上体验之间的五道坎5.1 离线指标漂亮线上全是同系列不同代现象离线评估 Recall10 做到 0.9 以上上线后人工抽检发现前十条里平均有 4 条是同系列的其他代用户实际想要的款排在第 7、8 位。原因评估集按 SKU 随机划分同一个款族的不同代被分到了 query 侧和 gallery 侧模型见过同族样本属于数据泄漏。加上“同系列不同代”的视觉差异本来就小在无泄漏的划分下难度会高得多。解决按series_id做分组划分保证同一个款族只出现在一侧。同时专门构造一批跨代负样本对同系列相邻两代单独统计这一子集的 Top1 准确率把它当核心指标盯而不是看整体 Recall。5.2 搜鞋返回整身模特图现象一张鞋子的实拍图检索结果前几条是模特全身穿搭图鞋在画面里只占很小一块。原因索引里混入了img_rolemodel和img_rolescene的图这些图的全局特征描述的是“人穿着衣服站在街上”跟鞋子 query 的相似度靠的是背景和色调而不是鞋本身。解决img_role字段在入库时就分好检索时按角色分层。鞋类目默认只搜 main/side/sole/detail模特图单独一个索引只在“搭配推荐”这个场景下启用。另外对scene类图做强制抠图后再提特征把背景信息去掉。5.3 抠图把白色鞋带和网面吃掉了现象浅色系鞋款的检索效果系统性偏差白色、米色的款召回率比深色款低十来个点。原因抠图模型对低对比度前景的判定不稳定白色鞋带和白色棚拍背景几乎同色alpha 预测在 0 附近二值化后整块被当成背景裁掉。模型拿到的输入本身就丢了关键结构。解决alpha 阈值从 0 提到 32 左右保住半透明区域同时做双路特征抠图路 原图路各存一份最终分数加权。原图路要先把背景用大色块覆盖掉比如纯灰避免棚拍白背景和白色鞋面混淆这个改动对浅色款的提升最明显。5.4 efSearch 调到 256延迟翻了三倍但召回没动现象为了提召回把efSearch从 64 一路加到 256QPS 从 800 掉到 250离线 Recall 只涨了 0.5 个点线上点击率无变化。原因瓶颈不在图搜索的候选宽度而在前置的类目路由和特征质量。真正应该改的是特征和过滤策略不是搜索宽度。另外M32时图的连通性已经够用继续加efSearch只是在遍历更多已访问过的节点。解决efSearch回到 6496 区间把精力放到三件事上按类目分桶建索引减少跨类目噪声、把颜色特征加进融合打分、上线切片重排序。这三项加起来带来的提升比把 efSearch 拉满大得多。5.5 新配色上市三天了还搜不到现象运营上新后用户拍图搜不到新款只能搜到同款的老配色。手工查索引新 SKU 的向量根本不在里面。原因索引是全量重建的离线批任务一天或一周跑一次新数据落在“已采集但未入库”的空窗期。这个空窗期在上新密集期特别致命鞋服行业一周几百个 SKU 是常态。解决改成“增量 add 定期 merge”的模式。Faiss 的 HNSW 支持直接index.add()追加新 SKU 特征算完立刻入库同时维护一张valid_until软删表下架款在检索后过滤而不是物理删除。每两周做一次全量重建把软删的节点真正清掉防止索引无限膨胀。6. 上线之后用采样回流把召回率一点点抠出来上线只是开始。以图搜款的召回率不是一个能一次调到位的东西它随着品类扩张、拍摄风格变化、用户拍摄习惯变化而持续漂移。真正决定长期效果的是能不能建起一条稳定的评估回流链路。评估集怎么建比模型怎么选更重要。我一般维护三份集合规模query 来源gallery 来源用途黄金集8001200 对人工挑选的真实实拍官方图全量版本对比固定不变漂移集每周 200 对上周线上随机采样官方图全量监控分布偏移难例集300 对同系列跨代、同款异色官方图全量专项优化黄金集要冻结任何时候都不许往里加样本否则历史版本的数字就没法比了。漂移集每周换一批主要看整体指标有没有系统性下滑——鞋服类目在换季前后用户拍摄背景从室内地板变成室外路面特征分布会有明显偏移漂移集能提前两周给出信号。指标上除了常规的 Recall10、mAP20我会单独盯一个同色命中率Top10 里与 query 同color_family的结果占比。这个指标比综合召回更能反映用户的实际感受因为它直接对应“搜出来的配色对不对”。同色命中率掉到 0.7 以下基本就是颜色融合权重出了问题或者新一批官图的抠图质量崩了。线上反馈的采集要克制。点击和加购能当弱标注用但鞋服的点击噪声很大——用户经常点开一个相似款看看然后返回去点真正的目标。稳妥做法是只看“点击后停留超过 8 秒且发生滚动”的样本把它当正例其余当无标注用这种带噪正例做阈值校准。每周抽 200 条线上真实 query 做人工判读标出“该召回的没召回”和“召回了不该召回的”把前者按原因归类是特征问题、过滤问题、还是索引里压根没有这个 SKU。我踩过的坑是早期只盯着模型换不换结果连续三周的人工判读里超过四成的漏召回其实是索引没更新跟模型一点关系都没有。最后说个习惯。每次调整参数前先把当前版本的黄金集结果、漂移集结果、线上指标三个数记进一张表改完再记一行。看起来笨但鞋服类目里很多改动是“这个类目涨、那个类目跌”没有这张表你永远说不清这次改动到底值不值。我自己吃过没有后悔药的亏——有一次把颜色权重从 0.2 调到 0.4鞋类涨了两个点卫衣类掉了一个点当时没记两周后运营反馈卫衣搜款变差花了三天才定位到是那次改动。希望帮到你。本文还有配套的精品资源点击获取