Representations

Vector search: HNSW, cosine similarity and BM25

Follow one query down an HNSW tower as it hops from the roof to its ten nearest neighbours with 63 distance computations instead of 500, see what ef buys in 32 dimensions, then watch real embeddings and BM25 each miss a question the other gets right, and what reciprocal rank fusion does with the two.

ef (search list)
Question
Loading 3D view

Compare with everythingVector search means finding the stored vectors nearest a query, and the obvious way compares the query with every single one: here that is 500 distance computations, and a billion vectors cost a billion.

Narrated with James Phoenix's AI voice.

63/ 500 scan
Distance computations
2 · 2 · 2 · 9roof to ground
Hops per floor
52%ef 10
Recall at 10, 32 dims
-
Top page
text-embedding-3-small2026-09-11
Embeddings

At ef 10 the traced query makes 63 distance computations against 500 for a full scan; in 32 dimensions the same ef finds 52% of the true ten.

Vector search finds the stored vectors nearest a query vector: the step inside retrieval-augmented generation (RAG), semantic search and recommendation that turns "which documents are about this?" into geometry. The exact answer needs a brute-force scan, one distance computation per stored vector, which is fine for thousands and ruinous for a billion. Approximate nearest neighbour (ANN) indexes such as HNSW trade a little recall for far fewer comparisons, and this film measures that trade rather than asserting it.

The tower is a real HNSW graph (hierarchical navigable small world, Malkov and Yashunin), built by the code on this page over 500 seeded points in a plane with four links per point on the upper floors and eight on the ground. Every point sits on the ground floor; each one climbs to higher floors with shrinking odds, so the floors hold 128, 35 and 9 points and their links stretch across the whole plan. A search enters at the roof, moves greedily to any neighbour closer to the query, and drops a floor when none is. The traced query makes two hops on each upper floor, then a short beam search on the ground floor, and finds exactly the ten neighbours a full scan finds, with 63 distance computations instead of 500. The ef control sets the length of that ground-floor candidate list; pause the film and change it to see the visited points and the counter grow.

The part people get wrong is how much ef matters, because a demo in two dimensions flatters every ANN index. On the flat floor ef 10 already finds 99.5% of the true ten on average. Run the same code on 500 unit vectors in 32 dimensions, closer to how real embeddings behave, and ef 10 finds only 52% of them, ef 64 finds 94% for 217 comparisons, and ef 128 finds 99% for 328. Recall also falls as the collection grows at a fixed ef, so the right setting is not a constant: measure recall at k against a brute-force scan on a sample of your own queries, and re-measure when the index grows.

The second half is text, with real numbers. Twelve help-centre pages and nine questions were embedded with openai/text-embedding-3-small on 11 September 2026, and every page was also indexed for BM25 with Lucene's stop words and defaults. Cosine similarity is drawn as it is defined, the angle between two rays: the protractor puts each page at its measured angle from the question. Asked "payment failed with E1417", the embedding ranks the E1147 page first, 0.64 against 0.61, because the two codes are nearly the same string to a model that reads meaning; BM25 looks the token e1417 up in its inverted index, finds one page, and ranks it first. Asked "How do I get my money back?", the failure reverses: BM25 matches money to Payouts and back to Cancelling an order, and never reaches Refunds, which shares no word with the question, while the embedding ranks Refunds first at 0.42.

Hybrid search runs both and fuses the lists, most often with reciprocal rank fusion, which scores each page by 1/(60 + rank) summed over the lists. On this recording fusion did not pick the winner. It rewards agreement, so it tied E1147 with E1417 and dropped Refunds to fourth behind pages both lists half-liked. Across all nine questions the right page came first 8 times by embeddings alone, 5 by BM25 and 4 by fusion, with 3 more tied at the top, while the fused top four held the right page every time. That is the honest shape of hybrid retrieval: it widens the candidate set cheaply, and a reranker or a model reading the top few does the choosing.

The caveats are about scale. Twelve pages and nine questions show the two failure modes but cannot rank the methods; published benchmarks such as BEIR find hybrid retrieval usually helps on large corpora, and the first corpus tried here, with shorter pages, did not show the exact-code failure at all. The tower is two-dimensional so it can be drawn, and production indexes use larger M and ef, millions of vectors, and filters, deletions and quantisation that this build leaves out. The numbers are nonetheless all computed or recorded, and the tests recompute every one the narration quotes.

The maths

Cosine similarity is the angle between two rays
cosθ  =  qdqd,θ=arccos(cosθ)\cos\theta \;=\; \frac{\mathbf{q}\cdot\mathbf{d}}{\lVert\mathbf{q}\rVert\,\lVert\mathbf{d}\rVert}, \qquad \theta = \arccos(\cos\theta)

The protractor draws each help page as a ray at angle θ from the question’s ray, using the recorded 1536-dimensional embeddings. For “payment failed with E1417” the E1147 page scores 0.64 (50 degrees) and the E1417 page 0.61 (52 degrees), so the wrong code ranks first. For unit vectors, ranking by cosine and by Euclidean distance give the same order, which is why the tower can use plain distance.

BM25 scores exact words, and saturates
BM25(d,q)  =  tqidf(t)ft,d(k1+1)ft,d+k1(1b+bdavgdl),idf(t)=ln(1+Nnt+0.5nt+0.5)\operatorname{BM25}(d, q) \;=\; \sum_{t \in q} \operatorname{idf}(t)\,\frac{f_{t,d}\,(k_1 + 1)}{f_{t,d} + k_1\bigl(1 - b + b\,\frac{|d|}{\text{avgdl}}\bigr)}, \qquad \operatorname{idf}(t) = \ln\Bigl(1 + \frac{N - n_t + 0.5}{n_t + 0.5}\Bigr)

Each card in the catalogue is one query term t with its idf and the pages that contain it. A term on one page of twelve (e1417, money) has idf 2.16; one on three pages (payment) has 1.31. Repeats of a term add less and less, never more than idf × (k₁ + 1), with k₁ = 1.2 and b = 0.75 as Lucene ships them. A page that shares no word with the question scores zero, however close its meaning.

Reciprocal rank fusion
RRF(d)  =  lists L1k+rankL(d),k=60\operatorname{RRF}(d) \;=\; \sum_{\text{lists } L} \frac{1}{k + \operatorname{rank}_L(d)}, \qquad k = 60

The fusion board adds one term per list the page appears in, from each list’s top five. Only ranks count, so a cosine of 0.42 and a BM25 score of 2.07 never have to share a scale. The price is that agreement wins: ranks 1 and 2 swapped between the lists tie exactly (E1147 and E1417, both 0.0325), and a page only one list finds (Refunds, 0.0164) falls below pages both lists half-like.

What ef buys, and what a floor costs
=lnu/lnM,Pr(f)=Mf,recall@k=foundtruek\ell = \lfloor -\ln u \,/\, \ln M \rfloor, \qquad \Pr(\ell \ge f) = M^{-f}, \qquad \text{recall@}k = \frac{|\,\text{found} \cap \text{true}\,|}{k}

With M = 4 each point reaches floor f with odds 4^−f, so the 500 points give floors of 128, 35 and 9 (the top floor is capped at 3). On the ground floor the search keeps a list of ef candidates. On the flat floor ef 10 already finds all ten; on 500 vectors in 32 dimensions ef 10 finds 52% of the true ten for 81 comparisons, ef 64 finds 94% for 217, and ef 128 finds 99% for 328, against 500 for a full scan.

Related terms

More visualisations

Building with language models?

These explainers come out of the work. If you want the same thinking applied to your own system, that is what I do.

See how I can help