Skip to content

ruvector-postgres 2.0.6 reintroduces #271: hnsw_costestimate lost the non-kNN guard, plain index scans silently return 0 rows #813

Description

@Bolivar1234

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions