P02 — Approximate Nearest-Neighbour Index
Run it first. There is a companion page that builds this project's machinery as numbered, independently runnable blocks and then assembles them into one measured system: P02 hands-on — block by block (
handson/h*.py). Every number on it was produced by running the code. Read it alongside the milestones below.
Medium · 77 hours · Weeks 9–15 · Stage 1 · Python, with a compiled inner loop
Table of Contents
- The Loop, Instantiated
- Why This Project Matters
- Prerequisites
- Duration and Size
- Central Technical Questions
- Architecture
- Showcase — Do This Before You Start
- Implementation Milestones
- Concepts To Study
- Primary-Source Readings
- Experiments
- Benchmarks and Metrics
- Correctness Tests
- Failure Tests
- Expected Difficulties
- Scope Boundaries
- Deliverables
- Exit Criteria
- Extension Ideas
- Connections
- References
The Loop, Instantiated
| Step | For this project |
|---|---|
| 1. Problem | Find the k most similar vectors to a query among n, in sub-linear time, at a recall you choose and can prove |
| 2. Constraints | Single machine, in-RAM, one distance metric fixed for the whole project, recall measured against exact brute force every time |
| 3. Naive design | Yours. Most people invent one of: k-d tree, LSH, clustering + probe nearest centroids, or a random graph walk |
| 4. Predicted failure | At what dimensionality does your structure stop helping? Predict the number before you measure it — for k-d trees it is far lower than people expect |
| 5. Minimal implementation | Brute force. It is the baseline and it is also the ground-truth oracle |
| 6. Correctness | Brute force agrees with a naive triple loop; recall against it is computed the same way every time |
| 7. Instrumentation | Distance computations per query. This is the single most important counter in the project |
| 8. Baseline | Brute force with BLAS. It is much harder to beat than you think |
| 9. Bottleneck | Decompose speedup into algorithmic (distance count) and constant factor (ns per distance). They are different bugs |
| 10. Hypothesis | The clustered-data recall ceiling is the best one available — see the worked notebook entry |
| 11. Modification | HNSW's neighbour-selection heuristic (Algorithm 4) |
| 12. Experiment | Recall/QPS curve at fixed n, d, M, efConstruction |
| 13. Failure analysis | Where does the missing recall physically go? Name the nodes it went to |
| 14. Report | The recall/QPS Pareto curve, plus a comparison against hnswlib you did not win |
Why This Project Matters
You use HNSW in production through OpenSearch. That means you already have opinions
about ef_search and m that you cannot currently defend from first principles, and
you have never seen what recall does when the parameters are wrong in a way that
doesn't show up as an error.
More generally: this is the project where "approximate" stops being a hand-wave and becomes a contract with a number attached. Almost every system in the rest of this journey trades exactness for speed somewhere — a Bloom filter, a watermark, a sampled metric, a quantized weight. This is the cleanest possible place to learn how to quantify what you gave up, because the exact answer is cheaply computable and the approximation error is a single scalar.
It is also the project that teaches the most transferable debugging technique in the whole track: when a better algorithm loses to a worse one, decompose the ratio into operation count and cost per operation before touching a parameter.
Prerequisites
- P01 is not required but supplies realistic test data (see Connections)
- Linear algebra: dot products, norms, the cosine/L2 equivalence under normalisation
- Basic graph algorithms: BFS, priority queues, greedy search
- From
math.md: §Concentration of Measure in High Dimensions (2 h). Read it before milestone 3 — it is what makes the curse of dimensionality quantitative rather than folkloric.
Duration and Size
Medium, 77 hours, 7 weeks.
| Tier | Contents | Hours |
|---|---|---|
| MVI | Brute force + exact ground truth + recall harness + a single-layer NSW graph with insertion and beam search. Produces a recall/latency curve. | 35 |
| Standard | + HNSW hierarchy, the neighbour-selection heuristic, deletion via tombstones, persistence and load, a compiled inner loop, full parameter sweeps, hnswlib comparison. | 77 |
| Extension | Product quantization on top of the graph; or an adaptive efSearch policy that predicts per-query difficulty. The latter is a research direction. | +30–45 |
Central Technical Questions
- Why does exact search get hard in high dimensions, and what is the right way to measure "hard"? (Not \(d\). See relative contrast.)
- What does a greedy graph walk assume, and where exactly is that assumption false? The missing recall is not diffuse; it goes to specific nodes for a specific reason.
- Why a hierarchy? What does it buy that a single well-connected graph does not?
- Why does neighbour selection matter more than it looks? This is the question most implementations get wrong.
- What is the actual crossover point where your index beats brute force, and which of the two factors — algorithmic or constant — decides it?
- What does recall@10 = 0.95 mean for a downstream recommender? Is the missing 5% uniformly distributed across queries, or concentrated on the hard ones? (It is the latter. Measure it.)
Architecture
Write your naive design first.
┌──────────────────────────────────┐
query vector ────────► │ Layer L (sparse, long edges) │ greedy, ef=1
│ │ descend at local min │
│ Layer 1 │ greedy, ef=1
│ │ │
│ Layer 0 (all points, dense) │ beam search, ef=efSearch
└──────────────┬───────────────────┘
▼
top-k by distance, exact within the visited set
Each point is inserted into layers \(0..\ell\) where \(\ell = \lfloor -\ln(U(0,1)) \cdot m_L \rfloor\) — a geometric distribution, so layer \(i\) holds roughly \(1/e^i\) of the points. The upper layers are a coarse skeleton for navigation; layer 0 holds everything and is where recall is decided.
The difficulty metric
Ambient dimension \(d\) does not predict ANN difficulty. Relative contrast does (He, Kumar & Chang 2012):
\[ \mathrm{RC} = \frac{\mathbb{E}_q[\text{mean distance from } q \text{ to all points}]}{\mathbb{E}_q[\text{distance from } q \text{ to its nearest neighbour}]} \]
As RC → 1, every point is about as far away as every other, greedy descent has no
gradient to follow, and any distance-based method degenerates. Measured with
tools/annlab.py on uniform unit-sphere data at n=10,000:
| d | RC | \(d_{10}/d_1\) |
|---|---|---|
| 16 | 2.220 | 1.2303 |
| 64 | 1.356 | 1.0717 |
| 128 | 1.224 | 1.0465 |
| 512 | 1.097 | 1.0196 |
At \(d=512\) the tenth-nearest neighbour is 2% further away than the first. No index can reliably distinguish them, and an ANN benchmark on uniform high-dimensional data is measuring the dataset, not the index.
Real embeddings are not uniform — they concentrate near a low-dimensional manifold. The same tool with 100 Gaussian clusters at σ=0.05 gives RC = 3.363 at d=64, 2.5× the uniform value at the same ambient dimension. Which dataset you benchmark on decides your conclusion, so state it every time.
One trap, measured: a Gaussian perturbation with per-axis σ in \(d\) dimensions has expected norm \(\sigma\sqrt{d}\). If the cluster centres are unit vectors and \(\sigma\sqrt{d} \gtrsim 1\), the noise exceeds the signal and your "clustered" dataset is uniform data wearing a hat. At d=64: σ=0.25 gives RC=1.393 against uniform's 1.356 — no difference. σ=0.05 gives 3.371. Verify your independent variable varies before you spend a run measuring its effect.
The two-factor speedup model
The most useful thing in this project. Decompose any comparison against brute force:
\[ \text{speedup} = \underbrace{\frac{n}{\text{dists/query}}}_{\text{algorithmic}} \Big/ \underbrace{\frac{\text{ns/dist}_{\text{graph}}}{\text{ns/dist}_{\text{brute}}}}_{\text{constant factor}} \]
Measured at n=10,000, d=64, M=16, efSearch=64, pure Python:
| dataset | algorithmic win | constant factor | predicted | measured |
|---|---|---|---|---|
| uniform (RC 1.36) | 10,000/1,459 = 6.9× | 899 vs 13.3 ns = 67.5× | 0.10× | 0.10× |
| clustered (RC 3.36) | 10,000/321 = 31.1× | 1317 vs 12.8 ns = 102.9× | 0.30× | 0.30× |
The model closes to two significant figures in both cases. It says something specific and actionable: the algorithm is right and the implementation is wrong, which is a different bug from "the algorithm is wrong" and has a different fix. Without the distance counter you would conclude "HNSW doesn't work at n=10k" and spend a week tuning M.
Break-even needs the algorithmic win to exceed the constant factor. Distances/query grows roughly logarithmically while brute force grows linearly, so in pure Python the crossover lands near n ≈ 1.5×10⁵ (a run at n=100,000, d=128 measured 0.80× at ef=64 — just short of parity). In compiled code the constant factor is ~1–2× and the crossover falls to a few thousand. This is why every serious ANN index is written in C++, and milestone 8 is where you find that out for yourself.
Showcase — Do This Before You Start
W2 and the tools · walkthroughs/annlab.py · ~30 minutes
A working miniature of this project: the full recall/QPS curve, the distance counter, and the two-factor decomposition —
run python3 tools/annlab.py and --clusters 100 to see both datasets.
cd walkthroughs && python3 annlab.py
It is 80-ish lines and it surfaces this project's central surprise in an evening rather than in week six. Run it before committing the weeks.
Implementation Milestones
| # | Milestone | Hours | Done when |
|---|---|---|---|
| 1 | Repo, dataset generators (uniform, clustered, and P01 embeddings), RC measurement | 4 | RC table above reproduced on your machine |
| 2 | Brute force + exact ground truth + recall/precision/NDCG harness | 5 | Agrees with a naive triple loop on n=100; metrics.py imported not rewritten |
| 3 | Distance functions: cosine, dot, L2 — and the normalisation equivalence test | 4 | Test proves all three give identical rankings on normalised data |
| 4 | Random-graph greedy search (no construction heuristic at all) | 6 | Works, is terrible, and you have the recall curve proving it |
| 5 | NSW: insertion, reciprocal edges, degree cap, beam search | 9 | Recall/QPS curve; distance counter instrumented |
| 6 | HNSW hierarchy: layer assignment, descent, layer-0 beam search | 10 | Curve dominates milestone 5's at equal distance count |
| 7 | Neighbour-selection heuristic (Algorithm 4) | 8 | Clustered-data recall ceiling measurably rises |
| 8 | Compiled inner loop (Rust via PyO3, or numpy batch-vectorised candidates) | 8 | ns/dist drops ≥10×; re-measure the crossover against the model |
| 9 | Persistence: save/load, format versioning, checksum | 5 | Round-trip preserves recall exactly; corrupt byte is detected |
| 10 | Deletion via tombstones + a re-insertion path | 4 | Deleted points never returned; recall after 20% churn measured |
| 11 | Parameter sweeps + hnswlib comparison | 8 | All rows in Experiments filled |
| 12 | Report | 6 | Written, including the comparison you lost |
Concepts To Study
- Metric spaces and the triangle inequality — and which pruning techniques it enables (and why cosine similarity is not a metric, but its induced distance is)
- Curse of dimensionality, stated quantitatively: concentration of pairwise distances, and why \(d_{10}/d_1 \to 1\)
- Intrinsic vs ambient dimensionality; why real embeddings are easier than their \(d\) suggests
- Small-world graphs: Kleinberg's navigability result, and why long-range links with the right distribution give \(O(\log^2 n)\) greedy routing
- Greedy search and local minima in proximity graphs; the beam as a remedy
- Skip lists — HNSW's hierarchy is a skip list in a metric space, and seeing that makes the layer-assignment distribution obvious
- Neighbour-selection heuristics: why "keep the M nearest" destroys connectivity on clustered data
- Product quantization: codebooks, asymmetric distance computation (extension only)
- SIMD and memory layout: why contiguous float32 and cache-line alignment matter more than instruction count
Primary-Source Readings
Budget: 11 hours. Read Malkov 2016 §3 after your milestone 5, not before — milestone 4 and 5 are your naive design and it must be yours.
| Reading | Why | Hours |
|---|---|---|
| Malkov & Yashunin, Efficient and robust ANN search using HNSW graphs, TPAMI 2020 (arXiv:1603.09320) | The source. Algorithm 4 is the part that matters most and is skipped most | 3 |
| Malkov et al., ANN algorithm based on navigable small world graphs, Information Systems 45, 2014 | The single-layer NSW you will have reinvented | 1.5 |
| He, Kumar & Chang, On the Difficulty of Nearest Neighbor Search, ICML 2012 | Relative contrast; makes "hard dataset" measurable | 1.5 |
| Beyer et al., When Is "Nearest Neighbor" Meaningful?, ICDT 1999 | The concentration result underneath everything above | 1.5 |
| Aumüller, Bernhardsson & Faithfull, ANN-Benchmarks, Information Systems 87, 2020 | The evaluation protocol you should imitate exactly | 1.5 |
| Jégou, Douze & Schmid, Product Quantization for Nearest Neighbor Search, TPAMI 33(1), 2011 | The other major family; read even if you skip the extension | 2 |
Experiments
| # | Experiment | Sweep | Fixed | Predict first |
|---|---|---|---|---|
| E1 | efSearch | {10,16,24,32,48,64,96,128,192,256} | n,d,M,efC | Shape of recall(ef). Where is the knee? |
| E2 | efConstruction | {50,100,200,400} | efSearch=64 | Build time vs query quality — which does it buy? |
| E3 | M (max connections) | {4,8,16,32,48} | efC=100 | Index size is linear in M; is recall? |
| E4 | Dimensionality | d ∈ {16,64,128,256,512} | n=50k | Report RC alongside; recall should track RC, not d |
| E5 | Dataset size | n ∈ {10³,10⁴,10⁵,10⁶} | d=128 | Where is the brute-force crossover? Derive from the two-factor model first |
| E6 | Normalised vs not | on/off | everything | Predict the recall drop for unnormalised cosine |
| E7 | Clustered vs uniform | RC ∈ {1.36, 3.36} | n,d,M,efC | The best hypothesis here. See below |
| E8 | Neighbour heuristic | naive M-nearest vs Algorithm 4 | clustered data | Predict which metric moves: recall ceiling, or latency? |
| E9 | Distance counter vs wall clock | all of the above | — | Do the two agree via the two-factor model? |
| E10 | Recall distribution | per-query recall histogram at ef=64 | — | Is the missing recall uniform or concentrated? |
| E11 | Churn | delete+reinsert 20%, 50% of points | — | Does recall degrade, and does compaction restore it? |
| E12 | hnswlib comparison | same data, same recall target | — | Predict your factor behind. Then measure it |
E7 is the experiment to build the project around. The naive prediction — clustered data is easier, so recall is higher everywhere — is false, and finding out why teaches you the thing the paper's Algorithm 4 exists for. Measured at n=10k, d=64:
| efSearch | uniform recall@10 | clustered recall@10 | uniform dists/q | clustered dists/q |
|---|---|---|---|---|
| 10 | 0.3605 | 0.4840 | 397 | 193 |
| 64 | 0.8160 | 0.8345 | 1459 | 321 |
| 128 | 0.9480 | 0.9030 | 2484 | 501 |
| 256 | 0.9930 | 0.9670 | 4059 | 840 |
Clustered is faster everywhere and worse above ef≈96. The clustered search cannot spend its budget: 840 distances at ef=256 versus uniform's 4,059. It runs out of reachable candidates. The full failure analysis — the degree cap deletes every inter-cluster bridge deterministically, because intra-cluster distance is 0.521 and inter-cluster is 1.413 — is in the worked notebook entry.
E12 you will lose. hnswlib is years of C++ tuning. Losing by 5–20× while matching
its recall curve at equal distance count is a good result and you should report it
that way: same algorithm, different constant factor, and you can prove the split.
Benchmarks and Metrics
| Metric | Notes |
|---|---|
| recall@k | Against exact brute force on the same data and metric. k stated always |
| Distance computations per query | The algorithmic currency; transfers across languages and machines |
| QPS (single-thread) | From the median, and say it is single-thread |
| Latency p50 / p95 / p99 | p95/p50 ratio is itself a reportable number |
| Build time | And whether it is parallelised |
| Index size | Vectors and graph, reported separately. Measured: 1.40× raw at M=16 |
| Peak build memory | Usually 2–3× the final index; the thing that OOMs you in production |
| Recall variance across queries | E10. A mean recall of 0.95 with 20% of queries at 0.5 is a different system from one with all queries at 0.95 |
| RC of the dataset | Report with every recall number, always |
Correctness Tests
- Brute force vs naive triple loop on n=100, d=8. Exact agreement.
- Metric equivalence: on normalised vectors, top-k by cosine, dot, and L2 are the identical index list. Proves \(||a-b||^2 = 2 - 2\langle a,b \rangle\).
- Recall ≤ 1.0 and = 1.0 when efSearch ≥ n. Exhaustive beam must find everything.
- Graph invariants: no self-loops; no duplicate edges; degree ≤ cap at every node; every node in layer \(i\) also in layers \(<i\); layer 0 contains all points.
- Reachability: every node reachable from the entry point in layer 0. This test catches the connectivity bug in E7 directly, and you should add it because of E7.
- Persistence round-trip: save, load, and get bit-identical results for a fixed query set.
- Deletion: a deleted id is never returned, at any efSearch.
- Determinism: fixed seed → identical graph. Verify by hashing the adjacency lists.
- Empty and degenerate cases: n=0, n=1, k>n, all-identical vectors, zero vectors (which have undefined cosine — decide the policy and test it).
Failure Tests
| Injection | Predicted symptom | Lesson |
|---|---|---|
| Skip normalisation, use cosine | Silent recall drop, no error | Silent quality failures are the ANN failure mode |
| Entry point in a disconnected component | Recall collapses for a subset of queries | Why E10's distribution matters more than the mean |
| Degree cap = M instead of 2M | Over-pruning; recall ceiling drops | The connectivity/size trade in the raw |
| Corrupt one byte in the persisted index | Must be detected, not silently mis-ranked | Checksums exist for this |
| Insert 10⁶ duplicate vectors | Degenerate graph; probe the failure mode | Real corpora contain duplicates |
| Query far outside the data distribution | Recall drops; the walk starts badly | Distribution shift, the ANN version |
| Concurrent insert during search (extension) | Undefined; document what you observe | Sets up P03's concurrency section |
Expected Difficulties
- Your first graph will be slower than brute force and you will assume you failed. You did not. Instrument the distance counter at milestone 5 — before you look at wall-clock — and the two-factor model will tell you which half is the problem.
- The beam-search stopping condition is subtle. Stopping when the nearest unexplored candidate is worse than the worst held result is correct only under a local-metric assumption. Getting it slightly wrong changes recall by tens of percent, silently.
- Algorithm 4 reads like an optimisation and is a correctness property. Budget time for milestone 7; do not fold it into 6.
- Recall measurement bugs are invisible. If ground truth and index disagree on tie-breaking among equidistant points, recall looks slightly low forever. Test with deliberate ties.
- Building at n=10⁶ in Python is a multi-hour job. Do milestone 8 before E5, or E5 will eat a week of wall-clock.
Scope Boundaries
In scope: in-memory, single-threaded, one metric, graph-based indexes.
Out of scope: disk-resident indexes (P03); distributed sharding (P05); GPU search; filtering (P03); IVF and tree-based families beyond a paragraph in the report; multi-vector or late-interaction retrieval; learned indexes.
Permitted-library line: numpy for array storage and BLAS distance batches, yes.
hnswlib/faiss/scann only as an external comparison in E12 — never as a
component. heapq is fine; it is a priority queue, not the mechanism under study.
Deliverables
annindex/— brute force, NSW, HNSW, persistence, CLI sweep runnerREPORT.mdwith the recall/QPS Pareto curve, the two-factor decomposition, and thehnswlibcomparison including the gap- Notebook entries for E5, E7, E8
- A reusable ANN benchmark harness — this is the artifact with independent value.
It should accept any index exposing
add/searchand emit the standard curve - Raw sweep data as JSON
Exit Criteria
- recall@10 ≥ 0.95 at some efSearch on a stated dataset, verified against exact brute force
- The recall/QPS curve dominates brute force at some n you measured, and you can state that n and explain it via the two-factor model
- E7 complete: clustered vs uniform, with the ceiling effect measured and explained
- E8 complete: Algorithm 4 implemented and its effect on the E7 ceiling measured
- All nine correctness tests pass, including reachability
-
hnswlibcomparison run, gap reported honestly with the algorithmic/constant split - Persistence round-trips and corruption is detected
-
REPORT.mdwritten with at least one falsified prediction
Extension Ideas
- Adaptive efSearch: predict per-query difficulty from the first few hops and set the beam width per query. If it holds recall at lower mean latency, that is a real result — see Research Directions.
- Product quantization on the graph: measure the memory/recall frontier.
- Filtered search preview: implement one filtering strategy here to feel the problem before P03 formalises it.
- Multi-threaded build with a measured scaling curve; the graph is a shared mutable structure and this is genuinely hard.
Connections
Backward: P01 gives you real embeddings with realistic RC — far better test data than random vectors, and the contrast between them is E4/E7.
Forward:
- → P03 (Vector DB): this index is P03's core. Keep the API narrow:
add,search,delete,save,load. - → P08 (Recommender): candidate retrieval calls this, not a library. Recall@k here becomes an upper bound on the recommender's recall, and quantifying that propagation is one of P08's experiments.
- → P14: the distance kernel is a memory-bound inner loop and a natural target for the tiling and SIMD work.
- → P15: an adaptive ANN policy is one of the candidate research questions.
References
- Malkov, Y. A., Yashunin, D. A. Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs. IEEE TPAMI 42(4), 2020.
- Malkov, Y., Ponomarenko, A., Logvinov, A., Krylov, V. Approximate nearest neighbor algorithm based on navigable small world graphs. Information Systems 45, 2014.
- He, J., Kumar, S., Chang, S.-F. On the Difficulty of Nearest Neighbor Search. ICML 2012.
- Beyer, K., Goldstein, J., Ramakrishnan, R., Shaft, U. When Is "Nearest Neighbor" Meaningful? ICDT 1999.
- Indyk, P., Motwani, R. Approximate Nearest Neighbors: Towards Removing the Curse of Dimensionality. STOC 1998. The LSH origin.
- Jégou, H., Douze, M., Schmid, C. Product Quantization for Nearest Neighbor Search. IEEE TPAMI 33(1), 2011.
- Kleinberg, J. Navigation in a Small World. Nature 406, 2000. Why greedy routing works when long-range links follow the right distribution.
- Aumüller, M., Bernhardsson, E., Faithfull, A. ANN-Benchmarks. Information Systems 87, 2020.
- Johnson, J., Douze, M., Jégou, H. Billion-scale similarity search with GPUs. IEEE Transactions on Big Data 7(3), 2021. The FAISS paper.
- Subramanya, S. J. et al. DiskANN: Fast Accurate Billion-point Nearest Neighbor Search on a Single Node. NeurIPS 2019. Read before P03 — it is the disk-resident answer.