Beranda Profil Langganan Per Project Proses FAQ Co-Researcher Blog Carousel Hubungi
This article is also available in English. Read version →

Tutorial: Membangun RAG Chatbot Laravel dengan pgvector

Banyak tim yang ingin menambahkan fitur tanya-jawab AI ke aplikasinya langsung berpikir untuk memasang vector database terpisah di samping PostgreSQL yang sudah jalan. Artinya satu service tambahan, satu pipeline sinkronisasi, satu set kredensial, dan satu dashboard monitoring baru. Untuk knowledge base berisi puluhan ribu entri, itu infrastruktur yang cukup berat hanya untuk sebuah query nearest-neighbor.

pgvector adalah ekstensi PostgreSQL yang menambahkan tipe kolom vector beserta operator similarity search langsung ke dalam Postgres. Dokumen dan embedding-nya hidup di tabel yang sama, dalam transaksi yang sama. Tidak ada yang perlu disinkronkan karena memang tidak ada dua sumber data. Untuk mayoritas beban kerja yang benar-benar dibangun tim developer, ini sudah lebih dari cukup.

Artikel ini membahas langkah praktis membangun RAG chatbot di project Laravel yang sudah berjalan.

Kapan RAG Belum Dibutuhkan

Sebelum menulis kode, periksa dulu ukuran knowledge base Anda. Jika seluruh materi Anda di bawah 200.000 token (sekitar 500 halaman), Anda bisa memasukkan semuanya langsung ke prompt tanpa RAG sama sekali. Dengan prompt caching, pendekatan ini jadi jauh lebih murah dan cepat. RAG baru masuk akal ketika korpus tumbuh melewati batas context window.

1. Setup pgvector dan Desain Tabel Embedding

Aktifkan ekstensi sekali per database, lalu rancang tabel chunk. Di Laravel, gunakan DB::statement() karena Schema Builder belum mengenal tipe vector.

// database/migrations/2026_01_01_000000_create_document_chunks_table.php
public function up(): void
{
    DB::statement('CREATE EXTENSION IF NOT EXISTS vector');

    Schema::create('document_chunks', function (Blueprint $table) {
        $table->id();
        $table->foreignId('document_id')->constrained()->cascadeOnDelete();
        $table->integer('chunk_index');
        $table->text('content');
        $table->text('contextualized_content')->nullable();
        $table->string('content_hash', 64)->index();
        $table->jsonb('metadata')->nullable();
        $table->timestamps();
    });

    // 1536 = dimensi text-embedding-3-small. Sesuaikan dengan model Anda.
    DB::statement('ALTER TABLE document_chunks ADD COLUMN embedding vector(1536)');
}

Beberapa keputusan desain yang penting sejak awal:

  • Dimensi dikunci di skema. Ganti model embedding berarti migrasi kolom baru. Simpan nama model di metadata supaya Anda tahu chunk mana yang perlu di-generate ulang.
  • content_hash dipakai agar proses ingest bisa melewati chunk yang isinya tidak berubah. Ini penyelamat biaya token terbesar Anda.
  • Tipe vector mendukung index hingga 2.000 dimensi. Jika model Anda lebih besar, gunakan halfvec (hingga 4.000 dimensi) yang juga memperkecil working set.

2. Pipeline Ingest: Parsing, Chunking, Embedding

Pipeline ingest punya empat tahap: ambil dokumen, potong jadi chunk, ubah jadi vektor, simpan. Potong teks pada batas natural (heading, paragraf) dengan ukuran beberapa ratus token per chunk dan sedikit overlap.

Masalah klasik chunking adalah hilangnya konteks. Potongan berbunyi "Pendapatan naik 3% dibanding kuartal sebelumnya" tidak menyebut perusahaan maupun periodenya, sehingga sulit ditemukan dan sulit dipakai. Solusi yang direkomendasikan Anthropic adalah Contextual Retrieval: menambahkan satu-dua kalimat konteks di depan setiap chunk sebelum di-embed. Eksperimen mereka menunjukkan teknik ini memangkas tingkat kegagalan retrieval top-20 sebesar 35%, dan sampai 49% bila digabung dengan BM25 kontekstual. Konteks 50–100 token itu dihasilkan oleh model murah, dan dengan prompt caching biayanya sekitar 1 dolar per juta token dokumen.

// app/Services/Rag/IngestService.php
public function ingest(Document $document): void
{
    $chunks = $this->chunker->split($document->body, maxTokens: 400, overlap: 50);

    foreach ($chunks as $index => $chunk) {
        $hash = hash('sha256', $chunk);

        // Lewati kalau isi chunk tidak berubah - hemat token.
        if (DocumentChunk::where('content_hash', $hash)->exists()) {
            continue;
        }

        $context   = $this->contextualizer->describe($document->body, $chunk);
        $forEmbed  = $context . "\n\n" . $chunk;
        $embedding = $this->embedder->embed($forEmbed); // array<float>

        DocumentChunk::create([
            'document_id'            => $document->id,
            'chunk_index'            => $index,
            'content'                => $chunk,
            'contextualized_content' => $forEmbed,
            'content_hash'           => $hash,
            'metadata'               => ['model' => 'text-embedding-3-small'],
            'embedding'              => '[' . implode(',', $embedding) . ']',
        ]);
    }
}

Jalankan lewat queue job, bukan request HTTP. Ingest ratusan dokumen bisa memakan menit hingga jam, dan API embedding punya rate limit yang perlu di-retry dengan backoff.

3. Query Similarity dan Pilihan Index

pgvector menyediakan beberapa operator jarak. Untuk embedding teks, yang relevan adalah tiga ini:

Operator Jarak Catatan
<=> Cosine distance Default untuk embedding teks
<-> L2 (Euclidean) Dipakai bila magnitudo bermakna
<#> Negative inner product Tercepat bila vektor sudah dinormalisasi

Query pencariannya sederhana:

$vector = '[' . implode(',', $queryEmbedding) . ']';

$sql = <<<'SQL'
SELECT id, document_id, content,
       1 - (embedding <=> ?::vector) AS similarity
FROM document_chunks
WHERE embedding IS NOT NULL
ORDER BY embedding <=> ?::vector
LIMIT 20
SQL;

$rows = DB::select($sql, [$vector, $vector]);

Perhatikan dua hal: ORDER BY harus langsung hasil operator jarak (bukan ekspresi seperti 1 - (...) DESC) dan harus ada LIMIT, kalau tidak planner tidak akan memakai index.

Secara default pgvector melakukan exact search dengan recall sempurna. Index approximate baru diperlukan saat tabel membesar.

Aspek HNSW IVFFlat
Performa query Lebih baik Lebih rendah
Waktu build Lambat Cepat
Penggunaan memori Besar Kecil
Butuh data dulu? Tidak Ya, wajib
Parameter utama m, ef_construction lists
Parameter query hnsw.ef_search (default 40) ivfflat.probes (default 1)

Pilih HNSW untuk hampir semua kasus chatbot dokumentasi: tabel Anda tumbuh terus, dan index bisa dibuat bahkan sebelum ada data. Pilih IVFFlat kalau build time dan memori jadi kendala serius, dan Anda bisa menunggu tabel terisi lebih dulu.

-- HNSW, dibuat concurrently agar tidak memblokir write di produksi
CREATE INDEX CONCURRENTLY document_chunks_embedding_hnsw
ON document_chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- IVFFlat: mulai dari lists = rows/1000 (sampai 1 juta baris)
-- dan probes = sqrt(lists) saat query
CREATE INDEX ON document_chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

Satu jebakan yang sering muncul: dengan index approximate, klausa WHERE diterapkan setelah index dipindai. Kalau Anda memfilter tenant_id dan hanya 10% baris yang cocok, dengan ef_search default 40 rata-rata hanya 4 baris yang lolos. Aktifkan iterative scan (SET hnsw.iterative_scan = strict_order;) atau gunakan partial index per tenant.

4. Merangkai Prompt dan Menangani Jawaban "Tidak Tahu"

Setelah chunk didapat, gabungkan ke prompt. Dua aturan membuat perbedaan besar antara chatbot yang dipercaya dan chatbot yang berhalusinasi.

Pertama, pasang ambang similarity. Jika skor tertinggi di bawah ambang (mulai dari 0.3–0.4 untuk cosine, lalu kalibrasi dengan data Anda sendiri), jangan panggil LLM sama sekali — langsung balas bahwa informasinya tidak tersedia. Ini menghemat token sekaligus mencegah model mengarang.

Kedua, instruksikan secara eksplisit di system prompt bahwa jawaban harus bersumber dari konteks.

$context = collect($rows)
    ->map(fn ($r, $i) => "[{$i}] {$r->content}")
    ->implode("\n\n");

$system = <<<'PROMPT'
Anda adalah asisten dokumentasi. Jawab HANYA berdasarkan konteks di bawah.
Sertakan nomor sumber [n] pada setiap klaim.
Jika konteks tidak memuat jawabannya, katakan apa adanya bahwa informasi itu
tidak ada di dokumentasi dan sarankan menghubungi tim support.
Jangan menebak, jangan memakai pengetahuan di luar konteks.
PROMPT;

Mengirim 20 chunk terbukti lebih efektif daripada 5 atau 10 dalam eksperimen Anthropic, namun terlalu banyak konteks juga bisa mengalihkan perhatian model. Uji sendiri di use case Anda. Bila butuh akurasi lebih tinggi, tambahkan tahap reranking: ambil kandidat luas dari pgvector, lalu saring dengan model reranker — kombinasi ini memangkas kegagalan retrieval hingga 67%.

Untuk query yang mengandung kode error atau nama variabel spesifik, embedding sering meleset. Manfaatkan full-text search bawaan Postgres dan gabungkan kedua hasil dengan Reciprocal Rank Fusion. Semuanya tetap di dalam satu database.

5. Checklist Produksi

Re-index saat konten berubah. Pasang model observer di Laravel agar perubahan dokumen memicu job re-ingest. Gunakan content_hash supaya hanya chunk yang benar-benar berubah yang di-embed ulang. Untuk HNSW, VACUUM bisa lambat; jalankan REINDEX INDEX CONCURRENTLY lebih dulu.

Caching berlapis. Cache embedding query (banyak pertanyaan berulang persis sama), cache hasil retrieval, dan gunakan prompt caching di sisi LLM untuk system prompt yang statis.

Batasi biaya token. Pasang rate limit per user, batasi panjang input, potong konteks pada jumlah token maksimum, dan catat penggunaan token setiap request ke tabel terpisah agar biaya bisa diaudit.

Kalibrasi ekspektasi latensi. Vector search hanya 5–50 ms, panggilan API embedding 100–300 ms, dan generasi LLM 500 ms–3 detik. Mengoptimasi vector search dari 10 ms ke 2 ms tidak akan terasa oleh pengguna. Fokus pada streaming respons.

Pantau recall. Bandingkan hasil approximate dengan exact search secara berkala (SET LOCAL enable_indexscan = off; di dalam transaksi) untuk memastikan index Anda masih sehat.

Penutup

Menambahkan RAG ke project Laravel tidak menuntut arsitektur baru. Satu ekstensi Postgres, satu tabel chunk, satu queue job untuk ingest, dan satu endpoint query sudah cukup untuk chatbot dokumentasi yang solid. Vector database khusus baru relevan ketika Anda berurusan dengan miliaran vektor atau write throughput ekstrem. Untuk sisanya, database yang sudah Anda jalankan hari ini kemungkinan besar sudah cukup.

Referensi

Bagikan Artikel