Kamu nyari di runbook tim: ketik "my database keeps running out of connections", hasilnya kosong. Padahal dokumen yang jawab pertanyaan itu ada, judulnya "PostgreSQL connection pool exhaustion". Keyword search nyocokin kata, dan kata-kata kamu nggak ada di dokumen itu. Maknanya cocok, teksnya nggak.
Celah itulah yang ditutup semantic search. Alih-alih mencocokkan token, dia ngubah query dan dokumen jadi vektor, lalu ngembaliin vektor yang jaraknya paling dekat. Dua kalimat yang maksudnya sama bakal berdekatan, walaupun nggak berbagi satu kata pun.
Chroma adalah vector database open source (Apache 2.0) yang bikin ini gampang banget. Install satu package, masukin dokumen, search. Semua yang ada di artikel ini beneran dijalanin: snippet-nya kode yang saya eksekusi, dan output-nya apa yang dicetak kode tersebut.
Prasyarat
- Python 3.9 ke atas. Itu syarat chromadb, saya cek di PyPI.
pip install chromadb. Versi terbaru 1.5.9 per Agustus 2026.- Disk sekitar 80 MB buat model embedding bawaan. Chroma download otomatis pas pertama kali dipake.
- Semua contoh saya tes di chromadb 0.6.3 dengan Python 3.11. Dokumentasi terbaru pake API yang sama buat semua yang dipake di sini, tapi pin versi kamu sendiri dan tes dulu sebelum percaya tutorial mana pun.
1. Install dan query pertama
pip install chromadb
Setup paling cepat adalah in-memory client. Data hidup di dalam proses dan mati bareng prosesnya, jadi pas banget buat eksperimen:
import chromadb
client = chromadb.Client()
collection = client.get_or_create_collection(name="ops_runbook")
collection.add(
ids=["doc-0", "doc-1", "doc-2", "doc-3", "doc-4", "doc-5", "doc-6", "doc-7"],
documents=[
"PostgreSQL connection pool exhaustion: check max_connections and increase pool_size in pgbouncer",
"Docker build fails with 'no space left on device': prune old images with docker image prune -a",
"Kubernetes pod stuck in CrashLoopBackOff: check liveness probe timeout and container logs",
"Nginx 502 Bad Gateway: upstream service is down or the timeout is too low in the location block",
"Redis OOM: set maxmemory policy to allkeys-lru and monitor with INFO memory",
"Disk full on CI runner: clean /tmp and Docker build cache in the pipeline",
"Slow page loads: check TTFB, then query times, then CDN cache hit ratio",
"SSL certificate expired: renew with certbot and reload nginx without downtime",
],
metadatas=[
{"service": "postgres", "severity": "high"},
{"service": "docker", "severity": "medium"},
{"service": "kubernetes", "severity": "high"},
{"service": "nginx", "severity": "medium"},
{"service": "redis", "severity": "high"},
{"service": "ci", "severity": "medium"},
{"service": "web", "severity": "low"},
{"service": "nginx", "severity": "high"},
],
)
results = collection.query(
query_texts=["my database keeps running out of connections"],
n_results=2,
)
Itu runbook ops mini. Tiap entry bawa metadata: service apa, dan biasanya separah apa. Query pake parafrase dari dokumen Postgres, hasilnya:
collection count: 8
query: 'my database keeps running out of connections'
rank 1: PostgreSQL connection pool exhaustion: check max_connections and increase pool_size in pgbouncer
distance=1.0598
rank 2: Nginx 502 Bad Gateway: upstream service is down or the timeout is too low in the location block
distance=1.5853
Query-nya nggak pernah nyebut "PostgreSQL", "pool", atau "exhaustion", dan dokumen pemenangnya nggak pernah nyebut "database". Yang dicocokin makna, bukan kata. Tiga hal yang perlu kamu tahu:
add()butuh id string yang unik. Kalau id-nya udah ada, record diabaikan tanpa error, jadi buat ingest yang idempotent pakeupdate()atauupsert().query()nge-embed teks query kamu otomatis pake embedding function milik collection. Kamu jarang pegang vektor langsung.- Distance-nya Euclidean (L2), dan makin kecil makin mirip. Nggak ada ambang similaritas: kamu selalu dapet
n_resultshasil, default 10.
2. Filter metadata
Nyari di semua data sekaligus jarang yang kamu mau di production. Biasanya kamu mau dokumen paling mirip dari satu bagian data. Di situ gunanya argumen where:
results = collection.query(
query_texts=["certificate about to expire"],
n_results=3,
where={"service": "nginx"},
)
Sekarang cuma entry yang metadata service-nya "nginx" yang jadi kandidat:
-- where={'service': 'nginx'} --
SSL certificate expired: renew with certbot and reload nginx without downtime
Nginx 502 Bad Gateway: upstream service is down or the timeout is too low in the location block
Operator metadatanya: $eq, $ne, $gt, $gte, $lt, $lte, $in, $nin, plus $and/$or buat gabungin klausa. {"service": "nginx"} itu cuma gula dari {"service": {"$eq": "nginx"}}. Filter severity dulu baru ranking itu pola yang kepake banget:
results = collection.query(
query_texts=["everything is on fire"],
n_results=5,
where={"severity": {"$in": ["high"]}},
)
Filter jalan di dalam query, jadi slot top-k kamu nggak kepake buat dokumen yang nggak bisa dipake user:
-- where={'severity': {'$in': ['high']}} --
Kubernetes pod stuck in CrashLoopBackOff: check liveness probe timeout and container logs
PostgreSQL connection pool exhaustion: check max_connections and increase pool_size in pgbouncer
Redis OOM: set maxmemory policy to allkeys-lru and monitor with INFO memory
SSL certificate expired: renew with certbot and reload nginx without downtime
3. Full-text search di isi dokumen
Kadang kamu butuh pencocokan kata kunci di teksnya, bukan similaritas. where_document filter berdasarkan isi dokumen:
results = collection.get(where_document={"$contains": "docker"})
Output:
-- where_document={'$contains': 'docker'} --
Docker build fails with 'no space left on device': prune old images with docker image prune -a
Operatornya: $contains, $not_contains, $regex, $not_regex, bisa digabung pake $and dan $or.
Tapi ada jebakannya: full-text search itu case-sensitive. Di korpus saya ada juga "Docker build cache" dengan D kapital, dan "$contains": "docker" ngelewatin itu. Kalau user kamu ngetik lowercase, normalisasi pas ingest, atau pake $regex kalau butuh kontrol lebih.
4. Persistence dengan PersistentClient
In-memory client lupa semua pas proses keluar. Buat yang beneran dipake, pake PersistentClient, yang nyimpen data di direktori lokal berbasis SQLite:
client = chromadb.PersistentClient(path="./chroma_data")
collection = client.get_or_create_collection(name="ops_runbook")
API-nya sama, tapi datanya selamat dari restart. get() ngambil record tanpa ranking similaritas, lengkap dengan pagination limit/offset:
results = collection.get(limit=3, offset=0)
print(results["ids"])
# ['doc-0', 'doc-1', 'doc-2']
Update dan delete:
collection.update(
ids=["doc-0"],
documents=["PostgreSQL pool exhaustion: raise max_connections, add pgbouncer pool sizing"],
metadatas=[{"service": "postgres", "severity": "high"}],
)
collection.delete(ids=["doc-7"])
print(collection.count()) # 7
update() ngantiin dokumen dan metadata buat id yang udah ada. upsert() insert atau replace, jadi itu pilihan yang tepat buat job ingest yang idempotent.
5. Mode client-server
Kalau beberapa proses atau mesin butuh index yang sama, jalanin Chroma sebagai server. Package pip-nya udah termasuk CLI:
chroma run --path /db_path
Sambungin pake HTTP client:
import chromadb
client = chromadb.HttpClient(host="localhost", port=8000)
Ini beneran saya jalanin pas nulis artikel: server di localhost:8000, endpoint heartbeat balas 200, dan query lewat HttpClient balik dengan dokumen yang bener. Ada juga varian async, AsyncHttpClient, dengan signature method yang sama. Opsi deployment, termasuk image Docker resmi, ada di dokumentasi server.
6. Embedding functions
Embedding function bawaan adalah all-MiniLM-L6-v2 dari Sentence Transformers, jalan lokal lewat ONNX. Outputnya vektor 384 dimensi, dan Chroma download file modelnya (~80 MB) pas pertama dipake, terus di-cache. Buat prototyping dan korpus kecil ini default yang solid: nggak perlu API key, data nggak keluar dari mesin kamu, dan nggak ganggu.
Kalau kualitas lebih penting, ganti ke model hosted:
from chromadb.utils.embedding_functions import OpenAIEmbeddingFunction
collection = client.create_collection(
name="my_collection",
embedding_function=OpenAIEmbeddingFunction(model_name="text-embedding-3-small"),
)
Ini ngirim teks kamu ke OpenAI, jadi pikir dulu datanya boleh ke mana. Custom embedding function juga didukung: implement EmbeddingFunction terus kasih dekorator register_embedding_function. Satu batasan: semua vektor di satu collection berbagi satu dimensi, dan query_embeddings harus cocok. Ganti embedding function berarti nge-embed ulang collection-nya.
7. Pipeline retrieval buat RAG
Bagian yang diurus Chroma di aplikasi RAG adalah retrieval: ngubah pertanyaan jadi top-k dokumen paling relevan. Fungsi kecil yang bisa dipake ulang:
def retrieve(query, top_k=3, where=None):
res = collection.query(query_texts=[query], n_results=top_k, where=where)
blocks = []
for i, doc in enumerate(res["documents"][0], start=1):
meta = res["metadatas"][0][i - 1]
blocks.append(f"[{i}] (service={meta['service']}, severity={meta['severity']}) {doc}")
return "\n".join(blocks), res["distances"][0]
Panggil:
query = "the app is slow and I don't know where to start looking"
context, distances = retrieve(query, top_k=3)
print("DISTANCES:", [round(d, 3) for d in distances])
print(context)
DISTANCES: [1.321, 1.638, 1.797]
[1] (service=web, severity=low) Slow page loads: check TTFB, then query times, then CDN cache hit ratio
[2] (service=nginx, severity=medium) Nginx 502 Bad Gateway: upstream service is down or the timeout is too low in the location block
[3] (service=kubernetes, severity=high) Kubernetes pod stuck in CrashLoopBackOff: check liveness probe timeout and container logs
Hit pertama "Slow page loads", persis entry yang harus dimunculin runbook itu. Dari sini string context masuk ke panggilan LLM. Langkah itu butuh API key kamu sendiri, jadi di kode saya di-comment, tapi cuma dua panggilan:
# from openai import OpenAI
# llm = OpenAI()
# response = llm.chat.completions.create(
# model="gpt-4o-mini",
# messages=[
# {"role": "system", "content": "Answer using only the provided context."},
# {"role": "user", "content": f"Context:\n{context}\n\nQuestion: {query}"},
# ],
# )
Bikin context yang di-retrieve tetap pendek dan instruksinya ketat. "Answer using only the provided context" itu yang nahan model biar nggak ngarang fakta yang nggak ada di dokumen kamu.
Kapan pake Chroma vs alternatifnya
| Tool | Pake kapan | Cari lain kalau |
|---|---|---|
| Chroma | mau lokal, Python-native, jalan dalam hitungan menit, korpus kecil sampai menengah | butuh scale horizontal atau query throughput yang sangat tinggi |
| pgvector | data kamu udah di Postgres dan nggak mau nambah satu sistem lagi | butuh fitur vektor yang nggak ada di Postgres, misal sebagian distance metric atau hybrid scoring |
| Qdrant | production service dengan filtering kaya dan QPS tinggi, server Rust, SDK banyak bahasa | mau zero infrastructure dan satu proses doang |
| FAISS | butuh kecepatan nearest-neighbor mentah di dalam proses sendiri, dan urus persistence sendiri | mau database dengan CRUD, filter, dan durability yang built-in |
| Milvus | skala sangat besar, deployment terdistribusi | nggak butuh search terdistribusi. Milvus berat dioperasikannya |
Tradeoff yang jujur: Chroma itu cara termudah bikin semantic search jalan, dan masih nyaman dipake jauh setelah tahap "prototype". Begitu requirement kamu jadi "beberapa service berbagi satu index" atau "ribuan query per detik", migrasinya mostly mekanis, karena API query-nya mirip di semua tool ini.
Catatan keamanan: CVE-2026-45829
Ini layak dapat section sendiri. Mei 2026, ada vulnerability kritis yang dipublikasi di ChromaDB: CVE-2026-45829 (GitHub advisory GHSA-f4j7-r4q5-qw2c), CVSS 9.3. Ini pre-authentication code injection yang kena versi 1.0.0 sampai 1.5.9 di mode server. Attacker yang nggak login bisa jalanin kode arbitrer di server dengan ngirim model repository jahat dengan trust_remote_code ke endpoint collections. Sampai artikel ini ditulis, belum ada release yang di-patch.
Artinya di praktik: in-memory dan persistent client di artikel ini nggak dengerin port jaringan, jadi nggak kena expose. Tapi chroma run dan image Docker itu server. Kalau kamu deploy di mana pun yang bisa dijangkau, anggap aja nggak ada autentikasi: taruh di belakang reverse proxy yang pake auth, jangan bind ke interface publik, pin versi, dan pantau advisory-nya sampai ada fix. Ini juga alasan bagus buat embedding lokal, kayak fungsi bawaan, daripada nyuruh server download model repository.
Gotcha
- Full-text search case-sensitive. Normalisasi teks pas ingest kalau user kamu ngetik lowercase.
- Distance-nya Euclidean dan nggak ada ambang similaritas, jadi
query()selalu balikinn_resultsitem, default 10. - Model bawaan download ~80 MB pas pertama jalan. Lingkungan offline perlu cache modelnya dulu.
- Chroma ngirim telemetry anonim. Di 0.6.x ada mismatch posthog yang bikin event-nya gagal dan nyetak warning "Failed to send telemetry event" yang nggak berbahaya di stderr, jadi di konfigurasi itu nggak ada yang keluar dari mesin kamu.
- Sejak 0.6.0,
list_collections()cuma balikin nama. Pakeclient.get_collection(name)buat ngakses collection-nya. - Nilai metadata harus str, int, float, atau bool. List dan nested dict bakal error.
Langkah selanjutnya
- Sambungin panggilan LLM dari section 7 jadi loop RAG penuh, dan balikin id dokumen bareng jawabannya biar model bisa nyebut sumbernya.
- Gabungin keyword dan vector search: hasil
$containsplus hasil vektor, digabung pake reciprocal rank fusion, biasanya lebih bagus dari salah satu doang. - Re-rank top 20 pake cross-encoder sebelum LLM lihat apa-apa. Itu langkah paling lambat, tapi kualitas jawaban naik kentara.
- Pas Chroma udah kekecilan, pindah ke pgvector atau Qdrant mostly mekanis. Blog ini udah punya walkthrough RAG pake pgvector buat mulai.