See It Flow

AI Foundations · Module 6 of 8

Vectors & Similarity Search

Similarity search answers a different question from keyword search: which stored vectors are nearest to this query vector under a chosen metric, and how do we do that fast enough for real systems?

Mangesh MandlikLast updated:

Visual Experience

From query vector to retrieval results, with the production trade-offs that matter.

Watch keyword search fail, embeddings build the space, and nearest-neighbor retrieval bring back relevant results.

AI Foundations · Module 6

Vectors & Similarity Search

Finding meaning in high-dimensional space — the engine behind every RAG system.

Mute narration
1 / 10

01 ·The Problem

Thousands of engineering documents — how does AI find the right ones?

💬

User Query

How do I scale PostgreSQL?

📄 Scaling Distributed Systems
📄 PostgreSQL Horizontal Scaling
📄 Redis Cache Strategies
📄 Database Sharding Guide
📄 Replication Patterns
📄 Kafka Consumer Groups
📄 Indexing Deep Dive
📄 Microservices at Scale

Which few documents actually match the meaning?

Similarity search starts with overload: lots of documents, one intent, and no obvious lexical path to the right answer

"You ask: how do I scale PostgreSQL?"

Previous scene
Play
Next scene
Mute narration
Replay from Scene 1

How Vector Similarity Search Works

Vector similarity search works in four steps: embed the query into a vector, measure its distance or similarity to stored vectors with a chosen function, rank the stored items, and return the top k. At scale, an approximate index can replace the exhaustive comparison. That is the whole contract: a ranked list of candidates, produced by arithmetic on numbers. With the toy query q = [1, 2] and candidates d1 = [2, 4], d2 = [2, 0], d3 = [-1, -2] (worked in full below), cosine similarity ranks d1 (1.000) ahead of d2 (0.447) and d3 (-1.000), so the top-1 result is d1. Finding the exact nearest neighbours means scoring every stored vector. Approximate nearest neighbour (ANN) indexes avoid that by examining only part of the collection, accepting that they may occasionally miss a true neighbour in exchange for lower latency and cost. Neither variant understands the user's intent, checks that the retrieved text is true, or writes an answer. How the numbers were produced is the subject of Embeddings Explained; this chapter is about what happens after you have them: which metric, which search strategy, and what each choice costs you.

A Query, Three Candidates, and a Ranking Problem

An online-store customer writes: "I was charged twice for order 88213, can I get one payment back?"The support system has a knowledge base of policy snippets. Suppose it has three, and the embedding model (a model we will pretend has only two dimensions so that we can draw it) maps them, and the question, to these vectors:

Toy 2-dimensional vectors (illustrative: real embeddings have hundreds or thousands of dimensions)
q  = [ 1,  2]   "I was charged twice, can I get one payment back?"
d1 = [ 2,  4]   "Duplicate charges are refunded to the original payment method."
d2 = [ 2,  0]   "Shipping times depend on the destination country."
d3 = [-1, -2]   "Gift cards cannot be redeemed for cash."

The axes have no names here. In a trained model, individual dimensions are not human-readable features; only the geometry between vectors carries information. The sentences are attached to make the example concrete, not because a real model would land exactly on these numbers.

The task is not to answer the customer. It is to decide which snippet or snippets deserve to be shown to whatever comes next: a human agent, a ranking model, or a language model that will draft a reply. That is a ranking problem. Given one query and a candidate set, produce an ordering and keep the top few. If the ordering is good, the next stage has the right material. If it is bad, no later component can see what was never retrieved. Keeping this distinction clear is the main guard against a common error: judging a retrieval system by how fluent the final answer sounds. Retrieval quality (did the right evidence appear in the top-k?) and answer quality (did the generator use it correctly?) are separate measurements with separate failure causes. See the RAG retrieval pipeline for how the two are connected in a full system.

Two more sentences of honesty about this scenario. First, the pretend model gave d1 the same direction as the query, which is what we hope a good model does for a paraphrase, but nothing in the mathematics guarantees it. Second, whether "charged twice" and "duplicate charges" end up close depends entirely on how the model was trained; a keyword engine would see no shared words at all, which is why vector search exists.

Vector Space, Metrics, and What 'Nearest' Means

Formally, an embedding model with output dimension d maps each input to a point in Rd, a list of d real numbers (stored as 32-bit floats in most systems). The query is q ∈ Rd and the stored items are x1, …, xN, each also in Rd. A scoring function s(q, x) turns a pair into one number. Top-kretrieval, also called k-nearest-neighbour (k-NN) search, returns the k items with the best score: the largest similarity, or equivalently the smallest distance.

"Nearest" is therefore never a property of the data alone. It is a property of the data together with a scoring function. Three functions dominate practice, and with a·b = Σ ajbj and ‖a‖ = √(a·a) they are:

The three standard scoring functions (sums run over j = 1..d)
dot product          a·b          = Σ a_j * b_j                        larger = more similar
cosine similarity    cos(a, b)    = (a·b) / (‖a‖ * ‖b‖)               range [-1, 1], larger = more similar
Euclidean distance   ‖a − b‖      = sqrt( Σ (a_j − b_j)^2 )            range [0, ∞), smaller = more similar

Dot product depends on direction and length; cosine removes length; Euclidean distance measures the straight-line gap between the two points.

The geometric meaning is worth holding on to. The dot product equals ‖a‖‖b‖ cos θ, where θ is the angle between the vectors, so cosine similarity is the dot product after dividing out both lengths, leaving only the angle. Euclidean distance instead measures how far apart the tips of the arrows are, so it is sensitive to length differences that cosine ignores. Whether length should matter depends on what the embedding model encodes in it, which the model's documentation, not intuition, must tell you.

Cosine Similarity vs Dot Product (and Euclidean Distance) on One Example

Cosine similarity vs dot product comes down to length: cosine compares only the direction of two vectors, while the dot product also grows with their magnitudes. For vectors scaled to unit length the two give identical rankings.

Use the vectors from the scenario: q = [1, 2], d1 = [2, 4], d2 = [2, 0], d3 = [-1, -2]. First the lengths needed for cosine: ‖q‖ = √(1² + 2²) = √5 ≈ 2.236; ‖d1‖ = √(4 + 16) = √20 ≈ 4.472; ‖d2‖ = √(4 + 0) = 2; ‖d3‖ = √(1 + 4) = √5 ≈ 2.236.

The query and three candidates in the planeArrows from the origin: q = (1, 2) and d1 = (2, 4) point the same way, d1 twice as long. d2 = (2, 0) lies along the horizontal axis, and d3 = (-1, -2) points in exactly the opposite direction to q.qd1d2d3
The query and three candidates in the plane. Arrows from the origin: q = (1, 2) and d1 = (2, 4) point the same way, d1 twice as long. d2 = (2, 0) lies along the horizontal axis, and d3 = (-1, -2) points in exactly the opposite direction to q.

Cosine similarity

  1. 1

    d1

    q·d1 = 1·2 + 2·4 = 10. Denominator √5 × √20 = √100 = 10. cos = 10 / 10 = 1.
  2. 2

    d2

    q·d2 = 1·2 + 2·0 = 2. Denominator √5 × 2 ≈ 4.472. cos = 2 / 4.472 ≈ 0.447.
  3. 3

    d3

    q·d3 = 1·(-1) + 2·(-2) = -5. Denominator √5 × √5 = 5. cos = -5 / 5 = -1.

The cosine ranking is d1 (1.000), then d2 (≈ 0.447), then d3 (-1.000). Two things deserve comment. d1 is twice as long as q yet scores a perfect 1: cosine reports same direction, and says nothing about magnitude. And a score of 1 does not mean "d1 answers the question". It means the model placed the two texts in the same direction. Cosine similarity is not a probability that the passage is correct or relevant, and a value like 0.447 has no universal meaning: the same number can be a good match under one model and noise under another.

Euclidean distance and dot product on the same points

Euclidean distances: to d1, √((1−2)² + (2−4)²) = √(1 + 4) = √5 ≈ 2.236; to d2, √((1−2)² + (2−0)²) = √(1 + 4) = √5 ≈ 2.236; to d3, √((1+1)² + (2+2)²) = √(4 + 16) = √20 ≈ 4.472. Dot products, already computed above, are 10, 2 and -5.

Same points, two metricsLeft: the points q, d1, d2 and d3 in the plane. A dashed circle of radius square root of 5 around q passes through both d1 and d2, so Euclidean distance ties them, while the dashed line through the origin shows that d1 lies exactly along q's direction and d3 exactly opposite. Right: the resulting rankings differ.q (1, 2)d1 (2, 4)d2 (2, 0)d3 (-1, -2)Rank by cosine (higher first)1. d1 1.000 2. d2 0.447 3. d3 -1.000Rank by Euclidean distance (lower first)1= d1 2.236 1= d2 2.236 3. d3 4.472Rank by dot product (higher first)1. d1 10 2. d2 2 3. d3 -5d2 is as close as d1 in space, but pointsin a clearly different direction.
Same points, two metrics. Left: the points q, d1, d2 and d3 in the plane. A dashed circle of radius square root of 5 around q passes through both d1 and d2, so Euclidean distance ties them, while the dashed line through the origin shows that d1 lies exactly along q's direction and d3 exactly opposite. Right: the resulting rankings differ.

Euclidean distance ties d1 and d2 exactly, as the dashed circle shows, although cosine separates them sharply (1.000 against 0.447). Either could be the "right" behaviour. If the model uses vector length to carry something you care about, such as confidence or specificity, a distance that respects length is meaningful. If length is an accident of training and only direction is semantic, then d2 is a poor match that Euclidean distance happens to like, because it is short and so close to everything near the origin. The dot product behaves a third way: it ranks d1 (10) above d2 (2), as cosine does, but it would also reward a very long vector that points only roughly the right way. Doubling d2 to [4, 0] raises its dot product to 4 without changing its direction or its cosine at all.

Normalisation and picking a metric

If every vector is scaled to unit length (‖x‖ = 1), the three metrics agree on the ranking. Expanding the squared distance gives ‖a − b‖² = ‖a‖² + ‖b‖² − 2 a·b = 2 − 2 cos(a, b), and the dot product of unit vectors is the cosine. So smaller Euclidean distance, larger dot product and larger cosine all pick the same neighbours in the same order. This is why many pipelines normalise once at write time and then use the cheapest operation, the dot product, at query time.

Use the metric the embedding model was trained and documented for
Do not choose a metric by taste. Models are trained with a particular objective, and their documentation usually states whether outputs are normalised and which similarity to use. Match the index's metric to that statement, normalise consistently for both stored vectors and queries, and remember that vectors from different embedding models (or different versions of one model) live in unrelated spaces and cannot be compared with each other at all.

Exact Search: Score Everything, Keep the Best k

The brute-force (also called flat) search is the reference implementation of nearest-neighbour retrieval: compute the score between the query and every stored vector, and keep the k best. A bounded min-heap of size k does the keeping, so memory for the result set is O(k). Scoring is O(N·d) multiply-adds for N vectors of dimension d, and the heap adds roughly O(N log k) comparison work in the worst case. For small k the scoring dominates.

Runnable Python (standard library only): exact top-k, verified on the worked example
import heapq, math

def dot(a, b):
    return sum(x * y for x, y in zip(a, b))

def cosine(a, b):
    return dot(a, b) / (math.sqrt(dot(a, a)) * math.sqrt(dot(b, b)))

def top_k(query, items, k, score=cosine):
    """items: iterable of (id, vector). Returns [(score, id)] with the best first."""
    return heapq.nlargest(k, ((score(query, v), i) for i, v in items))

docs = {"d1": [2, 4], "d2": [2, 0], "d3": [-1, -2]}
for s, i in top_k([1, 2], docs.items(), 3):
    print(i, round(s, 3))                 # d1 1.0 / d2 0.447 / d3 -1.0

# Euclidean as "higher is better" by negating the distance:
print(top_k([1, 2], docs.items(), 3, score=lambda a, b: -math.dist(a, b)))
# [(-2.236..., 'd2'), (-2.236..., 'd1'), (-4.472..., 'd3')]  <- the d1/d2 tie is broken arbitrarily

The last line shows the tie from the worked example: both distances equal √5, and which of d1 and d2 comes first is decided by tuple comparison, not by any property of the data. Systems that need deterministic results add an explicit tie-breaker such as the document ID.

Exact search is the right answer more often than people expect. With tens of thousands of vectors a flat scan takes milliseconds, has no index to build, no tuning parameters, no recall question, and trivially supports arbitrary filters, deletes and updates. It is also the ground truth against which every approximate index is later measured. Practical implementations do not loop in Python: they store vectors as one contiguous matrix and compute all scores as a matrix–vector (or matrix–matrix, for batched queries) product with vectorised, cache-friendly kernels, often on SIMD units or GPUs. That makes the constant factor small, but it does not change the O(N·d) growth: ten times as many vectors means ten times the work per query.

When Exhaustive Search Stops Fitting: Scale Arithmetic

Take a corpus of 2,000,000 chunks embedded at d = 1,024 dimensions in 32-bit floats (4 bytes per number). Raw vector storage is 2,000,000 × 1,024 × 4 B = 8,192,000,000 B, which is 8.192 GB in decimal units (about 7.63 GiB in binary units, since 1 GiB = 2³⁰ B = 1,073,741,824 B). That is already only the vectors: an index adds its own structures, metadata and original text need space, replicas multiply everything, and a hot cache or in-memory index must fit in RAM to be fast.

One exact query does 2,000,000 × 1,024 = 2,048,000,000 multiply-adds, about 4.1 × 10⁹ floating-point operations if you count the multiply and the add separately. A FLOP count is not a latency. A flat scan usually has to stream the whole 8.192 GB matrix from memory, so on a machine with, say, an assumed effective 20 GB/s of memory bandwidth to the cores doing the work, one unbatched query costs roughly 8.192 / 20 ≈ 0.41 s regardless of how fast the arithmetic units are. The 20 GB/s figure is an assumption chosen to illustrate the calculation. Real throughput depends on hardware, thread count, vector layout, precision and batching, and must be measured. At 100 queries per second the same arithmetic would demand about 819 GB/s of aggregate memory traffic unless queries are batched so that one pass over the matrix serves many of them.

Index size arithmetic for 2,000,000 vectors of 1,024 dimensionsBar lengths show decimal megabytes: 8,192 MB raw float32, 4,096 MB as float16, 2,048 MB as int8, and 256 MB for 128-byte product-quantization codes. A 256 MB bar shows an illustrative HNSW layer-0 link overhead that is added on top of the stored vectors, not instead of them.float32 raw (4,096 B/vector)8,192 MBfloat16 (2,048 B/vector)4,096 MBint8 scalar (1,024 B/vector)2,048 MBPQ, 128 B codes256 MBHNSW layer-0 links, M=16 (illustrative)256 MB
Index size arithmetic for 2,000,000 vectors of 1,024 dimensions. Bar lengths show decimal megabytes: 8,192 MB raw float32, 4,096 MB as float16, 2,048 MB as int8, and 256 MB for 128-byte product-quantization codes. A 256 MB bar shows an illustrative HNSW layer-0 link overhead that is added on top of the stored vectors, not instead of them.

Compression changes the picture, at a price in accuracy. Storing float16 halves the figure to 4.096 GB; scalar int8 quantisation quarters it to 2.048 GB. Product quantisation (introduced below) can go much further: 128 one-byte codes per vector is 256,000,000 B, a 32× reduction from 4,096 B per vector. The graph links of a graph index are extra: with HNSW's common parameter M = 16 each vector may keep up to 2M = 32 links on the bottom layer; at 4 bytes per neighbour ID that is 128 B per vector, or 256 MB for the corpus. That value is illustrative: link counts, ID widths and per-layer overhead vary by implementation. The general pattern is more important than any single figure: ANN indexes trade away exactness to reduce the amount of arithmetic and memory traffic per query, and some of them further trade away vector precision to shrink memory.

HNSW: Walking a Layered Proximity Graph

Hierarchical Navigable Small World (HNSW) indexes build a graph in which each vector is a node linked to a handful of nearby nodes. Nodes are also assigned, at random, to higher layers: every node is on layer 0, a smaller random subset on layer 1, a still smaller one on layer 2, and so on. Upper layers are sparse, so their links are long hops across the space; layer 0 has every node and fine-grained local links. Search enters at a fixed entry node on the top layer, and on each layer moves greedily: look at the current node's neighbours, jump to whichever is closest to the query, and repeat until no neighbour improves. Then it descends to the next layer starting from that node. On layer 0 the search keeps a bounded list of the best candidates seen so far (its size is the efSearch parameter; the result is the top k of that list) and continues exploring until the candidate list stops improving. This follows the structure of the original paper (Malkov and Yashunin, arXiv:1603.09320); libraries differ in details such as how neighbours are selected and how filters are handled, so treat this as intuition, not a specification. Two widely used implementations to read and benchmark against are the FAISS library and the PostgreSQL extension pgvector.

A graph walk on the toy points

Suppose layer 1 contains A, E and G, linked to each other, and layer 0 contains all eight points with local links (A–B, A–D, B–C, B–E, C–E, C–G, D–E, D–H, E–F, E–H, F–G, F–H). The walk for q = (5.2, 2.4):

  1. 1

    Enter on layer 1 at A

    Distance to q is 5.73. Neighbours E (2.28) and G (1.61) are both closer; G is closest, so move to G.
  2. 2

    Greedy step at G on layer 1

    G's layer-1 neighbours are A (5.73) and E (2.28), both farther than G (1.61). No improvement, so descend to layer 0 at G.
  3. 3

    Layer 0 at G

    Neighbours are C (2.68) and F (0.63). F is closer than G, so move to F and keep G and C in the candidate list.
  4. 4

    Layer 0 at F

    Neighbours are E (2.28), G (1.61), H (2.86): none beats F (0.63). Stop. Result: F, G, E. Distinct distances computed: A, E, G, C, F, H = 6 of 8.
HNSW search path on the toy graphTop row, layer 1: entry node A, then G chosen over E. Bottom row, layer 0: the search descends at G, moves to the better neighbour F, and checks F's neighbours E and H without finding anything closer. Solid arrows are moves, dashed arrows are inspected neighbours or the layer descent.Alayer 1 · 5.73Elayer 1 · 2.28Glayer 1 · 1.61Glayer 0 · 1.61C2.68F0.63 ✓E2.28H2.86closerdescendmove
HNSW search path on the toy graph. Top row, layer 1: entry node A, then G chosen over E. Bottom row, layer 0: the search descends at G, moves to the better neighbour F, and checks F's neighbours E and H without finding anything closer. Solid arrows are moves, dashed arrows are inspected neighbours or the layer descent.
The same walk in the planeThe dashed path goes from A to G to F. Ringed points A, E, G, C, F and H had their distances computed; B and D were never examined.ABCDEFGHquery (5.2, 2.4)
The same walk in the plane. The dashed path goes from A to G to F. Ringed points A, E, G, C, F and H had their distances computed; B and D were never examined.

Only 2 of 8 points were skipped, which is no saving at all at this size. The point of the structure is how the work grows: the number of hops and distance computations in a well-built graph grows slowly with N(empirically close to logarithmically for suitable data), not linearly, so a million-node graph can be searched with a few thousand distance evaluations. Greedy search can still get stuck: if the only edges out of a node lead away from the target, the walk stops at a local optimum. That is exactly why layer 0 keeps a candidate list wider than k and why raising efSearch raises recall and latency together. The construction side has its own knobs: M (links per node), which affects memory and recall, and efConstruction, the candidate-list size during insertion, which trades build time for graph quality. Defaults are a starting point only; the right values depend on the data and the recall target.

IVF and Quantisation: Partition, Probe, Compress

The inverted-file (IVF) index takes a different route. In a training step it runs a clustering algorithm (typically k-means) over a sample of vectors to produce nlist centroids, then assigns every stored vector to the list of its nearest centroid. A query is first compared with the nlist centroids only; the nprobe nearest centroids are selected, and exact scoring is done on the vectors in just those lists. With balanced clusters, work per query is about nlist + nprobe × (N /nlist) distance computations. For N = 2,000,000, nlist = 2,000 and nprobe = 10 that is 2,000 + 10 × 1,000 = 12,000 distance computations, 0.6 % of the exhaustive count, assuming perfectly even lists, which real data rarely produces.

On the toy data, take three centroids c1 = (1, 1), c2 = (3.5, 4), c3 = (5, 1). The assignments are c1: A, B; c2: D, E, F, H; c3: C, G. The query q = (5.2, 2.4) is closest to c3 (distance 1.41, against 2.33 for c2 and 4.43 for c1).

IVF with nprobe = 1 misses the true neighbourPoints are coloured by cluster. The query's nearest centroid is c3, so only C and G (ringed) are scored, and the search returns G at 1.61 while the true nearest neighbour F, at 0.63, sits in c2's list and is never examined.centroid c1centroid c2centroid c3ABCDEFGHquery (5.2, 2.4)
IVF with nprobe = 1 misses the true neighbour. Points are coloured by cluster. The query's nearest centroid is c3, so only C and G (ringed) are scored, and the search returns G at 1.61 while the true nearest neighbour F, at 0.63, sits in c2's list and is never examined.

With nprobe = 1 the search scans only C and G and returns G (1.61): wrong. The true nearest neighbour F lies in c2's list, on the far side of a cluster boundary from the query. This is the typical IVF failure: queries near partition boundaries. Setting nprobe = 2 also scans c2's list and finds F, at the price of 3 + 6 = 9 distance computations, more than the 8 of brute force here only because the dataset is absurdly small. At real scale the same raise from 1 to 2 probes roughly doubles the list work while recall climbs.

Change nprobe below to see which lists are scanned, what is returned and the recall@3 that results.

1
IVF lists in order of centroid distance, and which ones are scannedThe three centroid lists sorted by distance from the query: c3 at 1.41, c2 at 2.33, c1 at 4.43. Each chip is a stored point with its exact distance to the query. With nprobe = 1, the 1 nearest lists are scanned and the rest are skipped. The true top 3 are F (0.63), G (1.61) and E (2.28); F and E live in c2, so nprobe = 1 misses them.c3centroid 1.41 awayCdistance 2.68Gdistance 1.61c2centroid 2.33 awayDdistance 4.49Edistance 2.28Fdistance 0.63Hdistance 2.86c1centroid 4.43 awayAdistance 5.73Bdistance 3.49
  • scanned list
  • skipped list
  • true top-3 point (ring)
IVF lists in order of centroid distance, and which ones are scanned. The three centroid lists sorted by distance from the query: c3 at 1.41, c2 at 2.33, c1 at 4.43. Each chip is a stored point with its exact distance to the query. With nprobe = 1, the 1 nearest lists are scanned and the rest are skipped. The true top 3 are F (0.63), G (1.61) and E (2.28); F and E live in c2, so nprobe = 1 misses them.
Distances computed (toy)
3 + 2 = 5
centroids + scanned points; brute force = 8
Returned top-3
G, C
recall@3
1/3 = 0.333
exact top-3: F, G, E
Same nprobe at scale
3,000
N = 2,000,000, nlist = 2,000: 0.15% of exhaustive

Product quantisation

IVF and graphs reduce how many vectors are scored; product quantisation (PQ) reduces how much each vector costs to store and score. A 1,024-dimensional vector is split into m = 128 sub-vectors of 8 dimensions. For each sub-space, k-means learns 256 centroids (a codebook), so each sub-vector is replaced by the one-byte ID of its nearest codebook entry: 128 bytes instead of 4,096, a 32× reduction. The codebooks are small (128 × 256 × 8 × 4 B = 1,048,576 B, about 1 MB). At query time distances are computed approximately from lookup tables, with no need to decompress the stored vector, which also cuts memory traffic. The cost is quantisation error: nearby vectors can map to the same code, so ranking among close candidates blurs. Many systems therefore retrieve a larger candidate set from compressed codes and rescore it with the original full-precision vectors. These are well-known techniques and all libraries implement them differently, with different training requirements and parameter names, so consult the documentation for the index you use.

Choosing between them by workload

Without inventing benchmark numbers, the trade-offs follow from the mechanisms. A graph index like HNSW generally gives strong recall at low latency but keeps the full graph and, unless quantised, the full vectors in memory; adding vectors is incremental, but deleting them leaves holes in the graph (below). IVF needs a training pass, has recall that depends on cluster quality and the nprobe setting, but is cheap in memory, combines naturally with PQ, and can keep the lists on disk or compressed. A collection of tens of millions of vectors that must fit a tight memory budget leans toward IVF with PQ and a rescoring step; a collection that fits comfortably in RAM and needs lowest tail latency leans toward a graph; a small collection does not need either. In every case the decision is validated by measurement on your own data, not asserted from a rule of thumb.

Filters, Hybrid Retrieval, and the Order of Operations

Real queries are rarely "the nearest vectors to this vector, full stop". They are "the nearest vectors belonging to this tenant, written in this language, valid on this date, for this product line". Those constraints are metadata filters, and where they are applied relative to the vector search changes the results.

Post-filtering runs the ANN search over everything, takes the top-k, then discards results that fail the filter. Pre-filtering restricts the candidate set first and searches only inside it. Consider six chunks scored against a query by cosine, in descending order: 0.91 (tenant B), 0.88 (B), 0.86 (A), 0.84 (B), 0.80 (A), 0.79 (A), and a user from tenant A who asks for the top three. Post-filtering takes the global top three, (B, B, A), and removes the two tenant-B chunks: one result survives, 0.86. Pre-filtering searches only A's chunks and returns 0.86, 0.80, 0.79. The post-filtered system silently returned fewer than the requested results and missed items the corpus did contain. When a filter is selective the effect is severe: if tenant A owns 1 % of the vectors and the matches are scattered at random, a post-filtered top-10 contains 0.1 A-chunks in expectation; to get 10 you would need to over-fetch about 10 / 0.01 = 1,000 candidates first.

Post-filtering returns one result; pre-filtering returns threeSix chunks ranked by cosine score for a user in tenant A asking for the top 3. Post-filtering takes the global top 3 (0.91 B, 0.88 B, 0.86 A) and then drops tenant B's two chunks, leaving one result of 0.86. Pre-filtering searches only tenant A's chunks and returns 0.86, 0.80 and 0.79.Chunk (score, tenant)Post-filter: top-3, then filterPre-filter: tenant A only, then top-30.91 · tenant Bfetched, then droppedexcluded up front0.88 · tenant Bfetched, then droppedexcluded up front0.86 · tenant Areturnedreturned0.84 · tenant Bnever fetchedexcluded up front0.80 · tenant Anever fetched: missedreturned0.79 · tenant Anever fetched: missedreturnedPost-filter result: 1 chunk (0.86) · Pre-filter result: 3 chunks (0.86, 0.80, 0.79)
Post-filtering returns one result; pre-filtering returns three. Six chunks ranked by cosine score for a user in tenant A asking for the top 3. Post-filtering takes the global top 3 (0.91 B, 0.88 B, 0.86 A) and then drops tenant B's two chunks, leaving one result of 0.86. Pre-filtering searches only tenant A's chunks and returns 0.86, 0.80 and 0.79.

Pre-filtering is not free either. Filtered ANN has to be built into the index, because a graph traversal that is only allowed to visit matching nodes may become disconnected, and an IVF probe may find few matching vectors in the selected lists. Engines handle this in different ways (filtered traversal, per-partition indexes, fall back to an exact scan when the filter is very selective), and behaviour on highly selective filters is a first-class benchmark case.

Hybrid retrieval for identifiers and rare terms

Embeddings are good at paraphrase and poor at exactness. The customer who types order 88213 or the error code E-4021 wants documents containing precisely that string; a dense vector may place it near text about other orders or other codes. Lexical retrieval (an inverted index with a scoring function such as BM25) handles exact tokens and rare terms well and cannot paraphrase. A hybrid system runs both, then fuses the two ranked lists, commonly by reciprocal-rank fusion (score = Σ 1 / (60 + rank) over the lists, the constant 60 being a conventional choice) or by a weighted score combination. Fusion by rank sidesteps the problem that cosine scores and BM25 scores are on different, incomparable scales. How the fused candidates are then re-ordered is covered in Reranking.

Authorisation Belongs Before the Search, Not After

A multi-tenant support assistant indexes policy documents and customer-specific contracts. A user from tenant A asks something whose nearest neighbours are, by cosine score, two chunks from tenant B's confidential contract. A system that retrieves first and removes unauthorised citations from the final response has already moved tenant B's text into the application's memory, and in the usual design into the prompt of a language model, from where it can influence or leak into the reply even if no citation is shown. The permission check has to determine which documents are candidates, not which ones get displayed.

Authorisation as a retrieval constraintTop row, unsafe: the query searches the whole index, unauthorised text reaches the application and prompt, and filtering only hides citations. Bottom row, safe: the caller's identity yields an access filter, which is applied inside the vector search so that only authorised chunks are ever candidates.Queryuser in tenant ASearch ALLboth tenantsTop-k incl. Bleaked to promptHide citationstoo lateQuery + IDverified identityACL filtertenant = A, groupsSearch allowedA-only candidatesTop-kauthorised only
Authorisation as a retrieval constraint. Top row, unsafe: the query searches the whole index, unauthorised text reaches the application and prompt, and filtering only hides citations. Bottom row, safe: the caller's identity yields an access filter, which is applied inside the vector search so that only authorised chunks are ever candidates.

Three details make the safe design hold. The tenant and group claims must come from the authenticated session, never from text the user or model supplies. The filter must be enforced by the search layer (or a per-tenant index or namespace), not by application code that runs afterwards. And permissions must be kept current: a document whose access is revoked has to leave the candidate set promptly, which is an update-propagation problem treated below. The broader threat model of retrieval-based systems is in AI Security.

Measuring an Index: Recall, Latency and the Rest of the Workload

Because an ANN index is approximate, you evaluate it against exact search. The procedure: take a representative set of real queries, compute the true top-k for each by brute force, run the approximate index with a given parameter setting, and measure recall@k, the share of the true top-k that appears in the approximate top-k, averaged over queries:

Runnable Python: recall@k, checked against the toy index results
def recall_at_k(exact_ids, approx_ids, k):
    return len(set(exact_ids[:k]) & set(approx_ids[:k])) / k

exact = ["F", "G", "E"]              # true top-3 for q = (5.2, 2.4)
print(recall_at_k(exact, ["G", "C"], 3))        # IVF nprobe=1 returned only G, C -> 1/3 = 0.333
print(recall_at_k(exact, ["F", "G", "C"], 3))   # an index that finds F, G but misses E -> 2/3 = 0.667

Missing results count against recall. The IVF run returned only two candidates, because its probed lists held only two vectors, and only G is shared with the exact top-3, so recall@3 = 1/3.

A single recall number is not a benchmark. A useful one reports, at each tested parameter setting: recall@k for the k you actually use; latency percentiles, p50 for the typical case and p95 or p99 for the tail users notice; throughput in queries per second at a stated concurrency; memory resident and on disk; build time; and behaviour under the real write pattern: ingest rate, update and delete cost, and how recall changes after a day of churn. Test with filters at realistic selectivities (a filter matching 50 % of the data and one matching 0.1 % behave very differently), with concurrent load, and with both a cold index (just loaded, caches empty) and a warm one. Compare the points as a recall-versus-latency curve rather than as a single number, then pick the cheapest setting that meets a recall target tied to downstream quality.

Recall against exact search measures only the index. It does not measure whether the exact top-k itself was any good for the user's task: the embedding model may rank an irrelevant passage first even under perfect search. That requires a separate relevance evaluation, with labelled query-to-passage judgments, and a third one for the final answer. Do not let a high index recall stand in for retrieval quality.

Updates, Deletes, and Freshness

An index is a derived copy of your documents, and keeping it correct as they change is much of the operational work. New documents are chunked, embedded and inserted. An edited document must have its old chunks removed and new ones added, with stable chunk IDs making that safe to repeat. Many ANN structures do not remove elements cheaply: a deleted vector is often marked with a tombstone and skipped at query time, while remaining in the graph or list. Tombstones consume memory and, in graph indexes, may hurt traversal quality, so periodic compaction or a full rebuild reclaims space and restores quality. IVF lists also drift: if the data distribution changes, centroids trained on old data partition new data poorly, and retraining is needed.

Many stores are eventually consistent: a write is accepted, then becomes visible to searches after some delay (a refresh interval, a replica sync, a background segment merge). During that window the system returns the previous state: a superseded policy, or a revoked document still visible. For permission changes this is a security matter and not just a freshness one, so measure the visibility lag and decide what the application does for sensitive cases, for example by re-checking authorisation against the source of truth just before use.

Changing the embedding model is the biggest "update" of all. Vectors from two models, or two versions of one model, are not comparable: a query embedded with the new model against stored vectors from the old gives meaningless scores. Record the model name and version alongside each vector, build the new index in parallel, validate it, then switch queries and index together. Chunk and dimension changes (for instance switching from 1,024 to 768 dimensions) require the same full re-embed.

Diagnosing Retrieval Failures

When a retrieval-backed feature returns something wrong, the useful question is which stage failed. Reproduce the query, then compare three things: the exact top-k for the current index data, the approximate top-k that production returned, and the passage a human says should have appeared. Where those differ tells you where to look.

The near neighbour is off topic

The nearest vector can be nearest and still irrelevant: "refund policy" and "refund of a faulty product in store" may be close even though the customer is asking about a duplicate online payment. Such near misses ("semantic collisions") come from the embedding model's notion of similarity, not from the index. The exact search shows the same result. Mitigate by improving chunking, adding metadata filters, using hybrid retrieval, applying a reranker, or evaluating other embedding models. Query instructions or a model trained for the task and domain may also help.

Wrong language, stale index, missing access control

A multilingual model can place a translation near the original, but retrieval quality is rarely uniform across languages, so check by language and consider a language filter. A stale index shows up when the exact search over the source documents disagrees with the index: check ingestion lag, tombstones and the embedding model version recorded per vector. A missing access-control filter shows up in audits as results from the wrong tenant; prove the fix by an automated test that queries as each tenant and asserts no foreign IDs appear in any top-k.

Approximate miss and exact-identifier mismatch

If the exact search finds the right passage and the production index does not, the index missed it: raise efSearch or nprobe, check the filter path, and confirm the index was built with enough data. If neither finds it and the query contains an order number, SKU or error code, the issue is that dense vectors do not treat such strings as exact keys; add a lexical or key-lookup path. Finally, if the right passage was retrieved but the answer was still wrong, retrieval has done its job and the fault lies in the prompt, context assembly or the generation model: look there and do not tune the index.

A Minimal Retrieval Function

The pieces fit together in a short function. The sketch below is illustrative pseudocode in TypeScript style, not a library API: embed, index.search and ctx stand for whatever your embedding model, vector store and auth layer provide.

Illustrative pseudocode (TypeScript style, not a specific SDK)
interface Hit { chunkId: string; score: number; source: string; embeddingModel: string; indexVersion: string }

async function retrieve(queryText: string, ctx: AuthContext, k = 5): Promise<Hit[]> {
  const q = normalize(await embed(queryText));          // same model + normalisation as the stored vectors

  // Authorisation constrains the candidate set inside the search, from the verified session.
  const filter = { tenantId: ctx.tenantId, groups: ctx.groups, language: ctx.language };

  // Over-fetch, then trim: ANN returns approximate order and rescoring/fusion may reorder.
  const raw = await index.search({ vector: q, filter, topK: k * 4, params: { efSearch: 128 } });

  const MIN_SCORE = 0.35;   // a tuned heuristic for ONE model and corpus, set from labelled data, not a universal constant
  return raw
    .filter((h) => h.score >= MIN_SCORE)
    .slice(0, k)
    .map((h) => ({ chunkId: h.id, score: h.score, source: h.metadata.sourceUrl,
                   embeddingModel: h.metadata.model, indexVersion: index.version }));
}

Each returned hit carries provenance (source, model, index version) so that a wrong answer can later be traced to the exact retrieval event. Returning fewer than k hits, or none, is a legitimate outcome that downstream code must handle.

The score threshold deserves care. A cosine cut-off such as 0.35 is a heuristic that only means something for one embedding model on one corpus, so choose it by looking at labelled relevant and irrelevant pairs and the precision and recall trade-off they give, and revalidate when the model, chunking or corpus changes. Treat "nothing passed the threshold" as a signal ("no good evidence found") instead of forcing the least bad match into the answer, which is exactly how a confident but unfounded reply gets written.

Where Vector Search Ends and the Rest of the System Begins

Vector search is one component. It takes a representation and returns a ranking, and everything downstream depends on the quality of that ranking without being guaranteed by it. A retrieved passage is evidence to be weighed, not a verified fact: it may be outdated, wrong, or contradicted by another source, and similarity to the question says nothing about those properties. Systems that use retrieval for question answering, which is where most readers will meet this material, therefore add reranking, context assembly, citation, and evaluation layers. Those are the subjects of the retrieval lessons: RAG similarity search shows the production retrieval path, and vector databases covers the storage engines that package the indexes described here with filtering, replication and persistence. A vector database is one way to run these indexes; for a few thousand vectors, a flat array in the application process is also a legitimate choice.

The model that consumes the retrieved text has its own limits: how many tokens it can read at once is governed by the context window, and long context does not remove the need for retrieval, because cost, latency, permissions and changing data still decide what should be put in front of the model. Next the course moves from how text is compared to how modern models process token sequences at all.

Key Takeaways

  1. 1

    "Nearest" depends on the scoring function as well as the data. Use the metric the embedding model was trained for: cosine, Euclidean distance and dot product tie or disagree on the same vectors, and only on unit-length vectors do all three give the same ranking.

  2. 2

    Exact (flat) search is the reference and often the right answer for tens of thousands of vectors; at 2,000,000 chunks of 1,024 float32 dimensions a query costs about 2 billion multiply-adds and about 8 GB of raw vectors, which is when approximate indexes earn their place.

  3. 3

    An approximate index has no built-in accuracy guarantee. Measure recall@k against exact search on real queries and report it with latency at each setting (efSearch for HNSW, nprobe for IVF), not as a single number.

  4. 4

    IVF and graph indexes reduce how many vectors are scored, and product quantisation reduces the cost of each; IVF with nprobe = 1 can miss a true neighbour that sits in a neighbouring list.

  5. 5

    Apply tenant and permission filters inside the search, using claims from the authenticated session, rather than post-filtering a top-k list; and add exact or keyword matching for identifiers such as order numbers and error codes.

  6. 6

    When retrieval fails, check whether exact search finds the right passage. If it does, the index missed it (raise efSearch or nprobe); if not, the embedding model, chunking or filters are the problem. Changing the embedding model means re-embedding into a new index.

Up Next · AI Foundations

Transformer Basics

With retrieval covered, the next step is understanding the model architecture that consumes token sequences and powers modern LLM behavior.