RAG Unpacked, Module 02: When the Graph Is the Index, Not the Answer

Vector RAG is built on a simple idea: find documents that look like the question. It works well until the answer is not in any single document. Ask which directors have worked with actors who appeared in Warner Bros films and there is no passage you can embed that contains that fact. The fact is a path through several connected pieces of information. That is the problem graph RAG is built to solve.
But the honest version of what graph RAG does in production surprised me a little while building this module. The graph is not really the answer source. It is the index. The most important shift across these four notebooks is the one from structured rows (the LLM rephrases query results into a sentence) to text chunks (the graph picks the right document and the right passage inside it, and the LLM reads actual text). The graph informs the answer. The text is the answer.
The graph picks the document and the right passage inside it. The LLM only ever reads what it actually needs.
Why Kuzu Instead of Neo4j
Neo4j is the industry standard for graph databases. It also wants Docker, a JVM, a server process, and a cloud sign-up before you can run a first query. For a module someone might clone on a Tuesday night, that is a steep wall.
Kuzu is pip install kuzu and done. Embedded, in-process, the database lives in a single file on disk. It speaks openCypher, so every query written against Kuzu ports to Neo4j unchanged. The module ends with a four-line swap showing exactly how. But for learning a graph database, having a file is a much better starting point than managing a cloud instance. Every architectural decision in this module flowed from that choice: the corpus is rebuilt by script on every clone, iteration on the schema is fast, and the whole thing runs offline against a 25 MB file.
The Graph: Eleven Movies, Thirty People, 1,809 Scenes
The seed corpus is eleven movies chosen so the multi-hop queries have real non-obvious answers. Carrie-Anne Moss bridges the Wachowskis and Christopher Nolan via Memento. Leonardo DiCaprio bridges Tarantino and Nolan via Django Unchained and Inception. The graph is small enough to print and big enough that two and three-hop traversals are genuinely interesting.
The schema grows across the four notebooks. NB1 starts with Person, Movie, and ACTED_IN. NB2 adds Studio, DIRECTED, and PRODUCED_BY. NB4 introduces Script (whole screenplay text, attached to each Movie via HAS_SCRIPT) and Scene (the same screenplay chunked on INT./EXT. slug lines, attached via HAS_SCENE). That final layer adds 1,809 Scene nodes, ranging from 45 in Reservoir Dogs to 285 in Inception. The screenplay text comes from IMSDb and is fetched and cached on first run, never committed to the repo.
Four Notebooks: Crawl, Walk, Run, Real Text
Crawl starts with three hand-written nodes, three movies, six edges, and one MATCH query. No LLM, no LangChain. The teaching moment that earns its place: a two-hop co-actor query without a WHERE clause returns Keanu Reeves as his own co-actor three times. Same shape as a self-join in SQL. NB2 adds the WHERE filter. NB1 just makes sure you see the gotcha.
Walk loads the full movie graph and covers everything you write repeatedly in graph RAG: WHERE filters with numeric ranges and string CONTAINS, multi-hop traversals, aggregations, OPTIONAL MATCH as graph LEFT JOIN, parameterized queries. Still no LLM. The point is to get fluent in Cypher before asking a language model to write it for you. If you cannot read the Cypher, you cannot debug what the LLM generates in the later notebooks.
Run is the full RAG loop built twice. First by hand in forty lines of code: take a natural language question, prompt the LLM with the schema and ask for Cypher, run the Cypher, send question and results back for synthesis. Every prompt is visible. Every intermediate output is printed. Then the same thing through LangChain's KuzuQAChain.from_llm in five lines. The result is identical. The chain is fine. The reason to write the manual loop first is so you know what to look for when the chain misbehaves.
Real Text is where the module becomes production-relevant. Act I attaches whole screenplays as Script nodes. Act II demonstrates the failure mode: ask about something buried halfway through a 232 KB screenplay and the LLM truncates it out of context. Act III introduces Scene nodes. The same screenplay is now 118 chunks averaging 2,000 characters each. The Cypher changes from returning the whole script to filtering Scene bodies with CONTAINS and returning only matching chunks. Context shrinks by a factor of ten to one hundred. The questions that failed in Act II all work.

Three Gotchas Worth Knowing Before You Hit Them
• Kuzu's CONTAINS is case-sensitive. WHERE sc.body CONTAINS 'sunken place' returns zero rows because the script capitalizes it. The fix is to wrap both sides with lower(). The failure mode is silent zero rows, not an error, so the LLM correctly reports the text does not say, which makes the model look broken when it is not.
• Long phrases break substring search. The Ezekiel speech in Pulp Fiction is in the script but the phrase 'path of the righteous man' is not because the screenplay wraps it across lines with deep indentation. CONTAINS 'Ezekiel' finds it instantly. Prefer one short distinctive keyword over a long phrase, or accept the brittleness and use vector search instead.
• Kuzu changed its on-disk format mid-development. Older versions stored the database as a directory. Kuzu 0.11+ stores it as a single file plus a sibling write-ahead log. The corpus builder originally only deleted the main file. The orphaned WAL replayed the old schema on the next open and CREATE TABLE failed with already exists. Three lines of fix, but for a moment it looked like broken Cypher.
What This Module Is Really About
The first move in production graph RAG is choosing your chunk. A vector RAG system has a chunk size and an embedding model. A graph RAG system has a chunk and a graph schema. NB4 chunks screenplays on INT./EXT. slug lines because that is where natural section breaks live. For a different domain it will be something else: support tickets per ticket, chapters per chapter, function bodies per function. The wrong chunk is the source of most of the pain in this kind of system.
Graph RAG also inherits every maintenance burden from vector RAG and adds three more. The schema is a contract: rename a relationship and every Cypher prompt is stale. The chunking strategy is a contract: change the slug line pattern and 1,809 nodes need rebuilding. And entity linking matters more than in vector RAG because 'Keanu Reeves' matches the graph and 'keanu reaves' does not. The fix is usually a vector lookup over node names before generating Cypher, which is the hybrid pattern covered in module 05.
Code: github.com/allllc/rag-unpacked | Module 02: 02-graph-rag
Next up: Module 03, Vectorless RAG, keyword search and BM25 for when you do not need vectors at all.