TAI BUI
Back to curriculum
BuildPython

Image Retrieval & Metric Learning

A retrieval system ranks candidates by distance in embedding space. Metric learning is the discipline of shaping that space so the distances actually mean what you want them to.

TIME: ~45 minutesPREREQUISITES: Phase 4 Lesson 14 (ViT), Phase 4 Lesson 18 (CLIP)

Learning Objectives

  • Explain triplet, contrastive, and proxy-based metric learning losses and pick the right one for a given dataset
  • Implement L2-normalisation and cosine similarity correctly and audit the difference between "same item" and "same class" retrieval
  • Build a FAISS index, query it by text and by image, and report recall@K for a held-out query set
  • Use DINOv2, CLIP, and SigLIP as off-the-shelf embedding backbones and know when each wins

The Problem

Retrieval is everywhere in production vision: duplicate detection, reverse image search, visual search ("find similar products"), face re-identification, person re-ID for surveillance, instance-level matching for e-commerce. The product question never changes: given this query image, rank my catalogue.

Two design decisions shape the whole system. One is the embedding: what model produces the vectors. The other is the index: how you find nearest neighbours at scale. Both are commodity in 2026 (DINOv2 for the embedding, FAISS for the index), and that commoditization raises the bar instead of lowering it. The hard part is defining what counts as similar for your application, then shaping the embedding space so the distances actually match.

That shaping is metric learning — a small discipline that pays for itself many times over.

Warm-up0/3
1. For a visual search product where queries are text and gallery items are images, which backbone do you pick first?Warm-up
A box labelled query, text red running shoes, and a box labelled gallery, a catalogue of product photos, both with arrows down to a dashed box asking whether one backbone handles both.
2. Your photo app suggests 'similar photos' the moment you tap on one image, before you've told it what similar means. In plain terms, what must it be doing under the hood?Warm-up
A highlighted box labelled tapped photo next to five plain boxes labelled photo 1 through photo 5, above a dashed box noting no caption text or date was given and asking what the app is doing to pick which ones to show.
3. Which quantity measures the angle between two vectors, regardless of how long each vector is?Warm-up
Two arrows from one origin with an arc marking the angle between them, vector 1 short and vector 2 much longer, and a dashed box asking which quantity depends only on that angle and not on either length.
Check: The Problem0/1
1. What is metric learning?Define
Dots of two colors, photos of product A and photos of product B, overlapping in one embedding space, with an arrow to a dashed box asking what process acts on this space.

The Concept

Retrieval at a glance

Image retrieval means ranking a gallery of images by similarity to a query, then returning the closest matches first. The query can be an image, or plain text if the encoder maps both into one shared space (CLIP, SigLIP). Every system runs the same shape underneath: encode the query and the catalogue with the same encoder, drop both into a nearest-neighbour index, and rank candidates by distance.

Mermaid diagram

The encoder is the part that changes by model (DINOv2, CLIP, a fine-tune of either). The index is commodity infrastructure: FAISS, covered below. What decides whether the ranked results are actually useful is whether the embedding space puts the right things close together, and that's metric learning's job.

The four loss families

Four loss families cover most of how that space gets shaped:

LossRequiresProsCons
Contrastive(anchor, positive) + negativesSimple, works with any pair labelSlow to converge without many negatives
Triplet(anchor, positive, negative)Intuitive; direct margin controlHard-triplet mining is expensive
NT-Xent / InfoNCEPairs + batch-mined negativesScales to large batchesNeeds big batch or momentum queue
Proxy-based (ProxyNCA)Class labels onlyFast, stable, no miningCan overfit to proxies on small datasets

Start with a pretrained backbone for most production use cases, and only reach for a metric-learning fine-tune once the off-the-shelf embeddings underperform on your test set.

Triplet loss formally

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

Pull anchor a close to positive p, push it away from negative n, with a margin that keeps a gap between them. The three-image structure generalises to any similarity ordering.

Mining matters: easy triplets (n already far from a) contribute zero loss, so only hard triplets teach the network. Semi-hard mining (n further than p but still within margin) is the 2015 FaceNet recipe, and it still dominates.

Cosine similarity vs L2

Two metrics, two conventions:

  • Cosine: angle between vectors. Requires L2-normalised embeddings.
  • L2: Euclidean distance. Works on raw or normalised embeddings, but is usually paired with L2-normalised plus squared L2.

For most modern nets the two are equivalent: ||a - b||^2 = 2 - 2 cos(a, b) when ||a|| = ||b|| = 1. Pick the convention that matches your embedding training. Mixing them silently changes what "nearest" means.

Recall@K

The standard retrieval metric:

recall@K = fraction of queries where at least one correct match is in the top K results

Report recall@1, @5, @10 side by side. A recall@10 above 0.95 with recall@1 below 0.5 means the embedding space has the right structure, but the ranking inside it is noisy. Try longer fine-tunes or a re-ranking step.

For duplicate detection, precision@K matters more, since every false positive is a user-visible mistake. For visual search, recall@K is the product signal.

FAISS in one paragraph

Facebook AI Similarity Search. The de-facto library for nearest-neighbour search. Three index choices:

  • IndexFlatIP / IndexFlatL2: brute force, exact, no training. Use up to ~1M vectors.
  • IndexIVFFlat: partition into K cells, search only the closest few. Approximate, fast, needs training data.
  • IndexHNSW: graph-based, fastest for many queries, at the cost of index size.

For 100k vectors you probably want IndexFlatIP on cosine similarity. For 10M, IndexIVFFlat. Past 100M, add product quantisation (IndexIVFPQ).

Instance-level vs category-level retrieval

Two very different problems with the same name:

  • Category-level: "find cats in my catalogue." Class-conditional similarity; off-the-shelf CLIP / DINOv2 embeddings work well.
  • Instance-level: "find this exact product in my catalogue." Needs fine-grained discrimination between visually similar objects of the same class. Off-the-shelf embeddings underperform here; fine-tuning with metric learning is what closes the gap.

Always ask which one you're actually solving before picking a model.

Check: The Concept0/22
1. What does 'semi-hard mining' mean in triplet-loss training?Define
A distance axis from an anchor with the positive marked part way along and four unlabelled negative candidates placed at different distances, above a dashed box asking which candidate semi-hard mining picks.
2. What does triplet loss compare during training?Define
Three labelled points in an embedding space, an anchor, a positive and a negative, next to a dashed box asking what the loss compares among the three.
3. What is a proxy-based loss such as ProxyNCA?Define
Three groups of scattered dots in three colors, each with one dashed marker among its points, a note that there are three classes with one marker each, and a dashed box asking what the markers are and where they come from.
4. What does recall@K measure?Define
Five boxes in a row labelled rank 1 through rank 5 for one query, rank 3 marked match and the rest marked no match, above a dashed box asking what single number recall@K reports for this list.
Illustrative ranked list, not a captured run.
5. What is instance retrieval?Define
A highlighted query box labelled sneaker photo next to four catalogue boxes each labelled same model, above a dashed box asking what kind of retrieval problem this is.
6. What is FAISS?Define
A pipeline from an image or text box through an encoder box labelled produces vectors, into a dashed box, into a ranked list, with a note that the encoder already has a name and a question asking what the dashed box between it and the ranked list is called.
7. What is HNSW (hierarchical navigable small world)?Define
A sparse top row of circles linked down into a denser bottom row of circles, with a few longer links across the bottom, above a dashed box asking what kind of index this is and whether it is exact or approximate.
8. What shape do all image retrieval systems share underneath?Define
A box labelled query and a box labelled catalogue, both with arrows down into one wide dashed box marked with a question mark, which leads down to a box labelled ranked list.
9. In the triplet loss formula L = max(0, ||f(a) - f(p)||^2 - ||f(a) - f(n)||^2 + margin), what happens when the negative n is already much farther from the anchor than the positive p, beyond the margin?Explain
A distance axis from the anchor with the positive marked, a tick labelled positive plus margin, and the negative placed well beyond that tick, above a dashed box asking for the loss value in this case.
10. Why does triplet training bother with mining instead of sampling negatives at random?Explain
Two boxes, pick a negative at random, could land anywhere, and pick a negative by mining, deliberately chosen, each above its own dashed box asking whether a random pick always helps training and why bother picking at all.
11. Why does NT-Xent / InfoNCE need a big batch or a momentum queue?Explain
A row of four boxes labelled x1 to x4 under the heading batch of 4, a row of sixteen smaller boxes under the heading batch of 16, a note that the lesson says this loss needs the bigger one or a momentum queue, and a dashed box asking why this loss cares how many items are in the batch.
Illustrative batch sizes, not a captured run.
12. For unit-length embeddings, ||a - b||^2 = 2 - 2 cos(a, b). What does this imply for ranking neighbours?Explain
Two unit-length vectors a and b from one origin, drawn on a circle marking unit length, a code box stating the given identity squared L2 distance equals 2 minus 2 cosine, and a dashed box asking whether the two sides of that formula rank neighbours in the same order.
13. For duplicate detection, why does the lesson say precision@K matters more than recall@K?Explain
A box labelled duplicate detection, top result a wrong photo, and a box labelled visual search, a correct result sits at rank 8 of 10, each above its own dashed box, one asking how much the wrong top result costs and the other asking whether burying the hit at rank 8 still counts.
Illustrative result lists, not captured runs.
14. Between instance-level retrieval (find this exact car) and category-level retrieval (find cars), which requires metric-learning fine-tuning?Explain
A box labelled find this exact car, gallery of many similar cars, and a box labelled find cars, any of them, gallery of many different vehicles, above a dashed box asking which of the two needs fine-tuning.
15. What does the lesson recommend doing before reaching for a metric-learning fine-tune?Explain
A card listing what a metric-learning fine-tune costs, labelled pairs or triplets, mining the hard ones, and a training run on every change, above a dashed box asking what the lesson says to try before paying that cost.
16. You need nearest-neighbour search over 10 million image embeddings. Which FAISS index does the lesson suggest, and why not brute force?Apply
A badge reading collection size, 10,000,000 vectors, next to a dashed box asking whether a brute-force compare-every-vector index is practical at this scale, above a dashed strip asking which FAISS index fits and why not brute force.
17. Your retrieval system reports recall@10 = 0.95 but recall@1 = 0.42. What should you conclude?Analyze
A bar chart with recall@1 at 0.42 and recall@10 at 0.95 for the same evaluation, above a dashed box asking what the gap between the two numbers means.
18. A team trains an embedding, indexes it in FAISS, and reports recall@1 = 0.99 against the same gallery the model trained on. In its first week of real traffic the system falls apart. What is the most likely cause?Analyze
A box labelled training gallery linked by a dashed equals sign to a box labelled evaluation gallery, noted as the same set, an arrow down to a badge reading recall@1, 0.99, and an arrow to a dashed box asking what happens in the first week of real traffic.
19. The spec says 'find this exact bag'. The team ships off-the-shelf CLIP embeddings, the demo looks great, and return complaints start a month later. What is the diagnosis?Analyze
A box labelled spec, find this exact bag, and a box labelled shipped, off-the-shelf CLIP, a note that the demo looks great but return complaints start a month later, and a dashed box asking for the diagnosis.
20. A team trains with ProxyNCA on a small dataset. Training classes cluster tightly, but retrieval on held-out images is weak. Which known weakness fits this?Analyze
Two tight, separate clusters of dots labelled training tight clusters, next to two overlapping, spread-out clusters labelled held-out retrieval is weak, above a dashed box asking which known weakness fits this pattern.
Illustrative clusters, not a captured training run.
21. You must build search over a 100k-image catalogue that accepts both image and text queries, and you have no labelled training pairs. Which design fits?Design
A setup card listing a 100,000 image catalogue, queries that can be either an image or text, and no labelled training pairs available, above a dashed box asking which backbone-plus-index combination fits all three.
22. Your dataset has many images per class and only class labels, and you have no budget for pair or triplet mining. Which loss family fits best?Design
A setup card listing many images per class, class labels only with no instance pairs, and no mining budget, above a dashed box asking which loss family fits all three constraints.

Build It

Step 1: Triplet loss

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

One line. Works on L2-normalised or raw embeddings.

Step 2: Semi-hard mining

Given a batch of embeddings and labels, find the hardest semi-hard negative for each anchor.

python
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

Each anchor gets the hardest positive in-class and a semi-hard negative that is further than the positive but within margin.

Step 3: Recall@K

python
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 by inner product on L2-normalised embeddings equals top-k by cosine. Report the mean proportion of queries with at least one correct neighbour.

Step 4: Putting it together

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

After a few hundred steps the embedding clusters form one cluster per class.

Check: Build It0/8
1. The lesson's `recall_at_k` function computes `sim = query_emb @ gallery_emb.T` and takes the top-k. For this matmul to rank the same as cosine similarity, what must be true of `query_emb` and `gallery_emb`?Explain
A code box showing recall_at_k's matmul line sim equals query_emb at gallery_emb transpose, highlighted, above two boxes labelled query_emb, the query vectors, and gallery_emb, the stored vectors, and a line asking what both must satisfy for the matmul to rank like cosine similarity.
2. Using the lesson's `triplet_loss` with margin=0.2, one triplet has d_ap = 0.5 and d_an = 0.9. What does the function return?Apply
The lesson's triplet_loss function with its return line highlighted, a note giving one triplet's distances as d_ap 0.5, d_an 0.9, margin 0.2, and a dashed box asking what the function returns.
3. Using the lesson's `triplet_loss` with margin=0.2, one triplet has d_ap = 0.5 and d_an = 0.6. What does the function return?Apply
The lesson's triplet_loss function with its return line highlighted, a note giving one triplet's distances as d_ap 0.5, d_an 0.6, margin 0.2, and a dashed box asking what the function returns.
4. Two queries have labels [A, B]. With k=1, the labels of their top-1 gallery matches are [A, C]. What does `recall_at_k` return?Apply
A code box showing recall_at_k with k=1, below it two boxes giving the two queries' labels and their top-1 match labels, A matched to A and B matched to C, and a dashed strip asking what recall_at_k returns.
5. In `semi_hard_negatives`, which positive does each anchor get?Apply
A code box with the three lines that mask out different classes and the diagonal, a dashed line standing in for the reduction that follows, a note giving one anchor's same-class distances as 0.2, 0.7 and 0.3, and a dashed box asking which of the three becomes the positive.
6. In `semi_hard_negatives` with margin=0.2, an anchor's chosen positive is at distance 0.4. Its different-class items sit at distances 0.3, 0.5, and 0.9. Which one becomes its negative?Apply
A distance axis from the anchor with the chosen positive marked at 0.4 and the three different-class candidates marked at 0.3, 0.5 and 0.9 in value order, the margin of 0.2 noted, and a dashed box asking which candidate becomes the negative.
7. In the lesson's `Encoder` (emb_dim=64), what is the L2 norm of each row returned by `enc(x)`?Apply
A flow from x with shape B by 128 through self.net, a Linear, ReLU, Linear stack, to self.net(x) with shape B by 64, then a dashed box with a question mark, a note that forward does one more thing before returning, and a dashed box asking the L2 norm of each row of enc(x).
8. In the lesson's `semi_hard_negatives` function, what happens for an anchor when no negative farther than its positive exists?Apply
A code box showing the semi-hard filter, the argmin that follows, and a highlighted fallback check whose body is left as an ellipsis, a note that for one anchor no other-class item is farther than its positive, and a dashed box asking what neg_idx becomes for that anchor.

Use It

Production stacks in 2026:

  • DINOv2 + FAISS: general-purpose visual retrieval. Works off-the-shelf.
  • CLIP or SigLIP + FAISS: for text queries. Off-the-shelf, the two are close in quality; SigLIP's per-pair loss (Lesson 18) makes it the better pick if you fine-tune your own encoder under a small batch size.
  • Fine-tuned DINOv2 + FAISS: instance-level retrieval, face re-ID, fashion, e-commerce.
  • Milvus / Weaviate / Qdrant: managed vector DB wrappers around FAISS or HNSW.

For SOTA instance retrieval, the recipe is: DINOv2 backbone, add an embedding head, fine-tune with a triplet or InfoNCE loss on instance-labelled pairs, index in FAISS.

Check: Use It0/2
1. You plan to fine-tune your own text-image encoder with a small batch size. Which backbone family does the lesson favour, and why?Explain
A box labelled fine-tune your own text-image encoder next to a badge reading batch size, small, above a dashed box asking which backbone family handles this condition best.
2. You are building face re-identification and have instance-labelled pairs. Which approach matches the lesson's recipe for state-of-the-art instance retrieval?Design
A setup card listing face re-identification, instance-labelled pairs available, and a goal of state-of-the-art instance retrieval, above a dashed box asking which backbone, loss and index make up the recipe.

Ship It

This lesson produces:

  • outputs/prompt-retrieval-loss-picker.md: a prompt that picks triplet / InfoNCE / ProxyNCA for a given retrieval problem.
  • outputs/skill-recall-at-k-runner.md: a skill that writes a clean evaluation harness for recall@K with train/val/gallery splits and proper data contract.
Author's Note

The mistake I see most often here isn't in the loss function, it's in the eval. Someone trains a solid embedding, wires it into FAISS, and reports recall@K against the same gallery the model trained on instead of a genuinely held-out split. Recall@1 comes back at 0.99, everyone's happy — until the model falls apart the first week it sees real traffic, because the real test never ran.

The other trap is conflating instance retrieval with category retrieval. If the spec says "find this exact bag," and you ship a CLIP embedding tuned for "find bags like this," the demo looks great and the return-rate complaints start a month later. Decide which problem you're actually solving before you pick a backbone, not after you've already shipped one.

Exercises

  1. (Easy) Run the toy example above. Plot the embeddings with PCA before and after training to see the six clusters form.
  2. (Medium) Add a ProxyNCA loss implementation: one learned "proxy" per class, standard cross-entropy on cosine similarity. Compare convergence speed vs triplet loss on the toy data.
  3. (Hard) Take 1,000 ImageNet validation images, embed with DINOv2 via HuggingFace, build a FAISS flat index, and report recall@{1, 5, 10} against the same images as queries (should be 1.0) and against a held-out split with ImageNet labels as ground truth.

Key Terms

TermWhat people sayWhat it actually means
Metric learning"Shape the space"Training an encoder so distances in its output space reflect a target similarity
Triplet loss"Pull and push"L = max(0, d(a, p) - d(a, n) + margin); the canonical metric-learning loss
Semi-hard mining"Useful negatives"Negatives further from the anchor than the positive but within margin; empirically the most informative
Proxy-based loss"Class prototypes"One learned proxy per class; cross-entropy over similarity-to-proxies; no pair mining
Recall@K"Top-K hit rate"Fraction of queries with at least one correct result in the top K
Instance retrieval"Find this exact thing"Fine-grained matching; off-the-shelf features usually underperform
FAISS"The NN library"Facebook's nearest-neighbour library; supports exact and approximate indexes
HNSW"Graph index"Hierarchical navigable small world; fast approximate NN, at the cost of a larger index (graph links on top of the vectors)

Further Reading