For COSINE, the OSQ writer quantizes each vector against one centroid per segment. At flush it scales every vector to length 1, averages them, and scales the average to length 1; merges recompute the average from the stored centroids or the raw vectors and scale it the same way. Vectors that cancel exactly average to {0, 0}, which cannot be scaled, so it throws. The writer guards only an empty segment.
{1, 0} and {-1, 0}, through their unit-length sum (0, 0) and the throwing l2normalize at flush, to the closed IndexWriter, the controls and the failing merges. It ends on a summary card. Select the still to open it at full size. Generated with AI, checked against the code.- Give a COSINE vector field the opt-in
Lucene104ScalarQuantizedVectorsFormat(or its HNSW variant) and add two documents with{1, 0}and{-1, 0}. Both are legal:KnnFloatVectorFieldaccepts them. - Call
commit(). You expect it to succeed and a kNN query for{0.6, 0.8}to return both documents, scored 0.8 and 0.2, as with the default vectors format. - Instead the writer averages the vectors, each scaled to length 1, into a centroid: (
{1, 0}+{-1, 0}) / 2 ={0, 0}. It then scales that centroid to length 1, which is impossible for{0, 0}:IllegalArgumentException: Cannot normalize a zero-length vector. - The
IndexWritercloses (a tragic event) and the 2 uncommitted documents are lost. Merges fail the same way: two committed one-document segments{0.6, 0.8}and{-0.6, -0.8}flush fine, butforceMerge(1)closes the writer, again in a fresh writer. - It takes exact cancellation:
{1, 0},{-1, 1e-6}works. Every release from 10.2.0 to 10.5.2 fails, and main fails with all five encodings, flat and HNSW. On main a delete that leaves such a pair triggers it too.
Fields that opt into the scalar-quantized formats with COSINE (Lucene104 OSQ from 10.4.0; the Lucene102 binary-quantized formats in 10.2 and 10.3), when one flushed segment or one merge holds vectors that cancel exactly: tiny NRT segments with a v, -v pair, sign-symmetric data such as plus/minus one-hot vectors, or, on main and 10.6.0, a delete that leaves such a pair.
Mechanism, terms and numbers809 words
For COSINE, the scalar-quantized (OSQ) vectors formats quantize every vector against one centroid per segment. At flush it is the mean of the segment's vectors, each scaled to length 1; a merge recomputes it from the segments' stored centroids or from the raw stored vectors; in every case the mean is then scaled to length 1. When the vectors cancel out exactly, that mean is {0, 0}. This happens with {1, 0} and {-1, 0} in one flush, or with one-document segments {0.6, 0.8} and {-0.6, -0.8} in one merge. VectorUtil.l2normalize then throws IllegalArgumentException: Cannot normalize a zero-length vector. At flush the exception aborts the in-memory segment and closes the IndexWriter, and the uncommitted documents are lost. At merge the writer closes, and merging the same segments again fails the same way, also in a fresh writer (a merge whose vectors do not cancel as a whole, for example one that also takes in a segment {1, 0}, succeeds). Each vector is legal on its own, and the default vectors format and the OSQ format with DOT_PRODUCT index and search the same documents. The opt-in formats are affected from 10.2.0, which introduced the centroid with the binary-quantized formats, through 10.5.2, 10.6.0 (by reading) and main. A small fix (+22/-5 lines) keeps a zero centroid instead, which the quantizer already handles for DOT_PRODUCT.
- OSQ formats.
Lucene104ScalarQuantizedVectorsFormat(flat) andLucene104HnswScalarQuantizedVectorsFormat(HNSW), opt-in per field through a codec subclass; in 10.2.x and 10.3.x the same code is in the Lucene102 binary-quantized formats. The default codec uses the unquantizedLucene99HnswVectorsFormat. - Centroid. One mean vector per segment and field; each vector is quantized as its difference from it, and the query is quantized against the same centroid at search time.
- Tragic event. An exception after which
IndexWritercloses itself (isOpen() == false,getTragicException()set).
- The sum. Every added vector goes to
accumulate(addValue,Lucene104ScalarQuantizedVectorsWriter.java:740; the columnar bulk pathaddDenseValues,:750). For COSINE,accumulateFloatsdivides it by its own norm and adds it todimensionSums(:791-804):{1, 0}+{-1, 0}={0, 0}. - Flush.
flushcallscomputeCentroid()(:147), which divides by the count and callsVectorUtil.l2normalize(centroid)(:818). Its only guard isvectorCount > 0(:813); its javadoc promises "the mean of the accumulated vectors, unit-length for COSINE". - Merge, stored centroids (flat format): the count-weighted mean of the segments' centroids,
{0.6, 0.8} + {-0.6, -0.8} = {0, 0}, thenVectorUtil.l2normalize(mergedCentroid)(:631); onlytotalVectorCount == 0is guarded (:622, #15213). - Merge, recomputed centroid (any segment with deletions, and always for the HNSW format, whose reader does not expose a stored centroid,
:569-575,:614): the mean of the live raw stored vectors, which are not scaled to length 1 (accumulateCentroid,:660-680), thenVectorUtil.l2normalize(centroid)(:655); onlycount == 0is guarded (:648). Since #16764 the sum skips deleted documents, so on main deleting{0, 1}from{1, 0},{-1, 0},{0, 1}leaves a zero mean. - The throw.
VectorUtil.l2normalize(v)isl2normalize(v, true)(VectorUtil.java:206-209), which throws"Cannot normalize a zero-length vector"when the squared norm is 0 (DefaultVectorUtilSupport.java:479-487;PanamaVectorUtilSupport.java:1617-1624). - The writer closes. At flush,
DocumentsWriterPerThreadrecords an aborting exception, aborts the in-memory segment and fires the tragic event (DocumentsWriterPerThread.java:585-604); at merge,handleMergeExceptionrethrows the exception (only an aborted merge is swallowed there) andIndexWriter.mergecallstragicEvent(t, "merge")(IndexWriter.java:5030-5057,:5109-5113).
On main, the 5-test JUnit fails 4 of 5 (flush; merge with the flat format; merge with the HNSW format; merge after a delete) with assertions on and off, and with the Panama and the scalar vector code; the control passes. The study's test class, re-run on main: after the failed flush the directory holds 0 of the 2 documents; after the failed merge the index still has its 2 committed documents, and a second, fresh writer fails forceMerge(1) the same way; at flush, all five ScalarEncodings fail, flat and HNSW, and so do a random 768-d v with -v and a signed one-hot set {3,0,0}, {-7,0,0}, {0,0.5,0}, {0,-2,0} (at flush the magnitudes do not matter, because each vector is scaled to length 1 first). The trigger is exact: {1, 0}, {-1, 1e-6} commits fine, and two mirrored 3-document segments broke the flat format's merge but not the HNSW format's merge, which recomputes the mean from the raw vectors one by one instead of from the two stored centroids, so float rounding leaves a non-zero residue. For the same reason magnitudes can matter at merge: {2, 0} and {-1, 0} make the flush throw, and as two one-document segments the flat-format merge, but the HNSW-format merge of those segments succeeds.
Exposure and likelihood332 words
- Who is hit. Only fields with an OSQ (or, in 10.2-10.3, binary-quantized) format and COSINE; the default codec does not quantize. The trigger is a set of vectors that cancel exactly, in float32 and in every dimension. What has to cancel depends on the path: at flush, the segment's vectors, each scaled to length 1; at a merge with stored centroids (flat format, no deletions), the segments' unit centroids, weighted by their vector counts; at a merge that recomputes (always for the HNSW format, and for any merge with deletions), the raw live vectors of all the merged segments. Plausible: tiny segments (frequent NRT refreshes, small test corpora) holding a
v,-vpair; sign-symmetric data (plus/minus one-hot vectors, feature differences,v/-vaugmentation); on main and 10.6.0, a delete that leaves such a pair; on main, the new columnaraddBatchbulk path too. Exact cancellation is expected to be rare in large segments of learned embeddings (no survey of real data was done). - Default or opt-in. Opt-in format plus COSINE (supported; the javadoc prefers DOT_PRODUCT on normalized vectors). No assertions or flags needed: the same with
-eaand-da, Panama and scalar vector code. - Scale. Two documents: one flush, or one merge of two one-document segments.
- The damage. Flush: the commit throws, the writer closes and every document buffered since the last commit is lost. Merge: the writer closes; the committed index stays readable, and merging the same two segments again fails the same way, also in a fresh writer. A merge whose own vector set does not cancel succeeds:
forceMerge(1)of{0.6, 0.8},{-0.6, -0.8}and a third segment{1, 0}works with both formats. After the failed merge the index opened with its 2 documents and kNN found both;CheckIndexwas not run. Other ways to recover were not tested. - Workaround. Normalize vectors in the application and use DOT_PRODUCT, the javadoc's preferred way: the OSQ format with DOT_PRODUCT indexes and searches the same pair (control test).
The test shows that with Lucene104ScalarQuantizedVectorsFormat and COSINE, committing the two vectors {1,0} and {-1,0} fails, because their unit-scaled mean (the centroid) is exactly zero.
/*
* 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.lucene104;
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.VectorSimilarityFunction;
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 TestOsqAntipodalZeroCentroid extends LuceneTestCase {
public void testAntipodalCosineVectorsCommit() throws Exception {
IndexWriterConfig iwc =
new IndexWriterConfig()
.setCodec(TestUtil.alwaysKnnVectorsFormat(new Lucene104ScalarQuantizedVectorsFormat()));
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w = new IndexWriter(dir, iwc)) {
for (float[] v : new float[][] {{1, 0}, {-1, 0}}) {
Document doc = new Document();
doc.add(new KnnFloatVectorField("vec", v, VectorSimilarityFunction.COSINE));
w.addDocument(doc);
}
try {
w.commit();
} catch (IllegalArgumentException e) {
// expected: commit succeeds; actual: flush normalizes the zero centroid and throws
throw new AssertionError("expected commit of two COSINE vectors to succeed, got " + e, e);
}
}
}
}
# in a copy of this finding's repro folder (repro/LFV-047/, 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.codecs.lucene104.TestOsqAntipodalZeroCentroid
- Expected vs actual
- Expected: the commit succeeds, as it does with the default vectors format. Actual: flush throws
IllegalArgumentException: Cannot normalize a zero-length vectorfromcomputeCentroid(VectorUtil.l2normalizeon the zero centroid).
More files, environment, full output56 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: expected commit of two COSINE vectors to succeed, got java.lang.IllegalArgumentException: Cannot normalize a zero-length vector
org.apache.lucene.codecs.lucene104.TestOsqAntipodalZeroCentroid.testAntipodalCosineVectorsCommit(TestOsqAntipodalZeroCentroid.java:46)
Caused by: java.lang.IllegalArgumentException: Cannot normalize a zero-length vector
org.apache.lucene.internal.vectorization.PanamaVectorUtilSupport.l2normalize(PanamaVectorUtilSupport.java:1621)
org.apache.lucene.util.VectorUtil.l2normalize(VectorUtil.java:229)
org.apache.lucene.util.VectorUtil.l2normalize(VectorUtil.java:207)
org.apache.lucene.codecs.lucene104.Lucene104ScalarQuantizedVectorsWriter$FieldWriter.computeCentroid(Lucene104ScalarQuantizedVectorsWriter.java:818)
org.apache.lucene.codecs.lucene104.Lucene104ScalarQuantizedVectorsWriter.flush(Lucene104ScalarQuantizedVectorsWriter.java:147)
[...]- Each vector is legal:
KnnFloatVectorFieldaccepts it, and the default format and OSQ with DOT_PRODUCT index the pair. - Upstream fixed the sibling case, a NaN centroid from an empty set, as a bug (#15213), and guarded only that case.
- #16030's author called a zero centroid "geometrically equivalent to our data blind configuration" and relaxed the same assertions (later reverted).
The full argument363 words
- The input is legal.
KnnFloatVectorFieldrejects a COSINE vector only when every component is 0 (KnnFloatVectorField.java:53-56); otherwise it checks only for null and empty vectors (:43-49) and non-finite values (:105). COSINE is supported: "The similarity score is normalised to assure it is positive" (VectorSimilarityFunction.java:76-82); DOT_PRODUCT on normalized vectors is "preferred", a recommendation, not a restriction. The OSQ formats (@lucene.experimental,Lucene104ScalarQuantizedVectorsFormat.java:97,Lucene104HnswScalarQuantizedVectorsFormat.java:47) state no limit on which vectors may share a segment, andcomputeCentroid's javadoc documents no exception (Lucene104ScalarQuantizedVectorsWriter.java:806-809). - The sibling case was called a bug and fixed. #15209: a merge with zero samples gave a NaN centroid; benwtrent: "it does smell like a bug. The centroid is
nan, which indicates something is wrong with the centroid merging logic somewhere." #15213 (CHANGES: "Don't normalize centroids with zero samples, which caused divide-by-zero") added onlycount == 0guards. This bug is the remaining case: a non-empty set with a zero mean. - A zero centroid is a valid state. In the #16030 review its author, mccullocht, wrote: "If the center is already a zero vector that is geometrically equivalent to our data blind configuration. Centering wouldn't subtract anything from the vector, and for angular similarity center_dot would be zero." #16030 (
cab6cfcb604) relaxed exactly the twoOptimizedScalarQuantizerCOSINE assertions to|| VectorUtil.dotProduct(centroid, centroid) == 0; it was reverted (c72bc6abf18, 2026-09-25) for flaky tests and reader-wrapping problems unrelated to that change. The sandboxDedupQuantizerquantizes normalized vectors "against a zero centroid" with DOT_PRODUCT, "without the unit-centroid requirement of a cosine-configured quantizer" (DedupQuantizer.java:116-118). - In exact arithmetic the math works for any centroid. COSINE and DOT_PRODUCT share the score branch (
Lucene104ScalarQuantizedVectorScorer.java,quantizedScore), and the study's SMT proof A5 shows the correction identities hold for any centroid (exact reals, dimensions 1 to 6; it says nothing about float rounding or recall). With the recommended fix the two documents score 0.8 and 0.19999999, the same as the unquantized format. - History. The three unguarded calls came with the first centroid-based format, #14078 (10.2.0); no test, javadoc, CHANGES or MIGRATE entry says a cancelling COSINE set is unsupported.
The devil's-advocate check238 words
The strongest case for "intended" or "won't fix": the formats are @lucene.experimental and opt-in; the COSINE javadoc tells users to normalize and use DOT_PRODUCT; a zero mean of unit vectors has no direction, so throwing is arguably the honest answer; Lucene's threat model leaves input sanitizing to the application; and real embeddings practically never cancel exactly.
Why it fails: every vector passes the field's own validation, and what breaks is a property of a set of documents in one segment or one merge, which flushes, merge selection and deletes decide, so the application can neither see nor validate it. The result is not a ranking trade-off but a closed writer, lost documents and a merge of the cancelling segments that keeps failing, while the same quantizer handles the same vectors with a zero centroid under DOT_PRODUCT. Upstream fixed the sibling count == 0 case as a bug, and the #16030 author treated a zero centroid as valid. @lucene.experimental allows incompatible API changes, not closing the writer on valid input. The cross-model refute review (OpenAI GPT-5.6) rated the intended-behaviour argument weak. A later cross-model fact check rated it moderate: a zero centroid means the segment is quantized uncentered (data-blind), which may cost recall; upstream has not adopted zero-centroid semantics, because #16030 was reverted; and the #16030 remark is one contributor's view. That bears on which fix to pick, not on whether closing the writer on legal input is acceptable.
Recommended Keep the zero centroid (validated)
When the COSINE mean has squared norm 0, keep the zero centroid instead of normalizing it, with one helper for the three call sites. OptimizedScalarQuantizer's two COSINE assertions then accept a zero centroid, as the reverted #16030 did (+22/-5). The tests pass, scores equal the unquantized ones, and :lucene:core:test passes (8,895 tests, 0 failures, 322 skipped).
static void normalizeCosineCentroid(float[] centroid) {
if (VectorUtil.dotProduct(centroid, centroid) == 0) {
Arrays.fill(centroid, 0f); // also clears a mean whose squared norm underflows to zero
} else {
VectorUtil.l2normalize(centroid);
}
}
// OptimizedScalarQuantizer.multiScalarQuantize and scalarQuantize
assert similarityFunction != COSINE
|| VectorUtil.isUnitVector(centroid)
|| VectorUtil.dotProduct(centroid, centroid) == 0;All options and trade-offs4 options ยท 734 words
Two fixes were built and validated through gradle in a scratch worktree of a9779ef3c193; two other options are explained. All three throw sites need the same guard; the question is what a COSINE centroid should be when the mean has no direction.
A helper normalizeCosineCentroid replaces the three VectorUtil.l2normalize calls in Lucene104ScalarQuantizedVectorsWriter. It sets the centroid to all zeros when its squared norm is 0, the exact condition on which l2normalize throws, and otherwise normalizes as before. The two COSINE assertions in OptimizedScalarQuantizer also accept an all-zero centroid, which is the assertion change of #16030. The patch is +22/-5 lines in 2 files.
static void normalizeCosineCentroid(float[] centroid) {
if (VectorUtil.dotProduct(centroid, centroid) == 0) {
Arrays.fill(centroid, 0f); // also clears a mean whose squared norm underflows to zero
} else {
VectorUtil.l2normalize(centroid);
}
}
// OptimizedScalarQuantizer.multiScalarQuantize and scalarQuantize
assert similarityFunction != COSINE
|| VectorUtil.isUnitVector(centroid)
|| VectorUtil.dotProduct(centroid, centroid) == 0;Trade-offs
- No change to the file format, and no change to the bytes or results of any segment that worked before. A cancelling segment gets centroid
{0, ..., 0}andcentroidDP0, the same as DOT_PRODUCT quantization of those unit vectors. - It changes the assertions of a public utility class, though assertions only.
- Not covered: a mean whose squared norm is subnormal but not 0 still normalizes with poor precision. That case is extreme and was not tested.
- A zero centroid means the cancelling segment is quantized uncentered (data-blind). The two-document probe shows exact scores, but recall on realistic cancelling or near-cancelling segments was not measured.
What the checks validated
- Stock: the test and its columnar companion fail 5 of 6, with assertions on and off.
- With the fix they pass 6 of 6, with assertions on and off, and 50 of 50 with seed CAFEBABE and
tests.iters=10. - A probe over all five encodings, flat and HNSW: the flat format stores centroid
[0.0, 0.0](the probe cannot read the HNSW format's centroid). For both formats, kNN{0.6, 0.8}scores 0.8 and 0.19999999 after the flush, and 1.0 and 0.0 after the merge, identical to the unquantized format. - The closest suites pass: 25 classes and 314 tests (
codecs.lucene104.*,util.quantization.*,TestVectorUtil,TestKnnGraph,TestHnswMergeAbort, the rescore and full-precision vector tests, among others), with assertions on and off. - All of
:lucene:core:testpasses: 750 classes, 8,895 tests, 0 failures, 322 skipped.:lucene:core:check -x testpasses too.
The same helper, but a zero mean becomes the first unit basis vector {1, 0, ..., 0}. OptimizedScalarQuantizer is left alone. The patch is +17/-3 lines in 1 file.
Trade-offs
It keeps the "COSINE centroid is unit length" invariant without touching the quantizer, but the choice of centroid is arbitrary. In the probe, scores came out slightly off the unquantized ones: 0.2000001 instead of 0.19999999, and up to 6e-8 instead of 0.0.
What the checks validated
The tests pass 6 of 6 with assertions on and off. The closest suites (25 classes, 314 tests) pass with assertions on; they were not run with assertions off for this option, and neither were the full core suite and check.
Switch the three calls to the non-throwing l2normalize(centroid, false) and change nothing else.
Trade-offs
By code reading (not executed), the zero centroid then trips the quantizer's unit-centroid assertions under -ea (OptimizedScalarQuantizer.java:122, :190), at flush and at query time. It also leaves a non-zero mean whose squared norm underflows unnormalized. It is the recommended option without the assertion change and the underflow handling. The withdrawn #16100 used this call for a different trigger, all-zero input vectors, but not alone: its diff also relaxed both quantizer COSINE assertions to accept a zero centroid (VectorUtil.isZeroVector(centroid) || VectorUtil.isUnitVector(centroid), and the same for the vector), switched three other l2normalize calls to the non-throwing form and skipped zero-length vectors when summing. Its centroid change is therefore close to the recommended option without the underflow handling.
Drop the final scaling and keep each path's un-normalized mean: at flush, the mean of the unit-scaled vectors; at merge, the weighted mean of the stored centroids, or the mean of the raw vectors.
Trade-offs
It changes the centroid, the quantized bytes and possibly the recall of every non-zero COSINE segment, to fix a corner case. It would need benchmarks.
A service that indexes vectors from untrusted users into an OSQ COSINE field can have its writer closed by two documents such as {1, 0} and {-1, 0}, and merging those segments again fails the same way; normalizing vectors and using DOT_PRODUCT avoids it until a fix ships.
Formal. The 2026-10-22 study ran Z3 floating-point queries on the OSQ code. The obligation "the mean of the unit-scaled vectors of a segment is non-zero", the precondition of the three l2normalize calls, was refuted. Z3 even found a non-opposite pair with a zero sum; a JUnit covered the plain opposite pair. A verifier then reproduced it end to end.
The method in full198 words
Method: SMT (Z3) floating-point precondition analysis, the study's "formal" tier, run 3, experiment "quantization-smt", on main 7cecf79523.
- An AI tool read the OSQ writer and stated proof obligation PO-A2: "the mean of the unit-normalized vectors of a segment is non-zero", the precondition of
l2normalize(throwOnZero=true)at the flush and both merge sites. - Z3 refuted it with a pair that is not antiparallel, with extreme magnitudes, whose normalized sum is exactly 0 (sat, 491 s). The plain antipodal query returned UNKNOWN after 614 s, so a JUnit test covered
{1, 0},{-1, 0}and{0.6, 0.8}/{-0.6, -0.8}. The study verifier did not re-check the non-antiparallel witness, because the exact negations suffice. - A separate AI verifier wrote its own 9-test class and ran it on main (all 9 reproduce) and on the 10.5.2 jars (8 of 9; the delete case needs #16764, which 10.5.2 lacks); a flush probe reproduced the bug on the 10.2.0, 10.3.2 and 10.4.0 jars.
- This hunt:
- re-ran the bug on
a9779ef3c193and found the columnar route; - ran a cross-model refute review (OpenAI GPT-5.6), which wrote and ran its own test;
- bisected the introducing commit;
- searched for prior reports;
- validated two fixes.
- re-ran the bug on
No prior report of this trigger was found in apache/lucene issues, PRs and commits, the mailing lists, JIRA, downstream trackers or the web (2026-10-27). Closest: #16100, a withdrawn PR for all-zero input vectors whose diff contains both halves of the recommended fix.
Searches4 searches
No issue, PR, list thread, JIRA entry, downstream report or web page describes a zero centroid made by valid COSINE vectors that cancel. None of the open PRs on the same code fixes it. The closest item is #16100, withdrawn by its author on 2026-06-01: it was about all-zero input vectors, which validation now rejects, and it never describes cancelling vectors, but its diff switched the same three centroid calls to l2normalize(..., false) and relaxed the two quantizer COSINE assertions to accept a zero centroid. That is substantial code-level overlap. Elasticsearch's own copy of the writer (ES818BinaryQuantizedVectorsWriter) has the same three unguarded calls [read, not executed]; no downstream issue mentions it.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene) | 2026-10-27 | 113 GraphQL and 24 REST queries, for example "Cannot normalize a zero-length vector", "centroid is zero", "zero-norm centroid", l2normalize centroid zero, "sum to zero" vectors, quantized merge tragic; bodies and comments of 37 related threads read | no report; closest #16100 (withdrawn, all-zero input vectors; its diff overlaps the fix), #15209 / #15213 (zero samples), #16762 / #16764 (deleted docs), #16030 (zero centroid called valid), #15540 (all-zero inputs) |
| Open PRs on the same files | 2026-10-27 | diffs of #16839, #16819, #16871 (read-only) | all keep the unguarded normalization |
| Commits (full-history clone) | 2026-10-27 | git log -S on the three calls; upstream main a95c754ee003 | introduced by #14078; carried by #15169 and #16473; still unguarded upstream |
| Mailing lists, JIRA, downstream, web | 2026-10-27 | 56 list queries (dev@, java-user@, issues@), 6 JIRA, 4 downstream (Elasticsearch, OpenSearch, OpenSearch k-NN, Solr), 9 web | no report; the exact exception text has 0 hits |
Introduced in 10.2.0 by #14078 (8856a5f4eb7, merged 2025-03-04), the first centroid-based format in core, with all three unguarded calls. It was carried into the Lucene104 formats by #15169. Affects 10.2.0 to 10.5.2, branch_10_6 five commits before the 10.6.0 tag, 10.6.0 (by reading) and main; the delete route exists only since #16764 (main and 10.6).
Versions tested7 rows
Introduced by #14078, "Binary vector format for flat and hnsw vectors" (benwtrent). It was merged on main on 2025-03-04 as 8856a5f4eb7, and its branch_10x backport 39e7f0973ec shipped in 10.2.0. The fault arrived with the format, so it is not a regression. The later history:
- #15169 (
594eeb7e637, 2025-09-17) copied the three calls into the Lucene104 writer (10.4.0). - #15213 guarded only an empty set.
- #16473 (
50c1e6acb7f) moved the flush part intocomputeCentroid. - #16764 (
31c4511e591a, 2026-10-08; onbranch_10_6as the backport40fae66ec86, #16800) made the merge recompute skip deleted documents, which opened the delete route. - #16734 (
a9779ef3c193) added the columnar bulk path, which reaches the same flush.
Upstream main a95c754ee003 (2026-10-26) still has the three unguarded calls.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | 4 of 5 fail through gradle and the harness, assertions on and off, Panama and scalar providers; columnar companion 1 of 1 |
branch_10_6 2763db11b0a (before 10.6.0 RC1) | affected | history probe: flush, both merges and the delete route fail |
| 10.4.0, 10.5.0, 10.5.1, 10.5.2 | affected | Lucene104 formats: flush and both merges fail; 10.5.2 JUnit 3 of 5 with assertions on and off; delete route passes |
| 10.2.0, 10.2.1, 10.2.2, 10.3.0, 10.3.1, 10.3.2 | affected | Lucene102 binary-quantized formats: flush and both merges fail |
8856a5f4eb7 (#14078) and backport 39e7f0973ec | introducing commit | fail; their parents 914a4a5fbe1 and 57eb3a2cdf8 pass |
| 9.12.3, 10.0.0, 10.1.0 | no centroid | no centroid-based format; the Lucene99 scalar-quantized formats index the same pair |
fix A, scratch worktree of a9779ef3c193 | not affected | the tests pass; all 8,895 core tests pass (0 failures, 322 skipped) |
A study verifier and a cross-model refute review (OpenAI GPT-5.6) both reproduced it with their own tests and say it is real (high confidence); the reviewer rated the intended-behaviour case weak. This doc re-ran it through gradle on main and validated two fixes.
Checks and verdicts210 words
Every check that ran Java reproduced the bug. Neither reviewer supplied a fix; the fix check in this doc is the first.
| Check | Verdict | What it found |
|---|---|---|
| Study verifier (quantization SMT) | new bug | Its own 9-test class (asserting the bug) passes on main for every encoding, flat and HNSW, and 8 of 9 behave the same on 10.5.2 (the delete case needs #16764, which 10.5.2 lacks); a flush probe fails on 10.2.0, 10.3.2, 10.4.0 and 10.5.2. It found the HNSW recompute path, the persistence across writers and that l2normalize(centroid, false) alone is not enough. |
| Refute review (OpenAI GPT-5.6) | real, high | Its own public-API test fails on main with assertions on and off, and with the scalar provider (flush and merge), while the default-format control passes. It rated the intended-behaviour argument weak. Its test, re-run on a9779ef3c193, fails 2 of 3 with assertions on, off and with the scalar provider. |
| Main refresh (a9779ef3c193) | real | The candidate JUnit fails 4 of 5 on main and 3 of 5 on 10.5.2. The columnar addBatch route is new on main. |
| History and bisect | introduced by #14078 | 28 probe runs confirm the introducing commit on both branches and 10.2.0 as the first affected release. |
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 486 KB
Finding record, Lucene and upstream sources, repro files and diagram notes639 words
- Registry title
- Two antipodal COSINE vectors make the OSQ centroid zero, so flush or merge fails and the writer closes
- Area
- codecs / vectors
- Priority
- Medium, rank 148: Moved down within Medium: in the opt-in OSQ format with COSINE, an exactly cancelling vector pair zeroes the centroid so flush fails, the writer closes and merges stay blocked, but real embeddings rarely cancel exactly.
- Impact class
- index unusable
- Impact note
- Users of the opt-in Lucene104 OSQ format with COSINE whose segment vectors cancel exactly (tiny NRT segments, symmetric data; on main and 10.6.0 also through deletes); released 10.2.0-10.6.0 (10.6.0 by reading); merges stay blocked.
- Summary
- With the scalar-quantized (OSQ) vectors format and COSINE, you index vectors whose normalized sum is exactly zero, such as {1,0} and {-1,0}, into one segment. You expect flush and merge to work. Instead normalizing the zero centroid throws 'Cannot normalize a zero-length vector', the writer closes tragically with its buffered documents lost, and merges stay blocked.
- Introduced
- #14078 (8856a5f4eb7) added the l2normalize(centroid) pattern (Lucene102BinaryQuantizedVectorsWriter, 10.2.0); #15169 (594eeb7e637) carried it into Lucene104 [read] (2025-03-04 (8856a5f4eb7))
- First affected version
- 10.2.0
- Affected versions
- 10.2.0-10.5.2, branch_10_6 2763db11b0a (2026-10-24, five commits before the 10.6.0 tag 4ae946e5a9b) and main [exec]; 10.6.0 [read: those five commits leave Lucene104ScalarQuantizedVectorsWriter.java and VectorUtil.java unchanged]
- Status
- documented (brief, checks and a per-finding doc exist)
- Verification
- Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
- Found by
- formal: Z3
- Upstream and prior reports
- none found (study searches 2026-10-22); #15213 covers count==0 only; open PR #16839 would not fix it
- Lucene commit tested
- main
a9779ef3c193, runs of 2026-10-26 and 2026-10-27, with the results below. - The paste-ready JUnit and its columnar companion ran through gradle in a scratch worktree, with assertions on and off. Stock fails 5 of 6. With fix A or fix B, 0 of 6 fail. - Fix A also passed the closest suites (314 tests), all of:lucene:core:test(8,895 tests) and:lucene:core:check -x test. - The repro harness ran the JUnit on main with the Panama and the scalar vector provider (4 of 5 fail) and on the 10.5.2 release jars (3 of 5 fail). - The study's 9-test class was re-run on main (it asserts the bug, and all 9 pass). - The history probe ran on 9.12.3 to 10.5.2,branch_10_6and both commits of #14078 and their parents.
- Finding record
SECURITY.md:28 (threat model)
- The finding in 30 seconds
a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsWriter.java:147, :569-575, :593-635, :637-658, :740-756, :791-822 (accumulate, computeCentroid, merge centroids)a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:206-230; internal/vectorization/DefaultVectorUtilSupport.java:479-487; src/java25 PanamaVectorUtilSupport.java:1617-1624a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/index/DocumentsWriterPerThread.java:585-604; index/IndexWriter.java:5109-5113
- Who is affected
a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104Codec.java:123 (default vectors format); index/VectorSimilarityFunction.java:76-82
- Why it looks unintended
a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/document/KnnFloatVectorField.java:53-56, :90-106; index/VectorSimilarityFunction.java:76-82a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsFormat.java:97; Lucene104HnswScalarQuantizedVectorsFormat.java:47; Lucene104ScalarQuantizedVectorsWriter.java:806-809a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/util/quantization/OptimizedScalarQuantizer.java:122, :190; sandbox/codecs/dedup/DedupQuantizer.java:116-118 (lucene/sandbox/src/java)lucene/CHANGES.txt:298 at a9779ef3c193 (GITHUB#15213)a Lucene clone git show cab6cfcb604 -- OptimizedScalarQuantizer.java (#16030 assertion change); git show c72bc6abf18 (revert)
- Suggested fix
a Lucene clone git show cab6cfcb604 -- OptimizedScalarQuantizer.java (#16030 assertion change)
- TestOsqAntipodalZeroCentroid.java (JUnit test):
repro/LFV-047/src/org/apache/lucene/codecs/lucene104/TestOsqAntipodalZeroCentroid.java(50 lines)
They are driven by a diagram probe on the public API, run on main a9779ef3c193 with assertions on and off and on the 10.5.2 jars. The values on screen are parsed from those runs, and the code panels are verbatim from a9779ef3c193.
assets/LFV-047/bug.png(486 KB)
