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:#dcfce7Gunakan 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";
Full-Text Search #
<?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 — Faceted Search #
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 —
textuntuk full-text search dengan analisis,keyworduntuk exact match dan aggregation,integer/float/doubleuntuk angka,dateuntuk waktu.- Boolean query (
must,should,filter,must_not) adalah cara utama membangun query kompleks.filterlebih cepat darimustkarena tidak menghitung skor dan hasilnya bisa di-cache.- Fuzziness
AUTOmembuat pencarian toleran terhadap typo —laptpmasih bisa menemukanlaptop. 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 —
termsuntuk daftar kategori dengan jumlah,rangeuntuk filter harga,avg/min/maxuntuk statistik.- Edge n-gram analyzer memungkinkan autocomplete — token
laptbisa menemukanlaptopkarenalaptadalah prefix dari token yang sudah diindex._scoreadalah skor relevansi — semakin tinggi, semakin relevan. Boost field dengan^(misalnyanama^3) membuat kecocokan dinamatiga kali lebih berpengaruh dari kecocokan di field lain.