All findings · Lucene finding · LFV-047 · osq-antipodal-zero-centroid

Scalar-quantized vectors with COSINE: two opposite vectors make the centroid zero, so flush or merge throws and the IndexWriter closes

With the opt-in scalar-quantized (OSQ) vector formats and COSINE, two opposite vectors such as {1, 0} and {-1, 0} give the segment a zero centroid, and normalizing it throws "Cannot normalize a zero-length vector". The flush or merge fails and the IndexWriter closes, losing its buffered documents, since 10.2.0.

Summary

Priority
Medium, rank 148Moved 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
index unusable
Since
10.2.0 (#14078, merged 2025-03-04)introduced 2025-03-04 · about 1 year
Affected
10.2.0 to 10.5.2, 10.6.0 (by reading) and main
Status
documented
Found by
formal: Z3 floating-point precondition analysis

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

The finding in 30 seconds

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.

Summary card: two opposite COSINE vectors make the OSQ centroid zero, so commit() throws and the IndexWriter closes. What you do: a Lucene104Codec subclass returns Lucene104ScalarQuantizedVectorsFormat; add documents {1, 0} and {-1, 0}; commit(). Inside the writer at flush: the unit-length sum (1, 0) + (-1, 0) gives the mean (0, 0) with squared norm 0, and VectorUtil.l2normalize(centroid) throws "Cannot normalize a zero-length vector". Expected vs actual: commit() returns vs throws; writer.isOpen() true vs false; documents afterwards 2 vs 0; forceMerge(1) of {0.6, 0.8} and {-0.6, -0.8} segments returns vs throws, and a fresh writer fails the same merge again. The same documents with the default format or DOT_PRODUCT are fine. Call chain: commit, DocumentsWriterPerThread.flush, the writer's flush and computeCentroid, l2normalize throws, abort and tragic event. Fix (validated on main): keep the zero centroid and let the quantizer's COSINE asserts accept it; l2normalize(centroid, false) alone would trip them (code reading).
Six steps from two documents {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.
  1. 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: KnnFloatVectorField accepts them.
  2. 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.
  3. 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.
  4. The IndexWriter closes (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, but forceMerge(1) closes the writer, again in a fresh writer.
  5. 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.

Who is affected

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.

Terms

  • OSQ formats. Lucene104ScalarQuantizedVectorsFormat (flat) and Lucene104HnswScalarQuantizedVectorsFormat (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 unquantized Lucene99HnswVectorsFormat.
  • 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 IndexWriter closes itself (isOpen() == false, getTragicException() set).

The bug, step by step (main a9779ef3c193)

  1. The sum. Every added vector goes to accumulate (addValue, Lucene104ScalarQuantizedVectorsWriter.java:740; the columnar bulk path addDenseValues, :750). For COSINE, accumulateFloats divides it by its own norm and adds it to dimensionSums (:791-804): {1, 0} + {-1, 0} = {0, 0}.
  2. Flush. flush calls computeCentroid() (:147), which divides by the count and calls VectorUtil.l2normalize(centroid) (:818). Its only guard is vectorCount > 0 (:813); its javadoc promises "the mean of the accumulated vectors, unit-length for COSINE".
  3. Merge, stored centroids (flat format): the count-weighted mean of the segments' centroids, {0.6, 0.8} + {-0.6, -0.8} = {0, 0}, then VectorUtil.l2normalize(mergedCentroid) (:631); only totalVectorCount == 0 is guarded (:622, #15213).
  4. 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), then VectorUtil.l2normalize(centroid) (:655); only count == 0 is 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.
  5. The throw. VectorUtil.l2normalize(v) is l2normalize(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).
  6. The writer closes. At flush, DocumentsWriterPerThread records an aborting exception, aborts the in-memory segment and fires the tragic event (DocumentsWriterPerThread.java:585-604); at merge, handleMergeException rethrows the exception (only an aborted merge is swallowed there) and IndexWriter.merge calls tragicEvent(t, "merge") (IndexWriter.java:5030-5057, :5109-5113).

The numbers

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, -v pair; sign-symmetric data (plus/minus one-hot vectors, feature differences, v/-v augmentation); on main and 10.6.0, a delete that leaves such a pair; on main, the new columnar addBatch bulk 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 -ea and -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; CheckIndex was 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).

Minimal repro

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.

TestOsqAntipodalZeroCentroid.java (JUnit test)50 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.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);
      }
    }
  }
}
Command
# 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 vector from computeCentroid (VectorUtil.l2normalize on the zero centroid).

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

More files, environment, full output56 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: 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)
       [...]

Why it looks unintended

  • Each vector is legal: KnnFloatVectorField accepts 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. KnnFloatVectorField rejects 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, and computeCentroid'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 only count == 0 guards. 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 two OptimizedScalarQuantizer COSINE 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 sandbox DedupQuantizer quantizes 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.

Suggested fix

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.

Keep the zero centroid (validated) (recommended)

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} and centroidDP 0, 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:test passes: 750 classes, 8,895 tests, 0 failures, 322 skipped. :lucene:core:check -x test passes too.

Substitute a unit centroid (validated alternative)

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.

l2normalize(centroid, false) alone (not enough)

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.

Stop normalizing COSINE centroids (not recommended)

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.

Downstream note

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.

How it was found

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 a9779ef3c193 and 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene)2026-10-27113 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 readno 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 files2026-10-27diffs of #16839, #16819, #16871 (read-only)all keep the unguarded normalization
Commits (full-history clone)2026-10-27git log -S on the three calls; upstream main a95c754ee003introduced by #14078; carried by #15169 and #16473; still unguarded upstream
Mailing lists, JIRA, downstream, web2026-10-2756 list queries (dev@, java-user@, issues@), 6 JIRA, 4 downstream (Elasticsearch, OpenSearch, OpenSearch k-NN, Solr), 9 webno report; the exact exception text has 0 hits

History

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 into computeCentroid.
  • #16764 (31c4511e591a, 2026-10-08; on branch_10_6 as the backport 40fae66ec86, #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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affected4 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) affectedhistory probe: flush, both merges and the delete route fail
10.4.0, 10.5.0, 10.5.1, 10.5.2 affectedLucene104 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 affectedLucene102 binary-quantized formats: flush and both merges fail
8856a5f4eb7 (#14078) and backport 39e7f0973ec introducing commitfail; their parents 914a4a5fbe1 and 57eb3a2cdf8 pass
9.12.3, 10.0.0, 10.1.0 no centroidno centroid-based format; the Lucene99 scalar-quantized formats index the same pair
fix A, scratch worktree of a9779ef3c193 not affectedthe tests pass; all 8,895 core tests pass (0 failures, 322 skipped)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (quantization SMT) new bugIts 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, highIts 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) realThe 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 #1407828 probe runs confirm the introducing commit on both branches and 10.2.0 as the first affected release.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes639 words

Finding record

LFV-047, snapshot of 2026-11-06

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_6 and both commits of #14078 and their parents.

Lucene and upstream sources

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-1624
  • a 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-82
  • a Lucene main checkout (a9779ef3c193) lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104ScalarQuantizedVectorsFormat.java:97; Lucene104HnswScalarQuantizedVectorsFormat.java:47; Lucene104ScalarQuantizedVectorsWriter.java:806-809
  • a 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)

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

Repro files

About the diagrams

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.

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.