Advanced RAG
Move beyond naive retrieve-and-generate with hybrid search, reranking, query expansion, HyDE, and multi-step retrieval for production-grade accuracy.
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]}...")
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:
- Install dependencies:
pip install rank-bm25 sentence-transformers - Implement HybridSearcher with BM25 + vector search + RRF fusion
- Add cross-encoder reranking (ms-marco-MiniLM-L-6-v2)
- Implement HyDE as an optional query transformation
- Create 20 test queries with ground-truth relevant documents
- Compare: naive RAG vs hybrid vs hybrid+rerank (measure Precision@5)
- 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