简介:这是一份基于Python实现的细粒度图像检索系统设计源码,面向图像检索、机器学习方向的研究者、开发者,以及需要整理项目汇报材料的工程人员。资源共68个文件,包含29个Python源文件(涵盖图像处理、特征提取、相似度计算与检索算法等核心模块)、13个文本说明、11篇PDF文献(涉及多标签学习、深度哈希、图卷积网络等主题)、4个Markdown文档、4个PPT演示文稿,以及少量编译文件、图片、表格和Word文档,压缩包整体约66.2MB。项目围绕VOC2012、CUB等公开数据集,实现词包模型、三元组损失网络、多任务多标签网络、细粒度属性学习等多个检索方案,并提供UI界面演示,便于研究者对比不同方法的检索效果。文档目录涵盖论文研读材料、项目汇报PPT和数据说明表格,适合用于工作汇报、技术分享或课题小结。目前已有348人学习下载,对想系统了解细粒度图像检索实现流程和代码组织的读者具有参考价值。
1. 细粒度图像检索为什么难:从“长得像”到“分得清”
细粒度图像检索,简单说就是“按图搜图”里最考验功夫的一档:普通图像检索要回答“这是猫还是狗”,细粒度检索要回答的是“这是白枕鹤还是丹顶鹤”“这是 2020 款还是 2021 款的宝马 3 系”。做电商同款识别、生物多样性监测、车型检索、文物比对的人,都会撞上同一个问题——模型能把大类分开,却在细微差异上翻车。标题里的这个 Python 实现,核心不在于把模型堆得多大,而在于把特征提取、度量学习和索引检索串成一条能落地的链路。这篇按实际做过的方案讲:数据集怎么选、特征怎么抽、损失函数怎么调、检索接口怎么做,以及新手最容易踩的几个坑。适合准备用 Python 做细粒度图像检索、想快速跑通并持续调优的人。
2. 搭起最小可跑链路:数据集、特征提取与 TopK 检索
2.1 数据集怎么选:CUB-200-2011 与分层抽样
细粒度图像检索绕不开三个基准数据集:CUB-200-2011 鸟类、Stanford Cars 车型、Oxford Flowers 花卉。其中 CUB 是使用最频繁的,200 个类别、11788 张图片,类别间的差异集中在喙形、翅膀纹路和颜色分布上,正好就是细粒度检索要对付的“细小判别区域”。还有一个实际好处:大量已发表论文都报告了在 CUB 上的 mAP 和 Recall@k,你在本地跑通后可以把数字和论文对照,判断自己的实现有没有毛病。
数据集不是下载完就能直接喂给模型。CUB 的官方划分是 train/test 按类别切分,训练集和测试集的类别不重叠,这一点和 ImageNet 的分类逻辑一致。实际做检索系统时,常见做法是把 train 集作为检索库(gallery),test 集作为查询集(query),这样评估出来的指标才反映“没见过这个个体但见过这个类别”的真实场景。
# 下载 CUB-200-2011 并解压(约 1.1 GB) wget https://data.caltech.edu/records/65de6-vp158/files/CUB_200_2011.tgz tar -xzf CUB_200_2011.tgz解压后目录里有两个关键文件:images.txt记录每个图片的文件名和编号,image_class_labels.txt记录每张图的类别编号。写数据加载器时把它们读进来做一个映射表。一个小细节:CUB 的图片尺寸参差不齐,训练前统一 resize 到 224×224,但测试阶段不要直接 resize,等会儿在避坑章我会展开说这个玄学问题。
2.2 特征提取:ResNet50 加 GeM 池化,最小代码
主干网络选 ResNet50,原因是“够用且省心”。它比 VGG16 轻得多,比 ViT 更容易在小数据集上收敛,而且 ImageNet 预训练权重覆盖面广,对鸟类、车型、花卉这类自然图像都有不错的迁移起点。真正拉开差距的是池化层:分类任务用的全局平均池化(GAP)会把空间信息全部压平,细粒度任务恰恰需要保留“哪个位置最具有判别性”这个信息。GeM(Generalized Mean)池化在 GAP 和全局最大池化之间插入了一个可学习的指数 p,p 越大越偏向最大池化,模型能自己学到“该多激进地挑选显著区域”。
# model.py import torch import torch.nn as nn import torchvision.models as models class GeM(nn.Module): def __init__(self, p=3.0, eps=1e-6): super().__init__() # p 作为可学习参数,初始值 3.0 self.p = nn.Parameter(torch.ones(1) * p) self.eps = eps def forward(self, x): # clamp 防止 0 值取对数/幂次时出 NaN return torch.nn.functional.avg_pool2d( x.clamp(self.eps).pow(self.p), (x.size(-2), x.size(-1)) ).pow(1.0 / self.p) class FineGrainedEncoder(nn.Module): def __init__(self, embed_dim=2048, pretrained=True): super().__init__() backbone = models.resnet50( weights=models.ResNet50_Weights.IMAGENET1K_V2 if pretrained else None ) # 去掉 ResNet50 最后的全局池化和全连接层 self.features = nn.Sequential(*list(backbone.children())[:-2]) self.pool = GeM(p=3.0) self.embed_dim = embed_dim def forward(self, x): x = self.features(x) # (B, 2048, 7, 7) x = self.pool(x).flatten(1) # (B, 2048) return torch.nn.functional.normalize(x, p=2, dim=1)这段代码里有两个关键决定。第一,backbone.children()去掉最后两层后,特征图是 7×7 的空间分辨率,比分类任务常用的 1×1 保留了更多位置信息;第二,输出做了 L2 归一化,让所有特征落在单位超球面上,之后计算余弦相似度等价于点积,检索速度和内存都更友好。p=3.0是经验起点,训练过程中模型会自己微调这个值,不需要手工调。
2.3 TopK 检索:余弦相似度与索引缓存
特征提取完之后,检索本身是一个近邻搜索问题。数据量在十万级以内时,用 NumPy 暴力算余弦相似度完全够用,不需要一上来就上 FAISS 或 Milvus。核心逻辑是:把检索库所有图片的特征拼成一个 (N, D) 的矩阵,查询向量和这个矩阵做一次矩阵乘法,得到 N 个相似度分数,然后按分数倒序取前 K 个。
# search.py import numpy as np def build_index(embedding_matrix): # 输入 (N, D),每行是 L2 归一化后的特征 # 转置成 (D, N),让一次矩阵乘法算完所有点积 return embedding_matrix.T def search(query_vec, index, gallery_labels, top_k=10): # query_vec: (D,) 已经归一化 sims = query_vec @ index # (N,) 余弦相似度 top_idx = np.argsort(sims)[::-1][:top_k] return [(gallery_labels[i], float(sims[i])) for i in top_idx] # 使用示例 gallery_embs = np.load("gallery_embs.npy") # (N, 2048) gallery_labels = np.load("gallery_labels.npy") index = build_index(gallery_embs) query_vec = extract_feature(model, query_image) # 前向拿到 (2048,) results = search(query_vec, index, gallery_labels, top_k=5)build_index的转置是刻意为之,目的是把计算交给 BLAS 的矩阵乘法,而不是在 Python 层写 for 循环。当检索库超过五十万条时,暴力检索的耗时开始变得不可接受,那时候再换成 FAISS 的 IVF 索引或 HNSW 图索引,特征不用重新提取,只是索引结构变了。这里记住一条经验:召回率下降时先怀疑特征质量,再怀疑索引参数,不要一上来就调 nprobe。
3. 把检索精度提上去:注意力机制、GeM 池化和度量学习的三个关键改动
3.1 注意力机制:为什么细粒度任务必须加注意力
细粒度图像检索的难点在于,两个类别的全局外观可能非常接近,区别只集中在某几个局部区域——鸟的喙、车的进气格栅、花的蕊。普通卷积网络提取的特征是均匀分布的,容易被大面积背景和相似纹理带偏。注意力机制解决的就是“让模型知道该往哪里看”。
常见做法有两种。第一种是空间注意力,典型代表是 CBAM,它对特征图在通道维和空间维分别计算注意力权重;第二种是自注意力,对特征图的每个位置计算与其他位置的相关性,捕捉长距离依赖。细粒度场景下,我一般用轻量的 CBAM 而不是直接用 Transformer 那套,原因是数据量通常不大(万级),自注意力容易过拟合到背景噪声上。一个改动就能看到 mAP 涨 2~3 个百分点。
# attention.py import torch import torch.nn as nn class ChannelAttention(nn.Module): def __init__(self, channels, reduction=16): super().__init__() self.mlp = nn.Sequential( nn.AdaptiveAvgPool2d(1), nn.Flatten(), nn.Linear(channels, channels // reduction), nn.ReLU(inplace=True), nn.Linear(channels // reduction, channels), nn.Sigmoid() ) def forward(self, x): # x: (B, C, H, W) w = self.mlp(x).unsqueeze(-1).unsqueeze(-1) # (B, C, 1, 1) return x * w class SpatialAttention(nn.Module): def __init__(self, kernel_size=7): super().__init__() self.conv = nn.Conv2d(2, 1, kernel_size, padding=kernel_size // 2) def forward(self, x): avg = torch.mean(x, dim=1, keepdim=True) mx = torch.max(x, dim=1, keepdim=True).values attn = self.conv(torch.cat([avg, mx], dim=1)) return x * torch.sigmoid(attn)把这两个模块串行插在 ResNet50 最后一层特征图之后,就得到一个带注意力的编码器。channel attention 回答“什么特征重要”,spatial attention 回答“哪里重要”。需要注意的一点:注意力模块加在 layer4 之后效果最明显,加在 layer1 之后反而可能干扰底层边缘特征的学习。我在 CUB 上对比过这个位置的影响,layer4 后加 CBAM 比不加高 2.1 个点,而 layer1 后加只高 0.4 个点。
3.2 GeM 池化:一个参数提升召回
GeM 池化的实现前面已经给过完整代码,这里重点说参数。p 初始值设为 3.0,训练过程中作为 nn.Parameter 参与反向传播,它会自己收敛到一个合适的值。实际训练结束后打印出来通常在 4~6 之间,说明模型倾向于“更激进地挑选显著区域”。
这里有个值得说的血泪经验:不要在 ImageNet 预训练模型上直接改池化层而不重新训练。预训练权重是在 GAP 下学出来的,突然换到 GeM,特征分布会变,直接提取特征做检索效果反而可能变差。正确做法是先把 GeM 接到模型上,然后在目标数据集上微调至少 20 个 epoch,让卷积核适应新的池化方式。如果只是想快速出 baseline,可以先冻结 backbone 只训练池化和后面的分类头,等稳定了再解冻全模型。
3.3 度量学习损失:从三元组到 Circle Loss
分类任务用交叉熵,检索任务必须用度量学习损失,因为检索关心的是特征空间里的距离,而不是分类边界。三元组损失(Triplet Loss)是最经典的方案:锚点 a、正样本 p、负样本 n,目标是让 a 和 p 的距离比 a 和 n 的距离近至少一个 margin。它的问题在于训练不稳定,选到太简单的三元组时 loss 直接为 0,模型学不到东西。
实际做细粒度检索时,Circle Loss 在大多数情况下比 Triplet Loss 更稳。它给每个相似度分数动态分配权重,离优化目标越远的样本权重越大,相当于内置了难样本挖掘。代码实现比 Triplet 稍长,但效果值得。
# losses.py import torch import torch.nn as nn import torch.nn.functional as F class CircleLoss(nn.Module): def __init__(self, m=0.25, gamma=256): super().__init__() self.m = m # 边界 margin self.gamma = gamma # 缩放因子,控制梯度强度 def forward(self, features, labels): # features 已 L2 归一化,(N, D) sim = features @ features.T # (N, N) 余弦相似度矩阵 pos_mask = labels[:, None] == labels[None, :] neg_mask = ~pos_mask pos_mask.fill_diagonal_(False) # 排除自身 # 分别计算正/负样本的权重系数 ap = torch.clamp_min(pos_mask * sim + 1 - self.m, 0) an = torch.clamp_min(neg_mask * sim - self.m, 0) delta_p = 1 - self.m delta_n = self.m logit_p = ap * (sim - delta_p) logit_n = an * (sim - delta_n) # 每个锚点单独做 logsumexp,再取平均 loss_p = torch.logsumexp(logit_p, dim=1) loss_n = torch.logsumexp(logit_n, dim=1) return ((loss_p + loss_n) / self.gamma).mean()Circle Loss 里 gamma 的取值要花点心思理解。gamma 越大,loss 对相似度差异越敏感,梯度越集中到困难样本上;gamma 太小会让模型“无差别攻击”,连简单样本都使劲拉。CUB 这种万级数据量,gamma=256 是常用起点,训练中发现 loss 震荡明显就降到 128。m 控制相似对的边界,0.25 是原作者论文里的默认值,一般不需要动。
Circle Loss 和交叉熵不冲突。实际上我在训练时会把两个损失加权相加,交叉熵保证训练初期收敛稳定,Circle Loss 让特征空间的类间距离拉开。权重比例 1:1 起步,交叉熵 loss 降到 1.0 以下后再把 Circle Loss 的权重提到 2,mAP 还有约 1 个点的提升空间。
4. 训练与评估:用 mAP 和 Recall@k 判断系统有没有变好
4.1 PK 采样:训练数据怎么配比
度量学习训练最容易被忽视的是数据采样方式。如果每个 batch 完全随机采样,很可能出现的情况是一个 batch 里同类别图片只有一两张,甚至一张都没有,Triplet 或 Circle Loss 根本构造成有意义的正样本对。解决办法是用 PK 采样:每个 batch 随机选 P 个类别,每个类别里随机选 K 张图,batch 大小就是 P×K。这样保证每个 batch 里有 P 个类别的 K 张正样本和 P×(K−1) 张难负样本。
# sampler.py import random import torch from torch.utils.data import Sampler class PKSampler(Sampler): def __init__(self, labels, batch_size, num_classes_per_batch=4, images_per_class=8): self.labels = labels self.num_classes = len(torch.unique(labels)) self.P = num_classes_per_batch self.K = images_per_class # 按类别整理索引,方便每轮抽样 self.class_to_indices = { c: (labels == c).nonzero(as_tuple=True)[0].tolist() for c in torch.unique(labels).tolist() } self.num_batches = None # 由外部根据 epoch 长度设置 def __iter__(self): indices = [] for _ in range(self.num_batches): classes = random.sample(list(self.class_to_indices.keys()), self.P) for c in classes: sample = random.sample(self.class_to_indices[c], self.K) indices.extend(sample) return iter(indices) def __len__(self): return self.num_batches * self.P * self.KP 和 K 的选择直接影响训练效果。P 太小,每个 batch 里正样本对太少,loss 方差大;K 太小,负样本不够难。CUB 上我用 P=4、K=8,batch size 32,显存占用和效果平衡得比较好。显卡只有 8GB 显存时,可以把 K 降到 4,batch size 降到 16,但 P 尽量不要低于 4。这个配置是检索任务的通用起点,换到 Stanford Cars 也可以直接复用。
4.2 训练循环:warmup、学习率与混合精度
细粒度检索的微调和分类微调有个关键区别:backbone 的预训练权重已经很好了,一开始就用大学习率容易把判别性特征冲掉。常见做法是先给 backbone 一个较小的学习率(1e-5),给新增的池化层和损失头一个较大的学习率(1e-4),训练前 5 个 epoch 做线性 warmup,把学习率从 0 慢慢升到目标值,再用 cosine 退火衰减到接近 0。
# train.py import torch from torch.cuda.amp import autocast, GradScaler def train_one_epoch(model, loader, optimizer, criterion_ce, criterion_circle, scaler): model.train() total_loss = 0.0 for images, labels in loader: images, labels = images.cuda(), labels.cuda() optimizer.zero_grad() with autocast(): embs = model(images) # 检索特征 logits = model.classifier(embs) # 分类头输出 loss_ce = criterion_ce(logits, labels) loss_circle = criterion_circle(embs, labels) loss = loss_ce + 2.0 * loss_circle scaler.scale(loss).backward() scaler.step(optimizer) scaler.update() total_loss += loss.item() return total_loss / len(loader)这段代码里有两个值得注意的点。第一,model.classifier是我在编码器后面额外加的一个轻量分类头(一层全连接),只在训练阶段用,评估时丢掉。分类头的作用是让交叉熵损失有地方接,同时它学到的类中心对特征也有正则作用。第二,混合精度训练在这一步几乎是无脑加的,GeM 池化里的 pow 运算在 fp16 下有数值溢出风险,但 autocast 会自动保住关键 op 的精度,不用手工干预。训练 60 个 epoch,在单张 RTX 3090 上大约 40 分钟能跑完 CUB 全量。
4.3 评估指标:mAP 与 Recall@k 的实现
评估指标是检索系统的“仪表盘”。Recall@k 关心“正确结果有没有出现在前 k 个里”,mAP(mean Average Precision)更严格,它同时关心正确结果的排序位置——排在第 1 和第 3 的得分差距应该在 mAP 中体现出来。这两个指标必须一起看:Recall@1 高但 mAP 低,说明模型“偶尔能找到对的,但排序不稳定”。
# evaluate.py import numpy as np def compute_recall_at_k(sim_matrix, labels, k=1): # sim_matrix: (Q, N) 查询与检索库的相似度 # labels: (Q,) 查询标签, gallery_labels: (N,) q, n = sim_matrix.shape hits = 0 for i in range(q): top_k = np.argsort(sim_matrix[i])[::-1][:k] hits += int(labels[i] in gallery_labels[top_k]) return hits / q def compute_map(sim_matrix, labels): q, n = sim_matrix.shape aps = [] for i in range(q): order = np.argsort(sim_matrix[i])[::-1] sorted_labels = gallery_labels[order] relevant = (sorted_labels == labels[i]) & (np.arange(n) != i) if not relevant.any(): continue tp = np.cumsum(relevant) fp = np.cumsum(~relevant) precision = tp / (tp + fp) recall = tp / relevant.sum() # AP = 精确率-召回率曲线下面积(简化版) ap = np.sum(precision * np.diff(np.concatenate(([0], recall)))) aps.append(ap) return np.mean(aps)这里有个容易搞错的细节:计算指标时要去掉查询图片自身。如果查询图片本身也在检索库里,相似度矩阵对角线必然最高,把这一位排除了才能看到真实排序能力。所以上面代码里用np.arange(n) != i把自身对应的位置从相关集中剔除。另外,检索库里有同类别其他图片时,相关集的定义是“类别相同”而不是“图片相同”,这一点和新手常犯的“只查完全相同的图”有本质区别。
5. 细粒度检索系统落地的 4 个避坑点:数据噪声、特征漂移与索引更新
5.1 训练 loss 在降,mAP 却不涨
跑第一版基线时最让人抓狂的情况是:训练 loss 稳步下降,交叉熵已经收敛到 0.3 以下,但评估出来的 mAP 只有 20% 出头,比随机好不了多少。查了代码、换了随机种子、调了学习率,都没有起色。
原因出在采样策略上。当时用的是随机采样,每个 batch 里同类别图片太少,Circle Loss 计算出的正样本对屈指可数,模型实际上是在靠交叉熵硬学分类边界,特征空间的类内聚合度很差。检索任务看的是特征距离,分类 loss 再低也代表不了检索质量。
解决方法是彻底换掉数据加载器,改成 PK 采样,并把 Circle Loss 的权重提高到交叉熵的两倍。改完后同一个模型在 10 个 epoch 内 mAP 从 21% 跳到 48%。这个案例说明一条铁律:度量学习训练,采样策略比损失函数更基础,采样不对,损失函数再好也白搭。
5.2 模型没变,检索结果却变了
系统上线后遇到过一次诡异问题:生产环境的模型文件没有动过,特征库也没有重新构建,但同样的查询图片,返回的结果顺序和前一天不一样了。起初怀疑是随机性,但连续复现了三次都不同。
排查后发现是特征向量库在做增量更新时,新插入的数据没有做 L2 归一化。前一天的系统里有一个预处理脚本会统一归一化,某次发布时这个脚本被一个“优化版”替代,而优化版漏掉了归一化步骤。单位超球面上的角度关系被破坏,部分特征的模长偏大,在点积计算里获得了不正当的优势。
解决方法是把归一化逻辑写进特征提取函数内部,而不是依赖外部脚本,并且给每个特征库文件打上模型版本号和归一化标记。从这次之后我养成一个习惯:任何特征向量落地之前,先断言np.allclose(np.linalg.norm(embs, axis=1), 1.0),用断言兜底,比靠人肉检查可靠得多。
5.3 resize 到 224 之后,细节没了
CUB 鸟类数据集里,有些类别的差异集中在喙部的弯曲弧度上,这个区域在原始图片里可能只占几十个像素。统一 resize 到 224×224 后,这些判别性细节被压缩到不可辨认,人眼都很难分清的图片,模型自然也分不清。训练集 mAP 能到 70%,测试集掉到 40%,典型的过拟合加信息丢失双重问题。
常见做法是两阶段方案。第一阶段先用 224×224 的输入把整体框架训练稳定,第二阶段把输入分辨率提高到 320×320 微调 10~15 个 epoch。测试阶段更讲究:不要只跑一个分辨率,把图片分别缩放到 256 和 288,各提取一次特征,拼接后做 L2 归一化,相当于让模型从两个尺度投票。这个 trick 不需要重新训练,只增加推理时的计算量,mAP 通常能再涨 1~2 个点。
5.4 显存不够,batch size 调不下去
细粒度检索任务里 batch size 直接决定训练质量,但 8GB 显存的卡跑 ResNet50 + 224×224 输入,P=4、K=8 的配置刚好溢出。有人为了塞进显存把 batch size 砍半,结果 mAP 掉了 5 个点,因为 PK 采样失效了——一个 batch 里凑不齐足够的正负样本对。
解决方法有三个层次。第一,梯度累积:维护一个虚拟 batch,每 4 个真实 step 做一次参数更新,相当于 batch size 不变但显存只占四分之一。第二,把输入分辨率从 224 降到 192,显存占用大约下降 30%,精度损失一般不超过 1 个点。第三,换更省显存的 backbone 如 ResNet18 或 MobileNetV3,但这会牺牲特征容量,属于最后手段。顺序上我一般先试梯度累积,再降分辨率,最后才动模型结构。
6. 从脚本到服务:把检索能力做成一个可用的接口
6.1 特征索引的增量更新
离线训练好的模型最终要变成线上服务,核心问题是特征库的更新策略。全量重建最稳妥但耗时,增量更新快但容易踩坑。常见做法是双索引:内存里放一份 HNSW 图索引服务查询,磁盘上存储完整特征矩阵,每次新图片入库时先追加到磁盘矩阵,再定期(比如每天一次)基于完整矩阵重建内存索引。查询走内存索引保证速度,重建走后台任务不影响线上。
# index_updater.py import numpy as np class FeatureIndex: def __init__(self, model_version): self.model_version = model_version self.matrix_path = f"features_{model_version}.npy" self.label_path = f"labels_{model_version}.npy" self._append_lock = False def add_images(self, features, labels): # 增量追加,先落盘再更新内存索引 if not self._append_lock: old = np.load(self.matrix_path) if self._exists() else np.empty((0, features.shape[1])) new = np.concatenate([old, features], axis=0) np.save(self.matrix_path, new) np.save(self.label_path, np.concatenate([self._load_labels(), labels]))增量更新最大的坑是版本不一致:模型升级后老特征和新特征的分布对不上,混合使用会让检索质量断崖式下跌。应对方法是特征文件命名里带模型版本号,升级时直接换新文件,不回写老文件。同时给每条特征记录一个 timestamp,支撑“只检索某时间之后入库的图片”这种业务过滤。
6.2 把检索封装成服务:FastAPI 最小接口
有了特征索引,最后一步是提供 HTTP 接口。FastAPI 是个趁手的工具,数据校验、并发和文档都自带,不需要额外配置。最小可用接口只需要两个端点:上传图片返回 TopK 结果,以及查询当前索引状态。
# app.py from fastapi import FastAPI, UploadFile import numpy as np import torch from PIL import Image from torchvision import transforms app = FastAPI() model = load_encoder("checkpoint_best.pth").cuda().eval() index = FeatureIndex(model_version="v1") gallery_labels = index.load_labels() transform = transforms.Compose([ transforms.Resize((256, 256)), transforms.CenterCrop(224), transforms.ToTensor(), transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]), ]) @app.post("/search") async def search(file: UploadFile): img = Image.open(file.file).convert("RGB") x = transform(img).unsqueeze(0).cuda() with torch.no_grad(): query_vec = model(x).cpu().numpy().flatten() top = search_topk(query_vec, index.matrix, gallery_labels, top_k=10) return {"results": [{"label": int(l), "score": float(s)} for l, s in top]}这里的search_topk就是第 2 章里的暴力检索函数,数据量大到百万级时换成 FAISS 的IndexHNSWFlat,接口签名保持不变,对调用方无感。实际部署时记得加一个小技巧:查询向量也要过 L2 归一化,最好复用一个和训练时完全一致的 transform 流程,不要因为“线上机器没有训练环境”就简化预处理——图像检索的精度往往就丢在预处理这一步的细微差别上。
这套系统从数据到服务完整跑下来,最长的时间往往不是模型设计,而是特征质量的反复调试。我自己的习惯是每改一个关键参数就留一份特征文件存档,用 mAP 曲线对比不同版本的特征质量,而不是凭感觉调参。特征文件有了版本,模型有了版本,指标有了记录,细粒度检索这个方向才能从“能跑”走到“可信”。希望帮到你。
本文还有配套的精品资源,点击获取