图像检索与度量学习


文档摘要

图像检索与度量学习 本节摘要:检索系统按嵌入空间里的距离给候选排序,度量学习就是塑造这个空间、让距离符合你想要的含义的学科。本节讲清三大家族损失(对比、三元组、代理式),实现三元组损失与半难负样本挖掘,正确处理 L2 归一化与余弦相似度,审计「同一物品」与「同一类别」检索的差异;用 FAISS 建索引、按文按图查询、报 recall@K;并把 DINOv2、CLIP、SigLIP 当作开箱即用的嵌入主干,知道各自何时胜出。读完本节,你能为重复检测、以图搜图、行人重识别搭出可上线的检索栈。 对应原课程:Phase 4 · Lesson 20 · (原英文 )。 学习目标 阅读完本节,你应当能够: 解释三元组、对比、代理式度量学习损失,并为给定数据集选对损失。

图像检索与度量学习

本节摘要:检索系统按嵌入空间里的距离给候选排序,度量学习就是塑造这个空间、让距离符合你想要的含义的学科。本节讲清三大家族损失(对比、三元组、代理式),实现三元组损失与半难负样本挖掘,正确处理 L2 归一化与余弦相似度,审计「同一物品」与「同一类别」检索的差异;用 FAISS 建索引、按文按图查询、报 recall@K;并把 DINOv2、CLIP、SigLIP 当作开箱即用的嵌入主干,知道各自何时胜出。读完本节,你能为重复检测、以图搜图、行人重识别搭出可上线的检索栈。

对应原课程:Phase 4 · Lesson 20 · image-retrieval-metric(原英文 phases/04-computer-vision/20-image-retrieval-metric/docs/en.md)。

学习目标

阅读完本节,你应当能够:

  1. 解释三元组、对比、代理式度量学习损失,并为给定数据集选对损失。
  2. 正确实现 L2 归一化与余弦相似度,审计「同一物品」与「同一类别」检索的差异。
  3. FAISS 索引,按文按图查询,为留出查询集报 recall@K。
  4. DINOv2、CLIP、SigLIP 当开箱即用的嵌入主干,知道各自何时胜出。

一、问题与直觉

检索在生产视觉里无处不在:重复检测、反向图搜、视觉搜索(「找相似商品」)、人脸重识别、监控行人重识别、电商实例级匹配。产品问题永远一样:「给定这张查询图,给我的目录排序」。

两个设计决策决定整个系统。嵌入——什么模型产出向量。索引——大规模下怎么找最近邻。2026 年两者都已商品化(嵌入用 DINOv2,索引用 FAISS),门槛因此抬高:难的部分是定义什么算相似,再塑造嵌入空间让距离与之吻合。

这个塑造就是度量学习。它是一门小但高杠杆的学科。

检索一览

四大损失家族

损失 需要 优点 缺点
对比 (锚, 正) + 负样本 简单,任意配对标签都能用 没有大量负样本时收敛慢
三元组 (锚, 正, 负) 直观,直接控制间隔 难样本挖掘贵
NT-Xent / InfoNCE 配对 + 批内挖掘负样本 能扩到大批 需大批或动量队列
代理式(ProxyNCA) 仅类别标签 快、稳、无需挖掘 小数据上可能过拟合代理

多数生产用例,先用预训练主干,只有当开箱嵌入在你的测试集上不够好时,才加一次度量学习微调。

三元组损失形式化

L = max(0, ||f(a) - f(p)||^2 - ||f(a) - f(n)||^2 + margin)

把锚 a 拉近正样本 p、推开负样本 n,margin 保证一个间隔。这种三图结构可推广到任意相似度排序。

挖掘要紧:简单三元组(n 已离 a 很远)贡献零损失,只有难三元组才教网络。半难挖掘(np 远但在间隔内)是 2016 年 FaceNet 配方,至今主导。

余弦相似度 vs L2

两个度量,两套约定:

  • 余弦:向量夹角,需 L2 归一化嵌入。
  • L2:欧氏距离,作用在原始或归一化嵌入上,但通常配 L2 归一化 + 平方 L2。

对多数现代网络两者等价:||a|| = ||b|| = 1||a − b||^2 = 2 − 2cos(a, b)。选与你嵌入训练一致的约定;混用会悄悄改变「最近」的含义。

Recall@K

标准检索指标:

recall@K = 至少一个正确匹配出现在前 K 结果里的查询比例

并排报 recall@1、@5、@10。recall@10 > 0.95 而 recall@1 < 0.5 说明嵌入空间结构对但排序噪声大——试试更长微调或重排序步骤。

重复检测里 precision@K 更要紧,因为每个误报都是用户可见错误;视觉搜索里 recall@K 才是产品信号。

FAISS 一段话讲清

Facebook AI Similarity Search,最近邻搜索的事实标准库。三种索引:

  • IndexFlatIP / IndexFlatL2——暴力、精确、无需训练,约 100 万向量内用。
  • IndexIVFFlat——分成 K 个胞,只搜最近的几个胞,近似、快、需训练数据。
  • IndexHNSW——基于图,多查询时最快,索引体积大。

10 万向量你大概要 IndexFlatIP 配余弦相似度;1000 万要 IndexIVFFlat;一亿以上配合乘积量化(IndexIVFPQ)。

实例级 vs 类别级检索

两个截然不同却同名的问题:

  • 类别级——「找目录里的猫」,类条件相似度,开箱 CLIP/DINOv2 嵌入效果好。
  • 实例级——「找目录里这个确切的商品」,需要对同类但视觉相似物体做细粒度判别,开箱嵌入欠佳,度量学习微调要紧。

挑模型前永远先问:你解的是哪一个。

二、从零实现

步骤 1:三元组损失

import torch import torch.nn.functional as F def triplet_loss(anchor, positive, negative, margin=0.2): d_ap = F.pairwise_distance(anchor, positive, p=2) d_an = F.pairwise_distance(anchor, negative, p=2) return F.relu(d_ap - d_an + margin).mean()

一行。作用在 L2 归一化或原始嵌入上都行。

步骤 2:半难负样本挖掘

给定一批嵌入和标签,为每个锚找最难的半难负样本。

def semi_hard_negatives(emb, labels, margin=0.2): dist = torch.cdist(emb, emb) same_class = labels[:, None] == labels[None, :] diff_class = ~same_class N = emb.size(0) positives = dist.clone() positives[~same_class] = float("-inf") positives.fill_diagonal_(float("-inf")) pos_idx = positives.argmax(dim=1) semi_hard = dist.clone() semi_hard[same_class] = float("inf") d_ap = dist[torch.arange(N), pos_idx].unsqueeze(1) semi_hard[dist <= d_ap] = float("inf") neg_idx = semi_hard.argmin(dim=1) fallback_mask = semi_hard[torch.arange(N), neg_idx] == float("inf") if fallback_mask.any(): hardest = dist.clone() hardest[same_class] = float("inf") neg_idx = torch.where(fallback_mask, hardest.argmin(dim=1), neg_idx) return pos_idx, neg_idx

每个锚拿到类内最难的正样本,以及一个比正样本远但在间隔内的半难负样本。

步骤 3:Recall@K

def recall_at_k(query_emb, gallery_emb, query_labels, gallery_labels, k=1): sim = query_emb @ gallery_emb.T _, top_k = sim.topk(k, dim=-1) matches = (gallery_labels[top_k] == query_labels[:, None]).any(dim=-1) return matches.float().mean().item()

L2 归一化嵌入上按内积取 top-k 等于按余弦取 top-k。报告至少有一个正确邻居的查询比例均值。

步骤 4:串起来

import torch import torch.nn as nn from torch.optim import Adam class Encoder(nn.Module): def __init__(self, in_dim=128, emb_dim=64): super().__init__() self.net = nn.Sequential( nn.Linear(in_dim, 128), nn.ReLU(), nn.Linear(128, emb_dim), ) def forward(self, x): return F.normalize(self.net(x), dim=-1) torch.manual_seed(0) num_classes = 6 protos = F.normalize(torch.randn(num_classes, 128), dim=-1) def sample_batch(bs=32): labels = torch.randint(0, num_classes, (bs,)) x = protos[labels] + 0.15 * torch.randn(bs, 128) return x, labels enc = Encoder() opt = Adam(enc.parameters(), lr=3e-3) for step in range(200): x, y = sample_batch(32) emb = enc(x) pos_idx, neg_idx = semi_hard_negatives(emb, y) loss = triplet_loss(emb, emb[pos_idx], emb[neg_idx]) opt.zero_grad(); loss.backward(); opt.step()

几百步后嵌入簇每类一簇。

三、框架对比

2026 年生产栈:

  • DINOv2 + FAISS——通用视觉检索,开箱即用。
  • CLIP + FAISS——查询是文本时。
  • 微调 DINOv2 + FAISS——实例级检索、人脸重识别、时尚、电商。
  • Milvus / Weaviate / Qdrant——围绕 FAISS 或 HNSW 的托管向量数据库。

SOTA 实例检索配方:DINOv2 主干,加嵌入头,在实例标签对上用三元组或 InfoNCE 损失微调,FAISS 建索引。

四、可复用产物

本节产出两个可复用文件(位于原课程 outputs/):

  • prompt-retrieval-loss-picker.md:一个提示词——为给定检索问题在三元组 / InfoNCE / ProxyNCA 间挑。
  • skill-recall-at-k-runner.md:一个技能——写出干净的 recall@K 评估台,含训练/验证/目录划分和正确数据契约。

五、练习

  1. (简单) 跑上面玩具示例,用 PCA 画出训练前后的嵌入,看六簇成形。
  2. (中等) 加一个 ProxyNCA 损失实现:每类一个可学「代理」,余弦相似度上做标准交叉熵。在玩具数据上对比与三元组损失的收敛速度。
  3. (困难) 取 1000 张 ImageNet 验证图,用 DINOv2 经 HuggingFace 编码,建 FAISS flat 索引,以同图作查询(应 1.0)和以留出划分作查询(ImageNet 标签作真值)分别报 recall@{1, 5, 10}。

本节要点回顾

  1. 检索 = 按嵌入距离排序,度量学习塑造这个空间——两个决策:嵌入(什么模型)、索引(怎么找近邻)。
  2. 四大损失家族:对比(简单慢)、三元组(直观需挖掘)、InfoNCE(扩大批)、代理式(仅类别标签)。
  3. 三元组损失 max(0, d(a,p)−d(a,n)+margin)——拉正推负,margin 保间隔。
  4. 半难挖掘要紧——简单三元组零损失,半难(np 远但在 margin 内)最 informative。
  5. 余弦 vs L2 多数等价——||a||=||b||=1||a−b||²=2−2cos;别混用约定。
  6. recall@K 是标准指标——并报 @1/@5/@10;@10 高 @1 低说明结构对但排序噪声大。
  7. FAISS 三索引——Flat(精确,<100 万)、IVFFlat(近似,千万)、HNSW(图,最快)、IVFPQ(亿级)。
  8. 实例级 vs 类别级——前者要细粒度判别、开箱欠佳需微调;后者开箱 CLIP/DINOv2 即可;先问清解哪个。
  9. 2026 生产栈:DINOv2+FAISS(通用)、CLIP+FAISS(文本查询)、微调 DINOv2(实例)、Milvus/Weaviate/Qdrant(托管)。

下一节进入关键点检测与姿态估计——定位人体或物体的语义关键点,理解姿态与动作。


发布者: 作者: Rohit Gupta 转发
评论区 (0)
U