pgvectorPostgresVectorSearchRAG

pgvector Iterative Scan in Production: strict_order vs relaxed_order and max_scan_tuples

How PostgreSQL 17 and pgvector 0.8 fix the post-filtering trap with iterative index scans. Benchmark tradeoffs between strict_order and relaxed_order, and how max_scan_tuples stops runaway scans.

If you run vector search in PostgreSQL with metadata filters, pgvector 0.8 on PostgreSQL 17 solves the post-filtering trap that silently dropped rows below your LIMIT. But turning on iterative scans without tuning the scan mode and safety bounds can spike latency. Here is how strict_order, relaxed_order and max_scan_tuples behave in production.

#The problem iterative scans solve

In an earlier write-up on the pgvector filtering trap, I showed why a query with LIMIT 10 and a selective WHERE filter often returns only 3 or 4 rows. An approximate nearest neighbor index explores the vector graph first, collects candidates up to hnsw.ef_search (default 40), and applies your SQL filter afterward. If your filter matches only 10% of rows, roughly 4 rows pass.

Before PostgreSQL 17, the only fixes were raising ef_search to large numbers, creating partial indexes for fixed filter values, or partitioning tables. PostgreSQL 17 added iterator support to index access methods, allowing pgvector 0.8 to resume scanning the index incrementally whenever the filter discards candidates.

#Two scan modes: strict_order vs relaxed_order

You control the scan behavior through the hnsw.iterative_scan session variable:

text
[object Object]

The difference between the two active modes comes down to distance guarantees:

  • strict_order: Guarantees that every returned row is strictly closer than any row that could have been found later. To prove this, pgvector expands its search radius before yielding the next batch. On highly selective filters (matching 1% to 5% of your table), this requires scanning substantially more graph nodes.
  • relaxed_order: Yields matching rows as each graph neighborhood is visited. The overall list is roughly ordered by distance, but a row from iteration two might have a slightly smaller distance than a row from iteration one. For RAG retrieval where all top candidates feed into an LLM context window, this slight relaxation cuts query latency by 60% to 70% with negligible recall loss.

#Stopping runaway scans with max_scan_tuples

The biggest operational danger with iterative scans is a filter that matches very few rows, or zero rows. Consider this query:

text
[object Object]

If acme_corp has only two archived documents in a table of two million rows, an unconstrained iterative scan will inspect every single vector in the index searching for the remaining eight rows. This turns a 5-millisecond vector query into a multi-second table scan that monopolizes CPU cores.

To prevent this, pgvector provides hnsw.max_scan_tuples (default 20,000):

text
[object Object]

#Production decision matrix

Iterative scans are powerful, but they are not the right tool for every access pattern. Here is how to choose between index strategies:

Dynamic multi-tenant filter (thousands of tenants)hnsw.iterative_scan = 'relaxed_order'Requires Postgres 17+; handles arbitrary filters without index bloat
Strict ranking required (financial/legal discovery)hnsw.iterative_scan = 'strict_order'Guarantees sorted order; higher buffer reads on high selectivity
Fixed filter value (status = 'active')Partial HNSW IndexZero scan overhead during query; index must be defined in advance
Tenant data isolated into discrete partitionsTable Partitioning with per-partition HNSWPrunes entire partitions before scanning; operational setup overhead

#How to verify in EXPLAIN plans

Run EXPLAIN (ANALYZE, BUFFERS) on your query. In PostgreSQL 17 with iterative scans enabled, the execution node explicitly reports the iterative behavior and tuple count:

text
[object Object]

Notice Tuples Processed: 44 and Rows Removed by Filter: 34. The index scanned 44 candidates to satisfy the 10-row limit, rather than giving up after the first batch.

Frequently asked questions

What is iterative scan in pgvector?

Iterative scan is a feature introduced in pgvector 0.8.0 for PostgreSQL 17 that keeps scanning an HNSW index incrementally until the query LIMIT is satisfied. In earlier versions, pgvector only gathered candidates up to ef_search before filtering, which caused selective filters to return fewer rows than the requested LIMIT.

What is the difference between strict_order and relaxed_order in pgvector?

strict_order guarantees that returned vectors are strictly sorted by true distance across the entire scan, which requires expanding graph boundaries and scanning more tuples. relaxed_order allows approximate distance ordering between iteration batches, reducing buffer reads and latency by up to three times on selective filters while preserving high recall.

Why is hnsw.max_scan_tuples important in production?

max_scan_tuples sets a hard safety ceiling on the number of tuples an iterative scan can evaluate. If a filter matches zero or very few rows, an unconstrained iterative scan would traverse the entire index graph and spike CPU usage. The default ceiling stops the scan before it starves other database queries.

Related notes

pgvector: HNSW vs IVFFlat and the Filtering Trap

How to choose between HNSW and IVFFlat in pgvector, which settings matter, and why a filtered vector query can silently return too few rows.

Read

Supabase to Self-Hosted Postgres: A Production Migration

How I moved Opaeron off Supabase onto self-hosted PostgreSQL 16 with pgvector: a psycopg3 query-builder shim, losing RLS, pooling and additive migrations.

Read

Building something in this space and want another pair of hands on it?

Get in touch