ml-models / Retrieval and recommendation / Similarity search intro

Similarity search

Given a query vector, return the stored vectors most like it. This page covers exact search by brute force, the three similarity measures (dot product, cosine, Euclidean distance) and when they give the same ranking, why embeddings are normalized, what brute force costs, BM25 as the keyword baseline, and how hybrid search merges the two.

On this page
  1. Problem
  2. Intuition
  3. Mechanics
  4. Three measures and when they agree
  5. Exact search by brute force
  6. BM25, the lexical baseline
  7. Hybrid search
  8. Worked example
  9. Implementation
  10. Tradeoffs
  11. Connections
  12. Read first
  13. Read next
  14. References

Problem

An embedding model turns documents, images or products into vectors so that related items land near each other. Retrieval then becomes a geometry question: which of the n stored vectors are closest to the query vector? Answering it means picking a measure of "closest", computing it against the stored set fast enough, and knowing when the plain old keyword search it replaced still does better.

Intuition

A vector has a direction and a length. Cosine similarity looks only at direction (the angle between query and document). The dot product looks at direction and length together, so a long vector can win even when it points somewhat away. Euclidean (L2) distance looks at where the tip of the vector lands, so a short vector near the query's tip wins. Three measures, three different rankings, until every vector is scaled to length 1. Then length stops mattering and all three agree.

The diagram plots the query q = (1.0, 0.5) and five documents. Their unit versions sit on the dashed unit circle.

Five documents ranked by dot product, cosine and L2 distance Left: the query 1, 0.5 and documents A at 4, 1, B at 1, 1, C at 0.9, 0.3, D at 2, 2.4 and E at 0.2, 1, drawn as arrows from the origin, with their unit-length versions marked on a dashed unit circle. Right: the rankings. Dot product ranks A, D, B, C, E. Cosine ranks C, A, B, D, E. L2 distance ranks C, B, E, D, A. After normalizing every vector, all three rank C, A, B, D, E. 1 A B C D E q Small squares: the unit-length versions. Measure Ranking dot product q·x A D B C E cosine C A B D E L2 distance C B E D A any of them, unit vectors C A B D E Raw: the top results are A, C and C, and the three orders all differ. Normalized: one order.

Mechanics

Three measures and when they agree

For a query q and a stored vector x:

dot(q, x) = Σj qj xj     cos(q, x) = q·x / (‖q‖ ‖x‖)     L2(q, x) = ‖q − x‖

Expanding the squared distance ties them together:

‖q − x‖² = ‖q‖² + ‖x‖² − 2 q·x

  • Ranking by L2 is ranking by dot product only when every stored vector has the same length. ‖q‖² is the same for every candidate, so it never changes the order, but ‖x‖² does. A long vector pays a larger ‖x‖² penalty under L2 and gets a larger dot product, so the two measures pull opposite ways.
  • Cosine is the dot product of the normalized vectors. On unit vectors ‖q‖ = ‖x‖ = 1, so cos(q, x) = q·x and ‖q − x‖² = 2 − 2 cos(q, x). Smallest distance, largest cosine and largest dot product are then the same item.
  • Maximum inner product search (MIPS) is the name for ranking by raw dot product when lengths differ. Some retrievers train for it on purpose and let the length carry a "popularity" or "quality" signal. Then L2 and cosine indexes give the wrong answer.

That is why the usual recipe is to L2-normalize every embedding once at indexing time and the query at search time (PyTorch's F.normalize, scikit-learn's normalize), then rank by dot product. The search becomes one matrix-vector product, and the choice between cosine and L2 disappears. Whether to normalize is a property of the model, not of the search: use the measure the embedding model was trained with.

Exact search by brute force

Stack the n stored vectors as the rows of a matrix X of shape (n, d). Scoring a query is s = Xq, a matrix-vector product costing 2nd FLOPs. The top k of s takes O(n log k) with a heap, or a partial selection (torch.topk, numpy.argpartition). For a batch of m queries, XQT is a full matrix multiply. That is exact search: the answer is correct by construction, and it is the ground truth that approximate indexes are graded against (their recall@k).

L2 distance runs on the same matrix multiply. scikit-learn's euclidean_distances computes −2XYT + ‖x‖² + ‖y‖² and clamps negatives to zero; its docstring warns that this expansion can suffer catastrophic cancellation. torch.cdist uses the same matrix-multiply route for p = 2 once either side has more than 25 rows, unless compute_mode says otherwise.

NearestNeighbors wraps the whole thing. With algorithm="auto" it picks brute force when the data has more than 15 features, so for embeddings it is brute force in practice (the trees from nearest neighbors stop pruning in high dimensions). Its metric="cosine" works only with brute force.

ComponentShape/ParamsMeaningSource
nn.PairwiseDistance p=2.0, eps=1e-6, keepdim=False; (N, D), (N, D) → (N) Row-by-row ‖x1 − x2 + ε‖p: matched pairs, not all pairs. distance.py 10-61
nn.CosineSimilarity dim=1, eps=1e-8 Calls F.cosine_similarity along dim. distance.py 64-100
F.cosine_similarity broadcast x1, x2; dim squeezed x1·x2 / (max(‖x1‖, ε) · max(‖x2‖, ε)): each norm is clamped on its own, so a zero vector gives 0, not NaN. functional.py 5942-5971
F.normalize p=2.0, dim=1, eps=1e-12 v / max(‖v‖p, ε): scale each row to unit length before indexing. functional.py 6143-6177
torch.cdist (P, M), (R, M) → (P, R) All-pairs p-norm distance. For p = 2 it switches to the matrix-multiply expansion when P or R exceeds 25. functional.py 1483-1541
NearestNeighbors n_neighbors=5, radius=1.0, algorithm="auto", leaf_size=30, metric="minkowski", p=2 Unsupervised neighbor search: kneighbors returns distances and indices, nearest first. _unsupervised.py 10-179
algorithm="auto" d > 15 → brute Brute force for precomputed metrics, more than 15 features, or k ≥ n/2; otherwise a tree. _base.py 615-648
euclidean_distances (nX, d), (nY, d) → (nX, nY) −2XYT + XX + YY, clamped at 0, then a square root unless squared=True. pairwise.py 376-427
cosine_similarity (nX, d), (nY, d) → (nX, nY) Normalize both sides, then one dot product: equivalent to linear_kernel on L2-normalized data. pairwise.py 1703-1758
normalize norm="l2", axis=1 Scale each row to unit norm (also "l1" and "max"). _data.py 1978-2089
TfidfTransformer.fit smooth_idf=True idf(t) = ln((1 + n) / (1 + df(t))) + 1: the classic lexical weighting that BM25 refines. text.py 1675-1695

BM25, the lexical baseline

Before embeddings, search engines ranked documents by the query words they contain. BM25 (Okapi BM25) is still the standard version, and still a hard baseline to beat on queries full of names, codes and rare terms. For query terms t, a document D of length |D|, and an average length avgdl over the N documents:

score(D, Q) = Σt ∈ Q idf(t) · tf(t, D)·(k1 + 1) / (tf(t, D) + k1·(1 − b + b·|D|/avgdl))

idf(t) = ln((N − df(t) + 0.5) / (df(t) + 0.5) + 1)

  • idf rewards rare terms: a word in every document carries no information. The "+ 1" inside the log is Lucene's variant, which keeps idf positive even for terms in more than half the documents.
  • Saturation, k1 (usually 1.2 to 2). The term-frequency factor rises from 1 at tf = 1 toward k1 + 1 and never passes it, so the tenth "cat" adds far less than the second.
  • Length normalization, b (usually 0.75). A long document contains more words by chance, so its term frequencies are discounted by |D|/avgdl. b = 0 turns this off, b = 1 normalizes fully.

BM25 needs no training and no GPU, runs from an inverted index (term → documents containing it), and is exact about words. It is blind to meaning: "cats" does not match "cat" without stemming, and "kitten" never does. Dense embeddings have the opposite profile.

Hybrid search

Hybrid search runs both retrievers and merges the results. Their scores live on different scales (BM25 is unbounded, cosine sits in [−1, 1]), so the simplest robust merge ignores scores and uses only ranks. Reciprocal rank fusion (RRF) gives each document

RRF(d) = Σr ∈ rankers 1 / (k + rankr(d)),   k = 60

where a document a ranker did not return adds nothing for that ranker. The constant k damps the advantage of the very top ranks, so a document both retrievers like beats one that only one retriever puts first. The alternative is a weighted sum of normalized scores, which needs tuning, or a learned reranker on top.

Hybrid search: a BM25 list and a dense list merged by reciprocal rank fusion The query "cat mat" goes to two retrievers. BM25 returns d1 with score 2.177 and d2 with score 0.945, and nothing else, because d3 and d4 share no word with the query. The dense retriever returns d3, d1, d2, d4. Reciprocal rank fusion with k equal to 60 gives d1 0.0325, d2 0.0320, d3 0.0164 and d4 0.0156, so the fused order is d1, d2, d3, d4. query "cat mat" BM25 (inverted index) 1. d1 2.177 2. d2 0.945 d3, d4: no shared word Dense (embeddings) 1. d3 2. d1 3. d2 4. d4 RRF, k = 60 1. d1 0.0325 2. d2 0.0320 3. d3 0.0164 4. d4 0.0156

The dense list is an assumed ranking for illustration (no embedding model runs on this site); the BM25 scores and the fused scores are computed from the formulas above.

Worked example

One query, three measures. q = (1.0, 0.5), ‖q‖ = 1.118:

DocVector‖x‖q·xcos‖q − x‖
A(4.0, 1.0)4.1234.5000.9763.041
B(1.0, 1.0)1.4141.5000.9490.500
C(0.9, 0.3)0.9491.0500.9900.224
D(2.0, 2.4)3.1243.2000.9162.147
E(0.2, 1.0)1.0200.7000.6140.943

A has the largest dot product only because it is long (‖A‖ = 4.123); by L2 it is the farthest. C points almost exactly along q, so cosine puts it first, and it is also short enough to sit near q's tip. E is close in L2 but points about 52° away (cos = 0.614).

After normalizing. Divide every vector by its length and recompute:

Docq̂·x̂ = cos‖q̂ − x̂‖²2 − 2 cos
A0.9760.04760.0476
B0.9490.10260.1026
C0.9900.02010.0201
D0.9160.16770.1677
E0.6140.77210.7721

The dot products now equal the cosines from the first table, the squared distances equal 2 − 2 cos, and all three measures rank C, A, B, D, E.

What brute force costs. One million 768-dimensional embeddings:

  • Index size: 3,072,000,000 bytes (3.07 GB) in fp32, 1.54 GB in fp16.
  • One query: 2·n·d = 1,536,000,000 FLOPs (1.54 GFLOP), and every byte of the index is read once: 0.5 FLOP per byte. That is memory-bound. A GPU reading at 2 TB/s needs about 1.5 ms just to stream the index.
  • A batch of 1,000 queries reads the same 3.07 GB but does 1.536 TFLOP: 500 FLOP per byte. Batching turns the search into a matrix multiply, which is compute-bound and fast.

Exact search is practical well into the millions, especially batched on a GPU. Approximate indexes earn their complexity at hundreds of millions of vectors or tight per-query latency budgets.

BM25 by hand. Four documents, query "cat mat", k1 = 1.2, b = 0.75:

DocText|D|catmatScore
d1the cat sat on the mat60.7951.3822.177
d2a dog chased the cat around the cat tree90.9450.0000.945
d3cats and dogs make good pets60.0000.0000.000
d4the market fell as investors sold shares and the dollar rose against the euro140.0000.0000.000

avgdl = 8.75. "cat" appears in 2 of 4 documents, so idf(cat) = ln(2.5/2.5 + 1) = 0.693. "mat" appears in one, so idf(mat) = ln(3.5/1.5 + 1) = 1.204. d2 says "cat" twice and earns 0.945 for it against d1's 0.795: more, but not twice as much, and d2 is longer than average. d1 wins on the rare term "mat". d3 is about cats but scores 0, because "cats" is a different token.

Saturation with k1 = 1.2 at average length: tf 1 → 1.000, tf 2 → 1.375, tf 3 → 1.571, tf 5 → 1.774, tf 10 → 1.964. The ceiling is k1 + 1 = 2.2.

Fusing the lists. BM25 returns d1, d2. Suppose the embedding model ranks d3, d1, d2, d4:

  • d1: 0.0325 (in both lists)
  • d2: 0.0320 (in both lists)
  • d3: 0.0164 (dense only)
  • d4: 0.0156 (dense only)

d1 = 1/61 + 1/62 tops the fused list: it ranks well in both. d2 (ranks 2 and 3) edges out d3 even though the dense model put d3 first, because RRF rewards agreement. d3 still makes the top three, which BM25 alone would never return.

Implementation

import torch
import torch.nn.functional as F

# Index: n embeddings of dimension d, normalized once.
X = F.normalize(embeddings, dim=1)          # (n, d), every row has norm 1

def search(queries, k=10):
    Q = F.normalize(queries, dim=1)         # (m, d)
    scores = Q @ X.T                        # (m, n), cosine = dot on unit vectors
    return scores.topk(k, dim=1)            # values and indices, each (m, k)

# The same thing in scikit-learn.
from sklearn.neighbors import NearestNeighbors
nn = NearestNeighbors(n_neighbors=10, metric="cosine", algorithm="brute").fit(E)
dist, ind = nn.kneighbors(E_query)          # cosine distance = 1 - cos

# Reciprocal rank fusion of any number of ranked id lists.
def rrf(rankings, k=60):
    scores = {}
    for ranking in rankings:
        for rank, doc in enumerate(ranking, start=1):
            scores[doc] = scores.get(doc, 0.0) + 1 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

For indexes larger than one device's memory, split X into shards, take each shard's top k, and merge: the global top k is always among the shards' top k lists. Storing X in fp16 halves memory and bandwidth with negligible effect on the ranking. BM25 comes from a search library (Lucene, Elasticsearch, OpenSearch, or a small Python package), not from PyTorch or scikit-learn; scikit-learn's TfidfVectorizer is the nearest built-in lexical scorer.

Tradeoffs

  • Exact versus approximate. Brute force has perfect recall, no build step and trivial updates, but cost grows linearly with n. Approximate indexes cut the cost to roughly logarithmic or sublinear at the price of some recall and a build step.
  • Measure. Use what the embedding model was trained with. Cosine (normalize, then dot) is the safe default; raw dot product keeps length as a signal; L2 on unnormalized vectors mixes length and direction in a way few models intend.
  • Lexical versus dense. BM25 is exact about rare words, IDs and new vocabulary, needs no model, and explains itself. Dense retrieval handles paraphrase and synonyms but can miss exact matches and fails quietly out of domain. Hybrid search usually beats either alone.
  • Fusion. RRF needs no score calibration and no tuning beyond k, but throws away score gaps. Weighted score fusion keeps them but needs per-collection normalization.
  • Precision of the index. fp16 or int8 storage cuts memory and doubles or quadruples throughput on a memory-bound scan; near-ties can swap order.
When do dot-product ranking and L2 ranking give the same order?

When every stored vector has the same norm. ‖q − x‖² = ‖q‖² + ‖x‖² − 2q·x, and ‖q‖² is shared by all candidates, so if ‖x‖² is too, smaller distance means larger dot product.

Why is a single brute-force query memory-bound but a batch of a thousand compute-bound?

One query does 2 FLOPs for every value it reads from the index (0.5 FLOP per fp32 byte). A batch reuses each index value for every query in it, so FLOPs grow with the batch while bytes read stay the same.

Why does BM25 give d3 ("cats and dogs make good pets") a score of 0 for the query "cat mat"?

BM25 matches tokens exactly. "cats" is not "cat" without a stemmer, and d3 has no "mat", so neither term contributes. A dense retriever can still rank it highly, which is the case hybrid search covers.

Connections

Read first

Read next

References

  • Robertson, Walker, Jones, Hancock-Beaulieu and Gatford. Okapi at TREC-3. TREC, 1994 (BM25).
  • Robertson and Zaragoza. The Probabilistic Relevance Framework: BM25 and Beyond. Foundations and Trends in Information Retrieval, 2009.
  • Cormack, Clarke and Büttcher. Reciprocal Rank Fusion Outperforms Condorcet and Individual Rank Learning Methods. SIGIR, 2009.
  • Karpukhin et al. Dense Passage Retrieval for Open-Domain Question Answering. EMNLP, 2020. arXiv:2004.04906.
  • Johnson, Douze and Jégou. Billion-scale similarity search with GPUs. 2017. arXiv:1702.08734.
  • Manning, Raghavan and Schütze. Introduction to Information Retrieval. Cambridge University Press, 2008: chapters 6 and 11.
  • PyTorch v2.15.0: distance.py, F.normalize, torch.cdist.
  • scikit-learn 1.9.1: NearestNeighbors, cosine_similarity, euclidean_distances.