Hibernate Search Analyzers
|
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
Fundamentals introduces analyzers/normalizers conceptually and shows referencing one by name
(analyzer = "english"); this page is where that name is actually defined, plus what to do when a single
entity’s text content is not all in the same language.
Built-in analyzers
Both backends ship ready-made analyzers that need no configurer at all — reach for one of these before writing a custom chain; the "Defining a custom analyzer" section below is for the cases these don’t cover (n-grams, a token-filter combination no built-in offers).
Lucene backend — register a built-in org.apache.lucene.analysis.* class under a name with .instance(…)
instead of .custom():
context.analyzer("simple").instance(new org.apache.lucene.analysis.standard.StandardAnalyzer());
| Analyzer class | Purpose and typical use |
|---|---|
|
General-purpose Unicode text segmentation, lowercasing, and stop-word removal — the right default for ordinary prose in a single, unspecified language. |
|
Splits on any non-letter character and lowercases; no stop-word removal, no stemming. Cheap and predictable, for content where "words" are simple and language-specific processing is not wanted. |
|
Splits only on whitespace; does not lowercase or strip punctuation. For pre-normalized tokens (codes, identifiers) where any further processing would be wrong. |
|
|
|
Emits the entire input as a single, unmodified token — the |
|
A ready-made equivalent of the hand-assembled |
Elasticsearch/OpenSearch backend — reference a built-in analyzer by name, directly in the mapping
annotation, with no ElasticsearchAnalysisConfigurer needed:
| Analyzer name | Purpose and typical use |
|---|---|
|
The same general-purpose behavior as Lucene’s |
|
Splits on non-letters and lowercases; no stop words, no stemming. |
|
Splits only on whitespace; no lowercasing. |
|
|
|
A no-op analyzer — the whole input becomes one token, unmodified. |
|
Splits on a supplied regular expression instead of Unicode word boundaries — for delimiter-separated content (a custom code format) that doesn’t fit any of the above. |
|
Sorts, deduplicates, and concatenates a field’s tokens into one normalized token — built for near-duplicate detection (e.g. flagging two records whose text differs only in word order/casing/repetition), not general search. |
|
Ready-made per-language stemming and stop words, equivalent to Lucene’s |
Defining a custom analyzer
A LuceneAnalysisConfigurer bean, registered via the hibernate.search.backend.analysis.configurer property,
builds each custom analyzer as a tokenizer plus a chain of token filters. The language-specific analyzers below
are shown hand-assembled from individual filters for illustration — in practice, prefer registering the
built-in EnglishAnalyzer/FrenchAnalyzer classes from the table above via .instance(…) unless a token
filter outside what they already include is also needed (this hand-assembled form is what to reach for in that
case, or to combine language-specific stemming with something the built-in class doesn’t do):
public class MyAnalysisConfigurer implements LuceneAnalysisConfigurer {
@Override
public void configure(LuceneAnalysisConfigurationContext context) {
// language-specific stemming -- "running"/"runs" both index toward "run" (English rules here;
// the "language" param selects the Snowball stemming algorithm for that language)
context.analyzer("english").custom()
.tokenizer(StandardTokenizerFactory.class)
.tokenFilter(LowerCaseFilterFactory.class)
.tokenFilter(SnowballPorterFilterFactory.class).param("language", "English");
context.analyzer("french").custom()
.tokenizer(StandardTokenizerFactory.class)
.tokenFilter(LowerCaseFilterFactory.class)
.tokenFilter(ASCIIFoldingFilterFactory.class) // strips accents (é -> e)
.tokenFilter(SnowballPorterFilterFactory.class).param("language", "French");
// edge n-grams -- indexes every leading substring of each token ("hiber", "hibern", "hibernate", ...)
// so a partial, as-you-type query prefix matches before the user finishes typing the whole word
context.analyzer("autocomplete").custom()
.tokenizer(StandardTokenizerFactory.class)
.tokenFilter(LowerCaseFilterFactory.class)
.tokenFilter(EdgeNGramFilterFactory.class)
.param("minGramSize", "3")
.param("maxGramSize", "15");
}
}
hibernate:
search:
backend:
analysis:
configurer: class:com.example.search.MyAnalysisConfigurer
Reference any of these by name from a mapping annotation, exactly as
Hibernate Search Fundamentals'
@FullTextField(analyzer = "english") already does — analyzer = "autocomplete" on a title field indexes it
for prefix/as-you-type matching, analyzer = "french" applies French-specific stemming and accent-folding
instead of English’s. Regular (non-edge) n-grams (NGramFilterFactory, no Edge prefix) index every
substring rather than only leading ones — useful for "contains" style matching (a partial ISBN, a product code
fragment) at the cost of a noticeably larger index, since far more substrings get indexed per token.
The Elasticsearch/OpenSearch backend configures the equivalent analysis chain through its own
ElasticsearchAnalysisConfigurer (same wiring property, an Elasticsearch-shaped analyzer/filter JSON structure
instead of Lucene factory classes) — and, for the language-stemming case specifically, Elasticsearch already
ships ready-made built-in language analyzers ("english", "french", and many more) that can often be
referenced directly with no custom configurer needed at all. See
the Hibernate Search
reference’s Analysis chapter for the full tokenizer/token-filter catalog on both backends. Choosing which
backend to run against at all is covered on Hibernate
Search Backends — Elasticsearch’s own analyzer/analysis-chain concepts, which this section’s Elasticsearch
built-ins and ElasticsearchAnalysisConfigurer map onto directly, are covered in depth on
Elasticsearch.
Selecting an analyzer dynamically, per document (multi-language content)
An older version of Hibernate Search (5.x and earlier, before the ground-up rewrite that shipped as 6.0) had a
dedicated @AnalyzerDiscriminator annotation for exactly this: pick which pre-defined analyzer to apply to a
given entity instance at index time, based on the value of one of its own fields (a language column, say).
@AnalyzerDiscriminator was not carried forward into the current (6.0+/8.4) architecture — there is no
direct, single-annotation equivalent in the modern API. Two current, composable pieces of the API cover the
same ground instead, at index time and query time respectively:
Index time — one field per language, populated dynamically by a custom bridge. Declare one indexed field per
supported language, each with its own static, language-specific analyzer defined as above (title_en,
title_fr, …), then use a PropertyBinder/PropertyBridge — the same bridge mechanism
Hibernate Search Fundamentals
introduces for converting an unmapped value type — to read the entity’s own language field and write the text
into only the one field that matches it, leaving the other language fields empty for that document:
public class LocalizedTitleBinder implements PropertyBinder {
@Override
public void bind(PropertyBindingContext context) {
context.dependencies()
.use("language")
.use("title");
IndexFieldReference<String> titleEn = context.indexSchemaElement()
.field("title_en", f -> f.asString().analyzer("english")).toReference();
IndexFieldReference<String> titleFr = context.indexSchemaElement()
.field("title_fr", f -> f.asString().analyzer("french")).toReference();
context.bridge(Book.class, (target, book, bridgeContext) -> {
IndexFieldReference<String> field = switch (book.getLanguage()) {
case "fr" -> titleFr;
default -> titleEn;
};
target.addValue(field, book.getTitle());
});
}
}
@Entity
@Indexed
public class Book {
// ...
@PropertyBinding(binder = @PropertyBinderRef(type = LocalizedTitleBinder.class))
private String title;
private String language; // "en", "fr", ...
}
This is the direct, current-architecture equivalent of what @AnalyzerDiscriminator used to do: the decision of
which analyzer effectively applies to this document is still made per-document, at index time, from another
field’s value — it is just expressed as "which field gets written", via a bridge, rather than as a single
discriminator switch built into the framework.
Query time — override which analyzer parses the search string, per predicate. Independent of how a field was
indexed, MatchPredicateOptionsStep.analyzer(name) lets one match() predicate specify which analyzer parses
the query value itself, instead of using the field’s own configured analyzer:
List<Book> hits = searchSession.search(Book.class)
.where(f -> f.match().field("title_fr").matching(userQuery).analyzer("french"))
.fetchHits(20);
This is useful whenever the caller already knows which language the query is in (a UI language switch, a detected browser locale) and wants it parsed accordingly — it does not retroactively change how content already in the index was analyzed, so it complements the per-language-field indexing pattern above rather than replacing it.