All findings · Lucene finding · LFV-916 · vector-similarity-query-visit-hides-filter

Vector similarity queries never visit their filter, so its clauses escape the cumulative clause limit

FloatVectorSimilarityQuery and ByteVectorSimilarityQuery run their filter but never show it to a QueryVisitor, so IndexSearcher's clause limit does not count it: a query of 1,200 term clauses runs at the default limit of 1,024, while the same clauses in a FILTER clause are rejected. Hits stay correct.

Summary

Priority
Low, rank 737The filter of the niche vector similarity queries is also hidden from the clause limit and term collection while hits stay correct, the same QueryVisitor-hiding class as LFV-917 on a narrower API, so it moves next to that row for consistency.
Impact
minor
Since
9.10.0 (#12679, 2023)introduced 2023-12-12 · about 2 years
Affected
9.10.0 to 10.6.0 and main
Status
documented
Found by
semi-formal: bounded-exhaustive check of the clause limit against a model, then a public-API JUnit

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

A similarity-threshold vector query can carry a filter, which it runs. But its visit() only says "I am a leaf", so the clause-counting visitor never sees the filter's clauses. The filter is checked only on its own, so the clauses of several filters are never added up.

Background

  1. QueryVisitor. Every Lucene query has a visit(QueryVisitor) method that should walk the whole query tree: "Recurse through the query tree, visiting any child queries." Tools use it to collect terms, estimate memory or count clauses.
  2. The clause limit. Before searching, IndexSearcher walks the rewritten query with a counting visitor and throws TooManyNestedClauses when the total goes past maxClauseCount (1,024 by default; strictly, at the 1,026th counted clause), however the clauses are nested.
  3. Vector similarity queries. FloatVectorSimilarityQuery and ByteVectorSimilarityQuery (@lucene.experimental, since 9.10.0) return every document whose vector is similar enough to a target. An optional filter, any Query, restricts which documents qualify.
Diagram of the bug: BooleanQuery(MUST a 600-term disjunction, MUST FloatVectorSimilarityQuery with a 600-term filter), 1,200 term clauses against the default maxClauseCount of 1,024. IndexSearcher's clause-counting visitor counts the 600 terms and the similarity query as one leaf, 601, and never visits the filter, so the search runs; createWeight later checks the filter alone, 600. The same terms as a sibling FILTER clause count 1,201 and throw TooManyNestedClauses, and with the fix the similarity query's visit enters the filter, counts 1,201 and throws too.
The repro's query tree: the clause-counting visitor counts the 600 terms and the similarity query as one leaf (601 of 1,024) and never enters the filter, checked later on its own (600 of 1,024); with the fix it counts 1,201 and throws. Byte sizes are the query cache's sizeOf(query, 32); the term count is over the whole query. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build a BooleanQuery with two MUST clauses: a disjunction of 600 term queries, and a FloatVectorSimilarityQuery whose filter is another 600-term disjunction. That is 1,200 term clauses. Search it with a plain IndexSearcher and the default maxClauseCount of 1,024.
  2. You expect TooManyNestedClauses, the limit on "total clauses cumulatively in all of its children". Move the 600 filter terms into a sibling FILTER clause and that is what you get: the check counts 1,201.
  3. Instead the search runs. The similarity query's visit() reports itself as one leaf and never visits its filter, so the check counts 601: the 600 terms plus 1.
  4. The filter still runs and is checked, but alone: createWeight rewrites it separately, against a fresh budget of 1,024. Each filter is capped, but filters never add up: 10 similarity queries with 1,000-term filters each are accepted.
  5. Other visitors miss it too: QueryVisitor.termCollector finds 0 of the filter's 600 terms, and RamUsageEstimator.sizeOf reports 1,024 bytes instead of 701,824. A 3-line fix to visit restores all three.

Who is affected

Applications that put a filter on the experimental vector similarity queries and count on maxClauseCount to bound the whole query, or that inspect such queries with a visitor (term extraction, a term-extracting highlighter, RamUsageEstimator.sizeOf). The default searcher and limit apply. Hits stay correct; the visible effect is a missing exception, so it goes unnoticed.

Mechanism, terms and numbers569 words

FloatVectorSimilarityQuery, ByteVectorSimilarityQuery and, on the unreleased 11.x line (main and branch_11x), Float16VectorSimilarityQuery (all @lucene.experimental) take an optional filter and execute it in createWeight, but the visit() they inherit from AbstractVectorSimilarityQuery only calls visitor.visitLeaf(this). IndexSearcher's cumulative clause check walks the rewritten query with a visitor, so it never counts the filter's clauses; the filter is checked only on its own, when createWeight rewrites it. A query of 600 term clauses plus a similarity query with a 600-term filter runs at the default maxClauseCount of 1,024, while the same 1,200 clauses with the filter as a sibling FILTER clause are rejected; ten similarity queries with 1,000-term filters run too. QueryVisitor.termCollector and RamUsageEstimator.sizeOf (on the 11.x line also the query cache's key estimate) miss the filter for the same reason. Search hits are correct. The gap is as old as the API: every release from 9.10.0 to 10.6.0 and main. A 3-line fix visits the filter through a FILTER sub-visitor.

The code (main 645d4fe14a20, AbstractVectorSimilarityQuery.java)

// :145-152 createWeight: the filter is executed, after a rewrite (and so a clause check) of its own
final Weight filterWeight =
    filter == null
        ? null
        : searcher.createWeight(searcher.rewrite(filter), ScoreMode.COMPLETE_NO_SCORES, 1);

// :259-264 visit: the whole query reports one leaf; the filter is never visited
public void visit(QueryVisitor visitor) {
  if (visitor.acceptField(field)) {
    visitor.visitLeaf(this);
  }
}
  1. The check. IndexSearcher.search rewrites the query first (IndexSearcher.java:750-754). rewrite (:871-880) rewrites until stable and then calls query.visit(getNumClausesCheckVisitor()) (:878). That visitor (:891-932) returns itself from getSubVisitor and counts one per visitLeaf, consumeTerms or consumeTermsMatching call. Each call first throws TooManyNestedClauses if the count is already above maxClauseCount, then adds one (:907-929), so at the default 1,024 the 1,026th counted unit throws. It counts visitor callbacks, not terms as such; for the repro's TermQuery disjunctions that is one per term.
  2. The filter stays in the tree. Neither AbstractVectorSimilarityQuery nor its subclasses override rewrite, so the rewritten query still holds the filter (protected final Query filter, :57). It is skipped only because visit does not descend.
  3. The filter's own check. searcher.rewrite(filter) in createWeight starts a new counting visitor, so a filter over the limit on its own still throws, but its clauses are never added to the rest of the query.
  4. Counted, executed probe. For the repro's query a replica of the check's visitor counts 601 (600 terms + 1 leaf); with the 600 filter terms as a sibling FILTER clause it counts 1,201; the filter alone counts 600. With the fix the repro's query counts 1,201 too. An independent fact check found the same counts on the rewritten tree and through IndexSearcher.rewrite's real acceptance threshold.
  5. Other visitors. QueryVisitor.termCollector (QueryVisitor.java:86-98) collects only from consumeTerms and ignores visitLeaf (:53-58): 0 of the filter's 600 terms. RamUsageEstimator.sizeOf walks visit and charges a lone leaf the default 1,024 bytes: 1,024 instead of 701,824. On the unreleased 11.x line only (main and branch_11x), LRUQueryCache sizes cached query keys this way, with sizeOf(query, 32) (LRUQueryCache.java:465-471, #15124): 32 bytes for the similarity query, 105,632 for its filter alone (executed); released 10.x charges such queries a fixed default. Caching is off by default.
  6. For comparison. ConstantScoreQuery.visit passes its query a FILTER sub-visitor (ConstantScoreQuery.java:76-79), and BooleanQuery visits FILTER clauses, so the same filter in either is counted and collected.
Exposure and likelihood206 words
  • Who. Users of FloatVectorSimilarityQuery, ByteVectorSimilarityQuery or the unreleased 11.x line's Float16VectorSimilarityQuery with a filter. No Lucene query parser builds them; the application calls the constructor. Downstream (read on default branches, not run): Solr's {!vectorSimilarity} parser passes the request's pre-filter or filter queries as this filter, and the OpenSearch k-NN plugin uses FloatVectorSimilarityQuery for radial search; Elasticsearch has its own similarity query.
  • Default vs opt-in. Opt-in: the @lucene.experimental similarity query with a filter. Default: codec, IndexSearcher, maxClauseCount 1,024.
  • What it takes. The total must pass the limit only through filter clauses, each filter staying within the limit on its own (600 + 600 in the repro). One filter above the limit is still rejected. Large id lists usually go into a TermInSetQuery, which counts as one clause, so real queries rarely get there (likelihood: rare).
  • Effect. A query of N filtered similarity queries can carry about N x maxClauseCount clauses (executed: 10 and 50 queries with 1,000-term filters accepted). Visitor-based tools under-report the query: no filter terms, a 1,024-byte size estimate, and on the 11.x line a query-cache key charge that ignores the filter (32 bytes). No error, no wrong hits, no index effect; the work stays proportional to the query the application built.

Minimal repro

One JUnit test, no index: an empty MultiReader and a plain IndexSearcher with the default limit of 1,024, because the check runs before any segment is searched. The query holds 600 term clauses plus a similarity query whose filter holds 600 more, and the test expects TooManyNestedClauses.

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

import org.apache.lucene.index.MultiReader;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestVectorSimilarityQueryFilterClauseCount extends LuceneTestCase {

  private static Query disjunction(String prefix, int n) {
    BooleanQuery.Builder b = new BooleanQuery.Builder();
    for (int i = 0; i < n; i++) {
      b.add(new TermQuery(new Term("f", prefix + i)), Occur.SHOULD);
    }
    return b.build();
  }

  public void testFilterClausesCountTowardsMaxClauseCount() throws Exception {
    Query query =
        new BooleanQuery.Builder()
            .add(disjunction("a", 600), Occur.MUST)
            .add(
                new FloatVectorSimilarityQuery.Adaptive(
                    "v", new float[] {1, 0}, 0f, disjunction("b", 600)),
                Occur.MUST)
            .build();
    IndexSearcher searcher = new IndexSearcher(new MultiReader());
    // expected: TooManyNestedClauses (1,200 term clauses > default maxClauseCount 1024); actual: the filter's 600 are not counted, no exception
    expectThrows(IndexSearcher.TooManyNestedClauses.class, () -> searcher.search(query, 1));
  }
}
Command
cp TestVectorSimilarityQueryFilterClauseCount.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/ && cd $LUCENE && ./gradlew :lucene:core:test --tests TestVectorSimilarityQueryFilterClauseCount -Ptests.seed=DEADBEEF
Expected
TooManyNestedClauses: 1,200 term clauses are more than the default maxClauseCount of 1,024, and the same clauses with a FILTER clause are rejected.
Observed
No exception ("Expected exception TooManyNestedClauses but no exception was thrown"), with assertions on and off: the check counted 601.

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

More files, environment, full output303 words

Public API plus LuceneTestCase, in package org.apache.lucene.search only to sit next to Lucene's own tests. new IndexSearcher rather than newSearcher keeps it deterministic, and an empty MultiReader mirrors TestMaxClauseLimit. Each part (600) is under the limit and the sum (1,200) far above it, so the counter's boundary rule plays no part.

It compiles against 10.5.2 and later, where FloatVectorSimilarityQuery.Adaptive exists. For 9.10.0 to 10.5.1 use the old constructor, new FloatVectorSimilarityQuery("v", new float[] {1, 0}, 0f, 0f, filter); the result is the same.

Regression test: this same test; it passes with the recommended fix and fits as one more method of TestFloatVectorSimilarityQuery or TestMaxClauseLimit. Formatting: google-java-format wraps its one long comment line, nothing else.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; also fails on 10.6.0 and 10.5.2; the finder's old-constructor release variant fails on 10.5.1, 10.0.0, 9.12.3 and 9.10.0
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the code path is platform-independent)
Command (environment)
cp TestVectorSimilarityQueryFilterClauseCount.java $LUCENE/lucene/core/src/test/org/apache/lucene/search/
cd $LUCENE
./gradlew :lucene:core:test --tests TestVectorSimilarityQueryFilterClauseCount -Ptests.seed=DEADBEEF
# assertions off (production-like): add -Ptests.asserts=false

Observed output

# gradle, stock main 645d4fe14a20, assertions on, seed DEADBEEF (stack frames and environment lines dropped)
TestVectorSimilarityQueryFilterClauseCount > testFilterClausesCountTowardsMaxClauseCount FAILED
    junit.framework.AssertionFailedError: Expected exception TooManyNestedClauses but no exception was thrown
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:2DC7ECFDF571A694]:0)
:lucene:core:test (FAILURE): 1 test, 1  failure

# the same with assertions off: 1 test, 1 failure; with the fix: 1 test, 0 failures (on and off)
# the repro runner: fails on main (on, off), on the 10.6.0 and 10.5.2 jars (off)

# supporting probe (not the repro), stock main vs the fix:
                                                         stock      with the fix
clause-check count, BQ(MUST a600, MUST simQ(filter=b600))   601        1201
clause-check count, BQ(MUST a600, FILTER b600, MUST simQ)   1201       1201
clause-check count, the filter b600 alone                   600        600
termCollector over simQ(filter=b600)                        0 terms    600 terms
RamUsageEstimator.sizeOf(simQ(filter=b600))                 1024       701824

Why it looks unintended

  • Query#visit should visit "any child queries", and the limit counts clauses "cumulatively in all of its children"; the filter it runs acts as one.
  • LUCENE-8811 made the limit a visitor walk so splitting a query cannot evade it; LUCENE-8849 treated this gap as a bug.
  • kNN queries consume their filter in rewrite; this one keeps it in the checked tree.
The full argument318 words
  • The contract. Query.java:88-93: "Recurse through the query tree, visiting any child queries." QueryVisitor.java:53-58: visitLeaf is "Called by leaf queries that do not match on terms". IndexSearcher.java:1193-1199: TooManyNestedClauses is "Thrown when a client attempts to execute a Query that has more than getMaxClauseCount() total clauses cumulatively in all of its children"; the check's javadoc (:891-895) speaks of "the total number of clauses that a query and its children cumulatively have".
  • The design. CHANGES LUCENE-3041 (lucene/CHANGES.txt:6469-6473): queries should "either pass the visitor down to any child queries, or call a visitX() or consumeX() method on it". LUCENE-8811 (:4681-4684): the clause checks "are now implemented using a QueryVisitor and apply to all queries"; MIGRATE.md:1533: "max clause count checks on all types of queries". On #9855 (LUCENE-8811) Adrien Grand wrote that it "removes ways to by-pass the maximum clause count by splitting boolean queries into multiple sub boolean queries".
  • Precedent. LUCENE-8849 "DocValuesRewriteMethod.visit wasn't visiting its embedded query" (CHANGES.txt:5800; its fix also chose a FILTER sub-visitor, but never passes it on to the wrapped query, DocValuesRewriteMethod.java:73-77, which is the registry's LFV-038) and LUCENE-9050 (:6163-6164) were treated as bugs. On the open #14756, Luca Cavanna wrote "for the purpose of visiting, we should visit all variants".
  • No recorded intent. The leaf-only visit was copied from AbstractKnnVectorQuery in #12679; no comment or review there mentions visit, QueryVisitor or maxClauseCount. No test covers it; TestMaxClauseLimit.testLargeDisjunctionMaxQuery asserts the nested walk for another composite.
  • How strong the case is. Persuasive, not conclusive: LUCENE-3041 lets a query "call a visitX() or consumeX() method" instead of descending, so a leaf callback is allowed as such, and Query#visit does not define whether a filter held by a query is one of its children. What no contract allows for is an executed child that no visitor sees; maintainers may still consider vector queries opaque by design.
The devil's-advocate check177 words

The strongest case (the intended-behaviour verifier, weighed by the adjudication): every Lucene vector query is an opaque visitor leaf; ToParentBlockJoinQuery has a leaf visit and, when no child scores are needed, an inner rewrite of its child with its own budget (ToParentBlockJoinQuery.java:90-92, 98-108; with child scores no separate check runs, :109-121), and that branch is accepted the same way; the filter is still checked on its own; and maintainers called the limit approximate (Alan Woodward on LUCENE-8811: "it wouldn't be exact").

Why it fails: the kNN queries use up their filter inside rewrite (AbstractKnnVectorQuery.java:74-97), so no visit could expose it to the check; this query keeps the filter in the checked tree. Block-join children match other documents, which is why they stay out of term extraction; this filter matches the same documents, like a FILTER clause. "Approximate" was said about counting rules, not about skipping executed sub-queries, and nothing documents the exclusion. Maintainers could still choose to keep vector queries opaque and document it; that is why the adjudication's confidence is medium.

Suggested fix

Recommended Visit the filter through a FILTER sub-visitor

In AbstractVectorSimilarityQuery.visit, also visit the filter through a FILTER sub-visitor, as ConstantScoreQuery does (3 lines). The repro then throws as expected; the 8 closest suites and all 2,340 core search-package tests pass. A behaviour change: oversized queries that run today will throw, so maintainers may want it on the 11.x line only, not in 10.x.

if (visitor.acceptField(field)) {
  visitor.visitLeaf(this);
}
if (filter != null) {
  filter.visit(visitor.getSubVisitor(BooleanClause.Occur.FILTER, this));
}
All options and trade-offs3 options · 609 words

Option A was built and run through gradle. B (rewrite the filter in place) needs the same visit change and more code; C (document the gap) leaves the contract broken. Workarounds that need no fix: count the filter's clauses yourself before searching, keep large id filters in one TermInSetQuery, or set a QueryTimeout. Not part of this fix: the kNN queries' visit, which cannot help their clause check because they consume the filter in rewrite.

Visit the filter through a FILTER sub-visitor (validated by the checks) (recommended)

After the leaf call, if (filter != null) filter.visit(visitor.getSubVisitor(BooleanClause.Occur.FILTER, this)); in the shared base class, so Float, Byte and Float16 all get it. FILTER because the filter restricts matching without scoring, like a FILTER clause; it is visited whatever acceptField(field) says about the vector field, since its own leaves check their own fields. LUCENE-8849 also chose a FILTER sub-visitor (its fix never passes it on, see LFV-038).

if (visitor.acceptField(field)) {
  visitor.visitLeaf(this);
}
if (filter != null) {
  filter.visit(visitor.getSubVisitor(BooleanClause.Occur.FILTER, this));
}

Trade-offs

  • Behaviour change. A query whose filters take the cumulative count over the limit now throws TooManyNestedClauses, as other composites do. The code change is limited to visit, so matching is expected to stay the same and queries under the limit keep their hits; visitor consumers and query-cache accounting change (next point).
  • Visitors see the filter. Term collectors and term-extracting highlighters now get the filter's terms (600 instead of 0 in the probe), as they do for a FILTER clause; RamUsageEstimator.sizeOf gives 701,824 bytes instead of 1,024, and on the 11.x line the query cache's sizeOf(query, 32) gives 105,632 instead of 32 (executed; no cache run, so admission and eviction were not observed).
  • The similarity queries become stricter than the kNN queries, whose filter keeps a separate budget because rewrite consumes it.
  • The filter is still also rewritten and checked on its own in createWeight (a cheap second walk, left unchanged). Not benchmarked.

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (OpenJDK 25.0.3, seed DEADBEEF): stock, the minimal test fails with assertions on and off; with the fix it passes on and off.
  • The closest suites (8 classes: the Float, Byte, Float16 and Explicit similarity-query tests, TestVectorSimilarityCollector, TestMaxClauseLimit, TestQueryVisitor), assertions on and off: 8 classes, 97 tests, 0 failures (3 skipped).
  • Every test class of org.apache.lucene.search in :lucene:core, assertions on: 215 classes, 2,340 tests, 0 failures (49 skipped).
  • :lucene:core ecjLint and forbiddenApis (main and test) with the fix and the test: pass.
  • google-java-format passes on the fix and asks only to wrap the test's one long comment line; with that line wrapped it passes too.
  • Earlier: the finder's 2 tests and the 34 tests of the Float and Byte similarity-query suites pass with it; the independent reproducer's own identical fix passes its 7 tests; on 9.10.0 the fix passes the old-API variant.

Also rewrite the filter in place in rewrite()

Override rewrite to return a new similarity query with the rewritten filter, and let createWeight use it without its own searcher.rewrite.

Trade-offs

Removes the second clause walk and makes the post-rewrite tree honest, but still needs the visit change, and touches equals/hashCode (query-cache keys) and every subclass. More change for no extra correctness. Not run.

Document that the filter is not visited

Say in the class javadoc that the filter's clauses are checked only on their own and that visitors do not see the filter.

Trade-offs

A legitimate choice for consistency with the kNN queries, but it leaves the contradiction with Query#visit and the cumulative limit, and visitor tools still miss the filter. Not run.

Downstream note

Applications that rely on maxClauseCount alone to bound user-built filters inside vector similarity queries are not protected and should bound those filters themselves.

How it was found

A hunt unit compared IndexSearcher's clause limit with a model of the clauses each query really executes, over 1,440 small queries built from 16 wrapper queries. The similarity query with a filter was under-counted in 35 of its 90 checks; a bytecode scan showed its filter is never visited, and a public-API JUnit confirmed it.

The method in full211 words

Method: semi-formal (the hunt's tier for bounded-exhaustive checks of the real jar against a reference model). The hunt unit's lens was queries that execute an embedded query but hide it from QueryVisitor.

  • Differential check. HiddenSubqueryTV built every combination of 16 wrapper sites x primary leaves {1, 2, 4} x hidden leaves {1..10} x copies {1, 2, 3} (1,440 checks at maxClauseCount 8) and compared IndexSearcher's verdict with a model that counts the clauses each query executes, with assertions on and off. The site BQ(MUST P, MUST FloatVectorSimilarityQuery(filter = H)) was under-counted in 35 of its 90 checks. The BooleanQuery, ConstantScoreQuery and DisjunctionMaxQuery controls had 0 mismatches in 270, and the model also caught the known IndexOrDocValuesQuery over-count, so it is not vacuous.
  • Bytecode scan. A JDK ClassFile scan over 142 concrete Query classes in 17 module jars flagged AbstractVectorSimilarityQuery.filter as a field that is executed but never visited (an earlier unit's scan had the same row, triaged there for matching only).
  • Reduction. The finding was reduced to a public-API JUnit (float and byte), later minimized to the one-test repro above. Not claimed: the kNN queries and RescoreTopNQuery show the same count gap in the harness, but consume their sub-query in rewrite, so no visit change could help there.

Prior reports, history and checks

Prior reports

No prior report found: 54 tool queries and 38 manual GitHub searches on 2026-10-28 (issues, PRs, commits, mailing lists, the JIRA archive, downstream projects, the web), a scan of all 411 open PRs, and a GitHub re-check on 2026-10-31. Closest: LUCENE-8849, the same gap treated as a bug in another class.

Searches3 searches
  • Nearest items, none a report: #12679 (the origin; its review never discusses visit); #9855 (LUCENE-8811, the cumulative check and its intent); #14756 and open PR #16175 (the opposite over-count of IndexOrDocValuesQuery); #9892 (LUCENE-8849) and #10092 (LUCENE-9050), the same kind of gap treated as bugs in other classes; opensearch-project/k-NN#3299, where a downstream query builder's visit now visits its filter, at another layer and without mentioning Lucene's queries.
  • Open PRs: 5 of the 411 open PRs touch AbstractVectorSimilarityQuery.java (#16847, #16204, #15802, #13594, #13595); none changes visit. Of the 56 PRs updated since 2026-10-28, the only one that changes a cited source file on main is the open #16952 (bulk-scoring upper bound in IndexSearcher.java and LRUQueryCache.java, not the key sizing), with no clause-count, visit or query-size change. The others that list a cited file touch only CHANGES.txt or MIGRATE.md (#16806, #16816, #16857), another branch (#16911, branch_10x), or are a stale 2025 branch-merge PR (#14673). None changes visit, the clause check or the fix site.
  • Same contract, other fix sites (registry): LFV-038 (DocValuesRewriteMethod) and LFV-872 (IndriQuery).
  • Limits: keyword search only (the open-PR file scan finds fixes whatever their wording); private reports are not visible.
What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene incl. the migrated JIRA archive), downstream Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the web2026-10-28a scripted prior-report search, 54 queries (2,452 distinct hits, 0 high, 26 medium, read); 38 manual GitHub searches; the comment and review threads of 18 related issues and PRs; 4 Solr JIRA queriesno report of this gap or its symptoms
Open apache/lucene PRs2026-10-28changed-file scan of all 411 open PRs, diffs of those touching the fix-site file grepped for visit changes5 touch the file, none changes visit
GitHub (apache/lucene)2026-10-31"VectorSimilarityQuery visit"; "QueryVisitor filter", "maxClauseCount vector", "TooManyNestedClauses", "visit filter similarity", "similarity query filter clause" updated since 2026-10-28; the files of the 56 PRs updated since then; the commits on the fix-site filenothing new; last commit on the file still #16104 (2026-05-28)

History

Introduced with the class in 9.10.0 by #12679 (cd195980ecb, merged 2023-12-11), whose leaf-only visit was copied from the kNN queries. Every release since, 9.10.0 to 10.6.0, and main are affected; upstream has not changed visit.

Versions tested6 rows

Introduced by cd195980ecbcb45e19ce9465854fed0b6be79ea7 "Add support for similarity-based vector searches (#12679)" (Kaival Parikh; merged by Benjamin Trent on 2023-12-11; branch_9x backport 8089b8c9d33 plus 02127766776, a Java 11 adjustment of createWeight). It created AbstractVectorSimilarityQuery with both halves of the gap: the leaf-only visit and the separate rewrite of the filter. Not a regression, so no bisect. Float16VectorSimilarityQuery (#16735, unreleased, on the 11.x line only: main and branch_11x) inherits it. The visitor-based cumulative check (LUCENE-8811) was already in 9.0.0.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal test fails with assertions on and off (gradle and runner); passes with the fix
10.6.0, 10.5.2 affectedminimal test fails (assertions off); finder's and verifiers' tests fail too
10.5.1, 10.0.0, 9.12.3, 9.10.0 affectedthe old-constructor variant fails; on 9.10.0 the fix passes it
the other releases from 9.10.0 to 10.5.2 readunchanged visit and createWeight in every tag
9.9.2 no APIno similarity queries (the repro does not compile)
upstream main 2f424fd428da (2026-10-30) readnot fixed; last commit on the file is #16104 (2026-05-28)

Independent checks

Confirmed by an independent reproducer, a devil's-advocate verifier, a works-as-designed review by a reviewer from another model family (OpenAI GPT-5.6) and an adjudication; re-run by a credibility audit, the minimization and this doc's gradle run. The only dissent was a procedural "not real" from a read-only review that named no flaw.

Checks and verdicts361 words

No cross-model review could run code (the 2026-10-31 cross-model fact check was read-only too); the executed checks are the finder's, the independent reproducer's, the separate re-runs for the cross-model reviews, the devil's advocate's, the adjudication's, the registration check's, the audit's, the minimization's, this doc's and the independent fact check's.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (differential check, then JUnit) candidate2-test JUnit (float and byte) fails 2 of 2 on main (on, off), the 10.6.0 revision, 10.5.2 and 9.12.3; its in-test control throws; the one-line fix passes it.
Refute review (OpenAI GPT-5.6, read-only) not real (procedural, low)Could not run commands; named no flaw in the repro or root cause; rated the intended-behaviour case moderate. A separate re-run fails 2 of 2.
Independent reproducer realOwn 7-test class: 4 of 7 fail on main, 10.5.2, 9.12.3 and 9.10.0 (10 similarity queries with 1,000-term filters accepted; termCollector misses the filter); 3 controls pass; its own fix passes 7 of 7.
Works-as-designed review (OpenAI GPT-5.6, read-only) real (low)Agrees on the root cause; inputs in contract; separate internal rewrite validation does not preserve a cumulative budget. Separate re-runs fail on main and pass with the fix.
Intended-behaviour devil's advocate real (medium)Built the opaque-vector-leaf and block-join case and rejected it; probes: 50 filtered queries accepted, sizeOf 1,024 vs 1,169,024 bytes.
Adjudication confirmed (medium)Re-ran the independent reproduction's class on main (on, off): 4 of 7 fail each. Impact minor: hits are correct.
Credibility audit and minimization keepThe audit re-ran the finder's test (fails); the minimization cut it to one test on 645d4fe14a20 (fails on, off; passes with the fix).
Fact checks of this doc (2026-10-31) corrections appliedAn independent fact check re-ran the repro's gradle command on a copy of 645d4fe14a20 (fails on and off; passes with the fix) and its own probe (601 / 1,201 / 600 on the rewritten tree; one nested case accepted, rejected with the fix). A fact check by a reviewer from another model family (OpenAI GPT-5.6, read-only) and a diagram check agreed on the bug. Their wording and scope corrections are applied.

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

Finding record

LFV-916, snapshot of 2026-11-06

Registry title
Float/Byte/Float16VectorSimilarityQuery.visit() never visits the filter, so the filter's clauses escape IndexSearcher's cumulative maxClauseCount check and QueryVisitor term collection
Area
search / vector similarity queries (AbstractVectorSimilarityQuery QueryVisitor traversal; consumers: IndexSearcher's cumulative clause limit, QueryVisitor.termCollector, RamUsageEstimator.sizeOf(Query))
Priority
Low, rank 737: The filter of the niche vector similarity queries is also hidden from the clause limit and term collection while hits stay correct, the same QueryVisitor-hiding class as LFV-917 on a narrower API, so it moves next to that row for consistency.
Impact class
minor
Impact note
Applications that pass a filter to the @lucene.experimental Float/Byte/Float16VectorSimilarityQuery, with the default searcher and default maxClauseCount (1024), get no TooManyNestedClauses for the filter's share of the query: the cumulative check never counts the filter, which is checked only on its own with a fresh budget, so a query holding N filtered similarity queries can carry about N x maxClauseCount clauses (measured: 10 x 1,000-term filters accepted), and QueryVisitor.termCollector and RamUsageEstimator.sizeOf (the LRUQueryCache key estimate) also miss the filter; search hits are correct, no scale beyond the clause limit is needed, every release since 9.10.0.
Summary
You build BooleanQuery(MUST a 600-term disjunction, MUST FloatVectorSimilarityQuery.Adaptive(field, target, 0f, filter = another 600-term disjunction)), 1,200 term clauses in all, and search it with the default maxClauseCount of 1024. You expect TooManyNestedClauses, which you do get when the same 600 clauses are a sibling FILTER clause, because the check counts 'total clauses cumulatively in all of its children' and Query#visit is documented to visit 'any child queries'. Instead the search runs: AbstractVectorSimilarityQuery.visit only calls visitor.visitLeaf(this), so the filter, which stays in the rewritten query and is executed in createWeight, is checked only on its own; ByteVectorSimilarityQuery and Float16VectorSimilarityQuery behave the same, and QueryVisitor.termCollector returns none of the filter's terms.
Introduced
cd195980ecbcb45e19ce9465854fed0b6be79ea7 (apache/lucene#12679 'Add support for similarity-based vector searches', by GitHub kaivalnp, merged by benwtrent), released in 9.x through the branch_9x backport 8089b8c9d3374dc4d451a4280daae85e0b139b84 (plus 0212776677674db06439279849dafbafd7cc7dbc, a Java 11 adjustment of createWeight that leaves visit untouched); it created AbstractVectorSimilarityQuery with the leaf-only visit() copied from AbstractKnnVectorQuery and the createWeight line that rewrites and checks the filter on its own [read: git blame -w -M -C and git log -L on visit() show only cd195980ecb; no PR comment or review mentions visit, QueryVisitor or maxClauseCount; exec: fails on 9.10.0, the first release with the class, while 9.9.2 has no such class]; not a regression, so no bisect (2023-12-12 (author date, +0530; merged 2023-12-11T19:18:36Z; branch_9x backport committed 2023-12-11))
First affected version
9.10.0 [exec] (the first release with the vector similarity queries; 9.9.2 has none and the repro does not compile there [exec]); on the main line 10.0.0 [exec]; Float16VectorSimilarityQuery (#16735) is main only and unreleased
Affected versions
Every release that has the API: the 19 up to 10.5.2 (9.10.0, 9.11.0, 9.11.1, 9.12.0 to 9.12.3 and 10.0.0 to 10.5.2) and 10.6.0 (the branch_10_6 head 4ae946e5a9b, the RC1 revision), plus main a9779ef3c193 [exec: the finder's 2-test JUnit fails 2 of 2, with its in-test control passing, on 9.10.0 (-ea and -da), 9.12.3 (-ea and -da), 10.0.0, 10.5.1, 10.5.2 (-ea and -da), branch_10_6 (-da) and main (-ea and -da); the independent reproduction's 7-test JUnit fails 4 of 7, with its 3 controls passing, on 9.10.0 (-da), 9.12.3 (-da), 10.5.2 (-ea and -da) and main (-ea and -da); the one-line visit fix shadowed on 9.10.0 passes 2 of 2; read: the visit() body and the createWeight filter line are identical in all 19 release tags and on main, and no release overrides rewrite()]; the IndexSearcher visitor-based cumulative check (LUCENE-8811) already existed when the class was added; upstream main c5ab3e57b9ba, branch_10x and branch_10_6 still have the leaf-only visit (last commit on the file 6f30895add8, #16104, author date 2026-05-28, does not touch visit) [read 2026-10-28]; not fixed anywhere. All runs on aarch64 Linux with JDK 25.
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: exhaustive check
Upstream and prior reports
none found (the prior-report record, verdict new, searched 2026-10-28, read-only: a scripted prior-report search with 54 queries over GitHub issues, PRs and commits, downstream Elasticsearch, OpenSearch and Solr, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and 9 web searches (2,452 distinct hits, 0 high, 26 medium); 38 manual GitHub searches without rate-limit failures; the comment and review threads of #12679, #16654, #16735, #15784, #13285, #12922, #656, #12004, #951, #14860, #12579, #15869, #16586, #11946, #16725, #9855, #12538 and opensearch-project/k-NN#3299; 4 Solr JIRA queries; a changed-file scan of all 411 open apache/lucene PRs (5 touch AbstractVectorSimilarityQuery.java: #16847, #16204, #15802, #13594, #13595, and none changes visit) and of the 67 PRs updated since 2026-10-26), plus the finder's, the independent reproduction's, the intended-behaviour check's and the adjudication's own searches. Refresh 2026-10-28: GitHub search 'VectorSimilarityQuery visit' returns only the known similarity-query PRs (#15869, #16654, #16671, #15784, #15836, #12679); '"similarity query" filter clause' nothing new; 'QueryVisitor filter' and 'maxClauseCount' updated since 2026-10-25: 0; 'visit filter vector' only HNSW visited-node items; the PRs updated since a recent cutoff (#16858, #16700, #16931, #16857, #16926, #16145) and the newest items up to #16931 do not touch the fix-site files; the last upstream commit on AbstractVectorSimilarityQuery.java is still 6f30895add8 (#16104, author date 2026-05-28). Related, not this bug: #12679 (origin; its review never discusses visitors); LUCENE-8811 (#9855, the visitor-based cumulative check: 'removes ways to by-pass the maximum clause count'); #14756 and open PR #16175 (the opposite over-count of IndexOrDocValuesQuery; maintainers: 'for the purpose of visiting, we should visit all variants'); LUCENE-8849 (#9892, DocValuesRewriteMethod.visit fixed with the same getSubVisitor(FILTER, ...) shape; registry LFV-038 is the related DocValuesRewriteMethod case) and LUCENE-9050 (#10092), the same omission fixed as bugs in other classes; opensearch-project/k-NN#3299 (a downstream query-builder analogue that does not mention Lucene's queries). Gaps: GitHub search is keyword-based (the open-PR file scan finds fixes regardless of wording); the search API rate-limited once during the refresh and the queries were retried after the reset.
Lucene commit tested
main 645d4fe14a20 (gradle in a scratch worktree and the repro runner, 2026-10-31; the minimization, 2026-10-29) and a9779ef3c193 (the hunt and its verifiers, 2026-10-28; identical code for every cited source file; only lucene/CHANGES.txt gained unrelated entries, and its line numbers here are those of 645d4fe14a20); the release jars 9.10.0, 9.12.3, 10.0.0, 10.5.1, 10.5.2 and 10.6.0; 9.9.2 has no such classes. OpenJDK 25.0.3 on Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/search/AbstractVectorSimilarityQuery.java:145-152 (createWeight: searcher.createWeight(searcher.rewrite(filter), ScoreMode.COMPLETE_NO_SCORES, 1)), :259-264 (visit: if (visitor.acceptField(field)) visitor.visitLeaf(this)), :40 (@lucene.experimental); FloatVectorSimilarityQuery.java:34, ByteVectorSimilarityQuery.java:33, Float16VectorSimilarityQuery.java:34 (@lucene.experimental); Query.java:88-93 ('Recurse through the query tree, visiting any child queries'); IndexSearcher.java:871-880 (rewrite runs getNumClausesCheckVisitor), :892-932, :1193-1199 (TooManyNestedClauses 'total clauses cumulatively in all of its children'); AbstractKnnVectorQuery.java:93 (the kNN filter is rewritten inside rewrite)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/search/AbstractVectorSimilarityQuery.java:57, 69, 84, 145-152 (createWeight), 259-264 (visit) @ 645d4fe14a20
  • lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:80-84, 623, 750-754, 865-932 (rewrite and the clause-check visitor), 1193-1199 (TooManyNestedClauses)
  • lucene/core/src/java/org/apache/lucene/search/Query.java:88-93; QueryVisitor.java:53-99
  • lucene/core/src/java/org/apache/lucene/search/ConstantScoreQuery.java:76-79; LRUQueryCache.java:465-471; lucene/CHANGES.txt:206 (GITHUB#15124, 11.0.0)
  • branch_11x: a Lucene clone refs/remotes/origin/branch_11x (603af870375, 2026-10-29) and GitHub read-only on 2026-10-31 (live head d0c0decc6606, 2026-10-30): Float16VectorSimilarityQuery.java present, AbstractVectorSimilarityQuery.java:260-264 the same leaf-only visit, LRUQueryCache.java:470 sizeOf(query, 32); releases/lucene/10.6.0 LRUQueryCache.java:428-433 (QUERY_DEFAULT_RAM_BYTES_USED for non-accountable queries)
Who is affected
  • lucene/core/src/java/org/apache/lucene/search/FloatVectorSimilarityQuery.java:34; ByteVectorSimilarityQuery.java:33; Float16VectorSimilarityQuery.java:34 (@lucene.experimental); IndexSearcher.java:80-84
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/search/Query.java:88-93; QueryVisitor.java:53-58; IndexSearcher.java:891-895, 1193-1199; AbstractKnnVectorQuery.java:74-97, 478-483
  • lucene/CHANGES.txt:4681-4684, 5800, 6163-6164, 6469-6473; lucene/MIGRATE.md:1531-1534
  • lucene/core/src/test/org/apache/lucene/search/TestMaxClauseLimit.java:128-148
  • lucene/core/src/java/org/apache/lucene/search/DocValuesRewriteMethod.java:73-77 (LUCENE-8849 fix 5286098ac5c: getSubVisitor result unused); lucene/join/src/java/org/apache/lucene/search/join/ToParentBlockJoinQuery.java:90-92, 98-121 (inner rewrite only when the child score mode is None, LUCENE-8701 4b0c36b17b6)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/ConstantScoreQuery.java:76-79; AbstractKnnVectorQuery.java:74-97
History
  • lucene/CHANGES.txt:2973 (GITHUB#12679, 9.10.0)

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 the code at 645d4fe14a20, the minimal repro and a probe of the clause-check count on stock main and with the fix. The diagram's sizes come from RamUsageEstimator.sizeOf(query, 32), the query cache's call (32 bytes for the similarity query, 105,632 for its filter alone), and its term count from termCollector over the whole repro query (600 of 1,200); the text uses sizeOf(query) (1,024 vs 701,824) and the filter alone (0 of 600). Both pairs were executed. An independent diagram check found no errors.

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.