- Explain when cosine similarity, dot product and L2 distance produce identical rankings, and derive why from the expansion of the squared distance.
- Compare exact search with IVF, HNSW and product-quantized indexes in terms of recall, latency, memory and update cost.
- Describe how HNSW builds and searches its layered graph and what M, efConstruction and efSearch control.
- Calculate the memory footprint of a vector index for given N, dimensions, precision and index overhead.
- Implement exact search and recall@k measurement in numpy and an HNSW index in FAISS.
- Choose between a library, a database extension and a dedicated vector database, and a metadata-filtering strategy, for a given workload.
Before this: Embeddings and Retrieval-Augmented Generation, Linear Algebra for AI
Every retrieval-augmented system eventually has to answer one question millions of times a day: "which of my stored vectors are closest to this one?" Answering it exactly is easy for ten thousand vectors and ruinously slow for a billion. Vector indexes are the data structures that make the question cheap, and vector databases wrap them with storage, filtering, updates and replication. This lesson takes you from the geometry of embeddings to the internals of HNSW and product quantization, and ends with how to size and choose a system.
The picture: a library with no catalogue
Imagine a library where every book has been placed in a vast room according to its meaning: cookbooks cluster in one corner, tax law in another, and books about Italian tax law on cooking equipment sit somewhere in between. A question is also placed in the room, and your job is to find the nearest books. The exact method is to walk to every book and measure the distance. An index is a shortcut: you might divide the room into neighbourhoods and only search the few nearest your question (IVF), or you might follow signposts from book to book, always stepping toward the question, starting with long-distance express routes and switching to local streets as you get close (HNSW). Both shortcuts occasionally miss a book that was in fact the closest. That miss rate, traded against speed and memory, is the central engineering trade-off of this lesson.
Embeddings: models and dimensions
An embedding model maps a piece of content (a sentence, a passage, an image) to a fixed-length vector , where is the dimension. Retrieval embedding models are usually transformer encoders trained with a contrastive objective so that a query and a passage that answers it end up close, and unrelated pairs end up far apart (see the prerequisite lesson on embeddings and RAG).
Practical facts that matter for indexing:
- Dimensions for text embedding models commonly range from a few hundred to a few thousand. The widely used open model
all-MiniLM-L6-v2produces 384-dimensional vectors; many larger models use 768, 1024 or more. Memory and search cost grow linearly with . - Precision. Vectors are produced as 32-bit floats (4 bytes per dimension). Many systems store them as 16-bit floats, 8-bit integers, or even 1 bit per dimension (binary quantization), usually with a recall cost that should be measured, not assumed.
- Truncatable embeddings. Models trained with Matryoshka Representation Learning (Kusupati et al., 2022) put the most important information in the leading dimensions, so you can keep, say, the first 256 of 1,024 dimensions and re-normalize, trading a little quality for a 4x smaller index.
- Query and document must use the same model (and often a model-specific prefix or instruction for queries versus passages). Changing the model means re-embedding the entire corpus: vectors from different models live in unrelated spaces.
Similarity metrics and when they are equivalent
Three scores dominate. For vectors (query) and (stored item):
Higher dot and cosine mean more similar; lower L2 distance means more similar. Expanding the squared distance gives the key identity:
If every vector is unit-normalized (), this becomes , and the dot product equals the cosine. So for normalized vectors, all three metrics produce the same ranking. That is why the standard practice is: normalize once at ingestion and at query time, then use inner product (often the fastest kernel).
Check with numbers: and are both unit length. Their dot product is . Squared L2 distance is , and indeed .
When vectors are not normalized, the metrics disagree. Take , , :
| Metric | Winner | ||
|---|---|---|---|
| Dot | |||
| Cosine | |||
| L2 |
points in exactly the same direction as but is twice as long; L2 penalizes that length difference, cosine ignores it, and the dot product rewards it. Use the metric the embedding model was trained with (its model card says so). Some models deliberately encode information in the norm, in which case unnormalized inner product is correct.
Exact versus approximate nearest neighbour
Exact k-nearest-neighbour (kNN) search computes the score against all vectors and keeps the top . Cost per query is multiply-adds. With and that is about multiply-adds per query, which a modern CPU does in tens of milliseconds and a GPU much faster. Exact search is perfectly reasonable up to roughly a few million vectors, or for any corpus where queries are batched, and it is the ground truth you measure everything else against.
Approximate nearest neighbour (ANN) search uses an index to examine only a small fraction of vectors. Quality is measured by recall@k: the fraction of the true top- (from exact search) that the ANN search returns,
Note this is index recall (did the index find the true nearest vectors), not end-to-end retrieval recall (did we find the documents a human judges relevant). Both matter; they are covered in the advanced retrieval lesson.
Index types
Flat (brute force)
Store vectors in a contiguous array and scan them all. No training, no build time, 100% recall, trivially supports deletes. Memory is exactly bytes per value.
IVF: inverted file with coarse clustering
Run k-means on (a sample of) the data to get centroids. Each vector is assigned to its nearest centroid and stored in that centroid's inverted list. At query time, compare the query to all centroids, pick the closest, and scan only those lists.
- Work per query is about vector comparisons. With , and , that is roughly comparisons instead of , about 60 times less work.
- A common starting point is on the order of (more for large corpora), then tune for the recall you need.
- Failure mode: a true neighbour sitting just across a cluster boundary is missed unless its cluster is probed. Raising fixes this at the cost of speed.
- IVF needs a training step, and if the data distribution drifts far from the training sample, the clusters become unbalanced and recall or latency degrade. Periodic retraining is part of operating it.
HNSW: hierarchical navigable small-world graphs
HNSW (Malkov and Yashunin, arXiv 2016) is the most widely deployed in-memory ANN index. It is a proximity graph: each vector is a node linked to some of its near neighbours. Search is greedy graph traversal: start at an entry node, look at its neighbours, move to whichever is closest to the query, and repeat until no neighbour is closer.
A single flat graph has two problems: greedy search can get stuck in a local minimum, and reaching a far-away region takes many hops. HNSW fixes both with a hierarchy, much like a skip list:
- Layers. Each inserted node draws a maximum layer from an exponentially decaying distribution, so every node is in layer 0, a fraction is also in layer 1, a smaller fraction in layer 2, and so on. Upper layers are sparse "express routes"; layer 0 contains everything.
- Search. Start at the single entry point in the top layer. Greedily descend: in each upper layer, walk to the closest node you can reach, then drop to the next layer starting from there. In layer 0, run a beam search that keeps a dynamic candidate list of size : repeatedly expand the closest unexpanded candidate, add its unseen neighbours, and keep the best found so far. Stop when the closest unexpanded candidate is worse than the worst kept result. Return the top (so ).
- Insertion. A new node runs the same search to find its neighbourhood (using a candidate list of size ) on each layer it belongs to, then links to up to neighbours, chosen with a heuristic that prefers diverse directions rather than near-duplicates. Neighbours whose link lists overflow are pruned.
The three parameters you will tune:
| Parameter | What it controls | Raising it |
|---|---|---|
| Max links per node per layer (implementations commonly allow on layer 0) | Higher recall, more memory, slower build; typical 16 to 64 | |
| Candidate list size while building | Better graph quality and recall, slower build; typical 100 to 500 | |
| Candidate list size while querying | Higher recall, higher latency; tune per workload, can change per query |
HNSW gives excellent recall-latency curves and supports incremental inserts without retraining. Its costs are memory (the vectors stay uncompressed in RAM, plus the links), slow builds for large corpora, and awkward deletes (most implementations mark deleted nodes as tombstones and rebuild or repair periodically).
Product quantization (PQ)
PQ (Jégou, Douze and Schmid, 2011) compresses vectors so that far more fit in memory. Split each -dimensional vector into sub-vectors of dimensions. For each sub-space, run k-means to learn a codebook of centroids (usually , so 256 centroids). Each sub-vector is replaced by the index of its nearest centroid, so a vector becomes codes of bits: with , , , a vector shrinks from bytes to bytes, a 32x reduction.
Distances are computed asymmetrically: the query stays at full precision. For each sub-space, precompute a lookup table of the distance from the query's sub-vector to all 256 centroids; the approximate distance to any stored vector is then the sum of table lookups. PQ is lossy, so it is usually combined with IVF (FAISS's IndexIVFPQ) and followed by re-ranking: fetch a few hundred candidates by PQ distance, then rescore them with the exact vectors (kept on disk or in cheaper memory).
Other families worth knowing
- Scalar quantization (float32 to int8 per dimension): 4x compression, often small recall loss, simple.
- Binary quantization: 1 bit per dimension, 32x compression, Hamming distance is extremely fast; typically used as a first stage with full-precision rescoring.
- Disk-based graph indexes (for example the DiskANN family) keep compressed vectors in RAM and the graph plus full vectors on SSD, serving billion-scale corpora from a single machine.
The recall versus latency trade-off
Every ANN index exposes a knob (, , number of rescored candidates) that moves you along a curve: more work per query buys higher recall. The professional workflow is:
- Build a held-out set of real queries and compute exact top- with a flat index.
- Sweep the knob and plot recall@ against p50 and p99 latency (and throughput, queries per second, under realistic concurrency).
- Pick the cheapest setting that meets your recall target, often 0.95 to 0.99 for RAG, and re-check whenever data volume or distribution changes.
The code below makes this concrete with a toy IVF index written in numpy on clustered synthetic data.
import numpy as np
rng = np.random.default_rng(0)
N, d, k = 20_000, 64, 10
# Clustered synthetic data (real embeddings are clustered, not uniform noise)
centers = rng.standard_normal((200, d))
X = centers[rng.integers(0, 200, N)] + 1.5 * rng.standard_normal((N, d))
X = (X / np.linalg.norm(X, axis=1, keepdims=True)).astype(np.float32)
Q = centers[rng.integers(0, 200, 100)] + 1.5 * rng.standard_normal((100, d))
Q = (Q / np.linalg.norm(Q, axis=1, keepdims=True)).astype(np.float32)
def exact_search(Q, X, k):
S = Q @ X.T # (nq, N) cosine similarities
idx = np.argpartition(-S, k, axis=1)[:, :k] # unordered top-k per row
order = np.argsort(-np.take_along_axis(S, idx, 1), axis=1)
return np.take_along_axis(idx, order, 1)
truth = exact_search(Q, X, k) # ground truth for recall
Because all vectors are unit length, the matrix product Q @ X.T gives cosine similarities directly. argpartition finds the top in linear time, and only those are sorted. Now the IVF index:
def kmeans(X, nlist, iters=10):
C = X[rng.choice(len(X), nlist, replace=False)].copy()
for _ in range(iters):
assign = np.argmax(X @ C.T, axis=1) # nearest centroid by dot
for c in range(nlist):
m = X[assign == c]
if len(m):
C[c] = m.mean(0) / np.linalg.norm(m.mean(0))
return C, np.argmax(X @ C.T, axis=1)
nlist = 128
C, assign = kmeans(X, nlist)
lists = [np.where(assign == c)[0] for c in range(nlist)]
def ivf_search(q, k, nprobe):
probe = np.argsort(-(C @ q))[:nprobe] # nprobe closest centroids
cand = np.concatenate([lists[c] for c in probe])
return cand[np.argsort(-(X[cand] @ q))[:k]], len(cand)
for nprobe in (1, 4, 16, 64):
hits = scanned = 0
for i, q in enumerate(Q):
found, n = ivf_search(q, k, nprobe)
hits += len(set(found) & set(truth[i])); scanned += n
print(f"nprobe={nprobe:3d} recall@10={hits / (k * len(Q)):.3f} "
f"scanned={scanned / (len(Q) * N):.1%}")
On one run this printed recall@10 of about 0.59 when scanning under 1% of the vectors, 0.86 at about 12.5%, and 0.985 at 50%: exactly the shape of the trade-off curve. Real embeddings are usually more clustered than this deliberately noisy data, so production indexes reach high recall while scanning far less. The same experiment with FAISS, which you would use in practice:
import faiss # pip install faiss-cpu
d = X.shape[1]
flat = faiss.IndexFlatIP(d); flat.add(X) # exact, inner product
hnsw = faiss.IndexHNSWFlat(d, 32, faiss.METRIC_INNER_PRODUCT) # M = 32
hnsw.hnsw.efConstruction = 200
hnsw.add(X)
hnsw.hnsw.efSearch = 64 # query-time knob
_, I_exact = flat.search(Q, 10)
_, I_hnsw = hnsw.search(Q, 10)
recall = np.mean([len(set(a) & set(b)) / 10 for a, b in zip(I_exact, I_hnsw)])
print(f"HNSW recall@10 = {recall:.3f}")
FAISS expects contiguous float32 arrays; call faiss.normalize_L2(X) in place if your vectors are not already normalized and you want cosine similarity.
Worked example: sizing memory
You must index million chunks with a 768-dimensional model.
Raw float32 vectors: bytes GB (about 28.6 GiB).
HNSW overhead with : layer 0 holds up to neighbour IDs per node at 4 bytes each, so bytes per vector, or GB. Upper layers add only a few percent more because few nodes reach them. Total roughly GB, before IDs, metadata and the database's own overhead. Plan for a machine with comfortably more than that, since build time needs headroom too. (Exact overhead is implementation-dependent; measure it.)
Cheaper alternatives:
| Option | Bytes per vector | Total for | Notes |
|---|---|---|---|
| float32 flat | 3,072 | 30.7 GB | Exact |
| float16 | 1,536 | 15.4 GB | Usually negligible loss |
| Truncate to 256 dims (Matryoshka model), float32 | 1,024 | 10.2 GB | Only if model supports it |
| int8 scalar quantization | 768 | 7.7 GB | Rescore top candidates |
| IVF-PQ, , 8 bits, plus 8-byte IDs | 104 | 1.04 GB | Plus a few MB of codebooks; rescore |
| Binary, 1 bit per dim | 96 | 0.96 GB | First stage only |
The PQ codebooks are tiny: sub-spaces centroids dims 4 bytes bytes, under 1 MB. That table is the core of capacity planning: pick the cheapest row whose measured recall (after rescoring) meets your target.
Metadata filtering
Real queries come with constraints: "only documents this user may see", "only 2025 contracts", "language = de". Three strategies:
- Post-filtering: run ANN for top , then drop items failing the filter. Simple, but if the filter is selective (say 1% of the corpus passes), most of your results are discarded and you may return nothing. Over-fetching () helps only partially.
- Pre-filtering: compute the set of allowed IDs first, then search only among them. Exact for selective filters (brute force over the allowed subset is cheap), but naive graph search over a sparse allowed subset breaks connectivity.
- Filter-aware search: the index checks the filter during traversal, skipping disallowed nodes while still using them for navigation, or maintains per-value partitions. Most mature vector databases implement some form of this; behaviour with highly selective filters is exactly what to benchmark.
For access control, never rely on post-filtering by the LLM: enforce permissions in the retrieval query itself, so unauthorized text never enters the prompt.
Hybrid indexes
Dense vectors capture meaning but are weak on exact tokens: part numbers, error codes, rare names. Sparse lexical indexes (BM25 over an inverted index, or learned sparse vectors) excel there. Hybrid search runs both and fuses the results; many systems now store dense and sparse representations side by side and fuse in one query. The fusion methods are covered in the advanced retrieval lesson.
Choosing a vector store
The options fall into three groups (feature sets change quickly, so verify against current documentation before committing):
- Libraries such as FAISS (Meta) and hnswlib: in-process index implementations with no server, persistence layer or access control. Ideal for research, batch jobs and as the engine inside other systems.
- Extensions to databases you already run, such as pgvector for PostgreSQL, which adds a
vectorcolumn type, distance operators, and HNSW and IVFFlat index types. Elasticsearch, OpenSearch and several other databases offer comparable vector search. Big advantage: vectors live next to your relational data, with transactions, joins, backups and row-level security you already understand. - Dedicated vector databases (for example Milvus, Qdrant, Weaviate, Pinecone and Chroma): purpose-built for vector workloads, with features such as filtered ANN, multi-tenancy, quantization options, hybrid search and horizontal scaling.
A practical rule: if your corpus is in the low millions of vectors and you already operate PostgreSQL, start with pgvector; move to a dedicated system when measured scale, filtering performance or operational needs demand it.
Common pitfalls
- Mixing metrics. Building an index with L2 on unnormalized vectors from a model trained for cosine silently degrades ranking.
- Forgetting to normalize the query after normalizing the corpus.
- Mixing embedding models or versions in one index. Re-embed everything when the model changes, and record the model ID with each vector.
- Tuning on random queries instead of real ones. Recall on synthetic queries says little about production.
- Measuring average latency only. Users feel p99, and p99 grows under concurrency and with filters.
- Selective filters with post-filtering, returning empty or poor results.
- Unbounded HNSW deletes. Tombstones accumulate, recall and memory degrade; schedule compaction or rebuilds.
- Undersized RAM. An HNSW index that spills to swap is orders of magnitude slower.
In practice
- Start with exact search as the baseline and ground truth; only add ANN when latency or cost demands it.
- Normalize vectors and use inner product unless the model says otherwise.
- For HNSW, a sensible start is to , , then sweep against your recall target.
- Store the full-precision vectors somewhere so you can rescore after quantization and re-index without re-embedding.
- Version your index: embedding model, chunking settings and index parameters together, so you can roll back.
- Benchmark with your real filters, real query mix and realistic concurrency before choosing a product.
Key takeaways
- For unit-normalized vectors, cosine, dot product and L2 distance give the same ranking, so normalize once and use inner product unless the model says otherwise.
- Exact search is the ground truth and is often fast enough up to a few million vectors; add ANN only when latency or cost requires it.
- Every ANN index has a knob (nprobe, efSearch, rescoring depth) that trades recall for latency; tune it on real queries against exact results.
- HNSW gives excellent recall and latency but keeps full vectors plus links in RAM; IVF-PQ and other quantization trade some recall for 10 to 30 times less memory.
- Memory is roughly N times dimensions times bytes per value, plus index overhead; size it before choosing hardware or a service.
- Enforce filters, especially permissions, inside the retrieval query, and benchmark filtered queries because selective filters break naive ANN.
Terms in this lesson
- Embedding dimension
- The length d of the vector an embedding model outputs, which sets per-vector memory and search cost.
- Approximate nearest neighbour (ANN)
- Search that returns most, but not necessarily all, of the true nearest vectors while examining only a fraction of the collection.
- Recall@k (index)
- The fraction of the exact top-k neighbours that an approximate search also returns in its top k.
- IVF (inverted file index)
- An index that clusters vectors with k-means and searches only the lists of the nprobe centroids closest to the query.
- HNSW
- Hierarchical Navigable Small World, a layered proximity graph searched greedily from sparse upper layers down to a dense bottom layer.
- efSearch
- The size of the candidate list HNSW keeps during a query; larger values raise recall and latency.
- Product quantization (PQ)
- A compression scheme that splits vectors into sub-vectors and replaces each with the ID of its nearest centroid in a learned per-sub-space codebook.
- Pre-filtering
- Restricting the search to items that satisfy metadata constraints before or during the similarity search, rather than discarding results afterwards.
Check your understanding
- 1.
Two unit-length vectors have a dot product of 0.9. What is their squared Euclidean distance?
- 2.
Your HNSW index meets latency targets but recall@10 is 0.91 and you need 0.97. Which change requires no rebuild?
- 3.
Only 0.5% of documents pass a user's permission filter. The system takes the ANN top 100 and then post-filters to return 10. What is the most likely outcome?
- 4.
1,024-dimensional float32 vectors are compressed with PQ using m = 64 sub-quantizers of 8 bits each. What is the compression ratio of the codes?
- 5.
Which statement about an IVF index is correct?
Exercises
Work each problem before opening the solution.
- Exercise 1Core
You will index 50 million chunks embedded with a 1,024-dimensional model. Compute the memory for: (a) float32 flat vectors; (b) float16 flat vectors; (c) the HNSW link overhead with , assuming 4-byte neighbour IDs per node on layer 0 and ignoring upper layers; (d) IVF-PQ codes with sub-quantizers at 8 bits plus an 8-byte ID per vector. Use 1 GB = bytes.
Show solution
(a) bytes GB.
(b) Half of that: GB.
(c) links 4 bytes bytes per node; GB on top of the vectors, so HNSW over float32 needs roughly GB before metadata and headroom.
(d) bytes per vector; GB, plus negligible codebooks ( bytes, about 1 MB) and the coarse centroids. IVF-PQ fits on one ordinary server; float32 HNSW needs a very large-memory machine or sharding.
- Exercise 2Core
Let , and . (a) Rank by dot product, cosine and L2 distance. (b) Normalize and rank again. (c) Explain the result using the identity .
Show solution
(a) Dot: , , so wins. Cosine: has norm 1, cosine ; has norm , cosine , so wins. L2: ; , so wins. Dot product disagrees with the other two because it rewards the large norm of .
(b) . Dot = cosine (below ), L2 (above ). All three now rank first.
(c) With the identity becomes , a strictly decreasing function of the dot product, and the dot product equals the cosine. Hence the three orderings must agree. Without normalization the term differs between candidates and breaks the equivalence.
- Exercise 3Core
Write a numpy function
topk(X, q, k, metric)supporting'dot','cosine'and'l2'. Demonstrate on random vectors with varying norms that the three metrics can return different top-5 results, and that after normalizing all vectors they return identical results.Show solution
import numpy as np def topk(X, q, k, metric): if metric == "dot": s = X @ q elif metric == "cosine": s = (X @ q) / (np.linalg.norm(X, axis=1) * np.linalg.norm(q)) elif metric == "l2": s = -np.linalg.norm(X - q, axis=1) # negate: smaller distance = better else: raise ValueError(metric) return np.argsort(-s)[:k] rng = np.random.default_rng(1) X = rng.standard_normal((1000, 32)) * rng.uniform(0.5, 3, (1000, 1)) # varied norms q = rng.standard_normal(32) print({m: topk(X, q, 5, m).tolist() for m in ("dot", "cosine", "l2")}) Xn = X / np.linalg.norm(X, axis=1, keepdims=True) qn = q / np.linalg.norm(q) print({m: topk(Xn, qn, 5, m).tolist() for m in ("dot", "cosine", "l2")})The first print shows three different lists: dot favours long vectors, L2 favours vectors whose norm is close to the query's, and cosine ignores norms. The second print shows three identical lists, as the identity for unit vectors predicts. Multiplying each row by a random scale factor is what makes the norms vary; with plain Gaussian vectors in 32 dimensions the norms are similar and the lists often overlap heavily even before normalization.
- Exercise 4Challenge
You must serve 100 million 768-dimensional vectors from one machine with a 16 GB RAM budget for the index, targeting recall@10 of at least 0.95. (a) Show that float32 HNSW cannot fit. (b) Propose an IVF-PQ configuration with and estimate RAM for the codes, IDs and centroids. (c) With , estimate the number of vector comparisons per query and the fraction of the corpus scanned. (d) Explain how you would reach the recall target and verify it.
Show solution
(a) Vectors alone: GB, about 19 times the budget, before graph links.
(b) Choose sub-quantizers at 8 bits: 64 bytes of code + 8 bytes of ID = 72 bytes per vector, so GB. Coarse centroids: bytes GB. PQ codebooks: bytes, under 1 MB. Total about 7.4 GB, leaving headroom within 16 GB. (Choosing would give 104 bytes per vector, 10.4 GB, still feasible and more accurate.)
(c) Average list size . Comparisons (centroids) , about of the corpus. The centroid scan is now the larger part; an HNSW index over the centroids (FAISS supports this as the coarse quantizer) reduces it.
(d) PQ distances are approximate, so fetch a larger candidate set (say the top 200 to 500 by PQ distance) and rescore with full-precision vectors stored on SSD (307 GB on disk is cheap), returning the top 10. Verify by computing exact top-10 with a flat index (on a GPU or in batches) for about 1,000 real queries, then sweep , and the rescoring depth, plotting recall@10 against p99 latency, and pick the cheapest configuration meeting 0.95. Re-run the check when the data distribution changes.
Further reading
- paperMalkov & Yashunin, Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs (2016)
- paperJohnson, Douze & Jégou, Billion-scale similarity search with GPUs (2017)
- paperDouze et al., The Faiss library (2024)
- paperKusupati et al., Matryoshka Representation Learning (2022)
- docsFAISS documentation wiki
- docspgvector: open-source vector similarity search for Postgres
Go further
Sources
Fact-checked and last reviewed 2026-11-05