beamWidth (also called efConstruction) is how many candidates the HNSW graph builder keeps while it links a new vector into the graph. Since 10.2.0 the builder also keeps an extra queue of half that size for merges, and half of 1 is 0 in integer arithmetic.
- Each segment's kNN vectors get an HNSW graph. To insert a vector, the builder searches the graph and keeps the best
beamWidthcandidates in a priority queue; the defaultbeamWidthis 100,maxConn(M) is 16. - Small segments skip the graph: with the default threshold a segment gets one only from 648 vectors. A merge then builds the graph of the merged segment.
- Since 10.2.0, merges that combine existing graphs search level 0 from known neighbours with a smaller queue of
min(beamWidth / 2, M * 3)slots. Every builder allocates it up front, whether or not it is used.
beamWidth = 1 and merge two small segments; inside, the builder sizes one queue beamWidth / 2, which is 0; every graph build fails (a merge closes the writer; while a segment fills, addDocument rejects the vector documents that need a graph); broken since 10.2.0, with a one-line fix. Select the still to open it at full size. Generated with AI, checked against the code.- You configure a vector field with
Lucene99HnswVectorsFormat(16, 1): the default 16 connections and a beam width of 1, which the format accepts (any value from 1 to 3200). You index 1,000 2-d vectors, flush after 500, and callforceMerge(1). - You expect one merged segment with an HNSW graph, as
beamWidth = 2gives, and asbeamWidth = 1gave up to 10.1.0. - You get
IllegalArgumentException: initialCapacity must be > 0 and < 2147483630; got: 0from insideHnswGraphBuilder. TheIndexWritercloses as a tragic event, and the 1,000 uncommitted documents are lost. - Why: the builder sizes its level-0 merge queue
Math.min(beamWidth / 2, M * 3). Integer division makes1 / 2 = 0, and the queue's heap rejects size 0. The two 500-vector segments flushed only because segments below 648 vectors skip the graph. - A flush cannot get past that size either: the 648th vector document of a segment and the next one are rejected at
addDocument. On 10.2.x and 10.3.x even the first vector document fails.
Only applications that set beamWidth = 1 on an HNSW vector format; the default is 100. Then any maxConn and any data fail on the first graph build, loudly. The last commit stays intact, but a merge failure closes the writer and discards uncommitted documents. Rare: no upstream test uses 1, and the recorded searches found no report.
Mechanism, terms and numbers600 words
The HNSW vector formats (Lucene99HnswVectorsFormat, and by code reading the scalar-quantized and bit-vector HNSW formats and two sandbox formats) accept any beamWidth from 1 to 3200, and HnswGraphBuilder rejects only beamWidth <= 0. Since 10.2.0 (#14331, "Speedup merging of HNSW graphs") every builder also allocates an extra candidate queue for merge-time level-0 searches, sized Math.min(beamWidth / 2, M * 3). For beamWidth = 1 that is 0, and the heap under the queue throws IllegalArgumentException: initialCapacity must be > 0 and < 2147483630; got: 0. So no HNSW graph can be built with a beam width of 1, for any maxConn and any data. From 10.4.0, segments below the tiny-segment threshold (648 vectors by default) still flush without a graph; then the first merge that has to build one throws, and IndexWriter treats it as a tragic event: the writer closes and everything added since the last commit is discarded. On the flush path the 648th vector document of a segment is rejected, and so is the next one, while the writer stays open. On 10.2.x and 10.3.x the first vector document already fails. 10.1.0 and earlier work; every release from 10.2.0 to 10.6.0 and main fail. The fix is one line, Math.max(1, ...).
- Accepted range.
Lucene99HnswVectorsFormat.java:278-283:if (beamWidth <= 0 || beamWidth > MAXIMUM_BEAM_WIDTH)throws "beamWidth must be positive and less than or equal to 3200" (:112). The same check inLucene104HnswScalarQuantizedVectorsFormat.java:171-173,HnswBitVectorsFormat.java:116-118and the sandboxDedupHnswVectorsFormat.java:175-180andDedupHnswScalarQuantizedVectorsFormat.java:189-194(both write throughLucene99HnswVectorsWriter).HnswGraphBuilder.java:177-179: "beamWidth must be positive". - Three queues. The base constructor (
HnswGraphBuilder.java:166-196), which every other constructor and subclass reaches, allocatesentryCandidates = new GraphBuilderKnnCollector(1)(:193),beamCandidates = new GraphBuilderKnnCollector(beamWidth)(:194) andbeamCandidates0 = new GraphBuilderKnnCollector(Math.min(beamWidth / 2, M * 3))(:195).GraphBuilderKnnCollector(int k)makesnew NeighborQueue(k, false)(:821-822), which makesnew TernaryLongHeap(initialSize)(NeighborQueue.java:59-60), which throws forinitialCapacity < 1(TernaryLongHeap.java:57-59). - Only merges that reuse graphs use the third queue (
:349-351,level == 0 && eps0 != null && eps0.size() > 0, called fromMergingHnswGraphBuilder.java:229and, on main, from the concurrent merge'sHnswConcurrentMergeBuilder.java:602), but it is allocated in every builder, so all graph building fails: the merge without a source graph (IncrementalHnswGraphMerger.java:159-161, the repro), the flush (Lucene99HnswVectorsWriter.java:832-836->:809-814), the merge of segments with graphs (MergingHnswGraphBuilder.java:73; by code reading) and the concurrent merge workers (HnswConcurrentMergeBuilder.java:493,:519; by code reading). - When a graph is needed.
shouldCreateGraph(100, n)isn > (int) (ln(n) * 100)(Lucene99HnswVectorsWriter.java:716-722,HnswGraphSearcher.java:56-58,HNSW_GRAPH_THRESHOLD = 100atLucene99HnswVectorsFormat.java:137): false for 500 (621), true from 648 (647) and for 1,000 (690). - Why the flush keeps failing. In
addValuethe exception comes frominitializeGraphBuilder()(:835) beforenode++(:841), so the next vector document meets the same condition and throws again (the next one was executed). "Rejected" is whataddDocumentreports: the field writer has already buffered the vector (flatFieldVectorsWriter.addValue,:832), andDocumentsWriterPerThreadmarks the failed document deleted (DocumentsWriterPerThread.java:302-307,:399). - Why the writer closes.
IndexWriter.mergecatches theThrowableand callstragicEvent(t, "merge")(IndexWriter.java:5109-5113), which closes the writer and rolls back uncommitted changes (tragicEvent->maybeCloseOnTragicEvent->rollbackInternal,:6090-6105). Only a failed merge does this; a flush-path failure leaves the writer open. - Exhaustive check. Over all 1,638,400 accepted
(maxConn, beamWidth)pairs (1..512 x 1..3200), 512 give a non-positive queue size, all withbeamWidth = 1; with the fix, none.
Exposure and likelihood302 words
- Who is hit.
Lucene99HnswVectorsFormat(maxConn, 1)(executed); by code reading alsoLucene104HnswScalarQuantizedVectorsFormat,HnswBitVectorsFormatand the sandboxDedupHnswVectorsFormat/DedupHnswScalarQuantizedVectorsFormat, which accept the same range. AnymaxConn, any data, any similarity. - What goes wrong (10.4.0 and later). Small segments flush; the first merge, forced or natural, whose result needs a graph throws, and the writer closes as a tragic event, which rolls it back: every document added since the last commit is lost (all 1,000 in the repro, which never commits; executed: afterwards the directory holds no index). A flush of 648 or more vectors rejects the threshold-crossing document and the next one at
addDocument, while the writer stays open. - 10.2.x and 10.3.x build the flush graph eagerly, so the first vector
addDocumentalready throws (LongHeap"maxSize must be > 0 ... got: 0"). - Upgrades. An index written with
beamWidth = 1on 10.1.0 or earlier keeps working on 10.2.0 or later only until its first graph build: a merge that needs a graph, or a new vector document (on 10.2.x-10.3.x the first one, from 10.4.0 the one that takes a segment to 648 vectors). By code reading:beamWidthis not stored in the index. - What was not seen. Every executed failure is a loud exception; no silent corruption, hang or wrong search result was observed in the executed tests, and the last commit stayed readable (the independent reproduction's probe: 800 committed documents in 2 segments after the failed merge).
- Workaround. Use
beamWidth >= 2; 2 passes on main, 10.6.0 and 10.1.0. - How likely. Rare: upstream randomized tests draw beam widths from 5 or more (
HnswGraphTestCasenextInt(10) + 5,RandomCodec10 to 50), and the recorded searches found no report in the five affected release lines; whoever sets 1 hits it on the first graph build.
One JUnit test (public Lucene APIs plus the test framework, no package-private member): Lucene99HnswVectorsFormat(16, 1), two flushed segments of 500 fixed 2-d vectors, forceMerge(1). One thread (SerialMergeScheduler); it failed in every recorded run, with assertions on and off, on main and 10.6.0, and passes on 10.1.0. With the fix it passes unchanged, so it doubles as the regression test.
/*
* 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.codecs.lucene99;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.KnnFloatVectorField;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.SerialMergeScheduler;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;
public class TestHnswBeamWidthOneMerge extends LuceneTestCase {
// Expected: forceMerge builds the HNSW graph with beamWidth = 1.
// Actual: IllegalArgumentException "initialCapacity must be > 0 ... got: 0"; the writer closes.
public void testForceMergeWithBeamWidthOne() throws Exception {
IndexWriterConfig iwc =
new IndexWriterConfig()
.setCodec(TestUtil.alwaysKnnVectorsFormat(new Lucene99HnswVectorsFormat(16, 1)))
.setMergeScheduler(new SerialMergeScheduler());
try (Directory dir = newDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
for (int i = 0; i < 1000; i++) {
Document doc = new Document();
doc.add(new KnnFloatVectorField("v", new float[] {i, i % 7}));
w.addDocument(doc);
if (i == 499) {
w.flush();
}
}
w.forceMerge(1);
}
}
}
cp $REPRO/TestHnswBeamWidthOneMerge.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene99/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene99.TestHnswBeamWidthOneMerge -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and once more with -Ptests.asserts=false: it fails both ways
- Expected
forceMerge(1)builds one segment with an HNSW graph, as withbeamWidth = 2and as on 10.1.0.- Observed
IllegalArgumentException: initialCapacity must be > 0 and < 2147483630; got: 0atHnswGraphBuilder.<init>:195, and theIndexWritercloses.
More files, environment, full output529 words
Copy the test into lucene/core/src/test/org/apache/lucene/codecs/lucene99/ of a Lucene checkout and run the command below; it uses public Lucene APIs plus Lucene's test framework (LuceneTestCase, newDirectory(), TestUtil.alwaysKnnVectorsFormat) and no package-private member. The vectors, the codec and the merge scheduler are fixed by the test; newDirectory() returns a randomised directory, and LuceneTestCase also randomises the similarity, the locale and the timezone. It failed under seed DEADBEEF with assertions on and off and under seed CAFEBABE with assertions off. The 500/500 split matters: each flushed segment stays below the 648-vector graph threshold, so the failure shows at the merge, where it closes the writer.
This is the hunt's minimal test with its one comment line rewritten to fit the 100-column format check; the code is unchanged. It passes :lucene:core:checkGoogleJavaFormat; the original 127-column comment did not. With assertions on and off it fails on main and passes with the fix, under gradle and the standalone runner; on the standalone runner it fails on 10.6.0 and passes on 10.1.0.
The test checks only that forceMerge(1) does not throw; an upstream regression test may also assert one segment and a kNN hit, as the graph-merge probe under Suggested fixes does. The registration check, the credibility audit and the diagram use another, unpublished copy of the same test: the field is named "vector", two lines carry comments, and it ends with assertTrue(w.isOpen()), which on main is never reached because forceMerge throws. It fails and passes the same way.
| lucene | main 645d4fe14a20 (11.0.0-SNAPSHOT): fails; also fails on the 10.2.0, 10.3.2, 10.4.0, 10.5.2 and 10.6.0 release jars; passes on 10.1.0 |
|---|---|
| jdk | OpenJDK 25.0.3 (build 25.0.3+9-LTS) |
| os | Linux aarch64 (the only platform it ran on) |
cp $REPRO/TestHnswBeamWidthOneMerge.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene99/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene99.TestHnswBeamWidthOneMerge -Ptests.seed=DEADBEEF -Ptests.asserts=true
# and once more with -Ptests.asserts=false: it fails both ways
# gradle, fresh worktree at main 645d4fe14a20, -Ptests.seed=DEADBEEF -Ptests.asserts=true (the same with false)
TestHnswBeamWidthOneMerge > testForceMergeWithBeamWidthOne FAILED
java.lang.IllegalArgumentException: initialCapacity must be > 0 and < 2147483630; got: 0
at __randomizedtesting.SeedInfo.seed([DEADBEEF:D330CEFBEFE7284]:0)
at org.apache.lucene.util.TernaryLongHeap.<init>(TernaryLongHeap.java:59)
at org.apache.lucene.util.hnsw.NeighborQueue.<init>(NeighborQueue.java:60)
at org.apache.lucene.util.hnsw.HnswGraphBuilder$GraphBuilderKnnCollector.<init>(HnswGraphBuilder.java:822)
at org.apache.lucene.util.hnsw.HnswGraphBuilder.<init>(HnswGraphBuilder.java:195)
at org.apache.lucene.util.hnsw.HnswGraphBuilder.<init>(HnswGraphBuilder.java:147)
at org.apache.lucene.util.hnsw.HnswGraphBuilder.<init>(HnswGraphBuilder.java:141)
at org.apache.lucene.util.hnsw.HnswGraphBuilder.create(HnswGraphBuilder.java:123)
at org.apache.lucene.util.hnsw.IncrementalHnswGraphMerger.createBuilder(IncrementalHnswGraphMerger.java:160)
at org.apache.lucene.util.hnsw.IncrementalHnswGraphMerger.merge(IncrementalHnswGraphMerger.java:255)
at org.apache.lucene.codecs.lucene99.Lucene99HnswVectorsWriter.buildAndWriteGraph(Lucene99HnswVectorsWriter.java:525)
...
at org.apache.lucene.index.IndexWriter.merge(IndexWriter.java:5081)
...
at org.apache.lucene.index.IndexWriter.forceMerge(IndexWriter.java:2229)
1 test completed, 1 failed
# standalone runner, the same test (tests_run=1 failures=1 each, seed DEADBEEF)
main 645d4fe14a20 -ea and -da the same exception at HnswGraphBuilder.<init>:195
10.6.0 / 10.5.2 / 10.4.0 -da the same, at :216 / :215 / :190
10.3.2 / 10.2.0 -da at the first addDocument: maxSize must be > 0 and < 2147483630; got: 0 (LongHeap), :165
10.1.0 -ea and -da passes
# tragic close with the default ConcurrentMergeScheduler (an independent probe on main, -da; 2 committed 400-vector segments)
PROBE bw=2 forceMerge ok / isOpen=true tragic=null / committed docs=800 segs=1
PROBE bw=1 forceMerge threw java.io.IOException: background merge hit exception: ... cause=java.lang.IllegalArgumentException: initialCapacity must be > 0 and < 2147483630; got: 0
PROBE bw=1 isOpen=false tragic=java.lang.IllegalArgumentException: initialCapacity must be > 0 and < 2147483630; got: 0
PROBE bw=1 committed docs=800 segs=2
PROBE addDoc failed at doc 647: java.lang.IllegalArgumentException: initialCapacity must be > 0 ... (0-based: the 648th)
PROBE addDoc isOpen=true tragic=null
PROBE next addDoc: java.lang.IllegalArgumentException: initialCapacity must be > 0 ...- The formats and the builder state the rule as "beamWidth must be positive"; the limit tests reject only 0 and below.
- It worked through 10.1.0, which has no such queue; within the #14331 PR,
min(beamWidth, M * 2)becamemin(beamWidth / 2, M * 3). - The error is an internal heap check at merge time that closes the writer, not a validation.
The full argument234 words
- Contract. The format javadoc calls
beamWidth"the size of the queue maintained during graph construction" (Lucene99HnswVectorsFormat.java:191), the builder javadoc "the size of the beam search to use when finding nearest neighbors" (HnswGraphBuilder.java:133,:161). Nothing documents a minimum of 2 or says that half the beam sizes another queue; the merge builder's javadoc describes the merge-time level-0 search as "We also limit beamWidth (efConstruction to M*3)" (MergingHnswGraphBuilder.java:47-49), a cap, which forbeamWidth = 1would be 1, not 0. - Tests.
TestLucene99HnswVectorsFormat.testLimitsrejectsbeamWidth0, -1 and 3201 only (:64-74);HnswGraphTestCase.testHnswGraphBuilderInvalidsays// beamWidth must be > 0(:888-890). - History. Inside the #14331 PR branch, commit
09735cf80a1("Adjust beamCandidates0", 2025-03-18) replacedMath.min(beamWidth, M * 2)withMath.min(beamWidth / 2, M * 3); the discussion covers only the cap (M * 2 to M * 3). The builder'sbeamWidth <= 0check dates from 2020: it was added by09f78e2927c(LUCENE-9610);git blameshowse1cd426bce39(LUCENE-9644), which moved it. - A beam width below
maxConnhas been called valid. In #13752 (2024, closed as not planned) Michael McCandless asked whetherbeamWidth < maxConnshould throw; Ben Trent answered "It is a weird configuration for sure, but I am not sure its invalid", and no validation was added. - A sibling edge case in the same merger was fixed as a bug: #14407 / #14420 (an empty join-set heap, fixed in 10.2.0).
The devil's-advocate check223 words
The case (built by the intended-behaviour devil's advocate and the cross-model reviews, all rating it weak): a beam of one gives a poor graph and nobody should run it in production; the formats are @lucene.experimental (Lucene99HnswVectorsFormat.java:83); a maintainer may prefer to reject beamWidth < 2.
Why it fails: the documented and tested rule is "positive", the value worked through 10.1.0, and today's failure is an opaque internal error that closes the writer at merge time, not a clear validation. @lucene.experimental allows API changes, not crashes on accepted input. Rejecting beamWidth < 2 is one of the fixes (option B), and it still changes code. The argument bears on priority (Low), not on whether it is a bug.
The hunt's no-noise rule does not claim behaviour that "needs absurd parameters and then fails loudly". This bug does fail loudly, and 1 is an unusual value: that is the main argument for Low priority, and it is conceded. It is still claimed because the value is inside the documented and tested contract ("positive"), it worked through 10.1.0 (a regression), and the failure does not come where the value is set: it comes later, at the first graph build, often at a merge, where it closes the writer and rolls back uncommitted documents. The cross-model fact check reached the same call: claim, Low.
Recommended Clamp the level-0 merge queue to at least 1 (one line in HnswGraphBuilder)
Clamp the level-0 merge queue to at least one slot: Math.max(1, Math.min(beamWidth / 2, M * 3)) in the HnswGraphBuilder constructor. Nothing changes for beamWidth >= 2. The test, a graph-merge probe and the closest HNSW and codec suites (526 lucene/core tests and 8 bit-vector tests) pass.
beamCandidates0 = new GraphBuilderKnnCollector(Math.max(1, Math.min(beamWidth / 2, M * 3)));
// was: new GraphBuilderKnnCollector(Math.min(beamWidth / 2, M * 3))All options and trade-offs3 options ยท 539 words
Three options; A was validated with gradle in a fresh scratch worktree at 645d4fe14a20, B and C were not executed. A maintainer may prefer B, a clear early rejection; either resolves the bug. Not a fix: allocating beamCandidates0 lazily, only when the eps0 path needs it. Flushes and graph-free merges would work, but the merge of two segments that have graphs would still hit the zero size.
Clamp the level-0 merge queue to at least 1 (one line in HnswGraphBuilder) (validated by the checks) (recommended)
HnswGraphBuilder.java:195 becomes beamCandidates0 = new GraphBuilderKnnCollector(Math.max(1, Math.min(beamWidth / 2, M * 3)));.
beamCandidates0 = new GraphBuilderKnnCollector(Math.max(1, Math.min(beamWidth / 2, M * 3)));
// was: new GraphBuilderKnnCollector(Math.min(beamWidth / 2, M * 3))Trade-offs
- No change for
beamWidth >= 2: theremin(beamWidth / 2, M * 3)is already at least 1 (M >= 1), so every graph built today is built the same way. - For
beamWidth = 1the level-0 merge search keeps 1 candidate, the same as the main beam queue. - Keeps the documented "positive" contract, and
beamWidth = 1works again as it did up to 10.1.0.
What the checks validated
Gradle in a fresh scratch worktree at 645d4fe14a20 (JDK 25.0.3, -Ptests.seed=DEADBEEF), 2026-10-30:
- without the fix the minimal test fails with
-Ptests.asserts=trueandfalse;git applyof the one-line diff succeeds, and with it the test passes both ways; aftergit apply -Rit fails again; - the closest suites pass with the fix:
org.apache.lucene.util.hnsw.*,org.apache.lucene.codecs.lucene99.*,org.apache.lucene.codecs.lucene104.*andTestKnnGraph= 26 suites, 526 tests, 18 skipped, 0 failures (54 of them postings, segment-info and packing tests, and 1 the minimal test; among themTestHnswFloatVectorGraph32,TestHnswByteVectorGraph26,TestLucene99HnswVectorsFormat60,TestLucene104HnswScalarQuantizedVectorsFormat64);TestHnswBitVectorsFormatinlucene/codecs: 8 tests, 0 failures; :lucene:core:checkGoogleJavaFormatpasses with the fix and the test;- the graph-merge path, which by code reading uses the clamped queue (
MergingHnswGraphBuilder.java:229passes each smaller-graph node's known neighbours aseps0; the probe does not instrument the queue): a probe (not the repro) with two flushed 1,000-vector segments that each get a graph, thenforceMerge(1), passes with the fix (-ea,-da): the HNSW info stream reports "build graph from merging 2 graphs of 2000 vectors, graph sizes:1000 1000", the merged index has 1 segment and 2,000 documents, and a k = 5 kNN query returns 5 hits; - earlier: the hunt's shadow-fix runs pass the test on main and on 10.2.0, and an independent reviewer from another model family ran the upstream format-limit, builder-invalid-input and graph read/write tests with the fix: pass.
In Lucene99HnswVectorsFormat, Lucene104HnswScalarQuantizedVectorsFormat, HnswBitVectorsFormat and HnswGraphBuilder. Not executed.
Trade-offs
Fails fast at codec construction instead of at a merge, but changes the documented "positive" contract and the testLimits / testHnswGraphBuilderInvalid expectations, rejects configurations that worked through 10.1.0, and must be repeated in every format with the same check (the sandbox dedup formats have it too).
Not executed. Never 0 either.
Trade-offs
Changes the queue size for every odd beamWidth below 6 * M (3 -> 2, 5 -> 3, ...), so merge-built graphs change for those settings; option A changes nothing outside beamWidth = 1.
By code reading only, saved snapshots suggest a user-chosen value of 1 can reach this crash downstream: Solr's DenseVectorField passes hnswEfConstruction to Lucene99HnswVectorsFormat, and the OpenSearch k-NN plugin's Lucene HNSW method accepts any ef_construction > 0. Their revisions and the OpenSearch mapping to beamWidth were not confirmed.
Two independent hunts found it in the same minute, by reading the HNSW graph builder's constructor while deriving the parameter domain of their models; one hunt's bounded parameter sweep then hit the exception at beamWidth = 1 for every maxConn. A later hunt reduced it to one test and checked every accepted parameter pair.
The method in full223 words
Method: code reading during model building, then executed tests and an exhaustive check (the hunt's non-formal tier: the bug was seen in the code, not reported by a model checker).
- First signal (2026-10-25). A unit modelling the HNSW graph format derived the legal parameter domain from the format's validation (
1 <= beamWidth <= 3200) and checked that every buffer sized frommaxConnandbeamWidthis legal on it;beamWidth / 2is not. It confirmed this with an executed probe and a JUnit on the stock main jars. In the same minute a unit modelling merge-time graph reuse noticed the line while modelling the eps0 path, and its bounded-exhaustive harness reported the builder exception forbeamWidth = 1at everyMand for no other beam width. - Hunt. An adjudication-take-over unit re-ran the earliest repro on main (
-ea,-da: fails), reduced it to one JUnit test with no package-private member, checked the three queue sizes over all 1,638,400 accepted(maxConn, beamWidth)pairs (512 non-positive, all atbeamWidth = 1; 0 with the fix) and wrote the one-line fix. - Checks. An independent reproducer, an intended-behaviour devil's advocate, two executed reviews by an independent reviewer from another model family (OpenAI GPT-5.6), an adjudication, a credibility audit and a no-noise gate confirmed it; the version-history runs ran the release matrix.
No earlier report found (searched 2026-10-30, re-checked the same day). Closest: #14331, the introducing PR, whose discussion covers only the queue's upper cap, and #13752, where a beam width below maxConn was called a valid configuration.
Searches4 searches
Related, not this bug: #14331 (the introducing PR) and #14380 (its backport); #14407 / #14420 (a different empty-heap edge case in the same merger, fixed in 10.2.0); #13752 (2024, closed as not planned: a beamWidth below maxConn called valid, no validation added); #13399 and #13525 (open, stale: move the format's validation, same bounds); #11387 and an Elasticsearch PR with the same "maxSize must be > 0" family of message from unrelated search code.
| Where | When | How | Found |
|---|---|---|---|
| Search tool, two runs (GitHub issues, PRs and commits incl. the migrated JIRA archive; dev@, java-user@, issues@, commits@; Elasticsearch, OpenSearch, OpenSearch k-NN, Solr; web) | 2026-10-30 | 112 queries: class, field and message terms (beamCandidates0, "beamWidth / 2", GraphBuilderKnnCollector, "initialCapacity must be > 0", "maxSize must be > 0", "beamWidth must be positive", efConstruction minimum); 269 distinct hits, every plausible one opened | no report of this bug |
| GitHub (apache/lucene and all of GitHub) | 2026-10-30 | 18 own searches (beamWidth=1, "beam width 1", efConstruction=1, the messages); "beamCandidates0" anywhere on GitHub | only #14331 |
| Open and recent apache/lucene PRs | 2026-10-30 | the changed files of all 409 open PRs (16 touch the fix-site files, their diffs grepped) and the 49 PRs updated since 2026-10-28 | none changes beamCandidates0, beamWidth / 2 or the bounds |
| GitHub, re-check for this doc | 2026-10-30 (saved) | read-only gh calls, commands and output saved: issues updated since 2026-10-29 matching beamWidth; issues and PRs matching beamCandidates0; issues and PRs quoting "initialCapacity must be"; the 15 PRs updated since 2026-10-30, their changed files and HnswGraphBuilder.java patches; upstream main head and the file's newest commits | nothing new: only #16906 (unrelated) and #14331; #16806 changes HnswGraphBuilder.java but not the line; upstream main 2f424fd428da still has it at :195 |
Introduced in 10.2.0 by #14331 (3752dba9c51, merged 2025-03-20; backport aa1e9f34b37). 9.12.3, 10.0.0 and 10.1.0 pass; 10.2.0, 10.3.2, 10.4.0, 10.5.2, 10.6.0 and main fail; 10.2.0 with only the one-line fix passes. Not fixed upstream.
Versions tested7 rows
Introduced by #14331 "Speedup merging of HNSW graphs" (PR by Mayya Sharipova; CHANGES credits Mayya Sharipova and Tom Veasey), squash commit 3752dba9c51 on main and backport aa1e9f34b37 (#14380) the same day; git blame puts line 195 on it, and git log -S beamCandidates0 and -G 'beamWidth / 2' find only it. Inside the PR, 09735cf80a1 (2025-03-18) changed the size from Math.min(beamWidth, M * 2) to Math.min(beamWidth / 2, M * 3). The failure point moved in 10.4.0, when #14963 let tiny segments skip the graph: from the first vector addDocument to the first segment or merge that needs a graph. Read only: 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.5.0 and 10.5.1 contain the same line; all 9.x lack it. Upstream main 2f424fd428da (2026-10-30) still has it at :195.
| Version or commit | Verdict | Result |
|---|---|---|
main 645d4fe14a20 | affected | the minimal test fails (gradle and the standalone runner, -ea and -da) at :195 |
| 10.6.0, 10.5.2, 10.4.0 (release jars) | affected | fails at forceMerge: "initialCapacity must be > 0 ... got: 0" (:216, :215, :190) |
| 10.3.2, 10.2.0 (release jars) | earlier failure | fails at the first vector addDocument: LongHeap "maxSize must be > 0 ... got: 0" (:165) |
| 10.2.0 + the one-line fix | not affected | passes |
| 10.1.0 | not affected | passes (-ea, -da); no beamCandidates0 |
| 10.0.0, 9.12.3 | not affected | a port of the test passes |
fix A (scratch worktree at 645d4fe14a20) | not affected | the minimal test, the closest HNSW and codec suites (526 lucene/core tests, 54 of them postings, segment-info and packing tests) and 8 bit-vector tests in lucene/codecs pass |
An independent reproducer, an intended-behaviour devil's advocate, two executed cross-model reviews, an adjudication, a credibility audit and a no-noise gate confirm it; the version-history runs ran the release matrix. This doc re-ran the test and validated the fix with gradle.
Checks and verdicts470 words
The cross-model reviews ran on main ea57360912ed, where HnswGraphBuilder.java, NeighborQueue.java, TernaryLongHeap.java and Lucene99HnswVectorsFormat.java are identical to 645d4fe14a20; other call-path files changed in between (#16794), and the test fails at the same line on both.
| Check | Verdict | What it found |
|---|---|---|
| first finders (two hunt units, 2026-10-25) | candidate | Code reading of the builder constructor, a probe and a JUnit on the main jars; a bounded parameter sweep hit the exception at beamWidth = 1 for every M. |
| finder (judge-take-over unit) | candidate | Re-ran the earliest repro, reduced it to one JUnit, checked all 1,638,400 accepted pairs, wrote the fix. |
| cross-model review, refute lens (OpenAI GPT-5.6, executed) | real (high) | Own codec test ((4, 1), 700 vectors, commit and reopen) fails on the flush path with -ea and -da; the beamWidth = 2 control reads back 700 documents; passes with the fix. |
| independent reproducer | real | Own 2-test JUnit fails 2 of 2 on main (-ea, -da) on the merge and flush paths; fails on 10.6.0, 10.5.2 and 10.2.0, passes on 10.1.0 and 9.12.3; tragic-close probe. |
| cross-model review, intended lens (OpenAI GPT-5.6, executed) | real (high) | Own test fails on main, passes on 10.1.0 and with the fix; upstream limit, invalid-input and graph tests pass with the fix. |
| intended-behaviour devil's advocate | real (high) | No javadoc, CHANGES, MIGRATE, test or #14331 discussion restricts small beam widths; a core-only program fails on 10.6.0 and main and passes on 10.1.0; beamWidth = 2 passes on all three. |
| adjudication | confirmed (high); crash; Low | Re-ran the finder's test, the reproducer's test and the fix check; shipped the SerialMergeScheduler version. |
| Registration check, credibility audit, no-noise gate | keep; claim | Their copy of the test (an unpublished variant of the Minimal repro test, described there) fails on main (DEADBEEF -ea and -da, CAFEBABE -da) and passes with the fix. |
| the version-history runs | regression in 10.2.0 | Release matrix from 9.12.3 to 10.6.0; 10.2.0 plus the fix passes. |
| this doc | reproduced, fix validated | Gradle in a fresh worktree without and with the fix (assertions on and off) and the closest suites; the format-clean test on main, 10.6.0 and 10.1.0; a graph-merge probe with the fix. |
| independent fact check of this doc | real; 3 text errors, fixed | Ran the test with the standalone runner on main (DEADBEEF -ea, -da; CAFEBABE -da), 10.6.0, 10.1.0 and with the fix, and the doc's own gradle command in a fresh worktree without and with the fix; a SerialMergeScheduler probe showed the writer closed and no index on disk. |
| cross-model fact check of this doc (OpenAI GPT-5.6) | claim, Low | A read-only review of part of the doc's evidence; its wording corrections are in this version. |
| diagram check | pass |
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 417 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,488 words
- Registry title
- With beamWidth = 1, which the HNSW formats accept, every HnswGraphBuilder sizes its level-0 merge queue min(beamWidth / 2, M * 3) = 0 and throws, so building any HNSW graph fails and a merge closes the IndexWriter (since 10.2.0)
- Area
- core / HNSW graph construction (util/hnsw HnswGraphBuilder constructor; codecs/lucene99 Lucene99HnswVectorsFormat beamWidth range)
- Priority
- Low, rank 686: BeamWidth = 1 is accepted by the core HNSW formats but makes every graph build throw, so a merge closes the IndexWriter (released since 10.2.0), the same accepted-setting crash class as LFV-589.
- Impact class
- crash
- Impact note
- Hits only applications whose vector field uses an HNSW format built with beamWidth = 1 (Lucene99HnswVectorsFormat(maxConn, 1) [exec]; by code reading also Lucene104HnswScalarQuantizedVectorsFormat and HnswBitVectorsFormat, which accept the same 1..3200 range), for any maxConn, data and scale: from 10.4.0 segments below the tiny-segment threshold (about 650 vectors with the defaults) flush without a graph, then the first merge that has to build one throws IllegalArgumentException and closes the IndexWriter as a tragic event, which discards every document added since the last commit (the last commit stays intact), and on the flush path the vector document that pushes a segment over the threshold and every later one in that segment are rejected; on 10.2.x-10.3.x the first vector document already fails; -ea and -da, released 10.2.0 through 10.6.0.
- Summary
- You configure a vector field with an HNSW format and beamWidth = 1, the smallest value the format and the graph builder accept ('beamWidth must be positive'), index vectors and merge, and you expect it to work as it did up to 10.1.0, only with a poorer graph. Instead every HnswGraphBuilder constructor sizes its level-0 merge candidate queue as Math.min(beamWidth / 2, M * 3), which integer division makes 0, and the heap rejects capacity 0 ('initialCapacity must be > 0 ... got: 0'), so no HNSW graph can be built at all. Small segments still flush without a graph, then the first merge that needs one throws and closes the IndexWriter, and the vector document that would push a segment past the graph threshold is rejected.
- Introduced
- 3752dba9c51d817363c76e9a388cd68dcba5c4a0 (main) and its branch_10x backport aa1e9f34b373882025726a01982b768e4948ccf4 (#14380), first shipped in 10.2.0: apache/lucene#14331 'Speedup merging of HNSW graphs' (author mayya-sharipova, merged 2025-03-20). The 'beamWidth / 2' sizing entered the PR branch in 09735cf80a1 ('Adjust beamCandidates0', 2025-03-18), replacing Math.min(beamWidth, M * 2), which is at least 1 for every accepted value [read: blame of HnswGraphBuilder.java:195, git log -S beamCandidates0 and -G 'beamWidth / 2' in the full-history clone find only this commit; exec at release granularity: 10.1.0 passes, 10.2.0 fails, 10.2.0 with only the one-line fix shadowed in passes] (2025-03-20 (author date 2025-03-20T10:02:50-04:00 = 14:02:50Z, squash-merged to main the same minute; branch_10x backport merged 2025-03-20T18:43:08Z))
- First affected version
- 10.2.0 [exec: 10.2.0 fails with -ea and -da at the first vector addDocument; 10.1.0 passes with -ea and -da; 10.0.0 and 9.12.3 pass with a port of the test]
- Affected versions
- every release from 10.2.0 through 10.6.0 (10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2, 10.6.0) and main [exec: 10.2.0 (-ea, -da), 10.3.2 (-da), 10.4.0 (-da), 10.5.2 (-ea, -da), 10.6.0 = 4ae946e5a9b on the Maven Central jars (-ea, -da), main 645d4fe14a20 (-ea, -da; re-run at the registration check 2026-10-30: 1 of 1 fails each, seed DEADBEEF); read: 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.5.0 and 10.5.1 contain the same line]. The failure point moves with the version: in 10.2.x-10.3.x the flush writer builds the graph eagerly, so the first vector addDocument throws ('maxSize must be > 0' from LongHeap at HnswGraphBuilder.<init>:165); from 10.4.0 (#14963, tiny segments skip the graph) small segments flush and the failure moves to the first segment or merge that needs a graph ('initialCapacity must be > 0' from TernaryLongHeap; 10.4.0 at <init>:190, 10.5.2 at :215, 10.6.0 at :216, main at :195). Not affected: 10.1.0, 10.0.0 and 9.12.3 [exec] and all 9.x (no beamCandidates0) [read]. Not fixed upstream: main head 716d6f216f5 (2026-10-29) still has the line at HnswGraphBuilder.java:195 and branch_10_6 at :216, and no open PR changes it [read, 2026-10-30]. Lucene104HnswScalarQuantizedVectorsFormat and HnswBitVectorsFormat reach the same builder [read only, not executed].
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed by AI-run checks: an independent repro, a cross-model review that agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
- Found by
- non-formal: code reading; also reached by exhaustive check
- Upstream and prior reports
- none found (2026-10-30, read-only; the prior-report record, verdict new: two scripted prior-report searches with 112 queries over GitHub issues and PRs, commits, downstream projects (Elasticsearch, OpenSearch, OpenSearch k-NN, Solr), the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web (269 distinct hits, 0 high overlap, every plausible hit opened), 18 own GitHub searches, the changed files of all 409 open apache/lucene PRs (16 touch the fix-site files; none of their diffs changes beamCandidates0, 'beamWidth / 2' or the beamWidth bounds) and the 49 PRs updated since 2026-10-28; refresh 2026-10-30: no new item, the PRs and issues updated since 2026-10-29 that mention beamWidth or HnswGraphBuilder (#16806, #16915, #16906) do not change the line, open #16871 does not touch HnswGraphBuilder, and upstream main head 716d6f216f5 still has it at HnswGraphBuilder.java:195). Closest items, none the same bug: #14331 (introducing PR; its discussion covers only raising the cap from M * 2 to M * 3) and its backport #14380, #14407 / #14420 (a different empty-heap edge case in the same merger, fixed in 10.2.0), #13752 (2024, closed as not planned: benwtrent called a small-beamWidth configuration 'weird ... but I am not sure its invalid' and no validation was added).
- Lucene commit tested
- main
645d4fe14a20(11.0.0-SNAPSHOT), 2026-10-30: the minimal test with gradle in a fresh scratch worktree (fails with assertions on and off; with the one-line fix it passes, together with the closest HNSW and codec suites (526lucene/coretests, among them 54 postings, segment-info and packing tests and the minimal test, plus 8 bit-vector tests) and the core format check) and with the standalone runner (-ea,-da); a graph-merge probe with the fix shadowed (-ea,-da); the release jars 10.6.0 and 10.1.0 (this doc's runs), and in the hunt 9.12.3, 10.0.0 (a port), 10.1.0, 10.2.0, 10.3.2, 10.4.0, 10.5.2 and 10.6.0, plus 10.2.0 with only the one-line fix. JDK 25.0.3, Linux aarch64. The cross-model reviews ran on mainea57360912ed, whereHnswGraphBuilder.javaand the heap and format files are identical (other call-path files changed in #16794; the test fails at the same line on both).
- Finding record
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:177-179 (at 645d4fe14a20) (the builder rejects only beamWidth <= 0: 'beamWidth must be positive'), :195 (beamCandidates0 = new GraphBuilderKnnCollector(Math.min(beamWidth / 2, M * 3)), allocated in every constructor), :349-351 (beamCandidates0 is used only when eps0 is non-empty, the level-0 search of merge-time graph reuse), :818-822 (GraphBuilderKnnCollector(int k) -> new NeighborQueue(k, false))lucene/core/src/java/org/apache/lucene/util/hnsw/NeighborQueue.java:59-60 (at 645d4fe14a20) (new TernaryLongHeap(initialSize)) and lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.java:57-59 (initialCapacity < 1 -> IllegalArgumentException 'initialCapacity must be > 0 and < ...')lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:83 (at 645d4fe14a20) (@lucene.experimental), :137 (HNSW_GRAPH_THRESHOLD = 100), :191-193 ('@param beamWidth the size of the queue maintained during graph construction.', public 2-argument constructor), :278-283 ('beamWidth must be positive and less than or equal to ' MAXIMUM_BEAM_WIDTH = 3200); lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104HnswScalarQuantizedVectorsFormat.java:171 and lucene/codecs/src/java/org/apache/lucene/codecs/bitvectors/HnswBitVectorsFormat.java:116 (same range check)lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:809-814 (at 645d4fe14a20) (flush path: initializeGraphBuilder -> HnswGraphBuilder.create) and lucene/core/src/java/org/apache/lucene/util/hnsw/IncrementalHnswGraphMerger.java:158-161 (merge path: createBuilder -> HnswGraphBuilder.create)lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5112 (at 645d4fe14a20) (IndexWriter.merge: tragicEvent(t, "merge"), which closes the writer and rolls back uncommitted changes)lucene/core/src/test/org/apache/lucene/codecs/lucene99/TestLucene99HnswVectorsFormat.java:67-68 (at 645d4fe14a20) (testLimits rejects beamWidth 0 and -1 only) and lucene/core/src/test/org/apache/lucene/util/hnsw/HnswGraphTestCase.java:156 and :325 (random beamWidth nextInt(10) + 5)
- The finding in 30 seconds
lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:52, (at 645d4fe14a20) :57 (defaults 16 and 100), :166-196 (base constructor), :177-179, :193-195, :349-351, :821-822lucene/core/src/java/org/apache/lucene/util/hnsw/NeighborQueue.java:59-60; (at 645d4fe14a20) lucene/core/src/java/org/apache/lucene/util/TernaryLongHeap.java:56-62lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:112, (at 645d4fe14a20) :137, :278-283; lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104HnswScalarQuantizedVectorsFormat.java:171-173; (at 645d4fe14a20) lucene/codecs/src/java/org/apache/lucene/codecs/bitvectors/HnswBitVectorsFormat.java:116-118; (at 645d4fe14a20) lucene/sandbox/src/java/org/apache/lucene/sandbox/codecs/dedup/DedupHnswVectorsFormat.java:175-180, (at 645d4fe14a20) :201; DedupHnswScalarQuantizedVectorsFormat.java:189-194, :215lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsWriter.java:716-722, (at 645d4fe14a20) :809-816, :832-841; lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphSearcher.java:56-58 (at 645d4fe14a20)lucene/core/src/java/org/apache/lucene/util/hnsw/IncrementalHnswGraphMerger.java:157-177; (at 645d4fe14a20) MergingHnswGraphBuilder.java:73, :193-231; HnswConcurrentMergeBuilder.java:493, :519; HnswConcurrentMergeBuilder.java:582-602 (addJoinedNode -> addGraphNode(node, eps))lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:5109-5113, (at 645d4fe14a20) :6090-6105; lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:302-307, :399-414
- Who is affected
lucene/core/src/test/org/apache/lucene/util/hnsw/HnswGraphTestCase.java:156, (at 645d4fe14a20) :325; lucene/test-framework/src/java/org/apache/lucene/tests/index/RandomCodec.java:271-294
- Why it looks unintended
lucene/core/src/java/org/apache/lucene/codecs/lucene99/Lucene99HnswVectorsFormat.java:83, (at 645d4fe14a20) :191, :278-283; lucene/core/src/java/org/apache/lucene/util/hnsw/HnswGraphBuilder.java:133, (at 645d4fe14a20) :161, :177-179; lucene/core/src/java/org/apache/lucene/util/hnsw/MergingHnswGraphBuilder.java:47-49lucene/core/src/test/org/apache/lucene/codecs/lucene99/TestLucene99HnswVectorsFormat.java:64-74; (at 645d4fe14a20) lucene/core/src/test/org/apache/lucene/util/hnsw/HnswGraphTestCase.java:883-891github.com/apache/lucene/issues/13752 (comments by mikemccand and benwtrent, read 2026-10-30 with gh)
- History
github.com/apache/lucene/pull/14331/commits (09735cf80a1), read 2026-10-30 with gh api
- TestHnswBeamWidthOneMerge.java (lucene/core, package org.apache.lucene.codecs.lucene99, 1 test):
repro/LFV-1056/TestHnswBeamWidthOneMerge.java(50 lines)
Made in parallel with this doc from the code at 645d4fe14a20, the minimal repro and an executed probe on the stock main jars (-ea and -da, and with the fix). The code panel of step 1 shows the registration check's copy of the minimal test (field "vector", two comments and a closing assertTrue(w.isOpen())); see Minimal repro.
assets/LFV-1056/bug.png(417 KB)
