pgvector: HNSW vs IVFFlat and the Filtering Trap
How to choose between HNSW and IVFFlat in pgvector, which settings matter, and why a filtered vector query can silently return too few rows.
Opaeron stores embeddings in PostgreSQL 16 with pgvector, so semantic search sits next to the relational data it searches. The part that bites later is not the embedding model. It is the index choice and one behavior of filtered queries that returns fewer rows than you asked for without any error.
#The two index types
pgvector offers two approximate indexes. HNSW builds a multilayer graph and searches it. IVFFlat divides vectors into lists and searches the nearest few. The pgvector documentation describes the tradeoff plainly: the graph index costs more to build and holds more in memory, and in exchange it gives better query performance for the recall you get.
| HNSW | IVFFlat | |
|---|---|---|
| Build options | m = 16, ef_construction = 64 | lists (start at rows / 1000 up to 1M rows, sqrt(rows) beyond) |
| Query option | hnsw.ef_search = 40 | ivfflat.probes = 1 (start near sqrt(lists)) |
| Needs data before build | No | Yes, lists are computed from existing rows |
| Max indexed dimensions | 2,000 for vector, 4,000 for halfvec | 2,000 for vector, 4,000 for halfvec |
#Creating and querying
The operator must match the operator class on the index, or Postgres will ignore the index. Cosine distance uses <=> with vector_cosine_ops.
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;Index builds are faster when the graph fits in maintenance_work_mem, and the README warns against setting it so high that the server runs out of memory. Parallel builds are governed by max_parallel_maintenance_workers, which defaults to 2.
#The filtering trap
Real queries are scoped. In a multi-tenant platform every search carries an account id. With an approximate index, the scan returns its candidate set first and the filter runs afterwards. The README's own example: with ef_search at 40, a condition that matches 10% of rows yields about 4 rows on average. There is no error. Retrieval simply returns less context, and a RAG answer gets worse for reasons that are invisible in the logs.
- Raise ef_search. Widens the candidate set, at a cost in speed.
- Iterative scans. Setting
hnsw.iterative_scantostrict_orderorrelaxed_orderkeeps scanning 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. - Partial index. For a filter with few distinct values, index only those rows.
- Partitioning. For many distinct values such as tenant ids, partition by the filter column so each query touches one small index.
- B-tree first. If the filter matches a tiny fraction of rows, an ordinary index plus an exact scan of the survivors is both correct and fast.
#Measure recall yourself
Defaults are starting points. The reliable check is to run a sample of real queries twice, once with the index and once with the index disabled for an exact scan, and compare the top-k ids. If recall is low, raise ef_search or probes until it is not, then look at latency. Do this per filter shape, because a tenant-scoped query and an unscoped query behave differently.
#Related notes
- Supabase to Self-Hosted Postgres: A Production Migration
- LLM Model Routing to Keep Inference Costs Predictable
Sources
- pgvector README (github.com/pgvector/pgvector)Accessed 2026-10-28. HNSW and IVFFlat options and defaults, indexing dimension limits, iterative scans, filtering behavior, index build tips.
Frequently asked questions
Should I use HNSW or IVFFlat in pgvector?
Start with HNSW unless index build time or memory is the constraint. HNSW can be created on an empty table and keeps its structure as data arrives, while IVFFlat builds its lists from existing rows, so it should be created after the table holds representative data. IVFFlat builds faster and uses less memory. Whichever you pick, measure recall against an exact scan on your own embeddings before trusting the defaults.
Why does my filtered pgvector query return fewer rows than the LIMIT?
Approximate indexes apply the WHERE filter after the index scan. The pgvector documentation gives the example that with the default hnsw.ef_search of 40, a condition matching 10% of rows returns about 4 rows on average. Raising ef_search, enabling iterative index scans, adding a partial index for a fixed filter value, or partitioning by the filter column all address it.
What are the pgvector default settings for HNSW?
The documented defaults are m = 16 and ef_construction = 64 for building, and hnsw.ef_search = 40 for querying. A higher ef_construction improves recall but slows builds and inserts, and a higher ef_search improves recall at the cost of query speed.
Related questions
Building something in this space and want another pair of hands on it?
Get in touch