1. Home
  2. Filtering
Guide · filtered search

Filtering and iterative index scans

Add a WHERE clause to a vector query and an approximate index can quietly return 3 rows when you asked for 10. We measured exactly when that happens and what each fix costs.

Tested on pgvector 0.8.7 · PostgreSQL 18.6Updated 2026-10-276 min read

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:

psqlSame query, before and after enabling iterative scans
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;
RESET

Measured: 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 matchingModeAvg rowsRecall@10p50
50%off10.000.9392.9 ms
50%relaxed_order10.000.9392.9 ms
10%off3.990.3982.7 ms
10%relaxed_order10.000.9466.3 ms
1%off0.390.0392.7 ms
1%relaxed_order10.000.96737 ms
0.1%off0.030.0032.6 ms
0.1%relaxed_order10.000.79169 ms
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data

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 matchingStrategyRecall@10Avg rowsp50p95
50%HNSW, iterative_scan off0.93910.002.9 ms4.3 ms
50%HNSW, relaxed_order0.93910.002.9 ms4.4 ms
50%HNSW, strict_order0.93910.002.9 ms5.5 ms
50%Planner's choice (B-tree present)0.93910.002.9 ms4.1 ms
10%HNSW, iterative_scan off0.3983.992.7 ms4.1 ms
10%HNSW, relaxed_order0.94610.006.3 ms9.7 ms
10%HNSW, strict_order0.89910.006.2 ms10 ms
10%Planner's choice (B-tree present)1.00010.0058 ms77 ms
1%HNSW, iterative_scan off0.0390.392.7 ms4.1 ms
1%HNSW, relaxed_order0.96710.0037 ms59 ms
1%HNSW, strict_order0.90110.0036 ms61 ms
1%Planner's choice (B-tree present)1.00010.006.3 ms12 ms
0.1%HNSW, iterative_scan off0.0030.032.6 ms4.0 ms
0.1%HNSW, relaxed_order0.79110.0069 ms88 ms
0.1%HNSW, strict_order0.77310.0070 ms86 ms
0.1%Planner's choice (B-tree present)1.00010.000.82 ms1.1 ms
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data

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 matchingStrategyp50p95
50%HNSW, iterative_scan off2.9 ms4.3 ms
50%HNSW, relaxed_order2.9 ms4.4 ms
50%HNSW, strict_order2.9 ms5.5 ms
50%Planner's choice (B-tree present)2.9 ms4.1 ms
10%HNSW, iterative_scan off2.7 ms4.1 ms
10%HNSW, relaxed_order6.3 ms9.7 ms
10%HNSW, strict_order6.2 ms10 ms
10%Planner's choice (B-tree present)58 ms77 ms
1%HNSW, iterative_scan off2.7 ms4.1 ms
1%HNSW, relaxed_order37 ms59 ms
1%HNSW, strict_order36 ms61 ms
1%Planner's choice (B-tree present)6.3 ms12 ms
0.1%HNSW, iterative_scan off2.6 ms4.0 ms
0.1%HNSW, relaxed_order69 ms88 ms
0.1%HNSW, strict_order70 ms86 ms
0.1%Planner's choice (B-tree present)0.82 ms1.1 ms
Measured on PostgreSQL 18.6 + pgvector 0.8.7, 4 vCPU / 15 GB, DBpedia × OpenAI text-embedding-3-large.Method & raw data

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_order returned 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_order had to walk far through the graph (37 ms at 1%).
  • strict_order cost recall here — 0.899 vs 0.946 for relaxed_order at 10%. Use relaxed_order and 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#

SettingDefaultMeaning
hnsw.iterative_scanoffrelaxed_order or strict_order
hnsw.max_scan_tuples20,000Stop after visiting this many tuples (bounds worst-case latency)
hnsw.scan_mem_multiplier1Memory for the scan, as a multiple of work_mem
ivfflat.iterative_scanoffrelaxed_order only
ivfflat.max_probes32,768Stop after probing this many lists

If a relaxed scan's slight disorder matters, re-sort in a materialised CTE:

psqlpsql · PostgreSQL 18.6 · pgvector 0.8.7
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;
RESET

Partial 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:

psqlpsql · PostgreSQL 18.6 · pgvector 0.8.7
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#

  1. Index the filter columns with ordinary B-trees.
  2. Enable iterative scans where filtered vector queries run: SET hnsw.iterative_scan = relaxed_order;
  3. Check the plan with EXPLAIN for each common filter shape.
  4. Measure rows returned and recall for your most selective real filter.
  5. 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.