- 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.
- 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. - You expect recall@10 to stay about 0.906; the same data with the graph rebuilt from scratch gives 0.910.
- 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. - No error:
CheckIndexreports no problems, and the damage survives commit, reopen and later merges (recall 0.106 after 200 more documents and a second merge).
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.
- 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.
- While the graph is copied, the first surviving top-level node becomes the entry (
InitializedHnswGraphBuilder.java:363on main,hnsw.trySetNewEntryNode(newOrd, level)). Nobody checks how many neighbours it kept. - 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). addConnectionsseeds 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 ownaddConnectionscalls the same way, and in the repro it promotes nothing.- 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.
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_6and main whose merges reuse a graph with deletions: a singletonforceMerge(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,
rebalanceGraphuses an unseeded random, so results can differ between runs whenever rebalancing promotes nodes.
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.
/*
* 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);
}
}
}
}
# 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).
More files, environment, full output39 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- The code handles this case on purpose: "Node has no neighbors, add connections from scratch" (
HnswGraphBuilder.java:757-759). - Tests in
TestHnswFloatVectorGraphassert 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.
fixDisconnectedNodessends a node with zero neighbours toaddConnectionswith the comment "Node has no neighbors, add connections from scratch" (mainHnswGraphBuilder.java:757-759; there since #15003, spelled "nighbors" in 10.5.2InitializedHnswGraphBuilder.java:344-346). #16627 (main andbranch_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
TestHnswFloatVectorGraphassert 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:bugand 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.
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 (
addConnectionsis private inInitializedHnswGraphBuilderthere). 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 testpasses; 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.
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.
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.
Main's CheckIndex already computes 1/1419 connected but does not flag it; it could.
Trade-offs
Reports the damage instead of preventing it.
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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-23 | 46 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-10 | no report of this defect |
| Related items (read) | 2026-10-23 | the comments of 14 related threads | different 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 PRs | 2026-10-23 | PRs 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-23 | dev@ and java-user@, 365 days | no 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) | - |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main b83101cdd2, branch_10_6 d04ee73177 | affected | default 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 | affected | default codec: the same numbers (Maven Central jars, SHA-1 checked) |
| 10.4.0, 10.5.0 | custom codec only | default 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 affected | graphs 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 | bisect | the backport collapses with the default codec; its parent only with the non-per-field codec |
fix 3bd0c584d88 / 3108980e63e | not affected | 0.906, then 0.912; CheckIndex 1419/1419 connected |
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.
| Check | Verdict | What it found |
|---|---|---|
| exec-current | confirmed, with additions | Every 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 corrected | Bisected #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 corrections | Found the serial-merger gap in the fix sketch (probes C2/D2), the release-era javadoc, and the reuse history. |
| intended-behavior | not intended | Built 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 corrected | No 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 ready | Re-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 bug | HNSW concurrent-merge verifier reproduced it end to end on the default codec. |
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 236 KB
Finding record, Lucene and upstream sources, repro files and diagram notes543 words
- 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
b83101cdd2and branch_10_6d04ee73177(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 branches3bd0c584d88and3108980e63e. The public-list rerun reproduced the repro on mainb83101cdd2; the hunt's mainea57360912edadds only #16883, which narrows LFV-004 alone.
- 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)
- TestHnswMergeReuseIsolatedEntry.java (JUnit test):
repro/LFV-005/src/org/apache/lucene/index/TestHnswMergeReuseIsolatedEntry.java(64 lines)
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.
assets/LFV-005/bug.png(236 KB)
