Hibernate Search Fundamentals

This section documents Hibernate ORM 7.4.x (User Guide, Introduction, Query Language Guide, Data Repositories Guide), Jakarta Persistence 3.2, Hibernate Search 8.4.x, and the Hibernate Validator / Hibernate Reactive references — which are the references these pages are written and verified against.

This content was generated with the assistance of AI and should be verified against those official docs before being relied on in production.

Three older reference books were consulted as bibliography only while preparing these pages and are not the primary or main source for any page. All three predate Jakarta Persistence 3.2 and Hibernate ORM 6/7 (the javax.persistencejakarta.persistence namespace change, the ORM 6 query-engine rewrite, the Hibernate Search 6+ Elasticsearch backend), so the official documentation above wins on any discrepancy.

This section’s bibliography lists the reference material consulted while preparing these pages.

Hibernate Search keeps a full-text index automatically in sync with mapped Hibernate ORM entities, and exposes a Hibernate-style search DSL over that same domain model — so full-text search reads and feels like another kind of Hibernate query rather than a separate system’s own API.

What Hibernate Search adds

Without it, full-text search over entity data means either bolting LIKE '%term%' queries onto the relational schema (no relevance ranking, no tokenization, no fuzzy matching — and slow, since it cannot use a normal index) or hand-rolling synchronization code that pushes entity changes into a separate search engine. Hibernate Search does the second thing for you: mapped entities are annotated the same way as any other Hibernate mapping, and every persist/merge/remove (and, by default, bulk operations too) automatically updates the index without extra application code, while queries are built with a builder API in the same codebase, over the same entity classes.

Mapping annotations

@Entity
@Indexed
public class Book {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @FullTextField(analyzer = "english")
    private String title;

    @KeywordField
    private String isbn;

    @GenericField
    private Integer publishedYear;

    @IndexedEmbedded
    @ManyToOne(fetch = FetchType.LAZY)
    private Author author;
}

@Indexed
public class Author {
    @FullTextField(analyzer = "english")
    private String name;
}
Annotation Purpose

@Indexed

Marks the entity as having its own index — required at the root of anything searchable.

@FullTextField

A tokenized, analyzed text field — searched by term, ranked by relevance (BM25 by default).

@KeywordField

An exact-match, non-analyzed field — for values compared as a whole (an ISBN, a status code), sortable and filterable but not full-text-searched by individual words.

@GenericField

Any other simple type (numbers, dates, booleans) indexed for filtering/sorting/range queries.

@IndexedEmbedded

Pulls fields from an associated entity into the containing entity’s own index document — lets a Book search also match on its Author’s name, without the `Author needing its own top-level index entry to be searched this way.

Analyzers and normalizers

An analyzer processes a @FullTextField’s text at both index time and query time: tokenizing into terms, lowercasing, removing stop words, stemming (e.g. "running" and "runs" both reduce toward "run"). A normalizer does the same kind of processing but produces a single output token rather than several — for a `@KeywordField that should still be case-insensitive/accent-insensitive without becoming full-text-searchable by partial words. Analyzer/normalizer definitions are backend-specific configuration referenced by name from the mapping annotations, as analyzer = "english" does above — see Hibernate Search Analyzers for how to actually define an analyzer like that one (a language-specific stemming chain, an n-gram-based autocomplete analyzer, …​), and for how to handle content whose language varies per document.

Automatic vs. explicit indexing

By default, every persist/merge/remove (and their bulk-HQL equivalents, where Hibernate can analyze them) against an @Indexed entity automatically re-indexes the affected documents — no explicit call needed for the common case. MassIndexer handles the opposite scenario: (re)building the entire index from the current database contents, needed after first enabling Hibernate Search on existing data, after a mapping change, or to recover from an index that has drifted out of sync:

SearchSession searchSession = Search.session(entityManager);
searchSession.massIndexer(Book.class, Author.class)
        .threadsToLoadObjects(4)
        .startAndWait();

MassIndexer reads and indexes in large batches, bypassing the persistence context the way StatelessSession-based batch processing does, so it scales to reindexing a large existing table without the memory growth a naive per-entity loop would incur.

Mapping bridges

For a field type Hibernate Search’s built-in annotations do not map directly (a custom value object, a computed value combining several entity fields into one index field), a bridge (ValueBridge/PropertyBridge/TypeBridge) defines the conversion between the Java-side value and the indexed representation explicitly — the Hibernate Search equivalent of an AttributeConverter (see Basic & Embeddable Types), but converting to an index-document field rather than a database column.

The search DSL

SearchSession searchSession = Search.session(entityManager);

List<Book> hits = searchSession.search(Book.class)
        .where(f -> f.bool()
                .must(f.match().field("title").matching("persistence"))
                .must(f.range().field("publishedYear").atLeast(2020)))
        .sort(f -> f.field("publishedYear").desc())
        .fetchHits(20);

search() starts the query; where builds the predicate tree (match, range, bool with must/should/ mustNot, and more); sort orders results by an indexed field instead of relevance; aggregation (not shown) computes facet-style buckets over the result set. The DSL’s shape — and the underlying inverted-index/BM25 relevance concepts it queries against — is deliberately close to Elasticsearch's own query DSL, since (per Hibernate Search Backends) that is exactly what runs underneath for the Elasticsearch/OpenSearch backend.

Fuzzy matching — typo tolerance at query time

fuzzy() on a match predicate tolerates a bounded number of character-level edits (insertions, deletions, substitutions — the Levenshtein/edit distance) between the query term and an indexed term, catching typos without the caller needing to get the spelling exactly right:

List<Book> hits = searchSession.search(Book.class)
        .where(f -> f.match().field("title").matching("persistance").fuzzy(1))  // matches "persistence" too
        .fetchHits(20);

The argument (0-2) caps the edit distance — 1 catches a single typo’d/missing/extra character, 2 is more permissive but risks matching unrelated terms; 0 disables fuzziness entirely (equivalent to a plain match()). Fuzzy matching and an n-gram-based autocomplete analyzer (Hibernate Search Analyzers) solve different problems and are not interchangeable: fuzzy matching tolerates a typo against otherwise-complete terms at query time, while an n-gram analyzer prepares a field at index time so an incomplete, partial query term (the user has only typed the first few letters) still matches — an as-you-type search box typically wants the n-gram analyzer, a typo-tolerant "did you mean" style search wants fuzzy(), and the two can be combined on different fields (or the same field indexed twice, under two different @FullTextField names each with its own analyzer) when both kinds of forgiveness are wanted at once.

Projections: loading strategy, IDs, and scores

fetchHits() shown above returns fully loaded Book entities — by default, Hibernate Search never treats the index itself as a system of record: once it has the matching document identifiers, it issues its own database query (via a configurable SelectionLoadingStrategy) to load the actual, current entity state, the same way any other Hibernate query would. This guarantees search results are never stale relative to the database, but it means every hit costs a full entity load even when the caller only needs a lightweight result set first.

select() decouples which documents matched from loading their full state, projecting out only specific fields — most usefully the identifier and the relevance score — so the caller gets back a cheap "hit list" it can complete with its own, explicitly shaped database query afterward:

record BookHit(Long id, Float score) {
}

List<BookHit> hits = searchSession.search(Book.class)
        .select(f -> f.composite(BookHit::new, f.id(Long.class), f.score()))
        .where(f -> f.match().field("title").matching("persistence"))
        .sort(f -> f.score())                 // explicit -- relevance order is otherwise the default anyway
        .fetchHits(20);

List<Long> matchingIds = hits.stream().map(BookHit::id).toList();

// complete the result with a full database query -- full control over the fetch graph (a JOIN FETCH here),
// pagination strategy, or any additional filtering that only makes sense against the database
List<Book> books = entityManager.createQuery(
                "SELECT b FROM Book b JOIN FETCH b.author WHERE b.id IN :ids", Book.class)
        .setParameter("ids", matchingIds)
        .getResultList();

f.id(Long.class) projects just the identifier (the "bit-set" of matching results in the sense of a cheap, index-only hit list); f.score() projects the relevance score Hibernate Search itself computed for the hit; f.composite(…​) combines several projections into one result type per hit, the same way a JPQL SELECT new DTO projection combines several selected expressions (see HQL and JPQL). Sorting explicitly by f.score() is rarely needed on its own (relevance order is the default when no sort is specified at all), but is useful to make relevance an explicit secondary sort key alongside a primary field sort, or simply to document the intent in code.

Faceting with aggregations

Faceting — showing the user "12 results in Fiction, 5 in Non-Fiction" alongside a result list, and letting them narrow by clicking one — is built from an aggregation that counts hits per bucket, computed over the same query used for the results:

AggregationKey<Map<String, Long>> countsByGenre = AggregationKey.of("countsByGenre");

SearchResult<Book> result = searchSession.search(Book.class)
        .where(f -> f.match().field("title").matching("persistence"))
        .aggregation(countsByGenre, f -> f.terms().field("genre", String.class))
        .fetch(20);

List<Book> hits = result.hits();
Map<String, Long> facetCounts = result.aggregation(countsByGenre);   // e.g. {"Fiction": 12, "Non-Fiction": 5}

A terms aggregation (f.terms()) buckets by the distinct values of a field — the discrete-facet case above (genre, status, category). A range aggregation (f.range()) buckets a numeric/date field into caller-defined ranges instead — price bands, publication-decade buckets:

AggregationKey<Map<Range<Double>, Long>> priceBuckets = AggregationKey.of("priceBuckets");

searchSession.search(Book.class)
        .where(f -> f.matchAll())
        .aggregation(priceBuckets, f -> f.range()
                .field("price", Double.class)
                .range(0.0, 20.0)
                .range(20.0, 50.0)
                .range(50.0, null))
        .fetch(20);

The filtering half of faceting is just an ordinary predicate: when the user clicks the "Fiction" bucket, add .must(f.match().field("genre").matching("Fiction")) to the next search’s where clause — the aggregation counts, a predicate narrows, and a typical faceted-search UI runs both together on every request (the aggregation recomputed against the narrowed result set so bucket counts stay accurate as more facets are applied).

A field mapped as a GeoPoint (a latitude/longitude pair) supports proximity predicates, distance sorting, and distance projection — composed from two plain coordinate fields with @Latitude/@Longitude, bound together into one spatial field with @GeoPointBinding:

@Entity
@Indexed
@GeoPointBinding(fieldName = "location")
public class Store {

    @Id
    private Long id;

    @Latitude
    private Double latitude;

    @Longitude
    private Double longitude;
}
List<Store> nearbyStores = searchSession.search(Store.class)
        .where(f -> f.spatial().within().field("location")
                .circle(37.7749, -122.4194, 10, DistanceUnit.KILOMETERS))
        .sort(f -> f.distance("location", 37.7749, -122.4194))     // nearest first
        .fetchHits(20);

The within().circle(…​) predicate matches documents whose location falls inside the given radius around a center point; f.distance(…​) both sorts by distance and, via select(), projects the computed distance value itself — the same select()/f.composite(…​) pattern shown above for IDs and scores applies here too. This is an index-backed proximity search: fast and approximate, well suited to "stores near me" style queries over a large dataset. Both Hibernate ORM and Hibernate Search support geospatial data, at different layers — see Basic & Embeddable Types — Geospatial types with Hibernate Spatial for mapping and querying true Geometry/Point columns directly against the database (via hibernate-spatial/PostGIS-style spatial SQL functions), which is the right tool when the query needs exact geometric operations (polygon containment, precise area/length calculations) rather than an index-backed approximate nearest-neighbor search.