- An HNSW graph has levels: level 0 holds every vector (node), each higher level a random, shrinking subset. Every search starts at the entry node on the top level and walks down.
- Inserting a node means searching the graph with that node's vector as the query, then linking it to the best neighbours found, filtered by a diversity rule.
- A node drawn taller than the graph becomes the new entry node. With several merge workers, another worker may raise the graph first; then the node must also be searched and linked on the levels that worker added.
- Since 10.2.0 the builder reuses one scorer and moves its query with
setScoringOrdinal: to the inserted node, and during the diversity rule to each candidate.
- You turn on concurrent HNSW merging, e.g.
Lucene99HnswVectorsFormat(16, 100, 8, executor); the default is 1 merge worker. Several workers then insert vectors into one graph, and each scores with one reusable scorer that it points at the node it is working on. - Two workers each insert a node that is taller than the graph. Only one can become the new entry node; the loser must redo its search and linking on the levels the winner just added, with its own node's vector.
- You expect that redo to score from the inserted node. In the 4-vector repro (1-d values 0, 1, 3, 7, similarity 1/(1 + d²)), node 1's new link to node 3 should store sim(1, 3) = 1/37 = 0.027.
- Instead it stores 0.02 = 1/50 = sim(0, 3): while linking node 1 on level 0 the worker pointed its scorer at neighbour 0, and the redo never points it back. Its search, scores and neighbour choice on the new levels all come from node 0's vector.
- No error is raised. In test merges of 16,384 random vectors with 16 workers, 19 of 400 merges took the redo, and in 4 of its 22 runs a dry run of the redo selected different neighbours with the stale query than with the node's own vector. Recall impact was not measured.
Only concurrent HNSW merges (numMergeWorkers > 1); the default is 1 worker, and flush never takes this path. A hit needs two workers to raise the graph at the same moment: about 1.5-5% of 16,384-vector merges in tests, each touching one node's links on one or two top levels. No exception, no index corruption.
Mechanism, terms and numbers561 words
With concurrent HNSW merging (numMergeWorkers > 1 plus an executor; the default is 1 worker), several workers insert vectors into one graph. A worker that inserts a node taller than the graph links it on the existing levels and then tries to make it the new entry node. If another worker raised the graph first, the promotion fails and HnswGraphBuilder.addGraphNodeInternal repeats the search and linking for the new levels. Since 10.2.0 (#14181) the builder reuses one mutable UpdateableRandomVectorScorer instead of creating a scorer per node (on main, one per worker), pointed at the inserted node once before the insert; linking re-points that same scorer at every candidate, and the retry never points it back. So the retry searches, scores and applies the diversity rule with another node's vector. No exception is thrown and the index is not corrupted; per hit one node's links on one or two top levels are affected. A one-line fix re-points the scorer before the retry.
addGraphNode(HnswGraphBuilder.java:392-402) callsscorer.setScoringOrdinal(node)once (:394/:400) and thenaddGraphNodeInternal(node, scorer, ...). In a concurrent merge each worker is its own builder with its own scorer (HnswConcurrentMergeBuilder.java:153-165).addGraphNodeInternalrunsdo { ... } while (true)(:323-389): greedy descent withscoreras the query (:338), beam search per level (:353),popToScratchkeeps those scores (:356),addDiverseNeighborslinks with them (:360-362), thentryPromoteNewEntryNode(:372). On failure the loop repeats for the new levels. Nothing in the loop re-points the scorer atnode.- Linking moves the scorer:
selectAndLinkDiversecallsscorer.setScoringOrdinal(cNode)for every candidate (:515), andNeighborArrayre-points it when a neighbour array overflows (NeighborArray.java:141,:301). OnHeapHnswGraph.tryPromoteNewEntryNode(:249-256) fails only when another thread promoted first (the entry level changed, or itscompareAndSetlost to a concurrent promotion), so single-threaded builds (flush, the default merger) never retry.- Every worker draws its levels from a random generator seeded with the same
HnswGraphBuilder.randSeed(HnswConcurrentMergeBuilder.java:160,HnswGraphBuilder.java:185), one draw per node it inserts, so the k-th node each worker inserts (e.g. the same position in each worker's first batch) gets the same level, and workers tend to raise the graph at the same moment.
Four 1-d vectors 0, 1, 3, 7; M = 2; two workers with batch size 2 and randSeed 42, so each worker's first node gets level 0 and its second level 2.
- Worker 0 inserts node 0 (level 0); it becomes the entry.
- Worker 0 inserts node 1 (level 2):
setScoringOrdinal(1), searches level 0 and finds node 0 (score 0.5). - While linking level 0,
selectAndLinkDiversecallssetScoringOrdinal(0). At that moment worker 1 inserts node 2 (level 0) and node 3 (level 2); node 3's promotion succeeds and the graph now has 3 levels. - Worker 0's
tryPromoteNewEntryNode(1, 2, 0)fails, and the retry searches levels 1-2 from entry 3 with the scorer still on node 0: it stores 0.02 = sim(0, 3) instead of 0.027027028 = sim(1, 3), on levels 1 and 2, in both directions (4 wrong entries; the test stops at the first).
In this tiny graph node 3 is the only candidate, so only the scores differ; in larger graphs the wrong query can also select different neighbours (see Who is affected).
Exposure and likelihood321 words
- Who is exposed. Applications that enable concurrent HNSW merging:
numMergeWorkers > 1with an executor onLucene99HnswVectorsFormat,Lucene104HnswScalarQuantizedVectorsFormator another writing format with that setting (e.g.HnswBitVectorsFormat).Lucene99HnswVectorsWriter.createGraphMerger(:668-689) uses the concurrent merger only then;DEFAULT_NUM_MERGE_WORKER = 1(Lucene99HnswVectorsFormat.java:120). Merges of segments that already have graphs can take the same retry: since #16794 the concurrent merger's join inserts also go throughaddGraphNodeInternal(HnswConcurrentMergeBuilder.java:566-571,:582-604; read, not executed). - What changes. On the retried levels the node is searched with another node's vector: (a) the stored neighbour scores are wrong; they stay in memory, but overflow pruning later in the same build reads them; (b) the beam search and the diversity rule can keep different neighbours, and neighbour ids are what the writer persists.
- How often (executed). Study verifier, production
HnswConcurrentMergeBuilder.build(), 4 workers, 16,384 random 16-d vectors, M 16, beamWidth 100: 3 of 200 builds had 1-2 wrong upper-level entries; 0 of 200 with the fix. PublicIndexWriter.forceMergeof 33 graph-less segments into one (16,384 vectors), counters added to a copy of the builder: 8 workers, 9 of 200 and 5 of 200 merges took the retry, every retry stale; 16 workers, 19 of 400 merges, 21 of 22 retries stale; 1 worker, 0 retries in 30 merges; with the fix, 0 stale in 38 retries. Example: node 2219's retry scored its entry 171 as 0.233 instead of 0.351. - Changed neighbours (supporting). In the 16-worker probe a dry run of each retry with the stale and the correct query selected different neighbours in 4 of 22 retries (node 2052 on level 1: [4] instead of [4, 4100]); 0 of 28 with the fix. The study's forced 3-worker probe on stock main: level-1 neighbours 3=[2] 4=[2] instead of 3=[2, 4] 4=[2, 3].
- Not measured. Recall. Upper levels are where every search descends, but each hit touches one node, so the impact is rated low.
One JUnit test in package org.apache.lucene.util.hnsw (same-package access, like Lucene's own concurrent-builder test): four 1-d vectors, two workers, no extra thread. The executor holds worker 1's task, and the test scorer runs it the moment worker 0 moves its scorer off node 1, so the race happens the same way every run.
/*
* 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 org.apache.lucene.search.TaskExecutor;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestHnswConcurrentMergeRetryScorer extends LuceneTestCase {
private static final float[] X = {0, 1, 3, 7};
private Runnable forkedWorker1;
private static float sim(int a, int b) {
float d = X[a] - X[b];
return 1 / (1 + d * d);
}
private final class Scorer implements UpdateableRandomVectorScorer {
private int ord = -1;
@Override
public void setScoringOrdinal(int node) {
if (ord == 1 && node != 1 && forkedWorker1 != null) {
Runnable worker1 = forkedWorker1;
forkedWorker1 = null;
worker1.run();
}
ord = node;
}
@Override
public float score(int node) {
return sim(ord, node);
}
@Override
public int maxOrd() {
return X.length;
}
}
public void testLostPromotionRetryScoresAgainstInsertedNode() throws Exception {
RandomVectorScorerSupplier supplier =
new RandomVectorScorerSupplier() {
@Override
public UpdateableRandomVectorScorer scorer() {
return new Scorer();
}
@Override
public RandomVectorScorerSupplier copy() {
return this;
}
};
OnHeapHnswGraph graph = new OnHeapHnswGraph(2, X.length);
long savedRandSeed = HnswGraphBuilder.randSeed;
try {
HnswGraphBuilder.randSeed = 42;
HnswConcurrentMergeBuilder builder =
new HnswConcurrentMergeBuilder(
new TaskExecutor(r -> forkedWorker1 = r), 2, supplier, 10, graph, null);
builder.setBatchSize(2);
builder.build(X.length);
} finally {
HnswGraphBuilder.randSeed = savedRandSeed;
}
assertEquals("worker 1's node 3 won the promotion node 1 lost", 3, graph.entryNode());
for (int level = 0; level < graph.numLevels(); level++) {
for (int node = 0; node < X.length; node++) {
if (graph.nodeExistAtLevel(level, node)) {
NeighborArray nbrs = graph.getNeighbors(level, node);
for (int i = 0; i < nbrs.size(); i++) {
int nbr = nbrs.nodes()[i];
// expected: stored score = sim(node, nbr); actual: scored against a stale ordinal
String edge = "level " + level + ": " + node + " -> " + nbr;
assertEquals(edge, sim(node, nbr), nbrs.getScores(i), 0f);
}
}
}
}
}
}
./gradlew :lucene:core:test --tests org.apache.lucene.util.hnsw.TestHnswConcurrentMergeRetryScorer -Ptests.seed=DEADBEEF
- Expected
- Every stored neighbour score is sim(node, neighbour): node 1's level-1 link to node 3 stores sim(1, 3) = 1/37 = 0.027027028.
- Observed
AssertionError: level 1: 1 -> 3 expected:<0.027027028> but was:<0.02>: 0.02 = 1/50 = sim(0, 3), scored from node 0; the same with assertions off.
More files, environment, full output260 words
Put the file at lucene/core/src/test/org/apache/lucene/util/hnsw/TestHnswConcurrentMergeRetryScorer.java. Worker 0 runs on the test thread; the scorer runs worker 1's stored task inside selectAndLinkDiverse, where worker 0 holds no lock, which stands in for the OS pausing worker 0 there. randSeed is pinned to its default 42 and restored, because other tests change it; setBatchSize(2) gives worker 0 nodes 0-1 and worker 1 nodes 2-3. The entryNode() precondition keeps a fixed build from passing vacuously. The seed does not matter.
On 10.2.0-10.6.0 the public constructor still takes M: new HnswConcurrentMergeBuilder(new TaskExecutor(r -> forkedWorker1 = r), 2, supplier, 2, 10, graph, null). Releases before 10.4.0 also need older accessors for nodeExistAtLevel / getScores.
As a regression test, use this same test unchanged.
| lucene | main 645d4fe14a20; 10.2.0 to 10.6.0 with the release constructor (fail); 10.1.0 and older pass |
|---|---|
| jdk | OpenJDK 25.0.3 |
| os | Linux aarch64 |
# from a Lucene checkout ($LUCENE), with the test in lucene/core/src/test/org/apache/lucene/util/hnsw/
./gradlew :lucene:core:test --tests org.apache.lucene.util.hnsw.TestHnswConcurrentMergeRetryScorer -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false
# main 645d4fe14a20, Gradle, -Ptests.seed=DEADBEEF (assertions on; the same with -Ptests.asserts=false)
TestHnswConcurrentMergeRetryScorer > testLostPromotionRetryScoresAgainstInsertedNode FAILED
java.lang.AssertionError: level 1: 1 -> 3 expected:<0.027027028> but was:<0.02>
at org.junit.Assert.fail(Assert.java:89)
at org.junit.Assert.failNotEquals(Assert.java:835)
at org.junit.Assert.assertEquals(Assert.java:577)
at org.apache.lucene.util.hnsw.TestHnswConcurrentMergeRetryScorer.testLostPromotionRetryScoresAgainstInsertedNode(TestHnswConcurrentMergeRetryScorer.java:92)
BUILD FAILED
# the entry-node precondition (line 83, entry = 3) passed; 0.027027028 = 1/(1 + 6^2), 0.02 = 1/(1 + 7^2)
# with the one-line fix: BUILD SUCCESSFUL, assertions on and off
# release jars, same assertion: 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2 (API ports of the test), 10.4.0, 10.5.0, 10.5.1, 10.5.2, 10.6.0- The builder's javadoc says a node that loses the promotion is reinserted on the new levels by repeating its own search (
HnswGraphBuilder.java:293-295). - 10.1.0 passes the same test; #14181 (10.2.0) replaced its per-node scorer with a reused, re-pointed scorer and no reset before the retry.
NeighborArraydocuments "the neighbors of a node and their mutual scores".
The full argument270 words
- Contract.
HnswGraphBuilder.java:282-295: the path is "thread safe when the graph size is fixed (e.g. when merging)", and when the entry node changed meanwhile "we need to reinsert the node to the newly introduced levels (repeating step 2,3 for new levels)", step 2 being this node's own search.NeighborArray.java:29: "encodes the neighbors of a node and their mutual scores".UpdateableRandomVectorScorer.java:31-37: "Changes the scoring ordinal to the given node ... @param node the node to score against", so the ordinal is caller state. - It worked before. The retry loop dates from #12660 (2023, 9.9.0), when
addGraphNodecreated a fixed scorer per node (scorerSupplier.scorer(node), still in 10.1.0 atHnswGraphBuilder.java:237). #14181 (48884e76e08, 2025-02-06) switched to a reused, mutable scorer that linking re-points. Executed: 10.1.0 passes, 10.2.0 fails. - #14181 meant no behaviour change. In review its author reported recall dropping with multiple indexing and merging threads ("indicating I have a silly bug somewhere"), then "recall numbers all checkout and the API is nicer" before merging. Nobody discussed the retry.
- Supported option.
numMergeWorkersis documented (Lucene99HnswVectorsFormat.java:215-223); no javadoc, CHANGES or MIGRATE entry allows a retry to score with another node's vector. - Untested. No upstream test forces a lost promotion or checks what the retry scores or links.
testConcurrentMergeBuilderchecks only that no exception is thrown and "by no means guarantees the true correctness of the concurrent merge" (HnswGraphTestCase.java:1160-1164). The concurrent-merge tests added by #16794 compare vector-comparison counts, a one-worker build (which cannot retry) and recall with the serial merge, within a tolerance (concurrentRecall > 0.8and> serialRecall - 0.05,:1318-1319).
The devil's-advocate check145 words
Strongest case: HNSW is approximate, the concurrent builder never promised the same graph as a sequential build, and the scores the test checks are in-memory bookkeeping that is never written, reached through package-private hooks.
Why it fails: the javadoc defines the retry as this node's own search and 10.1.0 did exactly that, so this is a regression from a refactor that meant no behaviour change, not a designed trade-off. The query is not a coarser approximation but another node's vector, and in some retries it changes the persisted neighbour ids. The stored scores feed later pruning in the same build. The test uses the same same-package access as Lucene's own testConcurrentMergeBuilder (public constructor, the "test only" setBatchSize, OnHeapHnswGraph(int, int)), and a public-API merge probe reaches the stale retry with real threads and the production scorer. The fix costs nothing on the normal path.
Recommended Re-point the scorer at the inserted node before each retry (one line)
Point the scorer back at the inserted node at the top of each retry: if (lowestUnsetLevel > 0) scorer.setScoringOrdinal(node); in HnswGraphBuilder.addGraphNodeInternal. The repro then passes with assertions on and off, and 26 suites (526 tests) of the HNSW package, the lucene99 and lucene104 codec packages and TestKnnGraph pass. It costs nothing unless a promotion was lost.
do {
if (lowestUnsetLevel > 0) {
// a lost promotion: linking re-pointed the scorer, so point it at the inserted node again
scorer.setScoringOrdinal(node);
}
curMaxLevel = hnsw.numLevels() - 1;All options and trade-offs3 options · 375 words
Option A was built and validated; B and C are alternatives that were not built.
Re-point the scorer at the inserted node before each retry (one line) (validated by the checks) (recommended)
At the top of the do loop in addGraphNodeInternal (HnswGraphBuilder.java:323). lowestUnsetLevel > 0 only on a retry, which needs a lost promotion, so sequential builds and the normal concurrent path run no extra code (if open PR #16915 lands first, its connectNode(...) starts at lowestUnsetLevel = minLevel, so the guard may also fire on a first pass: one redundant, harmless call). The study's model, its verifier and a later check wrote the same change independently.
do {
if (lowestUnsetLevel > 0) {
// a lost promotion: linking re-pointed the scorer, so point it at the inserted node again
scorer.setScoringOrdinal(node);
}
curMaxLevel = hnsw.numLevels() - 1;Trade-offs
None known. Calling it right after a failed tryPromoteNewEntryNode, before the loop repeats, is equivalent. No recall or speed benchmark was run; the hot path is unchanged.
What the checks validated
- Gradle, scratch worktree of
645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF: stock, the repro fails with assertions on and off; with the fix it passes, on and off. - With the fix:
org.apache.lucene.util.hnsw.*,codecs.lucene99.*,codecs.lucene104.*andTestKnnGraphoflucene/core, the repro included: 26 suites, 526 tests, 0 failures, 0 errors, 18 skipped. checkGoogleJavaFormat,ecjLintMain,ecjLintTest,forbiddenApisMain,forbiddenApisTestoflucene/core: pass.- Earlier, standalone with the fix shadowed in: the topology probe keeps 3=[2, 4] 4=[2, 3], and the public-API merge probes show 0 stale retries in 38.
Have addDiverseNeighbors point the scorer back at the node when it returns.
Trade-offs
Protects any future code that scores after linking, but adds a setScoringOrdinal per level for every inserted node on the hot path, and the default scorer copies the node's vector on each call (DefaultFlatVectorScorer.java:128-130). Not built.
Keep the query scorer fixed on the node and give linking its own scorer, as before 10.2.0.
Trade-offs
One more scorer per worker and changed signatures of the NeighborArray diversity methods; it touches the same calls that open issue #16903 discusses for a performance reason. Not built.
Engines and applications built on Lucene that let users enable concurrent HNSW merging inherit this; it changes only graph links, never stored vectors, filters or deletions.
While writing a TLA+ model of the concurrent HNSW insert, an AI tool noticed that the retry never resets the scorer; the model checker then found a 33-step trace that breaks "every search's query is the node being inserted". An independent verifier reproduced it on the real code, and the one-test repro was cut from that work.
The method in full140 words
Method: semi-formal (the hypothesis came from reading the code while modelling the scorer ordinal; TLC then witnessed it).
- The model
HnswConc.tlatracks the ordinal of each worker'sUpdateableRandomVectorScorerat everysetScoringOrdinalcall site. TLC configuration C1 (3 nodes, 2 workers) violates the invariantScorerQueryOK(every search's query is the node being inserted or repaired) in 33 steps, 1,317 distinct states, 1 s. The model with the fix holds over 1,695 states; a merge-reuse configuration (C6bug) is violated too. - A separate verifier wrote its own tests on the real code (forced schedule, a topology probe and an unforced 200-build audit), reproduced it on main and 10.5.2 and wrote the same one-line fix.
- On 2026-10-29 the evidence was cut to the one deterministic test; on 2026-10-31 it was re-run on
645d4fe14a20and the releases, and the reach was measured throughIndexWriter.forceMerge.
No prior report: GitHub issues, PRs and commits, downstream repos, the mailing lists, JIRA and the web (about 50 GitHub phrasings), plus all 410 open PRs, searched 2026-10-31. Closest: #16903, a performance report about the same scorer re-pointing that does not mention the retry.
Searches3 searches
Verdict new. Related, not this bug: #16903 (open; the re-pointing calls as a page-cache cost under asymmetric quantization); #16956 (open PR; drops one setScoringOrdinal in InitializedHnswGraphBuilder's graph copy); #16915 (open PR; moves the loop into connectNode(...) and keeps the defect, so whichever lands second must carry the fix); #14181 (the introducing PR); #12660 (added the loop); #15184 (moved the retry comment into the javadoc and made the scorer a builder field; no re-point).
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), open and closed | 2026-10-22, 2026-10-31 | about 50 phrasings: setScoringOrdinal, tryPromoteNewEntryNode, addGraphNodeInternal, HnswConcurrentMergeBuilder, "entry node" promote, "reinsert the node", concurrent hnsw merge wrong scores, ... | no report of this bug; related items above |
| Open PRs | 2026-10-31 | all 410 open apache/lucene PRs checked for changes to the HNSW builder files; the 9 touching HnswGraphBuilder or OnHeapHnswGraph read | none re-points the scorer before the retry |
| Commits, downstream repos, lists, JIRA, web | 2026-10-31 | apache/lucene commit messages; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; the LUCENE JIRA archive; 6 web queries | unrelated hits only |
Introduced in 10.2.0 by #14181 (48884e76e08, 2025-02-06), which replaced the per-node scorer with one reusable scorer. Executed: all 11 releases 10.2.0 to 10.6.0 and main fail; 9.12.3, 10.0.0 and 10.1.0 pass; bisected at the release-line commit. Not fixed upstream.
Versions tested4 rows
Introduced by #14181 "Add updateable random scorer interface for vector index building" (48884e76e08 on main, 2025-02-06; the same-day branch_10x commit 56920974aa5, contained in every release tag 10.2.0 to 10.6.0). The retry loop is older (#12660, a8c52e2e19c, 2023-10-28, first released in 9.9.0) and was correct with the per-node scorer. Upstream main 2f424fd428da (2026-10-30) is unchanged; the newest commit to HnswGraphBuilder.java is d8468fd9375 (#16627, 2026-10-08).
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | fails, assertions on and off (Gradle and standalone) |
| 10.2.0 to 10.6.0 (11 releases) | affected | fail, same assertion (release jars) |
| 9.12.3, 10.0.0, 10.1.0 | not affected | pass (ports to the per-node scorer API; the retry linked node 1 correctly) |
56920974aa5 vs its parent | bisect | the commit fails, its parent passes, the commit with the fix passes |
The study's verifier reproduced it with its own tests; a current-main reproducer and a tie-break re-ran it on 645d4fe14a20 and the releases and measured it in real concurrent merges. A read-only review by another model family (OpenAI GPT-5.6) could not run code and found no substantive refutation.
Checks and verdicts137 words
This doc's fix validation (Gradle, scratch worktree) is under Suggested fix.
| Check | Verdict | What it found |
|---|---|---|
| Model checking (TLC) | invariant violated; fixed model clean | C1: ScorerQueryOK violated in 33 steps (1,317 states); C1fix: 1,695 states, no error. |
| Study verifier (independent) | new bug, low severity | Own forced, topology and unforced tests on main and 10.5.2; same one-line fix. |
| Current-main reproducer | real | Fails on 645d4fe14a20 (on and off), 10.5.2 and 10.6.0; passes with the fix. |
| Cross-model review (OpenAI GPT-5.6, read-only) | not real (low), procedural | Could not run anything; its reading agreed with the root cause; its one point (in-memory scores, same-package access) is answered in the devil's-advocate check. |
| Second adjudication | real | Re-ran the test (10 of 10 fail), 10.1.0 pass / 10.2.0 fail, bytecode check, public-API merge probes. |
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 451 KB
Finding record, Lucene and upstream sources, repro files and diagram notes493 words
- Registry title
- Concurrent HNSW build retries a lost entry-node promotion with the wrong query vector
- Area
- search / HNSW
- Priority
- Low, rank 902: Concurrent HNSW merging (numMergeWorkers > 1) retries a lost entry promotion with the wrong vector in about 1.5% of builds, but it only mis-scores one or two upper-level edges with negligible recall effect.
- Impact class
- silent wrong results
- Impact note
- Users of the opt-in concurrent HNSW merge (numMergeWorkers > 1); low impact (3 of 200 unforced builds); released since 10.2.0.
- Summary
- You build or merge HNSW graphs with more than one merge worker. You expect each node's neighbours to be scored against that node's vector. Instead, when an entry-node promotion is lost to another worker and retried, the shared scorer is not reset, so wrong scores are stored and the persisted topology changes.
- Introduced
- #14181 (48884e76e08) (2025-02-06)
- First affected version
- 10.2.0 [exec: 10.1.0 passes, 10.2.0 fails]
- Affected versions
- 10.2.0 to 10.6.0 (11 releases) and main [exec]; 9.12.3, 10.0.0 and 10.1.0 pass [exec]
- 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 (its low-confidence “not real” is procedural); not yet triaged by a person.
- Found by
- semi-formal: TLA+/TLC, code reading
- Upstream and prior reports
- none found (study searches 2026-10-22; public-list check 2026-10-20..23)
- Lucene commit tested
- main
645d4fe14a20(Gradle in a scratch worktree and standalone JUnit, assertions on and off); the release jars 10.2.0 to 10.6.0 (all fail) and 9.12.3, 10.0.0, 10.1.0 (pass, with ports to the older scorer API); the release-line commit56920974aa5and its parent (bisect); the one-line fix with the HNSW, lucene99 and lucene104 codec suites andTestKnnGraphoflucene/core. OpenJDK 25.0.3, Linux aarch64, 2026-10-31.
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:77, (at 645d4fe14a20) :181-185, :282-295, :300-402, :436, :515-516lucene/core/src/java/org/apache/lucene/util/hnsw/NeighborArray.java:29, (at 645d4fe14a20) :141, :301lucene/core/src/java/org/apache/lucene/util/hnsw/OnHeapHnswGraph.java:230-236, (at 645d4fe14a20) :249-256lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:153-165, (at 645d4fe14a20) :557-563
- Who is affected
lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:510-518, (at 645d4fe14a20) :668-689lucene/core/src/java/org/apache/lucene/util/hnsw/HnswConcurrentMergeBuilder.java:566-571, (at 645d4fe14a20) :582-604 (join inserts, #16794)lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:120, (at 645d4fe14a20) :215-223, :289-292
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:282-295 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/hnsw/NeighborArray.java:29 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/hnsw/UpdateableRandomVectorScorer.java:31-37 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:215-223 (at 645d4fe14a20)lucene/core/src/test/org/apache/lucene/util/hnsw/HnswGraphTestCase.java:1160-1176, (at 645d4fe14a20) :1201, :1231, :1253, :1277, :1318-1319 (#16794, c50bc9523db)github.com/apache/lucene/pull/14181 (review comments, read 2026-10-31)
- Suggested fix
lucene/core/src/java/org/apache/lucene/codecs/hnsw/DefaultFlatVectorScorer.java:128-130 (at 645d4fe14a20)github.com/apache/lucene/issues/16903 (read 2026-10-31)
- Diagrams
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:394, (at 645d4fe14a20) :515, :372
- TestHnswConcurrentMergeRetryScorer.java (JUnit, one test):
repro/LFV-061/src/org/apache/lucene/util/hnsw/TestHnswConcurrentMergeRetryScorer.java(98 lines)
A step-by-step picture of the repro, made in parallel with this doc from the code at 645d4fe14a20 and an executed trace of the test; the PNG is the summary still.
assets/LFV-061/bug.png(451 KB)
