
1. Why an index can cause a retrieval miss
You add an HNSW index to a pgvector table, retrieval gets faster, and a week later a question that used to work returns the wrong chunks. The usual suspects are chunking and the embedding model. A third suspect is the index itself. The pgvector README states that without an index pgvector performs exact nearest neighbour search with perfect recall, and that an approximate index trades some recall for speed, so the same query can return different rows once the index exists.
The Retrieval and RAG for Developers hub names that trade-off. This guide is the measurement procedure: you compute the exact top 10 for a set of queries, run the same queries through the index at several settings, and record recall@10 next to query time. The result is a table you can defend, instead of a default you inherited.
All defaults and behaviour below come from the pgvector README for version 0.8.7, released 2026-10-01 according to the pgvector changelog, checked on 2026-10-17. Check the README of the version you actually run.
2. Know the four settings you are tuning
HNSW has two build-time options and two query-time settings that matter here. Build options are fixed when the index is created; query settings can change per transaction.
| Setting | Default (README 0.8.7) | What a higher value does | Scope |
|---|---|---|---|
m | 16 | More connections per graph layer | Index build |
ef_construction | 64 | Better recall, slower build and inserts | Index build |
hnsw.ef_search | 40 | Better recall, slower queries | Session or SET LOCAL |
hnsw.max_scan_tuples | 20,000 | Lets iterative scans visit more tuples | Session or SET LOCAL |
The README says the build options have a significant impact on build time and recommends the defaults unless you see low recall. That gives you an order of work: measure at the defaults first, raise hnsw.ef_search second, and rebuild with larger m or ef_construction only when the query setting cannot reach your target at an acceptable latency.
3. Save and run the fixture
The fixture below needs PostgreSQL with pgvector 0.8.0 or later, because iterative index scans arrived in 0.8.0. It creates a schema inside a transaction and ends with ROLLBACK, so nothing persists. Run it in a disposable database, not in production. Save it as hnsw-fixture.sql and run:
psql -X --dbname=postgres --set=ON_ERROR_STOP=1 --file=hnsw-fixture.sql
It generates 10,000 synthetic 32-dimensional vectors in ten categories and 50 query vectors, computes exact top 10 before any vector index exists, then builds HNSW twice (m 32 with ef_construction 128, then the defaults) and sweeps hnsw.ef_search over 40, 100 and 200. A second block runs a filtered query in all three iterative scan modes. A third block runs EXPLAIN (ANALYZE, BUFFERS) and fails if the plan does not use the HNSW index. Save the full output, including the two version lines at the top.
\set ON_ERROR_STOP on
\pset pager off
SELECT version();
BEGIN;
CREATE EXTENSION IF NOT EXISTS vector;
SELECT extversion AS pgvector_version FROM pg_extension WHERE extname = 'vector';
CREATE SCHEMA hnsw_fixture;
SET LOCAL search_path = hnsw_fixture, public;
SELECT setseed(0.42);
CREATE TABLE items (
id integer PRIMARY KEY,
category integer NOT NULL,
embedding vector(32) NOT NULL
);
INSERT INTO items
SELECT g, g % 10,
(SELECT array_agg(random()) FROM generate_series(1, 32) d WHERE g > 0)::vector(32)
FROM generate_series(1, 10000) g;
CREATE TABLE queries (id integer PRIMARY KEY, embedding vector(32) NOT NULL);
INSERT INTO queries
SELECT g, (SELECT array_agg(random()) FROM generate_series(1, 32) d WHERE g > 0)::vector(32)
FROM generate_series(1, 50) g;
ANALYZE items;
ANALYZE queries;
-- Ground truth: exact search, computed before any vector index exists.
CREATE TABLE truth AS
SELECT q.id AS qid, n.id AS iid
FROM queries q CROSS JOIN LATERAL (
SELECT id FROM items ORDER BY embedding <=> q.embedding LIMIT 10
) n;
CREATE TABLE truth_filtered AS
SELECT q.id AS qid, n.id AS iid
FROM queries q CROSS JOIN LATERAL (
SELECT id FROM items WHERE category = 3 ORDER BY embedding <=> q.embedding LIMIT 10
) n;
DO $$ BEGIN
IF (SELECT count(*) FROM truth) <> 500 OR (SELECT count(*) FROM truth_filtered) <> 500
THEN RAISE EXCEPTION 'exact ground truth is incomplete'; END IF;
END $$;
CREATE TABLE approx (qid integer, iid integer);
CREATE TABLE results (
test text, m integer, ef_construction integer, ef_search integer,
iterative_scan text, build_ms numeric, ms_per_query numeric,
avg_rows numeric, recall_at_10 numeric
);
SET LOCAL enable_seqscan = off;
-- Unfiltered sweep: two build settings, three search settings.
DO $$
DECLARE cfg record; ef integer; t0 timestamptz; b_ms numeric; q_ms numeric;
BEGIN
FOR cfg IN SELECT * FROM (VALUES (32, 128), (16, 64)) v(m, efc) LOOP
DROP INDEX IF EXISTS items_embedding_hnsw;
t0 := clock_timestamp();
EXECUTE format('CREATE INDEX items_embedding_hnsw ON items USING hnsw (embedding vector_cosine_ops) WITH (m = %s, ef_construction = %s)', cfg.m, cfg.efc);
b_ms := extract(epoch FROM clock_timestamp() - t0) * 1000;
FOREACH ef IN ARRAY ARRAY[40, 100, 200] LOOP
PERFORM set_config('hnsw.ef_search', ef::text, true);
TRUNCATE approx;
t0 := clock_timestamp();
INSERT INTO approx
SELECT q.id, n.id FROM queries q CROSS JOIN LATERAL (
SELECT id FROM items ORDER BY embedding <=> q.embedding LIMIT 10
) n;
q_ms := extract(epoch FROM clock_timestamp() - t0) * 1000 / 50;
INSERT INTO results
SELECT 'unfiltered', cfg.m, cfg.efc, ef, 'off', round(b_ms), round(q_ms, 3),
(SELECT count(*) FROM approx) / 50.0,
(SELECT count(*) FROM approx a JOIN truth t USING (qid, iid)) / 500.0;
END LOOP;
END LOOP;
END $$;
-- Filtered sweep on the default build (m 16, ef_construction 64), ef_search 40.
DO $$
DECLARE mode text; t0 timestamptz; q_ms numeric;
BEGIN
PERFORM set_config('hnsw.ef_search', '40', true);
FOREACH mode IN ARRAY ARRAY['off', 'strict_order', 'relaxed_order'] LOOP
PERFORM set_config('hnsw.iterative_scan', mode, true);
TRUNCATE approx;
t0 := clock_timestamp();
INSERT INTO approx
SELECT q.id, n.id FROM queries q CROSS JOIN LATERAL (
SELECT id FROM items WHERE category = 3 ORDER BY embedding <=> q.embedding LIMIT 10
) n;
q_ms := extract(epoch FROM clock_timestamp() - t0) * 1000 / 50;
INSERT INTO results
SELECT 'category = 3', 16, 64, 40, mode, NULL, round(q_ms, 3),
(SELECT count(*) FROM approx) / 50.0,
(SELECT count(*) FROM approx a JOIN truth_filtered t USING (qid, iid)) / 500.0;
END LOOP;
PERFORM set_config('hnsw.iterative_scan', 'off', true);
END $$;
-- Plan check: the approximate query must use the HNSW index.
DO $$
DECLARE line text; uses_index boolean := false;
BEGIN
PERFORM set_config('hnsw.ef_search', '40', true);
FOR line IN EXECUTE 'EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM items ORDER BY embedding <=> (SELECT embedding FROM queries WHERE id = 1) LIMIT 10' LOOP
RAISE NOTICE '%', line;
IF line LIKE '%Index Scan using items_embedding_hnsw%' THEN uses_index := true; END IF;
END LOOP;
IF NOT uses_index THEN RAISE EXCEPTION 'plan does not use items_embedding_hnsw'; END IF;
END $$;
SELECT * FROM results ORDER BY test DESC, m DESC, ef_search, iterative_scan;
ROLLBACK;
\echo DONE: exact truth, unfiltered sweep, filtered sweep, plan check
4. Read recall@10 next to query time
The final SELECT prints one row per run. Recall@10 is the overlap between the index's 10 results and the exact 10, divided by 10 and averaged over the 50 queries. A value of 1 means the index returned exactly the exact-search set. Order is ignored, which matters later for relaxed iterative scans.
| Column | Meaning | How to judge it |
|---|---|---|
recall_at_10 | Average share of exact top 10 found | Compare with the target you set before running |
ms_per_query | Wall time for 50 lateral queries plus the insert, divided by 50 | Relative within one run; not a production latency |
build_ms | Time for CREATE INDEX | Grows with m and ef_construction |
avg_rows | Rows returned per query | Must be 10; lower means the query is starving |
Your answer key is a decision rule, written down before you look: for example, “the smallest hnsw.ef_search whose recall@10 meets my target on the default build”. If 200 still misses the target, compare the m 32 rows; if they reach it, the rebuild is justified and you know its build-time cost. If neither does, the problem is probably not the index parameters, and you go back to chunking, embeddings or the threshold logic in Cosine Similarity Is Not an Answer Confidence Score.
Uniform random vectors are a hard and unrealistic case, and an HNSW graph is not guaranteed to be identical between builds, so treat the synthetic numbers as a rehearsal. The execution record at the end shows one recorded run for comparison. The numbers that decide anything come from section 8, on your own data.
5. Change ef_search first, rebuild second
hnsw.ef_search is cheap to change. The README shows SET LOCAL inside a transaction to apply it to a single query, which lets you give an expensive, high-recall setting to one endpoint and keep 40 for the rest:
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;
A rebuild is expensive. The README notes that builds run significantly faster when the graph fits into maintenance_work_mem and prints a NOTICE when it no longer fits, and it recommends CREATE INDEX CONCURRENTLY in production to avoid blocking writes. The fixture uses plain CREATE INDEX because CONCURRENTLY cannot run inside a transaction block. If a model change forces the rebuild, that procedure belongs to Embedding Model Migration, not here.
6. Verify the plan with EXPLAIN ANALYZE
A recall of 1 at every setting can mean the index was never used. The README recommends EXPLAIN (ANALYZE, BUFFERS) for performance debugging, and the fixture's third block prints that plan and raises an error unless a line contains Index Scan using items_embedding_hnsw. The fixture sets enable_seqscan = off for the approximate runs, the README's way to encourage index use; on a table this small the planner might otherwise prefer a scan, which the README notes can be faster for small tables.
The README also lists the condition for index use: ORDER BY a distance operator in ascending order, plus LIMIT. Ordering by 1 - (embedding <=> $1) DESC to sort by similarity silently falls back to exact search. The operator class must match the operator: the fixture builds vector_cosine_ops because it queries with <=>. In production, run the plan check against the real query text your application sends.
7. Fix filtered queries that return too few rows
Many RAG queries carry a filter: a source, a language, a document status. The README states that with approximate indexes filtering is applied after the index scan, and gives the arithmetic: if a condition matches 10% of rows, HNSW with the default hnsw.ef_search of 40 returns only 4 matching rows on average. The fixture's filter, category = 3, matches 10% of its rows, so the off row is where you look for an avg_rows below 10. In the recorded run at the end of this guide, that row averaged 4.26 rows per query.
Iterative index scans, available since 0.8.0, keep scanning the index until enough rows are found or hnsw.max_scan_tuples is reached. That limit is approximate and does not apply to the initial scan, so a very selective filter can still come back short. The README describes two modes:
strict_orderreturns results in exact distance order.relaxed_orderallows slightly out-of-order results and gives better recall; wrap it in a materialized CTE and re-sort when order matters.
SET hnsw.iterative_scan = relaxed_order;
WITH relaxed_results AS MATERIALIZED (
SELECT id, embedding <=> $1 AS distance
FROM chunks WHERE source_id = $2
ORDER BY distance LIMIT 10
) SELECT * FROM relaxed_results ORDER BY distance + 0;
The README notes that + 0 is needed for Postgres 17 and later. If raising hnsw.max_scan_tuples does not improve recall, it suggests raising hnsw.scan_mem_multiplier (default 1). For filters with few distinct values, a partial index is an alternative; for many values, partitioning. Dead tuples can also reduce the row count, according to the README's troubleshooting section.
Do not confuse this with authorization. An iterative scan changes how many index entries are visited; it decides nothing about who may see a row. Keep tenant and permission predicates in the query or in a policy, as in PostgreSQL Row-Level Security for Multi-Tenant RAG. The README adds a performance reason to separate tenants: a shared approximate index lets one tenant's vectors affect recall and speed for others, and it suggests list partitioning or separate tables.
8. Repeat the measurement on your own queries
Replace the synthetic tables with a copy of your chunk table and 50 to 200 real questions, embedded with your production model. On a production table you cannot compute truth before the index exists, so use the README's monitoring method: run the exact side inside a transaction with SET LOCAL enable_indexscan = off, and the approximate side with your production settings and filters.
Record one row per configuration: PostgreSQL and pgvector versions, row count, m, ef_construction, hnsw.ef_search, iterative scan mode, filter, recall@10, rows returned and query time from EXPLAIN ANALYZE. Re-run it when the table grows substantially, when you change filters, and after any reindex. A retrieval miss is then something you can attribute to index settings, or rule them out.
Execution record
On 2026-10-17 the complete fixture above was run once on Windows 11 against PostgreSQL 18.6 on x86_64-windows, compiled by msvc-19.44.35229, 64-bit, with pgvector 0.8.7 built from source. The saved output shows 500 exact ground-truth rows for both the unfiltered and the filtered query, all four DO blocks completed, and the plan check printed Index Scan using items_embedding_hnsw on items with 10 rows returned. The run ended with ROLLBACK and the final DONE line, and psql exited with code 0. The only notice before the sweep was the expected message that the index did not yet exist when DROP INDEX IF EXISTS ran for the first build.
| Test | m / ef_construction | ef_search | Iterative scan | Build (ms) | ms per query | Rows per query | Recall@10 |
|---|---|---|---|---|---|---|---|
| Unfiltered | 32 / 128 | 40 | off | 8,728 | 0.830 | 10 | 0.992 |
| Unfiltered | 32 / 128 | 100 | off | 8,728 | 1.326 | 10 | 1.000 |
| Unfiltered | 32 / 128 | 200 | off | 8,728 | 2.209 | 10 | 1.000 |
| Unfiltered | 16 / 64 | 40 | off | 3,032 | 0.502 | 10 | 0.948 |
| Unfiltered | 16 / 64 | 100 | off | 3,032 | 0.825 | 10 | 0.994 |
| Unfiltered | 16 / 64 | 200 | off | 3,032 | 1.374 | 10 | 1.000 |
| category = 3 | 16 / 64 | 40 | off | – | 0.462 | 4.26 | 0.426 |
| category = 3 | 16 / 64 | 40 | relaxed_order | – | 1.405 | 10 | 0.972 |
| category = 3 | 16 / 64 | 40 | strict_order | – | 1.350 | 10 | 0.914 |
How to read this run: with the default build, raising hnsw.ef_search from 40 to 100 moved recall@10 from 0.948 to 0.994 while the fixture's time per query went from 0.502 to 0.825 ms. The m 32 build reached 0.992 at 40 but took 8,728 ms to build against 3,032 ms for the defaults. Without an iterative scan, the 10% filter returned 4.26 rows per query on average, in line with the README's example of about 4; both iterative modes filled all 10 slots. In this single run relaxed_order scored 0.972 and strict_order 0.914, but one run on one data set does not establish a general ranking.
These are illustrative numbers from 50 synthetic queries over 10,000 uniform random 32-dimensional vectors on one Windows machine, not a benchmark. The millisecond values include inserting the results into a table, they vary between runs and machines, and they say nothing about your production latency. Use the table to check that your own run behaves sensibly, then decide from your own data as in section 8.
Sources checked 2026-10-17
- pgvector README (version 0.8.7) — exact versus approximate search, HNSW options and defaults,
hnsw.ef_search, build time,EXPLAIN, index-use conditions, recall monitoring, filtering, iterative scans and their limits, multitenancy. - pgvector CHANGELOG — 0.8.7 release date and iterative scans added in 0.8.0.