All findings · Lucene finding · LFV-1120 · late-interaction-cosine-zero-token-nan-score

Late-interaction rescoring gives a document a NaN score when one of its token vectors is all zeros (default COSINE)

A document whose late-interaction token matrix has one all-zero row gets a NaN score from LateInteractionRescorer under its default COSINE similarity, even when another of its tokens matches the query exactly. The NaN can also mis-order the other rescored hits; every release since 10.3.0 has it.

Summary

Priority
Low, rank 574A document whose late-interaction token matrix holds an all-zero row gets a NaN rescored score under the default COSINE, which can also mis-order the other rescored hits, in every release since 10.3.0. It needs the opt-in, experimental late-interaction rescoring API and an uncommon zero or non-finite token vector, so it is a rare boundary-value case.
Impact
minor
Since
10.3.0 (#14729, 2025)introduced 2025-07-08 · about 1 year
Affected
10.3.0 to 10.6.0 (4 releases run, the other 4 read: same source) and main; not fixed upstream
Status
documented
Found by
semi-formal: a proof-obligation audit of LateInteractionFloatValuesSource's contract, witnessed by an executable repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

Background: late-interaction rescoring in Lucene

  1. A late-interaction (ColBERT-style) model turns a document and a query into small matrices of token vectors, one vector per token. Lucene stores a document's matrix in a LateInteractionField and rescores the top hits of a first-pass query with LateInteractionRescorer.
  2. The default score, SUM_MAX_SIM, takes for each query token its best (maximum) similarity over the document's tokens and adds these up. The default similarity is COSINE, scaled to the range [0, 1].
Diagram of the bug: a late-interaction document stores two token vectors, d0 = (1,0) and d1 = (0,0). The query token q = (1,0) is compared with each under COSINE: cos(q, d0) = 1, scaled to 1.0; cos(q, d1) = 0/0 = NaN. SUM_MAX_SIM keeps the best match with Float.max(1.0, NaN), which is NaN, so the document's score is NaN instead of 1.0; the rescorer's sort then orders any pair that involves the NaN by doc id alone, and other hits can be mis-ordered.
The repro's document has two token vectors, (1,0) and (0,0); the query token (1,0) matches the first with cosine 1.0, but the zero token gives 0/0 = NaN, Float.max(1.0, NaN) keeps the NaN, and the document's score is NaN instead of 1.0. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Index one document with new LateInteractionField("li", new float[][] {{1, 0}, {0, 0}}): two token vectors, the second all zeros (for example zero padding). Lucene accepts it without complaint.
  2. Rescore it with LateInteractionRescorer.create("li", new float[][] {{1, 0}}), which uses COSINE. You expect 1.0: the document's first token equals the query token, the score adds up each query token's best match, and no COSINE score exceeds 1.0, whatever finite value the zero token might get. Under DOT_PRODUCT or EUCLIDEAN it is 1.0.
  3. Instead the cosine of the zero token is 0/0 = NaN, and the best-match step, Float.max(1.0, NaN), returns NaN. The document scores NaN, with no error when assertions are off; with assertions on, VectorUtil.cosine throws an AssertionError.
  4. The rescorer then sorts with a comparator that orders two finite scores by score but any pair that involves the NaN by doc id alone, so the other hits can come back in the wrong order: in a 3-hit probe, scores 0.5497519, NaN and 0.9993762 came back in that order, the best hit last.

Who is affected

Applications that rescore with the opt-in, experimental late-interaction API and its default COSINE, and whose document token matrices can contain an all-zero row, such as kept zero padding. One such document is enough: its score is NaN with no error, and other hits can be mis-ordered. DOT_PRODUCT and EUCLIDEAN are not affected.

Mechanism, terms and numbers594 words

LateInteractionField accepts a token matrix in which one token vector is all zeros, and LateInteractionRescorer and LateInteractionFloatValuesSource use COSINE by default. The cosine of a zero vector is 0/0 = NaN, and SUM_MAX_SIM keeps each query token's best similarity with Float.max, which returns NaN as soon as one argument is NaN. So one zero row makes the document's whole score NaN, even when another of its tokens matches the query exactly: the document {{1,0},{0,0}} rescored with the query {{1,0}} scores NaN instead of 1.0 with assertions off, and throws an AssertionError in VectorUtil.cosine with assertions on (LateInteractionFloatValuesSource.java:175 at 5adff78bb168). The rescorer sorts with ScoreDoc.COMPARATOR, which orders two finite scores by score but any pair that involves the NaN by doc id alone, so the other hits can be mis-ordered too. The single-vector KnnFloatVectorField rejects a zero vector under COSINE; the late-interaction classes check only null, empty and equal dimensions. Every release since 10.3.0 has it. The API is opt-in and experimental and the input uncommon, so the priority is Low.

The mechanism (main 5adff78bb168, lucene/core/src/java/org/apache/lucene/)

  1. The zero row is accepted. LateInteractionField.encode checks that the matrix and its first token are non-null and non-empty and that all tokens have the same dimension, nothing else (document/LateInteractionField.java:74-102, a fix site); the javadoc asks only for that ("Value should not be null or empty. Composing token vectors for provided multi-vector value should have the same dimension.", :51-52). The query side, validateQueryVector, makes the same three checks (search/LateInteractionFloatValuesSource.java:65-80).
  2. COSINE is the default of LateInteractionRescorer.create(field, query) (search/LateInteractionRescorer.java:39-41) and of new LateInteractionFloatValuesSource(field, query) (search/LateInteractionFloatValuesSource.java:45-47).
  3. 0/0 = NaN. COSINE.compare is normalizeToUnitInterval(cosine(v1, v2)) (index/VectorSimilarityFunction.java:84-86). VectorUtil.cosine only does assert Float.isFinite(r) (util/VectorUtil.java:100-107, the assert at :105) and has no runtime handling of a zero norm. With assertions off it returns NaN, and Math.max((1 + NaN) / 2, 0) is NaN (util/VectorUtil.java:461-463).
  4. One NaN wins. SUM_MAX_SIM (search/LateInteractionFloatValuesSource.java:154-181) does, per query token, maxSim = Float.max(maxSim, vectorSimilarityFunction.compare(q, d)) (:175, the fix site of option A) and then result += maxSim (:177). In the repro Float.max(1.0, NaN) is NaN, so the document scores NaN although its first token matched exactly.
  5. The NaN becomes the hit's score and breaks the sort. DoubleValuesSourceRescorer.rescore stores it as hit.score (search/DoubleValuesSourceRescorer.java:75-76; LateInteractionRescorer.combine returns the value, search/LateInteractionRescorer.java:62-65) and sorts with ScoreDoc.COMPARATOR (search/DoubleValuesSourceRescorer.java:79-83), which tests > and < and otherwise falls back to the doc id (search/ScoreDoc.java:57-66). Both tests are false when a score is NaN, so two finite scores are ordered by score but any pair with the NaN hit by doc id alone: the order is no longer consistent. In a 3-hit probe, scores doc0 = 0.5497519, doc1 = NaN, doc2 = 0.9993762 came back in that order, the best finite hit last; with doc1's zero row dropped (or with fix A) doc1 scores 1.0 and the order is doc1, doc2, doc0. By reading only (not executed): when topN is below the number of hits, ArrayUtil.select first picks the top topN with the same comparator (search/DoubleValuesSourceRescorer.java:79-81), so a NaN hit could also change which hits survive the cut.

Related, confirmed by verifier probes but not part of the repro or of this finding: an all-zero query token makes every rescored document that has a value in the field NaN under COSINE (a hit without a value gets 0, search/LateInteractionRescorer.java:62-65); a NaN token component gives NaN under COSINE, DOT_PRODUCT and EUCLIDEAN; encode also accepts +Inf components.

Exposure and likelihood159 words
  • Who. Applications that rescore with the late-interaction (multi-vector, ColBERT-style) API using its default COSINE, and whose document token matrices can contain an all-zero row, for example zero padding of fixed-size token matrices. The ColBERT reference encoder returns zero rows for padded positions when padding is kept (read in its source); other toolkits were not checked.
  • Default vs opt-in. The API is opt-in; LateInteractionFloatValuesSource and LateInteractionRescorer are @lucene.experimental, LateInteractionField is not tagged. Within that API, COSINE is the default.
  • Effect. Silent: no exception with assertions off. The document's score is NaN, which TopScoreDocCollector documents as not a valid score (search/TopScoreDocCollector.java:30-31), and the other rescored hits can be mis-ordered.
  • Not hit. DOT_PRODUCT and EUCLIDEAN give a finite score for the same zero row (1.0 in the repro). Typical normalized token embeddings have no zero rows, so accidental hits are rare.
  • Scale. One document with one zero row; no concurrency, no size threshold. Every release from 10.3.0 through 10.6.0.

Minimal repro

One JUnit test method on the public API: one document with the token matrix {{1,0},{0,0}}, a match-all first pass, then LateInteractionRescorer.create("li", {{1,0}}) with its default COSINE, and an assertion that the score is not NaN. Deterministic, single thread; it fails with assertions on and off.

TestLateInteractionZeroTokenVector.java (lucene/core, package org.apache.lucene.search, 1 test)47 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.search;

import org.apache.lucene.document.Document;
import org.apache.lucene.document.LateInteractionField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestLateInteractionZeroTokenVector extends LuceneTestCase {

  // Expected: a finite score (or an IAE for the zero token); actual: NaN (-ea: AssertionError).
  public void testZeroTokenVectorDoesNotMakeScoreNaN() throws Exception {
    try (Directory dir = newDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
        Document doc = new Document();
        doc.add(new LateInteractionField("li", new float[][] {{1, 0}, {0, 0}}));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        TopDocs firstPass = searcher.search(MatchAllDocsQuery.INSTANCE, 1);
        TopDocs rescored =
            LateInteractionRescorer.create("li", new float[][] {{1, 0}})
                .rescore(searcher, firstPass, 1);
        assertFalse(Float.isNaN(rescored.scoreDocs[0].score));
      }
    }
  }
}
Command
cp $REPRO/TestLateInteractionZeroTokenVector.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestLateInteractionZeroTokenVector -Ptests.seed=DEADBEEF
# and with -Ptests.asserts=false: the silent form, the score is NaN
Expected
A finite score, here 1.0 (or a clean IllegalArgumentException for the zero token).
Observed
The score is NaN with assertions off; with assertions on, an AssertionError at VectorUtil.cosine(VectorUtil.java:105).

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

More files, environment, full output213 words

TestLateInteractionZeroTokenVector (one method, extends LuceneTestCase, package org.apache.lucene.search of the lucene/core tests). Line 43 is assertFalse(Float.isNaN(rescored.scoreDocs[0].score)), so the failure with assertions off means the score is NaN.

As the regression test, the same method fits in TestLateInteractionRescorer. It asserts only "not NaN", so it fits a fix that gives a finite score (options A and B); a fix that rejected the zero document token would need expectThrows instead. On 10.3.x, replace MatchAllDocsQuery.INSTANCE (added in 10.4.0) with new MatchAllDocsQuery(). An independent reproducer's test also fails with the scalar (non-SIMD) vector provider.

Environment

lucenemain 5adff78bb168 (12.0.0-SNAPSHOT): fails; 10.3.0, 10.4.0, 10.5.2, 10.6.0: fail; 10.2.2 and 9.12.3: API absent (does not compile)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux aarch64, the only platform run (nothing platform-specific: 0/0 is NaN under Java float arithmetic everywhere)
Command (environment)
cp $REPRO/TestLateInteractionZeroTokenVector.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.search.TestLateInteractionZeroTokenVector -Ptests.seed=DEADBEEF

Observed output

# gradle, scratch worktree at 5adff78bb168, -Ptests.seed=DEADBEEF -Ptests.asserts=false
TestLateInteractionZeroTokenVector > testZeroTokenVectorDoesNotMakeScoreNaN FAILED
    java.lang.AssertionError
        at org.junit.Assert.assertFalse(Assert.java:75)
        at org.apache.lucene.search.TestLateInteractionZeroTokenVector.testZeroTokenVectorDoesNotMakeScoreNaN(TestLateInteractionZeroTokenVector.java:43)
:lucene:core:test (FAILURE): 1 test, 1  failure

# gradle, same, assertions on
TestLateInteractionZeroTokenVector > testZeroTokenVectorDoesNotMakeScoreNaN FAILED
    java.lang.AssertionError
        at org.apache.lucene.util.VectorUtil.cosine(VectorUtil.java:105)
        at org.apache.lucene.index.VectorSimilarityFunction$3.compare(VectorSimilarityFunction.java:85)
        at org.apache.lucene.search.LateInteractionFloatValuesSource$ScoreFunction$1.compare(LateInteractionFloatValuesSource.java:175)
        at org.apache.lucene.search.LateInteractionFloatValuesSource$1.doubleValue(LateInteractionFloatValuesSource.java:92)
        at org.apache.lucene.search.DoubleValuesSourceRescorer.rescore(DoubleValuesSourceRescorer.java:75)
        at org.apache.lucene.search.TestLateInteractionZeroTokenVector.testZeroTokenVectorDoesNotMakeScoreNaN(TestLateInteractionZeroTokenVector.java:42)
:lucene:core:test (FAILURE): 1 test, 1  failure

# with fix A: :lucene:core:test (SUCCESS): 1 test, assertions on and off

Why it looks unintended

  • NaN is documented as "not a valid score" (TopScoreDocCollector), and no late-interaction javadoc forbids zero tokens.
  • KnnFloatVectorField rejects a zero vector under COSINE; after issue #15540 that check was added on the single-vector indexing paths, not to late interaction.
  • Lucene itself picks COSINE as the default here, and no upstream test or discussion pins the NaN.
The full argument285 words
  • Contract. "The values Float#NaN and Float#NEGATIVE_INFINITY are not valid scores." (search/TopScoreDocCollector.java:30-31). The late-interaction javadocs ask only for non-null, non-empty, equal-dimension tokens (document/LateInteractionField.java:51-52).
  • Design rule and precedent. Lucene validates vectors in the field and query classes, not in the performance-sensitive similarity functions; that is why VectorUtil.cosine only asserts. #12281 (071461ece54, 2023) added those checks to KnnFloatVectorField / KnnFloatVectorQuery; in its review rmuir wrote "Checking if vectors are finite needs to be done at eg index time (in the xxField class)". Today KnnFloatVectorField rejects a zero vector under COSINE (document/KnnFloatVectorField.java:53-56, :146-151, :172-176; "zero vector not allowed with cosine similarity function" at :55 and :174, "zero vector not allowed with cosine similarity configured in field type" at :148-149) and non-finite values (:105, :151, :176); KnnFloatVectorQuery checks only that its target is finite (search/KnnFloatVectorQuery.java:97). Open issue #15540 (a zero vector under COSINE is accepted at indexing and fails only later, at merge or in CheckIndex; single-vector path) was followed by #15751 (10.5.0, the KnnFloatVectorField and KnnByteVectorField constructors), #16098 (10.5.0, setVectorValue) and #16708 (10.6.0, columnar addBatch validation), which add the zero-under-COSINE check on the single-vector indexing paths; #15751 is by the author of #14729. On #15540 mikemccand wrote "the lenient + broken behavior now is no good" and benwtrent "I agree, cosine should be validated". None of these touched the late-interaction classes.
  • Tests. The upstream late-interaction tests build tokens from random().nextFloat() values, never non-finite and practically never all zero; their validation tests cover only null, empty and dimension cases (TestLateInteractionField, TestLateInteractionFloatValuesSource).
  • History. #14729, which added the three classes, never discusses zero, NaN or non-finite tokens; no later commit touches the files.
The devil's-advocate check172 words

The strongest case for "not a bug": the cosine of a zero vector is undefined, so NaN is garbage in, garbage out. LateInteractionField is a plain binary doc-values field that cannot know the similarity at index time, and on #15540 benwtrent wrote "I don't think we should prevent 0 vectors for similarities that support them" (DOT_PRODUCT, EUCLIDEAN), with a +1 from mikemccand. The COSINE javadoc says the preferred way is to normalize and use DOT_PRODUCT (index/VectorSimilarityFunction.java:77-80), and the API is experimental.

Why it fails: that shapes the fix (a blanket index-time rejection does not fit today's similarity-agnostic field), not the intent. Lucene picks COSINE as the default, the API accepts the input without error, NaN is documented as an invalid score, and the same input class was fixed on the single-vector indexing paths. @lucene.experimental says the API "might change in incompatible ways in the next release"; it says nothing about invalid scores. A credibility audit rated it real but low priority, in the same family as #15540; hence priority Low.

Suggested fix

Recommended Skip all-zero document tokens under COSINE in SUM_MAX_SIM (fix A)

Skip all-zero document tokens under COSINE in SUM_MAX_SIM (+5 lines in LateInteractionFloatValuesSource). The repro then scores 1.0 instead of NaN and passes with assertions on and off, and the repro and the four late-interaction test suites pass (16 tests, 2 @Nightly skipped). Option B adds input checks: non-finite tokens and a zero COSINE query token are rejected (also tested).

            if (vectorSimilarityFunction == VectorSimilarityFunction.COSINE
                && VectorUtil.isZeroVector(d)) {
              continue; // cosine with a zero vector is 0/0 = NaN: the token cannot match
            }
            maxSim = Float.max(maxSim, vectorSimilarityFunction.compare(q, d));
All options and trade-offs3 options ยท 590 words

Options A and A+B were built and checked through gradle on 5adff78bb168; C was not built. A blanket index-time rejection of zero document tokens does not fit today's field: it does not know the similarity, and zero vectors are legitimate under DOT_PRODUCT and EUCLIDEAN (#15540); a redesign could carry the similarity, but the smaller fix is on the score and query side. Whether a zero token should be ignored (A) or rejected (B for the query, C for documents) is a policy choice for the maintainers. Open PR #16748 (rank SUM_MAX_SIM by raw MaxSim) edits the same line and keeps Float.max over VectorUtil.cosine, so it does not fix this; whichever lands second may need a rebase.

Skip all-zero document tokens under COSINE in SUM_MAX_SIM (fix A) (validated by the checks) (recommended)

Under COSINE a zero token has no direction, so it cannot be a document's best match: skip it before calling compare. The repro then scores exactly 1.0 (a probe test with fix A prints score=1.0 with assertions on and off). A document whose tokens are all zero gets Float.MIN_VALUE per query token, the floor SUM_MAX_SIM already uses, also for an empty document (:160-162, :165). DOT_PRODUCT, EUCLIDEAN and MAXIMUM_INNER_PRODUCT are unchanged.

            if (vectorSimilarityFunction == VectorSimilarityFunction.COSINE
                && VectorUtil.isZeroVector(d)) {
              continue; // cosine with a zero vector is 0/0 = NaN: the token cannot match
            }
            maxSim = Float.max(maxSim, vectorSimilarityFunction.compare(q, d));

Trade-offs

isZeroVector returns at the first non-zero component, so for a typical token (first component non-zero) it costs one comparison. A is a policy choice: a zero document token silently stops counting under COSINE, and an all-zero document gets the Float.MIN_VALUE floor instead of an error. A leaves a zero query token (every document with a value NaN) and NaN components alone; option B covers those. Needs import org.apache.lucene.util.VectorUtil;.

What the checks validated

  • Gradle in a scratch worktree at 5adff78bb168, seed DEADBEEF: the repro fails without the fix (assertions on and off) and passes with it (on and off).
  • With the fix: TestLateInteractionField (3), TestLateInteractionFloatValuesSource (3), TestLateInteractionRescorer (2), TestRescoreTopNQuery (7, of which 2 are @Nightly and skipped) and the repro: 16 tests, 0 failures. checkGoogleJavaFormat passes.
  • A probe test with fix A shadowed over the main jars prints score=1.0 for the repro's document, with assertions on and off.
  • Earlier, the same logic passed the repro on the main jars with assertions on and off.

Also validate the inputs (fix B, built and tested together with A) (validated by the checks)

VectorUtil.checkFinite on every token in LateInteractionField.encode and in validateQueryVector, and, under COSINE, an IllegalArgumentException for an all-zero query token (the similarity is known in the constructor). This mirrors KnnFloatVectorField and KnnFloatVectorQuery. +18/-3 in two files, together with A.

Trade-offs

A behaviour change: NaN/Inf tokens are rejected at indexing and a zero query token under COSINE at construction; already-indexed non-finite tokens still score NaN. The query check runs before Objects.requireNonNull(vectorSimilarityFunction), so the existing testValidations (a null similarity with bad query vectors, expecting IllegalArgumentException) still passes. No test here covers B's new checks; the regression test stays the one repro.

What the checks validated

Same worktree with A+B: the repro passes (assertions off; on in the suite run), the same 16 tests pass and checkGoogleJavaFormat passes.

Fail loudly at score time (not built)

Throw IllegalArgumentException from SUM_MAX_SIM for a zero document token under COSINE.

Trade-offs

A clear signal, but one bad document fails the whole rescore, and the repro (which asserts a non-NaN score) would have to become expectThrows.

Downstream note

Downstream, a service that indexes client-supplied token vectors lets one document with a zero row get a NaN score and disturb the order of the other hits in a shared result list.

How it was found

A proof-obligation audit of LateInteractionFloatValuesSource's contract: its score must be a valid (finite) score for every token matrix that LateInteractionField and the constructor accept. An all-zero token is accepted, and the default COSINE turns it into 0/0; a public-API JUnit test then witnessed the NaN score.

The method in full128 words

Method: semi-formal (a proof-obligation audit of a class's contract, witnessed by an executable repro).

  • The finder read LateInteractionFloatValuesSource and SUM_MAX_SIM against the obligation that a rescored score is a valid score (TopScoreDocCollector: NaN is not one) and against what LateInteractionField.encode and validateQueryVector accept (null, empty and dimension checks only). VectorUtil.cosine states its precondition only as an assertion, and the single-vector entry points discharge it with explicit checks; the late-interaction entry points do not.
  • The finder then wrote the one-test public-API JUnit used here, which fails on main with assertions on and off and on 10.6.0. An independent reproducer, an executed cross-model review (OpenAI GPT-5.6) and a devil's-advocate verifier reproduced it with their own tests, and an adjudication re-ran the finder's test ("Prior reports, history and checks").

Prior reports, history and checks

Prior reports

No prior report in apache/lucene issues, PRs and commits, the dev@, java-user@, issues@ and commits@ lists, downstream trackers or the web (searched 2026-11-02). The nearest item, open issue #15540, covers zero vectors under COSINE on the single-vector path only.

Searches3 searches

No matching public report was found in the documented searches. The nearest items are related, not reports: #15540 (open; same input class, single-vector Knn*VectorField path) and its fixes #15751, #16098 and #16708, which never mention late interaction; #12281 (the validation design rule); #14729 (introduced the classes; no discussion of zero or non-finite tokens); open PR #16748 (SUM_MAX_SIM ranking change that keeps the NaN path); a downstream plugin's automated review comment about its own scorer (OpenSearch k-NN #3613), not a Lucene report. Its fix site differs from #15540's (the late-interaction classes, not the single-vector path), so it is a separate bug of the same family. Limits: keyword search; private reports are not visible.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene; the JIRA archive is moot, the classes date from 2025)2026-11-02, re-checkeda scripted prior-report search (51 queries in all: 16 issue/PR, 3 commit, 2 downstream, 20 mailing-list, 4 JIRA, 6 web; 69 distinct hits, none high-overlap) and 31 manual searches (LateInteractionField, LateInteractionFloatValuesSource, LateInteractionRescorer, 'late interaction NaN', 'multi-vector zero vector cosine', ...); threads of #15540, #15751, #16098, #16708, #12281, #14729 and #16748 read in fullno report; #15540 and its fixes cover the single-vector path only
Open PRs2026-11-02the changed files of all 408 open apache/lucene PRs and of the PRs updated since 2026-10-31only #16748 (keeps the NaN path) and #16847 (mechanical long-doc-id change) touch the late-interaction files
Mailing lists, downstream, web2026-11-02the scripted search's 20 list queries and manual ones (dev@, java-user@, issues@, commits@), the Solr, Elasticsearch and OpenSearch trackers, and its 6 web queriesnothing about this bug

History

Introduced in 10.3.0 by #14729 ("Support for Re-Ranking Queries using Late Interaction Model Multi-Vectors", 2025), which added the three classes without zero-vector or finiteness checks; the files are unchanged since. It fails on every release that was run, 10.3.0 to 10.6.0, and on main; not fixed upstream.

Versions tested6 rows

Introduced by #14729 (vigyasharma; CHANGES entry under 10.3.0 New Features): main commit d9618080b34, 2025-07-08, cherry-picked to branch_10x as 7de24ce973d the same day. git blame puts the checks and the SUM_MAX_SIM lines in d9618080b34, and no later commit touches the three files; they are byte-identical at every release tag from 10.3.0 to 10.6.0 and on main. Not a regression: the single-vector checks (#12281, 2023) were already in place when the classes shipped.

COSINE.compare changed in #15036 (2025-08-06, before 10.3.0) from Math.max((1 + cosine) / 2, 0) to normalizeToUnitInterval(cosine); both keep NaN. Not fixed upstream: on upstream main 1c2072f0284 (2026-11-02) the three files were still last changed by d9618080b34.

Versions and branches executed, with results
Version or commitVerdictResult
main 5adff78bb168 affectedgradle: fails with assertions on and off; passes with fix A and with fix A+B
10.6.0 affectedfails with assertions on (AssertionError in VectorUtil.cosine) and off (score NaN)
10.3.0 first releasefails with assertions on and off (test with new MatchAllDocsQuery())
10.4.0, 10.5.2 affectedfail with assertions off (score NaN)
10.3.1, 10.3.2, 10.5.0, 10.5.1 by readingthe three source files are byte-identical to 10.3.0 and main
10.2.2, 9.12.3 API absentthe late-interaction classes do not exist; the repro does not compile

Independent checks

A finder, an independent reproducer, an executed cross-model review (OpenAI GPT-5.6, refute lens), a devil's-advocate verifier, an adjudication, an audit and a final repro check reproduced it and agree on the root cause. This doc's gradle runs reproduced it on main and validated two fixes.

Checks and verdicts293 words

The cross-model refute review ran on main ea57360912ed, where the three late-interaction files, DoubleValuesSourceRescorer, VectorUtil and VectorSimilarityFunction are byte-identical to 5adff78bb168; its tests were re-run on 5adff78bb168 with the same results. Every other check ran on 5adff78bb168 or a release.

Each independent check and its verdict
CheckVerdictWhat it found
finder reproducedThe one-test JUnit fails on main (assertions on and off) and on 10.6.0; a cross-model review (OpenAI GPT-5.6) re-ran it and rated it real.
independent reproducer realOwn test (hand-built first-pass TopDocs) fails on main (on, off, and with the scalar vector provider), 10.6.0, 10.5.2 and 10.3.0; its probe: the zero row scores 1.0 under DOT_PRODUCT and EUCLIDEAN, NaN only under COSINE.
cross-model review, refute lens (OpenAI GPT-5.6, executed) real (high)Own tests for a zero document token, a NaN document token and a zero query token fail; the finite COSINE and zero-token EUCLIDEAN controls pass; intended argument rated moderate.
devil's-advocate verifier real (high)No "intended" defence survives; its probe shows the mis-ordering of the other hits and that single-vector KnnFloatVectorQuery with a zero COSINE target fails loudly instead (in that probe an IllegalArgumentException from the vector scorer's normalization, not a query-side check).
adjudication confirmed (high), LowRe-ran the test on stock main: assertions on and off fail.
separate re-run, audit and final repro check confirmed; the audit classed it as silent wrong results, the final check as minorThe one-test repro fails on main with assertions on and off, and passes with the skip-zero-token fix.
this doc reproduced; fixes A and A+B validatedGradle without a fix fails (on, off); with A, and with A+B, the repro and the four late-interaction suites pass (16 tests, 2 @Nightly skipped).

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 notes1,389 words

Finding record

LFV-1120, snapshot of 2026-11-06

Registry title
LateInteractionField and LateInteractionFloatValuesSource accept all-zero (and non-finite) token vectors, so late-interaction rescoring under its default COSINE gives the document a NaN score (AssertionError in VectorUtil.cosine with -ea)
Area
core / search: late-interaction rescoring (LateInteractionField, LateInteractionFloatValuesSource, LateInteractionRescorer)
Priority
Low, rank 574: A document whose late-interaction token matrix holds an all-zero row gets a NaN rescored score under the default COSINE, which can also mis-order the other rescored hits, in every release since 10.3.0. It needs the opt-in, experimental late-interaction rescoring API and an uncommon zero or non-finite token vector, so it is a rare boundary-value case.
Impact class
minor
Impact note
Only applications that use the opt-in late-interaction (multi-vector) rescoring API are hit: a document whose LateInteractionField value contains one all-zero token row (for example zero padding of a fixed-size token matrix) gets a NaN rescored score from LateInteractionRescorer.create(field, query) or new LateInteractionFloatValuesSource(field, query), whose default similarity is COSINE, and because ScoreDoc.COMPARATOR treats NaN as tied with every score the other rescored hits can be mis-ordered too (verifier probe: the best finite hit came last); with -ea it is an AssertionError in VectorUtil.cosine instead; no scale is needed, every release from 10.3.0 through 10.6.0 is affected, and DOT_PRODUCT or EUCLIDEAN give a finite score for the same zero row. (Minor rather than silent wrong results: opt-in @lucene.experimental API, uncommon input, easy workaround.)
Summary
You index a document with a LateInteractionField whose token matrix is {{1,0},{0,0}} (the second token is all zeros) and rescore it with LateInteractionRescorer.create("li", {{1,0}}), which uses COSINE by default. You expect a valid score, here 1.0 because the document's first token equals the query token, or an IllegalArgumentException for the zero token, as KnnFloatVectorField gives under COSINE. Instead the field accepts the zero row, the cosine of the zero row is 0/0 = NaN, Float.max in SUM_MAX_SIM keeps that NaN over the exact match, and the document comes back with score NaN (with -ea, VectorUtil.cosine throws AssertionError).
Introduced
d9618080b34 (apache/lucene#14729 'Support for Re-Ranking Queries using Late Interaction Model Multi-Vectors' by vigyasharma, main; branch_10x cherry-pick 7de24ce973d, released in 10.3.0) [read: git blame at the history head puts LateInteractionField.java:74-80, LateInteractionFloatValuesSource.java:65-80 and :170-178 in d9618080b34, and the three late-interaction files are byte-identical between d9618080b34 and main 5adff78bb168; exec: release level only, 10.2.2 and 9.12.3 cannot compile the repro (API absent) and 10.3.0 fails]; not a regression: the classes shipped without zero-vector or finiteness checks, while the single-vector precedent (finiteness checks in the field and query classes, #12281, 2023) was already in place (2025-07-08 (author and commit date of d9618080b34, #14729 merged; branch_10x cherry-pick 7de24ce973d the same day))
First affected version
10.3.0 [exec: fails with -ea and -da] (first release with the late-interaction classes; 10.2.2 and 9.12.3 give compile_error because the API is absent [exec])
Affected versions
every release from 10.3.0 through 10.6.0 (10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2 and 10.6.0, released 2026-10-29 at 4ae946e5a9b) and main 5adff78bb168 (12.0.0-SNAPSHOT). Executed: main with -ea and -da (the finder, the independent reproduction also with the scalar vector provider, the cross-model review's tests re-run, the intended-behaviour check, the adjudication and a separate re-run), 10.6.0 with -ea and -da, 10.5.2 with -da, 10.4.0 with -da and 10.3.0 with -ea and -da [exec]; 10.3.1, 10.3.2, 10.5.0 and 10.5.1 by reading (the three late-interaction source files are byte-identical to d9618080b34 at those tags) [read]. Not affected: 10.2.2 and earlier and all 9.x (API absent; 10.2.2 and 9.12.3 compile_error [exec]). Not new in 10.6 (10.5.2 and 10.3.0 fail). Not fixed upstream: on upstream main 1c2072f0284 (author date 2026-11-02) the three late-interaction files were last changed by d9618080b34, and the only open PR touching LateInteractionFloatValuesSource (#16748) keeps VectorUtil.cosine and Float.max [read].
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
semi-formal: proof-obligation audit
Upstream and prior reports
none found (2026-11-02, on top of the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's GitHub searches; rechecked the same day): a scripted prior-report search over 16 GitHub issue/PR, 3 commit, 2 downstream, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 6 web queries (69 distinct hits, none highly relevant), manual GitHub searches on apache/lucene, Solr, Elasticsearch and OpenSearch (k-NN, neural-search), the file lists of all 408 open apache/lucene PRs and of the 32 PRs updated since 2026-10-31 (late-interaction files are touched only by #16748 and by #16847, a mechanical long-doc-id change; VectorUtil.java only by vectorization PRs such as #16790), direct reads of #14729, #15540, #15751, #15621, #16098, #16099, #16100, #16708, #12281, #16748 and others. Related, not this bug: open issue #15540 (zero vectors under COSINE give NaN for KnnFloatVectorField/KnnByteVectorField in the HNSW path) and its fixes #15751, #16098 and #16708 (single-vector entry points only, never mention late interaction); #14729 (introduced the classes, no discussion of zero or non-finite tokens); open PR #16748 (SUM_MAX_SIM ranking change, keeps the NaN path).
Lucene commit tested
main 5adff78bb168 (2026-11-02) through gradle in a scratch worktree with assertions on and off, without a fix, with fix A and with fix A+B (the repro, TestLateInteractionField, TestLateInteractionFloatValuesSource, TestLateInteractionRescorer, TestRescoreTopNQuery, checkGoogleJavaFormat), and through a standalone JUnit runner on the main jars. The hunt ran the test on 9.12.3 and 10.2.2 (API absent: does not compile), 10.3.0, 10.4.0, 10.5.2 and 10.6.0 (all fail) and main. Only Linux aarch64 with OpenJDK 25.0.3 was run.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/document/LateInteractionField.java:48-56 (at 5adff78bb168) (setValue javadoc: 'Value should not be null or empty. Composing token vectors ... should have the same dimension.'), :74-102 (encode: null/empty and equal-dimension checks only)
  • lucene/core/src/java/org/apache/lucene/search/LateInteractionFloatValuesSource.java:36 (at 5adff78bb168) (@lucene.experimental), :45-47 (COSINE default), :65-80 (validateQueryVector: null/empty/length checks only), :154-181 (SUM_MAX_SIM: :175 maxSim = Float.max(maxSim, vectorSimilarityFunction.compare(q, d)), :177 result += maxSim)
  • lucene/core/src/java/org/apache/lucene/search/LateInteractionRescorer.java:30 (at 5adff78bb168) (@lucene.experimental), :39-41 (create(field, query) uses COSINE), :62-65 (combine returns the source value)
  • lucene/core/src/java/org/apache/lucene/util/VectorUtil.java:100-107 (at 5adff78bb168) (cosine: :105 assert Float.isFinite(r)), :461-463 (normalizeToUnitInterval: Math.max((1 + value) / 2, 0) keeps NaN); lucene/core/src/java/org/apache/lucene/index/VectorSimilarityFunction.java:76-86 (COSINE.compare = normalizeToUnitInterval(cosine(v1, v2)); javadoc prefers normalized vectors with DOT_PRODUCT)
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSourceRescorer.java:72-85 (at 5adff78bb168) (hit.score = combine(...); select and sort with ScoreDoc.COMPARATOR); search/ScoreDoc.java:56-66 (COMPARATOR: neither > nor < falls back to doc id, so NaN ties with every score); search/TopScoreDocCollector.java:30-31 ('The values Float#NaN and Float#NEGATIVE_INFINITY are not valid scores.')
  • lucene/core/src/java/org/apache/lucene/document/KnnFloatVectorField.java:53-56, (at 5adff78bb168) :105, :146-151, :172-176 (single-vector field rejects a zero vector under COSINE and non-finite values via VectorUtil.checkFinite); search/KnnFloatVectorQuery.java:97 (checkFinite on the target)
  • GitHub, 2026-11-02 (same-day recheck): GitHub search of apache/lucene issues and PRs for 'late interaction' (latest update #16748, 2026-10-24) and 'LateInteraction NaN' (0); commits by path for the three late-interaction files on main (only d9618080b34, author date 2025-07-08); upstream main head 1c2072f0284 (author date 2026-11-02); file lists of the 32 PRs updated since 2026-10-31 (only #16790, OptimizedScalarQuantizer vectorization, touches VectorUtil.java)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/document/LateInteractionField.java:51-52 (at 5adff78bb168) (setValue javadoc), :74-102 (encode: null/empty and equal-dimension checks only)
  • lucene/core/src/java/org/apache/lucene/search/LateInteractionFloatValuesSource.java:36 (at 5adff78bb168) (@lucene.experimental), :45-47 (COSINE default), :65-80 (validateQueryVector), :154-181 (SUM_MAX_SIM; :175 Float.max, :177 result += maxSim)
  • lucene/core/src/java/org/apache/lucene/search/LateInteractionRescorer.java:39-41 (at 5adff78bb168) (create uses COSINE), :62-65 (combine)
  • lucene/core/src/java/org/apache/lucene/index/VectorSimilarityFunction.java:84-86 (at 5adff78bb168) (COSINE.compare); util/VectorUtil.java:100-107 (cosine, assert at :105), :461-463 (normalizeToUnitInterval)
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSourceRescorer.java:72-85 (at 5adff78bb168) (hit.score, select and sort); search/ScoreDoc.java:56-66 (COMPARATOR)
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSourceRescorer.java:79-81 (at 5adff78bb168) (ArrayUtil.select with ScoreDoc.COMPARATOR when topN < hits.length; read, not executed)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/LateInteractionFloatValuesSource.java:36 (at 5adff78bb168) and search/LateInteractionRescorer.java:30 (@lucene.experimental); search/TopScoreDocCollector.java:30-31
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/TopScoreDocCollector.java:30-31; (at 5adff78bb168) document/LateInteractionField.java:51-52; index/VectorSimilarityFunction.java:77-80
  • lucene/core/src/java/org/apache/lucene/document/KnnFloatVectorField.java:53-56, (at 5adff78bb168) :105, :146-151, :172-176; search/KnnFloatVectorQuery.java:97
  • lucene/CHANGES.txt:952 (at 5adff78bb168) (#15751, 10.5.0), :1262 (#16098, 10.5.0), :574 (#16708, 10.6.0), :1635 (#14729, 10.3.0)
  • Lucene main 5adff78bb168 build-tools/build-infra/src/main/java/org/apache/lucene/gradle/plugins/java/RenderJavadocPlugin.java:502 (@lucene.experimental tag text)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/LateInteractionFloatValuesSource.java:160-165 (at 5adff78bb168) (empty document and MIN_VALUE floor); util/VectorUtil.java:485-492 (checkFinite), :516-523 (isZeroVector)
  • lucene/core/src/test/org/apache/lucene/search/TestLateInteractionFloatValuesSource.java:44-64 (at 5adff78bb168) (testValidations with a null similarity); TestRescoreTopNQuery.java:107, :192 (@Nightly)
History
  • a Lucene clone: git diff of releases/lucene/10.3.0, 10.3.2, 10.4.0, 10.5.2, 10.6.0 against 5adff78bb168 for the three files (empty)
  • lucene/CHANGES.txt:1635 (at 5adff78bb168) (#14729 under 10.3.0)
Diagrams
  • lucene/core/src/java/org/apache/lucene/search/LateInteractionFloatValuesSource.java:154-181; (at 5adff78bb168) util/VectorUtil.java:100-107

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

Repro files

About the diagrams

The diagram illustrates the repro's scenario (main 5adff78bb168, LateInteractionFloatValuesSource.java:154-181, VectorUtil.java:100-107).

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.