fix(index): align HNSW construction with reference - #8188
Conversation
# Conflicts: # rust/lance-index/src/vector/hnsw/builder.rs
There was a problem hiding this comment.
❌ Gate recommendation: request changes.
Keep the sampled hierarchy, but preserve the version-1 persisted-index contract: either serialize the configured level-boundary count (empty trailing levels are enough) or version this incompatible shape so older readers ignore it. Apply the same parameter validation at both offline and online construction boundaries.
| let level_count = self | ||
| .level_count | ||
| .into_iter() | ||
| .take(actual_levels) |
There was a problem hiding this comment.
This truncates version-1 metadata to the sampled height while HnswMetadata.params.max_level remains at the configured value. Prior version-1 readers derive the search height from that field and directly index the loaded per-level vectors, so querying a head-written index panics whenever the sampled height is lower. The HNSW vector-index version is unchanged, so those readers will not ignore the file.
Keep the configured number of serialized offsets (zero-length trailing ranges are sufficient, while current readers can derive the runtime height from nonempty levels), or bump the applicable vector-index format version.
Reproducer run on this head
I temporarily restored the base reader behavior:
fn max_level(&self) -> u16 {
self.params.max_level
}Then a unit test built, serialized, loaded, and queried one vector with default max_level = 7:
cargo test -p lance-index --lib gate_reproducer_current_writer_legacy_reader_missing_levels -- --nocapture
Expected one result; observed index out of bounds: the len is 1 but the index is 6 in LoadedHnswGraph::neighbors_at.
There was a problem hiding this comment.
| self | ||
| } | ||
|
|
||
| fn validate(&self) -> Result<()> { |
There was a problem hiding this comment.
The new validation is only invoked by HNSW::index_vectors; the public OnlineHnswBuilder::with_capacity still accepts the same invalid shared parameters. With m = 0, its prune path selects zero neighbors for both nodes and silently produces a disconnected graph. Make this validation available to the online builder and enforce it through a fallible online construction boundary.
Reproducer run on this head
let params = HnswBuildParams::default().max_level(1).num_edges(0);
let builder = OnlineHnswBuilder::with_capacity(2, params);
builder.insert(0, &storage);
builder.insert(1, &storage);
assert!(
!builder.nodes[0].bottom_neighbors.load().is_empty(),
"a two-node graph must not silently discard every connection"
);cargo test -p lance-index --lib gate_reproducer_online_builder_accepts_zero_m -- --nocapture
The assertion failed because node 0 had no connection.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
The follow-up resolves both earlier blockers: version-1 readers retain the configured level-boundary shape, and online/offline builders now share parameter validation. The reference-aligned refill is worthwhile, but its connectivity claim is stronger than the behavior at the lowest accepted construction settings. Treat those settings as an explicit recall-quality trade-off with coverage, or raise the validation floor if high-budget reachability is part of the contract.
| /// This uses the paper's `extendCandidates = false` and | ||
| /// `keepPrunedConnections = true` configuration. Keeping pruned connections | ||
| /// fills the requested degree without exceeding it, which prevents sparse | ||
| /// directed components when callers search with a large result budget. |
There was a problem hiding this comment.
Refilling rejected candidates does not prevent directed components for every parameter set that validate() accepts, so this comment overstates the guarantee. With m = 2 and ef_construction = 2, reciprocal pruning can still discard all incoming links to a new node from the entry-reachable component; a full-budget search reached only 121 of 2048 nodes.
If full-budget reachability is required, preserve at least one reciprocal link into every inserted node (or repair components after construction). Otherwise, document the low-M recall trade-off and add a regression floor, or reject parameters below that floor.
Reproducer run on this head
use rand::Rng;
#[test]
fn gate_reproducer_bottom_level_reachability() {
const DIM: usize = 32;
const TOTAL: usize = 2048;
let mut rng = SmallRng::seed_from_u64(0);
let values = Float32Array::from_iter_values(
(0..TOTAL * DIM).map(|_| rng.random::<f32>()),
);
let vectors = FixedSizeListArray::try_new_from_values(values, DIM as i32).unwrap();
let store = FlatFloatStorage::new(vectors.clone(), DistanceType::L2);
let hnsw = HNSW::index_vectors(
&store,
HnswBuildParams::default().num_edges(2).ef_construction(2),
)
.unwrap();
let results = hnsw.search_basic(
vectors.value(0),
TOTAL,
&HnswQueryParams {
ef: TOTAL,
lower_bound: None,
upper_bound: None,
dist_q_c: 0.0,
use_acorn: false,
},
None,
&store,
).unwrap();
assert_eq!(results.len(), TOTAL);
}cargo test -p lance-index --lib gate_reproducer_bottom_level_reachability -- --nocapture
Expected 2048 results; observed 121.
There was a problem hiding this comment.
Addressed in 01534bb. The Algorithm 4 documentation no longer claims universal connectivity, shared validation rejects m below 4, and a seeded minimum-setting regression requires at least 90% full-budget reachability.
|
Addressed in 01534bb. The minimum supported M is now 4, the documentation states the remaining low-setting quality trade-off without promising universal reachability, and the seeded minimum-setting regression enforces at least 90% full-budget reachability. |
There was a problem hiding this comment.
✅ Gate recommendation: approve.
The new quality-floor contract resolves the remaining risk: both construction paths reject m < 4, the selector documentation accurately scopes reachability, and the lowest accepted setting has a direct regression. Central validation is preferable to connectivity repair here because it preserves the reference degree semantics without adding a new graph-repair contract.
Please mark this PR with the breaking-change label.
|
As the author of the tracker (#8036): this is a thorough piece of work, and I want to acknowledge that before the notes. Every classified difference from the tracker is addressed, in both builders: reciprocal candidates now join the complete old-plus-new set before Algorithm 4 runs, the new-node The persisted-format handling also deserves credit: padding A few gaps against the tracker's completion criteria — none blocking this PR, but they should land somewhere:
Nice work. This closes the core of the tracker, and the structural tests are the ones the audit asked for. |
|
No code change was made: the requested breaking-change label is now applied and verified on current head 01534bb. |
Mirror the reference HNSW builder semantics (PR lance-format#8188) in the mem_wal memtable graph: - New-node degree limit is m at every level (Algorithm 1); Mmax0 = 2m is reserved for reciprocal edges on existing level-0 nodes (add_reverse_edge and the packed level-0 capacity hint keep 2m). - select_neighbors is Algorithm 4 with extendCandidates = false and keepPrunedConnections = true: pruned candidates refill the selected set in ascending distance order up to the requested degree. The degree fix without the refill fragments the graph (in lance-format#8188's CI, HNSW-PQ recall fluctuated 0.902-0.987), so both land in this single commit. - Node 0 draws its level from the seeded RNG like every other node instead of being pinned to level 0. This shifts the deterministic level sequence for a given seed, matching the reference builders. Dynamic first-highest entry promotion is unchanged. - BuildParams validation tightened: new MIN_M = 4 lower bound and an m <= usize::MAX / 2 overflow guard protecting the m * 2 level-0 reciprocal limit; the max_level == 0 message now carries the value. Breaking: m in 1..=3 is now rejected (m = 1 was already silently broken via the 1/ln(1) level multiplier). Fixes lance-format#8237 Validated with: - cargo test -p lance dataset::mem_wal::hnsw - cargo test -p lance dataset::mem_wal::index::hnsw - cargo test -p lance dataset::mem_wal::memtable::flush::tests::test_flusher_with_hnsw_index -- --exact - cargo test -p lance dataset::mem_wal::write::shard_writer_tests::test_writer_hnsw_params_override -- --exact - cargo test -p lance mem_wal
## Root cause SEARCH-LAYER previously captured the furthest result once per expanded node. While the result set was still filling, early neighbors could increase that bound; the stale, tighter value then rejected a later valid frontier node after it had already been marked visited, preventing the search from reaching better descendants. The runtime bound refresh landed on the base branch in #8188. This PR completes the repair with a deterministic regression that demonstrates the missed frontier path and locks both the expected result IDs and distance-computation count. ## Validation - `cargo test -p lance-index --lib` (971 passed, 2 ignored) - `cargo clippy --all --tests --benches -- -D warnings` - `cargo fmt --all -- --check` - `git diff --check` - Confirmed the regression fails when the per-neighbor refresh is removed: `[0, 2, 1]` is returned instead of `[0, 4, 3]`. Fixes #5183 <!-- lance-gatekeeper-fix:v1 agent=eca9d108d8a4e8ee71a8665697218797 generation=1 --> Co-authored-by: Gatefixer <312823363+lance-gatefixer[bot]@users.noreply.github.com>
What is the bug?
Several HNSW construction paths diverged from Algorithms 1–4 and hnswlib: reciprocal candidates could be rejected before diversity pruning, new level-0 nodes used the reciprocal Mmax0 limit, offline construction forced node 0 to full height, persisted level boundaries omitted the entry point, and SEARCH-LAYER reused a stale furthest bound. Invalid construction parameters also reached undefined algorithm states.
After correcting the new-node limit to M, Algorithm 4 selection could leave the directed graph under-filled and split into components. Current-head CI exposed this as stochastic HNSW-PQ recall between 0.902 and 0.987 when a high-budget query should reach essentially every indexed row. Truncating persisted levels to the sampled height also broke version-1 readers that index the configured level count directly.
How does this fix it?
Scope
This PR repairs the core algorithm deviations. The refreshed tracker evidence shows a remaining production recall deficit at low ef and large partitions, but it is a scale baseline rather than evidence for another construction deviation or a safe default change. Benchmark completion and the #5183 distance-computation regression remain follow-up work, so #8036 remains open for that work.
Validation
Refs #8036