Build Your First Knowledge Graph From Unstructured Documents Using Python
Learn how to convert a small set of unstructured engineering documents into a searchable knowledge graph using Python, spaCy, and NetworkX.
Join the DZone community and get the full member experience.
Join For FreeMany engineering teams currently face a knowledge challenge.
Information does exist; however, the information is distributed across various documentation formats such as design documents, runbooks, architectural notes, deployment guides, and incident reports. In general, a developer is aware of which services depend on each other (the Checkout Service depends upon the Payment API), the database or technology stack being used by the dependent services (the Payment API utilizes PostgreSQL), and who owns/operates the dependent service (Platform Team owns and operates the Payment API), however, these pieces of information typically reside in separate locations.
A traditional search capability can locate documents with references to those terms. Using a retrieval-augmented generation (RAG) solution allows retrieval of relevant fragments/chunks based on contextually relevant keywords provided to the RAG model, which can then be passed along to a large language model (LLM). That strategy is effective for answering most types of questions.
However, there are certain types of questions that are not simply about identifying the content of one document; they are about relating concepts.
For instance:
Which team is owns the service that 'Checkout' relies upon?
Relating all applicable data points is necessary when answering this type of question. To aid in the process of relating all applicable data points, a knowledge graph can become helpful.
This article describes building a very basic knowledge graph from unformatted text using Python.
As described above, keeping the example as simple as possible, but again, this is essentially how you would implement your own GraphRAG system: Identify entities from text, determine how those entities relate to each other, represent those relations as edges within a graph structure, and query that graph for relevant data prior to generating an answer.
What We Are Building
We will start with a few short engineering notes:
Checkout Service depends on Payment API.
Payment API uses PostgreSQL.
Platform Team owns Payment API.
Recommendation Service calls Catalog API.
Catalog API uses Elasticsearch.
Search Team owns Catalog API.
From those notes, we want to build a graph like this:
Checkout Service --DEPENDS_ON--> Payment API
Payment API --USES--> PostgreSQL
Platform Team --OWNS--> Payment API
Recommendation Service --CALLS--> Catalog API
Catalog API --USES--> Elasticsearch
Search Team --OWNS--> Catalog API
Once we have that structure, we can answer questions by traversing the graph instead of scanning raw text.
Project Setup
Create a new folder:
mkdir python-knowledge-graph
cd python-knowledge-graph
Create a requirements.txt file:
networkx==3.3
spacy==3.7.5
Install the dependencies:
pip install -r requirements.txt
python -m spacy download en_core_web_sm
We will use:
- spaCy for basic Natural Language Processing (NLP)
- NetworkX for building and querying the graph
For this first example, we will not use a database. Keeping everything in memory makes the workflow easier to understand.
Step 1: Identify The Input Documents
Create a new file called build_graph.py.
documents = ["Checkout Service depends on Payment API.",
"Payment API uses PostgreSQL.",
"Platform Team owns Payment API.",
"Recommendation Service calls Catalog API.",
"Catalog API uses Elasticsearch.",
"Search Team owns Catalog API.",
]
In a real-world deployment, the input document could have originated from a variety of sources (e.g., Markdown files, Confluence pages, GitHub repositories, service catalogs, incident reports). In this case, a couple of lines of example text should be sufficient to illustrate the concept.
Step 2: Determine Relationship Triples
Knowledge graphs typically store information in triple format (the subject, its relationship with another entity, and that other entity):
subject ---Relationship---> object
An example would be:
Checkout Service --DEPENDS_ON--> Payment API
In general, relationship detection in a large-scale application is often performed by a trained machine learning model. For demonstration purposes in this post, we'll utilize a simple rule-based detector to keep things straightforward.
Add the following to build_graph.py:
import re
RELATION_PATTERNS = [
(r"(.+?) depends on (.+?)\.", "DEPENDS_ON"),
(r"(.+?) uses (.+?)\.", "USES"),
(r"(.+?) owns (.+?)\.", "OWNS"),
(r"(.+?) calls (.+?)\.", "CALLS"),
]
def extract_triples(text):
triples = []
for pattern, relation in RELATION_PATTERNS:
match = re.match(pattern, text, re.IGNORECASE)
if match:
subject = normalize_entity(match.group(1))
object_ = normalize_entity(match.group(2))
triples.append((subject, relation, object_))
return triples
def normalize_entity(value):
return value.strip()
This function is intentionally simple. It looks for a small set of verbs and converts each sentence into a graph-friendly structure.
Try it:
for doc in documents:
print(extract_triples(doc))
Expected output:
[('Checkout Service', 'DEPENDS_ON', 'Payment API')]
[('Payment API', 'USES', 'PostgreSQL')]
[('Platform Team', 'OWNS', 'Payment API')]
[('Recommendation Service', 'CALLS', 'Catalog API')]
[('Catalog API', 'USES', 'Elasticsearch')]
[('Search Team', 'OWNS', 'Catalog API')]
This is the first useful step. We have converted unstructured text into structured facts.
Step 3: Create a Graph With NetworkX
We can now add those triplets into a directed graph.
import networkx as nx
def build_knowledge_graph(documents):
graph = nx.DiGraph()
for doc in documents:
triples = extract_triples(doc)
for subject, relation, object_ in triples:
graph.add_node(subject)
graph.add_node(object_)
graph.add_edge(subject, object_, relation=relation, source_text=doc)
return graph
The use of a directed graph makes sense when dealing with relations that are directional.
This:
Checkout Service --DEPENDS_ON--> Payment API
does not mean the same thing as this:
Payment API --DEPENDS_ON--> Checkout Service
Direction matters for dependency analysis, ownership lookup, impact analysis, and retrieval.
Step 4: Print the Graph
Add a helper function:
def print_graph(graph):
for source, target, data in graph.edges(data=True):
relation = data["relation"]
print(f"{source} --{relation}--> {target}")
Now run the full flow:
if __name__ == "__main__":
graph = build_knowledge_graph(documents)
print_graph(graph)
Output:
Checkout Service --DEPENDS_ON--> Payment API
Payment API --USES--> PostgreSQL
Platform Team --OWNS--> Payment API
Recommendation Service --CALLS--> Catalog API
Catalog API --USES--> Elasticsearch
Search Team --OWNS--> Catalog API
At this point, we have a working knowledge graph.
It is small, but it already gives us something normal keyword search does not: explicit relationships.
Step 5: Query the Graph
Let’s answer a practical question:
Who owns the API that Checkout Service depends on?
That requires two hops:
Checkout Service -> Payment API -> Platform Team
The first hop finds the dependency. The second hop finds the owner.
Add this function:
def find_owner_of_dependency(graph, service_name):
results = []
for dependency in graph.successors(service_name):
edge_data = graph.get_edge_data(service_name, dependency)
if edge_data["relation"] != "DEPENDS_ON":
continue
for possible_owner in graph.predecessors(dependency):
owner_edge = graph.get_edge_data(possible_owner, dependency)
if owner_edge["relation"] == "OWNS":
results.append(
{
"service": service_name,
"dependency": dependency,
"owner": possible_owner,
}
)
return results
Call it:
owners = find_owner_of_dependency(graph, "Checkout Service")
for item in owners:
print(
f"{item['owner']} owns {item['dependency']}, "
f"which is used by {item['service']}."
)
Output:
Platform Team owns Payment API, which is used by Checkout Service.
This is a simple example, but it shows the main value of graph-based retrieval. We did not search for similar text. We followed relationships.
Step 6. Save the Graph
When building a small prototype, you can often save the graph as JSON.
Here’s how to do that.
import json
def export_graph(graph, output_path):
data = {
"nodes": list(graph.nodes()),
"edges": [
{
"source": source,
"target": target,
"relation": edge_data["relation"],
"source_text": edge_data["source_text"],
}
for source, target, edge_data in graph.edges(data=True)
],
}
with open(output_path, "w", encoding="utf-8") as file:
json.dump(data, file, indent=2)
export_graph(graph, "graph.json")
The output looks like this:
{
"nodes": [
"Checkout Service",
"Payment API",
"PostgreSQL",
"Platform Team",
"Recommendation Service",
"Catalog API",
"Elasticsearch",
"Search Team"
],
"edges": [
{
"source": "Checkout Service",
"target": "Payment API",
"relation": "DEPENDS_ON",
"source_text": "Checkout Service depends on Payment API."
}
]
}
Keeping the original source_text when saving the graph is important. This is because if you were to plug this graph into either a RAG or GraphRAG pipeline, there needs to be some way for the LLM to see not just the relationship within the graph but also have access to the actual supporting text for each one.
Where spaCy Fits In
Regular expressions were used in the previous example because of how simple your examples were (you know exactly what will appear). However, most real-world documentation is not as straightforward.
This is why we use spaCy. It can find all types of named entities; e.g., organization, product, person, etc.
Below is a small sample:
import spacy
nlp = spacy.load("en_core_web_sm")
text = "Platform Team owns Payment API, which uses PostgreSQL."
doc = nlp(text)
for entity in doc.ents:
print(entity.text, entity.label_)
As with many things with regard to NLP, depending upon your specific model and the input data you provide, spaCy may automatically find some entities. However, generally speaking, you will need to either develop custom rules or train your own model to extract certain types of entities that pertain to specific domains of interest (e.g., engineering-related terms such as internal services, APIs, teams, etc.).
In practice, one common method to take advantage of this is to use a combination of approaches:
Use spaCy for general entity extraction.
Use rule-based patterns for known engineering relationships.
Use an LLM only when the relationship cannot be extracted reliably with simpler methods.
Using a combination of these methods provides a way to keep costs and complexity at reasonable levels.
How This Connects to GraphRAG Pipeline
GraphRAG (graph retrieval-augmented generation) uses a graph during the retrieval phase prior to generating an answer using an LLM.
Documents
|
Entity and relationship extraction
|
Knowledge graph
|
Graph traversal
|
Supporting text
|
Large Language Model
|
Answer
This article implements the graph construction and traversal steps only; LLM integration is outside the scope of this example.
The graph is used instead of traditional RAG in many cases. However, it is used most often when the question relates to entities that have some form of relationship to one another.
Good fit:
Which services are indirectly affected by a Payment API outage?
Usually not worth the extra complexity:
What is the timeout value for Payment API?
For simple fact lookup, vector search may be enough. For dependency, ownership, impact analysis, and multi-hop questions, graph retrieval can add real value.
Complete Example
Here is the complete script:
import json
import re
import networkx as nx
documents = [
"Checkout Service depends on Payment API.",
"Payment API uses PostgreSQL.",
"Platform Team owns Payment API.",
"Recommendation Service calls Catalog API.",
"Catalog API uses Elasticsearch.",
"Search Team owns Catalog API.",
]
RELATION_PATTERNS = [
(r"(.+?) depends on (.+?)\.", "DEPENDS_ON"),
(r"(.+?) uses (.+?)\.", "USES"),
(r"(.+?) owns (.+?)\.", "OWNS"),
(r"(.+?) calls (.+?)\.", "CALLS"),
]
def normalize_entity(value):
return value.strip()
def extract_triples(text):
triples = []
for pattern, relation in RELATION_PATTERNS:
match = re.match(pattern, text, re.IGNORECASE)
if match:
subject = normalize_entity(match.group(1))
object_ = normalize_entity(match.group(2))
triples.append((subject, relation, object_))
return triples
def build_knowledge_graph(documents):
graph = nx.DiGraph()
for doc in documents:
triples = extract_triples(doc)
for subject, relation, object_ in triples:
graph.add_node(subject)
graph.add_node(object_)
graph.add_edge(subject, object_, relation=relation, source_text=doc)
return graph
def find_owner_of_dependency(graph, service_name):
results = []
for dependency in graph.successors(service_name):
edge_data = graph.get_edge_data(service_name, dependency)
if edge_data["relation"] != "DEPENDS_ON":
continue
for possible_owner in graph.predecessors(dependency):
owner_edge = graph.get_edge_data(possible_owner, dependency)
if owner_edge["relation"] == "OWNS":
results.append(
{
"service": service_name,
"dependency": dependency,
"owner": possible_owner,
}
)
return results
def export_graph(graph, output_path):
data = {
"nodes": list(graph.nodes()),
"edges": [
{
"source": source,
"target": target,
"relation": edge_data["relation"],
"source_text": edge_data["source_text"],
}
for source, target, edge_data in graph.edges(data=True)
],
}
with open(output_path, "w", encoding="utf-8") as file:
json.dump(data, file, indent=2)
if __name__ == "__main__":
graph = build_knowledge_graph(documents)
for source, target, data in graph.edges(data=True):
print(f"{source} --{data['relation']}--> {target}")
owners = find_owner_of_dependency(graph, "Checkout Service")
for item in owners:
print(
f"{item['owner']} owns {item['dependency']}, "
f"which is used by {item['service']}."
)
export_graph(graph, "graph.json")
Run it:
python build_graph.py
Production Considerations
This example is intentionally small. In a real system, the hardest part is not creating the graph. It is keeping the graph clean.
A few things matter quickly:
- Entity normalization: Payment API, payment-api, and Payments API may all refer to the same system.
- Relationship quality: Bad relationships are worse than missing relationships because they lead retrieval in the wrong direction.
- Source tracking: Every edge should preserve where it came from. This helps with debugging, trust, and answer citation.
- Incremental updates: Rebuilding the entire graph every time a document changes is usually wasteful.
- Storage choice: NetworkX is excellent for local prototypes. For larger graphs, use a graph database such as Neo4j or another persistent graph store.
Key Takeaways
A knowledge graph is a practical way to represent relationships hidden inside documents.
You do not need a complex architecture to get started. A small Python script can extract triples, build a graph, and answer multi-hop questions.
Graph-based retrieval is most useful when the answer depends on connections between entities. It is less useful for simple lookup questions where traditional search already works well.
The foundation of a good GraphRAG system is not the LLM prompt. It is the quality of the entities, relationships, and supporting evidence in the graph.
Try It Yourself
Add these two documents:
Checkout Service runs on Kubernetes.
Platform Team manages Kubernetes.
Then add a new relationship type called RUNS_ON.
Update the query function to answer:
Who manages the platform that Checkout Service runs on?
This small exercise will help you see why graph traversal becomes useful as relationships grow.
Opinions expressed by DZone contributors are their own.
Comments