Phase 04 - Lesson 20

Recuperação de Imagens e Aprendizado de Métrica

This lesson includes a graded coding exercise that runs in your browser, unlocked with lifetime access.

Um sistema de recuperação ordena candidatos por uma distância no espaço de embeddings. O aprendizado de métrica é a disciplina de moldar esse espaço para que as distâncias signifiquem o que você quer.

Tipo: Build Linguagens: Python Pré-requisitos: Fase 4 Lição 14 (ViT), Fase 4 Lição 18 (CLIP) Tempo: ~45 minutos

Objetivos de Aprendizado

  • Explicar as perdas de aprendizado de métrica triplet, contrastiva e baseada em proxy, e escolher a certa para um dado conjunto de dados
  • Implementar corretamente a normalização L2 e a similaridade de cosseno, e auditar a diferença entre recuperação de "mesmo item" e "mesma classe"
  • Construir um índice FAISS, consultá-lo por texto e por imagem, e reportar o recall@K para um conjunto de consultas retido
  • Usar DINOv2, CLIP e SigLIP como backbones de embedding prontos para uso e saber quando cada um vence

O Problema

Recuperação está em todo lugar na visão computacional em produção: detecção de duplicatas, busca reversa de imagens, busca visual ("encontre produtos similares"), reidentificação facial, reidentificação de pessoas para vigilância, correspondência em nível de instância para e-commerce. A pergunta de produto é sempre a mesma: "dada esta imagem de consulta, ordene meu catálogo."

Duas decisões de design moldam todo o sistema. O embedding — qual modelo produz os vetores. O índice — como encontrar vizinhos mais próximos em escala. Ambos são commodity em 2026 (DINOv2 para o embedding, FAISS para o índice), o que eleva o sarrafo: a parte difícil é definir o que conta como similar para a sua aplicação e, então, moldar o espaço de embeddings para que as distâncias correspondam.

Essa moldagem é o aprendizado de métrica. É uma disciplina pequena, mas de alta alavancagem.

O Conceito

Recuperação em um relance

flowchart LR
    Q["Imagem de consulta<br/>ou texto"] --> ENC["Encoder"]
    ENC --> EMB["Embedding da consulta"]
    EMB --> IDX["Índice FAISS"]
    CAT["Imagens do catálogo"] --> ENC2["Encoder (mesmo)"] --> IDX_BUILD["Construir índice"]
    IDX_BUILD --> IDX
    IDX --> RANK["Top-k mais próximos<br/>por cosseno / L2"]
    RANK --> OUT["Resultados ordenados"]

    style ENC fill:#dbeafe,stroke:#2563eb
    style IDX fill:#fef3c7,stroke:#d97706
    style OUT fill:#dcfce7,stroke:#16a34a

As quatro famílias de perda

Perda Requer Prós Contras
Contrastiva (âncora, positivo) + negativos Simples, funciona com qualquer rótulo de par Convergência lenta sem muitos negativos
Triplet (âncora, positivo, negativo) Intuitiva; controle direto de margem Mineração de triplets difíceis é cara
NT-Xent / InfoNCE Pares + negativos minerados no lote Escala para lotes grandes Precisa de lote grande ou fila de momentum
Baseada em proxy (ProxyNCA) Apenas rótulos de classe Rápida, estável, sem mineração Pode sofrer overfitting nos proxies em conjuntos pequenos

Para a maioria dos casos de uso em produção, comece com um backbone pré-treinado e só adicione um fine-tune de aprendizado de métrica se os embeddings prontos para uso tiverem desempenho ruim no seu conjunto de teste.

A perda triplet formalmente

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

Puxe a âncora a para perto do positivo p, afaste-a do negativo n, com uma margin que garante um intervalo. A estrutura de três imagens generaliza para qualquer ordenação de similaridade.

A mineração importa: triplets fáceis (n já longe de a) contribuem com perda zero; somente triplets difíceis ensinam a rede. A mineração semi-difícil (n mais longe que p mas dentro da margem) é a receita do FaceNet de 2016 e ainda domina.

Similaridade de cosseno vs L2

Duas métricas, duas convenções:

  • Cosseno: ângulo entre vetores. Requer embeddings normalizados por L2.
  • L2: distância euclidiana. Funciona em embeddings brutos ou normalizados, mas geralmente é pareada com L2-normalizado + L2 ao quadrado.

Para a maioria das redes modernas as duas são equivalentes: ||a - b||^2 = 2 - 2 cos(a, b) quando ||a|| = ||b|| = 1. Escolha a convenção que combina com o treino do seu embedding; misturá-las muda silenciosamente o que "mais próximo" significa.

Recall@K

A métrica padrão de recuperação:

recall@K = fração de consultas em que pelo menos uma correspondência correta está nos K primeiros resultados

Reporte recall@1, @5, @10 lado a lado. Um recall@10 acima de 0,95 com recall@1 abaixo de 0,5 significa que o espaço de embeddings tem a estrutura certa, mas a ordenação é ruidosa — tente fine-tunes mais longos ou um passo de reordenação.

Para detecção de duplicatas, o precision@K importa mais porque cada falso positivo é um erro visível para o usuário. Para busca visual, o recall@K é o sinal de produto.

FAISS em um parágrafo

Facebook AI Similarity Search. A biblioteca de fato para busca de vizinhos mais próximos. Três escolhas de índice:

  • IndexFlatIP / IndexFlatL2 — força bruta, exato, sem treino. Use até ~1M de vetores.
  • IndexIVFFlat — particiona em K células, busca apenas as poucas células mais próximas. Aproximado, rápido, precisa de dados de treino.
  • IndexHNSW — baseado em grafo, mais rápido para muitas consultas, índice de tamanho grande.

Para 100k vetores você provavelmente quer IndexFlatIP com similaridade de cosseno. Para 10M você quer IndexIVFFlat. Para 100M+ combinado com quantização de produto (IndexIVFPQ).

Recuperação em nível de instância vs em nível de categoria

Dois problemas muito diferentes com o mesmo nome:

  • Nível de categoria — "encontre gatos no meu catálogo." Similaridade condicional à classe; embeddings prontos para uso de CLIP / DINOv2 funcionam bem.
  • Nível de instância — "encontre este produto exato no meu catálogo." Precisa de discriminação fina entre objetos visualmente similares da mesma classe; embeddings prontos para uso têm desempenho inferior; fine-tuning com aprendizado de métrica importa.

Sempre pergunte qual dos dois você está resolvendo antes de escolher um modelo.

Construa

Passo 1: Perda triplet

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()

Uma linha. Funciona em embeddings normalizados por L2 ou brutos.

Passo 2: Mineração semi-difícil

Dado um lote de embeddings e rótulos, encontre o negativo semi-difícil mais difícil para cada âncora.

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

Cada âncora recebe o positivo mais difícil dentro da classe e um negativo semi-difícil que está mais longe que o positivo mas dentro da margem.

Passo 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()

Top-k por produto interno em embeddings normalizados por L2 é igual a top-k por cosseno. Reporte a proporção média de consultas com pelo menos um vizinho correto.

Passo 4: Juntando tudo

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()

Após algumas centenas de passos os clusters de embedding formam um cluster por classe.

Use

Stacks de produção em 2026:

  • DINOv2 + FAISS — recuperação visual de propósito geral. Funciona pronto para uso.
  • CLIP + FAISS — quando as consultas são texto.
  • DINOv2 com fine-tune + FAISS — recuperação em nível de instância, reidentificação facial, moda, e-commerce.
  • Milvus / Weaviate / Qdrant — wrappers gerenciados de banco de dados vetorial em torno de FAISS ou HNSW.

Para recuperação de instância no estado da arte, a receita é: backbone DINOv2, adicione uma cabeça de embedding, faça fine-tune com uma perda triplet ou InfoNCE em pares rotulados por instância, indexe no FAISS.

Entregue

Esta lição produz:

  • outputs/prompt-retrieval-loss-picker.md — um prompt que escolhe triplet / InfoNCE / ProxyNCA para um dado problema de recuperação.
  • outputs/skill-recall-at-k-runner.md — uma skill que escreve um harness de avaliação limpo para recall@K com divisões train/val/gallery e contrato de dados adequado.

Exercícios

  1. (Fácil) Execute o exemplo de brinquedo acima. Plote os embeddings com PCA antes e depois do treino para ver os seis clusters se formarem.
  2. (Médio) Adicione uma implementação de perda ProxyNCA: um "proxy" aprendido por classe, cross-entropy padrão sobre a similaridade de cosseno. Compare a velocidade de convergência vs a perda triplet nos dados de brinquedo.
  3. (Difícil) Pegue 1.000 imagens de validação do ImageNet, gere embeddings com DINOv2 via HuggingFace, construa um índice flat no FAISS e reporte recall@{1, 5, 10} contra as mesmas imagens como consultas (deve ser 1,0) e contra uma divisão retida usando rótulos do ImageNet como verdade fundamental.

Termos-chave

Termo O que as pessoas dizem O que realmente significa
Aprendizado de métrica "Moldar o espaço" Treinar um encoder para que distâncias no seu espaço de saída reflitam uma similaridade-alvo
Perda triplet "Puxar e empurrar" L = max(0, d(a, p) - d(a, n) + margin); a perda canônica de aprendizado de métrica
Mineração semi-difícil "Negativos úteis" Negativos mais longe da âncora que o positivo mas dentro da margem; empiricamente os mais informativos
Perda baseada em proxy "Protótipos de classe" Um proxy aprendido por classe; cross-entropy sobre similaridade-com-proxies; sem mineração de pares
Recall@K "Taxa de acerto no Top-K" Fração de consultas com pelo menos um resultado correto nos K primeiros
Recuperação de instância "Encontre esta coisa exata" Correspondência fina; features prontas para uso geralmente têm desempenho inferior
FAISS "A biblioteca de NN" A biblioteca de vizinhos mais próximos do Facebook; suporta índices exatos e aproximados
HNSW "Índice de grafo" Hierarchical navigable small world; NN aproximado rápido com pouco overhead de memória

Leitura Adicional

0 lifetime access. Curriculum based on AI Engineering from Scratch by Rohit Ghumare (MIT, used under attribution).