MoreLikeThis turns a text into a query made of its most interesting terms. When it picks more terms than a BooleanQuery may hold, it shortens the query instead of failing, but it cuts off the wrong end.
- Run
MoreLikeThis.like(...)on a like-text of 1100 words that all have the same idf: 76 words occur twice (score 2), the other 1024 once (score 1). SetmaxQueryTermsto 1100; the clause limit stays at its default, 1024. - The query can hold only 1024 clauses, so 76 terms must go. You expect the 76 weakest to go and all 76 score-2 words to stay. With
maxQueryTerms1024 that is what you get. - You get 1024 clauses and none of the 76 score-2 words: the terms left out are exactly the best ones. No error.
MoreLikeThisQueryand the XMLLikeThisQuerydo the same. - Why: the term queue hands out the lowest score first.
createQueryadds one clause per term in that order and stops at the firstTooManyClauses, so everything still in the queue, the best terms, is dropped. - Each step of
maxQueryTermsabove 1024 drops one more of the best terms: 75 kept at 1025, 50 at 1050, none from 1100 on. Up to 4.9.1 the queue was best-first and the same stop kept the best terms.
Applications that set maxQueryTerms above the clause limit (1024) and run MoreLikeThis on long texts, through MoreLikeThis, MoreLikeThisQuery or the XML LikeThisQuery, or that lower the global clause limit. The defaults (25, 5 and 20 terms) never reach it. Every such call is hit, silently; only relevance changes.
Mechanism, terms and numbers537 words
MoreLikeThis builds a query from the most interesting terms of a text: it scores each candidate by tf*idf and keeps the best maxQueryTerms in a priority queue. A BooleanQuery may hold at most IndexSearcher.getMaxClauseCount() clauses (1024 by default), so when more terms are selected, createQuery catches TooManyClauses and returns the clauses it has. It fills the query by popping the queue, and since 4.10.0 the queue pops the lowest score first, so the clauses that fit are the weakest terms and the best ones are dropped. With 1100 selected terms, 76 of them scoring twice the others, the 1024-clause query holds none of the 76; with maxQueryTerms 1024 it holds all of them. It happens through MoreLikeThis.like(...), MoreLikeThisQuery and the XML query parser's LikeThisQuery, with assertions on and off, and only when more terms are selected than the clause limit (the defaults, 25 terms and 1024 clauses, never reach it). LUCENE-5795 (2014) flipped the queue order for a faster top-N selection and kept the old loop, which up to 4.9.1 kept the best terms. Skipping the lowest-scoring terms that cannot fit before building the query (+11 lines) fixes it, and the whole queries module passes with that change.
MoreLikeThis(lucene-queries,org.apache.lucene.queries.mlt) finds documents similar to a source: a like-text, a document id or a field map. It scores every candidate term of the source bytf * idfand keeps the bestmaxQueryTerms(default 25) as SHOULD clauses of aBooleanQuery.- The clause limit: a
BooleanQuery.Builderrefuses more thanIndexSearcher.getMaxClauseCount()clauses (1024 by default) and throwsIndexSearcher.TooManyClauses.
createQueuekeeps the topmin(maxQueryTerms, #eligible terms)terms (eligible: passing theminTermFreq,minDocFreqandmaxDocFreqfilters) in a bounded priority queue whose top is the lowest score (MoreLikeThis.java:624-677, comparator at:628-629). The selection is right.createQuerypops that queue, so it sees the terms lowest score first, and adds a SHOULDTermQueryper term (:593-616).- After 1024 clauses the builder throws (
BooleanQuery.java:82-84), andcreateQuerydoescatch (IndexSearcher.TooManyClauses _) { break; }(:611-613). Every term still in the queue, the highest-scoring ones, is dropped. - So whenever more terms are selected than the clause limit, the query loses the best
#selected - limitterms. No exception, no warning.
Up to 4.9.1 the queue popped the best term first (FreqQ.lessThan returned fa > fb), so the same break kept the best terms. LUCENE-5795 (4.10.0) made the queue lowest-first to bound it cheaply and kept the loop.
Default clause limit 1024; an index where all 1100 words have the same idf; a like-text in which 76 words occur twice and 1024 once (the diagram probe ran 45 sampled values from 25 to 2000; every row matches them, and the ranges follow from the mechanism):
maxQueryTerms | clauses | the 76 best terms in the query |
|---|---|---|
| 76 to 1024 | maxQueryTerms | all 76 |
| 1025 | 1024 | 75 |
| 1050 | 1024 | 50 |
| 1075 | 1024 | 25 |
| 1100 to 2000 | 1024 | 0 |
Lowered limit: with IndexSearcher.setMaxClauseCount(3), the default maxQueryTerms 25 and words a to f occurring 6, 5, 4, 3, 2 and 1 times, the query is {d, e, f}; with maxQueryTerms 3 it is {a, b, c}.
Exposure and likelihood277 words
- Who. Applications that let
MoreLikeThisselect more terms than the global clause limit:maxQueryTermsabove 1024 on a source with more than 1024 eligible terms, throughMoreLikeThis.setMaxQueryTerms,MoreLikeThisQuery.setMaxQueryTerms(rewritten byIndexSearcher) or the XML query parser'sLikeThisQuery, whosemaxQueryTermsattribute comes from the query text; or a global limit lowered below the selection size (IndexSearcher.setMaxClauseCountis public). - Not by default. The defaults are 25 terms (
MoreLikeThis), 5 (MoreLikeThisQuery) and 20 (XMLLikeThisQuery). The other in-tree users, the classification module's k-nearest-neighbour classifiers and Luke, never setmaxQueryTerms, so they are reached only if the global limit is below 25. - Eligible terms. By default a term must occur at least twice in the source and in at least 5 documents, and a field without term vectors is parsed up to 5,000 tokens, so the trigger needs long sources, several fields (the queue counts terms over all fields) or relaxed filters.
- Effect. Every such call returns a query built from the least interesting selected terms; the best
#selected - 1024terms are lost. The result is non-monotonic: asking for more terms makes the query worse. The query can give worse "more like this" relevance (no relevance benchmark was run): other terms change which documents match as well as how they rank, andMoreLikeThisQueryalso setsminimumNumberShouldMatchfrom the clause count. Nothing fails, nothing is corrupted, and the query never exceeds the clause limit. - How likely. Rare: large
maxQueryTermsvalues are unusual, and no report was found since 4.10.0 (2014). Anyone configured that way is hit on every long source without any sign, so the absence of reports is weak evidence.
The test shows that when MoreLikeThis selects more terms than the boolean clause limit allows, the truncated query keeps the lowest-scoring terms instead of the best ones.
/*
* 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.queries.mlt;
import java.io.StringReader;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.QueryVisitor;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestMoreLikeThisClauseLimit extends LuceneTestCase {
public void testLoweredClauseLimitKeepsBestTerms() throws Exception {
Analyzer analyzer = new StandardAnalyzer();
Directory dir = new ByteBuffersDirectory();
try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
Document doc = new Document();
doc.add(new TextField("text", "alpha beta gamma delta", Field.Store.NO));
w.addDocument(doc);
}
DirectoryReader reader = DirectoryReader.open(dir);
MoreLikeThis mlt = new MoreLikeThis(reader);
mlt.setAnalyzer(analyzer);
mlt.setMinTermFreq(1);
mlt.setMinDocFreq(1);
String like = "alpha alpha alpha alpha beta beta beta gamma gamma delta";
Set<Term> terms = new HashSet<>();
int saved = IndexSearcher.getMaxClauseCount();
IndexSearcher.setMaxClauseCount(2);
try {
mlt.like("text", new StringReader(like)).visit(QueryVisitor.termCollector(terms));
} finally {
IndexSearcher.setMaxClauseCount(saved);
}
// expected the 2 best terms [alpha, beta] (tf 4, 3); actual the 2 worst [delta, gamma] (tf 1, 2)
assertEquals(List.of("alpha", "beta"), terms.stream().map(Term::text).sorted().toList());
reader.close();
dir.close();
analyzer.close();
}
}
# in a copy of this finding's repro folder (repro/LFV-571/, 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:$LUCENE/lucene/queries/build/libs/lucene-queries-$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.queries.mlt.TestMoreLikeThisClauseLimit
- Expected vs actual
- Expected
[alpha, beta](tf 4 and 3) under a clause limit of 2 with the default maxQueryTerms; actual[delta, gamma](tf 1 and 2), with-eaand-da.
More files, environment, full output36 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:<[alpha, beta]> but was:<[delta, gamma]>
org.junit.Assert.fail(Assert.java:89)
org.junit.Assert.failNotEquals(Assert.java:835)
org.junit.Assert.assertEquals(Assert.java:120)
org.junit.Assert.assertEquals(Assert.java:146)
org.apache.lucene.queries.mlt.TestMoreLikeThisClauseLimit.testLoweredClauseLimitKeepsBestTerms(TestMoreLikeThisClauseLimit.java:63)- Up to 4.9.1 the same loop kept the best terms; LUCENE-5795 (4.10.0) flipped the queue order for speed, without discussing the clause limit.
- The class aims to "maximize a tf*idf score" and truncates on purpose, so the truncated query should keep the best terms.
- Asking for more makes it worse:
maxQueryTerms1024 keeps all 76 best terms, 1100 none.
The full argument267 words
- Purpose. The class javadoc: "Since you're trying to maximize a tf*idf score, you're probably most interested in terms with a high tf" (
MoreLikeThis.java:63-64), and "return a set of representative terms" (:79). - Best-first wording. The private
retrieveTerms(Reader, String)returns "the most interesting words in the document ordered by score, with the highest scoring, or best entry, first" (:875-876), the order thecreateQueryloop was written for; stale since LUCENE-5795.retrieveInterestingTerms: "we just want to return the top words" (:895,:920). - The limit was anticipated.
DEFAULT_MAX_QUERY_TERMS: "Return a Query with no more than this many terms." with@see IndexSearcher#getMaxClauseCount(:225-232);createQuerycatchesTooManyClausesand returns the query it has (:609-615). - History. From the first check-in (2005) to 4.9.1 the queue popped best-first; LUCENE-5795 is listed under 4.10.0 "Optimizations": "MoreLikeThisQuery now only collects the top N terms instead of collecting all terms from the like text when building the query." (
lucene/CHANGES.txt:11932-11934). Its 9 comments discuss the bounded queue, its struct, the added test and the commits; none mentions the pop order or the clause limit. Swapping onlyMoreLikeThis.javaflips the result both ways on 4.9.1 and 4.10.0. - Core precedent.
TopTermsScoringBooleanQueryRewrite"only uses the top scoring terms so it will not overflow the boolean max clause count" and caps its size atgetMaxClauseCount()(MultiTermQuery.java:146-169). - Tests. No
lucene/queriestest reachesTooManyClausesin MoreLikeThis;testTopNuses 25 terms under 1024.testSmallSampleFromCorpusasserts the lowest-first clause order andtestCustomFrequencyrelies on the lowest-score boost normalisation; the recommended fix keeps both, and both pass with it.
The devil's-advocate check310 words
The strongest works-as-designed case (the independent reproducer, the devil's advocate, the cross-model reviews and the adjudication): setMaxQueryTerms promises only a maximum ("the maximum number of query terms that will be included in any generated query"), and DEFAULT_MAX_QUERY_TERMS points at getMaxClauseCount, which could mean "keep maxQueryTerms at or below the clause limit". The catch could be just a heap backstop: BooleanQuery.Builder.add says its check exists "to prevent run-away 'rewriting' of bad queries from creating BQ objects that might eat up all the Heap". @jpountz wrote in #8931 (LUCENE-7880, a thread asking to allow more clauses per query): "I think of the max clause count as something that should never be changed. What is the use-case for having more than 1024 clauses?" That question arguably applies to the main trigger itself (maxQueryTerms above 1024). And upstream tests encode the ascending clause order.
Why it fails: no javadoc says values above the limit are unsupported, the setter accepts them, and the code chooses to return a truncated query. Up to 4.9.1 the same code kept the best terms, and the change came with an optimisation whose discussion never covered it. Asking for 1100 terms instead of 1024 removes all 76 best ones, which is hard to defend for a class that exists to choose the most interesting terms. The #8931 remark is about changing the limit at all, in a thread about raising it; this bug needs neither: the main trigger leaves the limit at its default, the query stays at 1024 clauses, and createQuery deliberately truncates instead of rejecting, so the only question is which 1024 terms survive. Clause order does not change matching, and the fix keeps it anyway. Verdict: not intended; a regression from LUCENE-5795. The expectation comes from the class's purpose, the pre-4.10 behaviour and the best-first javadoc, not from an explicit promise about the clause-limit path.
Recommended A: skip the lowest-scoring terms that cannot fit (createQuery)
In createQuery, first pop and skip the lowest-scoring terms that cannot fit under the clause limit, then build the query as today (+11 lines). Clause order and boosts do not change. The whole queries module passes with it (520 tests), the repros included, with assertions on and off.
float bestScore = -1;
// q pops the lowest score first. If more terms were selected than the clause limit allows,
// skip the lowest-scoring ones so that the best terms are kept (clause order and boost
// normalisation do not change).
int skip = q.size() - IndexSearcher.getMaxClauseCount();
while (skip-- > 0) {
scoreTerm = q.pop();
if (boost && bestScore == -1) {
bestScore = scoreTerm.score;
}
}
while ((scoreTerm = q.pop()) != null) {All options and trade-offs4 options · 640 words
Two fixes were built and run in a scratch worktree at a9779ef3c193. A changes only how the query is built and is recommended; B limits the selection itself.
A: skip the lowest-scoring terms that cannot fit (createQuery) (validated by the checks) (recommended)
Before the build loop, pop and discard q.size() - getMaxClauseCount() terms, which are the lowest-scoring ones, still seeding the boost normaliser from the lowest selected score. +11 lines in MoreLikeThis.java; the finder's change (+10) with a reworded, 3-line comment.
float bestScore = -1;
// q pops the lowest score first. If more terms were selected than the clause limit allows,
// skip the lowest-scoring ones so that the best terms are kept (clause order and boost
// normalisation do not change).
int skip = q.size() - IndexSearcher.getMaxClauseCount();
while (skip-- > 0) {
scoreTerm = q.pop();
if (boost && bestScore == -1) {
bestScore = scoreTerm.score;
}
}
while ((scoreTerm = q.pop()) != null) {Trade-offs
- Only the truncating case changes: a query that fits is built exactly as today. Clauses stay in ascending order, and with
setBoost(true)a kept term gets the boost it would get in the untruncated query. retrieveInterestingTermsandcreateQueueare untouched; thecatch (TooManyClauses)stays as a backstop, no longer reached unless another thread lowers the global clause limit while the query is built.- Cost: on the truncating path only. The skip loop pops
#selected - limitterms; stock already pops one term that does not fit, so the net extra is#selected - limit - 1pop()calls (O(log n) each). Not benchmarked. - It edits the same
bestScorelines as the fix for the sibling boost-normalisation bug (a selected score of 0 gives NaN boosts); if both are filed together, the skip loop needs the same positive-score rule.
What the checks validated
Gradle in a scratch worktree at a9779ef3c193, seed DEADBEEF: the whole :lucene:queries:test with the 4 repro classes added, 55 classes, 520 tests, 0 failures (the module's own skips: 5 with assertions on, 6 off); all 8 repro tests that fail on stock pass, and the randomized oracle (which also covers setBoost(true)) has 0 wrong of 600 checks. :lucene:queries:check -x test with the fix and the primary test (google-java-format, ecj lint, forbidden APIs): BUILD SUCCESSFUL. On ea57360912ed the same change also made the MoreLikeThisQuery and XML LikeThisQuery probe pass.
limit = min(maxQueryTerms, #terms, IndexSearcher.getMaxClauseCount()) in createQueue (+6/-2 lines). maxQueryTerms above the limit then behaves exactly like maxQueryTerms equal to the limit, and the queue never holds entries that cannot be used.
Trade-offs
retrieveInterestingTerms(...) also goes through createQueue, so it would return at most getMaxClauseCount() terms although it builds no query; keeping it unchanged needs the clamp on the like(...) paths only. With setBoost(true) the boosts of a truncated query are normalised by the lowest kept score instead of the lowest selected one. When several terms tie at the cut, fix A and fix B can keep different ones of them (the comparator has no tie-breaker); both keep a top-scoring set.
What the checks validated
Gradle with assertions on, the whole :lucene:queries:test with the 4 repro classes: 55 classes, 520 tests, 0 failures. The independent fact check also ran it with assertions off on the 4 repro classes and TestMoreLikeThis: 19 tests, 0 failures, 1 skipped. Not run through check.
Also keeps the best terms.
Trade-offs
Changes the clause order that testSmallSampleFromCorpus asserts and, unless adjusted, the boost normaliser. Not run.
A javadoc note on setMaxQueryTerms and MoreLikeThisQuery.setMaxQueryTerms.
Trade-offs
A legitimate maintainer choice, but the silent, non-monotonic truncation stays for anyone who misses the note, including XML queries that set the attribute. Not run.
Elasticsearch's and OpenSearch's own copies of this class (XMoreLikeThis) have the same loop over a lowest-first queue, so a Lucene fix will not reach them; this is from reading their source, not from a run.
The hunt plan flagged the pop order from code reading. A random comparison of the real class with a reference model written from the javadoc then failed its top-terms check (R3) in 851 of 118,800 checks, all at the clause limit; a small exhaustive model confirmed the exact condition, and the finder wrote a public-API JUnit test.
The method in full182 words
Method: bounded random translation validation against a reference semantics (the hunt's "semi-formal" tier).
- The hunt plan noted from code reading that
createQueryadds clauses in the pop order of the score queue and stops at the firstTooManyClauses, and its oracle asked whether MoreLikeThis keeps a lower-scoring term while dropping a higher-scoring one, "incl. at the clause limit". So the lead came from reading; the comparison confirmed it by execution. - The finder wrote a reference model of MoreLikeThis's term selection from its javadoc and compared it with the real queries jar on random inputs over the
like(int),like(Map)andlike(String, Reader)entry points: check R3, "the kept terms are the top-scoring eligible terms", failed 851 times in 118,800 checks (seed DEADBEEF), all at the clause limit and none elsewhere. - A bounded-exhaustive model of
createQueue/createQuery(61,208 cases) confirmed the condition: a failure exactly when the clause limit is belowmin(maxQueryTerms, #terms)and a dropped term outscores a kept one (18,480 cases); a best-first mutant fails none. - The finder then wrote the public-API JUnit test, and independent checkers reproduced it.
No prior report found (a 51-query search-tool run by the finder and three of 59 queries each, 19 manual GitHub searches, 12 JIRA queries, list archives and 18 web queries, 2026-10-26). The nearest item is LUCENE-5795 itself (#6857), whose discussion never mentions the clause limit.
Searches4 searches
Registry verdict new: nobody else has reported or discussed this bug. 32 hits were assessed; none is this bug. Nearest items: #6857 / LUCENE-5795 (the introducing change; its 9 comments discuss the O(n) selection, its test and the commits, never the clause limit), #8931 / LUCENE-7880 (open; a per-query clause limit, naming MoreLikeThis only as a user of the limit), #14761 (2025; replaced the queue class and kept the ascending order), and open PRs #940 and #16847, which touch MoreLikeThis.java but not createQuery. Elasticsearch's and OpenSearch's forks carry the same code with no report there; Lucene.NET ports 4.8.1, before the change. Limits: keyword search only; in two of the three 59-query runs the tool left its 6 web queries pending, and the manual web queries cover them only in part (all 6 for one run, 2 for the other); some Stack Overflow pages could not be read in full; web search was throttled at times.
| Where | When | How | Found |
|---|---|---|---|
| apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; downstream repos; web | 2026-10-26 | a scripted prior-report search: an earlier 51-query run by the finder, then three runs of 59 queries each (in two runs the web queries were left pending and partly run by hand): the clause-limit symptom; the same root cause seen as reversed order or boost normalisation; the introducing change and the forks | no report; nearest LUCENE-5795 (#6857), the introducing change |
| GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET) | 2026-10-26 | 19 manual searches; the changed files of all 406 open apache/lucene PRs (PRs with more than 100 files listed in full, e.g. #16847 with 438) | no report; only #940 and #16847 touch MoreLikeThis.java, not createQuery |
| JIRA (LUCENE, SOLR) and list archives (solr-user@, users@solr, java-user@, dev@, commits@) | 2026-10-26 | 12 JQL queries; every MoreLikeThis-subject mail since 2014-07 | no report |
| Web (Q&A sites, forums) | 2026-10-26 | 18 queries, including a forum search API | no report; pages about the bounded queue or other MoreLikeThis topics |
Introduced in 4.10.0 by LUCENE-5795 (1427d574d3d on branch_4x, 173a44e67c7 on trunk, 2014-07-10), which made the term queue lowest-first. 4.9.0 and 4.9.1 pass the repro; 4.10.0 through 10.5.2 and main fail. Swapping only MoreLikeThis.java flips the result both ways.
Versions tested8 rows
Introduced by LUCENE-5795 "More Like This: ensures selection of best terms is indeed O(n)" (#6857; an SVN-era JIRA patch committed by @s1monw, CHANGES credits Alex Ksikes and Simon Willnauer): 173a44e67c7c3c1a9ffbe7259ea8b45f1f53b015 on trunk (first in 6.0.0) and 1427d574d3dae460d638b8d6584116baf4756214 on branch_4x (first in 4.10.0), both 2014-07-10. It bounded the queue to min(maxQueryTerms, #terms), flipped FreqQ.lessThan from fa > fb to a.score < b.score, and removed createQuery's own maxQueryTerms stop, but kept the pop-and-break loop that dates from the first check-in (7952217cea6, 2005-01-17). Later edits kept the behaviour: LUCENE-6570, LUCENE-6590, LUCENE-8811 (the limit moved to IndexSearcher in 9.0), #14761 (2025, main only: PriorityQueue.usingComparator, still ascending). Upstream branch_10_6 and branch_10x have the same lowest-first queue (still the FreqQ class) and the same pop-and-break loop; nothing has fixed it.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | all 8 repro tests fail through gradle, assertions on and off |
main ea57360912ed | affected | fail: the finder (seed DEADBEEF -ea and -da, the adjudication's 5EED1234 -ea), the independent reproducer (DEADBEEF and C0FFEE11, -ea and -da), the randomized oracle (-ea) and the reachability probe (-ea and -da) |
| 10.5.2, 9.12.3 | affected | the finder's, the reproducer's and the probe's tests fail with -ea; the finder's also with -da |
| 10.0.0, 8.11.4, 7.7.3, 6.0.0, 5.0.0, 4.10.4 | affected | the finder's test (version variants) fails |
| 4.10.0 | first affected | fails 2 of 2 |
| 4.9.0, 4.9.1 | last unaffected | pass 2 of 2 (best-first queue) |
| 4.9.1 + the LUCENE-5795 MoreLikeThis.java | file swap | fails; 4.10.0 with LUCENE-5795 reverse-applied passes |
fix A on a9779ef3c193 | not affected | the whole queries module, repros included: 520 tests, 0 failures, assertions on and off |
An independent reproducer, a randomized oracle (wrong in exactly the 229 truncating checks of 600), a reachability probe through MoreLikeThisQuery and the XML parser, cross-model reviews and an adjudication. Every re-run failed on main with assertions on and off and passed with the fix; one review could not run code and voted not real on procedure only.
Checks and verdicts343 words
Each check ran or read on its own; the registration check re-ran the tests, and this doc re-ran them through gradle on a9779ef3c193.
| Check | Verdict | What it found |
|---|---|---|
| finder (reference-model comparison, JUnit) | candidate | JUnit fails on main (-ea, -da), 10.5.2, 9.12.3, 8.11.4 and 4.10.4; passes on 4.9.1; with the fix the repro and upstream TestMoreLikeThis pass. |
| cross-model reviews, intended and refute lenses | real, medium / not real, low (procedural) | An independent reviewer from another model family (OpenAI GPT-5.6), reading only. The refute lens could not run code and voted not real on procedure only while its cited evidence supports the bug; one control in the finder's lowered-limit test (maxQueryTerms 2 under the same limit) came from these reviews; they ran after the candidate was filed and before the adjudication. |
| independent reproducer | real | Own test, own package: 1024 keeps all 50 top words, 1100 keeps none; limit 3 gives {d, e, f}. Fails on main (-ea, -da), 10.5.2 and 9.12.3; passes with the fix. |
| cross-model review, root-cause lens + oracle | real, medium | Root cause agrees, inputs in contract (the reviewer only read code). A separate randomized oracle, written and run separately, is wrong in 229 of 600 checks, exactly the truncating ones, and in 0 with the fix. |
| intended-behaviour devil's advocate | real, low-to-moderate severity | Built the upper-bound and heap-backstop case and showed why it fails; its probe through MoreLikeThisQuery and the XML LikeThisQuery fails on main, 10.5.2 and 9.12.3 and passes with the fix. |
| adjudication | confirmed, high | Re-ran the reproducer's test (seed C0FFEE11, -ea and -da) and the finder's (seed 5EED1234, -ea): all fail. |
| this doc's gradle runs | stock fails, fixes pass | Stock main: 8 of 8 repro tests fail with assertions on and off. Fix A: the whole queries module, 520 tests, 0 failures, on and off; static checks pass. Fix B: 520 tests, 0 failures (assertions on); the independent fact check also ran it with assertions off on the repro classes and TestMoreLikeThis: 0 failures. |
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 425 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,333 words
- Registry title
- MoreLikeThis keeps the lowest-scoring selected terms when the query reaches the boolean clause limit, so the truncated query silently drops the most interesting terms (regression since 4.10.0)
- Area
- queries / MoreLikeThis query construction (MoreLikeThis.createQuery truncation at IndexSearcher.getMaxClauseCount)
- Priority
- Low, rank 453: MoreLikeThis keeps the lowest-scoring terms only when maxQueryTerms exceeds the clause limit, a rare setting that degrades relevance rather than matching, so it ends the suggestion and relevance-quality rows.
- Impact class
- silent wrong results
- Impact note
- Applications whose MoreLikeThis selects more terms than IndexSearcher.getMaxClauseCount() allows (maxQueryTerms set above the global clause limit, defaults 25 and 1024, on a like-text or source document with more eligible terms than the limit, or a lowered global clause limit) get, on every such call, with -ea and -da, the default codec and searcher and no scale, a query made of the lowest-scoring getMaxClauseCount() selected terms and no error, through MoreLikeThis.like(...), MoreLikeThisQuery rewritten by IndexSearcher and the XML query parser's LikeThisQuery maxQueryTerms attribute; the effect is worse relevance that is non-monotonic in maxQueryTerms (in the independent reproduction's test, raising it from 1024 to 1100 removed all 50 top words), not corruption, an exception or a hang.
- Summary
- You let MoreLikeThis select more terms than the global boolean clause limit, for example setMaxQueryTerms(2000) on a long text under the default limit of 1024, and call like(...). You expect the query, which createQuery deliberately truncates to the clause limit instead of failing, to keep the 1024 most interesting (highest tf*idf) of the selected terms, as it did up to 4.9.1. Instead createQuery pops its queue lowest score first and stops at the first TooManyClauses, so the query holds the 1024 lowest-scoring selected terms and none of the best ones (in the repro, 0 of the 76 top words survive).
- Introduced
- 173a44e67c7c3c1a9ffbe7259ea8b45f1f53b015 (trunk, svn r1609474; the line main descends from, shipped from 6.0.0 on) and its branch_4x backport 1427d574d3dae460d638b8d6584116baf4756214 (svn r1609493, shipped in 4.10.0 and 5.x): LUCENE-5795 'More Like This: ensures selection of best terms is indeed O(n)' (now apache/lucene#6857; a JIRA patch committed by s1monw, CHANGES credits Alex Ksikes and Simon Willnauer; SVN era, no pull request; CHANGES 4.10.0 'Optimizations') [read: git history clone; exec: the 1427d574d3d MoreLikeThis.java shadowed onto 4.9.1 fails 2 of 2, 4.10.0's file with 1427d574d3d reverse-applied passes 2 of 2]. It bounded the term queue to min(maxQueryTerms, #terms), flipped it from highest-first (FreqQ.lessThan 'fa > fb') to lowest-first ('a.score < b.score') and removed createQuery's own maxQueryTerms stop, but kept createQuery's pop-and-break-on-TooManyClauses loop, which dates from the first MoreLikeThis check-in (7952217cea6, 2005-01-17) and was correct only for a best-first queue; the issue thread (9 comments) never mentions the pop order or the clause limit. Later edits kept the behaviour: LUCENE-6570 (4aafacfbef4, BooleanQuery.Builder), LUCENE-6590 (962313b83ba, BoostQuery), LUCENE-8811 (53f56fb7ad4, the clause limit moved to IndexSearcher in 9.0), #14761 (a7704f5242d, 2025, FreqQ -> PriorityQueue.usingComparator, same ascending order) and ae82130c71b (catch parameter renamed to _) (2014-07-10)
- First affected version
- 4.10.0 [exec: fails 2 of 2; 4.9.0 and 4.9.1 pass 2 of 2, 4.9.1 being the last unaffected release (cut from branch_4_9 after 4.10.0, without the change)]
- Affected versions
- every release from 4.10.0 through 10.6.0 (10.6.0 by reading) and main ea57360912ed [exec, -ea: 4.10.0, 5.0.0, 6.0.0, 7.7.3, 8.11.4 (legacy test variant), 9.12.3, 10.0.0, 10.5.2 and main fail 2 of 2; 4.10.4 fails the 4.x lowered-limit variant; -da: 9.12.3, 10.5.2 and main fail 2 of 2 (4.x test framework refuses -da)]; not affected: 4.9.1 and older (4.9.0 and 4.9.1 [exec]; 2.4.0, 3.6.2 and 4.0.0 best-first by code reading); branch_10_6 (2763db11b0a, in the released 10.6.0) and the unreleased branch_10x (1309534296e) have the same code [read]; not fixed upstream [read, 2026-10-26: upstream main a9779ef3c19 has a byte-identical MoreLikeThis.java; of the 406 open PRs only #940 and #16847 touch MoreLikeThis.java, and neither changes createQuery]
- 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; also reached by code reading
- Upstream and prior reports
- none found (2026-10-26, read-only, the prior-report record verdict new: three scripted prior-report searches, each with 24 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 6 web queries (the clause-limit symptom; the same LUCENE-5795 root cause seen as pop order, retrieveInterestingTerms order and boost normalisation; the introducing change and the downstream forks); 19 manual GitHub searches over apache/lucene, Elasticsearch, OpenSearch, Solr and Lucene.NET; the changed-file lists of all 406 open apache/lucene PRs; 12 JQL queries (LUCENE, SOLR); list-archive searches over solr-user@, users@solr, java-user@, dev@ and commits@; 18 web queries; git history of MoreLikeThis.java); 32 hits assessed, none the same bug. Nearest: #6857 / LUCENE-5795 (the introducing change; its 9 comments discuss only O(n) selection); #8931 / LUCENE-7880 (open, per-query clause limit; names MoreLikeThis only as a class that would need it); #14761 (2025, kept the ascending queue); open PRs #940 (tf in createQueue) and #16847 (long doc ids) touch MoreLikeThis.java but not createQuery. Downstream: Elasticsearch's and OpenSearch's forked XMoreLikeThis.addToQuery have the same pop-and-break code with a lowest-first queue; no report there; Lucene.NET ports 4.8.1, before the change. Not fixed upstream (main a9779ef3c19 [read])
- Lucene commit tested
- main
a9779ef3c193(gradle in a scratch worktree: the repro tests stock with assertions on and off, fix A on the whole queries module with assertions on and off, fix B on the whole module with assertions on, the module's static checks with fix A) and mainea57360912ed(hunt, independent reproducer, randomized oracle, reachability probe, adjudication, several seeds, assertions on and off); the release jars 4.9.0, 4.9.1, 4.10.0, 4.10.4, 5.0.0, 6.0.0, 7.7.3, 8.11.4, 9.12.3, 10.0.0 and 10.5.2, and single-file swaps ofMoreLikeThis.javaon 4.9.1 and 4.10.0. JDK 25.0.3, Linux aarch64.
- Finding record
lucene/queries/src/java/org/apache/lucene/queries/mlt/MoreLikeThis.java:54-84 (at ea57360912ed) (class javadoc: 'maximize a tf*idf score'), :225-232 (DEFAULT_MAX_QUERY_TERMS = 25, 'Return a Query with no more than this many terms', @see IndexSearcher#getMaxClauseCount), :513-530 (get/setMaxQueryTerms: only a maximum, no validation), :593-616 (createQuery: pops q, adds SHOULD TermQuery clauses, 'catch (IndexSearcher.TooManyClauses _) { break; }' at :611-613), :624-629 (createQueue: limit = min(maxQueryTerms, #terms), PriorityQueue.usingComparator(limit, FloatComparator.comparing(st -> st.score)), lowest score on top), :875-876 (retrieveTerms(Reader, String) @return 'ordered by score, with the highest scoring, or best entry, first', stale since LUCENE-5795)lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:76-84 (at ea57360912ed) (Builder.add throws TooManyClauses once clauses.size() >= IndexSearcher.maxClauseCount; heap-protection comment)lucene/core/src/java/org/apache/lucene/search/MultiTermQuery.java:150-169 (at ea57360912ed) (TopTermsScoringBooleanQueryRewrite caps at getMaxClauseCount() and keeps the top-scoring terms; cited by the intended-behaviour check)lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/LikeThisQueryBuilder.java:89-90 (at ea57360912ed) (maxQueryTerms attribute from the XML query; the intended-behaviour check and the security record)lucene/CHANGES.txt:11916-11918 (at ea57360912ed) (LUCENE-5795 under 4.10.0: 'MoreLikeThisQuery now only collects the top N terms')
- The finding in 30 seconds
a Lucene main checkout lucene/queries/src/java/org/apache/lucene/queries/mlt/MoreLikeThis.java:593-616 (createQuery; catch at 611-613), 624-677 (createQueue; comparator at 628-629)a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:76-84
- Who is affected
a Lucene main checkout lucene/queries/src/java/org/apache/lucene/queries/mlt/MoreLikeThis.java:157, 165, 173, 225-232, 528-530, 679-686, 819a Lucene main checkout lucene/queries/src/java/org/apache/lucene/queries/mlt/MoreLikeThisQuery.java:44, 60-79 (76-77: minimumNumberShouldMatch from the clause count)a Lucene main checkout lucene/queryparser/src/java/org/apache/lucene/queryparser/xml/builders/LikeThisQueryBuilder.java:36, 89-90a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:245-261a Lucene main checkout (grep of setMaxQueryTerms outside the test sources: only MoreLikeThis, MoreLikeThisQuery and LikeThisQueryBuilder)
- Why it looks unintended
a Lucene main checkout lucene/queries/src/java/org/apache/lucene/queries/mlt/MoreLikeThis.java:63-64, 79, 225-232, 521-530, 609-615, 875-876, 895, 920a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:79-84a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/MultiTermQuery.java:146-169a Lucene main checkout lucene/CHANGES.txt:11932-11934 (LUCENE-5795 entry under 4.10.0 Optimizations)a Lucene main checkout lucene/queries/src/test/org/apache/lucene/queries/mlt/TestMoreLikeThis.java:131, 390, 545github.com/apache/lucene/issues/8931 (read-only, 2026-10-28: the @jpountz comment)
- Suggested fix
a Lucene main checkout lucene/queries/src/java/org/apache/lucene/queries/mlt/MoreLikeThis.java:593-616, 624-677, 886-924 (retrieveInterestingTerms through createQueue)
- TestMoreLikeThisClauseLimit.java (JUnit test):
repro/LFV-571/src/org/apache/lucene/queries/mlt/TestMoreLikeThisClauseLimit.java(68 lines)
A step-by-step picture of the repro's example, made in parallel with this doc.
assets/LFV-571/bug.png(425 KB)
