Hibernate Search: Full-Text Search with Lucene Example

Hibernate Search adds full-text search to Hibernate entities. Map fields with @FullTextField, @KeywordField and @GenericField, let the index follow every commit, rebuild it with the MassIndexer, and search with match, phrase, bool, range, sorting, paging and highlighting on Hibernate Search 8.4 and Lucene.

Jakarta_EE

Hibernate Search is a Hibernate library that adds full-text search to Hibernate entities. It copies the fields we choose into a search index, updates that index when a transaction commits, and answers queries with managed entities. Full-text search matches words, not characters, so a search for “testing” also finds “tests”, and the best match comes first.

We use Hibernate Search behind the search box of an app, for example a podcast catalog where listeners type a few words and expect the most relevant episodes first. The index is stored either in Apache Lucene inside our application or in an Elasticsearch or OpenSearch server.

The following example maps an Episode entity with @Indexed and field annotations such as @FullTextField, and searches it with the SearchSession DSL next to a JPQL LIKE query.

@Entity
@Indexed
public class Episode {

  @Id
  @GeneratedValue
  private Long id;

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

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

  @KeywordField
  private String host;

  @GenericField(sortable = Sortable.YES)
  private LocalDate publishedOn;
}
SearchSession searchSession = Search.session(em);

List<Episode> hits = searchSession.search(Episode.class)
    .where(f -> f.match().fields("title", "description").matching("testing"))
    .fetchHits(20);     // [Testing Spring Boot Apps, Code Reviews]
                        // select ... from Episode e1_0 where e1_0.id in (?,?)

List<Episode> likeRows = em.createQuery("from Episode where lower(description) like :text", Episode.class)
    .setParameter("text", "%testing%")
    .getResultList();   // [] : "tests" does not contain "testing"

Notice that the LIKE query returns an empty list, because “tests” does not contain the characters “testing”, whereas Hibernate Search finds both episodes and loads them with one SELECT by id.

Next, we compare full-text search with SQL LIKE and build the podcast example step by step, from the analyzer and indexing to boosting, phrases, filters, sorting, paging and highlighting.

1. What Does Hibernate Search Add Over SQL LIKE?

A LIKE ‘%text%’ condition compares characters, so it misses other forms of a word and matches words that only contain the text, such as “latest” for “test”. It also returns the rows in no useful order. A full-text index stores terms instead. An analyzer splits the text into lowercase words and reduces each word to its root, so “tests” and “testing” both become the term “test”. The query text goes through the same analyzer, so the query and the index compare the same terms.

Top: the english analyzer turns "Unit tests and integration tests" into the tokens Unit, tests, and, integration, tests, then lowercases them and stems them to unit, test, and, integr, test; the query "Testing" also becomes test and matches. Bottom: a grid where like '%testing%' finds no episode, like '%test%' finds three episodes including Hibernate Performance only because "latest" contains "test", and match "testing" finds Testing Spring Boot Apps and Code Reviews, best match first
The analyzer reduces “tests” and “Testing” to the same term. LIKE compares characters, so it misses word forms and matches parts of other words.

For example, a shop needs typo tolerance and the best match first in its product search box, whereas an admin screen that filters orders by an exact customer code works fine with plain SQL. Both approaches answer different questions, so most applications use both.

NeedSQL LIKE or JPQLHibernate Search
Other forms of a word (“tests” for “testing”)NoYes, through the analyzer
Avoid matches inside other words (“latest” for “test”)NoYes, whole terms only
Best match firstNo, needs an ORDER BY columnYes, by relevance score
Typos (“virtul” for “virtual”)NoYes, with fuzzy()
Highlight the matched wordsNoYes
Uses a database index on ‘%text%’No, scans the rowsYes, the Lucene index
Sees uncommitted changes in the same transactionYesNo, the index is updated after commit

Hibernate Search keeps two copies of the data. The database stays the source of truth; the index holds only the searchable fields and the entity id. A search runs against the index, and Hibernate ORM then loads the matching entities from the database by id.

Indexing path on the left: em.persist and commit; at flush Hibernate ORM inserts the row into the Episode table, and after commit Hibernate Search analyzes "Testing Spring Boot Apps" into test, spring, boot, app and writes the terms into the Lucene index. Query path on the right: search(Episode.class).where(match "testing") is analyzed to test, Lucene returns ids 1 and 4 with the best score first, Hibernate ORM runs select ... where id in (?,?) and returns the list Testing Spring Boot Apps, Code Reviews
The index answers “which ids match, in which order”. The database still returns the entities, with one SELECT per search.

2. Hibernate Search Example

The following example is a podcast catalog, in which each Episode has a title, a description, a host, a publish date and a duration. It runs on Hibernate Search 8.4.0.Final with Hibernate ORM 7.4, Java 25 and H2, and keeps the Lucene index in memory, so no search server is needed. We can also run Hibernate Search with Lucene in a Spring Boot application with the same mapping. The catalog starts with five episodes.

EpisodeHostPublishedMinutes
Testing Spring Boot AppsLokesh2026-01-1042
Virtual Threads in Java 25Anna2026-02-1435
Hibernate PerformanceLokesh2026-03-0350
Code ReviewsRavi2026-04-2128
Records and Pattern MatchingAnna2026-05-3031

2.1. Dependencies and Configuration

Hibernate Search needs two modules, namely a mapper that reads the annotations on our entities and a backend that stores the index. Hibernate Search 8.4 is the series built for Hibernate ORM 7.4, and its release page lists ORM 7.4 as the compatible version, with Java 17, 21, 25 or 26.

<dependency>
  <groupId>org.hibernate.search</groupId>
  <artifactId>hibernate-search-mapper-orm</artifactId>
  <version>8.4.0.Final</version>
</dependency>
<dependency>
  <groupId>org.hibernate.search</groupId>
  <artifactId>hibernate-search-backend-lucene</artifactId>
  <version>8.4.0.Final</version>
</dependency>

Hibernate Search starts with Hibernate and reads its settings from the same configuration. We bootstrap with HibernatePersistenceConfiguration; the same keys work in persistence.xml or hibernate.properties.

new HibernatePersistenceConfiguration("podcast-search")
    .managedClasses(Episode.class)
    .jdbcUrl("jdbc:h2:mem:podcast;DB_CLOSE_DELAY=-1")
    .property("hibernate.search.backend.directory.type", "local-heap")
    .property("hibernate.search.backend.lucene_version", "LATEST")
    .property("hibernate.search.backend.analysis.configurer",
        "class:com.howtodoinjava.hibernate.search.EnglishAnalysisConfigurer")
    .createEntityManagerFactory();

Two of the keys stay unset in our example, so Hibernate Search uses their defaults.

PropertyOur valueWhat it does
hibernate.search.backend.directory.typelocal-heapKeeps the index in memory; the default local-filesystem writes it to disk
hibernate.search.backend.directory.rootnot setFolder for local-filesystem indexes; default is the working directory
hibernate.search.backend.lucene_versionLATESTLucene index format; without it Hibernate Search logs warning HSEARCH000075 and uses 9.12.3
hibernate.search.backend.analysis.configurerour configurer classRegisters custom analyzers and normalizers
hibernate.search.schema_management.strategynot setCreates missing indexes at startup and validates existing ones (create-or-validate)

2.2. Mapping the Episode Entity

The @Indexed annotation gives the entity its own index, named after the entity. Each field annotation adds one field to that index, and the annotation decides how the value is stored and how we can search it.

AnnotationHow the value is indexedUse it forOur fields
@FullTextFieldSplit into terms by an analyzerWord search, phrases, relevance, highlightingtitle, description
@KeywordFieldThe whole value as one termExact match, sorting text, facetshost, title_sort
@GenericFieldAs a number, date, boolean or enumExact match, ranges, sortingpublishedOn, durationMinutes

A field is sortable only when we ask for it, because sorting needs extra data in the index. A full-text field cannot be sorted at all, so we index the title a second time as a keyword field named title_sort.

@FullTextField(analyzer = "english")
@KeywordField(name = "title_sort", sortable = Sortable.YES, normalizer = "lowercase")
private String title;

@FullTextField(analyzer = "english", highlightable = Highlightable.UNIFIED)
@Column(length = 2000)
private String description;

@KeywordField
private String host;

@GenericField(sortable = Sortable.YES)
private LocalDate publishedOn;

@GenericField(sortable = Sortable.YES)
private int durationMinutes;

The annotations @Entity, @Id and @Column are the usual JPA annotations, and Hibernate Search uses the entity id as the document id. A LocalDate field such as publishedOn is indexed as a date, so date ranges work without conversion.

2.3. Defining an Analyzer

An analyzer has one tokenizer, which splits the text into words, and a chain of token filters, which change each word. The Lucene backend has built-in analyzers named default, standard, simple, whitespace, stop and keyword. The default analyzer splits and lowercases, but it does not reduce words to their root, so “tests” would not match “testing”. We register our own english analyzer with a LuceneAnalysisConfigurer.

public class EnglishAnalysisConfigurer implements LuceneAnalysisConfigurer {

  @Override
  public void configure(LuceneAnalysisConfigurationContext context) {
    context.analyzer("english").custom()
        .tokenizer("standard")
        .tokenFilter("lowercase")
        .tokenFilter("snowballPorter").param("language", "English")
        .tokenFilter("asciiFolding");

    context.normalizer("lowercase").custom()
        .tokenFilter("lowercase")
        .tokenFilter("asciiFolding");
  }
}

The snowballPorter filter is the stemmer, which cuts “testing” and “tests” to “test”, and “integration” to “integr”. The asciiFolding filter turns accented letters into plain ones, so an accented “e” is indexed as “e”. A normalizer is an analyzer without a tokenizer, so it keeps the value as one term and only changes it, which is what keyword fields need.

2.4. Indexing on Commit

We save entities as usual with em.persist(). Hibernate Search listens to Hibernate ORM events and updates the index after the transaction commits, not at persist() or flush(), so a search in the same transaction does not see the new episodes yet. For example, an admin page that saves an episode and searches for it in the same transaction gets an empty list. The helper EpisodeSearch.match() runs the match query from the intro snippet.

emf.runInTransaction(em -> {
  Database.episodes().forEach(em::persist);   // insert into Episode (...) values (...)  x5
  em.flush();
  List<Episode> before = EpisodeSearch.match(em, "testing");   // [] : not indexed before commit
});

emf.runInTransaction(em ->
  EpisodeSearch.match(em, "testing"));        // [Testing Spring Boot Apps, Code Reviews]

Updates and deletes work the same way. Calling a setter on a managed entity reindexes it, and deleting the entity removes its document.

emf.runInTransaction(em -> {
  Episode reviews = EpisodeSearch.match(em, "reviews").get(0);
  reviews.setTitle("Code Reviews and Testing");   // update Episode set ... where id=?
});
List<Episode> updated = EpisodeSearch.match(em, "testing");   // [Testing Spring Boot Apps, Code Reviews and Testing]

emf.runInTransaction(em -> em.remove(EpisodeSearch.match(em, "reviews").get(0)));
                                      // delete from Episode where id=?
List<Episode> remaining = EpisodeSearch.match(em, "testing");   // [Testing Spring Boot Apps]

2.5. Indexing Existing Data With the MassIndexer

Hibernate Search sees only the changes that go through entities, so rows written with native SQL or a bulk JPQL update never reach the index. The same is true for rows from another application or a database restore, and for all the data that existed before we added Hibernate Search.

em.createNativeQuery("insert into Episode (id, title, ...) values (100, 'Kotlin for Java Developers', ...)")
  .executeUpdate();
List<Episode> kotlin = EpisodeSearch.match(em, "kotlin");   // []

The MassIndexer rebuilds an index from the database. It purges the index and reads all ids. After that, it loads the entities in batches, on its own threads and transactions, and writes the documents.

try (EntityManager em = emf.createEntityManager()) {
  Search.session(em).massIndexer(Episode.class).startAndWait();
}
// select count(e1_0.id) from Episode e1_0
// select e1_0.id from Episode e1_0
// select ... from Episode e1_0 where e1_0.id in (?,?,?,?,?)

List<Episode> rebuilt = EpisodeSearch.match(em, "kotlin");   // [Kotlin for Java Developers]

A bulk JPQL update is the less visible case, because the row changes but the index keeps the old text. After update Episode set title = ‘Kotlin Reviews’ where host = ‘Ravi’, a search for “kotlin” returns nothing, while a search for “code” still returns the episode, which Hibernate loads with the new title “Kotlin Reviews”.

Three ways a row changes: em.persist, setters and em.remove on managed entities are indexed after commit; a JPQL update or delete is not indexed and the index keeps the old text, so a search for code still finds the renamed episode; native SQL, other applications and older data are not indexed, so kotlin returns an empty list. Two fixes below: the MassIndexer rebuilds the index from the database for many rows, and indexingPlan().addOrUpdate(episode) reindexes one entity when the transaction commits
Only changes made through managed entities reach the index. Everything else needs the MassIndexer or the indexing plan.

Say a nightly import job writes a few new episodes with native SQL. For a few known rows like these, the indexing plan is cheaper than a full rebuild. For example, row 101, “Kotlin Coroutines”, was inserted with native SQL too, and addOrUpdate() reindexes it when the transaction commits.

emf.runInTransaction(em -> {
  Episode coroutines = em.find(Episode.class, 101L);
  Search.session(em).indexingPlan().addOrUpdate(coroutines);
});
List<Episode> one = EpisodeSearch.match(em, "kotlin");   // [Kotlin Coroutines]

We run the MassIndexer the first time Hibernate Search runs against an existing database, after a mapping change, and after any bulk SQL update. It loads entities on several threads, and each thread needs a database connection, so we size the connection pool for them.

2.6. Searching With match(), Boost and Fuzzy

Every search starts from a SearchSession, which we get from the EntityManager. The where() lambda gets a predicate factory f, and f.match() is the main full-text predicate.

SearchSession searchSession = Search.session(em);

List<Episode> hits = searchSession.search(Episode.class)
    .where(f -> f.match().fields("title", "description").matching("TESTS"))
    .fetchHits(20);     // [Testing Spring Boot Apps, Code Reviews]

The search text is analyzed too, so “TESTS” finds the same episodes as “testing”. With several words, match() finds documents that contain any of them, and documents with more matches get a higher score. Hits come back best score first.

The boost() method makes a match in one field count more. In the second query, a match in the title counts twice, so the episode with “code” in its title moves to the top.

.where(f -> f.match().fields("title", "description").matching("test code"))
                    // [Testing Spring Boot Apps, Code Reviews, Virtual Threads in Java 25]

.where(f -> f.match()
    .field("title").boost(2.0f)
    .field("description")
    .matching("test code"))
                    // [Code Reviews, Testing Spring Boot Apps, Virtual Threads in Java 25]

The fuzzy() option accepts small typos in the search text. With fuzzy(1), each term may differ by one letter edit, for example the missing “a” in “virtul”, and fuzzy(2) accepts two edits.

.where(f -> f.match().fields("title", "description").matching("virtul"))
                    // []
.where(f -> f.match().fields("title", "description").matching("virtul").fuzzy(1))
                    // [Virtual Threads in Java 25]

When we need only the number of matches, fetchTotalHitCount() reads it from the index and runs no SQL.

long count = searchSession.search(Episode.class)
    .where(f -> f.match().fields("title", "description").matching("threads"))
    .fetchTotalHitCount();      // 2

The match() predicate treats “virtual threads” as two separate words, so the episode about “thread pools” matches too. The phrase() predicate requires the words next to each other, in this order.

.where(f -> f.match().fields("title", "description").matching("virtual threads"))
                    // [Virtual Threads in Java 25, Hibernate Performance]

.where(f -> f.phrase().fields("title", "description").matching("virtual threads"))
                    // [Virtual Threads in Java 25]

2.8. Combining Conditions With bool()

The bool() predicate combines other predicates. For example, a listener searches for “threads” but wants only Anna’s episodes, so the host goes into a clause that does not change the relevance order. Each clause type treats the match and the score in its own way.

  • A must() clause requires a match, and the match adds to the score.
  • A should() clause is optional, and a match raises the score.
  • A filter() clause requires a match but leaves the score unchanged, so we use it for exact conditions.
  • A mustNot() clause excludes the documents that match.
.where(f -> f.bool()
    .must(f.match().fields("title", "description").matching("threads"))
    .filter(f.match().field("host").matching("Anna")))
                    // [Virtual Threads in Java 25]

.where(f -> f.bool()
    .must(f.match().fields("title", "description").matching("test"))
    .mustNot(f.match().field("host").matching("Ravi")))
                    // [Testing Spring Boot Apps]

2.9. Range Queries and Sorting

The range() predicate works on @GenericField numbers and dates, and on keyword fields. It offers between(), atLeast(), greaterThan(), atMost() and lessThan(). The sort() step replaces the relevance order with field values, like ORDER BY in sorting with JPQL.

.where(f -> f.range().field("durationMinutes").between(30, 45))
.sort(f -> f.field("durationMinutes"))
                    // [Records and Pattern Matching, Virtual Threads in Java 25, Testing Spring Boot Apps]

.where(f -> f.range().field("publishedOn").atLeast(LocalDate.of(2026, 3, 1)))
.sort(f -> f.field("publishedOn"))
                    // [Hibernate Performance, Code Reviews, Records and Pattern Matching]

.where(f -> f.matchAll())
.sort(f -> f.field("publishedOn").desc())
                    // [Records and Pattern Matching, Code Reviews, Hibernate Performance, ...]

2.10. Pagination and Total Hit Count

The call fetch(offset, limit) returns one page of hits together with the total number of matches, which is what a results page with pagination needs. We always pass a limit, because fetchAllHits() loads every match from the database.

SearchResult<Episode> page = searchSession.search(Episode.class)
    .where(f -> f.matchAll())
    .sort(f -> f.field("title_sort"))
    .fetch(2, 2);                   // offset 2, limit 2

List<Episode> hits = page.hits();       // [Records and Pattern Matching, Testing Spring Boot Apps]
long total = page.total().hitCount();   // 5

2.11. Highlighting Matched Words

A highlight projection returns the parts of a field that matched, with the matched words wrapped in em tags. A projection reads values from the index instead of loading entities, so this search runs no SQL. With the Lucene backend, the field must allow it with the highlightable attribute, as description does in section 2.2.

List<List<String>> fragments = searchSession.search(Episode.class)
    .select(f -> f.highlight("description"))
    .where(f -> f.match().field("description").matching("tests"))
    .fetchHits(20);
// [[We write unit <em>tests</em> and integration <em>tests</em> for a Spring Boot service.],
//  [What we look for in a pull request, and how to <em>test</em> a change before merging.]]

Each hit returns a list, because a long text can produce several fragments.

The complete project on GitHub runs every search of this section and prints the results with the SQL, and its 17 JUnit tests check them (mvn -q compile exec:java, mvn test).

3. Hibernate Search FAQs

3.1. Why Does a Search on a Keyword Field Return Nothing?

A keyword field stores the value as it is, so the match is case-sensitive and “lokesh” does not match “Lokesh”. A normalizer lowercases the value at indexing and search time, so we add a second keyword field for case-insensitive search.

@KeywordField
@KeywordField(name = "host_ignorecase", normalizer = "lowercase")
private String host;

The host field keeps the exact match, and host_ignorecase adds the case-insensitive one.

SearchResult
field(“host”).matching(“Lokesh”)[Testing Spring Boot Apps, Hibernate Performance]
field(“host”).matching(“lokesh”)[]
field(“host_ignorecase”).matching(“lokesh”)[Testing Spring Boot Apps, Hibernate Performance]

3.2. Why Can’t I Sort or Highlight a Field?

The field was not indexed with the data that the operation needs. Sorting needs sortable = Sortable.YES on a keyword or generic field, and highlighting with Lucene needs highlightable on a full-text field. Both mistakes fail when the query runs, with the same error code HSEARCH000604.

HSEARCH000604: Cannot use 'sort:field' on field 'title': Make sure the field is marked as searchable/sortable/projectable/aggregable/highlightable (whichever is relevant). If it already is, then 'sort:field' is not available for fields of this type.

To sort by a text field, we add a second @KeywordField with sortable = Sortable.YES on the same property, as title_sort in section 2.2.

3.3. What Replaced FullTextEntityManager and @Field?

Hibernate Search 6 replaced the whole API, and versions 7 and 8 kept the new one. Old tutorials still show the Hibernate Search 5 classes, which do not exist anymore. So an app that moves from Hibernate Search 5 to a current version rewrites every FullTextEntityManager call.

Hibernate Search 5Hibernate Search 6, 7 and 8
Search.getFullTextEntityManager(em)Search.session(em) returns a SearchSession
@Field@FullTextField, @KeywordField, @GenericField
QueryBuilder and createFullTextQuery()searchSession.search(Episode.class).where(…)
fullTextEntityManager.createIndexer()searchSession.massIndexer()
@AnalyzerDef on an entityLuceneAnalysisConfigurer
javax.persistencejakarta.persistence

3.4. Can I Use Elasticsearch or OpenSearch Instead of Lucene?

Yes. We replace the backend dependency and tell Hibernate Search where the cluster is, and the mapping and the search code stay the same.

<dependency>
  <groupId>org.hibernate.search</groupId>
  <artifactId>hibernate-search-backend-elasticsearch</artifactId>
  <version>8.4.0.Final</version>
</dependency>
hibernate.search.backend.hosts = localhost:9200
hibernate.search.backend.protocol = http

The main difference between the two backends is where the index is stored, which decides how several application nodes share it.

Lucene backendElasticsearch / OpenSearch backend
RunsInside our JVMAs a separate server or cluster
Supported versions (Hibernate Search 8.4)Lucene 9.12 (10.4 with the -next module)Elasticsearch 7.10 to 9.4, OpenSearch 1.3 to 3.6
Several application nodesEach node has its own indexAll nodes share one index
AnalyzersLuceneAnalysisConfigurerElasticsearchAnalysisConfigurer

With several application instances, we use Elasticsearch or OpenSearch, because a local Lucene index on each node only sees the changes made on that node.

3.5. Where Does Hibernate Search Store the Lucene Index?

With the default local-filesystem directory, each index is a folder named after the entity under hibernate.search.backend.directory.root, by default the working directory. Our Episode index would be in ./Episode/. The local-heap type from our example keeps the index in memory and loses it when the JVM stops, which suits tests with an in-memory database.

hibernate.search.backend.directory.type = local-filesystem
hibernate.search.backend.directory.root = /var/podcast/index

3.6. Does a Search Load Entities From the Database?

Yes. Hibernate ORM loads the entity hits with one SELECT … where id in (…) per search, through the persistence context. A count or a projection reads only the index, so it runs no SQL.

CallSQL statements
fetchHits(20) with two hits1: select … from Episode e1_0 where e1_0.id in (?,?)
fetchTotalHitCount()0
select(f -> f.highlight(“description”))0

4. Conclusion

Hibernate Search gives Hibernate entities a full-text index. The @Indexed annotation and the field annotations decide what goes into the index, and an analyzer turns text into searchable terms. After each commit, the index follows every change made through a managed entity. The SearchSession DSL combines match(), phrase(), bool(), range(), sorting, paging and highlighting and returns entities from the database. Data that bypasses the entities needs the MassIndexer or the indexing plan.

5. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.