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.
# 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']}")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.
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.
# 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]}...")| Query type | Dense-only | BM25-only | Hybrid (RRF) |
|---|---|---|---|
| Paraphrased how-to ("can't get in to my account") | Strong | Weak — no term overlap | Strong |
| Exact error code ("ERR_CONN_5031 meaning") | Weak — rare token blur | Strong | Strong |
| Internal project name / acronym | Weak | Strong | Strong |
| Conceptual question in the corpus's own vocabulary | Strong | Decent | Strong |
"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
- ▸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.