All findings · Lucene finding · LFV-068 · hnsw-addconnections-worst-candidate

HNSW merge repair (addConnections) walks down from the farthest upper-level candidate, not the closest one its javadoc describes

When a merge repairs a reused HNSW graph, addConnections walks down from the farthest upper-level candidate, not the closest one its javadoc names. After deletes concentrated in one region this costs 16-41% more merge search work, yet kept better recall than the documented walk: maintainers must choose.

Summary

Priority
Low, rank 940HNSW addConnections descends from the worst upper-level candidate on every merge with deletes, but the extra cost is negligible and naive fixes lowered recall, so it needs benchmarks before any change.
Impact
minor
Since
10.4.0 (#15003, merged 2025-12-01)introduced 2025-12-01
Affected
10.4.0-10.6.0 and main; default-codec merges since 10.5.1
Status
documented
Found by
formal: TLA+ model checking (TLC), property DescentGreedy

AI-assisted page: this finding was first signalled by a formal tool's own output and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-03.

The finding in 30 seconds

When a merge reuses a segment's HNSW graph with deletions, addConnections re-links nodes left with no neighbours and nodes promoted to a higher level. On each upper level it collects up to 100 candidates in a min-heap and continues from popNode(), the top of that heap: the farthest candidate, not the closest one its javadoc names.

HNSW merge repair in three lines

  1. An HNSW graph has levels: level 0 holds every vector, each level above holds about 1 in 16 of the level below (default M = 16).
  2. To place a vector, Lucene walks down: on each upper level it moves to the node closest to the new vector, then searches level 0 for its neighbours.
  3. Since 10.4.0 a merge can reuse a graph that has deletions: it drops the deleted nodes, re-links nodes left with no neighbours, and promotes nodes to keep the upper levels full. Re-linking and promotion both do that walk, in addConnections.
The minimal repro's HNSW graph: E at 0 (entry) and P at 100 on level 1, X at 1 on level 0 after its only neighbour D was deleted. The level-1 search finds E (distance 1) and P (distance 99); the current code continues from P and links X to P; the documented walk would continue from E and link X to E.
The repro's graph on a number line: the level-1 search from E finds E (distance 1) and P (distance 99); the code continues from P, the top of the min-heap, so X is re-linked to P instead of E. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You delete documents and merge. Since 10.4.0 the merge can reuse the HNSW graph of the segment with the most live vectors (if at most 40% of it is deleted): it drops the deleted nodes and re-links each node left with no neighbours by walking down the upper levels towards it, then searching level 0.
  2. Tiny example, 1-D vectors: the entry E at 0 and P at 100 are on level 1; X at 1 lost its only neighbour. The level-1 search finds E (distance 1) and P (distance 99). You expect the walk to go on from E, as the javadoc says: "find the closest node".
  3. It goes on from P, the farthest: it takes the top of a 100-slot min-heap, which is the worst candidate. X is re-linked to P (distance 99), not E (distance 1). Normal insertion does the same walk with a 1-slot collector and gets E.
  4. In real IndexWriter merges with the default codec, every walk step started off target in the random-delete runs (49 of 49 and 169 of 169), and 40-100% of them in the other runs. With random deletes the repairs are under 0.5% of the merge's graph-search work and recall moved by at most 0.0002.
  5. After deletes concentrated in one region of vector space, repairs are 20-44% of that work. The documented walk would cut the merge's total by 14-29%, but measured 2.6-8.2 points lower recall@10 near the deleted regions (synthetic data): a mismatch with a trade-off, not a recall bug.

Who is affected

Anyone whose merges reuse an HNSW graph with deletes: the default codec since 10.5.1 (10.4.0 and 10.5.0 only with a non-per-field vectors format), with the single-worker or the concurrent merger. With random deletes the effect is negligible; after deletes concentrated in vector space, such as one tenant's documents, repairs become 20-44% of merge search work. No errors.

Mechanism, terms and numbers597 words

Since 10.4.0 (#15003) a merge can reuse a segment's HNSW graph that has deletions: it copies the graph without the deleted nodes, re-links every node left with no neighbours on a level, and promotes nodes to keep the upper levels populated. Both steps call HnswGraphBuilder.addConnections, whose javadoc says it will "Navigate down from the top level to find the closest node at the target level". The loop instead collects up to beamWidth (default 100) candidates per upper level in a min-heap GraphBuilderKnnCollector and continues from popNode(), the top of that heap, which is the farthest of the candidates it kept. Normal insertion does the same walk with a size-1 collector.

The minimal test shows the choice directly: X (at 1) is re-linked to P (at 100) instead of E (at 0). On the real merge path, with the default codec, every walk step started from a non-best node in the random-delete runs, and 40-100% of them in the others. With random deletes the effect is negligible. After deletes concentrated in vector space, the documented walk would cut the merge's graph-search work by 14-29%, but recall@10 near the deleted regions was 2.6-8.2 points lower with it in synthetic probes. So this is a mismatch between code and documented intent with a measured trade-off; no recall loss from the current code is claimed, and the remedy (change the walk after a benchmark, or document it) is for the maintainers to choose.

The code (main 645d4fe14a20)

// HnswGraphBuilder.java:774 (javadoc): Navigate down from the top level to find the closest node at the target level
int beamWidth = beamCandidates.k();                                              // :787
GraphBuilderKnnCollector candidates = new GraphBuilderKnnCollector(beamWidth);   // :788
int[] eps = {hnsw.entryNode()};                                                  // :789
// Navigate down from top to target level, greedily moving toward the new node   // :791
for (int level = hnsw.numLevels() - 1; level > targetLevel; level--) {
  graphSearcher.searchLevel(candidates, scorer, level, eps, hnsw, null);         // :793
  eps[0] = candidates.popNode();                                                 // :794
  candidates.clear();
}
  1. GraphBuilderKnnCollector wraps new NeighborQueue(k, false) (HnswGraphBuilder.java:822), which is Order.MIN_HEAP (NeighborQueue.java:59-62); pop() "Removes the top element" (:127-130). The top of a min-heap is the lowest score.
  2. HnswGraphSearcher.searchLevel documents the order: "the most distant neighbor of the topK found, i.e. the one with the lowest score/comparison value, will be at the top of the heap, while the closest neighbor will be the last to be popped" (HnswGraphSearcher.java:272-277). popToScratch says the same for this collector: "sorted from worst to best" (HnswGraphBuilder.java:546-547).
  3. So at each upper level the walk continues from the farthest of up to beamWidth (default 100) candidates; on a small top level, from the farthest reachable node of the level.
  4. Normal insertion (addGraphNodeInternal) does the same walk with entryCandidates = new GraphBuilderKnnCollector(1) (HnswGraphBuilder.java:193, :334-340, "for levels > nodeLevel search with topk = 1"), so its pop is the best node.

Who calls it

  • fixDisconnectedNodes, for a node with no surviving neighbours on a level: "Node has no neighbors, add connections from scratch" (HnswGraphBuilder.java:757-759).
  • rebalanceGraph, for every promoted node that is not the first on its level (InitializedHnswGraphBuilder.java:472-478).
  • Both run whenever the reused graph has deletes (InitializedHnswGraphBuilder.java:303-316). Among the input graphs with at most 40% deleted vectors (an integer percentage), a merge reuses the one with the most live vectors (IncrementalHnswGraphMerger.java:65, :133-139), via MergingHnswGraphBuilder.fromGraphs (MergingHnswGraphBuilder.java:107-109), the default single-worker merger (Lucene99HnswVectorsWriter.java:688); the concurrent merger calls the same two methods (HnswConcurrentMergeBuilder.java:199, :427). Flush-time building and query-time search do not call addConnections.
Exposure and likelihood496 words

Default vs opt-in. In 10.4.0 and 10.5.0 a guard tested the per-field wrapper, "so every merge rebuilt the graph from scratch" (CHANGES.txt, GITHUB#16400 under 10.5.1); there the repair runs only with a codec whose knnVectorsFormat() is not the per-field wrapper. From 10.5.1 on it runs with the default codec.

Measured on the real merge path. IndexWriter with the default codec and HNSW format (M = 16, beamWidth = 100, one merge worker); segment A with deletes plus segment B (1,000 docs) without; forceMerge(1) reuses A's graph. Paired runs, current code vs the documented (size-1) walk, synthetic 50-cluster Gaussian data, 500 queries, recall@10 with k = 10:

A: docs, dims, deletescallssteps from a non-best noderepairs' share of merge search work (current / documented)merge search work, documented vs currentrecall@10 near deleted regions (current / documented)
20,000, 16-d, 30% random3449 of 490.45% / 0.19%-0.3%all queries 0.9386 / 0.9384
100,000, 32-d, 25% random66169 of 1690.14% / 0.08%-0.1%all queries 0.8040 / 0.8040
20,000, 16-d, 35% whole clusters94 of 100.27% / 0.24%-0.0%0.9313 / 0.9313
20,000, 16-d, 20% region cores173320 of 49019.6% / 7.0%-13.7%0.8700 / 0.7883
20,000, 16-d, 40% region cores (seed 1)348659 of 1,00527.4% / 12.7%-17.0%0.8878 / 0.8268
20,000, 16-d, 40% region cores (seed 2)3651,043 of 1,04327.7% / 13.1%-17.4%0.9005 / 0.8745
50,000, 32-d, 40% region cores5521,638 of 2,18944.2% / 22.4%-28.9%0.7711 / 0.7190
  • "Region cores": cluster centres are taken in turn and every vector within sqrt(1.5 x dim) of the centre is deleted until the target count is reached (the last centre may be only partly cleared), a stress case for deletes concentrated in vector space (for example a delete-by-query of one tenant, topic or time range whose embeddings sit together). Other queries were unchanged (within 0.0004).
  • Read the other way: after such deletes the current code does 16%, 20%, 21% and 41% more merge graph-search work, and keeps 2.6-8.2 points better recall@10 near the deleted regions.
  • "Search work" counts nodes scored in graph-builder searches, not wall time. CheckIndex was clean in all 19 runs. In the two configurations also run on the plain stock jar (20% region cores, and 40% region cores seed 1), recall matched the instrumented stock runs exactly.
  • Calls and walk steps are those of the current-code run; the documented-walk run made the same or slightly fewer repair calls (34/34, 65/66, 9/9, 166/173, 338/348, 355/365, 528/552).
  • An earlier study measurement of initGraph alone points the same way: the documented walk cut its score() calls by 11.7-30.2% in 8 region-delete configurations and lowered recall@10 in all 8 (-0.9 to -7.4 points); with random deletes it cut them by under 0.1%, with identical recall. Its unverified explanation: far entry points give orphaned nodes long-range links that act as bridges in a fragmented graph.
  • Not measured: real embeddings, luceneutil, wall-clock merge time.

Minimal repro

One JUnit test, one method, deterministic, single thread. It hand-builds a 4-node graph with 1-D vectors (same-package access, only for a package-private constructor) and calls the public InitializedHnswGraphBuilder.initGraph, as a merge does. It asserts that X, whose only neighbour was deleted, is re-linked through E, the level-1 node closest to it.

TestAddConnectionsRepairDescent.java (one test)49 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.util.hnsw;

import java.util.List;
import org.apache.lucene.codecs.hnsw.DefaultFlatVectorScorer;
import org.apache.lucene.index.FloatVectorValues;
import org.apache.lucene.index.VectorSimilarityFunction;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestAddConnectionsRepairDescent extends LuceneTestCase {

  private static final int E = 0, P = 1, X = 2, D = 3;

  public void testRepairDescendsFromClosestUpperLevelNode() throws Exception {
    OnHeapHnswGraph graph = new OnHeapHnswGraph(16, 4);
    for (int node : new int[] {E, P, X, D}) {
      graph.addNode(0, node);
    }
    graph.addNode(1, E);
    graph.addNode(1, P);
    graph.trySetNewEntryNode(E, 1);
    graph.getNeighbors(1, E).addOutOfOrder(P, Float.NaN);
    graph.getNeighbors(0, X).addOutOfOrder(D, Float.NaN);
    List<float[]> vectorsOfEPX = List.of(new float[] {0}, new float[] {100}, new float[] {1});
    RandomVectorScorerSupplier scorers =
        DefaultFlatVectorScorer.INSTANCE.getRandomVectorScorerSupplier(
            VectorSimilarityFunction.EUCLIDEAN, FloatVectorValues.fromFloats(vectorsOfEPX, 1));
    OnHeapHnswGraph merged =
        InitializedHnswGraphBuilder.initGraph(graph, new int[] {E, P, X, -1}, 3, 100, scorers);
    merged.seek(0, X);
    // X lost its only neighbor D: expected a descent via E (closest to X), actual via P
    assertEquals("X's level-0 neighbor (E=0, P=1)", E, merged.nextNeighbor());
  }
}
Command
cd $LUCENE   # an apache/lucene checkout at 645d4fe14a20 (or any commit since #15003)
cp $REPRO/TestAddConnectionsRepairDescent.java lucene/core/src/test/org/apache/lucene/util/hnsw/
./gradlew :lucene:core:test --tests org.apache.lucene.util.hnsw.TestAddConnectionsRepairDescent -Ptests.seed=DEADBEEF
# assertions off (production): add -Ptests.asserts=false; the result is the same
Expected
X (at 1) is re-linked to E (at 0, distance 1), the level-1 node closest to it.
Observed
AssertionError: X's level-0 neighbor (E=0, P=1) expected:<0> but was:<1>: X is linked to P (at 100, distance 99), with assertions on and off.

All files of this repro: repro/LFV-068/

More files, environment, full output349 words

The source graph: E at 0 (the entry, levels 0 and 1), P at 100 (levels 0 and 1), X at 1 and D (level 0); level-1 link E -> P, level-0 link X -> D. D is deleted (ord map {0, 1, 2, -1}, 3 surviving vectors, beamWidth 100).

  • X kept 0 of its 1 level-0 neighbours, so the repair calls addConnections(X, 0). The level-1 search from E finds E (score 0.5) and P (score 0.000102); popNode() returns P.
  • E and P have no level-0 links, so the level-0 search finds only the node it starts from: the start shows up directly as X's neighbour. On a connected level 0 the beam search usually recovers, and the cost is extra work instead.
  • With 3 nodes and M = 16, rebalanceGraph promotes nothing. Nothing is randomized; the seed only fixes the test framework's own choices.
  • Access: the package-private OnHeapHnswGraph(int, int) constructor, from the same package as Lucene's own HNSW tests; everything else is public (some of it @lucene.internal or @lucene.experimental). The test compiles unchanged against 10.4.0-10.6.0 and main.
  • As a regression test: the same test, but only if the maintainers choose the greedy walk.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, main 645d4fe14a20, seed DEADBEEF (same with -Ptests.asserts=false)
TestAddConnectionsRepairDescent > testRepairDescendsFromClosestUpperLevelNode FAILED
    java.lang.AssertionError: X's level-0 neighbor (E=0, P=1) expected:<0> but was:<1>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:36C37F5C40E3DE23]:0)
        ...
        at org.apache.lucene.util.hnsw.TestAddConnectionsRepairDescent.testRepairDescendsFromClosestUpperLevelNode(TestAddConnectionsRepairDescent.java:47)
1 test completed, 1 failed

# with the size-1 walk (fix option B): :lucene:core:test (SUCCESS): 1 test, then BUILD SUCCESSFUL (assertions on and off)

# instrumented copy of the stock code (print statements only)
TRACE node 2 has 0 neighbors on level 0 -> addConnections(2, 0)
TRACE level 1 searchLevel from eps [0]: size 2 k 100 best-first [score:node] [0.50000000:0, 0.00010202:1] heap top (popNode returns it) node 1 score 0.00010202
TRACE level 1 popNode() -> eps[0] = 1
TRACE node 2 level 0 neighbors after addDiverseNeighbors [1]

# release jars, same test: 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 fail with the same message

Why it looks unintended

  • The javadoc says the walk finds "the closest node at the target level"; the loop comment says "greedily moving toward the new node".
  • Its heap documents the top as "the most distant neighbor"; normal insertion does the same walk with a 1-slot collector.
  • No PR discussion, test, CHANGES or MIGRATE entry gives a reason to start from the farthest candidate.
The full argument206 words
  • Own documentation: "Navigate down from the top level to find the closest node at the target level" (HnswGraphBuilder.java:774) and "greedily moving toward the new node" (:791).
  • The primitive documents its order: "the most distant neighbor ... will be at the top of the heap" (HnswGraphSearcher.java:272-277); "sorted from worst to best" (HnswGraphBuilder.java:546-547).
  • The sibling walk in normal insertion uses a size-1 collector for this exact step (HnswGraphBuilder.java:193, :334-340).
  • Other code and PR text describe it as a descent from the entry node to the level: the concurrent merger orders repairs "because addConnections descends through the already-repaired upper levels" (HnswConcurrentMergeBuilder.java:435-436); #16627's PR body says the zero-neighbour path "walks from the entry node to reach the current level". Neither says which candidate the descent continues from.
  • History: #15003 wrote the method with this loop, javadoc and comment; #16627 moved it unchanged. Neither PR's discussion (#15003: 48 comments, 19 review comments; #16627: 8 comments, 36 review comments) mentions which candidate the walk starts from, and CHANGES.txt and MIGRATE.md document nothing about it.
  • Tests check that repaired nodes have neighbours and that the graph stays rooted and searchable; none pins where the walk starts.
The devil's-advocate check214 words

The reviewer from another model family (OpenAI GPT-5.6) built the strongest case for "intended", which it rated moderate: the repair is a heuristic, and a non-closest seed can add exploration or diversity; the public builders are @lucene.experimental and promise a usable approximate graph, not particular links; tests check only rootedness and searchability; the failing graph is hand-built; and the documented walk lowered recall in the region-delete measurements.

Why it fails: the method's own javadoc and comment are the contract the code contradicts, and @lucene.experimental does not make a contradiction intended. The same choice, with changed links, shows on the public IndexWriter.forceMerge path. Tests that check only rootedness are missing coverage, not a statement of intent. Nobody designed or documented a farthest-candidate start, and its recall benefit is measured here, not explained anywhere upstream. The recall result is why the remedy is open (document it, or change it after benchmarks), not a sign that the mismatch is intended. Verdict: a real mismatch, low priority; which side to fix is the maintainers' call. Limits: the javadoc and comment are internal documentation, not a public contract, and the silence of the PR threads is absence of evidence; so this shows a mismatch between the code and its local documentation, not what the repair was meant to do.

Suggested fix

Recommended Settle which walk is intended with a recall benchmark

Settle which walk is intended with a recall benchmark: neither side wins yet. The documented walk is a one-hunk change, a 1-slot collector as in normal insertion; with it the repro and 339 HNSW and kNN tests pass. It cut merge search work 14-29% after region deletes but lowered recall there 2.6-8.2 points. Else, document the current walk.

All options and trade-offs4 options · 659 words

Four options. The greedy walk was validated with gradle; none was benchmarked for recall or merge time with luceneutil.

Settle which walk is intended with a recall benchmark (not run) (recommended)

Which behaviour is intended is the maintainers' choice. Benchmark the greedy walk against main with luceneutil on real embeddings, with random deletes and with deletes concentrated in vector space, measuring merge time and recall. If the greedy walk holds recall, take it; if not, document the current walk.

Trade-offs

Slower than a patch, but the only measurements so far (synthetic data) point in opposite directions for cost and recall, so a patch either way would be a guess.

What the checks validated

Not run (no luceneutil benchmark was done).

Make the walk greedy, as documented: a size-1 collector, as normal insertion uses (validated by the checks)

+4/-3 lines in HnswGraphBuilder.addConnections: the upper-level searches go into their own GraphBuilderKnnCollector(1), so popNode() returns the closest candidate.

     // Navigate down from top to target level, greedily moving toward the new node
+    GraphBuilderKnnCollector entry = new GraphBuilderKnnCollector(1);
     for (int level = hnsw.numLevels() - 1; level > targetLevel; level--) {
-      graphSearcher.searchLevel(candidates, scorer, level, eps, hnsw, null);
-      eps[0] = candidates.popNode();
-      candidates.clear();
+      entry.clear();
+      graphSearcher.searchLevel(entry, scorer, level, eps, hnsw, null);
+      eps[0] = entry.popNode();
     }

Trade-offs

  • Matches the javadoc and the sibling walk; each upper-level search keeps 1 result instead of up to 100.
  • Measured: with random deletes -0.1% to -0.3% merge graph-search work and recall within 0.0002; after region-concentrated deletes it cuts the merge's graph-search work by 14-29% but lowers recall@10 near the deleted regions by 2.6-8.2 points (synthetic data). The study's initGraph-only runs agree: -11.7% to -30.2% score() calls, recall -0.9 to -7.4 points in 8 of 8 region-delete configurations.
  • The change alters two things at once: which candidate the walk continues from, and how far each upper-level search explores (1 result kept instead of up to 100). So the cost and recall differences cannot be credited to the nearest-versus-farthest choice alone; the beam-search variant below separates the two in part.
  • Changes repaired links only in merges that reuse a graph with deletes.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (removed afterwards), seed DEADBEEF: without the change the repro fails with assertions on and off; with it, it passes with assertions on and off.
  • The 16 closest suites pass: 339 tests, 0 failures, 9 skipped (340 tests in 17 classes with the repro itself; all of org.apache.lucene.util.hnsw.*, including the repair and merge-reuse tests of TestHnswFloatVectorGraph; TestLucene99HnswVectorsFormat, TestLucene99HnswVectorsFormatV0, TestKnnGraph, TestHnswMergeAbort, TestKnnFloatVectorQuery, TestKnnByteVectorQuery).
  • ./gradlew :lucene:core:check -x test passes with the repro test in place.
  • These suites check correctness, not recall or merge cost.

Keep the behaviour and document it

Change the javadoc (HnswGraphBuilder.java:774) and the loop comment (:791) to say the walk continues from the farthest of the candidates it kept, and why.

Trade-offs

No behaviour change, so no runtime risk; the remaining risk is documenting behaviour that may be accidental as intended. But it keeps the 16-41% extra merge graph-search work after region-concentrated deletes, and nobody has shown why the far start helps recall (the study's bridge explanation is unverified). If the benefit is real, a cheaper way to get it could be explored.

Keep the beam search per level, but continue from its best candidate (validated by the checks)

Pop all but the last candidate before taking eps[0] (the study's second patch).

Trade-offs

Same rule as the greedy walk (continue from the best candidate found), but over a full beamWidth search per level, so it can pick a different entry than the greedy walk, and it saves less (-9.6% to -27.4% initGraph score() calls) and lost more recall (-2.4 to -10.3 points) in the study's 8 region-delete configurations. The greedy walk is the simpler form.

What the checks validated

Only by the study's own tests (classpath shadowing, 2026-10-22).

Downstream note

Downstream engines that merge vector indexes after deletes, for example after removing one tenant's documents, run this repair on every such merge; it affects merge cost and which links repaired nodes get, not errors or which documents can match.

How it was found

TLA+ model checking (TLC) of Lucene's HNSW merge repair. A property written before any look for this bug, DescentGreedy ("addConnections descends to the best candidate"), failed in a 7-step trace (16 states, 1 s). An independent verifier and later checks reproduced it on the stock jars and measured it in real merges.

The method in full167 words

Method: TLA+ model checking (TLC) of the HNSW build, merge and repair code (the formal verification study, 2026-10-22, main 7cecf79523).

  • An AI modeller wrote a TLA+ model of the concurrent HNSW build, merge and repair, with a mapping table from model actions to Java lines (the action that takes eps[0] = candidates.popNode() maps to line 794).
  • Among its properties, DescentGreedy ("addConnections descends to the best candidate") was written before looking for this bug. On configuration R3 (2 levels, one node with no neighbours) TLC reported its violation in a 7-step trace: the level-1 search finds nodes 0 and 1 and the walk continues from 0, the worse one (16 distinct states, 1 s).
  • An independent AI verifier wrote its own tests on the stock jars (main and 10.5.2), measured reach in real IndexWriter merges and the effect of two candidate fixes. The hunt later cut the repro to one 4-node test and re-checked it on main 645d4fe14a20 and releases; a tie-breaker added the paired merge probe.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and review comments, the dev@, java-user@ and issues@ lists, JIRA or downstream trackers (searched 2026-11-01, after searches on 2026-10-22 and 2026-10-31). The PRs that wrote and moved the method never discuss where the walk starts. Not fixed upstream.

Searches4 searches

Closest items, all different:

  • #16627 (merged 2026-10-08): moved addConnections into HnswGraphBuilder; body, comments and review comments read, nobody discusses where the walk starts.
  • #15003 (merged 2025-12-01): wrote it; the descent choice is never mentioned.
  • #15470 (closed): back-links in addDiverseNeighbors, not the walk.
  • #16726 (open): reported that rebalanceGraph ignored the seed (on main it has been seeded since #16627); same caller, different defect.
  • #15778 (open): query-time findBestEntryPoint; a maintainer replied that, as far as they knew, the cost of starting from a "bad" top-level node had never been measured.
  • #14214 (open): the cost of connectComponents.

Open PRs touching the same files (#16956, #16806, #16915, #16555) do not change the walk.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene), downstream trackers2026-11-01a scripted prior-report search (22 GitHub, 3 commit and 2 downstream queries) plus 15 more searches, for example rebalanceGraph, fixDisconnectedNodes, GraphBuilderKnnCollector, entryCandidates, "popNode worst", "min heap" popNode, "farthest node entry point"; 7 downstream searches; open PRs by file; every HNSW issue and PR active since 2026-10-10no report; 13 related threads read in full, all different
Mailing lists (dev@, java-user@, issues@, commits@)2026-11-0124 queries; the issues@ mirror for addConnections (8 mails, all read), which covers PR review commentsno report
JIRA archive and the web2026-11-014 JIRA and 6 web queriesJIRA: no hits (the code dates from 2025, after the JIRA migration); web: 35 distinct pages, none reports it
Earlier searches2026-10-22, 2026-10-31the study's 16 GitHub queries and list searches; the reproducer's and tie-breaker's GitHub checksno report

History

Introduced in 10.4.0 by #15003 (ef10476d98a, merged 2025-12-01), which wrote addConnections with this walk; #16627 moved it unchanged (10.6.0). The repro fails on 10.4.0, 10.5.0-10.5.2, 10.6.0 and main; 10.3.2 has no repair path. With the default codec, merges reach it since 10.5.1.

Versions tested4 rows

Introduced by #15003 "Avoid reconstructing HNSW graphs during segment merging." (@Pulkitg64), merged 2025-12-01 as ef10476d98a on main and released through the branch_10x backport 3e024479d358 (CHANGES.txt, 10.4.0). It wrote the method this way, so it is not a regression. #16627 (merged 2026-10-08 as d8468fd9375 on main; first shipped in 10.6.0 through the backport a58062065eb) moved it unchanged into HnswGraphBuilder. In 10.4.0 and 10.5.0 graph reuse was off under the default per-field codec (GITHUB#16400, fixed in 10.5.1).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe test fails with assertions on and off (gradle and the standalone runner)
10.6.0, 10.5.2, 10.5.1, 10.5.0, 10.4.0 affectedthe test fails with assertions on and off; with the default codec, merges reach the code only from 10.5.1
ef10476d98a (#15003) and backport 3e024479d358 introducing commitfail; their parents fb3cc564a287 and 268778f0df71 have no repair path (the test does not compile); commit-level runs compile each commit's core source ahead of the 10.4.0 jars
10.3.2 and earlier not affectedno repair path: graphs with deletions are not reused

Independent checks

Real, low priority. A current-main reproducer and a tie-breaker re-ran the test on main and releases with assertions on and off and probed real IndexWriter merges. A reviewer from another model family (OpenAI GPT-5.6), read-only, agreed on the root cause but judged it not a bug on contract and severity grounds; the tie-breaker answered each point.

Checks and verdicts177 words

Every check used the same single test; the merge probe is supporting evidence, not a repro.

Each independent check and its verdict
CheckVerdictWhat it found
study verifier real, performanceOwn tests on main and 10.5.2; reach in real merges (49 of 49 and 169 of 169 steps from a non-best node, with random deletes); paired measurement of two fixes: less work, lower recall in 8 of 8 region-delete configurations.
current-main reproducer realThe test fails on 645d4fe14a20 (assertions on and off), 10.4.0, 10.5.2 and 10.6.0; the size-1 walk passes.
cross-model review (OpenAI GPT-5.6, refute) not real, medium confidence (read-only)Could not run commands; agreed with the root cause; argued that the experimental builders promise no particular links and that the effect is severity, not correctness.
tie-breaker real, low priorityRe-ran the test on main and three releases, checked the fix, and ran the paired IndexWriter.forceMerge probe (7 configurations) to answer the hand-built-graph objection.
history check introduced by #15003Commit-level runs on main and branch_10x; every release with the code fails.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes566 words

Finding record

LFV-068, snapshot of 2026-11-03

Registry title
HNSW addConnections descends from the worst upper-level candidate instead of the best
Area
search / HNSW
Priority
Low, rank 940: HNSW addConnections descends from the worst upper-level candidate on every merge with deletes, but the extra cost is negligible and naive fixes lowered recall, so it needs benchmarks before any change.
Impact class
minor
Impact note
Default configuration since 10.5.1 (in 10.4.0 and 10.5.0 only with a vectors format that is not the per-field wrapper, GITHUB#16400) during merge-time HNSW repair and rebalance; performance only (49 of 49 descent steps were not the best in real merges); released since 10.4.0.
Summary
During merge-time HNSW graph repair and rebalancing, addConnections descends through the upper levels to find entry points. You expect it to descend from the best candidate, as its javadoc says. Instead it pops from a min-heap and descends from the worst; the naive fixes cut distance computations by 10-30% but lowered recall in 8 of 8 region-delete configurations.
Introduced
#15003 (ef10476d98a) [read: the code first appeared there; CHANGES lists it under 10.4.0] (2025-12-01 (#15003 merged))
First affected version
10.4.0 [exec: the minimal test fails on the 10.4.0 release jars; 10.3.2 has no repair path and cannot compile it]
Affected versions
10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0 and main 645d4fe14a20 [exec: the minimal test fails on each release jar (SHA-1 checked) and on main, with -ea and -da]; not affected: 10.3.2 and earlier [exec: no repair path, the test does not compile]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that could only read the code and voted “not real” while agreeing with the root cause (the study's other checks rated it real); not yet triaged by a person.
Found by
formal: TLA+/TLC
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main 645d4fe14a20: gradle (stock fails; the size-1 change passes; assertions on and off) and the standalone runner; release jars 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0 (SHA-1 checked), all fail; #15003 ef10476d98a and its backport 3e024479d358 fail and their parents do not compile the test (commit-level runs); 10.3.2 has no repair path. Merge probe: IndexWriter.forceMerge with the default codec on 645d4fe14a20, 7 paired configurations.

Lucene and upstream sources

The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java at 645d4fe14a20 (lines 193, 334-340, 546-547, 757-759, 768-806, 821-832)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/NeighborQueue.java at 645d4fe14a20 (lines 59-62, 127-130)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java at 645d4fe14a20 (lines 272-277)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/InitializedHnswGraphBuilder.java at 645d4fe14a20 (lines 303-316, 435-482)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/IncrementalHnswGraphMerger.java, MergingHnswGraphBuilder.java, HnswConcurrentMergeBuilder.java at 645d4fe14a20 (lines in the text)
Who is affected
  • lucene/CHANGES.txt at 645d4fe14a20 (lines 875, 886-888)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java at 645d4fe14a20 (lines 668-688)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java at 645d4fe14a20 (lines 193, 334-340, 546-547, 774, 791)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java at 645d4fe14a20 (lines 272-277)
  • lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java at 645d4fe14a20 (lines 435-436)
History
  • lucene/CHANGES.txt at 645d4fe14a20 (lines 886-888, 1298, 1444)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

Made from the minimal repro's 4-node graph and an instrumented trace of the stock code and of the size-1 walk.

Built 2026-11-03 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.