A search box that only knows LIKE '%keyword%' is the kind of thing that works fine in a demo and then quietly costs a business real users — someone types “Godfaher” instead of “Godfather”, gets zero results, and leaves rather than trying again. Fixing that by hand-rolling a sync job to a search engine turns into its own nightmare the first time a database commit succeeds while the corresponding search-engine update fails.
This is the beginner-through-expert guide to Hibernate Search — automatic indexing tied to Hibernate’s own commit events, fuzzy full-text matching, exact keyword fields, searching through an association, and rebuilding an index from the database with a MassIndexer — built and verified against a real Spring Boot project. Every code block links to the file it came from and every console line was copied from a real, committed test run in the hibernate-demo companion repository.
Versions used in this post. Hibernate ORM 7.4.5.Final,hibernate-search-mapper-orm8.4.0.Final,hibernate-search-backend-lucene8.4.0.Final, Spring Boot 4.1.1, JDK 25. The earlier version of this post called the library “Hibernate Search 7” and pinned7.3.2.Final— both are corrected below, checked against Maven Central rather than assumed.
Why LIKE ‘%keyword%’ stops working long before you expect
A relational database is built to find an exact row by an exact value — a primary key, a foreign key, a value that matches a B-Tree index precisely. A LIKE '%keyword%' query with a leading wildcard cannot use that index at all: the database has no choice but to scan every row and compare the string by hand, which is fine at a thousand rows and unusable at a million.
Even where performance were not a concern, LIKE has no idea that “Godfaher” is a typo of “Godfather”, that “running” and “ran” are the same concept, or that a match in a title matters more than a match in a footer. Those are the jobs of a real search engine — tokenizing text, tolerating edit distance, scoring relevance — and Hibernate Search’s job is to keep that engine’s index synchronized with your entities without you writing the sync code by hand.
The mental model: two stores, kept in sync by Hibernate’s own event system
An @Indexed entity lives in two places at once: the row in the relational database, which is the source of truth, and a document in a separate search index (Lucene, in this repository), which exists purely to be searched quickly. Hibernate Search does not replace the database — it listens to Hibernate’s own persistence event system, and after a transaction commits a change to an @Indexed entity, it extracts the changed fields and writes them into the index automatically.
That last line is worth planting early: everything that goes wrong with automatic indexing later in this post — a stale index after a bulk update, an index that never got built for existing rows — is a variation of “something changed the database without going through this event system.”
The smallest thing that works: an @Indexed entity and a fuzzy search
@Entity
@Indexed
public class Movie {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@FullTextField
private String title;
@KeywordField
private String genre;
@GenericField(sortable = Sortable.YES)
private int releaseYear;
@ManyToOne
@JoinColumn(name = "director_id")
@IndexedEmbedded
@IndexingDependency(reindexOnUpdate = ReindexOnUpdate.SHALLOW)
private Director director;
// constructor, getters omitted -- see the file
}
Movie.java. @Indexed is the only annotation that actually enrolls an entity in the search index — everything else on this class describes how individual fields should be indexed, covered section by section below.
SearchSession searchSession = Search.session(em);
// Deliberate typo: "Godfaher" instead of "Godfather" -- fuzzy(1) tolerates a
// one-character edit distance.
List<Movie> hits = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Godfaher").fuzzy(1))
.fetchHits(20);
HibernateSearchTest.java persists two movies through a plain EntityManager, commits, then searches for a misspelled title:
RESULT[search-fulltext-fuzzy]: searching title for 'Godfaher' (a one-character typo of 'Godfather') with .fuzzy(1) matched: [Fts The Godfather, Fts The Godfather Part II] -- a plain SQL LIKE '%Godfaher%' would have matched nothing.
Full transcript: 25-fulltext-fuzzy.txt. Full chapter: companion repo chapter 25. Nothing here calls the search engine directly — em.persist and commit() are the only writes the application code performs; the indexing happened automatically, which is the entire point.
Two ways to index a field: analyzed text versus one exact value
@FullTextField and @KeywordField answer different questions, and mixing them up is the single most common reason a query returns the wrong number of hits. A @FullTextField runs the value through an analyzer — splitting it into lower-cased tokens — so it can be matched by any of those tokens, fuzzily, case-insensitively. A @KeywordField is stored and compared as one indivisible value, with no analysis at all, which is what makes it usable for exact filtering, sorting, and faceting.
SearchSession searchSession = Search.session(em);
List<Movie> exactMatch = searchSession.search(Movie.class)
.where(f -> f.bool()
.must(f.match().field("genre").matching("Science Fiction"))
.must(f.match().field("title").matching("Kwf")))
.fetchHits(20);
// A KeywordField compares the WHOLE stored value -- lowercase, partial "science"
// does not match "Science Fiction" the way an analyzed FullTextField term would.
List<Movie> partialLowercaseAttempt = searchSession.search(Movie.class)
.where(f -> f.bool()
.must(f.match().field("genre").matching("science"))
.must(f.match().field("title").matching("Kwf")))
.fetchHits(20);
HibernateSearchTest.java runs both queries against the same two movies:
RESULT[search-keyword-exact-match]: @KeywordField genre matched by the exact stored value 'Science Fiction' -> 1 hit(s) | the same field searched with the partial, lowercase 'science' -> 0 hit(s) -- a KeywordField is compared whole, unlike a FullTextField's tokenized and lower-cased terms.
Full transcript: 25-keyword-exact-match.txt.
@GenericField(sortable = Sortable.YES) is the third field type: a plain value, with no analysis and no exact-match keyword semantics, explicitly opted into sorting. The default for a @GenericField is not sortable — the attribute has to be set on purpose:
List<Movie> sortedNewestFirst = searchSession.search(Movie.class)
.where(f -> f.match().field("title").matching("Sgf"))
.sort(f -> f.field("releaseYear").desc())
.fetchHits(20);
RESULT[search-sortable-generic-field]: sort(f -> f.field("releaseYear").desc()) over the 3 Sgf-prefixed movies -- years in the order returned: [1993, 1982, 1975] -- @GenericField(sortable = Sortable.YES) is what makes this sort possible; the default is NOT sortable.
Full transcript: 25-sortable-generic-field.txt. See companion repo chapter 25 for the full field-type table, including @GenericField‘s other options.
- Naming an analyzer that isn’t registered —
@FullTextField(analyzer = "english")without a matchingLuceneAnalysisConfigurerbean — fails startup outright rather than falling back silently; the next section covers exactly this trap.
The correction: there is no “Hibernate Search 7”
An earlier version of this post called the library “Hibernate Search 7” and pinned 7.3.2.Final. Checked against repo1.maven.org/maven2/org/hibernate/search/hibernate-search-mapper-orm/maven-metadata.xml, the current GA line is 8.4.0.Final, and that artifact’s own pom.xml depends on hibernate-core:7.4.0.Final — compatible with this repository’s pinned Hibernate ORM 7.4.5.Final, since both are the same major.minor line. Hibernate Search simply does not version itself in lock-step with Hibernate ORM; Search 8.x pairing with ORM 7.x is what its own POM resolves to, not a coincidence of “8 follows 7.”
Two configuration traps that break the whole application, not just this feature. Both were found by wiring this chapter up against the real jars, not by reading about the API. First, naming an analyzer that Lucene’s backend does not ship —@FullTextField(analyzer = "english")with nothing registering an “english” analyzer — fails the entire application context at startup withHSEARCH000353: Unknown analyzer: 'english'; a custom analyzer needs aLuceneAnalysisConfigurerbean first, and omitting the attribute entirely just uses Hibernate Search’s own built-in default. Second, an@IndexedEmbeddedassociation with no defined inverse side fails bootstrap withHSEARCH700020: Unable to find the inverse side of the association— covered next.
Because this chapter’s Movie entity is @Indexed and lives in this application’s normally-scanned package tree, every test in the whole repository that boots the shared Spring context now bootstraps Hibernate Search too — confirmed the hard way, by watching an unrelated, already-passing aggregate-function test fail until the backend was configured correctly in application.yml‘s hibernate.search.* block. A search index is global to the application, not scoped to whichever test happens to use it.
Searching through an association with @IndexedEmbedded
Director carries no @Indexed annotation of its own — it only appears inside Movie‘s index because Movie.director is marked @IndexedEmbedded. That is what makes a search on the director’s name match the movie, without Director ever being independently searchable.
@ManyToOne
@JoinColumn(name = "director_id")
@IndexedEmbedded
@IndexingDependency(reindexOnUpdate = ReindexOnUpdate.SHALLOW)
private Director director;
Director.java / Movie.java. Without the @IndexingDependency(reindexOnUpdate = ReindexOnUpdate.SHALLOW) line, this association fails bootstrap outright with HSEARCH700020: Unable to find the inverse side of the association — Hibernate Search needs to know how to find every Movie that embeds a given Director so it can reindex them when that Director changes, and this model has no @OneToMany back-reference to answer that. Adding one is the alternative fix; opting out with SHALLOW is correct here because reindexing on a director update isn’t something this chapter needs.
SearchSession searchSession = Search.session(em);
List<Movie> hits = searchSession.search(Movie.class)
.where(f -> f.bool()
.must(f.match().field("title").matching("Iea"))
.must(f.match().field("director.name").matching("Iea Christopher Nolan")))
.fetchHits(20);
RESULT[search-indexed-embedded]: field 'director.name' matched the full keyword value 'Iea Christopher Nolan' -- [Iea Inception] -- Director itself carries no @Indexed annotation at all; its @KeywordField only exists inside Movie's index because of @IndexedEmbedded on the director association.
Full transcript: 25-indexed-embedded.txt.
- Nesting
@IndexedEmbeddedseveral levels deep is possible but grows the index document and the reindexing cost at every level — see chapter 25 for the reindex-on-update options beyondSHALLOW.
What breaks: a stale index, and the fix that rebuilds it from the database
The index and the database are two separate stores, and the diagram earlier already flagged the consequence: anything that changes the database without going through Hibernate’s event system — a bulk HQL UPDATE, a native SQL statement, a table that existed before Hibernate Search was added to the project — leaves the index unaware of the change. This is exactly the same gap this repository’s interceptors post found for interceptor callbacks: bulk writes bypass Hibernate’s ordinary managed-entity machinery entirely, and automatic indexing is built on that same machinery.
searchSession.workspace().purge(); // empty the index; the database row is untouched
searchSession.massIndexer(Movie.class).startAndWait();
HibernateSearchTest.java simulates a stale index by purging it deliberately, confirms the row disappears from search results while it is still in H2, then rebuilds the index straight from the database:
RESULT[search-mass-indexer]: hits before purge=1 | hits after workspace().purge() (row still in H2, index emptied)=0 | hits after massIndexer(Movie.class).startAndWait() (index rebuilt straight from the database, no re-persisting)=1.
Full transcript: 25-mass-indexer.txt. MassIndexer is the fix for both “the index went stale” and “this table existed before Hibernate Search was added to the project” — it reads straight from the database and does not re-persist anything.
The fingerprint of this gap in production. A search that returns stale or missing results specifically for rows touched by an admin bulk-edit feature or a batch job — while ordinary, one-at-a-time saves through application code always show up correctly — is exactly this bypass. Reindex explicitly, or run MassIndexer, after any write path that does not go through managed entities.
This repository’s tests all rely on Hibernate Search’s default coordination: indexing happens synchronously, in the same thread and transaction as the entity change, which is what lets a search immediately after commit() reliably see the new data. For a JTA or distributed deployment, hibernate-search-mapper-orm-coordination-outbox-polling (confirmed to exist at the same 8.4.0.Final line on Maven Central) writes index updates to an outbox table first and applies them asynchronously instead — trading that immediate-visibility guarantee for not blocking the write transaction on indexing work. See chapter 25 for the coordination-strategy trade-off in full, and the Lucene-versus-Elasticsearch backend choice, which changes the pom.xml dependency and backend properties without touching a single annotation on Movie or Director.
Should you reach for Hibernate Search at all? For genuine full-text needs — typo tolerance, relevance ranking, searching across several fields at once — yes, and the automatic indexing is what removes the sync-job problem that makes hand-rolled search integrations so fragile. For a feature that only needs an exact match, a range, or a small fixed set of filters, a plain HQL or Criteria query (see this repository’s aggregate functions and sorting posts) is simpler, has one store instead of two, and needs no MassIndexer step at all.
Further reading
- hibernate-demo companion repository — chapter 25 and all five tests behind this post
- Hibernate 7 Interceptors — the same bulk-update bypass, for interceptor callbacks instead of the search index
- Hibernate 7 Aggregate Functions
- Sorting in Hibernate 7
- Hibernate 7 Pagination
- Official Hibernate Search 8.4 Reference Documentation
No Comments yet!