图像检索与度量学习 本节摘要:检索系统按嵌入空间里的距离给候选排序,度量学习就是塑造这个空间、让距离符合你想要的含义的学科。本节讲清三大家族损失(对比、三元组、代理式),实现三元组损失与半难负样本挖掘,正确处理 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)。
阅读完本节,你应当能够:
检索在生产视觉里无处不在:重复检测、反向图搜、视觉搜索(「找相似商品」)、人脸重识别、监控行人重识别、电商实例级匹配。产品问题永远一样:「给定这张查询图,给我的目录排序」。
两个设计决策决定整个系统。嵌入——什么模型产出向量。索引——大规模下怎么找最近邻。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 很远)贡献零损失,只有难三元组才教网络。半难挖掘(n 比 p 远但在间隔内)是 2016 年 FaceNet 配方,至今主导。
两个度量,两套约定:
对多数现代网络两者等价:||a|| = ||b|| = 1 时 ||a − b||^2 = 2 − 2cos(a, b)。选与你嵌入训练一致的约定;混用会悄悄改变「最近」的含义。
标准检索指标:
recall@K = 至少一个正确匹配出现在前 K 结果里的查询比例
并排报 recall@1、@5、@10。recall@10 > 0.95 而 recall@1 < 0.5 说明嵌入空间结构对但排序噪声大——试试更长微调或重排序步骤。
重复检测里 precision@K 更要紧,因为每个误报都是用户可见错误;视觉搜索里 recall@K 才是产品信号。
Facebook AI Similarity Search,最近邻搜索的事实标准库。三种索引:
IndexFlatIP / IndexFlatL2——暴力、精确、无需训练,约 100 万向量内用。IndexIVFFlat——分成 K 个胞,只搜最近的几个胞,近似、快、需训练数据。IndexHNSW——基于图,多查询时最快,索引体积大。10 万向量你大概要 IndexFlatIP 配余弦相似度;1000 万要 IndexIVFFlat;一亿以上配合乘积量化(IndexIVFPQ)。
两个截然不同却同名的问题:
挑模型前永远先问:你解的是哪一个。
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 归一化或原始嵌入上都行。
给定一批嵌入和标签,为每个锚找最难的半难负样本。
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
每个锚拿到类内最难的正样本,以及一个比正样本远但在间隔内的半难负样本。
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。报告至少有一个正确邻居的查询比例均值。
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 年生产栈:
SOTA 实例检索配方:DINOv2 主干,加嵌入头,在实例标签对上用三元组或 InfoNCE 损失微调,FAISS 建索引。
本节产出两个可复用文件(位于原课程 outputs/):
prompt-retrieval-loss-picker.md:一个提示词——为给定检索问题在三元组 / InfoNCE / ProxyNCA 间挑。skill-recall-at-k-runner.md:一个技能——写出干净的 recall@K 评估台,含训练/验证/目录划分和正确数据契约。max(0, d(a,p)−d(a,n)+margin)——拉正推负,margin 保间隔。n 比 p 远但在 margin 内)最 informative。||a||=||b||=1 时 ||a−b||²=2−2cos;别混用约定。下一节进入关键点检测与姿态估计——定位人体或物体的语义关键点,理解姿态与动作。