Elasticsearch #
Elasticsearch adalah search engine berbasis Lucene yang dirancang untuk pencarian teks penuh yang cepat, relevan, dan dapat diskalakan secara horizontal. Jika kamu pernah mencari produk di toko online dan mendapatkan hasil yang relevan meski kata kuncinya tidak persis sama, kemungkinan besar ada Elasticsearch di baliknya. Di Ruby, ada tiga cara utama berinteraksi dengan Elasticsearch: gem elasticsearch-ruby sebagai client tingkat rendah yang mengikuti API Elasticsearch 1:1, gem searchkick yang menyederhanakan integrasi dengan Rails ActiveRecord, dan elasticsearch-model yang menyediakan integrasi model Rails dengan lebih banyak kontrol. Artikel ini membahas semua lapisan ini dari konsep dasar hingga pola pencarian yang digunakan di aplikasi produksi.
Konsep Dasar Elasticsearch #
Sebelum menulis kode, penting memahami terminologi Elasticsearch dan bagaimana ia berbeda dari database relasional:
Database Relasional Elasticsearch
────────────────────────────────────────
Database → Index
Tabel → (dihapus di ES 7+, dulu "type")
Baris → Document
Kolom → Field
Schema → Mapping
Primary Key → _id (auto-generate atau kustom)
SELECT → Search Query
GROUP BY → Aggregation
flowchart LR
A[Aplikasi Ruby] --> B[elasticsearch-ruby Client]
B --> C[Elasticsearch Node]
C --> D[Index: produk]
D --> E["Shard 1\n(dokumen 1-500)"]
D --> F["Shard 2\n(dokumen 501-1000)"]
D --> G["Shard Replica\n(backup)"]Instalasi dan Koneksi #
# Install gem
gem install elasticsearch
# Atau di Gemfile
# Gemfile
gem 'elasticsearch', '~> 8.0' # pastikan versi sesuai Elasticsearch server
# Untuk Rails dengan searchkick
gem 'searchkick', '~> 5.3'
require 'elasticsearch'
# Koneksi ke Elasticsearch lokal
client = Elasticsearch::Client.new(
host: "http://localhost:9200",
log: false # set true untuk debug query
)
# Koneksi dengan autentikasi (Elasticsearch 8+ dengan security enabled)
client = Elasticsearch::Client.new(
host: "https://localhost:9200",
user: "elastic",
password: ENV["ES_PASSWORD"],
ca_fingerprint: ENV["ES_CA_FINGERPRINT"] # fingerprint certificate
)
# Koneksi ke Elastic Cloud
client = Elasticsearch::Client.new(
cloud_id: ENV["ELASTIC_CLOUD_ID"],
api_key: ENV["ELASTIC_API_KEY"]
)
# Koneksi dengan multiple nodes (untuk high availability)
client = Elasticsearch::Client.new(
hosts: [
{ host: "es-node-1", port: 9200 },
{ host: "es-node-2", port: 9200 },
{ host: "es-node-3", port: 9200 }
],
retry_on_failure: 3,
reload_connections: true
)
# Cek koneksi
puts client.info.dig("version", "number")
puts client.ping ? "Terhubung!" : "Gagal!"
Manajemen Index #
Membuat Index dengan Mapping #
Mapping adalah schema Elasticsearch — mendefinisikan tipe setiap field:
# Buat index dengan mapping
client.indices.create(
index: "produk",
body: {
settings: {
number_of_shards: 1,
number_of_replicas: 0, # 0 untuk development, 1+ untuk production
analysis: {
analyzer: {
# Analyzer kustom untuk bahasa Indonesia
analyzer_indonesia: {
type: "custom",
tokenizer: "standard",
filter: ["lowercase", "stop", "asciifolding"]
}
}
}
},
mappings: {
properties: {
nama: {
type: "text",
analyzer: "analyzer_indonesia",
fields: {
keyword: { type: "keyword" } # untuk exact match dan sort
}
},
deskripsi: {
type: "text",
analyzer: "analyzer_indonesia"
},
harga: { type: "double" },
stok: { type: "integer" },
aktif: { type: "boolean" },
kategori: { type: "keyword" }, # keyword = exact match, tidak di-analyze
tag: { type: "keyword" }, # array keyword
created_at: { type: "date" },
lokasi: {
type: "geo_point" # untuk pencarian geospatial
},
spesifikasi: {
type: "object", # nested object
properties: {
prosesor: { type: "keyword" },
ram: { type: "keyword" },
storage: { type: "keyword" }
}
}
}
}
}
)
# Cek apakah index ada
puts client.indices.exists?(index: "produk")
# Hapus index
client.indices.delete(index: "produk")
# Alias — memungkinkan zero-downtime reindexing
client.indices.put_alias(index: "produk_v2", name: "produk")
CRUD Dokumen #
# INDEX (insert/replace) satu dokumen
client.index(
index: "produk",
id: "prod-001", # opsional — ES akan generate jika tidak ada
body: {
nama: "Laptop Gaming Pro 15",
deskripsi: "Laptop bertenaga tinggi untuk gaming dan kreator konten",
harga: 22_000_000,
stok: 10,
aktif: true,
kategori: "Elektronik",
tag: ["laptop", "gaming", "high-performance"],
spesifikasi: {
prosesor: "Intel Core i9-13900H",
ram: "32GB DDR5",
storage: "1TB NVMe SSD"
},
created_at: Time.now.iso8601
}
)
# GET satu dokumen berdasarkan ID
dok = client.get(index: "produk", id: "prod-001")
puts dok["_source"]["nama"] # => "Laptop Gaming Pro 15"
puts dok["_id"] # => "prod-001"
# Cek apakah dokumen ada
puts client.exists?(index: "produk", id: "prod-001")
# UPDATE — partial update (hanya field yang disebutkan)
client.update(
index: "produk",
id: "prod-001",
body: {
doc: {
harga: 21_500_000,
stok: 9,
updated_at: Time.now.iso8601
}
}
)
# UPDATE dengan script (untuk operasi seperti increment)
client.update(
index: "produk",
id: "prod-001",
body: {
script: {
source: "ctx._source.stok -= params.jumlah",
params: { jumlah: 1 }
}
}
)
# DELETE
client.delete(index: "produk", id: "prod-001")
# INDEX banyak dokumen sekaligus dengan Bulk API
# (jauh lebih efisien dari insert satu per satu)
body = []
produk_list.each do |p|
body << { index: { _index: "produk", _id: p[:id] } }
body << {
nama: p[:nama],
harga: p[:harga],
stok: p[:stok],
kategori: p[:kategori],
aktif: p[:aktif],
created_at: Time.now.iso8601
}
end
response = client.bulk(body: body)
puts "Errors: #{response['errors']}"
puts "Items diproses: #{response['items'].length}"
Pencarian — Query DSL #
Elasticsearch Query DSL adalah cara mengekspresikan query sebagai Hash Ruby yang dikonversi ke JSON:
match — Full-Text Search #
# match — analyze query dan dokumen, cocok untuk full-text search
response = client.search(
index: "produk",
body: {
query: {
match: {
nama: {
query: "laptop gaming",
operator: "and" # semua kata harus ada (default: "or")
}
}
}
}
)
# Iterasi hasil
hits = response["hits"]["hits"]
hits.each do |hit|
puts "#{hit['_score'].round(2)}: #{hit['_source']['nama']}"
end
puts "Total: #{response.dig('hits', 'total', 'value')} dokumen"
# multi_match — cari di banyak field sekaligus
response = client.search(
index: "produk",
body: {
query: {
multi_match: {
query: "laptop gaming murah",
fields: ["nama^3", "deskripsi", "tag"], # ^3 = nama 3x lebih penting
type: "best_fields"
}
}
}
)
term dan terms — Exact Match #
# term — exact match, tidak di-analyze (untuk keyword field)
response = client.search(
index: "produk",
body: {
query: {
term: { kategori: "Elektronik" }
}
}
)
# terms — cocok dengan salah satu dari array nilai
response = client.search(
index: "produk",
body: {
query: {
terms: { kategori: ["Elektronik", "Aksesori", "Monitor"] }
}
}
)
bool — Gabungan Query #
bool adalah query yang paling sering digunakan di production karena memungkinkan kombinasi kondisi:
# bool query — kombinasikan must, should, must_not, filter
response = client.search(
index: "produk",
body: {
query: {
bool: {
# must — WAJIB cocok, mempengaruhi skor relevance
must: [
{ match: { nama: "laptop" } }
],
# filter — WAJIB cocok, TIDAK mempengaruhi skor (lebih cepat, di-cache)
filter: [
{ term: { aktif: true } },
{ range: { harga: { gte: 5_000_000, lte: 25_000_000 } } },
{ term: { "tag" => "gaming" } }
],
# should — BOLEH cocok, meningkatkan skor jika cocok
should: [
{ term: { kategori: "Elektronik" } },
{ match: { deskripsi: "performa tinggi" } }
],
minimum_should_match: 1, # minimal 1 should harus cocok
# must_not — TIDAK BOLEH cocok
must_not: [
{ term: { stok: 0 } }
]
}
}
}
)
range, wildcard, prefix, fuzzy #
# range — pencarian rentang nilai atau tanggal
client.search(
index: "produk",
body: {
query: {
range: {
harga: { gte: 1_000_000, lte: 10_000_000 }
}
}
}
)
# Range tanggal
client.search(
index: "produk",
body: {
query: {
range: {
created_at: {
gte: "2024-01-01",
lte: "2024-12-31",
format: "yyyy-MM-dd"
}
}
}
}
)
# wildcard — cocokkan dengan pola (* = banyak karakter, ? = satu karakter)
client.search(
index: "produk",
body: { query: { wildcard: { "nama.keyword" => "Laptop*Pro*" } } }
)
# prefix — cocokkan awalan
client.search(
index: "produk",
body: { query: { prefix: { "nama.keyword" => "Laptop" } } }
)
# fuzzy — toleran terhadap typo (edit distance)
client.search(
index: "produk",
body: {
query: {
fuzzy: {
nama: { value: "laptoop", fuzziness: "AUTO" }
}
}
}
)
Sorting, Pagination, dan Source Filtering #
# Sort
client.search(
index: "produk",
body: {
query: { term: { aktif: true } },
sort: [
{ harga: { order: "asc" } }, # harga termurah dulu
{ "nama.keyword": { order: "asc" } } # lalu nama A-Z
]
}
)
# Pagination dengan from/size (max from: 10.000)
halaman = 2
per_halaman = 20
client.search(
index: "produk",
body: {
query: { match_all: {} },
from: (halaman - 1) * per_halaman,
size: per_halaman
}
)
# search_after — untuk deep pagination (> 10.000 dokumen)
# Pertama: ambil halaman pertama dengan sort + pit (Point in Time)
pit = client.open_point_in_time(index: "produk", keep_alive: "1m")
pit_id = pit["id"]
response = client.search(
body: {
size: 20,
query: { match_all: {} },
sort: [{ harga: "asc" }, { _id: "asc" }],
pit: { id: pit_id, keep_alive: "1m" }
}
)
# Selanjutnya: gunakan sort values dari hit terakhir
last_hit = response["hits"]["hits"].last
sort_values = last_hit["sort"]
response_2 = client.search(
body: {
size: 20,
query: { match_all: {} },
sort: [{ harga: "asc" }, { _id: "asc" }],
pit: { id: pit_id, keep_alive: "1m" },
search_after: sort_values
}
)
# Tutup PIT setelah selesai
client.close_point_in_time(body: { id: pit_id })
# Source filtering — ambil hanya field tertentu
client.search(
index: "produk",
body: {
query: { match_all: {} },
_source: ["nama", "harga", "stok"] # hanya ambil 3 field
}
)
# Atau exclude field tertentu
client.search(
index: "produk",
body: {
query: { match_all: {} },
_source: { excludes: ["deskripsi", "spesifikasi"] }
}
)
Highlight — Sorot Teks yang Cocok #
response = client.search(
index: "produk",
body: {
query: { match: { deskripsi: "gaming performa tinggi" } },
highlight: {
fields: {
nama: { number_of_fragments: 0 }, # sorot seluruh field
deskripsi: {
number_of_fragments: 3,
fragment_size: 150,
pre_tags: ["<mark>"],
post_tags: ["</mark>"]
}
}
}
}
)
response["hits"]["hits"].each do |hit|
puts hit["_source"]["nama"]
puts hit.dig("highlight", "deskripsi")&.join("... ")
end
# Output: "Laptop untuk <mark>gaming</mark> dengan <mark>performa tinggi</mark>"
Aggregations — Analisis Data #
Aggregations adalah cara Elasticsearch untuk menghitung statistik dan membangun faceted search:
response = client.search(
index: "produk",
body: {
query: { term: { aktif: true } },
size: 0, # 0 = hanya aggregasi, tidak butuh dokumen
aggs: {
# Hitung dokumen per kategori
per_kategori: {
terms: { field: "kategori", size: 20 }
},
# Statistik harga
statistik_harga: {
stats: { field: "harga" }
# Menghasilkan: count, min, max, avg, sum
},
# Range harga untuk faceted filter
range_harga: {
range: {
field: "harga",
ranges: [
{ to: 1_000_000, key: "Di bawah 1 Juta" },
{ from: 1_000_000, to: 5_000_000, key: "1-5 Juta" },
{ from: 5_000_000, to: 15_000_000, key: "5-15 Juta" },
{ from: 15_000_000, key: "Di atas 15 Juta" }
]
}
},
# Histogram harga per 1 juta
histogram_harga: {
histogram: {
field: "harga",
interval: 1_000_000,
min_doc_count: 1
}
},
# Top tag
top_tag: {
terms: { field: "tag", size: 10 }
},
# Nested aggregation — per kategori hitung rata-rata harga
per_kategori_dengan_rata_harga: {
terms: { field: "kategori", size: 20 },
aggs: {
rata_harga: { avg: { field: "harga" } },
stok_total: { sum: { field: "stok" } }
}
}
}
}
)
# Akses hasil aggregasi
puts "Jumlah produk per kategori:"
response.dig("aggregations", "per_kategori", "buckets").each do |bucket|
puts " #{bucket['key']}: #{bucket['doc_count']} produk"
end
stats = response.dig("aggregations", "statistik_harga")
puts "Harga: min=#{stats['min']}, max=#{stats['max']}, avg=#{stats['avg'].round}"
Suggest — Autocomplete dan Koreksi Typo #
# Completion suggester — autocomplete cepat
# Pertama, definisikan field suggest di mapping
client.indices.put_mapping(
index: "produk",
body: {
properties: {
nama_suggest: {
type: "completion",
analyzer: "standard"
}
}
}
)
# Index dokumen dengan field suggest
client.index(
index: "produk",
id: "prod-001",
body: {
nama: "Laptop Gaming Pro",
nama_suggest: {
input: ["Laptop Gaming Pro", "Laptop Gaming", "Laptop"],
weight: 10 # bobot relevansi
}
}
)
# Query autocomplete
response = client.search(
index: "produk",
body: {
suggest: {
autocomplete_produk: {
prefix: "lap", # teks yang diketik user
completion: {
field: "nama_suggest",
size: 5, # maksimal 5 saran
skip_duplicates: true
}
}
}
}
)
saran = response.dig("suggest", "autocomplete_produk", 0, "options")
saran.each { |s| puts s["text"] }
# => "Laptop", "Laptop Gaming", "Laptop Gaming Pro"
# Term suggester — koreksi typo ("laptoop" → "laptop")
response = client.search(
index: "produk",
body: {
suggest: {
koreksi_kata: {
text: "laptoop gamng",
term: {
field: "nama",
suggest_mode: "missing" # hanya suggest jika kata tidak ada di index
}
}
}
}
)
Searchkick — Integrasi Mudah dengan Rails #
searchkick menyederhanakan integrasi Elasticsearch dengan model ActiveRecord:
# Gemfile
# gem 'searchkick', '~> 5.3'
# app/models/produk.rb
class Produk < ApplicationRecord
searchkick(
word_start: [:nama], # autocomplete per kata
text_start: [:nama], # autocomplete dari awal
language: "indonesian", # analyzer bahasa
index_name: "produk_#{Rails.env}",
settings: {
number_of_shards: 1,
number_of_replicas: Rails.env.production? ? 1 : 0
}
)
scope :search_import, -> { includes(:kategori).where(aktif: true) }
# Kustomisasi data yang diindeks
def search_data
{
nama: nama,
deskripsi: deskripsi,
harga: harga,
stok: stok,
aktif: aktif,
kategori: kategori&.nama,
tag: tag,
created_at: created_at
}
end
end
# Indexing
Produk.reindex # index ulang semua produk
Produk.reindex(async: true) # secara asinkron (background job)
# Pencarian dasar
hasil = Produk.search("laptop gaming")
hasil.each { |p| puts p.nama }
puts "Total: #{hasil.total_count}"
# Pencarian dengan filter, sort, dan paginasi
hasil = Produk.search(
"laptop",
where: {
aktif: true,
harga: { gte: 5_000_000, lte: 25_000_000 },
kategori: ["Elektronik", "Aksesori"]
},
order: { harga: :asc },
page: params[:page] || 1,
per_page: 20,
includes: [:kategori] # eager load relasi
)
# Aggregations (facets)
hasil = Produk.search(
"laptop",
aggs: [:kategori, :tag],
where: { aktif: true }
)
# Akses facets
puts hasil.aggs.dig("kategori", "buckets")
# Highlight
hasil = Produk.search(
"laptop gaming",
highlight: { fields: [:nama, :deskripsi] }
)
hasil.with_highlights.each do |produk, highlight|
puts highlight[:nama] || produk.nama
end
# Autocomplete / Suggest
Produk.search("lap", fields: ["nama^5", "deskripsi"], match: :word_start, limit: 5)
# Sinkronisasi otomatis saat data berubah
produk = Produk.find(1)
produk.update!(harga: 20_000_000) # otomatis update index ES
produk.destroy # otomatis hapus dari ES
# Atau nonaktifkan auto-sync dan lakukan manual
Produk.searchkick_callbacks(false) do
Produk.update_all(harga: 15_000_000) # tidak trigger ES update
end
Produk.reindex # sync manual setelah bulk update
Sinkronisasi Data — Strategi #
# Strategi 1: Sync otomatis (default searchkick)
# Setiap save/update/destroy → update ES secara sinkron
# Cocok untuk aplikasi kecil, tapi bisa lambatkan HTTP request
# Strategi 2: Async dengan background job
# config/initializers/searchkick.rb
Searchkick.callbacks = :async # gunakan ActiveJob
# Strategi 3: Batch reindex terjadwal
# config/schedule.rb (dengan whenever gem)
every 1.hour do
runner "Produk.reindex"
end
# Strategi 4: Delta sync — hanya sync yang berubah
# app/models/produk.rb
after_commit :reindex, if: :saved_change_to_relevant_field?
def saved_change_to_relevant_field?
saved_changes.keys.any? { |k| %w[nama deskripsi harga stok aktif].include?(k) }
end
Zero-Downtime Reindexing #
Ketika perlu mengubah mapping atau reindex seluruh data tanpa downtime:
# Searchkick handle ini secara otomatis dengan alias
Produk.reindex
# Di balik layar:
# 1. Buat index baru: produk_20240815120000
# 2. Index semua data ke index baru
# 3. Swap alias "produk" ke index baru
# 4. Hapus index lama
# Manual dengan elasticsearch-ruby:
klien = client
# 1. Buat index baru
klien.indices.create(index: "produk_v2", body: mapping_baru)
# 2. Reindex dari index lama ke baru
klien.reindex(
body: {
source: { index: "produk_v1" },
dest: { index: "produk_v2" }
},
wait_for_completion: false # async untuk dataset besar
)
# 3. Monitor progress
# klien.tasks.get(task_id: task_id)
# 4. Swap alias
klien.indices.update_aliases(
body: {
actions: [
{ remove: { index: "produk_v1", alias: "produk" } },
{ add: { index: "produk_v2", alias: "produk" } }
]
}
)
# 5. Hapus index lama
klien.indices.delete(index: "produk_v1")
Ringkasan #
- Elasticsearch untuk search, database untuk source of truth — simpan data master di PostgreSQL/MySQL, index di Elasticsearch untuk pencarian; selalu sync saat data berubah.
filterlebih cepat darimust— kondisi yang tidak mempengaruhi skor relevansi (aktif = true, kategori filter) masukkan kefilterkarena di-cache dan tidak dihitung scoring.- Gunakan
.keyworduntuk sort dan exact match — fieldtextdi-analyze (tidak bisa sort); definisikanfields.keyword: { type: "keyword" }di mapping untuk kedua kebutuhan.- Bulk API untuk indexing massal —
client.bulkjauh lebih efisien dari index satu per satu; gunakan batch size 500-1000 dokumen per request.size: 0untuk pure aggregation — jika hanya butuh statistik atau facets, setsize: 0agar ES tidak mengembalikan dokumen yang tidak dibutuhkan.search_afterbukanfromuntuk deep pagination —frompunya batas 10.000 dokumen secara default;search_afterbisa paginate jutaan dokumen tanpa batas.- Analyzer bahasa Indonesia — gunakan analyzer
standarddengan filterlowercasedanasciifoldinguntuk menangani variasi penulisan huruf Indonesia.- Zero-downtime reindex dengan alias — jangan pernah hapus dan buat ulang index yang sedang digunakan; selalu buat index baru, isi data, lalu swap alias.
- Searchkick untuk Rails — menyederhanakan 90% kasus penggunaan Elasticsearch di Rails; gunakan client langsung hanya jika butuh kontrol query yang sangat spesifik.
- Monitoring dengan
_catAPI —client.cat.indices(v: true),client.cat.health, danclient.cat.shardsuntuk memantau kesehatan cluster Elasticsearch.