Skip to content

fix(index): align HNSW construction with reference - #8188

Merged
Xuanwo merged 5 commits into
mainfrom
gatekeeper/fix-8036-1
Aug 5, 2026
Merged

fix(index): align HNSW construction with reference#8188
Xuanwo merged 5 commits into
mainfrom
gatekeeper/fix-8036-1

Conversation

@lance-gatekeeper

@lance-gatekeeper lance-gatekeeper Bot commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

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?

  • Add reciprocal candidates before applying the diversity heuristic, and use M for new nodes while retaining Mmax0 for reciprocal level-0 capacity in both builders.
  • Use Algorithm 4 keepPrunedConnections to refill rejected candidates up to the existing M limit, reducing graph fragmentation without restoring the old 2*M new-node degree.
  • Randomly assign every node level, use the first globally highest node as the offline entry point, and retain online promotion semantics.
  • Keep corrected level offsets aligned with serialized rows while padding empty trailing ranges to the configured level count for version-1 reader compatibility. Current readers derive runtime height from non-empty levels.
  • Apply shared construction-parameter validation through OnlineHnswBuilder::try_with_capacity, deprecate the infallible compatibility constructor, and reject M below 4 because smaller values produce severely fragmented parallel builds.
  • Refresh the SEARCH-LAYER result bound per neighbor and document the selected Algorithm 4 options and low-setting quality trade-off.
  • Add structural, offline/online parity, loaded-index compatibility, parameter-validation, non-entry-point search, minimum-setting reachability, and recall regressions.

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

  • cargo test -p lance-index --lib (943 passed, 2 ignored)
  • cargo test -p lance-index --lib vector::hnsw (39 passed)
  • minimum-setting HNSW reachability regression repeated 20 times
  • HNSW-PQ null-case recall reproducer repeated 10 times (20 test cases passed)
  • cargo test -p lance-index --doc try_with_capacity (1 passed)
  • cargo clippy --all --tests --benches -- -D warnings
  • cargo fmt --all -- --check
  • git diff --check

Refs #8036

@github-actions github-actions Bot added bug Something isn't working A-index Vector index, linalg, tokenizer labels Aug 3, 2026

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cbf7075 and verified on current head 01534bb. Version-1 metadata now retains the configured level-boundary shape with empty trailing ranges, while current readers derive runtime height from non-empty levels; the loaded compatibility regression covers this path.

self
}

fn validate(&self) -> Result<()> {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in cbf7075 and verified on current head 01534bb. OnlineHnswBuilder::try_with_capacity now enforces the shared validation, and the compatibility constructor delegates through that fallible boundary; online invalid-parameter cases cover the behavior.

@codecov

codecov Bot commented Aug 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Gate recommendation: approve with a non-blocking risk.

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.

Comment thread rust/lance-index/src/vector/hnsw.rs Outdated
/// 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@Xuanwo
Xuanwo requested a review from BubbleCal August 4, 2026 07:32
@lance-gatekeeper

Copy link
Copy Markdown
Contributor Author

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.

@lance-gatekeeper lance-gatekeeper Bot left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@u70b3

u70b3 commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

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 M vs. reciprocal Mmax0 distinction is in place with per-role degree tests, offline construction adopts the online/paper level model (first-globally-highest is provably the fixpoint of sequential strict promotion — the parity tests pinning both builders to the same seeded level sequence are exactly the right coverage), the SEARCH-LAYER bound refresh matches Algorithm 2 at O(1) per neighbor, and validation now guards both construction boundaries with the stale default-value docs corrected. Making the keepPrunedConnections refill atomic with the new-node M correction is the right call — the CI recall evidence (0.902–0.987 without refill) shows the degree correction alone would have fragmented the graph.

The persisted-format handling also deserves credit: padding level_offsets to the configured level count keeps the version-1 reader contract while current readers derive runtime height from non-empty levels, and the legacy misaligned-load regression is preserved. One accuracy note for the changelog: the counting half of #5156 was already fixed on main by #8167; what this PR removes is the root cause (the forced full-height entry point), which is the more important half.

A few gaps against the tracker's completion criteria — none blocking this PR, but they should land somewhere:

  1. Please don't auto-close bug: track HNSW deviations from the reference algorithm #8036. This PR says "Fixes bug: track HNSW deviations from the reference algorithm #8036", but the tracker's completion criteria include benchmark evidence — recall, latency, build time, and degree distribution before/after, on SIFT and clustered data — which is not reported here. This matters concretely because the refill heuristic will saturate level-0 reciprocal lists at 2M far more often, so index size and build time should move measurably. I'd rather the tracker stay open (or this become "Refs bug: track HNSW deviations from the reference algorithm #8036") until the benchmark item is resolved; I'm happy to own that follow-up.

  2. Potential Bug: HNSW beam_search incorrectly uses a static furthest variable #5183 still lacks its regression test. The bound refresh is correct, but the tracker asked for a distance-computation regression to lock it in, and I don't see one here. The counter from fix(index): report distance comparisons from HNSW search #8142 would make that test nearly free once it lands — flagging so it isn't forgotten when this closes.

  3. Breaking-change surface. Rejecting m < 4 and ef_construction < m converts previously accepted (silently degraded) settings into hard errors. The gate review already asked for the breaking-change label — +1 from me, and per repo convention the title could carry !. The m >= 4 floor is stricter than the m > 1 precondition I proposed in the tracker; I think it's the right engineering call given the fragmentation evidence, but it deserves to be visible in release notes.

  4. Scope note, not this PR's job: the mem_wal memtable carries a third, independent HNSW construction (rust/lance/src/dataset/mem_wal/hnsw/graph.rs) with the same family of deviations. Filed bug: align mem_wal HNSW construction with the reference algorithm #8237 to track aligning it — I'm taking that one myself.

Nice work. This closes the core of the tracker, and the structural tests are the ones the audit asked for.

@lance-gatekeeper

Copy link
Copy Markdown
Contributor Author

No code change was made: the requested breaking-change label is now applied and verified on current head 01534bb.

u70b3 added a commit to u70b3/lance that referenced this pull request Aug 4, 2026
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
@Xuanwo

Xuanwo commented Aug 5, 2026

Copy link
Copy Markdown
Member

Please don't auto-close #8036.

@u70b3 Please create a new issue as a follow-up. If you intend to maintain a tracking issue, please don't mark it as a bug.

@Xuanwo
Xuanwo merged commit adb6829 into main Aug 5, 2026
50 checks passed
@Xuanwo
Xuanwo deleted the gatekeeper/fix-8036-1 branch August 5, 2026 09:15
Xuanwo pushed a commit that referenced this pull request Aug 5, 2026
## 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>
@Xuanwo Xuanwo added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-index Vector index, linalg, tokenizer breaking-change bug Something isn't working K-approved Latest Gatekeeper recommendation permits acceptance.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants