All findings · Lucene finding · LFV-498 · uh-summary-counts-multivalue-separator-as-passage

The UnifiedHighlighter's summary of a multi-valued field returns its hidden U+0000 separator, which can push out a sentence

When a hit has no match in a multi-valued field, the UnifiedHighlighter's default summary counts its internal U+0000 value separator as a sentence: for First value. and Second value., 2 passages give First value. plus a raw NUL instead of both. Default settings; affects 6.3.0 to 10.6.0 (10.6.0 by reading) and main.

Summary

Priority
Low, rank 546With default UnifiedHighlighter settings the no-match summary of a multi-valued field returns a NUL and one passage too few, ordinary data but display text only.
Impact
minor
Since
6.3.0 (LUCENE-7438, 2016)introduced 2016-10-04 · over 10 years
Affected
6.3.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
bounded-exhaustive check of the highlighter's passages against its javadoc

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

The highlighter keeps a field's values as one string with an invisible U+0000 between them and finds sentences inside each value. When nothing in the field matches, it shows the field's first few sentences, but the loop that collects them also counts the 1-character separator as a sentence.

Background: the default summary

  1. Highlighting shows the sentences of a stored field that contain the query's matches, with the matches marked, for example First <b>value</b>.
  2. If a hit has no match in that field, the UnifiedHighlighter shows a default summary instead: the field's first maxPassages sentences (a per-call argument; the 3-argument highlight call uses 1).
  3. A field can hold several values. The highlighter joins them into one string with the invisible character U+0000 between values, and finds sentences inside each value separately, so no sentence spans two values.
Summary card in three panels. 1, two stored values, no match, 2 passages: the values 'First value.' and 'Second value.' in body, a TermQuery on id, and UnifiedHighlighter.builder(searcher, analyzer).build().highlight("body", query, topDocs, 2) with the default sentence BreakIterator and maxNoHighlightPassages -1. 2, the summary loop takes the separator as passage 2: the values joined by U+0000, with the SplittingBreakIterator boundaries 0, 12, 13 and 26; passage 1 is 'First value.', passage 2 is only the U+0000 between 12 and 13, and the second value is never reached. 3, expected vs actual on main with -ea and -da: expected 'First value.... Second value.', actual 'First value.' followed by \u0000; maxPassages 3 gives the NUL between the two values; an empty first value with the default 1-passage call gives \u0000 alone; single-valued fields, the highlight path, hits, scores and the index are unaffected. Bottom line: the candidate fix skips a 1-character interval holding MULTIVAL_SEP_CHAR; the repro fails on 9 releases from 6.3.0 to 10.5.2 and on main; affected is a summary that asks for more passages than the first value has sentences, when another value follows (an empty first value has none, so even 1 passage gives \u0000).
The two values joined with U+0000 at index 12, the sentence boundaries 0, 12, 13 and 26, and the summary loop taking [12, 13), the separator alone, as passage 2: First value.\u0000 instead of First value.... Second value. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You store two values in one field, First value. and Second value., run a query that matches the document but nothing in that field, and ask the UnifiedHighlighter, with its default settings, for 2 passages of that field.
  2. You expect the documented default summary, the field's first 2 sentences, joined the way highlighted values are joined: First value.... Second value.
  3. You get First value.\u0000: a raw U+0000, invisible on screen, where the second sentence should be. With 3 passages you get First value.\u0000Second value.; with an empty first value even the default 1-passage call returns only \u0000. No error; hits and scores are unchanged.
  4. Why: the highlighter joins the values with U+0000 and reports a sentence boundary on both sides of it, at 12 and 13 (the other boundaries are 0 and 26). The summary loop makes a passage of every pair of neighbouring boundaries, so the 1-character gap from 12 to 13, the separator alone, becomes passage 2.

Who is affected

Applications that use the UnifiedHighlighter directly on multi-valued stored fields, with default settings, for hits with no match in that field. Asking for 2 or more passages hits it when the first value has fewer sentences than asked; an empty first value hits even the default 1-passage call. Only the snippet text is wrong.

Mechanism, terms and numbers647 words

When a hit has no match in a highlighted field, the UnifiedHighlighter returns a default summary, documented as "the first maxPassages sentences from the field". For a multi-valued stored field it joins the values with its internal separator U+0000 and reports a boundary on both sides of each separator. The summary loop turns every pair of neighbouring boundaries into a passage, so the 1-character separator becomes a passage of its own: it takes one of the requested slots and is copied into the snippet as a raw NUL. Two values First value. and Second value. with 2 passages give First value.\u0000; with an empty first value even the default 1-passage call gives \u0000. Hits, scores and the index are unaffected. The code dates from the UnifiedHighlighter's introduction (6.3.0, 2016): the test fails on the 9 releases tried from 6.3.0 to 10.5.2 and on main, and the code is the same at every other tagged release. Skipping that 1-character interval in the summary loop fixes it (7 added lines, one of them an import; validated with the whole highlighter module).

The bug, step by step

  1. Values are joined with U+0000. UnifiedHighlighter.MULTIVAL_SEP_CHAR = (char) 0 (UnifiedHighlighter.java:98), appended between the stored values (:1463). The example content is First value. + U+0000 + Second value.: 26 characters, the separator at index 12.
  2. The field's iterator is wrapped. Every field's BreakIterator is wrapped in new SplittingBreakIterator(getBreakIterator(field), MULTIVAL_SEP_CHAR) (UnifiedHighlighter.java:1135), which "virtually slices the text on both sides of every occurrence" of the separator, so that "the enclosed BreakIterator will never 'see' the splitting character" (SplittingBreakIterator.java:24-28). Its next() returns the end of value 1 and then, one character later, the start of value 2 (:104-124, :114). Boundaries: 0, 12, 13, 26. This is intended and tested (TestSplittingBreakIterator: aa|aa gives ^ ^^ ^), and the highlight path needs the boundary at 13 so that a passage in value 2 starts at its first character.
  3. No match, so a summary. When no passage has a match, FieldHighlighter calls getSummaryPassagesNoHighlight(maxNoHighlightPassages == -1 ? maxPassages : maxNoHighlightPassages) (FieldHighlighter.java:89-94); -1 is the default.
  4. The loop counts the gap. "By default this just returns the first maxPassages sentences" (FieldHighlighter.java:104-108), but the loop (:115-125) makes a passage of every pair of consecutive boundaries: [0, 12) First value., then [12, 13), which holds only U+0000, then [13, 26) Second value..
  5. The formatter copies it. DefaultPassageFormatter adds its ellipsis "... " only between passages that do not touch (DefaultPassageFormatter.java:66-68); these touch, so the NUL is copied verbatim and no ellipsis appears.

The numbers (default builder, default sentence iterator, no match in the field)

stored valuesmaxPassagesstockwith the fix
First value., Second value.1First value.First value.
same2First value.\u0000First value.... Second value.
same3First value.\u0000Second value.First value.... Second value.
empty, Second value.1\u0000Second value.
empty, Second value.2\u0000Second value.Second value.
A one., B two., C three.3A one.\u0000B two. (third value lost)A one.... B two.... C three.
First one. First two., Second value.2First one. First two.unchanged
control: a query matching value in both values2First <b>value</b>.... Second <b>value</b>.unchanged

The same with each of the three offset sources (re-analysis, offsets in postings, term vectors), each executed as a test of its own, and with the whole-text iterator; the hunt's exhaustive harness saw it with every iterator family it covered (sentence, word, custom separator, whole, length goal). When every value is empty the summary is \u0000 alone instead of no passage. All of this assumes stored values without a U+0000 of their own (a stored U+0000 is a separate bug, LFV-599). Every upstream no-match summary test is single-valued, which is why the tests miss it.

Exposure and likelihood421 words
  • Who is hit. Direct users of Lucene's UnifiedHighlighter on multi-valued stored fields, for hits with no match in the highlighted field (common when several fields are highlighted and the query matches only some), with the default no-match summary, and either 2 or more passages (maxPassages, or withMaxNoHighlightPassages(n) with n >= 2) while the first value has fewer sentences than asked, or an empty first value. The default 1-passage call with a non-empty first value is not affected (for stored values without a U+0000 of their own; that case is LFV-599).
  • Reach limit (read). The separator is only added while the field's maxLength budget lasts (default 10,000 characters): the stored-field visitor truncates the first value to maxLength and adds no further value once the budget is used up (UnifiedHighlighter.java:1446-1452). A custom maxLength that ends inside the first value prevents this summary shape.
  • Defaults. Default builder, default sentence iterator, default maxNoHighlightPassages (-1), all three offset sources (each executed separately). UnifiedHighlighter is @lucene.experimental; FieldHighlighter is @lucene.internal, but users never pick it: the UnifiedHighlighter builds it.
  • What happens. Display text only: a raw U+0000 in the snippet, which takes one of the requested passage slots and so pushes out a real sentence or value whenever the field has one left (2 values and 2 passages: the second value is lost; 3 values and 3 passages: the third). With nothing left to push out, the NUL is still there (2 one-sentence values and 3 passages: First value.\u0000Second value.). Hits, scores and the index are unaffected; nothing throws. The NUL is invisible on screen. An application that writes the snippet into XML without escaping control characters produces a document an XML parser rejects (run only with the JDK's own XML writer and parser as a stand-in; control characters from document text do the same; no real product was run).
  • Scale. Deterministic per document and field, independent of index size.
  • Downstream (by reading their source as examined during the hunt, not run; not a guarantee for their current code). Elasticsearch and OpenSearch override getSummaryPassagesNoHighlight and skip the separator, so in the examined source they are not exposed. Solr's default summary is off unless hl.defaultSummary=true; then Solr's own snippet separator is also U+0000 and it splits on it, which hides the NUL but returns one snippet fewer than hl.snippets (emulated).
  • Workarounds (not run). withMaxNoHighlightPassages(0) turns the summary off; or override newFieldHighlighter to return a FieldHighlighter whose summary skips the separator. Stripping U+0000 from the snippet afterwards still leaves a sentence missing.

Minimal repro

The test shows that the UnifiedHighlighter's no-match summary of a two-valued field ('First value.', 'Second value.') with maxPassages 2 returns the internal U+0000 value separator as the second passage.

TestUHSummaryMultiValueSeparator.java (JUnit test)60 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.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.search.IndexSearcher;
import org.apache.lucene.search.MatchAllDocsQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TopDocs;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestUHSummaryMultiValueSeparator extends LuceneTestCase {

  public void testNoMatchSummaryOfMultiValuedFieldHasNoSeparator() throws Exception {
    Analyzer analyzer = new StandardAnalyzer();
    try (Directory dir = new ByteBuffersDirectory()) {
      try (IndexWriter w = new IndexWriter(dir, new IndexWriterConfig(analyzer))) {
        Document doc = new Document();
        doc.add(new TextField("body", "First value.", Field.Store.YES));
        doc.add(new TextField("body", "Second value.", Field.Store.YES));
        w.addDocument(doc);
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        IndexSearcher searcher = new IndexSearcher(reader);
        Query query = MatchAllDocsQuery.INSTANCE;
        TopDocs topDocs = searcher.search(query, 1);
        UnifiedHighlighter highlighter = UnifiedHighlighter.builder(searcher, analyzer).build();
        String summary = highlighter.highlight("body", query, topDocs, 2)[0];
        // expected: the field's first 2 sentences, no U+0000; actual: "First value." + U+0000
        assertFalse(
            "2-passage summary contains the U+0000 value separator: "
                + summary.replace("\u0000", "\\u0000"),
            summary.contains("\u0000"));
      }
    }
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-498/, 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.TestUHSummaryMultiValueSeparator
Expected vs actual
Expected the field's first 2 sentences with no U+0000 ("the first maxPassages sentences from the field"); actual: First value.\u0000, with -ea and -da.

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

More files, environment, full output39 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: 2-passage summary contains the U+0000 value separator: First value.\u0000
       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.TestUHSummaryMultiValueSeparator.testNoMatchSummaryOfMultiValuedFieldHasNoSeparator(TestUHSummaryMultiValueSeparator.java:53)

Why it looks unintended

  • The javadoc promises "the first maxPassages sentences from the field"; U+0000 is not from the field, and the sentence iterator is documented never to see it.
  • With a match, the same document joins its values with the formatter's ellipsis, as a test asserts.
  • No test covers a multi-valued summary; one skips values containing the separator ("will throw things off").
The full argument220 words
  • The public contract. highlight(field, query, topDocs, maxPassages): "If no highlights were found for a document, the first maxPassages sentences from the field will be returned" (UnifiedHighlighter.java:759-761); the 3-argument form: "the first sentence for the field" (:742). The separator is inserted by the highlighter (:1463), and SplittingBreakIterator promises that the user's iterator "will never 'see' the splitting character" (SplittingBreakIterator.java:28).
  • The highlight path. For the same document with a matching query, values come out as separate passages joined by the ellipsis: TestUnifiedHighlighter.java:339 asserts highlightedValue + "... " + highlightedValue. LUCENE-7438 (#8490), which introduced the highlighter: "a passage will never span across values".
  • Lucene's own tests treat the separator as non-content. TestUnifiedHighlighterMTQ.java:1139 skips random values that contain it ("will throw things off"). Every no-match summary test is single-valued. No javadoc, CHANGES or MIGRATE entry sanctions a separator in the summary or a dropped value.
  • Downstream fixed it for themselves. Elasticsearch's override of the summary method (2017) skips leading separators and ends the passage at the next one (code reading).
  • Origin, not intent. The loop is a near-verbatim copy of PostingsHighlighter.getEmptyHighlight, whose multi-value separator was a visible, configurable space (getMultiValuedSeparator returned ' ') that its iterator saw. LUCENE-7438 replaced it with a hidden control character and did not adapt the loop.
The devil's-advocate check241 words

Strongest case for "intended" (built by the cross-model reviews, the independent reproducer, the devil's advocate and the adjudication): getMaxNoHighlightPassages says "the number of leading passages (as delineated by the BreakIterator)", and the iterator FieldHighlighter holds is the SplittingBreakIterator, which deliberately reports both sides of each separator (javadoc and TestSplittingBreakIterator), so [12, 13) is literally a delineated passage; the loop came from the PostingsHighlighter, where summaries contained the visible separator; FieldHighlighter is @lucene.internal, the UnifiedHighlighter and SplittingBreakIterator are @lucene.experimental, and the summary method is a protected override hook; Solr's U+0000 snippet separator hides the NUL.

Why it fails: the iterator's boundaries are fine, the summary loop's use of them is the defect, and the more specific public promise is sentences "from the field"; the literal reading also does not explain why a real sentence is pushed out. The PostingsHighlighter lineage explains where the bug came from, not that anyone wanted it. The labels allow API change; they do not make the output correct. Solr still loses a snippet (emulated), and Elasticsearch replaced the loop. The cross-model reviews rated the intended case moderate; the devil's advocate and the adjudication rejected it. Verdict: a defect; expect the literal reading to come up, and answer it with the highlight() javadoc and the dropped value. In the end it is a contract call for the maintainers: the boundaries are documented and tested, but nothing documents the separator as a summary passage.

Suggested fix

Recommended B. Skip the separator interval in the summary loop, reading the character from a copy

In the summary loop, skip a boundary pair that encloses exactly the U+0000 separator, so it never counts as a passage, and read that character from a copy of the iterator's text: +7 lines in FieldHighlighter. The test passes, and so do the module's other 722 tests and its checks.

     List<Passage> passages = new ArrayList<>(Math.min(maxPassages, 10));
+    // a copy, so that reading characters cannot move the break iterator
+    CharacterIterator text = (CharacterIterator) breakIterator.getText().clone();
     int pos = breakIterator.current();
     ...
       if (next == BreakIterator.DONE) {
         break;
       }
+      if (next - pos == 1 && text.setIndex(pos) == UnifiedHighlighter.MULTIVAL_SEP_CHAR) {
+        pos = next; // the separator between two values is not a passage
+        continue;
+      }
       Passage passage = new Passage();
All options and trade-offs4 options · 763 words

Two options were applied and tested with gradle on main 645d4fe14a20 (option B recommended, option A the hunt's original); two more were considered and not run. A and B skip exactly the same interval; they differ only in how they read the character.

B. Skip the separator interval in the summary loop, reading the character from a copy (validated by the checks) (recommended)

In FieldHighlighter.getSummaryPassagesNoHighlight (FieldHighlighter.java:109-128), take a copy of the iterator's text once before the loop and skip a boundary pair of length 1 whose character is MULTIVAL_SEP_CHAR. +7 lines, one of them the import of java.text.CharacterIterator.

What it changes. Only the no-match summary. The separator interval no longer counts toward maxPassages, so the next value's first sentence takes the slot; the two values' passages no longer touch (12 != 13), so the formatter puts its ellipsis between them, as on the highlight path. An empty first value gives the second value's first sentence; when every value is empty there is no passage and the snippet is null, as with maxNoHighlightPassages 0 (FieldHighlighter.java:96-100, read, not run). Single-valued fields (whose value holds no U+0000) and matched highlighting are unchanged.

     List<Passage> passages = new ArrayList<>(Math.min(maxPassages, 10));
+    // a copy, so that reading characters cannot move the break iterator
+    CharacterIterator text = (CharacterIterator) breakIterator.getText().clone();
     int pos = breakIterator.current();
     ...
       if (next == BreakIterator.DONE) {
         break;
       }
+      if (next - pos == 1 && text.setIndex(pos) == UnifiedHighlighter.MULTIVAL_SEP_CHAR) {
+        pos = next; // the separator between two values is not a passage
+        continue;
+      }
       Passage passage = new Passage();

Trade-offs

  • One getText() and one clone() per summary call (two small StringCharacterIterator objects with the default iterator).
  • It recognises the separator by its value, so a stored value that itself contains U+0000 is still mis-split; that is a separate known bug (LFV-599).
  • No CHANGES entry is drafted. Engines that override the summary method are unaffected either way.

What the checks validated

  • Gradle in a scratch worktree of main 645d4fe14a20: the 5-test repro passes with assertions on and off and with 3 seeds (stock: 3 of 5 fail); every uhighlight class, 15 classes and 476 tests (470 upstream), 0 failures; all of :lucene:highlighter:test, 36 classes and 728 tests (722 upstream), 0 failures.
  • :lucene:highlighter:check -x test passes with the repro added (google-java-format, ecjLint, forbiddenApis).
  • With the fixed class shadowed in the repro harness on 645d4fe14a20: the hunt's test passes 2 of 2 and the independent reproducer's test 4 of 4 (3 offset sources); stock they fail 2 of 2 and 3 of 4.

A. The same skip, reading the character through breakIterator.getText() (the hunt's 5-line form) (validated by the checks)

Five added lines in the same loop: if (next - pos == 1 && breakIterator.getText().setIndex(pos) == UnifiedHighlighter.MULTIVAL_SEP_CHAR) { pos = next; continue; }. With the highlighter's own wrapping it is correct, because SplittingBreakIterator.getText() returns a new StringCharacterIterator each time (SplittingBreakIterator.java:59-64).

+      if (next - pos == 1
+          && breakIterator.getText().setIndex(pos) == UnifiedHighlighter.MULTIVAL_SEP_CHAR) {
+        pos = next; // the separator between two values is not a passage
+        continue;
+      }

Trade-offs

  • Breaks custom iterators (executed). A FieldHighlighter can get any BreakIterator through the protected newFieldHighlighter hook. A JDK RuleBasedBreakIterator (the sentence and word instances) returns its live CharacterIterator from getText(), so setIndex(pos) moves the iterator back and the next next() repeats a boundary. With a raw JDK word iterator over the same two values and 8 passages, A gives [0,5)[5,6)[6,6)[6,11)[11,12)[12,12)[13,13)[13,19), three of them empty; stock gives [0,5)[5,6)[6,11)[11,12)[12,13)[13,19)[19,20)[20,25) and B [0,5)[5,6)[6,11)[11,12)[13,19)[19,20)[20,25)[25,26).
  • That is a custom, @lucene.internal configuration, but option B avoids it at no cost.

What the checks validated

  • The same results as option B for the repro (assertions on and off, 3 seeds), the 476 uhighlight tests and the 728 module tests (722 upstream), and check -x test.

C. Make SplittingBreakIterator report one boundary per separator (not run)

Remove the second boundary, so the separator is never an interval of its own.

Trade-offs

Not recommended: the two-sided boundaries are documented and tested (TestSplittingBreakIterator), and the highlight path relies on the boundary at a value's start (breakIterator.preceding(...), FieldHighlighter.java:178-182) so that a passage in a later value does not begin with the separator.

D. Skip by position or by iterator type (not run)

Compute the separator positions from the content, or test breakIterator instanceof SplittingBreakIterator.

Trade-offs

The protected getSummaryPassagesNoHighlight(int) has no content parameter, and changing its signature would break the overrides downstream engines ship; B gets the same result without that.

Downstream note

In the source examined during the hunt, Elasticsearch and OpenSearch replace this summary method and are not exposed; Solr's default summary is off, and with hl.defaultSummary=true it shows one snippet fewer (by reading their code and an emulation, not run).

How it was found

A bounded-exhaustive check of the real highlighter against a reference written from its javadoc. For every 2-3 value content of up to 5 characters and 61 iterator configurations, the oracle "the summary is the first N sentences of the values, never the separator" failed for every iterator family, on multi-valued content only. A public-API JUnit followed.

The method in full159 words

Method (semi-formal): bounded-exhaustive translation validation of the real highlighter jar against a reference semantics written from the javadoc, not from the code.

  • The reference says: when no passage has a match, the summary is the first N passages of the values in order, as delineated by the user's BreakIterator, and never the separator (N = maxPassages when maxNoHighlightPassages < 0).
  • Multi-valued tiers: every content of 2-3 values over a, space and . up to 5 characters (8,657 contents, 7 plain and 54 length-goal iterator configurations, 3,168,462 checks), and with a supplementary character up to 4 characters (2,096 contents, 31 configurations, 584,784 checks).
  • The failure class P2-summary-separator-passage appeared for every iterator family (sentence, word, custom separator, whole, length goal), on multi-valued content only; the same class also caught the all-values-empty case. The hypothesis came first from reading getSummaryPassagesNoHighlight while the reference was written; the harness confirmed it on its first multi-valued scope. The finder then wrote the public-API JUnit.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26, refreshed 2026-10-29: GitHub issues, PRs and commits, JIRA, the Lucene and Solr lists, downstream repositories, the web and all 408 open PRs' files). Closest: apache/solr#5126 notes a related U+0000 symptom on highlighted matches, which is a different bug.

Searches5 searches

Closest, all different (16 hits classified, none the same bug):

  • apache/solr#5126 (by dsmiley, merged 2026-10-27): its description notes that with hl.method=unified, multi-valued fields can return a leading empty snippet because Lucene's value separator and Solr's snippet separator are both U+0000, and that this "deserves its own issue". Their example is a highlighted match; an executed emulation reproduces it only with LengthGoalBreakIterator on the match path (the sibling bug LFV-494), not with this summary loop. It never mentions the default summary or a dropped value; no follow-up issue was found.
  • Elasticsearch and OpenSearch override getSummaryPassagesNoHighlight and skip the separator (since 2017): a workaround in code, with no issue text describing the Lucene defect.
  • #8490 / LUCENE-7438: the introduction ("a passage will never span across values").
  • #10135 / LUCENE-9093: changed LengthGoalBreakIterator for the default summary; single-valued.

Gaps: downstream trackers were searched by keyword, not exhaustively; private reports cannot be searched.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, hibernate/hibernate-search, apache/lucenenet)2026-10-2653 manual searches (for example getSummaryPassagesNoHighlight, MULTIVAL_SEP_CHAR, SplittingBreakIterator, maxNoHighlightPassages, defaultSummary multivalued, unified highlighter null character)no report; apache/solr#5126 is the closest (a different bug)
The hunt's scripted prior-report search (GitHub issues, PRs and commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive, the web)2026-10-26three runs, 45, 51 and 45 queries; 243, 332 and 119 distinct hitsno report; #8490 and #10135 the most relevant
Open apache/lucene PRs2026-10-26the changed files of all 408 open PRsnone touches the summary loop or SplittingBreakIterator
JIRA (LUCENE, SOLR, Hibernate), mailing lists, web2026-10-2613 JQL queries, 23 list queries, 9 web queriesno report
Refresh: apache/lucene and apache/solr2026-10-29the same key terms plus highlighter items created since 2026-10-25 and Solr snippet-separator issues since 2026-10-01; apache/solr#5126 re-readnothing new (#5126 merged 2026-10-27 with the same note)

History

Present since the UnifiedHighlighter was added: LUCENE-7438 (722e82712435, 2016-10-04), first released in 6.3.0. Executed on 6.3.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main: affected everywhere. Not a regression, never fixed.

Versions tested6 rows

Introduced by LUCENE-7438 "UnifiedHighlighter" (now #8490): commit 722e82712435 on master and its branch_6x cherry-pick 4b6794368df, both 2016-10-04, by dsmiley. It added the whole uhighlight package, including all three pieces the bug needs: the summary loop, the two-sided SplittingBreakIterator.next() and the wrapping on (char) 0. The contribution PR apache/lucene-solr#79 was closed without a GitHub merge; the code was committed directly. Later edits to the loop (LUCENE-7559, LUCENE-8145, #16056) are cosmetic. At every release tag from 6.3.0 to 10.5.2 in the history clone the code is the same (read; 8.8.2 to 8.11.3 are not tagged there, so they are inferred from 8.8.1, read, and 8.11.4, executed); branch_10_6 has the same summary loop and iterator (read, not run).

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe doc's JUnit fails 3 of 5 (gradle, assertions on and off, 3 seeds); the hunt's and the reproducer's tests fail
10.5.2, 9.12.3, 9.1.0 affectedthe doc's JUnit fails 3 of 5; the hunt's fails 2 of 2
10.0.0, 9.0.0, 8.11.4, 7.7.3, 6.6.6 affectedthe hunt's JUnit (or its pre-9.1 port) fails 2 of 2
6.3.0 first releasefails 2 of 2 with assertions on and off; passes with the fix applied to the 6.3.0 source
6.2.1 no UnifiedHighlighterthe API does not exist (the test does not compile)
fix B / fix A on 645d4fe14a20 not affectedthe JUnit passes, and so do the 722 upstream highlighter tests

Independent checks

An independent reproducer, reviews by a reviewer from another model family, a devil's advocate, an adjudication and a credibility audit re-ran or re-read it; every executed check reproduced it. This doc re-ran it through gradle on the latest main and validated two fixes.

Checks and verdicts402 words

Each check had a lens; the adjudication then re-ran both tests, the reproducer's with a new seed.

Each independent check and its verdict
CheckVerdictWhat it found
Cross-model review, refute lens not real (low), proceduralAn independent reviewer from another model family (OpenAI GPT-5.6), reading only: it votes not real without a run of its own. Root cause agrees, inputs in contract, intended case moderate; a separate re-run failed 2 of 2.
Independent reproducer realIts own JUnit, 3 offset sources, outside the uhighlight package: 3 of 4 fail on main (-ea, -da), 10.5.2 and 9.12.3; 30 of 30 bug cases with 10 iterations; main() variants fail on 8.11.4 and 6.3.0; 4 of 4 pass with the fix.
Cross-model review, intended lens real (medium)The same reviewer, reading only; a separate probe showed the separator with the sentence and whole-text iterators, with 2 and 3 values and an empty first value, and single-valued output unchanged by the fix.
Devil's advocate real (medium-high)Built the strongest works-as-designed case and rejected it; its probe showed that the default 1-passage call is hit only with an empty first value, and that a NUL-splitting consumer still loses a value.
Adjudication confirmed (high), minorRe-ran the reproducer's test with a new seed (-da, 3 of 4 fail) and the hunt's (-ea, 2 of 2 fail).
Follow-up and credibility audit keepHistory sweep over 9 releases and main, a security differential probe (none), a prior-art search (new); the audit re-ran the hunt's test on main and 10.5.2 and kept impact class minor.
This doc reproduced; two fixes validatedGradle on main 645d4fe14a20, stock and with fixes A and B; the hunt's and the reproducer's tests through the harness on 645d4fe14a20; the new test on 10.5.2, 9.12.3 and 9.1.0; a probe that found option A's problem with live-text iterators.
Fact checks of this doc wording fixed; nothing refutedAn independent fact check (re-ran the gradle command, both fixes and the harness), a cross-model fact check by a reviewer from another model family (OpenAI GPT-5.6, reading only) and a diagram check. They found tagging and wording overclaims (releases run vs read, the observed-output excerpt, offset sources, 'one sentence fewer'), now fixed; a follow-up probe ran each offset source as its own test (6 of 6 fail on main, pass with fix B).

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

Finding record

LFV-498, snapshot of 2026-11-06

Registry title
UnifiedHighlighter's no-match summary of a multi-valued field counts the internal U+0000 value separator as a passage, so the summary contains a NUL character and shows one value or sentence fewer than requested
Area
highlighter / UnifiedHighlighter default summary (FieldHighlighter.getSummaryPassagesNoHighlight over the SplittingBreakIterator that splits multi-valued fields on MULTIVAL_SEP_CHAR)
Priority
Low, rank 546: With default UnifiedHighlighter settings the no-match summary of a multi-valued field returns a NUL and one passage too few, ordinary data but display text only.
Impact class
minor
Impact note
Applications on any release from 6.3.0 through 10.6.0 (10.6.0 by reading) or on main that highlight a multi-valued stored field with the UnifiedHighlighter in its default configuration (default sentence BreakIterator, default maxNoHighlightPassages -1) get a wrong default summary for every hit with no match in that field whenever maxPassages (or maxNoHighlightPassages) is 2 or more and the leading passages reach a value boundary, or whenever the first value is empty (then even the default 1-passage call returns just U+0000): the snippet contains a raw U+0000 and one value or sentence fewer than requested; hits, scores and the index are unaffected, nothing throws, no index size is needed, and by code reading Elasticsearch and OpenSearch (which override the summary method) and Solr with its default hl.defaultSummary=false are not exposed.
Summary
You store two values in one field ('First value.' and 'Second value.'), run a query that matches the document but not that field, and ask the UnifiedHighlighter for 2 passages; you expect the documented default summary, the first 2 sentences from the field (for example 'First value.... Second value.', the way the highlight path joins values). Instead you get 'First value.\u0000': the highlighter joins the values with an internal U+0000 separator, its SplittingBreakIterator reports a boundary on both sides of that character, and FieldHighlighter.getSummaryPassagesNoHighlight turns the 1-character gap into a passage, so the NUL is returned and takes the second value's slot (3 passages give 'First value.\u0000Second value.'). With an empty first value even 1 passage returns just '\u0000'.
Introduced
722e82712435ecf46c9868137d885484152f749b on master and its branch_6x twin 4b6794368df (LUCENE-7438 'New UnifiedHighlighter' by dsmiley, 2016-10-04; JIRA issue now GitHub #8490; the contribution PR apache/lucene-solr#79 by Timothy055 was closed without a GitHub merge, the code was committed directly) [read: git blame of the summary loop and of SplittingBreakIterator#next(), git log -G for the wrapping line, git tag --contains in the full-history clone; exec: the repro fails on 6.3.0, the first release with the API, and does not compile on 6.2.1, where the API is absent]. The commit added all three pieces the bug needs: the summary loop that makes a passage of every consecutive pair of boundaries, SplittingBreakIterator#next() reporting both sides of the separator, and UnifiedHighlighter wrapping every field iterator in a SplittingBreakIterator on MULTIVAL_SEP_CHAR = (char) 0. Later edits to the loop (LUCENE-7559 c51e89014ac, LUCENE-8145 9422609a5d4, #16056 dbf0d1426f1) are cosmetic. Not a regression (2016-10-04)
First affected version
6.3.0 [exec] (first release with the UnifiedHighlighter, from branch_6x commit 4b6794368df; release commit a66a44513ee8, 2016-11-02); 6.2.1 has no org.apache.lucene.search.uhighlight package (the repro does not compile there) [exec]
Affected versions
every release with the UnifiedHighlighter, 6.3.0 through 10.6.0 (10.6.0 by reading), and main ea57360912ed [exec: the finder's JUnit, or its pre-9.1 port with identical test bodies, fails 2 of 2 on 6.3.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main, also with -da on 6.3.0, 9.0.0, 9.12.3, 10.5.2 and main; the independent reproduction's JUnit fails 3 of 4 on main (-ea, -da), 10.5.2 and 9.12.3, and its main() variants fail on 8.11.4 and 6.3.0; read: at every release tag from 6.3.0 to 10.5.2 the wrapping, the (char) 0 separator and the summary loop without a separator check are present, and SplittingBreakIterator#next() is identical after whitespace normalisation (8.8.2 to 8.11.4 are not tagged in the history clone; 8.11.4 was executed)]; not affected: releases up to 6.2.1, which have no UnifiedHighlighter [exec]; not fixed upstream [read, not executed: at upstream main a9779ef3c193 the last commit on FieldHighlighter.java is dbf0d1426f13 (#16056) and on SplittingBreakIterator.java 6faa4f98e048 (2020), and no open PR touches the summary loop or the separator handling]
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, code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record, plus the finder's run: three runs of the scripted prior-report search (45, 51 and 45 queries over apache/lucene issues and PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 243, 332 and 119 distinct hits, none this bug); 53 manual GitHub searches over apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch, hibernate/hibernate-search and apache/lucenenet; the changed files of all 408 open apache/lucene PRs; 13 JIRA queries (SOLR, LUCENE) plus the Hibernate JIRA; 23 list queries; 9 web queries; 16 hits classified, all same_bug false); not fixed upstream (read, not executed: upstream main a9779ef3c193, no CHANGES entry, no open PR touches the code); closest items, not reports: apache/solr#5126 (open, by dsmiley, 2026-10-13: notes that with hl.method=unified multi-valued fields can return a leading empty snippet because Lucene's value separator and Solr's snippet separator are both U+0000; an executed emulation shows that their example needs LengthGoalBreakIterator on the match path, the sibling candidate's code, not this summary loop), the Elasticsearch and OpenSearch CustomFieldHighlighter override of getSummaryPassagesNoHighlight (a downstream workaround in code from 2017; no issue or PR text describes the Lucene defect), and #8490 / LUCENE-7438 (the introduction: 'a passage will never span across values')
Lucene commit tested
main 645d4fe14a20 (2026-10-28) through gradle in a scratch worktree (removed afterwards), seeds DEADBEEF, 5E9A0498 and C0FFEE150, assertions on and off: stock, the 5-test JUnit fails 3 of 5 (its 2 controls pass); with fix B and, separately, fix A it passes, together with every uhighlight test class (470 upstream tests) and all of :lucene:highlighter:test (722 upstream tests), and :lucene:highlighter:check -x test passes with the repro added. Through the repro harness: the new JUnit on 10.5.2, 9.12.3 and 9.1.0 (3 of 5 fail each); the hunt's and an independent reproducer's JUnit on 645d4fe14a20, stock (fail) and with fix B (pass); a probe with one test per offset source (re-analysis, postings offsets, term vectors) on 645d4fe14a20, 6 of 6 fail with assertions on and off and pass with fix B. The hunt, its checks and the history sweep ran on main ea57360912ed, whose highlighter module is identical, and on 6.3.0, 6.6.6, 7.7.3, 8.11.4, 9.0.0, 9.1.0, 9.12.3, 10.0.0 and 10.5.2. OpenJDK 25.0.3, Linux aarch64.

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:88-94 (at ea57360912ed) (no passages -> getSummaryPassagesNoHighlight(maxNoHighlightPassages == -1 ? maxPassages : maxNoHighlightPassages)), :104-108 (javadoc: 'By default this just returns the first maxPassages sentences'), :109-128 (summary loop: a Passage for every consecutive pair of breakIterator.next() boundaries, no separator check)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/SplittingBreakIterator.java:23-31 (at ea57360912ed) (class javadoc: slices 'on both sides of every occurrence'; 'the enclosed BreakIterator will never "see" the splitting character'; @lucene.experimental), :103-124 (next(): end of value k, then sliceStartIdx = sliceEndIdx + 1, the start of value k+1)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:98 (at ea57360912ed) (MULTIVAL_SEP_CHAR = (char) 0), :634-639 (getMaxNoHighlightPassages javadoc: 'the number of leading passages (as delineated by the BreakIterator)', default -1 = maxPassages), :742-748 (highlight(field, query, topDocs) = maxPassages 1, 'the first sentence'), :759-761 (highlight(..., maxPassages): 'the first maxPassages sentences from the field will be returned'), :1135 (every field iterator wrapped in new SplittingBreakIterator(getBreakIterator(field), MULTIVAL_SEP_CHAR)), :1410 and :1463 (LimitedStoredFieldVisitor appends the separator between stored values)
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/DefaultPassageFormatter.java:66-68 (at ea57360912ed) (no ellipsis between connected passages, so the separator passage is copied verbatim); lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:339 (testMaxLengthWithMultivalue asserts value + '... ' + value on the highlight path)
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:98, :1135, :1410, :1463
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/SplittingBreakIterator.java:24-28 (class javadoc), :104-124 (next), :114
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:89-94 (summary call), :104-108 (javadoc), :109-128 (summary loop), :178-182 (highlight path passage start via preceding)
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/DefaultPassageFormatter.java:39-40 (default ellipsis), :66-68 (no ellipsis between touching passages)
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestSplittingBreakIterator.java:58-60; TestUnifiedHighlighter.java:759, :823, :858 (single-valued summary tests)
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:94 (@lucene.experimental), :116 (default -1), :401 (withMaxNoHighlightPassages), :1143-1161 (newFieldHighlighter); FieldHighlighter.java:32 (@lucene.internal)
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:100 (DEFAULT_MAX_LENGTH 10000), :1446-1452 (maxLength budget in LimitedStoredFieldVisitor)
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:635-638 (getMaxNoHighlightPassages javadoc), :742, :759-761, :1463
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/SplittingBreakIterator.java:24-31
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:339; TestUnifiedHighlighterMTQ.java:1139; TestSplittingBreakIterator.java:58-60
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/FieldHighlighter.java:96-100, :109-128, :178-182; SplittingBreakIterator.java:59-64; UnifiedHighlighter.java:1143-1161
History
  • a Lucene clone origin/branch_10_6 4ae946e5a9b9 (git diff against 645d4fe14a20 over the four uhighlight files: cosmetic only)

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

Repro files

About the diagrams

A summary still (bug.png) from the user's call to the joined string, the boundaries, the summary loop, expected vs actual, and the fix. Values and line numbers come from the code at 645d4fe14a20 and an executed probe.

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.