From a375f4ce45afa67f6b97a651bf172f975b8a839e Mon Sep 17 00:00:00 2001 From: Michael Froh Date: Wed, 10 Jun 2026 14:55:20 -0700 Subject: [PATCH] Limit term dictionary traversal in multi-term queries This change builds on the previous commit (#16222) by adding a "visit budget" on FilteredTermsEnum. We can use that to build a ScorerSupplier with more accurate cost if it can be done cheaply (e.g. prefix / regex queries with a prefix). If the terms enum is expensive (e.g. leading wildcard query), then the budget is exhausted and we give up. Co-authored-by: Claude Opus 4.6 --- lucene/CHANGES.txt | 2 + .../lucene/index/FilteredTermsEnum.java | 34 +++++- ...actMultiTermQueryConstantScoreWrapper.java | 107 ++++++++++-------- .../lucene/index/TestForTooMuchCloning.java | 2 +- .../lucene/search/TestWildcardQuery.java | 52 +++++++-- 5 files changed, 133 insertions(+), 64 deletions(-) diff --git a/lucene/CHANGES.txt b/lucene/CHANGES.txt index fd9092a1a7de..d3255a4fc643 100644 --- a/lucene/CHANGES.txt +++ b/lucene/CHANGES.txt @@ -397,6 +397,8 @@ Optimizations clause matching no documents) before a non-seekable scan, such as a leading wildcard, runs. (Tianxiao Wei) +* GITHUB#16240: Build on 16222 by letting automaton queries do "some" term dictionary traversal during planning, but with a bounded budget. (Michael Froh) + * GITHUB#16176: Restore WANDScorer for TOP_SCORES + minShouldMatch > 1. (Tianxiao Wei) * GITHUB#16153: Use TernaryLongHeap in UpdateGraphsUtils for faster HNSW graph merging. (Prithvi S) diff --git a/lucene/core/src/java/org/apache/lucene/index/FilteredTermsEnum.java b/lucene/core/src/java/org/apache/lucene/index/FilteredTermsEnum.java index 8099aef459a0..d2e5133e5ccc 100644 --- a/lucene/core/src/java/org/apache/lucene/index/FilteredTermsEnum.java +++ b/lucene/core/src/java/org/apache/lucene/index/FilteredTermsEnum.java @@ -34,6 +34,7 @@ public abstract class FilteredTermsEnum extends TermsEnum { private BytesRef initialSeekTerm; private boolean doSeek; + private int visitsBudget = Integer.MAX_VALUE; /** Which term the enum is currently positioned to. */ protected BytesRef actualTerm; @@ -90,6 +91,26 @@ protected FilteredTermsEnum(final TermsEnum tenum, final boolean startWithSeek) doSeek = startWithSeek; } + /** + * Sets a budget on the number of underlying {@link TermsEnum} operations ({@code seekCeil} and + * {@code next}) that {@link #next()} may perform. When the budget is exhausted, {@code next()} + * returns {@code null} as if the enum were exhausted. Use {@link #isVisitsBudgetExhausted()} to + * distinguish budget exhaustion from a true end of matching terms. + * + *

The default budget is {@link Integer#MAX_VALUE} (effectively unlimited). + */ + public void setVisitsBudget(int budget) { + this.visitsBudget = budget; + } + + /** + * Returns {@code true} if a previous {@link #next()} call returned {@code null} due to the visits + * budget being exhausted rather than the terms being truly exhausted. + */ + public boolean isVisitsBudgetExhausted() { + return visitsBudget <= 0; + } + /** * Use this method to set the initial {@link BytesRef} to seek before iterating. This is a * convenience method for subclasses that do not override {@link #nextSeekTerm}. If the initial @@ -228,19 +249,20 @@ public BytesRef next() throws IOException { if (doSeek) { doSeek = false; final BytesRef t = nextSeekTerm(actualTerm); - // System.out.println(" seek to t=" + (t == null ? "null" : t.utf8ToString()) + " tenum=" + - // tenum); // Make sure we always seek forward: assert actualTerm == null || t == null || t.compareTo(actualTerm) > 0 : "curTerm=" + actualTerm + " seekTerm=" + t; - if (t == null || tenum.seekCeil(t) == SeekStatus.END) { - // no more terms to seek to or enum exhausted - // System.out.println(" return null"); + if (t == null) { + return null; + } + if (--visitsBudget < 0 || tenum.seekCeil(t) == SeekStatus.END) { return null; } actualTerm = tenum.term(); - // System.out.println(" got term=" + actualTerm.utf8ToString()); } else { + if (--visitsBudget < 0) { + return null; + } actualTerm = tenum.next(); if (actualTerm == null) { // enum exhausted diff --git a/lucene/core/src/java/org/apache/lucene/search/AbstractMultiTermQueryConstantScoreWrapper.java b/lucene/core/src/java/org/apache/lucene/search/AbstractMultiTermQueryConstantScoreWrapper.java index c66c26496efc..b83b4ed52a7b 100644 --- a/lucene/core/src/java/org/apache/lucene/search/AbstractMultiTermQueryConstantScoreWrapper.java +++ b/lucene/core/src/java/org/apache/lucene/search/AbstractMultiTermQueryConstantScoreWrapper.java @@ -20,6 +20,8 @@ import java.util.ArrayList; import java.util.List; import java.util.Objects; +import org.apache.lucene.index.AutomatonTermsEnum; +import org.apache.lucene.index.FilteredTermsEnum; import org.apache.lucene.index.LeafReaderContext; import org.apache.lucene.index.Term; import org.apache.lucene.index.TermState; @@ -29,6 +31,7 @@ import org.apache.lucene.util.Accountable; import org.apache.lucene.util.BytesRef; import org.apache.lucene.util.RamUsageEstimator; +import org.apache.lucene.util.automaton.CompiledAutomaton; /** * Contains functionality common to both {@link MultiTermQueryConstantScoreBlendedWrapper} and @@ -42,6 +45,12 @@ abstract class AbstractMultiTermQueryConstantScoreWrapper weightOrIteratorSupplier; - // Only collect terms while building the ScorerSupplier when the query exposes a known, - // bounded term count (e.g. TermInSetQuery, getTermsCount() >= 0). There, collecting is - // cheap and lets us return a null supplier up-front so a parent BooleanQuery can - // short-circuit. - // - // For queries with an unknown term count (e.g. automaton queries: wildcard / regexp / - // prefix / range), collecting eagerly can scan the whole term dictionary during - // ScorerSupplier construction -- a leading wildcard such as "*foo*" cannot seek and must - // visit every term. That is supposed to be the cheap "planning" phase, and doing it there - // defeats a parent conjunction's ability to short-circuit (a sibling clause matching no - // documents can no longer skip this clause before the scan runs). So for an unknown term - // count we estimate the cost and defer term collection to ScorerSupplier#get(). - if (q.getTermsCount() >= 0) { - List collectedTerms = new ArrayList<>(); - boolean collectResult = collectTerms(fieldDocCount, termsEnum, collectedTerms); - if (collectResult) { - // Return a null supplier if no query terms were in the segment: - if (collectedTerms.isEmpty()) { - return null; - } + // Try to eagerly collect matching terms. For queries with a known term count + // (e.g. TermInSetQuery), we always collect eagerly. For queries with an unknown term + // count (e.g. automaton queries: wildcard / regexp / prefix / range), we attempt a + // budgeted probe: if the automaton can find all matching terms within a small number of + // underlying TermsEnum operations, we use those results. Otherwise (probe exhausts its + // budget, or no probe is possible), we estimate the cost and defer term collection to + // ScorerSupplier#get() -- eagerly scanning the whole term dictionary during the + // "planning" phase would defeat a parent conjunction's ability to short-circuit. + List eagerTerms = new ArrayList<>(); + TermsEnum deferredTermsEnum = termsEnum; + boolean eagerSuccess; - // TODO: Instead of replicating the cost logic of a BooleanQuery we could consider - // rewriting to a BQ eagerly at this point and delegating to its cost method (instead of - // lazily rewriting on #get). Not sure what the performance hit would be of doing this - // though. - long sumTermCost = 0; - for (TermAndState collectedTerm : collectedTerms) { - sumTermCost += collectedTerm.docFreq; - } - cost = sumTermCost; + if (q.getTermsCount() >= 0) { + eagerSuccess = collectTerms(fieldDocCount, termsEnum, eagerTerms); + } else { + // Unknown term count. Try a cheap budgeted probe: if the automaton can find + // all matching terms within a small number of underlying TermsEnum operations, + // use those results eagerly. Otherwise, fall back to deferred collection. + FilteredTermsEnum probeEnum = null; + if (termsEnum instanceof FilteredTermsEnum fte) { + probeEnum = fte; + } else if (q instanceof AutomatonQuery aq + && aq.getCompiled().type == CompiledAutomaton.AUTOMATON_TYPE.NORMAL) { + probeEnum = new AutomatonTermsEnum(terms.iterator(), aq.getCompiled()); + } + if (probeEnum != null) { + probeEnum.setVisitsBudget(AUTOMATON_TERM_COLLECT_VISIT_BUDGET); + boolean probeResult = collectTerms(fieldDocCount, probeEnum, eagerTerms); + eagerSuccess = probeResult && !probeEnum.isVisitsBudgetExhausted(); } else { - cost = estimateCost(terms, q.getTermsCount()); + eagerSuccess = false; } - weightOrIteratorSupplier = - leadCost -> { - if (collectResult) { - return rewriteAsBooleanQuery(context, collectedTerms); - } else { - // Too many terms to rewrite as a simple bq. - // Invoke rewriteInner logic to handle rewriting: - return rewriteInner( - context, fieldDocCount, terms, termsEnum, collectedTerms, leadCost); - } - }; + if (!eagerSuccess) { + deferredTermsEnum = (probeEnum == termsEnum) ? q.getTermsEnum(terms) : termsEnum; + eagerTerms = new ArrayList<>(); + } + } + + if (eagerSuccess) { + if (eagerTerms.isEmpty()) { + return null; + } + long sumTermCost = 0; + for (TermAndState collectedTerm : eagerTerms) { + sumTermCost += collectedTerm.docFreq; + } + cost = sumTermCost; + final List finalTerms = eagerTerms; + weightOrIteratorSupplier = _ -> rewriteAsBooleanQuery(context, finalTerms); } else { cost = estimateCost(terms, q.getTermsCount()); + final TermsEnum finalDeferredEnum = deferredTermsEnum; + final List partialTerms = eagerTerms; weightOrIteratorSupplier = leadCost -> { - List collectedTerms = new ArrayList<>(); - if (collectTerms(fieldDocCount, termsEnum, collectedTerms)) { - return rewriteAsBooleanQuery(context, collectedTerms); + if (collectTerms(fieldDocCount, finalDeferredEnum, partialTerms)) { + return rewriteAsBooleanQuery(context, partialTerms); } else { - // Too many terms to rewrite as a simple bq. - // Invoke rewriteInner logic to handle rewriting: return rewriteInner( - context, fieldDocCount, terms, termsEnum, collectedTerms, leadCost); + context, fieldDocCount, terms, finalDeferredEnum, partialTerms, leadCost); } }; } diff --git a/lucene/core/src/test/org/apache/lucene/index/TestForTooMuchCloning.java b/lucene/core/src/test/org/apache/lucene/index/TestForTooMuchCloning.java index fb1a4ba92e2b..8f20e0a4d212 100644 --- a/lucene/core/src/test/org/apache/lucene/index/TestForTooMuchCloning.java +++ b/lucene/core/src/test/org/apache/lucene/index/TestForTooMuchCloning.java @@ -94,7 +94,7 @@ public void test() throws Exception { // perform. assertTrue( "too many calls to IndexInput.clone during TermRangeQuery: " + queryCloneCount, - queryCloneCount <= Math.max(s.getLeafContexts().size(), s.getSlices().length) * 7); + queryCloneCount <= Math.max(s.getLeafContexts().size(), s.getSlices().length) * 10); r.close(); dir.close(); } diff --git a/lucene/core/src/test/org/apache/lucene/search/TestWildcardQuery.java b/lucene/core/src/test/org/apache/lucene/search/TestWildcardQuery.java index a3d46cb4b0c0..11e930bef9aa 100644 --- a/lucene/core/src/test/org/apache/lucene/search/TestWildcardQuery.java +++ b/lucene/core/src/test/org/apache/lucene/search/TestWildcardQuery.java @@ -445,10 +445,9 @@ public void testCostEstimate() throws IOException { Query rewritten = searcher.rewrite(query); Weight weight = rewritten.createWeight(searcher, ScoreMode.COMPLETE_NO_SCORES, 1.0f); ScorerSupplier supplier = weight.scorerSupplier(lrc); - // Automaton queries have an unknown term count, so term collection is deferred to get() and the - // cost is the worst-case estimate (sum of doc freqs across all terms) rather than the sum over - // the matching terms only. - assertEquals(3000, supplier.cost()); + // "foo*" matches only 2 terms ("foo bar" and "foo wuzzle"), which can be found cheaply + // within the visit budget. So the cost is the accurate sum of their docFreqs (2 * 1000). + assertEquals(2000, supplier.cost()); query = new WildcardQuery(new Term("body", "bar*")); rewritten = searcher.rewrite(query); @@ -491,12 +490,14 @@ public void testScorerSupplierDoesNotScanTermsEagerly() throws IOException { termsEnumNextCalls.set(0); ScorerSupplier supplier = weight.scorerSupplier(lrc); assertNotNull(supplier); - assertEquals( - "scorerSupplier() must not scan the term dictionary for an automaton MultiTermQuery", - 0, - termsEnumNextCalls.get()); - - // The scan is deferred to get(): building the scorer is where the terms are actually walked. + // The cheap probe runs during scorerSupplier() but is bounded by the visit budget. + // For a leading wildcard, the budget is exhausted quickly and term collection is deferred. + assertTrue( + "scorerSupplier() should use at most the visit budget for a leading wildcard", + termsEnumNextCalls.get() + <= AbstractMultiTermQueryConstantScoreWrapper.AUTOMATON_TERM_COLLECT_VISIT_BUDGET); + + // The full scan is deferred to get(): assertNotNull(supplier.get(Long.MAX_VALUE)); assertTrue("get() should scan the term dictionary", termsEnumNextCalls.get() > 0); @@ -504,6 +505,37 @@ public void testScorerSupplierDoesNotScanTermsEagerly() throws IOException { dir.close(); } + // Verifies that a prefix-like wildcard with few (or zero) matches is resolved eagerly + // during scorerSupplier(), enabling null-supplier short-circuiting. + public void testScorerSupplierResolvesSparsePrefixEagerly() throws IOException { + Directory dir = newDirectory(); + RandomIndexWriter writer = new RandomIndexWriter(random(), dir); + for (int i = 0; i < 1000; i++) { + Document doc = new Document(); + doc.add(newStringField("body", "term" + i, Field.Store.NO)); + writer.addDocument(doc); + } + writer.flush(); + writer.forceMerge(1); + writer.close(); + + DirectoryReader reader = DirectoryReader.open(dir); + IndexSearcher searcher = new IndexSearcher(reader); + LeafReaderContext lrc = reader.leaves().get(0); + + // No terms match "zzznomatch*", so the probe should find zero terms cheaply + // and scorerSupplier() should return null. + WildcardQuery query = new WildcardQuery(new Term("body", "zzznomatch*")); + Query rewritten = searcher.rewrite(query); + Weight weight = rewritten.createWeight(searcher, ScoreMode.COMPLETE_NO_SCORES, 1.0f); + assertNull( + "scorerSupplier() should return null when no terms match a prefix-like wildcard", + weight.scorerSupplier(lrc)); + + reader.close(); + dir.close(); + } + private static TermsEnum nextCountingTermsEnum(TermsEnum in, AtomicInteger counter) { return new FilterLeafReader.FilterTermsEnum(in) { @Override