Elasticsearch #
Ketika pengguna mengetik “laptop gaming murah” di kolom pencarian toko online kamu, mereka berharap hasil yang relevan muncul dalam milidetik — bukan query SQL LIKE '%laptop%gaming%murah%' yang lambat dan tidak cerdas. Elasticsearch adalah search engine berbasis Apache Lucene yang dirancang khusus untuk kebutuhan ini: full-text search yang cepat, relevan, dan scalable. Lebih dari sekadar search engine, Elasticsearch juga digunakan sebagai analytics engine untuk log aggregation, monitoring infrastruktur, dan business intelligence. Artikel ini membahas cara menggunakan Elasticsearch di Java — mulai dari konsep dasar inverted index, setup koneksi, operasi dokumen, berbagai jenis query, aggregation, hingga integrasi dengan Spring Data Elasticsearch.
Konsep Dasar Elasticsearch #
Memahami cara Elasticsearch menyimpan dan mencari data akan menjelaskan mengapa ia bisa begitu cepat. Elasticsearch bukan database biasa — ia adalah search engine yang dioptimalkan untuk pencarian teks.
Terminologi #
| Konsep | Elasticsearch | Keterangan |
|---|---|---|
| Database | Index | Wadah untuk kumpulan dokumen bertipe sama |
| Table | (tidak ada) | ES tidak membedakan tabel dalam satu index |
| Row | Document | Satu entri data dalam format JSON |
| Column | Field | Atribut dalam sebuah dokumen |
| Schema | Mapping | Definisi tipe data setiap field |
| SQL Query | Query DSL | JSON-based query language |
| GROUP BY | Aggregation | Mengelompokkan dan menghitung statistik |
Inverted Index — Mengapa Search Cepat #
Database SQL menyimpan data baris per baris. Saat kamu query WHERE nama LIKE '%laptop%', database harus membaca setiap baris — lambat untuk jutaan data. Elasticsearch membalik logika ini dengan inverted index.
flowchart TD
subgraph "Dokumen Input"
D1["Doc 1: 'Laptop Gaming Pro'"]
D2["Doc 2: 'Laptop Bisnis Slim'"]
D3["Doc 3: 'Keyboard Gaming Mekanik'"]
end
subgraph "Proses Analisis"
A["Tokenizer\n(pecah jadi kata)"]
B["Filter\n(lowercase, stemming)"]
end
subgraph "Inverted Index"
T1["'laptop' → Doc1, Doc2"]
T2["'gaming' → Doc1, Doc3"]
T3["'pro' → Doc1"]
T4["'bisnis' → 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 --> T6Saat user mencari “laptop gaming”, Elasticsearch langsung lookup ke inverted index: laptop → Doc1, Doc2 dan gaming → Doc1, Doc3. Interseksi keduanya: Doc1. Tidak ada full scan — langsung ke hasilnya.
Arsitektur Cluster #
flowchart TD
Client["Java Application"] --> LB["Load Balancer / Client Node"]
LB --> M["Master Node\n(koordinasi cluster)"]
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 --> D3Satu index di Elasticsearch dibagi menjadi shard (primary) yang terdistribusi di beberapa node. Setiap primary shard punya satu atau lebih replica shard di node berbeda — untuk high availability dan meningkatkan throughput read.
Instalasi dan Dependency #
Elasticsearch Java Client versi 8.x (official client terbaru) menggunakan arsitektur yang berbeda dari HLRC (High-Level REST Client) versi lama yang sudah 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 untuk serialisasi JSON -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.15.2</version>
</dependency>
<!-- Jakarta JSON API (dibutuhkan oleh ES client) -->
<dependency>
<groupId>jakarta.json</groupId>
<artifactId>jakarta.json-api</artifactId>
<version>2.1.1</version>
</dependency>
<!-- Jika menggunakan 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'
Jangan gunakanelasticsearch-rest-high-level-client(HLRC) untuk proyek baru. HLRC sudah deprecated sejak Elasticsearch 7.15 dan dihapus di versi 8.x. Gunakanelasticsearch-java(Java API Client) sebagai gantinya.
Koneksi ke Elasticsearch #
Koneksi Tanpa 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 (mengelola HTTP connections)
RestClient restClient = RestClient.builder(
new HttpHost("localhost", 9200)
).build();
// Transport layer dengan JSON mapper
ElasticsearchTransport transport = new RestClientTransport(
restClient,
new JacksonJsonpMapper()
);
// High-level typed client
return new ElasticsearchClient(transport);
}
}
Koneksi dengan Authentication dan 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 CA certificate untuk 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 semua cert
.build();
// Setup 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);
}
}
Menggunakan sebagai Bean Spring #
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 — Mendefinisikan Struktur Index #
Mapping di Elasticsearch setara dengan schema di SQL. Mendefinisikan mapping sebelum indexing data sangat penting — tipe data field menentukan bagaimana data diindex dan bisa diquery.
Tipe Data Penting #
| Tipe | Kegunaan | Contoh Nilai |
|---|---|---|
text | Full-text search, dianalisis (tokenized) | "Laptop Gaming Terbaik" |
keyword | Exact match, sorting, aggregation | "gaming", "aktif" |
integer, long | Angka bulat | 15000000 |
double, float | Angka desimal | 4.5 |
boolean | True/false | true |
date | Tanggal dan waktu | "2024-01-15T08:00:00Z" |
nested | Array of objects dengan relasi terjaga | List review produk |
geo_point | Koordinat latitude/longitude | { "lat": -6.2, "lon": 106.8 } |
Membuat Index dengan 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 buatIndexProduk() throws IOException {
// ANTI-PATTERN: membiarkan ES auto-detect tipe data
// Auto-detection sering salah, misalnya harga bisa terdeteksi sebagai long
// padahal butuh keyword untuk aggregation tertentu
// BENAR: definisikan mapping secara eksplisit
CreateIndexResponse response = client.indices().create(req -> req
.index("produk")
.settings(s -> s
.numberOfShards("3") // dibagi menjadi 3 primary shard
.numberOfReplicas("1") // 1 replica per primary shard
)
.mappings(m -> m
.properties("nama", p -> p
.text(t -> t
.analyzer("indonesian") // analisis teks bahasa Indonesia
.fields("keyword", f -> f // sub-field untuk exact match
.keyword(k -> k.ignoreAbove(256))
)
)
)
.properties("deskripsi", p -> p
.text(t -> t.analyzer("indonesian"))
)
.properties("kategori", p -> p
.keyword(k -> k) // keyword: untuk filter & aggregation exact match
)
.properties("harga", p -> p
.long_(l -> l)
)
.properties("rating", p -> p
.float_(f -> f)
)
.properties("aktif", p -> p
.boolean_(b -> b)
)
.properties("tags", p -> p
.keyword(k -> k)
)
.properties("dibuat_pada", p -> p
.date(d -> d.format("yyyy-MM-dd'T'HH:mm:ssZ||epoch_millis"))
)
)
);
System.out.println("Index dibuat: " + response.acknowledged());
}
}
Fieldnamamenggunakan tipetextdengan sub-fieldkeyword. Ini pola umum di Elasticsearch:nama(text) untuk full-text search,nama.keyword(keyword) untuk exact match, sorting, dan aggregation — dua kebutuhan yang berbeda, satu field.
Operasi Dokumen #
Indexing Dokumen — Menyimpan Data #
“Indexing” di Elasticsearch berarti menyimpan dokumen sekaligus membangun inverted index-nya — beda terminologi dari SQL INSERT biasa.
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 ProdukRepository {
private final ElasticsearchClient client;
private static final String INDEX = "produk";
public ProdukRepository(ElasticsearchClient client) {
this.client = client;
}
// Model produk
public record Produk(
String id,
String nama,
String deskripsi,
String kategori,
long harga,
float rating,
boolean aktif,
List<String> tags,
String dibuatPada
) {}
// Index satu dokumen dengan ID eksplisit
public String simpanProduk(Produk produk) throws IOException {
IndexResponse response = client.index(req -> req
.index(INDEX)
.id(produk.id())
.document(produk)
);
System.out.println("Result: " + response.result());
return response.id();
}
// Bulk indexing — jauh lebih efisien untuk banyak dokumen
public void simpanBanyakProduk(List<Produk> produkList) throws IOException {
// ANTI-PATTERN: memanggil index() satu per satu dalam loop
// Ini membuat HTTP request terpisah untuk setiap dokumen — sangat lambat
// for (Produk p : produkList) { client.index(...) } // JANGAN
// BENAR: gunakan bulk API untuk batch indexing
List<BulkOperation> operations = new ArrayList<>();
for (Produk produk : produkList) {
operations.add(BulkOperation.of(op -> op
.index(idx -> idx
.index(INDEX)
.id(produk.id())
.document(produk)
)
));
}
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 pada ID " + item.id() + ": " + item.error().reason()
));
}
System.out.println("Indexed " + response.items().size() + " dokumen");
}
}
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 ProdukCRUD {
private final ElasticsearchClient client;
private static final String INDEX = "produk";
public ProdukCRUD(ElasticsearchClient client) {
this.client = client;
}
// Get dokumen berdasarkan ID
public Produk getProduk(String id) throws IOException {
GetResponse<Produk> response = client.get(req -> req
.index(INDEX)
.id(id),
Produk.class
);
if (!response.found()) {
return null;
}
return response.source();
}
// Update parsial — hanya field yang disertakan yang berubah
public void updateHarga(String id, long hargaBaru) throws IOException {
Map<String, Object> updateFields = Map.of(
"harga", hargaBaru,
"diperbaruidPada", LocalDateTime.now().toString()
);
UpdateResponse<Produk> response = client.update(req -> req
.index(INDEX)
.id(id)
.doc(updateFields),
Produk.class
);
System.out.println("Update result: " + response.result());
}
// Delete dokumen
public boolean hapusProduk(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 — Mencari Dokumen #
Query DSL adalah bahasa query JSON milik Elasticsearch. Ini adalah bagian terpenting — memahami jenis query yang tepat untuk setiap kebutuhan akan menentukan relevansi dan performa search.
Match Query — Full-Text Search Dasar #
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 ProdukSearch {
private final ElasticsearchClient client;
private static final String INDEX = "produk";
public ProdukSearch(ElasticsearchClient client) {
this.client = client;
}
// Match query: full-text search pada satu field
// Elasticsearch akan menganalisis query (tokenize, lowercase, dll)
public List<Produk> cariByNama(String keyword) throws IOException {
SearchResponse<Produk> response = client.search(req -> req
.index(INDEX)
.query(q -> q
.match(m -> m
.field("nama")
.query(keyword)
.fuzziness("AUTO") // toleransi typo: "laotop" bisa match "laptop"
)
)
.size(20),
Produk.class
);
return response.hits().hits().stream()
.map(Hit::source)
.collect(Collectors.toList());
}
// Multi-match: cari di beberapa field sekaligus
public List<Produk> cariMultiField(String keyword) throws IOException {
SearchResponse<Produk> response = client.search(req -> req
.index(INDEX)
.query(q -> q
.multiMatch(m -> m
.fields("nama^3", "deskripsi^1", "tags^2") // bobot: nama 3x, tags 2x
.query(keyword)
.type(TextQueryType.BestFields) // ambil score dari field terbaik
)
),
Produk.class
);
return response.hits().hits().stream()
.map(Hit::source)
.collect(Collectors.toList());
}
// Term query: exact match, tidak dianalisis — untuk field keyword
public List<Produk> cariByKategori(String kategori) throws IOException {
SearchResponse<Produk> response = client.search(req -> req
.index(INDEX)
.query(q -> q
// ANTI-PATTERN: menggunakan match untuk field keyword
// match akan menganalisis query, tapi keyword field tidak dianalisis
// hasilnya bisa tidak match meski nilainya sama
// BENAR: gunakan term untuk field keyword
.term(t -> t
.field("kategori")
.value(kategori)
)
),
Produk.class
);
return response.hits().hits().stream()
.map(Hit::source)
.collect(Collectors.toList());
}
}
Bool Query — Kombinasi Kondisi #
Bool query adalah building block utama untuk query kompleks. Ia menggabungkan beberapa query dengan logika AND/OR/NOT.
public class BoolQueryExample {
private final ElasticsearchClient client;
public BoolQueryExample(ElasticsearchClient client) {
this.client = client;
}
// Bool query: kombinasi must, should, filter, must_not
public SearchResponse<Produk> advancedSearch(
String keyword,
String kategori,
long hargaMin,
long hargaMax,
List<String> tags) throws IOException {
return client.search(req -> req
.index("produk")
.query(q -> q
.bool(b -> {
// MUST: harus match, mempengaruhi relevance score
if (keyword != null && !keyword.isEmpty()) {
b.must(m -> m.multiMatch(mm -> mm
.fields("nama^3", "deskripsi")
.query(keyword)
));
}
// FILTER: harus match, TIDAK mempengaruhi score (lebih cepat, di-cache)
b.filter(f -> f.term(t -> t.field("aktif").value(true)));
if (kategori != null) {
b.filter(f -> f.term(t -> t.field("kategori").value(kategori)));
}
b.filter(f -> f.range(r -> r
.field("harga")
.gte(co.elastic.clients.json.JsonData.of(hargaMin))
.lte(co.elastic.clients.json.JsonData.of(hargaMax))
));
// SHOULD: bagus jika match, meningkatkan score tapi tidak wajib
if (tags != null && !tags.isEmpty()) {
for (String tag : tags) {
b.should(s -> s.term(t -> t.field("tags").value(tag)));
}
b.minimumShouldMatch("1"); // minimal 1 tag harus match
}
// MUST_NOT: tidak boleh match
b.mustNot(mn -> mn.term(t -> t.field("kategori").value("discontinued")));
return b;
})
)
.sort(s -> s.score(sc -> sc.order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)))
.from(0)
.size(20),
Produk.class
);
}
}
Alur Pemrosesan Bool Query #
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: Cek filter cache (aktif=true, kategori, harga)
Cache-->>ES: Bitset hasil filter (dokumen yang lolos)
ES->>Scorer: Hitung relevance score (must + should)
Scorer-->>ES: Score per dokumen
ES->>ES: Sort by score, apply pagination
ES-->>App: Hits dengan score dan highlightHighlight — Sorot Kata yang Match #
public List<Map<String, Object>> searchWithHighlight(String keyword) throws IOException {
SearchResponse<Produk> response = client.search(req -> req
.index("produk")
.query(q -> q.match(m -> m.field("nama").query(keyword)))
.highlight(h -> h
.fields("nama", hf -> hf
.numberOfFragments(0) // kembalikan seluruh field, bukan fragment
.preTags("<em class='highlight'>")
.postTags("</em>")
)
.fields("deskripsi", hf -> hf
.numberOfFragments(3) // ambil 3 fragment paling relevan
.fragmentSize(150) // 150 karakter per fragment
.preTags("<mark>")
.postTags("</mark>")
)
),
Produk.class
);
return response.hits().hits().stream()
.map(hit -> Map.of(
"produk", hit.source(),
"highlight", hit.highlight()
))
.collect(Collectors.toList());
}
Aggregation #
Aggregation memungkinkan kamu menghitung statistik, membuat grafik, atau membangun faceted navigation (filter sidebar di e-commerce) langsung dari Elasticsearch.
Jenis Aggregation #
flowchart TD
A[Aggregation] --> B[Bucket Aggregation\nMengelompokkan dokumen]
A --> C[Metric Aggregation\nMenghitung statistik]
A --> D[Pipeline Aggregation\nAggregasi dari aggregasi lain]
B --> B1["terms\nGroup by nilai field"]
B --> B2["range\nGroup by rentang nilai"]
B --> B3["date_histogram\nGroup by periode waktu"]
C --> C1["avg, min, max, sum"]
C --> C2["stats\n(semua sekaligus)"]
C --> C3["cardinality\n(count distinct)"]
D --> D1["moving_avg\nMoving average"]
D --> D2["bucket_sort\nSort hasil aggregasi"]Faceted Search — Filter Sidebar E-Commerce #
import co.elastic.clients.elasticsearch._types.aggregations.*;
public class FacetedSearch {
private final ElasticsearchClient client;
public FacetedSearch(ElasticsearchClient client) {
this.client = client;
}
public SearchResponse<Produk> searchDenganFacet(
String keyword, String kategoriFilter) throws IOException {
return client.search(req -> req
.index("produk")
.query(q -> {
if (keyword != null && !keyword.isEmpty()) {
return q.match(m -> m.field("nama").query(keyword));
}
return q.matchAll(m -> m);
})
// Filter berdasarkan pilihan user (post_filter: tidak mempengaruhi aggregation)
.postFilter(pf -> kategoriFilter != null
? pf.term(t -> t.field("kategori").value(kategoriFilter))
: pf.matchAll(m -> m)
)
.aggregations("kategori_facet", a -> a
// Terms aggregation: hitung produk per kategori
.terms(t -> t
.field("kategori")
.size(20)
)
)
.aggregations("harga_range", a -> a
// Range aggregation: kelompokkan by rentang harga
.range(r -> r
.field("harga")
.ranges(
rng -> rng.to("500000").key("Di bawah 500rb"),
rng -> rng.from("500000").to("1000000").key("500rb - 1jt"),
rng -> rng.from("1000000").to("5000000").key("1jt - 5jt"),
rng -> rng.from("5000000").key("Di atas 5jt")
)
)
)
.aggregations("statistik_harga", a -> a
// Stats aggregation: min, max, avg, sum sekaligus
.stats(s -> s.field("harga"))
)
.aggregations("produk_per_bulan", a -> a
// Date histogram: tren waktu
.dateHistogram(dh -> dh
.field("dibuat_pada")
.calendarInterval(CalendarInterval.Month)
.format("yyyy-MM")
)
)
.size(20),
Produk.class
);
}
// Parsing hasil aggregation
public void parseFacetResult(SearchResponse<Produk> response) {
// Ambil bucket terms aggregation
StringTermsAggregate kategoriFacet = response.aggregations()
.get("kategori_facet")
.sterms();
System.out.println("Kategori tersedia:");
kategoriFacet.buckets().array().forEach(bucket ->
System.out.println(" " + bucket.key() + ": " + bucket.docCount() + " produk")
);
// Ambil stats aggregation
StatsAggregate statsHarga = response.aggregations()
.get("statistik_harga")
.stats();
System.out.println("Harga minimum: " + statsHarga.min());
System.out.println("Harga maksimum: " + statsHarga.max());
System.out.println("Harga rata-rata: " + statsHarga.avg());
}
}
Pagination dan Sorting #
Elasticsearch punya dua pendekatan pagination yang berbeda untuk kebutuhan yang berbeda.
From/Size — Pagination Biasa #
public SearchResponse<Produk> paginasiStandar(String keyword, int halaman, int perHalaman)
throws IOException {
// ANTI-PATTERN: dari pagination biasa untuk halaman sangat dalam
// from=10000 berarti ES harus mengumpulkan 10.000 dokumen dari semua shard
// lalu membuang 9.990 — memboroskan memori dan CPU
// BENAR: gunakan from/size hanya untuk halaman awal (max ~10.000 total)
int from = (halaman - 1) * perHalaman;
return client.search(req -> req
.index("produk")
.query(q -> q.match(m -> m.field("nama").query(keyword)))
.from(from)
.size(perHalaman)
.sort(s -> s.score(sc -> sc.order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)))
.sort(s -> s.field(f -> f // secondary sort: stabilitasi urutan
.field("_id")
.order(co.elastic.clients.elasticsearch._types.SortOrder.Asc)
)),
Produk.class
);
}
Search After — Deep Pagination Efisien #
import co.elastic.clients.elasticsearch._types.FieldValue;
import java.util.List;
public SearchResponse<Produk> deepPaginasi(List<FieldValue> searchAfter) throws IOException {
var requestBuilder = client.search(req -> {
var builder = req
.index("produk")
.query(q -> q.matchAll(m -> m))
.sort(s -> s.field(f -> f
.field("dibuat_pada")
.order(co.elastic.clients.elasticsearch._types.SortOrder.Desc)
))
.sort(s -> s.field(f -> f.field("_id")))
.size(20);
// Lanjutkan dari posisi terakhir menggunakan sort values
if (searchAfter != null && !searchAfter.isEmpty()) {
builder.searchAfter(searchAfter);
}
return builder;
}, Produk.class);
// Untuk request berikutnya, ambil sort values dari hit terakhir:
// List<FieldValue> nextCursor = response.hits().hits()
// .get(response.hits().hits().size() - 1)
// .sort();
return requestBuilder;
}
Integrasi dengan Spring Data Elasticsearch #
Spring Data Elasticsearch menyederhanakan integrasi dengan pola yang familiar — mirip Spring Data MongoDB atau Spring Data JPA.
Konfigurasi #
# application.yml
spring:
elasticsearch:
uris: http://localhost:9200
username: elastic
password: your-password
connection-timeout: 5s
socket-timeout: 30s
Model Document #
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 = "produk")
@Setting(settingPath = "elasticsearch/produk-settings.json") // custom analyzer
public class ProdukDocument {
@Id
private String id;
@Field(type = FieldType.Text, analyzer = "indonesian")
private String nama;
@Field(type = FieldType.Text, analyzer = "indonesian")
private String deskripsi;
@Field(type = FieldType.Keyword)
private String kategori;
@Field(type = FieldType.Long)
private long harga;
@Field(type = FieldType.Float)
private float rating;
@Field(type = FieldType.Boolean)
private boolean aktif;
@Field(type = FieldType.Keyword)
private List<String> tags;
@Field(type = FieldType.Date, format = {}, pattern = "yyyy-MM-dd'T'HH:mm:ss")
private LocalDateTime dibuatPada;
// getter, setter, 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 ProdukSearchRepository
extends ElasticsearchRepository<ProdukDocument, String> {
// Query otomatis dari nama method
List<ProdukDocument> findByKategori(String kategori);
Page<ProdukDocument> findByAktifTrue(Pageable pageable);
// Custom Elasticsearch query dengan @Query
@Query("""
{
"bool": {
"must": {
"multi_match": {
"query": "?0",
"fields": ["nama^3", "deskripsi"],
"fuzziness": "AUTO"
}
},
"filter": [
{ "term": { "aktif": true } },
{ "range": { "harga": { "gte": ?1, "lte": ?2 } } }
]
}
}
""")
Page<ProdukDocument> searchByKeywordAndHarga(
String keyword, long hargaMin, long hargaMax, Pageable pageable);
}
ElasticsearchOperations untuk Query Kompleks #
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 ProdukSearchService {
@Autowired
private ElasticsearchOperations operations;
// Query dengan Criteria API (type-safe, tidak perlu tulis JSON)
public SearchHits<ProdukDocument> searchDenganCriteria(
String keyword, String kategori) {
Criteria criteria = new Criteria("nama").matches(keyword)
.and(new Criteria("kategori").is(kategori))
.and(new Criteria("aktif").is(true));
CriteriaQuery query = new CriteriaQuery(criteria);
return operations.search(query, ProdukDocument.class);
}
// Native query untuk full control (gunakan Query DSL langsung)
public SearchHits<ProdukDocument> nativeSearch(String keyword) {
NativeQuery query = NativeQuery.builder()
.withQuery(q -> q
.bool(b -> b
.must(m -> m.multiMatch(mm -> mm
.fields("nama^3", "deskripsi")
.query(keyword)
.fuzziness("AUTO")
))
.filter(f -> f.term(t -> t.field("aktif").value(true)))
)
)
.withHighlightQuery(h -> h
.withHighlightFields(Map.of("nama", new HighlightField()))
)
.withPageable(PageRequest.of(0, 20))
.build();
return operations.search(query, ProdukDocument.class);
}
}
Kapan Menggunakan Elasticsearch #
Elasticsearch adalah alat yang powerful, tapi bukan solusi untuk semua masalah. Menambahkan Elasticsearch ke stack berarti menambah kompleksitas operasional.
Gunakan Elasticsearch jika:
✓ Butuh full-text search yang relevan dengan scoring
✓ Perlu toleransi typo (fuzzy search) dalam pencarian
✓ Butuh faceted navigation (filter sidebar e-commerce)
✓ Perlu analitik dan agregasi real-time dari data besar
✓ Log aggregation dan monitoring (ELK Stack)
✓ Pencarian multi-bahasa dengan analyzer khusus
Pertimbangkan alternatif jika:
✗ Hanya butuh LIKE query sederhana — PostgreSQL sudah cukup
✗ Data kurang dari 100.000 dokumen — overhead tidak sebanding
✗ Tidak ada tim yang bisa maintain cluster ES
✗ Butuh strong consistency — ES adalah eventually consistent
✗ Budget terbatas — ES butuh RAM yang cukup besar (min 4GB per node)
Elasticsearch adalah eventually consistent — setelah indexing, dokumen belum tentu langsung bisa dicari karena ada proses refresh (default setiap 1 detik). Jangan gunakan ES sebagai satu-satunya storage untuk data penting. Pola yang umum: simpan data utama di PostgreSQL/MongoDB, lalu sync ke Elasticsearch untuk kebutuhan search.
Ringkasan #
- Inverted index adalah fondasi kecepatan Elasticsearch — ia memetakan kata ke dokumen, bukan sebaliknya, sehingga full-text search tidak perlu scan seluruh data.
- Gunakan Java API Client (elasticsearch-java), bukan HLRC yang sudah deprecated. Client baru ini type-safe dan menggunakan lambda builder yang idiomatik.
- Bedakan
textdankeyword—textuntuk full-text search (dianalisis, tokenized),keyworduntuk exact match, sorting, dan aggregation. Field yang butuh keduanya bisa menggunakan multi-field.- Bool query adalah building block utama — kombinasikan
must(wajib match, skor dihitung),filter(wajib match, skor tidak dihitung, di-cache),should(bonus skor), danmust_not.- Gunakan
filterbukanmustuntuk kondisi non-textual (harga, status, tanggal) — filter lebih cepat karena hasilnya di-cache dan tidak menghitung relevance score.- Bulk API wajib untuk indexing massal — jangan memanggil
index()satu per satu dalam loop. Bulk API mengurangi overhead HTTP request drastis.- Aggregation memungkinkan faceted navigation, statistik real-time, dan tren waktu langsung dari search query — tanpa query terpisah.
- ES bukan pengganti database utama — gunakan sebagai layer search di atas database utama (PostgreSQL, MongoDB). Data di ES bisa di-rebuild ulang dari database utama jika diperlukan.
post_filteruntuk faceted search — gunakanpost_filter(bukanfilterdalam query) agar pilihan filter user tidak mempengaruhi hitungan aggregation facet.