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.
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.
| Component | Shape/Params | Meaning | Source |
|---|---|---|---|
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.
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:
| Doc | Vector | ‖x‖ | q·x | cos | ‖q − x‖ |
|---|---|---|---|---|---|
| A | (4.0, 1.0) | 4.123 | 4.500 | 0.976 | 3.041 |
| B | (1.0, 1.0) | 1.414 | 1.500 | 0.949 | 0.500 |
| C | (0.9, 0.3) | 0.949 | 1.050 | 0.990 | 0.224 |
| D | (2.0, 2.4) | 3.124 | 3.200 | 0.916 | 2.147 |
| E | (0.2, 1.0) | 1.020 | 0.700 | 0.614 | 0.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:
| Doc | q̂·x̂ = cos | ‖q̂ − x̂‖² | 2 − 2 cos |
|---|---|---|---|
| A | 0.976 | 0.0476 | 0.0476 |
| B | 0.949 | 0.1026 | 0.1026 |
| C | 0.990 | 0.0201 | 0.0201 |
| D | 0.916 | 0.1677 | 0.1677 |
| E | 0.614 | 0.7721 | 0.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:
| Doc | Text | |D| | cat | mat | Score |
|---|---|---|---|---|---|
| d1 | the cat sat on the mat | 6 | 0.795 | 1.382 | 2.177 |
| d2 | a dog chased the cat around the cat tree | 9 | 0.945 | 0.000 | 0.945 |
| d3 | cats and dogs make good pets | 6 | 0.000 | 0.000 | 0.000 |
| d4 | the market fell as investors sold shares and the dollar rose against the euro | 14 | 0.000 | 0.000 | 0.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
- Vectors and dot products intro
- Nearest neighbors intro
Read next
- Vector indexes advanced
- Two-tower models core
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.