Extracting a Knowledge Graph With an LLM
12 min read · updated August 4, 2026
An LLM will happily return a knowledge graph from any text you give it. The graph will be about eighty per cent right, and the wrong twenty per cent will be indistinguishable from the rest — plausible entities, plausible relations, plausible everything. This page is about the validation pass that separates them, which is the part every tutorial on this subject leaves out.
Start with a closed schema
Open-ended extraction — “find the entities and relationships in this text” — produces a graph where the same relationship appears as ACQUIRED, acquired, BOUGHT, TOOK_OVER and PURCHASED_BY across five documents. That graph cannot be queried, because a query has to name a relationship type and there is no type to name. Fix the vocabulary before you extract anything.
# schema.py
ENTITY_TYPES = {
"Company": ["name", "jurisdiction"],
"Person": ["name"],
"Product": ["name"],
"Location": ["name"],
}
# (subject type, relation, object type) — nothing outside this list is accepted
RELATIONS = [
("Company", "ACQUIRED", "Company"),
("Company", "SUPPLIES", "Company"),
("Company", "HEADQUARTERED_IN", "Location"),
("Person", "EXECUTIVE_OF", "Company"),
("Company", "PRODUCES", "Product"),
]Two dozen relation types is a healthy ceiling for a first pass. The discipline is the same one described in ontologies, taxonomies and schemas: a closed vocabulary is the cheapest structure that makes a graph queryable, and it is a prerequisite for extraction rather than a refinement of it.
The extraction prompt
The prompt does three jobs: it states the schema, it demands evidence, and it gives explicit permission to return nothing. The third is the one people omit, and it is why models invent relations — a prompt that implies output is expected will produce output.
SYSTEM = """You extract structured facts from text.
Return JSON only, matching this shape:
{
"entities": [{"temp_id": "e1", "type": "Company", "name": "...",
"evidence": "exact quote from the text containing the name"}],
"relations": [{"subject": "e1", "relation": "ACQUIRED", "object": "e2",
"evidence": "exact quote from the text stating this relation"}]
}
Permitted entity types: Company, Person, Product, Location.
Permitted relations (subject type, relation, object type):
Company ACQUIRED Company
Company SUPPLIES Company
Company HEADQUARTERED_IN Location
Person EXECUTIVE_OF Company
Company PRODUCES Product
Rules:
1. Every "evidence" value must be a contiguous substring of the input text,
copied character for character. Do not paraphrase, correct or normalise it.
2. Extract a relation ONLY if the evidence quote states it. Do not infer a
relation from context, from general knowledge, or from two entities
appearing in the same sentence.
3. If the text supports no relations, return an empty relations array. An
empty result is a correct result.
4. Use the entity name exactly as it appears in the text. Normalisation
happens later."""Rule 1 is what makes the validation pass possible, and it is the whole trick. A model cannot fabricate a substring of the input as easily as it can fabricate a fact, because the substring is checkable by in — no judgement, no second model, no embeddings.
import json
from openai import OpenAI
client = OpenAI(
base_url="https://api.multigrid.ai/v1", # any OpenAI-compatible endpoint
api_key=os.environ["MULTIGRID_API_KEY"],
)
def extract(text: str, model: str = "openai/gpt-4.1-mini") -> dict:
resp = client.chat.completions.create(
model=model,
temperature=0,
response_format={"type": "json_object"},
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": text},
],
)
return json.loads(resp.choices[0].message.content)response_format support and its exact shape vary by model and by provider; a strict JSON-schema mode is stronger than json_object where it is available. Check what the model you have chosen supports rather than assuming — and either way keep the validation below, because a syntactically valid JSON object can still contain a fabricated relation.The validation pass
Four checks, applied in order, each of which throws work away rather than trying to repair it. Repairing extraction output is a trap: a fixed-up hallucination is still a hallucination with better formatting.
- Schema check. The entity type is in
ENTITY_TYPES; the triple of (subject type, relation, object type) is inRELATIONS. Anything else is dropped. - Evidence-is-a-substring check. Every
evidencevalue appears verbatim in the source text. This kills paraphrased, summarised and invented quotes. - Mention-in-evidence check. The entity’s
nameappears inside its own evidence span. An entity whose name is not in the quote that supposedly proves it is not grounded. - Both-ends-in-evidence check. For a relation, both the subject name and the object name appear inside the relation’s evidence span. This is the check that catches the plausible relation, and it is the reason the whole design exists.
import unicodedata
def norm(s: str) -> str:
"""Fold whitespace and unicode so quotes survive typographic differences."""
s = unicodedata.normalize("NFKC", s)
s = s.replace("\u2019", "'").replace("\u201c", '"').replace("\u201d", '"')
return " ".join(s.split()).lower()
def validate(raw: dict, source: str) -> tuple[dict, list[str]]:
src = norm(source)
rejects: list[str] = []
ok_entities: dict[str, dict] = {}
for e in raw.get("entities", []):
if e.get("type") not in ENTITY_TYPES:
rejects.append(f"entity type not in schema: {e.get('type')}")
continue
ev = norm(e.get("evidence", ""))
if not ev or ev not in src:
rejects.append(f"evidence not in source for entity {e.get('name')}")
continue
if norm(e.get("name", "")) not in ev:
rejects.append(f"name not inside its own evidence: {e.get('name')}")
continue
ok_entities[e["temp_id"]] = e
ok_relations = []
permitted = {(s, r, o) for s, r, o in RELATIONS}
for rel in raw.get("relations", []):
s = ok_entities.get(rel.get("subject"))
o = ok_entities.get(rel.get("object"))
if s is None or o is None:
rejects.append(f"relation references a rejected entity: {rel}")
continue
if (s["type"], rel.get("relation"), o["type"]) not in permitted:
rejects.append(f"relation not permitted by schema: {rel}")
continue
ev = norm(rel.get("evidence", ""))
if not ev or ev not in src:
rejects.append(f"relation evidence not in source: {rel}")
continue
if norm(s["name"]) not in ev or norm(o["name"]) not in ev:
rejects.append(f"both ends not in evidence span: {rel}")
continue
ok_relations.append(rel)
return {"entities": list(ok_entities.values()),
"relations": ok_relations}, rejectsKeep the rejects list. It is the single most useful artefact the pipeline produces: a rejection rate that jumps after a model change, or clusters around one relation type, tells you something is wrong long before anybody notices bad data in the graph.
The failure mode: the plausible relation
Consider this input:
“Acme Robotics and Vandenberg Automation announced a joint development programme for next-generation harmonic drives, to be manufactured at Vandenberg’s Leipzig site.”
A model asked for relations from that sentence will, some fraction of the time, return Acme ACQUIRED Vandenberg or Acme SUPPLIES Vandenberg. Neither is stated. Both are the kind of thing that tends to be true of two companies in a sentence together, which is exactly what a next-token predictor is good at producing. This is not a bug in the model; it is the model doing what makes models hallucinate in the first place — completing a high-probability pattern.
Check four catches it in one of two ways. If the model invents the evidence quote, the substring test fails. If the model supplies the real sentence as evidence, the relation survives that test — but the human reviewing rejects then has the exact quote next to the exact claim, which is the fastest possible review. What check four cannot do is catch a relation that is genuinely stated in the source and genuinely false. That is a source problem, and it belongs to provenance, not extraction.
One further guard, cheap and worth it: extract per sentence or per short paragraph rather than per document. A relation whose two ends are eleven paragraphs apart is nearly always an inference the model made rather than a fact the text stated, and a small extraction window makes that structurally impossible.
Loading what survived
The extractor emits temp_id values that mean nothing outside one document. Turning those into real nodes is entity resolution, and it is a separate stage with its own failure modes — do not let the extraction prompt attempt it by asking for “canonical names”.
UNWIND $entities AS e
MERGE (n:Entity {mention_key: e.type + '|' + toLower(e.name)})
ON CREATE SET n.type = e.type, n.name = e.name, n.first_seen = datetime()
SET n.mention_count = coalesce(n.mention_count, 0) + 1
WITH collect(n) AS _
UNWIND $relations AS r
MATCH (s:Entity {mention_key: r.subject_key})
MATCH (o:Entity {mention_key: r.object_key})
MERGE (s)-[rel:ASSERTED {relation: r.relation, doc_id: $doc_id}]->(o)
SET rel.evidence = r.evidence, rel.extracted_at = datetime()Note that these are mention nodes, keyed on the surface form, not resolved entities. Promoting mentions to canonical entities is what entity resolution does, and keeping the two layers separate means a resolution mistake can be undone without re-running extraction over the whole corpus.
Knowing whether it works
Label a hundred documents by hand, once. It is a day of work and it is the only thing that turns “the extraction seems good” into a number you can act on. Then track three figures per run:
- Relation precision — of the relations that survived validation, what share are correct against the labelled set. This is the number that must be high, because a wrong edge in a graph propagates into every multi-hop query that crosses it.
- Relation recall — of the relations in the labelled set, what share the pipeline found. Low recall is a much cheaper problem than low precision: a missing edge makes a query return less, a wrong edge makes it return nonsense.
- Rejection rate by check. Which of the four checks is firing, and how often. A spike in “evidence not in source” usually means a prompt or model change; a spike in “both ends not in evidence” means the extraction window is too wide.
Set the precision target from the consumer. A graph feeding an analyst’s dashboard can live at 0.9. A graph answering customer questions cannot, and the difference is made up with the human review queue described in turning a wiki into something machines can query.