All findings · Lucene finding · LFV-336 · late-interaction-values-source-cacheable-after-dv-update

LateInteractionFloatValuesSource always reports itself cacheable, so after an in-place update a cached filter on a late-interaction score keeps its old matches

LateInteractionFloatValuesSource reads multi-vectors from binary doc values but always reports itself cacheable, so after an in-place update of a LateInteractionField a cached filter on its score keeps the old matches (5 hits instead of 4). Affects 10.3.0 to 10.6.0 (10.6.0 by reading) with the default searcher, and main with an installed query cache.

Summary

Priority
Low, rank 353Needs the experimental LateInteractionFloatValuesSource used as a cached filter plus in-place multi-vector updates, with no known user.
Impact
silent wrong results
Since
10.3.0 (#14729, 2025), with the classintroduced 2025-07-08 · about 1 year
Affected
10.3.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
a bytecode scan of every isCacheable plus a cached-versus-uncached sweep

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

The query cache stores a filter's matches per segment and finds them again by the segment's core key, which a doc-values update does not change. So anything that reads doc values should report itself not cacheable once its field is updated. LateInteractionFloatValuesSource reads multi-vectors from binary doc values but always answers true.

Diagram of the bug: six documents in one segment, docs 0-2 with the multi-vector {{1, 0}} and docs 3-5 with {{0, 1}}. A FunctionMatchQuery over LateInteractionFloatValuesSource with score > 0.75 matches docs 0, 1, 2, and because isCacheable is true the cache stores {0, 1, 2} under the query and the segment core. Doc 0's multi-vector is replaced in place with updateBinaryDocValue and the reader is reopened; it shares the segment core. The same query is a cache hit: expected 2, actual 3. Why: getValues reads binary doc values (LateInteractionFloatValuesSource.java:84) yet isCacheable returns true (:145-148), against SegmentCacheable.
Step by step through an executed probe on main: six documents in one segment, the filter score > 0.75 cached with docs 0-2, doc 0's multi-vector replaced in place, and the same query on the reopened reader served from the cache: 3 hits where 2 are expected. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index 20 documents in one segment with a LateInteractionField multi-vector: docs 0-4 point the same way as the query {{1, 0}} (score 1.0), docs 5-19 are orthogonal (score 0.5). FunctionMatchQuery(new LateInteractionFloatValuesSource("mv", query), s -> s > 0.75) matches 5 documents, and the query cache stores that result for the segment.
  2. You re-embed doc 0 in place with updateBinaryDocValue (now orthogonal, score 0.5) and reopen. A doc-values update keeps the segment's core cache key, so a query that reads doc values should now report itself not cacheable.
  3. You expect 4 hits: an uncached searcher returns 4, and a numeric doc-values filter run through the same cache and reopen also gives the right answer.
  4. Instead the cached searcher returns 5: isCacheable says true, so the cache serves the pre-update match set, doc 0 included, until the entry is evicted, the cache is cleared or the segment core goes away (a later deletion does not end it). No error is raised.
  5. No setup needed on 10.3.0 to 10.5.2: with the default IndexSearcher cache (12,000-doc segment, the query run 6 times) the result is 5 versus 4 uncached (run on 10.3.0 and 10.5.2; the releases in between have the same source file and the same default cache). Main has no default cache, so it needs an installed one.

Who is affected

Applications that filter or range-facet on a late-interaction score (FunctionMatchQuery or DoubleRange#getQuery over this source), replace multi-vectors in place, and keep a query cache across reopens: the default on 10.3.0 to 10.5.2, opt-in on main. Re-ranking with LateInteractionRescorer or RescoreTopNQuery, the documented use, is not affected. Only updated documents are wrong.

Mechanism, terms and numbers535 words

LateInteractionFloatValuesSource scores documents by reading their multi-vectors from binary doc values (LateInteractionField is a BinaryDocValuesField), but its isCacheable always returns true. SegmentCacheable says doc-values-based objects should return DocValues#isCacheable, which turns false once the field is updated in place, because the query cache keys entries on the segment core and a doc-values update keeps that core. So when the source is used in a cached, non-scoring filter (FunctionMatchQuery, or the facet module's DoubleRange#getQuery) and a document's multi-vector is replaced with updateBinaryDocValue or updateDocValues, the reopened searcher keeps serving the match set computed from the old multi-vector: 5 hits instead of 4 in a 20-document repro. On 10.3.0 to 10.6.0 (10.6.0 by reading) the default IndexSearcher cache is enough; main needs an installed cache. Re-ranking with LateInteractionRescorer or RescoreTopNQuery, the documented use, is not affected. The fix changes one line (plus an import): return DocValues.isCacheable(ctx, fieldName);.

Terms

  • Late-interaction multi-vector: LateInteractionField (10.3.0) stores a document's token vectors as one binary doc value; it extends BinaryDocValuesField. LateInteractionFloatValuesSource scores a document against a query multi-vector (default: COSINE similarity, SUM_MAX_SIM), and is a DoubleValuesSource, so any query that takes a values source can use it.
  • Query cache: LRUQueryCache stores the matching doc ids of a non-scoring query per segment, keyed by the query and the segment's core cache key. That key deliberately ignores deletions and doc-values updates.
  • SegmentCacheable: every cacheable weight or values source says whether its result may be cached for a segment. Objects that read doc values should answer DocValues.isCacheable(ctx, field), which turns false once the field has been updated in place, so the cache evaluates them fresh.

The bug

  1. getValues reads the multi-vectors with ctx.reader().getBinaryDocValues(fieldName) (LateInteractionFloatValuesSource.java:84 on main a9779ef3c193).
  2. isCacheable returns true unconditionally (:145-148).
  3. FunctionMatchQuery (FunctionMatchQuery.java:111-114) and the facet module's DoubleRange#getQuery (the weight of the ValueSourceQuery it builds, DoubleRange.java:209-211; a fromSingleValued multi-valued source is unwrapped into the same query, :351-357) pass that answer on as their Weight#isCacheable.
  4. LRUQueryCache trusts it (LRUQueryCache.java:816, and :903 in count) and looks the entry up by query plus getCoreCacheHelper() (:827-834, :914-924). The core key is the same after updateBinaryDocValue or updateDocValues (LeafReader.java:66-70: readers with "different sets of deleted documents or doc values updates may be considered equal").
  5. So the entry filled before the update is found and served. DocValues.isCacheable would have said false (fi.getDocValuesGen() > -1, DocValues.java:339-346), as it does for DoubleValuesSource.fromLongField and fromDoubleField (DoubleValuesSource.java:609-611).

The numbers

20 documents in one segment; docs 0-4 hold {{1, 0}} (score 1.0 against the query {{1, 0}}), docs 5-19 hold {{0, 1}} (score 0.5); filter score > 0.75. Before the update: 5 hits, cached. After updateBinaryDocValue of doc 0 to {{0, 1}} and openIfChanged: uncached 4, cached 5; isCacheable on the updated segment is true while DocValues.isCacheable(ctx, "mv") is false. The same sequence with a fromLongField filter and updateNumericDocValue gives 4. A probe confirms the stale answer goes both ways and only for updated documents: doc 0 (updated out of the filter) is still returned, doc 7 (updated into it) is missing.

Exposure and likelihood373 words
  • Who. Users of LateInteractionFloatValuesSource (10.3.0+, @lucene.experimental) in a non-scoring, cacheable query, that is a filter or a range-facet drill-down on the late-interaction score, who also replace LateInteractionField multi-vectors in place (for example when re-embedding documents) and keep a query cache across NRT reopens.
  • Default vs opt-in. On 10.3.0 to 10.5.2 a plain new IndexSearcher(reader) shares the default LRUQueryCache with UsageTrackingQueryCachingPolicy, whose policy caches a query like this one after 5 uses, on a segment of at least 10,000 documents that holds more than half of the index's average documents per segment: zero configuration is enough (executed on 10.3.0 and 10.5.2). Main has had no default query cache since GITHUB#14187 (11.0.0, unreleased), so it is hit only with an installed cache (executed). branch_10_6 still installs the default cache.
  • Query reuse. The stale entry is found only by an equal query. DoubleRange#getQuery compares by value, so it goes stale even when rebuilt for every request (executed). FunctionMatchQuery compares its predicate object, so it needs the same query object or a predicate that compares equal. Equality covers the whole source (field name, similarity, score function and the query multi-vector, LateInteractionFloatValuesSource.java:121-130) and, for DoubleRange, the range and the fast-match query.
  • Not affected. Re-ranking (LateInteractionRescorer, RescoreTopNQuery) reads the values per hit and never puts the source in the query cache; scoring searches are not cached either.
  • Effect and reach. Silent wrong results for the updated documents only, in both directions; deletes after the update are still applied, the tested restriction clause (an exact query on another updated doc-values field) is evaluated fresh, and a query not equal to any entry cached before the update is computed from the current values (until the field's next in-place update). Nothing is written to disk. The stale answer lasts until the entry is evicted, the cache is cleared or the segment core goes away (closed after a merge, or a reader opened afresh from the directory); a later deletion does not end it (executed). Deterministic at every size tried: 6 to 20 documents with an explicit cache, 12,000 with the default one.
  • How likely. Rare: the source is documented for re-ranking, and filtering on a late-interaction threshold plus in-place multi-vector updates is an unusual combination.

Minimal repro

The test shows that LateInteractionFloatValuesSource#isCacheable still returns true after the LateInteractionField it reads (binary doc values) is updated in place with IndexWriter#updateDocValues, so a query cache can keep serving pre-update matches.

TestLateInteractionValuesSourceCacheable.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.search;

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

public class TestLateInteractionValuesSourceCacheable extends LuceneTestCase {

  public void testNotCacheableAfterDocValuesUpdate() throws Exception {
    float[][] before = {{1f, 0f}};
    float[][] after = {{0f, 1f}};
    try (Directory dir = new ByteBuffersDirectory();
        IndexWriter w = new IndexWriter(dir, new IndexWriterConfig())) {
      Document doc = new Document();
      doc.add(new StringField("id", "0", Field.Store.NO));
      doc.add(new LateInteractionField("mv", before));
      w.addDocument(doc);
      w.updateDocValues(new Term("id", "0"), new LateInteractionField("mv", after));
      try (DirectoryReader reader = DirectoryReader.open(w)) {
        DoubleValuesSource source = new LateInteractionFloatValuesSource("mv", before);
        // expected: false, SegmentCacheable's rule once "mv" is updated in place; actual: true
        assertEquals(false, source.isCacheable(reader.leaves().get(0)));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-336/, 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.search.TestLateInteractionValuesSourceCacheable
Expected vs actual
Expected false (SegmentCacheable: doc-values readers return DocValues#isCacheable; updated fields are not cacheable); actual: true (expected:<false> but was:<true>), with -ea and -da.

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

More files, environment, full output34 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:<false> but was:<true>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.junit.Assert.assertEquals(Assert.java:146)
       org.apache.lucene.search.TestLateInteractionValuesSourceCacheable.testNotCacheableAfterDocValuesUpdate(TestLateInteractionValuesSourceCacheable.java:46)

Why it looks unintended

  • SegmentCacheable: objects that depend on doc values "should return DocValues#isCacheable"; "Updated doc values fields are not suitable for caching."
  • The doc-values field sources (fromLongField, fromDoubleField) follow it and are correct in the same sequence.
  • The constant true came with the class (#14729), like VectorSimilarityValuesSource, whose vectors cannot change; the PR never discusses whether the source is cacheable.
The full argument315 words
  • The contract. SegmentCacheable.java:27-32: "Objects that depend only on segment-immutable structures such as Points or postings lists can just return true ... Objects that depend on doc values should return DocValues#isCacheable(LeafReaderContext, String...), which will check to see if the doc values fields have been updated. Updated doc values fields are not suitable for caching." DoubleValuesSource implements SegmentCacheable (DoubleValuesSource.java:48).
  • Siblings follow it. DoubleValuesSource's field sources (:609-611) and LongValuesSource (:269-271) return DocValues.isCacheable. In a bytecode scan of all 123 classes that declare isCacheable, this is the only SegmentCacheable that returns a constant true while reading doc values; the other constant-true implementations read only segment-immutable data (postings, points or vectors) or nothing from the index at all (for example the constant sources).
  • Tests. TestLRUQueryCache#testDocValuesUpdatesDontBreakCache ("second segment no longer cached due to DV update") and TestSegmentCacheables#testMultipleDocValuesDelegates ("after field3 is updated, all composites referring to it should be uncacheable") assert the contract for other classes. The late-interaction tests never update the field and never call isCacheable.
  • History. The line has not changed since #14729 (d9618080b340). No comment or review in the PR discusses whether the new source is cacheable or what a doc-values update does to it; caching comes up once, when the PR author compares FunctionScoreQuery with a Rescorer ("I believe we only cache hits (disi) and not scores"), which is about choosing a re-ranking API. The constant matches VectorSimilarityValuesSource#isCacheable (VectorSimilarityValuesSource.java:67-70), which is right there because vector values cannot be updated in place.
  • Precedent. LUCENE-8017 (#9065, "FunctionRangeQuery and FunctionMatchQuery can pollute the QueryCache") and LUCENE-8042 (#9090), 2017, created this contract for function queries over doc values.
  • Legal trigger. IndexWriter#updateBinaryDocValue: "You can only update fields that were indexed only with doc values" (IndexWriter.java:1982-1984), which a LateInteractionField is. No javadoc, CHANGES or MIGRATE entry restricts the source to re-ranking or forbids in-place updates.
The devil's-advocate check152 words

The intended-behaviour verifier and a works-as-designed review by a model from another family built the strongest case: the class javadoc calls the source "useful re-ranking query results using late interaction models", it is @lucene.experimental, its own re-ranking paths never consult isCacheable, and main has no default query cache, so a cached filter over it is off-label.

Why it fails: "useful for" is not "only for". The class is a public DoubleValuesSource, and true is a positive claim that its values never change for a segment core, which is false for a doc-values field; if caching was not meant, false would be the safe answer. @lucene.experimental is about API stability, not correctness, and the missing default cache on main only lowers exposure (10.3.0 to 10.5.2 cache by default, and MIGRATE.md shows main users how to turn caching back on). No reviewer or verifier rated the intended argument better than weak.

Suggested fix

Recommended Return DocValues.isCacheable(ctx, fieldName) (validated)

Return DocValues.isCacheable(ctx, fieldName) from isCacheable, as the doc-values field sources do (+2/-1, one file). With it the repro passes with assertions on and off, so does the facet DoubleRange variant, and the closest suites in core, queries and facet pass (220 tests); lint and format checks pass.

@Override
public boolean isCacheable(LeafReaderContext ctx) {
  return DocValues.isCacheable(ctx, fieldName);
}
All options and trade-offs4 options · 464 words

One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF); the other options were not run.

Return DocValues.isCacheable(ctx, fieldName) (validated) (recommended)

In LateInteractionFloatValuesSource, replace return true; with return DocValues.isCacheable(ctx, fieldName); and import org.apache.lucene.index.DocValues (+2/-1). This is the recipe SegmentCacheable gives for doc-values-based objects and what fromLongField and fromDoubleField do. A second diff adds the repro as a regression test in lucene/queries.

@Override
public boolean isCacheable(LeafReaderContext ctx) {
  return DocValues.isCacheable(ctx, fieldName);
}

Trade-offs

  • A segment stays cacheable until its multi-vector field gets its first in-place update, then is evaluated fresh until it is merged away: the same trade-off every doc-values source already makes.
  • No change for the re-ranking paths, which never consult isCacheable.
  • A core-only unit test could also assert isCacheable after updateBinaryDocValue next to the existing late-interaction tests.

What the checks validated

  • Stock main a9779ef3c193: the repro fails 2 of 3 with assertions on and off (expected:<4> but was:<5>; isCacheable true); the control passes. The facet range variant fails 1 of 2.
  • With the fix: the repro passes 3 of 3 with assertions on and with assertions off; the facet range variant passes 2 of 2.
  • With the fix, the closest suites pass: in core TestLateInteractionFloatValuesSource, TestLateInteractionRescorer, TestLateInteractionField, TestRescoreTopNQuery*, TestLRUQueryCache, TestSegmentCacheables and TestDoubleValuesSource; all of org.apache.lucene.queries.function; all of org.apache.lucene.facet.range: 27 classes, 220 tests, 5 skipped, 0 failures.
  • ecjLint and checkGoogleJavaFormat pass for core and queries (the queries run includes the repro test); both diffs apply cleanly to a clean a9779ef3c193 tree.
  • Earlier, with the fix shadowed in: the finder's, the independent reproducer's and the devil's advocate's tests pass, and the stale-set tests of the reach probe fail as expected.
  • Not run: the full lucene/core, queries and facet suites; no benchmark (the change only removes cache hits on segments whose multi-vector field was updated in place).

Return false (not run)

Never cache filters over this source.

Trade-offs

Always correct and simplest, but broader than necessary: it also stops caching for segments whose field was never updated (the performance effect was not measured), and it departs from the contract's recipe for doc-values sources.

Document the limitation (not run)

State that the source must not be used in cached filters when the field is updated in place.

Trade-offs

No code change, but the silent stale results stay for anyone who misses the sentence, and true remains a false claim.

Also guard the contract generically (optional, not run)

A test that updates the field of every doc-values-based DoubleValuesSource and asserts isCacheable turns false. The hunt found two other SegmentCacheable violations with separate one-site fixes (the shape doc-values queries, and DoubleRange/LongRange#getQuery ignoring the fast-match query's cacheability).

Trade-offs

More work; each violation still needs its own fix.

Downstream note

Downstream, Solr exposes this source through its lateVector() function, but on the code paths read it turns off Lucene's query cache and can update only numeric fields in place, and Elasticsearch and OpenSearch do not reference the class on their default branches; no downstream exposure was found (read only, nothing downstream was run).

How it was found

A bytecode scan of all 123 classes that declare isCacheable flagged this one as a constant true in a class that reads binary doc values. A cached-versus-uncached sweep of 35 doc-values query cases on the real jars then showed stale counts for FunctionMatchQuery and DoubleRange#getQuery over it, and a TLA+ model of the cache path gave a 5-state counterexample.

The method in full171 words

Method: a static scan plus differential testing on the real jars (semi-formal tier), in a hunt unit on query-cache correctness.

  • A ClassFile-API scan of every class in the module jars that declares isCacheable (123 classes) classified each implementation (for example a constant true, or a DocValues.isCacheable check) and listed the doc-values reads in its class family. LateInteractionFloatValuesSource was the only constant true next to a LeafReader.getBinaryDocValues read.
  • A differential sweep ran 35 doc-values query cases on main's jars: each query cached, the field updated in place with the same segment core, then cached versus uncached. It returned STALE for FunctionMatchQuery and DoubleRange#getQuery over this source (32 cached versus 31 uncached).
  • A TLA+ model of the LRUQueryCache path, written right after, violates its SearchCorrect invariant with a 5-state counterexample when isCacheable is a constant true, and holds for the correct shapes.
  • Found 2026-10-26 on main ea57360912ed; then checked by an independent reproducer, an intended-behaviour devil's advocate, reading-only reviews by a model from another family (OpenAI GPT-5.6) and an adjudication.

Prior reports, history and checks

Prior reports

No prior report found (2026-10-26) in 340 logged queries and reads: GitHub issues, PRs and commits, the mailing lists, the JIRA archive, Elasticsearch, OpenSearch and Solr, the web, and the changed files of all 411 open PRs. Closest: #14729 (introduced the class), #9065 and #9090 (2017, origin of the contract) and open #16748 (scoring only).

Searches4 searches

Verdict new: 23 hits read, none the same bug. Keyword search cannot prove absence; the class postdates the JIRA migration.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch and Solr; the dev@, java-user@, issues@ and commits@ lists; the JIRA archive; the web2026-10-26three runs of the hunt's prior-report tool (class terms, symptom and contract terms, and the finder's run), 166 queriesno report; context only: #14729, #9065 (LUCENE-8017), #9090 (LUCENE-8042)
GitHub (apache/lucene; Elasticsearch, OpenSearch, OpenSearch k-NN, neural-search, Solr)2026-10-2694 GraphQL searches: class names, isCacheable, SegmentCacheable, DocValues.isCacheable, query-cache staleness, updateBinaryDocValue, FunctionMatchQuery, DoubleRange, ColBERT, MaxSim, multi-vectorno report; downstream late-interaction work (k-NN #3605, #3439, #2934, #2972; OpenSearch #18727) never mentions cacheability or doc-values updates
lists.apache.org, JIRA, web2026-10-2648 list searches over four lists, 8 JIRA queries, 11 web queriesno report
Open apache/lucene PRs2026-10-26changed files of all 411 open PRs; direct reads of #16748, #16847, #16757 and #16628only #16748 touches the file (scoring only; isCacheable unchanged); #16847 and #16757 touch FunctionMatchQuery, DoubleRange or LRUQueryCache without changing cacheability

History

Introduced with the class by #14729 (d9618080b340, merged 2025-07-08), first released in 10.3.0. Executed: all seven releases from 10.3.0 to 10.5.2 and main fail; 10.2.2 and 9.12.3 do not have the class. Not fixed upstream: the file is unchanged on main, branch_10x and branch_10_6.

Versions tested7 rows

Introduced by apache/lucene#14729 "Support for Re-Ranking Queries using Late Interaction Model Multi-Vectors" (@vigyasharma; CHANGES credits Vigya Sharma and Jim Ferenczi), merged to main on 2025-07-08 as d9618080b340 and backported to branch_10x the same day as 7de24ce973d0 (Lucene 10.3.0, New Features). That one commit added both the binary doc-values read and the constant true; no later commit touches the file, whose blob 3bf3bcb3d0b9 is the same at every tag from 10.3.0 to 10.5.2, on branch_10x, branch_10_6 and main. Not a regression: no version ever had a correct isCacheable.

On main the exposure narrowed when GITHUB#14187 turned the default query cache off (11.0.0, unreleased); branch_10_6 (10.6.0, released 2026-10-29) still has the default cache. Upstream main e1c4184cb983 and branch_10x were read on 2026-10-26: unchanged.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 installed cachecompact JUnit 2 of 3 fail through gradle, assertions on and off, control passes; the zero-configuration program passes (no default cache)
main ea57360912ed installed cachethe finder's test 2 of 2 fail (40 of 40 over 20 iterations); the independent test 3 of 4, control passes
10.3.0, 10.5.2 affectedthe finder's and the independent tests fail, assertions on and off; zero configuration: default searcher 5, uncached 4
10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1 affectedthe finder's test 2 of 2 fail (release jars, SHA-1 checked)
10.6.0 (branch_10_6, released 2026-10-29) not runsame file blob and still a default query cache (read only)
10.2.2, 9.12.3 and older no such classcompile error: LateInteractionField and LateInteractionFloatValuesSource do not exist
fix (scratch worktree at a9779ef3c193) not affectedthe repro 3 of 3 and the facet range variant 2 of 2 pass, assertions on and off; the closest suites (220 tests) pass

Independent checks

An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing. Reviews by a model from another family (OpenAI GPT-5.6) could only read code; both agreed on the root cause and rated the intended argument weak. An adjudication confirmed it with high confidence, and this doc's gradle runs reproduce it on a9779ef3c193.

Checks and verdicts392 words

Every executing check wrote or re-ran its own JUnit on stock main; every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateScan plus sweep; its JUnit fails 2 of 2 with assertions on and off, 40 of 40 over 20 iterations, and on 10.3.0 and 10.5.2; the zero-configuration run is stale on 10.5.2; the fix passes, and so does the upstream TestLateInteractionRescorer.
independent reproducer realOwn test written from the claim: 3 of 4 fail on main, 10.5.2 and 10.3.0 with assertions on and off (a cached FILTER returns {0, 1, 2, 3, 4} although doc 0 fails the predicate); its fromDoubleField control passes; zero configuration gives 6000 instead of 5999 on 10.3.0 and 10.5.2, and on main once a default cache is installed; 4 of 4 pass with the fix.
intended-behaviour devil's advocate real, high; no intended argument holdsRejected seven intended-behaviour points; wrote the DoubleRange#getQuery variant rebuilt per request, stale on main and 10.5.2 and passing with the fix.
cross-model reviews (refute, intended) not real (low, procedural); real (medium)An independent reviewer from another model family (OpenAI GPT-5.6) reviewed by reading only, since it could not run Java. The refute lens agreed on the root cause, found the inputs within the contract and rated the intended argument weak, so its "not real" only means not reproduced by the reviewer; a separate run re-ran the repro, which fails 2 of 2.
adjudication confirmed, highRe-ran the independent test with a new seed (3 of 4 fail, assertions on and off, control passes), the finder's test (2 of 2 fail) and the fix check (4 of 4 pass).
Follow-up and main refresh reproduces; keepHistory matrix on all seven releases; a probe pins the stale sets (both directions, updated documents only); re-runs with new seeds; the main refresh to a9779ef3c193 still fails 2 of 2 with identical messages.
this doc's gradle runs reproduces; fix validatedThe compact JUnit through gradle on stock main (2 of 3 fail with assertions on and off; the control passes) and with the fix (3 of 3 pass); the facet range variant fails, then passes with the fix; with the fix the closest suites (220 tests) and lint pass; the zero-configuration program is stale on 10.3.0 and 10.5.2 and passes on main.

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,273 words

Finding record

LFV-336, snapshot of 2026-11-06

Registry title
LateInteractionFloatValuesSource#isCacheable always returns true although it reads the multi-vectors from binary doc values, so after an in-place update of a LateInteractionField the query cache keeps returning the old matches of a FunctionMatchQuery or DoubleRange query over it
Area
core / search: LateInteractionFloatValuesSource (late-interaction multi-vectors) and the query cache
Priority
Low, rank 353: Needs the experimental LateInteractionFloatValuesSource used as a cached filter plus in-place multi-vector updates, with no known user.
Impact class
silent wrong results
Impact note
Applications that filter or range-facet on a late-interaction similarity score (FunctionMatchQuery or the facet module's DoubleRange#getQuery over LateInteractionFloatValuesSource, an experimental source documented for re-ranking), replace LateInteractionField multi-vectors in place with IndexWriter#updateBinaryDocValue or updateDocValues, and repeat the same query across NRT reopens through a query cache are hit; on 10.3.0-10.6.0 the default IndexSearcher cache is enough (zero-configuration repro executed on a 12,000-doc segment; 10.6.0 by reading), on main only an application-installed cache (no default cache since GITHUB#14187); re-ranking through LateInteractionRescorer or RescoreTopNQuery does not use the query cache and is not affected; the updated documents keep their old match decision until the entry is evicted or the segment is merged away; ordinary scale.
Summary
You score documents against a query multi-vector with LateInteractionFloatValuesSource, filter on that score through a query cache (for example FunctionMatchQuery(source, s -> s > 0.75), or DoubleRange#getQuery in the facet module), then replace one document's LateInteractionField multi-vector in place with IndexWriter#updateBinaryDocValue and reopen the reader. You expect the reopened searcher to stop matching that document, because a source that reads doc values must report itself not cacheable once those doc values have been updated (SegmentCacheable). Instead isCacheable always returns true, the segment keeps its core cache key, and the cache keeps serving the pre-update result (5 hits instead of 4 in the repro) until the entry is evicted or the segment is merged away.
Introduced
d9618080b340 (apache/lucene#14729 'Support for Re-Ranking Queries using Late Interaction Model Multi-Vectors' by vigyasharma, main; branch_10x backport 7de24ce973d0, released in 10.3.0) [read: git log --follow over the full-history clone lists only this commit for LateInteractionFloatValuesSource.java, git blame attributes line 84 (getValues reads ctx.reader().getBinaryDocValues(fieldName)) and lines 145-148 (isCacheable returns true) to it, and the file does not exist at its parent; exec: release level only, 10.2.2 cannot compile the repro (class absent) and 10.3.0 fails]; not a regression in any release: the class shipped with the defect and no version ever had a correct isCacheable (2025-07-08 (#14729 merged on main; branch_10x backport the same day))
First affected version
10.3.0 [exec] (first release with the class; 10.2.2 and 9.12.3 give compile_error because the API is absent [exec])
Affected versions
all 7 releases from 10.3.0 to 10.5.2 (10.3.0, 10.3.1, 10.3.2, 10.4.0, 10.5.0, 10.5.1, 10.5.2) and main ea57360912ed with an installed LRUQueryCache [exec, -ea: both finder tests fail; -da also on 10.3.0, 10.5.2 and main, same result; the independent reproduction's independent test fails on 10.3.0, 10.5.2 and main with -ea and -da while its fromDoubleField control passes]; with zero configuration (default IndexSearcher cache and caching policy, 12,000-doc segment, the same query run 6 times) 10.3.0 and 10.5.2 [exec], not main, which has had no default query cache since GITHUB#14187 (unreleased) [exec]; 10.6.0 (branch_10_6) not executed, same file blob 3bf3bcb3d0b9 and still a default LRUQueryCache [read]; 10.2.2 and older, including all 9.x, not applicable because the class does not exist [exec on 10.2.2 and 9.12.3; read for the 10.0.0-10.2.2 tags]; not fixed upstream: isCacheable still returns true on upstream main e1c4184cb983 and branch_10x [read, 2026-10-26]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal; also reached by TLA+/TLC
Upstream and prior reports
none found (2026-10-26, the prior-report record verdict new: 340 logged queries and reads: a scripted prior-report search, 3 runs, with 48 GitHub issue/PR, 9 commit, 6 downstream-repo, 60 mailing-list (dev@, java-user@, issues@, commits@), 12 JIRA-archive and 31 web queries; 94 read-only GraphQL searches over apache/lucene and downstream repositories (Elasticsearch, OpenSearch, OpenSearch k-NN, neural-search, Solr); 48 lists.apache.org searches; 8 JIRA REST queries; 11 further web queries; the changed-file lists of all 411 open PRs; 13 direct reads of the introducing PR, the precedent issues and the upstream file history; the independent reproduction's searches agree, the intended-behaviour check's own searches were rate-limited); not fixed upstream (LateInteractionFloatValuesSource.java was last changed by d9618080b340 and isCacheable still returns true on upstream main e1c4184cb983 and branch_10x; the only open PR touching the file, #16748, changes SUM_MAX_SIM scoring and leaves isCacheable unchanged; #16847 and #16757 touch FunctionMatchQuery, DoubleRange or LRUQueryCache without changing cacheability). Related, not reports of this bug: #14729 (introduced the class; none of its comments or reviews mentions isCacheable, the query cache or doc-values updates, and the author's one caching remark is about choosing a re-ranking API), #9065 (LUCENE-8017, 'FunctionRangeQuery and FunctionMatchQuery can pollute the QueryCache', 2017) and #9090 (LUCENE-8042) (origin of the doc-values cacheability contract, eight years before this class), #16628 (vector values sources whose isCacheable is true, correct because vector values are segment-immutable), and downstream late-interaction proposals (OpenSearch k-NN #3605, #3439, #2934, #2972; OpenSearch #18727) that do not mention cacheability or doc-values updates
Lucene commit tested
main a9779ef3c193 (commit of 2026-10-26): a compact public-API JUnit through gradle in a scratch worktree, stock and with the fix, with assertions on and off, plus the facet DoubleRange variant and the closest test suites with the fix (2026-10-27); a zero-configuration program on the 10.3.0 and 10.5.2 release jars and on main. Hunt runs on main ea57360912ed and on the release jars 9.12.3 and 10.2.2 (compile error: no such class) and all seven releases 10.3.0 to 10.5.2; the main-refresh re-run on a9779ef3c193 still reproduces.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/LateInteractionFloatValuesSource.java:28-36 (class javadoc: 'useful re-ranking query results using late interaction models', @lucene.experimental), :83-84 (getValues reads ctx.reader().getBinaryDocValues(fieldName)), :145-148 (isCacheable returns true); lucene/core/src/java/org/apache/lucene/document/LateInteractionField.java:36 (extends BinaryDocValuesField)
  • lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:27-35 ('Objects that depend on doc values should return DocValues#isCacheable ... Updated doc values fields are not suitable for caching'); lucene/core/src/java/org/apache/lucene/index/DocValues.java:339-346 (isCacheable false once the field's doc-values generation is set); lucene/core/src/java/org/apache/lucene/index/LeafReader.java:63-68 (the core cache helper ignores deletions and doc values updates); lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:816-834 (trusts in.isCacheable(context), then keys on getCoreCacheHelper())
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:111-114 (Weight#isCacheable returns source.isCacheable(ctx)); lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:209-211 and :315-317 (Weight#isCacheable returns valueSource.isCacheable(ctx)); lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:609-611 (the doc-values field source returns DocValues.isCacheable); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1982-1984 (updateBinaryDocValue: 'You can only update fields that were indexed only with doc values'); lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:2222 (testDocValuesUpdatesDontBreakCache)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/LateInteractionFloatValuesSource.java:28-37, 84, 145-148
  • lucene/core/src/java/org/apache/lucene/document/LateInteractionField.java:36
  • lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:27-32
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:111-114
  • lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:209-211, 335-337, 351-357
  • lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:816-834, 903-924
  • lucene/core/src/java/org/apache/lucene/index/LeafReader.java:66-70, 78
  • lucene/core/src/java/org/apache/lucene/index/DocValues.java:339-346
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:48, 609-611
Who is affected
  • a Lucene clone releases/lucene/10.3.0 and 10.5.2: lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:86, 99; LRUQueryCache.java:176 (MinSegmentSizePredicate(10000)); UsageTrackingQueryCachingPolicy.java:126-127; origin/branch_10_6 2763db11b0a IndexSearcher.java:99
  • lucene/CHANGES.txt:333 (GITHUB#14187 under Lucene 11.0.0); lucene/MIGRATE.md:227-236; lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:84
  • lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:119-124 (equals compares the predicate)
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSourceRescorer.java:45; RescoreTopNQuery.java:68-99
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:27-32
  • lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:48, 609-611; LongValuesSource.java:269-271; VectorSimilarityValuesSource.java:67-70
  • lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:2222, 2281; TestSegmentCacheables.java:43-84
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1982-1984, 1996
  • lucene/core/src/java/org/apache/lucene/search/LateInteractionFloatValuesSource.java:32-36 (javadoc, @lucene.experimental)
History
  • a Lucene clone: d9618080b340, 7de24ce973d0; blob 3bf3bcb3d0b9 at origin/main a95c754ee00, origin/branch_10x, origin/branch_10_6 and a9779ef3c193; lucene/CHANGES.txt:333, 1581

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

Repro files

About the diagrams

Made in parallel with this doc from a probe run on main a9779ef3c193 (stock, and with the fix shadowed in); the values on screen come from the probe's trace. The diagram uses six documents, the compact repro twenty; the mechanism and the outcome are the same.

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.