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
New to vector databases? 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. Why it matters
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;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 MBThree 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.
Measuring recall against exact search#
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 msFilters 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: 40The 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;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.
If this comes up in an interview
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.
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.
Watch it explained
This is one section of Vector databases, 11 hours across 2 sections. Read it in full to get the whole topic in order.
Stop 22 of 50 on the AI Engineer route
0 of 50 topics done · 0h behind you, 603h to go
What you have marked done is stored in this browser only. It does not reach a server, and clearing site data will clear it.