Skip to main content
BlogWeb Dev

PostgreSQL Full-Text Search Tanpa Elasticsearch: Yang Saya Gunakan di Proyek ERP

Bagaimana tsvector dan GIN index membuat PostgreSQL menjadi mesin pencari yang mampu untuk dataset kecil-menengah — dan kapan Anda benar-benar membutuhkan Elasticsearch.

Matthews Wong

Software Engineer

Juni 202511 menit baca
Web Dev

Proyek ERP saya membutuhkan pencarian — pencarian di seluruh pelanggan, faktur, produk, dan karyawan. Insting saya adalah menjangkau Elasticsearch. Kemudian saya menjalankan benchmark: untuk dataset di bawah 5 juta baris, full-text search PostgreSQL dengan GIN index pada tsvector cocok dengan latensi Elasticsearch untuk sebagian besar jenis kueri dan jauh lebih sederhana untuk dioperasikan. Tidak ada infrastruktur tambahan, tidak ada lag sinkronisasi, tidak ada cluster Elasticsearch untuk dikelola.

Cara Kerja Full-Text Search PostgreSQL

Sistem FTS PostgreSQL mengkonversi teks menjadi `tsvector` — daftar lexeme (batang kata yang dinormalisasi) yang diurutkan dengan informasi posisi. Kueri dikonversi menjadi `tsquery` — ekspresi boolean dari lexeme. Operator `@@` mencocokkan tsvector dengan tsquery. PostgreSQL dikirim dengan kamus untuk 20+ bahasa yang menangani stemming, stop words, dan aksen.

Kolom tsvector dan GIN Index

Pola produksi adalah menambahkan kolom `tsvector` yang dihasilkan ke tabel Anda dan membangun GIN index di atasnya. Kolom yang dihasilkan dipertahankan secara otomatis oleh PostgreSQL — tidak perlu trigger atau sinkronisasi eksternal. GIN (Generalized Inverted Index) adalah jenis index yang dirancang untuk tsvectors: menyimpan pemetaan dari setiap lexeme ke baris yang mengandungnya, membuat kueri full-text O(log N) daripada O(N).

Meranking Hasil dengan ts_rank

Fungsi `ts_rank()` PostgreSQL menilai setiap baris yang cocok berdasarkan relevansi. Keterbatasannya: ts_rank memerlukan pembacaan tsvector dari setiap baris yang cocok, yang mahal pada skala. Untuk 1 juta pencocokan pada kueri yang luas, ranking dapat memakan waktu detik. Optimisasi: tambahkan filter WHERE untuk mengurangi kumpulan pencocokan sebelum ranking.

PostgreSQL FTS Pipeline:
─────────────────────────────────────────────────────────
Text input: "Running PostgreSQL queries efficiently"
                    │
                    ▼ to_tsvector('english', text)
tsvector: 'effici':4 'postgresql':2 'queri':3 'run':1
  (stemmed, stop words removed, positions recorded)
                    │
                    ▼ stored in generated column + GIN index
GIN Index: lexeme → [row_ids]
  'postgresql' → [1, 5, 23, 44]
  'queri'      → [1, 3, 5, 12]
  'run'        → [1, 8, 19]
                    │
Query: plainto_tsquery('english', 'postgresql queries')
       → 'postgresql' & 'queri'  (AND logic)
                    │
                    ▼ GIN index intersection
Matching rows: [1, 5]  → O(log N), not O(N)
                    │
                    ▼ ts_rank() for ordering
Ranked results with relevance score

Performance benchmark (1M rows, GIN index):
  Simple phrase search:        ~8ms
  Boolean AND (2 terms):       ~12ms
  Autocomplete (prefix :*):    ~5ms
  Broad match (many results):  can be slow → use LIMIT early

Dari pengalaman saya dengan pencarian ERP: gabungkan full-text search dengan trigram similarity (ekstensi pg_trgm) untuk toleransi typo. tsvector menangani pencocokan kata tepat yang distematisasi dengan baik, tetapi gagal pada kesalahan ejaan. Tambahkan indeks `gin_trgm_ops` pada kolom teks mentah bersamaan dengan GIN index tsvector dan gunakan `similarity()` sebagai sinyal ranking sekunder.

Pola SQL yang Saya Gunakan di Produksi

Ini adalah pola produksi lengkap yang saya gunakan dalam sistem ERP NestJS + PostgreSQL. Pola ini mencakup: kolom tsvector yang dihasilkan menggabungkan beberapa bidang teks (dengan bobot berbeda menggunakan setweight), GIN index pada tsvector, fungsi pencarian menggunakan plainto_tsquery, ts_rank untuk pengurutan, dan ts_headline untuk menghasilkan cuplikan hasil pencarian dengan pencocokan yang disorot.

Pencarian Multi-Bidang Berbobot

Dalam konteks ERP, pencocokan nama pelanggan harus berperingkat lebih tinggi dari pencocokan di bidang catatan. Fungsi `setweight()` PostgreSQL menetapkan bobot A-D ke tsvectors sebelum menggabungkannya. Bobot A adalah yang tertinggi. Saya menggunakan: A untuk nama/judul/kode, B untuk deskripsi, C untuk tag/kategori, D untuk catatan/keterangan.

-- Production FTS setup for ERP (customers table)
-- 1. Add generated tsvector column with weighted fields
ALTER TABLE customers ADD COLUMN search_vector tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('english', coalesce(name, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(code, '')), 'A') ||
    setweight(to_tsvector('english', coalesce(email, '')), 'B') ||
    setweight(to_tsvector('english', coalesce(notes, '')), 'D')
  ) STORED;

-- 2. GIN index for fast searching
CREATE INDEX idx_customers_search ON customers USING GIN(search_vector);

-- 3. Trigram index for autocomplete + fuzzy matching
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_customers_name_trgm ON customers USING GIN(name gin_trgm_ops);

-- 4. Search query (used in NestJS service)
SELECT
  id, name, code, email,
  ts_rank(search_vector, query) AS rank,
  ts_headline('english', name, query, 'MaxWords=10, MinWords=5') AS snippet
FROM customers, plainto_tsquery('english', $1) AS query
WHERE
  search_vector @@ query
  OR similarity(name, $1) > 0.3  -- fuzzy fallback
ORDER BY rank DESC, similarity(name, $1) DESC
LIMIT 20;

-- 5. Autocomplete endpoint (prefix match)
SELECT id, name
FROM customers
WHERE to_tsvector('english', name) @@ to_tsquery('english', $1 || ':*')
LIMIT 10;

Mengimplementasikan Pencarian di NestJS

Dalam layanan NestJS, full-text search dengan SQL mentah memberikan lebih banyak kontrol daripada kueri yang diabstraksikan ORM. Saya menggunakan TypeORM DataSource atau klien pg mentah dengan kueri berparameter. Kuncinya adalah menggunakan `plainto_tsquery` daripada `to_tsquery` untuk pencarian yang menghadap pengguna.

Benchmark menunjukkan PostgreSQL FTS kompetitif dengan Elasticsearch pada pencarian frasa dan kueri boolean. Tetapi untuk kueri yang mencocokkan jutaan baris, langkah ranking PostgreSQL membaca setiap tsvector yang cocok dan dapat memakan waktu 25+ detik. Ambang batas di mana Elasticsearch mengungguli PostgreSQL FTS adalah sekitar 1 juta+ baris dengan kueri kardinalitas tinggi.

Autocomplete dengan Prefix Matching

Standar `plainto_tsquery` tidak melakukan prefix matching — 'inv' tidak akan cocok dengan 'invoice'. Untuk autocomplete, gunakan `to_tsquery('english', $1 || ':*')` dengan operator `:*`, yang mencocokkan lexeme apa pun yang dimulai dengan prefix. Gabungkan ini dengan LIMIT 10 dan Anda memiliki endpoint autocomplete yang cepat.

Kapan Menambahkan Elasticsearch

PostgreSQL FTS mencakup 90% persyaratan pencarian ERP. Kasus di mana saya akan menambahkan Elasticsearch: dataset melebihi 5 juta baris dengan kueri broad-match, Anda memerlukan pencarian semantik/vektor, Anda memerlukan agregasi pencarian lintas layanan real-time, atau pemangku kepentingan Anda memerlukan fitur seperti koreksi ejaan 'did you mean?'.

Sumber & Bacaan Lebih Lanjut

FAQ

Pertanyaan Umum

Jawaban singkat untuk pertanyaan yang paling sering diajukan pembaca tentang topik ini.

Mengapa menggunakan full-text search PostgreSQL daripada Elasticsearch untuk proyek ERP?

Untuk dataset di bawah 5 juta baris, PostgreSQL FTS dengan GIN index pada kolom tsvector cocok dengan latensi Elasticsearch untuk sebagian besar jenis kueri. Keunggulan operasionalnya signifikan: tidak ada infrastruktur tambahan, tidak ada lag sinkronisasi antara database dan cluster pencarian terpisah, dan tidak ada cluster Elasticsearch yang perlu dikelola. Untuk dataset ERP yang biasanya berkisar 10K hingga 500K baris, PostgreSQL FTS sudah cukup cepat dan jauh lebih sederhana.

Bagaimana GIN index meningkatkan performa full-text search di PostgreSQL?

GIN (Generalized Inverted Index) menyimpan pemetaan dari setiap lexeme ke baris yang mengandungnya, mengubah kueri full-text dari O(N) table scan menjadi O(log N) index lookup. Index diperbarui secara inkremental saat ada insert dan update, bukan dibangun ulang dari awal. Untuk tabel dengan 1 juta baris, GIN index pada kolom tsvector memungkinkan kueri full-text di bawah 10ms.

Bagaimana cara menangani typo dan kesalahan ejaan dalam full-text search PostgreSQL?

tsvector PostgreSQL menangani pencocokan kata tepat yang distemming dengan baik, tetapi gagal pada kesalahan ejaan seperti 'recieve' sebagai ganti 'receive'. Solusinya adalah menambahkan indeks gin_trgm_ops pada kolom teks mentah (menggunakan ekstensi pg_trgm) bersamaan dengan GIN index tsvector, lalu menggunakan similarity() atau word_similarity() sebagai sinyal ranking sekunder. Klausa WHERE yang menggabungkan @@ plainto_tsquery() dengan OR similarity() > 0.3 mencakup pencocokan eksak maupun fuzzy.

Kapan PostgreSQL FTS mulai tidak memadai dan Elasticsearch diperlukan?

PostgreSQL FTS kesulitan ketika kueri mencocokkan jutaan baris karena ts_rank harus membaca tsvector dari setiap baris yang cocok — kueri yang luas dapat memakan waktu 25+ detik pada skala tersebut. Ambang batasnya adalah sekitar 1 juta baris atau lebih dengan kueri kardinalitas tinggi. Alasan lain untuk menambahkan Elasticsearch antara lain kebutuhan pencarian semantik/vektor, agregasi pencarian lintas layanan real-time, atau fitur koreksi ejaan bawaan.

Bagaimana cara mengimplementasikan autocomplete dengan full-text search PostgreSQL?

Standar plainto_tsquery tidak mendukung prefix matching, sehingga 'inv' tidak akan cocok dengan 'invoice'. Untuk autocomplete, gunakan to_tsquery('english', $1 || ':*') — operator :* mencocokkan lexeme apa pun yang dimulai dengan prefix yang diberikan dan bekerja efisien pada GIN index. Menggabungkannya dengan LIMIT 10 menghasilkan endpoint autocomplete yang cepat dengan waktu respons di bawah 20ms bahkan pada tabel dengan 100K baris atau lebih.