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.
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.- You index 20 documents in one segment with a
LateInteractionFieldmulti-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. - 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. - 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.
- Instead the cached searcher returns 5:
isCacheablesays 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. - No setup needed on 10.3.0 to 10.5.2: with the default
IndexSearchercache (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.
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);.
- Late-interaction multi-vector:
LateInteractionField(10.3.0) stores a document's token vectors as one binary doc value; it extendsBinaryDocValuesField.LateInteractionFloatValuesSourcescores a document against a query multi-vector (default: COSINE similarity,SUM_MAX_SIM), and is aDoubleValuesSource, so any query that takes a values source can use it. - Query cache:
LRUQueryCachestores 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 answerDocValues.isCacheable(ctx, field), which turns false once the field has been updated in place, so the cache evaluates them fresh.
getValuesreads the multi-vectors withctx.reader().getBinaryDocValues(fieldName)(LateInteractionFloatValuesSource.java:84on maina9779ef3c193).isCacheablereturnstrueunconditionally (:145-148).FunctionMatchQuery(FunctionMatchQuery.java:111-114) and the facet module'sDoubleRange#getQuery(the weight of theValueSourceQueryit builds,DoubleRange.java:209-211; afromSingleValuedmulti-valued source is unwrapped into the same query,:351-357) pass that answer on as theirWeight#isCacheable.LRUQueryCachetrusts it (LRUQueryCache.java:816, and:903incount) and looks the entry up by query plusgetCoreCacheHelper()(:827-834,:914-924). The core key is the same afterupdateBinaryDocValueorupdateDocValues(LeafReader.java:66-70: readers with "different sets of deleted documents or doc values updates may be considered equal").- So the entry filled before the update is found and served.
DocValues.isCacheablewould have said false (fi.getDocValuesGen() > -1,DocValues.java:339-346), as it does forDoubleValuesSource.fromLongFieldandfromDoubleField(DoubleValuesSource.java:609-611).
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 replaceLateInteractionFieldmulti-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 defaultLRUQueryCachewithUsageTrackingQueryCachingPolicy, 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_6still installs the default cache. - Query reuse. The stale entry is found only by an equal query.
DoubleRange#getQuerycompares by value, so it goes stale even when rebuilt for every request (executed).FunctionMatchQuerycompares 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, forDoubleRange, 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.
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.
/*
* 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)));
}
}
}
}
# 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 returnDocValues#isCacheable; updated fields are not cacheable); actual:true(expected:<false> but was:<true>), with-eaand-da.
More files, environment, full output34 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
=> java.lang.AssertionError: expected:<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)SegmentCacheable: objects that depend on doc values "should returnDocValues#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
truecame with the class (#14729), likeVectorSimilarityValuesSource, 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 returnDocValues#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) andLongValuesSource(:269-271) returnDocValues.isCacheable. In a bytecode scan of all 123 classes that declareisCacheable, this is the onlySegmentCacheablethat returns a constanttruewhile 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") andTestSegmentCacheables#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 callisCacheable. - 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 comparesFunctionScoreQuerywith aRescorer("I believe we only cache hits (disi) and not scores"), which is about choosing a re-ranking API. The constant matchesVectorSimilarityValuesSource#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 aLateInteractionFieldis. 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.
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.
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
isCacheableafterupdateBinaryDocValuenext 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>;isCacheabletrue); 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,TestSegmentCacheablesandTestDoubleValuesSource; all oforg.apache.lucene.queries.function; all oforg.apache.lucene.facet.range: 27 classes, 220 tests, 5 skipped, 0 failures. ecjLintandcheckGoogleJavaFormatpass for core and queries (the queries run includes the repro test); both diffs apply cleanly to a cleana9779ef3c193tree.- 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,queriesandfacetsuites; no benchmark (the change only removes cache hits on segments whose multi-vector field was updated in place).
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.
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.
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, 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).
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 constanttrue, or aDocValues.isCacheablecheck) and listed the doc-values reads in its class family.LateInteractionFloatValuesSourcewas the only constanttruenext to aLeafReader.getBinaryDocValuesread. - 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
FunctionMatchQueryandDoubleRange#getQueryover this source (32 cached versus 31 uncached). - A TLA+ model of the
LRUQueryCachepath, written right after, violates itsSearchCorrectinvariant with a 5-state counterexample whenisCacheableis a constanttrue, 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; Elasticsearch, OpenSearch and Solr; the dev@, java-user@, issues@ and commits@ lists; the JIRA archive; the web | 2026-10-26 | three runs of the hunt's prior-report tool (class terms, symptom and contract terms, and the finder's run), 166 queries | no report; context only: #14729, #9065 (LUCENE-8017), #9090 (LUCENE-8042) |
| GitHub (apache/lucene; Elasticsearch, OpenSearch, OpenSearch k-NN, neural-search, Solr) | 2026-10-26 | 94 GraphQL searches: class names, isCacheable, SegmentCacheable, DocValues.isCacheable, query-cache staleness, updateBinaryDocValue, FunctionMatchQuery, DoubleRange, ColBERT, MaxSim, multi-vector | no report; downstream late-interaction work (k-NN #3605, #3439, #2934, #2972; OpenSearch #18727) never mentions cacheability or doc-values updates |
| lists.apache.org, JIRA, web | 2026-10-26 | 48 list searches over four lists, 8 JIRA queries, 11 web queries | no report |
| Open apache/lucene PRs | 2026-10-26 | changed files of all 411 open PRs; direct reads of #16748, #16847, #16757 and #16628 | only #16748 touches the file (scoring only; isCacheable unchanged); #16847 and #16757 touch FunctionMatchQuery, DoubleRange or LRUQueryCache without changing cacheability |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | installed cache | compact JUnit 2 of 3 fail through gradle, assertions on and off, control passes; the zero-configuration program passes (no default cache) |
main ea57360912ed | installed cache | the 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 | affected | the 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 | affected | the finder's test 2 of 2 fail (release jars, SHA-1 checked) |
10.6.0 (branch_10_6, released 2026-10-29) | not run | same file blob and still a default query cache (read only) |
| 10.2.2, 9.12.3 and older | no such class | compile error: LateInteractionField and LateInteractionFloatValuesSource do not exist |
fix (scratch worktree at a9779ef3c193) | not affected | the repro 3 of 3 and the facet range variant 2 of 2 pass, assertions on and off; the closest suites (220 tests) pass |
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.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Scan 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 | real | Own 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 holds | Rejected 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, high | Re-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; keep | History 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 validated | The 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. |
The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.
- PNG 590 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,273 words
- 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 facetDoubleRangevariant 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 mainea57360912edand 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 ona9779ef3c193still reproduces.
- 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-148lucene/core/src/java/org/apache/lucene/document/LateInteractionField.java:36lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:27-32lucene/queries/src/java/org/apache/lucene/queries/function/FunctionMatchQuery.java:111-114lucene/facet/src/java/org/apache/lucene/facet/range/DoubleRange.java:209-211, 335-337, 351-357lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:816-834, 903-924lucene/core/src/java/org/apache/lucene/index/LeafReader.java:66-70, 78lucene/core/src/java/org/apache/lucene/index/DocValues.java:339-346lucene/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:99lucene/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:84lucene/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-32lucene/core/src/java/org/apache/lucene/search/DoubleValuesSource.java:48, 609-611; LongValuesSource.java:269-271; VectorSimilarityValuesSource.java:67-70lucene/core/src/test/org/apache/lucene/search/TestLRUQueryCache.java:2222, 2281; TestSegmentCacheables.java:43-84lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1982-1984, 1996lucene/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
- TestLateInteractionValuesSourceCacheable.java (JUnit test):
repro/LFV-336/src/org/apache/lucene/search/TestLateInteractionValuesSourceCacheable.java(50 lines)
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.
assets/LFV-336/bug.png(590 KB)
