Elasticsearch #
When a user types “cheap gaming laptop” into your online store’s search box, they expect relevant results to appear in milliseconds — not a slow, unintelligent SQL LIKE '%laptop%gaming%cheap%' query. Elasticsearch is a search engine based on Apache Lucene, designed specifically for this need: fast, relevant, and scalable full-text search. More than just a search engine, Elasticsearch is also used as an analytics engine for log aggregation, infrastructure monitoring, and business intelligence. This article covers how to use Elasticsearch in Java — from the basic inverted index concept, connection setup, document operations, various query types, aggregations, to Spring Data Elasticsearch integration.
Elasticsearch Basics #
Understanding how Elasticsearch stores and searches data explains why it can be so fast. Elasticsearch isn’t an ordinary database — it’s a search engine optimized for text search.
Terminology #
| Concept | Elasticsearch | Description |
|---|---|---|
| Database | Index | A container for a collection of documents of the same type |
| Table | (doesn’t exist) | ES doesn’t distinguish tables within an index |
| Row | Document | A single data entry in JSON format |
| Column | Field | An attribute within a document |
| Schema | Mapping | The definition of each field’s data type |
| SQL Query | Query DSL | JSON-based query language |
| GROUP BY | Aggregation | Grouping and calculating statistics |
Inverted Index — Why Search Is Fast #
SQL databases store data row by row. When you query WHERE name LIKE '%laptop%', the database must read every row — slow for millions of records. Elasticsearch flips this logic with the inverted index.
flowchart TD
subgraph "Input Documents"
D1["Doc 1: 'Gaming Pro Laptop'"]
D2["Doc 2: 'Business Slim Laptop'"]
D3["Doc 3: 'Mechanical Gaming Keyboard'"]
end
subgraph "Analysis Process"
A["Tokenizer\n(split into words)"]
B["Filter\n(lowercase, stemming)"]
end
subgraph "Inverted Index"
T1["'laptop' → Doc1, Doc2"]
T2["'gaming' → Doc1, Doc3"]
T3["'pro' → Doc1"]
T4["'business' → Doc2"]
T5["'slim' → Doc2"]
T6["'keyboard' → Doc3"]
end
D1 --> A
D2 --> A
D3 --> A
A --> B
B --> T1
B --> T2
B --> T3
B --> T4
B --> T5
B --> T6When a user searches for “gaming laptop”, Elasticsearch immediately looks up the inverted index: laptop → Doc1, Doc2 and gaming → Doc1, Doc3. Their intersection: Doc1. No full scan — straight to the results.
Cluster Architecture #
flowchart TD
Client["Java Application"] --> LB["Load Balancer / Client Node"]
LB --> M["Master Node\n(cluster coordination)"]
LB --> D1["Data Node 1\nShard 0 (Primary)\nShard 1 (Replica)"]
LB --> D2["Data Node 2\nShard 1 (Primary)\nShard 0 (Replica)"]
LB --> D3["Data Node 3\nShard 2 (Primary)\nShard 2 (Replica)"]
M --> D1
M --> D2
M --> D3An index in Elasticsearch is divided into shards (primaries) distributed across several nodes. Each primary shard has one or more replica shards on different nodes — for high availability and increased read throughput.
Installation and Dependencies #
The Elasticsearch Java Client 8.x (the newest official client) uses a different architecture from the old HLRC (High-Level REST Client), which is deprecated.
<!-- pom.xml — Elasticsearch Java Client 8.x -->
<dependency>
<groupId>co.elastic.clients</groupId>
<artifactId>elasticsearch-java</artifactId>
<version>8.11.0</version>
</dependency>
<!-- Jackson for JSON serialization -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<!-- Jakarta JSON API (required by the ES client) -->
<dependency>
<groupId>jakarta.json</groupId>
<artifactId>jakarta.json-api</artifactId>
<version>2.1.1</version>
</dependency>
<!-- If using Spring Boot -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
// build.gradle
implementation 'co.elastic.clients:elasticsearch-java:8.11.0'
implementation 'com.fasterxml.jackson.core:jackson-databind:2.15.2'
implementation 'jakarta.json:jakarta.json-api:2.1.1'
Don’t useelasticsearch-rest-high-level-client(HLRC) for new projects. HLRC has been deprecated since Elasticsearch 7.15 and removed in version 8.x. Useelasticsearch-java(Java API Client) instead.
Connecting to Elasticsearch #
Connection Without Authentication (Development) #
import co.elastic.clients.elasticsearch.ElasticsearchClient;
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
import co.elastic.clients.transport.ElasticsearchTransport;
import co.elastic.clients.transport.rest_client.RestClientTransport;
import org.apache.http.HttpHost;
import org.elasticsearch.client.RestClient;
public class ElasticsearchConfig {
public ElasticsearchClient createClient() {
// Low-level REST client (manages HTTP connections)
RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200)
).build();
// Transport layer with a JSON mapper
ElasticsearchTransport transport = new RestClientTransport(
restClient,
new JacksonJsonpMapper()
);
// High-level typed client
return new ElasticsearchClient(transport);
}
}
Connection with Authentication and TLS (Production) #
import org.apache.http.auth.AuthScope;
import org.apache.http.auth.UsernamePasswordCredentials;
import org.apache.http.impl.client.BasicCredentialsProvider;
import org.apache.http.ssl.SSLContextBuilder;
import javax.net.ssl.SSLContext;
import java.io.File;
import java.nio.file.Files;
public class ElasticsearchProductionConfig {
public ElasticsearchClient createSecureClient() throws Exception {
// Load the CA certificate for TLS
File certFile = new File("/path/to/http_ca.crt");
byte[] certBytes = Files.readAllBytes(certFile.toPath());
SSLContext sslContext = SSLContextBuilder.create()
.loadTrustMaterial(null, (chains, authType) -> true) // trust all certs
.build();
// Set up credentials
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
credentialsProvider.setCredentials(
AuthScope.ANY,
new UsernamePasswordCredentials("elastic", "your-password")
);
RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200, "https")
)
.setHttpClientConfigCallback(httpClientBuilder ->
httpClientBuilder
.setSSLContext(sslContext)
.setDefaultCredentialsProvider(credentialsProvider)
)
.build();
ElasticsearchTransport transport = new RestClientTransport(
restClient,
new JacksonJsonpMapper()
);
return new ElasticsearchClient(transport);
}
}
Using It as a Spring Bean #
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class ElasticsearchBeanConfig {
@Bean
public ElasticsearchClient elasticsearchClient() {
RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200)
).build();
ElasticsearchTransport transport = new RestClientTransport(
restClient,
new JacksonJsonpMapper()
);
return new ElasticsearchClient(transport);
}
}
Mapping — Defining the Index Structure #
Mapping in Elasticsearch is equivalent to a schema in SQL. Defining the mapping before indexing data is very important — a field’s data type determines how the data is indexed and can be queried.
Important Data Types #
| Type | Use Case | Example Value |
|---|---|---|
text | Full-text search, analyzed (tokenized) | "Best Gaming Laptop" |
keyword | Exact match, sorting, aggregation | "gaming", "active" |
integer, long | Whole numbers | 15000000 |
double, float | Decimal numbers | 4.5 |
boolean | True/false | true |
date | Date and time | "2024-01-15T08:00:00Z" |
nested | Array of objects with preserved relationships | Product review list |
geo_point | Latitude/longitude coordinates | { "lat": -6.2, "lon": 106.8 } |
Creating an Index with Mapping #
import co.elastic.clients.elasticsearch.indices.CreateIndexResponse;
import co.elastic.clients.elasticsearch.indices.PutMappingResponse;
import java.io.IOException;
public class IndexManager {
private final ElasticsearchClient client;
public IndexManager(ElasticsearchClient client) {
this.client = client;
}
public void createProductsIndex() throws IOException {
// ANTI-PATTERN: letting ES auto-detect data types
// Auto-detection is often wrong — e.g. price might be detected as long
// when you need keyword for certain aggregations
// CORRECT: define the mapping explicitly
CreateIndexResponse response = client.indices().create(req -> req
.index("products")
.settings(s -> s
.numberOfShards("3") // split into 3 primary shards
.numberOfReplicas("1") // 1 replica per primary shard
)
.mappings(m -> m
.properties("name", p -> p
.text(t -> t
.analyzer("indonesian") // Indonesian text analysis
.fields("keyword", f -> f // sub-field for exact match
.keyword(k -> k.ignoreAbove(256))
)
)
)
.properties("description", p -> p
.text(t -> t.analyzer("indonesian"))
)
.properties("category", p -> p
.keyword(k -> k) // keyword: for exact-match filter & aggregation
)
.properties("price", p -> p
.long_(l -> l)
)
.properties("rating", p -> p
.float_(f -> f)
)
.properties("active", p -> p
.boolean_(b -> b)
)
.properties("tags", p -> p
.keyword(k -> k)
)
.properties("created_at", p -> p
.date(d -> d.format("yyyy-MM-dd'T'HH:mm:ssZ||epoch_millis"))
)
)
);
System.out.println("Index created: " + response.acknowledged());
}
}
Thenamefield uses thetexttype with akeywordsub-field. This is a common pattern in Elasticsearch:name(text) for full-text search,name.keyword(keyword) for exact match, sorting, and aggregation — two different needs, one field.
Document Operations #
Indexing Documents — Storing Data #
“Indexing” in Elasticsearch means storing a document while building its inverted index — different terminology from a regular SQL INSERT.
import co.elastic.clients.elasticsearch.core.IndexResponse;
import co.elastic.clients.elasticsearch.core.BulkResponse;
import co.elastic.clients.elasticsearch.core.bulk.BulkOperation;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
public class ProductRepository {
private final ElasticsearchClient client;
private static final String INDEX = "products";
public ProductRepository(ElasticsearchClient client) {
this.client = client;
}
// Product model
public record Product(
String id,
String name,
String description,
String category,
long price,
float rating,
boolean active,
List<String> tags,
String createdAt
) {}
// Index a single document with an explicit ID
public String saveProduct(Product product) throws IOException {
IndexResponse response = client.index(req -> req
.index(INDEX)
.id(product.id())
.document(product)
);
System.out.println("Result: " + response.result());
return response.id();
}
// Bulk indexing — far more efficient for many documents
public void saveManyProducts(List<Product> productList) throws IOException {
// ANTI-PATTERN: calling index() one by one in a loop
// This makes a separate HTTP request per document — very slow
// for (Product p : productList) { client.index(...) } // DON'T
// CORRECT: use the bulk API for batch indexing
List<BulkOperation> operations = new ArrayList<>();
for (Product product : productList) {
operations.add(BulkOperation.of(op -> op
.index(idx -> idx
.index(INDEX)
.id(product.id())
.document(product)
)
));
}
BulkResponse response = client.bulk(req -> req.operations(operations));
if (response.errors()) {
response.items().stream()
.filter(item -> item.error() != null)
.forEach(item -> System.err.println(
"Error on ID " + item.id() + ": " + item.error().reason()
));
}
System.out.println("Indexed " + response.items().size() + " documents");
}
}
Get, Update, Delete #
import co.elastic.clients.elasticsearch.core.GetResponse;
import co.elastic.clients.elasticsearch.core.UpdateResponse;
import co.elastic.clients.elasticsearch.core.DeleteResponse;
import java.util.Map;
public class ProductCRUD {
private final ElasticsearchClient client;
private static final String INDEX = "products";
public ProductCRUD(ElasticsearchClient client) {
this.client = client;
}
// Get a document by ID
public Product getProduct(String id) throws IOException {
GetResponse<Product> response = client.get(req -> req
.index(INDEX)
.id(id),
Product.class
);
if (!response.found()) {
return null;
}
return response.source();
}
// Partial update — only the included fields change
public void updatePrice(String id, long newPrice) throws IOException {
Map<String, Object> updateFields = Map.of(
"price", newPrice,
"updatedAt", LocalDateTime.now().toString()
);
UpdateResponse<Product> response = client.update(req -> req
.index(INDEX)
.id(id)
.doc(updateFields),
Product.class
);
System.out.println("Update result: " + response.result());
}
// Delete a document
public boolean deleteProduct(String id) throws IOException {
DeleteResponse response = client.delete(req -> req
.index(INDEX)
.id(id)
);
return response.result() == co.elastic.clients.elasticsearch._types.Result.Deleted;
}
}
Query DSL — Searching Documents #
Query DSL is Elasticsearch’s JSON query language. It’s the most important part — understanding the right query type for each need determines the relevance and performance of your search.
Match Query — Basic Full-Text Search #
import co.elastic.clients.elasticsearch.core.SearchResponse;
import co.elastic.clients.elasticsearch.core.search.Hit;
import co.elastic.clients.elasticsearch._types.query_dsl.*;
import java.util.List;
import java.util.stream.Collectors;
public class ProductSearch {
private final ElasticsearchClient client;
private static final String INDEX = "products";
public ProductSearch(ElasticsearchClient client) {
this.client = client;
}
// Match query: full-text search on a single field
// Elasticsearch will analyze the query (tokenize, lowercase, etc.)
public List<Product> searchByName(String keyword) throws IOException {
SearchResponse<Product> response = client.search(req -> req
.index(INDEX)
.query(q -> q
.match(m -> m
.field("name")
.query(keyword)
.fuzziness("AUTO") // typo tolerance: "laotop" can match "laptop"
)
)
.size(20),
Product.class
);
return response.hits().hits().stream()
.map(Hit::source)
.collect(Collectors.toList());
}
// Multi-match: search several fields at once
public List<Product> searchMultiField(String keyword) throws IOException {
SearchResponse<Product> response = client.search(req -> req
.index(INDEX)
.query(q -> q
.multiMatch(m -> m
.fields("name^3", "description^1", "tags^2") // boost: name 3x, tags 2x
.query(keyword)
.type(TextQueryType.BestFields) // take the score from the best field
)
),
Product.class
);
return response.hits().hits().stream()
.map(Hit::source)
.collect(Collectors.toList());
}
// Term query: exact match, not analyzed — for keyword fields
public List<Product> searchByCategory(String category) throws IOException {
SearchResponse<Product> response = client.search(req -> req
.index(INDEX)
.query(q -> q
// ANTI-PATTERN: using match for keyword fields
// match will analyze the query, but keyword fields aren't analyzed
// the result may not match even when the value is the same
// CORRECT: use term for keyword fields
.term(t -> t
.field("category")
.value(category)
)
),
Product.class
);
return response.hits().hits().stream()
.map(Hit::source)
.collect(Collectors.toList());
}
}
Bool Query — Combining Conditions #
The bool query is the main building block for complex queries. It combines multiple queries with AND/OR/NOT logic.
public class BoolQueryExample {
private final ElasticsearchClient client;
public BoolQueryExample(ElasticsearchClient client) {
this.client = client;
}
// Bool query: combination of must, should, filter, must_not
public SearchResponse<Product> advancedSearch(
String keyword,
String category,
long minPrice,
long maxPrice,
List<String> tags) throws IOException {
return client.search(req -> req
.index("products")
.query(q -> q
.bool(b -> {
// MUST: must match, affects the relevance score
if (keyword != null && !keyword.isEmpty()) {
b.must(m -> m.multiMatch(mm -> mm
.fields("name^3", "description")
.query(keyword)
));
}
// FILTER: must match, does NOT affect the score (faster, cached)
b.filter(f -> f.term(t -> t.field("active").value(true)));
if (category != null) {
b.filter(f -> f.term(t -> t.field("category").value(category)));
}
b.filter(f -> f.range(r -> r
.field("price")
.gte(co.elastic.clients.json.JsonData.of(minPrice))
.lte(co.elastic.clients.json.JsonData.of(maxPrice))
));
// SHOULD: nice if it matches, boosts the score but isn't required
if (tags != null && !tags.isEmpty()) {
for (String tag : tags) {
b.should(s -> s.term(t -> t.field("tags").value(tag)));
}
b.minimumShouldMatch("1"); // at least 1 tag must match
}
// MUST_NOT: must not match
b.mustNot(mn -> mn.term(t -> t.field("category").value("discontinued")));
return b;
})
)
.sort(s -> s.score(sc -> sc.order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)))
.from(0)
.size(20),
Product.class
);
}
}
Bool Query Processing Flow #
sequenceDiagram
participant App as Java App
participant ES as Elasticsearch
participant Cache as Filter Cache
participant Scorer as Relevance Scorer
App->>ES: Bool Query (must + filter + should)
ES->>Cache: Check filter cache (active=true, category, price)
Cache-->>ES: Filter result bitset (passing documents)
ES->>Scorer: Calculate relevance scores (must + should)
Scorer-->>ES: Score per document
ES->>ES: Sort by score, apply pagination
ES-->>App: Hits with score and highlightHighlight — Highlighting Matching Words #
public List<Map<String, Object>> searchWithHighlight(String keyword) throws IOException {
SearchResponse<Product> response = client.search(req -> req
.index("products")
.query(q -> q.match(m -> m.field("name").query(keyword)))
.highlight(h -> h
.fields("name", hf -> hf
.numberOfFragments(0) // return the whole field, not a fragment
.preTags("<em class='highlight'>")
.postTags("</em>")
)
.fields("description", hf -> hf
.numberOfFragments(3) // take the 3 most relevant fragments
.fragmentSize(150) // 150 characters per fragment
.preTags("<mark>")
.postTags("</mark>")
)
),
Product.class
);
return response.hits().hits().stream()
.map(hit -> Map.of(
"product", hit.source(),
"highlight", hit.highlight()
))
.collect(Collectors.toList());
}
Aggregations #
Aggregations let you calculate statistics, build charts, or create faceted navigation (e-commerce sidebar filters) directly from Elasticsearch.
Aggregation Types #
flowchart TD
A[Aggregation] --> B[Bucket Aggregation\nGroup documents]
A --> C[Metric Aggregation\nCalculate statistics]
A --> D[Pipeline Aggregation\nAggregating other aggregations]
B --> B1["terms\nGroup by field value"]
B --> B2["range\nGroup by value range"]
B --> B3["date_histogram\nGroup by time period"]
C --> C1["avg, min, max, sum"]
C --> C2["stats\n(all at once)"]
C --> C3["cardinality\n(count distinct)"]
D --> D1["moving_avg\nMoving average"]
D --> D2["bucket_sort\nSort aggregation results"]Faceted Search — E-Commerce Sidebar Filters #
import co.elastic.clients.elasticsearch._types.aggregations.*;
public class FacetedSearch {
private final ElasticsearchClient client;
public FacetedSearch(ElasticsearchClient client) {
this.client = client;
}
public SearchResponse<Product> searchWithFacets(
String keyword, String categoryFilter) throws IOException {
return client.search(req -> req
.index("products")
.query(q -> {
if (keyword != null && !keyword.isEmpty()) {
return q.match(m -> m.field("name").query(keyword));
}
return q.matchAll(m -> m);
})
// Filter by user selection (post_filter: doesn't affect aggregations)
.postFilter(pf -> categoryFilter != null
? pf.term(t -> t.field("category").value(categoryFilter))
: pf.matchAll(m -> m)
)
.aggregations("category_facet", a -> a
// Terms aggregation: count products per category
.terms(t -> t
.field("category")
.size(20)
)
)
.aggregations("price_range", a -> a
// Range aggregation: group by price range
.range(r -> r
.field("price")
.ranges(
rng -> rng.to("500000").key("Below 500k"),
rng -> rng.from("500000").to("1000000").key("500k - 1M"),
rng -> rng.from("1000000").to("5000000").key("1M - 5M"),
rng -> rng.from("5000000").key("Above 5M")
)
)
)
.aggregations("price_stats", a -> a
// Stats aggregation: min, max, avg, sum all at once
.stats(s -> s.field("price"))
)
.aggregations("products_per_month", a -> a
// Date histogram: time trends
.dateHistogram(dh -> dh
.field("created_at")
.calendarInterval(CalendarInterval.Month)
.format("yyyy-MM")
)
)
.size(20),
Product.class
);
}
// Parsing aggregation results
public void parseFacetResult(SearchResponse<Product> response) {
// Get the terms aggregation buckets
StringTermsAggregate categoryFacet = response.aggregations()
.get("category_facet")
.sterms();
System.out.println("Available categories:");
categoryFacet.buckets().array().forEach(bucket ->
System.out.println(" " + bucket.key() + ": " + bucket.docCount() + " products")
);
// Get the stats aggregation
StatsAggregate priceStats = response.aggregations()
.get("price_stats")
.stats();
System.out.println("Minimum price: " + priceStats.min());
System.out.println("Maximum price: " + priceStats.max());
System.out.println("Average price: " + priceStats.avg());
}
}
Pagination and Sorting #
Elasticsearch has two different pagination approaches for different needs.
From/Size — Regular Pagination #
public SearchResponse<Product> standardPagination(String keyword, int page, int perPage)
throws IOException {
// ANTI-PATTERN: using regular pagination for very deep pages
// from=10000 means ES must collect 10,000 documents from all shards
// then discard 9,990 — wasting memory and CPU
// CORRECT: use from/size only for early pages (max ~10,000 total)
int from = (page - 1) * perPage;
return client.search(req -> req
.index("products")
.query(q -> q.match(m -> m.field("name").query(keyword)))
.from(from)
.size(perPage)
.sort(s -> s.score(sc -> sc.order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)))
.sort(s -> s.field(f -> f // secondary sort: stabilize the order
.field("_id")
.order(co.elastic.clients.elasticsearch._types.SortOrder.Asc)
)),
Product.class
);
}
Search After — Efficient Deep Pagination #
import co.elastic.clients.elasticsearch._types.FieldValue;
import java.util.List;
public SearchResponse<Product> deepPagination(List<FieldValue> searchAfter) throws IOException {
var requestBuilder = client.search(req -> {
var builder = req
.index("products")
.query(q -> q.matchAll(m -> m))
.sort(s -> s.field(f -> f
.field("created_at")
.order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)
))
.sort(s -> s.field(f -> f.field("_id")))
.size(20);
// Continue from the last position using sort values
if (searchAfter != null && !searchAfter.isEmpty()) {
builder.searchAfter(searchAfter);
}
return builder;
}, Product.class);
// For the next request, take the sort values from the last hit:
// List<FieldValue> nextCursor = response.hits().hits()
// .get(response.hits().hits().size() - 1)
// .sort();
return requestBuilder;
}
Spring Data Elasticsearch Integration #
Spring Data Elasticsearch simplifies integration with a familiar pattern — similar to Spring Data MongoDB or Spring Data JPA.
Configuration #
# application.yml
spring:
elasticsearch:
uris: http://localhost:9200
username: elastic
password: your-password
connection-timeout: 5s
socket-timeout: 30s
Document Model #
import org.springframework.data.annotation.Id;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import org.springframework.data.elasticsearch.annotations.Setting;
import java.time.LocalDateTime;
import java.util.List;
@Document(indexName = "products")
@Setting(settingPath = "elasticsearch/products-settings.json") // custom analyzer
public class ProductDocument {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "indonesian")
private String name;
@Field(type = FieldType.Text, analyzer = "indonesian")
private String description;
@Field(type = FieldType.Keyword)
private String category;
@Field(type = FieldType.Long)
private long price;
@Field(type = FieldType.Float)
private float rating;
@Field(type = FieldType.Boolean)
private boolean active;
@Field(type = FieldType.Keyword)
private List<String> tags;
@Field(type = FieldType.Date, format = {}, pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime createdAt;
// getters, setters, constructor...
}
Repository #
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.elasticsearch.annotations.Query;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
import java.util.List;
public interface ProductSearchRepository
extends ElasticsearchRepository<ProductDocument, String> {
// Automatic queries from method names
List<ProductDocument> findByCategory(String category);
Page<ProductDocument> findByActiveTrue(Pageable pageable);
// Custom Elasticsearch query with @Query
@Query("""
{
"bool": {
"must": {
"multi_match": {
"query": "?0",
"fields": ["name^3", "description"],
"fuzziness": "AUTO"
}
},
"filter": [
{ "term": { "active": true } },
{ "range": { "price": { "gte": ?1, "lte": ?2 } } }
]
}
}
""")
Page<ProductDocument> searchByKeywordAndPrice(
String keyword, long minPrice, long maxPrice, Pageable pageable);
}
ElasticsearchOperations for Complex Queries #
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.elasticsearch.core.ElasticsearchOperations;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.Criteria;
import org.springframework.data.elasticsearch.core.query.CriteriaQuery;
import org.springframework.data.elasticsearch.core.query.NativeQuery;
import org.springframework.stereotype.Service;
import co.elastic.clients.elasticsearch._types.query_dsl.QueryBuilders;
@Service
public class ProductSearchService {
@Autowired
private ElasticsearchOperations operations;
// Query with the Criteria API (type-safe, no need to write JSON)
public SearchHits<ProductDocument> searchWithCriteria(
String keyword, String category) {
Criteria criteria = new Criteria("name").matches(keyword)
.and(new Criteria("category").is(category))
.and(new Criteria("active").is(true));
CriteriaQuery query = new CriteriaQuery(criteria);
return operations.search(query, ProductDocument.class);
}
// Native query for full control (use Query DSL directly)
public SearchHits<ProductDocument> nativeSearch(String keyword) {
NativeQuery query = NativeQuery.builder()
.withQuery(q -> q
.bool(b -> b
.must(m -> m.multiMatch(mm -> mm
.fields("name^3", "description")
.query(keyword)
.fuzziness("AUTO")
))
.filter(f -> f.term(t -> t.field("active").value(true)))
)
)
.withHighlightQuery(h -> h
.withHighlightFields(Map.of("name", new HighlightField()))
)
.withPageable(PageRequest.of(0, 20))
.build();
return operations.search(query, ProductDocument.class);
}
}
When to Use Elasticsearch #
Elasticsearch is a powerful tool, but not the solution to every problem. Adding Elasticsearch to your stack means adding operational complexity.
Use Elasticsearch when:
✓ You need relevant full-text search with scoring
✓ You need typo tolerance (fuzzy search) in searches
✓ You need faceted navigation (e-commerce sidebar filters)
✓ You need real-time analytics and aggregations on large data
✓ Log aggregation and monitoring (ELK Stack)
✓ Multi-language search with special analyzers
Consider alternatives when:
✗ You only need simple LIKE queries — PostgreSQL is enough
✗ Fewer than 100,000 documents — the overhead isn't worth it
✗ There's no team that can maintain an ES cluster
✗ You need strong consistency — ES is eventually consistent
✗ Limited budget — ES needs a lot of RAM (min 4GB per node)
Elasticsearch is eventually consistent — after indexing, a document may not be immediately searchable because of the refresh process (default every 1 second). Don’t use ES as the only storage for important data. The common pattern: store primary data in PostgreSQL/MongoDB, then sync to Elasticsearch for search needs.
Summary #
- The inverted index is the foundation of Elasticsearch’s speed — it maps words to documents, not the other way around, so full-text search doesn’t need to scan all data.
- Use the Java API Client (elasticsearch-java), not the deprecated HLRC. The new client is type-safe and uses idiomatic lambda builders.
- Distinguish
textandkeyword—textfor full-text search (analyzed, tokenized),keywordfor exact match, sorting, and aggregation. Fields that need both can use multi-fields.- The bool query is the main building block — combine
must(must match, scored),filter(must match, not scored, cached),should(score bonus), andmust_not.- Use
filterinstead ofmustfor non-textual conditions (price, status, dates) — filters are faster because their results are cached and they don’t calculate relevance scores.- The bulk API is required for mass indexing — don’t call
index()one by one in a loop. The bulk API drastically reduces HTTP request overhead.- Aggregations enable faceted navigation, real-time statistics, and time trends directly from the search query — without separate queries.
- ES isn’t a replacement for your primary database — use it as a search layer on top of the primary database (PostgreSQL, MongoDB). ES data can be rebuilt from the primary database if needed.
post_filterfor faceted search — usepost_filter(notfilterinside the query) so the user’s filter selections don’t affect the facet aggregation counts.