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
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 |
|---|---|
|
Marks the entity as having its own index — required at the root of anything searchable. |
|
A tokenized, analyzed text field — searched by term, ranked by relevance (BM25 by default). |
|
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. |
|
Any other simple type (numbers, dates, booleans) indexed for filtering/sorting/range queries. |
|
Pulls fields from an associated entity into the containing entity’s own index document — lets a |
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).
Geospatial search
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.