Begitu fitur pencarian semantik diminta masuk ke proyek, reaksi pertama orang biasanya langsung cari database vektor baru: Pinecone, Weaviate, Qdrant, atau ChromaDB. Padahal kalau backend kamu sudah jalan di PostgreSQL, kamu bisa menyimpan embedding langsung di kolom tabel yang sama lewat ekstensi bernama pgvector. Tidak ada server baru, tidak ada sinkronisasi data ganda, dan kamu tetap bisa memakai JOIN, WHERE, serta transaksi seperti biasa.
Kenapa taruh embedding di Postgres, bukan database vektor terpisah?
Ada dua alasan praktis. Pertama, data kamu kemungkinan besar sudah ada di Postgres: produk, artikel, dokumen, atau riwayat chat. Memindahkan sebagian ke database vektor berarti menulis kode sinkronisasi yang bisa rusak kapan saja tanpa kamu sadari. Kedua, pencarian semantik jarang berjalan sendirian; hampir selalu digabung dengan filter biasa seperti kategori, harga, atau rentang tanggal. Dengan pgvector, query vektor dan filter SQL jadi satu SELECT.
Jalankan PostgreSQL dengan pgvector
Postgres bawaan tidak punya tipe vector. Cara paling cepat adalah image resmi pgvector/pgvector yang sudah menyertakan ekstensinya. Kalau kamu belum kenal container, sempatkan baca panduan Docker untuk pemula dulu supaya perintah di bawah tidak terasa asing.
docker run -d --name pgvector-demo \
-e POSTGRES_PASSWORD=rahasia \
-e POSTGRES_DB=tokobuku \
-p 5432:5432 \
pgvector/pgvector:pg16
Perintah ini menjalankan Postgres 16 di port 5432 dengan database tokobuku dan user postgres. Kalau kamu sudah punya Postgres sendiri, install paket postgresql-16-pgvector (Debian/Ubuntu) lalu lanjut ke langkah berikutnya — sisanya sama persis.
Aktifkan ekstensi dan buat tabel
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE produk (
id serial PRIMARY KEY,
nama text NOT NULL,
kategori text,
harga integer,
embedding vector(384)
);
Angka 384 di vector(384) adalah dimensi embedding, dan ini tidak boleh asal. Model embedding yang sama harus selalu menghasilkan jumlah dimensi yang sama. Di tutorial ini kita pakai model all-MiniLM-L6-v2 yang mengeluarkan 384 dimensi. Kalau kamu ganti ke text-embedding-3-small milik OpenAI, kolomnya wajib diubah jadi vector(1536).
Generate embedding dengan Python
Kita pakai sentence-transformers supaya tutorial ini jalan penuh di CPU tanpa API key dan tanpa biaya. Install dependensinya dulu:
pip install sentence-transformers pgvector "psycopg[binary]"
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
produk = [
("Laptop gaming RAM 16GB SSD 512GB", "elektronik", 12_500_000),
("SSD NVMe 1TB untuk upgrade laptop", "elektronik", 1_200_000),
("Buku resep masakan Padang untuk pemula", "buku", 85_000),
("Novel fiksi ilmiah tentang perjalanan waktu", "buku", 110_000),
("Kumpulan puisi karya Sapardi Djoko Damono", "buku", 65_000),
]
teks = [p[0] for p in produk]
embeddings = model.encode(teks, normalize_embeddings=True)
print(embeddings.shape) # (5, 384)
normalize_embeddings=True membuat panjang tiap vektor jadi 1. Efeknya, cosine similarity bisa dihitung sebagai dot product biasa — lebih cepat, dan kamu boleh pakai operator class vector_ip_ops saat membuat indeks nanti.
Simpan embedding ke Postgres
import psycopg
from pgvector.psycopg import register_vector
conn = psycopg.connect(
"host=localhost dbname=tokobuku user=postgres password=rahasia"
)
register_vector(conn)
with conn.cursor() as cur:
for (nama, kategori, harga), emb in zip(produk, embeddings):
cur.execute(
"INSERT INTO produk (nama, kategori, harga, embedding) "
"VALUES (%s, %s, %s, %s)",
(nama, kategori, harga, emb),
)
conn.commit()
conn.close()
register_vector(conn) dari paket pgvector mengajarkan psycopg cara mengubah array NumPy menjadi tipe vector Postgres. Tanpa itu, kamu harus mengirim string seperti '[0.1, 0.2, ...]' lalu melakukan cast manual di query — ribet dan rawan salah.
Cari dokumen paling mirip dengan cosine distance
Operator <=> menghitung cosine distance: makin kecil nilainya, makin mirip. Ada juga <-> untuk L2 distance dan <#> untuk negative inner product. Kita coba cari produk yang relevan untuk query “cara mempercepat laptop yang lemot”:
query = "cara mempercepat laptop yang lemot"
q_emb = model.encode(query, normalize_embeddings=True)
with psycopg.connect(
"host=localhost dbname=tokobuku user=postgres password=rahasia"
) as conn:
register_vector(conn)
with conn.cursor() as cur:
cur.execute(
"""
SELECT nama, 1 - (embedding <=> %s) AS similarity
FROM produk
ORDER BY embedding <=> %s
LIMIT 3;
""",
(q_emb, q_emb),
)
for nama, skor in cur.fetchall():
print(f"{skor:.3f} {nama}")
Ekspresi 1 - (embedding <=> %s) mengubah cosine distance menjadi cosine similarity dengan rentang 0 sampai 1, jadi angkanya lebih enak dibaca. Hasilnya, dua barang elektronik bakal muncul di atas, bukan buku-buku yang isinya tidak nyambung dengan query.
Gabungkan dengan filter SQL biasa
Di sinilah pgvector menang dibanding memisahkan database vektor. Kamu bisa menambahkan WHERE kategori = ... atau filter harga langsung di query yang sama:
SELECT nama, 1 - (embedding <=> %s::vector) AS similarity
FROM produk
WHERE kategori = 'elektronik'
AND harga < 2000000
ORDER BY embedding <=> %s::vector
LIMIT 3;
Query di atas hanya mencari di kategori elektronik dengan harga di bawah dua juta. Di setup database vektor terpisah, kamu harus menggabungkan hasil dua query secara manual, atau memindahkan seluruh filter ke database vektor — yang tidak selalu mendukungnya.
Percepat pencarian dengan indeks HNSW
Untuk tabel dengan puluhan ribu baris ke atas, sequential scan bakal terasa lambat. Tambahkan indeks HNSW untuk mempercepat pencarian nearest-neighbor:
CREATE INDEX ON produk USING hnsw (embedding vector_cosine_ops);
Jangan buru-buru membuat indeks. Untuk data ratusan baris, sequential scan justru lebih cepat daripada indeks, dan HNSW memakan RAM yang cukup besar. Bangun indeks hanya setelah tabelmu benar-benar besar dan kamu sudah mengukur query-nya lambat.
Script lengkap dari awal sampai akhir
Berikut versi utuh yang bisa kamu salin dan jalankan. Script ini membuat tabel, mengisi data contoh, lalu menjalankan satu pencarian semantik dengan filter kategori:
import psycopg
from pgvector.psycopg import register_vector
from sentence_transformers import SentenceTransformer
DSN = "host=localhost dbname=tokobuku user=postgres password=rahasia"
produk = [
("Laptop gaming RAM 16GB SSD 512GB", "elektronik", 12_500_000),
("SSD NVMe 1TB untuk upgrade laptop", "elektronik", 1_200_000),
("Buku resep masakan Padang untuk pemula", "buku", 85_000),
("Novel fiksi ilmiah tentang perjalanan waktu", "buku", 110_000),
("Kumpulan puisi karya Sapardi Djoko Damono", "buku", 65_000),
]
model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2")
def setup(conn):
with conn.cursor() as cur:
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
cur.execute(
"CREATE TABLE IF NOT EXISTS produk ("
"id serial PRIMARY KEY, nama text NOT NULL, "
"kategori text, harga integer, embedding vector(384))"
)
def isi_data(conn):
teks = [p[0] for p in produk]
embs = model.encode(teks, normalize_embeddings=True)
with conn.cursor() as cur:
for (nama, kategori, harga), emb in zip(produk, embs):
cur.execute(
"INSERT INTO produk (nama, kategori, harga, embedding) "
"VALUES (%s, %s, %s, %s)",
(nama, kategori, harga, emb),
)
def cari(conn, query, kategori=None, harga_maks=None):
q = model.encode(query, normalize_embeddings=True)
sql = ("SELECT nama, 1 - (embedding <=> %s) AS similarity "
"FROM produk ")
params = [q]
if kategori:
sql += "WHERE kategori = %s "
params.append(kategori)
if harga_maks:
sql += "AND harga < %s "
params.append(harga_maks)
elif harga_maks:
sql += "WHERE harga < %s "
params.append(harga_maks)
sql += "ORDER BY embedding <=> %s LIMIT 3"
params.append(q)
with conn.cursor() as cur:
cur.execute(sql, params)
for nama, skor in cur.fetchall():
print(f"{skor:.3f} {nama}")
with psycopg.connect(DSN) as conn:
register_vector(conn)
setup(conn)
isi_data(conn)
print("Cari 'upgrade penyimpanan laptop':")
cari(conn, "upgrade penyimpanan laptop", kategori="elektronik")
Langkah berikutnya yang paling masuk akal: pakai pgvector sebagai retriever di pipeline RAG-mu. Kamu bisa mengganti ChromaDB dengan Postgres di tutorial chatbot RAG, lalu mengembalikan hasil pencariannya ke LLM. Struktur datanya sudah siap, tinggal dihubungkan.