Study Notes
Noteapplied

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.

30 min read·verified Content review 2026·reviewed 2026-08-30

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
Neo4j / CypherConstrained DecodingEntity Resolution
Section 1

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#

Knowledge graph = data stored as nodes (things) + edges (relationships) + properties.
Node → Company / Executive / Document.
Edge → verb: WROTE, ACQUIRED, WORKS_FOR.
Query = traversal: hop node→edge→node to surface multi-hop insight.
doodle: 3 circles (Tony)—WROTE→(Report)—DISCUSSES→(AlphaAI)—ACQ_BY→(Microsoft)

B. Neo4j Engine (Python)#

Stack → Neo4j (graph DB) + Python driver + Docker (packaging) + Cypher (query lang).
Cypher = SQL for graphs. () = node, [] = edge.
Engine class → constructor opens driver (URI, user, pwd); 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.
Ports → 7687 = Bolt (Python driver) · 7474 = HTTP browser UI.
⚠Constraints = uniqueness (Company, Executive, Document) before ingest, else duplicates.
💡APOC = Awesome Procedures On Cypher → plugin for JSON parsing / LLM integration.

C. Why NOT regex or spaCy for extraction#

Regex → matches fixed patterns; fails on infinite phrasings of "acquired" (bought out, acquisition of, purchased).
⭐spaCy NER → gives nouns (nodes) only, NO verbs (edges). Nodes without edges = expensive word list, not a graph.
doodle: two circles floating, no line between = "no graph"

D. LLM as a parser, not a chatbot#

⭐Reframe → LLM = reasoning parser. Force a transformation: text → JSON → graph.
Output = triplet (subject → predicate → object).
⚠Chatbot vs pipeline → in a pipeline, politeness is a fatal error. One "Sure, here's..." → JSON decode error → crash.
⚠Hallucinated edge → "AlphaAI ACQUIRED Microsoft" → poisons ALL future traversals.

E. Structured output (the rigid box)#

Pydantic = Python data-validation classes → a schema contract for node/edge shape.
⭐Constrained decoding → pass schema to Gemini's hidden response_schema; a grammar mask sets P(non-JSON token)=0.
LangChain .with_structured_output() converts Pydantic → JSON schema → hidden param.
⚠Prompt-only ("output only JSON") → works ~95%, fails eventually. Can't fix an engineering problem with an English prompt.
doodle: LLM stick-figure inside a locked box, only JSON leaks out the slot

F. Entity Resolution — 3 tiers#

Problem → Microsoft / MSFT / Microsoft Corp = 3 nodes → graph shatters, MERGE misses.
TierWhereMethod
1 · In-flightduring extractrules in Pydantic description (strip suffix, ticker→name)
2 · Anchor dictsystem promptmaster vocab list; force exact match
3 · Graph dedupinside Neo4j (periodic)vector similarity / Levenshtein → MERGE nodes
⭐Fix duplicates BEFORE they hit Neo4j (tiers 1–2); tier 3 cleans leaks after.
Ontology / literal types → lock edge vocabulary; API zeros out any relationship not in the approved list.

G. Production extraction prompt = 4 zones#

1) Persona + mission   2) Schema (Pydantic)   3) Hard directives (what NOT to do)   4) Few-shot trajectory (input→output examples).
💡You're writing a rule set for a compiler, not a letter to a human. Vague prompt → vague graph.
§ § §
Section 2

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.

Part 1

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.

Analogy — a detective's corkboard. Photos pinned to the board are nodes. The red string running between two photos is an edge, and each string has a label ("boss of," "acquired," "wrote"). To answer "who is connected to this suspect?" you don't scan a list — you follow the strings. Following the strings is exactly what a graph query does; it's called a traversal.

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.

Tony Executive Q3 Report Document Alpha AI Company Microsoft Company WROTE DISCUSSES ACQ. BY
A multi-hop traversal: start at an executive, follow WROTE → DISCUSSES → ACQUIRED_BY to answer "which CEO is behind this acquisition?"
Why it mattersThe interview one-liner: "A knowledge graph makes relationships first-class." The power isn't storing entities — it's answering multi-hop questions ("find the CEO of the company acquired by the firm mentioned in Tony's report") in a single traversal instead of many table joins.

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.

Analogy — Cypher is SQL that you can draw. Where SQL writes 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#

PortPurposeWho uses it
7687Bolt protocol — the binary connectionYour Python driver / scripts
7474HTTP web UI — human-readable browserYou, visually inspecting the graph
💡TipRemember it as "87 for scripts, 74 for eyes." The 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.

💡Tip · interview triviaAPOC stands for "Awesome Procedures On Cypher." It's the standard extension library; you switch it on with configuration flags in the Docker Compose file.

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)]
            )
⭐Trick.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.
Python Engine driver + read/write methods Neo4j (Docker) nodes & edges Cypher write · bolt://:7687 records back (read)
The engine sends Cypher over the Bolt port and receives records back; Docker keeps Neo4j identical everywhere.

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.

⚠PitfallSet constraints BEFORE ingesting, not after. The uniqueness rule is what lets Neo4j's 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).

💡TipIn production the user types vague English, and your backend converts it into this rigid MATCH pattern before running it. The graph never sees natural language — it only ever sees the structured pattern.
§ § §
Part 2

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.

⚠PitfallRegex is fine for limited, fixed formats (dates, phone numbers). It cannot capture the infinite permutations of natural language. Reaching for regex to extract meaning is the classic beginner mistake.

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.

⭐Trick · the sentence that makes it click"spaCy gives you nodes but no edges — and a graph with no edges isn't a graph, it's an expensive list of words." Say this in an interview and you've shown you understand why LLMs are needed here at all.
spaCy / NER Microsoft Satya nouns only — no line = no graph vs LLM parser Satya Microsoft WORKS_FOR
NER floats disconnected nouns; the LLM recovers the verb (edge) that turns them into a real relationship.

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.

Analogy — a translator, not a conversationalist. You're not asking the model "what do you think about this paragraph?" You're saying "translate this paragraph into exactly this data shape." The model's job is to output the hidden subject → predicate → object triplets a graph needs — e.g. (Tony) → [FINALIZED] → (Q3 Report).
⚠Pitfall · the poisoned edgeWhen a chatbot hallucinates, the cost is a bad reply. When your data pipeline hallucinates — say it flips the direction and writes "Alpha AI ACQUIRED Microsoft" — that single corrupted edge poisons every future traversal that touches those nodes. One wrong string can silently break every downstream answer.

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.

⚠PitfallIn a data pipeline, politeness is a fatal error. Even one stray "Hi" in front of a valid JSON array corrupts the whole ingestion. The rules for talking to an LLM API change completely when you move from building chatbots to building pipelines.

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#

⚠Pitfall · the 95% trapWriting "output only valid JSON, no markdown, no conversation" works about 95% of the time. But eventually the model slips and prepends "Sure, here is the extracted JSON payload you requested:" — and that one time crashes production. You cannot solve an engineering problem with an English prompt. A prompt is a request; you need a hard constraint.

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.

⭐Trick · this is the whole gameThe difference between a prompt and constrained decoding: a prompt asks for JSON; constrained decoding makes any non-JSON token mathematically impossible (P = 0). That's what "putting the LLM in a rigid box" literally means — the box is enforced at the compiler/decoder level, not by persuasion.
LLM decoder predicts next token Grammar mask (response_schema) valid? keep : P=0 "Sure, here..." P=0 ✗ { valid JSON } ✓
Every candidate token passes through the schema mask; conversational tokens are zeroed out, only schema-valid JSON survives.

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.

Analogy — a guest list with nicknames. If your list has "Robert," "Bob," and "Mr. Smith" as three separate rows, you'll set three place settings for one guest. Entity resolution is the host quietly recognising they're all the same person before anyone sits down.
Microsoft MSFT Microsoft Corp 3 shattered nodes resolve Microsoft 1 canonical node
Left: three aliases become three nodes and the graph fragments. Right: resolution collapses them into one canonical node.

The three-tier defense#

TierWhere it runsHow it works
1. In-flight resolutionDuring 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 dictionaryIn the system promptGive 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 dedupInside Neo4j, periodicallyDespite 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.
⭐Trick · the ordering insightResolve duplicates BEFORE they hit Neo4j, not after. Tiers 1 and 2 normalize data while it's still in transit (cheap, prevents the mess). Tier 3 is only a cleanup crew for what slips through. Relying on tier 3 alone means constantly repairing a graph that keeps re-fragmenting.
⚠PitfallNever trust prompt engineering (tier 1) to be 100% reliable on critical data. That's precisely why tier 2's hard master list and tier 3's periodic algorithms exist — layered defense, not a single hopeful instruction.

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.

💡TipSame machinery, two jobs: constrained decoding first enforces "must be JSON," then ontology enforcement adds "and the relationship must be one of these exact verbs."

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:

ZoneJob
1. Persona & missionEstablish the baseline behavior you expect from the extractor.
2. Schema definitionThe Pydantic classes, applied at the API layer to lock the output shape.
3. Hard directivesNegative constraints — tell it explicitly what not to do.
4. Few-shot trajectoryPerfect input→output examples so the model learns your domain's terminology and nuance.
💡Tip · the mental model"You're not writing a letter to a human; you're writing a rule set for a compiler." Keep that framing and your prompts get precise, negative-constrained, and example-driven instead of chatty.
§ § §

? 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?
A knowledge graph makes relationships first-class, so multi-hop questions ("find the CEO of the company acquired by the firm named in this report") are a single traversal instead of many expensive JOINs. It shines when the connections between entities are the thing you care about, and when the schema of relationships is flexible and evolving rather than rigid rows and columns.
Q2 Why can't you just use regex or spaCy to build the graph?
Regex only matches fixed patterns and cannot cover the infinite ways humans phrase the same fact ("acquired," "bought out," "acquisition of…by"). spaCy's NER extracts the nouns (nodes) but not the verbs that connect them, so you get entities with no relationships. A graph without edges isn't a graph — it's an expensive list of words.
Q3 What does it mean to treat an LLM as a "reasoning parser"?
Instead of chatting, you hand the model text and force it to apply a transformation function: unstructured text into structured JSON into a graph schema. You're leveraging its pretrained understanding of language and domain jargon to recover subject-predicate-object triplets, then constraining its output so it behaves like a deterministic parser rather than a conversationalist.
Q4 Why isn't "output only JSON, no conversation" in the prompt enough?
A prompt is a request the model obeys most of the time — roughly 95% — but it will eventually slip and prepend filler like "Sure, here's the JSON," which crashes the pipeline with a JSON decode error. You cannot solve an engineering problem with an English prompt; you need a hard constraint enforced below the text level.
Q5 How does constrained decoding actually work?
Your Pydantic schema is converted into a JSON Schema object and passed to a hidden API parameter (Gemini's 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?
Entity resolution decides whether two differently-worded strings ("Microsoft," "MSFT," "Microsoft Corp") refer to the same real-world object so they collapse into one node. Without it, MERGE creates duplicate nodes, the graph fragments, and later queries miss data because a MATCH for one alias never reaches the others. One unresolved duplicate can silently break downstream traversals.
Q7 Walk through the three tiers of entity resolution.
Tier 1 (in-flight) embeds normalization rules in the Pydantic field descriptions so the LLM standardizes as it extracts (strip suffixes, expand tickers). Tier 2 (anchor dictionary) gives the model a master vocabulary list in the system prompt and forces exact matches. Tier 3 (graph dedup) runs periodic algorithms like vector similarity or Levenshtein inside Neo4j to merge leaked duplicates. Tiers 1–2 prevent duplicates before ingest; tier 3 cleans up what slips through.
Q8 What's the difference between the Bolt port and the HTTP port in Neo4j?
Port 7687 is the Bolt protocol, used by the Python driver for the actual binary database connection. Port 7474 is the HTTP web interface, used by humans to visually inspect the graph in a browser. The Docker Compose file exposes both, and your connection URI targets Bolt.
Q9 How do you stop the LLM from inventing new, unapproved relationship types?
You enforce an ontology by declaring the allowed relationship types as 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#

  1. Edges are the value. A knowledge graph is nodes + relationships + properties; the relationship is what a table can't cheaply give you.
  2. 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.
  3. Set constraints before ingest and call .consume() so writes actually commit.
  4. Regex and spaCy can't build graphs — regex drowns in phrasing variety; NER gives nodes but no edges.
  5. 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.
  6. Constrained decoding > prompting. A prompt asks for JSON; constrained decoding makes non-JSON tokens mathematically impossible (P=0) via the hidden response_schema.
  7. Resolve entities in three tiers — Pydantic rules, an anchor dictionary, and periodic graph dedup — and fix duplicates before they hit Neo4j.
  8. Write prompts like a compiler spec: persona, schema, hard directives, few-shot examples.

A curated study guide on LLM entity extraction — Text-to-Graph. Code snippets show the patterns in practice. Markers: ⭐ trick · ⚠ pitfall · 💡 tip · [+] added beyond source · [!] source conflict flagged inline.