Advanced RAG Techniques
Basic RAG works well for simple cases, but production systems need advanced techniques to handle complex queries, improve accuracy, and reduce hallucinations.
The Limitations of Basic RAG
Basic RAG can fail when:
- The query doesn't match document terminology
- Relevant information is spread across multiple chunks
- The user asks multi-part or complex questions
- Important context is lost during chunking
Advanced techniques address each of these issues.
Technique 1: Hybrid Search
Combine semantic search (embeddings) with keyword search (BM25) for better results:
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_chroma import Chroma
# Semantic retriever (embedding-based)
semantic_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})
# Keyword retriever (BM25)
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5
# Combine both
ensemble_retriever = EnsembleRetriever(
retrievers=[semantic_retriever, bm25_retriever],
weights=[0.6, 0.4] # Weight semantic higher
)
results = ensemble_retriever.invoke("error code E-4021")When Hybrid Search Helps
| Query Type | Semantic Only | Hybrid |
|---|---|---|
| "How to reset password?" | Good | Good |
| "Error code E-4021" | Poor (no semantic meaning) | Good (keyword match) |
| "API rate limit" | Good | Better |
| "John Smith's account" | Poor | Good (exact name match) |
Technique 2: Reranking
After initial retrieval, use a more powerful model to rerank results:
from openai import OpenAI
client = OpenAI()
def rerank_results(query, documents, top_k=3):
"""Use an LLM to rerank retrieved documents."""
doc_list = "\n\n".join(
f"[Document {i+1}]: {doc}" for i, doc in enumerate(documents)
)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"""Given this query: "{query}"
Rank these documents by relevance (most relevant first).
Return only the document numbers in order, separated by commas.
{doc_list}
Ranking (most relevant first):"""
}],
temperature=0,
max_tokens=50
)
# Parse the ranking
ranking = response.choices[0].message.content.strip()
indices = [int(x.strip()) - 1 for x in ranking.split(",")]
return [documents[i] for i in indices[:top_k]]Using Cohere Reranker (Purpose-Built)
# pip install cohere
import cohere
co = cohere.Client("your-cohere-key")
def cohere_rerank(query, documents, top_k=3):
results = co.rerank(
query=query,
documents=documents,
model="rerank-english-v3.0",
top_n=top_k
)
return [documents[r.index] for r in results.results]Technique 3: Query Transformation
Transform the user's query before retrieval to improve matches:
Query Expansion
def expand_query(query):
"""Generate multiple search queries from a single question."""
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"""Generate 3 alternative search queries for this question.
Each should capture a different aspect or use different terms.
Original: "{query}"
Return only the queries, one per line."""
}],
temperature=0.7,
max_tokens=200
)
queries = response.choices[0].message.content.strip().split("\n")
return [query] + queries # Include original
# Example:
# "How do I cancel my subscription?"
# → ["How do I cancel my subscription?",
# "Steps to unsubscribe from service",
# "Cancel recurring payment plan",
# "End membership account"]HyDE (Hypothetical Document Embeddings)
Generate a hypothetical answer, then search for real documents similar to it:
def hyde_search(query, vectorstore):
# Step 1: Generate a hypothetical answer
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"Write a short paragraph that would answer: {query}"
}],
temperature=0.5,
max_tokens=200
)
hypothetical_answer = response.choices[0].message.content
# Step 2: Search using the hypothetical answer as the query
results = vectorstore.similarity_search(hypothetical_answer, k=5)
return resultsTechnique 4: Parent Document Retrieval
Store small chunks for precise search, but return larger parent chunks for context:
from langchain.text_splitter import RecursiveCharacterTextSplitter
# Small chunks for search (precise matching)
child_splitter = RecursiveCharacterTextSplitter(
chunk_size=200,
chunk_overlap=50
)
# Large chunks for context (full paragraphs)
parent_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000,
chunk_overlap=200
)
def parent_document_search(query, documents):
# Create parent and child chunks
parents = parent_splitter.split_documents(documents)
parent_map = {}
for parent in parents:
children = child_splitter.split_text(parent.page_content)
for child in children:
parent_map[child] = parent.page_content
# Search against child chunks
matched_children = search_similar(query, list(parent_map.keys()), top_k=3)
# Return parent chunks
return [parent_map[child] for child in matched_children]Technique 5: Contextual Compression
Remove irrelevant parts from retrieved documents before sending to the LLM:
def compress_context(query, documents):
"""Extract only relevant parts from each document."""
compressed = []
for doc in documents:
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"""Extract only the parts of this document that are
relevant to answering: "{query}"
Document:
{doc}
Relevant excerpt (or "NOT RELEVANT" if nothing matches):"""
}],
temperature=0,
max_tokens=300
)
excerpt = response.choices[0].message.content
if excerpt.strip() != "NOT RELEVANT":
compressed.append(excerpt)
return compressedTechnique 6: Self-RAG (Self-Reflective RAG)
The model evaluates whether retrieved documents are actually helpful:
def self_rag(query, retriever, llm_client):
# Step 1: Retrieve documents
docs = retriever.invoke(query)
# Step 2: Check if retrieval is needed
response = llm_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": f"""Can this question be answered from these documents?
Question: {query}
Documents: {docs}
Answer YES or NO, then explain briefly."""
}],
temperature=0
)
if "NO" in response.choices[0].message.content[:10]:
# Retrieval didn't help — answer without context or say "I don't know"
return "I don't have enough information to answer that question."
# Step 3: Generate answer with quality check
answer = generate_answer(query, docs)
return answerAdvanced RAG Architecture
User Query
│
├─→ Query Transformation (expand, rewrite)
│ │
│ ├─→ Semantic Search ──┐
│ └─→ Keyword Search ───┤
│ │
│ ┌────────▼────────┐
│ │ Reranker │
│ └────────┬────────┘
│ │
│ ┌────────▼────────┐
│ │ Contextual │
│ │ Compression │
│ └────────┬────────┘
│ │
└─────────────────────→ LLM Generation
│
┌───────▼───────┐
│ Self-Check / │
│ Validation │
└───────┬───────┘
│
ResponseChoosing Techniques
| Problem | Solution |
|---|---|
| Exact terms not found | Hybrid search (BM25 + semantic) |
| Wrong docs ranked highest | Reranking |
| Query too vague | Query expansion / transformation |
| Too much irrelevant context | Contextual compression |
| Missing broader context | Parent document retrieval |
| Answers from bad sources | Self-RAG validation |
Performance Benchmarks
| Technique | Accuracy Improvement | Latency Impact |
|---|---|---|
| Hybrid search | +10-15% | Minimal |
| Reranking | +15-25% | +200-500ms |
| Query expansion | +10-20% | +500ms (extra embedding calls) |
| HyDE | +5-15% | +300ms (extra LLM call) |
| Contextual compression | +5-10% | +200ms per doc |
Key Takeaways
- Start with basic RAG, then add techniques based on failure analysis
- Hybrid search is the easiest win — always combine semantic + keyword
- Reranking gives the biggest quality boost for moderate latency cost
- Query transformation helps when users write ambiguous queries
- Measure everything — A/B test each technique against your test set
With RAG mastered, the next module covers fine-tuning — customizing models for your specific domain and style.