Operator @@ dari chapter Pencarian Teks menjawab pertanyaan biner: dokumen ini cocok atau tidak. Pengguna nyata tidak berpikir sebiner itu — ketika mencari “kopi toraja” di blog Toko Maju Jaya, pembaca berharap artikel yang membahas kopi Toraja secara khusus tampil lebih dulu daripada artikel yang hanya menyebut kata “kopi” sekali di tengah paragraf panjang. PostgreSQL menyediakan dua fungsi ranking untuk menghitung skor relevansi: ts_rank dan ts_rank_cd. Chapter ini membedah faktor yang memengaruhi skor (frekuensi kata, kedekatan posisi, bobot bagian dokumen), lalu menyusun query pencarian yang mengurutkan hasil dari yang paling relevan.

Skor Relevansi dengan ts_rank #

Fungsi ts_rank(vector tsvector, query tsquery) menerima pasangan dokumen terpraproses dan query, lalu mengembalikan angka real — semakin tinggi, semakin relevan. Skor dihitung dari beberapa faktor lokal: berapa kali lexeme query muncul di dokumen (term frequency), pada posisi berapa ia muncul, dan bobot label posisi. Data contoh chapter ini direkayasa agar tiap faktor terlihat terisolasi:

sql buat-tabel.sql
-- Delapan artikel Toko Maju Jaya: data sengaja direkayasa untuk demo ranking.
-- Baris 8 memiliki isi NULL untuk menunjukkan perilaku ts_rank pada input NULL.
CREATE TABLE artikel (
    id      integer PRIMARY KEY,
    judul   text NOT NULL,
    isi     text
);

INSERT INTO artikel VALUES
 (1, 'Gula Merah Jember',   'gula merah dan gula pasir'),
 (2, 'Gula Aren Madu',      'gula merah asli jember manis'),
 (3, 'Kopi Toraja Pilihan', 'kopi toraja enak rasanya'),
 (4, 'Kopi Dataran Tinggi', 'kopi enak rasanya toraja'),
 (5, 'Warung Kopi Toraja',  'kopi enak sekali'),
 (6, 'Warung Kopi Enak',    'kopi dataran tinggi toraja rasanya nikmat benar'),
 (7, 'Teh Melati Wangi',    'teh melati wangi rasanya'),
 (8, 'Catatan Harian',      NULL);

SELECT id, judul, coalesce(isi, '(isi kosong)') AS isi
FROM artikel
ORDER BY id;

Output yang Dihasilkan:

text
CREATE TABLE
INSERT 0 8
 id |        judul        |                       isi                       
----+---------------------+-------------------------------------------------
  1 | Gula Merah Jember   | gula merah dan gula pasir
  2 | Gula Aren Madu      | gula merah asli jember manis
  3 | Kopi Toraja Pilihan | kopi toraja enak rasanya
  4 | Kopi Dataran Tinggi | kopi enak rasanya toraja
  5 | Warung Kopi Toraja  | kopi enak sekali
  6 | Warung Kopi Enak    | kopi dataran tinggi toraja rasanya nikmat benar
  7 | Teh Melati Wangi    | teh melati wangi rasanya
  8 | Catatan Harian      | (isi kosong)
(8 rows)

Mari kita amati faktor frekuensi kata secara langsung. Isi baris 1 memuat kata gula dua kali (“gula merah dan gula pasir”), sedangkan isi baris 2 memuat kata yang sama sekali — dan keduanya sama-sama terdiri atas lima kata sehingga panjang dokumen tidak ikut campur:

sql rank-dasar.sql
-- ts_rank dasar: frekuensi kata menentukan skor.
-- Baris 1 memuat 'gula' dua kali, baris 2 sekali, keduanya sama panjang (5 kata).
SELECT id, judul,
       ts_rank(to_tsvector('simple', isi), to_tsquery('simple', 'gula')) AS rank
FROM artikel
ORDER BY rank DESC NULLS LAST, id;

-- Perilaku khusus: tanpa kecocokan skor 0; input NULL menghasilkan NULL.
SELECT ts_rank(to_tsvector('simple', 'teh wangi'), to_tsquery('simple', 'gula')) AS rank_tanpa_cocok,
       ts_rank(to_tsvector('simple', NULL),      to_tsquery('simple', 'gula')) AS rank_input_null;

Output yang Dihasilkan:

text
 id |        judul        |    rank     
----+---------------------+-------------
  1 | Gula Merah Jember   | 0.075990885
  2 | Gula Aren Madu      |  0.06079271
  3 | Kopi Toraja Pilihan |           0
  4 | Kopi Dataran Tinggi |           0
  5 | Warung Kopi Toraja  |           0
  6 | Warung Kopi Enak    |           0
  7 | Teh Melati Wangi    |           0
  8 | Catatan Harian      |            
(8 rows)

 rank_tanpa_cocok | rank_input_null 
------------------+-----------------
                0 |                
(1 row)

Hasilnya lugas: baris 1 mendapat 0.075990885, baris 2 mendapat 0.06079271 — kata yang muncul dua kali menghasilkan skor lebih tinggi. Lima baris lainnya menunjukkan dua perilaku penting. Baris tanpa kecocokan (baris 3 sampai 7) menghasilkan 0, bukan NULL. Baris 8 justru menghasilkan NULL karena kolom isi-nya NULL, sehingga to_tsvector('simple', NULL) ikut NULL — NULL hanya muncul dari input NULL, bukan dari ketiadaan kecocokan. Karena PostgreSQL mengurutkan NULL sebelum nilai lain pada ORDER BY rank DESC, klausa NULLS LAST dipasang eksplisit agar baris bermasalah itu jatuh ke urutan paling akhir.

Kedekatan Kata dengan ts_rank_cd #

Query multi-kata punya dimensi lain: seberapa berdekatan kata-kata pencarian itu muncul. Fungsi ts_rank_cd (cover density) menghitung kepadatan “sampul” — rentang teks terpendek yang memuat seluruh lexeme query. Semakin sempit rentangnya, semakin tinggi skornya. Isi baris 3 dan 4 sama-sama memuat kopi dan toraja, tetapi pada baris 3 keduanya berdampingan di posisi 1-2, sedangkan pada baris 4 kata toraja tertinggal di posisi 4:

sql rank-cd.sql
-- Cover density: kedekatan posisi 'kopi' dan 'toraja' dihitung oleh ts_rank_cd.
-- Baris 3: 'kopi toraja ...' (posisi 1 dan 2, berdampingan).
-- Baris 4: 'kopi ... toraja' (posisi 1 dan 4, berjauhan).
SELECT id, judul,
       ts_rank(to_tsvector('simple', isi), to_tsquery('simple', 'kopi & toraja')) AS rank,
       ts_rank_cd(to_tsvector('simple', isi), to_tsquery('simple', 'kopi & toraja')) AS rank_cd
FROM artikel
WHERE id IN (3, 4)
ORDER BY id;

Output yang Dihasilkan:

text
 id |        judul        |    rank    |   rank_cd   
----+---------------------+------------+-------------
  3 | Kopi Toraja Pilihan | 0.09910322 |         0.1
  4 | Kopi Dataran Tinggi | 0.09735848 | 0.033333335
(2 rows)

Perbandingan kedua kolom adalah inti section ini. ts_rank hampir tidak peduli: 0.09910322 lawan 0.09735848 — selisih tipis karena ts_rank hanya memakai posisi sebagai faktor kecil. ts_rank_cd membedah jauh lebih tegas: 0.1 lawan 0.033333335, tiga kali lipat lebih tinggi untuk dokumen yang kata-katanya berdekatan. Untuk pencarian frasa atau istilah multi-kata (“kopi toraja”, “gula merah”), ts_rank_cd biasanya lebih sesuai dengan intuisi relevansi. Satu syarat teknis: ts_rank_cd membutuhkan informasi posisi lexeme, sehingga lexeme yang telah dilucuti posisinya (misalnya hasil strip()) diabaikan dan skornya nol.

Menormalkan Skor terhadap Panjang Dokumen #

Tanpa penyesuaian, skor ts_rank mengabaikan panjang dokumen: artikel 3 kata dan artikel 300 kata yang menyebut kopi sekali mendapat skor persis sama. Padahal kata yang sama pada dokumen pendek biasanya lebih bermakna. Kedua fungsi ranking menerima argumen ketiga opsional — bitmask normalisasi — untuk membagi skor dengan ukuran dokumen:

Opsi bitwiseEfek pada skor
0 (bawaan)Panjang dokumen diabaikan
1Skor dibagi 1 + log(panjang dokumen)
2Skor dibagi panjang dokumen
4Skor dibagi rata-rata jarak harmonik antar kecocokan (hanya ts_rank_cd)
8Skor dibagi jumlah kata unik dokumen
16Skor dibagi 1 + log(jumlah kata unik)
32Skor dibagi skor + 1 — hasil selalu di bawah 1

Flag dapat digabung dengan operator | (misalnya 2|4) dan diterapkan berurutan. Isi baris 5 terdiri atas 3 kata, isi baris 6 atas 7 kata, dan keduanya memuat kopi tepat sekali:

sql normalisasi.sql
-- Normalisasi skor terhadap panjang dokumen.
-- Baris 5 (isi 3 kata) dan baris 6 (isi 7 kata) sama-sama memuat 'kopi' sekali.
SELECT id, judul,
       ts_rank(to_tsvector('simple', isi), to_tsquery('simple', 'kopi')) AS tanpa_norm,
       ts_rank(to_tsvector('simple', isi), to_tsquery('simple', 'kopi'), 2) AS norm_2,
       ts_rank(to_tsvector('simple', isi), to_tsquery('simple', 'kopi'), 32) AS norm_32
FROM artikel
WHERE id IN (5, 6)
ORDER BY id;

Output yang Dihasilkan:

text
 id |       judul        | tanpa_norm |   norm_2    |   norm_32   
----+--------------------+------------+-------------+-------------
  5 | Warung Kopi Toraja | 0.06079271 | 0.020264236 | 0.057308756
  6 | Warung Kopi Enak   | 0.06079271 | 0.008684673 | 0.057308756
(2 rows)

Tiga kolom menceritakan tiga perilaku. Kolom tanpa_norm memberi nilai identik 0.06079271 — panjang dokumen diabaikan. Kolom norm_2 membagi skor dengan panjang dokumen sehingga artikel pendek (0.020264236) mengungguli artikel panjang (0.008684673). Kolom norm_32 menghasilkan 0.057308756 untuk keduanya — pembagian skor/(skor+1) hanya memampatkan skala ke rentang 0 sampai 1 tanpa mengubah urutan; flag ini kosmetik, berguna saat skor ditampilkan ke pengguna sebagai persentase.

Memberi Bobot Bagian Dokumen dengan setweight #

Struktur dokumen menyimpan sinyal relevansi: judul lebih penting daripada isi. Fungsi setweight(vector, 'A'|'B'|'C'|'D') menempel label bobot pada semua posisi sebuah tsvector, dan operator || menggabungkan dua vektor sambil mempertahankan label masing-masing. Resepnya: bedah dokumen per bagian, beri bobot sebelum digabung. Label bobot dibaca oleh fungsi ranking lewat array empat elemen dengan urutan {D, C, B, A}:

LabelUrutan arrayBobot bawaan
Aelemen ke-41.0
Belemen ke-30.4
Celemen ke-20.2
Delemen ke-10.1

Kolom tersimpan berikut membangun vektor gabungan judul-bobot-A dan isi-bobot-B sebagai generated column, sehingga setiap baris otomatis memiliki vektor siap ranking:

sql setweight.sql
-- Kolom tsvector tersimpan: judul diberi bobot A, isi diberi bobot B.
-- coalesce menjaga baris dengan isi NULL tetap menghasilkan vektor (bukan NULL).
ALTER TABLE artikel ADD COLUMN vektor tsvector
    GENERATED ALWAYS AS
        (setweight(to_tsvector('simple', judul), 'A')
         || setweight(to_tsvector('simple', coalesce(isi, '')), 'B')) STORED;

SELECT id, judul, vektor
FROM artikel
WHERE id IN (5, 6, 8)
ORDER BY id;

-- 'toraja' di judul (bobot A) melampaui 'toraja' yang hanya ada di isi (bobot B).
SELECT id, judul,
       ts_rank(vektor, to_tsquery('simple', 'toraja')) AS rank
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'toraja')
ORDER BY rank DESC, id;

-- Array bobot kustom: urutan {D, C, B, A}.
-- Bobot seragam menghapus keunggulan judul; bobot bawaan direproduksi persis.
SELECT id, judul,
       ts_rank(ARRAY[0.1, 0.2, 0.4, 1.0], vektor, to_tsquery('simple', 'toraja')) AS rank_bawaan,
       ts_rank(ARRAY[0.4, 0.4, 0.4, 0.4], vektor, to_tsquery('simple', 'toraja')) AS rank_seragam
FROM artikel
WHERE id IN (5, 6)
ORDER BY id;

Output yang Dihasilkan:

text
ALTER TABLE
 id |       judul        |                                                    vektor                                                    
----+--------------------+--------------------------------------------------------------------------------------------------------------
  5 | Warung Kopi Toraja | 'enak':5B 'kopi':2A,4B 'sekali':6B 'toraja':3A 'warung':1A
  6 | Warung Kopi Enak   | 'benar':10B 'dataran':5B 'enak':3A 'kopi':2A,4B 'nikmat':9B 'rasanya':8B 'tinggi':6B 'toraja':7B 'warung':1A
  8 | Catatan Harian     | 'catatan':1A 'harian':2A
(3 rows)

 id |        judul        |    rank    
----+---------------------+------------
  3 | Kopi Toraja Pilihan | 0.66871977
  5 | Warung Kopi Toraja  |  0.6079271
  4 | Kopi Dataran Tinggi | 0.24317084
  6 | Warung Kopi Enak    | 0.24317084
(4 rows)

 id |       judul        | rank_bawaan | rank_seragam 
----+--------------------+-------------+--------------
  5 | Warung Kopi Toraja |   0.6079271 |   0.24317084
  6 | Warung Kopi Enak   |  0.24317084 |   0.24317084
(2 rows)

Mari kita bedah hasil query utamanya baris demi baris:

  1. Baris 3 (Kopi Toraja Pilihan, 0.66871977): kata toraja muncul di judul (bobot A) dan di isi (bobot B) — frekuensi dan bobot bertumpuk menghasilkan skor tertinggi.
  2. Baris 5 (Warung Kopi Toraja, 0.6079271): toraja hanya ada di judul dengan bobot A — satu kemunculan berbobot penuh.
  3. Baris 4 dan 6 (0.24317084, seri): toraja hanya ada di isi dengan bobot B — tepat 0.4 kali lipat skor bobot A, lalu dipecah seri dengan id.

Query terakhir membuktikan bahwa array bobot adalah parameter biasa: array {0.1, 0.2, 0.4, 1.0} mereproduksi bobot bawaan secara persis, sedangkan array seragam {0.4, 0.4, 0.4, 0.4} meratakan semua label sehingga keunggulan judul hilang dan kedua baris jatuh seri di 0.24317084. Pola lengkap sebuah halaman hasil pencarian pun tinggal satu langkah lagi: ORDER BY rank DESC dengan pemecah seri, LIMIT untuk halaman pertama, dan vektor berbobot yang diindeks GIN — materi chapter GIN Index untuk Pencarian .

Referensi #

Berkas Sumber

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

buat-tabel.sql

Unduh berkas
sql
-- Delapan artikel Toko Maju Jaya: data sengaja direkayasa untuk demo ranking.
-- Baris 8 memiliki isi NULL untuk menunjukkan perilaku ts_rank pada input NULL.
CREATE TABLE artikel (
    id      integer PRIMARY KEY,
    judul   text NOT NULL,
    isi     text
);

INSERT INTO artikel VALUES
 (1, 'Gula Merah Jember',   'gula merah dan gula pasir'),
 (2, 'Gula Aren Madu',      'gula merah asli jember manis'),
 (3, 'Kopi Toraja Pilihan', 'kopi toraja enak rasanya'),
 (4, 'Kopi Dataran Tinggi', 'kopi enak rasanya toraja'),
 (5, 'Warung Kopi Toraja',  'kopi enak sekali'),
 (6, 'Warung Kopi Enak',    'kopi dataran tinggi toraja rasanya nikmat benar'),
 (7, 'Teh Melati Wangi',    'teh melati wangi rasanya'),
 (8, 'Catatan Harian',      NULL);

SELECT id, judul, coalesce(isi, '(isi kosong)') AS isi
FROM artikel
ORDER BY id;

normalisasi.sql

Unduh berkas
sql
-- Normalisasi skor terhadap panjang dokumen.
-- Baris 5 (isi 3 kata) dan baris 6 (isi 7 kata) sama-sama memuat 'kopi' sekali.
SELECT id, judul,
       ts_rank(to_tsvector('simple', isi), to_tsquery('simple', 'kopi')) AS tanpa_norm,
       ts_rank(to_tsvector('simple', isi), to_tsquery('simple', 'kopi'), 2) AS norm_2,
       ts_rank(to_tsvector('simple', isi), to_tsquery('simple', 'kopi'), 32) AS norm_32
FROM artikel
WHERE id IN (5, 6)
ORDER BY id;

rank-cd.sql

Unduh berkas
sql
-- Cover density: kedekatan posisi 'kopi' dan 'toraja' dihitung oleh ts_rank_cd.
-- Baris 3: 'kopi toraja ...' (posisi 1 dan 2, berdampingan).
-- Baris 4: 'kopi ... toraja' (posisi 1 dan 4, berjauhan).
SELECT id, judul,
       ts_rank(to_tsvector('simple', isi), to_tsquery('simple', 'kopi & toraja')) AS rank,
       ts_rank_cd(to_tsvector('simple', isi), to_tsquery('simple', 'kopi & toraja')) AS rank_cd
FROM artikel
WHERE id IN (3, 4)
ORDER BY id;

rank-dasar.sql

Unduh berkas
sql
-- ts_rank dasar: frekuensi kata menentukan skor.
-- Baris 1 memuat 'gula' dua kali, baris 2 sekali, keduanya sama panjang (5 kata).
SELECT id, judul,
       ts_rank(to_tsvector('simple', isi), to_tsquery('simple', 'gula')) AS rank
FROM artikel
ORDER BY rank DESC NULLS LAST, id;

-- Perilaku khusus: tanpa kecocokan skor 0; input NULL menghasilkan NULL.
SELECT ts_rank(to_tsvector('simple', 'teh wangi'), to_tsquery('simple', 'gula')) AS rank_tanpa_cocok,
       ts_rank(to_tsvector('simple', NULL),      to_tsquery('simple', 'gula')) AS rank_input_null;

setweight.sql

Unduh berkas
sql
-- Kolom tsvector tersimpan: judul diberi bobot A, isi diberi bobot B.
-- coalesce menjaga baris dengan isi NULL tetap menghasilkan vektor (bukan NULL).
ALTER TABLE artikel ADD COLUMN vektor tsvector
    GENERATED ALWAYS AS
        (setweight(to_tsvector('simple', judul), 'A')
         || setweight(to_tsvector('simple', coalesce(isi, '')), 'B')) STORED;

SELECT id, judul, vektor
FROM artikel
WHERE id IN (5, 6, 8)
ORDER BY id;

-- 'toraja' di judul (bobot A) melampaui 'toraja' yang hanya ada di isi (bobot B).
SELECT id, judul,
       ts_rank(vektor, to_tsquery('simple', 'toraja')) AS rank
FROM artikel
WHERE vektor @@ to_tsquery('simple', 'toraja')
ORDER BY rank DESC, id;

-- Array bobot kustom: urutan {D, C, B, A}.
-- Bobot seragam menghapus keunggulan judul; bobot bawaan direproduksi persis.
SELECT id, judul,
       ts_rank(ARRAY[0.1, 0.2, 0.4, 1.0], vektor, to_tsquery('simple', 'toraja')) AS rank_bawaan,
       ts_rank(ARRAY[0.4, 0.4, 0.4, 0.4], vektor, to_tsquery('simple', 'toraja')) AS rank_seragam
FROM artikel
WHERE id IN (5, 6)
ORDER BY id;