Building a knowledge graph in Python takes three libraries and one clear decision about what counts as an entity. spaCy finds the entity mentions in text, rdflib turns them into RDF triples that serialize to Turtle and answer SPARQL queries, and NetworkX handles the graph algorithms that RDF stores are awkward at. This page takes one paragraph of text through the whole path, then covers what a language model adds to the extraction step and what it does not.
Every code block below is complete and runnable on its own. Install the dependencies first:
pip install rdflib networkx spacy
python -m spacy download en_core_web_sm
The source text
The example uses four sentences about Marie Curie. Small enough to check by hand, varied enough to contain the problems that matter.
TEXT = (
"Marie Curie was born in Warsaw in 1867. "
"She studied at the University of Paris, where she later became a professor. "
"She shared the 1903 Nobel Prize in Physics with Pierre Curie and Henri Becquerel. "
"In 1911 she received the Nobel Prize in Chemistry."
)
Two properties of that text drive most of the work ahead. “She” appears three times and refers to Marie Curie each time, which no entity recognizer will tell you. And “Nobel Prize in Physics” and “Nobel Prize in Chemistry” are different entities that share most of their surface form.
Step 1: Extract entities with spaCy
spaCy’s small English model tags named entities with types including PERSON, ORG, GPE (geopolitical entity), DATE, and WORK_OF_ART.
import spacy
TEXT = (
"Marie Curie was born in Warsaw in 1867. "
"She studied at the University of Paris, where she later became a professor. "
"She shared the 1903 Nobel Prize in Physics with Pierre Curie and Henri Becquerel. "
"In 1911 she received the Nobel Prize in Chemistry."
)
nlp = spacy.load("en_core_web_sm")
doc = nlp(TEXT)
for ent in doc.ents:
print(f"{ent.text:<28} {ent.label_}")
The output lists each mention with its predicted type. It finds names, cities, universities, and years; the exact type assigned to an award varies, and mentions like “a professor” aren’t named entities at all. That is the normal state of a statistical recognizer, and the reason the next step is a decision rather than a conversion.
What spaCy gives you is mentions. A graph needs entities: one node per real thing, with a stable identifier, whatever surface form appeared in the text. Turning the first into the second is entity linking, covered at /concepts/entity-linking-ner-and-extraction/. For four sentences, it is a hand-written dictionary. For a corpus, it is a reconciliation pipeline against Wikidata or an internal identifier system.
spaCy also finds nothing about relationships. doc.ents reports that Warsaw and Marie Curie are both present in a sentence, not that one is the birthplace of the other. Rule-based relation extraction over the dependency parse works for narrow, predictable patterns and degrades quickly on real prose, which is where language models enter later on this page.
Step 2: Model the triples in rdflib
rdflib represents a graph as a set of triples, added one at a time. Each subject and predicate is a URIRef, usually built from a Namespace; each object is either another URIRef or a Literal. The model itself is explained at /concepts/triples-subject-predicate-object/ and /languages/resource-description-framework/.
from rdflib import Graph, Namespace, URIRef, Literal
from rdflib.namespace import RDF, RDFS, XSD
EX = Namespace("https://example.org/kg/")
SCHEMA = Namespace("https://schema.org/")
g = Graph()
g.bind("ex", EX)
g.bind("schema", SCHEMA)
MarieCurie = EX["MarieCurie"]
PierreCurie = EX["PierreCurie"]
Warsaw = EX["Warsaw"]
UniversityOfParis = EX["UniversityOfParis"]
NobelPhysics = EX["NobelPrizeInPhysics"]
g.add((MarieCurie, RDF.type, SCHEMA.Person))
g.add((MarieCurie, RDFS.label, Literal("Marie Curie", lang="en")))
g.add((MarieCurie, SCHEMA.birthDate, Literal("1867-11-07", datatype=XSD.date)))
g.add((MarieCurie, SCHEMA.birthPlace, Warsaw))
g.add((MarieCurie, SCHEMA.alumniOf, UniversityOfParis))
g.add((MarieCurie, SCHEMA.award, NobelPhysics))
g.add((MarieCurie, SCHEMA.spouse, PierreCurie))
g.add((MarieCurie, SCHEMA.sameAs, URIRef("http://www.wikidata.org/entity/Q7186")))
g.add((PierreCurie, RDF.type, SCHEMA.Person))
g.add((PierreCurie, RDFS.label, Literal("Pierre Curie", lang="en")))
g.add((PierreCurie, SCHEMA.award, NobelPhysics))
g.add((PierreCurie, SCHEMA.spouse, MarieCurie))
g.add((Warsaw, RDF.type, SCHEMA.City))
g.add((Warsaw, RDFS.label, Literal("Warsaw", lang="en")))
g.add((UniversityOfParis, RDF.type, SCHEMA.CollegeOrUniversity))
g.add((UniversityOfParis, RDFS.label, Literal("University of Paris", lang="en")))
g.add((NobelPhysics, RDFS.label, Literal("Nobel Prize in Physics", lang="en")))
print(g.serialize(format="turtle"))
print(f"{len(g)} triples")
Four modeling decisions are visible in that block, and each is a habit worth keeping.
Identifiers are opaque and stable. ex:MarieCurie is a name for a thing, not a label to display; the display string lives in rdfs:label. Renaming a person then changes one triple rather than every reference to them.
Types come from a published vocabulary. Using schema:Person rather than a local class means the graph can generate structured data for a website without a second mapping. See /ecosystems/schema-org/.
Literals carry data types and language tags. Literal("1867-11-07", datatype=XSD.date) is a date that SPARQL date functions can operate on; the same string untyped is not.
External identifiers are attached immediately. The schema:sameAs link to Wikidata Q7186 is what lets this graph join with any other graph that knows the same person. Adding it later means revisiting every entity.
g.serialize(format="turtle") returns Turtle as a string. rdflib also writes xml, json-ld, nt and n3, and g.parse() reads all of them back.
Step 3: Query the graph with SPARQL
rdflib runs SPARQL against an in-memory graph, so the query language works before any database exists. This block parses the Turtle from step 2 as a string, which makes it self-contained.
from rdflib import Graph
TTL = """
@prefix ex: <https://example.org/kg/> .
@prefix schema: <https://schema.org/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:MarieCurie a schema:Person ;
rdfs:label "Marie Curie"@en ;
schema:award ex:NobelPrizeInPhysics ;
schema:birthPlace ex:Warsaw .
ex:PierreCurie a schema:Person ;
rdfs:label "Pierre Curie"@en ;
schema:award ex:NobelPrizeInPhysics ;
schema:spouse ex:MarieCurie .
ex:NobelPrizeInPhysics rdfs:label "Nobel Prize in Physics"@en .
ex:Warsaw a schema:City ; rdfs:label "Warsaw"@en .
"""
g = Graph()
g.parse(data=TTL, format="turtle")
q = """
PREFIX schema: <https://schema.org/>
PREFIX rdfs: <http://www.w3.org/2000/01/rdf-schema#>
SELECT ?name ?awardName WHERE {
?person a schema:Person ;
rdfs:label ?name ;
schema:award ?award .
?award rdfs:label ?awardName .
}
ORDER BY ?name
"""
for row in g.query(q):
print(f"{row.name} -> {row.awardName}")
Results are bound by variable name, so row.name reads the ?name column. The query returns two rows, one per laureate. Query syntax, including the property paths and aggregates this example does not need, is covered at /languages/sparql/.
Step 4: The same graph in NetworkX
SPARQL answers pattern questions. It is a poor fit for questions about graph structure: which node is most central, how far apart two nodes are, which clusters exist. NetworkX answers those, and the conversion is a loop.
The conversion involves a choice. RDF triples whose object is a literal are properties of a node rather than edges between nodes, and rdf:type edges connect everything to a handful of class nodes, which distorts every centrality measure. Both are dropped here.
import networkx as nx
from rdflib import Graph, Literal
from rdflib.namespace import RDF, RDFS
TTL = """
@prefix ex: <https://example.org/kg/> .
@prefix schema: <https://schema.org/> .
@prefix rdfs: <http://www.w3.org/2000/01/rdf-schema#> .
ex:MarieCurie a schema:Person ;
rdfs:label "Marie Curie"@en ;
schema:award ex:NobelPrizeInPhysics ;
schema:birthPlace ex:Warsaw .
ex:PierreCurie a schema:Person ;
rdfs:label "Pierre Curie"@en ;
schema:award ex:NobelPrizeInPhysics ;
schema:spouse ex:MarieCurie .
ex:NobelPrizeInPhysics rdfs:label "Nobel Prize in Physics"@en .
ex:Warsaw a schema:City ; rdfs:label "Warsaw"@en .
"""
rdf_graph = Graph()
rdf_graph.parse(data=TTL, format="turtle")
label = {s: str(o) for s, _, o in rdf_graph.triples((None, RDFS.label, None))}
nxg = nx.DiGraph()
for s, p, o in rdf_graph:
if isinstance(o, Literal) or p == RDF.type:
continue
nxg.add_edge(label.get(s, str(s)), label.get(o, str(o)), predicate=str(p))
print(f"{nxg.number_of_nodes()} nodes, {nxg.number_of_edges()} edges")
for node, score in sorted(nx.pagerank(nxg).items(), key=lambda kv: kv[1], reverse=True):
print(f"{score:.3f} {node}")
print(nx.shortest_path(nxg.to_undirected(), "Warsaw", "Pierre Curie"))
PageRank on four nodes is a demonstration rather than an analysis, but the pattern scales unchanged: on a graph of tens of thousands of entities, centrality identifies the entities everything else hangs off, and shortest paths explain how two entities are connected. to_undirected() Matters for pathfinding, because a directed path from Warsaw to Pierre Curie does not exist while a connection between them does. Drawing the result is covered at /build/knowledge-graph-visualization/.
LLM-assisted extraction: what it changes
Language models are now the default first attempt at extracting a graph from prose, and the reason is narrow but real.
What they do better than a rule-based pipeline. Relation extraction is the main one: given a sentence and a list of permitted predicates, a model proposes subject-predicate-object triples without a hand-written pattern per relation, and it handles sentences where the relation is implied rather than stated. Coreference is the second: “She shared the 1903 prize” resolves to Marie Curie, which a mention-level recognizer cannot do. The third is tolerance of messy input, where inconsistent punctuation, nested clauses, and lists inside sentences break dependency-parse rules but not a model.
What they do worse. Consistency across documents is the central problem. The same relationship extracted from a hundred paragraphs comes back as worksFor, employedBy, affiliatedWith and memberOf unless the permitted vocabulary is fixed in advance and enforced afterward. Hallucinated relations are the second: a model asked to find connections will find connections, including ones the text does not support, and a plausible false triple is harder to catch than an obvious one. Third, models do not produce stable identifiers. Output names a string, not an entity, so every mention still has to be reconciled against a real identifier system.
The practical consequence is that model output is a candidate set, not graph content. It gets validated against a schema, checked against the source text, reconciled against identifiers, and only then loaded.
The shape of a structured extraction prompt
Constrain the vocabulary, require evidence, and permit refusal. The prompt below is a template, not a snippet to run, since it needs an API call to a model:
Extract entities and relationships from the passage.
Allowed entity types: Person, Organization, Place, Award.
Allowed predicates: birthPlace, alumniOf, spouse, award, memberOf.
Rules:
- Use only the listed types and predicates. If a relationship in the text
does not match one of them, omit it.
- Every relation must include an evidence field quoting the exact span of
the passage that states it.
- Do not infer relationships that the passage does not state.
- Return JSON matching the schema. Return empty arrays if nothing matches.
Passage:
<<<{passage}>>>
The output is expected to match a fixed schema:
{
"type": "object",
"required": ["entities", "relations"],
"properties": {
"entities": {
"type": "array",
"items": {
"type": "object",
"required": ["id", "name", "type"],
"properties": {
"id": { "type": "string" },
"name": { "type": "string" },
"type": { "enum": ["Person", "Organization", "Place", "Award"] }
}
}
},
"relations": {
"type": "array",
"items": {
"type": "object",
"required": ["subject", "predicate", "object", "evidence"],
"properties": {
"subject": { "type": "string" },
"predicate": {
"enum": ["birthPlace", "alumniOf", "spouse", "award", "memberOf"]
},
"object": { "type": "string" },
"evidence": { "type": "string", "minLength": 1 }
}
}
}
}
}
Requiring an evidence span does two jobs. It gives a reviewer something to check without rereading the whole document, and it lets you automatically reject a triple whose evidence string doesn’t appear in the source.
Validating the output before it reaches the graph
Schema conformance is necessary and not sufficient. The checks below run on the parsed JSON with no dependencies, and they catch the failure modes that matter: unknown types, unknown predicates, dangling references and missing evidence.
ALLOWED_TYPES = {"Person", "Organization", "Place", "Award"}
ALLOWED_RELATIONS = {"birthPlace", "alumniOf", "spouse", "award", "memberOf"}
def validate(payload):
errors = []
seen = {}
for i, ent in enumerate(payload.get("entities", [])):
if not isinstance(ent.get("id"), str) or not ent["id"]:
errors.append(f"entity {i}: missing id")
continue
if ent.get("type") not in ALLOWED_TYPES:
errors.append(f"entity {ent['id']}: unknown type {ent.get('type')!r}")
if ent["id"] in seen:
errors.append(f"entity {ent['id']}: duplicate id")
seen[ent["id"]] = ent
for i, rel in enumerate(payload.get("relations", [])):
if rel.get("predicate") not in ALLOWED_RELATIONS:
errors.append(f"relation {i}: unknown predicate {rel.get('predicate')!r}")
for end in ("subject", "object"):
if rel.get(end) not in seen:
errors.append(f"relation {i}: {end} {rel.get(end)!r} not in entities")
if not rel.get("evidence"):
errors.append(f"relation {i}: no evidence span")
return errors
sample = {
"entities": [
{"id": "e1", "name": "Marie Curie", "type": "Person"},
{"id": "e2", "name": "Warsaw", "type": "Place"},
{"id": "e3", "name": "Sorbonne", "type": "School"},
],
"relations": [
{"subject": "e1", "predicate": "birthPlace", "object": "e2",
"evidence": "Marie Curie was born in Warsaw"},
{"subject": "e1", "predicate": "discovered", "object": "e9", "evidence": ""},
],
}
for message in validate(sample):
print("REJECT:", message)
Running it rejects the School type, the discovered predicate, the reference to an entity e9 that was never declared, and the empty evidence field. All four are ordinary language model output, and all four would have entered the graph unnoticed without the check.
A further step belongs after validation and before loading: reconciliation, matching each accepted entity name to an identifier that already exists, whether that is a Wikidata Q-ID (/ecosystems/wikidata/) or an internal key. Once triples are in a store, running SHACL shapes over them catches structural problems that per-record validation misses. See /languages/shacl/.
Where to go from here
The in-memory approach on this page holds until the graph outgrows memory or needs concurrent access. At that point the triples move to a triple store or graph database without changing the model. The full pipeline, including scoping, ontology design and maintenance, is at /build/; the store options are at /build/knowledge-graph-tools/; and using the finished graph to ground a language model is at /build/graphrag/.
For developers
Keep extraction, validation, and loading as three separate stages with a checkable artifact between each, because a graph that can only be rebuilt end to end cannot be debugged. Write the ontology first even if it has four classes, and make the loader refuse any predicate not in it. Store the source document identifier and character offsets alongside every extracted triple, which costs almost nothing at load time and is the only way to answer “where did this fact come from” six months later.
For SEOs
A Python pipeline over a site’s own content produces the entity inventory that entity-focused optimization needs: which entities each page mentions, which are covered thoroughly and which appear once, and where the same entity is described inconsistently across templates. rdflib can then emit JSON-LD for those entities directly, with sameAs values drawn from the reconciliation step rather than typed by hand into a template. See /seo/entity-seo/ and /seo/sameas-schema/.
Related pages
- How to build a knowledge graph
- Entity linking, NER and extraction
- RDF
- SPARQL
- GraphRAG
- Knowledge graph tools
FAQ
How do you build a knowledge graph in Python?
Extract entity mentions from text with spaCy, decide which mentions refer to the same real thing and give each one a stable identifier, add triples for those entities with rdflib, and serialize to Turtle. Query the result with SPARQL through rdflib, and convert the same triples to a NetworkX DiGraph when centrality, clustering, or pathfinding is needed.
Which Python library is best for knowledge graphs?
They cover different jobs. rdflib handles RDF: triples, namespaces, serialization, and SPARQL. NetworkX handles graph algorithms such as PageRank, shortest paths, and community detection. spaCy handles the language processing that turns text into entity mentions. A typical pipeline uses all three rather than choosing between them.
Can an LLM build a knowledge graph from text?
It can propose one. Language models are good at relation extraction, coreference, and messy prose, and poor at consistency, at avoiding relations the text does not support, and at producing stable identifiers. Treat the output as candidate triples: constrain the permitted types and predicates in the prompt, require an evidence span, validate against a schema, and reconcile entities before loading.
How do you convert rdflib triples to NetworkX?
Iterate the rdflib Graph and add an edge for each triple, skipping objects that are Literals, since those are node properties rather than edges. Skipping rdf:type triples is usually right as well, because class nodes otherwise collect edges from every instance and distort centrality scores. Keep the predicate as an edge attribute.
Sources and further reading
- rdflib documentation: https://rdflib.readthedocs.io/
- NetworkX documentation: https://networkx.org/documentation/stable/
- spaCy linguistic features, named entities: https://spacy.io/usage/linguistic-features#named-entities
- W3C, RDF 1.1 Turtle: https://www.w3.org/TR/turtle/
- W3C, SPARQL 1.1 Query Language: https://www.w3.org/TR/sparql11-query/
- schema.org vocabulary: https://schema.org/
