Lesson 13: Advanced RAG | AI Engineering
Module 3: RAG Pipeline

Advanced RAG

Move beyond naive retrieve-and-generate with hybrid search, reranking, query expansion, HyDE, and multi-step retrieval for production-grade accuracy.

Lesson 13 ~25 min Advanced

Why Naive RAG Isn't Enough

Naive RAG Limitations

The basic pipeline (embed query → vector search → stuff context → generate) has fundamental weaknesses:

  • Vocabulary mismatch: User says "auth issues" but docs say "authentication errors"
  • Semantic gap: Questions and answers have different embedding representations
  • Noisy retrieval: Top-K includes irrelevant chunks that confuse the LLM
  • Single-hop limit: Can't answer questions requiring info from multiple chunks
  • Lost context: Small chunks miss surrounding information needed for full answers

Advanced RAG techniques address each failure mode. Let's build a production pipeline step by step.

Hybrid Search: Dense + Sparse

Combine semantic search (embeddings) with keyword search (BM25) for the best of both worlds:

Why Hybrid?

  • Dense (vector) search: Captures meaning — "automobile" matches "car"
  • Sparse (BM25) search: Captures exact terms — "error code 403" matches exactly
  • Combined: Handles both semantic and keyword queries reliably
"""Hybrid Search: BM25 + Vector Search with Reciprocal Rank Fusion"""
from rank_bm25 import BM25Okapi
import numpy as np
from openai import OpenAI
import chromadb

client = OpenAI()

class HybridSearcher:
    def __init__(self, documents: list[dict]):
        """
        documents: list of {"content": str, "metadata": dict}
        """
        self.documents = documents
        self.contents = [d["content"] for d in documents]
        
        # Build BM25 index (sparse/keyword search)
        tokenized = [doc.lower().split() for doc in self.contents]
        self.bm25 = BM25Okapi(tokenized)
        
        # Build vector index
        self.chroma = chromadb.Client()
        self.collection = self.chroma.create_collection("hybrid")
        
        # Embed and store
        embeddings = self._embed(self.contents)
        self.collection.add(
            ids=[f"doc_{i}" for i in range(len(documents))],
            documents=self.contents,
            embeddings=embeddings,
            metadatas=[d["metadata"] for d in documents]
        )
    
    def _embed(self, texts: list[str]) -> list[list[float]]:
        response = client.embeddings.create(
            input=texts, model="text-embedding-3-small"
        )
        return [d.embedding for d in response.data]
    
    def search(self, query: str, top_k: int = 10, alpha: float = 0.7) -> list[dict]:
        """
        Hybrid search with Reciprocal Rank Fusion (RRF).
        
        alpha: weight for vector search (1-alpha for BM25)
        """
        # --- Sparse search (BM25) ---
        bm25_scores = self.bm25.get_scores(query.lower().split())
        bm25_ranked = np.argsort(bm25_scores)[::-1][:top_k * 2]
        
        # --- Dense search (vector) ---
        vector_results = self.collection.query(
            query_texts=[query], n_results=top_k * 2
        )
        # Map IDs back to indices
        vector_ranked = [
            int(id.split("_")[1]) for id in vector_results["ids"][0]
        ]
        
        # --- Reciprocal Rank Fusion ---
        k = 60  # RRF constant
        scores = {}
        
        for rank, doc_idx in enumerate(bm25_ranked):
            scores[doc_idx] = scores.get(doc_idx, 0) + (1 - alpha) / (k + rank + 1)
        
        for rank, doc_idx in enumerate(vector_ranked):
            scores[doc_idx] = scores.get(doc_idx, 0) + alpha / (k + rank + 1)
        
        # Sort by combined score
        ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:top_k]
        
        return [
            {
                "content": self.contents[idx],
                "metadata": self.documents[idx]["metadata"],
                "score": score,
                "rank": i + 1
            }
            for i, (idx, score) in enumerate(ranked)
        ]

# Usage
searcher = HybridSearcher(documents)
results = searcher.search("authentication error 403", alpha=0.6)

Cross-Encoder Reranking

Bi-encoders (embedding models) are fast but approximate. Cross-encoders see query+document together for much more accurate relevance scoring.

"""Reranking with Cross-Encoders"""
from sentence_transformers import CrossEncoder

# Load a cross-encoder reranker
reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")

def retrieve_and_rerank(
    query: str, 
    initial_results: list[dict],  # From hybrid search
    top_k: int = 5
) -> list[dict]:
    """
    Two-stage retrieval:
    1. Fast retrieval (bi-encoder/BM25) → top 20-50 candidates
    2. Accurate reranking (cross-encoder) → top 5 final results
    """
    # Prepare pairs for cross-encoder
    pairs = [(query, r["content"]) for r in initial_results]
    
    # Score all pairs (cross-encoder sees query + doc together)
    scores = reranker.predict(pairs)
    
    # Attach scores and sort
    for result, score in zip(initial_results, scores):
        result["rerank_score"] = float(score)
    
    reranked = sorted(
        initial_results, 
        key=lambda x: x["rerank_score"], 
        reverse=True
    )
    
    return reranked[:top_k]

# --- Full pipeline ---
# Stage 1: Fast retrieval (get 20 candidates)
candidates = searcher.search(query="How to fix memory leaks?", top_k=20)

# Stage 2: Accurate reranking (narrow to top 5)
final_results = retrieve_and_rerank(
    query="How to fix memory leaks?",
    initial_results=candidates,
    top_k=5
)

for r in final_results:
    print(f"[{r['rerank_score']:.3f}] {r['content'][:80]}...")
⚡ Performance: Cross-encoders are ~100x slower than bi-encoders. That's why we use two stages: bi-encoder to get 20–50 candidates fast, then cross-encoder to pick the best 5. Never run a cross-encoder over your entire corpus.

Query Expansion & HyDE

HyDE: Hypothetical Document Embeddings

Instead of embedding the question, ask an LLM to generate a hypothetical answer, then embed that. The hypothetical answer is closer in embedding space to real answers than the question is.

"""Query Expansion and HyDE"""
from openai import OpenAI

client = OpenAI()

def expand_query(query: str, n_expansions: int = 3) -> list[str]:
    """Generate multiple query variations to improve recall."""
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": (
                f"Generate {n_expansions} different ways to ask this question. "
                f"Include synonyms and related terms.\n\n"
                f"Original: {query}\n\n"
                f"Variations (one per line):"
            )
        }],
        temperature=0.7
    )
    
    variations = response.choices[0].message.content.strip().split("\n")
    return [query] + [v.strip("- 123.") for v in variations if v.strip()]


def hyde_search(query: str, collection, top_k: int = 5):
    """HyDE: Generate hypothetical answer, embed that instead."""
    # Generate hypothetical answer
    response = client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[{
            "role": "user",
            "content": (
                f"Write a short, factual paragraph that would answer "
                f"this question:\n\n{query}\n\n"
                f"Write as if you're a technical documentation page."
            )
        }],
        temperature=0.3
    )
    
    hypothetical_answer = response.choices[0].message.content
    
    # Search using the hypothetical answer (closer to real docs)
    results = collection.query(
        query_texts=[hypothetical_answer],
        n_results=top_k
    )
    
    return results, hypothetical_answer


# --- Multi-query retrieval ---
def multi_query_retrieve(query: str, collection, top_k: int = 5):
    """Retrieve using multiple query variations, deduplicate."""
    queries = expand_query(query)
    
    all_results = {}  # doc_id → (content, best_score)
    
    for q in queries:
        results = collection.query(query_texts=[q], n_results=top_k)
        for doc_id, doc, dist in zip(
            results["ids"][0], 
            results["documents"][0],
            results["distances"][0]
        ):
            score = 1 - dist
            if doc_id not in all_results or score > all_results[doc_id][1]:
                all_results[doc_id] = (doc, score)
    
    # Sort by best score, return top_k
    ranked = sorted(all_results.items(), key=lambda x: x[1][1], reverse=True)
    return [{"id": k, "content": v[0], "score": v[1]} for k, v in ranked[:top_k]]

Contextual Compression

Retrieved chunks often contain irrelevant padding. Compression extracts only the relevant parts:

def compress_context(query: str, chunks: list[str]) -> list[str]:
    """Extract only query-relevant portions from retrieved chunks."""
    compressed = []
    
    for chunk in chunks:
        response = client.chat.completions.create(
            model="gpt-4o-mini",
            messages=[{
                "role": "user",
                "content": (
                    f"Extract ONLY the parts of this text that are relevant "
                    f"to answering: '{query}'\n\n"
                    f"Text: {chunk}\n\n"
                    f"Relevant extract (or 'IRRELEVANT' if nothing matches):"
                )
            }],
            temperature=0,
            max_tokens=200
        )
        
        extract = response.choices[0].message.content.strip()
        if extract != "IRRELEVANT":
            compressed.append(extract)
    
    return compressed

# Reduces 5 chunks of 500 tokens each → maybe 3 chunks of 100 tokens
# More focused context = better generation quality

Parent-Child Chunk Strategy

The Idea

Embed small chunks (high precision retrieval) but return their parent chunks (more context for generation). Best of both worlds: precise matching + rich context.

"""Parent-Child Chunking Strategy"""
from langchain.text_splitter import RecursiveCharacterTextSplitter

# Create parent chunks (large, for context)
parent_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1500, chunk_overlap=200
)

# Create child chunks (small, for retrieval)
child_splitter = RecursiveCharacterTextSplitter(
    chunk_size=300, chunk_overlap=50
)

def build_parent_child_index(documents: list[str], collection):
    """Index child chunks but store parent references."""
    all_children = []
    parent_store = {}  # parent_id → parent_text
    
    for doc_idx, doc in enumerate(documents):
        # Split into parents
        parents = parent_splitter.split_text(doc)
        
        for p_idx, parent in enumerate(parents):
            parent_id = f"doc{doc_idx}_parent{p_idx}"
            parent_store[parent_id] = parent
            
            # Split parent into children
            children = child_splitter.split_text(parent)
            
            for c_idx, child in enumerate(children):
                all_children.append({
                    "id": f"{parent_id}_child{c_idx}",
                    "content": child,
                    "parent_id": parent_id
                })
    
    # Index children (what we search against)
    collection.add(
        ids=[c["id"] for c in all_children],
        documents=[c["content"] for c in all_children],
        metadatas=[{"parent_id": c["parent_id"]} for c in all_children]
    )
    
    return parent_store

def search_with_parents(query: str, collection, parent_store, top_k: int = 3):
    """Search children, return parents (deduped)."""
    results = collection.query(query_texts=[query], n_results=top_k * 2)
    
    # Get unique parents
    seen_parents = set()
    parent_chunks = []
    
    for meta in results["metadatas"][0]:
        parent_id = meta["parent_id"]
        if parent_id not in seen_parents:
            seen_parents.add(parent_id)
            parent_chunks.append(parent_store[parent_id])
        
        if len(parent_chunks) >= top_k:
            break
    
    return parent_chunks

Naive RAG vs Advanced RAG: Performance

Technique Precision Gain Recall Gain Latency Cost Complexity
Hybrid Search +10-15% +20-30% +5ms Low
Reranking +15-25% +5% +50-200ms Medium
HyDE +10-20% +15% +500ms (LLM call) Low
Query Expansion +5% +25-35% +300ms Low
Parent-Child +10% +10% +0ms Medium
All Combined +30-40% +40-50% +700ms High

🛠️ Mini-Project: Advanced RAG with Reranking

Upgrade your basic RAG pipeline with hybrid search and cross-encoder reranking.

Steps:

  1. Install dependencies: pip install rank-bm25 sentence-transformers
  2. Implement HybridSearcher with BM25 + vector search + RRF fusion
  3. Add cross-encoder reranking (ms-marco-MiniLM-L-6-v2)
  4. Implement HyDE as an optional query transformation
  5. Create 20 test queries with ground-truth relevant documents
  6. Compare: naive RAG vs hybrid vs hybrid+rerank (measure Precision@5)
  7. Bonus: Add parent-child chunking and measure context quality
"""Advanced RAG Pipeline - Integration"""

class AdvancedRAG:
    def __init__(self):
        self.hybrid_searcher = HybridSearcher(documents)
        self.reranker = CrossEncoder("cross-encoder/ms-marco-MiniLM-L-6-v2")
        self.client = OpenAI()
    
    def query(self, question: str, use_hyde: bool = True) -> dict:
        # Optional: HyDE transformation
        search_query = question
        if use_hyde:
            _, hypothetical = hyde_search(question, self.collection)
            search_query = hypothetical
        
        # Stage 1: Hybrid retrieval (fast, broad)
        candidates = self.hybrid_searcher.search(search_query, top_k=20)
        
        # Stage 2: Rerank (accurate, narrow)
        pairs = [(question, c["content"]) for c in candidates]
        scores = self.reranker.predict(pairs)
        for c, s in zip(candidates, scores):
            c["rerank_score"] = float(s)
        
        top_results = sorted(
            candidates, key=lambda x: x["rerank_score"], reverse=True
        )[:5]
        
        # Stage 3: Generate with top results
        return self._generate(question, top_results)

📋 Key Takeaways

  • Hybrid search (BM25 + vectors) handles both semantic and keyword queries — always use it in production
  • Cross-encoder reranking is the single biggest quality improvement you can add (+15-25% precision)
  • HyDE bridges the question↔answer semantic gap by generating a hypothetical answer first
  • Parent-child chunks give you precise retrieval + rich context for generation
  • Stack techniques incrementally — measure improvement at each stage before adding complexity