Integrating Apache Lucene for Bean Search — Part 3: Search

Friday, Sep 11, 2026 | 4 minute read

David Pilato
Integrating Apache Lucene for Bean Search — Part 3: Search

This post is part of a series of 3:

Part 1 mapped beans to documents. Part 2 owned the writer and kept the index warm. Lucene still returns documents, not your Java types — so the last mile is queries and hit → bean resolution.

From documents to beans

Run a Lucene query, collect hit ids (and scores when relevant), then join back to your beans — either by filtering a caller-provided corpus or by loading from a repository.

Build queries

You can hand-craft Lucene queries, or translate a user-facing string into a Query.

// Exact id match on a StringField — not analyzed
Query q = new TermQuery(new Term(TrackIndexFields.ID, "42"));
TopDocs hits = searcher.search(q, 10); // at most 10 hits

Free-text across boosted fields

Search several fields with relative boosts (title beats comment):

BooleanQuery.Builder fields = new BooleanQuery.Builder();
// SHOULD = OR across fields; BoostQuery raises score when title matches
fields.add(new BoostQuery(containsQuery("title", term), 4.0f), BooleanClause.Occur.SHOULD);
fields.add(new BoostQuery(containsQuery("artist", term), 3.0f), BooleanClause.Occur.SHOULD);
fields.add(new BoostQuery(containsQuery("genre", term), 2.0f), BooleanClause.Occur.SHOULD);
fields.setMinimumNumberShouldMatch(1); // at least one field must match
Query freeText = fields.build();

Fielded filters and ranges

Examples of what a small TrackLuceneQueryBuilder can produce from bookmarkable q strings:

User queryLucene idea
genre:Clubterm / wildcard on a keyword field
bpm:[120 TO 130]DoublePoint / DoubleField range
rating:5exact numeric
sinclar~FuzzyQuery (opt-in typo tolerance)
blankMatchAllDocsQuery

Keep query construction in one place (TrackLuceneQueryBuilder). That makes parity tests easy: index known beans, assert hit ids for representative queries.

Resolve hits to beans

One approach that works well for playlist-style scoping:

  1. Run the Lucene query against the full index.
  2. Collect hit ids (and scores when relevant).
  3. Intersect / order against a caller-provided corpus (List<Track>).
/**
 * Narrow {@code corpus} to tracks that match {@code q}, preserving Lucene score order.
 * Scoping (playlist vs whole library) stays outside Lucene — pass the base list in.
 */
public List<Track> filter(List<Track> corpus, String q) {
    // Empty query → no filtering; return a defensive copy of the input
    if (q == null || q.isBlank() || corpus.isEmpty()) {
        return List.copyOf(corpus);
    }
    try {
        IndexSearcher searcher = index.searcher();
        // Close the reader when done — it holds a snapshot of the index
        try (IndexReader reader = searcher.getIndexReader()) {
            // Ask for up to every doc so we can intersect with corpus afterwards
            TopDocs hits = searcher.search(
                    TrackLuceneQueryBuilder.build(q),
                    Math.max(1, reader.numDocs()));

            // O(1) lookup: only keep hits that belong to this corpus (e.g. one playlist)
            Map<String, Track> byId = new HashMap<>(corpus.size());
            for (Track track : corpus) {
                byId.put(track.id(), track);
            }

            // Walk hits in score order; skip ids not in the corpus
            List<Track> ordered = new ArrayList<>(hits.scoreDocs.length);
            for (var hit : hits.scoreDocs) {
                // Stored id field (Field.Store.YES) — Lucene doc id ≠ Track.id
                String id = reader.storedFields().document(hit.doc).get(TrackIndexFields.ID);
                Track track = byId.get(id);
                if (track != null) {
                    ordered.add(track);
                }
            }
            return List.copyOf(ordered);
        }
    } catch (IOException e) {
        throw new UncheckedIOException("Unable to search track index", e);
    }
}

Why the corpus step? Keep scoping outside Lucene: the handler picks the base list (all tracks or one playlist), then filter(base, q) drops ids that are not in that list. For a generic bean store you can instead load beans by id from a repository after the search.

When free-text scoring matters, walk hits.scoreDocs in order. When the query is pure filters, corpus order is often enough.

Optional: autocomplete with lucene-suggest

If you declared lucene-suggest, wire an AnalyzingInfixSuggester on a second directory. For small libraries it is fine to rebuild suggestions from the current bean map after every mutation (incremental suggest delete is awkward):

// Second Directory dedicated to suggestions (often also in-memory)
suggester = new AnalyzingInfixSuggester(suggestionDirectory, TrackAnalyzers.searchAnalyzer());
// Full rebuild after mutations — simpler than incremental suggest deletes
suggester.build(new YourInputIterator(beans));
// prefix → up to 10 suggestions; payloads can carry field name / metadata
List<Lookup.LookupResult> matches = suggester.lookup(prefix, Set.of(), 10, false, false);

Payloads can carry metadata (for example the field name: title / artist / genre). Skip this entirely if you only need search and filters.

Series

© 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