Elasticsearch

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 #

KonsepElasticsearchKeterangan
DatabaseIndexWadah untuk kumpulan dokumen bertipe sama
Table(tidak ada)ES tidak membedakan tabel dalam satu index
RowDocumentSatu entri data dalam format JSON
ColumnFieldAtribut dalam sebuah dokumen
SchemaMappingDefinisi tipe data setiap field
SQL QueryQuery DSLJSON-based query language
GROUP BYAggregationMengelompokkan 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 --> T6

Saat 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 --> D3

Satu 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 gunakan elasticsearch-rest-high-level-client (HLRC) untuk proyek baru. HLRC sudah deprecated sejak Elasticsearch 7.15 dan dihapus di versi 8.x. Gunakan elasticsearch-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 #

TipeKegunaanContoh Nilai
textFull-text search, dianalisis (tokenized)"Laptop Gaming Terbaik"
keywordExact match, sorting, aggregation"gaming", "aktif"
integer, longAngka bulat15000000
double, floatAngka desimal4.5
booleanTrue/falsetrue
dateTanggal dan waktu"2024-01-15T08:00:00Z"
nestedArray of objects dengan relasi terjagaList review produk
geo_pointKoordinat 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());
    }
}
Field nama menggunakan tipe text dengan sub-field keyword. 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 highlight

Highlight — 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 text dan keywordtext untuk full-text search (dianalisis, tokenized), keyword untuk 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), dan must_not.
  • Gunakan filter bukan must untuk 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_filter untuk faceted search — gunakan post_filter (bukan filter dalam query) agar pilihan filter user tidak mempengaruhi hitungan aggregation facet.

← Sebelumnya: MongoDB   Berikutnya: Kafka →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact