Sebuah situs artikel menyimpan puluhan teks panjang di kolom TEXT, lalu pengunjung mengetik satu kata kunci seperti “kopi” dan berharap artikel paling relevan muncul paling atas. Kebiasaan pertama pemula adalah LIKE '%kopi%' — sederhana, tetapi wildcard di awal pola mematikan index B-tree sehingga MySQL membaca setiap baris dari awal hingga akhir. Full-text search adalah jawaban MySQL untuk kebutuhan tersebut: index khusus bertipe FULLTEXT memetakan setiap kata ke daftar baris yang memuatnya, kemudian menghitung skor relevansi untuk mengurutkan hasil. Pada chapter ini kita bedah masalah LIKE '%kata%', cara kerja index terbalik di balik FULLTEXT, konsep relevansi, dan kapan sebaiknya kita memilih full-text search dibanding LIKE.

Masalah Pencarian dengan LIKE ‘%kata%’ #

Kita siapkan database latihan belajar_fts berisi tabel artikel dengan sepuluh artikel pendek berbahasa Indonesia bertema teknologi, kopi, dan masakan. Kolom judul juga kita beri index B-tree biasa bernama idx_judul sebagai pembanding, dan index FULLTEXT bernama idx_ft_artikel pada pasangan kolom judul, isi:

sql
DROP DATABASE IF EXISTS belajar_fts;
CREATE DATABASE belajar_fts;
USE belajar_fts;

Output yang Dihasilkan:

text
Query OK, 0 rows affected, 1 warning (0.00 sec)

Query OK, 1 row affected (0.00 sec)

Database changed
sql
CREATE TABLE artikel (
  id INT AUTO_INCREMENT PRIMARY KEY,
  judul VARCHAR(100) NOT NULL,
  isi TEXT NOT NULL,
  INDEX idx_judul (judul),
  FULLTEXT idx_ft_artikel (judul, isi)
) ENGINE=InnoDB;

INSERT INTO artikel (judul, isi) VALUES
  ('Belajar Database Relasional', 'Database relasional menyimpan data dalam tabel yang saling terhubung. MySQL adalah database relasional populer untuk aplikasi web. Indeks mempercepat pencarian data pada kolom tertentu.'),
  ('Panduan Menyeduh Espresso', 'Espresso adalah basis minuman kopi modern. Seduh espresso memakai mesin kopi bertekanan tinggi dan biji kopi gelap. Grinder yang baik menghasilkan rasa espresso seimbang.'),
  ('Memasak Rendang Daging', 'Rendang daging adalah masakan khas Padang. Memasak rendang membutuhkan santan, cabai, dan rempah. Proses memasak rendang memakan waktu beberapa jam hingga kuah mengering.'),
  ('Cloud Computing untuk Pemula', 'Cloud computing menyediakan server dan penyimpanan lewat internet. Perusahaan menyewa cloud agar tidak membeli server fisik. Layanan cloud populer contohnya penyimpanan berkas dan basis data terkelola.'),
  ('Asal Biji Kopi Gayo', 'Kopi Gayo tumbuh di dataran tinggi Aceh. Petani kopi Gayo memanen biji secara manual. Citarasa kopi Gayo khas dengan aroma rempah dan keasaman seimbang.'),
  ('Nasi Goreng Kampung', 'Nasi goreng kampung adalah masakan rumahan favorit. Bumbu nasi goreng terdiri dari bawang, cabai, dan kecap. Memasak nasi goreng butuh api besar agar wangi.'),
  ('JavaScript di Browser', 'JavaScript membuat halaman web menjadi interaktif. Browser menjalankan JavaScript pada sisi klien. Framework JavaScript mempercepat pembuatan antarmuka aplikasi modern.'),
  ('Cold Brew vs Kopi Panas', 'Cold brew diseduh dengan air dingin selama belasan jam. Rasa cold brew lebih halus dibanding kopi panas. Penikmat kopi sering menyimpan cold brew di kulkas.'),
  ('Tempe Sumber Protein', 'Tempe adalah makanan fermentasi kedelai khas Indonesia. Tempe mengandung protein tinggi dan harganya murah. Olahan tempe contohnya tempe goreng, tempe bacem, dan tempe crispy.'),
  ('Keamanan Aplikasi Web', 'Keamanan aplikasi web wajib dijaga sejak awal pengembangan. Pengembang memvalidasi input pengguna dan menyimpan sandi terenkripsi. Serangan umum pada aplikasi web contohnya SQL injection.');

Output yang Dihasilkan:

text
Query OK, 0 rows affected (0.00 sec)

Query OK, 10 rows affected (0.00 sec)
Records: 10  Duplicates: 0  Warnings: 0

Coba kita cari semua artikel yang menyebut kata “kopi” di kolom isi memakai pola kebiasaan lama:

sql
SELECT id, judul FROM artikel WHERE isi LIKE '%kopi%' ORDER BY id;

Output yang Dihasilkan:

text
+----+---------------------------+
| id | judul                     |
+----+---------------------------+
|  2 | Panduan Menyeduh Espresso |
|  5 | Asal Biji Kopi Gayo       |
|  8 | Cold Brew vs Kopi Panas   |
+----+---------------------------+
3 rows in set (0.00 sec)

Hasilnya benar: tiga artikel tentang kopi ditemukan. Biaya di baliknya baru terlihat saat kita minta rencana eksekusi query tersebut. Sebelum membaca EXPLAIN, kita segarkan dulu statistik tabel dengan ANALYZE TABLE supaya estimasi jumlah baris pada rencana eksekusi stabil dan tidak terombang-ambing oleh statistik sementara InnoDB:

sql
ANALYZE TABLE artikel;

Output yang Dihasilkan:

text
+---------------------+---------+----------+----------+
| Table               | Op      | Msg_type | Msg_text |
+---------------------+---------+----------+----------+
| belajar_fts.artikel | analyze | status   | OK       |
+---------------------+---------+----------+----------+
1 row in set (0.00 sec)
sql
EXPLAIN SELECT id FROM artikel WHERE isi LIKE '%kopi%';

Output yang Dihasilkan:

text
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+-------------+
| id | select_type | table   | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra       |
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+-------------+
|  1 | SIMPLE      | artikel | NULL       | ALL  | NULL          | NULL | NULL    | NULL |   10 |    11.11 | Using where |
+----+-------------+---------+------------+------+---------------+------+---------+------+------+----------+-------------+
1 row in set, 1 warning (0.00 sec)

Kolom type bernilai ALL — MySQL melakukan full table scan, membaca seluruh baris satu per satu, membandingkan pola LIKE terhadap isi setiap artikel, lalu membuang yang tidak cocok. Index B-tree tidak bisa membantu karena index tersebut bekerja dengan melompat ke area nilai yang berurutan: wildcard % di awal pola berarti teks yang dicari boleh diawali karakter apa pun, sehingga tidak ada satu area pun di index yang bisa menjadi titik mulai pencarian. Sebagai pembanding, pola awalan tetap sehat:

sql
EXPLAIN SELECT id FROM artikel WHERE judul LIKE 'Belajar%';

Output yang Dihasilkan:

text
+----+-------------+---------+------------+-------+--------------------------+-----------+---------+------+------+----------+--------------------------+
| id | select_type | table   | partitions | type  | possible_keys            | key       | key_len | ref  | rows | filtered | Extra                    |
+----+-------------+---------+------------+-------+--------------------------+-----------+---------+------+------+----------+--------------------------+
|  1 | SIMPLE      | artikel | NULL       | range | idx_judul,idx_ft_artikel | idx_judul | 402     | NULL |    1 |   100.00 | Using where; Using index |
+----+-------------+---------+------------+-------+--------------------------+-----------+---------+------+------+----------+--------------------------+
1 row in set, 1 warning (0.00 sec)

Kali ini type bernilai range dengan key = idx_judul — index B-tree pada kolom judul dipakai untuk melompat langsung ke area “Belajar…”. Dengan sepuluh baris latihan perbedaannya tak terasa; dengan satu juta artikel, LIKE '%kata%' membaca satu juta baris setiap kali pengunjung mengetik, sementara LIKE 'awalan%' tetap melompat ke sedikit baris yang relevan.

FULLTEXT Index dan Index Terbalik #

Index FULLTEXT menyelesaikan masalah dengan struktur yang berbeda. Index B-tree mengurutkan baris berdasarkan nilai kolom, sedangkan full-text index membangun index terbalik (inverted index): server memecah teks setiap baris menjadi kata-kata (token), lalu untuk setiap kata unik menyimpan daftar baris yang memuat kata tersebut. Mencari kata “kopi” kini berarti satu lompatan ke entri “kopi” pada index — langsung diperoleh daftar artikel 2, 5, dan 8 — tanpa membaca artikel lain sama sekali. Diagram berikut memperlihatkan pemetaannya pada data kita:

Query pengganti LIKE '%kopi%' memakai fungsi MATCH ... AGAINST:

sql
SELECT id, judul FROM artikel WHERE MATCH(judul, isi) AGAINST ('kopi') ORDER BY id;

Output yang Dihasilkan:

text
+----+---------------------------+
| id | judul                     |
+----+---------------------------+
|  2 | Panduan Menyeduh Espresso |
|  5 | Asal Biji Kopi Gayo       |
|  8 | Cold Brew vs Kopi Panas   |
+----+---------------------------+
3 rows in set (0.00 sec)

Hasilnya sama — artikel 2, 5, dan 8 — tetapi jalur eksekusinya sudah berganti:

sql
EXPLAIN SELECT id FROM artikel WHERE MATCH(judul, isi) AGAINST ('kopi');

Output yang Dihasilkan:

text
+----+-------------+---------+------------+----------+----------------+----------------+---------+-------+------+----------+-------------------------------+
| id | select_type | table   | partitions | type     | possible_keys  | key            | key_len | ref   | rows | filtered | Extra                         |
+----+-------------+---------+------------+----------+----------------+----------------+---------+-------+------+----------+-------------------------------+
|  1 | SIMPLE      | artikel | NULL       | fulltext | idx_ft_artikel | idx_ft_artikel | 0       | const |    1 |   100.00 | Using where; Ft_hints: sorted |
+----+-------------+---------+------------+----------+----------------+----------------+---------+-------+------+----------+-------------------------------+
1 row in set, 1 warning (0.00 sec)

Kolom type kini bernilai fulltext dengan key = idx_ft_artikel dan ref = const: kata pencarian dicari sebagai entri pada index terbalik, bukan dibandingkan terhadap isi setiap baris. Perhatikan juga daftar kolom di MATCH(judul, isi) — daftar tersebut harus persis sama dengan kolom yang didefinisikan pada index FULLTEXT; aturan ini kita buktikan lewat galat nyata di chapter berikutnya. Query di atas tanpa ORDER BY sebenarnya sudah mengembalikan baris terurut relevansi tertinggi lebih dulu, tetapi kita tetap menulis ORDER BY id agar urutan latihan ini deterministik — kebiasaan yang akan kita pakai sepanjang section.

Relevansi: Memberi Skor pada Hasil #

Full-text search tidak berhenti pada “cocok atau tidak”. Fungsi MATCH ... AGAINST mengembalikan angka desimal bernilai tidak negatif untuk setiap baris — skor kemiripan antara kata pencarian dan isi baris. Skor nol berarti tidak ada kemiripan, dan baris berskor nol tidak dikembalikan sama sekali ketika MATCH dipakai di klausa WHERE. Perhitungan skor mempertimbangkan jumlah kata pada baris, jumlah kata unik di baris tersebut, total kata pada seluruh koleksi, dan berapa baris yang memuat kata yang dicari — kata langka yang hanya muncul di satu artikel memberi kontribusi skor lebih besar daripada kata yang muncul di mana-mana.

Konsekuensinya, hasil pencarian bisa diurutkan: artikel yang membahas “kopi” sepuluh kali akan tampil lebih atas daripada artikel yang menyebutnya sekali di tengah kalimat. Fitur pengurutan berbasis skor inilah perbedaan mendasar dibanding LIKE, yang hanya menjawab ya atau tidak tanpa tingkatan. Angka skornya sendiri akan kita lihat nyata — lengkap dengan nilai desimalnya — di chapter Natural Language Search.

Kapan Memilih Full-Text Search atau LIKE #

Kedua alat memiliki wilayah kerja masing-masing. Tabel berikut merangkum pertimbangan yang paling sering kita hadapi:

AspekLIKE + index B-treeFull-Text Search
Pola pencarianAwalan 'kopi%' atau nilai persisKata atau frasa di tengah teks panjang
Index yang dipakaiB-treeFULLTEXT (index terbalik)
Urutan relevansiTidak ada — ya atau tidak sajaSkor relevansi, hasil bisa diurutkan
Kata di tengah teksPemindaian penuh (type = ALL)Tetap memakai index (type = fulltext)
Kolom yang cocokPendek: kode, nama, emailPanjang: TEXT, artikel, deskripsi
Contoh kasusCari produk dengan kode 'SKU-12%'Cari artikel yang membahas “kopi”

Batas Awal: Panjang Kata Minimum #

Index terbalik tidak menyimpan semua kata. Server membuang kata yang lebih pendek dari panjang minimum sebelum memasukkannya ke index, dan panjang minimum itu berbeda antara storage engine. Kita buktikan dengan membaca konfigurasi nyata pada server kita:

sql
SHOW VARIABLES LIKE 'innodb_ft_min_token_size';
SHOW VARIABLES LIKE 'ft_min_word_len';

Output yang Dihasilkan:

text
+--------------------------+-------+
| Variable_name            | Value |
+--------------------------+-------+
| innodb_ft_min_token_size | 3     |
+--------------------------+-------+
1 row in set (0.00 sec)

+-----------------+-------+
| Variable_name   | Value |
+-----------------+-------+
| ft_min_word_len | 4     |
+-----------------+-------+
1 row in set (0.00 sec)

Variabel innodb_ft_min_token_size mengatur index FULLTEXT di InnoDB dan bernilai 3 secara bawaan, sedangkan ft_min_word_len mengatur index di MyISAM dan bernilai 4. Artinya, pada tabel InnoDB kata “api” ikut terindeks, tetapi kata dua huruf seperti “di” atau “ke” dibuang dan tidak akan pernah ditemukan oleh MATCH ... AGAINST. Kebanyakan kata bahasa Indonesia berarti tiga huruf atau lebih sehingga batasan ini jarang terasa — tetapi kata depan pendek seperti “di” dan “ke” memang tidak bisa dicari lewat full-text search. Konsekuensi lengkapnya — termasuk daftar stopword yang ikut dibuang — kita bahas di chapter Batasan Full-Text Search.

Membersihkan Database Latihan #

Sebelum menutup, kita jatuhkan database latihan chapter ini:

sql
DROP DATABASE belajar_fts;

Output yang Dihasilkan:

text
Query OK, 1 row affected (0.00 sec)

Referensi #

Kita sudah melihat peta besarnya: LIKE '%kata%' cocok untuk teks pendek berawalan jelas, sementara MATCH ... AGAINST memakai index terbalik untuk melayani pencarian kata di tengah teks panjang lengkap dengan skor relevansi. Langkah berikutnya adalah praktik membuatnya: tiga cara mendeklarasikan index FULLTEXT, memverifikasinya lewat SHOW INDEX, dan galat ERROR 1191 yang muncul kalau kita lupa — semua itu dibahas di chapter Membuat Full-Text Index.