omiid
homenotebookai usage
pgvector for TypeScript·Part 4 of 8

HNSW vs IVFFlat: choosing and building your pgvector index

August 14, 2026

Up to a few hundred thousand rows, an exact scan over a vector column is genuinely fast enough. You don't need an ANN index. The query plan is a sequential scan, every row gets compared, the answer is exactly right.

Past that point, you need an index. Which one is a real decision, not a shrug. HNSW and IVFFlat have different build times, different memory profiles, different recall behavior, and different operational stories. Pick wrong and you'll either rebuild every week or live with results that quietly miss.

This post is the choice. Part 5 is what you do with the knobs after.

The two options, in one sentence each

HNSW builds a layered graph and walks it at query time. Slower to build, fast to query, high recall at default settings, holds the whole graph in memory.

IVFFlat divides the vector space into cells and probes a subset at query time. Faster to build, cheaper memory, recall is more sensitive to the data shape and the tuning.

Most production pgvector deployments end up on HNSW. That's not a fashion trend, it's because the speed-recall tradeoff is genuinely better for most workloads and the operational pain (slow build, big memory footprint) is predictable. The community rule of thumb that holds up in production: under roughly 10M vectors, standard HNSW is fine. Above that you start having opinions about quantization and graph parameters. IVFFlat is the better choice when you have either very strict build times, very tight memory, or a query pattern that wants large result sets. I wrote a shorter overview comparing the two that's worth a skim before this post if you've never used either.

The animation is worth a longer look before you commit:

HNSWgraph walk · ef_search 40
IVFFlatcells · probes 2
TLTRBLBR
HNSW

Walks the graph from an entry point to the query's neighbourhood. Slower to build, faster to query, recall scales smoothly with ef_search.

IVFFlat

Picks the right cells, exhaustively checks them. Faster to build, cheaper memory, recall is more sensitive to probe count and data shape.

step 1/6 · HNSW visited 1/18 · IVFFlat probed 0/2 cells

Left walks a graph. Right exhaustively checks the cells it picked. The target is the same point.

What you're watching matters. HNSW walks a small number of edges to reach a tight neighborhood; IVFFlat picks the right cells and then exhaustively checks them. The shapes of "wrong" are different. HNSW with too-low ef_search misses neighbors that live on the other side of the graph. IVFFlat with too-few probes misses neighbors that fell into a cell you didn't check.

The decision, made on real constraints

I'll skip the "it depends" answer. Here's how I'd actually pick.

Pick HNSW if any of these are true. Your write rate is moderate (under a few hundred inserts per second sustained). Your latency budget is under 50ms p95. Your recall target is above 90%. You can afford a one-time slow build. Memory headroom on the box is at least 1.5x the index size.

Pick IVFFlat if any of these are true. Your build budget is small and you re-index often. Memory is tight (you can't fit the HNSW graph in RAM comfortably). You query for large result sets (500+ rows). You're willing to spend more time tuning probes per query type than tuning ef_search.

The default for this series is HNSW. The docs assistant is read-heavy, the latency budget is interactive, and we want the recall headroom for Part 7's evals to be meaningful. Almost everything from here on assumes HNSW unless called out.

Build the index without locking the table

This is the part that bites people. A naive CREATE INDEX takes an ACCESS EXCLUSIVE lock for the whole build. On a real table that means your writes are blocked for however long the build takes. On a 200k-row table, that's a few minutes. On a 5M-row table, it can be hours.

You almost always want CONCURRENTLY. Drizzle Kit will generate a normal CREATE INDEX from the schema; you write the concurrent build by hand:

CREATE INDEX CONCURRENTLY chunks_embedding_hnsw_idx
ON chunks
USING hnsw (embedding vector_cosine_ops);

A few things to know about CONCURRENTLY before you ship it:

1. It can't run inside a transaction. Most migration tools wrap each migration in BEGIN ... COMMIT. You either disable the wrapper for this migration or run the index build outside the migration pipeline.

2. If it fails, the index is left INVALID. Drop it (DROP INDEX CONCURRENTLY chunks_embedding_hnsw_idx;) and try again. An invalid index does nothing for queries and slows down writes.

3. It still scans the whole table. "Concurrently" means "without blocking writes," not "in the background while you do other things." It's the same amount of work, scheduled politely.

Knobs that decide how long the build takes

Two settings matter for build speed, and they're the ones almost everyone leaves at default.

-- Bigger here = faster build at the cost of memory during the build.
SET maintenance_work_mem = '2GB';
 
-- Use multiple workers for the build. Default is often 2.
SET max_parallel_maintenance_workers = 7;

On a 1M-row table with 1024-dim vectors, raising maintenance_work_mem from the default 64MB to 2GB and max_parallel_maintenance_workers from 2 to 7 has cut my build times from an hour-plus to under ten minutes. The numbers will vary on your hardware; the direction won't.

Two HNSW-specific build parameters live on the index itself:

CREATE INDEX CONCURRENTLY chunks_embedding_hnsw_idx
ON chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

m is the max connections per node in the graph (default 16). Higher means a denser graph, which improves recall but inflates the index size and build time. 16 is fine for most workloads; raise to 24 or 32 for high-recall use cases. ef_construction is the candidate-list size during build (default 64). Higher improves the quality of the graph but slows the build. 64 to 200 covers most cases. These are build-time choices and you can't change them without rebuilding, so pick once and move on.

maintenance_work_mem2048MB
parallel workers7
Index build0 / 13 min
Writes against table0 / 13 min
Est. duration
13 min
Elapsed
0.0 min
Lock state
none

CONCURRENTLY doesn't make the build faster, only friendlier: same total work, scheduled politely. The build knobs above are what actually move the wall clock.

est. build: 13 min · elapsed 0 min · writes flowing

Flip CONCURRENTLY, raise mem and workers. The build window changes by orders of magnitude.

Build it on data that exists

A common mistake worth naming: people build the index on an empty table, then ingest. With HNSW that's fine (the index supports incremental inserts well), but you miss the chance to build with parallel workers and maintenance_work_mem cranked, because there's nothing to build from.

The order I'd recommend for the project:

1. Ingest the corpus into the table with no ANN index. Inserts are fast, no index to maintain. 2. Bump maintenance_work_mem and max_parallel_maintenance_workers for the session. 3. Build the index CONCURRENTLY. Walk away. Come back to a query plan that uses it.

For subsequent ingestions (a new doc batch every day, say), let the index maintain itself. The amortized cost per insert with HNSW is small.

Confirm the index is used with EXPLAIN

EXPLAIN answers this directly. After the index is built:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, body
FROM chunks
ORDER BY embedding <=> '[0.01, 0.02, ...]'
LIMIT 10;

Look for an Index Scan using chunks_embedding_hnsw_idx. If you see a Seq Scan instead, the planner thinks a sequential scan is cheaper. Two common reasons: the table is too small (Postgres knows it can scan it faster than it can walk the graph) or the operator in your query doesn't match the operator class on the index. If you built with vector_cosine_ops and query with <-> (L2 distance), the index isn't applicable. We dig much deeper into reading these plans in Part 5.

From the trenches

Don't drop and rebuild "to refresh" the index. HNSW indexes don't degrade with time; they degrade with very high deletion rates because deleted graph nodes still exist until vacuum cleans them up. If you're worried, run VACUUM first. Drop-and-rebuild is an hour you didn't need to spend.

Wrap the build in idle_in_transaction_session_timeout = 0. Long builds against a connection pooler can be killed mid-build by aggressive timeouts. The build then leaves an INVALID index and you start over.

Half-precision vectors are tempting. pgvector supports halfvec for indexes, which halves memory at a small recall cost. For 1024-dim vectors at 5M rows it's a meaningful difference. Don't reach for it on day one. Get the full-precision index running, get the eval working, then measure the recall delta with halfvec on your data before committing.

Build on a replica, then promote. For very large tables, the safest path is to build on a streaming replica (max_parallel_maintenance_workers cranked, no traffic), then either replicate the new index back or promote the replica to primary. This avoids touching production for the hours of the build entirely. We come back to this pattern in Part 8 for re-embedding migrations.

The index is built, but not tuned

You now have an HNSW index built CONCURRENTLY, on a table that already holds data, with build parameters chosen on purpose. You also know the conditions under which IVFFlat would have been the better call.

What you don't control yet is the speed-recall curve at query time. The index will give you reasonable results at default settings, and reasonable is not the goal of this series. Part 5 is the tuning.

pgvector for TypeScriptPart 5 of 8 is coming next.
← Vector search relevance: chunking, metadata, and the 0.81 problemAll 8 parts

Join My Newsletter

Occasional notes on software, tools, and things I learn. No spam.

Unsubscribe anytime.

Continue Reading
  • AI agent architecture patterns that survive production05-18-2026 · Most agent demos are a model in a loop. Production agents need a run store, typed memory layers, idempotent tools, a trace log, and approval gates. Here's the full architecture with Postgres schema and TypeScript patterns.
  • Stop adding prompts. Your agent needs control flow.05-17-2026 · A while-loop calling an LLM keeps doing the wrong thing, and more prompt engineering isn't the fix. Here's the control-flow ladder for AI agent orchestration: bounded loop, workflow, state machine, with TypeScript examples at each level.
  • pgvector vs Pinecone for RAG05-16-2026 · If you already run Postgres, pgvector covers most RAG workloads without Pinecone. A workload-by-workload comparison: HNSW and IVFFlat index types, filtered search, cost at 1M and 10M vectors, and the signals that mean it's time to switch.
  • RAG with Postgres, Drizzle, and pgvector05-15-2026 · You don't need Pinecone. If you already run Postgres, pgvector is a one-extension install away from a full RAG pipeline. Here's the whole thing in TypeScript with Drizzle: schema, ingestion, retrieval, hybrid search, and eval.
  • Building an MCP Server in TypeScript with NestJS05-14-2026 · How to build a NestJS MCP server in TypeScript on top of a real backend: dependency injection, Zod-typed tools, stdio and Streamable HTTP transports, bearer auth, Pino logging, and handlers you can unit test.