Qdrant: Setup Vector Database, Semantic Search, dan Hybrid Query
Kamu udah embed beberapa ribu dokumen, terus baru sadar bahwa kamu sebenarnya nggak punya search engine. Loop Python buat cosine similarity emang jalan, tapi cuma sampai batas tertentu: nggak ada yang ke-persist, nggak ada filter, dan tiap query scan semua data. Di titik ini orang mulai cari-cari vector database, dan daftar pendeknya biasanya cuma pgvector, Chroma, dan Qdrant. Panduan ini setup Qdrant beneran, dengan kode yang udah diuji jalan, dan ditutup dengan jawaban jujur kapan pilih yang mana.
Prerequisites
- Docker Engine (cek dengan
docker version) - Python 3.10+
- Familiar basic dengan Python dan pip
Qdrant jalan sebagai service. Semua langkah di sini pakai container lokal, jadi nggak perlu akun cloud.
Langkah 1: Jalankan Qdrant dengan Docker
Pull image dan start server:
docker pull qdrant/qdrant
docker run -p 6333:6333 -p 6334:6334 \
-v "$(pwd)/qdrant_storage:/qdrant/storage" \
qdrant/qdrant
Dua port yang penting:
6333adalah REST API, dan dashboard web ada dihttp://localhost:6333/dashboard6334adalah gRPC API, dipakai client Rust, Go, Java, dan C#
Flag -v memastikan semua data tersimpan di ./qdrant_storage, jadi restart nggak menghilangkan apa pun. Qdrant ditulis pake Rust, lisensi Apache 2.0, dan tag latest Docker-nya v1.19 saat artikel ini ditulis.
Langkah 2: Install Python client
pip install qdrant-client
Connect dan cek kesehatan:
from qdrant_client import QdrantClient
client = QdrantClient(url="http://localhost:6333")
print(client.get_collections())
Kamu harusnya lihat daftar collection kosong. Kalau koneksi gagal, cek dulu container-nya masih jalan atau nggak.
Langkah 3: Bikin collection
Collection itu semacam tabel di Qdrant. Dua setting yang penting saat pembuatan: size, harus sama dengan dimensi output model embedding kamu, dan distance, metrik kemiripannya.
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
client.create_collection(
collection_name="articles",
vectors_config=models.VectorParams(
size=384,
distance=models.Distance.COSINE,
),
)
Opsi distance ada COSINE, DOT, dan EUCLID. Cosine adalah default paling aman buat text embedding: dia membandingkan arah, bukan magnitudo, dan kebanyakan penyedia embedding sudah normalize vektor. Angka 384 di sini sesuai dengan model BAAI/bge-small-en-v1.5 yang dipakai di bawah.
Satu hal yang wajib diputuskan sebelum ngisi data: setting collection itu tetap selamanya. Ganti dimensi nanti artinya bikin ulang collection, jadi pilih size dan distance sekali, terus konsisten.
Langkah 4: Embed teks dan upsert points
Install fastembed, library embedding resmi dari ekosistem Qdrant:
pip install fastembed
from fastembed import TextEmbedding
model = TextEmbedding("BAAI/bge-small-en-v1.5")
def embed(text: str) -> list:
return list(model.embed([text]))[0].tolist()
Sekarang embed beberapa chunk teks dan simpan. Payload adalah JSON bebas yang nempel di tiap vector, dan ini yang bikin kamu bisa filter hasil pencarian nanti.
docs = [
("Qdrant is a vector database written in Rust. It stores vectors on disk and indexes them with HNSW.", "qdrant", "overview"),
("Chroma runs inside your Python process, which makes it fast to prototype and awkward to scale.", "chroma", "overview"),
("pgvector adds vector search to PostgreSQL as an extension, so vectors live next to relational data.", "pgvector", "overview"),
("Hybrid search combines dense and sparse vectors to match both meaning and exact keywords.", "concepts", "search"),
]
client.upsert(
collection_name="articles",
points=[
models.PointStruct(
id=idx,
vector=embed(text),
payload={"text": text, "tool": tool, "category": category},
)
for idx, (text, tool, category) in enumerate(docs)
],
wait=True,
)
Points itu tuple (id, vector, payload). wait=True bikin script nunggu sampai write selesai di-index, sesuai buat dipakai di script. Untuk bulk load, batch upsert-nya dan hapus flag itu.
Langkah 5: Jalanin semantic search
query = "which database runs inside the application process?"
hits = client.query_points(
collection_name="articles",
query=embed(query),
limit=3,
).points
for hit in hits:
print(hit.id, round(hit.score, 3), hit.payload["tool"])
Output asli dari script yang persis sama:
1 0.659 chroma
0 0.574 qdrant
2 0.557 pgvector
Dengan cosine distance, score lebih tinggi berarti maknanya lebih dekat. Query-nya soal database yang jalan di dalam proses aplikasi, dan chunk Chroma menang padahal kata "process" nggak muncul di query sama sekali. Itulah bagian semantik dari semantic search.
Langkah 6: Filter pakai payload
Similarity mentah jarang jadi satu-satunya kriteria. Search di production hampir selalu bawa constraint: cuma tenant ini, cuma penulis ini, cuma dokumen dari sumber ini.
hits = client.query_points(
collection_name="articles",
query=embed("vector database"),
query_filter=models.Filter(
must=[
models.FieldCondition(
key="tool",
match=models.MatchValue(value="pgvector"),
)
]
),
limit=3,
).points
for hit in hits:
print(hit.id, hit.payload["text"])
Perintah ini cuma nyari points di mana tool == "pgvector", dan filter jalan sebelum ranking, bukan sesudahnya. Dari sini ada kebiasaan yang bagus dipelajari sejak awal: field apa pun yang dipakai filter harus dikasih payload index, kalau nggak tiap search bakal berubah jadi full scan.
client.create_payload_index(
collection_name="articles",
field_name="tool",
field_schema=models.PayloadSchemaType.KEYWORD,
)
Langkah 7: Hybrid search dengan dense dan sparse vectors
Keyword yang persis itu titik lemahnya dense search murni. Tanya "HNSW", embedding yang bagus pun bisa lebih memprioritaskan paragraf generik soal graph index daripada paragraf yang beneran berisi kata itu. Gabung dense vector buat makna dengan sparse vector buat kata persis, dan itu yang dipecahkan oleh prefetch plus fusion di Qdrant.
Satu collection bisa menampung dua jenis vector sekaligus sebagai named vectors:
if client.collection_exists("articles"):
client.delete_collection("articles")
client.create_collection(
collection_name="articles",
vectors_config={
"dense": models.VectorParams(size=384, distance=models.Distance.COSINE),
},
sparse_vectors_config={
"text": models.SparseVectorParams(),
},
)
Sparse vectors cuma nyimpen index dan value yang bukan nol. Di production, kamu bikinnya dengan sparse encoder kayak SPLADE. Biar contoh ini nggak butuh dependency tambahan, encoder term-frequency kecil di bawah kerjanya sama dan ngitung per token biar nggak ada index ganda:
vocab = {}
def sparse_encode(text: str) -> models.SparseVector:
counts = {}
for token in text.lower().split():
idx = vocab.setdefault(token, len(vocab))
counts[idx] = counts.get(idx, 0) + 1
indices = list(counts.keys())
values = [float(counts[i]) for i in indices]
return models.SparseVector(indices=indices, values=values)
Upsert tiap point dengan dua kunci vector:
client.upsert(
collection_name="articles",
points=[
models.PointStruct(
id=idx,
vector={
"dense": embed(text),
"text": sparse_encode(text),
},
payload={"text": text, "tool": tool, "category": category},
)
for idx, (text, tool, category) in enumerate(docs)
],
wait=True,
)
Lalu jalankan dua search sekaligus dan gabung hasilnya dengan reciprocal rank fusion (RRF). RRF mengabaikan score mentah dan bekerja berdasarkan posisi rank, jadi dua retriever dengan skala score yang beda jauh bisa digabung tanpa kalibrasi:
query = "Rust database with HNSW indexing"
hits = client.query_points(
collection_name="articles",
prefetch=[
models.Prefetch(query=sparse_encode(query), using="text", limit=20),
models.Prefetch(query=embed(query), using="dense", limit=20),
],
query=models.RrfQuery(rrf=models.Rrf(k=60)),
limit=5,
).points
for hit in hits:
print(hit.id, hit.payload["tool"], "|", hit.payload["text"][:60])
Output asli dari script yang persis sama:
0 qdrant | Qdrant is a vector database written in Rust. It stores vecto
2 pgvector | pgvector adds vector search to PostgreSQL as an extension, s
3 concepts | Hybrid search combines dense and sparse vectors to match bot
1 chroma | Chroma runs inside your Python process, which makes it fast
Chunk qdrant menang dari dua sisi: kemiripan dense, plus kecocokan persis untuk "rust" dan "hnsw". Konstanta k bikin fusion lebih halus. Nilai kecil memperkuat rank teratas, nilai besar kasih porsi lebih ke hasil yang lebih dalam. Default-nya 2, dan 60 dipakai di contoh resmi Qdrant. Weighted RRF, yang bikin kamu bisa lebih percaya ke satu retriever daripada yang lain, tersedia sejak v1.17.
Qdrant vs pgvector vs Chroma
Ketiganya melakukan vector search. Bedanya di mana data tinggal dan seberapa jauh mereka bisa scale.
| Pilih | Kapan |
|---|---|
| pgvector | Kamu sudah pakai PostgreSQL dan vector bisa tinggal bareng data relasional. Filter jadi SQL biasa, dan nggak ada service tambahan yang dioperasikan. Benchmark komunitas menempatkannya nyaman di kisaran 10M+ vector untuk kebanyakan aplikasi. |
| Chroma | Prototyping dan notebook. Jalan in-process, jadi demo pertama cuma butuh beberapa menit. Single node, terbatas memory, nggak ada sharding bawaan, jadi siapkan migrasi sebelum traffic konkuren beneran datang. |
| Qdrant | Service standalone yang harus melayani user beneran. Payload filtering native yang jalan sebelum ranking, opsi quantization (kompresi sekitar 8x dengan TurboQuant di jalur v1.18), hybrid search, plus sharding dan replication buat berkembang. |
Titik awal kamu lebih penting daripada daftar fitur. Kalau Postgres udah jalan, pgvector menghapus satu service dari stack kamu. Kalau butuh fitur kualitas search kayak hybrid retrieval dan latency yang bisa diprediksi di bawah beban, database yang memang dibangun khusus seperti Qdrant layak dipakai. Kalau masih nyusun demo, Chroma oke, selama kamu sadar itu prototype, bukan platform.
Langkah selanjutnya
- Ambil snapshot secara rutin. Dashboard dan API dua-duanya mendukung pembuatan snapshot, dan ini kasih kamu backup point-in-time untuk seluruh direktori storage.
- Baca production checklist sebelum mengekspos Qdrant ke publik: isinya autentikasi, snapshot, dan dashboard operator.
- Untuk collection besar, lihat quantization buat memangkas memory per vector, lalu pilih antara storage on-disk dan in-memory per collection.