Recommended start: CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops); with maintenance_work_mem large enough for the graph. With the defaults (m = 16, ef_construction = 64, ef_search = 40), our 100,000 OpenAI embeddings returned 94.7% of the true top-10 in a median 2.9 ms. Need more recall? Raise hnsw.ef_search first — it's free to change and needs no rebuild.
How HNSW finds neighbours#
HNSW (Hierarchical Navigable Small World) links every vector to its nearest neighbours, then adds sparser "express" layers on top. A search enters at the top, greedily hops towards the query on each layer, drops down, and finishes with a wider search on the bottom layer, where every vector lives. That last step keeps a list of the ef_search best candidates seen so far.
A real HNSW built in your browser over 260 random 2-D points. Real embeddings have 1,536 dimensions, but the mechanics are identical: a handful of upper-layer hops, then a bounded search at the bottom. Push ef_search up and watch recall rise along with the distance computations.
Creating the index#
demo=# CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);
CREATE INDEXChoose the operator class for the distance your queries use — vector_cosine_ops for <=>, vector_ip_ops for <#>, vector_l2_ops for <->. A mismatch silently falls back to a full scan (see the plans). Over 2,000 dimensions, index a halfvec cast (guide).
ef_search: the knob to turn first#
hnsw.ef_search changes per query and costs nothing to experiment with. We ran our 1,000 held-out queries at seven values against the default-built index:
Recall@10 vs ef_search (HNSW, m = 16, ef_construction = 64)
100,000 × 1,536-dim OpenAI embeddings, 1,000 held-out queries. pgvector's default ef_search is 40.
| ef_search | Recall@10 | p50 latency | p95 latency | QPS (1 client) |
|---|---|---|---|---|
| 10 | 0.815 | 1.4 ms | 2.5 ms | 625 |
| 20 | 0.897 | 1.9 ms | 3.2 ms | 482 |
| 40 | 0.947 | 2.9 ms | 5.3 ms | 304 |
| 64 | 0.970 | 3.9 ms | 5.9 ms | 246 |
| 100 | 0.983 | 5.3 ms | 8.4 ms | 181 |
| 200 | 0.990 | 9.1 ms | 14 ms | 106 |
| 400 | 0.995 | 16 ms | 24 ms | 61 |
Query latency vs ef_search
Single client, warm cache. Median and 95th percentile of 1,000 queries.
- p50
- p95
| ef_search | p50 | p95 | p99 |
|---|---|---|---|
| 10 | 1.4 ms | 2.5 ms | 4.3 ms |
| 20 | 1.9 ms | 3.2 ms | 5.3 ms |
| 40 | 2.9 ms | 5.3 ms | 12 ms |
| 64 | 3.9 ms | 5.9 ms | 9.9 ms |
| 100 | 5.3 ms | 8.4 ms | 12 ms |
| 200 | 9.1 ms | 14 ms | 23 ms |
| 400 | 16 ms | 24 ms | 32 ms |
What the curves say:
- The default of 40 is a sensible middle: 94.7% recall at 2.9 ms.
- Doubling ef_search roughly doubles latency (40 → 100: 2.9 ms → 5.3 ms) while recall gains shrink fast: +3.5 points from 40 → 100, +1.2 from 100 → 400.
- Exact search is not an option at scale: without an index the same query took 441 ms — 150× slower.
| Target recall@10 | ef_search at 100K rows | ef_search at 500K rows |
|---|---|---|
| 90% | 40 (0.947, 2.9 ms) | 40 (0.931, 4.1 ms) |
| 95% | 64 (0.970, 3.9 ms) | 100 (0.968, 6.2 ms) |
| 98% | 100 (0.983, 5.3 ms) | 200 (0.982, 11 ms) |
| 99% | 200 (0.990, 9.1 ms) | > 200 (not measured) |
Set it where it applies — per transaction keeps it from leaking into other queries on a pooled connection:
demo=# SET hnsw.ef_search = 100;
SET
demo=# BEGIN;
BEGIN
demo=# SET LOCAL hnsw.ef_search = 200;
SET
demo=# SHOW hnsw.ef_search;
hnsw.ef_search
----------------
200
(1 row)
demo=# COMMIT;
COMMIT
demo=# SHOW hnsw.ef_search;
hnsw.ef_search
----------------
100
(1 row)Or per role: ALTER ROLE search_api SET hnsw.ef_search = 100;. It must be at least your LIMIT; without iterative scans an HNSW scan never returns more than ef_search rows.
Build options: m and ef_construction#
These are fixed at CREATE INDEX time. m is how many links each vector keeps per layer (twice that on the bottom layer); ef_construction is the candidate-list size used while inserting. Better graphs give more recall per millisecond of search — at the cost of build time and, for m, size.
Effect of ef_construction on the recall/latency curve (m = 16)
Points are ef_search 10 → 400. A better-built graph gives more recall per millisecond.
- ef_construction = 32
- ef_construction = 64
- ef_construction = 128
- ef_construction = 256
| ef_construction | ef_search | Recall@10 | p50 |
|---|---|---|---|
| 32 | 10 | 0.782 | 1.4 ms |
| 32 | 20 | 0.874 | 1.9 ms |
| 32 | 40 | 0.925 | 2.7 ms |
| 32 | 64 | 0.949 | 3.7 ms |
| 32 | 100 | 0.966 | 4.9 ms |
| 32 | 200 | 0.979 | 8.1 ms |
| 32 | 400 | 0.988 | 14 ms |
| 64 | 10 | 0.815 | 1.4 ms |
| 64 | 20 | 0.897 | 1.9 ms |
| 64 | 40 | 0.947 | 2.9 ms |
| 64 | 64 | 0.970 | 3.9 ms |
| 64 | 100 | 0.983 | 5.3 ms |
| 64 | 200 | 0.990 | 9.1 ms |
| 64 | 400 | 0.995 | 16 ms |
| 128 | 10 | 0.819 | 1.4 ms |
| 128 | 20 | 0.906 | 2.0 ms |
| 128 | 40 | 0.960 | 2.9 ms |
| 128 | 64 | 0.979 | 4.0 ms |
| 128 | 100 | 0.989 | 5.4 ms |
| 128 | 200 | 0.996 | 9.4 ms |
| 128 | 400 | 0.998 | 17 ms |
| 256 | 10 | 0.818 | 1.5 ms |
| 256 | 20 | 0.910 | 2.0 ms |
| 256 | 40 | 0.964 | 3.0 ms |
| 256 | 64 | 0.983 | 4.3 ms |
| 256 | 100 | 0.991 | 5.9 ms |
| 256 | 200 | 0.997 | 10 ms |
| 256 | 400 | 0.998 | 19 ms |
ef_construction: going from 64 to 128 bought 1.2 points of recall at ef_search = 40 for 1.7× the build time. Going on to 256 added only 0.4 points for another 1.7×. Dropping to 32 saved build time but capped recall at 0.988 even with ef_search = 400.
Effect of m on the recall/latency curve (ef_construction = 64)
Points are ef_search 10 → 400. Higher m lifts the recall ceiling at the same latency.
- m = 8
- m = 16
- m = 32
| m | ef_search | Recall@10 | p50 |
|---|---|---|---|
| 8 | 10 | 0.677 | 1.1 ms |
| 8 | 20 | 0.811 | 1.6 ms |
| 8 | 40 | 0.896 | 2.1 ms |
| 8 | 64 | 0.928 | 2.8 ms |
| 8 | 100 | 0.953 | 3.8 ms |
| 8 | 200 | 0.976 | 5.8 ms |
| 8 | 400 | 0.987 | 10 ms |
| 16 | 10 | 0.815 | 1.4 ms |
| 16 | 20 | 0.897 | 1.9 ms |
| 16 | 40 | 0.947 | 2.9 ms |
| 16 | 64 | 0.970 | 3.9 ms |
| 16 | 100 | 0.983 | 5.3 ms |
| 16 | 200 | 0.990 | 9.1 ms |
| 16 | 400 | 0.995 | 16 ms |
| 32 | 10 | 0.891 | 1.8 ms |
| 32 | 20 | 0.946 | 2.6 ms |
| 32 | 40 | 0.975 | 3.9 ms |
| 32 | 64 | 0.986 | 5.4 ms |
| 32 | 100 | 0.991 | 7.2 ms |
| 32 | 200 | 0.995 | 13 ms |
| 32 | 400 | 0.997 | 23 ms |
m: m = 32 reached 0.975 at ef_search = 40 versus 0.947 for the default, but each search step checks twice as many links, so per-millisecond the advantage is small, and the build took 1.6× longer. m = 8 built fastest but topped out at 0.987.
HNSW build time by m and ef_construction
100,000 × 1,536 dims, maintenance_work_mem 4 GB. Single-process builds (no parallel workers launched — see the HNSW guide).
| m | ef_construction | Build time | Index size |
|---|---|---|---|
| 8 | 64 | 2.0 min | 781 MB |
| 16 | 32 | 2.0 min | 781 MB |
| 16 | 64 | 3.4 min | 781 MB |
| 32 | 64 | 5.6 min | 781 MB |
| 16 | 128 | 5.9 min | 781 MB |
| 16 | 256 | 10.0 min | 781 MB |
Index size didn't change with m at all for these vectors — 781 MB for m 8, 16 and 32 alike. A 1,536-dim element (6.2 KB) plus its neighbour list fills an 8 KB page by itself, so the extra links fit in space that would otherwise be wasted. For smaller vectors m does add size; the storage calculator models it.
Our recommendation for 1,536-dim embeddings: keep m = 16. Use ef_construction = 64 (default) for fast builds, or 128 if you need ≥ 99% recall and can afford 1.7× the build time. Tune recall with ef_search.
Build time and memory#
Most "pgvector is slow" reports are slow builds. Same 100,000 vectors, same index, only the setup changed:
HNSW build time: memory, parallel workers and load order
Same 100,000 × 1,536-dim vectors, same index (m 16, ef_construction 64). Workers launched were counted from pg_stat_activity.
| Setup | Build time | Parallel workers |
|---|---|---|
| Index first, then load 100K rows | 13.8 min | 0 workers (row-by-row inserts) |
| 64 MB maintenance_work_mem (default) | 10.4 min | 0 workers launched |
| 256 MB maintenance_work_mem | 8.3 min | 0 workers launched |
| 4 GB, max_parallel_maintenance_workers = 3 | 3.4 min | 0 workers launched |
| 4 GB + ALTER TABLE SET (parallel_workers = 1) | 1.8 min | 1 worker launched |
| 4 GB + ALTER TABLE SET (parallel_workers = 3) | 56 s | 3 workers launched |
Three things decide build time, and the third one surprised us.
1. maintenance_work_mem must hold the graph#
With PostgreSQL's 64 MB default, pgvector logged hnsw graph no longer fits into maintenance_work_mem after 9753 tuples and the build took 10.4 min instead of 3.4 min — 3.1× slower. 256 MB was barely better (8.3 min). Both notices imply the same ~6,881 bytes of memory per 1,536-dim element, so budget about 6.7 KB × rows. Set it for the session only:
SET maintenance_work_mem = '4GB';2. Your parallel workers may never start#
The standard advice is to raise max_parallel_maintenance_workers. We did — and PostgreSQL launched zero workers: 3.4 min with the setting at 3, 3.4 min with it at 0. Counting processes in pg_stat_activity during the build confirmed it.
The cause is TOAST. A 1,536-dim vector is 6 KB, so Postgres stores it out of line; our 100,000-row table had 795 MB of vectors in TOAST but a main heap of only 5.0 MB. PostgreSQL decides how many workers a CREATE INDEX gets from the size of the main heap, and below min_parallel_table_scan_size (8 MB) the answer is none. The fix is one line:
ALTER TABLE items SET (parallel_workers = 3);
SET max_parallel_maintenance_workers = 3;
SET maintenance_work_mem = '4GB';
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);| Setup (4 GB, 4 vCPUs) | Workers launched | Build time | Speed-up |
|---|---|---|---|
max_parallel_maintenance_workers = 3 only | 0 | 3.4 min | — |
+ parallel_workers = 1 on the table | 1 | 1.8 min | 1.9× |
+ parallel_workers = 3 on the table | 3 | 56 s | 3.6× |
The heap grows with row count, so bigger tables cross the 8 MB line on their own. To check, we rebuilt at 250,000 and 500,000 rows and counted workers again: with heaps of 13 MB and 25 MB, PostgreSQL launched all 3 workers without any table option (2.6 min and 5.1 min), and setting parallel_workers = 3 changed nothing (2.6 min and 5.4 min). At 52 bytes of heap per row, the cut-off for 1,536-dim vectors is roughly 160,000 rows — so the trap catches small and medium tables, and a table that grows past it suddenly builds several times faster. Setting parallel_workers explicitly is harmless at any size. In Docker, parallel builds also need --shm-size at least as large as maintenance_work_mem (why).
Every other build time on this site — the m / ef_construction comparison, halfvec, IVFFlat — was measured the default way, without the table option, so those builds ran as a single process at 100K rows. Compare them with each other, and expect roughly 3.6× less with workers on 4 cores.
3. Load first, index second#
Creating the index on an empty table and then loading is the slowest path: every row is inserted into the graph one at a time, with no parallelism. Loading 100,000 rows into an indexed table took 13.8 min; loading (65 s) then building (3.6 min) took 4.7 min. For ongoing writes the per-row cost is what matters: a single-row INSERT into the indexed 100K table took 10 ms versus 0.78 ms without the index.
Watching a build#
demo=# SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%",
demo-# tuples_done, tuples_total
demo-# FROM pg_stat_progress_create_index;
phase | % | tuples_done | tuples_total
----------------+------+-------------+--------------
building index | 39.6 | 39634 | 0
(1 row)Run it from another session. Use CREATE INDEX CONCURRENTLY on a live table so writes aren't blocked — it takes longer, and a failure leaves an invalid index to drop.
How it scales#
We repeated the default build at 250,000 and 500,000 rows, with the same 1,000 queries:
HNSW recall as the table grows (m 16, ef_construction 64)
Same 1,000 queries against 100K, 250K and 500K rows. Recall drops slowly with size at a fixed ef_search.
- ef_search 40
- ef_search 100
- ef_search 200
| Rows | ef_search | Recall@10 | p50 | Build time | Index size |
|---|---|---|---|---|---|
| 100,000 | 40 | 0.948 | 2.8 ms | 3.3 min | 781 MB |
| 100,000 | 100 | 0.982 | 5.4 ms | 3.3 min | 781 MB |
| 100,000 | 200 | 0.990 | 8.9 ms | 3.3 min | 781 MB |
| 250,000 | 40 | 0.938 | 3.1 ms | 2.6 min | 1.91 GB |
| 250,000 | 100 | 0.974 | 5.7 ms | 2.6 min | 1.91 GB |
| 250,000 | 200 | 0.986 | 9.7 ms | 2.6 min | 1.91 GB |
| 500,000 | 40 | 0.931 | 4.1 ms | 5.6 min | 3.81 GB |
| 500,000 | 100 | 0.968 | 6.2 ms | 5.6 min | 3.81 GB |
| 500,000 | 200 | 0.982 | 11 ms | 5.6 min | 3.81 GB |
Exact scan vs HNSW: median query latency
Exact = no index, sequential scan (at 100K rows Postgres planned no parallel workers — the same TOAST effect as builds). HNSW at ef_search 40.
- Exact scan
- HNSW
| Rows | Exact p50 | HNSW p50 (ef 40) | Speed-up |
|---|---|---|---|
| 100,000 | 447 ms | 2.8 ms | 160× |
| 250,000 | 680 ms | 3.1 ms | 217× |
| 500,000 | 1016 ms | 4.1 ms | 246× |
| Rows | Build | Index size | Recall@10 (ef 40) | p50 (ef 40) | Exact scan p50 |
|---|---|---|---|---|---|
| 100,000 | 3.3 min | 781 MB | 0.948 | 2.8 ms | 447 ms |
| 250,000 | 2.6 min | 1.91 GB | 0.938 | 3.1 ms | 680 ms |
| 500,000 | 5.6 min | 3.81 GB | 0.931 | 4.1 ms | 1016 ms |
Index size grew exactly with row count (one 8 KB page per 1,536-dim row). Build times are not comparable across sizes: the 100K build ran as a single process, while the 250K and 500K builds got 3 parallel workers automatically (see above). With 3 workers at every size, the build took 56 s, 2.6 min and 5.4 min for 100K, 250K and 500K rows — slightly worse than linear in row count. Median query latency grew far slower than the table, from 2.8 ms to 4.1 ms, while exact search went from 447 ms to 1016 ms. Recall at a fixed ef_search slips gently as the table grows (0.948 → 0.931 at 40), so re-check recall after big data growth.
Inserts, updates, deletes and vacuum#
- Inserts go into the graph immediately; no rebuild is ever required.
- Deletes and updates leave dead elements that
VACUUMmust repair, which for HNSW means reconnecting their neighbours — vacuum can be slow on heavily-updated tables. AREINDEX INDEX CONCURRENTLYbefore vacuuming can be faster than repairing in place. - Run 0.8.3 or later. Earlier versions had a bug that could corrupt HNSW indexes during vacuum (check your version).
Checklist#
- Load data first, then
CREATE INDEX. - Before building:
SET maintenance_work_memto about 7 KB × rows (for 1,536 dims), andALTER TABLE … SET (parallel_workers = N)so workers actually start. - Match the operator class to your query's operator.
- Measure recall with real queries (query), then tune
hnsw.ef_search. - Keep the index in memory: size
shared_buffersand RAM for it. - Filtering? Turn on iterative scans.
Frequently asked questions
What does ef_search do in pgvector?
hnsw.ef_search is the size of the candidate list kept while searching the graph's bottom layer (default 40). Larger values visit more of the graph: on our 100K benchmark, raising it from 40 to 100 lifted recall@10 from 0.947 to 0.983 and median latency from 2.9 ms to 5.3 ms. It can be set per session or per transaction and must be at least your LIMIT.
Can I create an HNSW index on an empty table?
Yes. Unlike IVFFlat, HNSW has no training step, so it can be created first and filled by inserts. It's much slower that way, though: inserting our 100,000 rows into an already-indexed table took 13.8 min versus 4.5 min to load first and build afterwards.
How much memory does an HNSW index need?
On disk, about one 8 KB page per row for 1,536-dim vector embeddings (781 MB for 100K rows). To build quickly, maintenance_work_mem must hold the whole graph; to query quickly, the index should stay in RAM. The storage calculator estimates both.
Does HNSW support all distance functions?
HNSW supports L2, inner product, cosine and L1 for vector, halfvec and sparsevec, plus Hamming and Jaccard for bit. Use the operator class that matches the operator in your ORDER BY.