Text → Graph — LLM Entity Extraction
Building the knowledge-graph engine (Neo4j, the Bolt driver, Docker, Cypher, APOC) and then automating its ingestion — turning thousands of messy documents into clean nodes and edges by using an LLM as a constrained reasoning parser: structured output, constrained decoding, ontology enforcement, and three-tier entity resolution.
Assumes you know
After this you should be able to
- Explain the core idea in Text → Graph — LLM Entity Extraction
- Apply Text → Graph — LLM Entity Extraction in a representative exercise
Notebook Summary#
Hand-copyable revision notes. Margin markers (★ ⚠ 💡) are yours to box. Fully derivable from the Detailed Notes below.
A. What a Knowledge Graph is#
B. Neo4j Engine (Python)#
() = node, [] =
edge.close() ends session.execute_write /
execute_read wrap a session; use
lambda tx: tx.run(q, params)..consume() → forces query to finish before session closes.7687 = Bolt (Python driver) ·
7474 = HTTP browser UI.C. Why NOT regex or spaCy for extraction#
D. LLM as a parser, not a chatbot#
E. Structured output (the rigid box)#
response_schema; a grammar mask sets P(non-JSON token)=0..with_structured_output() converts Pydantic → JSON schema → hidden
param.F. Entity Resolution — 3 tiers#
| Tier | Where | Method |
|---|---|---|
| 1 · In-flight | during extract | rules in Pydantic description (strip suffix, ticker→name) |
| 2 · Anchor dict | system prompt | master vocab list; force exact match |
| 3 · Graph dedup | inside Neo4j (periodic) | vector similarity / Levenshtein → MERGE nodes |
G. Production extraction prompt = 4 zones#
Detailed Study Notes#
The full topic, from the ground up. Everything in the summary above is derived from here.
Imagine you're handed 5,000 corporate earnings-call transcripts and press releases and asked one question: "Which companies acquired which, and who signed off on it?" You cannot read them. You cannot scroll them. Somewhere in that pile is the sentence "Microsoft is moving aggressively into the space — bought out Alpha AI," and somewhere else "the acquisition of Alpha AI by Microsoft was finalized today." Same fact, worded two completely different ways. A keyword search misses both. A spreadsheet can't connect them. What you actually need is a way to store facts and how they relate — and a machine that can read chaotic human text and drop clean facts into that store without ever lying. That machine is what this guide builds.
The Knowledge Graph Engine#
First we build the destination — a graph database that stores facts as a web of connected things.
1 What a knowledge graph actually is#
A knowledge graph stores information as things connected by labeled relationships — not as rows in a table.
A normal database stores values in rows and columns. A knowledge graph stores connections. It has three ingredients: the things themselves (nodes), the relationships between them (edges), and descriptive facts attached to either (properties). "Satya Nadella" is a node; "works for" is an edge; "title: CEO" is a property.
The whole reason a graph beats a table is the edge. If you only kept a list of people and a list of companies, you'd know who exists but not who did what to whom. The relationship is the value.
2 The stack: Neo4j, the Python driver, Docker & Cypher #
Neo4j is the graph database; Python talks to it through a driver; Docker packages the whole thing; Cypher is the query language.
Four pieces work together. Neo4j is the database that physically stores nodes and edges. The
Python driver (the neo4j library) is the phone line your code uses to call the
database. Docker is the shipping container: it bundles Neo4j plus its exact settings so the
app runs identically on anyone's machine — no "works on my laptop" dependency pain.
Cypher is the language you write queries in.
SELECT ... JOIN, Cypher literally sketches the pattern with punctuation: round brackets
() are the circles (nodes) and square brackets [] are the labeled strings
(edges). (exec)-[:WROTE]->(doc) reads almost like the picture it describes.Two ports you must not confuse#
| Port | Purpose | Who uses it |
|---|---|---|
7687 | Bolt protocol — the binary connection | Your Python driver / scripts |
7474 | HTTP web UI — human-readable browser | You, visually inspecting the graph |
docker-compose file exposes both, and the
Python connection URI uses bolt://127.0.0.1:7687. Use the literal IP
127.0.0.1 rather than the word localhost to avoid name-resolution
hiccups.APOC — the plugin that unlocks LLM work#
By default Neo4j is a pure graph store: it builds and traverses hierarchies, nothing more. To parse JSON, do heavy string manipulation, or run advanced procedures (the kind you need when wiring an LLM in), you enable a plugin called APOC.
3 The engine class: connect, read, write, close#
A single Python class wraps the database connection and exposes clean read/write methods so the rest of your code never touches raw sessions.
The class constructor takes a URI, username, and password and calls
GraphDatabase.driver(...) — this authenticates and opens the line, exactly
like logging into any SQL database. A matching close() method shuts the connection
down cleanly when you're done, because you don't want an open connection lingering.
Inside, two workhorse methods do all the talking: one to write data in and one to
read results out. Each opens a session and runs your Cypher query through a small
anonymous lambda function. The write method uses Neo4j's built-in
execute_write; the read method uses execute_read and collects each
returned record into a list so it's easy to loop over afterward.
class AlphaFundGraphEngine:
def __init__(self, uri, user, password):
self.driver = GraphDatabase.driver(uri, auth=(user, password))
def close(self):
self.driver.close()
print("Neo4j connection closed securely.")
def execute_write(self, cypher, params=None):
with self.driver.session() as session:
# lambda runs the query inside a managed transaction
session.execute_write(lambda tx: tx.run(cypher, params).consume())
def execute_read(self, cypher, params=None):
with self.driver.session() as session:
return session.execute_read(
lambda tx: [record for record in tx.run(cypher, params)]
).consume() is the detail that saves you. Neo4j
transactions are lazy — a write query may not actually execute until its results are read.
Calling .consume() forces the query to finish before the session closes, so
your data is really committed and not silently dropped.4 Constraints, ingestion & traversal#
Before loading any data, declare uniqueness constraints; then ingest with a MERGE query; then traverse to surface an insight.
Just as SQL schemas declare "primary key can't be null," a graph declares constraints.
Here we make three node types unique — Company name, Executive name, and Document title
— so the same real-world thing never becomes two separate nodes. You run each constraint
query through execute_write first, then ingest.
MERGE ("create if it doesn't already exist") recognise a repeat and reuse the node.
Ingest first and you'll already have duplicates that constraints can no longer fix cleanly.
Ingestion feeds a JSON payload (author, report title, startup, acquirer, CEO…) into a
Cypher query. Where you write $author, Neo4j substitutes the value "Tony" and
builds the corresponding executive node; square-bracket sections create the edges. The pattern
(exec)-[:WROTE]->(doc) reads plainly: Tony wrote the Q3 report.
A traversal query then walks that structure to answer a real question.
MATCH in Cypher is like SELECT in SQL: you specify a start node
(executive "Tony"), follow the chain of relationships, and return the useful endpoints —
here, the target CEO (Satya Nadella) and the acquiring company (Microsoft).
MATCH
pattern before running it. The graph never sees natural language — it only ever sees the
structured pattern.Text → Graph: Extraction with LLMs#
The engine is hungry for data. Now we automate filling it — turning thousands of messy documents into clean nodes and edges.
5 Why the old tools fail#
Regex can't handle the infinite ways humans phrase things, and classic NER extracts nouns but not the verbs that connect them.
Regex breaks on human variety#
Suppose you write a pattern to catch acquisitions: Company A acquired Company B. It
works — until the text says "the acquisition of Alpha AI by Microsoft," or
"Microsoft… bought out Alpha AI." Each phrasing needs a different pattern, and
human language has effectively infinite phrasings.
spaCy gives you nodes but no edges#
The traditional NLP tool for this is spaCy, and its core trick is Named Entity Recognition (NER) — the science of tagging which words are which type of thing. Feed it "Microsoft CEO Satya Nadella spoke to us yesterday" and it tags Microsoft → Organization and Satya Nadella → Person. Useful — but it stops there.
NER hands you a list of nouns. It has no idea whether Satya works for Microsoft, is suing Microsoft, or just bought a Microsoft laptop. It never extracts the verb that connects two entities.
6 Reframing the LLM as a reasoning parser#
Stop thinking of the LLM as a chatbot; use it as a machine that mathematically transforms unstructured text into a structured graph.
Most people equate "LLM" with "chatbot." As an engineer you treat a model like Gemini 2.5 Flash as a highly advanced reasoning parser — it already carries a deep pretrained understanding of corporate dynamics, legal phrasing, and financial jargon. You don't chat with it. You hand it a chunk of text and force it to apply a transformation function: unstructured text → JSON → graph schema.
7 The rigid box: structured output #
Force the LLM to return only schema-valid JSON — never a word of conversation — because a single filler word crashes the pipeline.
Picture the ideal output for "extract the entities." You want pure data. What you fear is the
model prepending "Sure! I found some entities, here they are:" before the JSON. In a
chatbot that's friendly. In a pipeline it's a disaster: your Python passes the model's
output straight into a JSON parser, and that conversational filler throws a
JSONDecodeError and crashes the server.
Step 1 — Pydantic: the data contract#
Pydantic is Python's standard data-validation library (the backbone of FastAPI). It lets you declare a strict schema with classes and type hints — what a node looks like, what an edge looks like, which fields are required or optional. Think of it as a contract for the expected output shape.
Step 2 — why prompting alone isn't enough#
Step 3 — constrained decoding (the real lock)#
Constrained decoding physically prevents the model from generating any token that would break the required JSON schema.
LangChain's .with_structured_output(schema) doesn't just paste your schema into
the prompt. It converts your Pydantic classes into a strict JSON Schema object and
passes it to a hidden Gemini API parameter called response_schema. At the
neural-network level, as the model predicts each next token, the API applies a grammar
mask: if the candidate token (e.g. "Sure") isn't valid under the schema, its
probability is forced to zero. A token with probability zero cannot be emitted.
8 Entity resolution: the same thing, many names#
Entity resolution decides whether two differently-worded strings ("Microsoft," "MSFT," "Microsoft Corp") refer to the same real-world thing — so they become one node, not three.
Even with perfect JSON, a new problem appears. Pull the same acquisition from ten articles
and one says "Microsoft," another "MSFT," another "Microsoft Corporation." If you blindly
MERGE all three, Neo4j creates three separate nodes — it has no
idea they're the same company. The graph shatters into disconnected fragments, and a later
MATCH for "Microsoft" silently misses the other two.
The three-tier defense#
| Tier | Where it runs | How it works |
|---|---|---|
| 1. In-flight resolution | During extraction (in the LLM) | Rules embedded in each Pydantic field's description —
e.g. "strip suffixes like Inc/Corp/LLC," "if you see a ticker (MSFT), output
the full name (Microsoft)," "capitalize names." First line of defense. |
| 2. Anchor dictionary | In the system prompt | Give the model a master vocabulary list ("Microsoft, Apple, Nvidia…") and instruct: if you identify a company, check this list and use the exact string if it matches. Anchors output to known-good terms. |
| 3. Graph-algorithm dedup | Inside Neo4j, periodically | Despite tiers 1–2, duplicates leak. A DBA runs algorithms (vector similarity, Levenshtein distance) on a schedule to find similarly-named nodes, MERGE their edges, and delete the duplicates. |
Ontology enforcement — locking the edge vocabulary#
Duplicates hit edges too, not just nodes. To stop that, you build a semantic wall
(also called ontology enforcement): a strict, finite dictionary of the only
relationship types your database is allowed to use. In Python you declare these as
Literal types; LangChain translates them into an allowed-values list inside the
JSON schema. Now the same grammar mask that killed conversational filler also zeroes out any
relationship token that isn't on your approved list.
9 The production extraction prompt: four zones#
A production extraction prompt is a rule set for a compiler, split into four strict zones — not a friendly note to a human.
When you write the extraction prompt you're addressing a machine, so every word carries weight; a vague prompt yields a vague graph. Split it into four zones:
| Zone | Job |
|---|---|
| 1. Persona & mission | Establish the baseline behavior you expect from the extractor. |
| 2. Schema definition | The Pydantic classes, applied at the API layer to lock the output shape. |
| 3. Hard directives | Negative constraints — tell it explicitly what not to do. |
| 4. Few-shot trajectory | Perfect input→output examples so the model learns your domain's terminology and nuance. |
? Interview & practice Q&A#
The follow-ups this material naturally invites. Click to reveal each answer — each is short enough to say aloud.
Q1 Why use a knowledge graph instead of a relational (SQL) database?
Q2 Why can't you just use regex or spaCy to build the graph?
Q3 What does it mean to treat an LLM as a "reasoning parser"?
Q4 Why isn't "output only JSON, no conversation" in the prompt enough?
Q5 How does constrained decoding actually work?
response_schema). As the model decodes each
next token, the API applies a grammar mask: any token that would violate the schema has
its probability forced to zero, so it physically cannot be emitted. The result is
guaranteed schema-valid JSON, not hopefully-valid JSON.Q6 What is entity resolution and why does it matter for graphs?
Q7 Walk through the three tiers of entity resolution.
Q8 What's the difference between the Bolt port and the HTTP port in Neo4j?
Q9 How do you stop the LLM from inventing new, unapproved relationship types?
Literal values in the schema, which become an allowed-values list in the
JSON Schema. The same grammar mask that blocks conversational filler then zeroes out any
relationship token not on the approved list, so the model can only ever emit edges from
your fixed vocabulary.Key Takeaways#
- Edges are the value. A knowledge graph is nodes + relationships + properties; the relationship is what a table can't cheaply give you.
- Know your stack. Neo4j stores it, the Python driver talks to it (Bolt :7687),
Docker ships it, Cypher queries it (
()=node,[]=edge), APOC unlocks LLM work. - Set constraints before ingest and call
.consume()so writes actually commit. - Regex and spaCy can't build graphs — regex drowns in phrasing variety; NER gives nodes but no edges.
- Use the LLM as a parser, not a chatbot. In a pipeline, one polite word is a fatal crash, and one hallucinated edge poisons every future traversal.
- Constrained decoding > prompting. A prompt asks for JSON; constrained
decoding makes non-JSON tokens mathematically impossible (P=0) via the hidden
response_schema. - Resolve entities in three tiers — Pydantic rules, an anchor dictionary, and periodic graph dedup — and fix duplicates before they hit Neo4j.
- Write prompts like a compiler spec: persona, schema, hard directives, few-shot examples.