Should I use HNSW or IVFFlat in pgvector?
Short answer
Default to HNSW. The pgvector README describes it as the better speed and recall tradeoff, at the cost of slower builds and more memory, and unlike IVFFlat it can be built on an empty table. Choose IVFFlat when build time or memory is the constraint, then tune lists and probes. With either, measure recall against an exact scan on your own embeddings, and never assume a filtered query returns LIMIT rows.
#HNSW and IVFFlat side by side
| HNSW | IVFFlat | |
|---|---|---|
| Build option defaults | m = 16, ef_construction = 64 | lists chosen by you: rows / 1000 up to 1M rows, sqrt(rows) beyond |
| Query setting | hnsw.ef_search = 40 | ivfflat.probes = 1, start near sqrt(lists) |
| Needs data before building | No | Yes, lists are computed from existing rows |
| Build speed and memory | Slower, more memory | Faster, less memory |
| Indexable dimensions | vector up to 2,000, halfvec up to 4,000 | vector up to 2,000, halfvec up to 4,000 |
A probes value of 1 is low recall for most data, and setting probes equal to the number of lists gives an exact search.
#Create and query an index
The operator in the query must match the operator class on the index, or Postgres will not use it. Cosine distance is <=> with vector_cosine_ops. The other operators are <-> for L2 distance, <#> for negative inner product and <+> for L1 distance.
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
SET hnsw.ef_search = 100;
SELECT id, content
FROM chunks
ORDER BY embedding <=> $1
LIMIT 10;A higher ef_construction improves recall but slows builds and inserts. A higher hnsw.ef_search improves recall at the cost of query speed.
#Build tips
- Indexes build significantly faster when the graph fits in
maintenance_work_mem. Do not set it so high that the server runs out of memory. max_parallel_maintenance_workersdefaults to 2, andmax_parallel_workersdefaults to 8. Raise both for large builds.
#The filtering trap
Approximate indexes apply a WHERE filter after the index scan. The README's own example: with the default hnsw.ef_search of 40, a condition that matches 10 percent of rows returns about 4 rows on average. There is no error, so retrieval quietly returns less context. This matters most in multi-tenant systems where every query is scoped to an account.
- Raise
hnsw.ef_searchto widen the candidate set. - Turn on iterative scans with
hnsw.iterative_scanset tostrict_orderorrelaxed_order. The scan continues until enough rows survive the filter orhnsw.max_scan_tuples(default 20,000) is reached. Relaxed order allows slightly out-of-order results for better recall. - For a filter with few distinct values, use a partial index.
- For many distinct values, such as tenant ids, partition by the filter column.
- If the filter matches a tiny share of rows, an ordinary B-tree index plus an exact scan of the survivors is both correct and fast.
SET hnsw.iterative_scan = relaxed_order;
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
WHERE (account_id = 42);#Measure recall on your own data
- Take a sample of real queries, including each filter shape you serve.
- Run each query with the index and again as an exact scan with the index disabled, and compare the top-k ids.
- If recall is low, raise
hnsw.ef_searchorivfflat.probesuntil it is acceptable, then look at latency. - Repeat per filter shape, because a tenant-scoped query and an unscoped query behave differently.
The longer version is in pgvector: HNSW vs IVFFlat and the Filtering Trap. Opaeron stores embeddings this way, as described in Supabase to Self-Hosted Postgres.
Sources
- pgvector README (github.com/pgvector/pgvector)Accessed 2026-10-28. Index options, defaults, iterative scans and filtering behavior.
Read the full write-up
Have a question that is not answered here?
Ask me directly