AI
RAG Bahasa Indonesia: Full-Text Search Postgres Sering Meleset
September 202613 menit baca

Ya. PostgreSQL 16 membawa 29 text search configuration dan indonesian salah satunya, memakai Snowball stemmer bahasa Indonesia. Dukungannya nyata tetapi belum lengkap: configuration itu tidak mendaftarkan stop word list, sehingga setiap yang, dan, di dan dari ikut ter-index lalu menjadi term wajib di query Anda.
Awalan me- dalam bahasa Indonesia berasimilasi dan menyerap konsonan pertama kata dasarnya, sehingga menerima menyembunyikan huruf t dari terima di dalam bunyi n. Snowball stemmer membuang awalan tanpa mengembalikan konsonan itu sehingga menghasilkan erima, sedangkan terima sendiri salah dibaca sebagai ter- plus ima. Kedua bentuk berakhir sebagai lexeme berbeda dan tidak pernah cocok.
Snowball menerbitkan stop word list bahasa Indonesia yang tidak dipasang Postgres. Ratakan menjadi satu kata polos per baris, simpan sebagai indonesian.stop di direktori tsearch_data yang ditunjukkan pg_config --sharedir, lalu buat dictionary snowball dengan StopWords bernilai indonesian dan petakan configuration salinan ke dictionary itu. Tambahkan juga kata noise dari corpus Anda sendiri.
Di SEA-BED, kolom Indonesia pada Tabel 2 paper SEA-Embedding mencatat SEA-Embedding-E5-Large-600M 0.808, Cohere-embed-multilingual-v3.0 0.797, multilingual-e5-large-instruct 0.795 dan bge-m3 dense 0.781. Itu rata-rata per bahasa di sembilan jenis task, bukan skor retrieval saja. Sebarannya untuk bahasa Indonesia sempit, jadi sisi lexical retriever Anda lebih menentukan daripada pilihan model.
Tidak kalau berdiri sendiri. Bahkan setelah stop word list dipasang, stemmer-nya masih salah menangani beberapa kata kerja berawalan me- yang umum, dan dalam pengujian saya query kata dasar gagal menemukan dokumen berimbuhan dua kali dari lima percobaan. Pakai sebagai sisi yang lebih lemah dari hybrid retriever, dengan multilingual embedding model memikul bobot lebih besar.

Ringkasan Utama
PostgreSQL 16 menyediakan text search configuration indonesian, tetapi tidak mendaftarkan stop word list apa pun dan Snowball stemmer-nya tidak mengembalikan asimilasi nasal bahasa Indonesia. Mencari terima tidak menemukan dokumen berisi menerima. Pasang stop word list Indonesia dari Snowball, uji setiap keluarga imbuhan yang dipakai domain Anda, dan padukan lexical search dengan multilingual embedding model.
Sebuah keyword query yang seharusnya cocok malah tidak mengembalikan apa pun. Frasanya pembayaran yang belum diterima, sedangkan dokumen tersimpan berbunyi pembayaran belum diterima dari pelanggan. Kata-katanya sama, hanya satu kata sambung yang hilang. Postgres full-text search, configuration indonesian, tidak ada hasil. Pada 18 September 2026 saya membuka psql di PostgreSQL 16.11 dan menghabiskan satu sore membongkar configuration itu.
Semua di bawah ini saya ukur sendiri di instance tersebut dan setiap barisnya bisa Anda ulang dengan psql. Temuannya bukan bahwa Postgres tidak mendukung bahasa Indonesia, justru sebaliknya, dan di situlah masalahnya. Configuration-nya ada, terlihat benar di catalogue, tetapi diam-diam meng-index semua stop word dan salah men-stem banyak kata kerja yang justru paling sering dicari.
Di titik inilah kebanyakan tulisan berhenti, dan itu titik yang salah. PostgreSQL 16.11 membawa 29 text search configuration dan indonesian salah satunya, bersama arabic, hindi, nepali dan tamil. to_tsvector dengan configuration indonesian jalan, GIN index di atasnya terbentuk, EXPLAIN menunjukkan index itu terpakai, dan tidak ada satu pun peringatan. Jadi Anda mencentang dukungan bahasa Indonesia lalu lanjut, persis seperti yang saya lakukan.
-- PostgreSQL 16.11. The trap is not a missing config, it is a missing
-- parameter on the dictionary INSIDE a config that already exists.
SELECT c.cfgname, d.dictname, d.dictinitoption
FROM pg_ts_config c
JOIN pg_ts_config_map m ON m.mapcfg = c.oid
JOIN pg_ts_dict d ON d.oid = m.mapdict
WHERE c.cfgname IN ('english', 'indonesian')
GROUP BY 1, 2, 3 ORDER BY 1, 2;
-- cfgname | dictname | dictinitoption
-- ------------+-----------------+---------------------------------------------
-- english | english_stem | language = 'english', stopwords = 'english'
-- indonesian | indonesian_stem | language = 'indonesian'
--
-- No stopwords parameter. No warning, no notice, no error at CREATE INDEX time.Perbedaannya dengan English bersembunyi di satu kolom pg_ts_dict yang tidak pernah dibaca siapa pun. english_stem dibuat dengan parameter language dan parameter stopwords. indonesian_stem dibuat dengan parameter language saja. Ketiadaan itu berpasangan dengan ketiadaan di disk: tidak ada file indonesian.stop di direktori tsearch_data, jadi parameter yang hilang itu pun tidak punya sasaran seandainya Anda menambahkannya.
Kalimat bahasa Indonesia sepanjang tiga belas kata menghasilkan tiga belas lexeme. Kalimat pembanding berbahasa Inggris sepanjang sembilan belas kata menghasilkan delapan. Tidak ada satu pun yang dibuang dari sisi Indonesia, tidak yang, tidak dan, tidak di, tidak dari, tidak belum. Jalankan keduanya berdampingan dan ketimpangannya langsung terlihat, tetapi hanya kalau Anda terpikir untuk menjalankannya, dan tidak ada apa pun di tooling yang menyarankan itu.
-- 13 Indonesian words in.
SELECT to_tsvector('indonesian',
'Pelanggan yang sudah melakukan pembayaran di aplikasi dan belum menerima invoice dari sistem');
-- 'aplikasi':7 'bayar':5 'belum':9 'dan':8 'dari':12 'di':6 'erima':10
-- 'invoice':11 'laku':4 'langgan':1 'sistem':13 'sudah':3 'yang':2
-- 13 lexemes out. Nothing dropped.
-- 19 English words in, as a control.
SELECT to_tsvector('english',
'Customers who have made a payment in the app and have not yet received an invoice from the system');
-- 'app':9 'custom':1 'invoic':16 'made':4 'payment':6 'receiv':14 'system':19 'yet':13
-- 8 lexemes out. 11 words dropped.
-- The same asymmetry on bare function words:
SELECT to_tsvector('indonesian', 'yang dan di dari sudah belum');
-- 'belum':6 'dan':2 'dari':4 'di':3 'sudah':5 'yang':1 -- all six kept
SELECT to_tsvector('english', 'the and in from already not');
-- 'alreadi':5 -- five of six droppedIni soal file yang tidak ada, bukan soal preferensi tuning. Postgres memasang lima belas stop word list di tsearch_data: danish, dutch, english, finnish, french, german, hungarian, italian, nepali, norwegian, portuguese, russian, spanish, swedish dan turkish. english.stop berisi 127 kata. Bahasa Indonesia tidak kebagian, sehingga kata yang, yang muncul di sebagian besar kalimat, berakhir dengan posting list sebesar tabel Anda, dan setiap ranking function harus menimbangnya seperti term biasa.
Menyimpan stop word di index memakan storage. Menyimpannya di query memakan recall, dan recall itulah yang sampai ke Anda sebagai laporan bug. plainto_tsquery menyambung term yang diambilnya dengan AND, jadi setiap kata fungsi yang kebetulan diketik pengguna berubah menjadi term yang wajib ada di dokumen.
-- plainto_tsquery joins its terms with AND. Every stop word the user typed
-- becomes a term the document is REQUIRED to contain.
SELECT plainto_tsquery('indonesian', 'pembayaran yang belum diterima');
-- 'bayar' & 'yang' & 'belum' & 'terima' -- 4 required terms, 2 meaningless
SELECT plainto_tsquery('english', 'payment that has not been received');
-- 'payment' & 'receiv' -- 2 required terms
-- And here is the bug report you will actually receive.
SELECT doc, to_tsvector('indonesian', doc)
@@ plainto_tsquery('indonesian', 'pembayaran yang belum diterima') AS hit
FROM unnest(ARRAY[
'Pembayaran belum diterima dari pelanggan', -- f no 'yang', no match
'Pembayaran yang belum diterima dari pelanggan', -- t
'Invoice ini belum dibayar oleh pelanggan' -- f
]) AS doc;
-- Dropping 'yang' is a style choice in Indonesian, not a change of meaning.
-- The index treats the two sentences as different documents.Semakin panjang dan semakin wajar query bahasa Indonesia-nya, semakin rendah recall-nya. Itu kebalikan persis dari yang diajarkan test suite berbahasa Inggris, dan itu sebabnya keluhannya selalu berbentuk sama: pencarian jalan kalau saya ketik dua kata, dan berhenti jalan kalau saya ketik satu kalimat.
Empat term wajib, dua di antaranya tanpa makna. Pembanding bahasa Inggrisnya menghasilkan dua. Dan dokumen yang menulis kalimat sama tanpa kata yang, yang dalam bahasa Indonesia soal ragam bahasa bukan soal makna, sama sekali tidak bisa cocok. Pasangan itu saya ukur langsung: versi dengan yang cocok, versi tanpa yang tidak, dan tidak ada bagian sistem yang menganggap itu aneh.
Bahasa Indonesia membentuk kata kerja dengan awalan me-, dan awalan itu berasimilasi dengan bunyi pertama kata dasarnya lalu menyerapnya. me- plus tulis menjadi menulis, huruf t ditelan oleh n. me- plus kirim menjadi mengirim, huruf k lebur ke ng. me- plus masak menjadi memasak. Bagi pembaca konsonan dasarnya tidak hilang, ia tersimpan di bunyi nasal, dan setiap penutur membongkarnya tanpa perlu berpikir.
Algoritma Indonesia milik Snowball, yang dipakai Postgres, adalah implementasi stemmer bergaya Porter untuk bahasa Indonesia yang dijelaskan dalam tesis Fadillah Z Tala tahun 2003. Ia membuang awalan, termasuk varian men, meng, meny dan mem, tetapi tidak mengembalikan konsonan yang tertelan. Jadi menerima kehilangan men lalu menjadi erima, sementara terima sendiri salah dibaca sebagai awalan ter- plus kata dasar ima. Satu keluarga kata pecah menjadi tiga lexeme terpisah: terima menghasilkan ima, diterima menghasilkan terima, dan menerima serta penerimaan sama-sama menghasilkan erima.
| Bentuk tertulis | Stem yang dihasilkan | Kata dasar | Stem kata dasar | Lexeme sama? |
|---|---|---|---|---|
| menerima | erima | terima | ima | Tidak |
| menulis | ulis | tulis | tulis | Tidak |
| mengirim | irim | kirim | kirim | Tidak |
| memasak | pasak | masak | masak | Tidak |
| menyimpan | simpan | simpan | simpan | Ya |
| memproses | proses | proses | proses | Ya |
| mencatat | catat | catat | catat | Ya |
Perhatikan lagi baris memasak. Stem-nya bukan sekadar salah, melainkan pasak, sebuah kata Indonesia sungguhan yang berarti patok kayu. Saya cek dua arah: query pasak cocok dengan dokumen tentang memasak nasi, dan query memasak cocok dengan dokumen tentang memasang pasak kayu. Itu bukan meleset tipis, itu tabrakan diam-diam antara dua topik yang tidak berhubungan, dan tidak ada log yang akan menyebutnya.
Tiga dari tujuh kata kerja yang saya uji ter-stem dengan benar. menyimpan, memproses dan mencatat semuanya kembali ke kata dasarnya. Keluarga bayar bahkan sempurna: bayar, membayar, pembayaran, dibayar, membayarkan dan pembayar semuanya menghasilkan satu lexeme bayar, persis seperti yang Anda harapkan dari sebuah stemmer.
-- Search the root, see whether the inflected document comes back.
SELECT q, doc, to_tsvector('indonesian', doc) @@ plainto_tsquery('indonesian', q) AS hit
FROM (VALUES
('terima', 'Kami sudah menerima barang dari vendor'), -- f
('kirim', 'Barang akan dikirim besok'), -- t di- is stripped fine
('kirim', 'Kami mengirim barang besok'), -- f meng- is not
('masak', 'Juru masak sedang memasak nasi'), -- t matched the bare root
('bayar', 'Pelanggan sudah membayar tagihan') -- t
) AS v(q, doc);
-- 2 misses out of 5. Same root, two prefixes, one of them works.
-- Now the family that makes you stop testing:
SELECT w, to_tsvector('indonesian', w)
FROM unnest(ARRAY['bayar','membayar','pembayaran','dibayar','membayarkan','pembayar']) AS w;
-- every one of the six gives 'bayar'. Perfect. So nobody checks the next verb.Itulah yang membuat masalah ini mahal. Uji sekilas dengan bayar, simpulkan stemmer-nya benar, lalu rilis. Secara end to end, mencari kata dasar gagal menemukan dokumen berimbuhan dua kali dari lima percobaan. kirim cocok dengan dokumen berisi dikirim dan gagal pada dokumen berisi mengirim, kata dasar sama dengan dua awalan, hanya satu yang selamat. Tidak ada error, tidak ada baris log, tidak ada metric yang bergerak. Satu-satunya sinyal adalah pengguna yang bilang pencariannya jelek, berbulan-bulan kemudian.
Halaman Indonesia milik Snowball menerbitkan stop word list. Postgres saja yang tidak pernah memasangnya. File terbitannya 91 baris dan setelah diratakan menjadi 93 kata unik, dan memasukkannya ke tsearch_data bersama sebuah dictionary yang merujuknya memakan waktu di bawah dua puluh menit. Kalimat uji saya turun dari tiga belas lexeme menjadi delapan, query empat term menyusut jadi dua term, dan dokumen yang tadinya hilang mulai muncul.
# Snowball publishes the list; Postgres simply never installs it.
# The published file is annotated -- word, tab, a pipe, an English gloss --
# and a few lines hold two words. Postgres wants one bare word per line and
# does no other processing, so strip the comments and split the pairs first.
curl -sL https://snowballstem.org/algorithms/indonesian/stop.txt |
awk -F'|' '{ print $1 }' | tr -s ' \t' '\n' | sed '/^$/d' | sort -u \
> "$(pg_config --sharedir)/tsearch_data/indonesian.stop" # 93 words-- Same Snowball stemmer, now with the stop list the shipped config omits.
CREATE TEXT SEARCH DICTIONARY indonesian_stop_stem (
TEMPLATE = snowball,
Language = indonesian,
StopWords = indonesian -- resolves to tsearch_data/indonesian.stop
);
CREATE TEXT SEARCH CONFIGURATION id_search (COPY = indonesian);
ALTER TEXT SEARCH CONFIGURATION id_search
ALTER MAPPING FOR asciiword, asciihword, hword_asciipart,
word, hword, hword_part
WITH indonesian_stop_stem;
-- Same sentence as before: 13 lexemes becomes 8.
-- And the query that missed now matches:
SELECT to_tsvector('id_search', 'Pembayaran belum diterima dari pelanggan')
@@ plainto_tsquery('id_search', 'pembayaran yang belum diterima'); -- t
-- because the query itself shrank from four required terms to two:
SELECT plainto_tsquery('id_search', 'pembayaran yang belum diterima');
-- 'bayar' & 'terima'Masukkan juga noise dari corpus Anda sendiri ke file yang sama. Kalau sebuah kata muncul di hampir setiap record yang Anda index, misalnya judul kolom seperti nomor atau keterangan yang ikut tercetak di setiap dokumen hasil export, kata itu memakan ruang index dan mengencerkan ranking persis seperti kata yang. Hitung dulu document frequency-nya, jangan menebak isi daftarnya.
Dua jebakan sebelum Anda menyalin file itu. File-nya beranotasi, tiap baris berisi kata, tab, tanda pipa dan terjemahan Inggrisnya, dan beberapa baris memuat dua kata. Postgres ingin satu kata polos per baris dan tidak melakukan pemrosesan lain, jadi buang dulu komentarnya dan pecah pasangannya. Kedua, daftar itu belum lengkap untuk teks bisnis: ada telah tetapi tidak ada sudah, padahal sudah muncul terus-menerus di tulisan operasional berbahasa Indonesia. Tambahkan kata Anda sendiri dan simpan file-nya di version control.
Stemmer-nya sama sekali tidak tersentuh. menerima tetap menghasilkan erima setelah perbaikan itu, dan akan selalu begitu, karena stop list dan stemmer adalah bagian dictionary yang terpisah. Tersisa tiga pilihan dan tidak ada yang gratis.
-- Option 1, the tempting regex. Do not ship this.
SELECT w, regexp_replace(w, '^(me|mem|men|meng|meny)', '') AS naive
FROM unnest(ARRAY['menerima','mengirim','memasak','menulis','meja']) AS w;
-- menerima -> erima still wrong
-- mengirim -> irim still wrong
-- meja -> ja "table" is now a fragment; the rule has no idea
-- which words are even verbs
-- Option 2, trigrams as a fuzzy net. Recovers some morphology by accident.
CREATE EXTENSION IF NOT EXISTS pg_trgm;
SELECT w, similarity('terima', w), 'terima' % w AS passes_default_threshold
FROM unnest(ARRAY['terima','diterima','menerima','penerimaan']) AS w;
-- terima 1.0 t
-- diterima 0.4545 t
-- menerima 0.3333 t -- clears the 0.3 default from show_limit()
-- penerimaan 0.2 f -- does not
-- Good enough for a short field. Not a document search.Di sistem production saya akan menjalankan ketiganya, dengan urutan usaha seperti itu: stop list di hari pertama, trigram index pada field pendek yang benar-benar diketik orang, dan bobot fusion yang tidak berpura-pura bahwa ts_rank atas teks Indonesia sama bisa dipercayanya dengan ts_rank atas teks Inggris. Yang ketiga memang penilaian subjektif, dan menuliskannya sebagai angka di config lebih baik daripada membiarkannya tersirat.
SEA-BED adalah benchmark embedding untuk Asia Tenggara, 169 dataset di 10 bahasa dan 9 jenis task. Di Tabel 2 paper SEA-Embedding, kolom Indonesia berbunyi: SEA-Embedding-E5-Large-600M 0.808, Cohere-embed-multilingual-v3.0 0.797, multilingual-e5-large-instruct 0.795, bge-m3 dense 0.781, Qwen3-Embedding-0.6B 0.756. Itu skor rata-rata per bahasa di seluruh sembilan jenis task, bukan skor retrieval saja, dan mengutipnya sebagai angka retrieval jelas keliru.
Baca sebaran angkanya, bukan peringkatnya. Untuk bahasa Indonesia jarak antara model terkuat dan terlemah di daftar itu sekitar lima poin. Kolom Lao di tabel yang sama terentang dari 0.841 sampai 0.298. Bahasa Indonesia termasuk yang paling terlayani di setiap multilingual encoder serius, dan itu memberi tahu ke mana recall Anda sebenarnya pergi: bukan ke pilihan embedding, melainkan ke sisi lexical yang Anda konfigurasi dalam sepuluh detik. MMTEB, yang mencakup lebih dari 500 task di lebih dari 250 bahasa, menunjuk arah sama dengan menyebut multilingual-e5-large-instruct berukuran 560 juta parameter sebagai model publik terbaiknya, di atas model berukuran miliaran parameter.
Satu hal yang tidak saya benchmark dan tidak akan saya klaim: chunk size. Imbuhan bahasa Indonesia membuat kata lebih panjang dalam jumlah karakter dibanding padanan Inggrisnya, sehingga chunker yang memotong berdasarkan jatah karakter hasil tuning bahasa Inggris akan mendarat di tempat berbeda relatif terhadap batas kalimat. Ukur panjang chunk Anda sendiri dengan tokenizer Anda sendiri sebelum memakai ulang default bahasa Inggris, dan anggap angka chunk size apa pun yang disodorkan tanpa menyebut tokenizer-nya sebagai hiasan belaka.
Mengaktifkan text search configuration indonesian tidak sama dengan mendukung pencarian bahasa Indonesia, dan selisihnya tidak terlihat dari catalogue. Pasang stop word list sebelum Anda meng-index apa pun. Uji setiap keluarga imbuhan yang benar-benar dipakai domain Anda, bukan satu yang kebetulan berhasil. Perlakukan Postgres full-text search sebagai sisi yang lebih lemah dari hybrid retriever bahasa Indonesia, bukan sisi yang Anda tuning. Kalau hanya satu yang sempat dikerjakan, kerjakan stop list-nya: dua puluh menit, dan recall-nya naik di hari yang sama.
Sumber