Semantic search adalah kotak pencarian yang mencari berdasarkan makna, bukan huruf yang sama persis. Pelanggan Toko Buku mengetik "novel tentang sekolah di Belitung" atau "kisah zaman kolonial", dan bukunya ketemu walaupun kata-katanya gak ada di judul. Bayangin dua penjaga toko. Penjaga pertama cuma membaca label di punggung buku: dia cepat menemukan "Pramoedya", tapi bingung kalau ditanya "cerita anak sekolah di pulau". Penjaga kedua sudah membaca semua sinopsis, jadi paham maksudnya, tapi kadang lupa nama orang. Toko yang bagus menyuruh keduanya mencari, lalu menggabungkan jawabannya. Itu hybrid search.
🧪 Tebakan cepat: pelanggan mengetik "kisah murid di sebuah pulau". Keyword search (yang mencocokkan kata) menaruh buku apa di urutan pertama: Laskar Pelangi, atau buku lain? Tebak dulu, jawabannya ada di bagian "Keyword search: BM25".
Dua tahap: index sekali, cari berkali-kali
Pelajaran Embedding dan RAG sudah mengenalkan dua waktu ini. Di kotak pencarian, keduanya jadi kode yang jalan terus:
- Index time: tiap sinopsis di-embed sekali, lalu disimpan sebagai satu baris:
bookId,vektor, danembeddedAt(kapan vector itu dibuat). - Query time: tiap pencarian, teks pelanggan di-embed dengan model yang sama, dibandingkan dengan semua vector, lalu diambil yang teratas.
// Index time: sekali per buku, dan lagi kalau sinopsisnya berubah
async function indexBuku(books) {
const res = await client.embeddings.create({
model: "text-embedding-3-small",
input: books.map((b) => b.sinopsis), // banyak sinopsis dalam 1 request
});
return books.map((b, i) => ({
bookId: b.id,
vektor: res.data[i].embedding, // 1.536 angka, panjangnya 1
embeddedAt: new Date().toISOString(),
}));
}
- Satu request bisa membawa banyak sinopsis. Keempat sinopsis totalnya 194 token, sekitar $0,000004 dengan harga $0,02 per 1 juta token (Oktober 2026).
embeddedAtdipakai nanti untuk tahu vector mana yang sudah basi. - Untuk 4 buku, baris-baris ini cukup disimpan di kolom JSON atau di memori. Kapan butuh database khusus dibahas di pelajaran Vector Databases.
Embedding mainan di Lab: hitung konsep
Vector dari model gak bisa dihitung di kertas, dan Lab gak memanggil model. Di pelajaran Embedding kita memakai embedding mainan hitung kata, dan dia gagal untuk "kisah murid di sebuah pulau" karena gak tahu "murid" dan "sekolah" berkaitan. Di sini kita tambah satu langkah: kata yang maknanya berdekatan dihitung di dimensi yang sama. Kamusnya kecil, 8 konsep, ditulis tangan. Ini bukan model: model asli belajar sendiri kedekatan kata dari teks yang sangat banyak, dengan 1.536 dimensi.
Kedelapan konsep itu: pendidikan (sekolah, bersekolah, sd, hbs, guru, murid, siswa, belajar), desa/pulau (belitung, pulau, dusun, desa), keluarga (keluarga, anak, cucunya, putri), kolonial (kolonial, belanda, penjajahan), politik (politik, 1965, gejolak), tari (ronggeng, penari, tari, tradisional), cinta, dan miskin. Hasilnya untuk sinopsis (yang di-embed cuma sinopsisnya):
| Buku | Vector konsep | Panjang |
|---|---|---|
| Laskar Pelangi | pendidikan 3, keluarga 2, desa/pulau 1, miskin 1 | √15 = 3,873 |
| Bumi Manusia | pendidikan 2, keluarga 1, kolonial 1, cinta 1 | √7 = 2,646 |
| Cantik Itu Luka | keluarga 2, kolonial 2, politik 1 | √9 = 3 |
| Ronggeng Dukuh Paruk | tari 3, politik 3, desa/pulau 1 | √19 = 4,359 |
Mencari: embed pertanyaan, cosine, top-k, ambang
"novel tentang sekolah di Belitung" jadi pendidikan 1 dan desa/pulau 1, panjangnya √2 = 1,414. Cosine similarity-nya (dot product dibagi perkalian panjang, dari pelajaran Embedding):
| Buku | Dot product | Cosine | Ambang 0,30 | Ambang 0,60 |
|---|---|---|---|---|
| Laskar Pelangi | 3 + 1 = 4 | 4 / (1,414 × 3,873) = 0,730 | tampil | tampil |
| Bumi Manusia | 2 | 2 / (1,414 × 2,646) = 0,535 | tampil | dibuang |
| Ronggeng Dukuh Paruk | 1 | 1 / (1,414 × 4,359) = 0,162 | dibuang | dibuang |
| Cantik Itu Luka | 0 | 0 | dibuang | dibuang |
Top-k artinya ambil k hasil teratas (di sini k = 2). Ambang adalah skor minimal supaya hasil boleh tampil. Tanpa ambang, Ronggeng Dukuh Paruk ikut tampil untuk pertanyaan tentang sekolah cuma karena kata "dusun". Angka ambang yang pas tergantung modelnya, jadi ukur dengan data sendiri (bagian terakhir).
const cache = new Map(); // teks pertanyaan -> vector
async function embedPertanyaan(teks) {
const kunci = teks.trim().toLowerCase().replace(/\s+/g, " ");
if (cache.has(kunci)) return cache.get(kunci); // cache hit: tanpa request
const res = await client.embeddings.create({ model: "text-embedding-3-small", input: kunci });
cache.set(kunci, res.data[0].embedding);
return res.data[0].embedding;
}
async function cariVector(teks, index, ambang = 0.3, k = 2) {
const q = await embedPertanyaan(teks);
return index
.map((row) => ({ bookId: row.bookId, skor: dot(q, row.vektor) })) // panjang 1: dot = cosine
.filter((h) => h.skor >= ambang)
.sort((a, b) => b.skor - a.skor)
.slice(0, k);
}
embedPertanyaanmenyimpan vector tiap pertanyaan dicache. Pertanyaan yang sama (setelah dirapikan: huruf kecil, spasi tunggal) gak dikirim ke OpenAI lagi.cariVectormemakai dot product, karena vector OpenAI sudah panjang 1 (pelajaran Embedding).filtermembuang yang di bawah ambang sebelumslice, jadi hasilnya bisa kurang dari k, bahkan kosong. Itu lebih jujur daripada menampilkan buku yang gak nyambung.
Keyword search: BM25
Keyword search mencocokkan kata yang sama persis di judul, penulis, dan sinopsis. Cara menilai yang paling umum namanya BM25: kata yang langka bernilai lebih (kata "belitung" cuma ada di 1 dari 4 buku), kata yang diulang menambah skor tapi ada batasnya, dan teks yang panjang sedikit dikurangi. Rumus lengkapnya, dengan k1 = 1.2 dan b = 0.75 (nilai bawaan yang lazim), ada di kode Lab. Di Postgres, peran ini dipegang full-text search bawaan (to_tsvector, ts_rank_cd); rumus rankingnya bukan BM25 persis, tapi tugasnya sama: mencocokkan kata.
Hasilnya dibanding vector konsep:
| Pertanyaan | Keyword (BM25) | Vector (konsep, ambang 0,30) |
|---|---|---|
| novel tentang sekolah di Belitung | Laskar Pelangi 1,264 (cuma "belitung" yang sama) | Laskar Pelangi 0,730, Bumi Manusia 0,535 |
| kisah murid di sebuah pulau | Cantik Itu Luka 1,171 | Laskar Pelangi 0,730, Bumi Manusia 0,535 |
| Pramoedya | Bumi Manusia 1,129 | (kosong) |
Untuk "kisah murid di sebuah pulau", keyword search menaruh Cantik Itu Luka di urutan pertama, cuma karena kata "kisah" ada di sinopsisnya. "Murid" dan "pulau" gak ada di sinopsis mana pun. Itu jawaban tebakannya. Vector menemukan Laskar Pelangi, karena "murid" dan "bersekolah" ada di konsep yang sama.
Jadi bedanya: keyword search mencocokkan kata secara literal, semantic search memahami makna dan konteks. Tapi keyword menang untuk nama, kode, dan angka yang harus sama persis: "Pramoedya" ketemu lewat keyword, sedangkan vector konsep kosong karena nama orang gak punya konsep.
Hybrid search: reciprocal rank fusion
Hybrid search menjalankan keduanya lalu menggabungkan hasilnya. Skornya gak bisa dijumlah langsung (BM25 1,171 dan cosine 0,730 beda skala), jadi yang digabung peringkatnya. Caranya, reciprocal rank fusion (RRF): tiap buku dapat 1 / (60 + peringkat) dari tiap daftar tempat dia muncul, lalu dijumlah. Angka 60 adalah konstanta yang lazim; contoh hybrid search dari pgvector juga memakai 60.
| "kisah murid di sebuah pulau" | Peringkat keyword | Peringkat vector | RRF |
|---|---|---|---|
| Laskar Pelangi | (gak ada) | 1 | 1/61 = 0,0164 |
| Cantik Itu Luka | 1 | (gak ada) | 1/61 = 0,0164 |
| Bumi Manusia | (gak ada) | 2 | 1/62 = 0,0161 |
Laskar Pelangi dan Cantik Itu Luka seri, diputus dengan id buku. Dengan top-2, buku yang benar ikut tampil walaupun keyword salah. Untuk "kisah zaman kolonial", Cantik Itu Luka peringkat 1 di kedua daftar (1/61 + 1/61 = 0,0328) dan Bumi Manusia peringkat 2 di keduanya (1/62 + 1/62 = 0,0323): buku yang muncul di dua daftar naik.
const K_RRF = 60;
function rrf(daftarKeyword, daftarVector) {
const skor = new Map();
for (const daftar of [daftarKeyword, daftarVector]) {
daftar.forEach((h, i) => skor.set(h.bookId, (skor.get(h.bookId) ?? 0) + 1 / (K_RRF + i + 1)));
}
return [...skor]
.map(([bookId, s]) => ({ bookId, skor: s }))
.sort((a, b) => b.skor - a.skor || a.bookId - b.bookId); // seri: id lebih kecil dulu
}
Index ulang dan cache pertanyaan
Bu Admin mengubah sinopsis Laskar Pelangi tanggal 11 Oktober 2026 jam 09.15 ("Bu Mus" jadi "Bu Muslimah"). Vector-nya dibuat 1 Oktober, jadi sekarang basi: dia mewakili teks lama. Index keyword di Postgres (misalnya kolom tsvector yang di-generate) ikut berubah di UPDATE yang sama, tapi vector butuh request ke OpenAI, jadi harus di-embed ulang:
// Jalan setelah PUT /books/{id}, atau tiap malam
const basi = books.filter((b) => {
const row = index.find((r) => r.bookId === b.id);
return !row || b.updatedAt > row.embeddedAt; // belum di-index, atau sinopsisnya lebih baru
});
const baru = basi.length ? await indexBuku(basi) : [];
index = [...index.filter((r) => !basi.some((b) => b.id === r.bookId)), ...baru];
Hasilnya 1 buku di-embed ulang, 3 dilewati. Membandingkan updatedAt dengan embeddedAt lebih murah dan lebih aman daripada meng-embed ulang semua buku setiap kali ada perubahan.
Cache pertanyaan menghemat request yang sama. "novel tentang sekolah di Belitung" cuma 8 token; 10.000 pencarian sehari × 8 token = 80.000 token = $0,0016. Jadi untung utamanya bukan uang, tapi waktu tunggu (satu request HTTP lebih sedikit) dan batas rate limit. Kalau model embedding diganti, kosongkan cache-nya dan index ulang semua buku.
Mengukur kualitas: precision@k dan recall
Jangan menilai pencarian dari satu-dua coba. Buat set berlabel: daftar pertanyaan, masing-masing dengan buku yang memang relevan. Lalu hitung:
- Precision@k: dari hasil yang tampil (paling banyak k), berapa bagian yang relevan. Kalau gak ada hasil, dihitung 0.
- Recall@k: dari semua buku yang relevan, berapa bagian yang muncul di hasil.
Set berlabel di Lab punya enam pertanyaan: sekolah di Belitung (relevan: Laskar Pelangi), zaman kolonial (Bumi Manusia, Cantik Itu Luka), murid di pulau (Laskar Pelangi), Pramoedya (Bumi Manusia), politik 1965 (Cantik Itu Luka, Ronggeng Dukuh Paruk), penari tradisional (Ronggeng Dukuh Paruk). Contoh satu baris: vector dengan ambang 0,30 menampilkan Laskar Pelangi dan Bumi Manusia untuk pertanyaan sekolah, jadi precision 1/2 = 0,5 dan recall 1/1 = 1. Rata-rata keenamnya, k = 2:
| Cara | Ambang 0,30: precision / recall | Ambang 0,60: precision / recall |
|---|---|---|
| Keyword (BM25) | 0,833 / 0,833 | 0,833 / 0,833 |
| Vector (konsep) | 0,667 / 0,833 | 0,833 / 0,667 |
| Hybrid (RRF) | 0,833 / 1,000 | 0,917 / 1,000 |
Menaikkan ambang ke 0,60 membuang Bumi Manusia (0,535) dari pertanyaan sekolah, jadi precision vector naik. Tapi Bumi Manusia (0,378) juga hilang dari "kisah zaman kolonial", jadi recall-nya turun. Ambang selalu tawar-menawar antara dua angka ini. Hybrid menang di keduanya karena keyword menutup lubang vector, dan sebaliknya. Enam pertanyaan itu kecil sekali; di toko asli, ambil puluhan sampai ratusan pertanyaan nyata dari log pencarian.
⚠️ Jebakan yang sering ketemu
- Gak ada ambang. Top-k selalu mengembalikan k buku, termasuk Ronggeng Dukuh Paruk dengan skor 0,162 untuk pertanyaan tentang sekolah. Buang yang di bawah ambang, dan ukur ambangnya.
- Lupa index ulang. Sinopsis berubah, vector-nya tetap mewakili teks lama. Bandingkan
updatedAtdenganembeddedAtdan embed ulang yang basi saja. Kalau model diganti, index ulang semua buku dan kosongkan cache pertanyaan: vector dari model berbeda gak bisa dibandingkan. - Mengandalkan vector saja untuk nama dan kode. "Pramoedya" atau ISBN butuh kecocokan persis. Pakai hybrid.
- Menilai dari perasaan. Satu pencarian yang terlihat bagus bukan bukti. Hitung precision dan recall di set berlabel sebelum dan sesudah mengubah apa pun.
TL;DR: Semantic search mencari berdasarkan makna: sinopsis di-embed sekali dan disimpan dengan bookId dan embeddedAt, pertanyaan di-embed dengan model yang sama (vector-nya di-cache), lalu diurutkan dengan cosine (dot product untuk vector OpenAI), dibuang yang di bawah ambang, dan diambil top-k. Keyword search (BM25) mencocokkan kata secara literal dan unggul untuk nama dan kode; semantic search memahami makna. Hybrid menggabungkan peringkat keduanya dengan RRF: 1 / (60 + peringkat), dijumlah. Kalau updatedAt lebih baru dari embeddedAt, embed ulang buku itu saja. Ukur kualitas di set berlabel: precision@k (berapa bagian hasil yang relevan) dan recall (berapa bagian buku relevan yang ketemu); di Lab, hybrid dengan ambang 0,60 dapat 0,917 / 1,000.