All findings · Lucene finding · LFV-005 · hnsw-entry-node-isolation

HNSW graph reuse at merge can leave the entry node with no neighbours

When a merge reuses an HNSW graph that has deletions, the node every search starts from can end up with no links: recall@10 drops from 0.906 to 0.000 in the repro and CheckIndex reports the index clean. It ships in 10.5.1 and 10.5.2 with the default codec.

Summary

Priority
High, rank 8Bottom of High: on the default codec since 10.5.1, clustered deletes around a poorly linked HNSW entry node plus a no-rebalance segment size can silently and persistently drop a merged segment's kNN recall to zero with CheckIndex clean, a rare trigger (random deletes are safe) that comes from ordinary delete-and-merge operations rather than crafted input or boundary values.
Impact
silent wrong results
Since
10.4.0 (non-per-field HNSW codec); 10.5.1 (default codec)introduced 2025-12-01
Affected
10.5.1, 10.5.2, branch_10_6, main with the default codec …
Status
documented
Found by
formal: TLA+/TLC

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

The finding in 30 seconds

Result panel of the diagram, a table of recall@10 over the 50 queries (k = 10): 0.906 before the merge (10 hits per query); 0.000 after forceMerge(1) (1 hit per query: doc 171); 0.106 after +200 docs and a 2nd forceMerge(1) (island only: 0 of 500 hits are old docs); 0.910 with the same data and the graph rebuilt from scratch (Lucene 10.4.0 / 10.5.0). CheckIndex: No problems were detected, every time. Seen with the default codec on main, branch_10_6, 10.5.1 and 10.5.2.
The HNSW graph as three stacked level bands; node positions are schematic and only some nodes and links are drawn. Select the still to open it at full size. Generated with AI, checked against the code.
  1. Index 1,450 16-dimensional vectors with the default codec. In the flushed graph the entry node, doc 171, links only into cluster A, 31 documents.
  2. Delete cluster A (2.1% of the documents) and run forceMerge(1). The merge reuses the graph, drops the deleted nodes and repairs the nodes that lost neighbours.
  3. You expect recall@10 to stay about 0.906; the same data with the graph rebuilt from scratch gives 0.910.
  4. Instead the repair of doc 171 searches from the entry, which is doc 171 itself, and keeps 0 neighbours. Every search starts there: KnnFloatVectorQuery(k=10) returns 1 hit, doc 171, and recall@10 is 0.000.
  5. No error: CheckIndex reports no problems, and the damage survives commit, reopen and later merges (recall 0.106 after 200 more documents and a second merge).

Who is affected

Default-codec users on 10.5.1, 10.5.2, branch_10_6 and main whose merges reuse a graph with deletions: forceMerge(1), forceMergeDeletes() or ordinary background merges (10.4.0 and 10.5.0 only with a non-per-field vectors format). It takes deletes clustered around the lowest-ord top-level node, such as a delete-by-query of one tenant, and a rebalance that promotes nothing.

Mechanism, terms and numbers532 words

When a merge reuses a segment's HNSW graph that has deletions, the node every search starts from can end up with no links at all. kNN search on the merged segment then returns about one hit, the entry itself: recall@10 drops from 0.906 to 0.000 in the repro, the damage survives commit, reopen and later merges, and CheckIndex reports the index clean. It ships in 10.5.1 and 10.5.2 with the default codec.

Terms

  • HNSW graph: Lucene's index for kNN vector search. Each vector is a node with a number, its ord, and links to a few nearby nodes, its neighbours. A search only follows a node's own outgoing links. Level 0 holds every node; each higher level holds a random, shrinking subset (about 1 in 16 per level with the default M = 16).
  • Entry node: the top-level node where every search starts. The search walks along links towards the query, then drops a level. If the entry has no links, the search cannot go anywhere. On disk, the reader always uses the lowest-ord node of the top level.
  • Merge-time graph reuse: a merge can start from an existing segment's graph instead of building one from scratch. Reuse of graphs without deletions dates from 9.6.0; #15003 (10.4.0) added reuse of a graph with deletions: copy it without the deleted nodes and their links, repair nodes that lost neighbours, rebalance levels that became too small, then insert the other segments' vectors.

The bug

  1. While the graph is copied, the first surviving top-level node becomes the entry (InitializedHnswGraphBuilder.java:363 on main, hnsw.trySetNewEntryNode(newOrd, level)). Nobody checks how many neighbours it kept.
  2. If the deletes removed all its neighbours on a level, the node is flagged for repair, and a node with no neighbours goes to addConnections ("Node has no neighbors, add connections from scratch", HnswGraphBuilder.java:757-759).
  3. addConnections seeds its search with the entry node, int[] eps = {hnsw.entryNode()}; (HnswGraphBuilder.java:789), and the entry is the node itself. The search has no links to follow, so its only candidate is the node, and that candidate is skipped because a node may not link to itself (if (node == cNode) continue;, :510). The node keeps 0 neighbours. Rebalancing seeds its own addConnections calls the same way, and in the repro it promotes nothing.
  4. Searches start at this node (on disk too, unless rebalancing promotes a lower ord onto the top level) and return only it, or only what is linked to it. Vectors inserted later, by this merge or a later one, are also placed by searching from it, so they attach only to it and to each other: an island.

The numbers

Default codec, 1,450 documents, 16 dimensions, 31 documents deleted (2.1%), forceMerge(1): KnnFloatVectorQuery(k=10) returns 1 hit, the entry doc 171, far from every query, and recall@10 goes from 0.906 to 0.000. After 200 more documents and a second forceMerge(1), reopened from the directory, recall is 0.106 and queries return only new documents. The fix gives 0.906, then 0.912. On main, CheckIndex at level 2 or higher prints level 0: 1419 nodes, 1/1419 connected and then "No problems were detected".

Exposure and likelihood295 words
  • Who is exposed. Default-codec users on 10.5.1, 10.5.2, branch_10_6 and main whose merges reuse a graph with deletions: a singleton forceMerge(1), forceMergeDeletes(), or ordinary background merges (with the default TieredMergePolicy and ConcurrentMergeScheduler, deleting a cluster and then making 40 commits of 20 documents gave recall 0.386). 10.4.0 and 10.5.0 are affected only with a codec whose vectors format is not wrapped per field. The opt-in concurrent merger fails the same way.
  • Silent and lasting. No error, CheckIndex clean, and the damage survives commit, reopen and later merges, growing into an island of later vectors.
  • What it takes. Deletes that remove the links of the first (lowest-ord) surviving top-level node: all links give the full collapse, all upper-level links can be enough. In practice that means deletes clustered around that node (for example a delete-by-query on a tenant, topic or time range whose embeddings sit together), or an entry with very few links. And rebalancing must promote no node onto its levels, which happens in narrow, size-dependent windows.
  • How likely. A well-connected entry is effectively safe from random deletes: on 20,000 real Cohere vectors the entry had 32/16/12/1 links on levels 0-3. An independent tenant-shaped repro (5 tenants on an arc, delete-by-query of a 13-14% tenant, 32-dimensional DOT_PRODUCT) collapsed from 0.932 to 0.000.
  • Partial form on real data. When rebalancing promotes a lower ord, the on-disk entry moves there and the bad node stays a level-0 dead end: on the first 20k Cohere v3 vectors, deleting exactly the entry's 57 neighbours (0.29%) left 7 of 100 queries with fewer than 10 hits and recall 0.853 to 0.785; the fix gives 0 of 100.
  • Release behaviour can vary. In 10.4.0-10.5.x, rebalanceGraph uses an unseeded random, so results can differ between runs whenever rebalancing promotes nodes.

Minimal repro

The test shows that with the default codec, deleting the 31-doc cluster that the HNSW entry node (doc 171) links into and calling forceMerge(1) reuses the graph but leaves the entry with no neighbours, so kNN search returns only that node.

TestHnswMergeReuseIsolatedEntry.java (JUnit test)64 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.index;

import java.util.Random;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.KnnFloatVectorQuery;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestHnswMergeReuseIsolatedEntry extends LuceneTestCase {

  public void testForceMergeAfterDeletingAllNeighboursOfEntryNode() throws Exception {
    Random random = new Random(20261022L);
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc = new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec());
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        for (int i = 0; i < 1450; i++) {
          float[] v = new float[16];
          for (int d = 0; d < v.length; d++) {
            v[d] = (float) (random.nextGaussian() * 0.3);
          }
          boolean inA = i < 30 || i == 215;
          v[0] += i == 171 ? -10 : inA ? 0 : 10;
          Document doc = new Document();
          doc.add(new KnnFloatVectorField("v", v, VectorSimilarityFunction.EUCLIDEAN));
          doc.add(new StringField("cluster", inA ? "A" : "B", Field.Store.NO));
          w.addDocument(doc);
        }
        w.commit();
        w.deleteDocuments(new Term("cluster", "A"));
        w.forceMerge(1);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        float[] query = new float[16];
        query[0] = 10;
        TopDocs hits = new IndexSearcher(reader).search(new KnnFloatVectorQuery("v", query, 10), 10);
        // expected: 10 hits; actual: 1, the entry doc 171, which lost all neighbours in the merge
        assertEquals("kNN hits among 1419 live docs", 10, hits.scoreDocs.length);
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-005/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.index.TestHnswMergeReuseIsolatedEntry
Expected vs actual
Expected: a k=10 KnnFloatVectorQuery returns 10 hits among the 1419 live docs. Actual (-ea and -da): 1 hit (doc 171).

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

More files, environment, full output39 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: kNN hits among 1419 live docs expected:<10> but was:<1>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:647)
       org.apache.lucene.index.TestHnswMergeReuseIsolatedEntry.testForceMergeAfterDeletingAllNeighboursOfEntryNode(TestHnswMergeReuseIsolatedEntry.java:60)

Why it looks unintended

  • The code handles this case on purpose: "Node has no neighbors, add connections from scratch" (HnswGraphBuilder.java:757-759).
  • Tests in TestHnswFloatVectorGraph assert that no repaired node is left without neighbours and that a kNN search returns k results.
  • 2.1% deletes turn recall 0.906 into 0.000; the same data rebuilt from scratch gives 0.910. That is not an approximation trade-off.
The full argument218 words
  • The code handles this exact case on purpose. fixDisconnectedNodes sends a node with zero neighbours to addConnections with the comment "Node has no neighbors, add connections from scratch" (main HnswGraphBuilder.java:757-759; there since #15003, spelled "nighbors" in 10.5.2 InitializedHnswGraphBuilder.java:344-346). #16627 (main and branch_10_6) rewrote the javadoc to "A node with no neighbors is instead connected from scratch via addConnections".
  • The class javadoc says the builder "Identifies and repairs disconnected nodes".
  • Tests in TestHnswFloatVectorGraph assert the goal: "flagged node ... has no neighbors after repair on level" must not happen, "graph should be a single rooted component after repair", and "KNN search should return k results" (10). Their deletes are not clustered in vector space (every third ord, or the first N doc ids, over random vectors).
  • #16627's PR body assumes the node is not the entry: "nodes with no surviving neighbors have no entry point to search from, so it walks from the entry node to reach the current level".
  • Effect size. 2.1% deletes, far below the 40% reuse limit, turn recall 0.906 into 0.000 and k = 10 into 1 hit. The same data rebuilt from scratch (10.4.0, 10.5.0) gives 0.910. That is not an approximation trade-off.
  • Precedent. #16552, a recall loss from graph reuse, was filed as type:bug and fixed in 10.5.2.
The devil's-advocate check154 words

The intended-behaviour checker built the strongest case for "known limitation": in 10.4.0 through 10.5.2 the private fixDisconnectedNodes javadoc says "If a node has no neighbors at all, it cannot be repaired at this level and will rely on the rebalancing phase", and in the failing window rebalancing promotes nothing; connectivity is best effort (connectComponents() is commented out because of #14214, and CheckIndex treats connectedness as information only); reuse past 40% deletes "may have degraded connectivity"; and the repro geometry is artificial.

Why it fails: the code right under that javadoc does try to connect the node from scratch, and the method is private, so users never see the sentence; rebalancing cannot do the repair either (same seed, nothing promoted); the tests and the PR body state the goal; the effect is far beyond a trade-off. Verdict: not intended, a defect. Not security: correct use of public APIs, and accepted-docs filtering still applies.

Suggested fix

Recommended Seed the repair from other nodes when the node is the entry (sketch)

Inside addConnections, when the repaired node is the entry, seed the search from the other nodes of the highest level that has any (+27/-2 in HnswGraphBuilder.java, branches 3bd0c584d88 and 3108980e63e). Both tests pass; recall 0.906, then 0.912. Known gap: in the serial merger a second node that lost all its level-0 links can end up linked only to the entry.

int startLevel = hnsw.numLevels() - 1;
int[] eps = {hnsw.entryNode()};
if (eps[0] == node) {
  // seed from all other nodes of the highest level that has any
  eps = otherNodesOnLevel(node, startLevel);
  while (eps.length == 0 && startLevel > targetLevel) {
    eps = otherNodesOnLevel(node, --startLevel);
  }
  if (eps.length == 0) {
    return; // the node is alone on the target level
  }
}
All options and trade-offs5 options · 564 words

One fix sketch was built and checked; it has a known gap. The alternatives come from the brief and the checkers.

Seed the repair from other nodes when the node is the entry (sketch) (validated by the checks) (recommended)

Branches hnsw-entry-node-isolation-main-fix (3bd0c584d88) and -106-fix (3108980e63e), each one commit on its test commit, +27/-2 in HnswGraphBuilder.java. Inside addConnections, when hnsw.entryNode() is the node itself, it seeds from every other node of the highest level that has any (otherNodesOnLevel); if the node is alone on the target level it returns.

int startLevel = hnsw.numLevels() - 1;
int[] eps = {hnsw.entryNode()};
if (eps[0] == node) {
  // seed from all other nodes of the highest level that has any
  eps = otherNodesOnLevel(node, startLevel);
  while (eps.length == 0 && startLevel > targetLevel) {
    eps = otherNodesOnLevel(node, --startLevel);
  }
  if (eps.length == 0) {
    return; // the node is alone on the target level
  }
}

Trade-offs

  • Known gap in the serial merger (hand-built graphs): if another node W with a lower ord also lost all its level-0 links, W is repaired first, its descent ends on the outlier entry X (the descent keeps the worst candidate), so W links only to X; X then has one neighbour and is repaired from W only. On the fix: level 0 reachable from the entry 2/196, a query near the entry gets 2 hits. The same on unfixed main, so a remaining gap, not a regression.
  • Changing the descent to "take the best" lands on the node itself and re-breaks the repair; any change there must take the best candidate other than the node.
  • Cost: only when the node is the entry; worst case one O(n) brute-force pass, and the searcher's scratch arrays stay grown to n.
  • The 10.5.x backport needs a different patch (addConnections is private in InitializedHnswGraphBuilder there). No recall or latency benchmark was run; open PR #16815 changes the same code.

What the checks validated

  • main-fix: the 2 tests pass (20/20 with iters=10, 10/10 with iters=5, and with assertions off); util.hnsw.*, codecs.lucene99.*, TestKnnGraph, TestHnswMergeAbort and TestKnnFloatVectorQuery: 19 classes, 382 tests, 0 failures; codecs.*, search.*Knn*, *Vector* and TestCheckIndex: 92 classes, 2,088 tests, 0 failures; :lucene:core:check -x test passes; CheckIndex prints 5/5, 101/102 and 1419/1419 connected.
  • 106-fix: the 2 tests pass; 18 classes, 357 tests, 0 failures.
  • The independent repros, the natural-merge probe and the Cohere probes are fixed too.

Pick a different in-memory entry

Choose a top-level node that kept neighbours as the entry.

Trade-offs

Does not work on its own: the format does not store the entry, and the reader uses the lowest-ord top-level node (Lucene99HnswVectorsReader.java:572 on main b83101cdd2, :578 on ea57360912ed), which stays the isolated one unless it is repaired or the reader rule changes.

After repair, if the entry has no neighbours, repair it from another node

A targeted second pass.

Trade-offs

Misses the serial-merger gap above, where the entry ends with exactly one neighbour.

Repair the entry first on each level, from other nodes, and take the best candidate other than the node

Suggested by a checker; untested.

Trade-offs

Touches the descent too, so it needs benchmarks; fixes for a related descent issue lowered recall before.

Detection only

Main's CheckIndex already computes 1/1419 connected but does not flag it; it could.

Trade-offs

Reports the damage instead of preventing it.

How it was found

TLA+ model checking (TLC) of the concurrent HNSW build, merge and repair. An AI modeller hand-picked a start state in which entry node 0 had already lost all its neighbours, and TLC showed with a 9-state trace that the repair cannot fix it (invariant RepairLinked). So it is not a blind discovery; other AI tools then reproduced it end to end.

The method in full285 words

Method: TLA+ model checking (TLC) of the concurrent HNSW build, merge and repair (the study's "formal" tier: the tool's own counterexample). Run 3, experiment "hnsw-concurrent-merge" (2026-10-22, main 7cecf79523).

  • The task text for this area asked for a P or TLA+ model that treats "the shared graph and entry point faithfully", with properties such as "every inserted node is linked at all its levels". It named the merge and repair classes and the entry node in general, and did not mention deletes, repair of a node with no neighbours, or this case.
  • An AI modeller read the code and wrote a TLA+ model of the concurrent HNSW build, merge and repair (HnswConc.tla, 555 lines), with the invariant RepairLinked: a repaired node has at least one neighbour wherever another node exists on its level.
  • It hand-picked the start state of configuration R1: 4 nodes on one level, where entry node 0 has already lost all its neighbours. TLC explored that configuration completely (18 distinct states, 0 left on the queue) in 1 s and reported the RepairLinked violation with a 9-state trace (2026-10-22). So the AI tool chose the scenario, and TLC showed that the repair cannot fix it; it is not a blind discovery.
  • The same AI tool wrote small Java repros (14 vectors). A separate AI verifier, which had read the finder's write-up but not its test code (self-reported), wrote its own end-to-end repro with the default codec and 1,450 documents, ran it on 10.5.2 and main, and scanned segment sizes. On 2026-10-23 a third AI tool found that 10.4.0 and 10.5.0 do not reproduce with the default codec, and wrote the JUnit tests, the plain-Java repro and the fix sketch.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues and PRs (46 queries, 14 related threads read) or the dev@ and java-user@ lists, searched 2026-10-23; no separate JIRA search is recorded. Open PR #16815 overlaps the code most.

Searches5 searches

No report of this defect was found. Several related HNSW items exist, and open PR #16815 overlaps the code most.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-2346 queries in two batches (titles, bodies, comments), for example addConnections, InitializedHnswGraphBuilder, fixDisconnectedNodes, trySetNewEntryNode, "entry node" hnsw, hnsw isolated / island / unreachable, knn "1 hit", knn recall zero merge, hnsw clustered deletes; every issue and PR created since 2026-10-10no report of this defect
Related items (read)2026-10-23the comments of 14 related threadsdifferent mechanisms: #16552 / #16559 (degree decay across merges), #16555 (open, absolute degree threshold), #16726 (unseeded rebalance random), #16817 (entry stuck among skewed duplicates, no deletes), #16728 (no duplicate links in repair), #12627 (disconnected flush-built graphs, 2024), #12533 (the original idea of reusing graphs with deleted docs)
Open PRs2026-10-23PRs touching the HNSW builder and merger#16815 overlaps most (moves the entry methods, touches the repair hunk); #16794 and #16879 change the merger only
Mailing lists (lists.apache.org)2026-10-23dev@ and java-user@, 365 daysno report; the dev@ thread on the 10.5.2 recall regression (#16552) is a different bug
JIRA archive-no separate search is recorded; the code dates from #15003 (2025)-

History

Introduced in 10.4.0 by #15003 (ef10476d98a, merged 2025-12-01), which added merge-time reuse of graphs with deletions; on by default since 10.5.1 (#16403). Affects 10.5.1, 10.5.2, branch_10_6 and main with the default codec; both commits bisected.

Versions tested7 rows

Introduced by #15003 "Avoid reconstructing HNSW graphs during segment merging." (@Pulkitg64; merged by @benwtrent on 2025-12-01 as ef10476d98a on main; backport 3e024479d35; milestone 10.4.0), which added reuse of graphs with deletions. Reuse of deletion-free graphs is older (9.6.0, #12050). On by default in releases since #16403 (merged by @msokolov 2026-07-23, milestone 10.5.1), which restored graph reuse under the per-field vectors format; it reached 10.5.1 and 10.5.2 as 6c59b2e5933. #16627 (2026-10-08, main and branch_10_6 only) moved the repair into HnswGraphBuilder and kept the seed.

Versions and branches executed, with results
Version or commitVerdictResult
main b83101cdd2, branch_10_6 d04ee73177 affecteddefault codec: 0.906 to 0.000 (1 hit), then 0.106; JUnit tests 2/2 fail; the repros also collapse with 4 merge workers
10.5.1, 10.5.2 affecteddefault codec: the same numbers (Maven Central jars, SHA-1 checked)
10.4.0, 10.5.0 custom codec onlydefault codec rebuilds the graph (0.910); with a non-per-field HNSW codec 0.918 to 0.000, 1 hit
9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.0-10.3.2 not affectedgraphs with deletions are not reused, with any codec
main ef10476d98a (#15003) vs its parent fb3cc564a28 bisect#15003 collapses (0.000, then 0.106); its parent does not (0.910)
branch_10_5 6c59b2e5933 (#16403 backport) vs parent a9fa8763b62 bisectthe backport collapses with the default codec; its parent only with the non-per-field codec
fix 3bd0c584d88 / 3108980e63e not affected0.906, then 0.912; CheckIndex 1419/1419 connected

Independent checks

Independent AI checkers, a lens each, and a claim re-check checked brief v1: the re-run numbers matched to the last digit, 3 of 36 claims were refuted and corrected in brief v2, and the study's verifier reproduced the bug end to end on the default codec.

Checks and verdicts265 words

Independent AI checkers each took a lens on brief v1, and a claim re-check re-ran the disputed and single-source claims; brief v2 applies the result.

Each independent check and its verdict
CheckVerdictWhat it found
exec-current confirmed, with additionsEvery re-run number matched to the last digit, with -ea and -da, scalar and Panama code, 1 and 4 merge workers, on disk; its own tenant repro collapses too; found the 10.4.0/10.5.0 custom-codec exposure, forceMergeDeletes(), and that the first alternative fix would not work.
exec-versions confirmed, history correctedBisected #15003 and the #16403 backport; the reuse history in v1 was wrong (deletion-free graphs were already reused in 9.x, and with the default codec in 10.3.2); default-codec exposure starts in 10.5.1.
code-history confirmed, with correctionsFound the serial-merger gap in the fix sketch (probes C2/D2), the release-era javadoc, and the reuse history.
intended-behavior not intendedBuilt the strongest case for "known limitation" (the release javadoc) and showed why it fails; widened the trigger (natural merges, real data).
dups-policy no duplicates; provenance correctedNo report in 46+ queries and the lists; the prompt named the entry node in general, R1's start state was hand-picked, so not a blind find.
Claim re-check status readyRe-ran the plain repro, the non-per-field codec on 10.4.0/10.5.0, the natural merge and the Cohere probes; 36 claim verdicts: 3 refuted (the reuse history, the fix size, a one-liner that left out AI) and 1 misleading (an alternative fix).
study verifier new bugHNSW concurrent-merge verifier reproduced it end to end on the default codec.

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

Finding record

LFV-005, snapshot of 2026-11-06

Registry title
HNSW merge-time graph reuse can leave the entry node with no neighbours, dropping kNN recall to zero
Area
search / HNSW
Priority
High, rank 8: Bottom of High: on the default codec since 10.5.1, clustered deletes around a poorly linked HNSW entry node plus a no-rebalance segment size can silently and persistently drop a merged segment's kNN recall to zero with CheckIndex clean, a rare trigger (random deletes are safe) that comes from ordinary delete-and-merge operations rather than crafted input or boundary values.
Impact class
silent wrong results
Impact note
Default codec on 10.5.1+ (10.4.0+ with a non-per-field HNSW codec); needs locality-correlated deletes (2.1% in the repro) and a narrow no-rebalance segment-size window (e.g. 1450-1538 vectors); the damage persists in the merged segment.
Summary
You delete a cluster of documents around the HNSW entry node and merge, for example with forceMerge(1), so the merge reuses and repairs the biggest segment's graph. You expect kNN recall to stay about the same. Instead the repair cannot reconnect an entry node that lost every neighbour, every search starts at that isolated node and returns only it (recall@10 0.906 to 0.000 in the repro), and CheckIndex reports no problem.
Introduced
#15003 (ef10476d98a; backport 3e024479d35) added merge-time reuse of graphs with deletions (2025-12-01)
First affected version
10.4.0 (non-per-field HNSW codec); 10.5.1 (default codec)
Affected versions
10.5.1, 10.5.2, branch_10_6, main with the default codec [exec]; 10.4.0 and 10.5.0 only with a codec whose vectors format is not wrapped per field (#16400) [exec]; 10.3.2 not affected [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; not yet triaged by a person.
Found by
formal: TLA+/TLC
Upstream and prior reports
none found (2026-10-23: 46+ gh queries, the recent-issues list, dev@ and java-user@); #16817 is a different mechanism; open #16815 overlaps the code
Lucene commit tested
main b83101cdd2 and branch_10_6 d04ee73177 (2026-10-23); the Maven Central jars 9.12.3, 10.0.0, 10.1.0, 10.2.2, 10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1 and 10.5.2; the introducing commits and their parents (bisect); fix branches 3bd0c584d88 and 3108980e63e. The public-list rerun reproduced the repro on main b83101cdd2; the hunt's main ea57360912ed adds only #16883, which narrows LFV-004 alone.

Lucene and upstream sources

Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsReader.java:578 (reader entry rule; #16883 added 6 lines above it)

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

Repro files

About the diagrams

A structural picture: the HNSW graph as three stacked level bands with nodes, links and dashed lines joining copies of the same node. Node positions are schematic and only some nodes and links are drawn (said on screen). The PNG is the final result frame.

v1 was made on 2026-10-23; v2 applied the corrections of an independent visual check; v3 and v4 are two visual-fixer rounds on 2026-10-24 (pace, layout of step 9, labels), re-rendered and re-checked frame by frame.

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.