hello·ai

Vector indexes

AssumesVector space intuitionRetrieval & RAG

In one line

An index that is allowed to be wrong, where the tuning dial is recall and correctness is something you measure rather than assume.

Why it exists

Finding the nearest vectors to a query means computing similarity against every stored vector. At ten million chunks and 768 dimensions that is roughly 7.7 billion multiply-adds per query. Perfectly correct, and far too slow.

A cuts that to something sublinear by only examining a promising subset. The thing that makes it different from every other index you have used is that the subset might not contain the true nearest neighbour. B-trees do not have this property. Hash indexes do not have this property. Every practical vector index does, and designing around it is the whole topic.

exact search: compare against every vectorO(n) — correct, and a full scanHNSW: descend a layered proximity graphO(log n) — fast, and allowed to missThere is no exact index for this. Recall becomes a number you measureagainst a brute-force scan, not a property you assume.
Exact search is a full scan. Every practical index trades a defined amount of correctness for orders of magnitude less work.

Approximate means it can be wrong#

An index over an ordered key range partitions cleanly: a value is on one side of a boundary or the other. High-dimensional space has no such partition, because every region borders too many others — the geometry of the space simply does not admit it.

So structures navigate rather than partition. They start somewhere and move toward the query, examining a bounded number of candidates, and stop. If the true nearest neighbour sits in a region the walk never entered, it is missed. Silently, with no error and no signal.

That makes a first-class operational number: of the genuinely nearest k vectors, how many did the index actually return? Ninety per cent recall means one relevant chunk in ten never reaches the model, and downstream this presents as "the assistant doesn't know things" rather than as a search fault.

Measuring it is straightforward and almost nobody does it: take a few hundred sample queries, run a brute-force exact scan to get ground truth, compare. Once you have that number you can tune against it, and the shape of the curve is always the same — recall rises steeply at first and then asymptotes, so the last few points cost disproportionately.

The structures you will meet#

Flat is no index: compare against everything. Exact, trivially correct, and entirely appropriate below roughly 100,000 vectors, where a scan is milliseconds. A surprising number of production systems should be using this and are not.

builds a layered proximity graph. The top layer is sparse with long edges for covering distance quickly; each layer down is denser; search descends until it converges. It is the default in most vector databases because it gives excellent recall at high . Its costs are memory — the graph itself is often comparable to the vectors — and awkward updates, since deletes are tombstoned and the graph degrades until rebuilt.

IVF clusters vectors and searches only the nearest few clusters. Much lighter on memory, needs a training pass over a sample to pick centroids, and recall depends on how many clusters you probe. It pairs naturally with product quantization, which compresses each vector into a short code and lets billions of vectors fit in memory at the cost of some precision.

Disk-based graphs such as DiskANN keep most of the index on SSD and are the right answer at a scale where RAM is the binding cost.

The two dials on HNSW that you will actually touch: M, the edges per node, traded against memory; and ef_search, the candidate list size at query time, traded directly against latency. ef_search is a query-time parameter, which means you can raise it for a slow path and lower it for an interactive one without rebuilding anything.

Operating one#

A vector index is a derived artefact, and treating it as a database is the source of most operational pain.

Rebuilds are routine. Changing model, strategy or means re-embedding everything. Build the new index alongside, verify recall on a sample, then swap. Store the model id and dimension in index metadata so a mismatched write fails loudly instead of quietly corrupting results.

Deletes are lazy. Graph indexes tombstone rather than remove, so heavy churn degrades both recall and latency until a rebuild. If your corpus turns over often, schedule rebuilds rather than waiting for a complaint.

Filtering needs care. "Nearest neighbours where team = platform" can be done by filtering after search, which may return nothing if the top k were all other teams, or before search, which can be expensive. Modern engines support filtered search that constrains the graph walk; check that yours does before designing around it.

at write time. With unit vectors, and dot product agree and the index can use whichever is faster. Doing it per query is wasted work and one more place for a mismatch to creep in.

And the boring point worth repeating: most systems do not need a dedicated vector database. Postgres with pgvector handles millions of vectors, keeps your metadata joins in the same transaction, and removes an entire piece of infrastructure from your diagram. Reach for a specialised engine when you have measured that you need it.

The math

Recall at k is the overlap between what the index returned and the true nearest set:

recall@k=RkGkk\text{recall@}k = \frac{|R_k \cap G_k|}{k}

where RkR_k is the index's top k and GkG_k the exact top k.

Sample query, k=10k = 10. Ground truth ids from a brute-force scan:

G  = {12, 47, 88, 91, 103, 156, 201, 233, 278, 310}
R  = {12, 47, 88, 91, 156, 201, 233, 278, 310, 402}

Nine of the ten true results are present; 402 is a false positive that displaced 103.

recall@10=910=0.9\text{recall@}10 = \frac{9}{10} = 0.9

Average that over 300 sample queries and you have a number you can tune ef_search against — and a service level objective you can alert on.

Worked example

Two million chunks, 768 dimensions, float32.

raw vectors     2e6 x 768 x 4 bytes           = 6.1 GB
HNSW graph      M=16, ~2 x 16 x 4 x 2e6       = 0.3 GB
total in memory                               ~ 6.4 GB

Fits on one machine comfortably. Now measure the recall-latency curve by sweeping ef_search against a 300-query ground-truth set:

ef_search   recall@10    p50 latency
   16         0.82          1.1 ms
   40         0.93          2.4 ms
  100         0.98          5.6 ms
  200         0.99         10.8 ms

The decision falls out of the table. Going from 40 to 100 buys five points of recall for 3ms — clearly worth it in a pipeline where the model call takes two seconds. Going from 100 to 200 buys one point for another 5ms, which is not.

Then apply to the stored vectors:

int8 vectors    2e6 x 768 x 1 byte            = 1.5 GB   (-75%)
recall@10 at ef_search=100                    = 0.97     (-1 point)

A quarter of the memory for one point of recall, which for most corpora is the right trade — and is only visible as a trade because the measurement existed.

Gotchas

  • Never measuring recall. Without ground truth you cannot tell a retrieval problem from a generation problem, and the default assumption will be that the model is at fault. Three hundred queries and a brute-force scan is an afternoon's work and it anchors everything downstream.

  • Reaching for a vector database at small scale. Under a hundred thousand vectors, a flat scan in Postgres is exact, fast enough, and one fewer system to operate. Specialised engines earn their place with measurements, not with anticipated growth.

  • Forgetting that the index is derived state. It cannot be migrated, only rebuilt, and every embedding or chunking change means a full rebuild. Plan the swap procedure before you need it, not during an incident.

  • Post-filtering and wondering where the results went. Retrieving the top 10 and then filtering by tenant can legitimately return zero rows. Use pre-filter or filtered search, and treat permissions filtering as a correctness requirement rather than a relevance feature.

  • Tuning recall without checking first. If your misses are exact identifiers, no ef_search value will fix them. The vectors do not contain the information, and a keyword index does.

Mental model

It is an index with a configurable error rate — a Bloom filter turned inside out. A Bloom filter gives false positives and never false negatives; an ANN index gives false negatives and rarely tells you. You would not deploy a Bloom filter without deciding its false-positive rate, and this is the same decision, made against a measured recall curve rather than a formula.

In practice

Problem statements that lean on this topic. Rated by the phase that makes them land.

Done reading?

Nothing marks itself complete. Say so only when you could explain this to someone else.

This topic has 3 subtopics.