@0xMorlex: https://x.com/0xMorlex/status/2086754640968732729
Summary
A technical post explaining why RAG fails for multi-hop queries and presenting a 9-step roadmap for building context graphs, where entities are nodes and relationships are edges, with a minimal 150-line engine.
View Cached Full Text
Cached at: 08/11/26, 03:40 AM
From RAG to Context Graphs: the 9-step roadmap
Ask a chunk retriever - “what breaks if Redis goes down” and it hands you the one sentence in your corpus that contains the word Redis.
- That sentence does not answer the question. The answer was spread across four documents that never mention each other, and similarity search has no way to walk from one to the next.
That is the ceiling of RAG, and it is not a tuning problem. Embeddings retrieve text that mentions the thing you asked about. They cannot retrieve the thing itself, or the chain of relationships that actually holds the answer. For a single-fact lookup that is fine. For anything multi-hop, it is structurally the wrong tool.
- The fix is to stop retrieving passages and start retrieving a graph: entities as nodes, relationships as edges, answers as paths. This is the 9-step roadmap from chunks to a context graph you can traverse and cite. No framework. The engine below is about 150 lines.
Tier I - Extract
01. Start from the question RAG can’t answer
Do not begin by building a graph. Begin by writing down the query that your current retriever fails, because that query defines the entities and relations the graph actually needs.
The failing queries have a shape: they are multi-hop. “What breaks if Redis goes down” is really “what depends on the thing that depends on the thing that Redis backs.” Each hop is a relationship, and the answer is a path, not a passage. If your hardest queries are single-hop lookups, you do not need a graph. If they are chains, you do.
02. Pull entities and relations, not embeddings
RAG embeds chunks. A context graph reads each chunk and extracts triples: subject, relation, object. In production the extractor is an LLM call. Here it is a handful of deterministic patterns, so the output is reproducible.
pythonPATTERNS = [ (re.compile(r“(.+?) depends on (.+)“, re.I), “depends_on”), (re.compile(r“(.+?) is backed by (.+)“, re.I), “backed_by”), (re.compile(r“(.+?) runs on (.+)“, re.I), “runs_on”), (re.compile(r“(.+?) is owned by (.+)“, re.I), “owned_by”), ]
def extract(chunk_id: str, text: str) -> list[Triple]: out = [] for sentence in re.split(r“[.\n]“, text): s = sentence.strip() if not s: continue for rx, rel in PATTERNS: m = rx.match(s) if not m: continue subj = m.group(1) for obj in re.split(r”\band\b“, m.group(2)): # “X and Y” -> two edges obj = obj.strip() if obj: out.append(Triple(subj.strip(), rel, obj, chunk_id)) break return out
The “chunk_id” on every triple is not an afterthought. It is what makes the eventual answer citable, and it is the thing RAG throws away the moment it concatenates chunks into a prompt.
03. Canonicalize, or the graph fractures
First gate.
- “The Auth Service,” “auth service,” and “AuthService” are one entity.
If the graph treats them as three, every edge you draw lands on a different node and the paths never connect. Canonicalization is the unglamorous step that decides whether the graph is a graph or three disconnected fragments.
pythonALIASES = { “authservice”: “auth service”, “user db”: “user database”, “postgres”: “postgresql”, }
def canon(name: str) -> str: n = name.strip().lower() n = re.sub(r“^the\s+“, “”, n) n = re.sub(r“\s+“, “ “, n) n = re.sub(r”[.]$“, “”, n) return ALIASES.get(n, n)
Lowercase, strip the article, collapse whitespace, apply an alias map. This is the entity-resolution problem in miniature, and getting it wrong is the single most common reason a homegrown GraphRAG returns nothing useful: the nodes are all there, they just never touch.
Tier II - Connect
04. Build the edge list: subject, relation, object
The graph itself is two adjacency maps, forward and reverse, because you will traverse in both directions: forward to answer **“what does X depend on” **reverse to answer “what depends on X.”
python@dataclass class Edge: rel: str dst: str weight: int = 1 sources: set = field(default_factory=set)
def add(self, t: Triple) -> None: s, o = canon(t.subj), canon(t.obj) self.nodes.add(s); self.nodes.add(o) self._merge(self.fwd, s, t.rel, o, t.source) self._merge(self.rev, o, t.rel, s, t.source)
Storing both directions doubles the memory and removes an entire class of “I can only search one way” bugs. For a knowledge graph that is always the right trade.
05. Weight edges by how many sources agree
If two different chunks both say the auth service depends on the token cache, that edge is stronger than one asserted by a single source. This is corroboration, the same idea that turned a swarm into a verifier, applied to a graph.
python@staticmethod def _merge(side, a, rel, b, src) -> None: for e in side[a]: if e.rel == rel and e.dst == b: # same edge seen again = corroboration e.weight += 1 e.sources.add(src) return side[a].append(Edge(rel, b, 1, {src}))
On the real run, the auth-service-to-token-cache edge came from two chunks and carries weight 2. When two paths could answer a question, the one built from corroborated edges is the one to trust, and the weight is how you rank them in step 8.
06. Attach provenance to every edge
Second gate.
Every edge carries the set of chunks it came from. This is the property that makes a context graph answerable rather than merely suggestive: when the graph returns a path, it returns the receipt for each hop.
pythondef cite_path(g: ContextGraph, path: list[str]) -> list[str]: lines = [] for a, b in zip(path, path[1:]): for e in g.fwd.get(a, []): if e.dst == b: src = “,”.join(sorted(e.sources)) lines.append(f“{a} -{e.rel}-> {b} [{src}] x{e.weight}“) break return lines
The output looks like this, and it is the whole point:
login flow -depends_on-> auth service [c1] x1 auth service -depends_on-> token cache [c2,c8] x2 token cache -backed_by-> redis [c3] x1
Three hops, three citations, one of them corroborated. A graph that cannot cite its edges is a pile of assertions with better packaging.
Tier III - Traverse
07. Retrieve by k-hop neighborhood
Now retrieval stops being similarity and becomes a walk. The context for a question is not the top-k most similar chunks. It is the subgraph within k hops of the entities the question names.
pythondef neighborhood(g: ContextGraph, start: str, hops: int) -> set: seen, frontier = {canon(start)}, {canon(start)} for _ in range(hops): nxt = set() for n in frontier: for e in g.fwd.get(n, []): nxt.add(e.dst) frontier = nxt - seen seen |= nxt return seen
08. Rank paths, not passages
Third gate.
The multi-hop question is answered by reverse reachability: if Redis breaks, walk the reverse edges to find everything upstream that breaks with it.
pythondef impacted_by(g: ContextGraph, failed: str) -> list[tuple[str, list[str]]]: start = canon(failed) out, seen = [], {start} q = deque([(start, [start])]) while q: node, path = q.popleft() for e in g.rev.get(node, []): if e.dst in seen: continue seen.add(e.dst) newpath = path + [e.dst] out.append((e.dst, newpath)) q.append((e.dst, newpath)) return out
The result is not a ranked list of documents. It is a ranked list of paths, each one a causal chain you can read and check.
token cache via redis <- token cache auth service via redis <- token cache <- auth service login flow via redis <- token cache <- auth service <- login flow survives: billing, platform team, postgresql, user database
Three things break, four survive, and the graph can name both sets. A chunk retriever cannot produce the “survives” list at all, because nothing in the corpus states it. It is only visible in the structure.
09. Assemble the subgraph, with citations
The final step hands the model a subgraph, not a wall of text. The nodes on the answer path, the edges that connect them, and the chunk id behind each edge become the context window, and the model’s job shrinks from “find the answer in this pile” to “read this chain and phrase it.”
That shrinking is the real win. You have moved the hard part, connecting scattered facts, out of the prompt and into a structure you built and can inspect. The model stops guessing at connections and starts reading them.
What the graph actually answers
I built the graph from eight one-fact chunks, ran the chunk retriever and the traversal on the same question, and printed both.
Read the first block against the last. The chunk retriever returns the sentence with Redis in it, and that sentence does not contain the answer. The graph returns the chain from the login flow down to Redis, with a citation on every hop and a weight of 2 on the edge two sources agreed on. Same corpus. One retriever found the word, the other found the answer.
Conclusion: RAG retrieves what mentions the answer. A graph retrieves the answer.
Nine steps, three tiers, three gates.
Tier 1 turns prose into entities and relations and makes them line up. Tier 2 connects them into a weighted, cited graph. Tier 3 retrieves by walking that graph instead of by measuring similarity, and returns paths you can read and verify.
Notice where the gates sit. Canonicalization ends Tier 1, provenance ends Tier 2, the ranked path ends Tier 3. Same shape as the loop, the graph, and the swarm, and for the same reason. A context graph with no citation is RAG with extra steps.
- For two years the retrieval pitch has been better embeddings: bigger models, longer context, smarter chunking. That work has a ceiling, and the ceiling is not embedding quality. It is that a chunk can only tell you what it says, and a multi-hop answer is never in any single chunk. No amount of similarity closes a gap that is structural.
But the honest version is not that everyone should build a graph. Most retrieval is single-hop, and for single-hop lookups RAG is simpler, cheaper, and correct. The test is step 1: are your hardest questions lookups or chains? If they are lookups, embed your chunks and move on. If they are chains, you are already asking the graph a question. You just have not built it yet.
- If you pass the test, build small. One relation type. One canonical entity table. One traversal. Get “what depends on X” returning a cited path before you add a second edge type. Order matters: extraction feeds connection, connection feeds traversal, and a traversal over un-canonicalized nodes returns nothing at all.
The point was never better search over text. It was retrieving structure instead of prose. Build the graph. Return the path.
Similar Articles
@akshay_pachaar: RAG vs. Graph RAG vs. Agentic RAG, clearly explained! Standard RAG embeds documents into vectors and retrieves the most…
A clear explanation of Standard RAG, Graph RAG, and Agentic RAG, covering their differences, use cases, and how they handle single-hop vs. multi-hop queries.
@0xMorlex: https://x.com/0xMorlex/status/2080598414576812378
A 13-step roadmap for transitioning from loop-based to graph-based agent design, emphasizing immutable state, pure nodes, and checkpoints for resilience.
@qdrant_engine: Looking to Build GraphRAG? Start with This Practical Guide from our star, Pavan In this practical guide, Pavan demonstr…
This article provides a practical guide to building a GraphRAG system using LangExtract, Neo4j, Qdrant, and Ollama, combining entity extraction, knowledge graphs, and vector search for context-aware retrieval.
We open-sourced a graph-free multi-hop RAG framework — matches Graph-RAG accuracy without the rebuild cost (Apache-2.0)
MOTHRAG is a graph-free multi-hop RAG framework that matches the accuracy of graph-based systems like GraphRAG and HippoRAG on benchmarks, while avoiding costly graph rebuilds by using a dense index and query-time orchestration.
RAGU: A Multi-Step GraphRAG Engine with a Compact Domain-Adapted LLM
RAGU is an open-source multi-step GraphRAG engine that uses a compact 7B fine-tuned LLM (Meno-Lite-0.1) to achieve high-quality knowledge graph construction at a fraction of the cost of larger models, outperforming larger systems on benchmarks.