Summary
The 2.0.6 cost-model rewrite in crates/ruvector-postgres/src/index/hnsw_am.rs (PR #738, commit ff4862b) dropped the non-kNN guard that 2.0.5's hnsw_costestimate carried (comment: "Fixes #271 — HNSW planner interference with non-vector queries"). In 2.0.5 that guard priced index paths with no ORDER BY operators at cost 1e10, so the planner never chose an HNSW index for a bare filter query. In 2.0.6 the planner CAN choose it — and the AM's rescan/gettuple path (unchanged between versions, "Fixes #152") returns early with no tuples when norderbys <= 0, so the query silently returns 0 rows instead of the correct result. Silent wrong results, not an error.
Reproduction (2.0.6)
create table t (id serial primary key, is_active boolean default true, embedding ruvector(768));
-- insert a few hundred rows with random 768-dim vectors, mixed is_active
create index t_hnsw on t using ruhnsw (embedding ruvector_cosine_ops) where is_active;
analyze t;
select id from t where is_active limit 1;
On a production table (263k rows, partial HNSW index WHERE is_active) the planner chose Index Scan using <hnsw index> for this query and returned 0 rows. Same query on 2.0.5 plans as Seq Scan and returns rows. (On small tables the planner may still prefer seqscan, masking the bug.)
Root cause
Diff hnsw_costestimate between crates.io ruvector-postgres-2.0.5 and 2.0.6: 2.0.5 has:
// HNSW only supports ORDER BY <distance_op> scans. ... Fixes #271
let has_orderbys = !(*path).indexorderbys.is_null();
if !has_orderbys {
*index_startup_cost = 1.0e10;
*index_total_cost = 1.0e10;
...
return;
}
2.0.6 replaced the function body with the logarithmic ef_search cost model and this block is gone. The hnsw_rescan comment ("let hnsw_gettuple return false, forcing PostgreSQL to fall back to a sequential scan") is not how the executor works — a chosen Index Scan node that yields no tuples is just an empty result; Postgres does not re-plan at execution time, so the guard in costestimate is load-bearing.
Fix
Re-add the has_orderbys guard at the top of the new hnsw_costestimate. We've verified this patch on our production deployment (Supabase PG17, 263k-row table, partial cosine HNSW index): planner correctly falls back to Seq Scan for bare filters while ORDER BY <=> queries still use the index.
Summary
The 2.0.6 cost-model rewrite in
crates/ruvector-postgres/src/index/hnsw_am.rs(PR #738, commit ff4862b) dropped the non-kNN guard that 2.0.5'shnsw_costestimatecarried (comment: "Fixes #271 — HNSW planner interference with non-vector queries"). In 2.0.5 that guard priced index paths with no ORDER BY operators at cost 1e10, so the planner never chose an HNSW index for a bare filter query. In 2.0.6 the planner CAN choose it — and the AM's rescan/gettuple path (unchanged between versions, "Fixes #152") returns early with no tuples when norderbys <= 0, so the query silently returns 0 rows instead of the correct result. Silent wrong results, not an error.Reproduction (2.0.6)
On a production table (263k rows, partial HNSW index
WHERE is_active) the planner choseIndex Scan using <hnsw index>for this query and returned 0 rows. Same query on 2.0.5 plans as Seq Scan and returns rows. (On small tables the planner may still prefer seqscan, masking the bug.)Root cause
Diff
hnsw_costestimatebetween crates.io ruvector-postgres-2.0.5 and 2.0.6: 2.0.5 has:2.0.6 replaced the function body with the logarithmic ef_search cost model and this block is gone. The
hnsw_rescancomment ("let hnsw_gettuple return false, forcing PostgreSQL to fall back to a sequential scan") is not how the executor works — a chosen Index Scan node that yields no tuples is just an empty result; Postgres does not re-plan at execution time, so the guard in costestimate is load-bearing.Fix
Re-add the has_orderbys guard at the top of the new
hnsw_costestimate. We've verified this patch on our production deployment (Supabase PG17, 263k-row table, partial cosine HNSW index): planner correctly falls back to Seq Scan for bare filters while ORDER BY<=>queries still use the index.