Post

Spring Boot + Elasticsearch — Full-Text Search Done Right

Add powerful full-text search to Spring Boot with Elasticsearch. Covers document mapping, Spring Data ElasticsearchRepository, custom queries, fuzzy matching, highlighting, aggregations, and production indexing strategies.

Spring Boot + Elasticsearch — Full-Text Search Done Right

The Problem: Why SQL LIKE Doesn’t Scale

Every application eventually needs search. The first instinct is WHERE title LIKE '%keyword%'. It works for a few hundred rows but falls apart quickly:

IssueSQL LIKEElasticsearch
Performance on 1M+ rowsFull table scan — secondsInverted index — milliseconds
Typo toleranceNoneFuzzy matching built-in
Relevance scoringNone — binary matchTF-IDF / BM25 scoring
Multi-field searchManual UNION logicMulti-match with field boosting
AggregationsGROUP BY — limitedNative facets, histograms
HighlightingManual string opsBuilt-in <em> wrapping

If your users type “elastcsearch” (typo) and expect results, you need a real search engine.


What is Elasticsearch?

Elasticsearch is a distributed search and analytics engine built on Apache Lucene. It stores data as JSON documents and builds an inverted index — a mapping from every term to the documents that contain it.

flowchart LR
    A[Document Indexed] --> B[Analyzer: Tokenize + Normalize]
    B --> C[Inverted Index Updated]
    D[Search Query] --> E[Analyzer: Tokenize + Normalize]
    E --> F[Lookup in Inverted Index]
    F --> G[Score Results: BM25]
    G --> H[Return Ranked Results]

diagram

When you index “Spring Boot Elasticsearch Tutorial”:

  • Tokenizer splits into: [spring, boot, elasticsearch, tutorial]
  • Each token maps to the document ID
  • A search for “elasticsearch” instantly finds the document via the index

Setup: Docker Compose

We run Elasticsearch 8.14 as a single node with security disabled for development:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
version: '3.8'

services:
  elasticsearch:
    image: docker.elastic.co/elasticsearch/elasticsearch:8.14.0
    container_name: elasticsearch
    environment:
      - discovery.type=single-node
      - xpack.security.enabled=false
      - xpack.security.http.ssl.enabled=false
      - ES_JAVA_OPTS=-Xms512m -Xmx512m
    ports:
      - "9200:9200"
    volumes:
      - es_data:/usr/share/elasticsearch/data
    healthcheck:
      test: ["CMD-SHELL", "curl -f http://localhost:9200/_cluster/health || exit 1"]
      interval: 10s
      timeout: 5s
      retries: 10

volumes:
  es_data:
    driver: local

Start it:

1
2
docker-compose up -d
curl http://localhost:9200  # verify it's running

Dependencies

1
2
3
4
5
6
7
8
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Configuration in application.yml:

1
2
3
spring:
  elasticsearch:
    uris: http://localhost:9200

Document Mapping: @Document and @Field

Elasticsearch documents map to Java classes with annotations:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Document(indexName = "articles")
public class Article {

    @Id
    private String id;

    @Field(type = FieldType.Text, analyzer = "standard")
    private String title;

    @Field(type = FieldType.Text, analyzer = "standard")
    private String content;

    @Field(type = FieldType.Keyword)
    private String author;

    @Field(type = FieldType.Keyword)
    private List<String> tags;

    @Field(type = FieldType.Date, format = DateFormat.date)
    private LocalDate publishedAt;
}

Key field types:

FieldTypeUse CaseAnalyzed?Exact Match?
TextFull-text searchable contentYesNo
KeywordFiltering, sorting, aggregationsNoYes
DateDate range queriesNoYes
Integer/LongNumeric range queriesNoYes

Text fields are analyzed (tokenized + normalized) for full-text search. Keyword fields are stored as-is for exact match, sorting, and aggregations.


ElasticsearchRepository: Derived Queries

Spring Data Elasticsearch provides repository support just like Spring Data JPA:

1
2
3
4
5
6
7
@Repository
public interface ArticleRepository extends ElasticsearchRepository<Article, String> {

    List<Article> findByAuthor(String author);

    List<Article> findByTagsContaining(String tag);
}

Derived query methods work the same way you know from JPA — the method name is parsed into an Elasticsearch query.


Custom Queries with @Query

For real search functionality, use the @Query annotation with native Elasticsearch JSON:

1
2
3
4
5
6
7
8
9
10
@Query("""
    {
      "multi_match": {
        "query": "?0",
        "fields": ["title^3", "content"],
        "type": "best_fields"
      }
    }
    """)
List<Article> searchByTitleAndContent(String query);

The ^3 boosts title matches — a match in the title is weighted 3x higher than content.

Fuzzy Matching

Handle typos with fuzziness:

1
2
3
4
5
6
7
8
9
10
@Query("""
    {
      "multi_match": {
        "query": "?0",
        "fields": ["title^3", "content"],
        "fuzziness": "AUTO"
      }
    }
    """)
List<Article> fuzzySearch(String query);

fuzziness: AUTO uses edit distance based on term length:

  • 1-2 characters: exact match
  • 3-5 characters: 1 edit allowed
  • 6+ characters: 2 edits allowed

Compound Query: Bool with Filters

1
2
3
4
5
6
7
8
9
10
11
12
13
@Query("""
    {
      "bool": {
        "must": [
          { "multi_match": { "query": "?0", "fields": ["title^3", "content"] } }
        ],
        "filter": [
          { "term": { "author": "?1" } }
        ]
      }
    }
    """)
List<Article> searchByQueryAndAuthor(String query, String author);

The must clause scores results, while filter narrows without affecting score — more efficient for exact-match conditions.


Full-Text Search with Relevance Scoring

Elasticsearch uses the BM25 algorithm (an evolution of TF-IDF) to score documents:

  • Term Frequency (TF): How often the term appears in the document
  • Inverse Document Frequency (IDF): How rare the term is across all documents
  • Field Length Normalization: Shorter fields get a boost (a match in a 5-word title > a match in a 5000-word body)

The result: documents are ranked by how relevant they are, not just whether they match.


Highlighting: Wrap Matches in Tags

Show users exactly which part of the text matched:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public List<SearchHit<Article>> searchWithHighlighting(String query) {
    var highlightFields = List.of(
            new HighlightField("title"),
            new HighlightField("content")
    );
    var highlight = new Highlight(highlightFields);

    NativeQuery searchQuery = NativeQuery.builder()
            .withQuery(q -> q.multiMatch(m -> m
                    .query(query)
                    .fields("title^3", "content")))
            .withHighlightQuery(new HighlightQuery(highlight, Article.class))
            .build();

    SearchHits<Article> searchHits = elasticsearchOperations.search(searchQuery, Article.class);
    return searchHits.getSearchHits();
}

The response includes highlight fragments:

1
2
3
4
5
6
7
{
  "content": { ... },
  "highlightFields": {
    "title": ["Getting Started with <em>Elasticsearch</em>"],
    "content": ["<em>Elasticsearch</em> is a distributed search engine..."]
  }
}

Aggregations: Analytics on Your Data

Aggregations let you compute summaries without fetching documents.

Articles Per Author (Terms Aggregation)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public Map<String, Long> getArticlesPerAuthor() {
    NativeQuery query = NativeQuery.builder()
            .withAggregation("authors",
                    Aggregation.of(a -> a.terms(t -> t.field("author").size(50))))
            .withMaxResults(0)  // we don't need documents, just aggregation
            .build();

    SearchHits<Article> searchHits = elasticsearchOperations.search(query, Article.class);
    Map<String, Long> authorCounts = new HashMap<>();

    ElasticsearchAggregations aggregations =
            (ElasticsearchAggregations) searchHits.getAggregations();
    var authorAgg = aggregations.aggregations().get(0).aggregation().getAggregate();
    List<StringTermsBucket> buckets = authorAgg.sterms().buckets().array();
    for (StringTermsBucket bucket : buckets) {
        authorCounts.put(bucket.key().stringValue(), bucket.docCount());
    }

    return authorCounts;
}

Tag Cloud (Terms Aggregation)

1
2
3
4
5
6
7
8
9
public Map<String, Long> getTagCloud() {
    NativeQuery query = NativeQuery.builder()
            .withAggregation("tags",
                    Aggregation.of(a -> a.terms(t -> t.field("tags").size(100))))
            .withMaxResults(0)
            .build();

    // ... similar extraction logic
}

Indexing Strategies

Bulk Indexing

For initial data loads, use bulk operations:

1
2
3
public List<Article> saveAll(List<Article> articles) {
    return (List<Article>) articleRepository.saveAll(articles);
}

Under the hood, Spring Data Elasticsearch uses the Bulk API — much faster than individual saves.

Async Indexing

For production, consider async indexing to avoid blocking request threads:

1
2
3
4
5
@Async
public CompletableFuture<Article> saveAsync(Article article) {
    Article saved = articleRepository.save(article);
    return CompletableFuture.completedFuture(saved);
}

Index Aliases and Zero-Downtime Reindexing

For production deployments, use index aliases:

  1. Create articles-v2 with updated mapping
  2. Reindex data from articles-v1 to articles-v2
  3. Switch the articles alias to point to articles-v2
  4. Delete articles-v1

No downtime. No code changes.


Elasticsearch vs Database LIKE: Full Comparison

FeatureSQL LIKEElasticsearch
QueryWHERE title LIKE '%term%'multi_match with BM25
Index usageNone (full scan)Inverted index
Latency (1M docs)500ms–5s5–50ms
Fuzzy/typoNot supportedBuilt-in
Relevance rankingNoneBM25 scoring
Facets/aggregationsExpensive GROUP BYNative, sub-second
HighlightingManualBuilt-in
ScalingVertical onlyHorizontal (shards)
MaintenanceZeroCluster management

Common Problems

ProblemCauseSolution
No results for known dataText field queried as keywordCheck mapping — use Text for searchable fields
Keyword field not searchableField mapped as KeywordKeyword is exact-match only; use Text for full-text
Slow indexingSingle-document insertsUse bulk API
Stale search resultsNear-real-time delay (1s default)Call refresh API or accept 1s delay
OOM on large result setsFetching too many docsUse pagination (from/size)
Score 0.0 on filter queriesUsing filter contextfilter doesn’t score — use must for scoring

Full Working Example

The complete implementation is available on GitHub:

spring-boot-examples/33-elasticsearch

Clone and run:

1
2
3
4
git clone https://github.com/AnupamSinha/spring-boot-examples.git
cd spring-boot-examples/33-elasticsearch
docker-compose up -d
./mvnw spring-boot:run

References

This post is licensed under CC BY 4.0 by the author.