Hibernate Search with Lucene gives a Spring Boot application full-text search over its JPA entities. Full-text search finds rows by words instead of characters, so a search for “cherries” also finds “cherry”, and the best match comes first. Hibernate Search copies the fields we mark into an Apache Lucene index on disk and updates that index whenever a transaction commits. A search reads the matching ids from the index and returns a list of entities.
We use Hibernate Search for the search box of a product catalog or a help center, where a SQL LIKE query on “cherry” misses “cherries” and cannot rank the results. In Spring Boot, we add two dependencies, pass the settings through spring.jpa.properties, map the entity with @Indexed, and query with SearchSession.
The following example indexes three fields of a wine entity and searches them from a Spring service.
@Entity
@Indexed
public class Wine {
@FullTextField(analyzer = "wine")
private String tastingNotes;
@KeywordField(normalizer = "lowercase")
private String region;
@GenericField(sortable = Sortable.YES)
private BigDecimal price;
}
SearchResult<Wine> result = Search.session(entityManager).search(Wine.class)
.where(f -> f.match().fields("name", "grape", "tastingNotes").matching("cherries"))
.fetch(0, 10);
long total = result.total().hitCount(); // 3
List<Wine> wines = result.hits(); // [Chianti Classico, Barolo, Rioja Reserva]
// select ... from wine w1_0 where w1_0.id in (?,?,?)
Notice that the search for “cherries” also finds the wines described with “cherry”, and Hibernate ORM loads the three hits with one SQL query.
Next, we set up the dependencies and the Lucene index in a Spring Boot 4.1 app and index the existing rows on startup. After that, we build a REST search endpoint with paging and sorting, and test it with MockMvc.
1. How Hibernate Search Works in a Spring Boot Application
Hibernate Search keeps two copies of our data. The database stays the source of truth, and the Lucene index holds only the searchable fields and the entity id. A search asks the index which ids match and in which order, and Hibernate ORM then loads those entities from the database. For a save, the order is reversed. Hibernate writes the row first and updates the index after the transaction commits.

Spring Boot has no starter for Hibernate Search, but every piece connects to a feature Spring Boot already has.
| Spring Boot feature | What Hibernate Search uses it for |
|---|---|
| spring-boot-starter-data-jpa | Creates the EntityManagerFactory; Hibernate Search starts with it |
| *spring.jpa.properties.** keys | Passes the Hibernate Search settings to Hibernate ORM |
| Spring beans | The analyzer configuration is a @Component, referenced by bean name |
| ApplicationReadyEvent | Starts the MassIndexer, which indexes rows that already exist |
| @Transactional | Each commit updates the index |
The Hibernate Search DSL also offers phrase(), range() and highlighting, but here we focus on the Spring Boot integration and use only the queries the wine search needs.
2. Hibernate Search With Spring Boot Example
The following example is a wine catalog with a REST endpoint GET /wines/search?q=…. It runs on Spring Boot 4.1.1, Hibernate ORM 7.4, Hibernate Search 8.4.0.Final with the Lucene backend, Java 25 and an H2 database. A customer of an online wine shop types a flavor such as “cherry” into the search box and expects the closest matches first. The catalog starts with eight wines, loaded from data.sql.
| Name | Region | Grape | Tasting notes | Price |
|---|---|---|---|---|
| Rioja Reserva | Rioja | Tempranillo | Ripe cherry and plum with vanilla from oak barrels. | 24.00 |
| Chianti Classico | Tuscany | Sangiovese | Sour cherry, dried herbs and a dry finish. | 19.50 |
| Barolo | Piedmont | Nebbiolo | Roses, tar and red cherries with strong tannins. | 55.00 |
| Sauvignon Blanc | Marlborough | Sauvignon Blanc | Fresh lime, passion fruit and cut grass. | 16.00 |
| Malbec Reserva | Mendoza | Malbec | Blackberry and plum with a smoky finish. | 21.00 |
| Napa Cabernet | Napa Valley | Cabernet Sauvignon | Blackcurrant, cedar and dark chocolate. | 48.00 |
| Chablis | Burgundy | Chardonnay | Green apple, lemon and a crisp mineral finish. | 27.00 |
| Prosecco | Veneto | Glera | Light bubbles with pear and white flowers. | 12.00 |
2.1. Maven Dependencies
Hibernate Search has two parts, namely the mapper, which reads the annotations on our entities, and the backend, which stores the index. Spring Boot does not manage their versions, so we import the Hibernate Search BOM (a POM that pins compatible versions of all its modules).
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
</parent>
<properties>
<java.version>25</java.version>
<hibernate.version>7.4.11.Final</hibernate.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.hibernate.search</groupId>
<artifactId>hibernate-search-bom</artifactId>
<version>8.4.0.Final</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.search</groupId>
<artifactId>hibernate-search-mapper-orm</artifactId>
</dependency>
<dependency>
<groupId>org.hibernate.search</groupId>
<artifactId>hibernate-search-backend-lucene</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
The Hibernate Search version must match the Hibernate ORM minor version that Spring Boot brings. Hibernate Search 8.4 is the series for Hibernate ORM 7.4, and Spring Boot 4.1.1 manages Hibernate ORM 7.4.5.Final. The hibernate.version property moves it to the latest 7.4 patch, which is the version Hibernate Search recommends. The Lucene backend brings Lucene 9.12.3 with it.
2.2. Configuring the Lucene Backend and the Index Directory
Hibernate Search reads its settings from the Hibernate ORM configuration, not from Spring Boot. In application.properties, every Hibernate Search key needs the spring.jpa.properties. prefix, so that Spring Boot passes it to Hibernate.
spring.datasource.url=jdbc:h2:mem:wines
spring.jpa.hibernate.ddl-auto=create-drop
spring.jpa.defer-datasource-initialization=true
spring.jpa.open-in-view=false
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.search.backend.type=lucene
spring.jpa.properties.hibernate.search.backend.directory.type=local-filesystem
spring.jpa.properties.hibernate.search.backend.directory.root=data/wine-index
spring.jpa.properties.hibernate.search.backend.lucene_version=LATEST
spring.jpa.properties.hibernate.search.backend.analysis.configurer=bean:wineAnalysisConfigurer
Each Hibernate Search setting has one job, and only analysis.configurer points to our own code.
| Property (after spring.jpa.properties.) | Our value | What it does |
|---|---|---|
| hibernate.search.backend.type | lucene | Selects the backend; needed only when more than one backend is on the classpath |
| hibernate.search.backend.directory.type | local-filesystem | Stores the index on disk (the default); local-heap keeps it in memory |
| hibernate.search.backend.directory.root | data/wine-index | Parent folder of the indexes; the default is the working directory |
| hibernate.search.backend.lucene_version | LATEST | Lucene index format; without it, Hibernate Search logs a warning at startup |
| hibernate.search.backend.analysis.configurer | bean:wineAnalysisConfigurer | The Spring bean that defines our analyzers (section 2.4) |
Each indexed entity gets its own folder under the root. On the first start, Hibernate Search creates the folder and logs its path.
INFO org.hibernate.search.lucene : HSEARCH000041: Index directory does not exist, creating: '.../data/wine-index/Wine'
The spring.jpa.defer-datasource-initialization=true setting runs data.sql after Hibernate has created the wine table.
2.3. Mapping the Wine Entity
The @Indexed annotation gives the entity its own index, and each field annotation adds a field to that index and decides how the value is stored. The id becomes the document id, so it needs no annotation.
@Entity
@Indexed
public class Wine {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@FullTextField(analyzer = "wine")
@KeywordField(name = "name_sort", sortable = Sortable.YES, normalizer = "lowercase")
private String name;
@KeywordField(normalizer = "lowercase")
private String region;
@FullTextField(analyzer = "wine")
private String grape;
@FullTextField(analyzer = "wine")
@Column(length = 1000)
private String tastingNotes;
@GenericField(sortable = Sortable.YES)
@Column(precision = 8, scale = 2)
private BigDecimal price;
}
@Entity, @Id and @Column are the usual JPA annotations. The Hibernate Search annotations index a value in different ways.
| Annotation | How the value is indexed | Our fields | We use it for |
|---|---|---|---|
| @FullTextField | Split into words by an analyzer | name, grape, tastingNotes | The q parameter |
| @KeywordField | The whole value as one term | region, name_sort | Exact filter, sorting by name |
| @GenericField | As a number, date, boolean or enum | price | Sorting by price |
A full-text field cannot be sorted because it holds many words per value, so we index name twice, once as full text for searching and once as the sortable keyword field name_sort.
2.4. Registering the Analyzer as a Spring Bean
An analyzer turns text into the terms stored in the index. It has one tokenizer, which splits the text into words, and a chain of token filters, which change each word. The query text goes through the same analyzer, so the query terms match the terms in the index.
We define the analyzers in a LuceneAnalysisConfigurer. Because it is a Spring @Component, it can use dependency injection like any other bean.
@Component("wineAnalysisConfigurer")
public class WineAnalysisConfigurer implements LuceneAnalysisConfigurer {
@Override
public void configure(LuceneAnalysisConfigurationContext context) {
context.analyzer("wine").custom()
.tokenizer("standard")
.tokenFilter("lowercase")
.tokenFilter("snowballPorter").param("language", "English")
.tokenFilter("asciiFolding");
context.normalizer("lowercase").custom()
.tokenFilter("lowercase")
.tokenFilter("asciiFolding");
}
}
The bean: prefix in application.properties tells Hibernate Search to look the configurer up by name in the Spring context. The lookup works because Spring Boot registers a bean container for Hibernate.
The snowballPorter filter is the stemmer, which cuts each word to its root. The asciiFolding filter turns accented letters into plain ones. A normalizer is an analyzer without a tokenizer, so it keeps the value as one term, and ours lowercases it and folds accented letters. We use it for the region keyword field.
The wine analyzer turns every form of “cherry” into the same term cherri, whereas a typo produces a term that matches nothing.
| Text | Terms in the index or the query |
|---|---|
| “Roses, tar and red cherries with strong tannins.” | rose, tar, and, red, cherri, with, strong, tannin |
| “cherry”, “Cherries”, “CHERRY” | cherri |
| “chery” (a typo) | cheri, which matches nothing |
2.5. Indexing Existing Rows on Startup
Hibernate Search updates the index only for changes that go through Hibernate entities. Rows inserted by data.sql, a native SQL query, a database restore or another application never reach the index. Without an extra step, our eight wines are in the database, but a search finds none of them.
long count = wineRepository.count(); // 8
long found = wineSearchService.search("cherry", null, 0, 10, WineSort.RELEVANCE)
.total(); // 0
The MassIndexer rebuilds an index from the database. It removes the old documents and reads all ids. After that, it loads the entities in batches on its own threads and transactions. We run it when Spring Boot publishes ApplicationReadyEvent, the event that marks the end of startup.
@Component
@ConditionalOnBooleanProperty(name = "wine.search.reindex-on-startup", matchIfMissing = true)
public class IndexRebuilder {
private final EntityManagerFactory entityManagerFactory;
@EventListener(ApplicationReadyEvent.class)
public void rebuildIndex() throws InterruptedException {
log.info("Mass indexing started");
long start = System.currentTimeMillis();
try (EntityManager em = entityManagerFactory.createEntityManager()) {
Search.session(em).massIndexer(Wine.class)
.threadsToLoadObjects(2)
.batchSizeToLoadObjects(25)
.startAndWait();
}
log.info("Mass indexing finished in {} ms", System.currentTimeMillis() - start);
}
}
Each loading thread needs a database connection, so threadsToLoadObjects() must stay below the size of the connection pool. The @ConditionalOnBooleanProperty switch lets us turn the rebuild off with wine.search.reindex-on-startup=false. The log shows the three queries the MassIndexer runs.
INFO o.s.boot.tomcat.TomcatWebServer : Tomcat started on port 8080 (http) with context path '/'
INFO c.h.hibernate.searchboot.IndexRebuilder : Mass indexing started
Hibernate: select count(w1_0.id) from wine w1_0
Hibernate: select w1_0.id from wine w1_0
INFO o.hibernate.search.mapper.massindexing : HSEARCH000027: Mass indexing is going to index 8 entities.
Hibernate: select w1_0.id,w1_0.grape,w1_0.name,w1_0.price,w1_0.region,w1_0.tasting_notes from wine w1_0 where w1_0.id in (?,?,?,?,?,?,?,?)
INFO o.hibernate.search.mapper.massindexing : HSEARCH700168: Mass indexing complete in PT0.293023604S. Indexed 8/8 entities.
INFO c.h.hibernate.searchboot.IndexRebuilder : Mass indexing finished in 7968 ms
Notice the order of the first two log lines. Tomcat accepts requests before ApplicationReadyEvent is published. When we restart the application and send GET /wines/search?q=cherry during startup, every response has “total”:0 while the MassIndexer runs. The first response after it has written the documents has “total”:3.

For a small catalog, a few seconds of empty results after a restart are acceptable. For a large one, we run the rebuild only when the index is out of date, such as after the first deployment or a mapping change.
2.6. Writing the Search Service With SearchSession
Search.session(entityManager) returns the SearchSession for the current Spring transaction. Spring injects a shared EntityManager that points to the persistence context of the current transaction, so we run the search in a read-only @Transactional method.
@Transactional(readOnly = true)
public WinePage search(String text, String region, int page, int size, WineSort sort) {
SearchSession searchSession = Search.session(entityManager);
SearchResult<Wine> result = searchSession.search(Wine.class)
.where(f -> f.bool().with(b -> {
b.must(f.matchAll());
if (text != null && !text.isBlank()) {
b.must(f.match()
.field("name").boost(2.0f)
.fields("grape", "tastingNotes")
.matching(text));
}
if (region != null && !region.isBlank()) {
b.filter(f.match().field("region").matching(region));
}
}))
.sort(f -> switch (sort) {
case RELEVANCE -> f.score();
case NAME -> f.field("name_sort");
case PRICE_ASC -> f.field("price").asc();
case PRICE_DESC -> f.field("price").desc();
})
.fetch(page * size, size);
return new WinePage(text, result.total().hitCount(), page, size, result.hits());
}
The method builds one query from the optional parameters.
- The bool() predicate combines conditions. The must() clauses have to match and add to the score, whereas filter() clauses have to match but do not change the score.
- The matchAll() predicate matches every wine, so an empty q lists the whole catalog.
- The match() predicate searches the full-text fields, and boost(2.0f) makes a match in the name count twice.
- The sort() call replaces the default relevance order, and f.score() keeps it.
- The call fetch(offset, limit) returns one page of hits and the total number of matches.
WinePage is a record with the fields query, total, page, size and wines. Each search runs one SQL statement, which loads the hits of the page by id.
select w1_0.id,w1_0.grape,w1_0.name,w1_0.price,w1_0.region,w1_0.tasting_notes from wine w1_0 where w1_0.id in (?,?)
2.7. Adding the REST Endpoint
The controller reads the query parameters, limits the page size, and rejects an unknown sort order with HTTP 400.
@RestController
@RequestMapping("/wines")
public class WineController {
@GetMapping("/search")
public WinePage search(@RequestParam(defaultValue = "") String q,
@RequestParam(required = false) String region,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size,
@RequestParam(defaultValue = "relevance") String sort) {
WineSort wineSort;
try {
wineSort = WineSort.valueOf(sort.toUpperCase());
} catch (IllegalArgumentException e) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unknown sort: " + sort);
}
return wineSearchService.search(q, region, Math.max(page, 0), Math.clamp(size, 1, 50), wineSort);
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Wine create(@RequestBody Wine wine) {
return wineSearchService.save(wine);
}
}
A search for “cherry” with two wines per page returns the two best matches and the total number of matches.
curl "http://localhost:8080/wines/search?q=cherry&size=2"
{
"query": "cherry",
"total": 3,
"page": 0,
"size": 2,
"wines": [
{"name": "Chianti Classico", "region": "Tuscany", "grape": "Sangiovese",
"tastingNotes": "Sour cherry, dried herbs and a dry finish.", "price": 19.50, "id": 2},
{"name": "Barolo", "region": "Piedmont", "grape": "Nebbiolo",
"tastingNotes": "Roses, tar and red cherries with strong tannins.", "price": 55.00, "id": 3}
]
}
The region parameter filters on the keyword field. Its lowercase normalizer makes the filter case-insensitive.
curl "http://localhost:8080/wines/search?q=plum®ion=rioja"
{"query":"plum","total":1,"page":0,"size":10,"wines":[{"name":"Rioja Reserva","region":"Rioja","grape":"Tempranillo","tastingNotes":"Ripe cherry and plum with vanilla from oak barrels.","price":24.00,"id":1}]}
Without the region, “plum” matches Rioja Reserva and Malbec Reserva.
2.8. Paging and Sorting the Results
The parameters page and size map to fetch(page size, size)*, so page numbers start at 0, like in Spring Data. The total field lets a client show “page 2 of 3” without a second query. Paging works like pagination in JPA, except that Hibernate Search applies the offset in the index. The sort values cover relevance, name and price.
| Request | total | wines |
|---|---|---|
| ?q=cherry | 3 | Chianti Classico, Barolo, Rioja Reserva |
| ?q=cherry&sort=price_asc | 3 | Chianti Classico (19.50), Rioja Reserva (24.00), Barolo (55.00) |
| ?q=cherry&sort=price_desc | 3 | Barolo, Rioja Reserva, Chianti Classico |
| ?sort=name&page=1&size=3 | 8 | Malbec Reserva, Napa Cabernet, Prosecco |
| ?q=cherries | 3 | Chianti Classico, Barolo, Rioja Reserva |
| ?q=chery | 0 | (empty) |
| ?q=cherry&sort=year | HTTP 400 |
Sorting works only on fields indexed with sortable = Sortable.YES, which is why name_sort and price have it. Sorted by name, the second page with three wines per page starts at Malbec Reserva.
{"query":"","total":8,"page":1,"size":3,"wines":[
{"name":"Malbec Reserva","region":"Mendoza","grape":"Malbec","tastingNotes":"Blackberry and plum with a smoky finish.","price":21.00,"id":5},
{"name":"Napa Cabernet","region":"Napa Valley","grape":"Cabernet Sauvignon","tastingNotes":"Blackcurrant, cedar and dark chocolate.","price":48.00,"id":6},
{"name":"Prosecco","region":"Veneto","grape":"Glera","tastingNotes":"Light bubbles with pear and white flowers.","price":12.00,"id":8}]}
Sorting by relevance is the default for a text search. For a list without q, matchAll() gives no useful relevance order, so a client asks for sort=name or a price sort.
2.9. Automatic Indexing After Save
We do not call Hibernate Search when we save. The service method is a plain Spring Data save() in a transaction.
@Transactional
public Wine save(Wine wine) {
return wineRepository.save(wine); // indexed when this transaction commits
}
Hibernate Search listens to Hibernate ORM events. When the transaction commits, it writes the new document to the index, and the next search sees it.
curl -X POST http://localhost:8080/wines -H "Content-Type: application/json" \
-d '{"name":"Beaujolais","region":"Burgundy","grape":"Gamay","tastingNotes":"Fresh red cherries and strawberry.","price":15.00}'
HTTP/1.1 201
{"name":"Beaujolais","region":"Burgundy","grape":"Gamay","tastingNotes":"Fresh red cherries and strawberry.","price":15.00,"id":9}
Hibernate: insert into wine (grape,name,price,region,tasting_notes,id) values (?,?,?,?,?,default)
curl "http://localhost:8080/wines/search?q=cherry&sort=price_asc&size=2"
# total 4: [Beaujolais (15.00), Chianti Classico (19.50)]
Updates and deletes work the same way. Changing a field of a managed entity reindexes it at commit, and deleting the entity with wineRepository.delete() removes its document. After the delete, a search for “gamay” returns total 0 again.
2.10. Testing the Search With @SpringBootTest and MockMvc
A @SpringBootTest starts the whole application, including the MassIndexer on ApplicationReadyEvent, so the tests search the same eight wines. We give the tests their own index folder, so a test run does not touch the index of a running application.
@SpringBootTest(properties = "spring.jpa.properties.hibernate.search.backend.directory.root=target/test-index")
@AutoConfigureMockMvc
class WineSearchTest {
@Autowired
MockMvc mvc;
@Test
void restEndpointReturnsOnePage() throws Exception {
mvc.perform(get("/wines/search").param("q", "cherry").param("size", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.total").value(3))
.andExpect(jsonPath("$.wines", hasSize(2)))
.andExpect(jsonPath("$.wines[*].name", contains("Chianti Classico", "Barolo")));
}
}
@AutoConfigureMockMvc (from spring-boot-starter-webmvc-test) sends requests to the controller without a real HTTP port. The method jsonPath() reads fields from the JSON response.
Do not put @Transactional on a search test that saves data. The test transaction never commits, so the index is never updated. A test that checks automatic indexing commits first, here with Spring’s TransactionTemplate.
long insideTransaction = transactionTemplate.execute(status -> {
wineRepository.saveAndFlush(new Wine("Pinot Noir", "Oregon", "Pinot Noir",
"Raspberry and earthy notes.", new BigDecimal("32.00")));
return search("raspberry").total(); // 0: not indexed yet
});
List<Wine> afterCommit = search("raspberry").wines(); // [Pinot Noir]: indexed at commit
Tests that add wines delete them at the end, so every test sees the same catalog. The complete project on GitHub has 15 tests for these results (mvn test); mvn spring-boot:run starts the application, and the README lists the curl commands.
3. Hibernate Search and Spring Boot FAQs
Most problems with Hibernate Search in Spring Boot come from the configuration and the transaction boundaries, not from the search DSL.
3.1. Why Are My hibernate.search Properties Ignored?
The properties are written without the spring.jpa.properties. prefix. Spring Boot does not know the *hibernate.search.** keys and does not pass them to Hibernate, and it reports no error.
# Ignored: Spring Boot never passes this to Hibernate Search
hibernate.search.backend.directory.root=target/ignored-index
# Used
spring.jpa.properties.hibernate.search.backend.directory.root=data/wine-index
Hibernate Search never sees the first value. With this line, the application starts without a warning, and the target/ignored-index folder is never created. The same applies to application.yaml, where the keys go under spring.jpa.properties.
3.2. Which Hibernate Search Version Works With My Spring Boot Version?
Pick the Hibernate Search series that matches the Hibernate ORM minor version managed by Spring Boot.
| Spring Boot | Hibernate ORM it manages | Hibernate Search |
|---|---|---|
| 3.5.x | 6.6 | 7.2.x |
| 4.0.x | 7.1 (4.0.0) or 7.2 (later 4.0 patches) | 8.1.x or 8.2.x |
| 4.1.x | 7.4 | 8.4.x |
Hibernate Search 9.0 is in development for Hibernate ORM 8.0, and every Hibernate Search release series names the ORM version it needs.
3.3. Do I Need to Run the MassIndexer on Every Startup?
No. Our example runs it on every start because H2 is an in-memory database, so the data is new on every start, and the index on disk still holds the documents of the previous run. With a real database and a local-filesystem index, both survive a restart, so a rebuild on every start only costs time. Rebuild the index only when it can be out of date.
- After the first deployment with Hibernate Search, when the existing rows are not indexed.
- After a change in the @Indexed mapping or the analyzers.
- After bulk SQL updates, imports or a database restore.
Our IndexRebuilder has a property that turns the startup rebuild off.
wine.search.reindex-on-startup=false
To keep the startup thread free, we call massIndexer().start(), which returns a CompletionStage instead of blocking like startAndWait().
3.4. Can I Keep the Index in Memory for Tests?
Yes. The local-heap directory type keeps the index in the JVM heap, and it is gone when the test ends. Our second test class uses it together with its own H2 database, and switches the startup rebuild off to show that data.sql rows are not indexed.
@SpringBootTest(properties = {
"wine.search.reindex-on-startup=false",
"spring.datasource.url=jdbc:h2:mem:wines-not-indexed",
"spring.jpa.properties.hibernate.search.backend.directory.type=local-heap"
})
Each different set of test properties starts a new Spring context. Two contexts must not use the same index folder at the same time, because Lucene locks an index for one writer. We avoid the conflict with a separate folder or with local-heap.
3.5. What Changed From Hibernate Search 5 in Older Spring Boot Tutorials?
Many tutorials still use the Hibernate Search 5 API, which Hibernate Search 6 replaced and which no longer exists.
| Hibernate Search 5 | Hibernate Search 6, 7 and 8 |
|---|---|
| Search.getFullTextEntityManager(em) | Search.session(em) |
| @Field | @FullTextField, @KeywordField, @GenericField |
| QueryBuilder, createFullTextQuery() | search(Wine.class).where(…) |
| fullTextEntityManager.createIndexer() | searchSession.massIndexer() |
| @AnalyzerDef on the entity | LuceneAnalysisConfigurer bean |
| javax.persistence | jakarta.persistence |
4. Conclusion
In Spring Boot, Hibernate Search needs two dependencies, the mapper and the Lucene backend, and every setting needs the spring.jpa.properties. prefix. We map the entities with @Indexed and one field annotation per searchable field, and define the analyzers in a Spring bean.
The MassIndexer on ApplicationReadyEvent indexes the rows that already exist, while every committed change through Hibernate keeps the index current. A SearchSession in a transactional service gives the REST endpoint relevance, filters, sorting and paging in one query.
5. References
- Hibernate Search 8.4 Reference Guide
- Hibernate Search 8.4: Spring Boot configuration properties
- Hibernate Search 8.4: Bean references
- Hibernate Search 8.4: MassIndexer
- Hibernate Search 8.4 release and compatibility
- Spring Boot: Additional native JPA properties
- Spring Boot: Application events and listeners
- Spring Boot: Testing with a mock environment
- Apache Lucene
Happy Learning !!