Skip to main content

Full-Text Search with Spring Data Elasticsearch on Spring Boot 4

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 via mvn dependency:tree rather 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.

“Ergonomic Office Chair” stored two different ways name: Text [ergonomic, office, chair] analyzed into tokens matches “office chairs”, fuzzy typos, partial terms category: Keyword “furniture” stored exactly, unanalyzed exact filter match only – sorts and aggregates cleanly Mapping the wrong way for the job fails silently – no error, just results that don’t match what you expect.
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.

One search request, two things happening at once must + filter (bool query) must: multiMatch(“wireless”) filter: price ≤ ceiling ranked SearchHits<Product> only must affects the score terms aggregation group by category (keyword) withMaxResults(0) {furniture: 2, …} counts only, no documents returned Both run server-side against the same matching set – neither pulls extra data over the wire to compute client-side.
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 name and description fields use Elasticsearch’s default standard analyzer. 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 @Setting and 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.

NeedWhat to use
Exact lookup by a known fieldElasticsearchRepository derived query method
Multi-field relevance search with typo tolerancemultiMatch with per-field boosts and fuzziness
Narrowing results without affecting rankA bool query’s filter clause, not must
Counting matches by category, without fetching documentsA terms aggregation with withMaxResults(0)

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.