All findings · Lucene finding · LFV-061 · concurrent-hnsw-promotion-retry-scorer

Concurrent HNSW build retries a lost entry-node promotion with the wrong query vector

With concurrent HNSW merging (numMergeWorkers > 1), a worker that loses the race to become the entry node redoes its top-level search with its scorer on another node, so it links from the wrong vector. Silent and since 10.2.0, it hit 1.5-5% of test merges; the fix is one line.

Summary

Priority
Low, rank 902Concurrent 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
silent wrong results
Since
10.2.0 (#14181, 2025-02-06)introduced 2025-02-06 · about 1 year
Affected
10.2.0 to 10.6.0 and main, only with numMergeWorkers > 1
Status
documented
Found by
noticed while writing a TLA+ model of the concurrent insert; the model checker confirmed it

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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-06.

The finding in 30 seconds

HNSW insertion in four lines

  1. 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.
  2. 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.
  3. 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.
  4. 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.
Diagram of the minimal repro: worker 0 inserts node 1 and, while linking it on level 0, points its shared scorer at node 0; worker 1 meanwhile inserts nodes 2 and 3 and makes node 3 the entry node; worker 0's promotion of node 1 fails and its retry on levels 1 and 2 scores node 3 as 0.02 = sim(0, 3) instead of 0.027 = sim(1, 3). Fix: re-point the scorer at node 1 before the retry.
The repro step by step: the two workers, the four vectors 0, 1, 3, 7, and the retry that scores from node 0 instead of node 1. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

Who is affected

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.

Mechanism at 645d4fe14a20

  • addGraphNode (HnswGraphBuilder.java:392-402) calls scorer.setScoringOrdinal(node) once (:394 / :400) and then addGraphNodeInternal(node, scorer, ...). In a concurrent merge each worker is its own builder with its own scorer (HnswConcurrentMergeBuilder.java:153-165).
  • addGraphNodeInternal runs do { ... } while (true) (:323-389): greedy descent with scorer as the query (:338), beam search per level (:353), popToScratch keeps those scores (:356), addDiverseNeighbors links with them (:360-362), then tryPromoteNewEntryNode (:372). On failure the loop repeats for the new levels. Nothing in the loop re-points the scorer at node.
  • Linking moves the scorer: selectAndLinkDiverse calls scorer.setScoringOrdinal(cNode) for every candidate (:515), and NeighborArray re-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 its compareAndSet lost 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.

The repro, step by step

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.

  1. Worker 0 inserts node 0 (level 0); it becomes the entry.
  2. Worker 0 inserts node 1 (level 2): setScoringOrdinal(1), searches level 0 and finds node 0 (score 0.5).
  3. While linking level 0, selectAndLinkDiverse calls setScoringOrdinal(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.
  4. 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 > 1 with an executor on Lucene99HnswVectorsFormat, Lucene104HnswScalarQuantizedVectorsFormat or 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 through addGraphNodeInternal (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. Public IndexWriter.forceMerge of 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.

Minimal repro

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.

TestHnswConcurrentMergeRetryScorer.java (JUnit, one test)98 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 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);
          }
        }
      }
    }
  }
}
Command
./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.

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

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.

Environment

lucenemain 645d4fe14a20; 10.2.0 to 10.6.0 with the release constructor (fail); 10.1.0 and older pass
jdkOpenJDK 25.0.3
osLinux aarch64
Command (environment)
# 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

Observed output

# 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

Why it looks unintended

  • 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.
  • NeighborArray documents "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 addGraphNode created a fixed scorer per node (scorerSupplier.scorer(node), still in 10.1.0 at HnswGraphBuilder.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. numMergeWorkers is 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. testConcurrentMergeBuilder checks 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.8 and > 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.

Suggested fix

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.* and TestKnnGraph of lucene/core, the repro included: 26 suites, 526 tests, 0 failures, 0 errors, 18 skipped.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain, forbiddenApisTest of lucene/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.

Restore the ordinal after linking

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.

A second scorer for the diversity checks

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.

Downstream note

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.

How it was found

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.tla tracks the ordinal of each worker's UpdateableRandomVectorScorer at every setScoringOrdinal call site. TLC configuration C1 (3 nodes, 2 workers) violates the invariant ScorerQueryOK (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 645d4fe14a20 and the releases, and the reach was measured through IndexWriter.forceMerge.

Prior reports, history and checks

Prior reports

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).

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), open and closed2026-10-22, 2026-10-31about 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 PRs2026-10-31all 410 open apache/lucene PRs checked for changes to the HNSW builder files; the 9 touching HnswGraphBuilder or OnHeapHnswGraph readnone re-points the scorer before the retry
Commits, downstream repos, lists, JIRA, web2026-10-31apache/lucene commit messages; Elasticsearch, OpenSearch, Solr; dev@, java-user@, issues@, commits@; the LUCENE JIRA archive; 6 web queriesunrelated hits only

History

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).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedfails, assertions on and off (Gradle and standalone)
10.2.0 to 10.6.0 (11 releases) affectedfail, same assertion (release jars)
9.12.3, 10.0.0, 10.1.0 not affectedpass (ports to the per-node scorer API; the retry linked node 1 correctly)
56920974aa5 vs its parent bisectthe commit fails, its parent passes, the commit with the fix passes

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Model checking (TLC) invariant violated; fixed model cleanC1: ScorerQueryOK violated in 33 steps (1,317 states); C1fix: 1,695 states, no error.
Study verifier (independent) new bug, low severityOwn forced, topology and unforced tests on main and 10.5.2; same one-line fix.
Current-main reproducer realFails 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), proceduralCould 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 realRe-ran the test (10 of 10 fail), 10.1.0 pass / 10.2.0 fail, bytecode check, public-API merge probes.

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 notes493 words

Finding record

LFV-061, snapshot of 2026-11-06

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 commit 56920974aa5 and its parent (bisect); the one-line fix with the HNSW, lucene99 and lucene104 codec suites and TestKnnGraph of lucene/core. OpenJDK 25.0.3, Linux aarch64, 2026-10-31.

Lucene and upstream sources

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-516
  • lucene/core/src/java/org/apache/lucene/util/hnsw/NeighborArray.java:29, (at 645d4fe14a20) :141, :301
  • lucene/core/src/java/org/apache/lucene/util/hnsw/OnHeapHnswGraph.java:230-236, (at 645d4fe14a20) :249-256
  • lucene/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-689
  • lucene/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

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

Repro files

About the diagrams

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.

Built 2026-11-06 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.