06_retrieval_strategies/

Five sub-lessons exploring retrieval beyond top-k cosine

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

5
sub-lessons
~1500
LOC
3
key algorithms
k=60
RRF constant

📚 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

DecisionChoiceWhy
Five separate filesOne technique per file, runnable independentlyReader can compare each technique's diff against the baseline. Bundling them would obscure individual contributions.
Same corpus across lessonsRAG paper PDFHolding data constant isolates the algorithm as the variable.
Re-implement RRF inlineDon't import from src/The reader sees the formula 1/(k+rank) with their own eyes.
MMR before hybridSub-lesson 02 before 03MMR is conceptually simpler (greedy selection); hybrid retrieval introduces a second retriever.
Compression lastSub-lesson 05Requires 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

FromToInterfaceWhy this interaction exists
Sub-lesson 01 (top-k baseline)HNSW vector storestore.search(qVec, k)Establishes what we're improving on.
Sub-lesson 02 (MMR)vector store + cosinefetch 2k candidates, then greedy MMRTrades extra cosine ops for diversity.
Sub-lesson 03 (hybrid+RRF)vector + BM25 retrieversparallel retrieve() + rrf()Combines complementary signals.
Sub-lesson 04 (multi-query)ChatLlamaCpp + vector storeLLM expands → N retrievals → dedupImproves recall on poorly-phrased queries.
Sub-lesson 05 (parent-doc)chunk store + parent-doc mapretrieve small → lookup parentEmbedding precision + context completeness.
(reuse) EmbeddingModel + cacheall sub-lessonsstandard embedding APISame 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

ScenarioWhat Actually Happens
RRF: doc only in one listScored by that single rank only. Still ranks — just lower than docs that appear in both. This is the feature, not a bug.
MMR: selected[] emptyFirst iteration: maxSimToSelected = 0 (the ... spread of empty + Math.max(0, ...) initial). Pure relevance picks the #1 candidate.
Multi-query: LLM hallucinates new entitiesVariations include made-up names. Retrieval misses or returns garbage. Mitigation: cap variations at 3, validate against known entities.
Parent-doc: parent missing from storeLookup returns null; lesson falls back to the chunk. No crash but context-completeness benefit is lost for that doc.
Compression: LLM removes the answerAggressive 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

ParameterDefaultDescription
RRF_K60RRF damping constant. Larger = flatter weights.
MMR_LAMBDA0.5Relevance vs diversity balance.
MMR_FETCH_K20Candidate pool size before greedy selection.
FINAL_K5Top results returned to caller.
MULTI_QUERY_VARIATIONS3LLM-generated query rewrites.
HYBRID_VECTOR_K10Per-retriever fetch before RRF fusion.
HYBRID_BM25_K10Per-retriever fetch before RRF fusion.
PARENT_CHUNK_SIZE200Small chunk size for embedding precision.
PARENT_DOC_SIZE1500Larger 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());