The query cache stores one doc set per query and segment and shares it between readers whose deletions differ, so the set must ignore deletions; each search removes its own deleted docs afterwards. This query builds its set with the current reader's deletions, the search's timeout and the filter, and still says "cacheable".
- Index docs 0-5 in one segment, doc i with vector (i, 1).
FloatVectorSimilarityQuerywith target (0, 1) and threshold 0.15 matches docs 0, 1, 2 (similarity 1.0, 0.5, 0.2). Run it as a count or filter, so the query cache may store its result. - Open reader A, delete doc 1, reopen as reader B. Both share the segment, and the cache keys entries on query + segment, so one entry serves both. B counts first and correctly gets 2.
- You expect A, where doc 1 still exists, to count 3. Instead it gets 2, hits
[0, 2]: the cache hands A the set B built with B's deletion applied.TermQueryin the same order gives 3. - Same flaw, two more ways: a search whose timeout fires caches its empty result, so a later search without a timeout gets 0, not 3 (10.0.0 and later, approximate path); and after doc 2's filter field is updated in place, the newer reader still gets 3, doc 2 included, although the filter now rejects it.
- No error is raised. On 9.10.0-10.5.2 a plain
new IndexSearcher(reader)already shares a default cache: a zero-configuration probe fails on every release, and with the same settings installed explicitly on main, 12,000 docs and 6 repeated searches give the older reader 11,999 instead of 12,000.
Anyone running a vector similarity query without scores (IndexSearcher#count, ConstantScoreQuery, a FILTER or MUST_NOT clause) through a query cache shared by readers or searchers: the default on 9.10.0-10.6.0 (10.6.0 by reading), an installed cache on main. It then takes an older reader searched after a newer one, a timed-out search, or a filter on doc values updated in place.
Mechanism, terms and numbers879 words
FloatVectorSimilarityQuery, ByteVectorSimilarityQuery and Float16VectorSimilarityQuery (the last on main only) share one Weight that runs the vector search per segment and builds the matching set with three inputs that are not part of the segment: the reader's live docs, the searcher's timeout (on the approximate path), and the filter. Yet its isCacheable returns true. LRUQueryCache keys entries on the query and the segment core, which readers that differ only in deletions or doc-values updates share, so the first reader or search that fills the entry decides what the others get in non-scoring use (IndexSearcher#count, ConstantScoreQuery, a FILTER or MUST_NOT clause, also inside a scoring query): an older point-in-time reader misses a document only a newer reader deleted (2 instead of 3 in the diagram's example, 99 instead of 100 in the test), a search without a timeout gets an earlier timed-out search's empty result (0 instead of 100), and a filter on doc values updated in place lets through a document it now rejects (100 instead of 99). As a MUST_NOT clause the stale set excludes too little, so the parent query returns too much (1 instead of 0; 100 instead of 0 after a timeout). No error is raised. It has been in every release since the query appeared in 9.10.0 (the timeout case since 10.0.0); on 9.10.0-10.6.0 (10.6.0 by reading) the default IndexSearcher cache is enough, on main an application must install a cache.
- Query cache:
LRUQueryCachestores, per query and per segment, the set of documents a non-scoring query matches, and reuses it. Its javadoc: "these cache and policy instances can be shared across several queries and readers" (LRUQueryCache.java:80). In 9.x and 10.x releases everyIndexSearchershares one static default cache withUsageTrackingQueryCachingPolicy(10.5.2IndexSearcher.java:83-99); on main the default isnull(IndexSearcher.java:84, #14187). - Segment core: readers opened at different times, or wrapped views of one reader, share a segment's core when only its deletions or doc-values updates differ. The cache key uses the core (
getCoreCacheHelper()), documented to cache "regardless of deletions"; readers with "different sets of deleted documents or doc values updates may be considered equal" (LeafReader.java:65-69). That is safe because an ordinary query's cached set ignores deletions and every search applies its own reader's live docs afterwards. - Vector similarity query:
new FloatVectorSimilarityQuery.Adaptive(field, target, resultSimilarity, decay, filter)matches every document whose vector similarity totargetis at leastresultSimilarity; decay 1 is the exact path, the default 0.5 an approximate graph search.@lucene.experimental.
createWeight(AbstractVectorSimilarityQuery.java:146) builds the filter Weight (:149-152) and capturessearcher.getTimeout()with aTimeLimitingKnnCollectorManager(:154-156).scorerSupplierreadsleafReader.getLiveDocs()(:188) and builds the set from it on every path: exact "to guarantee completeness" (:192-198,AcceptDocs.fromLiveDocsat:196), approximate (:200-207,:204), and filtered (AcceptDocs.fromIteratorSupplier(filter iterator, liveDocs, maxDoc),:209-220). With a filter, decay 1 goes straight to an exact search (:228-233); otherwise a graph search runs first and its result is returned when it is complete or the timeout has fired ("Return partial results only when timeout is met",:239-243), else the Weight falls back to an exact scan (:244-248). Without a filter, the approximate path returns its partial result after a timeout as is (:200-207). So the timeout is read only on the approximate paths (decay below 1).isCacheable(ctx)returnstrue(:252-255) and never asksfilterWeight.isCacheable(ctx), whereasBooleanWeightandConstantScoreQuerypass on their children's answer (BooleanWeight.java:273-285,ConstantScoreQuery.java:193-194).LRUQueryCachetrusts it (LRUQueryCache.java:816) and stores or finds the set under the query plusgetCoreCacheHelper()(:827,:834);countreturns the cached count for a reader without deletions (:892-924).- The next reader or search on that core gets the cached set intersected with its own live docs: it loses documents (live docs, timeout) or, through the filter, keeps one that no longer matches.
The top-k Knn*VectorQuery avoids this: it rewrites to DocAndScoreQuery, whose equals/hashCode include the reader context identity (DocAndScoreQuery.java:294-306) and whose createWeight rejects another reader (:170).
Diagram probe (one segment, docs 0-5, vector (i, 1), target (0, 1), threshold 0.15, an always-cache policy; main ea57360912ed, assertions on and off):
| case | expected | got |
|---|---|---|
| reader B (doc 1 deleted) counts first, then the older reader A | A: 3, [0, 1, 2] | 2, [0, 2] |
| a searcher whose timeout already fired runs it first, then a searcher without a timeout | 3 | 0 |
filter n == 0; doc 2 updated in place to n = 1; the reopened reader | 2, [0, 1] | 3, [0, 1, 2] |
TermQuery in the same order; separate caches; the reverse order; a scoring search(q, 10) | right | right |
The doc's JUnit test (100 docs; 12,000 for the default settings): 99 instead of 100, 11,999 instead of 12,000, 0 instead of 100, 100 instead of 99; the FieldExistsQuery control is right. As a clause of a scoring BooleanQuery: FILTER 99 instead of 100; MUST_NOT 1 instead of 0 (older reader) and 100 instead of 0 (after a timed-out search) on main and 10.5.2, assertions on and off.
Exposure and likelihood543 words
- Who is exposed. Non-scoring use (count,
ConstantScoreQuery, a FILTER or MUST_NOT clause, also inside a scoring query) ofFloatVectorSimilarityQuery,ByteVectorSimilarityQuery(both since 9.10.0) orFloat16VectorSimilarityQuery(main only) with a query cache shared by several readers or searchers, plus one of: an older point-in-time reader searched after a newer one with fresh deletions on the same segment (a documented pattern:SearcherLifetimeManager"Keeps track of current plus old IndexSearchers", for example when "the user clicks next page",SearcherLifetimeManager.java:30,:48; also in-flight requests on the previous searcher after a refresh), or a soft-deletes view next to the raw view of one commit; a search withIndexSearcher#setTimeoutwhose timeout fires on the approximate path (10.0.0 and later); or a filter on a doc-values field updated in place. - Not exposed. A direct scoring use, with the similarity query at the top of
search(q, n):IndexSearcherwraps a Weight in the cache only when scores are not needed (IndexSearcher.java:979-981; the probe's scoringsearch(q, 10)is right). But FILTER and MUST_NOT clauses of a scoringBooleanQueryare created without scores (BooleanClause.java:84-86,BooleanWeight.java:58-59) and are cached, so they are exposed (executed). Also not exposed: separate caches, and main without an installed cache. - Default or opt-in. On 9.10.0-10.5.2 no configuration is needed:
IndexSearcherinstalls a staticLRUQueryCache(1000, min(32 MB, heap/20))withUsageTrackingQueryCachingPolicy, shared by every searcher. A zero-configuration probe (new IndexSearcher(reader), defaultIndexWriterConfig) fails on every release 9.10.0-10.5.2 and passes on main, where #14187 (in no release yet) removed the default cache. The query itself is@lucene.experimental, and a threshold vector query used as a filter is a niche use. - Scale. None with an always-cache policy (6 documents). With the default policy the query becomes eligible on its 5th use (
UsageTrackingQueryCachingPolicy.java:122-138) and is then cached on segments of at least 10,000 documents that are not much smaller than the average (LRUQueryCache.java:251-253,:278-296), if the worst-case entry fits and the lead-cost check passes (:792-807,:816-853). The doc's test installs these settings explicitly; its 12,000-document case goes wrong after 6 searches. - How long. A wrong entry stays until LRU eviction, an explicit
clearQuery,clearCoreCacheKeyorclear(LRUQueryCache.java:443-457), or until its segment core closes, after a merge has replaced the segment and the last reader holding it is closed (:1133-1135). A poisoned timeout entry survived an NRT reopen with an unrelated deletion (0 instead of 11,999) in the security probe. - What you see. No error, assertions on or off. Fewer hits or a lower count (live docs, timeout), or a document the filter rejects (filter); as a MUST_NOT clause, documents the clause should exclude (1 hit instead of 0; 100 instead of 0 after a timeout). It is not approximate recall: the exact path (decay 1) is affected and separate caches are right.
- Downstream (source reading only, nothing run there): OpenSearch's k-NN plugin builds these queries for radial search (already in 2.x releases); since 3.7.0 the server also sets
IndexSearcher#setTimeout, and it uses a node-levelLRUQueryCache, so the timeout case may reach its users on 3.7.0 and later. Solr's searcher disables Lucene's query cache; a code search found no use of these queries in Elasticsearch.
The test shows that FloatVectorSimilarityQuery says its per-segment result is cacheable, but the result depends on live docs, so a shared query cache gives an older reader the result of a newer reader that deleted a document (same segment core).
/*
* 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.KnnFloatVectorField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.NoMergePolicy;
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;
import org.apache.lucene.tests.util.TestUtil;
public class TestVectorSimilarityQueryCache extends LuceneTestCase {
public void testOlderReaderAfterNewerReader() throws Exception {
try (Directory dir = new ByteBuffersDirectory();
IndexWriter w =
new IndexWriter(
dir,
new IndexWriterConfig()
.setCodec(TestUtil.getDefaultCodec())
.setMergePolicy(NoMergePolicy.INSTANCE))) {
for (String id : new String[] {"0", "1"}) {
Document doc = new Document();
doc.add(new StringField("id", id, Field.Store.NO));
doc.add(new KnnFloatVectorField("v", new float[] {1, 0}));
w.addDocument(doc);
}
try (DirectoryReader older = DirectoryReader.open(w)) {
w.deleteDocuments(new Term("id", "0"));
try (DirectoryReader newer = DirectoryReader.openIfChanged(older, w)) {
QueryCache cache = new LRUQueryCache(1000, 1 << 24, _ -> true, Float.POSITIVE_INFINITY);
Query query =
new FloatVectorSimilarityQuery.Adaptive("v", new float[] {1, 0}, 0f, 1f, null);
assertEquals(1, searcher(newer, cache).count(query));
// doc 0 is live in the older reader (same segment core): expected 2, actual 1
assertEquals(2, searcher(older, cache).count(query));
}
}
}
}
private static IndexSearcher searcher(IndexReader reader, QueryCache cache) {
IndexSearcher searcher = new IndexSearcher(reader);
searcher.setQueryCache(cache);
searcher.setQueryCachingPolicy(
new QueryCachingPolicy() {
@Override
public void onUse(Query query) {}
@Override
public boolean shouldCache(Query query) {
return true;
}
});
return searcher;
}
}
# in a copy of this finding's repro folder (repro/LFV-106/, 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.TestVectorSimilarityQueryCache
- Expected vs actual
- Expected
count2 on the older reader, where doc 0 is still live; actual 1 (expected:<2> but was:<1>), 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:<2> but was:<1>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:647)
org.junit.Assert.assertEquals(Assert.java:633)
org.apache.lucene.search.TestVectorSimilarityQueryCache.testOlderReaderAfterNewerReader(TestVectorSimilarityQueryCache.java:58)SegmentCacheable: only objects that "depend only on segment-immutable structures" may return true. Live docs, a timeout and an updated filter are not.- The cache key ignores deletions and doc-values updates by contract (
LeafReader#getCoreCacheHelper). - The exact path, documented "to guarantee completeness", is affected too;
TermQueryand separate caches are right in the same sequences.
The full argument341 words
- The contracts.
SegmentCacheable.java:24-35: "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 ... Updated doc values fields are not suitable for caching"; "Objects that are not segment-immutable ... should return false".LeafReader.java:65-69: the core cache helper caches "regardless of deletions"; readers with "different sets of deleted documents or doc values updates may be considered equal".QueryCache.java:26-30: the returned weight "will only be equivalent if scores are not needed".LRUQueryCache.java:893: "Our cache won't have an accurate count if there are deletions", so cached sets are meant to be computed without applying deletions. - Upstream tests.
testTimeoutturns the cache off: "This query is cacheable, explicitly prevent it" (BaseVectorSimilarityQueryTestCase.java:623-624, added by #13285; copied intoBaseExplicitVectorSimilarityQueryTestCase.java:503-504by #16654). That avoids only the harmless direction, a cached full result hiding a timeout. The test framework installs query caches at random (LuceneTestCaseParent.java:2635,:2686), which is why the test must turn the cache off; the upstream similarity-query tests never reopen a reader, update doc values, or share one cache across readers or searchers.TestLRUQueryCacheasserts that cached counts equal uncached ones acrossSearcherManagerrefreshes with deletions: the repro's oracle. - History. #12679 (2023) added the query with
isCacheablereturningtrue; #13285 (2024) added the timeout input; neither thread discusses query caching. The query's author later commented, in another contributor's open PR #13594, that "the similarity query is cacheable" as a design difference from the top-k query, and that top-k kNN queries are not cacheable because deletes invalidate per-segment results (#15379): the same principle, applied to the other family. - Precedent. LUCENE-8017 (#9065):
FunctionRangeQueryandFunctionMatchQuerycould "pollute the QueryCache" and were made non-cacheable, which led to theSegmentCacheablecontract (LUCENE-8042, #9090). In LUCENE-10060 (#11098) jpountz wrote that the cache keys on the segment core, "everything but live docs and doc-value updates", which "requires cache entries to be computed on the entire segment and follow-up searches to apply deletes on top of cache entries".
The devil's-advocate check295 words
The strongest case for "intended" (built by a devil's-advocate verifier, an OpenAI GPT-5.6 reviewer arguing "works as designed" and the adjudication): the author says the query is cacheable by design (#13594); the upstream timeout test knowingly disables the cache; the classes are @lucene.experimental and search "all (approximate) vectors"; Weight#scorer only says live docs "need to be checked on top", which does not forbid a Weight from applying them; and timeouts deliberately allow partial results, so an application could be expected to turn off or partition caching for searches with a timeout.
Why it fails: "cacheable" claims that caching is legal, not that serving one reader's or search's result to another is right, and no javadoc, CHANGES, MIGRATE entry or PR comment accepts that; the test comment covers only the harmless direction and says nothing about live docs or filters; the exact path promises completeness and separate caches give the right answer, so this is cross-reader reuse, not approximation; applying live docs is fine for an uncached Weight, the bug is applying them and also saying true; @lucene.experimental does not permit wrong results. The timeout part is the strongest: no javadoc says setTimeout and the cache must not be combined, and turning the cache off in a test does not accept that later searches reuse a partial result, but a maintainer could prefer documenting it or skipping the cache when a timeout is set (fix B does the latter). The case also misses scoring parents: as a FILTER or MUST_NOT clause the stale set is used too. The GPT-5.6 reviews rated the intended argument weak, and moderate for the timeout part only; the independent reproduction and the intended-behaviour check gave no rating but found none that holds; none could apply it to the live-docs part.
Recommended B: cacheable only without deletions, without a timeout and with a cacheable filter
Let isCacheable say true only when the cached set cannot differ between readers: no deletions in the segment, no searcher timeout, and a cacheable filter (+6/-1). All 5 tests, every earlier check and 139 tests in 11 suites pass, and the safe case is still cached. The one-line return false also passes every bug test but never caches.
@Override
public boolean isCacheable(LeafReaderContext ctx) {
// The per-segment result is computed with the reader's live docs, the searcher's timeout
// and the filter. The query cache keys entries on the segment core, which ignores
// deletions and doc-values updates, so only cache a result that depends on none of them.
return ctx.reader().getLiveDocs() == null
&& queryTimeout == null
&& (filterWeight == null || filterWeight.isCacheable(ctx));
}All options and trade-offs4 options ยท 937 words
The Weight must stop claiming that a set built from per-reader and per-search inputs is shareable. Two options were built and validated in a scratch worktree at ea57360912ed (removed afterwards); the others are untested or partial.
B: cacheable only without deletions, without a timeout and with a cacheable filter (validated by the checks) (recommended)
In AbstractVectorSimilarityQuery's Weight, isCacheable(ctx) returns ctx.reader().getLiveDocs() == null && queryTimeout == null && (filterWeight == null || filterWeight.isCacheable(ctx)). A cached set is then always computed with every document live, no timeout and a segment-immutable filter, so it is the same for every reader sharing the core, and each reader applies its own live docs on top as for any other query. A reader with deletions, a searcher with a timeout or a non-cacheable filter neither reads nor writes the entry. +6/-1, one file.
@Override
public boolean isCacheable(LeafReaderContext ctx) {
// The per-segment result is computed with the reader's live docs, the searcher's timeout
// and the filter. The query cache keys entries on the segment core, which ignores
// deletions and doc-values updates, so only cache a result that depends on none of them.
return ctx.reader().getLiveDocs() == null
&& queryTimeout == null
&& (filterWeight == null || filterWeight.isCacheable(ctx));
}Trade-offs
- Segments with deletions never cache this query, and nor does a searcher with a timeout, so NRT indexes with deletes lose part of the caching benefit. No benchmark was run.
queryTimeout == nullis conservative: a timeout that never fires still skips the cache.- Letting readers with deletions also read an entry filled by a reader without them would be exact for decay 1, but
isCacheablegoverns reading and writing alike, so that needs a change beyond this method. - Open draft #15802 by the query's author rewrites this Weight (keeping
isCacheable); a PR may need to coordinate with it. - Assumes readers, filters and query parts that keep Lucene's cacheability and core-key contracts, and immutable query state. Main also accepts a caller-supplied
KnnSearchStrategy(FloatVectorSimilarityQuery.java:58-64); itsSeededvariant holds aDocIdSetIteratorthat the graph search consumes (KnnSearchStrategy.java:107-171,SeededHnswGraphSearcher.java:37-52). No test covers such strategies or custom reader wrappers.
What the checks validated
- This doc's test: 5 of 5 pass, assertions on and off, through gradle and the harness.
- The 11 nearest upstream suites (
Test*VectorSimilarity*,TestLRUQueryCache,TestUsageTrackingFilterCachingPolicy): 139 tests, 0 failures, 8 skipped, assertions on and off. - The caching probe passes: exact, approximate and filtered queries are still cached and hit on a segment without deletions.
- Every earlier hunt test of this bug, assertions on and off: the finder's 3, the independent reproducer's 9 (Float, Byte, Float16, filtered, timeout, release defaults), the devil's advocate's 9 (controls, soft-deletes view, updated doc-values filter), the security probe's 6 and the GPT-5.6 reviewers' 4 each.
:lucene:core:check -x testpasses (google-java-format, ecjLint, forbiddenApis).tidycompleted (its log does not record which files it changed); the final patch and the test passcheckGoogleJavaFormatagain in the fact check's re-run.- The scoring-parent probe (FILTER and MUST_NOT clauses of a scoring query): 4 of 4, assertions on and off.
Replace return true; with return false; and a comment naming the three inputs. +3/-1. The filter query itself is part of the cache key (equals/hashCode, AbstractVectorSimilarityQuery.java:273, :279); its per-reader matches are not. It was validated first by the finder, the independent reproducer, the devil's advocate, the security probe and the GPT-5.6 reviewers, and endorsed by the adjudication, who did not re-run it.
@Override
public boolean isCacheable(LeafReaderContext ctx) {
// The result depends on the reader's live docs, the searcher's timeout and the filter's
// per-reader matches, none of which the cache key (query + segment core) captures.
return false;
}Trade-offs
The query is never cached, even where that is safe, so every reuse as a filter or count repeats the vector search per segment (an exact scan for decay 1). It contradicts the author's stated intent that the query be cacheable (#13594).
What the checks validated
- This doc's test 5 of 5 and the same 139 upstream tests pass, assertions on and off;
:lucene:core:check -x testpasses; with the reworded comment, google-java-format, ecjLint and forbiddenApis pass again. The caching probe fails, as expected. - Re-run on main with the reworded comment, assertions on and off: the finder's 3, the independent reproducer's 9, the security probe's 6, both reviewers' 4 each and the scoring-parent probe's 4 pass. The devil's advocate's 9 give 8: its reverse-direction control first asserts that the query was cached, which this fix rules out (the same expected failure as the caching probe).
- Earlier: 137 upstream tests with the finder's version, and the history probe on main and 9.12.3.
Build the per-segment set without live docs and let collection apply them, as other queries do; still refuse caching with a timeout or a non-cacheable filter.
Trade-offs
Keeps caching on segments with deletions, but changes the approximate path: the graph search already visits and scores rejected nodes and checks the accepted docs only before collecting one (AbstractHnswGraphSearcher.java:98-104, HnswGraphSearcher.java:328-349), so without live docs deleted documents would also be collected and dropped only later, and the filtered path's accepted count and its exact fallback would count them. Needs recall and latency checks.
Asking filterWeight.isCacheable(ctx), or refusing to cache after a timeout, fixes one input and leaves the other two.
Trade-offs
The finder's first alternative left out the filter; a verifier and the adjudication caught it.
Why: the cache key ignores deletions and doc-values updates, so whichever search fills the entry, even a timed-out one or one on a restricted view, decides what others get. Hidden or deleted documents never leak. A filter on in-place-updated doc values fails open, and one timed-out search can empty a MUST_NOT exclusion for later searches. No exploit known.
Code reading. An AI tool compared what each vector query's per-segment result depends on with the query-cache key (query plus segment core), saw that this query reads live docs and the timeout while isCacheable returns true, and confirmed it with a JUnit test on the stock jar. A small TLA+ model written afterwards illustrates the mechanism.
The method in full160 words
Method: code reading (the hunt's non-formal tier).
- A hunt unit (the kNN query layer, 2026-10-25). The finder, an AI tool, compared for each vector query Weight the inputs of its per-segment result with what the
LRUQueryCachekey contains.AbstractVectorSimilarityQuery'sscorerSupplierreads the reader's live docs and the searcher's timeout, whileisCacheablereturnstrue. - Public-API probes and a deterministic JUnit test on the stock jar then showed both the live-docs and the timeout cases.
- A small TLA+ model written afterwards (two readers sharing a core, a timeout flag; invariant
Correct) is violated withCacheable = TRUEand holds withCacheable = FALSE. It illustrates the mechanism; its files are timestamped after the finding, so it was not the first signal. - An independent reviewer from another model family (OpenAI GPT-5.6) then tried to refute it; verifiers and an adjudication confirmed it, one of them adding the soft-deletes and doc-values filter triggers; the history, prior-report and security checks followed.
No prior report found (2026-10-25/26): 186 prior-art-tool queries over GitHub, commits, downstream code, mailing lists and JIRA, 17 manual GitHub searches, 13 web queries, and the file lists of all 412 open PRs. Closest: the upstream test comment "This query is cacheable, explicitly prevent it" and the author's remark that it is cacheable by design.
Searches6 searches
The searches found no upstream issue, PR, commit, mailing-list thread or JIRA entry that reports this bug, and no PR that fixes it. GitHub search reads only the first page per query, so "none found" is not proof that none exists. One downstream review remark (below) is the only item that could make the novelty "partially known" if read differently; the follow-up counts it as not a report.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene and downstream repositories) | 2026-10-25/26 | 70 queries from the scripted prior-report search in 4 runs (class and method names such as AbstractVectorSimilarityQuery, VectorSimilarityScorerSupplier, isCacheable, getLiveDocs; caching framing such as "pollute the query cache", "core cache key", SegmentCacheable; timeout and filter framing), plus 17 manual read-only searches and REST reads of 54 items | no report; the feature PRs #12679, #13285 and later, and the related remarks below |
| Closest traces (read in full) | 2026-10-26 | #13285 and its testTimeout comment; #13594 and #15379 (the author's remarks); dev@ "Cacheable DocAndScoreQuery in AbstractKnnVectorQuery" (2024-07-19); opensearch-project/k-NN#3300 | none is a report: a test workaround, design remarks, a question about another class, and a passing downstream review remark that a radial-search wrapper weight "technically" should not be cacheable, naming no Lucene class or mechanism |
| Open PRs | 2026-10-26 | the file lists of all 412 open apache/lucene PRs | 5 touch the file (#13594, #13595, drafts #15802, #16204, #16847); none changes isCacheable |
| Commits and downstream code | 2026-10-26 | 12 commit queries, the file's history on main, branch_10x and branch_10_6; 8 downstream-repo queries and code search | no fix; OpenSearch k-NN (radial search) and Solr use the queries, Solr with Lucene's query cache disabled |
| Mailing lists and JIRA archive | 2026-10-26 | 80 queries over dev@, java-user@, issues@ and commits@; 16 JIRA-archive queries (LUCENE and SOLR) | no report; precedents LUCENE-8017, LUCENE-8042, LUCENE-8062, LUCENE-10060 |
| Web | 2026-10-26 | 13 queries (1 more throttled) | no relevant page |
There since the query was added by #12679 (merged 2023-12-11, first released in 9.10.0); the timeout input came with #13285 (merged 2024-08-06, first released in 10.0.0). All 19 releases 9.10.0-10.5.2 and main fail, and each case flips exactly at its introducing commit. Not a regression; not fixed upstream.
Versions tested8 rows
Introduced by #12679, "Add support for similarity-based vector searches" (kaivalnp, merged by benwtrent on 2023-12-11 as cd195980ecb on main; backport 8089b8c9d33), with isCacheable returning true and a per-segment result built from live docs and the filter. #13285, "Add timeout support to AbstractVectorSimilarityQuery" (kaivalnp, merged by vigyasharma on 2024-08-06 as e0e5d81df80, main only), added the timeout input without changing isCacheable. CHANGES.txt lists #13285 under 9.12.0, but no 9.x release has its code (the entry came with the #13633 backport), so the timeout case starts at 10.0.0. #14187 (2025-04-02, main only) removed IndexSearcher's default query cache, so on main an application must install one. branch_10x, branch_10_6 and branch_9_12 still return true (read).
| Version or commit | Verdict | Result |
|---|---|---|
| 9.9.2 | class absent | no FloatVectorSimilarityQuery |
| 9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3 | no timeout case | live docs, filter and zero-configuration cases fail; the timeout case passes (the 9.x query never reads the timeout) |
| 10.0.0-10.4.0 (9 releases) | affected | all cases fail, also with the default IndexSearcher cache and no configuration |
| 10.5.0, 10.5.1, 10.5.2 | affected | all cases fail; this doc's test 4 of 5 on 10.5.2, assertions on and off |
main ea57360912ed | affected | all cases fail with an installed cache (no default cache since #14187); this doc's test 4 of 5 |
cd195980ecb (#12679) vs its parent | commit level | the parent has no API; the commit fails the live-docs and filter cases |
e0e5d81df80 (#13285) vs parent 43c80117dd5 | commit level | the parent passes the timeout cases, the commit fails them |
| main and 9.12.3 + isCacheable false | root-cause control | every case passes |
A refute review from another model family (OpenAI GPT-5.6), an independent reproducer, a "works as designed" reviewer, a devil's advocate with two more triggers, and an adjudication all say real, each with its own test on main. This doc's test was re-run on main, on 10.5.2 and with both fixes; fact checks and a diagram check corrected the wording.
Checks and verdicts605 words
Every check ran on main ea57360912ed unless noted, with assertions on and off.
| Check | Verdict | What it found |
|---|---|---|
| Refute review (OpenAI GPT-5.6) | real, high confidence | Its own public-API count test fails 2 of 4 (an older reader's 6 matches become 5; 32 become 0 after a timed search) with three seeds; the separate-cache controls pass; isCacheable false passes its test and 79 focused upstream tests. Rated the intended case moderate, for the timeout part only. |
| Independent reproducer | real | Own test from the claim only, outside org.apache.lucene: 8 of 9 fail (Float exact and approximate, filtered, Byte, Float16, timeout with and without filter, release defaults); the TermQuery control passes; 7 of 8 fail on 10.5.2 and 10.0.0, 5 of 8 on 9.12.3 and 9.10.0 (timeout passes there). |
| "works as designed" reviewer (OpenAI GPT-5.6) | real, high; intended argument weak | Its own 64-doc test fails 2 of 4 (63 instead of 64, 0 instead of 64); the distinct-cache and uncached controls pass; 4 of 4 with the fix. |
| Devil's advocate | real; no intended argument holds | Controls (TermQuery, FieldExistsQuery, KnnFloatVectorQuery) pass on main and 10.5.2. Found two more triggers that fail on main and 10.5.2 and pass with the fix: a soft-deletes view next to the raw view (99 instead of 100), and the fail-open doc-values filter (100 instead of 99). |
| Adjudication | confirmed, high | Re-ran the independent reproduction with a fresh seed (8 of 9 fail), the finder's test (3 of 3) and the intended-behaviour check's filter probe (1 of 2 fail); corrected "fails closed" (the filter case fails open) and the narrower fix, which must cover all three inputs. |
| History and fix re-runs | reproduced; both fixes validated | The follow-up's probe ran on 9.9.2, all 19 affected releases, main and 4 commit-level builds. This doc's test fails 4 of 5 on main and 10.5.2 and passes 5 of 5 with either fix; fix B also passes every earlier hunt test and keeps the safe case cached. |
| Independent fact check | real; 2 wording errors and 9 precision items, applied | A separate AI tool re-ran the brief's gradle command (assertions on and off), the harness on main and 10.5.2 with three seeds, the test from a package outside org.apache.lucene, the diagram probe and fix B against 13 suites (145 tests, 0 failures). It found two wording errors (a hiding view does not empty the other view's result, it removes one document: 99 instead of 100; the adjudication endorsed fix A but did not run it) and nine precision items (Float16 is main only, the test framework installs caches, the attribution of #13594 and of the copied test comment, the OpenSearch version, and others). |
| Cross-model fact check | real; 16 items, all applied | An independent reviewer from another model family (OpenAI GPT-5.6) checked the brief against the code: 5 errors (the security route; scoring parents are affected through FILTER and MUST_NOT clauses; the filtered path falls back to exact search; the filter query is part of the cache key; the graph search already visits deleted nodes), 6 overclaims and 5 missing caveats. All were applied except the vendor note, which the hunt policy allows. Its MUST_NOT point was then executed: 1 instead of 0, and 100 instead of 0 after a timeout. |
| Diagram check | one label error, fixed | Every value, API name and line number in the PNG matched the code and the probe. One error: the summary card listed Float16 next to the release range, although it exists only on main. Fixed, with four wording suggestions and longer holds, and re-rendered. |
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 552 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,382 words
- Registry title
- Vector similarity queries report isCacheable() == true although their per-segment result depends on live docs, the search timeout and the filter, so a shared query cache serves one reader's or one search's result to another
- Area
- search / vector similarity queries and the query cache
- Priority
- High, rank 5: Released 9.10-10.6.0 (10.6.0 by reading) with the default shared query cache, a vector similarity query used as a filter or count can cache another reader's live-docs view, a timed-out result or a non-cacheable filter and keep serving wrong hits from ordinary point-in-time and timeout patterns, just below LFV-044 because the threshold vector query in a non-scoring context is a niche experimental API.
- Impact class
- silent wrong results
- Impact note
- Applications that run a Float/Byte/Float16VectorSimilarityQuery in a non-scoring context (IndexSearcher#count, ConstantScoreQuery, a FILTER clause) through a query cache shared by several readers or searchers, which on 9.10.0-10.5.2 the default IndexSearcher setup already is (a static LRUQueryCache; zero-configuration repro executed) and on main only an application-installed cache is (#14187), and that then search an older point-in-time reader (or another live-docs view of the same segment) after a newer one, or let a timed-out search fill the entry (10.0.0 and later), or pass a filter on in-place-updated doc values; ordinary scale (100 docs with an always-cache policy, 12,000 docs with the default cache settings); released 9.10.0-10.5.2 and, by reading, 10.6.0.
- Summary
- You search with a vector similarity (threshold) query, for example FloatVectorSimilarityQuery, in a non-scoring context such as IndexSearcher#count or ConstantScoreQuery, with a query cache that several readers or searchers share (the IndexSearcher default in 9.10.0-10.6.0, 10.6.0 by reading). You expect every reader and every search to get the hits it gets without the cache. Instead the cache hands one reader's or one search's per-segment result to another: an older point-in-time reader misses a document that only a newer reader has deleted (99 hits instead of 100), a search without a timeout gets the empty result of an earlier timed-out search (0 instead of 100), and with a filter on an updated doc-values field a newer reader gets a document that no longer matches the filter (100 instead of 99).
- Introduced
- cd195980ecb (#12679 'Add support for similarity-based vector searches', main; branch_9x backport 8089b8c9d33) for the live-docs and filter dependence: the query was added with isCacheable() returning true and a per-segment result built from leafReader.getLiveDocs() and the filter Weight [read: git blame at main, 10.5.2 and 9.12.3; exec: lucene-core built at the parent 1630ed4bd84 has no FloatVectorSimilarityQuery, at cd195980ecb the live-docs and filter scenarios fail and the timeout scenarios pass]; e0e5d81df80 (#13285 'Add timeout support to AbstractVectorSimilarityQuery', main only, never backported to 9.x) added the timeout dependence without changing isCacheable() [exec: its parent 43c80117dd5 passes the timeout scenarios, e0e5d81df80 fails them]; not a regression: isCacheable() has returned true since the query's first version (2023-12-11 (#12679 merged on main 19:18 UTC; branch_9x backport 19:43 UTC the same day); 2024-08-06 (#13285 merged on main))
- First affected version
- 9.10.0 [exec] (live docs and filter; first release with the query, 9.9.2 has no FloatVectorSimilarityQuery [exec]); 10.0.0 [exec] for the timeout manifestation
- Affected versions
- live docs and non-cacheable filter: all 19 releases 9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2 and main ea57360912ed [exec, -ea; -da also on 9.10.0, 9.12.3, 10.0.0, 10.5.2 and main, same result]; timeout: the 12 releases 10.0.0-10.5.2 and main [exec], not 9.x [exec; CHANGES.txt lists #13285 under 9.12.0, but no 9.x release has its code, read]; with zero cache configuration every release 9.10.0-10.5.2 is affected, because IndexSearcher installs a shared static LRUQueryCache with UsageTrackingQueryCachingPolicy [exec], main only when the application installs a query cache (#14187 removed the default; in no release yet) [exec]; not applicable before 9.10.0 (API absent) [exec]; upstream main, branch_10x, branch_10_6 and branch_9_12 still return true [read, 2026-10-26], so 10.6.0 (released 2026-10-29) is affected too [read, not executed]; not fixed upstream [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
- non-formal: code reading; also reached by TLA+/TLC
- Upstream and prior reports
- none found (2026-10-25 and 2026-10-26: a scripted prior-report search, 4 runs with 70 GitHub issue/PR, 12 commit, 8 downstream-repo, 80 mailing-list (dev@, java-user@, issues@, commits@) and 16 JIRA-archive queries; 17 manual read-only gh issue/PR searches across apache/lucene and downstream repositories, gh code search, REST reads of 42 apache/lucene and 12 downstream items, changed-file lists of all 412 open PRs (5 touch the file, none changes isCacheable), lists.apache.org and LUCENE/SOLR JIRA searches, 13 web queries with 1 more throttled); related prior items: #13285 (introduced the timeout dependence; its test disables the cache with 'This query is cacheable, explicitly prevent it', with no discussion), #13594 (the query's author states that the similarity query is cacheable by design), #15379 (the author explains that top-k kNN queries are not cacheable because deletes invalidate per-segment results: the same principle, other query family), dev@ 2024-07-19 'Cacheable DocAndScoreQuery in AbstractKnnVectorQuery' (different class, unanswered), opensearch-project/k-NN#3300 (downstream review remark that a radial-search weight 'technically' should not be cacheable, no mechanism, nothing filed), #12679 (introduced the query, no cache discussion), open draft #15802 (rewrites the Weight, isCacheable unchanged), precedents #9065 (LUCENE-8017), #9090 (LUCENE-8042), #9110 (LUCENE-8062) and #11098 (LUCENE-10060)
- Lucene commit tested
- main
ea57360912ed(2026-10-25/26), assertions on and off, seed DEADBEEF and the verifiers' own seeds, through gradle and a standalone JUnit harness; this doc's test also on the 10.5.2 release jars (assertions on and off); a version-independent probe on 9.9.2, all 19 releases 9.10.0-10.5.2 and main; lucene-core built at the two introducing commits and their parents; both fixes in a scratch worktree atea57360912ed(removed afterwards); a scoring-parent probe (FILTER and MUST_NOT clauses) on main and the 10.5.2 jars and with both fixes (2026-10-26).
- Finding record
a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/AbstractVectorSimilarityQuery.java:40 (@lucene.experimental), :149-152 (filterWeight), :154-156 (searcher.getTimeout(), TimeLimitingKnnCollectorManager), :186-188 (scorerSupplier reads leafReader.getLiveDocs()), :190-206 (no filter: exact path with DECAY_MAX_QUALITY 'to guarantee completeness', AcceptDocs.fromLiveDocs at :196 and :204), :207-220 (filter: AcceptDocs.fromIteratorSupplier(..., liveDocs, ...)), :239-243 ('Return partial results only when timeout is met'), :252-255 (isCacheable returns true)a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:24-35 ('Objects that depend only on segment-immutable structures ... can just return true'; doc-values dependents 'should return DocValues#isCacheable'; 'Objects that are not segment-immutable ... should return false')a Lucene main checkout lucene/core/src/java/org/apache/lucene/index/LeafReader.java:63-66 (core cache helper caches 'regardless of deletions'; readers with 'different sets of deleted documents or doc values updates may be considered equal')a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:816-834 (CachingWrapperWeight.scorerSupplier trusts in.isCacheable(context) and keys entries on the query and getCoreCacheHelper())a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/QueryCache.java:26-30 (doCache: the returned weight 'will only be equivalent if scores are not needed')a Lucene main checkout lucene/core/src/test/org/apache/lucene/search/BaseVectorSimilarityQueryTestCase.java:623-624 ('// This query is cacheable, explicitly prevent it', searcher.setQueryCache(null), added by #13285)a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/Weight.java:121-122 ('The returned Scorer does not have LeafReader#getLiveDocs() applied, they need to be checked on top'; describes scorer output, cited as supporting evidence only)
- The finding in 30 seconds
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/AbstractVectorSimilarityQuery.java:40, 146, 149-156, 186-255, 273, 279Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/LRUQueryCache.java:80, 816, 827, 834, 892-924Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/LeafReader.java:65-69Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/BooleanWeight.java:273-285; ConstantScoreQuery.java:193-194; DocAndScoreQuery.java:170, 294-306; IndexSearcher.java:84a Lucene clone tag releases/lucene/10.5.2 lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:83-99
- Who is affected
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/SearcherLifetimeManager.java:30, 48; UsageTrackingQueryCachingPolicy.java:122-138; LRUQueryCache.java:251-253, 278-296, 443-457, 792-807, 816-853, 1133-1135; IndexSearcher.java:979-981; BooleanClause.java:84-86; BooleanWeight.java:58-59
- Why it looks unintended
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/SegmentCacheable.java:24-35; QueryCache.java:26-30; LRUQueryCache.java:893; index/LeafReader.java:65-69
- Suggested fix
Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/hnsw/AbstractHnswGraphSearcher.java:98-104; HnswGraphSearcher.java:328-349; search/knn/KnnSearchStrategy.java:107-171; util/hnsw/SeededHnswGraphSearcher.java:37-52
- TestVectorSimilarityQueryCache.java (JUnit test):
repro/LFV-106/src/org/apache/lucene/search/TestVectorSimilarityQueryCache.java(79 lines)
A worked example on the same six documents as the steps above: the segment with both readers' live docs, the cache entry being filled by the newer reader and handed to the older one, the code lines that build the set and say true, the two other cases (timeout, doc-values filter), and a summary card (bug.png). Every number on screen comes from a probe run on ea57360912ed with assertions on and off.
assets/LFV-106/bug.png(552 KB)
![Summary card titled "Vector similarity queries: a shared query cache hands one reader's result to another" (docs 0-5 in one segment; doc 1 deleted in the newer reader only). Three steps: 1, q = new FloatVectorSimilarityQuery.Adaptive("v", new float[] {0, 1}, 0.15f, 1f, null) matches docs 0, 1, 2 (similarity 1.0, 0.5, 0.2; decay 1 = exact search); 2, newSearcher.count(q) = 2, right, and because isCacheable is true the shared cache stores {0, 2} under (q, core of segment _0); 3, oldSearcher.count(q): same query, same core, a cache hit, so the older reader gets {0, 2}. Expected: docs 0, 1, 2 = 3. Actual: docs 0 and 2 and an empty slot = 2; doc 1 is live in oldReader, yet missing. Notes: timeout (10.0.0 and later), a timed-out search caches an empty set and a later search without a timeout gets 0, not 3; filter on doc values (9.10.0 and later), doc 2 updated in place to fail the filter is still returned, 3, not 2; controls (TermQuery in the same order, separate caches, isCacheable returning false) are right. Why box: scorerSupplier builds the set from getLiveDocs(), the searcher's timeout and the filter, yet isCacheable returns true; the cache key is the query plus the segment core, which ignores deletions and doc-values updates. Affected: 9.10.0 to 10.5.2 and main (timeout case 10.0.0 and later); in 9.10.0 to 10.5.2 a plain new IndexSearcher(reader) already shares a default LRUQueryCache (it caches after repeated use, on segments of 10,000+ docs), on main only an installed cache does; ByteVectorSimilarityQuery (9.10.0 and later) and Float16VectorSimilarityQuery (main only) use the same Weight.](../assets/LFV-106/bug.png)