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_searchof 40, leaves “only 4 rows will match on average”. ALIMIT 10therefore returns about 4 rows, silently. hnsw.ef_searchis 40 by default, with a hard maximum of 1000. Both values are in the source, not just the docs:HNSW_DEFAULT_EF_SEARCH 40andHNSW_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_scantorelaxed_orderorstrict_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_tuplesdefaults to 20,000 andhnsw.scan_mem_multiplierto 1×work_mem. When the scan stops there, the query is still allowed to return fewer rows than theLIMIT. - 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 repairederror. 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
vectorcolumn can be indexed with HNSW only up to 2,000 dimensions;halfvecraises that to 4,000 andbitto 64,000. A 3,072-dimension embedding fails atCREATE INDEXtime, 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;
| Output | Meaning | Next step |
|---|---|---|
| extversion < 0.8.0 | No 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.2 | Iterative 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.3 | Has 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.7 | Current release line at this review date (0.8.7, 2026-10-01). Defaults still apply. | Continue to check 2 |
| 0.9.0 (unreleased) or newer | The 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
| Output | Meaning | Next step |
|---|---|---|
| iterative_scan = off | The default. Filtered queries may return fewer rows than the LIMIT. | Check 3 to prove it on your data |
| iterative_scan = relaxed_order or strict_order | Enabled 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 = 40 | The default candidate list. A 10% filter leaves roughly 4 candidates. | Check 3 |
| ERROR: unrecognized configuration parameter | The 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;
| Output | Meaning | Next step |
|---|---|---|
| matching_rows = 3 | The 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 = 10 | Not affected by under-return on this data and this session. | Check 4 only if latency is also a problem |
| matching_rows >= 10, rows_returned < 10 | Reproduced. The filter matches enough rows and the query still returns fewer. | Check 4, then failure mode 01 |
| rows_returned = 0 | Worst 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 dimensions | The 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 shape | Meaning | Next step |
|---|---|---|
| Index Scan using chunks_embedding_idx | The 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 Filter | Rows 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 + Sort | The 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 + Sort | Same family as above: no approximate index scan in the plan. | Failure mode 03 |
| Index Scan, no Filter, returned rows = LIMIT | No 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';
| Output | Meaning | Next step |
|---|---|---|
| n_dead_tup is a large share of n_live_tup | The 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 indexdef | There 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.
LIMIT 10 that returns four rowsThere 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.
SET that never reached the queryA 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.
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 / component | What breaks | What the runbook has you change |
|---|---|---|
| RAG query path (application) | Fewer rows than LIMIT; empty references panel | SET LOCAL hnsw.iterative_scan = relaxed_order in the same transaction as the query; measure selectivity before touching ef_search |
| Connection pool / transaction pooling | A session SET sometimes applies and sometimes does not | Move the setting into the transaction (SET LOCAL) or into pooler connect settings; verify with SHOW on a borrowed connection |
| Batch or reporting job | A LIMIT above 1000 cannot be satisfied by the candidate list alone | Iterative scan plus max_scan_tuples/scan_mem_multiplier, or an exact plan for large result sets |
| Multi-tenant retrieval | One global index; tenants share recall and latency | Partial 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 limit | halfvec, an expression index with the same cast in the query, or dimensionality reduction |
| Upgrade window | Recall changes after an extension upgrade or VACUUM | Upgrade 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
- pgvector README — filtering applied after the index scan, the “4 rows” example, iterative index scans,
max_scan_tuples20,000, HNSW dimension caps (reviewed 2026-11-08) - pgvector CHANGELOG — iterative scans in 0.8.0 (2024-10-30); HNSW vacuum fixes in 0.8.3 (2026-06-17) and 0.8.4 (2026-06-30); latest release 0.8.7 (2026-10-01) (reviewed 2026-11-08)
- pgvector source —
HNSW_DEFAULT_EF_SEARCH 40,HNSW_MAX_EF_SEARCH 1000, 2,000-dimension HNSW cap and GUC defaults:iterative_scan = off,max_scan_tuples = 20000(reviewed 2026-11-08) - pgvector issue #1032 — planner refused HNSW behind an
= ANY(...)filter plus an expression qual (2026-09-27) (reviewed 2026-11-08: the reporter’s measurements are on PG 17.11 / pgvector 0.8.6; the maintainer closed it the same day as not a pgvector bug — reason quoted in failure mode 03 of the paid chapter) - pgvector issue #862 —
relaxed_order, ordering and the PostgreSQL 17 materialized-CTE behaviour (opened 2025-06-20, closed 2025-07-02) (reviewed 2026-11-08) - pgvector issue #912 — session-scope confusion around the scan settings (opened 2025-10-16, closed 2025-10-18) (reviewed 2026-11-08)
- PostgreSQL 17 —
SETandSET LOCALscope, EXPLAIN and EXPLAIN ANALYZE, planner cost constants (reviewed 2026-11-08)
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.
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.
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.