Runbook 07 · AI stack (vector search)

pgvector HNSW with a WHERE clause: you asked for 10 rows and got 3

A filtered nearest-neighbour query returns fewer rows than the LIMIT, with no error, no warning and no slow query — the app just shows an emptier “references” panel. The cause is two defaults that did not change in 2026: hnsw.ef_search is 40, and hnsw.iterative_scan is off even after you upgrade pgvector. Platform: pgvector 0.8.x on PostgreSQL 13–18. Change: iterative index scans added in pgvector 0.8.0 (2024-10-30), default off; HNSW vacuum fixes in 0.8.3 (2026-06-17) and 0.8.4 (2026-06-30). Detection chapter reviewed: 2026-11-08. The paid chapter is still in re-verification on a live setup; the status notice in the appendix stays until that finishes.

What changed

This one is a default-value trap rather than a breaking announcement. pgvector’s own README states the mechanism without dressing it up: with an approximate index, filtering is applied after the index is scanned. The mitigation — iterative index scans — shipped in 0.8.0 and stayed opt-in. Six things define the window:

  • Post-scan filtering is documented behaviour, not a bug. The README’s own example: a condition matching 10% of rows, with HNSW and the default hnsw.ef_search of 40, leaves “only 4 rows will match on average”. A LIMIT 10 therefore returns about 4 rows, silently.
  • hnsw.ef_search is 40 by default, with a hard maximum of 1000. Both values are in the source, not just the docs: HNSW_DEFAULT_EF_SEARCH 40 and HNSW_MAX_EF_SEARCH 1000. Raising it trades latency for recall; it does not raise the 1000 ceiling.
  • Iterative scans arrived in 0.8.0 (2024-10-30) and default to off. You have to set hnsw.iterative_scan to relaxed_order or strict_order, and the setting is session-scoped. Upgrading the extension changes nothing on its own.
  • Iterative scans have their own stop conditions. hnsw.max_scan_tuples defaults to 20,000 and hnsw.scan_mem_multiplier to 1× work_mem. When the scan stops there, the query is still allowed to return fewer rows than the LIMIT.
  • 2026 is the maintenance window, not the cause. 0.8.3 (2026-06-17) fixed “possible index corruption with HNSW vacuuming”; 0.8.4 (2026-06-30) fixed the hnsw graph not repaired error. 0.8.7 (2026-10-01) is the latest release at this review date, and the unreleased 0.9.0 entry in the changelog drops PostgreSQL 13 and 14.
  • The index has a dimension wall. A vector column can be indexed with HNSW only up to 2,000 dimensions; halfvec raises that to 4,000 and bit to 64,000. A 3,072-dimension embedding fails at CREATE INDEX time, which is at least an honest error — unlike the row shortfall.

Detect: are you affected?

All five checks are read-only and use SQL only. Checks 1 and 2 tell you whether the defaults apply to your session; check 3 is the one that proves the row shortfall; check 4 explains which failure family you are in; check 5 is the cheap sanity check people skip.

Check 1 — extension and server version

SELECT extversion FROM pg_extension WHERE extname = 'vector';
SHOW server_version;
OutputMeaningNext step
extversion < 0.8.0No iterative index scans exist in this build. Only index design (partial index, partitioning) can fix filtered under-return.Plan an upgrade; skip to check 3 to size the problem
0.8.0 – 0.8.2Iterative scans exist with the defaults below, and this range predates both HNSW vacuum fixes (0.8.3 and 0.8.4).Continue, then plan upgrade + REINDEX (failure mode 05)
0.8.3Has the 2026-06-17 HNSW vacuum corruption fix but not the 2026-06-30 hnsw graph not repaired fix.Continue; plan an upgrade to at least 0.8.4
0.8.4 – 0.8.7Current release line at this review date (0.8.7, 2026-10-01). Defaults still apply.Continue to check 2
0.9.0 (unreleased) or newerThe changelog entry drops PostgreSQL 13 and 14. Confirm your server version is still supported before copying any setting.Continue to check 2

Check 2 — the three settings that decide the outcome

SHOW hnsw.ef_search;        -- 40 by default
SHOW hnsw.iterative_scan;   -- off by default
SHOW hnsw.max_scan_tuples;  -- 20000 by default
OutputMeaningNext step
iterative_scan = offThe default. Filtered queries may return fewer rows than the LIMIT.Check 3 to prove it on your data
iterative_scan = relaxed_order or strict_orderEnabled on this session only. If your app opens another connection, that connection is still off.Check 3 with the setting forced back to off, then failure mode 02
ef_search = 40The default candidate list. A 10% filter leaves roughly 4 candidates.Check 3
ERROR: unrecognized configuration parameterThe extension is not loaded in this database, or the build predates the setting.Fix the extension before reading the rest

Check 3 — the count(*) comparison that proves it

Run this inside one transaction so SET LOCAL cannot leak into other callers. Replace the tenant value and the vector literal with real ones; the literal must have the same dimension as the column.

BEGIN;
SET LOCAL hnsw.iterative_scan = off;   -- reproduce the default explicitly

-- how many rows the filter alone matches (must be at least the LIMIT)
SELECT count(*) AS matching_rows FROM chunks WHERE tenant_id = 42;

-- how many rows the query actually returns
SELECT count(*) AS rows_returned
  FROM (
    SELECT id
      FROM chunks
     WHERE tenant_id = 42
     ORDER BY embedding <=> '[0.013,-0.041,0.087]'::vector
     LIMIT 10
  ) s;
ROLLBACK;
OutputMeaningNext step
matching_rows = 3The filter itself matches fewer rows than the LIMIT. Nothing is broken; your data cannot satisfy the query.Stop. Do not buy this runbook.
matching_rows >= 10, rows_returned = 10Not affected by under-return on this data and this session.Check 4 only if latency is also a problem
matching_rows >= 10, rows_returned < 10Reproduced. The filter matches enough rows and the query still returns fewer.Check 4, then failure mode 01
rows_returned = 0Worst case: a selective filter and the default candidate list never reach a matching row.Failure mode 01, then 02
ERROR: operator does not exist / different vector dimensionsThe literal is not comparable with the column. This is a test error, not a database finding.Fix the literal and rerun
ERROR: unrecognized configuration parameter "hnsw.iterative_scan"The build is older than 0.8.0. Drop the SET LOCAL line and use the row count alone.Check 1 upgrade path

One caveat: wrapping the query in SELECT count(*) can change the plan, because the outer query no longer needs rows in distance order. Use check 3 to measure the row count and check 4 for the definitive plan.

Check 4 — EXPLAIN tells you which family you are in

EXPLAIN (ANALYZE, BUFFERS)
SELECT id
  FROM chunks
 WHERE tenant_id = 42
 ORDER BY embedding <=> '[0.013,-0.041,0.087]'::vector
 LIMIT 10;
Plan shapeMeaningNext step
Index Scan using chunks_embedding_idxThe HNSW index is used. If the node carries Order By: and a Filter: on the tenant column, filtering happens after the scan.Failure mode 01 / 02
Rows Removed by FilterRows the index scanned and the filter then discarded. Compare it with the returned row count; a large number confirms post-filtering. The 2026 report in issue #1032 shows the shape at defaults: 40 tuples emitted, 28 removed by the post-index filter, 12 returned for a LIMIT 30.Failure mode 01
Bitmap Heap Scan + SortThe planner refused the vector index and chose an exact access path plus a top-N sort. Results can be correct while latency is far worse.Failure mode 03
Seq Scan + SortSame family as above: no approximate index scan in the plan.Failure mode 03
Index Scan, no Filter, returned rows = LIMITNo filtered under-return. Stop here unless the LIMIT is above 1000.Stop, or read failure mode 04

Check 5 — the cheap ones people skip

SELECT relname, n_live_tup, n_dead_tup, last_vacuum, last_autovacuum
  FROM pg_stat_user_tables
 WHERE relname = 'chunks';

SELECT indexname, indexdef
  FROM pg_indexes
 WHERE tablename = 'chunks';
OutputMeaningNext step
n_dead_tup is a large share of n_live_tupThe README states that dead tuples can reduce results independently of filtering.VACUUM, then rerun check 3; see failure mode 05 before any REINDEX
no HNSW index in indexdefThere is no approximate index to refuse; every query is exact.Re-evaluate whether this runbook applies at all
embedding vector(3072)Above the 2,000-dimension HNSW cap for vector; a separate index type is required.Failure mode 06

Not affected? If check 3 returns as many rows as the LIMIT, and check 4 shows either an exact plan on purpose or an HNSW scan without a post-filter, stop here and do not buy it. If you want a second opinion first, email [email protected] with the four outputs (version, settings, count(*) row, and the plan) and the pgvector version you are on.

Decision tree

not enough rows match the filter   -> stop, the query is answering honestly
rows match, query returns fewer
  + Index Scan + Filter            -> FM-01 (post-filter under-return)
  + iterative_scan = off           -> FM-02 (the setting never reached the query)
  + Bitmap Heap Scan + Sort        -> FM-03 (planner refused the HNSW index)
  + rows out of distance order     -> FM-04 (ordering semantics)
  + recall fell after upgrade/VACUUM -> FM-05 (rebuild the index)
CREATE INDEX fails on dimensions   -> FM-06 (2,000-dimension wall)

Failure modes

Six failures, each with the trigger, the observable evidence and the fix direction. Two are visible here.

01 — The LIMIT 10 that returns four rows
There is no error line for this one. The query succeeds, the plan looks healthy, and the row count is short: the README’s own numbers are a condition matching 10% of rows, the default hnsw.ef_search of 40, and “only 4 rows will match on average”. EXPLAIN (ANALYZE) shows an Index Scan on the HNSW index with a Filter: on the tenant column and a large Rows Removed by Filter — that combination is the fingerprint. Teams usually conclude “the embedding model is bad” or “the data is missing”; the retrieval layer never reported that it stopped looking. The fix direction is an executor setting scoped to the query, or an index design that removes the filter from the scan, and both need the selectivity measured first.
02 — The SET that never reached the query
A developer sets hnsw.iterative_scan in a SQL window, reruns the benchmark in the application or an IDE, and sees no change. This is not a pgvector quirk: the setting is session-scoped, and PostgreSQL documents that SET LOCAL lasts only until the end of the current transaction, while a plain SET is visible only in the session that issued it. In pgvector issue #912 (2025-10-16) the reporter had exactly this problem and closed it after learning the parameters were session-level, so the query ran without them. The same class of mistake reappears with a transaction-pooling layer, where the SET and the query may not share a server connection — that specific case is inferred here, not measured. Verify on the connection the application actually uses, not in a separate window.
The remaining four, and the migration, are in the paid bundle
Failure modes 03–06: the planner refusing the HNSW index for a filtered query (including the maintainer’s reply to the 2026 report), the ordering semantics of relaxed_order and the PostgreSQL 17 materialized-CTE trap, the upgrade-and-REINDEX question left by the 0.8.3/0.8.4 HNSW vacuum fixes, and the 2,000-dimension wall for 3,072-dimension embeddings. Plus the migration checklist split by caller (application query path, connection pool, batch jobs, multi-tenant queries, embedding pipeline), the rollback plan, and the first 24 hours of observation. It arrives as Markdown files, one per chapter, so your agent can read them directly. Access runs for 30 days from purchase, and a refund revokes it.

Migration: what the paid chapter covers

The fix is almost never “turn the knob up”. It is a decision about where the filter belongs, made per caller, because a human debugging session and a pooled application connection do not see the same settings. The chapter splits the work like this:

Caller / componentWhat breaksWhat the runbook has you change
RAG query path (application)Fewer rows than LIMIT; empty references panelSET LOCAL hnsw.iterative_scan = relaxed_order in the same transaction as the query; measure selectivity before touching ef_search
Connection pool / transaction poolingA session SET sometimes applies and sometimes does notMove the setting into the transaction (SET LOCAL) or into pooler connect settings; verify with SHOW on a borrowed connection
Batch or reporting jobA LIMIT above 1000 cannot be satisfied by the candidate list aloneIterative scan plus max_scan_tuples/scan_mem_multiplier, or an exact plan for large result sets
Multi-tenant retrievalOne global index; tenants share recall and latencyPartial HNSW index for a small number of tenants, list partitioning for many, per the README’s multitenancy section
Embedding pipeline (3072-dim)CREATE INDEX fails on the dimension limithalfvec, an expression index with the same cast in the query, or dimensionality reduction
Upgrade windowRecall changes after an extension upgrade or VACUUMUpgrade past the 0.8.3/0.8.4 HNSW vacuum fixes, rebuild the index, compare recall before and after

The bundle also carries the decision tree that picks between “enable iterative scan”, “partial index”, “partition” and “exact plan”, a rollback that is one setting, and a 24-hour watch list: returned-rows histogram per query, Rows Removed by Filter on the retrieval query, and recall against a frozen sample set.

Appendix

Verification status
The detection chapter above is reviewed against the sources linked in this appendix and is complete. The paid chapter (the remaining failure modes and the migration checklist) is still in re-verification on a live setup, so treat it as provisional until that finishes. Price and structure are final. A 7-day refund applies from the day of purchase — see the refund policy.
For agents

Need a runbook that does not exist yet?

Runbooks are written by an AI from requests. Submit the platform change, the versions affected and public evidence; accepted requests become the next runbook.

Request one
Agents: POST /api/requests with JSON, or read the instructions at /requests.md. Accepted and published requests are listed at /api/requests. Humans: email [email protected].

Asked for ten rows and got three?

Send the two outputs from the detection chapter — the count(*) pair and the plan — plus your pgvector and PostgreSQL versions. If it is this family, one page of checklist separates you from rows you can trust.