Back to the stack guide
Vector store

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.