pgvector in production
Vector search inside Postgres. The reason it wins so often is not benchmark numbers — it is that your vectors sit in the same transaction, the same backup, the same access control and the same ops runbook as everything else.
CREATE EXTENSION vector;
CREATE TABLE chunks (
id BIGSERIAL PRIMARY KEY,
doc_id BIGINT REFERENCES documents(id),
content TEXT NOT NULL,
embedding VECTOR(1536),
meta JSONB
);Note: as of 2026-08 pgvector is 0.8.6. It also has
halfvec(half-precision, up to 4,000 dims), binary quantisation andsparsevec, which are the levers when the index stops fitting in RAM.
The operators decide the index
| Operator | Distance | Index opclass |
|---|---|---|
<=> |
cosine | vector_cosine_ops |
<-> |
L2 | vector_l2_ops |
<#> |
negative inner product | vector_ip_ops |
An index built for one operator will not be used by a query written with another. This is the most common “why is it doing a sequential scan” answer, and it is silent — you get correct results, slowly.
HNSW vs IVFFlat
CREATE INDEX ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX ON chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 1000);| HNSW | IVFFlat | |
|---|---|---|
| Build time | slow | fast |
| Memory | higher | lower |
| Recall/speed | better | acceptable |
| Needs data first | no | yes |
IVFFlat must be built on populated data, because lists are centroids
derived from the existing rows. Build it on an empty table and recall is
poor forever — a real production trap during a re-index. HNSW has no such
requirement, which is one reason it is the default choice now.
Rules of thumb: m = 16 and ef_construction = 64 are fine defaults; raise
m for higher recall at the cost of memory. For IVFFlat, lists ≈ rows/1000
up to a million rows, then sqrt(rows).
ef_search is the dial you actually turn
SET hnsw.ef_search = 100; -- default 40
SET ivfflat.probes = 10; -- default 1This is the recall/latency knob, set per session or per query, not at
index build. Raising it searches more candidates: better recall, more time.
Being able to say “I tuned recall with ef_search rather than rebuilding the
index” is the concrete detail that separates having used pgvector from having
read about it.
Tune it against a golden set — see Agentic RAG and evaluating retrieval.
The filtered-query cliff
The failure mode that surprises people:
SELECT id, content
FROM chunks
WHERE meta->>'tenant' = 'acme'
ORDER BY embedding <=> $1
LIMIT 10;An ANN index returns approximately the nearest ef_search candidates, and the
filter is applied to those. If acme is 1% of the table, most candidates
are discarded and you may get back three rows instead of ten — or Postgres
gives up on the index and scans.
Three ways out:
- Partial indexes per high-cardinality value, when the set is small and known — one index per tenant.
- Raise
ef_searchso more candidates survive the filter. Cheap, and usually enough for mild selectivity. - Partition the table by the filter column, so each partition has its own index and the filter becomes partition pruning.
Iterative index scans in recent pgvector versions soften this by fetching more candidates when the filter eats too many, but the reasoning is still what an interviewer wants to hear.
Keeping it fast
- Index build is memory-hungry. Raise
maintenance_work_membefore creating an HNSW index or it spills and takes hours. Build concurrently on a live table. - Vacuum matters as much as anywhere else. Updated embeddings leave dead tuples the index still walks.
- Dimension is storage. 1536 float32 dims is ~6 KB per row before the
index.
halfvechalves it for a small recall cost; binary quantisation with rescoring goes much further. - Store the chunk text alongside the vector. Retrieval that needs a second round trip to fetch content is the easiest latency win to skip.
When to leave
pgvector comfortably handles the low millions of vectors on one machine. The honest triggers for a dedicated store are: tens of millions of vectors, a need to scale search independently of your OLTP database, or built-in features you would otherwise write — native hybrid ranking, multi-tenant isolation, distributed sharding.
Starting on pgvector and moving later is the right order for almost every product, because the migration is a re-index, not a rewrite.
Related
Interview angle 5
- “Why pgvector rather than a dedicated vector database?” - the vectors live in the same transaction, backup, access control and ops runbook as the rest of the data, and you can join them to it. For the low millions of vectors that is a smaller system with fewer failure modes, and moving later is a re-index rather than a rewrite.
- “HNSW or IVFFlat?” - HNSW by default: better recall for the latency, and no build-order constraint. IVFFlat builds faster and uses less memory, but its lists are centroids computed from existing rows, so building it on an empty table gives permanently poor recall.
- “How do you trade recall against latency?” -
hnsw.ef_search, set per session or per query rather than at build time. It widens the candidate list: more recall, more time. Tune it against a golden set instead of guessing. - “Why did my filtered vector query get slow or return too few rows?” - the ANN index returns
ef_searchcandidates and the filter is applied to those, so a selective filter discards most of them. Fix with partial indexes, a higheref_search, or partitioning by the filter column. - “Why is Postgres ignoring my vector index?” - the query uses a different operator from the one the index was built for. An index on
vector_cosine_opsdoes nothing for a<->query, and the failure is silent: right answers, sequential scan.