Module 3: RAG Done Properly · Lesson 3 of 5 · 40 min

Vector DBs & Hybrid Search (BM25 + Dense + RRF)

In-memory numpy stops scaling fast; a vector DB gives you ANN search, filters, and persistence. But dense retrieval alone has famous blind spots — production systems fuse it with BM25 keyword search using reciprocal rank fusion.

Brute-force cosine over a numpy matrix is fine for a thousand chunks; at hundreds of thousands you want a vector database: approximate-nearest-neighbor (ANN) indexes for sub-linear search, metadata filtering ("only chunks from the billing docs"), persistence, and updates without re-indexing the world. We use Qdrant in local mode — it runs embedded inside your Python process, no server, no Docker, and the same client API scales to a real deployment later.

Qdrant local: create, upsert, query
# Colab cell 1 — run once (local embedding model + embedded vector DB;
# no server, no API key needed).
!pip install -q qdrant-client sentence-transformers

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct
from sentence_transformers import SentenceTransformer

encoder = SentenceTransformer("all-MiniLM-L6-v2")
qdrant = QdrantClient(path="./qdrant_data")     # embedded local mode — a directory, not a server

# A small pre-chunked corpus — the shape lesson 2's structural chunker
# emits — so retrieval has something real to search.
chunks = [
    {"doc_id": "runbook", "heading": "Retry configuration",
     "text": "Set retry_backoff_max to cap exponential backoff at 60 seconds. "
             "Workers retry failed jobs five times before dead-lettering."},
    {"doc_id": "runbook", "heading": "Connection errors",
     "text": "ERR_CONN_5031 means the gateway dropped a keep-alive connection. "
             "Restart the connection pool or raise the idle timeout."},
    {"doc_id": "faq", "heading": "Account access",
     "text": "To reset your password, open Settings, choose Security, and "
             "select 'Send reset link'. The link expires after one hour."},
    {"doc_id": "faq", "heading": "Billing",
     "text": "Invoices are issued on the first of each month. Enterprise "
             "plans can switch to quarterly billing in the console."},
    {"doc_id": "guide", "heading": "Ingestion pipeline",
     "text": "Large PDFs are split into pages before parsing. Ingestion "
             "jobs that stall usually hit the 50 MB per-file limit."},
    {"doc_id": "guide", "heading": "Search tuning",
     "text": "Hybrid search fuses BM25 and dense rankings with reciprocal "
             "rank fusion. Tune top-k on a labeled eval set."},
    {"doc_id": "guide", "heading": "Single sign-on",
     "text": "SAML SSO is available on enterprise plans. Configure the "
             "identity provider under Settings > Authentication."},
    {"doc_id": "runbook", "heading": "Deployments",
     "text": "Deploys roll out region by region. A failed health check "
             "pauses the rollout and pages the on-call engineer."},
    {"doc_id": "guide", "heading": "Data retention",
     "text": "Event logs are retained for 90 days by default. EU tenants "
             "can shorten retention to 30 days for compliance."},
    {"doc_id": "faq", "heading": "Plan changes",
     "text": "Upgrades apply immediately; downgrades take effect at the "
             "next billing cycle. Seat counts adjust pro rata."},
]
for c in chunks:
    c["embed_text"] = f"{c['heading']}\n{c['text']}"

if qdrant.collection_exists("docs"):    # safe to re-run the cell
    qdrant.delete_collection("docs")
qdrant.create_collection(
    collection_name="docs",
    vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)

points = [
    PointStruct(
        id=i,
        vector=encoder.encode(c["embed_text"]).tolist(),
        payload={"text": c["text"], "doc_id": c["doc_id"], "heading": c["heading"]},
    )
    for i, c in enumerate(chunks)
]
qdrant.upsert(collection_name="docs", points=points)

hits = qdrant.query_points(
    collection_name="docs",
    query=encoder.encode("how do I configure retries?").tolist(),
    limit=10,
).points
for h in hits:
    print(f"{h.score:.3f}  [{h.payload['doc_id']}] {h.payload['heading']}")
The payload carries the chunk text and citation metadata alongside the vector, so one query returns everything the generator needs. Batch-encode in production (encoder.encode(list_of_texts)) — per-chunk encoding is the classic accidental 50× slowdown of ingestion. The inline corpus stands in for lesson 2's chunker output, and the delete-if-exists guard makes the cell safe to re-run.

What 'approximate' actually means: HNSW in one whiteboard sketch

"How does the vector index actually work?" is a standard senior probe, and the answer to sketch is HNSW (hierarchical navigable small world), the index behind most vector DBs. Every vector is a node in a graph, linked to a handful of near neighbors; graphs are stacked in layers like a skip list — sparse express layers on top, the dense full graph at the bottom. A query greedily walks from an entry point: at each layer, hop to whichever neighbor is closest to the query until no neighbor improves, then descend. Result: logarithmic-ish search instead of comparing against every vector — that's the entire point of a vector DB versus the numpy matrix.

Three consequences worth saying unprompted. (1) It's approximate — greedy walks can miss the true nearest neighbor; recall is a tunable, not a given: search-breadth parameters (how many candidates the walk keeps, ef in HNSW terms) trade latency for recall, and accepting ~95–99% recall is what buys the speed. So your retrieval stack has two recall knobs — the ANN's internal recall and your top-k — and a mysteriously missing chunk is sometimes just an ANN miss: verify by comparing against exact brute-force on a sample. (2) RAM is the cost center — the graph plus vectors traditionally live in memory; at scale you pay in GB (quantization and disk-backed indexes are the mitigations, trading a little recall for a lot of memory). (3) Deletes and updates are second-class — graphs degrade as nodes churn, so heavy-churn corpora need periodic re-indexing or segment merging; ask any vector DB how it handles deletes before trusting it with a living corpus.

Filtering: before, not after
Metadata filters ('only tenant-42 docs') interact badly with naive ANN: post-filtering retrieves top-k then filters, so a k=10 query where the tenant owns 1% of the corpus returns ~0 usable results. You need pre-filtering (the filter constrains the graph walk itself — Qdrant's default behavior) or oversampling as a crude fallback (retrieve 10×k, filter, keep k — fragile when the filter is highly selective). This is the trap question about multi-tenant vector search: 'why does search return nothing for small tenants?' Also mention the clean alternative for hard isolation: one collection per tenant, trading operational sprawl for zero cross-tenant risk and per-tenant re-indexing.

Where dense retrieval fails — and BM25 wins

  • Exact identifiers: error codes (ERR_CONN_5031), SKUs, ticket numbers, config keys. Embedding models squash rare tokens toward noise; BM25 treats a rare exact term as gold.
  • Rare proper nouns and acronyms: an internal project name the embedding model never saw is just an out-of-vocabulary blur to it.
  • Conversely, BM25 fails on paraphrase: "reset my password" vs. "forgot login credentials" share no terms — lexical overlap is zero, dense similarity is high.
  • Neither mode dominates; their failure sets barely overlap. That's exactly the situation where fusion wins.

BM25 is the classic lexical ranking function: score a document by the query terms it shares, weighting rare terms more (inverse document frequency), diminishing returns for repetition (term-frequency saturation), and normalizing for document length. No training, no vectors, decades of production mileage. Hybrid search runs both retrievers and merges their rankings — and because BM25 scores and cosine similarities live on incomparable scales, you merge ranks, not scores, with Reciprocal Rank Fusion (RRF): each document earns 1/(k + rank) from each list that contains it (k ≈ 60 damps the top-rank dominance), and you sort by the summed score.

hybrid retrieval: BM25 + dense, fused with RRF
# Colab cell 2 — run cell 1 first (it defines chunks, encoder, qdrant).
!pip install -q rank-bm25

import numpy as np
from rank_bm25 import BM25Okapi

tokenized = [c["text"].lower().split() for c in chunks]
bm25 = BM25Okapi(tokenized)

def bm25_search(query: str, k: int = 50) -> list[int]:
    scores = bm25.get_scores(query.lower().split())
    return [int(i) for i in np.argsort(scores)[::-1][:k]]

def dense_search(query: str, k: int = 50) -> list[int]:
    hits = qdrant.query_points(
        collection_name="docs",
        query=encoder.encode(query).tolist(),
        limit=k,
    ).points
    return [h.id for h in hits]

def rrf_fuse(rankings: list[list[int]], k: int = 60, top: int = 50) -> list[int]:
    scores: dict[int, float] = {}
    for ranking in rankings:
        for rank, chunk_id in enumerate(ranking):
            scores[chunk_id] = scores.get(chunk_id, 0.0) + 1.0 / (k + rank + 1)
    return sorted(scores, key=lambda cid: scores[cid], reverse=True)[:top]

def hybrid_search(query: str, top: int = 50) -> list[int]:
    return rrf_fuse([dense_search(query), bm25_search(query)], top=top)

# demo: the exact-ID query dense retrieval fumbles — watch BM25 carry it
for cid in hybrid_search("what does ERR_CONN_5031 mean", top=5):
    print(f"[{cid}] {chunks[cid]['heading']}: {chunks[cid]['text'][:48]}...")
Note that RRF never looks at a raw score — only at positions. A chunk ranked #1 by BM25 and #40 by dense still fuses high, which is the desired behavior for an exact-ID query that dense fumbled. Keep each retriever toggleable behind a config flag: your Lab 03 eval report compares dense-only vs. BM25-only vs. hybrid, and that requires running each in isolation.
Key insight
Why rank fusion instead of score fusion? BM25 scores are unbounded and corpus-dependent; cosine similarities live in [-1, 1] and cluster tightly. Any weighted sum of the two is an arbitrary, corpus-specific hack that breaks when you change embedding models. Ranks are the only stable common currency — which is why RRF, despite being almost insultingly simple, is the production default.
Hiring signal
Vector databases show up by name in agent-engineering job postings — Pinecone, Qdrant, Weaviate — usually right next to "hybrid search" and "reranking" in the requirements list. You don't need production miles on all three: the same client concepts (collections, upserts, payload metadata, pre-filtering) transfer, and what actually screens well is explaining why filters must constrain the ANN walk, when index recall is the silent culprit, and what RRF buys over score blending. This lesson plus Lab 03's per-mode metrics table is that conversation, prepared.
Query typeDense-onlyBM25-onlyHybrid (RRF)
Paraphrased how-to ("can't get in to my account")StrongWeak — no term overlapStrong
Exact error code ("ERR_CONN_5031 meaning")Weak — rare token blurStrongStrong
Internal project name / acronymWeakStrongStrong
Conceptual question in the corpus's own vocabularyStrongDecentStrong
Predict the output
Query: "what does ERR_CONN_5031 mean". The corpus has exactly one chunk documenting that error code. Predict where that chunk ranks in (a) dense-only, (b) BM25-only, and (c) the RRF fusion — and what the fused list looks like overall.

Whiteboard drills

Check yourself
Drill: "Walk me through what happens inside the vector DB between 'here's a query vector' and 'here are 10 ids' — and where it can silently lose the right answer."
Check yourself
Drill: Design retrieval for a B2B product: 50M chunks across 2,000 tenants, hard isolation required, p95 search under 200ms, corpus updated continuously. Walk the design.
Key takeaways
  • A vector DB buys ANN speed, metadata filters, and persistence; Qdrant local mode runs embedded — no server.
  • Dense retrieval fails on exact IDs, error codes, and rare names; BM25 fails on paraphrase. Their failure sets barely overlap.
  • Fuse ranks, not scores: RRF gives each doc the sum of 1/(k + rank) across retrievers.
  • ANN (HNSW) = greedy walks over a layered neighbor graph: sub-linear, approximate, RAM-hungry, delete-averse. Recall is a knob you tune and verify against brute force.
  • Filter before the walk (pre-filtering), never after — post-filtering starves selective filters; multi-tenant isolation is enforced server-side.
  • Store citation metadata in the payload so retrieval returns generator-ready evidence.
  • Make every retrieval mode toggleable — the eval report demands per-mode numbers.