Lagi bangun aplikasi Islami dan pusing tujuh keliling karena pencarian hadis sering zonk? Pengguna mencari "Bukhari nomor 52", tapi di edisi lain yang Anda pakai, hadis itu nomor 55. Hasilnya? Salah kutip, salah rujuk, dan kepercayaan pengguna merosot.
Ini masalah klasik di korpus hadis. Beda penerbit, beda edisi, beda metodologi, hasilnya beda penomoran. Sebagai developer, kita nggak bisa cuma pasrah.
Artikel ini adalah deep dive teknis—studi kasus ringan—tentang cara kita merancang arsitektur Information Retrieval (IR) yang robust di [BRAND] untuk mengatasi masalah ini. Kita akan pakai Next.js, PostgreSQL (dengan pg_trgm + pgvector), Prisma, dan sentuhan AI (LLM) untuk re-ranking.
Fokus kita 100% di arsitektur teknis, UX, dan integritas data. Zero klaim Fiqh.
TL;DR: Strategi 4 Lapis Anti-Nyasar
Kalau Anda sedang buru-buru, ini arsitektur solusinya:
- Sumber Kebenaran Tunggal: Kita buat Canonical ID (CHID), satu ID unik internal (misalnya UUID) untuk setiap teks hadis yang unik, terlepas dari nomornya.
- Petakan Varian: Bikin tabel numbering_variants yang isinya memetakan CHID tadi ke semua varian penomoran yang ada di luar sana (misal: Edisi Lidwa Pustaka, International, Fath al-Bari, dll.).
- Mesin Pencari Hibrida: Gabungkan pencarian lexical (kata kunci) pakai FTS + pg_trgm (buat atasi typo/transliterasi) dengan pencarian semantik (makna) pakai pgvector. Hasil keduanya digabung pakai Reciprocal Rank Fusion (RRF).
- Penjaga Gerbang AI: Hasil hibrida tadi di-sortir ulang oleh LLM Re-ranker. Tapi pakai prompt super ketat yang hanya boleh output JSON berisi chid dan relevance_score, serta dilarang keras mengarang nomor atau info baru (anti-halusinasi).
Latar Masalah: Kenapa Penomoran Hadis Itu "Rusuh"?
Bayangkan Anda punya tiga buku Sahih Bukhari dari tiga penerbit berbeda. Sangat mungkin hadis tentang niat—yang kita semua kenal—punya nomor berbeda di ketiganya.
- Edisi A: Bukhari #1
- Edisi B: Bukhari #1 (tapi beda kitab)
- Edisi C: Bukhari #52 (misalnya)
Kalau database Anda cuma SELECT * FROM hadiths WHERE number = 1, Anda mengembalikan hasil yang salah untuk pengguna Edisi C. Ini bukan cuma UX yang buruk, ini masalah integritas data.
Solusi Inti: Pisahkan Teks dari Nomor
Solusinya adalah berhenti menjadikan "nomor" sebagai primary key. Kita harus memisahkan apa (teks hadis) dari di mana (sitasinya).
- Tabel canonical_hadiths: Menyimpan teks hadis yang unik, bersih, dan ternormalisasi. Punya satu ID unik: chid.
- Tabel numbering_variants: Menyimpan pemetaan chid ke semua sitasi eksternal (collection_name, publisher_edition, hadith_number).
Dengan ini, pencarian "Bukhari #52" akan me-resolusi chid-123. Dan saat menampilkan chid-123, UI akan menunjukkan: "Hadis ini juga dikenal sebagai Bukhari #1 (Intl), Bukhari #52 (Lidwa), dst." Game changer.
Arsitektur Teknis (High-Level)
Secara alur, begini data mengalir dari kueri pengguna hingga jadi hasil yang relevan:
[ User Query ]
|
[ Normalisasi & Embedding ]
|
.-----( Hybrid Retrieval )-----.
| |
v v
[ Lexical Search ] [ Vector Search (ANN) ]
(FTS + pg_trgm) (pgvector HNSW)
| |
'-----( Reciprocal Rank Fusion (RRF) )-----'
|
v
[ Top-K Kandidat CHID ]
|
v
[ LLM Re-ranker (JSON) ]
(Cek relevansi, anti-halu)
|
v
[ Hasil CHID Terurut Final ]
|
v
[ Frontend (Next.js) ] - Ambil teks & *semua* varian nomor
Singkatnya: Kueri pengguna (misal: "keutamaan puasa yaumul bidh") diubah jadi vektor DAN teks. Keduanya mencari di Postgres secara paralel. Hasilnya digabung pakai RRF, lalu disortir ulang oleh AI untuk relevansi terbaik, sebelum akhirnya CHID final dikirim ke frontend.
Implementasi Praktis (The "How-To")
Mari kita bedah beberapa bagian krusialnya.
1. Normalisasi Teks (Arab & Indo)
Teks Arab itu kompleks. "Al-Rahman" bisa ditulis dengan atau tanpa harakat. Kita perlu normalisasi sebelum indexing.
TStypescript
// Contoh konseptual di backend Node.js function normalizeArabic(text: string): string { return ( text // Hapus harakat (Fathah, Kasrah, Dammah, dll.) .replace(/[\u064B-\u0652]/g, "") // Normalisasi Alif (أ, إ, آ -> ا) .replace(/[\u0622\u0623\u0625]/g, "\u0627") // Normalisasi Ta Marbuta (ة -> ه) .replace(/\u0629/g, "\u0647") // Normalisasi Ya (ى -> ي) .replace(/\u0649/g, "\u064A") // Hapus tatweel (garis pemanjang) .replace(/\u0640/g, "") ); }
Teks yang sudah dinormalisasi inilah yang kita simpan di kolom normalized_arabic dan normalized_translation di tabel canonical_hadiths.
2. Indexing di PostgreSQL (Lexical + Vector)
Kita butuh dua ekstensi Postgres: pg_trgm (untuk pencocokan fuzzy / typo) dan vector (untuk pencarian semantik).
- - Aktifkan ekstensi yang diperlukan
CREATE EXTENSION IF NOT EXISTS pg_trgm;CREATE EXTENSION IF NOT EXISTS vector;- Buat index GIN (Generalized Inverted Index) untuk FTS dan Trigram
- Ini SANGAT cepat untuk pencarian teks "shalat" vs "solat"
CREATE INDEX idx_hadith_lexical_searchON canonical_hadithsUSING GIN ((to_tsvector('simple', normalized_arabic)),(to_tsvector('indonesian', normalized_translation)));CREATE INDEX idx_hadith_trigramON canonical_hadithsUSING GIN (normalized_arabic gin_trgm_ops, normalized_translation gin_trgm_ops);- - Buat index HNSW (Hierarchical Navigable Small Worlds) untuk vector
- - Kita asumsikan embedding punya 384 dimensi (dari model MiniLM)
- - 'vector_cosine_ops' penting karena model sentence-transformer
- - mengukur relevansi pakai cosine similarity.
CREATE INDEX idx_hadith_semantic_searchON canonical_hadithsUSING HNSW (embedding vector_cosine_ops);3. Skema Prisma (Model Kanonis + Varian)
Skema Prisma kita harus mencerminkan arsitektur "pisahkan teks dari nomor".
scheme
// ./prisma/schema.prisma // Tabel ini adalah 'sumber kebenaran' // Menyimpan SATU entri unik per hadis model Hadith { // Ini adalah Canonical Hadith ID (CHID) kita id String @id @default(uuid()) normalizedArabic String @db.Text normalizedTranslation String @db.Text // Teks mentah asli (opsional, bisa juga di tabel varian) rawArabic String @db.Text rawTranslation String @db.Text // Vektor embedding dari (normalizedArabic + normalizedTranslation) // Perlu setup kustom untuk tipe Vector embedding Unsupported("vector(384)") // Relasi: Satu hadis bisa punya BANYAK varian nomor numberVariants HadithNumberVariant[] @@map("canonical_hadiths") } // Tabel ini adalah 'kamus pemetaan' // Menyimpan SEMUA varian nomor untuk satu hadis model HadithNumberVariant { id Int @id @default(autoincrement()) hadith Hadith @relation(fields: [hadithId], references: [id]) hadithId String // Foreign key ke Hadith.id (CHID) // Nama koleksi, misal: "sahih-bukhari" collectionName String // Edisi penerbit, misal: "Lidwa Pustaka", "International" publisherEdition String // Nomor hadis spesifik edisi tersebut hadithNumber String // Pakai String, karena ada nomor "52a", "52b" bookName String? bookNumber Int? @@unique([collectionName, publisherEdition, hadithNumber]) @@map("numbering_variants") }
4. Hybrid Query dan RRF
Di backend Express/Next.js API route Anda, Anda perlu menjalankan dua kueri secara paralel dan menggabungkannya.
TStypescript
// ./app/api/search/route.ts (Konseptual) async function getHybridResults(query: string) { const queryVector = await getEmbedding(query); // 1. Jalankan dua query secara paralel const [lexicalResults, vectorResults] = await Promise.all([ // Query Lexical (FTS + Trigram) prisma.$queryRaw` SELECT id, 0.5 * ts_rank(..., query) + 0.5 * similarity(..., query) as score FROM canonical_hadiths WHERE ... -- FTS/Trigram logic ORDER BY score DESC LIMIT 50 `, // Query Vector (ANN) prisma.$queryRaw` SELECT id, 1 - (embedding <=> ${queryVector}) as score FROM canonical_hadiths ORDER BY score DESC LIMIT 50 `, ]); // 2. Lakukan Reciprocal Rank Fusion (RRF) const fusedScores: Record<string, number> = {}; const k = 60; // Konstanta RRF, default yang baik lexicalResults.forEach((doc, index) => { const rank = index + 1; fusedScores[doc.id] = (fusedScores[doc.id] || 0) + 1 / (k + rank); }); vectorResults.forEach((doc, index) => { const rank = index + 1; fusedScores[doc.id] = (fusedScores[doc.id] || 0) + 1 / (k + rank); }); // 3. Urutkan berdasarkan skor RRF const fusedResults = Object.entries(fusedScores) .sort((a, b) => b[1] - a[1]) .map(([id, score]) => ({ id, score })); // Ambil top 10 untuk di-re-rank oleh LLM return fusedResults.slice(0, 10); }
5. Re-ranking dengan Prompt Anti-Halusinasi
10 hasil teratas dari RRF tadi kita lempar ke LLM (misal: Gemini 2.5 Flash) untuk di-ranking ulang. Ini prompt yang krusial untuk mencegah AI "ngarang" nomor hadis.
JSONjson
{ "role": "system", "content": "Anda adalah asisten ahli untuk sistem pencarian Hadis. Tugas Anda adalah mengurutkan ulang daftar kandidat Hadis berdasarkan relevansinya dengan kueri pengguna. Anda HARUS mematuhi aturan berikut SECARA KETAT:\n1. Evaluasi setiap Hadis HANYA berdasarkan teks yang disediakan ('arabic' dan 'translation').\n2. Tentukan skor relevansi dari 1 (paling tidak relevan) hingga 10 (paling relevan).\n3. Output Anda HARUS berupa objek JSON yang valid yang berisi satu kunci 'ranked_results', yang merupakan sebuah array objek.\n4. Setiap objek dalam array harus berisi HANYA dua kunci: 'chid' (disalin persis dari input) dan 'relevance_score' (skor yang Anda hitung).\n5. JANGAN menambahkan, menyimpulkan, atau menghasilkan informasi lain apa pun. JANGAN menjelaskan alasan Anda. JANGAN mengeluarkan teks apa pun di luar struktur JSON." }, { "role": "user", "content": "User Query: \"keutamaan puasa yaumul bidh\"\n\nCandidate Hadiths:\n[... (String JSON berisi 10 kandidat {chid, arabic, translation}) ...]\n\nYour JSON Output:" }
Dengan memaksa output JSON yang ketat, kita mengubah LLM dari "penulis kreatif" menjadi "fungsi sortir yang cerdas".
6. UI/UX Anti-Nyasar
Di frontend (Next.js), saat Anda menerima chid hasil akhir:
- JANGAN cuma tampilkan satu nomor.
- TAMPILKAN teks hadis dari canonical_hadiths.
- TAMPILKAN semua varian nomor yang terkait dari numbering_variants.
Contoh UI:
... (Teks Hadis Tentang Niat) ...
Sumber Rujukan:
- Sahih Bukhari (Lidwa): #52
- Sahih Bukhari (International): #1
- Fath al-Bari: #55
Ini mendidik pengguna bahwa satu hadis bisa punya banyak nomor, dan nomor mana pun yang mereka pakai akan merujuk ke teks yang sama.
Kualitas & Evaluasi: Mengukur Apa yang Penting
Gimana kita tahu sistem ini berhasil? Kita pakai metrik IR standar:
- nDCG@10 (Normalized Discounted Cumulative Gain): Mengukur kualitas urutan 10 hasil teratas. Apakah hasil paling relevan ada di peringkat #1, #2, dst?
- MRR@10 (Mean Reciprocal Rank): Mengukur seberapa cepat pengguna menemukan jawaban pertama yang benar.
- Hallucination Rate (Custom): Metrik paling penting kita. Didefinisikan sebagai: (Jumlah respons AI yang JSON-nya rusak ATAU berisi 'chid' / 'nomor' yang tidak ada di input) / (Total kueri).
- Target kita: Hallucination Rate = 0%. Tidak ada toleransi.
Performa & Deployment di Vercel
Arsitektur ini berjalan di atas Vercel (Next.js + Serverless Functions) dan Postgres (misal: Supabase, Neon). Ada beberapa jebakan:
- Koneksi DB: Fungsi serverless bisa cepat menghabiskan koneksi DB.
- Mitigasi: Gunakan Prisma Singleton Pattern untuk me-reuse satu instance PrismaClient di hot-reloads (development) dan antar pemanggilan (production).
- Mitigasi: Pastikan DATABASE_URL Anda menggunakan connection pooler (misal: via PgBouncer).
- Cold Starts: Pemanggilan fungsi pertama bisa lambat.
- Mitigasi: Jaga ukuran bundle fungsi tetap kecil. Gunakan caching (Vercel KV, Upstash Redis) untuk kueri populer.
- Latensi LLM: Panggilan ke API AI bisa jadi bottleneck.
- Mitigasi: Gunakan timeout agresif (misal: 5 detik). Jika LLM gagal atau lambat, langsung kembalikan hasil dari RRF (tahap sebelum LLM). Lebih baik hasil "cukup relevan" tapi cepat, daripada error timeout.
Checklist Implementasi (Actionable)
Setup DB: Sediakan Postgres, aktifkan ekstensi pg_trgm dan vector.
Definisi Skema: Tulis skema Prisma untuk Hadith (canonical) dan HadithNumberVariant. Jalankan prisma migrate.
Data Ingestion: Buat skrip offline untuk:
- Membaca data sumber.
- Menjalankan SimHash (untuk deteksi duplikat dekat).
- Mengisi canonical_hadiths.
- Menghasilkan embedding untuk setiap hadis kanonis.
- Mengisi numbering_variants.
Backend API: Buat API route (/api/search) di Next.js yang:
- Melakukan kueri hibrida (lexical + vector) secara paralel.
- Menggabungkan hasil pakai RRF.
- Mengirim top-N ke LLM Re-ranker (dengan prompt ketat).
- Mengembalikan CHID final yang terurut.
Frontend UI: Buat halaman pencarian yang:
- Mem-fetch API di atas.
- Me-render hasil (teks + semua varian nomornya).
- Gunakan SWR atau React Query untuk caching di sisi klien.
Evaluasi: Buat golden dataset (kueri + CHID relevan) dan skrip untuk menghitung nDCG@10, MRR@10, dan Hallucination Rate.
FAQ Teknis Singkat
Q: Kenapa harus hybrid (lexical + vector)?
A: Lexical (FTS/Trigram) jago untuk mencocokkan kata kunci spesifik, nama, atau istilah. Vector (Semantik) jago untuk memahami maksud atau "topik" (misal: "puasa senin kamis" akan menemukan hadis yang menyebut "shaum itsnain wal khamis"). Gabungan keduanya menutup kelemahan masing-masing.
Q: pg_trgm buat apa?
A: Untuk toleransi typo dan transliterasi. Kueri "sholat" bisa menemukan "shalat" atau "solat" berkat trigram matching.
Q: Apa gunanya SimHash di pipeline ingesti?
A: Untuk deduplikasi. Banyak hadis punya teks 99% mirip tapi beda sedikit di sanad (rantai perawi). SimHash mengelompokkan mereka sebagai varian dari satu hadis kanonis yang sama, alih-alih dianggap dua hadis berbeda.
Q: Kenapa pakai index HNSW, bukan IVFFlat di pgvector?
A: Korpus hadis relatif statis (jarang update). HNSW butuh waktu build lebih lama tapi query-nya jauh lebih cepat dan akurat (recall-nya tinggi), yang kita inginkan untuk aplikasi real-time.
Q: Aman nggak sih pakai LLM? Takut halusinasi.
A: Aman, jika dan hanya jika kita mengendalikannya dengan ketat. Dengan memaksa outputnya hanya JSON berisi chid (yang kita berikan) dan score (yang dia hitung), kita mencegahnya "menambahkan" atau "mengarang" info baru.
Q: Tips handle koneksi DB di Vercel Serverless?
A: Wajib hukumnya pakai Prisma Singleton Pattern dan Connection Pooler (PgBouncer). Jangan pernah new PrismaClient() di dalam request handler.
Siap Mencoba?
Membangun pencarian hadis yang akurat itu bukan cuma soal coding canggih, tapi soal amanah dalam menyajikan data. Dengan memisahkan data kanonis dari varian penomoran, kita sudah mengambil langkah besar.
[cta_label: "Liut Implementasi"]
[cta_url: "[CTA_URL]"]
Semoga panduan ini bermanfaat bagi teman-teman developer yang sedang membangun produk untuk umat. Jika ada pertanyaan teknis, jangan ragu tinggalkan komentar!
Sumber Tambahan:
- Ingin melihat data mentahnya? Anda dapat mengakses halaman berikut
- Lebih suka mendengarkan sambil coding?

