Pencarian Semantik di PostgreSQL dengan pgvector
Diperbaharui 13 November 2026 7:08 PMPencarian semantik membuat hasil pencarian mengikuti maksud pengguna, bukan sekadar kecocokan huruf. Resep umumnya: simpan embedding di layanan vektor terpisah, lalu sinkronkan sendiri dengan database utama. Padahal PostgreSQL punya ekstensi pgvector yang menyimpan vektor di tabel biasa — ikut transaksi, ikut backup, dan tetap bisa digabung dengan WHERE maupun JOIN. Artikel ini membahas pemasangan, migration Laravel, sampai tuning indeks HNSW.
Satu database untuk data dan vektor
Memisahkan data relasional dan vektor berarti dua sumber kebenaran. Setiap kali artikel dihapus atau hak akses berubah, ada dua tempat yang harus diperbarui — dan kegagalan sinkronisasi muncul sebagai hasil pencarian yang menunjuk dokumen yang sudah tidak ada.
Dengan pgvector, kolom vektor berdiri di sebelah title, status, dan user_id. Filter hak akses, filter kategori, dan pengurutan berdasarkan kemiripan bisa dieksekusi dalam satu query. Konsekuensinya, kamu ikut mengurus hal yang biasanya dipegang layanan terpisah: tipe kolom, indeks, dan parameter pencarian.
Memasang ekstensi pgvector
Ekstensi ini mendukung PostgreSQL 13 ke atas. Rilis terbaru yang terpasang saat artikel ini ditulis adalah v0.8.7. Ada dua jalur pemasangan yang umum:
# Docker — image resmi sudah berisi ekstensi
docker pull pgvector/pgvector:pg18-trixie
# atau kompilasi dari source
git clone --branch v0.8.7 https://github.com/pgvector/pgvector.git
cd pgvector && make && make install
# aktifkan di database, cukup sekali per database
psql -d blog -c "CREATE EXTENSION IF NOT EXISTS vector;"
psql -d blog -c "\dx vector"
Kalau perintah terakhir gagal dengan pesan extension "vector" is not available, artinya binary belum masuk ke direktori lib server — bukan masalah izin database.
Menyiapkan kolom vektor di Laravel
Untuk PHP, paket pgvector/pgvector versi 0.2.2 (butuh PHP 8.1+) menyediakan tipe kolom, cast Eloquent, dan query nearest neighbor. Semua perintahnya tetap SQL biasa.
composer require pgvector/pgvector
php artisan vendor:publish --tag="pgvector-migrations"
php artisan migrate
Setelah migration bawaan paket jalan, tipe vector bisa dipakai di migration berikutnya:
Schema::create('documents', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('body');
$table->vector('embedding', 768); // jumlah dimensi ikut model embedding
$table->timestamps();
});
// indeks dibuat terpisah, setelah data awal masuk
DB::statement('CREATE INDEX documents_embedding_idx
ON documents USING hnsw (embedding vector_cosine_ops)');
vector, halfvec, atau sparsevec?
vector— presisi tunggal, butuh4 × dimensi + 8byte per baris.halfvec— presisi setengah,2 × dimensi + 8byte, berguna saat data sudah besar.bit— untuk embedding yang sudah dikuantisasi jadi biner, dipakai dengan jarak Hamming atau Jaccard.sparsevec— untuk vektor yang mayoritas elemennya nol, misalnya hasil pembobotan kata.
Tipe vector menerima hingga 16.000 dimensi, tetapi indeks HNSW dibatasi 2.000 dimensi (halfvec 4.000). Di atas itu pencarian tetap jalan tanpa indeks, tapi lebih lambat.
Menyimpan embedding
Di Eloquent, daftarkan cast dan trait bawaan paket — trait itulah yang menambahkan method nearestNeighbors ke model:
use Pgvector\Laravel\Vector;
use Pgvector\Laravel\HasNeighbors;
class Document extends Model
{
use HasNeighbors;
protected $casts = ['embedding' => Vector::class];
}
$document = new Document();
$document->title = 'Panduan pgvector';
$document->embedding = $vectorDariModelEmbedding; // array float
$document->save();
Tiga hal yang sering terlewat. Pertama, jumlah dimensi harus konsisten — mengganti model embedding berarti membuat kolom baru atau menormalisasi ulang seluruh data. Kedua, untuk impor besar pakai COPY dan bangun indeks setelahnya, karena menyisipkan jutaan baris ke tabel berindeks jauh lebih lambat. Ketiga, kalau memilih inner product, normalisasi dulu dengan l2_normalize() agar peringkatnya sebanding dengan cosine similarity.
Query tetangga terdekat
pgvector menyediakan empat operator jarak. Pilih satu, lalu pakai operator yang sama di indeks:
<->— jarak Euclidean (L2)<=>— jarak cosine<#>— negatif inner product (semakin kecil, semakin mirip)<+>— jarak L1, tersedia sejak versi 0.7.0
use Pgvector\Laravel\Distance;
$hasil = Document::query()
->nearestNeighbors('embedding', $queryVector, Distance::Cosine)
->take(5)
->get();
$hasil->pluck('neighbor_distance'); // skor jarak tiap baris
Padanan SQL-nya sederhana dan enak dipakai saat debugging:
SELECT id, title, embedding <=> :query AS distance
FROM documents
WHERE status = 'publish'
ORDER BY embedding <=> :query
LIMIT 5;
Tanpa indeks, query di atas melakukan pencarian exact: seluruh baris dipindai dan hasilnya selalu akurat. Biayanya tumbuh seiring jumlah data — di sinilah indeks mulai dibutuhkan.
Indeks HNSW dan parameternya
HNSW membangun graf multi-lapis sehingga query hanya menyusuri sebagian kecil graf. Hasilnya approximate: lebih cepat, tetapi recall tidak lagi 100 persen. Buat satu indeks untuk setiap operator yang dipakai — vector_cosine_ops tidak akan membantu query yang memakai <->.
CREATE INDEX documents_embedding_idx
ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
SET hnsw.ef_search = 100; -- default 40
m mengatur koneksi per lapisan, ef_construction mengatur besar kandidat saat graf dibangun. Menaikkan keduanya memperbaiki recall dengan harga waktu build dan kecepatan insert. Saat query, hnsw.ef_search yang menentukan; pakai SET LOCAL di dalam transaksi kalau nilainya hanya perlu untuk satu query.
Waktu build sangat dipengaruhi memori: selama graf masih muat di maintenance_work_mem, prosesnya jauh lebih cepat. PostgreSQL akan memberi peringatan hnsw graph no longer fits into maintenance_work_mem ketika sudah tidak muat.
Filter dan jebakan recall
Dengan indeks approximate, filter WHERE dievaluasi setelah indeks dipindai. Kalau filter hanya cocok pada 10 persen baris dan ef_search bernilai 40, rata-rata hanya sekitar 4 baris yang lolos — jauh dari LIMIT 5 yang kamu minta.
Sejak versi 0.8.0 ada iterative index scan yang otomatis memindai lebih jauh sampai hasil cukup:
SET hnsw.iterative_scan = strict_order; -- urutan jarak tetap presisi
SET hnsw.iterative_scan = relaxed_order; -- recall lebih baik, urutan mendekati
SET hnsw.max_scan_tuples = 20000; -- batas baris yang dipindai
Alternatif lain: indeks B-tree pada kolom filter (cocok kalau filter menyisakan sedikit baris), partial index, atau partisi tabel. Untuk multi-tenant, satu indeks bersama membuat vektor tenant lain ikut memengaruhi recall — partisi per customer_id biasanya lebih aman.
Hybrid search: teks dan vektor
Pencarian vektor bagus untuk kemiripan makna, tetapi lemah untuk nama produk atau kode error yang persis. Gabungkan dengan full-text search bawaan PostgreSQL, lalu satukan peringkatnya memakai Reciprocal Rank Fusion (RRF):
WITH vektor AS (
SELECT id, row_number() OVER (ORDER BY embedding <=> :q) AS rnk
FROM documents ORDER BY embedding <=> :q LIMIT 50
), teks AS (
SELECT id, row_number() OVER (
ORDER BY ts_rank_cd(search_vector, plainto_tsquery('indonesian', :kw)) DESC
) AS rnk
FROM documents
WHERE search_vector @@ plainto_tsquery('indonesian', :kw) LIMIT 50
)
SELECT id, SUM(1.0 / (60 + rnk)) AS skor
FROM (SELECT * FROM vektor UNION ALL SELECT * FROM teks) gabung
GROUP BY id ORDER BY skor DESC LIMIT 10;
Konstanta 60 adalah nilai umum pada RRF; yang penting peringkat, bukan skor mentah dua metode yang skalanya berbeda. Contoh versi PHP-nya ada di repo pgvector-php.
Catatan produksi dan batasannya
- Pantau recall: jalankan query yang sama dengan
SET LOCAL enable_indexscan = offdi dalam transaksi, lalu bandingkan hasilnya. - Kalau indeks membengkak, pindah ke
halfvecsebelum memikirkan layanan lain. - Vektor ikut WAL, jadi replikasi dan point-in-time recovery tetap berlaku.
- Untuk tabel besar, atur
shared_bufferslewat PgTune lalu ukur ulang.
Kapan sebaiknya tidak memakai pgvector
Kalau data masih di bawah puluhan ribu baris, exact scan sering sudah cukup dan kamu tidak perlu berurusan dengan parameter recall. pgvector juga bukan pilihan terbaik saat butuh kuantisasi bawaan, replikasi multi-region berlatensi sangat rendah, atau filter metadata yang sangat kompleks. Untuk kebutuhan khas aplikasi bisnis dan blog, menambah satu komponen infrastruktur biasanya lebih mahal daripada satu ekstensi.
FAQ
Apakah pgvector butuh server terpisah?
Tidak. pgvector dipasang di server PostgreSQL yang sama; yang bertambah hanya tipe data, operator, dan indeks.
Berapa dimensi maksimal yang bisa diindeks?
Tipe vector menampung 16.000 dimensi, tetapi indeks HNSW dibatasi 2.000 dimensi. Di atas itu pakai halfvec (4.000), binary quantization (64.000), atau turunkan dimensi.
Kenapa hasil pencarian berubah setelah indeks dibuat?
Karena indeks HNSW bersifat approximate. Tanpa indeks hasilnya exact; dengan indeks, urutan bisa sedikit berbeda. Naikkan hnsw.ef_search kalau presisi lebih penting daripada latensi.
Kesimpulan
pgvector menghapus satu komponen dari arsitektur: vektor tinggal di database yang sudah kamu operasikan. Mulai dari kolom vector dan query operator jarak, lalu tambahkan indeks HNSW ketika data mulai lambat. Setelah itu uji recall dengan filter nyata — bukan hanya query tanpa WHERE, karena di situlah kejutan produksi muncul.
Untuk langkah berikutnya, pasang embedding dari model pilihanmu lewat Laravel AI SDK, atau bandingkan beberapa provider lewat Prism PHP. Kalau butuh data contoh untuk uji coba, pola seeder dan factory di Laravel masih relevan sampai sekarang.
Referensi