Qdrant
One shared Qdrant store — embedded in dev, a server in production — serving hybrid dense+BM25 retrieval with Reciprocal Rank Fusion to every lane of the platform.
One substrate, many lanes
Everything retrievable on this platform — lessons, case-study source code, ground
sources, research papers — lives in one Qdrant store. Locally that store is
embedded (a directory at data/qdrant, no server process); in production the
same client code targets Qdrant Cloud via QDRANT_URL — a free-tier
cluster holding ai_engineer_roadmap (22,210 lesson + code nodes), which the
serving host mounts strictly load-only (QDRANT_LOAD_ONLY=1: a controlled
build job owns writes; the service can never wipe or rebuild it). That cloud
collection is the only copy of the prod retrieval index, so a daily cron
snapshots it to R2 — the ops story is in the
RAG serving deep dive. Locally, the main
collection kg-corpus holds ~8,000 nodes; namespace collections (kg-ns-*)
carry lane-specific corpora such as research papers and learning-science
sources.
The RAG service mounts the store read-only and serves per-namespace views
of the shared corpus: the glossary, the tutor, select-to-explain, and the
research lanes all query the same substrate through their own filter. There is
no second index to keep in sync — even the BM25 leg hydrates its corpus back
out of Qdrant at boot via store_nodes().
Hybrid retrieval with RRF
In the full (dev/pipeline) profile, queries run two legs — production serving runs the dense leg only, because hydrating 22k nodes for BM25 does not fit its 512Mi instance:
- dense — FastEmbed bge-small vectors, cosine similarity — catches paraphrase and conceptual matches;
- sparse (BM25) — exact identifiers, API names, error strings — the things a technical corpus is full of and embeddings blur.
The legs are merged by Reciprocal Rank Fusion (QueryFusionRetriever in
reciprocal_rerank mode). RRF fuses ranks, not scores — BM25 and cosine
scores aren't calibrated against each other, so score interpolation needs
per-corpus weight tuning while rank fusion needs none. Fusion deliberately
retrieves wide (top-20 per leg) to maximize recall; a cross-encoder
reranker then narrows for precision.
The single-process rule
Embedded Qdrant takes a file lock: one process at a time. That constraint shaped the workflow instead of fighting it — batch ingestion lanes run while the service is down, then the service boots and mounts the result read-only. If the corpus collection is missing, boot fails fast with the fix in the error message ("run: make corpus-ingest") rather than silently rebuilding an index at startup.
Why Qdrant
One store had to serve two very different lives: an embedded directory in dev (no server process, nothing to install) and a managed cluster in production — with the same client code targeting both. Qdrant is one of the few vector stores where that's a first-class mode switch, and it carries dense and sparse (BM25) legs plus payload-filtered namespace views in the same collection, so hybrid retrieval never means a second system to operate. The embedding and reranking legs stay CPU-only; the store had to match that lightweight profile.
A cache is also a collection
Near-duplicate "explain this" queries on the same page hit a semantic cache — its own Qdrant collection where the query embedding is the key. A hit above the cosine threshold reuses the stored answer; a payload index scopes lookups to the page. The vector store turns out to be a perfectly good cache backend: same infra, same client, one more collection.
Numbers
- ~8,000 nodes in the main local corpus, 8 collections total; 22,210 lesson
- code nodes in the production cloud collection.
- Content-keyed embedding cache cuts a full corpus re-sync from 392s to 4s.
- Repeat explanations answer from cache in ~0.05s vs ~1.9s cold.
- Fusion retrieves top-20 per leg (dense + BM25) before the reranker narrows.