All findings · Lucene finding · LFV-571 · mlt-clause-limit-keeps-lowest-scoring-terms

MoreLikeThis keeps its lowest-scoring selected terms when its query hits the boolean clause limit

When MoreLikeThis selects more terms than a BooleanQuery may hold (1024 by default), it truncates the query but keeps the lowest-scoring terms: of 1100 selected terms, the 76 it drops are exactly the 76 best. A silent regression since 4.10.0 that needs maxQueryTerms above the clause limit.

Summary

Priority
Low, rank 453MoreLikeThis 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
silent wrong results
Since
4.10.0 (2014), LUCENE-5795introduced 2014-07-10 · over 12 years
Affected
4.10.0 to 10.6.0 and main (9 affected releases run; 10.6.0 by reading)
Status
documented
Found by
random comparison with a reference model

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

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.

MoreLikeThis's term queue for the repro's example: 1100 selected terms, 76 of them scoring twice the others, and room for 1024 clauses. Terms leave the queue lowest score first; the first 1024 become SHOULD clauses, the 1025th add throws TooManyClauses, and the loop stops, so the 76 best terms are never added. With maxQueryTerms 1024 the query holds all 76.
The repro's example: 1100 selected terms leave MoreLikeThis's queue lowest score first, the first 1024 become clauses, and the 76 best terms, still in the queue when the clause limit is reached, are dropped. Select the still to open it at full size. Generated with AI, checked against the code.
  1. 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). Set maxQueryTerms to 1100; the clause limit stays at its default, 1024.
  2. 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 maxQueryTerms 1024 that is what you get.
  3. You get 1024 clauses and none of the 76 score-2 words: the terms left out are exactly the best ones. No error. MoreLikeThisQuery and the XML LikeThisQuery do the same.
  4. Why: the term queue hands out the lowest score first. createQuery adds one clause per term in that order and stops at the first TooManyClauses, so everything still in the queue, the best terms, is dropped.
  5. Each step of maxQueryTerms above 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.

Who is affected

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.

Terms

  • 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 by tf * idf and keeps the best maxQueryTerms (default 25) as SHOULD clauses of a BooleanQuery.
  • The clause limit: a BooleanQuery.Builder refuses more than IndexSearcher.getMaxClauseCount() clauses (1024 by default) and throws IndexSearcher.TooManyClauses.

The bug

  1. createQueue keeps the top min(maxQueryTerms, #eligible terms) terms (eligible: passing the minTermFreq, minDocFreq and maxDocFreq filters) in a bounded priority queue whose top is the lowest score (MoreLikeThis.java:624-677, comparator at :628-629). The selection is right.
  2. createQuery pops that queue, so it sees the terms lowest score first, and adds a SHOULD TermQuery per term (:593-616).
  3. After 1024 clauses the builder throws (BooleanQuery.java:82-84), and createQuery does catch (IndexSearcher.TooManyClauses _) { break; } (:611-613). Every term still in the queue, the highest-scoring ones, is dropped.
  4. So whenever more terms are selected than the clause limit, the query loses the best #selected - limit terms. 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.

The numbers

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):

maxQueryTermsclausesthe 76 best terms in the query
76 to 1024maxQueryTermsall 76
1025102475
1050102450
1075102425
1100 to 200010240

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 MoreLikeThis select more terms than the global clause limit: maxQueryTerms above 1024 on a source with more than 1024 eligible terms, through MoreLikeThis.setMaxQueryTerms, MoreLikeThisQuery.setMaxQueryTerms (rewritten by IndexSearcher) or the XML query parser's LikeThisQuery, whose maxQueryTerms attribute comes from the query text; or a global limit lowered below the selection size (IndexSearcher.setMaxClauseCount is public).
  • Not by default. The defaults are 25 terms (MoreLikeThis), 5 (MoreLikeThisQuery) and 20 (XML LikeThisQuery). The other in-tree users, the classification module's k-nearest-neighbour classifiers and Luke, never set maxQueryTerms, 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 - 1024 terms 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, and MoreLikeThisQuery also sets minimumNumberShouldMatch from the clause count. Nothing fails, nothing is corrupted, and the query never exceeds the clause limit.
  • How likely. Rare: large maxQueryTerms values 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.

Minimal repro

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.

TestMoreLikeThisClauseLimit.java (JUnit test)68 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.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();
  }
}
Command
# 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 -ea and -da.

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

More files, environment, full output36 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# 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)

Why it looks unintended

  • 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: maxQueryTerms 1024 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 the createQuery loop 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); createQuery catches TooManyClauses and 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 only MoreLikeThis.java flips 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 at getMaxClauseCount() (MultiTermQuery.java:146-169).
  • Tests. No lucene/queries test reaches TooManyClauses in MoreLikeThis; testTopN uses 25 terms under 1024. testSmallSampleFromCorpus asserts the lowest-first clause order and testCustomFrequency relies 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.

Suggested fix

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.
  • retrieveInterestingTerms and createQueue are untouched; the catch (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 - limit terms; stock already pops one term that does not fit, so the net extra is #selected - limit - 1 pop() calls (O(log n) each). Not benchmarked.
  • It edits the same bestScore lines 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.

B: clamp the selection to the clause limit (createQueue) (validated by the checks)

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.

Pop into an array and add the clauses best-first

Also keeps the best terms.

Trade-offs

Changes the clause order that testSmallSampleFromCorpus asserts and, unless adjusted, the boost normaliser. Not run.

Document that maxQueryTerms must not exceed the clause limit

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.

Downstream note

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.

How it was found

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 createQuery adds clauses in the pop order of the score queue and stops at the first TooManyClauses, 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) and like(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 below min(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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; dev@, java-user@, issues@, commits@; LUCENE JIRA archive; downstream repos; web2026-10-26a 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 forksno report; nearest LUCENE-5795 (#6857), the introducing change
GitHub (apache/lucene, Elasticsearch, OpenSearch, Solr, Lucene.NET)2026-10-2619 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-2612 JQL queries; every MoreLikeThis-subject mail since 2014-07no report
Web (Q&A sites, forums)2026-10-2618 queries, including a forum search APIno report; pages about the bounded queue or other MoreLikeThis topics

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedall 8 repro tests fail through gradle, assertions on and off
main ea57360912ed affectedfail: 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 affectedthe 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 affectedthe finder's test (version variants) fails
4.10.0 first affectedfails 2 of 2
4.9.0, 4.9.1 last unaffectedpass 2 of 2 (best-first queue)
4.9.1 + the LUCENE-5795 MoreLikeThis.java file swapfails; 4.10.0 with LUCENE-5795 reverse-applied passes
fix A on a9779ef3c193 not affectedthe whole queries module, repros included: 520 tests, 0 failures, assertions on and off

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder (reference-model comparison, JUnit) candidateJUnit 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 realOwn 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, mediumRoot 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 severityBuilt 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, highRe-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 passStock 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.

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

Finding record

LFV-571, snapshot of 2026-11-06

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 main ea57360912ed (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 of MoreLikeThis.java on 4.9.1 and 4.10.0. JDK 25.0.3, Linux aarch64.

Lucene and upstream sources

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, 819
  • a 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-90
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/IndexSearcher.java:245-261
  • a 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, 920
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/BooleanQuery.java:79-84
  • a Lucene main checkout lucene/core/src/java/org/apache/lucene/search/MultiTermQuery.java:146-169
  • a 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, 545
  • github.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)

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

About the diagrams

A step-by-step picture of the repro's example, made in parallel with this doc.

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.