Back to the map
DataCore11h

Vector databases

Approximate nearest neighbour at scale.

Embeddings place each piece of text at coordinates, so finding relevant text means finding nearby points. With a thousand pieces you can measure the distance to every one of them; with ten million, on every query, you cannot. A vector database stores those coordinates and builds an index over them — a structure that narrows the search to a promising neighbourhood instead of checking everything — and does the ordinary database work too: keeping the original text beside its coordinates, filtering by fields like date or owner, and staying correct as documents come and go.

The index is an approximation, and that is the trade to understand: it accepts a small chance of missing the genuinely closest match in exchange for answering in milliseconds rather than seconds. The other half is knowing when you do not need one — below roughly a hundred thousand documents, an array of vectors and a bit of arithmetic is genuinely fine, and adopting a specialist system early buys operational work rather than better answers.

It sits directly on embeddings and adds storage, filtering and speed to the idea. In the retrieval pipeline it is the middle: chunking decides what gets stored, the database finds candidates, reranking puts the best of them first, and RAG is what the result is for. Vendor comparisons date quickly; the access patterns do not.

It is an index over the coordinates embeddings produce, trading a little recall for a great deal of speed.

In more detailExact nearest-neighbour search does not scale, so these systems trade a little recall for a lot of speed using indexes like HNSW. What matters in practice is the access pattern: filtering by metadata alongside vector similarity, keeping the index fresh as documents change, and knowing that below roughly a hundred thousand documents a plain array and a dot product is genuinely fine.

Learn first

Section 1 of 2, 6h

Storing and searching vectors

By the end: You can choose a storage approach that fits your scale and explain the trade-off.

  • When you need one at all
  • Approximate nearest neighbour
  • Metadata filtering
  • Keeping the index fresh

A vector database stores embeddings and finds the ones closest to a query embedding. Before reaching for one, it is worth knowing how much work that actually is, because the answer surprises people in both directions: far less than expected at small scale, and a genuine engineering problem past a few million vectors. This section measures the crossover, then covers the approximate search that makes the large case possible, the filtering problem that breaks more production systems than anything else here, and keeping an index current.

When you need one at all#

Searching vectors exactly is one matrix multiply. Every embedding is a row, the query is a column, and the result is a similarity per document — which modern hardware does very fast. Measure it before designing around it.

import numpy as np, time

d = 768
for n in (10_000, 100_000, 1_000_000):
    X = np.random.default_rng(0).normal(size=(n, d)).astype(np.float32)
    X /= np.linalg.norm(X, axis=1, keepdims=True)     # so a dot product is cosine
    q = X[0]
    t0 = time.perf_counter()
    for _ in range(5):
        sims = X @ q
        top = np.argpartition(-sims, 10)[:10]         # not a full sort
    print(f"n={n:>9,}  {(time.perf_counter()-t0)/5*1000:6.1f} ms   {X.nbytes/1e9:.2f} GB")

# n=   10,000     1.4 ms   0.03 GB
# n=  100,000    13.9 ms   0.31 GB
# n=1,000,000    97.9 ms   3.07 GB
A hundred thousand documents is 14 ms and 300 MB on one machine. If that is your scale, a numpy array in memory is a complete and correct implementation, and adding a vector database adds an operational dependency rather than a capability.

So the honest rule is about the second column as much as the first. Below roughly a hundred thousand vectors, exact search in memory is simpler and gives perfect recall. Past a few million the memory becomes the constraint before the latency does, and you want an index and a system that owns it. The other reasons to adopt one are real but are not about speed: you want persistence and updates without rebuilding, you want metadata filtering alongside the search, or you want more than one process to query the same index.

argpartition, not sort

Getting the top ten of a million similarities does not require ordering the other 999,990. np.argpartition is linear where a sort is n log n, and on a million rows it is the difference between the search being dominated by the multiply or by the sort. The same idea is why every index returns an unordered candidate set that you order at the end.

Approximate nearest neighbour#

Past the point where exact search stops fitting, you trade exactness for speed. Every method does the same thing underneath: avoid comparing the query against most of the collection. The clearest version to reason about is an inverted file index — cluster the vectors once, then search only the clusters nearest the query.

from sklearn.cluster import KMeans

# Real embeddings are clustered, not uniform: documents fall into topics.
rng = np.random.default_rng(0)
n, d, k, n_topics = 100_000, 128, 10, 400
topics = rng.normal(size=(n_topics, d)).astype(np.float32)
X = topics[rng.integers(0, n_topics, n)] + rng.normal(0, 0.35, (n, d)).astype(np.float32)
X /= np.linalg.norm(X, axis=1, keepdims=True)

km = KMeans(n_clusters=256, n_init=1, random_state=0).fit(X[:20_000])
assign = km.predict(X)
buckets = [np.where(assign == c)[0] for c in range(256)]
cen = km.cluster_centers_ / np.linalg.norm(km.cluster_centers_, axis=1, keepdims=True)

# Search: pick the nprobe nearest clusters, rank only what is inside them.
def search(q, nprobe):
    cs = np.argpartition(-(cen @ q), nprobe)[:nprobe]
    cand = np.concatenate([buckets[c] for c in cs])
    return cand[np.argpartition(-(X[cand] @ q), k)[:k]]

# Measure it against exact search on 200 queries.
qs = X[rng.integers(0, n, 200)] + rng.normal(0, 0.15, (200, d)).astype(np.float32)
qs /= np.linalg.norm(qs, axis=1, keepdims=True)
truth = [set(np.argpartition(-(X @ q), k)[:k]) for q in qs]

for nprobe in (1, 2, 4, 8):
    t0 = time.perf_counter()
    hits = sum(len(set(search(q, nprobe)) & want) for q, want in zip(qs, truth))
    ms = (time.perf_counter() - t0) / len(qs) * 1000
    print(f"nprobe={nprobe:2d}  {ms:5.2f} ms/query  recall {hits / (k * len(qs)):.3f}")

# exact        1.80 ms/query   recall 1.000   (for comparison)
# nprobe= 1    0.08 ms/query   recall 0.932
# nprobe= 2    0.13 ms/query   recall 0.975
# nprobe= 4    0.26 ms/query   recall 0.990
# nprobe= 8    0.46 ms/query   recall 1.000
One probe is twenty times faster and finds 93 of every 100 true neighbours. Eight probes finds all of them and is still four times faster than exact. Timings move a little run to run; the recall figures do not. nprobe is the only knob, and it moves recall and latency together.

One detail in that code is load-bearing and is worth saying out loud: the vectors were built with topic structure, because real embeddings have it. Repeating the same measurement on uniformly random vectors gives 0.053 recall at one probe and 0.661 at sixty-four — approximate search collapses. It works in practice because documents genuinely cluster, not because the algorithm is clever, and that is also why recall on your data is something to measure rather than to read off a benchmark.

HNSW, the index most systems actually ship, replaces the clustering with a navigable graph and behaves the same way from the outside: one knob that trades recall for latency, and a recall figure you must measure yourself. Whichever you use, the measurement is the same — run a few hundred real queries through both the index and exact search, and report the overlap.

Metadata filtering, and the bug everyone ships#

Almost every real system needs the search restricted: this tenant, this language, documents this user may see. The naive implementation searches first and filters after, and it fails in a way that looks like an empty page rather than an error. Here two per cent of documents belong to the tenant being filtered to.

tenant = rng.random(n) < 0.02                  # 2% of the collection
sims = X @ X[0]

for fetch in (10, 100, 1000, 10000):
    top = np.argpartition(-sims, fetch)[:fetch]
    print(fetch, tenant[top].sum())

# 10        0     <- the user sees nothing at all
# 100       2     <- fewer than the 10 asked for
# 1000     16
# 10000   162
Fetch ten and filter, and a tenant holding 2% of the data gets an empty result for a query that has thousands of good answers. Overfetching hides it until a tenant is small enough, and then it returns.

There are two real fixes. Pre-filter — restrict to the eligible rows first and search within them, which always returns k and is what you want when the filter is very selective, since two thousand candidate rows are exact-searchable in under a millisecond. Or use an index that applies the filter during traversal rather than after it, which most mature vector databases now support and which is the reason to prefer one over a hand-rolled index. Post-filtering with a fixed overfetch is the option to avoid: it is a guess about selectivity that is silently wrong for your smallest customers.

Permissions are not a filter you can defer

If the filter encodes who may see a document, post-filtering is not merely a quality problem. An overfetch that returns the wrong rows before filtering has already loaded them, and any logging, caching or reranking between the two steps sees documents the user may not. Apply permission filters at or before retrieval, never after.

Keeping the index fresh#

Indexes are built from data that changes, and the update path is where these systems rot. Three things need a decision before launch rather than after.

  • Deletes. Graph indexes usually tombstone rather than remove, so deleted documents keep occupying the graph and recall drifts until a rebuild. Know your system's answer and how often it compacts.
  • Updates. A changed document means a changed embedding; treat it as delete plus insert, and make the identity stable so the old vector cannot survive alongside the new one.
  • Re-embedding. Changing embedding model invalidates every vector at once, because vectors from two models are not comparable. This is a migration with a cost you can compute in advance, and it needs a plan for serving during it — usually a second index built in the background and an atomic switch.

The last one is the one that surprises teams, and it is worth pricing before choosing a model rather than after. Re-embedding a few million chunks is hours of throughput and a real bill, and the temptation under that pressure is to keep a model you have outgrown. Knowing the number in advance makes it a decision instead of a constraint.

Where people go wrong

Choosing on benchmark numbers. Those measure raw similarity search on a clean corpus, which is rarely what breaks. Filtering, updates and operations are what you live with.

Build this: Build search over ten thousand chunks with plain NumPy and measure the latency. Then add metadata filtering by tenant and date, and see which part actually needed a real database.

Open this section on its own page
Section 2 of 2, 5h

Running vector search in Postgres

By the end: You can set up pgvector, tune an index, measure its recall, and change embedding models without downtime.

  • A table, a column and a query
  • HNSW index settings
  • Measuring recall against exact search
  • Filters that return too few rows
  • Changing embedding models

If your data already lives in Postgres, pgvector lets the vectors live there too, which removes a whole system from the diagram: one database, one backup, one transaction, and joins between your embeddings and everything else you already store. This section is a real 50,000-row index, with the timings, the index size, the recall measured against exact search, and the filtering behaviour that returns an empty page in production while looking entirely correct in code. Every number below came from running it.

A table, a column and a query#

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE chunks (
  id        int primary key,
  document  int,
  tenant    boolean,          -- whatever you will filter on
  content   text,
  embedding vector(384)       -- the dimension is fixed at declaration
);

-- Nearest neighbours by cosine distance. The operator picks the metric:
--   <=>  cosine        <->  L2        <#>  negative inner product
SELECT id, content
FROM chunks
ORDER BY embedding <=> $1
LIMIT 10;
Use the operator that matches how the embeddings were trained. Most sentence-embedding models are trained for cosine, and mixing metrics silently returns plausible, wrong neighbours.

With no index that query is a sequential scan, and for 50,000 rows of 384 dimensions it took 31 ms. The table itself was 80 MB. That is already usable for a small corpus, and it has perfect recall, so do not add an index until the numbers say to.

HNSW index settings#

CREATE INDEX ON chunks
  USING hnsw (embedding vector_cosine_ops)   -- must match the query operator
  WITH (m = 16, ef_construction = 64);

-- Build output on 50,000 rows of 384 dimensions:
--   NOTICE: hnsw graph no longer fits into maintenance_work_mem
--           after 28367 tuples
--   DETAIL: Building will take significantly more time.
--   11.6 seconds, index size 98 MB
The index is larger than the table it indexes. Budget for that, and note the NOTICE: past the point where the graph outgrows maintenance_work_mem the build slows sharply, so raise it before a large build rather than waiting hours for one.

Three settings matter and they split cleanly. The operator class has to match the operator you query with — build with vector_cosine_ops and query with the L2 operator and Postgres will simply not use the index, which shows up as a query that was fast in testing and is slow in production. Then m and ef_construction are build-time: higher means a better graph, a larger index and a slower build, and 16 and 64 are sensible defaults. Finally ef_search is query-time, changeable per session, and is the knob you actually tune.

An approximate index has a recall, and you do not know it until you measure it on your own data. The measurement is simple: take a hundred real queries, get the true top ten by forcing a sequential scan, get the indexed top ten, and report the overlap.

-- The ground truth: turn the index off for this session.
SET enable_indexscan = off;
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;

-- The indexed answer, at a given ef_search.
SET enable_indexscan = on;
SET hnsw.ef_search = 40;
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;

-- Overlap over 100 queries, 50,000 rows:
--   ef_search   recall     query time
--      10        0.974       1.7 ms
--      20        0.994       1.8 ms
--      40        0.995       1.9 ms
--     100        0.995       1.6 ms
--   exact        1.000      31.3 ms
Eighteen times faster at 99.5% recall. Note also that recall stops improving at 0.995 however high ef_search goes: the graph itself is lossy, and no query-time setting recovers the last half per cent. If you need exactly right, you need exact search.

Filters that return too few rows#

This is the one that ships. The query below is correct SQL, it is the obvious thing to write, and on this data it returns nothing at all — for a tenant that has 1,059 matching documents.

SELECT id FROM chunks
WHERE tenant                       -- 1,059 of the 50,000 rows
ORDER BY embedding <=> $1
LIMIT 10;

--  0 rows

EXPLAIN ANALYZE on the same query:
  Index Scan using chunks_embedding_idx on chunks
    (actual time=1.407..1.407 rows=0 loops=1)
    Rows Removed by Filter: 40
Read the last line. The index walked 40 candidates, the filter rejected all 40, and the query returned an empty result rather than looking further. No error, no warning, and a page that says no results found.

The mechanism is that the graph traversal does not know about your WHERE clause. It fetches ef_search candidates by vector distance alone and the filter is applied to whatever comes back, so the rarer the filtered category, the more likely every candidate is rejected. A tenant with two per cent of the rows breaks at the default; a tenant with twenty per cent does not, which is exactly why this reaches production — it works for every customer you tested with.

-- Fix 1: widen the search until enough survive the filter.
SET hnsw.ef_search = 1000;
SELECT id FROM chunks WHERE tenant ORDER BY embedding <=> $1 LIMIT 10;
--  10 rows

-- Fix 2: for a selective filter, skip the index entirely. 1,059 rows
-- is nothing to scan exactly, and the answer is exactly right.
SET enable_indexscan = off;
SELECT id FROM chunks WHERE tenant ORDER BY embedding <=> $1 LIMIT 10;
--  10 rows

-- Fix 3: a partial index per high-traffic tenant, so the graph
-- contains only rows that can survive the filter.
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
  WHERE tenant;
Fix 2 is the one people skip and is usually right: past a certain selectivity the filtered subset is small enough that exact search is both faster and correct. The useful habit is to know the row count your filter implies before deciding.

Test with your smallest tenant, not your average one

Every failure above is invisible at average selectivity and certain at low selectivity. A staging dataset where every tenant holds a similar share of the rows cannot produce this bug. Seed one tenant with one per cent of the data, and assert that every filtered search returns the number of rows it asked for.

Changing embedding models#

Vectors from two different models are not comparable, so changing model invalidates the entire column at once — there is no incremental path and no way to serve both from one index. The workable migration is a second column, backfilled in the background while the old one keeps serving, an index built on it, then a switch of the query and a drop of the old column. The dimension is declared in the table, so a model with a different width needs the new column regardless.

Price it before you choose the model rather than after. On the numbers above, the index alone was 98 MB for 50,000 chunks, so a few million chunks is tens of gigabytes of index that has to be rebuilt, plus the embedding cost and the hours of throughput to produce them. That figure is what decides whether a model upgrade is a Tuesday or a quarter.

Where people go wrong

Adding an approximate index to a small table because it sounds right, and quietly losing recall you never measured.

Build this: Load ten thousand chunks into Postgres with pgvector. Measure latency and recall at k=10 without an index, with HNSW at default settings, and at two ef_search values. Then add a tenant filter that matches 1% of rows and see what happens.

Open this section on its own page

Where you use this

A topic is easier to care about when you can see what it is for. These projects lean on it at a named stage.

RAG with citations you can check
  • Stage 2, Embed and index: Whether you need a database at all at your corpus size, and what it would buy you.

Now actually do it

Reading a topic is not learning it. These are the hours that count, and each one tells you when you are finished.

1Exact search in NumPy first

6h

Build search over 10,000 chunks with a plain matrix multiply and measure latency. Then add metadata filtering by tenant and date and see which part actually needed a database.

Done when: You can state the corpus size at which your plain implementation stopped being adequate.

6 hours of practice

Words used here

3 terms this topic teaches, each with a one-line definition if one of them stopped you.

Vector database
A store built to find the vectors nearest a query vector quickly, across millions of items, usually alongside metadata to filter on.
Approximate nearest neighbour search
Finding vectors close to a query without checking every one, trading a small chance of missing a true neighbour for orders of magnitude more speed.
HNSW
The dominant approximate search index: a layered graph where each search starts on a sparse top layer and descends, following edges towards the query. Fast and accurate, at the cost of an index that lives in memory and is expensive to build.

Interview questions

6 questions, grouped by the section that teaches the answer. An answer you have read is not an answer you can give, so use Test yourself and say it out loud first.

Storing and searching vectors

When do you actually need a vector database?

When exact search stops being fast enough, which is usually well past a hundred thousand chunks, or when you need operational features like filtering, replication and incremental updates. Below that, an array and a matrix multiply, or a vector extension on a database you already run, is simpler.

Then they push further

You have 20,000 chunks. What would you use?

Probably the database you already run, such as Postgres with pgvector, or even an in-memory index. At that size exact search is fast enough, and one fewer system to operate matters more.

What would change your mind?

Tens of millions of vectors, strict latency targets under heavy load, filtering or hybrid search the current store handles badly, or a team that already runs a dedicated store well.

What gets marked up. Marked up for starting from existing infrastructure and real numbers. Reaching for a dedicated vector database by default is marked down.

What does approximate nearest neighbour trade away?

Recall. It searches a promising subset rather than everything, so it can miss a vector exact search would have found. Both HNSW and IVF expose a knob trading accuracy against speed.

What matters most when choosing a vector store?

How well it filters alongside similarity search, because real queries are scoped by tenant, date and permissions. That, plus incremental update and delete, matters far more in practice than benchmark query speed.

Running vector search in Postgres

How do you know your vector index is returning the right results?

Measure recall against exact search on a sample of real queries: run each with and without the index and see what fraction of the true top results the index returns. Then tune the search settings until recall is acceptable at your latency target.

Then they push further

Recall is high but retrieval quality is still poor. Why?

Index recall only says the index agrees with exact search. If exact search itself returns the wrong chunks, the problem is the embeddings, chunking or query, and needs a retrieval eval with labelled relevant chunks.

What gets marked up. Strong answers separate index recall from retrieval quality. Candidates who conflate them are marked down.

You filter by tenant and sometimes get fewer results than you asked for. Why?

The approximate index picks candidates before the filter is applied, and most of them belong to other tenants. Use iterative scanning if your version supports it, partial indexes for large tenants, or exact search within small tenants.

How would you switch to a new embedding model?

Treat it as a migration. Store new vectors alongside the old, backfill, compare on the retrieval eval, switch reads if the new model wins, and keep the old vectors until you are sure.

These questions on their own page, one link per question

Resources

Free to watch and read, picked for this topic.

Stop 22 of 50 on the AI Engineer route

0 of 50 topics done · 0h behind you, 603h to go

Next on this routeChunking & document processingThe unglamorous half of retrieval.8h

What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.