How to Tune pgvector Performance: HNSW, ef_search and Quantization (2026)
This guide is for engineers who already have embeddings in Postgres and now have a slow query, a slow index build, or a filtered search that quietly returns three rows instead of ten. By the end you will have an HNSW index built in seconds rather than minutes, ef_search chosen from measured recall instead of guesswork, filtered queries that return full result sets, and an index roughly half or a sixth of its original size. Everything here was run on Postgres 18.4 with pgvector 0.8.2 on an Apple M3 Pro laptop, and checked against the pgvector README for 0.8.7, the current release as of 1 October 2026; the 0.8.3 to 0.8.7 releases are bug and memory fixes, so the settings are the same. You need psql access with permission to create indexes and change session settings. If you are still deciding whether Postgres is the right home for your vectors at all, read the companion piece, Vector Databases in 2026: Do You Need One, or Is pgvector Enough?, first.
All timings below come from a 100,000-row table of synthetic 384-dimension vectors grouped into 1,000 clusters, with a category_id column that matches 1 percent of rows. Real embeddings behave differently in detail, so use the numbers for direction and rerun the scripts on your own data.
Step 1: Check your pgvector version and get a test environment
Iterative index scans arrived in 0.8.0, and 0.8.3 and 0.8.4 fixed HNSW vacuuming bugs including possible index corruption, per the changelog. Check what you have before tuning anything.
SELECT extversion FROM pg_extension WHERE extname = 'vector';
-- Expected on a current install:
-- extversion
-- ------------
-- 0.8.7
-- After installing newer binaries on the server (package or image), upgrade in place:
ALTER EXTENSION vector UPDATE;
For experiments, a throwaway container with the official image keeps your laptop clean:
# pgvector 0.8.7 on Postgres 18; tag verified on Docker Hub
docker run -d --name pgv -e POSTGRES_PASSWORD=localdev -p 5433:5432 \
pgvector/pgvector:0.8.7-pg18
psql "postgresql://postgres:localdev@localhost:5433/postgres" \
-c "CREATE EXTENSION IF NOT EXISTS vector;"
Load data before you create indexes. The README recommends COPY ... WITH (FORMAT BINARY) for bulk loads and building indexes afterwards, because HNSW inserts are far slower than a single build.
Step 2: Choose HNSW or IVFFlat
pgvector has two approximate index types. HNSW builds a layered graph: better speed for a given recall, slower to build, more memory, and no training step, so you can create it on an empty table. IVFFlat clusters vectors into lists and searches the nearest few: quick to build and smaller, but it needs data present when created and has a worse speed to recall trade.
Here is what the two looked like on the test table, each built with maintenance_work_mem = '2GB' and seven parallel workers:
| Index | Settings | Build time | Size | Recall@10 | Query time (one query) |
|---|---|---|---|---|---|
| None (exact, parallel seq scan) | 2 workers | n/a | n/a | 1.000 | 26.9 ms |
| IVFFlat | lists = 100, probes = 1 | 3.2 s | 157 MB | 0.636 | not measured |
| IVFFlat | lists = 100, probes = 10 | 3.2 s | 157 MB | 0.922 | 4.3 ms |
| HNSW | m = 16, ef_construction = 64, ef_search = 40 | 8.1 to 8.6 s | 195 MB | 0.848 to 0.922 | 0.45 ms |
| HNSW | same, ef_search = 100 | same | same | 0.928 to 0.962 | not measured |
The HNSW ranges are two separate builds of the same index; graph construction involves randomness, which is one more reason to measure recall on every rebuild. My default is HNSW for anything that serves user queries, and IVFFlat only when build time or memory is the binding constraint. For IVFFlat, the README's starting points are lists = rows / 1000 up to a million rows and sqrt(rows) above that, with ivfflat.probes = sqrt(lists).
-- HNSW: one index per distance operator you query with
CREATE INDEX items_hnsw ON items USING hnsw (embedding vector_cosine_ops);
-- IVFFlat alternative: create only after the table has representative data
CREATE INDEX items_ivf ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
SET ivfflat.probes = 10;
Match the operator class to the operator in your query: vector_cosine_ops with <=>, vector_l2_ops with <->, vector_ip_ops with <#>. If your embeddings are normalised to length 1, as OpenAI's are, the README suggests inner product for the best performance.
Step 3: Make HNSW builds fast with maintenance_work_mem and parallel workers
The biggest build-time win is memory. HNSW builds fast while the graph fits in maintenance_work_mem; when it no longer fits, pgvector tells you and falls back to a much slower path. With the default of 64MB, my build printed this:
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 28361 tuples
DETAIL: Building will take significantly more time.
HINT: Increase maintenance_work_mem to speed up builds.
Set both knobs for the build session only:
-- Session-level; do not set it so high that the server runs out of memory
SET maintenance_work_mem = '2GB';
-- Default is 2; the leader process works too. May need max_parallel_workers raised (default 8).
SET max_parallel_maintenance_workers = 7;
-- In production, build without blocking writes
CREATE INDEX CONCURRENTLY items_hnsw ON items USING hnsw (embedding vector_cosine_ops);
-- From another session, watch progress (HNSW phases: initializing, loading tuples)
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;
Memory alone took the build from 57.1 s to 17.4 s; adding workers took it to 8.1 s. On uniformly random vectors, a worst case for graph construction, a 32MB build took 12 minutes 48 seconds against 29 seconds with 2GB and seven workers. Size maintenance_work_mem from the index you expect: roughly the size of the finished index is a reasonable first guess (195 MB here), and the NOTICE tells you when you guessed low.
Step 4: Tune m, ef_construction and ef_search against measured recall
Three parameters control HNSW quality:
m: maximum connections per layer, default 16. Set at build time.ef_construction: candidate list size while building, default 64. Higher means better recall and slower builds and inserts.hnsw.ef_search: candidate list size at query time, default 40. Higher means better recall and slower queries. It also caps how many rows one index scan can return.
The README's advice is to keep the build defaults unless you see low recall, and tune ef_search first, since it needs no rebuild. To tune it you need recall, and recall needs ground truth from exact search. This script measures recall@10 for a set of sample queries; swap in 50 to 100 real query embeddings from your logs.
-- Sample queries: replace with real query embeddings
CREATE TABLE queries (qid int PRIMARY KEY, v vector(384));
-- INSERT INTO queries ... ;
-- Ground truth with the index disabled (exact search)
SET enable_indexscan = off;
CREATE TABLE truth AS
SELECT q.qid, n.id
FROM queries q
CROSS JOIN LATERAL (SELECT id FROM items ORDER BY embedding <=> q.v LIMIT 10) n;
RESET enable_indexscan;
-- Recall@10 at a given ef_search (SET LOCAL lasts for the function's transaction)
CREATE OR REPLACE FUNCTION recall_at_10(ef int) RETURNS numeric
LANGUAGE plpgsql AS $$
DECLARE r numeric;
BEGIN
PERFORM set_config('hnsw.ef_search', ef::text, true);
SELECT round(count(*)::numeric / (SELECT count(*) FROM truth), 3) INTO r
FROM queries q
CROSS JOIN LATERAL (SELECT id FROM items ORDER BY embedding <=> q.v LIMIT 10) a
JOIN truth t ON t.qid = q.qid AND t.id = a.id;
RETURN r;
END $$;
SELECT ef, recall_at_10(ef) FROM unnest(ARRAY[10, 20, 40, 100, 200]) AS ef;
On the first build of the test index, the output was:
ef | recall_at_10
-----+--------------
10 | 0.726
20 | 0.768
40 | 0.848
100 | 0.928
200 | 0.986
Pick the smallest ef_search that meets your recall target, and set it per query rather than globally:
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id FROM items ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;
If you need high recall and ef_search is already in the hundreds, then rebuild with a higher ef_construction (try 128) or m (try 24 or 32), and measure build time and size again, since both grow.
Step 5: Fix filtered queries with iterative scans, partial indexes or partitions
This is the pitfall that bites most teams. With an approximate index, the WHERE clause is applied after the index scan. The scan finds ef_search candidates, then the filter throws away the ones that do not match. My 1 percent filter returned nothing at all:
SELECT count(*) FROM (
SELECT id FROM items WHERE category_id = 7
ORDER BY embedding <=> (SELECT v FROM queries WHERE qid = 1) LIMIT 10
) s;
-- count = 0 (EXPLAIN shows "Rows Removed by Filter: 40")
Adding a B-tree index on category_id did not help: the planner still chose HNSW and still returned zero rows. pgvector 0.8.0's iterative index scans fix this by continuing to scan the graph until enough rows pass the filter:
BEGIN;
SET LOCAL hnsw.iterative_scan = relaxed_order; -- or strict_order for exact distance order
SELECT id FROM items WHERE category_id = 7
ORDER BY embedding <=> (SELECT v FROM queries WHERE qid = 1) LIMIT 10;
COMMIT;
-- 10 rows; EXPLAIN: "Rows Removed by Filter: 9417", Execution Time: 19.331 ms
relaxed_order gives better recall and may return results slightly out of distance order; wrap it in a materialized CTE and re-sort if order matters (the README notes you need ORDER BY distance + 0 on Postgres 17 and later). Two settings bound the extra work: hnsw.max_scan_tuples (default 20,000) and hnsw.scan_mem_multiplier (default 1, a multiple of work_mem). For IVFFlat the equivalents are ivfflat.iterative_scan and ivfflat.max_probes.
Iterative scans cost time: 19 ms instead of under 1 ms here, because the scan visited about 9,400 rows. When filters are predictable, structural fixes are faster:
-- A few hot filter values: partial index per value
CREATE INDEX items_cat7_hnsw ON items USING hnsw (embedding vector_cosine_ops)
WHERE (category_id = 7);
-- Many tenants: list partitioning, so each tenant gets its own smaller index
CREATE TABLE items_p (id bigint, tenant_id int, embedding vector(384))
PARTITION BY LIST (tenant_id);
Partitioning per tenant also stops one tenant's vectors from affecting another's recall, which the README calls out explicitly. For tenant isolation at the row level on top of this, see the Postgres row-level security guide.
Step 6: Shrink the index with halfvec and binary quantization
Smaller indexes stay in memory, and an HNSW index that does not fit in RAM is where latency goes to die. pgvector gives you two tools without changing your table. halfvec stores 2 bytes per dimension instead of 4, and indexes up to 4,000 dimensions instead of 2,000. Binary quantization reduces each dimension to one bit and indexes up to 64,000 dimensions; you re-rank the candidates against the full vectors to recover recall.
-- Half-precision expression index (table keeps full precision)
CREATE INDEX items_hnsw_half ON items
USING hnsw ((embedding::halfvec(384)) halfvec_cosine_ops);
SELECT id FROM items
ORDER BY embedding::halfvec(384) <=> $1::halfvec(384) LIMIT 10;
-- Binary quantized expression index
CREATE INDEX items_hnsw_bq ON items
USING hnsw ((binary_quantize(embedding)::bit(384)) bit_hamming_ops);
-- Re-rank: take 100 candidates by Hamming distance, order them by true cosine distance
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id FROM (
SELECT id, embedding FROM items
ORDER BY binary_quantize(embedding)::bit(384) <~> binary_quantize($1) LIMIT 100
) c
ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;
Measured on the test table:
| Index | Size | Build time | Recall@10 |
|---|---|---|---|
vector HNSW |
195 MB | 8.1 s | 0.848 (ef 40), 0.928 (ef 100) |
halfvec HNSW |
112 MB | 8.3 s | 0.872 (ef 40), 0.972 (ef 100) |
| Binary quantized, 40 candidates re-ranked | 33 MB | 2.0 s | 0.652 |
| Binary quantized, 100 candidates re-ranked | 33 MB | 2.0 s | 0.992 |
halfvec was free recall-wise here. Binary quantization was six times smaller and four times faster to build, but useless without enough re-ranking candidates. Expect binary quantization to lose more accuracy on low-dimension embeddings; Elastic's BBQ docs warn that datasets under 384 dimensions may see less accuracy with its binary quantization, and Elasticsearch only makes it the default at 384 dimensions and above.
Step 7: Read EXPLAIN and confirm the index is used
Use EXPLAIN (ANALYZE, BUFFERS) on the exact query your application sends. A healthy HNSW plan looks like this:
Limit (actual time=0.432..0.440 rows=10.00 loops=1)
-> Index Scan using items_hnsw on items (actual time=0.431..0.439 rows=10.00 loops=1)
Order By: (embedding <=> (InitPlan 1).col1)
Index Searches: 1
Buffers: shared hit=535
Execution Time: 0.450 ms
The common reason for a sequential scan is the query shape. The index is only used for ORDER BY <distance operator> ... LIMIT n in ascending order. Converting distance to similarity breaks it:
-- Uses the index
SELECT id FROM items ORDER BY embedding <=> $1 LIMIT 10;
-- Does NOT use the index: the plan becomes Parallel Seq Scan + Sort
SELECT id FROM items ORDER BY 1 - (embedding <=> $1) DESC LIMIT 10;
Compute similarity in the select list instead. Also check that the operator matches the index's operator class, that the column is not wrapped in a different cast from the index expression, and that shared hit dominates read in the buffers line; lots of reads means the index is not in memory.
Step 8: Maintain the index and monitor recall
HNSW vacuuming is slow; the README suggests REINDEX INDEX CONCURRENTLY before VACUUM on tables with heavy churn. Watch latency with pg_stat_statements, and put the Step 4 recall script on a schedule so recall becomes a tracked number. Recall drifts as data changes, and nothing else will tell you.
REINDEX INDEX CONCURRENTLY items_hnsw;
VACUUM items;
SELECT pg_size_pretty(pg_relation_size('items_hnsw')); -- compare against available RAM
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
NOTICE: hnsw graph no longer fits into maintenance_work_mem |
Build memory too small | SET maintenance_work_mem to about the index size for the session; rebuild |
| Build uses one CPU only | max_parallel_maintenance_workers at default 2, or max_parallel_workers too low |
Raise both for the session; check pg_stat_progress_create_index |
Filtered query returns fewer rows than LIMIT |
Filter applied after a scan of ef_search candidates |
SET hnsw.iterative_scan = relaxed_order; or partial index or partitions |
| Fewer results after adding an HNSW index, no filter | ef_search (default 40) below LIMIT, dead tuples, or NULL vectors (not indexed) |
Raise hnsw.ef_search above LIMIT; vacuum; filter NULLs |
| IVFFlat returns few or poor results | Index built on too little data, or probes at default 1 |
Drop and rebuild after loading; set ivfflat.probes near sqrt(lists) |
EXPLAIN shows Seq Scan |
ORDER BY uses an expression, DESC order, wrong operator class, or tiny table |
Order by the raw distance operator ascending with LIMIT; match ops class |
| Error creating index on 3,072-dim column | vector HNSW and IVFFlat index up to 2,000 dimensions |
Index embedding::halfvec(3072) (up to 4,000) or binary quantize |
| Latency high and buffers show many reads | Index larger than memory | Use halfvec or binary quantization; add RAM; check shared_buffers |
| Iterative scan slow or still short | Scan stopped at hnsw.max_scan_tuples (20,000) |
Raise it and hnsw.scan_mem_multiplier; prefer partitions for selective filters |
| VACUUM on the table takes hours | HNSW vacuum cost | REINDEX INDEX CONCURRENTLY first, then VACUUM |
FAQ
What is a good ef_search value for pgvector?
There is no universal value. Start at the default 40, measure recall@10 against exact search, and raise it until you hit your target; on my test data 100 gave 93 to 96 percent and 200 gave 99 percent. Always keep ef_search at or above your LIMIT, because it caps the rows an index scan returns.
Should I use HNSW or IVFFlat in pgvector?
HNSW for most production search: it gave 0.45 ms queries against 4.3 ms for IVFFlat at similar recall in my test. Choose IVFFlat when build time or memory is the constraint, and only after the table holds representative data.
How much maintenance_work_mem does a pgvector HNSW build need?
Enough to hold the graph, which is roughly the size of the finished index. pgvector prints a NOTICE when the graph stops fitting. Set it per session for the build and leave headroom so the server does not run out of memory.
Why does my pgvector query with a WHERE clause return fewer results?
Because filtering happens after the approximate index scan, which only produces ef_search candidates. Enable hnsw.iterative_scan (pgvector 0.8.0 and later), or use partial indexes or partitioning for filters you know in advance.
When a pgvector query is slow, the first thing I check is the buffers line in EXPLAIN (ANALYZE, BUFFERS), because an index that does not fit in memory explains most bad latency; the second is whether a WHERE clause is silently starving the result set. In production I build with generous maintenance_work_mem in a dedicated session, use halfvec for anything above a few hundred dimensions, partition by tenant, and run a nightly recall job so drift shows up on a dashboard before it shows up in answers. The authoritative references are the pgvector README and changelog, and the Postgres docs on progress reporting and partitioning.
Related: Vector Databases in 2026: Do You Need One, or Is pgvector Enough?, Agent Memory in 2026 and How to Build a RAG Pipeline With Postgres and pgvector (2026).
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Fine-Tune an LLM with LoRA Using Unsloth or TRL (2026)
Step-by-step LoRA and QLoRA fine-tuning of an open-weight LLM with Unsloth 2026.10 or Hugging Face TRL 1.16: dataset format, hyperparameters, GPU memory, training script, evaluation against the base model, GGUF and vLLM export, and fixes for OOM and loss problems.
14 minOct 20, 2026Running LLMs Locally in 2026: Ollama vs llama.cpp vs LM Studio vs MLX
A practical 2026 guide to running LLMs locally: Ollama 0.40, llama.cpp 0.7, LM Studio 0.4, MLX and vLLM compared, which models fit in 8, 16, 32 and 64GB, how Q4_K_M quantization works, the bandwidth maths behind tokens per second, licences and privacy.
12 minOct 20, 2026How to Build a RAG Pipeline With Postgres and pgvector (2026)
Step-by-step RAG pipeline on Postgres 18 and pgvector 0.8.7: install, schema, chunking, OpenAI embeddings, an HNSW index, hybrid search with full-text and reciprocal rank fusion, a Python example, retrieval evals, and fixes for slow queries and poor recall.
17 min