fix(hnsw): stop search() silently dropping stored points (#773) - #944
fix(hnsw): stop search() silently dropping stored points (#773)#944vidaunited wants to merge 1 commit into
Conversation
`VectorDB.search()` intermittently omitted a stored row (~4% of small
indexes, ~0.5% of points in a 5k clustered index) while `db.get()` still
returned it and `db.len()` still counted it. Raising `efSearch` never
recovered the row, because the point had no in-edges to traverse to.
Two defects in the vendored `hnsw_rs`, both in graph construction:
1. `reverse_update_neighborhood_simple` wrote every symmetric edge into
the neighbour's list at index `new_point.p_id.0` — the new point's
*top* level — instead of `l`, the layer the forward edge was built
on. A point at level >= 1 therefore received no layer-0 in-edges at
all, so a layer-0 traversal could only ever reach it when it happened
to be the entry point. It also wrote edges above the neighbour's own
level, so level-0 points ended up carrying layer-1 neighbour lists.
Fixed to `min(l, q.level)`: the layer the edge was built on, clamped
so it is never written above either endpoint's own level.
2. `search_layer` short-circuited on `points_by_layer[layer].is_empty()`.
`points_by_layer` buckets each point under its top level only, while a
point of level L participates at every layer 0..=L — so an empty
bucket does not mean an empty layer. Once every point in a small index
had level >= 1, layer 0's bucket was empty while layer 0 still carried
edges, and the bail-out left the point being inserted with no layer-0
neighbours at all. The traversal is seeded from `entry_point` and
bounded by neighbour lists, so it is well defined without the check.
Both are required. Measured over 20 000 three-row indexes (m=32,
efConstruction=200, k=64, efSearch=256 — the defaults the `ruvector` npm
wrapper passes):
baseline 780/20000 short
fix 1 only 22/20000
fix 2 only 821/20000
both 0/20000
Recall improves rather than regresses, and self-probe loss goes to zero:
n=1000 clustered lost 0 -> 0 recall@10 0.9970 -> 0.9980
n=5000 uniform lost 12 -> 0 recall@10 0.9445 -> 0.9565
n=5000 clustered lost 24 -> 0 recall@10 0.8820 -> 0.9200
Verified end-to-end through the reproduction in the issue, against a
locally built `@ruvector/core`: 7/200 short before the change, 0/1000
after, same build pipeline both times.
Adds `crates/ruvector-core/tests/hnsw_issue_773_regression.rs` (both
tests fail against the unpatched vendored crate). `cargo test -p
ruvector-core` is 494 passed / 0 failed, including the existing
`hnsw_integration_test`.
Note: the vendored crate's own `#[cfg(test)]` modules do not compile —
they use the rand 0.9 API while the vendoring pins rand 0.8 for WASM
compatibility. This is pre-existing (identical 25 errors on the
unmodified file) and is why the regression test lives in ruvector-core.
|
Follow-up with measurements I owe this PR, from preparing the same fix against upstream 1. My original self-probe metric was too tight on dense dataThe measurements in the PR description used 2. Re-measured with a reachability-grade probe, the fix is broader than reportedSequential insert, orphans = not returned by a search for its own vector at
3. Caveat: two dense-cluster configurations are not fixed, and one is worse
The second row matters for this repo specifically, because I believe this is a distinct upstream defect that this change exposes rather than causes: Nothing above affects the two regression tests in this PR, the 494-test |
Fixes #773.
VectorDB.search()intermittently omitted a stored row whiledb.get()still returned it anddb.len()still counted it, and raisingefSearchnever recovered it. That last detail is the tell: the point had no in-edges to traverse to, so no amount of extra breadth could reach it.Root cause is two defects in graph construction in the vendored
patches/hnsw_rs. Both are required — fixing either alone still leaves indexes broken (ablation below).1. Symmetric edges written on the wrong layer
reverse_update_neighborhood_simple(hnsw.rs:1245):The backlink was stored at the new point's top level rather than at
l, the layer the forward edge was just built on. Consequences:Graph dump of a failing three-row index (
searchreturned[0, 2], dropping 1):Node 1's edge to node 0 exists; the return edge landed on layer 1, so layer 0 can never arrive at node 1.
Fixed to
min(l, q.level)— the layer the edge was built on, clamped so it is never written above either endpoint's own level. The clamp is needed becausesearch_layerseeds its heap with the entry point unconditionally and can therefore hand back aqwhose own level is belowl.2.
search_layershort-circuited on an empty layer buckethnsw.rs:933:
points_by_layerbuckets each point under its top level only (generate_new_pointpushes topoints_by_layer[p_id.0]), while a point of level L participates in the graph at every layer0..=L. An empty bucket therefore does not mean an empty layer. Once every point in a small index had level >= 1, layer 0's bucket was empty while layer 0 still carried edges — and the bail-out left the point being inserted with no layer-0 neighbours whatsoever.The traversal below that check is seeded from
entry_pointand bounded by neighbour lists, so it is well defined regardless of the bucket: an entry point with no neighbours at that layer simply yields itself. Check removed.Measurements
20,000 independent three-row indexes, using the defaults the
ruvectornpm wrapper passes (m=32, efConstruction=200, k=64, efSearch=256):Recall improves rather than regressing, and self-probe loss (search each stored vector with itself) goes to zero:
This also confirms the reporter's observation that clustered real embeddings fail at roughly twice the rate of near-orthogonal random vectors (24 vs 12).
End-to-end verification
Ran the reproduction script from #773 unchanged, against a locally built
@ruvector/coreswapped intonode_modules. The control was built through the identical pipeline with only thehnsw.rschange reverted, so the clean result cannot be an artifact of a local build differing from the published one:Tests
Adds
crates/ruvector-core/tests/hnsw_issue_773_regression.rs:issue_773_three_row_index_returns_every_row— the exact shape from the issue, aggregated over 400 indexesissue_773_every_point_retrieves_itself— the detection probe from the issue over a 2,000-point indexBoth were confirmed to fail against the unpatched vendored crate (9/400 short; 6/2000 unreachable), so they are not vacuous. Levels come from
StdRng::from_entropy(), so each test aggregates enough independent indexes that passing with the defect present is ~1e-5.cargo test -p ruvector-core --release: 494 passed, 0 failed, including the pre-existinghnsw_integration_test.Note for reviewers
The vendored crate's own
#[cfg(test)]modules do not compile — they use the rand 0.9 API (rand::rng(),Uniform::new(..).unwrap()) while the vendoring pins rand 0.8 for WASM compatibility. This is pre-existing and unrelated to this change (an unmodifiedpatches/hnsw_rs/src/hnsw.rsproduces the identical 25 errors undercargo test --lib), and it is why the regression test lives inruvector-corerather than beside the code it guards.Both defects most likely also exist upstream in
jean-pierreBoth/hnswlib-rs, sincepatches/hnsw_rswas vendored for a rand downgrade rather than forked for behaviour. I have not checked their currentmain.🤖 Generated with claude-flow
https://claude.ai/code/session_016Z5LMV7ZW9PFaG9fzvpuwu