Knowledge Graphs & Graph RAG
Why flat vector search misses relational reasoning — knowledge graph primitives (nodes, edges, properties), building a graph index with LLM extraction, and querying it with Graph RAG for multi-hop answers flat RAG can't reach.
After this you should be able to
- Explain the core idea in Knowledge Graphs & Graph RAG
- Apply Knowledge Graphs & Graph RAG in a representative exercise
Study Reference · Applied GenAI
Turning messy text into a queryable graph with an LLM, then answering questions no plain vector search can touch.
Section 1
Notebook Summary#
Copy this by hand. Every line is derivable from the detailed notes below. Boxed lines (★ ⚠ 💡) are the ones worth a margin mark.
Big Picture#
- Goal → raw unstructured text → structured graph DB
- Why → answer multi-hop / relational queries over messy text
- Stack → Gemini 2.5 Flash + LangChain + Pydantic + Neo4j (Cypher)
- nodes = nouns (Person/Company/Document) · edges = verbs (acquired, competes_with…)
Pipeline (5 steps)#
- 1 Load → feed raw text in chunks (not whole doc)
- 2 Extract → each chunk → Gemini, temp = 0.0 (deterministic)
- 3 Validate → Pydantic → nodes to typed labels, edges to fixed vocab
- 4 Ingest → write to Neo4j with MERGE (not CREATE)
- 5 Verify → multi-hop Cypher confirms relationships are correct
- ★TRICK:
llm.with_structured_output(KG)= constrained decoding → no free text, no hallucinated schema - ★TRICK:
Literal[...]fixes the edge vocabulary → LLM maps synonyms into it - ⚠PITFALL: temp not 0 → non-deterministic graph, dupes, schema drift
- ⚠PITFALL: CREATE instead of MERGE → duplicate nodes on rerun
- 💡TIP: canonical ID resolves aliases + pronouns (MSFT=Microsoft=he/it)
MERGE (idempotent write)#
- MERGE on canonical id → exists? bind : create new
- safe to rerun · no duplicates
Why plain RAG fails on complex Qs#
| Method | Can do | Blind to |
|---|---|---|
| Vector (FAISS) | find scattered text by meaning | relationally blind — can't walk a path |
| Pure Cypher | walk exact paths | semantically deaf — needs exact start node |
- 💡TIP: memorize the pair — vector = relationally blind, Cypher = semantically deaf
Graph RAG = best of both#
- Idea → put the vector embedding INSIDE the Neo4j node (as a property)
- node holds: name + embedding(summary, industry, news…)
- ★TRICK: embed the node, don't choose FAISS or Neo4j — fuse them
3-Phase Retrieval Engine#
- P1 Vector Jump → embed query → cosine sim vs node vectors → entry node
- P2 Cypher Traversal → from entry node, hop edges → pull ego graph
- P3 LLM Synthesis → query + ego-graph JSON → human answer
- 💡TIP: P1 finds "the door to the maze"; P2 walks the maze; P3 reads the map aloud
- ★TRICK: feed LLM the topology (small JSON of relationships), not stuffed chunks
Context payoff#
- plain RAG → stuff many chunks + pray
- graph RAG → tiny precise ego graph → less noise, less context, no hallucination
Section 2
Detailed Study Notes#
You've got a folder of financial transcripts. Someone asks: "Who is the CEO of the company that acquired the startup Tony wrote about?" Every fact lives in a different paragraph — Tony's document, the startup, the acquisition, the parent company, its CEO. No single sentence answers it. A keyword search returns five unrelated snippets and shrugs. To answer, you don't need better search — you need to walk the connections. That walk is what this whole guide builds.
Part 1 · The extraction pipeline, end to end#
In one line: an autonomous knowledge-graph extraction pipeline reads plain, messy text and turns it — with no human labeling — into a structured graph database of entities and the relationships between them.
A knowledge graph is just a picture of facts where the dots are things and the lines are how those things relate. In the jargon, the dots are nodes and the lines are edges. The instructor's rule of thumb worth burning in: nodes are nouns, edges are verbs. "Microsoft" is a node; "acquired" is an edge; "GitHub" is another node.
Why it exists. Ordinary search finds documents; a graph lets you find answers that live across documents. The target use case here is multi-hop relational queries over unstructured data — questions whose answer requires chaining several facts together. The inputs are things like financial transcripts and compliance reports; the output is a Neo4j graph you can traverse.
The pipeline runs five steps, each mapped to one file in the project:
| Step | What happens | Where |
|---|---|---|
| 1 · Load | Raw text is split into small chunks and fed in one at a time | main.py |
| 2 · Extract | Each chunk goes to Gemini 2.5 Flash at temperature 0.0 | extractor.py |
| 3 · Validate | Pydantic forces output into typed nodes + a fixed edge vocabulary | models.py |
| 4 · Ingest | Nodes & edges written to Neo4j with a MERGE query | neo4j_writer.py |
| 5 · Verify | A multi-hop Cypher query confirms the relationships are correct | verifier.py |
Don't hand the LLM an entire report and expect a clean graph. Split into chunks, build a subgraph per chunk, and let the database stitch them together. Overlapping chunks (via a recursive character text splitter) also let the model resolve references that span chunk boundaries.
The schema is the whole game: typed nodes, a fixed edge vocabulary#
In one line: instead of letting the LLM write prose, you hand it a rigid form to fill in — and it can only tick the boxes you allow.
The schema lives in models.py as Pydantic classes. Three pieces:
from typing import Literal
from pydantic import BaseModel, Field
# edges may ONLY be one of these — a fixed vocabulary
RelationshipType = Literal[
"acquired", "invested_in", "competes_with",
"works_for", "wrote", "mentions"
]
class Node(BaseModel):
id: str # canonical name (resolves aliases + pronouns)
label: Literal["Person", "Company", "Document"]
description: str # original text span, kept as metadata
class Edge(BaseModel):
source_id: str
target_id: str
type: RelationshipType
class KnowledgeGraph(BaseModel):
nodes: list[Node]
edges: list[Edge]Two design choices carry the weight. First, typed labels (Person, Company,
Document) so every node has a known kind. Second, the Literal type on
relationships pins the edges to a fixed list of verbs. The LLM can't invent "purchased" or "bought out" — it
has to map those synonyms onto acquired.
with_structured_output
In the extractor, llm.with_structured_output(KnowledgeGraph) is a built-in LangChain call
that forces Gemini to return typed JSON matching your schema — nothing else. No "Sure, here's
your JSON…" preamble, no free-form prose, no hallucinated fields. This is what makes an LLM safe to wire
directly into a database.
Each node's id is its canonical name. "MSFT", "Microsoft Inc.", and "Microsoft" all
resolve to one node; so do "Satya Nadella", "Mr. Nadella", and a later "he". The prompt tells the model
to resolve all pronouns to canonical names and use only allowed relationship types — so pronouns
never leak into the graph as edges.
Extraction needs deterministic output: the same text should always yield the same graph. Any temperature above zero introduces variation, which shows up as inconsistent labels, drifting edge types, and near-duplicate nodes across runs.
The extraction prompt is short and blunt. Paraphrased: "You are an elite information extractor. Extract entities and relationships. Resolve all pronouns to canonical names. Use only the named allowed relationship types. Output must strictly adhere to the JSON schema." The chunk of text gets slotted in as the human message.
Fine for a demo, dangerous in production. Read the model name, Neo4j URI, user, and password from
environment variables (via python-dotenv), never literals in code. And add Neo4j's local
data directory to .gitignore so you don't push the database into your repo.
Writing to Neo4j: why MERGE, never CREATE#
In one line: MERGE means "make this node if it doesn't exist, otherwise
reuse the one that does" — so running the pipeline twice never doubles your data.
CREATE is a turnstile: every time "Microsoft"
walks through, it counts a new person. MERGE is a guest list: it checks the name first and only
adds Microsoft once, no matter how many chunks mention it.
The writer opens a Neo4j session over the Python "bolt" driver, loops over the extracted nodes and edges, and
runs a MERGE Cypher query for each. Because MERGE keys on the canonical
id, it's idempotent — safe to rerun without creating duplicates. When a later chunk
mentions "Microsoft Inc.", it binds to the existing Microsoft node instead of spawning a second one. If it's
a genuinely new entity like "Apple", a new node is created.
A mental model: it's like a SQL table whose schema comes from the LLM and whose rows are filled by the LLM too. Before the write, the extracted nodes and edges are just loose objects; they only become a connected graph once Neo4j links them by shared canonical IDs.
Verification: prove the multi-hop path actually resolves#
In one line: after loading, run one hard question as a Cypher query — if the graph answers it correctly, the extraction worked.
Take a single input sentence: "Tim Cook mentioned that Neural Net Labs directly competes with Alpha in
the generative space." The LLM should extract nodes (Tim Cook → Person, Neural Net Labs / Alpha →
Company) with a competes_with edge. The verifier then walks the graph with a match query —
Cypher's equivalent of a SELECT — and confirms it returns the expected competitor and the
acquired startup, owned by Microsoft. The pipeline's final line reads: "Neural Net Labs
competes directly with Alpha, which is owned by Microsoft."
Extraction quality is invisible until you query it. A verification query that traverses several hops is the cheapest possible test that your nodes, edges, and canonical IDs all line up — if any link is missing or mislabeled, the traversal returns nothing.
Two names worth pinning down: the vector library is FAISS and the graph database is
Neo4j (used consistently throughout). The embedding
model named in passing is Google's text-embedding-004. [+]
Part 2 · Why one complex question breaks ordinary RAG#
Now the harder half. Consider the query the whole paradigm is built to answer:
"What is the name of the executive who leads the AI company that competes with the startup Microsoft just bought?"
Read it twice — most people have to. Notice you can't name a single node in it. You don't know the executive, the AI company, the competitor, or the acquired startup. Every anchor is described indirectly. This is a dual failure waiting to happen, and it defeats both of the tools you already have.
Vector search (FAISS) is relationally blind#
Plain-English: vector search finds text that means something similar to your query. Embed the long question, and FAISS pulls back scattered paragraphs — some about Microsoft, some about AI companies, some about executives. But it cannot walk a logical path from Microsoft → acquired startup → competitor → executive. It retrieves neighborhoods of meaning; it can't follow a chain.
Pure Cypher is semantically deaf#
Plain-English: Cypher can walk any path perfectly — if you tell it where to start. To write
MATCH (c {name:"Alpha"})-[:competes_with]->... you must already know a node's exact name. But
the user said "the startup Microsoft just bought", not "Alpha". Cypher hears the structure but not the
meaning, so it has no starting node to anchor on.
| Method | Strength | Fatal flaw |
|---|---|---|
| Vector / FAISS | Matches meaning; finds relevant text anywhere | Relationally blind — can't traverse a logical path |
| Pure Cypher | Deterministic, exact multi-hop traversal | Semantically deaf — needs the exact starting node |
Vectors are relationally blind; Cypher is semantically deaf. One finds meaning but can't follow structure; the other follows structure but can't find meaning. Say that sentence and you've captured the entire motivation for Graph RAG.
If the source happened to be a FAQ where one chunk literally contains the full answer, plain vector RAG would nail it. Real sources — transcripts, financial reports — scatter the facts across many chunks that never state the connection outright. That's exactly the case where naive RAG quietly fails.
Graph RAG: put the embedding inside the graph#
In one line: stop choosing between a vector store and a graph — store each node's meaning as a vector property on the node itself, so one database can search by meaning and walk relationships.
Concretely: a node used to hold only plain properties like name: "Alpha". In Graph RAG it also
carries an embedding — a mathematical fingerprint of everything known about that node: its summary,
its industry, recent news. Neo4j stores this in a native vector index, and because the vector rides
along on the node (a "labeled property graph"), a single query can jump to a node by meaning and then
traverse from it by structure.
The three-phase retrieval engine#
When the user hits enter, three phases fire in sequence. Each phase switches the "engine" it runs on.
Phase 1 · Vector Jump — find the entry point#
Take the user's phrase — "the startup Microsoft just bought" — and push it through the Google embedding
model. That query vector is fired into Neo4j's native vector index, and cosine similarity asks: which
node's embedding best matches the concept of a startup Microsoft just bought? No edges yet, no
verbs — just "which door do we open." It fires and returns a hit: Company: Alpha. You've
found the door to the maze.
Phase 2 · Cypher Traversal — extract the ego graph#
Now switch engines. From the Alpha node you stop doing vector math and start walking the edges
deterministically, hop by hop: Alpha → competes_with → Neural Net Labs → works_for
→ Elena (the executive). What you pull out is the small, densely connected neighborhood around your entry
node — its ego graph (a node plus everything directly connected to it). That subgraph is serialized
to a clean JSON string.
An ego graph is one focal node ("ego") plus its immediate connections. Instead of dumping the whole database into the prompt, you extract just this tight, relevant neighborhood — the exact facts needed to answer, and nothing else.
Phase 3 · LLM Synthesis — answer the user#
Feed two things into the LLM's context window: the original user prompt and the extracted ego-graph JSON. The model isn't guessing or retrieving now — the answer is literally sitting in the JSON as a chain of relationships. It just phrases it in plain English: "The executive is Elena, who leads Neural Net Labs, the company that competes with Alpha — the startup Microsoft acquired." No room to hallucinate, because the facts are already resolved.
The payoff: a paradigm shift in context management#
In one line: plain RAG stuffs the prompt with chunks and prays; Graph RAG hands the LLM a tiny, exact map of relationships.
The framing: you're no longer feeding the model unstructured paragraphs, you're feeding it topology — a compact, Cypher-derived JSON of nouns and the labeled edges between them. The result is dramatically less noise, far smaller context-window usage, and no filler text for the model to trip over. "Here is the map — read it and tell the user what you see."
| Standard vector RAG | Graph RAG | |
|---|---|---|
| What's fed to the LLM | Many raw text chunks | A small ego-graph JSON (topology) |
| Context window | Large, noisy, padded | Small, precise, no filler |
| Multi-hop queries | Usually fails | Handles them by design |
| Hallucination risk | Higher — model infers | Lower — answer is pre-resolved in the graph |
This is the interview headline: Graph RAG doesn't just retrieve better, it changes what you retrieve. By resolving relationships in the database first and passing only the resulting topology, you cut token cost, cut noise, and cut hallucination — all at once.
Interview & practice Q&A#
The follow-ups this material invites. Read each answer aloud in 2–4 sentences.
Q1Why constrain the LLM's output with Pydantic instead of parsing free text?
with_structured_output(KnowledgeGraph) uses
constrained decoding to force typed JSON that matches the schema exactly, so it can be written straight
into the database. It guarantees valid node labels and edge types with no post-processing.Q2Why MERGE instead of CREATE, and why does it
matter?
MERGE is idempotent: it reuses a node if one with the same canonical ID exists
and creates it otherwise. CREATE always inserts, so rerunning the pipeline (or processing
chunks that mention the same entity) would produce duplicate nodes and split the graph.
MERGE keyed on the canonical ID keeps every entity as a single node.Q3What is a canonical ID and what two problems does it solve?
Q4Explain "vectors are relationally blind, Cypher is semantically deaf."
Q5How does Graph RAG combine the two instead of choosing one?
Q6Walk through the three-phase retrieval engine.
Q7What is an ego graph and why extract it rather than the whole database?
Q8Why is temperature set to 0.0 for extraction?
Q9How does Graph RAG change context management versus standard RAG?
Key takeaways#
- Nodes are nouns, edges are verbs. A knowledge graph turns scattered text into a queryable web of entities and relationships.
- The schema is the safety net. Pydantic +
with_structured_output+ aLiteraledge vocabulary force clean, typed, hallucination-free graphs. - MERGE + canonical IDs make ingestion idempotent and keep every real-world entity as exactly one node.
- Vectors are relationally blind; Cypher is semantically deaf. Neither alone can answer a complex, indirectly-phrased multi-hop question.
- Graph RAG fuses them by storing embeddings inside the graph, then running Vector Jump → Cypher Traversal → LLM Synthesis.
- Feed topology, not chunks. Passing a small ego graph instead of stuffed text slashes context usage, noise, and hallucination.
Build it · Chapter 04 · Atlas
Build it — a property graph you can walk#
Everything on this page becomes runnable here. No Neo4j, no driver, no keys — a Graph in ~50 lines of standard-library Python, with the one move flat search can't make: walking an ego graph to return topology instead of text. This is the seed the next ten chapters grow on.
atlas/graph.py — nodes are nouns, edges are verbs:
"""Chapter 04 — a tiny property graph + Graph-RAG retrieval.
nodes = nouns, edges = verbs. No database, no dependencies: just enough of a
knowledge graph to answer questions plain vector search can't.
"""
from collections import defaultdict
class Graph:
def __init__(self):
self.nodes = {} # id -> {"label": str, **props}
self.out = defaultdict(list) # id -> [(rel, dst), ...]
self.inc = defaultdict(list) # id -> [(rel, src), ...]
def add_node(self, id, label, **props):
# MERGE, not CREATE: re-adding an id updates it instead of duplicating.
self.nodes.setdefault(id, {"label": label})
self.nodes[id].update(label=label, **props)
return id
def add_edge(self, src, rel, dst):
self.add_node(src, self.nodes.get(src, {}).get("label", "Entity"))
self.add_node(dst, self.nodes.get(dst, {}).get("label", "Entity"))
if (rel, dst) not in self.out[src]:
self.out[src].append((rel, dst))
self.inc[dst].append((rel, src))
def neighbors(self, id):
"""Every edge touching a node, in both directions."""
return ([(id, rel, dst) for rel, dst in self.out[id]] +
[(src, rel, id) for rel, src in self.inc[id]])
def ego(self, id, hops=2):
"""The subgraph within `hops` of a node — its neighborhood as triples."""
seen, frontier, triples = {id}, {id}, []
for _ in range(hops):
nxt = set()
for node in frontier:
for s, r, o in self.neighbors(node):
if (s, r, o) not in triples:
triples.append((s, r, o))
nxt |= {s, o}
frontier = nxt - seen
seen |= nxt
return triples
def seed_graph():
g = Graph()
g.add_node("Microsoft", "Company"); g.add_node("Alpha", "Company")
g.add_node("Nimbus", "Company"); g.add_node("Elena", "Person")
g.add_node("Tony", "Person"); g.add_node("Q3 Report", "Document")
g.add_edge("Microsoft", "acquired", "Alpha")
g.add_edge("Nimbus", "competes_with", "Alpha")
g.add_edge("Elena", "works_for", "Nimbus")
g.add_edge("Tony", "wrote", "Q3 Report")
g.add_edge("Q3 Report", "mentions", "Alpha")
return g
def graph_rag(g, seed, hops=2):
"""Find a start node, walk its neighborhood, hand back the topology.
Phase 1 (vector jump) is faked here as an exact seed; Chapter 07 makes it
semantic. What matters now: we return a tiny, exact map — not raw text.
"""
triples = g.ego(seed, hops)
context = "\n".join(f"({s})-[{r}]->({o})" for s, r, o in triples)
return context
if __name__ == "__main__":
g = seed_graph()
print("Who leads the company that competes with the startup Microsoft bought?\n")
print(graph_rag(g, seed="Alpha"))Run it — you'll see:
Who leads the company that competes with the startup Microsoft bought? (Microsoft)-[acquired]->(Alpha) (Nimbus)-[competes_with]->(Alpha) (Q3 Report)-[mentions]->(Alpha) (Elena)-[works_for]->(Nimbus) (Tony)-[wrote]->(Q3 Report)
This is one checkpoint of Atlas — a single project built across notes 04–14. Full code, setup, and the map of all checkpoints: Atlas — Build the notes ↗.