L3 · 05 · Advanced RAG & Knowledge Graphs
Basic RAG (embed, retrieve top-k, prompt) works for demos. Production retrieval needs hybrid search, re-ranking, query rewriting and honest evaluation. This page adds each layer, explains when knowledge graphs help, and finishes with agentic retrieval — where the model decides what to look up.
By the end of this page you can
- Combine keyword (BM25) and vector search with Reciprocal Rank Fusion.
- Add a re-ranker stage and explain why it improves precision.
- Rewrite, expand and decompose queries before retrieval.
- Decide when a knowledge graph (GraphRAG) beats plain chunks.
- Measure retrieval quality separately from answer quality.
The full retrieval pipeline
Each stage fixes a failure of the previous one. Add them only when you can show the failure exists (L3·04 evals).
Stage 1: fix the query first
Users type "the thing for earbuds" when they mean "EARBUDS-AIR2 silicone tips". Before retrieving:
| Technique | What it does | When to use |
|---|---|---|
| Query rewriting | LLM turns chatty input into a clean search query, using conversation context | Multi-turn chats where "it", "that one" need resolving |
| Query expansion | Add synonyms/related terms ("earbuds" → "earphones", "headphones") | Vocabulary mismatch between users and docs |
| HyDE | LLM writes a hypothetical answer, embed that instead of the question | Short or unusual queries that embed poorly |
| Decomposition | Split a compound question into sub-questions, retrieve for each | "Compare return policy vs warranty period for earbuds and chargers" |
Stage 2: hybrid retrieval
Vector search understands meaning but misses exact codes ("ORD-482913"). Keyword search (BM25) nails codes but misses meaning. Use both, fuse the rankings with Reciprocal Rank Fusion — no score calibration needed, just rank positions:
import math
import re
from collections import Counter
def tokenize(text: str) -> list[str]:
return re.findall(r"[a-z0-9\-]+", text.lower())
def bm25_scores(query: str, docs: list[str], k1: float = 1.5, b: float = 0.75) -> list[float]:
"""Classic BM25 over the corpus."""
q_terms, doc_terms = tokenize(query), [tokenize(d) for d in docs]
avg_len = sum(len(t) for t in doc_terms) / len(doc_terms)
df = Counter(t for terms in doc_terms for t in set(terms))
n = len(docs)
scores = []
for terms in doc_terms:
tf = Counter(terms)
score = sum(
math.log((n - df[t] + 0.5) / (df[t] + 0.5) + 1)
* (tf[t] * (k1 + 1)) / (tf[t] + k1 * (1 - b + b * len(terms) / avg_len))
for t in q_terms if t in tf)
scores.append(score)
return scores
def ranks(scores: list[float]) -> list[int]:
"""Index order sorted by score descending."""
return sorted(range(len(scores)), key=lambda i: -scores[i])
def rrf(bm25_rank: list[int], vec_rank: list[int], k: int = 60) -> list[int]:
"""Fuse two rankings by rank position, not raw score."""
fused = Counter()
for pos, idx in enumerate(bm25_rank):
fused[idx] += 1 / (k + pos + 1)
for pos, idx in enumerate(vec_rank):
fused[idx] += 1 / (k + pos + 1)
return [i for i, _ in fused.most_common()]
if __name__ == "__main__":
docs = [
"Return policy: unopened items within 30 days, receipt required.",
"Order ORD-482913 is ready for pickup at the Riverside store.",
"Wireless earbuds with noise cancellation and 24h battery.",
"Opened electronics may be returned within 14 days with receipt.",
]
# Query with an exact code -> BM25 wins. Semantic query -> vector wins.
# Here we fake vector ranking to show fusion; real code embeds query+docs.
for query, fake_vec_rank in [
("ORD-482913 status", [1, 3, 0, 2]),
("can I bring back opened earphones", [3, 2, 0, 1]),
]:
fused = rrf(ranks(bm25_scores(query, docs)), fake_vec_rank)
top = docs[fused[0]]
print(f"{query!r} -> doc {fused[0]}: {top[:55]}...")
assert True
Notice the fusion: BM25 ranked the exact-code doc first, the fake vector ranking agreed, and the merged list surfaces the right doc for both query types.
Stage 3: re-rank for precision
Retrieval is recall-oriented: fetch 30–50 candidates cheaply. A re-ranker (a cross-encoder or an LLM) then scores each candidate against the query properly and keeps the top 5. It's slower per item but applied to few items — the classic "cheap recall, expensive precision" split:
- Cross-encoder re-ranker (a small model that reads query+doc together): fast, consistent, run locally.
- LLM re-ranker: prompt "rate relevance 0–10" per doc, or listwise ranking. More expensive, more flexible.
Stage 4: when chunks aren't enough — knowledge graphs
Plain chunk retrieval fails on multi-hop questions: "Which stores selling the earbuds also had a June staffing gap?" needs store → product → staffing relationships, not text similarity.
Knowledge graph / GraphRAG when
- Questions need multi-hop reasoning over relationships
- Entities matter: stores, SKUs, orders, people, dates
- You need "how are X and Y connected" answers
- Global summaries ("what are the main themes across all tickets")
Stick with chunks when
- Questions are single-hop ("what's the return policy")
- Docs are self-contained answers, not a web of facts
- You can't justify the extraction pipeline to build the graph
GraphRAG extracts entities and relationships into a graph at index time (LLM-assisted), then retrieves subgraphs and community summaries, not just chunks. More work to build; only worth it for genuinely relational questions.
Stage 5: agentic retrieval
The endpoint of RAG sophistication is giving the model retrieval as a tool: it decides whether to search, what to search for, reads the result, and searches again if needed (a small agent loop, L3·02). Use it when queries are unpredictable or multi-step. The cost is latency and non-determinism — for fixed FAQ-style traffic, a static pipeline is cheaper and more predictable.
Measure retrieval separately from answering
| Metric | What it isolates |
|---|---|
| Recall@k / hit@k | Did the right doc appear in the top k? (retrieval quality) |
| MRR (mean reciprocal rank) | How high did the first relevant doc rank? |
| Answer correctness | Given retrieval, did the model answer right? (generation quality) |
If recall@5 is 60%, no prompt will save you — fix retrieval. If recall is 95% but answers are wrong, fix generation. Always know which side is failing before you tune.
- Take 15 store docs. Build a golden query set: 5 exact-code, 5 semantic, 5 multi-hop.
- Measure hit@5 for vector-only vs. hybrid+RRF. How many exact-code queries did vector miss?
- Add an LLM re-ranker on the top 20. Does hit@5 improve? At what cost?
- Try one multi-hop question. Would a graph help, or is it overkill here?
Key takeaways
- Production RAG = rewrite query → hybrid retrieve → fuse (RRF) → re-rank → answer with citations.
- Vectors for meaning, BM25 for exact codes, RRF to merge without comparing scores.
- Knowledge graphs earn their cost on multi-hop relational questions, not simple FAQs.
- Measure retrieval (hit@k) and answering separately so you know which to fix.