Retrieval gagal tanpa suara. Kamu tanya soal exit code 137, yang balik malah paragraf tentang MemoryError, karena dua teks itu berdekatan di ruang embedding. Model lalu menulis jawaban yang lancar di atas chunk yang salah, dan tidak ada apa pun di log yang bilang langkah mana yang bikin kacau.
Vector search membandingkan makna. Dia lemah pada token persis: kode error, versi software, nama fungsi, nomor order. BM25 kebalikannya. Dia cocokkan token dengan tepat sambil tidak paham bahwa "process killed" dan OOMKilled menceritakan kejadian yang sama. Gabungan keduanya mengalahkan salah satu saja, dan reranker cross-encoder di atasnya memperbaiki kesalahan urutan yang tidak bisa dilihat fusi, karena fusi cuma membaca posisi, bukan isi.
Semua yang ada di bawah jalan waktu saya nulis ini: Qdrant 1.19.1 di Docker, qdrant-client 1.19.1, fastembed 0.8.1, index 16 dokumen, 2 vCPU. Semua skor dan waktu di sini datang dari jalan itu.
Yang perlu disiapkan
- Docker Engine 24 atau lebih baru (
docker version) - Python 3.10+ di virtualenv
- Sekitar 1 GB disk kosong untuk model ONNX dan RAM 2 GB
- Paham dasar embedding dan cosine similarity
Langkah 1: jalankan Qdrant
docker run -d --name qdrant-rag \
-p 6333:6333 -p 6334:6334 \
-v "$(pwd)/qdrant_storage:/qdrant/storage" \
qdrant/qdrant:latest
curl -s http://localhost:6333/
Cek versinya, karena fitur yang dipakai nanti datang di waktu yang berbeda:
{"title":"qdrant - vector search engine","version":"1.19.1","commit":"6ab21cac..."}
Hybrid query dengan prefetch ada sejak v1.10.0, parameter k untuk RRF sejak v1.16.0, dan weighted RRF sejak v1.17.0. Versi di bawah 1.10 akan menolak bentuk query di Langkah 5.
Langkah 2: client dan model
python3 -m venv .venv && source .venv/bin/activate
pip install "qdrant-client[fastembed]"
FastEmbed menjalankan model lewat ONNX Runtime, jadi kamu dapat embedding dan reranker lokal tanpa install torch atau tumpukan CUDA.
Langkah 3: satu koleksi, dua named vector
from qdrant_client import QdrantClient, models
COLLECTION = "kb"
DENSE = "dense"
SPARSE = "sparse"
client = QdrantClient(url="http://localhost:6333")
client.create_collection(
collection_name=COLLECTION,
vectors_config={DENSE: models.VectorParams(size=384, distance=models.Distance.COSINE)},
sparse_vectors_config={SPARSE: models.SparseVectorParams(modifier=models.Modifier.IDF)},
)
Baris modifier=models.Modifier.IDF itu bagian yang sering dilewatkan. Qdrant/bm25 di FastEmbed mengeluarkan term frequency, bukan skor BM25 jadi, dan model card-nya menandai requires_idf: True. Separuh IDF dari BM25 harus dihitung di suatu tempat, dan dengan menyerahkannya ke server, dokumen baru yang masuk nanti menggeser bobot itu untuk seluruh koleksi, bukan membekukan nilai yang kamu hitung waktu indexing.
Lalu index chunk-nya:
from fastembed import TextEmbedding, SparseTextEmbedding
dense_model = TextEmbedding(model_name="BAAI/bge-small-en-v1.5")
sparse_model = SparseTextEmbedding(model_name="Qdrant/bm25")
dense_vecs = list(dense_model.embed(DOCS))
sparse_vecs = list(sparse_model.embed(DOCS))
client.upsert(
collection_name=COLLECTION,
points=[
models.PointStruct(
id=i,
vector={
DENSE: d.tolist(),
SPARSE: models.SparseVector(indices=s.indices.tolist(), values=s.values.tolist()),
},
payload={"text": t},
)
for i, (t, d, s) in enumerate(zip(DOCS, dense_vecs, sparse_vecs))
],
wait=True,
)
Embed 16 dokumen pakai dua model itu makan 394 ms di mesin ini. Point ID di contoh cuma index list; di index beneran pakai hash dari isi chunk supaya re-index meng-update chunk, bukan bikin duplikat.
Langkah 4: tes tiap retriever sendiri
Di sini kamu tahu mana dari keduanya yang menanggung beban kerjamu. Tiga query ke 16 dokumen yang sama:
| Query | Dense top-1 | BM25 top-1 |
|---|---|---|
exit code 137 |
dokumen exit-code-137, cosine 0.7536 | dokumen exit-code-137, BM25 16.9190 |
why does my process die with no stack trace when the app itself looks healthy |
dokumen exit-code-137, cosine 0.6679 | dokumen heap Node.js, BM25 24.4693 |
OOMKilled |
dokumen OOMKilled, cosine 0.7922 | dokumen OOMKilled, 6.7338, cuma satu hasil |
Tiga hal menonjol. BM25 balikin cuma satu dokumen untuk OOMKilled, karena hanya satu chunk memuat token itu, sementara dense balikin lima hasil dengan cosine 0.53 sampai 0.79 dan sebagian besar cuma noise soal MemoryError. Dense melewatkan chunk heap Node.js untuk query parafrase, padahal itu jawaban yang benar, dan BM25 menemukannya karena chunk itu benar-benar memuat kata "stack trace". Skor dari dua retriever ini beda skala dan tidak bisa dibandingkan, dijumlah, atau di-threshold satu terhadap yang lain. Itu sebabnya fusi butuh peringkat, bukan skor.
Langkah 5: gabungkan dua list dengan RRF
dense_q = list(dense_model.embed([query]))[0].tolist()
sq = list(sparse_model.embed([query]))[0]
sparse_q = models.SparseVector(indices=sq.indices.tolist(), values=sq.values.tolist())
hits = client.query_points(
collection_name=COLLECTION,
prefetch=[
models.Prefetch(query=dense_q, using=DENSE, limit=20),
models.Prefetch(query=sparse_q, using=SPARSE, limit=20),
],
query=models.RrfQuery(rrf=models.Rrf(k=60)),
limit=5,
).points
Tiap prefetch jalan sebagai pencarian sendiri, dan query luar menggabungkannya. Reciprocal rank fusion menilai dokumen dengan menjumlahkan 1 / (k + rank) untuk setiap list yang membalikkannya, jadi cuma posisi yang dihitung. Dokumentasi Qdrant menyebut konstanta ini default-nya 2, dan saya set 60 secara eksplisit, nilai dari paper RRF aslinya. Alasannya: k yang kecil bikin hasil rank-1 dari satu list mengalahkan kesepakatan di rank 4 dan 5 list lainnya, dan situasi itu justru yang ingin dihindari hybrid search.
Hasil nyata untuk exit code 137:
1. 0.0333 A container killed by the kernel usually reports exit code 137 ...
2. 0.0328 The timeout command exits with status 124 when the deadline fires
3. 0.0304 Vector search compares embeddings, which capture meaning but drop exact tokens
4. 0.0161 OOMKilled is the Kubernetes container status ...
5. 0.0159 A JVM inside a container reads the cgroup limit
Skor fusi itu ada di kisaran 0.03 karena itu jumlah peringkat, bukan similarity, jadi perlakukan sebagai urutan saja.
Sekarang mode gagalnya, yang harus kamu antisipasi. Untuk query parafrase, hybrid menaruh chunk timeout di urutan pertama dengan 0.0328 dan chunk heap Node.js di urutan kedua dengan 0.0323:
1. 0.0328 The timeout command exits with status 124 ...
2. 0.0323 Node.js sets a default old space cap around 2 GB ...
Chunk timeout ada di tengah tabel di kedua list, dan kesepakatan itu mengalahkan satu hasil kuat di satu list saja. Fusi tidak punya cara untuk sadar bahwa chunk Node.js justru yang menjawab pertanyaannya. Ini batas dari penggabungan berbasis peringkat, dan di sini reranker berguna.
Langkah 6: rerank kandidatnya
from fastembed.rerank.cross_encoder import TextCrossEncoder
reranker = TextCrossEncoder(model_name="Xenova/ms-marco-MiniLM-L-6-v2")
candidates = client.query_points(
collection_name=COLLECTION,
prefetch=[
models.Prefetch(query=dense_q, using=DENSE, limit=20),
models.Prefetch(query=sparse_q, using=SPARSE, limit=20),
],
query=models.RrfQuery(rrf=models.Rrf(k=60)),
limit=20,
).points
texts = [p.payload["text"] for p in candidates]
scores = list(reranker.rerank(query, texts))
order = sorted(range(len(texts)), key=lambda i: scores[i], reverse=True)[:3]
Cross-encoder membaca query dan chunk dalam satu pass lalu mengeluarkan skor relevansi, beda dengan dot product antara dua vektor yang di-encode terpisah. Untuk query parafrase, dia menyusun ulang list hasil fusi jadi:
1. 2.2160 Node.js sets a default old space cap around 2 GB ...
2. -10.5071 The timeout command exits with status 124 ...
3. -11.0184 A container killed by the kernel usually reports exit code 137 ...
Chunk yang benar naik dari posisi dua ke posisi satu, dan yang salah turun. Untuk exit code 137, reranker menjaga chunk benar tetap di atas dengan 8.2984 dan mendorong hasil lemah ke bawah, dengan selisih skor pertama dan kedua sekitar 15 poin. Skornya tidak dibatasi rentang tertentu dan bukan probabilitas, jadi urutkan saja listnya, jangan di-threshold.
Latensi terukur di mesin yang sama, per query:
| Tahap | Waktu |
|---|---|
| Dense search, top-5 | 3 sampai 4 ms |
| BM25 search, top-5 | 2 ms |
| Hybrid RRF, dua prefetch, top-5 | 3 ms |
| Rerank 20 kandidat, MiniLM-L-6 | 199 sampai 250 ms |
Reranking dua orde lebih mahal daripada retrieval dan biayanya naik linear dengan jumlah kandidat, jadi jaga pool di 20 sampai 50 dan potong jadi tiga atau lima chunk sebelum masuk prompt. Rerank seribu chunk per query itu cara mengubah sistem cepat jadi lambat.
Langkah 7: fungsi retrieval
def retrieve(query: str, k: int = 3, pool: int = 20):
dense_q = list(dense_model.embed([query]))[0].tolist()
s = list(sparse_model.embed([query]))[0]
sparse_q = models.SparseVector(indices=s.indices.tolist(), values=s.values.tolist())
candidates = client.query_points(
collection_name=COLLECTION,
prefetch=[
models.Prefetch(query=dense_q, using=DENSE, limit=pool),
models.Prefetch(query=sparse_q, using=SPARSE, limit=pool),
],
query=models.RrfQuery(rrf=models.Rrf(k=60)),
limit=pool,
).points
texts = [p.payload["text"] for p in candidates]
scores = list(reranker.rerank(query, texts))
ranked = sorted(range(len(texts)), key=lambda i: scores[i], reverse=True)[:k]
return [{"text": texts[i], "score": round(scores[i], 3)} for i in ranked]
Masukkan tiga chunk itu ke prompt bersama ID-nya, dan catat skor rerank di samping pertanyaannya. Waktu jawabannya salah, log itu yang memberi tahu apakah retrieval yang gagal atau generasi yang gagal, dan dua masalah itu butuh penanganan berbeda.
Kapan pakai yang mana
- Dense saja: pertanyaan bahasa natural di korpus besar yang token persisnya jarang penting.
- BM25 saja: log, kode sumber, identifier, dan index yang tidak muat kalau tiap titik harus di-embed.
- Hybrid: default untuk knowledge base campuran. Biayanya satu vektor tambahan per titik dan satu query tambahan per pencarian.
- Rerank: kalau presisi top-3 lebih penting daripada 200 ms. Dia tidak bisa menyelamatkan korpus yang sejak awal tidak memuat jawabannya.
Satu perbaikan yang lebih besar efeknya daripada tuning: buat chunk cukup kecil sehingga satu chunk sama dengan satu fakta. Chunk 1500 token yang membahas enam topik memberi masalah yang sama ke kedua retriever dan ke reranker, karena relevansi jadi sifat dari keseluruhan chunk.
Checklist
- Bikin koleksi dengan dua named vector dan pasang
Modifier.IDFdi yang sparse. - Embed beberapa dokumen dan query tiap retriever sendiri, supaya kamu tahu mana yang bekerja.
- Gabungkan pakai
prefetchplus RRF, dan tentukan nilai k, jangan cuma terima default. - Rerank pool 20 sampai 50 kandidat dengan cross-encoder lokal, lalu simpan 3 sampai 5.
- Catat skor fusi dan skor rerank untuk setiap query, dan baca ulang waktu jawabannya salah.