Chapter Ranking Hasil Pencarian menutup dengan janji satu langkah lagi: mengindeks vektor berbobot supaya pencarian tetap cepat. Tanpa index, operator @@ hanya punya satu jalur — membaca setiap baris, membandingkan tsquery dengan tsvector baris itu, lalu membuang yang tidak cocok. Untuk tabel ratusan baris perihal itu tak terasa; untuk blog dengan puluhan ribu artikel, query setiap keystroke menjadi pukulan berat. Untungnya tsvector adalah struktur yang dibuat untuk inverted index: dari setiap lexeme, langsung menuju daftar baris yang memuatnya. Struktur itu adalah GIN — chapter GIN Index di section Indexes telah memperkenalkan mesinnya untuk array dan jsonb; chapter ini memasangnya untuk pencarian teks, membandingkan dua gaya penempatan (kolom tersimpan lawan expression), dan membuktikan akselerasinya lewat EXPLAIN.

Data Uji: 20.000 Artikel #

Tabel uji memakai domain artikel dengan vektor sebagai generated column — pola yang diperkenalkan pada chapter ranking, tanpa bobot agar fokus chapter ini tetap pada kecepatan. Distribusi kata sengaja timpang: arabika hanya menempel pada 100 dari 20.000 baris (0,5%), kopi pada 5.000 baris (25%), dan mengenai hadir di semua baris:

sql buat-tabel.sql
-- Data uji: 20.000 artikel dengan vektor tsvector tersimpan (generated column).
-- Kata 'arabika' hanya muncul pada 100 baris (0,5%) supaya query contoh selektif.
CREATE TABLE artikel (
    id      integer PRIMARY KEY,
    judul   text NOT NULL,
    isi     text NOT NULL,
    vektor  tsvector GENERATED ALWAYS AS (to_tsvector('simple', judul || '. ' || isi)) STORED
);

INSERT INTO artikel (id, judul, isi)
SELECT g,
       (ARRAY['Kopi','Teh','Gula','Beras'])[1 + g % 4] || ' ' ||
       (ARRAY['Toraja','Hijau','Melati','Pasir'])[1 + (g / 4) % 4],
       repeat('ulasan mengenai produk berkualitas dari toko maju jaya ', 2) ||
       CASE WHEN g % 200 = 0 THEN 'kopi arabika pilihan ' ELSE '' END ||
       'untuk pelanggan setia edisi ' || g
FROM generate_series(1, 20000) AS g;

ANALYZE artikel;

SELECT count(*) AS total,
       count(*) FILTER (WHERE vektor @@ to_tsquery('simple', 'arabika')) AS ada_arabika,
       count(*) FILTER (WHERE vektor @@ to_tsquery('simple', 'mengenai')) AS ada_mengenai,
       count(*) FILTER (WHERE vektor @@ to_tsquery('simple', 'kopi')) AS ada_kopi
FROM artikel;

SELECT id, judul, left(isi, 58) || '...' AS cuplikan_isi
FROM artikel
WHERE id IN (200, 201)
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 20000
ANALYZE
 total | ada_arabika | ada_mengenai | ada_kopi 
-------+-------------+--------------+----------
 20000 |         100 |        20000 |     5000
(1 row)

 id  |    judul    |                         cuplikan_isi                          
-----+-------------+---------------------------------------------------------------
 200 | Kopi Melati | ulasan mengenai produk berkualitas dari toko maju jaya ula...
 201 | Teh Melati  | ulasan mengenai produk berkualitas dari toko maju jaya ula...
(2 rows)

Rencana eksekusi sebelum index menegaskan apa yang dipertaruhkan. Angka cost dan estimasi rows di bawah bergantung pada statistik dan lingkungan tempat query dijalankan, jadi nilainya dapat berbeda di mesin Anda — bentuk rencananya yang menjadi kesimpulan:

-- Tanpa index: satu-satunya jalur adalah Seq Scan, memeriksa vektor tiap baris.
EXPLAIN SELECT id, judul
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'arabika');

Output yang Dihasilkan:

text
                         QUERY PLAN                          
-------------------------------------------------------------
 Seq Scan on artikel  (cost=0.00..1335.00 rows=100 width=15)
   Filter: (vektor @@ '''arabika'''::tsquery)
(2 rows)

Seq Scan memeriksa 20.000 vektor satu per satu hanya untuk menemukan 100 baris — total biaya 1335 unit pada lingkungan kursus ini. Seluruh pekerjaan itu terjadi meski kata arabika langka dan vektor sudah tersimpan rapi di kolom; yang tidak ada adalah jalan pintas dari lexeme menuju baris.

GIN pada Kolom tsvector #

Membangun index-nya satu baris CREATE INDEX — cukup tambahkan USING gin pada kolom bertipe tsvector:

sql index-gin.sql
-- GIN pada kolom tsvector: satu entri per lexeme, bukan per baris.
CREATE INDEX idx_artikel_vektor
    ON artikel USING gin (vektor);

-- Query kata langka kini dilayani posting list lexeme 'arabika'.
SELECT count(*) AS artikel_arabika
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'arabika');

-- Pola halaman hasil: index menyaring kandidat, ORDER BY rank menyusun urutan.
SELECT id, judul,
       ts_rank(vektor, to_tsquery('simple', 'arabika')) AS rank
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'arabika')
ORDER BY rank DESC, id
LIMIT 5;

Output yang Dihasilkan:

text
CREATE INDEX
 artikel_arabika 
-----------------
             100
(1 row)

  id  |    judul    |    rank    
------+-------------+------------
  200 | Kopi Melati | 0.06079271
  400 | Kopi Toraja | 0.06079271
  600 | Kopi Melati | 0.06079271
  800 | Kopi Toraja | 0.06079271
 1000 | Kopi Melati | 0.06079271
(5 rows)

Query count(*) menemukan 100 artikel yang sama, dan pola halaman hasilnya persis resep chapter ranking: WHERE vektor @@ query menyaring kandidat lewat index, ts_rank menghitung skor, ORDER BY rank DESC, id menyusun urutan dengan pemecah seri, LIMIT 5 memotong halaman. Kelima skor seri di 0.06079271 — setiap artikel memuat arabika tepat sekali — sehingga pemecah seri id yang menentukan urutan akhir. Empat rencana berikut membedah apa yang berubah di bawah tenda:

-- Kata langka (100 dari 20.000 baris): planner memilih Bitmap Index Scan.
EXPLAIN SELECT id, judul
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'arabika');

-- Kata yang ada di semua baris: planner jujur memilih Seq Scan.
EXPLAIN SELECT id, judul
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'mengenai');

-- Alat penyelidik: paksa jalur index terlihat untuk kata umum.
SET enable_seqscan = off;

EXPLAIN SELECT id, judul
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'mengenai');

RESET enable_seqscan;

-- Query berbasis expression BERBEDA tidak bisa dilayani index kolom vektor.
EXPLAIN SELECT id, judul
FROM artikel
WHERE to_tsvector('simple', isi) @@ to_tsquery('simple', 'arabika');

Output yang Dihasilkan:

text
                                     QUERY PLAN                                     
------------------------------------------------------------------------------------
 Bitmap Heap Scan on artikel  (cost=13.35..312.94 rows=100 width=15)
   Recheck Cond: (vektor @@ '''arabika'''::tsquery)
   ->  Bitmap Index Scan on idx_artikel_vektor  (cost=0.00..13.33 rows=100 width=0)
         Index Cond: (vektor @@ '''arabika'''::tsquery)
(4 rows)

                          QUERY PLAN                           
---------------------------------------------------------------
 Seq Scan on artikel  (cost=0.00..1335.00 rows=20000 width=15)
   Filter: (vektor @@ '''mengenai'''::tsquery)
(2 rows)

SET
                                      QUERY PLAN                                       
---------------------------------------------------------------------------------------
 Bitmap Heap Scan on artikel  (cost=146.70..1481.70 rows=20000 width=15)
   Recheck Cond: (vektor @@ '''mengenai'''::tsquery)
   ->  Bitmap Index Scan on idx_artikel_vektor  (cost=0.00..141.70 rows=20000 width=0)
         Index Cond: (vektor @@ '''mengenai'''::tsquery)
(4 rows)

RESET
                                    QUERY PLAN                                     
-----------------------------------------------------------------------------------
 Gather  (cost=1000.00..5183.24 rows=100 width=15)
   Workers Planned: 1
   ->  Parallel Seq Scan on artikel  (cost=0.00..4173.24 rows=59 width=15)
         Filter: (to_tsvector('simple'::regconfig, isi) @@ '''arabika'''::tsquery)
(4 rows)

Empat pelajaran dari empat rencana (angka cost bervariasi antar lingkungan; bentuk rencana yang stabil):

  1. Rencana pertama: query arabika kini dilayani Bitmap Index Scan pada idx_artikel_vektor — GIN mencari lexeme arabika, posting list-nya berisi 100 kandidat, heap dibaca sekali untuk 100 baris itu. Total biaya turun dari 1335 menjadi 312.94.
  2. Rencana kedua: kata mengenai ada di seluruh 20.000 baris, dan planner jujur menolak index — Seq Scan 1335.00 lebih murah daripada membaca bitmap seluruh tabel. Index bukan jimat; untuk query yang cocoknya hampir semua baris, scan penuh memang jawaban yang benar.
  3. Rencana ketiga: dengan SET enable_seqscan = off, jalur GIN untuk mengenai terlihat — Bitmap Heap Scan 1481.70, lebih mahal dari Seq Scan. Planner sudah mengambil keputusan yang tepat.
  4. Rencana keempat: query yang memakai expression to_tsvector('simple', isi) — bentuk yang berbeda dari kolom vektor — tidak bisa dilayani idx_artikel_vektor. Planner kembali ke (paralel) Seq Scan meski index GIN berdiri di tabel yang sama.

Index Expression sebagai Alternatif #

Rencana keempat di atas bukan vonis mati untuk query expression — hanya vonis untuk kecocokan bentuk. Chapter Expression Index memperkenalkan solusinya: indeks hasil expression itu sendiri. Bentuk dua argumen to_tsvector('simple', isi) bersifat immutable sehingga sah sebagai ekspresi index:

-- Index expression: vektor dihitung di dalam index, tanpa kolom tersimpan.
CREATE INDEX idx_artikel_isi_ts
    ON artikel USING gin (to_tsvector('simple', isi));

-- Ukuran ketiga objek pada data yang sama.
SELECT pg_size_pretty(pg_relation_size('artikel'))            AS tabel,
       pg_size_pretty(pg_relation_size('idx_artikel_vektor')) AS idx_kolom_tersimpan,
       pg_size_pretty(pg_relation_size('idx_artikel_isi_ts')) AS idx_expression;

-- Query HARUS menulis expression yang persis sama dengan definisi index.
SELECT id, judul
FROM artikel
WHERE to_tsvector('simple', isi) @@ to_tsquery('simple', 'arabika')
ORDER BY id
LIMIT 3;

Output yang Dihasilkan:

text
CREATE INDEX
  tabel  | idx_kolom_tersimpan | idx_expression 
---------+---------------------+----------------
 8680 kB | 1552 kB             | 1488 kB
(1 row)

 id  |    judul    
-----+-------------
 200 | Kopi Melati
 400 | Kopi Toraja
 600 | Kopi Melati
(3 rows)
-- Setelah index expression ada, query expression yang sama dilayani index.
EXPLAIN SELECT id, judul
FROM artikel
WHERE to_tsvector('simple', isi) @@ to_tsquery('simple', 'arabika');

Output yang Dihasilkan:

text
                                      QUERY PLAN                                       
---------------------------------------------------------------------------------------
 Bitmap Heap Scan on artikel  (cost=13.35..337.94 rows=100 width=15)
   Recheck Cond: (to_tsvector('simple'::regconfig, isi) @@ '''arabika'''::tsquery)
   ->  Bitmap Index Scan on idx_artikel_isi_ts  (cost=0.00..13.33 rows=100 width=0)
         Index Cond: (to_tsvector('simple'::regconfig, isi) @@ '''arabika'''::tsquery)
(4 rows)

Rencana berubah dari Parallel Seq Scan menjadi Bitmap Index Scan pada idx_artikel_isi_ts — Index Cond-nya menyalin expression index secara persis. Perbandingan ukuran pada data yang sama juga tercetak: tabel 8680 kB, index kolom tersimpan 1552 kB, index expression 1488 kB. Keduanya layak; pilihan bergantung pada bentuk data dan pola akses:

AspekKolom tsvector tersimpanIndex expression to_tsvector(...)
Penyimpanan vektorMaterialized di tabel + indexHanya di dalam index
KesegaranDijaga generated column / triggerSelalu dihitung ulang saat tulis
Bentuk queryvektor @@ queryto_tsvector('simple', kolom) @@ query — harus persis sama
Bobot bagian dokumenPasangan setweight + || bebasEkspresi makin panjang dan kaku
Ukuran (contoh 20.000 baris)1552 kB1488 kB

Catatan Performa GIN #

Dua perilaku internal GIN patut diketahui sebelum produksi. Pertama, struktur utama GIN menyimpan kunci terurut, tetapi pembaruan cepat (fastupdate) menampung entri baru di pending list yang tidak terurut; daftar ini dipindahkan ke struktur utama secara massal saat VACUUM, saat gin_clean_pending_list() dipanggil, atau saat ukurannya melewati gin_pending_list_limit. Konsekuensinya: query harus turut memeriksa pending list, sehingga tabel yang terus ditulis bisa sedikit melambat pencariannya — trade-off yang sudah dibahas pada chapter GIN Index dan bisa dimatikan lewat WITH (fastupdate = off) saat waktu respons konsisten lebih penting daripada kecepatan tulis.

Kedua, GIN pada tsvector menyimpan lexeme saja tanpa label bobot posisi. Query yang melibatkan bobot — misalnya vektor berbobot A/B dari chapter ranking — memicu recheck baris heap setelah bitmap terkumpul; hasilnya tetap benar, hanya ada satu pemeriksaan ulang yang perlu dihitung saat mengukur performa. Untuk beban kerja tulis-banyak di atas tabel ber-vektor, kombinasikan pemahaman ini dengan pemantauan ukuran index dan pending list lewat pg_relation_size serta gin_clean_pending_list().

Dengan vektor terindeks, pencarian teks kolom biasa sudah berjalan pada kecepatan produksi. Satu bentangan data masih tertutup: teks yang terkubur di dalam dokumen jsonb — deskripsi produk di balik kunci keterangan, ulasan pelanggan di dalam array ulasan. Chapter penutup section ini membawa to_tsvector menembus struktur JSON itu lewat index expression pada jalur jsonb.

Referensi #

Berkas Sumber

Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.

buat-tabel.sql

Unduh berkas
sql
-- Data uji: 20.000 artikel dengan vektor tsvector tersimpan (generated column).
-- Kata 'arabika' hanya muncul pada 100 baris (0,5%) supaya query contoh selektif.
CREATE TABLE artikel (
    id      integer PRIMARY KEY,
    judul   text NOT NULL,
    isi     text NOT NULL,
    vektor  tsvector GENERATED ALWAYS AS (to_tsvector('simple', judul || '. ' || isi)) STORED
);

INSERT INTO artikel (id, judul, isi)
SELECT g,
       (ARRAY['Kopi','Teh','Gula','Beras'])[1 + g % 4] || ' ' ||
       (ARRAY['Toraja','Hijau','Melati','Pasir'])[1 + (g / 4) % 4],
       repeat('ulasan mengenai produk berkualitas dari toko maju jaya ', 2) ||
       CASE WHEN g % 200 = 0 THEN 'kopi arabika pilihan ' ELSE '' END ||
       'untuk pelanggan setia edisi ' || g
FROM generate_series(1, 20000) AS g;

ANALYZE artikel;

SELECT count(*) AS total,
       count(*) FILTER (WHERE vektor @@ to_tsquery('simple', 'arabika')) AS ada_arabika,
       count(*) FILTER (WHERE vektor @@ to_tsquery('simple', 'mengenai')) AS ada_mengenai,
       count(*) FILTER (WHERE vektor @@ to_tsquery('simple', 'kopi')) AS ada_kopi
FROM artikel;

SELECT id, judul, left(isi, 58) || '...' AS cuplikan_isi
FROM artikel
WHERE id IN (200, 201)
ORDER BY id;

explain-dengan-index.sql

Unduh berkas
sql
-- Kata langka (100 dari 20.000 baris): planner memilih Bitmap Index Scan.
EXPLAIN SELECT id, judul
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'arabika');

-- Kata yang ada di semua baris: planner jujur memilih Seq Scan.
EXPLAIN SELECT id, judul
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'mengenai');

-- Alat penyelidik: paksa jalur index terlihat untuk kata umum.
SET enable_seqscan = off;

EXPLAIN SELECT id, judul
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'mengenai');

RESET enable_seqscan;

-- Query berbasis expression BERBEDA tidak bisa dilayani index kolom vektor.
EXPLAIN SELECT id, judul
FROM artikel
WHERE to_tsvector('simple', isi) @@ to_tsquery('simple', 'arabika');

explain-expression.sql

Unduh berkas
sql
-- Setelah index expression ada, query expression yang sama dilayani index.
EXPLAIN SELECT id, judul
FROM artikel
WHERE to_tsvector('simple', isi) @@ to_tsquery('simple', 'arabika');

explain-tanpa-index.sql

Unduh berkas
sql
-- Tanpa index: satu-satunya jalur adalah Seq Scan, memeriksa vektor tiap baris.
EXPLAIN SELECT id, judul
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'arabika');

index-expression.sql

Unduh berkas
sql
-- Index expression: vektor dihitung di dalam index, tanpa kolom tersimpan.
CREATE INDEX idx_artikel_isi_ts
    ON artikel USING gin (to_tsvector('simple', isi));

-- Ukuran ketiga objek pada data yang sama.
SELECT pg_size_pretty(pg_relation_size('artikel'))            AS tabel,
       pg_size_pretty(pg_relation_size('idx_artikel_vektor')) AS idx_kolom_tersimpan,
       pg_size_pretty(pg_relation_size('idx_artikel_isi_ts')) AS idx_expression;

-- Query HARUS menulis expression yang persis sama dengan definisi index.
SELECT id, judul
FROM artikel
WHERE to_tsvector('simple', isi) @@ to_tsquery('simple', 'arabika')
ORDER BY id
LIMIT 3;

index-gin.sql

Unduh berkas
sql
-- GIN pada kolom tsvector: satu entri per lexeme, bukan per baris.
CREATE INDEX idx_artikel_vektor
    ON artikel USING gin (vektor);

-- Query kata langka kini dilayani posting list lexeme 'arabika'.
SELECT count(*) AS artikel_arabika
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'arabika');

-- Pola halaman hasil: index menyaring kandidat, ORDER BY rank menyusun urutan.
SELECT id, judul,
       ts_rank(vektor, to_tsquery('simple', 'arabika')) AS rank
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'arabika')
ORDER BY rank DESC, id
LIMIT 5;