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
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 |
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.
Chunking Strategies
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.
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.
in lowers blood s...
ts include nausea...
Kidney function...
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.
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);
}
Hybrid Search: BM25 + Dense
BM25Best Match 25 — a probabilistic ranking algorithm that scores documents by term frequency (how often a query term appears in the document) and inverse document frequency (how rare that term is across all documents). It handles exact keyword matching efficiently and is the backbone of Elasticsearch and most search engines built before neural embeddings. (Best Match 25) is a probabilistic ranking function that scores documents by how often query terms appear in them, weighted by how rare those terms are across all documents. It excels at exact keyword matches — product codes, drug IDs, person names — that dense embeddings blur into neighborhood similarity. Reciprocal Rank FusionA rank aggregation method that combines ranked lists from multiple retrieval systems. Each document's RRF score = sum of 1/(k + rank_i) across all lists, where k is a smoothing constant (typically 60). This prevents a single very-high-ranked result from dominating while rewarding consistently high-ranked results across systems. (RRF) merges the BM25 ranked list and the dense embedding ranked list by computing a combined score of 1/(k+rank) for each result in each list, then sorting by combined score.
The intuition: RRF is ranked-choice voting. Each retriever — BM25 and dense — casts a ballot, which is just its ranked list. A document’s final score is the sum of its votes, and a vote is worth more the higher the document placed: a #1 finish is worth 1/(60+1) = 0.0164, a #3 finish 1/(60+3) = 0.0159. Why add 60? It deliberately shrinks the gap between rank 1 and rank 2 so no single list can crown the winner by itself. Here’s the payoff, and the whole reason hybrid search works: a document that lands mid-pack in both lists (two modest votes) beats a document that’s #1 in only one list (a single big vote) — 0.0159 + 0.0159 = 0.0318 is greater than 0.0164. Agreement across two different retrieval methods is a stronger signal than a high score from either one alone. Watch that happen in the animation below: the two documents that appear in both lists rise to the top, while documents that scored well in only one list fall behind.
+
Dense
Fusion
k=60
WHY: RRF is parameter-light (just k) and consistently outperforms weighted linear combination of scores
GOTCHA: BM25 operates on in-memory token lists — reload the BM25Okapi object if new documents are added
# hybrid_search.py
# pip install rank-bm25 chromadb sentence-transformers
from rank_bm25 import BM25Okapi
import chromadb
from sentence_transformers import SentenceTransformer
from typing import List, Dict, Any
import numpy as np
# WHAT: Build both indexes from the same document list
# WHY: Keeping them in sync ensures BM25 rank and dense rank cover identical docs
# GOTCHA: Use doc IDs consistently — ChromaDB returns them by ID, BM25 by list index
class HybridSearchIndex:
def __init__(self, model_name: str = "all-MiniLM-L6-v2"):
self.encoder = SentenceTransformer(model_name)
self.client = chromadb.Client()
# Delete collection if it exists (for clean demo runs)
try:
self.client.delete_collection("hybrid_docs")
except Exception:
pass
self.collection = self.client.create_collection("hybrid_docs")
self.docs: List[str] = []
self.doc_ids: List[str] = []
self.bm25: BM25Okapi | None = None
def add_documents(self, docs: List[str], ids: List[str]) -> None:
self.docs = docs
self.doc_ids = ids
# WHAT: Build BM25 index — tokenize by lowercasing and splitting on spaces
# GOTCHA: For better BM25 performance, add stop-word removal and stemming
tokenized = [d.lower().split() for d in docs]
self.bm25 = BM25Okapi(tokenized)
# WHAT: Embed and add to ChromaDB
embeddings = self.encoder.encode(docs, show_progress_bar=False).tolist()
self.collection.add(
documents=docs,
embeddings=embeddings,
ids=ids,
)
print(f"Indexed {len(docs)} documents")
# WHAT: Reciprocal Rank Fusion — merge ranked lists from BM25 and dense
# WHY: RRF(k=60) is robust: k prevents a rank-1 result from completely
# dominating the merged score; default k=60 is well-validated
@staticmethod
def _rrf(rankings: List[List[str]], k: int = 60) -> Dict[str, float]:
scores: Dict[str, float] = {}
for ranked_list in rankings:
for rank, doc_id in enumerate(ranked_list, start=1):
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (k + rank)
return scores
def search(self, query: str, top_k: int = 5, fetch_k: int = 20) -> List[Dict[str, Any]]:
"""Hybrid search: returns top_k docs after BM25 + dense + RRF merge."""
# ── BM25 lexical retrieval ────────────────────────────────────────────
bm25_scores = self.bm25.get_scores(query.lower().split())
bm25_ranked = [
self.doc_ids[i]
for i in np.argsort(bm25_scores)[::-1][:fetch_k]
]
# ── Dense semantic retrieval ──────────────────────────────────────────
query_embedding = self.encoder.encode([query], show_progress_bar=False).tolist()
dense_results = self.collection.query(
query_embeddings=query_embedding,
n_results=min(fetch_k, len(self.docs)),
)
dense_ranked = dense_results["ids"][0] # already sorted by similarity
# ── RRF merge ────────────────────────────────────────────────────────
rrf_scores = self._rrf([bm25_ranked, dense_ranked])
sorted_ids = sorted(rrf_scores, key=rrf_scores.get, reverse=True)[:top_k]
# ── Build result objects ──────────────────────────────────────────────
id_to_doc = dict(zip(self.doc_ids, self.docs))
return [
{
"id": doc_id,
"text": id_to_doc[doc_id],
"rrf_score": rrf_scores[doc_id],
}
for doc_id in sorted_ids
if doc_id in id_to_doc
]
# ── Demo ──────────────────────────────────────────────────────────────────────
if __name__ == "__main__":
docs = [
"Metformin lowers blood sugar by reducing hepatic glucose production.",
"FDA approval number 123-456 was granted for metformin HCl 500mg tablets.",
"Side effects of metformin include nausea, diarrhea, and abdominal discomfort.",
"Drug interactions: iodinated contrast agents may cause lactic acidosis.",
"Metformin is contraindicated in patients with eGFR below 30 mL/min.",
"FDA approval number 789-012 covers the extended-release formulation.",
"Kidney function monitoring every 6 months is required for all metformin users.",
]
ids = [f"doc-{i}" for i in range(len(docs))]
index = HybridSearchIndex()
index.add_documents(docs, ids)
for query in ["FDA approval number 123-456", "what are the side effects"]:
print(f"\nQuery: {query!r}")
results = index.search(query, top_k=3)
for r in results:
print(f" [{r['rrf_score']:.4f}] {r['text'][:80]}")
// hybrid_search.js
// npm install @xenova/transformers chromadb
// Note: rank-bm25 is Python only; we implement BM25Okapi from scratch below
import { pipeline } from '@xenova/transformers';
import { ChromaClient } from 'chromadb';
// WHAT: Minimal BM25 implementation — equivalent to rank_bm25.BM25Okapi
// WHY: No maintained npm equivalent; this ~40-line version covers the full algorithm
// GOTCHA: avgdl is computed across all docs; adding docs later requires recomputation
class BM25 {
constructor(docs, k1 = 1.5, b = 0.75) {
this.k1 = k1; this.b = b;
this.corpus = docs.map(d => d.toLowerCase().split(/\s+/));
this.N = this.corpus.length;
this.avgdl = this.corpus.reduce((s, d) => s + d.length, 0) / this.N;
this.df = {};
for (const doc of this.corpus)
for (const term of new Set(doc))
this.df[term] = (this.df[term] || 0) + 1;
}
idf(term) {
const df = this.df[term] || 0;
return Math.log((this.N - df + 0.5) / (df + 0.5) + 1);
}
score(docTokens, queryTokens) {
let score = 0;
const dl = docTokens.length;
const tf = {};
for (const t of docTokens) tf[t] = (tf[t] || 0) + 1;
for (const qt of queryTokens) {
const f = tf[qt] || 0;
score += this.idf(qt) * (f * (this.k1 + 1)) /
(f + this.k1 * (1 - this.b + this.b * dl / this.avgdl));
}
return score;
}
getScores(query) {
const qt = query.toLowerCase().split(/\s+/);
return this.corpus.map(doc => this.score(doc, qt));
}
}
function rrf(rankings, k = 60) {
const scores = {};
for (const list of rankings)
list.forEach((id, i) => { scores[id] = (scores[id] || 0) + 1 / (k + i + 1); });
return scores;
}
class HybridSearchIndex {
constructor() {
this.client = new ChromaClient();
this.bm25 = null;
this.docs = [];
this.ids = [];
this.extractor = null;
}
async init() {
this.extractor = await pipeline('feature-extraction', 'Xenova/all-MiniLM-L6-v2');
try { await this.client.deleteCollection({ name: 'hybrid_docs' }); } catch {}
this.collection = await this.client.createCollection({ name: 'hybrid_docs' });
}
async addDocuments(docs, ids) {
this.docs = docs; this.ids = ids;
this.bm25 = new BM25(docs);
const embeddings = [];
for (const doc of docs) {
const out = await this.extractor(doc, { pooling: 'mean', normalize: true });
embeddings.push(Array.from(out.data));
}
await this.collection.add({ documents: docs, embeddings, ids });
console.log(`Indexed ${docs.length} documents`);
}
async search(query, topK = 5, fetchK = 20) {
// BM25 retrieval
const bm25Scores = this.bm25.getScores(query);
const bm25Ranked = bm25Scores
.map((s, i) => ({ id: this.ids[i], score: s }))
.sort((a, b) => b.score - a.score)
.slice(0, fetchK).map(r => r.id);
// Dense retrieval
const qEmb = await this.extractor(query, { pooling: 'mean', normalize: true });
const denseRes = await this.collection.query({
queryEmbeddings: [Array.from(qEmb.data)], nResults: fetchK
});
const denseRanked = denseRes.ids[0];
// RRF merge
const rrfScores = rrf([bm25Ranked, denseRanked]);
const sorted = Object.entries(rrfScores)
.sort(([, a], [, b]) => b - a).slice(0, topK);
const idToDoc = Object.fromEntries(this.ids.map((id, i) => [id, this.docs[i]]));
return sorted.map(([id, score]) => ({ id, text: idToDoc[id], rrfScore: score }));
}
}
(async () => {
const index = new HybridSearchIndex();
await index.init();
const docs = [
'Metformin lowers blood sugar by reducing hepatic glucose production.',
'FDA approval number 123-456 was granted for metformin HCl 500mg tablets.',
'Side effects of metformin include nausea, diarrhea, and abdominal discomfort.',
];
await index.addDocuments(docs, docs.map((_, i) => `doc-${i}`));
const results = await index.search('FDA approval number 123-456', 3);
console.log('\nResults:');
results.forEach(r => console.log(` [${r.rrfScore.toFixed(4)}] ${r.text}`));
})();
Re-ranking
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.
| Approach | Model | Latency (20 docs) | Quality | Extra 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 |
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.
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);
}
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.
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);
}
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.
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.
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
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.
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] }));
}
}
Production RAG Pipeline
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,
};
}
}
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.
Lab: Upgrade the M09 RAG Pipeline
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.
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.
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.
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.
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.
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);
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:
2. Semantic chunking splits documents where:
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?
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:
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:
Knowledge check complete — review any missed sections above