Advanced RAG: Self-RAG, CRAG, HyDE, GraphRAG, multi-query
When and why to reach for query rewriting, self-critique, graph-based retrieval, and multi-index fanout.
Why this matters
Naive RAG fails in well-known ways: irrelevant retrieval, hallucination on out-of-corpus questions, and inability to multi-hop reason. The 5 patterns in this lesson are how senior engineers fix those failures. They also distinguish your portfolio from the 95% of candidates who only built "embed-search-answer" pipelines.
Learning objectives
- Implement HyDE to improve queries before search.
- Implement multi-query expansion.
- Implement Self-RAG (decide whether to retrieve, score chunks, regenerate if poor).
- Implement Corrective RAG (CRAG) with web-search fallback.
- Build a tiny GraphRAG for multi-hop questions.
1. HyDE β hypothetical document embeddings
User queries are short. Documents are long. Their embeddings live in different "regions" of the space, hurting recall.
Trick: ask the LLM to write a hypothetical answer to the question. Embed the hypothetical answer (which looks like a document!) and use that as the query vector.
HYDE = """Write a 2-3 sentence factual answer to the question, even if you have to imagine it. This is for a search task."""
def hyde_search(question: str, k: int = 10):
hypo = client.responses.create(
model="gpt-4.1-mini",
instructions=HYDE,
input=question,
).output_text
q_vec = embed(hypo)
return vector_store.query(query_embeddings=[q_vec], n_results=k)Cost: one extra cheap LLM call per query. Lift: typically 5-15% recall on factual QA. Free win.
When not to use HyDE: queries that are exact-match by nature (IDs, codes, names). Combine with BM25 to keep both.
2. Multi-query expansion
A user asks one question. You quietly turn it into 3 reformulations and search each.
EXPAND = """Rewrite the user's question into 3 alternative search queries that might find the same answer. Return as JSON array."""
def multi_query(q: str) -> list[str]:
r = client.responses.parse(
model="gpt-4.1-mini",
instructions=EXPAND,
input=q,
text_format=list[str],
)
return [q] + r.output_parsed[:3]
def search_all(q: str, k: int = 10):
queries = multi_query(q)
all_hits: list[list[str]] = [vector_store.query(query_texts=[qq], n_results=k)["ids"][0] for qq in queries]
return rrf(all_hits, k=60)LangChain has MultiQueryRetriever if you do not want to write your own.
3. Self-RAG β let the agent decide
A naive retriever runs unconditionally on every query. Self-RAG (Asai et al., 2023) lets the model:
- Decide if retrieval is even needed (
Retrieve?). - Score retrieved chunks for relevance (
IsRel?). - After answering, judge if the answer is supported (
IsSup?) and useful (IsUse?). - If poor, regenerate with different chunks or no retrieval.
You implement this as a LangGraph state machine (you will learn LangGraph in Phase 3, but the concept is here):
[user_query]
β
[decide_retrieve] ββnoβββ [direct_answer] β END
β yes
β
[retrieve_top_k]
β
[score_chunks] ββlow avg scoreβββ [reformulate_query] β loop
β ok
β
[generate_answer]
β
[judge_supported & useful] ββnoβββ [regenerate] β END
β yes
β
ENDPseudocode:
def self_rag(question: str) -> str:
if not needs_retrieval(question):
return direct_answer(question)
chunks = retrieve(question)
scores = [score_relevance(c, question) for c in chunks]
if max(scores) < 0.5:
question = reformulate(question)
chunks = retrieve(question)
answer = generate(question, chunks)
if not judge_supported(answer, chunks):
answer = regenerate(question, chunks)
return answerneeds_retrieval, score_relevance, judge_supported are all 1-paragraph LLM-as-judge calls. Fast, cheap, much more reliable than naive RAG.
4. Corrective RAG (CRAG) β fall back to web search
What if the local KB is poor? CRAG adds a fallback: if retrieved confidence is below a threshold, do a web search instead.
def crag(question: str) -> str:
chunks = retrieve(question)
score = avg_score(chunks)
if score >= 0.7:
return generate(question, chunks)
elif score >= 0.4:
# ambiguous: combine local + web
web = web_search(question)
return generate(question, chunks + web)
else:
# local KB cannot help
web = web_search(question)
return generate(question, web)Web search options:
- Tavily β built for LLMs, structured snippets. Free tier 1k/month.
- Brave Search API β generous free tier, good quality.
- SerpAPI / Serper β Google results.
- Exa (formerly Metaphor) β semantic web search.
- Perplexity API β answers + sources.
# uv add tavily-python
from tavily import TavilyClient
tv = TavilyClient(api_key=os.environ["TAVILY_API_KEY"])
res = tv.search("recent agentic AI papers", max_results=5, search_depth="advanced")CRAG is essential for any agent that promises "current information."
5. GraphRAG β for multi-hop questions
Vector RAG fails on questions like "Which engineers work for the company that acquired Acme last year?" because the answer is not in any single chunk β it requires traversing relationships.
GraphRAG (Microsoft, 2024) builds a knowledge graph of entities and relations from your documents, then answers via graph traversal + community summaries.
The full Microsoft graphrag library is heavy. Here is a minimal version with NetworkX that demonstrates the idea:
# uv add networkx
import networkx as nx
import json
EXTRACT = """Extract entities and relations from the text as JSON:
{"entities":[{"id":"E1","type":"Person","name":"..."}],
"relations":[{"src":"E1","tgt":"E2","label":"works_at"}]}"""
class Graph:
def __init__(self): self.g = nx.MultiDiGraph()
def ingest(self, text: str):
out = client.responses.parse(
model="gpt-4.1-mini",
instructions=EXTRACT,
input=text,
text_format=dict,
).output_parsed
for e in out["entities"]:
self.g.add_node(e["id"], **e)
for r in out["relations"]:
self.g.add_edge(r["src"], r["tgt"], label=r["label"])
def hop(self, start: str, max_hops: int = 2):
return nx.ego_graph(self.g, start, radius=max_hops)Then to answer:
- Identify entities mentioned in the question.
- Pull a small ego subgraph from each.
- Serialise the subgraph as text/JSON.
- Pass to LLM as context.
For real production, use:
- Microsoft GraphRAG (full Python, includes community summaries).
- LightRAG (HKU, 2024 β simpler, faster).
- Neo4j + LLM Graph Builder (managed graph DB + ingest).
- Memgraph with Cypher queries.
GraphRAG is heavy compute; reserve it for genuinely multi-hop corpora (legal, biomed, knowledge bases of organisations).
6. Combining advanced patterns β a 2026 reference architecture
[user query]
β
[multi_query expand] ββ 3 queries
β
[hybrid retrieve] ββ BM25 + dense for each β RRF
β
[contextual chunks already had context prepended]
β
[rerank top-50 with Cohere rerank-3]
β
[Self-RAG relevance score] ββ if low β CRAG β web search fallback
β
[answer with citations]
β
[judge supported] ββ if no β regenerate
β
ENDImplement once. You can defend it for an hour in any interview.
Hands-on lab (5 hours)
Take your hybrid RAG from Lesson 2.3 and upgrade:
- Add HyDE as a flag on the retriever.
- Add multi-query expansion (3 reformulations).
- Wrap retrieval in a Self-RAG decision: should we retrieve, are chunks relevant, is answer supported.
- Add a CRAG path that calls Tavily when retrieval scores are low.
- Bonus: build a tiny GraphRAG index over a 50-page corpus and answer one multi-hop question.
Eval requirement: build a 30-question test set including:
- 10 standard factual (vector wins).
- 5 exact-ID (BM25 wins).
- 5 multi-hop (GraphRAG wins).
- 5 out-of-corpus (CRAG β web wins).
- 5 unanswerable (model should refuse).
Compare your advanced pipeline against the naive pipeline on this set. Print a markdown table. This is portfolio gold.
Common pitfalls
- HyDE on ID queries. It hallucinates and ranks worse. Detect numeric/ID patterns and skip HyDE.
- Multi-query without dedup. RRF dedup happens automatically, but watch the 3x token cost.
- Self-RAG infinite loops. Cap regenerations at 2. Track in
state["regen_count"]. - CRAG on private corpora. You leak the user's private question to a web search. Add a check.
- GraphRAG bloat. Extract only top-N entities per chunk; do not extract every noun.
Self-check
- Why does HyDE help on most queries but hurt on ID lookups?
- Where does Self-RAG decide to skip retrieval entirely?
- What is the failure mode CRAG specifically fixes?
- When does GraphRAG beat vector RAG and when does it lose?
- How would you measure that each of these techniques is contributing to the lift?
References
- HyDE paper (https://arxiv.org/abs/2212.10496)
- Self-RAG (https://arxiv.org/abs/2310.11511)
- CRAG (https://arxiv.org/abs/2401.15884)
- Microsoft GraphRAG (https://github.com/microsoft/graphrag)
- LightRAG (https://github.com/HKUDS/LightRAG)
- LangChain MultiQueryRetriever (https://python.langchain.com/docs/how_to/MultiQueryRetriever/)
- Tavily API (https://tavily.com/)
Sign in to save your progress and earn badges.