Study Reference · Applied GenAI

Autonomous Knowledge Graphs & the Graph RAG Paradigm

Turning messy text into a queryable graph with an LLM, then answering questions no plain vector search can touch.

Gemini 2.5 Flash LangChain Pydantic Neo4j Cypher Vector index

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.

Knowledge Graphs & Graph RAG nodes = nouns · edges = verbs

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)
✎ doodle chunk LLM nodes • edges Neo4j

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
✎ doodle Q embed nearest walk ego graph 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.

Analogy — a detective's corkboard. Photos of people and companies are pinned up (nodes); red string connects them with a label like "works for" or "acquired" (edges). To crack a case you don't stare at one photo — you follow the string from suspect to motive to accomplice. The database is a corkboard a machine can query.

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.

Raw text chunks Gemini 2.5 Flash temp = 0.0 via LangChain + Pydantic schema Knowledge graph nodes + edges (typed JSON) MERGE Neo4j
The full loop: text → LLM (schema-constrained) → graph object → idempotent write to Neo4j.

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
⚠ Pitfall — feed chunks, not the whole document

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.

Analogy — a passport form, not an essay. Ask someone to "describe yourself" and you get a paragraph. Hand them a passport form with fixed fields and a dropdown of nationalities, and you get clean, comparable data every time. Pydantic is that form; the dropdown is your fixed relationship vocabulary.

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.

★ Trick — constrained decoding via 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.

💡 Tip — the canonical ID quietly does two hard jobs

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.

⚠ Pitfall — temperature must be exactly 0.0

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.

💡 Tip — never hardcode the model or secrets

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.

Analogy — a guest list, not a turnstile. 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.

Chunk 1 MSFT Chunk 2 "Microsoft Inc." MERGE Microsoft one node aliases bind to canonical id
Two chunks naming the same company collapse into a single canonical node.
💡 Tip — think "SQL table you never declared"

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."

◆ Why it matters

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.

[!] A naming note

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.

The path the answer requires MSFT startup competitor exec acquired competes works_for Vector (FAISS) scattered hits, no path Pure Cypher ? knows how to walk, no start node
The answer is a 3-hop path. Vector search sees dots but no line; Cypher sees the line but can't find its start.
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
💡 Tip — the one-liner examiners love

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.

⚠ Pitfall — "just make it a Q&A doc" only works by accident

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.

Analogy — a library where every shelf label is also searchable by vibe. A normal graph is a library organized by call number: precise, but you must know the number. Graph RAG tapes a rich description to each shelf that you can find by describing what you're after ("that startup a giant just bought") — then, once you're standing at the right shelf, you walk the aisles by the exact call numbers.

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.

Node : Company name: "Alpha" label: Company embedding: [ … ] = summary · industry · news
One node holds both symbolic properties (searchable by Cypher) and a semantic vector (searchable by cosine similarity).

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 query cosine entry node Phase 2 · Cypher Traversal ego graph (hop the edges) Phase 3 · Synthesis ego JSON LLM human-readable answer
Find the door (vector) → walk the maze (Cypher) → read the map aloud (LLM).

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.

💡 Tip — "ego graph" is the term to know

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.

Analogy — surgical theater, not a hoarder's garage. Standard RAG backs a truck of paragraphs up to the context window and hopes the answer is buried in there. Graph RAG behaves like a surgeon: it brings in only the precise instruments (a few relationship triples) needed for this one operation.

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
◆ Why it matters

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?
Free-form LLM output is unreliable to parse and can hallucinate fields or wrap the answer in prose ("Sure, here's your JSON…"). 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?
It's the single normalized name a node is keyed on. It resolves aliases ("MSFT", "Microsoft Inc." → Microsoft) and pronouns ("he", "it" → the actual entity), so the same real-world thing never fragments into several nodes. The prompt explicitly tells the model to resolve pronouns to canonical names before emitting edges.
Q4Explain "vectors are relationally blind, Cypher is semantically deaf."
Vector search matches meaning and can retrieve relevant text anywhere, but it can't traverse a logical chain of relationships — it returns scattered hits, not a path. Cypher can walk any multi-hop path deterministically but requires the exact starting node name, so it can't handle a query that only describes its anchors. Each covers the other's blind spot.
Q5How does Graph RAG combine the two instead of choosing one?
It stores each node's semantic embedding as a property on the node inside Neo4j's native vector index. A query can then find an entry node by cosine similarity (the vector strength) and traverse relationships from it with Cypher (the graph strength) — all in one database, no separate vector store.
Q6Walk through the three-phase retrieval engine.
Phase 1 (Vector Jump): embed the user query, cosine-match it against node embeddings to find an entry node. Phase 2 (Cypher Traversal): from that node, hop the edges deterministically to pull the ego graph as JSON. Phase 3 (Synthesis): pass the query plus the ego-graph JSON to the LLM to phrase a human answer.
Q7What is an ego graph and why extract it rather than the whole database?
An ego graph is a focal node plus its immediate connections — a small, dense neighborhood. Extracting only this gives the LLM exactly the relevant relationships and nothing else, which minimizes context-window usage and noise, and reduces the chance of hallucination compared with stuffing many chunks.
Q8Why is temperature set to 0.0 for extraction?
Extraction must be deterministic so the same text always yields the same graph. Any temperature above zero introduces randomness, causing inconsistent labels, drifting edge types, and near-duplicate nodes across runs. Zero temperature makes the pipeline reproducible.
Q9How does Graph RAG change context management versus standard RAG?
Standard RAG stuffs many raw chunks into the prompt and hopes the answer is present, inflating tokens and noise. Graph RAG feeds only the topology — a compact JSON of the relevant nouns and their labeled edges — so the context is small, precise, and filler-free, which lowers cost and hallucination while enabling multi-hop answers.

Key takeaways

  1. Nodes are nouns, edges are verbs. A knowledge graph turns scattered text into a queryable web of entities and relationships.
  2. The schema is the safety net. Pydantic + with_structured_output + a Literal edge vocabulary force clean, typed, hallucination-free graphs.
  3. MERGE + canonical IDs make ingestion idempotent and keep every real-world entity as exactly one node.
  4. Vectors are relationally blind; Cypher is semantically deaf. Neither alone can answer a complex, indirectly-phrased multi-hop question.
  5. Graph RAG fuses them by storing embeddings inside the graph, then running Vector Jump → Cypher Traversal → LLM Synthesis.
  6. 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.

File atlas/graph.pyRun python3 -m atlas.graphDeps Python 3 · stdlib only

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 ↗.