Skip to content

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.

  1. Schema check. The entity type is in ENTITY_TYPES; the triple of (subject type, relation, object type) is in RELATIONS. Anything else is dropped.
  2. Evidence-is-a-substring check. Every evidence value appears verbatim in the source text. This kills paraphrased, summarised and invented quotes.
  3. Mention-in-evidence check. The entity’s name appears inside its own evidence span. An entity whose name is not in the quote that supposedly proves it is not grounded.
  4. 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}, rejects

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