Pencarian pakai LIKE '%kata%' memang enak ditulis: satu baris, langsung jalan. Tapi begitu tabel kamu berisi puluhan ribu baris, dia mulai tersedak karena PostgreSQL mesti scan semua baris satu per satu. Lebih parah lagi, LIKE nggak paham konsep kata — 'database' dan 'Database' dianggap dua hal berbeda, nggak ada skor relevansi, dan pengguna yang typo sedikit saja langsung dapat hasil kosong. PostgreSQL sebenarnya sudah menyimpan mesin full-text search bawaan: tsvector, tsquery, dan index GIN. Tiga komponen ini menyelesaikan masalah di atas tanpa perlu menjalankan Elasticsearch atau Meilisearch terpisah.

Siapkan Database dan Data Uji

Kita pakai PostgreSQL lewat Docker supaya bisa langsung dicoba tanpa instalasi manual. Kalau belum kenal Docker, ada panduan lengkapnya di Belajar Docker untuk Pemula. Jalankan container berikut:

bash
docker run --name artikel-db \
  -e POSTGRES_PASSWORD=postgres \
  -e POSTGRES_DB=artikel_db \
  -p 5432:5432 -d postgres:16

docker exec -it artikel-db psql -U postgres -d artikel_db

Setelah masuk ke psql, buat tabel dan isi beberapa baris data uji. Kalau koneksi Python ke PostgreSQL masih baru buat kamu, sempatkan baca Belajar PostgreSQL dengan Python dulu.

sql
CREATE TABLE artikel (
    id    SERIAL PRIMARY KEY,
    judul TEXT NOT NULL,
    isi   TEXT NOT NULL
);

INSERT INTO artikel (judul, isi) VALUES
    ('Belajar PostgreSQL dari Nol',
     'PostgreSQL adalah database relasional open source yang kuat dan andal.'),
    ('Membuat REST API dengan FastAPI',
     'FastAPI memudahkan pembuatan REST API dengan validasi otomatis.'),
    ('Full-Text Search di PostgreSQL',
     'PostgreSQL punya mesin pencarian teks bawaan bernama full-text search.'),
    ('Pencarian Cepat dengan Index GIN',
     'Index GIN mempercepat pencarian kata di dalam dokumen teks yang panjang.');

Dua fungsi ini jantungnya full-text search. to_tsvector() mengubah teks menjadi tsvector, yaitu representasi kata yang sudah dipecah, dinormalisasi ke huruf kecil, dan diberi posisi. to_tsquery() mengubah string pencarian menjadi tsquery yang siap dicocokkan. Lihat bedanya dengan konfigurasi 'english', yang sekalian menghapus stopword dan menyingkat kata ke bentuk dasar:

sql
SELECT to_tsvector('english', 'The running dogs are running faster');
-- 'dog':3 'fast':6 'run':2,5

SELECT to_tsquery('english', 'running & dog');
-- 'run' & 'dog'

Operator @@ yang menjodohkan keduanya. Query berikut mencari baris yang isinya mengandung kata database, tanpa peduli besar-kecil huruf:

sql
SELECT judul
FROM artikel
WHERE to_tsvector('simple', isi) @@ to_tsquery('simple', 'database');

--  Belajar PostgreSQL dari Nol
--  Full-Text Search di PostgreSQL

Ranking dengan ts_rank

Hasil pencarian jauh lebih berguna kalau diurutkan berdasarkan relevansi. ts_rank() menghitung skor dari seberapa sering kata kunci muncul dan seberapa dekat posisinya. Baris yang memuat database & relasional berdekatan akan dapat skor lebih tinggi:

sql
SELECT judul,
       ts_rank(to_tsvector('simple', isi),
               to_tsquery('simple', 'database & relasional')) AS rank
FROM artikel
WHERE to_tsvector('simple', isi) @@ to_tsquery('simple', 'database & relasional')
ORDER BY rank DESC;

Supaya Cepat: Index GIN

Selama to_tsvector(isi) dihitung ulang setiap query jalan, PostgreSQL tetap harus membaca semua baris. Solusinya: simpan hasil tsvector ke kolom yang digenerate otomatis, lalu pasang index GIN di atasnya. Dengan begitu nilainya dihitung sekali saat baris ditulis, bukan setiap kali dicari.

Kolom GENERATED ALWAYS AS ... STORED otomatis ikut ter-update setiap kali isi berubah. Kamu nggak perlu repot bikin trigger untuk menjaga index tetap sinkron.

sql
ALTER TABLE artikel
    ADD COLUMN isi_tsv tsvector
    GENERATED ALWAYS AS (to_tsvector('simple', isi)) STORED;

CREATE INDEX artikel_isi_tsv_idx ON artikel USING GIN (isi_tsv);

Query-nya sekarang tinggal memakai kolom itu, dan PostgreSQL memakai index untuk mencari tanpa scan penuh:

sql
SELECT judul
FROM artikel
WHERE isi_tsv @@ to_tsquery('simple', 'database');

Pencarian Ramah Pengguna dengan websearch_to_tsquery

Input dari kotak pencarian biasanya kalimat bebas, bukan operator & atau |. websearch_to_tsquery() meniru sintaks mesin pencari: kata dipisah spasi diartikan AND, frasa dalam tanda kutip dicari berurutan, dan kata dengan tanda minus dikecualikan.

Untuk input pengguna, hampir selalu pakai websearch_to_tsquery(), bukan to_tsquery(). to_tsquery() menuntut sintaks khusus dan bisa error kalau pengguna mengetik kata biasa yang mengandung karakter tak dikenal.

sql
SELECT websearch_to_tsquery('simple', 'database relasional');
-- 'database' & 'relasional'

SELECT websearch_to_tsquery('simple', '"index gin"');
-- 'index' <-> 'gin'

Highlight Hasil dengan ts_headline

Setelah dapat hasil yang relevan, tunjukkan di mana kata kuncinya muncul. ts_headline() mengembalikan cuplikan teks dengan kata yang cocok dibungkus tag <b>:

sql
SELECT judul,
       ts_headline('simple', isi, to_tsquery('simple', 'database')) AS cuplikan
FROM artikel
WHERE isi_tsv @@ to_tsquery('simple', 'database');

--  Belajar PostgreSQL dari Nol
--  PostgreSQL adalah <b>database</b> relasional open source yang kuat...

Catatan untuk Teks Bahasa Indonesia

Konfigurasi 'english' bagus untuk teks berbahasa Inggris karena punya daftar stopword dan stemmer. PostgreSQL tidak mengirim konfigurasi bahasa Indonesia, jadi untuk konten berbahasa Indonesia pakailah 'simple', yang hanya menurunkan huruf besar tanpa stemming. Konsekuensinya, belajar dan mempelajari dianggap dua kata berbeda.

Karena 'simple' tidak melakukan stemming, pencarian “belajar” tidak akan menemukan “mempelajari”. Kalau butuh pencocokan sebagian kata (prefix) atau toleransi typo, kombinasikan dengan ekstensi pg_trgm dan index GIN bertipe gin_trgm_ops.

Integrasi dengan Python (psycopg)

Terakhir, rakit semuanya di aplikasi Python. Contoh ini memakai psycopg versi 3, install dengan pip install "psycopg[binary]", lalu jalankan pencarian dari input pengguna. Perhatikan parameter %s yang mencegah SQL injection — bukan menyisipkan string secara langsung.

import psycopg

conn = psycopg.connect(
    "postgresql://postgres:postgres@localhost:5432/artikel_db"
)

kata_kunci = input("Cari: ")

with conn.cursor() as cur:
    cur.execute(
        """
        SELECT judul,
               ts_rank(isi_tsv, query)            AS rank,
               ts_headline('simple', isi, query) AS cuplikan
        FROM artikel,
             websearch_to_tsquery('simple', %s) AS query
        WHERE isi_tsv @@ query
        ORDER BY rank DESC
        LIMIT 10
        """,
        (kata_kunci,),
    )
    for judul, rank, cuplikan in cur.fetchall():
        print(f"{judul}  (skor: {rank:.4f})")
        print(f"  {cuplikan}\n")

Langkah Berikutnya

Mesin full-text ini menangani pencarian kata kunci dengan sangat baik, tapi nggak paham maksud di balik kalimat. Kalau kamu butuh pencarian berdasarkan makna, gabungkan dengan vector search — panduannya ada di Vector Search di PostgreSQL dengan pgvector. Kombinasi full-text (kata kunci eksak) dan vector (kemiripan makna) adalah resep pencarian yang dipakai banyak aplikasi produksi. Mulai dari skema tabel di atas, pasang pg_trgm, lalu ukur beda kecepatan LIKE versus index GIN dengan EXPLAIN ANALYZE di data kamu sendiri.