← Kembali ke Blog

Index pgvector Kamu Bohong Soal Recall. Ukur Sendiri Pakai SQL.

Kamu nambah kolom vector, jalanin CREATE INDEX ... USING hnsw, lihat p95 turun dari 27ms jadi di bawah 1 milidetik, lalu tutup tiketnya. Nggak ada yang nanya satu hal yang paling penting: 10 baris yang balik itu beneran 10 tetangga terdekat?

Index approximate boleh salah. Itu harga yang kamu bayar buat kecepatan. Yang biasanya nggak kamu tahu adalah seberapa salah, karena Postgres nggak pernah lapor. Setting default itu kompromi yang dipilih orang lain, buat dataset yang bukan punyamu.

Di 50.000 vektor 384 dimensi, setting default HNSW di pgvector 0.8.7 cuma balikin 95,9% dari top 10 yang benar di pengukuran saya. Turunin ef_search ke 10, angkanya jadi 84,1%. Dari sisi aplikasi dua query itu kelihatan sama persis: cepat, tanpa error, balik 10 baris.

Ngukurnya cuma butuh satu fungsi SQL dan sekitar sepuluh menit. Semua di bawah ini jalan lokal di Docker. Tanpa Python, tanpa embedding API, tanpa vector database yang harus di-install.

Yang perlu disiapkan

  • Docker 24 atau lebih baru (docker version)
  • Client psql, atau pakai docker exec seperti contoh
  • Sekitar 1GB RAM kosong dan satu port bebas
  • Nyaman nulis SQL

Langkah 1: jalankan Postgres dengan pgvector

docker run -d --name pgv \
  --shm-size=1g \
  -e POSTGRES_PASSWORD=bench \
  -p 5433:5432 pgvector/pgvector:pg17

docker exec -it pgv psql -U postgres -c 'CREATE EXTENSION vector;'
docker exec -it pgv psql -U postgres -c "SELECT extversion FROM pg_extension WHERE extname = 'vector';"

Hasilnya 0.8.7 di atas PostgreSQL 17.11.

Flag --shm-size=1g wajib kalau kamu mau naikin maintenance_work_mem pas build index. Tanpa itu container tetap pakai /dev/shm default 64MB, dan build-nya mati dengan pesan:

ERROR: could not resize shared memory segment "/PostgreSQL.2933610774"
to 265326048 bytes: No space left on device

Saya kena ini di percobaan pertama. Pesannya kedengeran kayak disk penuh, padahal bukan. Build index paralel pakai dynamic shared memory, dan Docker membatasinya terpisah dari disk.

Langkah 2: bikin 50.000 vektor pakai SQL saja

Kamu bisa install stack Python dan embed 50.000 dokumen, tapi itu lebih lama dari benchmark-nya sendiri. Gantinya, generate vektor pseudo-random deterministik dari hash md5: 200 pusat cluster, tiap baris adalah pusat ditambah noise, lalu dinormalisasi jadi unit vector. Struktur cluster ini penting. Embedding asli memang menggerombol, dan recall HNSW sangat bergantung ke situ.

CREATE TABLE centers AS
SELECT c.id,
       ARRAY(
         SELECT ((('x' || substr(md5('center' || c.id || ':' || s.n), 1, 8))::bit(32)::int)::float8 / 2147483648.0)
         FROM generate_series(1, 384) s(n)
       ) AS v
FROM generate_series(1, 200) c(id);

CREATE TABLE items (id bigserial PRIMARY KEY, embedding vector(384));

INSERT INTO items (id, embedding)
SELECT g.id,
       l2_normalize(ARRAY(
         SELECT c.v[s.n] + 0.6 * ((('x' || substr(md5(g.id::text || ':' || s.n), 1, 8))::bit(32)::int)::float8 / 2147483648.0)
         FROM generate_series(1, 384) s(n)
         CROSS JOIN LATERAL (SELECT cn.v FROM centers cn WHERE cn.id = 1 + (g.id % 200)) c
       )::vector)
FROM generate_series(1, 50000) g(id);

ANALYZE items;

Dua detail yang sering kepencet. generate_series di dalam harus mereferensikan baris luar (g.id), kalau nggak Postgres mengevaluasi subquery-nya sekali dan semua baris dapat vektor yang sama. Saya buang satu run gara-gara ini: 50.000 embedding identik, semua jarak cosine sama, dan "recall" 0% yang artinya nggak ada. Kalau count(DISTINCT embedding::text) bukan 50000, berhenti dan perbaiki dulu.

Detail kedua, l2_normalize baru ada sejak pgvector 0.7. Cosine distance tetap jalan di vektor yang belum dinormalisasi, tapi vektor ternormalisasi bikin distribusi jaraknya mirip embedding produksi.

Langkah 3: bikin ground truth

Ground truth artinya jawaban exact, dihitung tanpa index. Matikan index scan, biarkan Postgres lakukan sequential scan plus top-N sort, simpan hasilnya.

CREATE TABLE queries (qid int, embedding vector(384));

INSERT INTO queries (qid, embedding)
SELECT g.qid,
       l2_normalize(ARRAY(
         SELECT c.v[s.n] + 0.6 * ((('x' || substr(md5('query' || g.qid || ':' || s.n), 1, 8))::bit(32)::int)::float8 / 2147483648.0)
         FROM generate_series(1, 384) s(n)
         CROSS JOIN LATERAL (SELECT cn.v FROM centers cn WHERE cn.id = 1 + ((g.qid * 7) % 200)) c
       )::vector)
FROM generate_series(1, 100) g(qid);

SET enable_indexscan = off;
SET enable_bitmapscan = off;

CREATE TABLE truth AS
SELECT q.qid, array_agg(t.id ORDER BY t.dist) AS exact_ids
FROM queries q
CROSS JOIN LATERAL (
  SELECT i.id, i.embedding <=> q.embedding AS dist
  FROM items i ORDER BY i.embedding <=> q.embedding LIMIT 10
) t
GROUP BY q.qid;

RESET enable_indexscan;
RESET enable_bitmapscan;

100 query dengan 27,6ms per query selesai di bawah tiga detik. Scan exact-nya yang lambat, dan itu intinya: kamu bayar sekali, offline, buat tahu apa yang dilewatkan jalur cepat.

Langkah 4: satu fungsi yang lapor recall dan latency

CREATE OR REPLACE FUNCTION recall_at_10() RETURNS TABLE(avg_ms numeric, recall_pct numeric) AS $$
DECLARE
  q record; t0 timestamptz; ids bigint[]; hits int;
  total_ms numeric := 0; total_hits int := 0; n int := 0;
BEGIN
  FOR q IN SELECT qid, embedding FROM queries LOOP
    t0 := clock_timestamp();
    SELECT array_agg(id) INTO ids
    FROM (SELECT id FROM items ORDER BY embedding <=> q.embedding LIMIT 10) s;
    total_ms := total_ms + extract(epoch FROM clock_timestamp() - t0) * 1000;

    SELECT count(*) INTO hits
    FROM (SELECT unnest(ids) INTERSECT SELECT unnest(t.exact_ids) FROM truth t WHERE t.qid = q.qid) x;

    total_hits := total_hits + hits;
    n := n + 1;
  END LOOP;

  avg_ms := round(total_ms / n, 3);
  recall_pct := round(100.0 * total_hits / (n * 10), 1);
  RETURN NEXT;
END; $$ LANGUAGE plpgsql;

Recall di sini artinya irisan dengan top 10 exact, dirata-rata dari 100 query. Skor 95,9 berarti index melewatkan sekitar 4 dari setiap 100 tetangga yang benar. INTERSECT mengabaikan urutan dan duplikat, dan itu memang yang kita mau: kalau index balikin sepuluh ID yang benar dengan urutan beda, itu bukan kegagalan retrieval. Urutan diurus reranking nanti.

Langkah 5: baseline

SELECT avg_ms, recall_pct FROM recall_at_10();

Tanpa index di kolom itu: 27,6ms dan recall 100%, tentu saja, karena planner nggak punya pilihan lain. Itu plafon latency sekaligus definisi "benar" kamu.

Langkah 6: bikin index lalu sweep ef_search

SET maintenance_work_mem = '256MB';
CREATE INDEX items_hnsw ON items
  USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);

SET hnsw.ef_search = 40;   -- default
SELECT avg_ms, recall_pct FROM recall_at_10();

SET hnsw.ef_search = 100;
SELECT avg_ms, recall_pct FROM recall_at_10();

Build makan 10,4 detik dan menghasilkan index 102MB untuk 50.000 baris 384 dimensi. Sweep dengan 100 query:

  • scan exact: 27,6ms, recall 100%
  • ef_search 10: 0,80ms, recall 84,1%
  • ef_search 40 (default): 0,75ms, recall 95,9%
  • ef_search 100: 0,58ms, recall 99,0%
  • ef_search 200: 0,76ms, recall 100%

Baca kolom latency-nya dengan curiga. Seluruh index muat di page cache, jadi beda antara 0,58ms dan 0,80ms itu noise, bukan sinyal. Di 50.000 baris kesimpulan jujurnya: kecepatan datar, yang bergerak cuma recall. Di tabel 10 juta vektor kurva latency-nya baru nyata, dan di situ ef_search jadi keputusan biaya yang asli.

Yang jelas dari tabel: default 40 meninggalkan sekitar 4% tetangga asli di lantai. Turun ke 10 bikin kamu kehilangan 16 poin recall dan nggak dapat apa-apa yang terukur. Buat sistem RAG, saya lebih pilih bayar satu milidetik ekstra daripada diam-diam menghilangkan chunk yang berisi jawabannya.

Set per query, bukan global, biar batch job bisa pakai budget lebih ketat daripada pencarian user:

BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id, content FROM chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;

Langkah 7: m dan ef_construction

m adalah jumlah maksimum koneksi per layer graph, default 16. ef_construction adalah ukuran candidate list saat build, default 64. Dua-duanya terdokumentasi di README pgvector, dan README-nya sendiri menyarankan pakai default kecuali recall-mu rendah.

50.000 vektor yang sama, tiga build, recall diukur di dua nilai ef_search:

  • m=8: build 7,1 detik, 89,4% di ef_search 40, 98,8% di 100
  • m=16: build 10,3 detik, 98,9% di ef_search 40, 99,0% di 100
  • m=32: build 20,5 detik, 97,0% di ef_search 40, 100% di 100

Build time naik dua kali lipat dari m=16 ke m=32 sementara recall di setting default cuma geser di dalam noise. Ukuran index juga nyaris nggak gerak: 102MB untuk m=8 dan m=16, 106MB untuk m=32. Kalau recall-mu jelek, naikin ef_search di query time itu perbaikan yang lebih murah. Naikin m berarti kamu bayar build time dan ukuran index di setiap write setelahnya.

Hasil m=32 di ef_search 40 (97,0%) yang lebih rendah dari m=16 (98,9%) jadi pengingat bahwa selisih satu dua poin dari 1.000 pengecekan tetangga bukan vonis. Pakai lebih banyak query sebelum menyimpulkan dari selisih kecil.

Langkah 8: IVFFlat sebagai pembanding

IVFFlat membangun partisi k-means, bukan graph. Tabel yang sama, lists = 100 (README menyarankan jumlah baris dibagi 1000 untuk sampai satu juta baris):

CREATE INDEX items_ivf ON items
  USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);

SET ivfflat.probes = 10;  -- README menyarankan mulai dari sqrt(lists)
SELECT avg_ms, recall_pct FROM recall_at_10();
  • build: 1,4 detik, 78MB
  • probes=1: 0,55ms, recall 100%
  • probes=10: 2,99ms, recall 100%
  • probes=50: 27,5ms, recall 100%

Angka 100% di satu probe itu sifat dataset sintetis ini, bukan hasil umum. Query saya dibangkitkan dari 200 pusat yang sama dengan datanya, jadi query mendarat di list yang benar dan tetangga terdekatnya ada di situ. Embedding asli tumpang tindih antar cluster, dan satu probe pasti meleset. Yang bisa digeneralisasi adalah bentuk kurvanya: IVFFlat build sekitar tujuh kali lebih cepat dan makan disk lebih sedikit, lalu bayarnya di search, di mana probes=50 sudah lebih lambat dari sequential scan.

Dua aturan IVFFlat yang sudah menghabiskan banyak sore orang:

  1. Bikin index setelah tabel punya data. Index di tabel kosong nggak punya baris buat di-cluster.
  2. probes sama dengan lists menghasilkan pencarian exact, dan planner nggak akan pakai index-nya sama sekali. Angka 27,5ms di probes=50 dan 26,8ms di probes=100 itu dua-duanya sequential scan yang mau kamu hindari.

Kapan dipakai dan apa yang dilewati

Di bawah sekitar 50.000 vektor, ukur dulu dan pertimbangkan skip index. Sequential scan 27ms masih wajar buat tool internal, dan dia nggak bisa kehilangan tetangga. Di atas itu, HNSW dengan default plus ef_search yang sudah kamu uji sendiri mengalahkan semua folklore tuning.

Lewati pgvector kalau index-nya sudah nggak muat di RAM dengan headroom yang wajar, atau kalau kamu butuh quantization dan filtering yang didorong ke layer index. Itu percakapan Qdrant atau Milvus, dan harganya satu service tambahan buat dioperasikan. Jangan mulai percakapan itu sebelum kamu punya angka recall, karena tanpa angka kamu nggak bisa bilang sistem barunya lebih baik.

Jebakan yang perlu dicatat

  • Shared memory Docker: naikin --shm-size sebelum naikin maintenance_work_mem, kalau nggak build gagal dengan error disk yang menyesatkan.
  • Subquery tanpa korelasi: kalau generator vektor nggak mereferensikan baris luar, semua baris dapat vektor sama. Cek count(DISTINCT ...) sebelum percaya benchmark apa pun.
  • Vektor random uniform itu kasus terburuk: run pertama saya pakai vektor random uniform, bukan cluster, dan recall@10 jatuh ke 5,2% di ef_search = 10. Kalau datamu memang menyebar rata di seluruh ruang, HNSW bakal mengecewakan dan nggak ada parameter yang bisa menolong.
  • Rebuild setelah re-embedding massal: ganti model embedding, graph lama mengindeks vektor yang artinya sudah beda.
  • Angka cache-warm itu bohong: jalankan tiap konfigurasi minimal dua kali dan laporkan run kedua.

Langkah berikutnya

Ganti generator sintetis dengan 5.000 baris dari korpus aslimu, jalankan recall_at_10() di ef_search 10, 40, dan 100, lalu pilih nilai terkecil yang menjaga recall di atas threshold-mu. Setelah itu tulis angka itu sebagai komentar di sebelah CREATE INDEX, biar orang berikutnya yang mengubahnya tahu apa yang sedang dia pertahankan.

Referensi

Butuh Bantuan Implementasi?

Saya membantu tim mendesain dan membangun infrastruktur cloud scalable, pipeline DevOps, dan sistem production-grade.

Konsultasi Gratis