Search…

Multimodal Vision: CLIP, Embeddings, and Retrieval Systems

In this series (30 parts)
  1. Computer Vision Roadmap: From Basics to Real Projects
  2. What is Computer Vision? From Pixels to Decisions
  3. A Short History of Computer Vision: 1545 to Now
  4. Math for CV Beginners: Vectors, Matrices, Convolutions
  5. Image Fundamentals: Color, Histograms, Noise, Filtering
  6. How Images and Video Are Stored: Colour, JPEG, Frames
  7. OpenCV Setup + First 10 Tasks in Python
  8. Vision Metrics: Accuracy, Precision, Recall, mAP, IoU
  9. CV Project Workflow: Dataset, Baseline, Error Iteration
  10. Intensity Transforms and Frequency-Domain Filtering
  11. Edge Detection and Thresholding That Actually Work
  12. Morphology, Contours, and Shape Analysis for Real Images
  13. Feature Matching (SIFT/ORB) and Image Stitching
  14. Camera Calibration and Perspective Correction
  15. Epipolar Geometry, Stereo Vision, and Depth Estimation
  16. Optical Flow and Motion Tracking in Video
  17. HOG, HOF and MBH: Descriptors Before Deep Learning
  18. Your First Image Classifier (PyTorch + Transfer Learning)
  19. Data Pipelines and Augmentation for Vision Models
  20. CNN Architectures Explained: From LeNet to ResNet
  21. YOLO Detection Pipeline: Data to Inference
  22. Semantic and Instance Segmentation: U-Net to Mask R-CNN
  23. Vision Transformers (ViT) and When to Use Them
  24. CV Explainability: Grad-CAM, Failures, Bias Checks
  25. 3D Vision Basics: SfM, Point Clouds, and Pose Estimation
  26. Multimodal Vision: CLIP, Embeddings, and Retrieval Systems
  27. Video Understanding: Flow Networks, Interpolation, Stabilisation
  28. Real-Time CV Systems: Tracking, Latency, Streaming
  29. Deploying CV Models: ONNX, TensorRT, Edge, and APIs
  30. Responsible CV: Privacy, Fairness, Security, Governance

A clothing retailer has 400,000 product photos and a search box that only matches the words someone typed into a spreadsheet three years ago. Search for “beige linen shirt” and you get nothing, because the product was tagged “sand cotton-blend top”. Every classifier so far needed a fixed list of classes and labelled examples for each. This problem has no fixed list.

Prerequisites: Vision transformers and your first image classifier.

One space for two things

The trick is a single vector space that both images and sentences map into.

joint embedding space A vector space where an image of a thing and a description of that thing land near each other, so distance between them is meaningful even though they started as different kinds of data.

Once both live in one space, search is arithmetic. Encode the query sentence, compare it against every stored image vector, sort.

Words with similar meanings cluster together in embedding space. Analogies work as vector arithmetic: the direction from "man" to "woman" is similar to the direction from "king" to "queen".

Cosine similarity, computed

Similarity is the cosine of the angle between two vectors:

sim(a,b)=abab\text{sim}(a, b) = \frac{a \cdot b}{\|a\|\,\|b\|}

Take a 4-dimensional image embedding and three candidate text embeddings.

a=[3,  4,  0,  0],a=9+16=5a = [3,\; 4,\; 0,\; 0], \qquad \|a\| = \sqrt{9 + 16} = 5 a^=[0.600,  0.800,  0.000,  0.000]\hat{a} = [0.600,\; 0.800,\; 0.000,\; 0.000]

Candidate 1 — “a photo of a beige linen shirt”:

t1=[2,  3,  1,  0],t1=4+9+1=14=3.7417t_1 = [2,\; 3,\; 1,\; 0], \qquad \|t_1\| = \sqrt{4+9+1} = \sqrt{14} = 3.7417 t^1=[0.5345,  0.8018,  0.2673,  0.0000]\hat{t}_1 = [0.5345,\; 0.8018,\; 0.2673,\; 0.0000] a^t^1=0.600(0.5345)+0.800(0.8018)=0.3207+0.6414=0.9621\hat{a} \cdot \hat{t}_1 = 0.600(0.5345) + 0.800(0.8018) = 0.3207 + 0.6414 = \mathbf{0.9621}

Candidate 2 — “a photo of a leather boot”:

t2=[0,  1,  4,  2],t2=0+1+16+4=21=4.5826t_2 = [0,\; 1,\; 4,\; 2], \qquad \|t_2\| = \sqrt{0+1+16+4} = \sqrt{21} = 4.5826 t^2=[0.0000,  0.2182,  0.8729,  0.4364]\hat{t}_2 = [0.0000,\; 0.2182,\; 0.8729,\; 0.4364] a^t^2=0.800(0.2182)=0.1746\hat{a} \cdot \hat{t}_2 = 0.800(0.2182) = \mathbf{0.1746}

Candidate 3 — “a photo of a garment”:

t3=[1,  1,  1,  1],t3=2,t^3=[0.5,  0.5,  0.5,  0.5]t_3 = [1,\; 1,\; 1,\; 1], \qquad \|t_3\| = 2, \qquad \hat{t}_3 = [0.5,\;0.5,\;0.5,\;0.5] a^t^3=0.600(0.5)+0.800(0.5)=0.7000\hat{a} \cdot \hat{t}_3 = 0.600(0.5) + 0.800(0.5) = \mathbf{0.7000}

# Caption Cosine similarity target Rank
1 a photo of a beige linen shirt 0.9621 1
2 a photo of a garment 0.7000 2
3 a photo of a leather boot 0.1746 3

The specific correct caption wins, the vague-but-true caption comes second, the wrong one is far behind.

The practical shortcut. If you normalise every vector to unit length once, when you store it, then cosine similarity is exactly the dot product — no division at query time. Every production system does this, and it is the single most useful implementation detail in retrieval.

emb = model.encode_image(images)
emb = emb / emb.norm(dim=-1, keepdim=True)   # do this ONCE, at index time
# later: similarity is just a matmul
scores = query_emb @ index.T

Zero-shot classification

There is no separate mechanism for this. You compare the image against sentences instead of against stored images.

labels = ["shirt", "trousers", "shoes", "bag"]
prompts = [f"a photo of a {c}, a type of clothing." for c in labels]
text_emb = model.encode_text(clip.tokenize(prompts))
text_emb = text_emb / text_emb.norm(dim=-1, keepdim=True)
probs = (100.0 * image_emb @ text_emb.T).softmax(dim=-1)

The 100.0 is the inverse temperature — CLIP learns it during training and it lands near 100.

What temperature does

Take the three similarities above, 0.9621, 0.1746, 0.7000, and softmax them at different temperatures.

At τ=1\tau = 1: logits are the raw similarities.

e0.9621=2.6172,e0.1746=1.1908,e0.7000=2.0138,sum=5.8218e^{0.9621} = 2.6172, \quad e^{0.1746} = 1.1908, \quad e^{0.7000} = 2.0138, \quad \text{sum} = 5.8218 probs=[0.4496,  0.2045,  0.3459]\text{probs} = [0.4496,\; 0.2045,\; 0.3459]

At τ=0.1\tau = 0.1: logits become 9.621, 1.746, 7.000. Subtracting the max for stability gives 0, −7.875, −2.621.

e0=1,e7.875=0.000381,e2.621=0.07270,sum=1.07308e^{0} = 1, \quad e^{-7.875} = 0.000381, \quad e^{-2.621} = 0.07270, \quad \text{sum} = 1.07308 probs=[0.9319,  0.000355,  0.06775]\text{probs} = [0.9319,\; 0.000355,\; 0.06775]

# Temperature τ P(shirt) target P(boot) P(garment) Character
1 1.00 0.4496 0.2045 0.3459 soft, barely committed
2 0.50 0.6172 0.1200 0.2628 leaning
3 0.10 0.9319 0.000355 0.06775 confident (CLIP's regime)
4 0.01 ≈1.000 ≈0 ≈0 effectively argmax

Same three similarities, four temperatures. Cosine scores are all crammed into a narrow band, so a low temperature is needed to separate them.

Temperature never changes the ordering, so it never changes top-1 accuracy. It changes how confident the numbers look, which matters whenever a threshold controls a decision. This is the same idea as the temperature scaling used for calibration.

Prompt wording matters

# Prompt template ImageNet zero-shot top-1 target Gain
1 "{label}" 58.2%
2 "a photo of a {label}" 62.1% +3.9
3 "a photo of a {label}, a type of clothing." 63.4% +5.2
4 Ensemble of 80 templates, averaged 64.9% +6.7

Approximate figures from the CLIP paper. Rewording the prompt is worth several points, for free.

The reason is mundane. CLIP was trained on image–caption pairs scraped from the web, and web captions are sentences, not bare nouns. A bare noun is out of distribution for the text encoder. Wrapping it in a sentence puts it back in.

How CLIP is trained

Loss 2.80
The anchor and its augmented positive view should become nearby in embedding space, while negatives are pushed away.

Take a batch of NN image–caption pairs. Encode all of them, giving an N×NN \times N similarity matrix. The diagonal entries are the true pairs. Every off-diagonal entry is a wrong pairing, and the loss pushes the diagonal up and everything else down.

InfoNCE loss Cross-entropy over the similarity row: the correct pairing must beat all the other captions in the batch. Every other item in the batch acts as a negative example.

Li=logexp(sii/τ)j=1Nexp(sij/τ)\mathcal{L}_i = -\log \frac{\exp(s_{ii}/\tau)}{\sum_{j=1}^{N} \exp(s_{ij}/\tau)}

Worked, for one row of a batch of 4. Cosine similarities of image 1 against all four captions, with τ=0.1\tau = 0.1:

# Caption Cosine ÷ τ exp target Is it the true pair?
1 1 (true pair) 0.92 9.2 9897.1 yes
2 2 0.31 3.1 22.20 no
3 3 0.18 1.8 6.05 no
4 4 0.25 2.5 12.18 no

Row 1 of the similarity matrix. The true pair's exponential dominates.

sum=9897.1+22.20+6.05+12.18=9937.5\text{sum} = 9897.1 + 22.20 + 6.05 + 12.18 = 9937.5 p=9897.19937.5=0.99593p = \frac{9897.1}{9937.5} = 0.99593 L1=ln(0.99593)=0.00408\mathcal{L}_1 = -\ln(0.99593) = 0.00408

Nearly zero loss — this pair is already well learned. Now a badly learned row, where the true pair scores 0.35 and a distractor scores 0.28:

[2.8,  3.5,  3.0,  2.2]    [16.445,  33.115,  20.086,  9.025],sum=78.671[2.8,\; 3.5,\; 3.0,\; 2.2] \;\rightarrow\; [16.445,\; 33.115,\; 20.086,\; 9.025], \quad \text{sum} = 78.671 p=33.11578.671=0.42094,L=ln(0.42094)=0.8653p = \frac{33.115}{78.671} = 0.42094, \qquad \mathcal{L} = -\ln(0.42094) = 0.8653

A loss 212 times larger, so the gradient goes overwhelmingly to the pairs the model has not learned.

Similarity matrix (diagonal = true pairs) (4×4)
0.92
0.31
0.18
0.25
0.28
0.35
0.3
0.22
0.21
0.19
0.88
0.24
0.3
0.26
0.23
0.79

Row 2 is the weak one — its diagonal, 0.35, barely beats the 0.30 next to it. Rows 1, 3, and 4 have diagonals far above their neighbours and contribute almost nothing to the gradient.

What a real similarity matrix looks like

The matrix above uses round numbers to make the arithmetic readable. A real trained CLIP model produces something that surprises people the first time they see it.

Here is a zero-shot run: seven images, eight candidate captions, cosine similarity for every pair. The correct pairing for each image is on the diagonal.

The diagonal values are around 0.30. Off-diagonal values are around 0.19. Correct pairings win by roughly 0.10, not by 0.6.

This also explains why the temperature parameter is so important. Dividing by τ=0.01\tau = 0.01 turns a 0.30-versus-0.19 gap into 30 versus 19, which after softmax is a decisive difference. Without it, the softmax over raw cosines would be nearly uniform and the loss would carry almost no signal.

Why batch size matters so much

Each row has N1N - 1 negatives, so the batch size is the difficulty of the task.

# Batch size Negatives per sample target Task difficulty Note
1 256 255 easy Random captions are trivially distinguishable
2 1024 1023 moderate
3 8192 8191 hard Some genuinely similar items now compete
4 32768 32767 very hard CLIP's actual batch size

Contrastive learning is one of the few settings where batch size is a genuine model-quality decision, not just a throughput one.

With 255 random negatives, telling a shirt from a boot is easy and the model learns coarse categories. With 32,767, the batch contains other shirts, and the model must learn what distinguishes beige linen from white cotton. That is where the fine-grained ability comes from.

Building the search system

Why brute force stops working

Store 400,000 vectors of 512 dimensions as float32:

400,000×512×4=819,200,000 bytes=819.2 MB400{,}000 \times 512 \times 4 = 819{,}200{,}000 \text{ bytes} = 819.2 \text{ MB}

Every query is a dot product against all of them:

400,000×512=204,800,000 multiply-adds400{,}000 \times 512 = 204{,}800{,}000 \text{ multiply-adds}

# Method Index size Query latency target Recall@10 When
1 Brute force (float32) 819 MB ~40 ms 1.000 under ~100k vectors
2 HNSW 1.3 GB ~1.2 ms 0.98 the usual default
3 IVF-PQ (64 bytes/vec) 26 MB ~0.8 ms 0.91 when memory is the constraint
4 Binary hashing 3.2 MB ~0.3 ms 0.74 first stage of a two-stage system

400,000 vectors of 512 dimensions. IVF-PQ is 31x smaller than brute force and loses 9 points of recall.

recall@k Of the items that should have been in the top k, what fraction actually were. At k=10 with 20 relevant items and 7 found, recall@10 = 7/20 = 0.35.

Precision and recall at kk answer different questions and both matter:

P@10=710=0.700R@10=720=0.350P@10 = \frac{7}{10} = 0.700 \qquad R@10 = \frac{7}{20} = 0.350

Precision@10 says 7 of the 10 results shown were good, which is what the user experiences. Recall@10 says only a third of the relevant catalogue made it onto page one, which is what the merchandising team cares about.

PropertyCLIP zero-shotFine-tuned classifierKeyword search
Labelled data needednonehundreds per classmanual tags
New category at 3amtype a sentencecollect data, retrainre-tag everything
Accuracy on a fixed known setmoderatehighdepends on tag quality
Handles unusual phrasingyesn/ano
Explains its rankingsimilarity score onlyclass probabilitiesyes, exact matches
Cost to add a classzerodayshours of tagging
Three ways to make a catalogue searchable. They combine well.

Practice task

Use open_clip and a few thousand images from any public dataset.

  1. Encode 2,000 images. Confirm every embedding has unit norm after normalising.
  2. Verify that the dot product of two normalised vectors matches cosine_similarity exactly.
  3. Run text queries and inspect the top 10. Note which query wordings work best.
  4. Run zero-shot classification with bare labels, then with "a photo of a {label}". Compare accuracy.
  5. Build a template ensemble of 5 prompts per class by averaging their embeddings. Measure the gain.
  6. Sweep the temperature over 1, 0.5, 0.1, 0.01. Confirm accuracy does not change but confidence does.
  7. Build 30 queries with hand-labelled relevant sets. Compute precision@10 and recall@10.
  8. Build an HNSW index with FAISS. Measure latency and recall@10 against brute force.

Step 6 is the one that fixes a common misunderstanding. Watching the probabilities move dramatically while accuracy stays fixed makes it clear that temperature is a presentation choice, not a modelling one.

Summary

CLIP puts images and sentences in one 512-dimensional space, so search is a cosine similarity. The correct caption scored 0.9621, a vague-but-true one 0.7000, and a wrong one 0.1746. Normalise once at index time and every query becomes a plain dot product.

Zero-shot classification is the same operation aimed at label sentences. Temperature sharpens the softmax without ever changing the ranking: at τ=1 the top class got 0.450, at τ=0.1 it got 0.932. Wrapping labels in "a photo of a {label}" is worth about 4 points, and a template ensemble another 3.

Training uses InfoNCE over an N×NN \times N similarity matrix. A well-learned row gave loss 0.00408; a poorly learned one gave 0.8653, 212 times more, so the gradient concentrates where the model is weak. Batch size is the number of negatives, which is why CLIP trained at 32,768.

Four hundred thousand 512-d float32 vectors are 819 MB and 205M multiply-adds per query. HNSW is 33× faster at 0.98 recall; IVF-PQ is 31× smaller at 0.91.

What comes next

One note on the other direction. CLIP learns a shared image–text space in order to match them. Run the same idea generatively — condition an image generator on a text embedding — and you get DALL·E and the diffusion models that followed. They are siblings from the same insight: once images and text live in one space, you can move in either direction between them. Retrieval finds an existing image for your sentence; generation makes one.

Retrieval at 1.2 ms per query is fast because it is one matrix operation over precomputed vectors. Live video is a harder problem: frames arrive whether or not you are ready. Real-time computer vision systems covers latency budgets, what happens when the queue fills up, and why average FPS is the wrong thing to measure.

Test your understanding
Your CLIP-based search works well on product photos but returns near-random results on X-ray inspection images. Why?
Test your understanding
You lower the temperature from 1.0 to 0.05 and top-1 accuracy does not move at all, though the probabilities look far more decisive. Is something broken?

Frequently asked questions

Which CLIP model should I use?
ViT-B/32 is the sensible default: fast, small, and good enough for most retrieval. ViT-L/14 is meaningfully more accurate at roughly four times the compute, which is worth it if quality drives revenue. Check open_clip's benchmark table rather than guessing, and note that checkpoints trained on LAION-2B generally beat the original OpenAI weights on retrieval tasks.
Can I fine-tune CLIP on my own data?
Yes, and it helps a lot on specialised domains, but you need image-text pairs rather than image-label pairs. Product titles, alt text, and captions all work as the text side. Use a small learning rate around 1e-5 and watch for catastrophic forgetting — the general ability that made CLIP useful can disappear within an epoch if the learning rate is too high or the dataset too narrow.
How do I combine embedding search with filters?
Retrieve more than you need, then filter. Ask the index for the top 200, apply your in-stock, size, and price constraints, and show the top 24 of what survives. Filtering before search means the index cannot use its graph structure efficiently, and most vector databases support this pattern natively as pre-filtered or post-filtered search. Check which one yours does, because pre-filtering can silently degrade recall.
What embedding dimension should I use?
Whatever your chosen model produces — 512 for ViT-B/32, 768 for ViT-L/14. You can reduce it afterwards with PCA if memory is tight, and dropping from 512 to 128 typically costs only a couple of points of recall while cutting the index by four. Matryoshka-style embeddings, which are trained so that any prefix is a valid shorter embedding, make this trade-off free where they are available.
Does CLIP handle counting or spatial relationships?
Poorly, and this is one of its best-documented weaknesses. 'Three red cars' and 'red cars' embed almost identically, and so do 'a cat on a mat' and 'a mat on a cat'. Web captions rarely depend on exact counts or precise arrangement, so the training signal for those was never there. If your queries need counting or spatial reasoning, use a detector for the counting and an embedding for the rest.
Start typing to search across all content
navigate Enter open Esc close