Back openDesk Edu for a sovereign, open-source education β every vote counts.
Vote nowKnowledge graphs are only as valuable as the data they contain. Querying, visualising, and reasoning over a graph are well-understood problems β but the hardest engineering challenge remains the first mile: how do you populate a knowledge graph from raw, unstructured text?
Most enterprises sit on terabytes of documents β PDFs, emails, internal wikis, support tickets, news articles β all containing entities and relationships that never make it into a structured database. Extracting that structure at scale is the critical bottleneck.
This article walks through the end-to-end pipeline for constructing a knowledge graph from unstructured text, from document ingestion through entity extraction, relationship inference, entity resolution, and graph population. We compare traditional NLP approaches with modern LLM-based pipelines and give you a decision framework for when each makes sense.
A production knowledge graph construction pipeline has six stages:
Each stage introduces design decisions that ripple through the entire pipeline. Let us examine each in turn.
The ingestion layer normalises diverse input formats into a uniform representation. A common pattern stores each document as a :Document node with its text content, metadata, and chunked passages as :ContentChunk children:
(:Document {title, source, date})-[:CONTAINS]->(:ContentChunk {text, index})
Chunking strategy matters. Overlapping chunks with sentence boundaries (not fixed token counts) preserve entity context across chunks, which is especially important when entities span paragraph breaks. A sliding window of 256 tokens with 32-token overlap strikes a good balance for most domains.
Entity extraction is where most engineering effort concentrates. Two approaches dominate in 2026:
Fine-tuned NER models (SpaCy, GLiNER, or BERT-based) offer fast, cheap extraction for well-defined entity types:
import spacy
nlp = spacy.load("en_core_web_trf")
doc = nlp("Neo4j announced GraphRAG support at GraphSummit London.")
for ent in doc.ents:
print(f"{ent.text} -> {ent.label_}")
# Neo4j -> ORG
# GraphRAG -> PRODUCT
# GraphSummit London -> EVENT
Traditional NER excels at precision for fixed types (Person, Organisation, Location) but struggles with domain-specific entities β API names, CVE identifiers, product versions β without costly fine-tuning.
LLMs can extract arbitrary entity types from any domain without fine-tuning:
import openai
def extract_entities(text: str, entity_types: list[str]) -> list[dict]:
response = openai.chat.completions.create(
model="gpt-4o",
messages=[{
"role": "system",
"content": f"Extract JSON entities: {entity_types}"
}, {
"role": "user",
"content": text
}]
)
return json.loads(response.choices[0].message.content)
The trade-off is cost and latency β LLM-based extraction is 10β100Γ more expensive per entity than traditional NER. The pragmatic approach is a hybrid pipeline: use traditional NER for high-volume standard entities and route only ambiguous or domain-specific cases to an LLM.
Once entities are extracted, you need edges between them. Three common strategies offer different trade-offs:
| Strategy | Approach | Precision | Recall | Cost |
|---|---|---|---|---|
| Co-occurrence | Entities in same sentence β edge | Low | High | Free |
| Dependency parsing | Subject-verb-object extraction | Medium | Medium | Low |
| LLM prompting | Extract relationships explicitly | High | High | High |
Co-occurrence is the simplest heuristic: if two entities appear in the same sentence, create an edge. This produces dense graphs with high recall but low precision β most co-occurring entities are related in the broadest sense but not in a semantically meaningful way.
Dependency parsing uses grammatical structure to extract subject-verb-object triples from each sentence. For "Neo4j acquired a startup," dependency parsing identifies Neo4j as the subject, acquired as the verb, and startup as the object, yielding (:Entity {name: "Neo4j"})-[:ACQUIRED]->(:Entity {name: "startup"}).
LLM-based extraction produces the highest-quality relationships but at the highest cost. A sensible middle ground: use dependency parsing as a first pass, then task an LLM with validating and refining only the extracted triples that fall below a confidence threshold.
Entity resolution β also called deduplication or record linkage β is the most underestimated component of KG construction. A graph built without deduplication quickly becomes unusable as the same real-world entity accrues multiple nodes with slight name variations.
Resolution techniques ranked by sophistication:
In production, a rule-based blocker followed by an embedding similarity scorer with a tunable threshold captures 90% or more of duplicates at acceptable latency.
With entities resolved and relationships inferred, you load the graph into Neo4j. Use periodic batching with MERGE to avoid duplicating nodes:
:auto USING PERIODIC COMMIT 500
LOAD CSV FROM 'file:///entities.csv' AS row
MERGE (e:Entity {id: row.id})
ON CREATE SET e.name = row.name,
e.type = row.type,
e.source = row.source
For relationships, batch via UNWIND:
UNWIND $relationships AS rel
MATCH (a:Entity {id: rel.source_id})
MATCH (b:Entity {id: rel.target_id})
MERGE (a)-[r:HAS_RELATIONSHIP {type: rel.type}]->(b)
SET r.confidence = rel.confidence,
r.evidence = rel.evidence
Always store a confidence score and evidence trace (source document ID, sentence) on each relationship. These enable downstream quality filtering and auditability β critical for the regulated use cases discussed in Knowledge Graphs Are the Antidote to AI Hallucination Liability.
A pipeline without QA metrics is one that silently degrades. Track these four signals:
For ontology-aware validation, apply the principles from Ontology in Graph Databases: your construction pipeline should honour the node labels, property constraints, and relationship types defined by your ontology. When the pipeline produces a node that does not match the schema, it should either be transformed or flagged for review.
Building the extraction logic is only half the work. The other half is running it reliably on a schedule, handling failures, and processing only what has changed.
Most documents in an enterprise corpus are static β once ingested, they rarely change. A full rebuild on every run wastes tokens and time. Design your pipeline for incremental processing:
| Strategy | Trigger | Use Case |
|---|---|---|
| Timestamp-based | Compare lastModified against the last pipeline run | Files on S3, network drives |
| Event-driven | Webhook or SQS notification on upload | Real-time ingestion from document management systems |
| Watermark table | Track processed document IDs in a separate PipelineState node in Neo4j | General-purpose; survives restarts |
| Full rebuild | Reprocess every document | After ontology changes or extraction model upgrades |
A simple watermark approach stores pipeline state directly in the graph:
MERGE (p:PipelineState {name: "kg-construction"})
ON MATCH SET p.lastRunAt = datetime(), p.documentsProcessed = $count
ON CREATE SET p.lastRunAt = datetime(), p.documentsProcessed = $count
A typical KG construction pipeline expressed as a Prefect or Airflow DAG has five tasks:
from prefect import flow, task
@task
def ingest_documents(source_path: str) -> list[dict]:
"""List and chunk documents modified since last run."""
...
@task
def extract_entities(chunks: list[dict]) -> list[dict]:
"""Run NER or LLM extraction on each chunk."""
...
@task
def resolve_entities(entities: list[dict]) -> list[dict]:
"""Deduplicate and merge entity references."""
...
@task
def write_graph(entities: list[dict], relations: list[dict]) -> None:
"""Batch-write to Neo4j with MERGE."""
...
@task
def run_quality_checks() -> dict:
"""Validate precision, recall, and schema compliance."""
...
@flow(log_prints=True)
def kg_pipeline(source_path: str = "/data/documents"):
chunks = ingest_documents(source_path)
entities = extract_entities(chunks)
resolved = resolve_entities(entities)
write_graph(entities, relations)
quality = run_quality_checks()
print(f"Quality report: {quality}")
Each task should be idempotent: running it twice produces the same result. Idempotency lets you retry failed stages without duplicating entities. The MERGE operations in the graph population stage already enforce this at the database level β the orchestration layer just needs to pass the same data twice.
The most common failure modes in KG construction pipelines are API rate limits (LLM providers), malformed documents (corrupted PDFs), and transient database connection drops. Handle each with a circuit breaker pattern: retry up to three times with exponential backoff, then route the failed document to a dead-letter queue for manual inspection.
LLM-based extraction dominates the pipeline budget. A pipeline processing 10,000 documents through GPT-4o can easily run up thousands of dollars in a single run. Three optimisation strategies keep costs under control.
Route each chunk through a decision tree rather than sending everything to the most expensive model:
Chunk β Cheap NER (spaCy/GLiNER)
ββ Entities found with confidence > 0.9 β Accept directly
ββ Entities found, confidence 0.5β0.9 β Send to gpt-4o-mini for validation
ββ No entities found β Send to gpt-4o for extraction
This pattern reduces LLM calls by 60β80% in practice. The cheap NER model handles high-confidence extractions for standard entity types; the LLM only sees ambiguous cases and domain-specific concepts.
LLM calls are deterministic for the same input at temperature 0. Cache extraction results keyed by a hash of the input chunk text plus the extraction schema:
import hashlib
import diskcache as dc
cache = dc.Cache("/tmp/kg-extraction-cache")
def cached_extract(text: str, schema_hash: str) -> dict:
key = hashlib.sha256((text + schema_hash).encode()).hexdigest()
if key in cache:
return cache[key]
result = llm_extract(text) # expensive call
cache[key] = result
return result
When re-running the pipeline after a schema change, invalidate only entries with the old schema hash. This avoids re-extracting documents whose entities are unaffected by the change.
LLM providers charge per input and output token regardless of whether you send one chunk or ten, up to the context window limit. Batch multiple chunks into a single request with structured output requesting an array of entity sets:
response = openai.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "system",
"content": "Extract entities from each chunk. Return a JSON array."
}, {
"role": "user",
"content": "\n---CHUNK BREAK---\n".join(chunks)
}],
response_format={"type": "json_object"}
)
Batching 5β10 chunks per request cuts API costs by 40β60% and improves throughput proportionally, with negligible quality degradation when chunks share a document context.
Knowledge graphs are living artefacts. Your ontology will change, extraction models will improve, and new document sources will appear. A versioning strategy prevents the graph from becoming an inconsistent mess.
Tag every extracted entity and relationship with the pipeline version that produced it:
MERGE (e:Entity {id: $id})
SET e.pipelineVersion = $version,
e.extractedAt = datetime()
This enables three critical operations:
pipelineVersion older than N days can be flagged for re-extraction.When your ontology adds a new node label or relationship type, existing entities should be backfilled rather than re-extracted. Define migration scripts that transform the existing graph:
// Example: Split a generic :Entity label into typed labels
MATCH (e:Entity)
WHERE e.type = "Company"
CALL {
WITH e
SET e:Company
REMOVE e.type
} IN TRANSACTIONS OF 1000 ROWS;
Automate these migrations in your orchestration framework β they are database operations, not extraction tasks, and should run as a separate step after the extraction pipeline completes.
| Factor | Traditional NLP | LLM-Based | Hybrid |
|---|---|---|---|
| Entity coverage | Limited to trained types | Unlimited | Both |
| Per-entity cost | Pennies per million | Cents per hundred | Tiered |
| Latency | Milliseconds | Seconds | Configurable |
| Precision | High (known types) | High (good prompts) | High |
| Recall | Low (misses domain entities) | High | High |
| Setup complexity | Fine-tuning pipeline | Prompt engineering | Both |
For most production KG construction workloads in 2026, the hybrid approach wins: fast traditional NER for high-volume standard entities, LLM-based extraction for domain-specific concepts and complex relationship inference, and a rule-based entity resolution layer that keeps the graph clean.
Building a knowledge graph from unstructured data is not a one-shot ETL job β it is an iterative process that improves as you add better extraction models, refine your ontology, and accumulate more training data for entity resolution. Start with a simple pipeline (co-occurrence plus exact match), measure quality, then layer in sophistication where the quality gap is widest.
The operational layers β orchestration, cost optimisation, versioning, and monitoring β are what separate a one-time extraction script from a production pipeline. Without them, your knowledge graph will drift as schemas evolve, costs spiral, and failures go undetected. Invest in them from day one, even if the initial pipeline handles only a few hundred documents.
For a deeper dive into the ontology design that your construction pipeline should target, see Ontology in Graph Databases. For a practical walkthrough using the neo4j-graphrag Python package, read Knowledge Graph Construction from Unstructured Data: A Practical Pipeline. And for how the finished graph powers AI applications, read Neo4j + AI: Building Intelligent Applications.
neo4j-graphrag package