Elasticsearch

Elasticsearch #

Elasticsearch adalah search engine dan analytics engine terdistribusi yang dibangun di atas Apache Lucene — dirancang khusus untuk pencarian teks penuh yang cepat, relevan, dan berskala besar. Berbeda dari database biasa yang bisa melakukan LIKE query, Elasticsearch memahami bahasa secara lebih dalam: ia menganalisis teks (tokenisasi, stemming, stop word removal), menghitung skor relevansi, mendukung pencarian fuzzy untuk typo, autocomplete, highlight hasil pencarian, dan agregasi analitik secara real-time. Elasticsearch bukan pengganti database utama — ia adalah lapisan pencarian yang bekerja berdampingan dengan database relasional atau MongoDB, menyimpan salinan data yang dioptimalkan untuk pencarian. Artikel ini membahas cara menggunakan Elasticsearch dari PHP menggunakan library resmi elastic/elasticsearch.

Kapan Menggunakan Elasticsearch #

flowchart LR
    DB[(Database Utama\nMySQL/PostgreSQL/MongoDB)] -- "sync data" --> ES[(Elasticsearch\nSearch Index)]
    App[Aplikasi PHP] -- "CRUD data" --> DB
    App -- "query pencarian" --> ES
    ES -- "hasil relevan" --> App

    style ES fill:#fef9c3
    style DB fill:#dcfce7
Gunakan Elasticsearch untuk:
  ✓ Full-text search dengan relevansi (bukan sekadar LIKE '%kata%')
  ✓ Autocomplete dan suggest-as-you-type
  ✓ Pencarian fuzzy — toleran terhadap typo
  ✓ Faceted search — filter berdasarkan kategori, harga, rating
  ✓ Log analytics dan monitoring (ELK Stack)
  ✓ Dashboard analytics real-time
  ✓ Pencarian produk e-commerce

Jangan gantikan database utama dengan Elasticsearch:
  ✗ Elasticsearch bukan sumber kebenaran data — sinkronisasi dari database utama
  ✗ Transaksi ACID tidak tersedia
  ✗ Relasi kompleks tidak didukung natively

Instalasi #

# Install library PHP resmi
composer require elastic/elasticsearch

# Pastikan Elasticsearch berjalan
curl http://localhost:9200
# {
#   "name": "node-1",
#   "cluster_name": "elasticsearch",
#   "version": { "number": "8.x.x" }
# }

Koneksi #

<?php
require 'vendor/autoload.php';

use Elastic\Elasticsearch\ClientBuilder;
use Elastic\Elasticsearch\Exception\ClientResponseException;
use Elastic\Elasticsearch\Exception\ServerResponseException;

// Koneksi lokal
$client = ClientBuilder::create()
    ->setHosts(['http://localhost:9200'])
    ->build();

// Dengan autentikasi HTTP Basic
$client = ClientBuilder::create()
    ->setHosts(['https://localhost:9200'])
    ->setBasicAuthentication('elastic', 'changeme')
    ->setSSLVerification(false) // hanya untuk development
    ->build();

// Elastic Cloud
$client = ClientBuilder::create()
    ->setElasticCloudId('my-deployment:dXMtZWFzdC0x...')
    ->setApiKey('api-key-id', 'api-key-value')
    ->build();

// Verifikasi koneksi
$info = $client->info();
echo "Elasticsearch " . $info['version']['number'] . " terhubung\n";

Index — Mapping dan Settings #

Di Elasticsearch, “index” adalah setara dengan “database” atau “tabel” di SQL — tempat dokumen disimpan. Mapping mendefinisikan tipe setiap field:

<?php
// Buat index dengan mapping
$params = [
    'index' => 'produk',
    'body'  => [
        'settings' => [
            'number_of_shards'   => 1,
            'number_of_replicas' => 1,
            'analysis' => [
                'analyzer' => [
                    'indonesian_analyzer' => [
                        'type'      => 'custom',
                        'tokenizer' => 'standard',
                        'filter'    => ['lowercase', 'indonesian_stop'],
                    ],
                    'autocomplete_analyzer' => [
                        'type'      => 'custom',
                        'tokenizer' => 'standard',
                        'filter'    => ['lowercase', 'edge_ngram_filter'],
                    ],
                    'autocomplete_search' => [
                        'type'      => 'custom',
                        'tokenizer' => 'standard',
                        'filter'    => ['lowercase'],
                    ],
                ],
                'filter' => [
                    'indonesian_stop' => [
                        'type'      => 'stop',
                        'stopwords' => ['dan', 'atau', 'untuk', 'yang', 'di', 'ke', 'dari', 'ini', 'itu'],
                    ],
                    'edge_ngram_filter' => [
                        'type'     => 'edge_ngram',
                        'min_gram' => 2,
                        'max_gram' => 20,
                    ],
                ],
            ],
        ],
        'mappings' => [
            'properties' => [
                'id'         => ['type' => 'integer'],
                'nama'       => [
                    'type'     => 'text',
                    'analyzer' => 'indonesian_analyzer',
                    'fields'   => [
                        'autocomplete' => [
                            'type'            => 'text',
                            'analyzer'        => 'autocomplete_analyzer',
                            'search_analyzer' => 'autocomplete_search',
                        ],
                        'keyword' => ['type' => 'keyword'], // untuk exact match dan aggregation
                    ],
                ],
                'deskripsi'  => ['type' => 'text', 'analyzer' => 'indonesian_analyzer'],
                'harga'      => ['type' => 'double'],
                'stok'       => ['type' => 'integer'],
                'kategori'   => ['type' => 'keyword'],             // exact match, aggregation
                'tags'       => ['type' => 'keyword'],             // array of keywords
                'rating'     => ['type' => 'float'],
                'aktif'      => ['type' => 'boolean'],
                'created_at' => ['type' => 'date', 'format' => 'yyyy-MM-dd HH:mm:ss||epoch_millis'],
                'lokasi'     => ['type' => 'geo_point'],           // koordinat lat/lon
            ],
        ],
    ],
];

try {
    $response = $client->indices()->create($params);
    echo "Index 'produk' dibuat\n";
} catch (ClientResponseException $e) {
    if ($e->getCode() === 400) {
        echo "Index sudah ada\n";
    } else {
        throw $e;
    }
}

// Hapus index (hati-hati!)
// $client->indices()->delete(['index' => 'produk']);

CRUD Dokumen #

<?php
// Index (simpan) satu dokumen
$client->index([
    'index' => 'produk',
    'id'    => '1',              // opsional — Elasticsearch generate otomatis jika tidak ada
    'body'  => [
        'id'         => 1,
        'nama'       => 'Laptop Pro 14 Gen3',
        'deskripsi'  => 'Laptop premium dengan prosesor terkini, cocok untuk programmer dan desainer.',
        'harga'      => 15000000,
        'stok'       => 5,
        'kategori'   => 'elektronik',
        'tags'       => ['laptop', 'komputer', 'gaming', 'kerja'],
        'rating'     => 4.8,
        'aktif'      => true,
        'created_at' => date('Y-m-d H:i:s'),
    ],
]);

// Get dokumen by ID
$response = $client->get(['index' => 'produk', 'id' => '1']);
$dokumen  = $response['_source'];
echo $dokumen['nama'] . ": Rp " . number_format($dokumen['harga']) . "\n";

// Update dokumen — partial update
$client->update([
    'index' => 'produk',
    'id'    => '1',
    'body'  => [
        'doc' => [
            'harga'      => 14500000,
            'updated_at' => date('Y-m-d H:i:s'),
        ],
    ],
]);

// Delete dokumen
$client->delete(['index' => 'produk', 'id' => '1']);

// Cek apakah dokumen ada
$exists = $client->exists(['index' => 'produk', 'id' => '999']);
var_dump($exists->asBool()); // bool(false)

Bulk Indexing #

Untuk menyinkronkan banyak dokumen dari database ke Elasticsearch, gunakan bulk API — jauh lebih cepat dari index satu per satu:

<?php
function bulkIndex(
    \Elastic\Elasticsearch\Client $client,
    string $indexName,
    array $dokumen,
    int $ukuranBatch = 500,
): void {
    $batches = array_chunk($dokumen, $ukuranBatch);

    foreach ($batches as $noBatch => $batch) {
        $params = ['body' => []];

        foreach ($batch as $doc) {
            // Setiap dokumen butuh dua entry: action + dokumen itu sendiri
            $params['body'][] = [
                'index' => [
                    '_index' => $indexName,
                    '_id'    => $doc['id'], // pakai ID database sebagai ID Elasticsearch
                ],
            ];
            $params['body'][] = $doc;
        }

        $response = $client->bulk($params);

        // Cek error per item
        if ($response['errors']) {
            foreach ($response['items'] as $item) {
                if (isset($item['index']['error'])) {
                    error_log("Bulk error ID {$item['index']['_id']}: " .
                              json_encode($item['index']['error']));
                }
            }
        }

        echo "Batch $noBatch: " . count($batch) . " dokumen diindex\n";

        // Hindari overload Elasticsearch
        usleep(10_000); // 10ms antar batch
    }
}

// Sinkronisasi dari database
$stmt = $pdo->query("SELECT id, nama, deskripsi, harga, stok, kategori FROM produk WHERE aktif = 1");
$produk = $stmt->fetchAll();

bulkIndex($client, 'produk', $produk, ukuranBatch: 1000);
echo "Sinkronisasi selesai: " . count($produk) . " produk\n";

<?php
// Multi-match — cari di beberapa field sekaligus
$response = $client->search([
    'index' => 'produk',
    'body'  => [
        'query' => [
            'multi_match' => [
                'query'  => 'laptop gaming murah',
                'fields' => ['nama^3', 'deskripsi', 'tags^2'], // ^ = boost (bobot)
                'type'   => 'best_fields', // atau most_fields, cross_fields
                'fuzziness' => 'AUTO',     // toleran typo: lapop → laptop
            ],
        ],
        'highlight' => [
            'fields' => [
                'nama'      => ['number_of_fragments' => 0],
                'deskripsi' => ['fragment_size' => 150, 'number_of_fragments' => 2],
            ],
        ],
        'from' => 0,
        'size' => 10,
        '_source' => ['id', 'nama', 'harga', 'rating', 'kategori'],
    ],
]);

$hits  = $response['hits'];
echo "Total: " . $hits['total']['value'] . " hasil\n";

foreach ($hits['hits'] as $hit) {
    $doc       = $hit['_source'];
    $highlight = $hit['highlight'] ?? [];

    echo "\n[{$hit['_score']}] {$doc['nama']}\n";
    echo "Harga: Rp " . number_format($doc['harga']) . "\n";

    // Tampilkan highlight — kata yang cocok dibungkus <em>
    if (isset($highlight['nama'])) {
        echo "Nama: " . implode(' ... ', $highlight['nama']) . "\n";
    }
    if (isset($highlight['deskripsi'])) {
        echo "Deskripsi: " . implode(' ... ', $highlight['deskripsi']) . "\n";
    }
}

Filter — Pencarian dengan Kondisi Eksak #

<?php
// Bool query — kombinasi must, should, filter, must_not
$response = $client->search([
    'index' => 'produk',
    'body'  => [
        'query' => [
            'bool' => [
                // must — harus cocok, mempengaruhi skor relevansi
                'must' => [
                    ['multi_match' => [
                        'query'     => 'laptop',
                        'fields'    => ['nama^2', 'deskripsi'],
                        'fuzziness' => 'AUTO',
                    ]],
                ],
                // filter — harus cocok tapi TIDAK mempengaruhi skor (lebih cepat, bisa di-cache)
                'filter' => [
                    ['term'  => ['aktif' => true]],
                    ['term'  => ['kategori' => 'elektronik']],
                    ['range' => ['harga' => ['gte' => 5000000, 'lte' => 20000000]]],
                    ['range' => ['stok'  => ['gt' => 0]]],
                ],
                // should — opsional, tapi meningkatkan skor jika cocok
                'should' => [
                    ['range' => ['rating' => ['gte' => 4.0]]],
                ],
                // must_not — tidak boleh cocok
                'must_not' => [
                    ['term' => ['tags' => 'refurbished']],
                ],
            ],
        ],
        'sort' => [
            '_score',                              // relevansi dulu
            ['rating' => ['order' => 'desc']],     // lalu rating
            ['harga'  => ['order' => 'asc']],      // lalu harga termurah
        ],
        'from' => 0,
        'size' => 12,
    ],
]);

Autocomplete #

<?php
// Autocomplete menggunakan edge n-gram analyzer (sudah dikonfigurasi di mapping)
function autocomplete(
    \Elastic\Elasticsearch\Client $client,
    string $keyword,
    int $limit = 5,
): array {
    $response = $client->search([
        'index' => 'produk',
        'body'  => [
            'query' => [
                'bool' => [
                    'must' => [
                        ['match' => [
                            'nama.autocomplete' => [
                                'query'    => $keyword,
                                'operator' => 'and',
                            ],
                        ]],
                    ],
                    'filter' => [['term' => ['aktif' => true]]],
                ],
            ],
            '_source' => ['id', 'nama', 'harga', 'kategori'],
            'size'    => $limit,
        ],
    ]);

    return array_map(
        fn($hit) => $hit['_source'],
        $response['hits']['hits']
    );
}

// Panggil saat user mengetik
$saran = autocomplete($client, 'lapt'); // 'lapt' → ['Laptop Pro 14', 'Laptop Gaming X', ...]
foreach ($saran as $produk) {
    echo "{$produk['nama']} — Rp " . number_format($produk['harga']) . "\n";
}

Aggregation memungkinkan membangun filter sidebar (facets) seperti di toko online:

<?php
$response = $client->search([
    'index' => 'produk',
    'body'  => [
        'query' => ['match' => ['nama' => 'laptop']],
        'size'  => 12, // hasil pencarian

        // Aggregation — untuk filter sidebar
        'aggs' => [
            'kategori' => [
                'terms' => ['field' => 'kategori', 'size' => 20],
            ],
            'rentang_harga' => [
                'range' => [
                    'field'  => 'harga',
                    'ranges' => [
                        ['key' => 'Di bawah 5 Juta',  'to'   => 5000000],
                        ['key' => '5 - 10 Juta',      'from' => 5000000,  'to' => 10000000],
                        ['key' => '10 - 20 Juta',     'from' => 10000000, 'to' => 20000000],
                        ['key' => 'Di atas 20 Juta',  'from' => 20000000],
                    ],
                ],
            ],
            'rata_rata_rating' => [
                'avg' => ['field' => 'rating'],
            ],
            'histogram_harga' => [
                'histogram' => ['field' => 'harga', 'interval' => 1000000],
            ],
        ],
    ],
]);

// Ambil hasil pencarian
$produk = array_map(fn($h) => $h['_source'], $response['hits']['hits']);

// Ambil facets
$facets = $response['aggregations'];

echo "Kategori:\n";
foreach ($facets['kategori']['buckets'] as $bucket) {
    echo "  {$bucket['key']}: {$bucket['doc_count']} produk\n";
}

echo "\nRentang Harga:\n";
foreach ($facets['rentang_harga']['buckets'] as $bucket) {
    echo "  {$bucket['key']}: {$bucket['doc_count']} produk\n";
}

echo "\nRata-rata rating: " . round($facets['rata_rata_rating']['value'], 1) . "\n";

Sinkronisasi dengan Database #

Elasticsearch bukan sumber data utama — ia perlu disinkronisasi dengan database:

<?php
class ProdukSearchService
{
    public function __construct(
        private \Elastic\Elasticsearch\Client $es,
        private \PDO $db,
    ) {}

    // Sinkronisasi satu produk (panggil setelah CREATE/UPDATE di database)
    public function sinkronisasi(int $produkId): void
    {
        $stmt = $this->db->prepare("
            SELECT id, nama, deskripsi, harga, stok, kategori, rating, aktif, created_at
            FROM produk WHERE id = :id
        ");
        $stmt->execute([':id' => $produkId]);
        $produk = $stmt->fetch();

        if ($produk === false) {
            // Produk dihapus dari DB — hapus dari ES juga
            try {
                $this->es->delete(['index' => 'produk', 'id' => (string) $produkId]);
            } catch (\Exception $e) {
                // Mungkin tidak ada di ES, abaikan
            }
            return;
        }

        $this->es->index([
            'index' => 'produk',
            'id'    => (string) $produk['id'],
            'body'  => $produk,
        ]);
    }

    public function cari(string $keyword, array $filter = [], int $halaman = 1, int $perHalaman = 12): array
    {
        $offset   = ($halaman - 1) * $perHalaman;
        $esFilter = [['term' => ['aktif' => true]]];

        if (!empty($filter['kategori'])) {
            $esFilter[] = ['term' => ['kategori' => $filter['kategori']]];
        }
        if (!empty($filter['harga_min'])) {
            $esFilter[] = ['range' => ['harga' => ['gte' => $filter['harga_min']]]];
        }
        if (!empty($filter['harga_max'])) {
            $esFilter[] = ['range' => ['harga' => ['lte' => $filter['harga_max']]]];
        }

        $response = $this->es->search([
            'index' => 'produk',
            'body'  => [
                'query' => [
                    'bool' => [
                        'must'   => [['multi_match' => ['query' => $keyword, 'fields' => ['nama^3', 'deskripsi'], 'fuzziness' => 'AUTO']]],
                        'filter' => $esFilter,
                    ],
                ],
                'from' => $offset,
                'size' => $perHalaman,
            ],
        ]);

        return [
            'total'  => $response['hits']['total']['value'],
            'data'   => array_map(fn($h) => $h['_source'], $response['hits']['hits']),
            'halaman'=> $halaman,
        ];
    }
}

Ringkasan #

  • Elasticsearch bukan pengganti database — ia adalah lapisan pencarian yang bekerja berdampingan dengan database utama. Data disimpan di database, disinkronisasi ke Elasticsearch untuk pencarian.
  • Mapping yang tepat adalah kunci performa — text untuk full-text search dengan analisis, keyword untuk exact match dan aggregation, integer/float/double untuk angka, date untuk waktu.
  • Boolean query (must, should, filter, must_not) adalah cara utama membangun query kompleks. filter lebih cepat dari must karena tidak menghitung skor dan hasilnya bisa di-cache.
  • Fuzziness AUTO membuat pencarian toleran terhadap typo — laptp masih bisa menemukan laptop. Skor relevansi memastikan hasil yang paling tepat muncul pertama.
  • Bulk API untuk sinkronisasi massal — 500-1000 dokumen per batch adalah ukuran yang umum. Index satu per satu untuk volume besar sangat lambat.
  • Aggregation untuk faceted search — terms untuk daftar kategori dengan jumlah, range untuk filter harga, avg/min/max untuk statistik.
  • Edge n-gram analyzer memungkinkan autocomplete — token lapt bisa menemukan laptop karena lapt adalah prefix dari token yang sudah diindex.
  • _score adalah skor relevansi — semakin tinggi, semakin relevan. Boost field dengan ^ (misalnya nama^3) membuat kecocokan di nama tiga kali lebih berpengaruh dari kecocokan di field lain.

← Sebelumnya: MongoDB   Berikutnya: Redis →

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