Semantic Search di Django Tanpa Vector Database: pgvector dengan Benar
Bangun semantic search di Django dengan pgvector dan PostgreSQL: dari embedding, HNSW, filtered recall, hingga hybrid search dengan RRF.
Hook
Pengguna mencari “cara membatalkan pesanan”, tetapi dokumen yang relevan memakai frasa “prosedur pengajuan refund”. Pencarian berdasarkan kata yang persis sama bisa melewatkan hasil yang sebenarnya tepat.
Semantic search mencoba menangkap kedekatan makna dengan membandingkan embedding—representasi teks sebagai vektor angka. Kalau aplikasi sudah menggunakan PostgreSQL, pgvector dapat menyimpan embedding dan menjalankan pencarian kemiripan di database yang sama. Data tetap dekat dengan transaksi, filter, dan backup aplikasi; tidak perlu menyinkronkan setiap perubahan ke layanan vector terpisah.
Tetapi, “simpan vektor lalu cari yang terdekat” belum cukup untuk production. Dimensi embedding, versi model, index approximate, filter tenant, dan strategi upgrade semuanya memengaruhi hasil. Mari bangun alur Django sambil membahas jebakan yang sering terlewat.
Problem/Context
Pencarian teks biasa menemukan kecocokan kata, bukan selalu kecocokan maksud. Semantic search menjembatani perbedaan istilah, tetapi memerlukan tiga bagian: model embedding yang menghasilkan vektor berdimensi tetap, PostgreSQL dengan ekstensi pgvector untuk menyimpan serta mengukur jarak, dan index approximate nearest neighbor (ANN) untuk mempercepat pencarian pada dataset besar.
pgvector bukan database terpisah. Ia ekstensi PostgreSQL, dengan integrasi Django melalui paket Python pgvector. Bedakan versinya: berdasarkan keadaan pada 28 September 2026, brief ini menggunakan ekstensi pgvector 0.8.6 dan paket Python pgvector 0.5.0. Keduanya dirilis dan di-upgrade terpisah.
Contoh mengasumsikan PostgreSQL yang menyediakan ekstensi pgvector, Django 6.0, Python 3.12+, paket pgvector==0.5.0, dan model embedding Sentence Transformers. Django 6 mendukung Python 3.12–3.14; pgvector-python 0.5.0 memerlukan Python 3.10+. Perhatikan breaking change 0.5.0: field vector di Django mengembalikan list, bukan NumPy array, dan dependensi NumPy dihapus. Kode yang mengasumsikan tipe lama perlu diperiksa sebelum upgrade (changelog paket).
Solution Walkthrough
1. Pasang integrasi dan aktifkan ekstensi
Paket Python tidak memasang ekstensi pada server database. Pastikan pgvector tersedia di PostgreSQL yang dipakai aplikasi, lalu pasang integrasinya:
pip install "pgvector==0.5.0"
Buat migration untuk mengaktifkan ekstensi sebelum migration yang membuat kolom vector:
# documents/migrations/0001_enable_vector.py
from django.db import migrations
from pgvector.django import VectorExtension
class Migration(migrations.Migration):
initial = True
dependencies = []
operations = [VectorExtension()]
Jika ekstensi dikelola di luar migration aplikasi, pastikan ekstensi sudah diaktifkan terlebih dahulu di database target.
2. Definisikan model dan embedding
Contoh ini memakai all-MiniLM-L6-v2 dari Sentence Transformers, yang menghasilkan 384 dimensi. Nilai dimensions harus persis sama dengan output model. Sebagai pembanding, text-embedding-3-small dari OpenAI memakai 1536 dimensi secara default.
pip install sentence-transformers
Model mencatat identitas embedding agar hasil dari model berbeda tidak tercampur dalam pencarian yang sama. Index HNSW di sini memakai operator cosine:
# documents/models.py
from django.db import models
from pgvector.django import HnswIndex, VectorField
class Document(models.Model):
tenant_id = models.BigIntegerField(db_index=True)
title = models.CharField(max_length=250)
content = models.TextField()
embedding_model = models.CharField(max_length=100)
embedding = VectorField(dimensions=384)
class Meta:
indexes = [
HnswIndex(
name="document_embedding_hnsw",
fields=["embedding"],
m=16,
ef_construction=64,
opclasses=["vector_cosine_ops"],
),
models.Index(
fields=["tenant_id", "embedding_model"],
name="document_tenant_model_idx",
),
]
Buat dan jalankan migration setelah migration ekstensi:
python manage.py makemigrations documents
python manage.py migrate
3. Hasilkan dan simpan embedding
Gunakan model dan preprocessing yang sama untuk dokumen maupun query. Contoh berikut menormalisasi vektor; konsistensi lebih penting daripada normalisasi itu sendiri, jadi jangan menerapkannya hanya pada salah satu sisi.
# documents/embeddings.py
from sentence_transformers import SentenceTransformer
EMBEDDING_MODEL = "sentence-transformers/all-MiniLM-L6-v2"
_model = SentenceTransformer(EMBEDDING_MODEL)
def embed_text(text: str) -> list[float]:
vector = _model.encode(text, normalize_embeddings=True)
return vector.tolist()
Simpan versi model bersama vector yang dihasilkan:
from documents.embeddings import EMBEDDING_MODEL, embed_text
from documents.models import Document
def create_document(*, tenant_id: int, title: str, content: str) -> Document:
text = f"{title}\n\n{content}"
return Document.objects.create(
tenant_id=tenant_id,
title=title,
content=content,
embedding_model=EMBEDDING_MODEL,
embedding=embed_text(text),
)
Untuk volume besar, embedding bisa dibuat lewat job background. Pastikan perubahan isi memicu regenerasi dan jangan tandai dokumen siap dicari sebelum embedding baru tersimpan.
4. Query dengan Django ORM
Django integration menyediakan ekspresi jarak seperti CosineDistance, L2Distance, dan MaxInnerProduct. Berikut query yang membatasi hasil pada tenant dan model embedding yang sama:
from pgvector.django import CosineDistance
from documents.embeddings import EMBEDDING_MODEL, embed_text
from documents.models import Document
def semantic_search(*, tenant_id: int, text: str, limit: int = 10):
query_vector = embed_text(text)
return (
Document.objects
.filter(tenant_id=tenant_id, embedding_model=EMBEDDING_MODEL)
.annotate(distance=CosineDistance("embedding", query_vector))
.order_by("distance")[:limit]
)
Jarak lebih kecil berarti lebih dekat menurut metrik tersebut. Jangan membandingkan angka jarak dari metrik berbeda atau memperlakukannya sebagai skor confidence tanpa evaluasi dan kalibrasi. Operator index juga harus cocok dengan ekspresi query: contoh cosine memakai vector_cosine_ops.
5. Index mempercepat pencarian, tetapi hasilnya approximate
Tanpa ANN index, PostgreSQL dapat membandingkan query dengan setiap vektor; biayanya bertambah seiring jumlah data. HNSW dan IVFFlat mempercepat pencarian dengan kompromi recall. Hasil ANN dapat berbeda dari pencarian exact, tanpa query memberi peringatan bahwa tetangga terdekat terlewat.
HNSW adalah titik awal praktis, tetapi cenderung membutuhkan lebih banyak memori. m dan ef_construction memengaruhi pembangunan index; hnsw.ef_search mengatur kandidat yang dipertimbangkan saat query. Default pgvector lazimnya 40, tetapi provider dapat mengubahnya—verifikasi nilai pada instance sendiri. Nilai pencarian lebih besar bisa meningkatkan recall dengan biaya kerja tambahan. IVFFlat membagi data ke dalam list dan memeriksa sebagian list; parameter list/probes perlu dituning sesuai dataset. Jangan mengandalkan angka benchmark generik: ukur latency dan recall pada data serta hardware yang benar-benar dipakai.
Jika footprint index menjadi masalah, HalfVectorField memungkinkan index half-precision atas kolom vector penuh. Ini bisa mengurangi kebutuhan memori index, tetapi perubahan presisi dapat memengaruhi kualitas dan wajib dievaluasi:
from django.contrib.postgres.indexes import OpClass
from django.db import models
from django.db.models.functions import Cast
from pgvector.django import HalfVectorField, HnswIndex, VectorField
class CompactDocument(models.Model):
embedding = VectorField(dimensions=384)
class Meta:
indexes = [
HnswIndex(
OpClass(
Cast("embedding", HalfVectorField(dimensions=384)),
name="halfvec_cosine_ops",
),
name="document_embedding_half_hnsw",
m=16,
ef_construction=64,
),
]
Untuk memakai OpClass, tambahkan 'django.contrib.postgres' ke INSTALLED_APPS. Deklarasi tersebut merupakan alternatif index; jangan menambahkan index penuh dan halfvec tanpa alasan terukur. Pastikan versi PostgreSQL, pgvector, Django, dan paket Python mendukung ekspresi index yang dipilih.
6. Waspadai filtered recall
Index ANN bisa mengambil kandidat terdekat lebih dahulu, lalu PostgreSQL menerapkan kondisi WHERE. Akibatnya, filter tenant atau kategori yang selektif dapat membuat hasil tersisa lebih sedikit daripada LIMIT yang diminta. Jika hanya sekitar 10% baris memenuhi filter sementara pencarian mempertimbangkan 40 kandidat, rata-rata hanya beberapa kandidat yang lolos. Query tetap berhasil—hasilnya saja tidak lengkap.
pgvector 0.8.0+ menyediakan iterative index scans: pencarian dapat terus memindai kandidat sampai cukup hasil ditemukan atau batas pemindaian tercapai. Terapkan parameter secara lokal dalam transaksi Django:
from django.db import connection, transaction
from pgvector.django import CosineDistance
from documents.embeddings import EMBEDDING_MODEL, embed_text
from documents.models import Document
def filtered_search(*, tenant_id: int, text: str, limit: int = 10):
query_vector = embed_text(text)
with transaction.atomic():
with connection.cursor() as cursor:
cursor.execute("SET LOCAL hnsw.iterative_scan = relaxed_order")
cursor.execute("SET LOCAL hnsw.max_scan_tuples = 20000")
return list(
Document.objects
.filter(tenant_id=tenant_id, embedding_model=EMBEDDING_MODEL)
.annotate(distance=CosineDistance("embedding", query_vector))
.order_by("distance")[:limit]
)
Pilih relaxed_order atau strict_order sesuai kebutuhan urutan dan uji perilakunya pada versi yang dijalankan. Batas scan yang lebih besar bukan jaminan semua hasil ditemukan. Index B-tree untuk kolom filter tetap berguna, tetapi tidak menghapus kompromi ANN; filter yang sangat selektif—terutama yang cocok pada kurang dari sekitar 1% data—masih dapat kekurangan hasil.
Ukur hasil dan query plan dengan filter yang sama seperti production. Misalnya:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id
FROM documents_document
WHERE tenant_id = 42
ORDER BY embedding <=> '[0.12, 0.04, ...]'::vector
LIMIT 10;
Ganti vector ilustratif tersebut dengan vector berdimensi penuh yang valid. Bandingkan recall dengan baseline exact pada sampel query representatif, bukan hanya waktu respons.
7. Gabungkan vector dan full-text lewat RRF
Semantic search membantu kecocokan makna; full-text search tetap kuat untuk istilah persis seperti nama produk, kode, atau frasa pendek. Reciprocal Rank Fusion (RRF) menggabungkan peringkat dari dua jalur tanpa mencampur skor yang skalanya berbeda.
Untuk setup full-text di bawah ini, tambahkan 'django.contrib.postgres' ke INSTALLED_APPS (selain kebutuhan OpClass di atas). SearchVectorField harus diisi dan dipelihara saat judul atau isi berubah—misalnya dengan trigger database atau mekanisme aplikasi yang andal. Tambahkan GIN index pada field itu untuk membantu pencarian leksikal:
from django.contrib.postgres.indexes import GinIndex
from django.contrib.postgres.search import SearchVectorField
from django.db import models
class Document(models.Model):
# field lain termasuk tenant_id, embedding_model, embedding
search_vector = SearchVectorField(null=True, blank=True)
class Meta:
indexes = [GinIndex(fields=["search_vector"], name="document_search_gin")]
Berikut implementasi sederhana yang mengambil kandidat dari masing-masing jalur, lalu mengurutkannya dengan skor RRF 1 / (60 + rank):
from django.contrib.postgres.search import SearchQuery, SearchRank
from pgvector.django import CosineDistance
from documents.embeddings import EMBEDDING_MODEL, embed_text
from documents.models import Document
def hybrid_search(*, tenant_id: int, text: str, limit: int = 10):
query_vector = embed_text(text)
lexical_query = SearchQuery(text, search_type="websearch")
semantic_ids = list(
Document.objects
.filter(tenant_id=tenant_id, embedding_model=EMBEDDING_MODEL)
.annotate(distance=CosineDistance("embedding", query_vector))
.order_by("distance")
.values_list("pk", flat=True)[:100]
)
lexical_ids = list(
Document.objects
.filter(tenant_id=tenant_id, search_vector__isnull=False)
.filter(search_vector=lexical_query)
.annotate(rank=SearchRank("search_vector", lexical_query))
.order_by("-rank")
.values_list("pk", flat=True)[:100]
)
scores: dict[int, float] = {}
for ids in (semantic_ids, lexical_ids):
for rank, pk in enumerate(ids, start=1):
scores[pk] = scores.get(pk, 0.0) + 1 / (60 + rank)
ordered_ids = sorted(scores, key=scores.get, reverse=True)[:limit]
by_id = Document.objects.in_bulk(ordered_ids)
return [by_id[pk] for pk in ordered_ids if pk in by_id]
Angka 60 adalah konstanta RRF yang lazim dipakai, bukan hasil benchmark. Contoh ini mengambil hingga 100 kandidat per jalur; sesuaikan setelah mengevaluasi relevansi dan biaya. Untuk query singkat atau istilah presisi, sinyal leksikal bisa sangat membantu, sedangkan query deskriptif panjang mungkin lebih mengandalkan vector. Dokumentasi Django menjelaskan PostgreSQL full-text search, dan repository pgvector-python menyediakan contoh hybrid search dengan RRF.
Trade-offs
Satu PostgreSQL, satu set batas operasional
Menaruh data aplikasi, teks, dan embedding dalam PostgreSQL menyederhanakan arsitektur serta menghindari sinkronisasi antar-database. Namun, pencarian vector memakai CPU, memori, storage, dan kapasitas yang juga dibutuhkan query transaksi. Pantau dampaknya pada workload database secara keseluruhan.
ANN tidak menjamin tetangga paling dekat
HNSW dan IVFFlat menukar recall dengan kecepatan, dan filter dapat memperburuk kekurangan hasil. Evaluasi kualitas menggunakan query nyata, baseline exact, distribusi filter tenant, dan metrik yang sesuai produk. Jangan mengklaim index approximate selalu mengembalikan hasil identik dengan pencarian exhaustive.
Perubahan model adalah migrasi data
Vektor dari model atau versi berbeda tidak otomatis berada di ruang embedding yang dapat dibandingkan. Catat identitas model, dimensi, dan preprocessing. Saat model berganti, rencanakan re-embedding; shadow column atau dual-write dan backfill bertahap dapat mengurangi risiko migrasi. Jangan mencampur embedding lama dan baru dalam satu pencarian seolah-olah nilainya sebanding.
Pin dan upgrade kedua komponen
Paket Python pgvector 0.5.0 mengganti return type menjadi list dan menghapus NumPy; cari asumsi tipe lama, lalu uji aplikasi. Upgrade paket Python tidak meng-upgrade ekstensi di server.
Periksa juga keamanan dan integritas index. Ekstensi pgvector 0.8.2 memperbaiki CVE-2026-3172, buffer overflow pada parallel HNSW builds yang berdampak pada 0.6.0–0.8.1. Rangkaian 0.8.x berikutnya mencakup perbaikan penting, termasuk kemungkinan korupsi index saat vacuum HNSW serta isu pembangunan dan pemeliharaan index. Periksa changelog resmi pgvector 0.8.6 dan pengumuman rilis 0.8.2; rencanakan upgrade ekstensi melalui prosedur backup dan deployment database yang sesuai.
Kapan perlu layanan vector terpisah?
Jangan memakai jumlah row tunggal sebagai ambang universal. Pertimbangkan pemisahan bila pengukuran menunjukkan kebutuhan skala, isolasi resource, latency tail, fitur operasional, atau beban filter tidak lagi cocok dengan PostgreSQL yang ada. Bandingkan alternatif seperti pgvectorscale dan layanan khusus berdasarkan traffic, recall, biaya, dan overhead operasi.
Conclusion + CTA
pgvector membuat semantic search di Django terasa seperti bagian alami dari aplikasi PostgreSQL yang sudah ada. Tetapi hasil production yang dapat dipercaya menuntut lebih dari VectorField: samakan dimensi dan model embedding, cocokkan metrik dengan operator index, ukur recall ANN terutama pada filter tenant, dan rencanakan perubahan model serta upgrade ekstensi secara sadar.
Mulailah dengan satu use case, kumpulkan query representatif, lalu bandingkan hasil exact dan approximate. Jika pencarian leksikal juga penting, gabungkan peringkatnya dengan RRF dan pastikan full-text vector tetap terpelihara. Sudah menjalankan pgvector di Django? Bagikan cara kamu menguji filtered recall atau memigrasikan versi embedding.
