Semantic Search Implementation — AI Web

Implementasi Semantic Search Semantic search memahami makna query, bukan hanya keyword. Membangun semantic search dari awal untuk web app. Architecture // 1…

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:

  1. Index time: tiap sinopsis di-embed sekali, lalu disimpan sebagai satu baris: bookId, vektor, dan embeddedAt (kapan vector itu dibuat).
  2. 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(),
  }));
}

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);
}

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:

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

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.

Yang akan kamu pelajari