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:

sql buat-tabel.sql
-- 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:

text
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:

  1. Baris CASE WHEN g % 40 = 0 ... END AS tag: Tag garansi hanya menempel pada produk elektronik pilihan (25 baris) dan terbatas hanya pada koleksi edisi terbatas (25 baris lainnya) — tag langka inilah yang membuat query contoh selektif dan layak diindeks.
  2. Baris 'tag', to_jsonb(s.tag): Kolom atribut sengaja meniru isi kolom tag di dalam dokumen, sehingga query array dan query jsonb di chapter ini membandingkan dua pintu ke data yang sama.
  3. Subquery FROM ( SELECT ... ) AS s: Nilai tag dihitung 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:

sql tanpa-index.sql
-- 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:

text
                       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:

sql index-gin.sql
-- 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:

text
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:

text
                                  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
RESET

Empat 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:

sql jsonb-gin.sql
-- 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:

text
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:

text
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:

Aspekjsonb_ops (default)jsonb_path_ops
Containment @>DidukungDidukung — sering lebih cepat
Operator keberadaan ?, `?, ?&`Didukung
Query jsonpath @?, @@DidukungDidukung
Ukuran indexLebih besarLebih kecil
Kapan dipilihButuh 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:

sql teks-penuh.sql
-- 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:

text
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
sql
-- 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
sql
-- @> 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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
sql
-- 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');