Knowledge Graphs for AI Agents Β· From RAG Limits to Self-Updating Codebase Brains
Track 1 β€” Foundations

M01 Β· Knowledge Graph Fundamentals

πŸ“ Module 2 of 14 ⏱️ ~45 minutes 🧰 Prerequisites: M00 πŸ“Ά Level: Beginner

Every tool in this course β€” Graphify, CodeGraph, okf-rs, even OKF's humble Markdown links β€” is built from the same seven primitives: nodes, typed edges, traversal, multi-hop paths, communities, centrality, and provenance. In this module you build all seven by hand, in ~40 lines of dependency-free code, over the orderflow repo. When a tool's README later says "Leiden community detection identified 13 subsystems," you'll know exactly what happened.

Learning objectives

By the end of this module you will be able to:

  • Model a codebase as a directed graph of typed edges and explain why "code is a social network, not a book."
  • Contrast deterministic traversal with probabilistic search, including the cost model of each.
  • Explain what communities and god nodes reveal about software architecture β€” and what computing them costs at scale.
  • Compute a blast radius (transitive impact set) with a breadth-first search you wrote yourself.
  • Apply the EXTRACTED / INFERRED / AMBIGUOUS provenance discipline that separates honest graphs from confident guessers.

Nodes, edges, and why the types matter

Before: think of how you read a novel β€” front to back, page by page, because a book's structure IS its reading order. Now think about how you actually navigate a city: nobody reads a city front to back. You use a map of places and the roads between them, and you care what KIND of road each one is β€” footpath, one-way street, highway.

The pain: your IDE β€” and a mapless AI agent β€” treats code like the novel: files and line numbers, read in order. But nobody experiences a codebase that way. You experience it as "this function calls that one," "this class inherits from that one," "this module imports those three." Reading files front-to-back to answer a relationship question is like reading every street sign in a city to find one address.

The mapping: a knowledge graph is the city map for code. Functions, classes, and modules are the places (nodes). Calls, imports, and inheritance are the roads (edges) β€” and each road is labeled with its kind and its direction. One article in this course's corpus put it perfectly: your code isn't a book; it's a social network of narcissistic objects all trying to talk over each other. A graph is just the honest picture of who talks to whom.

A graphA data structure made of nodes (things) and edges (relationships between pairs of things). Nothing more exotic than that. is a set of nodes and a set of edges connecting pairs of nodes. For code, the nodes are program entities: modules, classes, functions, methods β€” and in richer graphs, database tables, API routes, documents, even diagrams.

The edges are what elevate this above a file listing, and two properties do the work. First, edges are directed: verify_token β†’ decode_jwt means verify_token calls decode_jwt, and emphatically not the reverse β€” direction is the difference between "what do I depend on?" and "who depends on me?", two completely different questions. Second, edges are typed: calls, imports, inherits, implements, publishes_to. A typed edge is a fact with a precise meaning; an untyped line between two boxes is just decoration.

Here is what one node and one edge actually look like in the machine-readable form every tool in this course emits β€” this exact shape comes from the course's own extractor running on orderflow:

graph.json (excerpt) Β· one node, one edge
{
  "nodes": [
    { "id": "function:shared.auth.decode_jwt",
      "kind": "function",
      "name": "decode_jwt",
      "file": "shared/auth.py", "line": 16 }
  ],
  "edges": [
    { "source": "function:shared.auth.verify_token",
      "target": "function:shared.auth.decode_jwt",
      "type": "calls",
      "provenance": "EXTRACTED" }
  ]
}

Read that edge aloud and you get a sentence: "verify_token calls decode_jwt β€” and we extracted that fact directly from the source." A graph is a database of such sentences. Everything else in this course is machinery for producing, storing, serving, and trusting them.

Animation Β· GRAPH_BUILD β€” files become nodes, relationships become edges
Reduced motion: showing final state.
⚠️ Common misconceptions

"A knowledge graph needs a graph database." β€” No. Every tool you'll meet stores its graph in ordinary things: a JSON file (Graphify), a SQLite file (CodeGraph), a folder of Markdown files with links (OKF, okf-rs). The graph is the shape of the data, not a product you install.

"More edges = better graph." β€” The opposite, usually. An edge is a claim; false or noisy claims poison every query that touches them. That's why provenance tags (below) exist, and why messy code produces graphs "like a ball of yarn after a cat attack."

"The folder tree is already a graph." β€” It is β€” but of the wrong relationship. Directories encode where files sit, not who talks to whom. orderflow's billing and notifications folders are siblings, yet at runtime they're intimate: billing publishes the very events notifications consumes. Only an edge can say that.

Traversal vs search β€” the determinism dividend

A map is only useful if following it is cheaper than wandering. Here's the precise sense in which it is.

To answer "who calls decode_jwt?" without a graph, an agent searches: grep for the name (getting every mention β€” imports, comments, strings), open each candidate file, and read enough surrounding code to judge which hits are real call sites. The cost is proportional to the number and size of files touched, and the result is a judgment call. Run it twice, phrase the question differently, and you may get different answers.

With a graph, the agent traverses: look up the node, follow its incoming calls edges, done. The cost is proportional to the number of edges followed β€” usually a handful β€” and the answer is a fact retrieved, not a text interpreted. Same question, every time, same answer. That repeatability is what the course means by deterministic: no similarity thresholds, no ranking, no luck.

This is also the moment to name the complexity honestly. Search costs O(files Γ— file size) in tokens per query, paid every query. Traversal costs O(edges followed) β€” after a one-time extraction cost paid when the graph is built. Extraction moved the expensive work out of the query path. You'll hear this called "compile once, query forever," and it's the same economic trick a database index plays.

Animation Β· TRAVERSAL β€” answering "who calls decode_jwt?" by following edges
Reduced motion: showing final state.
πŸ’° Why it matters β€” the arithmetic you'll reuse all course

A real measured example from the corpus (you'll rebuild it in M09): answering "who calls cmd_generate?" by hand meant opening a 672-line, ~24 KB file and reading enough of it to find the caller β€” roughly 6,000 tokens. The equivalent graph query returned one line: about 15 tokens. That's ~400Γ— for a single question β€” and it compounds, because the agent never re-opens that file after context compaction either. Per query and per session, traversal's cost stays flat while search's cost grows.

Multi-hop reasoning β€” where flat retrieval structurally fails

Some questions cannot be answered from any single file, no matter how cleverly you retrieve it. Consider: "What breaks in the API layer if I alter the schema in db/migrations/04_users.sql?" The answer requires chaining facts: the migration alters a table β†’ a model class maps that table β†’ a repository uses the model β†’ three route handlers call the repository. Four files. The connecting facts never co-occur in one place; each hop lives in a different file, and some hops (the model↔table mapping) are implicit.

A graph answers this by construction: it's a path. Start at the table node, walk maps_to β†’ used_by β†’ called_by edges, and the four-file chain falls out as a sequence of lookups. In orderflow miniature: change decode_jwt's claims format, and the graph walks decode_jwt ← verify_token ← post_invoice β€” two hops from an auth helper to a billing route that never mentions JWTs at all.

Hold this example: in M02 you'll see exactly why chunk-based retrieval cannot make this chain (the hops are never co-located in a chunk), and in M10 "multi-hop reasoning failure" becomes the first of two formal triggers that justify adding a structural graph to a production stack.

Communities β€” subsystems the graph discovers by itself

Before: at a wedding reception with 100 guests, nobody hands you a seating chart of who knows whom. Yet watch the room for ten minutes and the structure is obvious β€” clusters form: the bride's university friends, the groom's coworkers, the extended family. Nobody assigned those clusters; they emerge from who actually talks to whom.

The pain: codebases have the same latent structure β€” the billing code all calls other billing code β€” but nothing in the file system enforces that the folders match the conversation patterns. In old codebases they often don't: the "utils" folder talks to everyone, and a "microservices" architecture turns out to be, as the corpus memorably put it, a secret monolith in a trench coat.

The mapping: community detection is the algorithm that watches the room. It clusters nodes that are densely connected to each other and sparsely connected to everyone else. The clusters it finds are your actual subsystems β€” the architecture as practiced, not as drawn on the wiki.

Community detection algorithms maximize modularityA score measuring how much denser connections are inside clusters than you'd expect by random chance. High modularity = crisp subsystems. β€” the degree to which edges concentrate inside clusters rather than between them. Two algorithms matter for this course. Leiden (used by Graphify) is a refinement of the classic Louvain method: fast, and guarantees well-connected communities. Clauset–Newman–Moore (used by okf-rs) is an agglomerative modularity maximizer β€” okf-rs's own dogfooding showed it correctly splitting an 18-crate workspace that naive connected-components analysis collapsed into one blob.

That last contrast deserves one more sentence, because it's a common trap: connected components only asks "is there ANY path between these nodes?" β€” and in real code, everything reaches everything through shared utilities, so you get one giant component. Modularity-based methods ask the sharper question: "are these nodes unusually densely connected?" β€” which is what separates billing from notifications even though both touch the database pool.

On orderflow, community detection finds three clusters β€” billing, orders, notifications β€” matching the service boundaries. On a healthy codebase that's typical. On an unhealthy one, the mismatch IS the finding: a "shared" module that community detection assigns to billing is a module that only billing really uses.

⚠️ Where it breaks (file this for M11)

In legacy enterprise monoliths, community detection often clusters unrelated modules together merely because they all reference shared infrastructure β€” logging singletons, telemetry helpers. The wedding-party equivalent: everyone talks to the bartender, but that doesn't make the whole room one friend group. Treat detected communities as a strong hint, not gospel, and expect noise proportional to how tangled the codebase already is.

Animation Β· COMMUNITY_DETECT β€” orderflow's nodes cluster; the god node emerges
Reduced motion: showing final state.

God nodes and the price of centrality

Watch the animation's final frames: DatabasePool is reached by billing and orders, while publish is the one that actually touches all three communities. Which of them is "the god node" depends entirely on which measure you ask, and orderflow is small enough that you can check every answer by hand.

MeasureWhat it asksWinner on orderflow
in-degreewho is depended on most?shared/auth.py (6 in-edges), then db.py 5, events.py 4
services reaching ithow far does it span?shared/events.py — the only module all three services touch
betweennesswho sits on paths between others?billing/webhooks.py and invoice.py (4.00); db.py scores 0.00
blast radiuswho breaks the most if changed?shared/db.py — 7 transitive dependents, vs 4 for events and 3 for auth

Graph tools call the winners god nodes: the main characters of your codebase, identified by centralityA family of graph measures scoring how "important" a node is β€” by how many edges it has (degree), or how many shortest paths run through it (betweenness). measures. The corpus definition is memorable and accurate: "the classes everything else depends on β€” we call these God Nodes because if they die, the universe ends."

Why you care, concretely: a god node is where refactoring risk, review scrutiny, and onboarding attention should concentrate. On orderflow that is shared/db.py β€” not because it is the most connected (it is not) but because it has the largest blast radius: change it and seven functions across billing and orders can change behaviour. A new engineer (or agent) reading the graph learns in seconds what might take weeks of tribal exposure: this is the file you don't touch casually.

Read the measure before you believe the ranking. shared/db.py scores zero betweenness β€” nothing routes through the persistence layer, because it calls nothing back; it is a sink. A tool that ranked orderflow by betweenness would put a billing file on top and never mention the database at all. "Most important" is not a property of the graph, it is a property of the question β€” and any tool handing you a single god-node list has already picked the question for you.

Now the honest cost accounting. The strictest centrality measure β€” betweenness centrality, which counts how many shortest paths pass through each node β€” costs O(VΓ—E): nodes times edges. On orderflow (36 nodes, 22 edges) that's nothing. On a real monorepo it's a wall: one documented case in the corpus involved a 450,000-node, 690,000-edge graph β€” roughly 310 billion operations, single-threaded, because the algorithm doesn't parallelize cleanly. A full rebuild on a 96-core Xeon was killed after 114 minutes without completing, while a different (smaller) repo finished in ~10 minutes on a laptop. Remember this number in M11: it's why "just rebuild the graph on every commit" stops being a plan at scale, and why cheap proxies like plain degree count (which our lab uses) are often the right engineering choice.

Blast radius β€” impact analysis as a graph walk

The single most valuable query a code graph answers is the one that prevents outages: "if I change this, what could break?" The graph formulation is clean: the blast radius of a node is everything reachable by walking incoming call edges transitively β€” callers, callers-of-callers, and so on. It's a breadth-first searchBFS: explore a graph level by level from a starting node, using a queue and a visited-set so nothing is processed twice. over the reversed graph, about ten lines of code, and you write it below.

Mature tools score the result rather than just listing it. okf-rs's impact command, for instance, ranks every changed concept between two git refs by three factors: transitive-caller count (the raw radius), public-API membership (external consumers can't be refactored alongside you), and cycle participation (changes inside dependency cycles propagate unpredictably). Its review subcommand turns that into a PR comment with a --fail-on-risk CI gate β€” impact analysis promoted from a query to a policy.

The corpus example that sells the feature: a developer asks, "if I delete this 'deprecated' method, will the payment gateway explode?" β€” and the graph shows the 14 hidden dependencies that say Yes. Fourteen facts no one person remembered, retrieved in milliseconds.

Provenance β€” the honesty layer

One primitive remains, and it's the one that separates trustworthy graphs from confident guessers.

Not every edge is equally certain. When a parser sees decode_jwt(token) inside verify_token's body in the same module, the call edge is a fact. But when code calls execute(...) and three different classes define an execute method, the extractor must guess β€” or admit it can't. Serious tools label every edge:

TagMeaningTrust level
EXTRACTEDParsed directly from source; the relationship is explicitGround truth β€” act on it
INFERREDResolved by heuristic (name matching, proximity, "this variable is named xyzAuth and gets passed to a validator, so they're probably best friends")Probably right β€” verify before a destructive action
AMBIGUOUSMultiple candidate targets; all emitted, none confirmedA question, not an answer

One reviewer in the corpus called this labeling "the whole argument" for graph tools: it is the only approach in the codebase-context landscape that tells you which of its answers are facts and which are guesses. Grep doesn't. Vector search certainly doesn't β€” every RAG result arrives with the same confident tone regardless of quality (M02 makes this failure visceral). A machine-readable confidence label is what lets an agent act boldly on EXTRACTED edges and double-check INFERRED ones β€” exactly the policy you'll implement in the lab's final step.

Where do dynamic languages fit? Python and JavaScript β€” where, as the corpus jokes, types are merely suggestions β€” force more INFERRED edges because names can't be resolved statically. M03 shows the resolution ladder (heuristics β†’ language servers β†’ honest AMBIGUOUS labels). The discipline stays the same: never emit a guess dressed as a fact.

Walk it, step by step

Build the graph by hand now β€” nodes, then edges, then the provenance that decides whether an edge can be trusted. Watch how few edges a parser can prove outright.

Code walkthrough β€” the whole toolkit in 40 lines

Let's build every primitive from this module over orderflow's real graph. The extractor (labs/shared_tools/kg_extract.py) has already produced graph.json β€” 36 nodes, 22 edges, each edge provenance-tagged. We'll write three functions: a reverse index, a direct-callers query, and a provenance-aware blast radius.

Chunk 1 — the reverse index. Edges are stored source→target ("who do I call?"), but impact questions run the other way ("who calls me?"). Rather than scanning all edges per query, we index them once by target. This tiny dict IS the difference between search and traversal: after this line, "who calls X" is a hash lookup. Gotcha: filter to calls edges — in richer graphs, walking imports edges into a blast radius wildly overstates impact.

Chunk 2 β€” direct callers. Resolve a human-friendly short name ("decode_jwt") to node ids, then read the reverse index. Note the deliberate handling of misses: an empty list, not an exception β€” in M09 the same behavior becomes the graceful "not in graph, try grep" MCP response that lets agents fall back safely.

Chunk 3 β€” blast radius with a confidence gate. A textbook BFS with a queue and a visited-set (the visited-set matters: real graphs have cycles, and without it you loop forever). The one addition worth staring at: when min_confidence="EXTRACTED", the walk skips unverified edges β€” computing the radius you can prove versus the radius you suspect. Two numbers, and the gap between them is your uncertainty, made visible.

graph_basics.py Β· the three functions (full runnable file in labs/M01)
import json
from collections import defaultdict, deque

graph = json.load(open("graph.json", encoding="utf-8"))
short = lambda node_id: node_id.rsplit(".", 1)[-1]

# ---- Chunk 1: index edges by TARGET, once ----
reverse = defaultdict(list)
for edge in graph["edges"]:
    if edge["type"] == "calls":
        reverse[edge["target"]].append(edge)

# ---- Chunk 2: direct callers, with graceful misses ----
def callers(name):
    targets = [n["id"] for n in graph["nodes"] if short(n["id"]) == name]
    found = {short(e["source"]) for t in targets for e in reverse.get(t, [])}
    return sorted(found)          # unknown name -> [] (never a crash)

# ---- Chunk 3: BFS blast radius with a confidence gate ----
def blast_radius(name, min_confidence=None):
    seeds = [n["id"] for n in graph["nodes"] if short(n["id"]) == name]
    queue, visited, impacted = deque(seeds), set(seeds), set()
    while queue:
        for edge in reverse.get(queue.popleft(), []):
            if min_confidence == "EXTRACTED" and edge["provenance"] != "EXTRACTED":
                continue          # skip guesses when proof is demanded
            if edge["source"] not in visited:
                visited.add(edge["source"])
                impacted.add(short(edge["source"]))
                queue.append(edge["source"])
    return sorted(impacted)

print(callers("decode_jwt"))                     # ['verify_token']
print(blast_radius("decode_jwt"))                # [... 'post_invoice', 'verify_token']
print(blast_radius("execute"))                   # everything touching the DB pool
print(blast_radius("execute", "EXTRACTED"))      # ...the provable subset
graph_basics.mjs Β· the same three functions
import { readFileSync } from "node:fs";

const graph = JSON.parse(readFileSync("graph.json", "utf-8"));
const short = (id) => id.split(".").at(-1);

// ---- Chunk 1: index edges by TARGET, once ----
const reverse = new Map();
for (const edge of graph.edges) {
  if (edge.type !== "calls") continue;
  if (!reverse.has(edge.target)) reverse.set(edge.target, []);
  reverse.get(edge.target).push(edge);
}

// ---- Chunk 2: direct callers, with graceful misses ----
function callers(name) {
  const targets = graph.nodes.filter((n) => short(n.id) === name).map((n) => n.id);
  const found = new Set();
  for (const t of targets) for (const e of reverse.get(t) ?? []) found.add(short(e.source));
  return [...found].sort();            // unknown name -> [] (never a crash)
}

// ---- Chunk 3: BFS blast radius with a confidence gate ----
function blastRadius(name, minConfidence = null) {
  const seeds = graph.nodes.filter((n) => short(n.id) === name).map((n) => n.id);
  const queue = [...seeds], visited = new Set(seeds), impacted = new Set();
  while (queue.length) {
    for (const edge of reverse.get(queue.shift()) ?? []) {
      if (minConfidence === "EXTRACTED" && edge.provenance !== "EXTRACTED") continue;
      if (!visited.has(edge.source)) {
        visited.add(edge.source);
        impacted.add(short(edge.source));
        queue.push(edge.source);
      }
    }
  }
  return [...impacted].sort();
}

console.log(callers("decode_jwt"));                  // ['verify_token']
console.log(blastRadius("decode_jwt"));              // [..., 'post_invoice', 'verify_token']
console.log(blastRadius("execute", "EXTRACTED"));    // the provable subset
πŸ”Ž What just happened?

You built a queryable code intelligence system in ~40 lines: one dict inversion turned stored edges into instant reverse lookups; one BFS turned those lookups into transitive impact analysis; one if statement turned provenance metadata into a trust policy. Graphify, CodeGraph, and okf-rs are these three moves plus better extraction, storage, and serving. The primitives never change.

Hands-on lab β€” labs/M01-graph-fundamentals

πŸ“‚ Get the files: labs/M01-graph-fundamentals on GitHub β€” or clone the whole course once: git clone https://github.com/varasrinivas/knowledge-graph-course.git

What you'll build: the file above, from a TODO skeleton, verified by automated checks. Time: 30–45 min. Files: starter/graph_basics.py (yours), solution/ (if stuck), expected_output/sample_output.txt.

  1. Step 1 β€” Load the graph. cd labs/M01-graph-fundamentals/starter && python graph_basics.py. The loader runs the extractor for you. Expected: 36 nodes, 22 edges loaded. βœ… If you see an import error, run from inside starter/.
  2. Step 2 β€” TODO 1: the reverse index. Loop over graph["edges"], append each calls edge under its target.
  3. Step 3 β€” TODO 2: BFS blast radius. Seed a deque with matching node ids; walk the reverse index with a visited-set. blast_radius("decode_jwt") must include post_invoice β€” two hops away.
  4. Step 4 β€” TODO 3: the confidence gate. Skip non-EXTRACTED edges when demanded; compare both radii for execute.
  5. Verify. python graph_basics.py --check β†’ ALL CHECKS PASSED. πŸŽ‰

Troubleshooting: empty blast radius almost always means you walked edges forward (caller→callee) instead of backward. KeyError: 'edges' means the loader was edited — diff against the solution.

Stretch goal: compute each node's in-degree from the reverse index and print the top 3. The winner should be orderflow's god node. You've just implemented (degree) centrality β€” the cheap cousin of the O(VΓ—E) betweenness measure from this module.

Knowledge check

1. What makes an edge in a code knowledge graph more than a line between boxes?
Its visual layout position
It is directed and typed β€” a precise, machine-readable claim like "A calls B"
It stores the full source code of both endpoints
2. Why is graph traversal called deterministic while RAG retrieval is probabilistic?
Traversal uses more compute, which increases accuracy
Traversal follows explicit stored edges β€” same query, same answer, every time; similarity search ranks candidates and can return different, possibly stale ones
Traversal only works on small graphs
3. Community detection on your codebase assigns a "shared utils" module entirely to the billing cluster. What is the most useful reading?
The algorithm is broken and should be re-run
The module is "shared" in name only β€” in practice, essentially only billing uses it; the architecture-as-practiced differs from the architecture-as-drawn
Billing should be deleted
4. Why does the course keep warning about betweenness centrality at scale?
It requires a GPU
It costs O(VΓ—E) and parallelizes poorly β€” ~310 billion operations on a 450K-node/690K-edge monorepo; one 96-core rebuild was killed after 114 minutes
It only works on directed graphs
5. An extractor sees execute(...) called, and three classes define an execute method. The honest output is:
Pick the most popular class and emit one EXTRACTED edge
Emit no edge β€” uncertain information is worthless
Emit all three candidate edges tagged AMBIGUOUS, so consumers know it's a question, not an answer
6. What is a node's blast radius?
The set of functions it calls, transitively
Everything reachable by walking INCOMING call edges transitively β€” all code whose behavior could change if the node changes
The number of lines in its file

Module summary

Nodes & typed edges

Code as a social network: entities plus directed, typed, machine-readable claims ("A calls B"). The graph is a shape, not a product.

Traversal

Follow edges instead of reading files: O(edges followed) per query after one-time extraction. 6,000 tokens vs 15 for "who calls X".

Multi-hop

Impact chains span files that never co-occur in a chunk β€” paths answer what flat retrieval structurally cannot.

Communities

Leiden / CNM clustering finds subsystems from actual call density β€” architecture as practiced, noise included.

God nodes

Risk concentrators β€” but name the measure first: on orderflow in-degree picks shared/auth.py, reach picks events.py, blast radius picks db.py. Betweenness costs O(VΓ—E) β€” the scale wall M11 revisits.

Provenance

EXTRACTED / INFERRED / AMBIGUOUS: the only context layer that labels facts vs guesses. Never emit a guess dressed as a fact.

What we built on orderflow: a 36-node, 22-edge graph plus reverse index, callers query, and provenance-gated blast radius β€” the exact primitives every later module refines.

Next β€” M02: The RAG Baseline and Where It Breaks. Before adopting graphs everywhere, you need the disease in your hands: you'll build a retrieval pipeline over orderflow's docs and watch it confidently return a stale metric definition β€” nothing crashing, everything wrong.

References

  • Course extractor: labs/shared_tools/kg_extract.py Β· ground-truth edges: labs/sample-project/README.md
  • Leiden algorithm (Traag, Waltman & van Eck) β€” used by Graphify for community detection
  • Clauset–Newman–Moore modularity clustering β€” used by okf-rs graph communities
  • okf-rs impact analysis (impact, review --fail-on-risk) β€” github.com/jyjeanne/okf-rs
  • Graphify god-node and community reporting β€” graphify.net Β· GRAPH_REPORT.md format (M04)