Search your beans with Lucene — Highlighting

Thursday, Sep 17, 2026 | 4 minute read

David Pilato
Search your beans with Lucene — Highlighting

Let say we found some documents matching our query. But how could we know, where in the text, it matched? Let’s try to bold the tokens in the stored title, artist or genre.

Suggest and highlighting look similar in the UI — both wrap matched letters in <b> — but they are not the same call.

AnalyzingInfixSuggester.lookup(..., highlight=true) returns a display string (highlightKey) from a second dictionary. The chip value stays clean (key); the bold markup is never stored.

UnifiedHighlighter runs on the track index, with the same Query that just scored the hit. It reads stored field text and paints token offsets — including the trailing PrefixQuery (sincla<b>Sinclar</b>).

One is “help me write the query.” The other is “show me why this row matched.”

Highlight my results

Highlighting lives in its own artefact:

<dependency>
  <groupId>org.apache.lucene</groupId>
  <artifactId>lucene-highlighter</artifactId>
  <version>10.5.1</version>
</dependency>

WholeBreakIterator keeps the full stored value as one fragment — no sentence chopping for short metadata fields. Same builder as the playground:

Query bob = new BooleanQuery.Builder()
  .add(new BoostQuery(new TermQuery(new Term("title", "bob")), 4.0f), BooleanClause.Occur.SHOULD)
  .add(new BoostQuery(new PrefixQuery(new Term("title", "bob")), 1.0f), BooleanClause.Occur.SHOULD)
  .add(new BoostQuery(new TermQuery(new Term("artist", "bob")), 3.0f), BooleanClause.Occur.SHOULD)
  .add(new BoostQuery(new PrefixQuery(new Term("artist", "bob")), 0.75f), BooleanClause.Occur.SHOULD)
  // … genre^2 / album^1.5 / label^1 / comment^0.5 (+ prefixes) …
  .setMinimumNumberShouldMatch(1)
  .build();

// Search and retrieve the 10 first hits
TopDocs hits = searcher.search(bob, 10);

UnifiedHighlighter highlighter = UnifiedHighlighter.builder(searcher, analyzer)
    .withMaxLength(10_000)
    .withBreakIterator(WholeBreakIterator::new)
    .build();
Map<String, String[]> hl = highlighter.highlightFields(
        new String[]{"title", "artist", "genre", "album", "label", "comment"},
        bob, hits);

Fields must be Field.Store.YES otherwise Lucene cannot extract the source data we can highlight it.

Each field maps to a parallel array — one snippet per hit. Missing or blank snippets mean that field did not contribute markup for that doc. Hand the map to the template as display-only HTML; keep the clean bean for chips and URLs.

q=Bob wraps the token wherever it matched:

Outro Lugar - <b>Bob</b> Sinclar Remix
Crazy (<b>Bob</b> Sinclar vs. Dimitri Vegas & Like Mike remix)

This also works for PrefixQuery even though the whole term is highlighted, not just the typed prefix:

Outro Lugar - Bob <b>Sinclar</b> Remix

How it works in Lucene

Search answers “which documents contain this term?” Highlighting answers “where in the stored string?” Both read the same inverted index — they just dig one level deeper.

You remember the posting list we generated in a previous post?

artist:bob       →  1, 2, 3
artist:claude    →  4, 5
artist:francois  →  4, 5, 6
artist:marley    →  2
artist:sinclar   →  1, 3
artist:valery    →  6

Actually, it was not only generating posting lists for each term, it also kept track of the positions of those terms within each document.

Positions for phrase queries

Each posting also stores positions: the ordinal of the term inside that field’s token stream (0-based).

For example, consider the artist name “Claude François”, which is tokenized into two terms: claude and francois. The positions of these terms within the document are as follows:

PositionTerm
0claude
1francois

For the artist name François “Valéry” (tokenized as francois and valery), the positions are:

PositionTerm
0francois
1valery

For “Earth Wind and Fire”, the positions are:

PositionTerm
0earth
1wind
2and
3fire

If you search for francois valery, you will get back the document “François Valéry” because one of the query terms matched the document. If you search for valery francois, it will give you the exact same two documents, because the order of terms in a simple conjunction query does not matter.

What if it matters? In that case, you would need to use a phrase query or a span query to enforce the order of terms. This is where term positions come into play.

Offsets paint the characters

But highlighting needs one more step: map the position back onto characters of the stored surface string (Field.Store.YES).

At analysis time, each token also carries character offsets into the original string — start inclusive, end exclusive:

"Earth Wind and Fire"
 earth         → [0, 5)
 wind          → [6, 10)
 and           → [11, 14)
 fire          → [15, 19)

The UnifiedHighlighter re-runs the same analyzer on the stored value, keeps tokens that satisfy the query (TermQuery wind, or a PrefixQuery win* that matches wind), and wraps those [start, end) slices with <b></b> html tags:

Earth <b>Wind</b> and Fire

Now you just need to adapt the CSS to match the way you want it to look like.

q=wind — orange <b>Wind</b> on Earth, Wind & Fire and titles like Ride Like the Wind.

q=wind — orange <b>Wind</b> on Earth, Wind & Fire and titles like Ride Like the Wind.

© 2010 - 2026 David Pilato

Search is powered by Pagefind. Just hit CTRL+K or CMD+K to start searching.

Powered by Hugo with Dream and Devrel themes.

Details

I discovered Elasticsearch project in 2011. After contributed to the project and created open source plugins for it, David joined elastic the company in 2013 where he is Developer and Evangelist. He also created and still actively managing the French spoken language User Group. At elastic, he mainly worked on Elasticsearch source code, specifically on open-source plugins. In his free time, he likes talking about elasticsearch in conferences or in companies (Brown Bag Lunches AKA BBLs). He is also author of FSCrawler project which helps to index your pdf, open office, whatever documents in elasticsearch using Apache Tika behind the scene.

Who am I?

Developer | Evangelist at elastic and creator of the Elastic French User Group. Frequent speaker about all things Elastic, in conferences, for User Groups and in companies with BBL talks. In my free time, I enjoy coding and deejaying as DJ Elky, just for fun. Living with my children in Cergy, France.

Social Links