Skip to content
advanced Phase 10 · Vector Databases in Depth

Approximate Nearest Neighbor Indexes

Understand HNSW, IVF, and how recall trades off against query latency.

1h
0 problems
Topic Progress 0%

The Arithmetic of Exact Search

One million ticket embeddings at 768 dimensions in float32: 1,000,000 × 768 × 4 = 3.07 GB. Exact nearest-neighbor means reading all 3.07 GB for one query. On a node pulling roughly 9 GB/s from memory that is a 340 ms query before a single multiply-accumulate finishes — three queries per second for every core you dedicate, at a latency no type-ahead box accepts.

   exact search                      approximate search

 read 3.07 GB                       read ~4,000 candidate vectors
 768-dim distance × 1,000,000       768-dim distance × 4,000
 rank, keep top 10                  rank, keep top 10
        |                                   |
        v                                   v
  recall = 1.000, 310 ms            recall ≈ 0.95, 6 ms

Two escapes exist. Shrink what is stored — product quantization turns a 3 KB vector into 32 bytes — and the error becomes systematic: every query pays it, and nobody can tell you which neighbors it displaced. Or accept that the index may skip a true neighbor. That is approximate nearest neighbor, and every ANN structure makes the same bargain: examine a subset of the corpus per query, then measure how much was missed with recall@k.

recall@10 = average over queries of  |ANN top 10 ∩ exact top 10| / 10

Everything else in this topic is a mechanism for moving along one curve — recall against latency — plus the arithmetic to know what you bought. The knobs each engine exposes are in Vector Search with pgvector and Managed Vectors with Pinecone; the measurement protocol that ties them together closes this topic.

IVF: Ask the Clusters, Not the Vectors

IVF (inverted file) starts with a question that costs nothing to answer: which region of the space is the query in? Before it looks at vectors, it looks at centroids.

Training runs k-means over the corpus and splits it into lists clusters — 1024 for a million rows, roughly rows divided by 1000. Each vector records its centroid. At query time the search finds the nearest centroids, examines only those clusters, and ranks what they hold. probe is how many clusters it examines.

probe (of 1024 lists) recall@10 p95 latency vectors visited
1 (default) 0.61 4 ms ~8k
8 0.87 7 ms ~65k
32 0.96 16 ms ~260k
128 0.99 52 ms ~1M — brute force again

1M × 768-dim float32 vectors, 1000 held-out queries, one 8-core node. Your absolute numbers will move with hardware; the shape of the curve will not.

Read the last row: probe enough clusters and IVF converges on exact search, paying exact search's cost. That convergence is the honest part of the design — IVF never claims free recall, it lets you buy it in units of latency without rebuilding anything.

Three failure modes are structural rather than accidental:

  • Default probe is 1. The shipped configuration examines one cluster, which is the fastest and least accurate point on the curve. If nobody tuned it, recall is exactly what the first row says.
  • Skewed data defeats uniform lists. Dense regions of the corpus get many small clusters, the long tail gets a few large ones; a probe of 8 serves common tenants well and rare tenants badly. Measure recall per slice, not only in aggregate.
  • Centroids trained on stale rows. The clusters describe the distribution at build time. After the corpus drifts, recall decays while latency looks healthy — the symptom that only a re-measurement against exact search will surface.

HNSW: Walk a Small-World Graph

IVF asks which region; HNSW asks which neighbor to walk to next. It builds a proximity graph — each vector linked to its nearest m neighbors — and stacks it in layers where each level up keeps roughly one node in three:

layer 2      A --------------------------- F        (few nodes, long edges)
layer 1      A ----------- C ------- E --- F
layer 0      A - B - C - D - E - F - G - H - I ...  (every node)
             ^
      entry point: descend greedily while distance improves,
      then beam-search layer 0 carrying ef_search candidates

The descent through sparse layers costs almost nothing and lands near the query. Layer 0 then runs a beam search with a candidate heap of ef_search entries: larger heap, more of the neighborhood explored, better recall, more distance computations.

ef_search recall@10 p95 latency
16 0.78 3 ms
40 (default) 0.93 5 ms
100 0.97 9 ms
400 0.995 31 ms

Same corpus, same node as the IVF table. Put the two side by side: at equal recall around 0.96–0.97, HNSW answers in ~9 ms where IVF needs ~16 ms. HNSW paid for that at build time — minutes of link construction against IVF's 25-second k-means pass — and it holds more memory.

The memory figure is worth computing instead of accepting. At m = 16, layer 0 stores up to 2m = 32 neighbor ids per node at 4 bytes each: about 128 bytes of graph per vector, plus a few percent for upper layers.

Vector shape Raw storage HNSW graph overhead
768-dim float32 3,072 B ~4%
128-dim float32 512 B ~28%

The graph is cheap against wide text embeddings and expensive against narrow ones. And steady-state size understates the real constraint: construction wants the vectors and the growing graph resident together, which is why builds OOM hosts that serve the table fine.

Inserts behave differently from IVF: the graph accepts new nodes by linking them during descent, so incremental writes need no rebuild. IVF is the one that must be retrained when the distribution moves.

Measuring What You Actually Purchased

Published recall figures are measured on vectors you do not have, for queries you will never run. The protocol to produce your own number is short enough to run after every index change:

rng = np.random.default_rng(0)
q_idx = rng.choice(len(emb), 1000, replace=False)   # held-out queries
queries = emb[q_idx]

exact = [set(np.argpartition(row, -10)[-10:])       # brute-force ground truth
         for row in queries @ emb.T]                # 1000 × 1M cosine scores

hits = 0
for q, truth in zip(queries, exact):
    res = index.query(vector=q.tolist(), top_k=10, namespace="tickets")
    hits += len(truth & {m["id"] for m in res["matches"]})
print(f"recall@10 = {hits / (1000 * 10):.3f}")

One number per configuration, and the trade table builds itself:

Structure Build p95 at recall ≈ 0.96 RAM (768-dim) Dial
Brute force none 310 ms 3.1 GB none
IVF, lists=1024 25 s 16 ms at probe=32 3.1 GB + centroids probe; lists need a rebuild
HNSW, m=16 6 min 9 ms at ef_search=100 ~3.3 GB ef_search; no rebuild

Where to aim depends on what runs after retrieval. A pipeline that re-ranks the top-30 with a cross-encoder (Reranking Models) recovers neighbors the index missed, provided they survived into the 30 — recall near 0.90 at the vector stage is often enough. Retrieval that answers directly, or hybrid setups that blend lexical matches (Hybrid Search), wants 0.95 or better because nothing downstream repairs a missing neighbor.

Record four things with every configuration: structure, parameter values, measured recall, measured p95. Without them, the next engineer who raises ef_search to quiet a complaint has no way to name the latency they spent or the recall they gained — and the alternative to that record is a benchmark on synthetic vectors that predicts nothing about your filters.

Quiz

1. You raise `hnsw.ef_search` from 40 to 400 and p95 latency moves from 5 ms to 31 ms. What else changed?

Question 1 options

2. An IVFFlat index answers in 4 ms but misses obvious neighbors, and no configuration was ever tuned. What is the most likely cause?

Question 2 options

3. A 12M-vector corpus at 128 dimensions is rebuilt weekly because the data changes. Which structure fits that cadence?

Question 3 options

4. How do you measure recall@10 for your own workload?

Question 4 options

Flashcards

Question

Why is exact nearest-neighbor search slow at 1M vectors?

Answer

It reads the whole matrix: 1M × 768 × 4 B = 3.07 GB per query, bound by memory bandwidth (~9 GB/s → ~340 ms) before distance math. ANN works because it visits ~4k candidates instead.

Question

What does IVF's probe parameter control and what does it cost?

Answer

How many of the k-means clusters each query searches. Low probe = fast, low recall (default 1 ≈ 0.61 recall in the worked example); probe 32 ≈ 0.96 at 16 ms; probe = lists converges on brute force.

Question

HNSW build parameters vs query parameter

Answer

m (neighbors per node) and ef_construction (beam during build) are fixed at creation; ef_search (default 40) is the query-time heap size that trades recall for latency without a rebuild.

Question

When is HNSW's memory overhead significant rather than negligible?

Answer

Graph links cost ~128 B per node at m=16, so overhead is ~4% on 768-dim float32 vectors but ~28% on 128-dim ones. Build-time peak (vectors + growing graph resident) hurts before steady-state size does.

Question

What is the recall-recording protocol after any index change?

Answer

Sample held-out queries, compute exact top-k exhaustively for ground truth, run the index on the same queries, average the intersection size over k. Record structure, parameters, recall@k, and p95 together.

Revision Notes

Key Takeaways

  • 1. Exact search is bound by memory bandwidth, so ANN exists to read thousands of vectors instead of gigabytes
  • 2. IVF and HNSW are two mechanisms for the same dial: recall against latency, tunable at query time without a rebuild
  • 3. Defaults are chosen for safety, not quality — IVF probe 1 and HNSW ef_search 40 both leave recall on the table
  • 4. Memory overhead is dimensionality-dependent: the same graph is 4% at 768 dims and 28% at 128 dims
  • 5. Recall you have not measured on your own queries is a claim you are repeating, not a property you have

Interview Tips

  • • Walk the arithmetic: 3.07 GB per scan, ~9 GB/s bandwidth, ~340 ms — then state the ANN bargain as missed neighbors quantified by recall@k
  • • Be able to draw HNSW's layers and say what ef_search changes (query-time beam) versus ef_construction (graph quality at build)
  • • Explain why IVF must be retrained after distribution drift while HNSW accepts incremental inserts
  • • Bring a measured operating point to design discussions: structure, parameters, recall@k, p95 — and say which downstream component (reranker, hybrid) makes a lower recall defensible

Cheat Sheet

ANN Indexes — Cheat Sheet

The bound

1M × 768-dim float32 = 3.07 GB  ->  exact scan ~310-340 ms/query (bandwidth-bound)
recall@10 = mean over queries of |ann ∩ exact| / 10

IVF (cluster, then probe)

CREATE INDEX ON t USING ivfflat (embedding vector_cosine_ops) WITH (lists = 1024);  -- rows/1000
SET ivfflat.probe = 32;   -- clusters per query; default 1 is the worst recall point
probe recall@10 p95
1 0.61 4 ms
8 0.87 7 ms
32 0.96 16 ms
128 0.99 52 ms

HNSW (layered proximity graph)

CREATE INDEX ON t USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
SET hnsw.ef_search = 100;   -- default 40
ef_search recall@10 p95
16 0.78 3 ms
40 0.93 5 ms
100 0.97 9 ms
400 0.995 31 ms

Pick

  • Frequent rebuilds / low dims / tight memory → IVF (fast build, tiny centroid table)
  • Low-latency at high recall, stable corpus → HNSW (expensive build, incremental inserts)
  • Reranker downstream → vector-stage recall ~0.90 can be enough; direct answers want ≥0.95

Measure

exact = [set(np.argpartition(row, -10)[-10:]) for row in queries @ emb.T]
recall = mean(len(ann ∩ truth) / 10 ...)

Record structure + parameters + recall@k + p95 together, per data slice.

Continue Learning

Before You Start

  • No prerequisites listed.

Related Reading