The fix: SET hnsw.iterative_scan = relaxed_order; (pgvector 0.8.0+) plus an ordinary B-tree on the filter column. In our test, a 10% filter went from 4.0 of 10 rows to all 10 at 0.946 recall; for 1% filters the planner used the B-tree and returned exact results in 6.3 ms.
The problem: filters run after the index#
An HNSW scan collects ef_search candidates (40 by default), and Postgres then applies your WHERE clause. If only 1% of rows match, about 1% of 40 candidates survive. Here it is on a 20,000-row table where category = 7 matches 1% of rows, with the HNSW index forced:
demo=# RESET hnsw.ef_search;
RESET
demo=# SET enable_seqscan = off;
SET
demo=# SET enable_bitmapscan = off;
SET
demo=# SELECT count(*) FROM (SELECT id FROM items WHERE category = 7 ORDER BY embedding <=> (SELECT embedding FROM items WHERE id = 1) LIMIT 10) t;
count
-------
0
(1 row)
demo=# SET hnsw.iterative_scan = relaxed_order;
SET
demo=# SELECT count(*) FROM (SELECT id FROM items WHERE category = 7 ORDER BY embedding <=> (SELECT embedding FROM items WHERE id = 1) LIMIT 10) t;
count
-------
10
(1 row)
demo=# RESET hnsw.iterative_scan;
RESET
demo=# RESET enable_bitmapscan;
RESET
demo=# RESET enable_seqscan;
RESETMeasured: four selectivities, four strategies#
Setup: 100,000 DBpedia × OpenAI text-embedding-3-large (1,536-dim) vectors, HNSW m 16 / ef_construction 64, ef_search = 40, LIMIT 10, and a tenant_id filter keeping 50%, 10%, 1% or 0.1% of rows. Ground truth is the exact top-10 among the matching rows. First, the HNSW index on its own (no B-tree on the filter column):
Rows returned for LIMIT 10 with a WHERE filter (HNSW index)
100K rows, ef_search 40, no B-tree on the filter column. Without iterative scans, selective filters starve the result.
- iterative_scan = off
- iterative_scan = relaxed_order
| Rows matching | Mode | Avg rows | Recall@10 | p50 |
|---|---|---|---|---|
| 50% | off | 10.00 | 0.939 | 2.9 ms |
| 50% | relaxed_order | 10.00 | 0.939 | 2.9 ms |
| 10% | off | 3.99 | 0.398 | 2.7 ms |
| 10% | relaxed_order | 10.00 | 0.946 | 6.3 ms |
| 1% | off | 0.39 | 0.039 | 2.7 ms |
| 1% | relaxed_order | 10.00 | 0.967 | 37 ms |
| 0.1% | off | 0.03 | 0.003 | 2.6 ms |
| 0.1% | relaxed_order | 10.00 | 0.791 | 69 ms |
Without iterative scans the index runs dry as soon as filters get selective: 4.0 rows instead of 10 at 10%, 0.39 at 1%, 0.03 at 0.1%. With relaxed_order the scan keeps walking the graph until LIMIT is met (bounded by hnsw.max_scan_tuples, 20,000 by default).
Now recall and latency, adding the realistic case where a B-tree on tenant_id exists and the planner chooses:
Recall@10 with filters: four strategies
100K rows, HNSW ef_search 40, LIMIT 10. The planner's choice switched to a B-tree + exact sort at 10% and below.
- HNSW, iterative_scan off
- HNSW, relaxed_order
- HNSW, strict_order
- Planner's choice (B-tree present)
| Rows matching | Strategy | Recall@10 | Avg rows | p50 | p95 |
|---|---|---|---|---|---|
| 50% | HNSW, iterative_scan off | 0.939 | 10.00 | 2.9 ms | 4.3 ms |
| 50% | HNSW, relaxed_order | 0.939 | 10.00 | 2.9 ms | 4.4 ms |
| 50% | HNSW, strict_order | 0.939 | 10.00 | 2.9 ms | 5.5 ms |
| 50% | Planner's choice (B-tree present) | 0.939 | 10.00 | 2.9 ms | 4.1 ms |
| 10% | HNSW, iterative_scan off | 0.398 | 3.99 | 2.7 ms | 4.1 ms |
| 10% | HNSW, relaxed_order | 0.946 | 10.00 | 6.3 ms | 9.7 ms |
| 10% | HNSW, strict_order | 0.899 | 10.00 | 6.2 ms | 10 ms |
| 10% | Planner's choice (B-tree present) | 1.000 | 10.00 | 58 ms | 77 ms |
| 1% | HNSW, iterative_scan off | 0.039 | 0.39 | 2.7 ms | 4.1 ms |
| 1% | HNSW, relaxed_order | 0.967 | 10.00 | 37 ms | 59 ms |
| 1% | HNSW, strict_order | 0.901 | 10.00 | 36 ms | 61 ms |
| 1% | Planner's choice (B-tree present) | 1.000 | 10.00 | 6.3 ms | 12 ms |
| 0.1% | HNSW, iterative_scan off | 0.003 | 0.03 | 2.6 ms | 4.0 ms |
| 0.1% | HNSW, relaxed_order | 0.791 | 10.00 | 69 ms | 88 ms |
| 0.1% | HNSW, strict_order | 0.773 | 10.00 | 70 ms | 86 ms |
| 0.1% | Planner's choice (B-tree present) | 1.000 | 10.00 | 0.82 ms | 1.1 ms |
Median latency with filters: four strategies
Same queries. Exact search over the matching rows is fast when few rows match, slow when many do.
- HNSW, iterative_scan off
- HNSW, relaxed_order
- HNSW, strict_order
- Planner's choice (B-tree present)
| Rows matching | Strategy | p50 | p95 |
|---|---|---|---|
| 50% | HNSW, iterative_scan off | 2.9 ms | 4.3 ms |
| 50% | HNSW, relaxed_order | 2.9 ms | 4.4 ms |
| 50% | HNSW, strict_order | 2.9 ms | 5.5 ms |
| 50% | Planner's choice (B-tree present) | 2.9 ms | 4.1 ms |
| 10% | HNSW, iterative_scan off | 2.7 ms | 4.1 ms |
| 10% | HNSW, relaxed_order | 6.3 ms | 9.7 ms |
| 10% | HNSW, strict_order | 6.2 ms | 10 ms |
| 10% | Planner's choice (B-tree present) | 58 ms | 77 ms |
| 1% | HNSW, iterative_scan off | 2.7 ms | 4.1 ms |
| 1% | HNSW, relaxed_order | 37 ms | 59 ms |
| 1% | HNSW, strict_order | 36 ms | 61 ms |
| 1% | Planner's choice (B-tree present) | 6.3 ms | 12 ms |
| 0.1% | HNSW, iterative_scan off | 2.6 ms | 4.0 ms |
| 0.1% | HNSW, relaxed_order | 69 ms | 88 ms |
| 0.1% | HNSW, strict_order | 70 ms | 86 ms |
| 0.1% | Planner's choice (B-tree present) | 0.82 ms | 1.1 ms |
What we learned:
- Broad filters (50%) are a non-issue: every strategy used the HNSW index and returned 0.939 recall in about 2.9 ms.
- At 10%, iterative scans are the big win. Plain HNSW returned 4.0 rows (recall 0.398);
relaxed_orderreturned all 10 at 0.946 recall in 6.3 ms. Given a B-tree, the planner instead chose an exact sort of the 10,000 matching rows — perfect recall, but 58 ms, 9.2× slower. - At 1% and below, the B-tree wins. Exact search over a few hundred rows took 6.3 ms at 1% and 0.82 ms at 0.1% with recall 1.0, while
relaxed_orderhad to walk far through the graph (37 ms at 1%). strict_ordercost recall here — 0.899 vs 0.946 forrelaxed_orderat 10%. Userelaxed_orderand re-sort in SQL if you need exact ordering (below).
Recipe from the data: create a B-tree on columns you filter by and enable relaxed_order. The planner takes the B-tree path when few rows match (where it's fastest) and HNSW when many match. The middle ground (~10%) is where the planner may still pick a slow exact plan — check EXPLAIN for your common filters.
Iterative scan settings#
| Setting | Default | Meaning |
|---|---|---|
hnsw.iterative_scan | off | relaxed_order or strict_order |
hnsw.max_scan_tuples | 20,000 | Stop after visiting this many tuples (bounds worst-case latency) |
hnsw.scan_mem_multiplier | 1 | Memory for the scan, as a multiple of work_mem |
ivfflat.iterative_scan | off | relaxed_order only |
ivfflat.max_probes | 32,768 | Stop after probing this many lists |
If a relaxed scan's slight disorder matters, re-sort in a materialised CTE:
demo=# SET hnsw.iterative_scan = relaxed_order;
SET
demo=# WITH relaxed AS MATERIALIZED (
demo-# SELECT id, embedding <=> (SELECT embedding FROM items WHERE id = 1) AS distance
demo-# FROM items WHERE category = 7
demo-# ORDER BY distance LIMIT 10
demo-# )
demo-# SELECT id, round(distance::numeric, 4) AS distance FROM relaxed ORDER BY distance;
id | distance
-------+----------
9007 | 0.1632
3407 | 0.1720
10107 | 0.1762
1907 | 0.1766
6707 | 0.1779
9807 | 0.1898
15307 | 0.1943
1007 | 0.1947
17207 | 0.1963
17807 | 0.1980
(10 rows)
demo=# RESET hnsw.iterative_scan;
RESETPartial indexes for hot values#
When a few filter values dominate traffic — one big tenant, one language — give each its own index. The planner uses it automatically when the query's WHERE matches:
demo=# CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops) WHERE (category = 7);
CREATE INDEX
demo=# EXPLAIN (COSTS OFF) SELECT id FROM items WHERE category = 7 ORDER BY embedding <=> (SELECT embedding FROM items WHERE id = 1) LIMIT 10;
QUERY PLAN
------------------------------------------------------
Limit
InitPlan 1
-> Index Scan using items_pkey on items items_1
Index Cond: (id = 1)
-> Index Scan using items_embedding_idx1 on items
Order By: (embedding <=> (InitPlan 1).col1)
(6 rows)For many values, consider partitioning the table by the filter column (PARTITION BY LIST (tenant_id)): each partition gets its own HNSW index and Postgres prunes the rest.
Recipe#
- Index the filter columns with ordinary B-trees.
- Enable iterative scans where filtered vector queries run:
SET hnsw.iterative_scan = relaxed_order; - Check the plan with
EXPLAINfor each common filter shape. - Measure rows returned and recall for your most selective real filter.
- Hot values get partial indexes; many tenants get partitions.
Frequently asked questions
What is an iterative index scan in pgvector?
Added in 0.8.0, it lets an HNSW or IVFFlat scan keep going when the rows it found were removed by your WHERE clause, instead of stopping after ef_search (or probes) candidates. Enable it with SET hnsw.iterative_scan = relaxed_order;. In our 1%-selectivity test it raised the average rows returned for LIMIT 10 from 0.39 to 10.00.
What's the difference between relaxed_order and strict_order?
strict_order guarantees results come back exactly sorted by distance; relaxed_order may return slightly out-of-order rows in exchange for speed. Wrap a relaxed query in a materialised CTE and re-sort if you need exact order. strict_order is HNSW-only.
Should I use a partial index for each tenant?
For a few large, frequently-queried values — yes: a partial HNSW index (WHERE tenant_id = 7) gives full recall at full speed for that value. For thousands of tenants, use one index plus iterative scans, a B-tree on the tenant column, or table partitioning by tenant.