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.

Level 360 minTrack 3Last verified 2026-10

By the end of this page you can

The full retrieval pipeline

queryrewrite hybrid retrieveBM25 + vector fuse(RRF) re-ranktop 50 → 5 promptwith sources answer +citations

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:

TechniqueWhat it doesWhen to use
Query rewritingLLM turns chatty input into a clean search query, using conversation contextMulti-turn chats where "it", "that one" need resolving
Query expansionAdd synonyms/related terms ("earbuds" → "earphones", "headphones")Vocabulary mismatch between users and docs
HyDELLM writes a hypothetical answer, embed that instead of the questionShort or unusual queries that embed poorly
DecompositionSplit 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:

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

MetricWhat it isolates
Recall@k / hit@kDid the right doc appear in the top k? (retrieval quality)
MRR (mean reciprocal rank)How high did the first relevant doc rank?
Answer correctnessGiven 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.

Try it (75 min)
  1. Take 15 store docs. Build a golden query set: 5 exact-code, 5 semantic, 5 multi-hop.
  2. Measure hit@5 for vector-only vs. hybrid+RRF. How many exact-code queries did vector miss?
  3. Add an LLM re-ranker on the top 20. Does hit@5 improve? At what cost?
  4. Try one multi-hop question. Would a graph help, or is it overkill here?

Key takeaways