A detailed backend reference

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.

01

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.
These three requirements, speed, relevance, typo-tolerance, are exactly the founding charter of dedicated full-text search engines.
02

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.

User Query "machine learning" Postgres ILIKE Scan scans EVERY row Harry Potter Game of Thrones Intro to ML Cooking Deep Learning Fiction Scans every book one by one, O(n) Two Fatal Flaws: (1) Slow, O(n) scan (2) No relevance order
Fig 1: Postgres ILIKE performs a full sequential scan: every row is examined individually.

Dua masalahnya diringkas:

  1. 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.
  2. 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.
03

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.

A leading % 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.

04

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:

  1. Tokenisasi: pecah teks menjadi token individual (kata). "Introduction to Machine Learning" -> ["introduction", "to", "machine", "learning"]
  2. Normalisasi / huruf kecil: ubah ke huruf kecil supaya "Machine" dan "machine" token yang sama.
  3. Penghapusan stop-word: buang kata umum tanpa nilai pembeda ("to", "the", "a"). (Opsional, bisa dikonfigurasi.)
  4. Stemming / Lemmatisasi: perkecil kata ke akarnya: "running" -> "run", "searches" -> "search". Jadi query "searching" juga mencocokkan "searched".
  5. Pembuatan entri index: untuk tiap istilah hasil, catat ID dokumen dan posisinya.
Documents Doc 1: "Intro to Machine Learning" Doc 2: "The Machine Age" Doc 3: "Coffee Machine Manual" Doc 4: "Deep Learning Fundamentals" Analyse & Index Inverted Index TERM POSTINGS (doc, positions) machine Doc1(p1,15,23) Doc2(p5,89) Doc3(p1) learning Doc1(p1,16,24) Doc4(p2,8,45) intro Doc1(p1) coffee Doc3(p1,4,12) deep Doc4(p1,3) Query: "machine learning" -> Intersect postings -> ranked results in O(log n)
Fig 2: Inverted index: term -> posting list (document IDs + positions). Lookup is O(1) hash or O(log n) B-tree.
The key insight: inverted index lookup time depends on the number of matching documents, not the total number of documents. Millions of documents = no problem, as long as the hit list is small.
05

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 MappingDianalisis?Dipakai untuk
textYa, ditokenisasi, di-stemNama produk, deskripsi, review
keywordTidak, disimpan apa adanyaStatus 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.

06

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:

BM25 Relevance Score Term Frequency (TF) How often term appears in THIS document Inverse Doc Freq (IDF) How rare the term is across ALL documents Document Length Normalise for long docs (shorter = higher boost) Field Boosting title > description > content (configurable)
Fig 3: The four pillars of BM25 relevance scoring in Elasticsearch.
FaktorYang DiukurEfek pada Skor
Term FrequencyJumlah istilah query di dokumenLebih tinggi = lebih relevan (dengan hasil yang semakin mengecil)
Inverse Doc FrequencyKelangkaan istilah di seluruh indexIstilah langka membawa sinyal lebih banyak daripada yang umum
Document LengthPanjang vs. panjang dokumen rata-rataDokumen pendek berisi istilah mendapat peringkat lebih tinggi daripada dokumen panjang
Field BoostField mana tempat istilah munculKecocokan judul > kecocokan body (pengali yang bisa dikonfigurasi)
You don't need to understand the BM25 math to use Elasticsearch productively. Just know: field-boosted + term-frequency + IDF = the score. Tune boosts in mapping and query DSL.

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.

07

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 1
  • treading -> trending: 1 penggantian -> edit distance 1
  • mcbook -> 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.

Type-ahead / autocomplete interfaces like those on Amazon or Google use this capability. As you type "trendin", Elasticsearch finds "trending" with fuzziness=1 and returns suggestions in <50 ms.
08

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.

Logstash Collect & Parse Logs Elasticsearch Index & Search Logs Kibana Visualise & Dashboard Beats (Filebeat / Metricbeat) -> lightweight agents that ship logs/metrics to Logstash or directly to ES
Fig 4: The ELK Stack. Logstash ingests & parses, Elasticsearch indexes, Kibana visualises.

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.

09

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))
}
The @@ operator is Postgres's full-text match operator. It uses the GIN index, query time stays O(log n) regardless of table size.
10

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' } })
}
11

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:

Search Latency on 50 000 rows 3 s 7 s 0 500ms ES: "laptop" 500ms ES: "only" ~3s PG: "laptop" ~7.5s PG: "only"
Fig 5: Postgres ILIKE scales poorly with wider matches; Elasticsearch stays flat at ~500ms.
QueryElasticsearchPostgres ILIKEPercepatan
"laptop"±500 ms±3 detik6x
"only" (luas)±500 ms±7,5 detik15x

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.

12

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
As the lecturer noted: "You don't have to master Elasticsearch the way you must master databases. Most search use cases are covered by copy-pasting from the official docs or an LLM." Know when to reach for the tool; the tool's docs will handle the rest.

Alur Keputusan

Need text search? Millions of docs or search-first product? Yes Elasticsearch / OpenSearch No Postgres tsvector+GIN Both route through Apache Lucene (or compatible) under the hood. Master your database first; search is the second skill to add.
Fig 6: Quick decision tree for choosing your search backend.
13

Referensi & Bacaan Lanjutan


Versi Sederhana

Backend Field Manual / Bab Full-Text Search / Catatan disusun dari video kuliah