All findings · Lucene finding · LFV-756 · uh-highlightwithoutsearcher-ignores-maxlength

UnifiedHighlighter.highlightWithoutSearcher ignores maxLength and highlights past it

UnifiedHighlighter documents that text is cut to maxLength (default 10,000 chars) before highlighting; the searcher path cuts it, highlightWithoutSearcher does not. With withMaxLength(20), Alpha beta gamma. Delta epsilon. Lucene is here. gives <b>Lucene</b> is here. (a match at char 33) instead of Alpha beta gamma. .

Summary

Priority
Low, rank 556HighlightWithoutSearcher ignores the documented maxLength on a rarely used entry point and only produces a plausible snippet from past the bound plus extra work, so this display-only bug closes the UnifiedHighlighter output rows after LFV-599.
Impact
minor
Since
6.3.0 (2016, LUCENE-7438, when the class was added)introduced 2016-10-04 · over 10 years
Affected
6.3.0 to 10.5.2, 10.6.0 (by reading) and main; 11 releases (plus main) executed
Status
documented
Found by
code reading of each documented option on each entry point, then a differential JUnit (non-formal)

AI-assisted page: this finding was first signalled by code reading or testing 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

highlightWithoutSearcher passes the caller's text straight to the field highlighter. The only cut to maxLength is in the stored-field loader (LimitedStoredFieldVisitor), which only the searcher entry points use, and the re-analysis code that follows assumes "content is already truncated to maxLength".

Background: maxLength and the two ways to highlight

  1. UnifiedHighlighter turns a document's text into a short snippet with the query's matches marked, such as <b>Lucene</b> is here..
  2. maxLength (default 10,000 chars; withMaxLength(n) changes it) bounds how much text it looks at: "Content will be truncated to this size before highlighting." A match after it is not shown; the first passage comes back instead.
  3. There are two ways in. The searcher entry points (highlight, highlightFields) load each hit's stored field. highlightWithoutSearcher, on a highlighter built with builderWithoutSearcher, takes the text as a String and re-analyzes it; no index is needed.
Diagram of the bug: the text 'Alpha beta gamma. Delta epsilon. Lucene is here.' (48 chars, the only match 'Lucene' at char 33) with maxLength 20 goes down two paths. Searcher path: the stored-field loader cuts it to its first 20 chars, no match is left, and the snippet is 'Alpha beta gamma. '. highlightWithoutSearcher: the text reaches the highlighter whole, is re-analyzed, and the snippet is '<b>Lucene</b> is here.'.
The same 48-char text with maxLength 20 on both paths: through a searcher the stored-field loader cuts it to 20 chars and the snippet is Alpha beta gamma. ; through highlightWithoutSearcher nothing cuts it, and the match at char 33 is highlighted. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You build a highlighter without an index, UnifiedHighlighter.builderWithoutSearcher(analyzer).withMaxLength(20).build(), and call highlightWithoutSearcher("body", query, text, 1) for the term lucene on the 48-char text Alpha beta gamma. Delta epsilon. Lucene is here.. Its only match starts at char 33.
  2. You expect the text cut to its first 20 chars before highlighting, as getMaxLength() documents: "Content will be truncated to this size before highlighting". The same text, query and option through an index and a searcher give Alpha beta gamma. , with no match.
  3. Instead you get <b>Lucene</b> is here.: all 48 chars were analyzed and the match past char 20 is highlighted, while getMaxLength() returns 20.
  4. The default is skipped too: on a 10,527-char text whose only match is at char 10,512, the searcher path returns Alpha beta gamma. and highlightWithoutSearcher returns <b>Lucene</b> is here..
  5. Why: the only cut is in the stored-field loader, which only the searcher entry points use. highlightWithoutSearcher hands the caller's String straight to the field highlighter, whose re-analysis code assumes "content is already truncated to maxLength".

Who is affected

Applications that call highlightWithoutSearcher with text longer than maxLength: any text over 10,000 chars by default. The snippet can come from past the bound, and the work grows with the whole text (58 ms for 1 million chars in one probe). Hits, counts and the searcher entry points are unaffected; the entry point itself is rarely used.

Mechanism, terms and numbers631 words

UnifiedHighlighter has a maxLength option, 10,000 chars by default, documented as "The maximum content size to process. Content will be truncated to this size before highlighting." With the default stored-field loader, the searcher entry points (highlight, highlightFields) cut the text while they load it. highlightWithoutSearcher, the entry point for a highlighter built with builderWithoutSearcher, takes the text from the caller and never cuts it: the whole text is analyzed, and a match or passage after maxLength can be returned. With withMaxLength(20), the 48-char text Alpha beta gamma. Delta epsilon. Lucene is here. gives <b>Lucene</b> is here. where the searcher path gives Alpha beta gamma. ; with the default, a match at char 10,512 of a 10,527-char text is highlighted. The work grows with the whole text (58 ms at 1 million chars and 217 ms at 4 million in one probe, against about 1 ms when the caller cuts it). Hits, counts and scores are correct, and no recorded run threw an exception. Present since the class was added in 6.3.0 (2016); a 4-line cut, with a javadoc sentence, passes the whole highlighter test suite.

Terms

  • maxLength: DEFAULT_MAX_LENGTH = 10000 (UnifiedHighlighter.java:100). getMaxLength(): "The maximum content size to process. Content will be truncated to this size before highlighting. Typically snippets closer to the beginning of the document better summarize its content." (:596-603). Builder.withMaxLength: "The text to be highlight is effectively truncated by this length." (:354). builderWithoutSearcher returns that same Builder (:453-455).
  • Searcher entry points: the public highlight (:747, :766) and highlightFields (:796, :828, :854) overloads, which all reach the protected highlightFieldsAsObjects (:890). It loads each hit's text with loadFieldValues (:958, :1382-1404); a subclass may override that loader.
  • highlightWithoutSearcher(field, query, content, maxPassages) (:1096-1108): "Highlights text passed as a parameter. ... This use-case is more rare. Naturally, the mode of operation will be OffsetSource#ANALYSIS" (:1079-1081), that is, the text is re-analyzed. Its javadoc says nothing about maxLength.

The bug

  1. On the searcher path, the default loadFieldValues reads stored fields through new LimitedStoredFieldVisitor(fields, MULTIVAL_SEP_CHAR, getMaxLength()) (:1409-1410), which keeps value.substring(0, Math.min(maxLength, value.length())) (:1446-1447) and caps later values of a multi-valued field by the remaining budget (:1450-1465). The cut text goes to fieldHighlighter.highlightFieldForDoc(leafReader, docId, content.toString()) (:987-988).
  2. highlightWithoutSearcher checks that the searcher is null and that content is not null, then calls highlightFieldForDoc(null, -1, content) with the caller's full String (:1104-1107).
  3. Nothing downstream cuts: FieldHighlighter and the offset strategies never read maxLength; the only getMaxLength() use in the class is the visitor above.
  4. With the default flags, the re-analysis is done by MemoryIndexOffsetStrategy (chosen at :1294-1302), whose getOffsetsEnum says "note: don't need LimitTokenOffsetFilter since content is already truncated to maxLength" (MemoryIndexOffsetStrategy.java:104). With phrases not position sensitive and both passage relevancy and weight matches off, TokenStreamOffsetStrategy re-analyzes the full text the same way (the fact check's probe ran both strategies: the same snippet).
  5. The break iterator, the scorer and the formatter then see the whole text, so the best passage can lie after maxLength, and the analysis work grows with the whole length.

The numbers

case (term query lucene, MockAnalyzer)through a searcherhighlightWithoutSearcher
withMaxLength(20), Alpha beta gamma. Delta epsilon. Lucene is here. (48 chars, match at 33)Alpha beta gamma. <b>Lucene</b> is here.
default 10,000, "Alpha beta gamma. " x 584 + "Lucene is here." (10,527 chars, match at 10,512)Alpha beta gamma. <b>Lucene</b> is here.
control: withMaxLength(1000), the 48-char text<b>Lucene</b> is here. (fact-check probe)<b>Lucene</b> is here.

Main 645d4fe14a20, assertions on and off; the same on the 10.5.2 and 10.0.0 jars.

Exposure and likelihood284 words
  • Who is hit. Every caller of highlightWithoutSearcher (a highlighter built with builderWithoutSearcher, or the deprecated constructor with a null searcher) whose text is longer than maxLength. With the default that is any text over 10,000 chars, such as a full article; with a small withMaxLength almost any text. Every such call skips the cut; the snippet differs when the chosen passage depends on text past maxLength.
  • Default vs opt-in. The entry point is opt-in and rare: its javadoc says "This use-case is more rare", the class is @lucene.experimental, and Lucene itself has no non-test caller. Once used, the default bound is skipped with no option set.
  • Effect on the snippet. A match or passage after maxLength can be returned where the searcher path returns the first passage. The snippet is valid text from the caller's own string.
  • Effect on the work. One probe (StandardAnalyzer, one TermQuery, default maxLength; time is the median of 5 calls, allocation the bytes the thread allocated in the last of them) measured 21 ms and 63.5 MiB for 100,000 chars, 58 ms and 635 MiB for 1,000,000 chars, and 217 ms and 2,538 MiB for 4,000,000 chars; about 1 ms and 6.4 MiB when the caller cuts the text to 10,000 first. Only these sizes, one analyzer and one query were measured: a sanity check on one machine, not a benchmark; the allocation is not retained memory.
  • Not affected. Hits, counts and scores; the searcher entry points with the default loader. No recorded run threw an exception or crashed, with assertions on or off.
  • Why nobody noticed. The output is a plausible snippet, and the upstream tests call highlightWithoutSearcher only on short text.

Minimal repro

The test shows that UnifiedHighlighter.highlightWithoutSearcher ignores maxLength: with withMaxLength(20) it still highlights a match at offset 33 of a 48-char text.

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

import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.index.Term;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestHighlightWithoutSearcherMaxLength extends LuceneTestCase {

  public void testMaxLength() throws Exception {
    try (StandardAnalyzer analyzer = new StandardAnalyzer()) {
      UnifiedHighlighter highlighter =
          UnifiedHighlighter.builderWithoutSearcher(analyzer).withMaxLength(20).build();
      // the only match, "Lucene", starts at offset 33, past maxLength 20
      Object snippet =
          highlighter.highlightWithoutSearcher(
              "body",
              new TermQuery(new Term("body", "lucene")),
              "Alpha beta gamma. Delta epsilon. Lucene is here.",
              1);
      assertFalse(
          "expected no match past maxLength 20, got: " + snippet,
          snippet.toString().contains("<b>Lucene</b>"));
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-756/, 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/highlighter/build/libs/lucene-highlighter-$V.jar
CP=$CP:$LUCENE/lucene/memory/build/libs/lucene-memory-$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.search.uhighlight.TestHighlightWithoutSearcherMaxLength
Expected vs actual
Expected: the text is cut to 20 chars before highlighting (getMaxLength javadoc), so there is no <b>Lucene</b>; actual: <b>Lucene</b> is here., with -ea and -da.

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

More files, environment, full output40 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 no match past maxLength 20, got: <b>Lucene</b> is here.
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.assertTrue(Assert.java:42)
       org.junit.Assert.assertFalse(Assert.java:65)
       org.apache.lucene.search.uhighlight.TestHighlightWithoutSearcherMaxLength.testMaxLength(TestHighlightWithoutSearcherMaxLength.java:37)

Why it looks unintended

  • The option's javadoc has no exception: "Content will be truncated to this size before highlighting" (getMaxLength); withMaxLength says the same on the Builder that builderWithoutSearcher returns.
  • The re-analysis code on this path relies on it ("content is already truncated to maxLength"), written in the same 2016 commit as the method.
  • No test, javadoc or discussion exempts this path.
The full argument197 words
  • The contract is unconditional. getMaxLength (UnifiedHighlighter.java:596-603, wording from the original 2016 commit) and Builder.withMaxLength (:354, added in #412 / LUCENE-10197, 2021) describe the text that is highlighted, with no exception for any entry point. The highlightWithoutSearcher javadoc (:1078-1093) is silent on maxLength.
  • The code relies on it on this exact path. MemoryIndexOffsetStrategy.java:104 ("content is already truncated to maxLength") came in with highlightWithoutSearcher in 722e8271243 (then in AnalysisOffsetStrategy; moved by LUCENE-7526).
  • The 2021 builder review used this combination. In #412 the withMaxLength wording was suggested in review by @dsmiley, and the PR author's example in the same review was builderWithoutSearcher(indexAnalyzer).withMaxLength(100), with no remark that it has no effect.
  • Tests pin maxLength as visible on the searcher path. TestUnifiedHighlighterMTQ.testWithMaxLen (withMaxLength(25): "ask for 2 but we'll only get 1") and TestUnifiedHighlighter.testFormatWithMatchExceedingContentLength (withMaxLength(17)) assert snippets cut by it. The only highlightWithoutSearcher tests (TestUnifiedHighlighterReanalysis, TestLengthGoalBreakIterator, TestUnifiedHighlighterStrictPhrases.testMatchNoDocsQuery) use short text and the default.
  • Precedent. maxLength edge cases of the predecessor PostingsHighlighter were fixed as bugs: LUCENE-5166 (a term spanning the boundary) and LUCENE-5032 (multi-valued fields exceeding it). No CHANGES, MIGRATE or package entry exempts this path.
The devil's-advocate check220 words

The strongest case for "works as designed" (the intended-behaviour checker, and this hunt's own earlier triage, which had left it as a documentation gap): maxLength is a stored-value loading bound. It is enforced only in LimitedStoredFieldVisitor, whose javadoc says it "honors a max length to retrieve"; a subclass that overrides loadFieldValues also gets uncut text (executed: with a searcher and withMaxLength(20), an override returning the full text gives <b>Lucene</b> is here.); highlightWithoutSearcher loads nothing, its caller owns the String, its javadoc is silent, the class is experimental, and the behaviour has not changed in ten years, so a code change alters what existing callers get.

Why we still read it as a bug: the option's own javadoc describes the highlighted text, not loading; the module's code on this exact path assumes the cut (this shows that the documentation and the implementation disagree, not that the authors considered caller-supplied text and chose to cut it); the default 10,000 is skipped with stock settings, while the override case needs a user to replace the loader on purpose; and we found no doc, test or discussion that records the loading-only reading. Our reading: not intended. The reviewers rated the intended-behaviour case weak to moderate, so a maintainer may fairly prefer to document the gap; the fix section offers both.

Suggested fix

Recommended Truncate in highlightWithoutSearcher (recommended, validated)

Cut content to getMaxLength() at the start of highlightWithoutSearcher, as the stored-field loader does, and say so in its javadoc (+6/-1). With it the repro passes and so does the whole highlighter test task (725 tests, assertions on and off). Callers who pass longer text today then get only its first maxLength chars highlighted, worth a CHANGES note.

Objects.requireNonNull(content, "content is required");
if (content.length() > getMaxLength()) {
  // like LimitedStoredFieldVisitor on the searcher path; the offset strategies rely on it
  content = content.substring(0, getMaxLength());
}
Set<Term> queryTerms = extractTerms(query);
All options and trade-offs3 options · 630 words

Two code fixes were applied in a scratch worktree of 645d4fe14a20 and tested with gradle; the documentation-only option was not run.

Truncate in highlightWithoutSearcher (recommended, validated)

Four lines and one javadoc sentence in UnifiedHighlighter.java (the full patch):

--- a/lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java
+++ b/lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java
@@ -1078,7 +1078,8 @@ public class UnifiedHighlighter {
   /**
    * Highlights text passed as a parameter. This requires the {@link IndexSearcher} provided to this
    * highlighter is null. This use-case is more rare. Naturally, the mode of operation will be
-   * {@link OffsetSource#ANALYSIS}. The result of this method is whatever the {@link
+   * {@link OffsetSource#ANALYSIS}. As with stored values, {@code content} is truncated to {@link
+   * #getMaxLength()} before highlighting. The result of this method is whatever the {@link
    * PassageFormatter} returns. For the {@link DefaultPassageFormatter} and assuming {@code content}
    * has non-zero length, the result will be a non-null string -- so it's safe to call {@link
    * Object#toString()} on it in that case.
@@ -1102,6 +1103,10 @@ public class UnifiedHighlighter {
               + " without an IndexSearcher.");
     }
     Objects.requireNonNull(content, "content is required");
+    if (content.length() > getMaxLength()) {
+      // like LimitedStoredFieldVisitor on the searcher path; the offset strategies rely on it
+      content = content.substring(0, getMaxLength());
+    }
     Set<Term> queryTerms = extractTerms(query);
     return getFieldHighlighter(field, query, queryTerms, maxPassages)
         .highlightFieldForDoc(null, -1, content);
Objects.requireNonNull(content, "content is required");
if (content.length() > getMaxLength()) {
  // like LimitedStoredFieldVisitor on the searcher path; the offset strategies rely on it
  content = content.substring(0, getMaxLength());
}
Set<Term> queryTerms = extractTerms(query);

Trade-offs

  • Behaviour change for callers who pass more than maxLength chars today (10,000 by default) and rely on highlights beyond it; they would need withMaxLength(...).
  • The cut is in UTF-16 units, like the searcher path's, so it can split a supplementary character; that is the separate bug LFV-775 (the same cut on the searcher path). One surrogate-safe helper can serve both sites.
  • getMaxLength() is overridable and is read twice; a local variable would read it once.
  • Not tested at the edges: withMaxLength(0) (allowed), a maxLength near Integer.MAX_VALUE, an overridden getMaxLength(), or a supplementary character at the cut.

What the checks validated

  • The paste-ready test: 3 of 3 pass with gradle, assertions on and off, and 30 of 30 with 10 random seeds per method; also through the JUnit runner with the patched class.
  • The whole :lucene:highlighter:test task with the test added: 35 classes, 725 tests, 0 failures, assertions on and off (seed DEADBEEF), including TestUnifiedHighlighterReanalysis, TestLengthGoalBreakIterator and TestUnifiedHighlighterStrictPhrases.
  • :lucene:highlighter:check -x test (google-java-format, ecj lint, forbidden APIs) passes.
  • The hunt's version of the same cut (no javadoc) made the finder's, the reproducer's, the devil's advocate's and the history probe's tests pass.

One cut for every source of text (validated)

A private truncateToMaxLength(String) applied in highlightWithoutSearcher and at the searcher path's highlightFieldForDoc call (UnifiedHighlighter.java:987-988), +8/-2. It also bounds text that a loadFieldValues override returns.

Trade-offs

A second behaviour change, for subclasses that override loadFieldValues (whose javadoc says "a subclass can change the source" and says nothing about maxLength); arguably a separate question. The upstream override test testCustomFieldValueSource uses short text and passes. Better as a follow-up to the recommended fix.

What the checks validated

  • The whole highlighter test task with the paste-ready test: 35 classes, 725 tests, 0 failures (assertions on); format and ecj lint pass.
  • The loader-override observation changes as intended: an override returning the full 48-char text with withMaxLength(20) gives <b>Lucene</b> is here. on main and Alpha beta gamma. with this fix.

Document the gap instead (not run)

Say in the highlightWithoutSearcher, getMaxLength and withMaxLength javadocs that this entry point does not apply maxLength and the caller must cut content, and correct the comment in MemoryIndexOffsetStrategy.

Trade-offs

No behaviour change, but the default bound stays a no-op on this path, and getMaxLength() keeps reporting a value that has no effect there.

Downstream note

A service that relies on maxLength to bound highlighting of long untrusted text it passes to highlightWithoutSearcher gets no bound; cutting the text before the call restores it.

How it was found

Code reading (non-formal). A hunt unit checked each documented UnifiedHighlighter option on each of its entry points and saw that maxLength is applied only by the stored-field loader, which highlightWithoutSearcher never calls. A JUnit comparing the same text through a searcher and without one confirmed it.

The method in full104 words
  • Unit. An inventory of override hooks and accessors that can bypass documented behaviour. One leg crossed each documented option with the entry points (the public highlight and highlightFields overloads, the protected highlightFieldsAsObjects they reach, and highlightWithoutSearcher).
  • Signal. Code reading: the only getMaxLength() use is newLimitedStoredFieldsVisitor, reached only from loadFieldValues, and highlightWithoutSearcher never calls it.
  • Witness. A differential public-API JUnit (the same text, query and maxLength through a searcher and without one) failed 1 of 1 on main with assertions on and off, and on 10.5.2 and 9.12.3; with the hunt's cut shadowed it passed (2026-10-27).
  • Independent checks followed (see "Independent checks").

Prior reports, history and checks

Prior reports

No prior report found: 142 non-web tool queries, 45 by the finder and 47 manual searches over GitHub, JIRA, the mailing lists and the web, plus the changed files of all 408 open PRs (2026-10-27), re-checked on GitHub on 2026-10-29. Closest: #412 (LUCENE-10197), which wrote the withMaxLength javadoc.

Searches4 searches

No report of this defect was found. The nearest items introduced the method, the comment or the withMaxLength javadoc, or call highlightWithoutSearcher on short text for other bugs. Not fixed upstream: the method body is unchanged on main 645d4fe14a20, branch_10_6 and branch_10x (read 2026-10-29). Open tracking issue #16918 lists the deprecated constructor and setters, including setMaxLength, for removal in 11.0, so a fix may need a textual rebase. Gaps: keyword search only, GitHub's search rate limit (one re-check query hit the rate limit), one web query throttled, private channels not searchable, no downstream code search.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene, apache/lucene-solr), JIRA archive, mailing lists (dev@, java-user@, issues@, commits@), downstream repositories2026-10-27two runs of a scripted prior-report search, 142 non-web queries (60 GitHub issue/PR, 6 commit, 4 downstream, 64 mailing-list, 8 JIRA), plus the finder's run (45 queries)no report
GitHub, lists.apache.org, JIRA, web (manual)2026-10-2747 searches and reads: 27 GraphQL searches over apache/lucene, apache/lucene-solr, apache/solr, two downstream search engines and all of GitHub; 7 list searches; 4 JIRA REST queries; 6 web searches; upstream file history on 4 branchesrelated items only: #412 / #11234 (LUCENE-10197, the Builder and the withMaxLength javadoc), #8490 (LUCENE-7438, the method), #8577 (LUCENE-7526, the comment's move); #8768, #8820, #9538, #10135 use the method on short text for other bugs
Open PRs2026-10-27the changed-file lists of all 408 open apache/lucene PRs; the diffs of the 3 that touch UnifiedHighlighter.java#12295, #13165, #16847 touch the file but not this method
GitHub issues and PRs (apache/lucene), re-check2026-10-29highlightWithoutSearcher, UnifiedHighlighter maxLength, withMaxLengthonly the four older bugs above, nothing, and #412; a fourth query hit the rate limit. No log was saved; the fact check's same-day re-run gave the same results for the first two queries and hit the rate limit on the third

History

Present since the class was added: 722e8271243 (LUCENE-7438, 2016-10-04), first released in 6.3.0. The method has never cut its input; the 2021 Builder (LUCENE-10197) added the withMaxLength javadoc without changing it. Executed on 11 releases from 6.3.0 to 10.5.2 and on main: all affected. Never fixed.

Versions tested7 rows

Introduced by 722e8271243 "LUCENE-7438: New UnifiedHighlighter" (2016-10-04, now GitHub issue #8490; proposed as apache/lucene-solr #79, closed without a GitHub merge and committed directly; branch_6x backport 4b6794368df). That commit added highlightWithoutSearcher passing content uncut, the getMaxLength javadoc, and the "content is already truncated to maxLength" comment, so this is not a regression. Later edits to the method (2e948fea300, LUCENE-7575; 6faa4f98e04, LUCENE-9570, a reformat) did not touch truncation. LUCENE-10197 (#412, b1b1a890d08 / a9f812c1cf9, 9.1.0) added the Builder, builderWithoutSearcher and the withMaxLength javadoc.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthis doc's test fails 2 of 3 (gradle and runner, assertions on and off); 20 of 30 with 10 random seeds
10.5.2, 10.0.0 affectedthe same (this doc's test, runner)
9.1.0, 9.12.3 affectedthe hunt's Builder-API JUnit (9.x variant) fails 1 of 1; this doc's test with only the 9.x accessor change fails 2 of 3 (fact check)
6.3.0 to 9.0.0 probea probe with the original setters fails on 6.3.0, 6.4.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4 and 9.0.0: 48 chars and 1 match seen with maxLength 20; the searcher control sees 20 chars and 0 matches
6.2.1 no codeno uhighlight package
branch_10_6, branch_10x readsame method body (read, not executed)
main + the fix not affectedthe test 3 of 3 pass; the highlighter test task 725 tests, 0 failures

Independent checks

The finder, an independent reproducer, an intended-behaviour devil's advocate, the adjudication and a credibility audit each ran a public-API test and said real. Reading-only reviews by another model family (OpenAI GPT-5.6) said real (medium) and not real (low: it had no run of its own). This doc re-ran it through gradle and validated two fixes.

Checks and verdicts436 words

The cross-model reviews could not run code; separate runs re-ran the finder's test, which failed on main in both; the intended lens's check also ran it with the fix, and it passed. Every other hunt check executed code on main a9779ef3c193; this doc's runs and its fact check used 645d4fe14a20.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidateDifferential JUnit fails 1 of 1 on main (assertions on and off), 10.5.2 and 9.12.3; passes with the hunt's cut.
cross-model review, refute lens (OpenAI GPT-5.6) not real on procedure only (low)Could not run Java, and it may not vote real without a run of its own. It agreed with the root cause; its one contrary evidence item was that the class is @lucene.experimental, and it rated the loading-bound reading weak. A separate re-run failed 1 of 1.
independent reproducer realOwn JUnit with a recording formatter: the formatter sees 48 chars (explicit 20) and 10,560 chars (default), with a match ending at 10,541; searcher controls pass; also 10.5.2, 9.12.3, 8.11.4 and 6.3.0; the fix passes 4 of 4.
cross-model review, intended lens (OpenAI GPT-5.6, reading only) real (medium)Works-as-designed argument weak; root cause agreed; inputs in contract. Separate runs fail on main and pass with the fix.
intended-behaviour devil's advocate real (medium-high)Built the loading-bound case and rejected it; the default-maxLength JUnit fails on main and 10.5.2; recorded that a loadFieldValues override also gets uncut text.
adjudication confirmed (high)Re-ran the independent reproduction's JUnit with a new seed on a clean checkout: 2 of 4 fail with assertions on and off, 4 of 4 pass with the fix. Impact must stay minor.
credibility audit keepRe-ran the finder's and the independent reproduction's tests with new seeds; code re-read; impact minor unchanged; a committer would take a code or a javadoc fix and push back on denial-of-service framing.
this doc reproduced; fixes validatedIts own 3-test JUnit fails 2 of 3 on main 645d4fe14a20 (gradle, assertions on and off), 10.5.2 and 10.0.0; both code fixes pass the whole highlighter test task.
fact checks of this doc bug holds; wording fixedOne check re-ran the test through gradle and the runner (main, 10.5.2, 10.0.0; 9.1.0 and 9.12.3 with the 9.x accessor change), both fixes over the whole highlighter test task, and a probe of the searcher-path control and of TokenStreamOffsetStrategy. A reading-only check by another model family (OpenAI GPT-5.6) re-read the code and logs. Both found the bug, mechanism and fixes correct, and found count and wording errors, now fixed.

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

Finding record

LFV-756, snapshot of 2026-11-06

Registry title
UnifiedHighlighter.highlightWithoutSearcher ignores maxLength (the default 10,000 included): it analyzes and highlights the whole supplied text, although getMaxLength and Builder.withMaxLength document that content is truncated to maxLength before highlighting
Area
highlighter / UnifiedHighlighter (highlightWithoutSearcher entry point, maxLength option)
Priority
Low, rank 556: HighlightWithoutSearcher ignores the documented maxLength on a rarely used entry point and only produces a plausible snippet from past the bound plus extra work, so this display-only bug closes the UnifiedHighlighter output rows after LFV-599.
Impact class
minor
Impact note
Hits every application that calls the rarely used, @lucene.experimental UnifiedHighlighter.highlightWithoutSearcher (no searcher, re-analysis offsets) with text longer than maxLength, the default of 10,000 chars included: the whole text is analyzed, a match or passage after maxLength can be returned, and the work grows linearly with the full text length (one probe: about 55 ms and 635 MB of transient allocation per million chars, against about 1 ms when the caller truncates), but there are no wrong hits or counts, no crash and no exception, with -ea and -da alike; the searcher entry points truncate correctly [exec: main, 10.5.2, 10.0.0, 9.12.3, 9.1.0, 9.0.0, 8.11.4, 7.7.3, 7.0.0, 6.6.6, 6.4.0, 6.3.0].
Summary
You build a UnifiedHighlighter without a searcher (builderWithoutSearcher, optionally withMaxLength(20)) and call highlightWithoutSearcher on a text longer than maxLength, expecting the text to be cut to its first maxLength chars before highlighting, as getMaxLength ('Content will be truncated to this size before highlighting') and withMaxLength document and as the searcher entry points do. Instead the whole text is analyzed and highlighted: with maxLength 20, the 48-char text 'Alpha beta gamma. Delta epsilon. Lucene is here.' gives '<b>Lucene</b> is here.' (its only match is at offset 33) where the searcher path returns 'Alpha beta gamma. ', and with the default maxLength of 10,000 a match at char 10,541 of a 10,560-char text is highlighted. The only truncation lives in the stored-field loader (LimitedStoredFieldVisitor), which this entry point never calls, although the re-analysis strategy it uses relies on it ('content is already truncated to maxLength').
Introduced
722e82712435ecf46c9868137d885484152f749b (LUCENE-7438 'New UnifiedHighlighter', GitHub issue #8490; proposed as apache/lucene-solr#79, which was closed without a GitHub merge, and committed directly; branch_6x backport 4b6794368df) [read: git log -S and git log -L of the method, git blame on main; exec: 6.2.1 has no uhighlight package, 6.3.0 is affected]: the method passes content untruncated since it was created, so this is not a regression. The same commit added the getMaxLength javadoc and the 'content is already truncated to maxLength' comment; LUCENE-10197 (#412, b1b1a890d08 / a9f812c1cf9, 9.1.0) later added the withMaxLength javadoc on the Builder that builderWithoutSearcher returns. Later edits to the method (2e948fea300 LUCENE-7575 SortedSet to Set, 6faa4f98e04 LUCENE-9570 formatting) did not touch truncation (2016-10-04)
First affected version
6.3.0 [exec: the first release with UnifiedHighlighter; 6.2.1, the release before, has no org.apache.lucene.search.uhighlight package and the probe does not compile]; on the main line 7.0.0
Affected versions
Every release from 6.3.0 to 10.5.2, 10.6.0 (by reading), and main a9779ef3c193; never fixed (the method body is unchanged at upstream main f7bcc0abd816, branch_10x and branch_10_6; no open PR touches it; read 2026-10-27). [exec] a cross-version probe (original non-Builder API, recording PassageFormatter, searcher-path and large-maxLength controls) fails on 6.3.0 (-ea and -da), 6.4.0, 6.6.6, 7.0.0, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the finder's Builder-API JUnit fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da); the independent reproduction's JUnit fails on 10.5.2 (-ea and -da), 9.12.3 and main, and its old-API main() on 8.11.4 and 6.3.0. Releases in between are affected by code identity [read]; 10.6.0 (released 2026-10-29) and the unreleased 10.7 branch were read, not executed.
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
non-formal: code reading
Upstream and prior reports
none found (2026-10-27, read-only; the prior-report record, verdict new): two scripted prior-report searches with 142 non-web queries (60 GitHub issue/PR, 6 commit, 4 downstream-repository, 64 mailing-list queries over dev@, java-user@, issues@ and commits@, 8 JIRA-archive) plus the finder's run (45 queries); 47 manual searches and reads (27 GitHub GraphQL searches over apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch and all of GitHub; the changed files of all 408 open apache/lucene PRs and the diffs of the 3 that touch UnifiedHighlighter.java; upstream file history on 4 branches; 7 lists.apache.org searches; 4 JIRA REST queries; 6 web searches); every hit read in full, none the same bug. Closest items, context not reports: #412 / #11234 (LUCENE-10197, merged 2021) added builderWithoutSearcher and the withMaxLength javadoc ('effectively truncated by this length', suggested by dsmiley), and a review comment by apanimesh061 shows builderWithoutSearcher(...).withMaxLength(100) as an example, with no remark that the no-searcher path ignores it; #8490 (LUCENE-7438) introduced the method and the truncation comment; #8577 (LUCENE-7526) created MemoryIndexOffsetStrategy; #8768, #8820, #9538, #10135 use highlightWithoutSearcher on short text for other bugs. Not fixed upstream: UnifiedHighlighter.java last changed on main by 54283150a816 (#16255, 2026-06-14) and on branch_10x and branch_10_6 by c8b00d2ba72d (#15681), elsewhere; three open PRs touch the file elsewhere (#12295, #13165, #16847); #16918 (opened 2026-10-27) plans to remove the deprecated constructor and setters including setMaxLength, so a fix may need a textual rebase. Gaps: keyword-based search, GitHub's search rate limit, one web query throttled, private channels and security@ not searchable, no downstream code search
Lucene commit tested
main 645d4fe14a20 (gradle in a scratch worktree with assertions on and off and 10 random seeds per test method, plus the standalone JUnit runner); the 10.5.2 and 10.0.0 release jars (this doc's test, runner); the hunt's runs on main a9779ef3c193, whose highlighter and memory sources equal 645d4fe14a20, and on 6.3.0 to 10.5.2 (a public-API probe on 11 releases); the fact check's runs: this doc's test with only the 9.x accessor change on 9.1.0 and 9.12.3, and a probe of the searcher-path control and of the TokenStreamOffsetStrategy flags on 645d4fe14a20; both fixes on main (gradle, the whole highlighter test task; the recommended one also with assertions off and with the module's check task without tests).

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:94 (at a9779ef3c193) (@lucene.experimental), :100 (DEFAULT_MAX_LENGTH = 10000), :354 (Builder.withMaxLength javadoc: 'The text to be highlight is effectively truncated by this length.'), :596-603 (getMaxLength javadoc: 'The maximum content size to process. Content will be truncated to this size before highlighting.'), :1078-1093 (highlightWithoutSearcher javadoc: 'This use-case is more rare', mode ANALYSIS, silent on maxLength), :1096-1108 (highlightWithoutSearcher passes content to highlightFieldForDoc(null, -1, content) untruncated)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:1410 (at a9779ef3c193) (newLimitedStoredFieldsVisitor: new LimitedStoredFieldVisitor(fields, MULTIVAL_SEP_CHAR, getMaxLength())), :1426-1447 (LimitedStoredFieldVisitor: value.substring(0, Math.min(maxLength, value.length())), the only truncation, reached only from loadFieldValues on the searcher path)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/MemoryIndexOffsetStrategy.java:101-104 (at a9779ef3c193) (getOffsetsEnum: 'note: don't need LimitTokenOffsetFilter since content is already truncated to maxLength')
  • SECURITY.md:38 (Untrusted query and analysis input: limits on untrusted input are the downstream application's job), :42 (configuration values are trusted), :50 (In-scope reports, not met)
The finding in 30 seconds
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:100 (at 645d4fe14a20) (DEFAULT_MAX_LENGTH), :354 (withMaxLength javadoc), :453-455 (builderWithoutSearcher), :596-603 (getMaxLength javadoc), :1078-1108 (highlightWithoutSearcher javadoc and body)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:958, (at 645d4fe14a20) :987-988 (searcher path: loadFieldValues, highlightFieldForDoc), :1382-1404 (loadFieldValues), :1409-1410 (newLimitedStoredFieldsVisitor), :1446-1447, :1450-1465 (LimitedStoredFieldVisitor.stringField), :1294-1302 (getOffsetStrategy for ANALYSIS)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/MemoryIndexOffsetStrategy.java:101-104 (at 645d4fe14a20) ('content is already truncated to maxLength'); lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:73 (at 645d4fe14a20) (highlightFieldForDoc)
Who is affected
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:94 (at 645d4fe14a20) (@lucene.experimental), :1080 ('This use-case is more rare')
  • lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterReanalysis.java:34-54 (at 645d4fe14a20) (short text only)
Why it looks unintended
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:354, (at 645d4fe14a20) :596-603, :1078-1093, :1413-1418 (LimitedStoredFieldVisitor javadoc)
  • lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterMTQ.java:778-809 (at 645d4fe14a20) (testWithMaxLen); lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:214, :225-255 (testFormatWithMatchExceedingContentLength and its helper); lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighterReanalysis.java, TestLengthGoalBreakIterator.java:218-248, TestUnifiedHighlighterStrictPhrases.java:540-544
  • lucene/CHANGES.txt:13345-13347 (at 645d4fe14a20) (LUCENE-5166), :13661-13663 (LUCENE-5032)
Suggested fix
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:1372-1381 (at 645d4fe14a20) (loadFieldValues javadoc); lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:707-756 (at 645d4fe14a20) (testCustomFieldValueSource)
Prior reports
  • a Lucene clone origin/branch_10_6 4ae946e5a9b, origin/branch_10x 657014b3c57 (method body read 2026-10-29)
  • GitHub search re-check 2026-10-29 (gh search issues --include-prs; gh issue view 16918), read-only
History
  • a Lucene clone origin/branch_10_6, origin/branch_10x (read 2026-10-29)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at main 645d4fe14a20 and the executed runs; every value drawn should come from the paste-ready test's output and the source at that commit.

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.