Handbook AI Engineering
Bagian III - Bab 12

Vector Database dan Semantic Search

Keterbatasan brute-force search, cara kerja HNSW, implementasi Pinecone dan FAISS, serta hybrid search.

Apa yang Akan Kamu Pahami Setelah Bab Ini

Bab 9 membangun intuisi tentang embedding dan cosine similarity. Bab ini melanjutkan dari sana ke pertanyaan yang lebih praktis: bagaimana menyimpan jutaan embedding dan mencarinya secara efisien dalam hitungan milidetik? Di sinilah vector database berperan.

12.1 Keterbatasan Pencarian Brute-Force

Di Bab 9, kita menghitung cosine similarity antara query dan setiap dokumen dalam corpus secara langsung. Pendekatan ini disebut exact nearest neighbor search atau brute-force, dan ia bekerja dengan baik untuk corpus kecil.

Masalahnya muncul saat skala bertambah. Jika corpus memiliki 1 juta dokumen dengan embedding 768 dimensi, setiap query membutuhkan 1 juta perhitungan cosine similarity. Ini tidak bisa berjalan dalam hitungan milidetik yang dibutuhkan aplikasi produksi.

Vector database menyelesaikan masalah ini dengan teknik yang disebut Approximate Nearest Neighbor (ANN), yang menukar sedikit akurasi dengan kecepatan pencarian yang jauh lebih tinggi.

12.2 Cara Kerja HNSW

Algoritma ANN yang paling umum digunakan di vector database modern adalah HNSW (Hierarchical Navigable Small World). HNSW membangun sebuah graf berlapis dari semua vektor yang diindeks. Setiap vektor terhubung ke beberapa tetangga terdekatnya. Lapisan paling atas berisi sedikit vektor dengan koneksi jarak jauh (seperti jalan tol), lapisan bawah berisi semua vektor dengan koneksi jarak pendek (seperti jalan lokal).

Pencarian dimulai dari lapisan atas (lompatan jauh), turun ke lapisan bawah (presisi tinggi)

Saat query masuk, pencarian dimulai dari lapisan teratas, melompat jauh untuk menemukan wilayah yang mendekati. Lalu turun ke lapisan yang lebih detail untuk menemukan tetangga terdekat yang presisi. Ini jauh lebih efisien dari membandingkan query dengan setiap vektor satu per satu.

12.3 Proses Indexing dan Query

Ada dua fase dalam penggunaan vector database yang perlu dipahami secara terpisah.

12.4 Implementasi dengan Pinecone

Pinecone adalah vector database cloud yang paling umum digunakan dalam ekosistem AI Engineering saat ini. Ia menangani semua kompleksitas HNSW di balik layar, kamu hanya perlu fokus pada indexing dan querying.

Setup dan Inisialisasi

Setup koneksi ke Pinecone dan inisialisasi index. Dimensi harus sesuai dengan model embedding yang digunakan, mismatch akan menyebabkan error saat indexing.

# pip install pinecone-client sentence-transformers
import os
from pinecone import Pinecone, ServerlessSpec
from sentence_transformers import SentenceTransformer
from dotenv import load_dotenv

load_dotenv()

# Inisialisasi Pinecone client
pc = Pinecone(api_key=os.getenv("PINECONE_API_KEY"))

# Nama index dan dimensi embedding
INDEX_NAME = "handbook-rag"
DIMENSION = 384  # sesuai multilingual-MiniLM-L12-v2

# Buat index jika belum ada
if INDEX_NAME not in pc.list_indexes().names():
    pc.create_index(
        name=INDEX_NAME,
        dimension=DIMENSION,
        metric="cosine",  # gunakan cosine similarity
        spec=ServerlessSpec(cloud="aws", region="us-east-1")
    )
    print(f"Index '{INDEX_NAME}' berhasil dibuat")

# Sambungkan ke index
index = pc.Index(INDEX_NAME)

# Load model embedding
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")

print(f"Siap: {index.describe_index_stats()}")

Indexing Dokumen

Proses indexing: ubah teks menjadi embedding lalu simpan ke Pinecone. Metadata disimpan bersama vektor agar bisa digunakan untuk filtering dan untuk menampilkan teks asli ke pengguna.

def index_dokumen(dokumen: list[dict], batch_size: int = 100) -> None:
    """
    Index kumpulan dokumen ke Pinecone.

    Args:
        dokumen: list of {"id": str, "teks": str, "metadata": dict}
        batch_size: jumlah dokumen per batch (max 100 untuk Pinecone)
    """
    print(f"Mengindeks {len(dokumen)} dokumen...")

    for i in range(0, len(dokumen), batch_size):
        batch = dokumen[i:i + batch_size]

        # Buat embedding untuk seluruh batch sekaligus
        teks_batch = [d["teks"] for d in batch]
        embeddings = model.encode(teks_batch).tolist()

        # Format yang dibutuhkan Pinecone: list of (id, vektor, metadata)
        vectors = [
            (
                d["id"],
                embeddings[j],
                {"teks": d["teks"], **d.get("metadata", {})}
            )
            for j, d in enumerate(batch)
        ]

        index.upsert(vectors=vectors)
        print(f"Batch {i//batch_size + 1}: {len(batch)} dokumen diindeks")

    print("Indexing selesai")

# Contoh penggunaan
dokumen_contoh = [
    {
        "id": "doc-001",
        "teks": "Kebijakan pengembalian barang berlaku 30 hari setelah pembelian.",
        "metadata": {"sumber": "kebijakan.pdf", "halaman": 3}
    },
    {
        "id": "doc-002",
        "teks": "Pengiriman gratis untuk pembelian di atas Rp 200.000.",
        "metadata": {"sumber": "kebijakan.pdf", "halaman": 5}
    },
]
index_dokumen(dokumen_contoh)

Querying dan Filter

Querying: ubah pertanyaan pengguna menjadi embedding lalu cari chunk yang paling relevan. Metadata filter memungkinkan pencarian hanya pada subset dokumen tertentu.

def cari_dokumen(
    query: str,
    top_k: int = 3,
    filter_metadata: dict = None
) -> list[dict]:
    """
    Cari chunk paling relevan untuk query.

    Args:
        query: pertanyaan pengguna
        top_k: jumlah hasil yang dikembalikan
        filter_metadata: filter opsional, misal {"sumber": "kebijakan.pdf"}
    """
    # Embedding query menggunakan model yang SAMA dengan saat indexing
    embedding_query = model.encode(query).tolist()

    # Query ke Pinecone
    hasil = index.query(
        vector=embedding_query,
        top_k=top_k,
        include_metadata=True,  # sertakan teks asli
        filter=filter_metadata  # None = cari semua
    )

    # Format hasil menjadi list yang mudah digunakan
    dokumen_relevan = []
    for match in hasil["matches"]:
        if match["score"] > 0.5:  # threshold minimum relevansi
            dokumen_relevan.append({
                "id": match["id"],
                "teks": match["metadata"]["teks"],
                "skor": round(match["score"], 3),
                "sumber": match["metadata"].get("sumber", "unknown")
            })

    return dokumen_relevan

# Test pencarian
hasil = cari_dokumen("Bagaimana cara mengembalikan barang?")
for d in hasil:
    print(f"[{d['skor']}] {d['teks']}")

# Pencarian dengan filter metadata
hasil_filter = cari_dokumen(
    query="kebijakan pengiriman",
    filter_metadata={"sumber": "kebijakan.pdf"}
)

12.5 FAISS vs Pinecone: Kapan Menggunakan Masing-Masing

Aspek FAISS (Meta, open-source) Pinecone (cloud managed)
DeploymentBerjalan di memori lokal atau server sendiri, tidak butuh internet saat queryLayanan cloud dikelola sepenuhnya, butuh koneksi internet dan API Key
BiayaGratis, open-source. Biaya hanya dari infrastruktur serverAda free tier terbatas. Paid tier untuk skala produksi
SkalabilitasTerbatas oleh RAM server, perlu di-rebuild saat data bertambah signifikanSkala otomatis, bisa menampung miliaran vektor
Persistensi dataIndex disimpan sebagai file lokal, perlu manajemen backup sendiriData tersimpan di cloud secara persisten
Metadata filteringTidak ada dukungan native, perlu filter manual setelah retrievalDukungan filter metadata yang kaya dan efisien
Privasi dataData tidak keluar dari infrastrukturmu, ideal untuk data sensitifData dikirim ke cloud pihak ketiga
Kapan menggunakanPrototyping lokal, data sensitif, skala kecil (<500k vektor)Produksi, tim yang tidak ingin kelola infrastruktur, skala besar (>500k vektor)

Berikut implementasi FAISS sebagai alternatif lokal dari Pinecone, kodenya hampir identik dari sisi penggunaan, tapi berjalan sepenuhnya di mesin kamu sendiri.

# pip install faiss-cpu sentence-transformers numpy
import faiss
import numpy as np
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
DIMENSION = 384

# Buat index FAISS dengan cosine similarity (Inner Product setelah normalisasi)
index = faiss.IndexFlatIP(DIMENSION)

# Simpan teks asli secara terpisah, FAISS hanya menyimpan vektor
teks_tersimpan = []

def index_dokumen_faiss(dokumen: list[str]) -> None:
    """Tambahkan dokumen ke FAISS index."""
    embeddings = model.encode(dokumen, normalize_embeddings=True)
    index.add(embeddings.astype(np.float32))
    teks_tersimpan.extend(dokumen)
    print(f"{len(dokumen)} dokumen diindeks. Total: {index.ntotal}")

def cari_faiss(query: str, top_k: int = 3) -> list[dict]:
    """Cari dokumen paling relevan menggunakan FAISS."""
    embedding_query = model.encode([query], normalize_embeddings=True)
    skor, indeks = index.search(embedding_query.astype(np.float32), top_k)

    hasil = []
    for skor_val, idx in zip(skor[0], indeks[0]):
        if idx != -1 and skor_val > 0.5:
            hasil.append({
                "teks": teks_tersimpan[idx],
                "skor": round(float(skor_val), 3)
            })
    return hasil

# Simpan dan muat index dari disk
def simpan_index(path: str) -> None:
    faiss.write_index(index, path)
    print(f"Index disimpan ke {path}")

def muat_index(path: str) -> None:
    global index
    index = faiss.read_index(path)
    print(f"Index dimuat dari {path}: {index.ntotal} vektor")

# Contoh penggunaan
dokumen = [
    "Kebijakan pengembalian barang berlaku 30 hari setelah pembelian.",
    "Pengiriman gratis untuk pembelian di atas Rp 200.000.",
    "Layanan pelanggan tersedia setiap hari pukul 08.00 - 22.00.",
]
index_dokumen_faiss(dokumen)

hasil = cari_faiss("Bagaimana cara mengembalikan produk?")
for h in hasil:
    print(f"[{h['skor']}] {h['teks']}")

12.6 Keyword Search vs Semantic Search vs Hybrid Search

Tidak semua jenis query terlayani dengan baik oleh semantic search saja.

BM25 / Elasticsearch

Keyword Search

✓ Cocok untuk: kode, nama, nomor, ID
✗ Lemah di: sinonim, parafrase
Query: "ORD-2024-789"
✓ Menemukan persis
Query: "cara retur barang"
✗ Tidak menemukan "pengembalian"
Vector Database / ANN

Semantic Search

✓ Cocok untuk: makna, sinonim, parafrase
✗ Lemah di: kode spesifik, nama unik
Query: "cara retur barang"
✓ Menemukan "kebijakan pengembalian"
Query: "ORD-2024-789"
✗ Mungkin tidak menemukan
Keyword + Semantic

Hybrid Search

✓ Cocok untuk keduanya: kode + makna + parafrase
▲ Lebih kompleks: perlu menggabungkan skor
Query: "ORD-2024-789"
✓ Menemukan persis
Query: "cara retur barang"
✓ Menemukan "pengembalian"

Keyword search (seperti Elasticsearch atau BM25) mencari berdasarkan kecocokan kata yang tepat. Semantic search unggul untuk query berbasis makna, pertanyaan dengan redaksi berbeda tapi maksud sama. Hybrid search menggabungkan keduanya, mengambil hasil dari kedua pendekatan lalu menggabungkan skornya. Ini adalah pendekatan yang direkomendasikan untuk sistem produksi.

Kesalahan Umum

Model embedding saat indexing dan querying harus identik.

Ini adalah kesalahan yang paling sering menyebabkan hasil retrieval yang acak. Jika kamu menggunakan multilingual-MiniLM-L12-v2 saat indexing, kamu harus menggunakan model yang sama saat membuat embedding untuk query. Menyimpan nama model sebagai konfigurasi (bukan hardcode) dan memverifikasinya saat startup adalah praktik yang baik.

Jangan mengindeks ulang seluruh dokumen setiap kali ada perubahan kecil.

Re-indexing adalah operasi yang mahal, terutama jika corpus besar dan embedding menggunakan API berbayar. Gunakan operasi upsert di Pinecone untuk memperbarui hanya dokumen yang berubah. Untuk FAISS, pertimbangkan menyimpan ID dokumen dan melakukan penghapusan + penambahan inkremental.

Threshold skor relevansi perlu dikalibrasi per use case.

Nilai threshold 0.5 yang digunakan dalam contoh kode bukan nilai universal. Model embedding yang berbeda menghasilkan distribusi skor yang berbeda. Setelah sistem berjalan, periksa distribusi skor dari hasil pencarian nyata dan sesuaikan threshold berdasarkan data aktual, bukan asumsi.

Gunakan FAISS untuk development, Pinecone untuk produksi.

Kode yang berjalan di FAISS mudah dipindahkan ke Pinecone karena interface-nya serupa. Mulai dengan FAISS lokal agar bisa iterasi cepat tanpa biaya API. Saat sistem sudah stabil, migrasi ke Pinecone untuk mendapat persistensi, skalabilitas, dan metadata filtering yang lebih baik.

Simpan metadata yang berguna bersama setiap vektor.

Selain teks asli, simpan metadata seperti nama file sumber, nomor halaman, tanggal dokumen, atau kategori. Metadata ini memungkinkan dua hal: filtering pencarian yang presisi, dan menampilkan sumber kepada pengguna sehingga mereka bisa memverifikasi jawaban. Sistem RAG yang baik selalu bisa menjawab "jawaban ini berasal dari dokumen mana".

Ringkasan Bab 12

Checklist Pemahaman
  • Saya memahami mengapa exact nearest neighbor search tidak skalabel, dan bagaimana ANN menyelesaikannya dengan menukar sedikit akurasi untuk kecepatan yang jauh lebih tinggi.
  • Saya bisa menjelaskan intuisi HNSW: graf berlapis yang memungkinkan pencarian dimulai dari lapisan atas lalu turun ke lapisan bawah.
  • Saya memahami perbedaan antara fase indexing (sekali saat setup) dan fase querying (setiap ada pertanyaan pengguna).
  • Saya bisa melakukan indexing dokumen ke Pinecone beserta metadata-nya, dan melakukan query dengan filter metadata.
  • Saya bisa mengimplementasikan semantic search lokal menggunakan FAISS sebagai alternatif Pinecone untuk development dan prototyping.
  • Saya memahami perbedaan keyword search, semantic search, dan hybrid search dan tahu kapan masing-masing lebih tepat digunakan.
  • Saya tahu bahwa model embedding harus identik antara proses indexing dan querying, dan menyimpan konfigurasi model sebagai variabel yang bisa diubah.
  • Saya menyimpan metadata yang bermakna bersama setiap vektor, minimal teks asli dan nama sumber dokumen.