06_retrieval_strategies/
The most algorithmically dense lesson in the project. Covers MMR re-ranking, hybrid keyword+vector retrieval with Reciprocal Rank Fusion, multi-query expansion, parent-document retrieval, and contextual compression. Each sub-lesson stands alone but the arc tells a coherent story: top-k cosine is the floor, not the ceiling.
Lesson 06 5 sub-lessons RRF + MMR Most advanced
📚 What is this & why does it exist?
Lessons 04 and 05 use the simplest retrieval: embed the query, return the top-k most similar chunks. That works often enough that beginners don't see the failure modes — redundant chunks (three near-duplicates of the same paragraph), missing keyword matches (cosine likes paraphrases but misses exact-name lookups like "GPT-4"), and lost surrounding context (a chunk is just a sentence; the answer needs the paragraph). Lesson 06 introduces five techniques that, layered together, make retrieval production-grade.
💡 Real-world analogy
If top-k is "give me the 5 closest restaurants", lesson 06 is "give me 5 restaurants that are close, but also represent different cuisines (MMR), considering both my walking-distance app and my Yelp ratings (hybrid + RRF), expanding my query to include related terms like 'lunch spots' (multi-query), showing me the full menu not just the dish name (parent-document), and trimming irrelevant menu sections from each (compression)". The endpoint is a result set that's diverse, comprehensive, and lean.
🎯 Why This Component
The capstone lesson. By placing retrieval-strategy refinement after the basic pipeline (lessons 04, 05) the curriculum follows the real-world progression: build the dumb thing first, observe its failures, then add machinery. Each technique here is a direct response to a failure mode the reader has already experienced in the simpler lessons.
🏗️ Design Rationale
| Decision | Choice | Why |
|---|---|---|
| Five separate files | One technique per file, runnable independently | Reader can compare each technique's diff against the baseline. Bundling them would obscure individual contributions. |
| Same corpus across lessons | RAG paper PDF | Holding data constant isolates the algorithm as the variable. |
| Re-implement RRF inline | Don't import from src/ | The reader sees the formula 1/(k+rank) with their own eyes. |
| MMR before hybrid | Sub-lesson 02 before 03 | MMR is conceptually simpler (greedy selection); hybrid retrieval introduces a second retriever. |
| Compression last | Sub-lesson 05 | Requires understanding all preceding techniques to appreciate the cost it saves. |
Architecture — the five techniques
flowchart TD
Q[Query] --> B[Baseline: top-k cosine]
B --> B_out[5 chunks, possibly redundant]
Q --> MMR[MMR re-rank
greedy: maximise score - λ·max-sim-to-selected]
MMR --> MMR_out[5 chunks, diverse]
Q --> H1[Vector retriever]
Q --> H2[BM25 / keyword retriever]
H1 --> RRF[RRF fuse: Σ 1/(k+rank_i)]
H2 --> RRF
RRF --> H_out[5 chunks, both literal+semantic]
Q --> MQ[Multi-query: LLM rewrites Q into 3-5 variations]
MQ --> MQ_run[run each variation]
MQ_run --> MQ_dedup[union + dedup]
MQ_dedup --> MQ_out[broader recall]
Q --> PD1[Retrieve small chunks]
PD1 --> PD2[Map back to parent document]
PD2 --> PD_out[full surrounding context]
Q --> CC1[Retrieve chunks]
CC1 --> CC2[LLM extracts only relevant sentences]
CC2 --> CC_out[5 lean snippets]
Lifecycle — RRF in action (sub-lesson 03)
sequenceDiagram
participant U as User
participant H as Hybrid retriever
participant V as Vector retriever
participant B as BM25 retriever
participant RRF as RRF fuser
U->>H: query "RAG attention mechanism"
par Vector
H->>V: search(query, k=10)
V-->>H: ranked: [d3, d7, d1, d9, d2, ...]
and Keyword
H->>B: search(query, k=10)
B-->>H: ranked: [d1, d4, d3, d11, d7, ...]
end
H->>RRF: fuse([vector_ranks, bm25_ranks], k_const=60)
Note over RRF: for each doc, score = Σ 1/(60+rank_i)
across both lists
doc d3: 1/(60+1) + 1/(60+3) = .03224
doc d1: 1/(60+3) + 1/(60+1) = .03224
doc d7: 1/(60+2) + 1/(60+5) = .03152
RRF->>RRF: sort desc by fused score
RRF-->>H: top-5 fused ranking
H-->>U: [d3, d1, d7, d4, d9]
Note over U: keyword-only winner d4 keeps its rank;
vector-only d2 missed both top-5s
Interactions
| From | To | Interface | Why this interaction exists |
|---|---|---|---|
| Sub-lesson 01 (top-k baseline) | HNSW vector store | store.search(qVec, k) | Establishes what we're improving on. |
| Sub-lesson 02 (MMR) | vector store + cosine | fetch 2k candidates, then greedy MMR | Trades extra cosine ops for diversity. |
| Sub-lesson 03 (hybrid+RRF) | vector + BM25 retrievers | parallel retrieve() + rrf() | Combines complementary signals. |
| Sub-lesson 04 (multi-query) | ChatLlamaCpp + vector store | LLM expands → N retrievals → dedup | Improves recall on poorly-phrased queries. |
| Sub-lesson 05 (parent-doc) | chunk store + parent-doc map | retrieve small → lookup parent | Embedding precision + context completeness. |
(reuse) EmbeddingModel + cache | all sub-lessons | standard embedding API | Same model across techniques — isolates retrieval as the variable. |
📝 Annotated Code Walkthrough — RRF implementation (sub-lesson 03)
Constant k = 60
const RRF_K = 60;
Standard from Cormack, Clarke, Büttcher (2009). Smaller k over-rewards #1; larger k flattens the score — effectively making rank-1 and rank-50 indistinguishable. 60 is the published sweet spot.
Index by document id
function rrf(rankedLists, k = RRF_K) {
const scores = new Map();
for (const list of rankedLists) {
list.forEach((doc, rank) => {
const id = doc.id || doc.metadata?.id || doc.pageContent;
scores.set(id, (scores.get(id) || 0) + 1 / (k + rank + 1));
});
}
Identity is critical — the fusion needs to recognise the same document in two lists. Falls back through id → metadata.id → raw text. The +1 accounts for 0-based ranks (RRF formula uses 1-based).
Sort by fused score
const fused = [];
for (const [id, score] of scores) fused.push({ id, score });
fused.sort((a, b) => b.score - a.score);
Each document's score is the sum across all retrieval lists. Documents that appear high in any list score well; those that appear high in multiple lists score best.
Map ids back to documents
const docById = new Map();
for (const list of rankedLists) for (const d of list)
docById.set(d.id || d.metadata?.id || d.pageContent, d);
return fused.map(f => ({ ...docById.get(f.id), rrfScore: f.score }));
}
Reattaches the document object — we lost it when we kept only ids. Tagging rrfScore on the result lets debug output show why each doc made the cut.
📝 MMR implementation (sub-lesson 02)
Fetch a candidate pool
const candidates = await store.search(qVec, fetchK); // e.g., 20
MMR re-ranks among a pre-fetched pool. fetchK (~4× the desired output) trades a few extra cosine ops for diversity headroom.
Greedy selection loop
const selected = [];
const remaining = [...candidates];
while (selected.length < k && remaining.length) {
let bestIdx = -1, bestScore = -Infinity;
for (let i = 0; i < remaining.length; i++) {
const c = remaining[i];
const relevance = cosine(qVec, c.vector);
const maxSimToSelected = Math.max(
0, ...selected.map(s => cosine(s.vector, c.vector))
);
const mmr = lambda * relevance - (1 - lambda) * maxSimToSelected;
if (mmr > bestScore) { bestScore = mmr; bestIdx = i; }
}
selected.push(remaining.splice(bestIdx, 1)[0]);
}
The maxSimToSelected is the diversity penalty — if a candidate is very similar to one we've already picked, its MMR score takes a hit. λ tunes the relevance/diversity balance: 1.0 = ignore diversity (pure top-k), 0.0 = ignore relevance (pure spread).
Why O(k·n) is fine
For typical k=5, n=20: 100 cosine ops. Trivial. MMR's expense scales with desired output size, not the corpus — the corpus filtering already happened in the vector store. The greedy approach trades optimal selection (NP-hard) for tractable runtime.
Deep-Dive Highlights
Why RRF over weighted score blend Algorithm
Different retrievers produce scores on incompatible scales (cosine 0–1 vs BM25 0–30). RRF uses only ranks, sidestepping calibration. 1/(k+rank) is the harmonic-mean-flavoured weighting.
MMR's λ sweet spot Tuning
0.5–0.7 in practice. Below 0.5, picks become weirdly off-topic. Above 0.8, MMR collapses to top-k. The lessons set λ=0.5 to make diversity vivid.
Multi-query LLM cost Tradeoff
Each variation triggers a fresh embedding + retrieval. 5 variations = 5× cost. Justify only when recall on terse queries is the bottleneck.
Parent-doc indexing Storage
Maintain two stores: small chunks for embedding precision, parent docs for context completeness. Each chunk's metadata carries a parent_id. Adds storage but solves the "answer fragment lacks context" problem cleanly.
⚠ Edge Cases & Boundaries
| Scenario | What Actually Happens |
|---|---|
| RRF: doc only in one list | Scored by that single rank only. Still ranks — just lower than docs that appear in both. This is the feature, not a bug. |
| MMR: selected[] empty | First iteration: maxSimToSelected = 0 (the ... spread of empty + Math.max(0, ...) initial). Pure relevance picks the #1 candidate. |
| Multi-query: LLM hallucinates new entities | Variations include made-up names. Retrieval misses or returns garbage. Mitigation: cap variations at 3, validate against known entities. |
| Parent-doc: parent missing from store | Lookup returns null; lesson falls back to the chunk. No crash but context-completeness benefit is lost for that doc. |
| Compression: LLM removes the answer | Aggressive compression can strip the relevant span. Validation: after compression, check if any output is shorter than 20 chars and warn. |
Directory Map
06_retrieval_strategies/
examples/06_retrieval_strategies/
├── 01_top_k_baseline.js ← control: plain top-k cosine
├── 02_mmr.js ← Maximal Marginal Relevance re-rank
├── 03_hybrid_rrf.js ← vector + keyword + RRF
├── 04_multi_query.js ← LLM expands query, runs N retrievals
└── 05_parent_document.js ← small-chunk index + parent-doc lookup
Configuration Reference
| Parameter | Default | Description |
|---|---|---|
RRF_K | 60 | RRF damping constant. Larger = flatter weights. |
MMR_LAMBDA | 0.5 | Relevance vs diversity balance. |
MMR_FETCH_K | 20 | Candidate pool size before greedy selection. |
FINAL_K | 5 | Top results returned to caller. |
MULTI_QUERY_VARIATIONS | 3 | LLM-generated query rewrites. |
HYBRID_VECTOR_K | 10 | Per-retriever fetch before RRF fusion. |
HYBRID_BM25_K | 10 | Per-retriever fetch before RRF fusion. |
PARENT_CHUNK_SIZE | 200 | Small chunk size for embedding precision. |
PARENT_DOC_SIZE | 1500 | Larger context returned after lookup. |
COMPRESSION_PROMPT | (template) | "Extract only the sentences relevant to the question..." |
Code Examples
RRF fusion (sub-lesson 03 core)
async function hybridRetrieve(query, vectorStore, bm25, k = 5) {
const qVec = await embed.embedQuery(query);
const [vec, bm] = await Promise.all([
vectorStore.search(qVec, 10),
bm25.search(query, 10)
]);
const fused = rrf([vec, bm], 60);
return fused.slice(0, k);
}
MMR re-rank
async function mmrRetrieve(query, store, k = 5, lambda = 0.5, fetchK = 20) {
const qVec = await embed.embedQuery(query);
const candidates = await store.search(qVec, fetchK);
return mmrRerank(qVec, candidates, k, lambda);
}
Multi-query expansion
const variations = await chat.invoke([
{ role: 'system', content: 'Generate 3 variations of the user query, each on a new line.' },
{ role: 'user', content: query }
]).then(s => s.split('\n').filter(Boolean));
const results = await Promise.all(variations.map(v => retrieve(v, store, 5)));
const unique = dedupById(results.flat());