Building AI Agents with Open Source Models
Track 3: Memory & Retrieval OS Track · Module 9 of 20
⏱ 90 min 📊 Intermediate
🦎
100% Local — Zero API Keys Required Every component in this module runs on your machine: sentence-transformers (embedding + cross-encoder), BM25 (lexical search), ChromaDB (vector store), Mistral via Ollama (LLM). The retrieval and chunking code is provider-agnostic — it works identically whether your generation step uses Mistral, Claude, or GPT-4.

M10: Advanced RAG Patterns

Basic RAG from M09 gets you to 60% retrieval quality. The other 40% lives in chunking strategy, hybrid search, re-ranking, and query expansion. This module covers every technique that separates a demo from a production system.

Learning Objectives

  • Diagnose the four failure modes of basic RAG with concrete examples
  • Implement all four chunking strategies: fixed-overlap, sentence-aware, semantic, and recursive
  • Build a hybrid BM25 + dense search pipeline with Reciprocal Rank Fusion
  • Add cross-encoder re-ranking and LLM-based re-ranking; understand the latency tradeoff
  • Generate multi-query expansions with Mistral and deduplicate by embedding similarity
  • Apply contextual compression to reduce context token usage
  • Implement parent-child (small-to-big) chunking for precision retrieval with rich context return
  • Assemble a modular production RAG pipeline and benchmark each enhancement

What Basic RAG Gets Wrong

Before the Pain

Imagine using a library's card catalogue to find a book by searching only the title. Before keyword search was invented, that's exactly what you had: a rigid, one-dimensional lookup that could only match what the cataloguer typed on the index card. If the title used "automobile" and you searched "car," you found nothing — even though the relevant book was right there on the shelf.

The pain arrives when your users start asking real questions. "What are the drug interactions for metformin?" retrieves cards about drug efficacy studies because "interaction" and "effect" are semantically close in embedding space. The answer about dangerous combinations is two shelves away, labeled under "contraindications" — a word that never appeared in your query.

Basic RAG is that card catalogue. It embeds the query, finds the nearest chunks by cosine distance, and stuffs them into context. When the query is precise and the documents are evenly chunked, it works beautifully. When real users ask ambiguous questions, the relevant information is spread across five chunks, or a specific product ID must be found exactly — the system fails silently and Mistral hallucinates with confidence.

The Four Failure Modes

Failure Mode Example Query What Goes Wrong Fix
Semantic drift "Side effects of drug X?" Retrieves efficacy chunks — semantically nearby but wrong Hybrid BM25 + dense
Exact-match miss "FDA approval #123-456" Dense vectors can't encode rare IDs; embedding similarity fails BM25 lexical search
Context split "Compare Q1 vs Q2 revenue" Answer spans two page boundaries; fixed chunks cut it apart Parent-child chunking
Query ambiguity "Python memory issues" Only one facet of a multi-faceted query retrieves; misses related framings Multi-query expansion
Enhanced RAG Pipeline — Six Stages Animated
🔍
1. Query Expansion
mistral generates 3 query variants
📄
2. Semantic Chunking
split at cosine similarity drops
🗄
3. Hybrid Indexing
ChromaDB dense + BM25 inverted index
📍
4. Hybrid Retrieval + RRF
20 candidates, merged via reciprocal rank fusion
📊
5. Cross-Encoder Re-ranking
top 5 from 20 candidates
🤖
6. Generation
mistral answers with compressed context
Enhanced RAG pipeline: Query Expansion → Semantic Chunking → Hybrid Indexing → Hybrid Retrieval + RRF → Cross-Encoder Re-ranking → Generation with compressed context.
Why It Matters: Real Retrieval Numbers

Teams at Cohere and LlamaIndex have benchmarked advanced RAG techniques on domain-specific corpora. Adding hybrid search alone lifts recall@5 by 12-18%. Adding a cross-encoder re-ranker lifts precision@5 by a further 15-22%. Combined, these two techniques cut hallucination rate in half on factoid QA tasks — the model gets better context and makes fewer things up. The cost: ~80ms of extra latency per query and zero extra API calls.

Before we can retrieve better, we need to store better. Every improvement downstream is limited by how well the original documents were chunked. Let's fix chunking first.

Chunking Strategies

Definition: Chunking

Chunking is the process of splitting a long document into smaller pieces that can be individually embedded and stored in a vector database. Each chunk becomes one entry in the index. When a query arrives, the most similar chunks are retrieved and put into the LLM's context window. The goal is chunks that are small enough to be retrieved precisely but large enough to contain a coherent, complete thought. Naive fixed-size chunking optimizes for neither — it just cuts every N characters whether or not a sentence, paragraph, or logical section boundary falls there.

The Newspaper Scissors Problem

Imagine clipping articles from a newspaper with scissors that cut every 200 characters regardless of where you are on the page. Before you know it, you're cutting mid-sentence, slicing through a photograph, chopping the headline off the article body. You end up with fragments that make no sense in isolation.

The pain is obvious when you try to file those clippings: "Drug Recall" gets filed under D, but the clipping that has the drug's actual name starts with the bottom of the headline and ends mid-paragraph. Keyword search works on words; it can't reassemble your mangled fragments.

Chunking strategy is how you decide where to cut. A sentence-aware strategy puts the scissors down at periods. A semantic strategy watches the meaning shift and cuts when the topic changes. The right strategy depends on your document type: dense technical prose calls for semantic chunking; dialogue transcripts call for turn-aware chunking; legal contracts call for section-aware chunking.

Same Paragraph — Four Chunking Strategies Compared
Fixed + Overlap
The drug metform
in lowers blood s...
cuts mid-word
...ugar. Side effec
ts include nausea...
overlap: 50 chars
...a and diarrhea.
Kidney function...
200-char size
Sentence-Aware
The drug metformin lowers blood sugar.
sentence intact
Side effects include nausea and diarrhea.
clean boundary
Kidney function should be monitored.
full thought
Semantic
The drug metformin lowers blood sugar. It is the first-line treatment for type 2 diabetes.
topic: mechanism
Side effects include nausea, diarrhea, and rarely lactic acidosis.
topic: side effects
Kidney function monitoring is required every 6 months.
topic: monitoring
Recursive
The drug metformin lowers blood sugar. It is the first-line treatment.
split on \n\n first
Side effects: nausea, diarrhea, lactic acidosis (rare).
then \n, then .
Monitor kidney function every 6 months.
respects structure
Four chunking strategies on the same paragraph: Fixed+Overlap (cuts mid-word, poor quality), Sentence-Aware (intact sentences), Semantic (splits at topic shifts), Recursive (paragraph → sentence → word hierarchy).

Strategy 1 — Fixed-Size with Overlap (The Baseline)

Split every N characters with an M-character overlap between adjacent chunks. Fast, requires no NLP libraries. Use it when: documents are already structured similarly (e.g., database rows), chunk size is well-calibrated to your use case, or speed matters more than quality.

Strategy 2 — Sentence-Aware

Use nltk.sent_tokenize() to split at sentence boundaries, then group sentences until the chunk reaches a target token count. Never cuts mid-sentence. Works well for prose, product descriptions, support tickets.

Strategy 3 — Semantic

Embed consecutive sentences and compute cosine distance between adjacent pairs. Where distance exceeds a threshold (topic shift), start a new chunk. Produces the most semantically coherent chunks at the cost of variable chunk sizes.

Strategy 4 — Recursive Character Splitting

Try to split on paragraph breaks (\n\n) first; if the resulting piece is still too large, split on newlines (\n); if still too large, split on sentences (. ); finally on spaces. Respects document structure hierarchy.

WHAT: All four chunking strategies in one file — swap strategies by changing one import
WHY: Keeping strategies consistent lets you A/B test them on the same document and eval set
GOTCHA: Semantic chunking requires sentence-transformers and is 10-50x slower than fixed-size on large corpora
# chunking_strategies.py
# pip install nltk sentence-transformers numpy
# python -c "import nltk; nltk.download('punkt')"

from __future__ import annotations
import re
import numpy as np
import nltk
from sentence_transformers import SentenceTransformer
from typing import List

# WHAT: Shared sentence encoder used by the semantic chunker
# WHY: Load once at module level — model initialization is expensive (~3s)
# GOTCHA: all-MiniLM-L6-v2 runs on CPU; for GPU, set device='cuda'
_encoder = None
def _get_encoder():
    global _encoder
    if _encoder is None:
        _encoder = SentenceTransformer("all-MiniLM-L6-v2")
    return _encoder


# ── Strategy 1: Fixed-size with overlap ───────────────────────────────────────
# WHAT: Naive baseline — split every chunk_size chars with overlap chars repeated
# WHY: Overlap ensures a sentence cut at a boundary is still findable from either chunk
# GOTCHA: overlap must be < chunk_size; typical ratio is 10-20%
def fixed_size_chunks(text: str, chunk_size: int = 512, overlap: int = 64) -> List[str]:
    chunks = []
    start = 0
    while start < len(text):
        end = min(start + chunk_size, len(text))
        chunks.append(text[start:end])
        start += chunk_size - overlap
    return chunks


# ── Strategy 2: Sentence-aware chunking ───────────────────────────────────────
# WHAT: Group sentences until a target_chars limit is reached; start a new chunk
# WHY: Sentences are the atomic unit of meaning in most prose documents
# GOTCHA: Very long sentences (technical specs, legal clauses) can produce
#         over-sized chunks; add a fallback fixed split for sentences > 1000 chars
def sentence_aware_chunks(text: str, target_chars: int = 500) -> List[str]:
    try:
        sentences = nltk.sent_tokenize(text)
    except LookupError:
        nltk.download("punkt", quiet=True)
        sentences = nltk.sent_tokenize(text)

    chunks: List[str] = []
    current: List[str] = []
    current_len = 0

    for sent in sentences:
        if current_len + len(sent) > target_chars and current:
            chunks.append(" ".join(current))
            current = []
            current_len = 0
        current.append(sent)
        current_len += len(sent) + 1

    if current:
        chunks.append(" ".join(current))
    return chunks


# ── Strategy 3: Semantic chunking ─────────────────────────────────────────────
# WHAT: Split where cosine distance between consecutive sentence embeddings exceeds threshold
# WHY: Detects actual topic shifts, not just length limits
# GOTCHA: threshold=0.3 works for technical docs; use 0.2 for narrative prose
#         where topics transition more gradually
def semantic_chunks(text: str, threshold: float = 0.3) -> List[str]:
    try:
        sentences = nltk.sent_tokenize(text)
    except LookupError:
        nltk.download("punkt", quiet=True)
        sentences = nltk.sent_tokenize(text)

    if len(sentences) <= 1:
        return sentences

    encoder = _get_encoder()
    embeddings = encoder.encode(sentences, show_progress_bar=False)

    # Compute cosine distance between consecutive sentences
    def cosine_dist(a: np.ndarray, b: np.ndarray) -> float:
        return 1.0 - float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-8))

    chunks: List[str] = []
    current: List[str] = [sentences[0]]

    for i in range(1, len(sentences)):
        dist = cosine_dist(embeddings[i - 1], embeddings[i])
        if dist > threshold:
            # Topic shift detected — flush current group
            chunks.append(" ".join(current))
            current = [sentences[i]]
        else:
            current.append(sentences[i])

    if current:
        chunks.append(" ".join(current))
    return chunks


# ── Strategy 4: Recursive character splitting ─────────────────────────────────
# WHAT: Try each separator in order; recurse on pieces still larger than max_size
# WHY: Respects document structure (paragraphs > sentences > words) without
#      requiring any NLP — fast and reliable on structured docs
# GOTCHA: separators list must be ordered from coarsest to finest
def recursive_chunks(
    text: str,
    max_size: int = 500,
    separators: List[str] | None = None,
) -> List[str]:
    if separators is None:
        separators = ["\n\n", "\n", ". ", "? ", "! ", " "]

    def _split(text: str, seps: List[str]) -> List[str]:
        if len(text) <= max_size:
            return [text] if text.strip() else []
        sep, *rest = seps
        if not sep:  # last resort: character split
            return [text[i:i + max_size] for i in range(0, len(text), max_size)]
        parts = text.split(sep)
        result: List[str] = []
        for part in parts:
            part = part.strip()
            if not part:
                continue
            if len(part) <= max_size:
                result.append(part)
            else:
                result.extend(_split(part, rest))
        return result

    return _split(text, separators)


# ── Demo: compare strategies on one passage ───────────────────────────────────
if __name__ == "__main__":
    sample = (
        "Metformin is the first-line pharmacological treatment for type 2 diabetes. "
        "It works by decreasing hepatic glucose production and improving insulin sensitivity. "
        "Common side effects include nausea, vomiting, and diarrhea, particularly when starting treatment. "
        "These GI effects usually subside after a few weeks. "
        "Serious but rare side effects include lactic acidosis, which requires immediate medical attention. "
        "Kidney function should be monitored every 3-6 months because metformin is excreted unchanged by the kidneys. "
        "Patients with eGFR below 30 mL/min should not use metformin. "
        "Drug interactions include iodinated contrast agents, which may cause acute kidney injury when combined."
    )

    for name, fn in [
        ("Fixed+Overlap",  lambda t: fixed_size_chunks(t, 200, 30)),
        ("Sentence-Aware", lambda t: sentence_aware_chunks(t, 300)),
        ("Semantic",       lambda t: semantic_chunks(t, 0.3)),
        ("Recursive",      lambda t: recursive_chunks(t, 250)),
    ]:
        chunks = fn(sample)
        print(f"\n{'='*50}")
        print(f"{name}: {len(chunks)} chunks")
        for i, c in enumerate(chunks):
            print(f"  [{i+1}] ({len(c)}c) {c[:80]}{'...' if len(c)>80 else ''}")
// chunking_strategies.js
// npm install @xenova/transformers natural
// Node.js 18+ required for ES modules

import { pipeline } from '@xenova/transformers';
import natural from 'natural';

const tokenizer = new natural.SentenceTokenizer();

// WHAT: Lazy-loaded embedding pipeline (downloads ~22MB model on first run)
// WHY: @xenova/transformers runs all-MiniLM-L6-v2 in pure JavaScript via ONNX
// GOTCHA: First call takes 3-8s while the ONNX runtime compiles the model
let _extractor = null;
async function getExtractor() {
  if (!_extractor) {
    _extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
  }
  return _extractor;
}

// ── Strategy 1: Fixed-size with overlap ──────────────────────────────────────
export function fixedSizeChunks(text, chunkSize = 512, overlap = 64) {
  const chunks = [];
  let start = 0;
  while (start < text.length) {
    chunks.push(text.slice(start, start + chunkSize));
    start += chunkSize - overlap;
  }
  return chunks;
}

// ── Strategy 2: Sentence-aware ────────────────────────────────────────────────
// WHAT: Group sentences until targetChars limit; emit chunk and restart
// GOTCHA: The `natural` library sentence tokenizer is less accurate than NLTK
//         for medical/legal text — consider punkt-tokenizer npm package instead
export function sentenceAwareChunks(text, targetChars = 500) {
  const sentences = tokenizer.tokenize(text);
  const chunks = [];
  let current = [];
  let currentLen = 0;

  for (const sent of sentences) {
    if (currentLen + sent.length > targetChars && current.length) {
      chunks.push(current.join(' '));
      current = [];
      currentLen = 0;
    }
    current.push(sent);
    currentLen += sent.length + 1;
  }
  if (current.length) chunks.push(current.join(' '));
  return chunks;
}

// ── Strategy 3: Semantic chunking ─────────────────────────────────────────────
// WHAT: Compute cosine distance between consecutive sentence embeddings
// WHY: Detects actual topic shifts, not just character boundaries
// GOTCHA: Embedding 100 sentences takes ~2-3s on CPU — cache results in prod
export async function semanticChunks(text, threshold = 0.3) {
  const sentences = tokenizer.tokenize(text);
  if (sentences.length <= 1) return sentences;

  const extractor = await getExtractor();
  const embeddings = [];
  for (const s of sentences) {
    const out = await extractor(s, { pooling: 'mean', normalize: true });
    embeddings.push(Array.from(out.data));
  }

  function cosineDist(a, b) {
    const dot = a.reduce((sum, v, i) => sum + v * b[i], 0);
    const normA = Math.sqrt(a.reduce((s, v) => s + v * v, 0));
    const normB = Math.sqrt(b.reduce((s, v) => s + v * v, 0));
    return 1 - dot / (normA * normB + 1e-8);
  }

  const chunks = [];
  let current = [sentences[0]];

  for (let i = 1; i < sentences.length; i++) {
    const dist = cosineDist(embeddings[i - 1], embeddings[i]);
    if (dist > threshold) {
      chunks.push(current.join(' '));
      current = [sentences[i]];
    } else {
      current.push(sentences[i]);
    }
  }
  if (current.length) chunks.push(current.join(' '));
  return chunks;
}

// ── Strategy 4: Recursive character splitting ─────────────────────────────────
export function recursiveChunks(
  text,
  maxSize = 500,
  separators = ['\n\n', '\n', '. ', '? ', '! ', ' ']
) {
  function split(text, seps) {
    if (text.length <= maxSize) return text.trim() ? [text.trim()] : [];
    const [sep, ...rest] = seps;
    if (!sep) {
      return Array.from({ length: Math.ceil(text.length / maxSize) },
        (_, i) => text.slice(i * maxSize, (i + 1) * maxSize));
    }
    return text.split(sep)
      .map(p => p.trim()).filter(Boolean)
      .flatMap(p => p.length <= maxSize ? [p] : split(p, rest));
  }
  return split(text, separators);
}
Now that documents are chunked well, we need a retrieval layer that can find both semantic matches and exact keyword matches. Dense-only retrieval will always miss one or the other. The fix is hybrid search.
Hybrid search returns 20 good candidates. Now the question is: which 5 should actually go into the prompt? Re-ranking answers that with cross-encoder precision rather than bi-encoder recall.

Re-ranking

Definition: Cross-Encoder vs Bi-Encoder

A bi-encoderAn architecture where the query and document are encoded independently into separate embedding vectors. Similarity is computed as dot product or cosine distance between the two vectors. Fast at query time because document embeddings can be pre-computed and indexed. Used by sentence-transformers for first-stage retrieval. encodes query and document independently and scores their similarity via dot product — fast because documents are pre-indexed. A cross-encoderAn architecture where the query and document are concatenated and passed jointly through a transformer encoder. The model directly outputs a relevance score for the (query, document) pair. Much more accurate than bi-encoder because it can attend to both texts simultaneously, but cannot pre-index documents — every (query, doc) pair requires a fresh forward pass. receives both query and document at once, attends across the pair jointly, and outputs a single relevance score. Cross-encoders are ~10x more accurate for relevance scoring but cannot pre-index documents, so they are only practical for re-ranking a small candidate set (typically 20-50 docs) rather than an entire corpus.

ApproachModelLatency (20 docs)QualityExtra dependency
Cross-encoder ms-marco-MiniLM-L-6-v2 ~120ms (CPU) Highest sentence-transformers
LLM re-ranking Mistral via Ollama ~2-8s (20 calls) High none (already have Ollama)
None (bi-encoder) all-MiniLM-L6-v2 ~5ms Baseline none
OS Track: Fully Local Re-ranking

The cross-encoder model cross-encoder/ms-marco-MiniLM-L-6-v2 is 67MB and runs entirely on CPU with sentence-transformers. It achieves near-SOTA re-ranking quality while requiring zero network calls. The LLM re-ranker uses Ollama which you already have running — no extra models needed.

WHAT: Cross-encoder and LLM re-ranking implementations — swap strategy via a flag
WHY: Cross-encoder is the production choice at 120ms; LLM re-ranker is useful when you need custom relevance criteria beyond general retrieval quality
GOTCHA: Cross-encoder scores are unbounded logits — use them only for ordering, not as probabilities
# reranker.py
# pip install sentence-transformers openai

from sentence_transformers import CrossEncoder
from openai import OpenAI
import json
from typing import List, Dict, Any

# WHAT: Cross-encoder re-ranker using local model
# WHY: cross-encoder/ms-marco-MiniLM-L-6-v2 is specifically trained
#      on MS MARCO passage ranking — best single-model choice for RAG reranking
# GOTCHA: load the model once at startup; each predict() call is a forward pass
#         on all (query, doc) pairs simultaneously — batch efficiently
class CrossEncoderReranker:
    def __init__(self, model: str = "cross-encoder/ms-marco-MiniLM-L-6-v2"):
        self.model = CrossEncoder(model)

    def rerank(
        self,
        query: str,
        candidates: List[Dict[str, Any]],
        top_k: int = 5,
    ) -> List[Dict[str, Any]]:
        if not candidates:
            return []

        # WHAT: Build (query, document) pairs for the cross-encoder
        # GOTCHA: cross-encoder scores all pairs in one batch call —
        #         much faster than looping with individual predict() calls
        pairs = [(query, c["text"]) for c in candidates]
        scores = self.model.predict(pairs)   # returns numpy array of logit scores

        # Sort by score descending, return top_k
        ranked = sorted(
            zip(candidates, scores),
            key=lambda x: x[1],
            reverse=True,
        )
        return [
            {**cand, "rerank_score": float(score)}
            for cand, score in ranked[:top_k]
        ]


# WHAT: LLM-based re-ranker using Mistral — useful when relevance criteria
#       are domain-specific and can't be captured by a general-purpose cross-encoder
# WHY: For specialized domains (clinical trials, legal precedent, UCC filings),
#      Mistral can apply domain logic the MS MARCO model never saw
# GOTCHA: 20 Ollama calls per query adds 2-8s latency — use only when accuracy
#         matters more than speed and for offline batch pipelines
class LLMReranker:
    def __init__(self, base_url: str = "http://localhost:11434/v1"):
        self.client = OpenAI(base_url=base_url, api_key="ollama")

    def _score_candidate(self, query: str, text: str) -> float:
        try:
            resp = self.client.chat.completions.create(
                model="mistral",
                messages=[
                    {
                        "role": "system",
                        "content": (
                            "You are a relevance judge. Score how relevant the document "
                            "is to the query on a scale of 0.0 to 1.0. "
                            "Respond with JSON only: {\"score\": 0.0}"
                        ),
                    },
                    {
                        "role": "user",
                        "content": f"Query: {query}\n\nDocument: {text[:500]}",
                    },
                ],
                max_tokens=32,
                temperature=0,
            )
            raw = resp.choices[0].message.content or "{}"
            # Strip markdown fences if present
            raw = raw.strip().lstrip("```json").lstrip("```").rstrip("```")
            return float(json.loads(raw).get("score", 0.0))
        except Exception:
            return 0.0

    def rerank(
        self,
        query: str,
        candidates: List[Dict[str, Any]],
        top_k: int = 5,
    ) -> List[Dict[str, Any]]:
        scored = [
            {**c, "rerank_score": self._score_candidate(query, c["text"])}
            for c in candidates
        ]
        return sorted(scored, key=lambda x: x["rerank_score"], reverse=True)[:top_k]


# ── Demo ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    candidates = [
        {"id": "doc-0", "text": "Metformin lowers blood sugar through hepatic glucose reduction."},
        {"id": "doc-1", "text": "Side effects of metformin: nausea, diarrhea, lactic acidosis."},
        {"id": "doc-2", "text": "FDA approval number 123-456 granted for metformin 500mg."},
        {"id": "doc-3", "text": "Drug interactions: contrast agents plus metformin risk lactic acidosis."},
        {"id": "doc-4", "text": "Kidney function monitoring schedule for metformin patients."},
    ]
    query = "What are the serious adverse effects of metformin?"

    print("Cross-Encoder Re-ranking:")
    reranker = CrossEncoderReranker()
    for r in reranker.rerank(query, candidates, top_k=3):
        print(f"  [{r['rerank_score']:.3f}] {r['text'][:70]}")
// reranker.js
// npm install @xenova/transformers openai
// WHAT: Cross-encoder via @xenova/transformers (ONNX runtime, pure JS)
// GOTCHA: The ms-marco cross-encoder ONNX model is ~67MB; cached after first download

import { pipeline } from '@xenova/transformers';
import OpenAI from 'openai';

// WHAT: Lazy-loaded cross-encoder pipeline
// WHY: Reuse across multiple rerank() calls — init cost is ~2s
let _crossEncoder = null;
async function getCrossEncoder() {
  if (!_crossEncoder) {
    _crossEncoder = await pipeline(
      'text-classification',
      'Xenova/ms-marco-MiniLM-L-6-v2'
    );
  }
  return _crossEncoder;
}

export async function crossEncoderRerank(query, candidates, topK = 5) {
  const ce = await getCrossEncoder();
  const pairs = candidates.map(c => `${query} [SEP] ${c.text}`);

  // WHAT: Score all pairs; pipeline returns [{label, score}, ...]
  // GOTCHA: Some Xenova cross-encoder models return 'LABEL_1' for relevant
  //         Always check the model card for which label = "relevant"
  const results = await ce(pairs, { topk: 1 });
  const scores = results.map(r => {
    const s = Array.isArray(r) ? r[0] : r;
    return s.label === 'LABEL_1' ? s.score : 1 - s.score;
  });

  return candidates
    .map((c, i) => ({ ...c, rerankScore: scores[i] }))
    .sort((a, b) => b.rerankScore - a.rerankScore)
    .slice(0, topK);
}

// LLM re-ranker using Ollama (no extra model needed)
const ollamaClient = new OpenAI({
  baseURL: 'http://localhost:11434/v1',
  apiKey: 'ollama',
});

async function scoreCandidate(query, text) {
  try {
    const resp = await ollamaClient.chat.completions.create({
      model: 'mistral',
      messages: [
        {
          role: 'system',
          content: 'Score document relevance 0.0-1.0. JSON only: {"score": 0.0}',
        },
        { role: 'user', content: `Query: ${query}\nDocument: ${text.slice(0, 500)}` },
      ],
      max_tokens: 32,
      temperature: 0,
    });
    const raw = (resp.choices[0].message.content ?? '{}')
      .replace(/```json|```/g, '').trim();
    return parseFloat(JSON.parse(raw).score ?? 0);
  } catch {
    return 0;
  }
}

export async function llmRerank(query, candidates, topK = 5) {
  const scored = await Promise.all(
    candidates.map(async c => ({
      ...c,
      rerankScore: await scoreCandidate(query, c.text),
    }))
  );
  return scored.sort((a, b) => b.rerankScore - a.rerankScore).slice(0, topK);
}
Re-ranking improves precision from a fixed candidate set. But what if the query itself is the problem — too narrow, ambiguous, or missing synonyms? Multi-query expansion generates multiple phrasings so retrieval casts a wider net before re-ranking narrows it back down.

Multi-Query Expansion

Definition: Multi-Query Expansion

Multi-query expansion uses the LLM to generate N alternative phrasings of the original query, retrieves results for each phrasing independently, then deduplicates the combined result set before passing it to the re-ranker. The intuition: if the original query was "memory leak in Python," useful documents might use the terms "garbage collection," "reference counting," "memory profiling," or "high RSS." Generating variant queries surfaces all of those documents in the initial candidate pool even though none contained the original phrase.

WHAT: Generate 3-5 query variants with Mistral; deduplicate retrieved results by embedding cosine similarity
WHY: Deduplication by embedding (not by text) catches paraphrase duplicates that raw text comparison misses
GOTCHA: Set temperature=0.4-0.7 for query generation — too low produces nearly identical variants; too high produces off-topic variants
# multi_query.py
# pip install openai sentence-transformers numpy

from openai import OpenAI
from sentence_transformers import SentenceTransformer
import json
import numpy as np
from typing import List, Dict, Any, Callable

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")
encoder = SentenceTransformer("all-MiniLM-L6-v2")

# WHAT: Ask Mistral to generate N semantically diverse query variants
# WHY: Diverse phrasings surface documents that use different terminology
#      for the same concept — crucial for technical and medical domains
# GOTCHA: Ask for JSON array output; Mistral sometimes wraps it in markdown
#         fences — strip them before parsing
def expand_query(query: str, n: int = 3) -> List[str]:
    resp = client.chat.completions.create(
        model="mistral",
        messages=[
            {
                "role": "system",
                "content": (
                    f"Generate {n} alternative phrasings of the user's search query. "
                    "Each variant should use different vocabulary while preserving the "
                    "original intent. Include technical synonyms, related terms, and "
                    "alternative framings. Return a JSON array of strings only."
                ),
            },
            {"role": "user", "content": query},
        ],
        response_format={"type": "json_object"},
        temperature=0.5,
        max_tokens=256,
    )
    raw = resp.choices[0].message.content or "[]"
    # GOTCHA: response_format may still return {"queries": [...]} — handle both shapes
    parsed = json.loads(raw)
    if isinstance(parsed, list):
        variants = parsed
    else:
        variants = next(
            (v for v in parsed.values() if isinstance(v, list)), []
        )
    return [query] + [str(v) for v in variants[:n]]   # include original


# WHAT: Deduplicate a list of retrieved docs by embedding similarity
# WHY: Multiple queries often retrieve the same doc under different rankings
#      Text dedup misses paraphrases; embedding dedup is more robust
# GOTCHA: threshold=0.95 is conservative — lower it (0.85) for cleaner dedup
#         at the cost of occasionally removing genuinely different docs
def deduplicate_by_embedding(
    docs: List[Dict[str, Any]],
    threshold: float = 0.95,
) -> List[Dict[str, Any]]:
    if not docs:
        return []
    texts = [d["text"] for d in docs]
    embeddings = encoder.encode(texts, show_progress_bar=False)

    keep = []
    for i, doc in enumerate(docs):
        is_dup = False
        for j in keep:
            sim = float(np.dot(embeddings[i], embeddings[j]) /
                        (np.linalg.norm(embeddings[i]) * np.linalg.norm(embeddings[j]) + 1e-8))
            if sim > threshold:
                is_dup = True
                break
        if not is_dup:
            keep.append(i)

    return [docs[i] for i in keep]


# WHAT: Full multi-query retrieval pipeline
# WHY: Combining all variants before dedup gives the re-ranker the richest pool
def multi_query_retrieve(
    query: str,
    search_fn: Callable[[str], List[Dict[str, Any]]],
    n_variants: int = 3,
    dedup_threshold: float = 0.95,
) -> List[Dict[str, Any]]:
    variants = expand_query(query, n_variants)
    print(f"Query variants: {variants}")

    # Retrieve for each variant; label source variant for debugging
    all_results: List[Dict[str, Any]] = []
    seen_ids = set()
    for v in variants:
        results = search_fn(v)
        for r in results:
            if r.get("id") not in seen_ids:
                all_results.append({**r, "_source_variant": v})
                seen_ids.add(r.get("id"))

    # Final embedding-level dedup before re-ranking
    return deduplicate_by_embedding(all_results, threshold=dedup_threshold)


# ── Demo ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    variants = expand_query("Python memory leak", n=3)
    print("Expanded queries:")
    for i, v in enumerate(variants):
        print(f"  {i+1}. {v}")
// multi_query.js
// npm install openai @xenova/transformers

import OpenAI from 'openai';
import { pipeline } from '@xenova/transformers';

const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });
let _encoder = null;
async function getEncoder() {
  if (!_encoder) _encoder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
  return _encoder;
}

// WHAT: Generate N query variants using Mistral
// GOTCHA: response_format json_object support varies by Ollama version;
//         add a try/catch fallback for plain text responses
export async function expandQuery(query, n = 3) {
  const resp = await client.chat.completions.create({
    model: 'mistral',
    messages: [
      {
        role: 'system',
        content: `Generate ${n} alternative phrasings of the search query. `
          + 'Use different vocabulary, synonyms, and alternative framings. '
          + 'Return JSON array of strings only.',
      },
      { role: 'user', content: query },
    ],
    temperature: 0.5,
    max_tokens: 256,
  });
  try {
    const raw = (resp.choices[0].message.content ?? '[]')
      .replace(/```json|```/g, '').trim();
    const parsed = JSON.parse(raw);
    const variants = Array.isArray(parsed) ? parsed
      : Object.values(parsed).find(v => Array.isArray(v)) ?? [];
    return [query, ...variants.slice(0, n).map(String)];
  } catch {
    return [query];
  }
}

function cosineSim(a, b) {
  const dot = a.reduce((s, v, i) => s + v * b[i], 0);
  const nA = Math.sqrt(a.reduce((s, v) => s + v * v, 0));
  const nB = Math.sqrt(b.reduce((s, v) => s + v * v, 0));
  return dot / (nA * nB + 1e-8);
}

// WHAT: Deduplicate retrieved docs by embedding similarity
export async function deduplicateByEmbedding(docs, threshold = 0.95) {
  if (!docs.length) return [];
  const encoder = await getEncoder();
  const embs = await Promise.all(docs.map(async d => {
    const out = await encoder(d.text, { pooling: 'mean', normalize: true });
    return Array.from(out.data);
  }));
  const keep = [];
  for (let i = 0; i < docs.length; i++) {
    const isDup = keep.some(j => cosineSim(embs[i], embs[j]) > threshold);
    if (!isDup) keep.push(i);
  }
  return keep.map(i => docs[i]);
}

export async function multiQueryRetrieve(query, searchFn, nVariants = 3) {
  const variants = await expandQuery(query, nVariants);
  console.log('Query variants:', variants);
  const seenIds = new Set();
  const allResults = [];
  for (const v of variants) {
    const results = await searchFn(v);
    for (const r of results) {
      if (!seenIds.has(r.id)) {
        allResults.push({ ...r, _sourceVariant: v });
        seenIds.add(r.id);
      }
    }
  }
  return deduplicateByEmbedding(allResults);
}
Multi-query gives us a richer, deduplicated candidate pool. But each retrieved chunk might be 500 tokens of which only 50 are relevant. Putting all 500 tokens in context wastes the model's attention. Contextual compression extracts just the relevant excerpt.

Contextual Compression

Definition: Contextual Compression

Contextual compressionA RAG technique where the LLM is asked to extract only the portion of a retrieved chunk that is directly relevant to the query, discarding the surrounding context. The extracted excerpt replaces the full chunk in the final prompt. This reduces context window usage and improves answer quality by removing distracting information. asks the LLM to extract only the portion of a retrieved chunk that directly answers the query. Instead of passing a 500-token pharmaceutical overview to the model, you extract "lactic acidosis — rare but serious, requires immediate medical attention" (15 tokens). This saves tokens (reducing cost and staying within context limits) and improves answer quality by removing distracting irrelevant content from the model's context window.

When NOT to Use Compression

Contextual compression adds one LLM call per retrieved chunk, multiplying latency. Use it when: chunks are long (>400 tokens), questions are specific, and context window is a constraint. Skip it when: chunks are already short (<150 tokens), the question is broad and benefits from full context, or real-time latency is critical. A good heuristic: measure average compression ratio on your corpus first — if it's <50% (chunks already compact), the overhead isn't worth it.

WHAT: Compress each retrieved chunk to its relevant excerpt before building the final prompt
WHY: A 10-chunk context of compressed excerpts can outperform a 3-chunk context of full passages
GOTCHA: Always include fallback to full chunk if compression returns empty string or fails
# contextual_compression.py
# pip install openai

from openai import OpenAI
from typing import List, Dict, Any

client = OpenAI(base_url="http://localhost:11434/v1", api_key="ollama")

# WHAT: Ask Mistral to extract the relevant excerpt from a chunk
# WHY: The model sees the query + chunk and returns only the directly useful text
# GOTCHA: If the entire chunk is relevant, Mistral will (correctly) return
#         most of it — don't expect dramatic compression in all cases
def compress_chunk(query: str, chunk: str, max_tokens: int = 256) -> str:
    if len(chunk) < 150:
        return chunk   # too short to compress; return as-is

    resp = client.chat.completions.create(
        model="mistral",
        messages=[
            {
                "role": "system",
                "content": (
                    "Extract the portion of the provided document that is directly "
                    "relevant to the query. Return only the relevant excerpt verbatim. "
                    "If no portion is relevant, respond with: [NOT RELEVANT]"
                ),
            },
            {
                "role": "user",
                "content": f"Query: {query}\n\nDocument:\n{chunk}",
            },
        ],
        max_tokens=max_tokens,
        temperature=0,
    )
    excerpt = (resp.choices[0].message.content or "").strip()

    # GOTCHA: Fall back to full chunk if model returns nothing useful
    if not excerpt or excerpt == "[NOT RELEVANT]" or len(excerpt) < 10:
        return chunk
    return excerpt


def compress_results(
    query: str,
    results: List[Dict[str, Any]],
    max_tokens_per_chunk: int = 256,
) -> List[Dict[str, Any]]:
    """Compress all retrieved chunks and filter out non-relevant ones."""
    compressed = []
    for r in results:
        excerpt = compress_chunk(query, r["text"], max_tokens_per_chunk)
        if excerpt != "[NOT RELEVANT]":
            compressed.append({**r, "text": excerpt, "original_text": r["text"]})
    return compressed


# ── Demo ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    long_chunk = (
        "Metformin is a biguanide class antidiabetic agent. It was first described in "
        "the scientific literature in 1922, synthesized in 1929, and approved in France "
        "in 1957. It works primarily by reducing hepatic glucose production. "
        "The mechanism involves activation of AMP-activated protein kinase (AMPK). "
        "SERIOUS SIDE EFFECTS: Lactic acidosis — rare but potentially fatal. "
        "Risk factors include renal impairment, hepatic impairment, and iodinated contrast "
        "agents. Stop metformin 48 hours before contrast procedures. "
        "Monitor serum creatinine annually in all patients and every 3-6 months in "
        "patients with eGFR 30-60. "
        "The drug is available as generic metformin HCl and various extended-release formulations."
    )
    query = "What are the serious side effects of metformin?"
    result = compress_chunk(query, long_chunk)
    tokens_saved = len(long_chunk) // 4 - len(result) // 4
    print(f"Original: {len(long_chunk)} chars")
    print(f"Compressed: {len(result)} chars (~{tokens_saved} tokens saved)")
    print(f"\nExcerpt: {result}")
// contextual_compression.js
// npm install openai

import OpenAI from 'openai';

const client = new OpenAI({ baseURL: 'http://localhost:11434/v1', apiKey: 'ollama' });

// WHAT: Extract relevant excerpt from chunk using Mistral
// GOTCHA: response can be markdown-wrapped; always strip fences before using
export async function compressChunk(query, chunk, maxTokens = 256) {
  if (chunk.length < 150) return chunk;  // too short to compress

  const resp = await client.chat.completions.create({
    model: 'mistral',
    messages: [
      {
        role: 'system',
        content: 'Extract the portion of the document directly relevant to the query. '
          + 'Return only the relevant excerpt verbatim. '
          + 'If nothing is relevant, respond: [NOT RELEVANT]',
      },
      { role: 'user', content: `Query: ${query}\n\nDocument:\n${chunk}` },
    ],
    max_tokens: maxTokens,
    temperature: 0,
  });

  const excerpt = (resp.choices[0].message.content ?? '').trim();
  if (!excerpt || excerpt === '[NOT RELEVANT]' || excerpt.length < 10) return chunk;
  return excerpt;
}

export async function compressResults(query, results, maxTokensPerChunk = 256) {
  const compressed = await Promise.all(
    results.map(async r => {
      const excerpt = await compressChunk(query, r.text, maxTokensPerChunk);
      return excerpt === '[NOT RELEVANT]' ? null : { ...r, text: excerpt, originalText: r.text };
    })
  );
  return compressed.filter(Boolean);
}

Parent-Child Chunking

Definition: Parent-Child (Small-to-Big) Chunking

Parent-child chunkingA retrieval strategy where documents are indexed at two granularities: small "child" chunks (50-150 tokens) for precise matching, and larger "parent" chunks (300-600 tokens) that contain the child. When a child chunk matches a query, its corresponding parent chunk is returned to the LLM instead — combining retrieval precision with answer context richness. maintains two sets of chunks: small child chunks (50-150 tokens) for precise retrieval matching, and large parent chunks (300-600 tokens) that provide rich surrounding context for generation. The child chunks are indexed in ChromaDB; when a child chunk matches a query, the system retrieves its parent chunk instead. This solves the precision vs. context tradeoff: small chunks are better for finding specific facts, but the surrounding paragraph is needed for the model to generate a coherent answer.

WHAT: Two ChromaDB collections: child_chunks (indexed) and parent_chunks (returned on match)
WHY: Child chunks are 4x more precise for retrieval; parent chunks provide 4x more context for generation
GOTCHA: Store the parent_id in each child chunk's metadata — you need it to look up the parent after retrieval
# parent_child_chunking.py
# pip install chromadb sentence-transformers nltk

import chromadb
from sentence_transformers import SentenceTransformer
import nltk
from typing import List, Dict, Any

encoder = SentenceTransformer("all-MiniLM-L6-v2")

class ParentChildIndex:
    def __init__(self):
        self.client = chromadb.Client()
        # WHAT: Two collections — children are indexed, parents are stored for retrieval
        # GOTCHA: Delete collections before re-indexing to avoid duplicate IDs
        for name in ["parent_chunks", "child_chunks"]:
            try: self.client.delete_collection(name)
            except Exception: pass
        self.parents = self.client.create_collection("parent_chunks")
        self.children = self.client.create_collection("child_chunks")

    def _sentence_chunks(self, text: str, target_chars: int) -> List[str]:
        try:
            sentences = nltk.sent_tokenize(text)
        except LookupError:
            nltk.download("punkt", quiet=True)
            sentences = nltk.sent_tokenize(text)
        chunks, current, current_len = [], [], 0
        for sent in sentences:
            if current_len + len(sent) > target_chars and current:
                chunks.append(" ".join(current))
                current, current_len = [], 0
            current.append(sent)
            current_len += len(sent) + 1
        if current:
            chunks.append(" ".join(current))
        return chunks

    def index_document(
        self,
        doc_id: str,
        text: str,
        parent_size: int = 500,
        child_size: int = 120,
    ) -> None:
        # WHAT: Create parent chunks (larger, for context-rich retrieval)
        parent_chunks = self._sentence_chunks(text, parent_size)
        parent_ids = [f"{doc_id}__parent_{i}" for i in range(len(parent_chunks))]

        parent_embeddings = encoder.encode(parent_chunks, show_progress_bar=False).tolist()
        self.parents.add(
            documents=parent_chunks,
            embeddings=parent_embeddings,
            ids=parent_ids,
        )

        # WHAT: Create child chunks from each parent and store parent_id in metadata
        # WHY: The metadata link lets us look up the parent after a child matches
        all_child_docs, all_child_ids, all_child_metas, all_child_embs = [], [], [], []
        for p_idx, (parent_text, parent_id) in enumerate(zip(parent_chunks, parent_ids)):
            children = self._sentence_chunks(parent_text, child_size)
            for c_idx, child_text in enumerate(children):
                child_id = f"{doc_id}__child_{p_idx}_{c_idx}"
                all_child_docs.append(child_text)
                all_child_ids.append(child_id)
                all_child_metas.append({"parent_id": parent_id})

        if all_child_docs:
            all_child_embs = encoder.encode(all_child_docs, show_progress_bar=False).tolist()
            self.children.add(
                documents=all_child_docs,
                embeddings=all_child_embs,
                ids=all_child_ids,
                metadatas=all_child_metas,
            )
        print(f"Indexed {len(parent_chunks)} parents, {len(all_child_docs)} children for {doc_id!r}")

    def search(self, query: str, top_k: int = 3) -> List[Dict[str, Any]]:
        """Search children, then return their corresponding parents."""
        query_emb = encoder.encode([query], show_progress_bar=False).tolist()

        # WHAT: Retrieve child matches — small chunks for precision
        child_results = self.children.query(
            query_embeddings=query_emb,
            n_results=min(top_k * 3, 20),  # fetch 3x to ensure enough unique parents
        )

        # WHAT: Look up parent IDs from child metadata; deduplicate
        seen_parent_ids = set()
        parent_ids_ordered = []
        for meta in child_results["metadatas"][0]:
            pid = meta["parent_id"]
            if pid not in seen_parent_ids:
                seen_parent_ids.add(pid)
                parent_ids_ordered.append(pid)
                if len(parent_ids_ordered) >= top_k:
                    break

        # WHAT: Fetch parent documents by ID for the final context
        if not parent_ids_ordered:
            return []
        parent_data = self.parents.get(ids=parent_ids_ordered)
        return [
            {"id": pid, "text": text}
            for pid, text in zip(parent_data["ids"], parent_data["documents"])
        ]


if __name__ == "__main__":
    long_doc = (
        "Metformin is the first-line treatment for type 2 diabetes. It acts by inhibiting "
        "complex I of the mitochondrial respiratory chain. The primary effect is reduction "
        "of hepatic glucose output. Insulin sensitivity is also improved. "
        "Gastrointestinal side effects are common at initiation: nausea, vomiting, diarrhea. "
        "These typically resolve within 2-4 weeks. Taking metformin with food reduces GI effects. "
        "Lactic acidosis is the most serious adverse effect. It is rare (incidence ~3 per 100,000 "
        "patient-years) but carries ~50% mortality. Risk factors: renal impairment (eGFR < 30), "
        "hepatic impairment, congestive heart failure, excessive alcohol use. "
        "Contraindication: hold metformin 48h before and after IV contrast procedures."
    )
    index = ParentChildIndex()
    index.index_document("metformin-overview", long_doc)
    results = index.search("serious adverse effects of metformin", top_k=2)
    print("\nParent-Child Results:")
    for r in results:
        print(f"\n[{r['id']}]\n{r['text']}")
// parent_child_chunking.js
// npm install chromadb @xenova/transformers natural

import { ChromaClient } from 'chromadb';
import { pipeline } from '@xenova/transformers';
import natural from 'natural';

const tokenizer = new natural.SentenceTokenizer();
let _encoder = null;
async function getEncoder() {
  if (!_encoder) _encoder = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
  return _encoder;
}

async function embed(texts) {
  const enc = await getEncoder();
  return Promise.all(texts.map(async t => {
    const out = await enc(t, { pooling: 'mean', normalize: true });
    return Array.from(out.data);
  }));
}

function sentenceChunks(text, targetChars) {
  const sents = tokenizer.tokenize(text);
  const chunks = []; let cur = [], curLen = 0;
  for (const s of sents) {
    if (curLen + s.length > targetChars && cur.length) {
      chunks.push(cur.join(' ')); cur = []; curLen = 0;
    }
    cur.push(s); curLen += s.length + 1;
  }
  if (cur.length) chunks.push(cur.join(' '));
  return chunks;
}

// WHAT: Parent-child index with two ChromaDB collections
// GOTCHA: chromadb Node.js client requires a running ChromaDB server;
//         start one with: docker run -p 8000:8000 chromadb/chroma
export class ParentChildIndex {
  constructor() {
    this.client = new ChromaClient();
  }

  async init() {
    try { await this.client.deleteCollection({ name: 'parent_chunks' }); } catch {}
    try { await this.client.deleteCollection({ name: 'child_chunks' }); } catch {}
    this.parents = await this.client.createCollection({ name: 'parent_chunks' });
    this.children = await this.client.createCollection({ name: 'child_chunks' });
  }

  async indexDocument(docId, text, parentSize = 500, childSize = 120) {
    const parentChunks = sentenceChunks(text, parentSize);
    const parentIds = parentChunks.map((_, i) => `${docId}__parent_${i}`);
    const parentEmbs = await embed(parentChunks);
    await this.parents.add({ documents: parentChunks, embeddings: parentEmbs, ids: parentIds });

    const childDocs = [], childIds = [], childMetas = [], childEmbs = [];
    for (let pi = 0; pi < parentChunks.length; pi++) {
      const children = sentenceChunks(parentChunks[pi], childSize);
      for (let ci = 0; ci < children.length; ci++) {
        childDocs.push(children[ci]);
        childIds.push(`${docId}__child_${pi}_${ci}`);
        childMetas.push({ parent_id: parentIds[pi] });
      }
    }
    if (childDocs.length) {
      const embs = await embed(childDocs);
      await this.children.add({ documents: childDocs, embeddings: embs, ids: childIds, metadatas: childMetas });
    }
    console.log(`Indexed ${parentChunks.length} parents, ${childDocs.length} children`);
  }

  async search(query, topK = 3) {
    const enc = await getEncoder();
    const qOut = await enc(query, { pooling: 'mean', normalize: true });
    const qEmb = [Array.from(qOut.data)];
    const childRes = await this.children.query({ queryEmbeddings: qEmb, nResults: topK * 3 });
    const seenParents = new Set(); const parentIds = [];
    for (const meta of childRes.metadatas[0]) {
      if (!seenParents.has(meta.parent_id)) {
        seenParents.add(meta.parent_id);
        parentIds.push(meta.parent_id);
        if (parentIds.length >= topK) break;
      }
    }
    if (!parentIds.length) return [];
    const parentData = await this.parents.get({ ids: parentIds });
    return parentData.ids.map((id, i) => ({ id, text: parentData.documents[i] }));
  }
}
All the individual techniques are now in your toolbox. Production systems don't pick one — they compose them. Let's assemble the full pipeline, add configurable stages, and benchmark each enhancement.

Production RAG Pipeline

WHAT: Class-based pipeline combining semantic chunking, hybrid search, cross-encoder re-ranking, contextual compression, and Mistral generation
WHY: Each stage is optional and individually measurable — this makes A/B testing and ablation studies trivial
GOTCHA: Run the benchmark() method before deciding which stages to keep in production; sometimes compression hurts quality on short corpora
# production_rag_pipeline.py
# pip install rank-bm25 sentence-transformers chromadb nltk openai

from __future__ import annotations
import time
from dataclasses import dataclass, field
from typing import List, Optional
from openai import OpenAI

# Import our custom modules (defined in this module set)
from chunking_strategies import semantic_chunks
from hybrid_search import HybridSearchIndex
from reranker import CrossEncoderReranker
from contextual_compression import compress_results

# WHAT: Pipeline configuration — toggle features with boolean flags
# WHY: Allows quick ablation testing without code changes
@dataclass
class PipelineConfig:
    chunk_strategy: str = "semantic"     # "fixed", "sentence", "semantic", "recursive"
    use_hybrid_search: bool = True       # BM25 + dense vs dense-only
    use_reranking: bool = True           # cross-encoder re-ranker
    use_compression: bool = False        # contextual compression (adds latency)
    retrieve_k: int = 20                 # candidates before re-ranking
    final_k: int = 5                     # chunks passed to generation
    ollama_url: str = "http://localhost:11434/v1"
    model: str = "mistral"


class ProductionRAGPipeline:
    def __init__(self, config: PipelineConfig = None):
        self.config = config or PipelineConfig()
        self.index = HybridSearchIndex()
        self.reranker = CrossEncoderReranker() if self.config.use_reranking else None
        self.client = OpenAI(base_url=self.config.ollama_url, api_key="ollama")
        self._documents_indexed = False

    # WHAT: Index a document with the configured chunking strategy
    def index_documents(self, texts: List[str], ids: List[str]) -> None:
        all_chunks, all_ids = [], []
        for doc_id, text in zip(ids, texts):
            if self.config.chunk_strategy == "semantic":
                chunks = semantic_chunks(text, threshold=0.3)
            elif self.config.chunk_strategy == "sentence":
                from chunking_strategies import sentence_aware_chunks
                chunks = sentence_aware_chunks(text, 400)
            elif self.config.chunk_strategy == "recursive":
                from chunking_strategies import recursive_chunks
                chunks = recursive_chunks(text, 400)
            else:  # fixed
                from chunking_strategies import fixed_size_chunks
                chunks = fixed_size_chunks(text, 400, 60)
            for i, chunk in enumerate(chunks):
                all_chunks.append(chunk)
                all_ids.append(f"{doc_id}__chunk_{i}")
        self.index.add_documents(all_chunks, all_ids)
        self._documents_indexed = True

    def _retrieve(self, query: str) -> List[dict]:
        return self.index.search(query, top_k=self.config.retrieve_k)

    def _rerank(self, query: str, candidates: List[dict]) -> List[dict]:
        if self.reranker:
            return self.reranker.rerank(query, candidates, top_k=self.config.final_k)
        return candidates[:self.config.final_k]

    def _compress(self, query: str, chunks: List[dict]) -> List[dict]:
        if self.config.use_compression:
            return compress_results(query, chunks)
        return chunks

    def _generate(self, query: str, context_chunks: List[dict]) -> str:
        context = "\n\n".join(
            f"[{i+1}] {c['text']}" for i, c in enumerate(context_chunks)
        )
        resp = self.client.chat.completions.create(
            model=self.config.model,
            messages=[
                {
                    "role": "system",
                    "content": (
                        "Answer the question using only the provided context. "
                        "Cite context numbers like [1], [2] when referencing sources. "
                        "If the context doesn't contain the answer, say so explicitly."
                    ),
                },
                {
                    "role": "user",
                    "content": f"Context:\n{context}\n\nQuestion: {query}",
                },
            ],
            max_tokens=512,
            temperature=0,
        )
        return resp.choices[0].message.content or ""

    def query(self, question: str) -> dict:
        """Run the full pipeline; return answer and timing breakdown."""
        timings = {}

        t0 = time.time()
        candidates = self._retrieve(question)
        timings["retrieve_ms"] = int((time.time() - t0) * 1000)

        t0 = time.time()
        reranked = self._rerank(question, candidates)
        timings["rerank_ms"] = int((time.time() - t0) * 1000)

        t0 = time.time()
        final_chunks = self._compress(question, reranked)
        timings["compress_ms"] = int((time.time() - t0) * 1000)

        t0 = time.time()
        answer = self._generate(question, final_chunks)
        timings["generate_ms"] = int((time.time() - t0) * 1000)

        timings["total_ms"] = sum(timings.values())
        return {
            "answer": answer,
            "sources": [c["id"] for c in final_chunks],
            "timings": timings,
            "chunk_count": len(final_chunks),
        }

    def benchmark(self, queries: List[str]) -> None:
        """Print timing breakdown across multiple queries."""
        print(f"\nBenchmark: {len(queries)} queries")
        print(f"Config: hybrid={self.config.use_hybrid_search}, "
              f"rerank={self.config.use_reranking}, "
              f"compress={self.config.use_compression}")
        print("-" * 60)
        totals = {"retrieve_ms": 0, "rerank_ms": 0, "compress_ms": 0, "generate_ms": 0}
        for q in queries:
            result = self.query(q)
            for k in totals:
                totals[k] += result["timings"][k]
        n = len(queries)
        for k, v in totals.items():
            print(f"  {k:20s}: {v//n:4d}ms avg")
        print(f"  {'total_ms':20s}: {sum(totals.values())//n:4d}ms avg")


# ── Demo ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
    docs = [
        "Metformin is a biguanide antidiabetic drug and first-line therapy for type 2 diabetes. "
        "It reduces hepatic glucose production via AMPK activation. Contraindicated with eGFR < 30. "
        "Lactic acidosis risk increases with renal impairment. Hold 48h before contrast procedures. "
        "GI side effects (nausea, diarrhea) resolve within 2-4 weeks. Take with food.",
    ]
    config = PipelineConfig(use_reranking=True, use_compression=False, retrieve_k=10, final_k=3)
    pipeline = ProductionRAGPipeline(config)
    pipeline.index_documents(docs, ["metformin-doc"])
    result = pipeline.query("What are the contraindications for metformin?")
    print("\nAnswer:", result["answer"])
    print("Timings:", result["timings"])
// production_rag_pipeline.js
// npm install openai @xenova/transformers chromadb natural

import OpenAI from 'openai';
// Import from the modules defined earlier in this module set
import { HybridSearchIndex } from './hybrid_search.js';
import { crossEncoderRerank } from './reranker.js';
import { compressResults } from './contextual_compression.js';
import { semanticChunks } from './chunking_strategies.js';

// WHAT: Configuration class — toggle features without changing pipeline code
// GOTCHA: use_compression=false is the safe default; enable only when chunks > 400 tokens
const defaultConfig = {
  chunkStrategy: 'semantic',
  useHybridSearch: true,
  useReranking: true,
  useCompression: false,
  retrieveK: 20,
  finalK: 5,
  ollamaUrl: 'http://localhost:11434/v1',
  model: 'mistral',
};

export class ProductionRAGPipeline {
  constructor(config = {}) {
    this.config = { ...defaultConfig, ...config };
    this.index = new HybridSearchIndex();
    this.client = new OpenAI({ baseURL: this.config.ollamaUrl, apiKey: 'ollama' });
  }

  async init() { await this.index.init(); }

  async indexDocuments(texts, ids) {
    const allChunks = [], allIds = [];
    for (let di = 0; di < texts.length; di++) {
      let chunks;
      if (this.config.chunkStrategy === 'semantic') {
        chunks = await semanticChunks(texts[di], 0.3);
      } else {
        // fallback: fixed size
        const { fixedSizeChunks } = await import('./chunking_strategies.js');
        chunks = fixedSizeChunks(texts[di], 400, 60);
      }
      chunks.forEach((c, i) => { allChunks.push(c); allIds.push(`${ids[di]}__chunk_${i}`); });
    }
    await this.index.addDocuments(allChunks, allIds);
  }

  async query(question) {
    const t = {};
    let t0 = Date.now();
    let candidates = await this.index.search(question, this.config.retrieveK);
    t.retrieve_ms = Date.now() - t0;

    t0 = Date.now();
    let reranked = this.config.useReranking
      ? await crossEncoderRerank(question, candidates, this.config.finalK)
      : candidates.slice(0, this.config.finalK);
    t.rerank_ms = Date.now() - t0;

    t0 = Date.now();
    let finalChunks = this.config.useCompression
      ? await compressResults(question, reranked)
      : reranked;
    t.compress_ms = Date.now() - t0;

    t0 = Date.now();
    const context = finalChunks.map((c, i) => `[${i+1}] ${c.text}`).join('\n\n');
    const resp = await this.client.chat.completions.create({
      model: this.config.model,
      messages: [
        {
          role: 'system',
          content: 'Answer using only the provided context. Cite [1],[2] for sources.',
        },
        { role: 'user', content: `Context:\n${context}\n\nQuestion: ${question}` },
      ],
      max_tokens: 512,
      temperature: 0,
    });
    t.generate_ms = Date.now() - t0;
    t.total_ms = Object.values(t).reduce((s, v) => s + v, 0);

    return {
      answer: resp.choices[0].message.content ?? '',
      sources: finalChunks.map(c => c.id),
      timings: t,
    };
  }
}
+18%
recall@5 from hybrid search alone
+22%
precision@5 adding cross-encoder re-rank
120ms
re-ranking overhead (20 docs, CPU)
What Just Happened?

You assembled a six-stage production RAG pipeline where every enhancement is individually toggleable via a config flag. This design lets you run ablation tests: set use_hybrid_search=False to measure its exact contribution, set use_reranking=False to quantify re-ranking quality lift. Each stage adds measurable latency — the benchmark() method tells you exactly where time is spent so you can make informed production tradeoffs. The entire pipeline makes zero external API calls: all models run locally via Ollama and sentence-transformers.

You've built every advanced component. Now let's apply them to the M09 pipeline you already have — step by step, with before/after quality measurement.

Lab: Upgrade the M09 RAG Pipeline

Lab Prerequisites

You need the M09 RAG pipeline working with ChromaDB and Mistral. Install new dependencies: pip install rank-bm25 sentence-transformers nltk. Run python -c "import nltk; nltk.download('punkt')" once after install.

1

Replace fixed-size chunking with sentence-aware

In your M09 indexing script, replace fixed_size_chunks(text, 512) with sentence_aware_chunks(text, 400). Re-index your document corpus. Measure: run your 5 test queries and note how many return coherent sentences vs. mid-sentence fragments.

2

Add BM25 hybrid search

Wrap your ChromaDB retrieval with the HybridSearchIndex class from this module. Change all collection.query() calls to index.search(). Test the exact-match query: "FDA approval number 123-456" — compare how many relevant results appear in the top 5 before and after.

3

Add cross-encoder re-ranking

After retrieval, pipe the top 20 results through CrossEncoderReranker().rerank(query, candidates, top_k=5). The cross-encoder model downloads automatically on first use (~67MB). Run your test queries again and compare which chunks appear in position 1-3 before and after re-ranking.

4

Measure improvement with a 5-question eval set

Create eval_questions.json with 5 questions covering: a factual lookup (tests hybrid search), a broad conceptual question (tests semantic chunking), an ambiguous query (tests multi-query), a question requiring context synthesis (tests parent-child), and a question with a specific number/ID (tests BM25 keyword match). Score each pipeline variant: baseline → +chunking → +hybrid → +reranking. This is the eval set you'll use with Ragas in M18.

WHAT: Before/after benchmark that measures retrieval quality improvement at each stage
WHY: Quantifying improvement lets you justify the latency cost of each enhancement to stakeholders
GOTCHA: Use a fixed random seed for any stochastic operations so benchmark results are reproducible
# lab_benchmark.py
# Measures retrieval quality improvement at each pipeline stage
# Assumes you have the M09 corpus indexed in ChromaDB at ./chroma_db/

from production_rag_pipeline import ProductionRAGPipeline, PipelineConfig
import json, time

# WHAT: Five eval questions covering different retrieval challenge types
# WHY: Each question type is designed to stress-test a specific enhancement
EVAL_QUESTIONS = [
    {
        "id": "Q1",
        "question": "What is FDA approval number 123-456?",
        "type": "exact_match",           # tests BM25 keyword search
        "expected_keyword": "123-456",
    },
    {
        "id": "Q2",
        "question": "What are the serious adverse effects of metformin?",
        "type": "semantic",              # tests semantic chunking + reranking
        "expected_keyword": "lactic acidosis",
    },
    {
        "id": "Q3",
        "question": "When should metformin be held before a procedure?",
        "type": "specific_fact",         # tests sentence-aware chunking
        "expected_keyword": "48",
    },
    {
        "id": "Q4",
        "question": "kidney monitoring metformin",
        "type": "ambiguous",             # tests multi-query expansion
        "expected_keyword": "eGFR",
    },
    {
        "id": "Q5",
        "question": "Compare mechanism and side effects of metformin",
        "type": "synthesis",             # tests parent-child or semantic chunks
        "expected_keyword": "AMPK",
    },
]

def score_results(results, expected_keyword):
    """Check if expected keyword appears in top-3 retrieved chunks."""
    for i, r in enumerate(results[:3]):
        if expected_keyword.lower() in r.get("text", "").lower():
            return {"found": True, "rank": i + 1}
    return {"found": False, "rank": None}


def run_benchmark(docs, doc_ids, questions, configs):
    for config_name, config in configs.items():
        pipeline = ProductionRAGPipeline(config)
        pipeline.index_documents(docs, doc_ids)

        print(f"\n{'='*60}")
        print(f"Config: {config_name}")
        hits = 0
        for q in questions:
            t0 = time.time()
            # Direct retrieval (no generation) for benchmarking speed
            candidates = pipeline.index.search(q["question"], top_k=config.final_k)
            if config.use_reranking and pipeline.reranker:
                candidates = pipeline.reranker.rerank(q["question"], candidates, config.final_k)
            elapsed_ms = int((time.time() - t0) * 1000)
            result = score_results(candidates, q["expected_keyword"])
            if result["found"]:
                hits += 1
            icon = "PASS" if result["found"] else "FAIL"
            rank_str = f"rank {result['rank']}" if result["found"] else "not found"
            print(f"  [{icon}] {q['id']} ({q['type']}) — {rank_str} in {elapsed_ms}ms")

        print(f"  Score: {hits}/{len(questions)} ({hits*100//len(questions)}%)")


if __name__ == "__main__":
    # Load your M09 corpus here
    docs = [
        "Metformin is first-line treatment for type 2 diabetes. FDA approval number 123-456 "
        "was granted for metformin HCl 500mg. It reduces hepatic glucose via AMPK activation. "
        "Lactic acidosis is rare but serious — stop metformin if eGFR drops below 30. "
        "Hold 48 hours before and after iodinated contrast procedures. "
        "GI side effects: nausea, diarrhea. Monitor kidney function every 6 months.",
    ]
    doc_ids = ["metformin-guide"]

    configs = {
        "Baseline (dense-only, no rerank)": PipelineConfig(
            use_hybrid_search=False, use_reranking=False, final_k=5
        ),
        "+Hybrid Search (BM25+dense)": PipelineConfig(
            use_hybrid_search=True, use_reranking=False, final_k=5
        ),
        "+Hybrid +Reranking": PipelineConfig(
            use_hybrid_search=True, use_reranking=True, final_k=5
        ),
    }
    run_benchmark(docs, doc_ids, EVAL_QUESTIONS, configs)
// lab_benchmark.js
// npm install openai @xenova/transformers chromadb natural

import { ProductionRAGPipeline } from './production_rag_pipeline.js';

const EVAL_QUESTIONS = [
  { id: 'Q1', question: 'What is FDA approval number 123-456?', type: 'exact_match', expected: '123-456' },
  { id: 'Q2', question: 'What are the serious adverse effects of metformin?', type: 'semantic', expected: 'lactic acidosis' },
  { id: 'Q3', question: 'When should metformin be held before a procedure?', type: 'specific_fact', expected: '48' },
  { id: 'Q4', question: 'kidney monitoring metformin', type: 'ambiguous', expected: 'eGFR' },
  { id: 'Q5', question: 'Compare mechanism and side effects of metformin', type: 'synthesis', expected: 'AMPK' },
];

function scoreResults(results, expected) {
  for (let i = 0; i < Math.min(results.length, 3); i++) {
    if ((results[i].text || '').toLowerCase().includes(expected.toLowerCase()))
      return { found: true, rank: i + 1 };
  }
  return { found: false, rank: null };
}

async function runBenchmark(docs, docIds, questions, configs) {
  for (const [name, config] of Object.entries(configs)) {
    const pipeline = new ProductionRAGPipeline(config);
    await pipeline.init();
    await pipeline.indexDocuments(docs, docIds);
    console.log(`\n${'='.repeat(55)}\nConfig: ${name}`);
    let hits = 0;
    for (const q of questions) {
      const t0 = Date.now();
      let candidates = await pipeline.index.search(q.question, config.retrieveK || 10);
      if (config.useReranking) {
        const { crossEncoderRerank } = await import('./reranker.js');
        candidates = await crossEncoderRerank(q.question, candidates, config.finalK || 5);
      } else {
        candidates = candidates.slice(0, config.finalK || 5);
      }
      const elapsed = Date.now() - t0;
      const result = scoreResults(candidates, q.expected);
      if (result.found) hits++;
      const icon = result.found ? 'PASS' : 'FAIL';
      const rank = result.found ? `rank ${result.rank}` : 'not found';
      console.log(`  [${icon}] ${q.id} (${q.type}) — ${rank} in ${elapsed}ms`);
    }
    console.log(`  Score: ${hits}/${questions.length} (${Math.round(hits*100/questions.length)}%)`);
  }
}

const docs = [
  'Metformin is first-line treatment for type 2 diabetes. FDA approval number 123-456 '
  + 'was granted for metformin HCl 500mg. It reduces hepatic glucose via AMPK activation. '
  + 'Lactic acidosis is rare but serious — stop metformin if eGFR drops below 30. '
  + 'Hold 48 hours before iodinated contrast procedures. Monitor kidney function every 6 months.',
];
const docIds = ['metformin-guide'];

const configs = {
  'Baseline (dense-only)': { useHybridSearch: false, useReranking: false, finalK: 5 },
  '+Hybrid Search':         { useHybridSearch: true,  useReranking: false, finalK: 5 },
  '+Hybrid +Reranking':     { useHybridSearch: true,  useReranking: true,  finalK: 5 },
};

runBenchmark(docs, docIds, EVAL_QUESTIONS, configs).catch(console.error);
What Just Happened?

You ran a structured ablation study measuring the contribution of each enhancement. Typical results: the baseline dense-only system scores 2-3/5 on these targeted questions. Adding hybrid search brings it to 4/5 (the exact-match question that was previously failing now works). Adding re-ranking might not improve the count but improves the rank of correct results from position 3 to position 1 — which matters for generation quality because the model attends more strongly to earlier context. You now have quantitative evidence for every technique in this module, not just theoretical arguments. These numbers become your eval baseline for M18.

Knowledge Check

1. A user queries "FDA approval number NDA-021218" and basic RAG fails to return the relevant document even though it is in the corpus. The most likely fix is:

ASwitch to a larger embedding model like all-mpnet-base-v2
BAdd BM25 hybrid search — exact product codes and IDs are not well-represented in embedding space and need keyword matching
CUse semantic chunking instead of fixed-size chunking
DIncrease the retrieve_k parameter from 5 to 50

2. Semantic chunking splits documents where:

AA fixed character count is reached, regardless of sentence boundaries
BA paragraph break (double newline) appears in the source text
CThe cosine distance between consecutive sentence embeddings exceeds a threshold, indicating a topic shift
DA named entity transition is detected by a spaCy NER model

3. In Reciprocal Rank Fusion, a document ranks #3 in both the BM25 list and the dense list. Another document ranks #1 in the dense list but does not appear in the BM25 list. Which has the higher RRF score?

AThe dense-only #1 document — it has the best single-system score
BThe double-ranked #3 document — appearing in both lists accumulates two 1/(60+3) contributions, totaling ~0.032, vs 1/(60+1) ≈ 0.016 for the single-list document
CThey tie — RRF treats all ranks equally
DWhichever has the higher TF-IDF score in the original corpus

4. You need to re-rank 20 retrieved candidates. Latency is critical — queries must complete under 200ms total. Cross-encoder re-ranking on CPU takes ~120ms alone. The best strategy is:

AUse LLM re-ranking with Mistral — it's faster than cross-encoders
BSkip re-ranking and use only hybrid search for candidate selection, accepting the quality tradeoff
CUse cross-encoder re-ranking with a GPU or smaller reranker model — cross-encoder/ms-marco-MiniLM-L-4-v2 runs in ~35ms on CPU
DRe-rank using BM25 scores as a proxy for cross-encoder scores

5. Contextual compression reduces a 500-token chunk to a 40-token excerpt before passing it to the generation model. The primary risk of this approach is:

AThe embedding quality of the compressed chunk degrades
BThe LLM compressor may discard context that was indirectly relevant, narrowing the answer quality; plus it adds one LLM call per chunk, multiplying latency
CChromaDB cannot store compressed chunks
DThe BM25 index must be rebuilt after compression
0/5

Knowledge check complete — review any missed sections above