-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Limit term dictionary traversal in multi-term queries #16240
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<Q extends MultiTermQue | |
| // mtq that matches 16 terms or less will be executed as a regular disjunction | ||
| static final int BOOLEAN_REWRITE_TERM_COUNT_THRESHOLD = 16; | ||
|
|
||
| // Budget for underlying TermsEnum operations when probing an unknown-count query | ||
| // during scorerSupplier construction. Prefix-like patterns use few operations per | ||
| // match (the automaton seeks efficiently), while leading wildcards exhaust this | ||
| // quickly and fall back to deferred collection. | ||
| static final int AUTOMATON_TERM_COLLECT_VISIT_BUDGET = 256; | ||
|
|
||
| protected final Q query; | ||
|
|
||
| protected AbstractMultiTermQueryConstantScoreWrapper(Q query) { | ||
|
|
@@ -228,62 +237,66 @@ public ScorerSupplier scorerSupplier(LeafReaderContext context) throws IOExcepti | |
| final long cost; | ||
| final IOLongFunction<WeightOrDocIdSetIterator> 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<TermAndState> 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<TermAndState> 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<>(); | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. when the probe runs out of budget, this throws away whatever terms it already found (eagerTerms = new ArrayList<>())... those are valid matches :) since collectTerms appends to the list, we could keep the partial results and let the deferred pass continue from where the probe stopped, instead of redoing that work? |
||
| } | ||
| } | ||
|
|
||
| if (eagerSuccess) { | ||
| if (eagerTerms.isEmpty()) { | ||
| return null; | ||
| } | ||
| long sumTermCost = 0; | ||
| for (TermAndState collectedTerm : eagerTerms) { | ||
| sumTermCost += collectedTerm.docFreq; | ||
| } | ||
| cost = sumTermCost; | ||
| final List<TermAndState> finalTerms = eagerTerms; | ||
| weightOrIteratorSupplier = _ -> rewriteAsBooleanQuery(context, finalTerms); | ||
| } else { | ||
| cost = estimateCost(terms, q.getTermsCount()); | ||
| final TermsEnum finalDeferredEnum = deferredTermsEnum; | ||
| final List<TermAndState> partialTerms = eagerTerms; | ||
| weightOrIteratorSupplier = | ||
| leadCost -> { | ||
| List<TermAndState> 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); | ||
| } | ||
| }; | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
budget gets decremented before checking seekCeil/next, so if the enum naturally runs out of terms and visitsBudget happens to land on 0 at the same time, isVisitsBudgetExhausted() returns true even though you got all the terms...caller then throws away complete results and falls back to deferred collection for no reason
can we have separate boolean budgetExhausted flag that only gets set when the budget check actually fires?