Engineering

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

Defaults and starting points from the pgvector README, accessed 2026-10-28.
HNSWIVFFlat
Build option defaultsm = 16, ef_construction = 64lists chosen by you: rows / 1000 up to 1M rows, sqrt(rows) beyond
Query settinghnsw.ef_search = 40ivfflat.probes = 1, start near sqrt(lists)
Needs data before buildingNoYes, lists are computed from existing rows
Build speed and memorySlower, more memoryFaster, less memory
Indexable dimensionsvector up to 2,000, halfvec up to 4,000vector 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.

HNSW index and a cosine query
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_workers defaults to 2, and max_parallel_workers defaults 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_search to widen the candidate set.
  • Turn on iterative scans with hnsw.iterative_scan set to strict_order or relaxed_order. The scan continues until enough rows survive the filter or hnsw.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.
Iterative scan and a partial index
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

  1. Take a sample of real queries, including each filter shape you serve.
  2. Run each query with the index and again as an exact scan with the index disabled, and compare the top-k ids.
  3. If recall is low, raise hnsw.ef_search or ivfflat.probes until it is acceptable, then look at latency.
  4. 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

  1. pgvector README (github.com/pgvector/pgvector)Accessed 2026-10-28. Index options, defaults, iterative scans and filtering behavior.

Read the full write-up

More questions

What is Opaeron?

Opaeron is a multi-tenant AI operations platform for a digital marketing agency, built and operated by Dinesh Solanki at Trilliant Digital. Staff...

Read the answer

Why did my Celery task run twice with the Redis broker?

With the Redis broker, a message that is not acknowledged within the visibility timeout is delivered to another worker, and Celery's documentation...

Read the answer

Have a question that is not answered here?

Ask me directly