Skip to content

feat(index): stop HNSW greedy descent before level 0 - #8035

Open
u70b3 wants to merge 1 commit into
lance-format:mainfrom
u70b3:fix/hnsw-descent-stop-before-level-0
Open

feat(index): stop HNSW greedy descent before level 0#8035
u70b3 wants to merge 1 commit into
lance-format:mainfrom
u70b3:fix/hnsw-descent-stop-before-level-0

Conversation

@u70b3

@u70b3 u70b3 commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes #5208.

Per the HNSW paper (Algorithm 2), the greedy (ef=1) descent should cover only levels max_level-1 down to 1; level 0 must be searched solely by the ef-bounded beam search starting from the level-1 entry point. HNSW::run_search and HNSW::run_search_acorn were greedily descending through level 0 first, which collapsed the beam's entry point into a deep local minimum — extra distance computations and measurably lower recall at low ef.

The SIFT1M benchmark in the issue (3 independent graph builds, paired bootstrap intervals) shows stopping before level 0 is a repeatable Pareto improvement: +3.7% recall@10 at ef=16, +0.5% at ef=32, statistically equal recall at higher ef, and lower paired latency throughout the sweep. Note the online builder's query path (OnlineHnswBuilder::search) and both build-time insert paths already used the paper-correct range — only these two query paths were off.

Test

New structural regression test test_greedy_descent_stops_before_level_0 drives run_search / run_search_acorn over a hand-built 6-node, 2-level graph with a counting DistCalculator: the entry node is alone at level 1, so any greedy step at level 0 shows up as extra distance computations (6 expected, 16 pre-fix). I used a structural assertion rather than a recall threshold because on synthetic uniform data the recall delta is not sign-stable across graph sizes — the SIFT1M numbers in the issue are the recall evidence.

Verification

  • cargo test -p lance-index --lib: 887 passed
  • cargo test -p lance --lib -- vector index::: 724 passed
  • cargo clippy --all --tests --benches -- -D warnings: clean
  • cargo fmt --all: clean

Per the HNSW paper (Algorithm 2), the greedy (ef=1) descent should cover
only levels max_level-1 down to 1; level 0 must be searched solely by
the ef-bounded beam search starting from the level-1 entry point. The
query path was greedily descending through level 0 first, which
collapsed the beam's entry point into a deep local minimum -- extra
distance computations and measurably lower recall at low ef.

On SIFT1M (see the issue) stopping before level 0 is a repeatable
Pareto improvement: +3.7% recall@10 at ef=16, +0.5% at ef=32,
statistically equal recall at higher ef, and lower paired latency
throughout. The online builder's query path already used the
paper-correct range.

Change both HNSW::run_search and HNSW::run_search_acorn, and add a
structural regression test that drives the search over a hand-built
graph with a counting distance calculator: any greedy step at level 0
shows up as extra distance computations.

Fixes lance-format#5208
@github-actions github-actions Bot added A-index Vector index, linalg, tokenizer enhancement New feature or request labels Jul 28, 2026
@codecov

codecov Bot commented Jul 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.51163% with 3 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-index/src/vector/hnsw/builder.rs 96.51% 3 Missing ⚠️

📢 Thoughts on this report? Let us know!

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 enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: HNSW::search_inner incorrectly sets the entry point for the bottom level

1 participant