Spring Data Elasticsearch’s ElasticsearchRepository looks exactly like JpaRepository — derived query methods, @Id, the same muscle memory. That similarity hides the one decision that actually matters in Elasticsearch: how each field is mapped. Map a field “text” and it’s tokenized, stemmed and fuzzy-matchable; map it “keyword” and it’s an exact, unanalyzed string good for filtering, sorting and aggregating. Pick wrong and nothing throws an error — the field just quietly stops doing what you expect.
This post builds a small product-search service on Spring Boot 4.1 and Elasticsearch 9.4 — a repository for the simple lookups, and the query DSL for everything a repository method can’t express: multi-field relevance scoring with per-field boosting, typo-tolerant fuzzy matching, a price filter that narrows results without touching their rank, and a terms aggregation for category facet counts.
Versions used in this post. Spring Boot 4.1.1 · Spring Data Elasticsearch 6.1.1 · elasticsearch-java client 9.4.5 · Elasticsearch server 9.4.8 · JDK 25 (Temurin) · Testcontainers 2.0.5 (testcontainers-elasticsearch). The server version is pinned to match the client’s minor version — confirmed viamvn dependency:treerather than assumed, after discovering elsewhere in this series that a mismatched tag can be silently broken upstream.
The mapping decision: text vs keyword
Every field in the document below is deliberately mapped one way or the other. name and description are analyzed text, so a multi-word query matches partial, stemmed, reordered tokens inside them. category is a keyword, so it filters and aggregates on the exact string — “furniture” matches “furniture”, not “furnit” or “Furniture”.
@Document(indexName = "products")
public record Product(
@Id String id,
@Field(type = FieldType.Text) String name,
@Field(type = FieldType.Text) String description,
@Field(type = FieldType.Keyword) String category,
@Field(type = FieldType.Double) BigDecimal price,
@Field(type = FieldType.Keyword) List<String> tags) {
}
Full document: Product.java.
Going deeper: price is mapped Double, and that’s a real trade-off
BigDecimal price is mapped FieldType.Double here because it’s the obvious choice and this post’s prices never get large or precise enough to show the problem — but double is a binary floating-point type, and currency values occasionally don’t round-trip exactly through it. Elasticsearch has a dedicated scaled_float type for exactly this case: it stores values as a long multiplied by a fixed scaling factor (e.g. 100 for cents), so a price is stored and compared exactly, with none of binary float’s rounding surprises. For anything money-shaped going to production, scaled_float is the type to reach for, not double.
Repositories: point lookups, nothing extra
A derived query method works exactly as it does for any other Spring Data module:
public interface ProductRepository extends ElasticsearchRepository<Product, String> {
List<Product> findByCategory(String category);
}
Full repository: ProductRepository.java. That’s genuinely all a repository needs to cover here — everything below is a job a derived query method has no vocabulary for.
Relevance search: multi-field boosting and fuzzy matching
A search box needs to check several fields at once, weight them differently, and tolerate a typo. ElasticsearchOperations plus the elasticsearch-java client’s own query builders give type-safe access to the full query DSL for this:
Query textQuery = Query.of(q -> q.multiMatch(m -> m
.query(term)
.fields("name^2", "description")
.fuzziness("AUTO")));
Query priceFilter = Query.of(q -> q.range(r -> r
.number(n -> n.field("price").lte(maxPrice.doubleValue()))));
Query combined = Query.of(q -> q.bool(b -> b
.must(textQuery)
.filter(priceFilter)));
NativeQuery query = NativeQuery.builder().withQuery(combined).build();
SearchHits<Product> hits = operations.search(query, Product.class);
Full service: ProductSearchService.java. name^2 doubles that field’s contribution to the relevance score, and fuzziness("AUTO") lets Elasticsearch’s edit-distance matching catch a misspelled term. Querying for the misspelled “wireles” against a small seeded catalog:
query="wireles" (misspelled) max_price=1000.00
Wireless Mechanical Keyboard ($89.99, electronics)
Wireless Earbuds Pro ($129.0, electronics)
Ergonomic Office Chair ($259.0, furniture)
Transcript: 27-fuzzy-search.txt. The office chair shows up too — its description mentions a “wireless lumbar adjustment motor” — which is exactly what the boost is for:
query="wireless" max_price=1000.00 (ranked by relevance)
Wireless Mechanical Keyboard
Wireless Earbuds Pro
Ergonomic Office Chair
Transcript: 27-relevance-boost.txt. Both products with “wireless” in the name outrank the one where it only appears in the description — that’s the name^2 boost doing its job, not luck.
Going deeper: why the price check is a filter, not part of the query
A bool query’s must clauses contribute to the relevance score; its filter clauses only decide yes-or-no inclusion and are cached more aggressively by Elasticsearch since their result never changes the ranking. A price ceiling is binary — a product is in budget or it isn’t — so it belongs in filter. Putting it in must instead would work for correctness but would let a lower price nudge a product’s relevance score upward, which is a different (and usually undesired) product decision disguised as a performance detail.
With the price ceiling tightened to $100, the filter excludes the earbuds without touching how the keyboard’s own match is scored:
query="wireless" max_price=100.00
Wireless Mechanical Keyboard ($89.99)
Transcript: 27-price-filter.txt.
Facets: counting, not fetching
A storefront’s category sidebar needs a count per category for the current search, not the matching documents themselves. A terms aggregation on the category keyword field computes those counts inside Elasticsearch, over the full matching set, with withMaxResults(0) so no document bodies come back at all:
NativeQuery query = NativeQuery.builder()
.withQuery(textQuery)
.withAggregation("by_category", Aggregation.of(a -> a
.terms(t -> t.field("category"))))
.withMaxResults(0)
.build();
Querying for “desk” against the seeded catalog — it matches both the standing desk and the desk lamp, both in the furniture category:
query="desk" facet counts by category:
furniture=2
Transcript: 27-category-facets.txt.
Going deeper: why the aggregation result needs an explicit cast
Spring Data Elasticsearch’s SearchHits.getAggregations() returns a module-agnostic AggregationsContainer because Spring Data Elasticsearch historically supported two different low-level clients with two different aggregation result shapes. On the elasticsearch-java client stack used in this post, that container is always an ElasticsearchAggregations, and reaching the actual terms-aggregation buckets means casting to it and calling .get("by_category").aggregation().getAggregate().sterms() — verbose, but it’s the real, compiled, type-safe path to the data rather than a shortcut Spring Data happens to expose for this one case.
What this post didn’t cover
- No custom analyzers. The
nameanddescriptionfields use Elasticsearch’s defaultstandardanalyzer. A real search box often wants more — synonyms, an edge-ngram analyzer for autocomplete-as-you-type, or language-specific stemming — all configured through an explicit@Settingand custom mapping, not shown here. - No highlighting. Elasticsearch can return the matched snippet with the query term wrapped in markup, which a real results page would use; this post only reads back the matched documents themselves.
- No index aliases or zero-downtime reindexing.
createWithMapping()is fine for a demo and for most development loops, but a production index whose mapping needs to change later is usually fronted by an alias so the application never points at a index name directly.
When this is the right tool
If search means “find rows where a column equals a value,” a relational database’s own indexes already do that, and Elasticsearch is a second system to operate for no benefit. Reach for it specifically when the query itself needs ranking by relevance, typo tolerance, or faceted counts over free text — the things a WHERE clause fundamentally can’t express, not things it merely expresses awkwardly.
| Need | What to use |
|---|---|
| Exact lookup by a known field | ElasticsearchRepository derived query method |
| Multi-field relevance search with typo tolerance | multiMatch with per-field boosts and fuzziness |
| Narrowing results without affecting rank | A bool query’s filter clause, not must |
| Counting matches by category, without fetching documents | A terms aggregation with withMaxResults(0) |
Further reading
- Companion repository: spring-data-demo/elasticsearch
- Elasticsearch field data types: Field data types — Elasticsearch Reference
- Spring Data Elasticsearch reference — query methods and the query DSL: Spring Data Elasticsearch documentation
- Elasticsearch bool query (must vs filter): Boolean query — Elasticsearch Reference
No Comments yet!