Dari MySQL FULLTEXT ke mesin pencari
Pencarian adalah titik di mana satu request bisa menahan koneksi selama detik-detik dan menyeret seluruh portal. Ini urutan perbaikannya, dari yang gratis sampai yang butuh infrastruktur.
Intisari
LIKE '%kata%'tidak bisa memakai index โ ia memindai seluruh tabel, selalu.- MySQL
FULLTEXTjauh lebih baik dan gratis, tapi punya batas nyata untuk bahasa Indonesia. - Batas terbesarnya: tidak ada stemming โ "memberitakan" tidak cocok dengan "berita".
- Pindah ke OpenSearch atau Meilisearch saat kamu butuh stemming, toleransi salah ketik, atau faset.
- Apa pun mesinnya, pencarian harus ke replica dan hasilnya harus di-cache.
Kenapa LIKE '%kata%' berbahaya di trafikmu
EXPLAIN SELECT id, judul FROM artikel
WHERE judul LIKE '%rupiah%' OR isi LIKE '%rupiah%'
LIMIT 20;
-- type: ALL
-- key: NULL
-- rows: 203847 โ seluruh tabel, tiap kali
| Akibat | Di trafikmu |
|---|---|
| Memindai seluruh tabel | Ratusan milidetik sampai beberapa detik per kueri |
| Menahan koneksi selama itu | Sepuluh pencarian bersamaan bisa menghabiskan pool |
| Mengisi buffer pool | Data panas terusir; kueri lain ikut melambat |
| Tidak bisa di-cache | Tiap kata kunci berbeda adalah kueri baru |
Ini vektor penolakan layanan yang tidak sengaja โ dan bisa disengaja. Siapa pun bisa membuka halaman pencarianmu dan mengirim seratus kata kunci acak dalam semenit. Tidak ada satu pun yang terlihat seperti serangan, dan database-mu akan tersendat. Rate limit pencarian dari Fase 5 adalah pertahanan minimum; memperbaiki kuerinya adalah perbaikan sesungguhnya.
Langkah 1: FULLTEXT (gratis, cepat dipasang)
ALTER TABLE artikel
ADD FULLTEXT INDEX ft_artikel (judul, ringkasan, isi)
WITH PARSER ngram;
export async function cariArtikel(q: string, batas = 20) {
return sql<{ id: number; judul: string; slug: string; skor: number }>`
SELECT id, judul, slug,
MATCH(judul, ringkasan, isi) AGAINST (${q} IN NATURAL LANGUAGE MODE) AS skor
FROM artikel
WHERE MATCH(judul, ringkasan, isi) AGAINST (${q} IN NATURAL LANGUAGE MODE)
AND status = 'terbit'
ORDER BY skor DESC
LIMIT ${batas}
`.execute(dbBaca);
}
| Mode | Perilaku |
|---|---|
NATURAL LANGUAGE MODE | Peringkat relevansi; mengabaikan kata yang terlalu umum |
BOOLEAN MODE | Mendukung +wajib -kecuali "frasa persis" |
WITH QUERY EXPANSION | Cari dua kali, perluas dari hasil pertama โ mahal |
Batasnya untuk bahasa Indonesia
| Batas | Contoh | Dampak |
|---|---|---|
| Tidak ada stemming | "memberitakan" tidak cocok dengan "berita" | Besar โ imbuhan sangat produktif di bahasa Indonesia |
| Panjang token minimum | ngram_token_size default 2 | Kata pendek terlewat |
| Tidak ada toleransi salah ketik | "ekonimi" tidak menemukan apa pun | Sedang |
| Daftar kata umum bawaan berbahasa Inggris | "yang", "dan", "di" tetap diindeks | Indeks lebih besar, relevansi menurun |
| Tidak ada faset | Filter kategori + tanggal + penulis | Harus WHERE tambahan, memperlambat |
Ketiadaan stemming adalah batas yang paling terasa untuk portal berbahasa Indonesia. Imbuhan di bahasa Indonesia mengubah bentuk kata secara drastis: beritakan, memberitakan, diberitakan, pemberitaan semuanya berakar pada berita, dan FULLTEXT memperlakukannya sebagai lima kata yang tidak berhubungan. Pembaca yang mencari "pemberitaan rupiah" tidak akan menemukan artikel berjudul "Berita rupiah hari ini".
Langkah 2: mesin pencari terpisah
| OpenSearch | Meilisearch | |
|---|---|---|
| Stemming bahasa Indonesia | Ya (analyzer indonesian) | Ya |
| Toleransi salah ketik | Bisa dikonfigurasi | Bawaan, bagus |
| Faset & filter | Sangat lengkap | Cukup |
| Kesulitan operasional | Tinggi | Rendah |
| Layanan terkelola AWS | Ya (OpenSearch Service) | Tidak |
| Kebutuhan memori | Besar | Sedang |
| Cocok kalau | Butuh agregasi & analitik log juga | Hanya butuh pencarian yang bagus |
Untuk portal berita yang butuh pencarian artikel yang bagus dan tidak butuh analitik log, Meilisearch biasanya pilihan yang lebih tepat: satu binari, konfigurasi sederhana, dan hasil yang langsung terasa lebih baik. OpenSearch masuk akal kalau kamu sudah memakainya untuk hal lain.
Menjaga indeks tetap sinkron
// src/lib/indeks.ts
export async function indeksArtikel(id: number) {
const a = await dbBaca
.selectFrom("artikel as a")
.leftJoin("kategori as k", "k.id", "a.kategori_id")
.select(["a.id", "a.judul", "a.ringkasan", "a.isi", "a.slug",
"a.terbit_pada", "a.premium", "k.slug as kategori"])
.where("a.id", "=", id)
.executeTakeFirst();
if (!a || a.status !== "terbit") {
await mesin.index("artikel").deleteDocument(String(id));
return;
}
await mesin.index("artikel").addDocuments([{
id: a.id,
judul: a.judul,
ringkasan: a.ringkasan,
// Indeks teks bersih, bukan HTML
isi: bersihkanTeks(a.isi).slice(0, 20_000),
slug: a.slug,
kategori: a.kategori,
premium: a.premium === 1,
terbitPada: Math.floor(a.terbit_pada!.getTime() / 1000),
}]);
}
| Kapan mengindeks | Cara |
|---|---|
| Artikel diterbitkan / disunting | Panggil di action, setelah commit โ sama seperti purge cache |
| Artikel ditarik | Hapus dari indeks |
| Muat awal 200 ribu artikel | Skrip batch, per 1.000 dokumen |
| Rekonsiliasi | Tugas harian: bandingkan jumlah dokumen dengan jumlah baris |
Indeks pencarian akan menyimpang dari database. Pengindeksan gagal, deploy memotong proses di tengah, atau seseorang menyunting langsung di database. Tugas rekonsiliasi harian yang membandingkan jumlah dokumen dan menemukan selisihnya jauh lebih murah daripada pembaca yang melaporkan "artikelnya tidak ketemu di pencarian" berbulan-bulan kemudian.
Yang berlaku untuk mesin apa pun
| Aturan | Kenapa |
|---|---|
| Pencarian membaca replica | Jangan pernah mengganggu writer |
| Cache hasil untuk kata kunci populer | Sepuluh kata kunci teratas biasanya sebagian besar trafik pencarian |
| Batasi panjang kata kunci | z.string().max(100) |
| Rate limit per IP | Fase 5 |
Halaman hasil noindex, follow | Mencegah konten tipis terindeks |
| Jangan indeks isi premium penuh | Cuplikan hasil pencarian bisa membocorkan isi berbayar |
| Timeout ketat + fallback | Mesin pencari mati โ portal mati |
// Fallback saat mesin pencari tidak tersedia
export async function cari(q: string) {
try {
return await mesin.index("artikel").search(q, { limit: 20 });
} catch (e) {
console.error(JSON.stringify({ level: "error", pesan: "mesin cari mati", e: String(e) }));
// Jatuh ke FULLTEXT MySQL โ lebih buruk, tapi hidup
return { hits: await cariArtikelFulltext(q, 20), degradasi: true };
}
}
Latihan: jalankan EXPLAIN pada kueri pencarian CI3-mu yang sebenarnya dan catat
rows-nya. Tambahkan index FULLTEXT dan bandingkan. Lalu uji batasnya: cari
"pemberitaan" dan lihat apakah artikel berjudul "berita" muncul. Kalau tidak โ dan kemungkinan besar
tidak โ kamu baru saja mengukur sendiri kenapa mesin pencari terpisah dibutuhkan.
Rangkuman ini sengaja dipangkas ke bagian yang dipakai di roadmap. Buka sumber aslinya saat kamu butuh detail lengkap atau referensi parameter.