Elasticsearch

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 — 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.
  • filter lebih cepat dari must — kondisi yang tidak mempengaruhi skor relevansi (aktif = true, kategori filter) masukkan ke filter karena di-cache dan tidak dihitung scoring.
  • Gunakan .keyword untuk sort dan exact match — field text di-analyze (tidak bisa sort); definisikan fields.keyword: { type: "keyword" } di mapping untuk kedua kebutuhan.
  • Bulk API untuk indexing massalclient.bulk jauh lebih efisien dari index satu per satu; gunakan batch size 500-1000 dokumen per request.
  • size: 0 untuk pure aggregation — jika hanya butuh statistik atau facets, set size: 0 agar ES tidak mengembalikan dokumen yang tidak dibutuhkan.
  • search_after bukan from untuk deep paginationfrom punya batas 10.000 dokumen secara default; search_after bisa paginate jutaan dokumen tanpa batas.
  • Analyzer bahasa Indonesia — gunakan analyzer standard dengan filter lowercase dan asciifolding untuk 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 _cat APIclient.cat.indices(v: true), client.cat.health, dan client.cat.shards untuk memantau kesehatan cluster Elasticsearch.

← Sebelumnya: MongoDB   Berikutnya: Kafka →

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