简介:「阿迪达斯」是一份基于 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 层。原因是用户拍一张图上来,最想确认的是“这是不是那双白红配色的”,颜色是决策变量;但详情页只能落到 SPU,SKU 是页面内的选项。如果一开始就把向量库建成 SPU 粒度,同款不同色会被强行合成一个点,颜色区分能力直接损失掉;反过来只做 SKU 粒度又会导致同一个款在结果列表里刷屏,前十条全是同款换色。
落到表结构上,至少要有这么几个字段:
| 字段 | 类型 | 说明 | 是否参与检索过滤 |
|---|---|---|---|
| spu_id | string | 款编号,同设计同代共用 | 展示去重用 |
| sku_id | string | 款+配色,检索主键 | 是 |
| category_l1/l2 | string | 鞋/服 → 跑步/篮球/训练 | 是,强过滤 |
| color_family | string | 归一后的色系,如 黑/白/红 | 是,兜底过滤 |
| img_role | enum | main / side / sole / detail / model / scene | 是,索引分层 |
| season | string | 上市季节 | 否,辅助排序 |
| series_id | string | 款族,跨代归组 | 是,评估集划分用 |
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_size=16): """ 遍历目录,给每张图算 pHash,按 spu_id 分桶返回。 hash_size=16 -> 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_size=hash_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_thresh=8): """ 只在同一个 spu_id 内比对,把 O(N^2) 降到可接受量级。 dist_thresh=8 (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 对人工标注的“同图/不同图”跑一遍,画个距离分布,取两条分布曲线的交叉点偏保守一侧即可。鞋服类目我一般落在 6~10 之间,服装平铺图更宽松,鞋子因为背景干净反而可以严一点。
去重之后还有一步是同款多视角归组:同一个 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, size=518, pad_ratio=0.08): img = Image.open(src_path).convert("RGB") cut = remove(img, session=SESSION, alpha_matting=False) 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, quality=95)alpha > 32这个阈值是血泪经验:默认的 0 阈值下,白色鞋带和浅色网面经常整块被判成背景,抠完只剩鞋底,模型拿到的输入本身就残缺。调高到 32 左右相当于“只要有一点点前景概率就保留”,代价是边缘会带一圈淡淡的地板色,但这圈噪声对特征的影响远小于丢掉鞋带。
new_session建议在进程启动时只初始化一次,别放进循环里。抠图是整条流水线最慢的一环,单张 CPU 大约几百毫秒,百万级图库要上 GPU batching,否则数据准备就能拖两周。
3. 特征提取:骨干网选型与向量入库的最小闭环
3.1 鞋类更依赖局部纹理,服装更依赖整体廓形
选骨干网之前先想清楚类目的视觉特性。运动鞋的判别信息高度集中在局部:鞋侧的品牌条、鞋底纹路、鞋面织法、鞋带孔排布,这些在整图上占比很小。服装相反,版型、长度比例、肩线位置这些全局信息更有区分度,局部纹理反而容易受印花干扰。
这就导致同一个骨干在两个类目上的表现不一样,选型要分开看:
| 骨干 | 维度 | 鞋类 Recall@10 | 服装 Recall@10 | 单图推理(518) | 备注 |
|---|---|---|---|---|---|
| ResNet50 (IN1k) | 2048 | 基线 | 基线 | 最快 | 全局池化丢局部,鞋类吃亏 |
| CLIP ViT-B/32 | 512 | 中 | 较好 | 快 | 语义强、细粒度弱,patch 太大 |
| DINOv2 ViT-S/14 | 384 | 较好 | 较好 | 中 | patch 14 分辨率高,自监督纹理表达好 |
| DINOv2 ViT-B/14 | 768 | 最好 | 最好 | 慢 | 显存和索引成本翻倍 |
如果只能选一个,我一般从 DINOv2 ViT-S/14 起步,它的 patch size 是 14,518 输入下能得到 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, pretrained=True, num_classes=0) model.eval().to(device) cfg = timm.data.resolve_data_config(model.pretrained_cfg) transform = timm.data.create_transform(**cfg, is_training=False) def extract(paths, batch_size=64): 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", dtype=torch.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_classes=0让模型输出池化后的 embedding 而不是分类 logits,这是 timm 的约定。autocast用 fp16 推理,ViT-S 在 518 输入下显存占用从约 2.4G 降到 1.2G 左右,单卡 batch 64 够用;如果换 ViT-B,batch 要降到 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 条,因为后面颜色过滤会砍掉一批同款不同色的结果,切片重排序又会重排,留足冗余。这个冗余度的教训很直接:早期直接k=20,颜色过滤一开,返回条数掉到 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_color=0.25): """颜色权重别超过 0.35,否则会压过结构信息,搜出鞋型完全不对的结果。""" return cos_feat + w_color * cos_colorbins=(8,8,8)是 512 维,对鞋服够用。调太细(比如 16³)会变敏感,同款不同批次拍摄的色差就能把分数拉开;调太粗(4³)又区分不了黑和深灰。
w_color的取值要按类目分。鞋类结构稳定、颜色区分明确,0.25~0.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×384=1536 维)再去建索引,效果反而不如加权平均。原因是拼接后不同视角占据了不同的子空间,余弦相似度会被“哪个视角更匹配”这件事主导,而用户 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, grid=24): """取 patch token 并做 L2 归一化。ViT-S/14 @518 -> 37x37。""" t = model.forward_features(x) # (B, 1+N, 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()grid=24是把 patch 网格池化到 24×24=576 个块,对应原来 37×37 的 1369 个,降一半左右,计算量可控且保留足够局部性。重排序的最终分数用0.7 * 全局余弦 + 0.3 * late_score混合,纯用 late_score 会因为局部偶然匹配而把完全不相关的图排上来。
这套重排序对“同款不同代”的区分有效,因为换代会改变鞋侧线条和鞋底纹路的走向,patch 级匹配能抓住这些差异。但它对模糊 query 不友好——用户拍糊了,patch 全是噪声,最大相似度会被随机匹配拉高。稳妥做法是加个质量门控:用 query 的拉普拉斯方差判断清晰度,低于阈值的直接跳过重排序,只用全局特征。
5. 避坑排查:离线 Recall@10 到线上体验之间的五道坎
5.1 离线指标漂亮,线上全是同系列不同代
现象:离线评估 Recall@10 做到 0.9 以上,上线后人工抽检发现前十条里平均有 4 条是同系列的其他代,用户实际想要的款排在第 7、8 位。
原因:评估集按 SKU 随机划分,同一个款族的不同代被分到了 query 侧和 gallery 侧,模型见过同族样本,属于数据泄漏。加上“同系列不同代”的视觉差异本来就小,在无泄漏的划分下难度会高得多。
解决:按series_id做分组划分,保证同一个款族只出现在一侧。同时专门构造一批跨代负样本对(同系列相邻两代),单独统计这一子集的 Top1 准确率,把它当核心指标盯,而不是看整体 Recall。
5.2 搜鞋返回整身模特图
现象:一张鞋子的实拍图,检索结果前几条是模特全身穿搭图,鞋在画面里只占很小一块。
原因:索引里混入了img_role=model和img_role=scene的图,这些图的全局特征描述的是“人穿着衣服站在街上”,跟鞋子 query 的相似度靠的是背景和色调,而不是鞋本身。
解决:img_role字段在入库时就分好,检索时按角色分层。鞋类目默认只搜 main/side/sole/detail,模特图单独一个索引,只在“搭配推荐”这个场景下启用。另外对scene类图做强制抠图后再提特征,把背景信息去掉。
5.3 抠图把白色鞋带和网面吃掉了
现象:浅色系鞋款的检索效果系统性偏差,白色、米色的款召回率比深色款低十来个点。
原因:抠图模型对低对比度前景的判定不稳定,白色鞋带和白色棚拍背景几乎同色,alpha 预测在 0 附近,二值化后整块被当成背景裁掉。模型拿到的输入本身就丢了关键结构。
解决:alpha 阈值从 0 提到 32 左右,保住半透明区域;同时做双路特征,抠图路 + 原图路各存一份,最终分数加权。原图路要先把背景用大色块覆盖掉(比如纯灰),避免棚拍白背景和白色鞋面混淆,这个改动对浅色款的提升最明显。
5.4 efSearch 调到 256,延迟翻了三倍但召回没动
现象:为了提召回把efSearch从 64 一路加到 256,QPS 从 800 掉到 250,离线 Recall 只涨了 0.5 个点,线上点击率无变化。
原因:瓶颈不在图搜索的候选宽度,而在前置的类目路由和特征质量。真正应该改的是特征和过滤策略,不是搜索宽度。另外M=32时图的连通性已经够用,继续加efSearch只是在遍历更多已访问过的节点。
解决:efSearch回到 64~96 区间,把精力放到三件事上:按类目分桶建索引减少跨类目噪声、把颜色特征加进融合打分、上线切片重排序。这三项加起来带来的提升,比把 efSearch 拉满大得多。
5.5 新配色上市三天了还搜不到
现象:运营上新后,用户拍图搜不到新款,只能搜到同款的老配色。手工查索引,新 SKU 的向量根本不在里面。
原因:索引是全量重建的离线批任务,一天或一周跑一次,新数据落在“已采集但未入库”的空窗期。这个空窗期在上新密集期特别致命,鞋服行业一周几百个 SKU 是常态。
解决:改成“增量 add + 定期 merge”的模式。Faiss 的 HNSW 支持直接index.add()追加,新 SKU 特征算完立刻入库;同时维护一张valid_until软删表,下架款在检索后过滤而不是物理删除。每两周做一次全量重建,把软删的节点真正清掉,防止索引无限膨胀。
6. 上线之后:用采样回流把召回率一点点抠出来
上线只是开始。以图搜款的召回率不是一个能一次调到位的东西,它随着品类扩张、拍摄风格变化、用户拍摄习惯变化而持续漂移。真正决定长期效果的,是能不能建起一条稳定的评估回流链路。
评估集怎么建,比模型怎么选更重要。我一般维护三份:
| 集合 | 规模 | query 来源 | gallery 来源 | 用途 |
|---|---|---|---|---|
| 黄金集 | 800~1200 对 | 人工挑选的真实实拍 | 官方图全量 | 版本对比,固定不变 |
| 漂移集 | 每周 200 对 | 上周线上随机采样 | 官方图全量 | 监控分布偏移 |
| 难例集 | 300 对 | 同系列跨代、同款异色 | 官方图全量 | 专项优化 |
黄金集要冻结,任何时候都不许往里加样本,否则历史版本的数字就没法比了。漂移集每周换一批,主要看整体指标有没有系统性下滑——鞋服类目在换季前后,用户拍摄背景从室内地板变成室外路面,特征分布会有明显偏移,漂移集能提前两周给出信号。
指标上,除了常规的 Recall@10、mAP@20,我会单独盯一个同色命中率:Top10 里与 query 同color_family的结果占比。这个指标比综合召回更能反映用户的实际感受,因为它直接对应“搜出来的配色对不对”。同色命中率掉到 0.7 以下,基本就是颜色融合权重出了问题,或者新一批官图的抠图质量崩了。
线上反馈的采集要克制。点击和加购能当弱标注用,但鞋服的点击噪声很大——用户经常点开一个相似款看看,然后返回去点真正的目标。稳妥做法是只看“点击后停留超过 8 秒且发生滚动”的样本,把它当正例,其余当无标注,用这种带噪正例做阈值校准。
每周抽 200 条线上真实 query 做人工判读,标出“该召回的没召回”和“召回了不该召回的”,把前者按原因归类:是特征问题、过滤问题、还是索引里压根没有这个 SKU。我踩过的坑是早期只盯着模型换不换,结果连续三周的人工判读里,超过四成的漏召回其实是索引没更新,跟模型一点关系都没有。
最后说个习惯。每次调整参数前,先把当前版本的黄金集结果、漂移集结果、线上指标三个数记进一张表,改完再记一行。看起来笨,但鞋服类目里很多改动是“这个类目涨、那个类目跌”,没有这张表,你永远说不清这次改动到底值不值。我自己吃过没有后悔药的亏——有一次把颜色权重从 0.2 调到 0.4,鞋类涨了两个点,卫衣类掉了一个点,当时没记,两周后运营反馈卫衣搜款变差,花了三天才定位到是那次改动。
希望帮到你。
本文还有配套的精品资源,点击获取