Semua index pada section ini sejauh ini bekerja dengan asumsi yang sama: satu baris, satu kunci. Asumsi itu runtuh pada kolom text[] berisi banyak tag, dokumen jsonb dengan belasan kunci, dan teks panjang yang ingin dicari per kata — di sana satu baris memuat banyak nilai, dan query yang justru bertanya “baris mana yang memuat nilai X?”. B-tree yang menyimpan satu entri per baris tidak bisa menjawab pertanyaan itu secara efisien; yang dibutuhkan adalah struktur yang dibalik: dari nilai, langsung ke daftar baris yang memuatnya. Itulah GIN, Generalized Inverted Index — mesin yang sama di balik mesin pencari dokumen. Chapter ini membangun GIN pada kolom array, membuktikan akselerasinya lewat EXPLAIN untuk operator @> dan &&, membedah operator class jsonb_ops lawan jsonb_path_ops, lalu menutup dengan full-text search pada tsvector.
Inverted Index: Dari Kunci ke Daftar Baris #
Ide dasarnya satu kalimat: GIN menyimpan pasangan (kunci, posting list) — setiap elemen unik menjadi kunci, dan posting list berisi daftar baris yang memuat kunci itu. Satu baris dengan tiga tag muncul di tiga posting list yang berbeda; kunci yang sama dipakai bersama ribuan baris lain tanpa diduplikasi. Struktur ini persis seperti indeks buku: kata “transaksi” muncul sekali, diikuti daftar nomor halaman tempat kata itu berada. Tabel demo berikut memakai domain katalog 1000 produk dengan distribusi tag yang timpang — mayoritas produk ber tag biasa, dua tag langka hanya menempel pada 25 produk masing-masing:
-- 1000 produk: nama dua kata, tag array, dan dokumen atribut jsonb.
CREATE TABLE produk (
id integer PRIMARY KEY,
nama text NOT NULL,
harga integer NOT NULL,
tag text[] NOT NULL,
atribut jsonb NOT NULL
);
INSERT INTO produk
SELECT s.g,
s.nama,
s.harga,
s.tag,
jsonb_build_object(
'merek', 'Merek ' || (s.g % 20),
'stok', (s.g * 13) % 500,
'berat_gram', 100 + (s.g % 40) * 50,
'negara_asal', (ARRAY['Indonesia', 'Vietnam', 'Brazil', 'Kolombia'])[1 + s.g % 4],
'tag', to_jsonb(s.tag)
)
FROM (
SELECT g,
(ARRAY['Kopi', 'Teh', 'Gula', 'Beras', 'Susu'])[1 + g % 5]
|| ' '
|| (ARRAY['Toraja', 'Hijau', 'Pasir', 'Melati', 'Kental'])[1 + (g / 5) % 5]
AS nama,
1000 + (g * 7919) % 500000 AS harga,
CASE
WHEN g % 40 = 0 THEN ARRAY['elektronik', 'garansi']
WHEN g % 40 = 20 THEN ARRAY['koleksi', 'terbatas']
WHEN g % 4 = 0 THEN ARRAY['elektronik', 'reguler']
WHEN g % 2 = 0 THEN ARRAY['diskon', 'laris']
ELSE ARRAY['rumah tangga']
END AS tag
FROM generate_series(1, 1000) AS g
) AS s;
ANALYZE produk;
SELECT id, nama, tag
FROM produk
WHERE id IN (1, 4, 6, 20, 40)
ORDER BY id;
SELECT atribut
FROM produk
WHERE id = 40;Output yang Dihasilkan:
CREATE TABLE
INSERT 0 1000
ANALYZE
id | nama | tag
----+-------------+----------------------
1 | Teh Toraja | {"rumah tangga"}
4 | Susu Toraja | {elektronik,reguler}
6 | Teh Hijau | {diskon,laris}
20 | Kopi Kental | {koleksi,terbatas}
40 | Kopi Melati | {elektronik,garansi}
(5 rows)
atribut
-------------------------------------------------------------------------------------------------------------------
{"tag": ["elektronik", "garansi"], "stok": 20, "merek": "Merek 0", "berat_gram": 100, "negara_asal": "Indonesia"}
(1 row)Mari kita bedah konstruksi pentingnya:
- Baris
CASE WHEN g % 40 = 0 ... END AS tag: Taggaransihanya menempel pada produk elektronik pilihan (25 baris) danterbatashanya pada koleksi edisi terbatas (25 baris lainnya) — tag langka inilah yang membuat query contoh selektif dan layak diindeks. - Baris
'tag', to_jsonb(s.tag): Kolomatributsengaja meniru isi kolomtagdi dalam dokumen, sehingga query array dan query jsonb di chapter ini membandingkan dua pintu ke data yang sama. - Subquery
FROM ( SELECT ... ) AS s: Nilaitagdihitung sekali di subquery lalu dipakai dua kali — untuk kolom array dan untuk isi dokumen — tanpa menulis ulang CASE yang panjang.
Containment Array: @> dengan dan tanpa Index #
Query khas kolom array adalah containment: “produk yang tag-nya memuat garansi”, ditulis tag @> ARRAY['garansi']. Tanpa index, satu-satunya jalan adalah memeriksa array di setiap baris:
-- Tanpa index: seluruh tabel discan, array tiap baris diperiksa satu per satu.
EXPLAIN
SELECT id, nama
FROM produk
WHERE tag @> ARRAY['garansi'];Output yang Dihasilkan:
QUERY PLAN
---------------------------------------------------------
Seq Scan on produk (cost=0.00..42.50 rows=25 width=15)
Filter: (tag @> '{garansi}'::text[])
(2 rows)Membangun indexnya hanya menambahkan USING gin:
-- Index GIN pada kolom array: satu entri per elemen, bukan per baris.
CREATE INDEX idx_produk_tag
ON produk USING gin (tag);
SELECT count(*) AS bertag_garansi
FROM produk
WHERE tag @> ARRAY['garansi'];
SELECT id, nama, tag
FROM produk
WHERE tag @> ARRAY['garansi']
ORDER BY id
LIMIT 3;Output yang Dihasilkan:
CREATE INDEX
bertag_garansi
----------------
25
(1 row)
id | nama | tag
-----+-------------+----------------------
40 | Kopi Melati | {elektronik,garansi}
80 | Kopi Hijau | {elektronik,garansi}
120 | Kopi Kental | {elektronik,garansi}
(3 rows)Query yang sama sekarang menemukan 25 produk bergaransi lewat posting list kunci garansi. Bandingkan rencana eksekusinya sebelum dan sesudah — sekaligus dua operator lain yang berbagi panggung yang sama:
-- @> dengan index GIN: pencarian berhenti di kunci 'garansi'.
EXPLAIN
SELECT id, nama
FROM produk
WHERE tag @> ARRAY['garansi'];
-- && dua tag: estimasi 49 baris, planner memilih Seq Scan di tabel sekecil ini.
EXPLAIN
SELECT id, nama
FROM produk
WHERE tag && ARRAY['garansi', 'terbatas'];
-- Alat penyelidik klasik: matikan Seq Scan agar jalur index && terlihat.
SET enable_seqscan = off;
SET jit = off;
EXPLAIN
SELECT id, nama
FROM produk
WHERE tag && ARRAY['garansi', 'terbatas'];
-- = ANY: bahkan dengan Seq Scan dimatikan, tidak ada jalur index.
EXPLAIN
SELECT id, nama
FROM produk
WHERE 'garansi' = ANY (tag);
RESET enable_seqscan;
RESET jit;Output yang Dihasilkan:
QUERY PLAN
------------------------------------------------------------------------------
Bitmap Heap Scan on produk (cost=8.65..39.13 rows=25 width=15)
Recheck Cond: (tag @> '{garansi}'::text[])
-> Bitmap Index Scan on idx_produk_tag (cost=0.00..8.64 rows=25 width=0)
Index Cond: (tag @> '{garansi}'::text[])
(4 rows)
QUERY PLAN
---------------------------------------------------------
Seq Scan on produk (cost=0.00..42.50 rows=49 width=15)
Filter: (tag && '{garansi,terbatas}'::text[])
(2 rows)
SET
SET
QUERY PLAN
-------------------------------------------------------------------------------
Bitmap Heap Scan on produk (cost=13.17..44.94 rows=49 width=15)
Recheck Cond: (tag && '{garansi,terbatas}'::text[])
-> Bitmap Index Scan on idx_produk_tag (cost=0.00..13.16 rows=49 width=0)
Index Cond: (tag && '{garansi,terbatas}'::text[])
(4 rows)
QUERY PLAN
----------------------------------------------------------------------------
Seq Scan on produk (cost=10000000000.00..10000000042.50 rows=25 width=15)
Filter: ('garansi'::text = ANY (tag))
(2 rows)
RESET
RESETEmpat rencana itu memuat seluruh pelajaran operator array pada GIN. Rencana pertama: @> dilayani Bitmap Index Scan dengan Index Cond yang menyebut operatornya — GIN menemukan kandidat baris lewat posting list, menyusunnya menjadi bitmap, lalu membaca heap sekali untuk 25 baris itu. Rencana kedua jujur memilih Seq Scan: operator && (irisan — salah satu tag hadir) memperkirakan 49 baris, dan biaya bitmap 44.94 kalah tipis dari scan penuh 42.50 pada tabel sekecil ini. Rencana ketiga membuktikan jalur index untuk && memang ada: dengan SET enable_seqscan = off, planner menunjukkan Bitmap Index Scan untuk query yang sama — angka cost yang membengkak bukan biaya nyata, melainkan penalti yang dipasang planner pada jalur yang dinonaktifkan. Rencana keempat adalah vonis bagi = ANY(): meski Seq Scan dimatikan, planner tetap tidak punya pilihan lain — bentuk 'garansi' = ANY (tag) tidak memiliki jalur index GIN sama sekali. Nilai cost dan rows bergantung statistik dan lingkungan Anda, jadi angkanya dapat berbeda; bentuk rencana dan kesimpulannya yang stabil.
JSONB: Dokumen sebagai Item Multi-Kunci #
Dokumen jsonb adalah item multi-kunci yang sempurna untuk GIN: setiap pasangan kunci-nilai, elemen array, dan teks di dalamnya bisa menjadi kunci index. Operator utamanya tetap @>, diuji langsung pada struktur di dalam dokumen:
-- GIN pada jsonb: setiap kunci dan nilai dokumen menjadi kunci index.
CREATE INDEX idx_produk_atribut
ON produk USING gin (atribut);
-- Containment dokumen: @> memeriksa struktur di dalam jsonb.
SELECT count(*) AS merek_7
FROM produk
WHERE atribut @> '{"merek": "Merek 7"}';
-- Containment array di dalam dokumen juga terindex.
SELECT id, nama, atribut ->> 'merek' AS merek
FROM produk
WHERE atribut @> '{"tag": ["garansi"]}'
ORDER BY id
LIMIT 3;Output yang Dihasilkan:
CREATE INDEX
merek_7
---------
50
(1 row)
id | nama | merek
-----+-------------+---------
40 | Kopi Melati | Merek 0
80 | Kopi Hijau | Merek 0
120 | Kopi Kental | Merek 0
(3 rows)Dua query itu memperlihatkan dua kedalaman containment: pencarian pasangan kunci-nilai ({"merek": "Merek 7"} — 50 produk) dan pencarian elemen di dalam array dokumen ({"tag": ["garansi"]} — 25 produk, persis baris yang sama dengan query array sebelumnya). Operator pembacaan -> dan ->> dari chapter JSON dan JSONB
tetap berjalan seperti biasa di daftar SELECT; yang diakselerasi GIN adalah penyaringan @> di klausa WHERE. Teknik lanjutan — expression index di atas ->>, kombinasi dengan partial index, sampai pemetaan kunci mana yang layak diindeks — dibahas khusus di chapter Index pada JSONB
.
Operator Class: jsonb_ops atau jsonb_path_ops #
Satu tipe data bisa punya lebih dari satu operator class — paket logika yang menentukan kunci apa yang diekstrak dan operator mana yang dilayani. Untuk jsonb ada dua:
-- Operator class kedua: jsonb_path_ops, hanya untuk @>.
CREATE INDEX idx_produk_atribut_path
ON produk USING gin (atribut jsonb_path_ops);
-- Bandingkan ukuran dua operator class pada data yang sama.
SELECT pg_size_pretty(pg_relation_size('idx_produk_atribut')) AS jsonb_ops,
pg_size_pretty(pg_relation_size('idx_produk_atribut_path')) AS jsonb_path_ops;
-- jsonb_path_ops tetap melayani query @>.
SELECT count(*) AS merek_7_via_path_ops
FROM produk
WHERE atribut @> '{"merek": "Merek 7"}';Output yang Dihasilkan:
CREATE INDEX
jsonb_ops | jsonb_path_ops
-----------+----------------
72 kB | 64 kB
(1 row)
merek_7_via_path_ops
----------------------
50
(1 row)Kedua index dibangun di atas kolom dan data yang persis sama, tetapi jsonb_path_ops menghasilkan 64 kB lawan 72 kB — dan query @> tetap berjalan pada keduanya. Sumber penghematannya: jsonb_ops menyimpan setiap kunci dan nilai sebagai kunci index terpisah, sedangkan jsonb_path_ops hanya menyimpan hash dari pasangan jalur + nilai. Konsekuensinya terasa pada daftar operator:
| Aspek | jsonb_ops (default) | jsonb_path_ops |
|---|---|---|
Containment @> | Didukung | Didukung — sering lebih cepat |
Operator keberadaan ?, `? | , ?&` | Didukung |
Query jsonpath @?, @@ | Didukung | Didukung |
| Ukuran index | Lebih besar | Lebih kecil |
| Kapan dipilih | Butuh operator ? | Beban kerja dominan @> |
Aturan praktisnya sederhana: mulai dengan default jsonb_ops sampai terbukti query ? tidak dipakai; begitu seluruh akses berupa @>, pindah ke jsonb_path_ops untuk index lebih ramping dan posting list lebih pendek.
Full-Text: tsvector dan Operator @@ #
Pola GIN yang sama bekerja untuk pencarian teks: yang diindeks bukan string utuh, melainkan tsvector — daftar kata terindeks yang diekstrak dari teks. Operator kecocokannya @@, dan expression index-nya memakai bentuk dua argumen to_tsvector('simple', nama) agar hasilnya immutable:
-- Full-text search: GIN pada hasil to_tsvector (config eksplisit = immutable).
CREATE INDEX idx_produk_nama_ts
ON produk USING gin (to_tsvector('simple', nama));
SELECT id, nama
FROM produk
WHERE to_tsvector('simple', nama) @@ plainto_tsquery('simple', 'kopi')
ORDER BY id
LIMIT 3;
SELECT count(*) AS mengandung_kata_kopi
FROM produk
WHERE to_tsvector('simple', nama) @@ plainto_tsquery('simple', 'kopi');Output yang Dihasilkan:
CREATE INDEX
id | nama
----+-------------
5 | Kopi Hijau
10 | Kopi Pasir
15 | Kopi Melati
(3 rows)
mengandung_kata_kopi
----------------------
200
(1 row)Query menemukan 200 produk yang namanya memuat kata kopi — kata, bukan awalan: to_tsvector memecah teks menjadi token, sehingga pencocokannya per kata utuh. Config 'simple' dipakai karena data contoh berbahasa Indonesia; untuk stemming bahasa Inggris tersedia config 'english', dan keterangan lengkap mesin pencarian teks — ranking, kamus, ts_rank — adalah materi section Full-Text Search tersendiri.
Dengan GIN, jangkauan index kita bergerak dari “nilai tunggal per baris” ke “banyak nilai per baris”. Kapabilitas terakhir di section ini melangkah lebih jauh lagi: GiST, kerangka index yang tidak mengindeks nilai, melainkan wilayah — rentang waktu yang saling menumpuk, titik koordinat dengan tetangga terdekat, dan constraint yang mencegah dua data menempati ruang yang sama.
Referensi #
Berkas Sumber
Berkas pendukung halaman ini dapat diunduh dan dijalankan langsung sesuai penjelasan di atas.
buat-tabel.sql
Unduh berkas-- 1000 produk: nama dua kata, tag array, dan dokumen atribut jsonb.
CREATE TABLE produk (
id integer PRIMARY KEY,
nama text NOT NULL,
harga integer NOT NULL,
tag text[] NOT NULL,
atribut jsonb NOT NULL
);
INSERT INTO produk
SELECT s.g,
s.nama,
s.harga,
s.tag,
jsonb_build_object(
'merek', 'Merek ' || (s.g % 20),
'stok', (s.g * 13) % 500,
'berat_gram', 100 + (s.g % 40) * 50,
'negara_asal', (ARRAY['Indonesia', 'Vietnam', 'Brazil', 'Kolombia'])[1 + s.g % 4],
'tag', to_jsonb(s.tag)
)
FROM (
SELECT g,
(ARRAY['Kopi', 'Teh', 'Gula', 'Beras', 'Susu'])[1 + g % 5]
|| ' '
|| (ARRAY['Toraja', 'Hijau', 'Pasir', 'Melati', 'Kental'])[1 + (g / 5) % 5]
AS nama,
1000 + (g * 7919) % 500000 AS harga,
CASE
WHEN g % 40 = 0 THEN ARRAY['elektronik', 'garansi']
WHEN g % 40 = 20 THEN ARRAY['koleksi', 'terbatas']
WHEN g % 4 = 0 THEN ARRAY['elektronik', 'reguler']
WHEN g % 2 = 0 THEN ARRAY['diskon', 'laris']
ELSE ARRAY['rumah tangga']
END AS tag
FROM generate_series(1, 1000) AS g
) AS s;
ANALYZE produk;
SELECT id, nama, tag
FROM produk
WHERE id IN (1, 4, 6, 20, 40)
ORDER BY id;
SELECT atribut
FROM produk
WHERE id = 40;
dengan-index.sql
Unduh berkas-- @> dengan index GIN: pencarian berhenti di kunci 'garansi'.
EXPLAIN
SELECT id, nama
FROM produk
WHERE tag @> ARRAY['garansi'];
-- && dua tag: estimasi 49 baris, planner memilih Seq Scan di tabel sekecil ini.
EXPLAIN
SELECT id, nama
FROM produk
WHERE tag && ARRAY['garansi', 'terbatas'];
-- Alat penyelidik klasik: matikan Seq Scan agar jalur index && terlihat.
SET enable_seqscan = off;
SET jit = off;
EXPLAIN
SELECT id, nama
FROM produk
WHERE tag && ARRAY['garansi', 'terbatas'];
-- = ANY: bahkan dengan Seq Scan dimatikan, tidak ada jalur index.
EXPLAIN
SELECT id, nama
FROM produk
WHERE 'garansi' = ANY (tag);
RESET enable_seqscan;
RESET jit;
index-gin.sql
Unduh berkas-- Index GIN pada kolom array: satu entri per elemen, bukan per baris.
CREATE INDEX idx_produk_tag
ON produk USING gin (tag);
SELECT count(*) AS bertag_garansi
FROM produk
WHERE tag @> ARRAY['garansi'];
SELECT id, nama, tag
FROM produk
WHERE tag @> ARRAY['garansi']
ORDER BY id
LIMIT 3;
jsonb-gin.sql
Unduh berkas-- GIN pada jsonb: setiap kunci dan nilai dokumen menjadi kunci index.
CREATE INDEX idx_produk_atribut
ON produk USING gin (atribut);
-- Containment dokumen: @> memeriksa struktur di dalam jsonb.
SELECT count(*) AS merek_7
FROM produk
WHERE atribut @> '{"merek": "Merek 7"}';
-- Containment array di dalam dokumen juga terindex.
SELECT id, nama, atribut ->> 'merek' AS merek
FROM produk
WHERE atribut @> '{"tag": ["garansi"]}'
ORDER BY id
LIMIT 3;
opclass-path.sql
Unduh berkas-- Operator class kedua: jsonb_path_ops, hanya untuk @>.
CREATE INDEX idx_produk_atribut_path
ON produk USING gin (atribut jsonb_path_ops);
-- Bandingkan ukuran dua operator class pada data yang sama.
SELECT pg_size_pretty(pg_relation_size('idx_produk_atribut')) AS jsonb_ops,
pg_size_pretty(pg_relation_size('idx_produk_atribut_path')) AS jsonb_path_ops;
-- jsonb_path_ops tetap melayani query @>.
SELECT count(*) AS merek_7_via_path_ops
FROM produk
WHERE atribut @> '{"merek": "Merek 7"}';
tanpa-index.sql
Unduh berkas-- Tanpa index: seluruh tabel discan, array tiap baris diperiksa satu per satu.
EXPLAIN
SELECT id, nama
FROM produk
WHERE tag @> ARRAY['garansi'];
teks-penuh.sql
Unduh berkas-- Full-text search: GIN pada hasil to_tsvector (config eksplisit = immutable).
CREATE INDEX idx_produk_nama_ts
ON produk USING gin (to_tsvector('simple', nama));
SELECT id, nama
FROM produk
WHERE to_tsvector('simple', nama) @@ plainto_tsquery('simple', 'kopi')
ORDER BY id
LIMIT 3;
SELECT count(*) AS mengandung_kata_kopi
FROM produk
WHERE to_tsvector('simple', nama) @@ plainto_tsquery('simple', 'kopi');