1. Home
  2. HNSW
Guide · indexing

HNSW indexes in pgvector

HNSW is the index most pgvector users should start with. Below: an interactive graph you can search, what each parameter costs and buys on real OpenAI embeddings, and how to build it in minutes instead of hours.

Tested on pgvector 0.8.7 · PostgreSQL 18.6Updated 2026-10-2713 min read

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.

greedy pathcandidatesquery

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#

psqlpsql · PostgreSQL 18.6 · pgvector 0.8.7
demo=# CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);
CREATE INDEX

Choose 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).

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_searchRecall@10p50 latencyp95 latencyQPS (1 client)
100.8151.4 ms2.5 ms625
200.8971.9 ms3.2 ms482
400.9472.9 ms5.3 ms304
640.9703.9 ms5.9 ms246
1000.9835.3 ms8.4 ms181
2000.9909.1 ms14 ms106
4000.99516 ms24 ms61
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data

Query latency vs ef_search

Single client, warm cache. Median and 95th percentile of 1,000 queries.

  • p50
  • p95
ef_searchp50p95p99
101.4 ms2.5 ms4.3 ms
201.9 ms3.2 ms5.3 ms
402.9 ms5.3 ms12 ms
643.9 ms5.9 ms9.9 ms
1005.3 ms8.4 ms12 ms
2009.1 ms14 ms23 ms
40016 ms24 ms32 ms
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data

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@10ef_search at 100K rowsef_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:

psqlpsql · PostgreSQL 18.6 · pgvector 0.8.7
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_constructionef_searchRecall@10p50
32100.7821.4 ms
32200.8741.9 ms
32400.9252.7 ms
32640.9493.7 ms
321000.9664.9 ms
322000.9798.1 ms
324000.98814 ms
64100.8151.4 ms
64200.8971.9 ms
64400.9472.9 ms
64640.9703.9 ms
641000.9835.3 ms
642000.9909.1 ms
644000.99516 ms
128100.8191.4 ms
128200.9062.0 ms
128400.9602.9 ms
128640.9794.0 ms
1281000.9895.4 ms
1282000.9969.4 ms
1284000.99817 ms
256100.8181.5 ms
256200.9102.0 ms
256400.9643.0 ms
256640.9834.3 ms
2561000.9915.9 ms
2562000.99710 ms
2564000.99819 ms
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data

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
mef_searchRecall@10p50
8100.6771.1 ms
8200.8111.6 ms
8400.8962.1 ms
8640.9282.8 ms
81000.9533.8 ms
82000.9765.8 ms
84000.98710 ms
16100.8151.4 ms
16200.8971.9 ms
16400.9472.9 ms
16640.9703.9 ms
161000.9835.3 ms
162000.9909.1 ms
164000.99516 ms
32100.8911.8 ms
32200.9462.6 ms
32400.9753.9 ms
32640.9865.4 ms
321000.9917.2 ms
322000.99513 ms
324000.99723 ms
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data

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).

mef_constructionBuild timeIndex size
8642.0 min781 MB
16322.0 min781 MB
16643.4 min781 MB
32645.6 min781 MB
161285.9 min781 MB
1625610.0 min781 MB
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data

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.

SetupBuild timeParallel workers
Index first, then load 100K rows13.8 min0 workers (row-by-row inserts)
64 MB maintenance_work_mem (default)10.4 min0 workers launched
256 MB maintenance_work_mem8.3 min0 workers launched
4 GB, max_parallel_maintenance_workers = 33.4 min0 workers launched
4 GB + ALTER TABLE SET (parallel_workers = 1)1.8 min1 worker launched
4 GB + ALTER TABLE SET (parallel_workers = 3)56 s3 workers launched
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data

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:

sql
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:

sqlTell Postgres how many workers the table deserves
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 launchedBuild timeSpeed-up
max_parallel_maintenance_workers = 3 only03.4 min—
+ parallel_workers = 1 on the table11.8 min1.9×
+ parallel_workers = 3 on the table356 s3.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#

psqlpsql · PostgreSQL 18.6 · pgvector 0.8.7
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
Rowsef_searchRecall@10p50Build timeIndex size
100,000400.9482.8 ms3.3 min781 MB
100,0001000.9825.4 ms3.3 min781 MB
100,0002000.9908.9 ms3.3 min781 MB
250,000400.9383.1 ms2.6 min1.91 GB
250,0001000.9745.7 ms2.6 min1.91 GB
250,0002000.9869.7 ms2.6 min1.91 GB
500,000400.9314.1 ms5.6 min3.81 GB
500,0001000.9686.2 ms5.6 min3.81 GB
500,0002000.98211 ms5.6 min3.81 GB
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data

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
RowsExact p50HNSW p50 (ef 40)Speed-up
100,000447 ms2.8 ms160×
250,000680 ms3.1 ms217×
500,0001016 ms4.1 ms246×
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data
RowsBuildIndex sizeRecall@10 (ef 40)p50 (ef 40)Exact scan p50
100,0003.3 min781 MB0.9482.8 ms447 ms
250,0002.6 min1.91 GB0.9383.1 ms680 ms
500,0005.6 min3.81 GB0.9314.1 ms1016 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 VACUUM must repair, which for HNSW means reconnecting their neighbours — vacuum can be slow on heavily-updated tables. A REINDEX INDEX CONCURRENTLY before 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#

  1. Load data first, then CREATE INDEX.
  2. Before building: SET maintenance_work_mem to about 7 KB × rows (for 1,536 dims), and ALTER TABLE … SET (parallel_workers = N) so workers actually start.
  3. Match the operator class to your query's operator.
  4. Measure recall with real queries (query), then tune hnsw.ef_search.
  5. Keep the index in memory: size shared_buffers and RAM for it.
  6. 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.