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.
- 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).
- 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.
- 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.
- 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.
- 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".
- 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.
- In real
IndexWritermerges 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. - 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.
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.
// 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();
}GraphBuilderKnnCollectorwrapsnew NeighborQueue(k, false)(HnswGraphBuilder.java:822), which isOrder.MIN_HEAP(NeighborQueue.java:59-62);pop()"Removes the top element" (:127-130). The top of a min-heap is the lowest score.HnswGraphSearcher.searchLeveldocuments 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).popToScratchsays the same for this collector: "sorted from worst to best" (HnswGraphBuilder.java:546-547).- 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. - Normal insertion (
addGraphNodeInternal) does the same walk withentryCandidates = new GraphBuilderKnnCollector(1)(HnswGraphBuilder.java:193,:334-340, "for levels > nodeLevel search with topk = 1"), so its pop is the best node.
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), viaMergingHnswGraphBuilder.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 calladdConnections.
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, deletes | calls | steps from a non-best node | repairs' share of merge search work (current / documented) | merge search work, documented vs current | recall@10 near deleted regions (current / documented) |
|---|---|---|---|---|---|
| 20,000, 16-d, 30% random | 34 | 49 of 49 | 0.45% / 0.19% | -0.3% | all queries 0.9386 / 0.9384 |
| 100,000, 32-d, 25% random | 66 | 169 of 169 | 0.14% / 0.08% | -0.1% | all queries 0.8040 / 0.8040 |
| 20,000, 16-d, 35% whole clusters | 9 | 4 of 10 | 0.27% / 0.24% | -0.0% | 0.9313 / 0.9313 |
| 20,000, 16-d, 20% region cores | 173 | 320 of 490 | 19.6% / 7.0% | -13.7% | 0.8700 / 0.7883 |
| 20,000, 16-d, 40% region cores (seed 1) | 348 | 659 of 1,005 | 27.4% / 12.7% | -17.0% | 0.8878 / 0.8268 |
| 20,000, 16-d, 40% region cores (seed 2) | 365 | 1,043 of 1,043 | 27.7% / 13.1% | -17.4% | 0.9005 / 0.8745 |
| 50,000, 32-d, 40% region cores | 552 | 1,638 of 2,189 | 44.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
initGraphalone 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.
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.
/*
* 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());
}
}
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.
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,
rebalanceGraphpromotes 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.internalor@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.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT); also fails on 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0 |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# 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- 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.
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.
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), seedDEADBEEF: 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 ofTestHnswFloatVectorGraph;TestLucene99HnswVectorsFormat,TestLucene99HnswVectorsFormatV0,TestKnnGraph,TestHnswMergeAbort,TestKnnFloatVectorQuery,TestKnnByteVectorQuery). ./gradlew :lucene:core:check -x testpasses with the repro test in place.- These suites check correctness, not recall or merge cost.
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.
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 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.
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
IndexWritermerges and the effect of two candidate fixes. The hunt later cut the repro to one 4-node test and re-checked it on main645d4fe14a20and releases; a tie-breaker added the paired merge probe.
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
addConnectionsintoHnswGraphBuilder; 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
rebalanceGraphignored 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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene), downstream trackers | 2026-11-01 | a 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-10 | no report; 13 related threads read in full, all different |
| Mailing lists (dev@, java-user@, issues@, commits@) | 2026-11-01 | 24 queries; the issues@ mirror for addConnections (8 mails, all read), which covers PR review comments | no report |
| JIRA archive and the web | 2026-11-01 | 4 JIRA and 6 web queries | JIRA: no hits (the code dates from 2025, after the JIRA migration); web: 35 distinct pages, none reports it |
| Earlier searches | 2026-10-22, 2026-10-31 | the study's 16 GitHub queries and list searches; the reproducer's and tie-breaker's GitHub checks | no report |
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).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the 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 | affected | the 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 commit | fail; 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 affected | no repair path: graphs with deletions are not reused |
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.
| Check | Verdict | What it found |
|---|---|---|
| study verifier | real, performance | Own 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 | real | The 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 priority | Re-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 #15003 | Commit-level runs on main and branch_10x; every release with the code fails. |
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.
- PNG 495 KB
Finding record, Lucene and upstream sources, repro files and diagram notes566 words
- 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; #15003ef10476d98aand its backport3e024479d358fail and their parents do not compile the test (commit-level runs); 10.3.2 has no repair path. Merge probe:IndexWriter.forceMergewith the default codec on645d4fe14a20, 7 paired configurations.
- 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)
- TestAddConnectionsRepairDescent.java (one test):
repro/LFV-068/src/org/apache/lucene/util/hnsw/TestAddConnectionsRepairDescent.java(49 lines)
Made from the minimal repro's 4-node graph and an instrumented trace of the stock code and of the size-1 walk.
assets/LFV-068/bug.png(495 KB)
