Full-Text Search & Elasticsearch
Dari ILIKE sederhana yang terengah-engah di bawah jutaan baris, sampai pencarian milidetik berperingkat relevansi, bab ini menjelaskan kenapa tool seperti Elasticsearch ada, cara kerja inverted index, apa yang dilakukan BM25, dan kapan meraih Postgres FTS versus Elasticsearch di backend sungguhan.
Kisah Asal: Kenapa Pencarian Jadi Sulit
Bayangkan 2005. Anda engineer backend di startup e-commerce yang tumbuh cepat. Katalog produk ±5.000 item. Anda menulis endpoint pencarian memakai pola SQL klasik:
SQL-- Works great at 5 000 rows SELECT * FROM products WHERE name ILIKE '%laptop%' OR description ILIKE '%laptop%';
Ini mengembalikan hasil dalam ±50 ms. Pengguna senang, manajer senang, hidup sederhana.
Maju dua tahun. Katalog meledak ke jutaan produk. Query yang sama kini makan 30 detik. Perusahaan kehilangan penjualan. Di atas masalah kecepatan, datang kebutuhan baru:
- Relevansi: mencari "laptop" harus menampilkan MacBook Pro sebelum tas laptop.
- Toleransi typo: pengguna mengetik lapto saat obral besar; hasil harus tetap muncul.
- Kecepatan: pencarian harus kembali dalam milidetik, bukan detik.
Analogi Pustakawan
Anggap database relasional Anda (Postgres, MySQL, dll.) sebagai pustakawan di perpustakaan raksasa. Pustakawan tahu persis di mana setiap buku berada di rak.
Tapi ia punya satu cacat fatal: untuk menemukan buku tentang sebuah topik, ia harus berjalan fisik ke setiap rak, menarik keluar setiap buku, dan membacanya halaman demi halaman untuk mencari kata kunci Anda.
Dua masalahnya diringkas:
- Kecepatan: tabel dengan 50 juta baris berarti 50 juta perbandingan baris. Di disk berputar ini bencana, bahkan di era SSD ia membuang I/O luar biasa.
- Tanpa relevansi: buku yang judulnya "Introduction to Machine Learning" mendapat peringkat identik dengan buku yang sekadar menyebut "machine" sekali di lampiran. Database tidak punya konsep kepentingan sama sekali.
Kenapa ILIKE '%term%' Tidak Bisa Pakai Index
Index B-tree Postgres bekerja dengan menyortir nilai. B-tree bisa menemukan name LIKE 'lapt%' (kecocokan awalan) secara efisien karena string tersortir berbagi awalan. Tapi ILIKE '%laptop%' punya wildcard di depan, tidak ada awalan berguna untuk disortir. Mesin harus jatuh ke sequential scan.
% in a LIKE/ILIKE expression disables B-tree index usage. Postgres will read every single page of the heap.Seperti apa query plan-nya
SQL / EXPLAIN ANALYZEEXPLAIN ANALYZE SELECT id, name FROM products WHERE name ILIKE '%laptop%'; -- Output (simplified) -- Seq Scan on products (cost=0.00..18450.00 rows=5 width=36) -- Filter: ((name)::text ~~* '%laptop%'::text) -- Rows Removed by Filter: 4 999 995 -- Planning Time: 0.2 ms -- Execution Time: 28 940.7 ms <- 29 seconds!
Perhatikan Seq Scan: setiap baris diperiksa, 4.999.995 baris dibuang, waktu eksekusi ±29 detik.
Inverted Index: Penemuan Inti
"Alih-alih melewati setiap dokumen untuk menemukan istilahnya, pelihara pemetaan dari setiap istilah ke dokumen yang memuatnya."
Wawasan ini, membalik pencarian: itulah yang ditangkap namanya. Index normal memetakan dokumen -> kata. Inverted index memetakan kata -> dokumen.
Cara Ia Dibangun
Saat dokumen pertama disimpan (atau diperbarui), mesin pencari menjalankannya lewat pipeline analisis:
- Tokenisasi: pecah teks menjadi token individual (kata). "Introduction to Machine Learning" ->
["introduction", "to", "machine", "learning"] - Normalisasi / huruf kecil: ubah ke huruf kecil supaya "Machine" dan "machine" token yang sama.
- Penghapusan stop-word: buang kata umum tanpa nilai pembeda ("to", "the", "a"). (Opsional, bisa dikonfigurasi.)
- Stemming / Lemmatisasi: perkecil kata ke akarnya: "running" -> "run", "searches" -> "search". Jadi query "searching" juga mencocokkan "searched".
- Pembuatan entri index: untuk tiap istilah hasil, catat ID dokumen dan posisinya.
Elasticsearch: Apa Itu & Cara Kerjanya
Elasticsearch adalah mesin pencari dokumen JSON terdistribusi yang dibangun di atas Apache Lucene: library Java yang teruji perang yang mengimplementasikan inverted index dan scoring BM25. Lucene ada sejak 1999; Elasticsearch (2010) membungkusnya dalam REST API dan menambah clustering terdistribusi, replikasi, dan query DSL yang kaya.
Karakteristik kuncinya:
- Dokumen JSON tanpa skema: seperti MongoDB, Anda menyimpan JSON. Field terdeteksi otomatis (atau dipetakan eksplisit).
- Hampir real-time: dokumen yang baru di-index bisa dicari dalam ±1 detik (bisa dikonfigurasi).
- Skalabilitas horizontal: data dipecah menjadi shard (sub-index), tiap shard adalah index Lucene lengkap. Lebih banyak node = lebih banyak throughput.
- Query DSL yang kuat: berbasis JSON. Mendukung full-text, fuzzy, range, geo-distance, agregasi, dll.
Membuat Index dengan Field Mapping
HTTP / Elasticsearch DSLPUT /products { "mappings": { "properties": { "name": { "type": "text", // analysed, tokenised, stemmed "boost": 3 // title matches are 3x more relevant }, "description": { "type": "text", "boost": 1.5 }, "category": { "type": "keyword" // not analysed, exact match only }, "price": { "type": "float" } } } }
Perbedaan antara text dan keyword kritis:
| Tipe Mapping | Dianalisis? | Dipakai untuk |
|---|---|---|
text | Ya, ditokenisasi, di-stem | Nama produk, deskripsi, review |
keyword | Tidak, disimpan apa adanya | Status code, tag, ID kategori |
Query Full-Text Search
HTTP / Elasticsearch DSLGET /products/_search { "query": { "multi_match": { "query": "laptop", "fields": ["name^3", "description^1.5", "category"], "fuzziness": "AUTO" // typo tolerance } }, "size": 10 }
^3 adalah field boosting: kecocokan di name menyumbang 3x lipat skor relevansi dibanding bobot field default.
BM25: Cara Kerja Skor Relevansi
BM25 (Best Match 25) adalah fungsi ranking default di Elasticsearch (dan juga di Postgres FTS). Ia menghasilkan skor floating-point per dokumen; lebih tinggi = lebih relevan. Formula lengkapnya punya banyak komponen, tapi secara konsep empat faktor menggerakkan skor:
| Faktor | Yang Diukur | Efek pada Skor |
|---|---|---|
| Term Frequency | Jumlah istilah query di dokumen | Lebih tinggi = lebih relevan (dengan hasil yang semakin mengecil) |
| Inverse Doc Frequency | Kelangkaan istilah di seluruh index | Istilah langka membawa sinyal lebih banyak daripada yang umum |
| Document Length | Panjang vs. panjang dokumen rata-rata | Dokumen pendek berisi istilah mendapat peringkat lebih tinggi daripada dokumen panjang |
| Field Boost | Field mana tempat istilah muncul | Kecocokan judul > kecocokan body (pengali yang bisa dikonfigurasi) |
Kenapa IDF Penting
Anggap kata "the", ia muncul di hampir setiap dokumen Inggris, jadi ia punya document frequency sangat tinggi dan bobot IDF sangat rendah. Ia hampir tidak menyumbang apa pun ke skor. Kata langka seperti "photovoltaic" atau "elasticsearch" yang muncul di dokumen adalah sinyal kuat relevansi topikal.
Toleransi Typo: Fuzzy Search
Elasticsearch memakai Levenshtein edit distance di balik layar untuk pencocokan fuzzy. Edit distance adalah jumlah minimum operasi karakter tunggal (sisip, hapus, ganti) yang dibutuhkan untuk mengubah satu kata menjadi kata lain.
lapto->laptop: 1 sisipan -> edit distance 1treading->trending: 1 penggantian -> edit distance 1mcbook->macbook: 1 sisipan -> edit distance 1
Elasticsearch DSL / fuzzy query{ "query": { "fuzzy": { "name": { "value": "lapto", "fuzziness": "AUTO", // 0 for 1-2 chars, 1 for 3-5, 2 for 6+ "prefix_length": 1 // first N chars must match exactly } } } }
"fuzziness": "AUTO" adalah setelan yang direkomendasikan. Ia otomatis menyesuaikan edit distance yang diizinkan berdasarkan panjang kata. prefix_length: 1 mencegah mencocokkan kata yang sama sekali tak berhubungan yang kebetulan berjarak 1-2 edit.
ELK Stack: Log & Observabilitas
Elasticsearch bukan hanya untuk pencarian produk. Ia huruf "E" dalam ELK Stack yang terkenal, dipakai untuk manajemen log terpusat dan observabilitas.
Jika perusahaan Anda sudah menjalankan ELK untuk logging, menambah pencarian produk/konten di atas klaster Elasticsearch yang sama adalah pilihan alami dan hemat biaya; Anda sudah membayar infrastrukturnya dan tim ops Anda sudah mengenalnya.
Full-Text Search Postgres di Go
Postgres punya full-text search natif sejak versi 8.3 via tipe tsvector / tsquery. Ia memakai inverted index-nya sendiri (index GIN) dan scorer varian BM25 bernama ts_rank. Tidak sekaya Elasticsearch tapi cukup memadai untuk kebutuhan pencarian sedang pada data yang sudah ada di Postgres.
DDL: Tambah Index GIN
SQL-- Add a generated tsvector column and index it ALTER TABLE products ADD COLUMN search_vec tsvector GENERATED ALWAYS AS ( setweight(to_tsvector('english', coalesce(name, '')), 'A') || setweight(to_tsvector('english', coalesce(description, '')), 'B') ) STORED; CREATE INDEX idx_products_fts ON products USING GIN(search_vec);
setweight('A') berarti kecocokan judul menang atas kecocokan deskripsi, padanan Postgres dari field boosting.
Go: Handler Full-Text Search
Gopackage main import ( "context" "fmt" "log" "net/http" "github.com/jackc/pgx/v5/pgxpool" ) type Product struct { ID int Name string Description string Rank float64 } func searchHandler(pool *pgxpool.Pool) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { query := r.URL.Query().Get("q") if query == "" { http.Error(w, "missing query param q", http.StatusBadRequest) return } // plainto_tsquery converts plain text safely (no special chars needed) // websearch_to_tsquery also supports AND/OR/-term syntax sql := ` SELECT id, name, description, ts_rank(search_vec, plainto_tsquery('english', $1)) AS rank FROM products WHERE search_vec @@ plainto_tsquery('english', $1) ORDER BY rank DESC LIMIT 20 ` rows, err := pool.Query(context.Background(), sql, query) if err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } defer rows.Close() var results []Product for rows.Next() { var p Product rows.Scan(&p.ID, &p.Name, &p.Description, &p.Rank) results = append(results, p) } w.Header().Set("Content-Type", "application/json") fmt.Fprintf(w, "found %d results\n", len(results)) } } func main() { pool, _ := pgxpool.New(context.Background(), "postgres://...") http.HandleFunc("/search", searchHandler(pool)) log.Fatal(http.ListenAndServe(":8080", nil)) }
@@ operator is Postgres's full-text match operator. It uses the GIN index, query time stays O(log n) regardless of table size.Elasticsearch di Python (SDK Resmi)
Klien Python resminya elasticsearch-py. Pasang:
Shellpip install elasticsearch
Index Setup & Bulk Insert
Pythonfrom elasticsearch import Elasticsearch, helpers import csv es = Elasticsearch( cloud_id="YOUR_CLOUD_ID", api_key="YOUR_API_KEY" ) INDEX = "reviews" # 1. Create index with explicit mapping if es.indices.exists(index=INDEX): es.indices.delete(index=INDEX) es.indices.create(index=INDEX, body={ "mappings": { "properties": { "review": {"type": "text"}, # analysed full-text "sentiment": {"type": "keyword"} # exact: "positive" / "negative" } } }) # 2. Bulk insert from CSV def generate_docs(path: str): with open(path) as f: reader = csv.DictReader(f) for row in reader: if row.get("review") and row.get("sentiment"): yield { "_index": INDEX, "_source": { "review": row["review"], "sentiment": row["sentiment"] } } success, errors = helpers.bulk(es, generate_docs("reviews.csv")) print(f"Inserted {success} documents, {len(errors)} errors")
Cari dengan Fuzzy + Field Boost
Pythondef search_reviews(query: str, sentiment_filter: str = None) -> list: must_clauses = [{ "multi_match": { "query": query.lower(), "fields": ["review"], "fuzziness": "AUTO", "operator": "and" # all terms must appear } }] filter_clauses = [] if sentiment_filter: filter_clauses.append({ "term": {"sentiment": sentiment_filter} }) body = { "query": {"bool": {"must": must_clauses, "filter": filter_clauses}}, "size": 20 } resp = es.search(index=INDEX, body=body) return [ {"score": hit["_score"], **hit["_source"]} for hit in resp["hits"]["hits"] ] # Usage results = search_reviews("gret product", sentiment_filter="positive") # "gret" (typo for "great") matched via fuzziness=AUTO (edit distance 1)
Streaming Hasil dari Dua Sumber (Pola Node.js)
Demo di kuliah memakai route API Next.js yang men-stream hasil dari Postgres dan Elasticsearch secara bersamaan sehingga tidak ada yang menunggu yang lain. Ini polanya dalam Node.js murni:
Node.js (ESM)import { Client } from '@elastic/elasticsearch' import { neon } from '@neondatabase/serverless' export async function GET(req) { const { searchParams } = new URL(req.url) const term = searchParams.get('q') const encoder = new TextEncoder() const stream = new ReadableStream({ async start(controller) { // Fire BOTH queries concurrently, don't await sequentially const [pgResult, esResult] = await Promise.allSettled([ pgSearch(term), esSearch(term), ]) controller.enqueue(encoder.encode(JSON.stringify({ source: 'postgres', ...pgResult }))) controller.enqueue(encoder.encode(JSON.stringify({ source: 'elasticsearch', ...esResult }))) controller.close() } }) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream' } }) }
Benchmark: ILIKE vs Elasticsearch pada 50 Ribu Baris
Demo di kuliah mengisi 50.000 dokumen review ke Neon (Postgres cloud) dan Elastic Cloud, keduanya dihosting di us-west untuk menyamakan latensi jaringan. Hasilnya:
| Query | Elasticsearch | Postgres ILIKE | Percepatan |
|---|---|---|---|
| "laptop" | ±500 ms | ±3 detik | 6x |
| "only" (luas) | ±500 ms | ±7,5 detik | 15x |
Semakin luas istilah pencariannya (semakin banyak baris yang cocok), semakin parah ILIKE, karena Postgres harus mengambil dan mengembalikan semua baris itu dari disk. Elasticsearch menghitung skor relevansi di memori pada shard dan hanya mengembalikan top-N, jadi latensinya tetap rendah.
Kapan Memakai Apa
Full-Text Search Postgres
- Data sudah di Postgres
- Pencarian fitur sekunder, bukan produk inti
- Tim kecil / tanpa infrastruktur khusus ES
- Volume data sedang (< beberapa juta baris)
- Tidak butuh fuzzy/typo search di hari pertama
Elasticsearch
- Pencarian fitur kelas satu
- Jutaan+ dokumen
- Butuh toleransi typo / autocomplete
- Perlu penyetelan relevansi kompleks
- Sudah menjalankan ELK untuk log toh
- Butuh agregasi / analitik pada hasil pencarian
Alur Keputusan
Referensi & Bacaan Lanjutan
Backend Field Manual / Bab Full-Text Search / Catatan disusun dari video kuliah