Nearest neighbour search: brute force, heaps and clustering
Nearest neighbour search finds the k vectors closest to a query vector. Brute force compares the query with every vector and keeps the best k in a heap, which is exact and fine up to a few hundred thousand vectors. Beyond that, approximate methods (clustering like IVF, graphs like HNSW) search a fraction of the vectors, trading a little recall for a lot of speed, and you measure that trade-off against brute-force results.
Brute force with a heap
Compute the similarity between the query and every vector, then keep the top k. Sorting all n scores costs O(n log n), and a heap of size k costs O(n log k), which matters when n is large and k is 5.
import heapq
import numpy as np
rng = np.random.default_rng(1)
n, dim, k = 20000, 64, 5
vectors = rng.normal(size=(n, dim)).astype(np.float32)
vectors /= np.linalg.norm(vectors, axis=1, keepdims=True)
query = vectors[42] + 0.05 * rng.normal(size=dim).astype(np.float32)
query /= np.linalg.norm(query)
scores = vectors @ query
exact = [i for _, i in heapq.nlargest(k, ((s, i) for i, s in enumerate(scores)))]
print("exact top-k:", exact)
n_clusters = 50
centroids = vectors[rng.choice(n, n_clusters, replace=False)]
for _ in range(5):
assign = np.argmax(vectors @ centroids.T, axis=1)
for c in range(n_clusters):
members = vectors[assign == c]
if len(members):
centroids[c] = members.mean(axis=0) / np.linalg.norm(members.mean(axis=0))
for n_probe in (1, 3, 10):
probe = np.argsort(-(centroids @ query))[:n_probe]
candidates = np.where(np.isin(assign, probe))[0]
approx = candidates[np.argsort(-(vectors[candidates] @ query))[:k]]
recall = len(set(approx) & set(exact)) / k
print(f"probe {n_probe} clusters: searched {len(candidates)} vectors, recall@{k} {recall:.1f}")
With normalised vectors, the dot product is cosine similarity, so one matrix multiplication scores everything.
Approximate search: clustering (IVF)
Split the vectors into clusters with k-means, and at query time search only the closest few clusters. More clusters probed means higher recall and slower queries. The example above shows the trade-off: probing 1 cluster searches a small fraction of the vectors, and probing more brings recall back up. The vectors in the example are random, so they have no natural clusters and recall is lower than you'd see with real embeddings, which cluster by topic.
Approximate search: graphs (HNSW)
HNSW builds a graph where each vector links to its close neighbours, with sparse upper layers for long jumps and dense lower layers for local search. A query starts at the top, greedily moves toward closer nodes, and drops a layer each time it can't improve. It's the default index in most vector databases, and it uses more memory than IVF. See vector index internals.
Measuring recall
Recall@k is the fraction of the true top k (from brute force) that the approximate index returns. Measure it on a sample of real queries before you tune anything else, because a fast index that misses the right chunk makes your RAG system wrong in a way the LLM can't fix.
When brute force is enough
| Vectors | Usually fine |
|---|---|
| Up to about 100,000 | Brute force, or pgvector without an index |
| 100,000 to tens of millions | HNSW or IVF in a vector database |
| More, or tight memory | IVF with compressed vectors (product quantization) |
These ranges are rough and depend on dimensions, latency targets and hardware, so time it on your data.
Check your understanding
Change n_clusters to 200 and explain what happens to the number of vectors searched and to recall.
From the floor
In the AI data structures lecture, I placed similarity search right after embeddings in the sequence of stages, because once meaning is a vector, finding related items becomes a question of distance, and I tied it back to search: a query like "can you get medicine for someone" needs the system to find pages about buying medicine on someone's behalf, not pages that contain the same words.