PostgreSQL Mastery
PostgreSQL Mastery lessons / Module 43: Vectors and pgvector

HNSW and IVFFlat indexes

Level 9: AI and Vector Search30 min

Exact nearest-neighbour search reads every vector. HNSW and IVFFlat indexes answer approximately and very much faster, trading a little recall for speed. Knowing their parameters, and measuring recall against exact results, is the core skill of running vector search in production.

This lesson is interactive. Run every example on a real PostgreSQL 18 server in your browser, read EXPLAIN ANALYZE plans, and take the quiz.
Open the interactive lesson
You will learn to
  • Build HNSW and IVFFlat indexes with the right operator class
  • Tune ef_search and probes for recall versus speed
  • Measure recall by comparing approximate with exact results

conceptThe concept

HNSW (hierarchical navigable small world) builds a layered graph in which each vector links to its nearest neighbours. Search starts at the top layer and greedily walks toward the query. Parameters: m (links per node, default 16) and ef_construction (candidate list size while building, default 64) at build time; hnsw.ef_search (candidate list while searching, default 40) at query time. Higher values mean better recall, more memory and slower builds.

IVFFlat clusters vectors into lists with k-means at build time, then searches only the ivfflat.probes nearest clusters. It builds faster and uses less memory than HNSW, but must be built after the table has representative data, and recall drops as data drifts from the original clusters.

The operator class must match the query operator: vector_cosine_ops for <=>, vector_l2_ops for <->, vector_ip_ops for <#>.

Recall is the share of the true nearest neighbours that the index returns. Measure it by running the same query with the index disabled and comparing.

whyWhy it matters

An exact scan of 10 million 1536-dimension vectors reads about 60 GB. An HNSW index answers in milliseconds. Without measuring recall, though, you do not know how many of the right answers you are silently missing.

Use caseReal-world scenario

A team indexes 8 million product embeddings with HNSW (m = 16, ef_construction = 128). An offline job samples 1,000 queries each night, compares index results with exact results at ef_search 40 and 100, and alerts when recall at 10 drops below 0.95.

Try itExamples

Run and edit these in the interactive lesson.

Build an HNSW index and see the plan use it
BEGIN;
CREATE TABLE items AS
SELECT g AS id, ARRAY(SELECT random() FROM generate_series(1, 16) WHERE g > 0)::real[]::vector(16) AS v
FROM generate_series(1, 3000) g;
CREATE INDEX items_v_hnsw ON items USING hnsw (v vector_cosine_ops) WITH (m = 16, ef_construction = 64);
ANALYZE items;
EXPLAIN (COSTS OFF)
SELECT id FROM items ORDER BY v <=> (SELECT v FROM items WHERE id = 1) LIMIT 10;
ROLLBACK;
The WHERE g > 0 inside the array subquery makes it run once per row, so every row gets different random values.
Measure recall: approximate against exact
BEGIN;
CREATE TABLE items AS
SELECT g AS id, ARRAY(SELECT random() FROM generate_series(1, 16) WHERE g > 0)::real[]::vector(16) AS v
FROM generate_series(1, 3000) g;
CREATE TEMP TABLE exact AS
SELECT id FROM items ORDER BY v <=> (SELECT v FROM items WHERE id = 1) LIMIT 10;
CREATE INDEX ON items USING hnsw (v vector_cosine_ops);
ANALYZE items;
SET LOCAL hnsw.ef_search = 10;
CREATE TEMP TABLE approx AS
SELECT id FROM items ORDER BY v <=> (SELECT v FROM items WHERE id = 1) LIMIT 10;
SELECT count(*) AS found_of_10 FROM approx JOIN exact USING (id);
ROLLBACK;
The exact list is computed before the index exists. Raise ef_search and run it again to watch recall climb.
IVFFlat and the probes setting
BEGIN;
CREATE TABLE items AS
SELECT g AS id, ARRAY(SELECT random() FROM generate_series(1, 16) WHERE g > 0)::real[]::vector(16) AS v
FROM generate_series(1, 3000) g;
CREATE INDEX items_v_ivf ON items USING ivfflat (v vector_l2_ops) WITH (lists = 50);
ANALYZE items;
SET LOCAL ivfflat.probes = 5;
EXPLAIN (COSTS OFF)
SELECT id FROM items ORDER BY v <-> (SELECT v FROM items WHERE id = 1) LIMIT 10;
ROLLBACK;
A common starting point is lists = rows / 1000 up to 1 million rows and probes = sqrt(lists).
Production build settings
SET maintenance_work_mem = '8GB';          -- keep the graph in memory while building
SET max_parallel_maintenance_workers = 7;   -- parallel HNSW build
CREATE INDEX CONCURRENTLY kb_chunks_embedding_hnsw
  ON kb_chunks USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 128);

-- per query or per transaction
SET LOCAL hnsw.ef_search = 100;

internalsUnder the hood

HNSW search returns at most ef_search candidates, so a LIMIT larger than ef_search cannot be filled from the index alone. A WHERE filter applied after the index scan can discard most of those candidates; pgvector 0.8 added iterative scans (hnsw.iterative_scan) that keep searching until enough rows pass the filter. HNSW builds are much faster when the graph fits in maintenance_work_mem.

Avoid / preferDo this, not that

Avoid
CREATE INDEX ON kb_chunks USING hnsw (embedding vector_l2_ops);
SELECT id FROM kb_chunks ORDER BY embedding <=> $1 LIMIT 5;
Prefer
CREATE INDEX ON kb_chunks USING hnsw (embedding vector_cosine_ops);
SELECT id FROM kb_chunks ORDER BY embedding <=> $1 LIMIT 5;

An index built for L2 cannot serve a cosine query, so the planner falls back to an exact scan. The operator class and the operator must match.

Avoid
CREATE TABLE docs (...);
CREATE INDEX ON docs USING ivfflat (embedding vector_cosine_ops) WITH (lists = 1000);
-- then load 5 million rows
Prefer
-- load the data first, then
CREATE INDEX ON docs USING ivfflat (embedding vector_cosine_ops) WITH (lists = 1000);

IVFFlat picks its cluster centres from the rows present at build time. Built on an empty table, every vector lands in poor clusters and recall collapses.

PitfallsCommon mistakes

  • Never measuring recall, so a tuning change silently returns worse results.
  • Asking for LIMIT 100 with hnsw.ef_search at its default of 40.
  • Building HNSW with a tiny maintenance_work_mem and waiting hours instead of minutes.

LimitsWhen not to use it

Below roughly tens of thousands of vectors, or when queries always filter down to a few thousand rows first (one tenant, one category), an exact scan is fast enough, gives perfect recall and needs no tuning. Add an approximate index when exact search no longer meets latency targets.

SecuritySecurity notes

Index builds on large tables are heavy: run them with CREATE INDEX CONCURRENTLY and resource limits, and do not let application roles create indexes freely. A role that can set maintenance_work_mem very high can exhaust server memory.

ChallengeTry it yourself

Measure recall at 20 for hnsw.ef_search values 10, 40 and 100 on 3,000 random 16-dimension vectors, printing one notice per setting.

Show one solution
One solution
BEGIN;
CREATE TABLE items AS
SELECT g AS id, ARRAY(SELECT random() FROM generate_series(1, 16) WHERE g > 0)::real[]::vector(16) AS v
FROM generate_series(1, 3000) g;
CREATE TEMP TABLE exact AS
SELECT id FROM items ORDER BY v <=> (SELECT v FROM items WHERE id = 1) LIMIT 20;
CREATE INDEX ON items USING hnsw (v vector_cosine_ops);
ANALYZE items;
DO $$
DECLARE ef integer; hits integer;
BEGIN
  FOREACH ef IN ARRAY ARRAY[10, 40, 100] LOOP
    PERFORM set_config('hnsw.ef_search', ef::text, true);
    SELECT count(*) INTO hits
    FROM (SELECT id FROM items ORDER BY v <=> (SELECT v FROM items WHERE id = 1) LIMIT 20) a
    JOIN exact USING (id);
    RAISE NOTICE 'ef_search % found %/20', ef, hits;
  END LOOP;
END $$;
ROLLBACK;
With ef_search 10 the index can return at most 10 rows, so recall at 20 cannot exceed one half.

InterviewQuestions you should be able to answer

Compare HNSW and IVFFlat.
HNSW is a multi-layer proximity graph: better recall-speed trade-off, no training step, can be built on an empty table and updated incrementally, but slower to build and larger in memory. IVFFlat clusters vectors with k-means and searches the nearest probes lists: faster to build and smaller, but needs representative data at build time and loses recall as data drifts. HNSW is the default choice for most workloads today.
How do you measure and tune recall in pgvector?
Take a sample of real queries, compute exact top-k by running them with index scans disabled (or before the index exists), then run them through the index and compute the share of exact results returned. Tune hnsw.ef_search (or ivfflat.probes) per query or transaction until recall meets the target, watching latency. Rebuild with higher m or ef_construction if query-time settings are not enough.

QuizCheck your understanding

4 questions with instant feedback in the interactive lesson; a wrong answer goes into your review deck.

Go deeperOfficial documentation