Graph Conversion¶
Overview¶
Graph conversion transforms Pydantic models into NetworkX directed graphs, creating nodes for entities and edges for relationships. This is the foundation of knowledge graph creation.
In this guide:
- Conversion process
- Node and edge creation
- Node ID registry
- Graph validation
- Automatic cleanup
Conversion Process¶
High-Level Flow¶
%%{init: {'theme': 'redux-dark', 'look': 'default', 'layout': 'elk'}}%%
flowchart LR
%% 1. Define Classes
classDef input fill:#E3F2FD,stroke:#90CAF9,color:#0D47A1
classDef config fill:#FFF8E1,stroke:#FFECB3,color:#5D4037
classDef output fill:#E8F5E9,stroke:#A5D6A7,color:#1B5E20
classDef decision fill:#FFE0B2,stroke:#FFB74D,color:#E65100
classDef data fill:#EDE7F6,stroke:#B39DDB,color:#4527A0
classDef operator fill:#F3E5F5,stroke:#CE93D8,color:#6A1B9A
classDef process fill:#ECEFF1,stroke:#B0BEC5,color:#263238
%% 2. Define Nodes
A@{ shape: terminal, label: "Pydantic Models" }
B@{ shape: lin-proc, label: "Pre-register Models" }
C@{ shape: procs, label: "Create Nodes" }
D@{ shape: procs, label: "Create Edges" }
E@{ shape: tag-proc, label: "Auto Cleanup" }
F@{ shape: tag-proc, label: "Validate Graph" }
G@{ shape: tag-proc, label: "Calculate Stats" }
H@{ shape: doc, label: "NetworkX Graph" }
%% 3. Define Connections
A --> B
B --> C
C --> D
D --> E
E --> F
F --> G
G --> H
%% 4. Apply Classes
class A input
class B,C,D process
class E,F,G operator
class H output
GraphConverter¶
Basic Usage¶
from docling_graph.core import GraphConverter
# Create converter
converter = GraphConverter()
# Convert models to graph
graph, metadata = converter.pydantic_list_to_graph(models)
print(f"Created graph with {metadata.node_count} nodes and {metadata.edge_count} edges")
With Configuration¶
from docling_graph.core import GraphConverter
converter = GraphConverter(
add_reverse_edges=False, # Don't create bidirectional edges
validate_graph=True, # Validate structure
auto_cleanup=True # Remove phantom nodes
)
graph, metadata = converter.pydantic_list_to_graph(models)
Node Creation¶
What Becomes a Node?¶
Entities (models with is_entity=True) become nodes:
from pydantic import BaseModel
# ✅ Becomes a node
class Organization(BaseModel):
name: str
model_config = {"is_entity": True} # Default
# ❌ Does NOT become a node
class Address(BaseModel):
street: str
city: str
model_config = {"is_entity": False} # Component
Node Structure¶
# Node in graph
{
"id": "organization_acme_corp",
"label": "Organization",
"type": "entity",
"__class__": "Organization",
"name": "Acme Corp",
"address": None, # Reference to nested entity
"__provenance__": {"document_id": "...", "match": "verbatim", "chunks": [2], "pages": [1]},
}
__provenance__ is added by the data-grounding binder when provenance is not "off" (the default). Being a dunder attribute, it can never collide with a template field. See Data Grounding & Provenance.
Edge Creation¶
Automatic Edge Generation¶
Edges are created automatically from model relationships:
class BillingDocument(BaseModel):
document_no: str
issued_by: Organization # Creates edge: BillingDocument -> Organization
line_items: List[LineItem] # Creates edges: BillingDocument -> LineItem (multiple)
Edge Structure¶
# Edge in graph
{
"source": "invoice_001",
"target": "organization_acme_corp",
"label": "issued_by",
"properties": {}
}
Custom Edge Labels¶
from pydantic import BaseModel, Field
class BillingDocument(BaseModel):
issued_by: Organization = Field(
json_schema_extra={"edge_label": "ISSUED_BY"}
)
Result: Edge label becomes ISSUED_BY instead of issued_by
Node ID Registry¶
What is Node ID Registry?¶
The NodeIDRegistry ensures consistent, deterministic node IDs across multiple extractions.
How It Works¶
# Same entity always gets same ID
org1 = Organization(name="Acme Corp")
org2 = Organization(name="Acme Corp")
# Both get ID: "organization_acme_corp"
id1 = registry.get_node_id(org1)
id2 = registry.get_node_id(org2)
assert id1 == id2 # True
ID Generation¶
The registry's get_node_id() derives an ID from a content fingerprint — a blake2b hash of the model's identity fields, truncated to 16 hex characters — and formats it as "{ClassName}_{fingerprint}":
- Entities (have
graph_id_fieldsinmodel_config): the fingerprint is computed from those fields only, so two instances with the same identity field values always get the same ID regardless of other differing fields. - Components (
is_entity=False, nograph_id_fields): the fingerprint is computed from all non-empty scalar fields, so identical components are deduplicated by content.
This logic is internal to NodeIDRegistry — to change how a model's identity is derived, set graph_id_fields on that model's model_config rather than subclassing the registry (see Key Concepts: Node ID Generation).
Provenance Binding¶
pydantic_list_to_graph() accepts an optional provenance_binder — a callable that annotates each node with a __provenance__ attribute after edges are created but before automatic cleanup, so that when cleanup merges duplicate nodes, their provenance views are unioned rather than one being lost.
def pydantic_list_to_graph(
self,
model_instances: list[BaseModel],
provenance_binder: Callable[[nx.DiGraph, list[BaseModel]], None] | None = None,
) -> tuple[nx.DiGraph, GraphMetadata]:
The pipeline builds this closure automatically (using the same NodeIDRegistry instance the converter uses, so binding can never disagree on node IDs) whenever provenance is not "off"; GraphConverter itself has no dependency on the provenance module. See Data Grounding & Provenance for what the binder does and the resulting node attribute shape.
Graph Validation¶
Automatic Validation¶
Validation checks graph structure:
converter = GraphConverter(validate_graph=True)
graph, metadata = converter.pydantic_list_to_graph(models)
# Validates:
# - No isolated nodes
# - Valid node IDs
# - Valid edge connections
# - No self-loops (optional)
Manual Validation¶
from docling_graph.core.utils import validate_graph_structure
try:
validate_graph_structure(graph, raise_on_error=True)
print("✅ Graph structure valid")
except ValueError as e:
print(f"❌ Validation failed: {e}")
Automatic Cleanup¶
What Gets Cleaned?¶
Automatic cleanup removes:
- Phantom nodes - Nodes with no data
- Duplicate nodes - Same entity multiple times
- Orphaned edges - Edges to non-existent nodes
- Empty attributes - Null or empty values
Configuration¶
converter = GraphConverter(
auto_cleanup=True # Enable cleanup (default)
)
graph, metadata = converter.pydantic_list_to_graph(models)
Manual Cleanup¶
from docling_graph.core.utils import GraphCleaner
cleaner = GraphCleaner(verbose=True)
cleaned_graph = cleaner.clean_graph(graph)
print(f"Removed {graph.number_of_nodes() - cleaned_graph.number_of_nodes()} phantom nodes")
What was lost when a phantom is removed¶
Removing a phantom node also drops every edge that pointed to or from it — which means a relationship was lost, not just a node. GraphCleaner records each dropped edge as {"source", "label", "target"}:
cleaner.clean_graph(graph)
for rel in cleaner.last_dropped_relationships:
print(f"{rel['source']} -[{rel['label']}]-> {rel['target']}")
The same list is stashed on the graph itself (graph.graph["dropped_relationships"]), which is how the markdown report's Dropped Relationships section surfaces it without extra plumbing — useful for spotting which specific relationship a run silently lost, not just how many.
Complete Examples¶
📍 Basic Conversion¶
from docling_graph.core import GraphConverter
from my_templates import BillingDocument, Organization, LineItem
# Create sample models
models = [
BillingDocument(
document_no="INV-001",
issued_by=Organization(name="Acme Corp"),
line_items=[
LineItem(description="Product A", total=100),
LineItem(description="Product B", total=200)
],
total=300
)
]
# Convert to graph
converter = GraphConverter()
graph, metadata = converter.pydantic_list_to_graph(models)
print(f"Nodes: {metadata.node_count}")
print(f"Edges: {metadata.edge_count}")
print(f"Node types: {metadata.node_types}")
📍 With Reverse Edges¶
from docling_graph.core import GraphConverter
# Create bidirectional edges
converter = GraphConverter(add_reverse_edges=True)
graph, metadata = converter.pydantic_list_to_graph(models)
# Original edge: BillingDocument -> Organization (ISSUED_BY)
# Reverse edge: Organization -> Invoice (reverse_ISSUED_BY)
print(f"Total edges (with reverse): {metadata.edge_count}")
📍 Shared Registry for Batches¶
from docling_graph.core import GraphConverter
from docling_graph.core.converters.node_id_registry import NodeIDRegistry
# Create shared registry
registry = NodeIDRegistry()
# Convert first batch
converter1 = GraphConverter(registry=registry)
graph1, _ = converter1.pydantic_list_to_graph(batch1_models)
# Convert second batch (same registry)
converter2 = GraphConverter(registry=registry)
graph2, _ = converter2.pydantic_list_to_graph(batch2_models)
# Same entities get same IDs across batches
print(f"Registry has {registry.get_stats()['total_entities']} unique entities")
📍 Custom Configuration¶
add_reverse_edges and validate_graph are set directly on GraphConverter — they are read from these constructor arguments, not from the config object's matching-named fields:
from docling_graph.core import GraphConverter
converter = GraphConverter(add_reverse_edges=True, validate_graph=True)
graph, metadata = converter.pydantic_list_to_graph(models)
GraphConfig itself mainly carries fixed internal constants (NODE_ID_HASH_LENGTH, MAX_STRING_LENGTH, TRUNCATE_SUFFIX); pass a custom instance only if you need to override those:
from docling_graph.core import GraphConverter, GraphConfig
converter = GraphConverter(config=GraphConfig(), add_reverse_edges=True)
Graph Metadata¶
Metadata Structure¶
GraphMetadata is a frozen Pydantic model (docling_graph.core.converters.models), not a plain dataclass:
class GraphMetadata(BaseModel):
model_config = ConfigDict(frozen=True)
node_count: int
edge_count: int
node_types: Dict[str, int] = {}
edge_types: Dict[str, int] = {}
created_at: datetime = ... # defaults to now(), UTC
source_models: int
average_degree: float | None = None
Using Metadata¶
graph, metadata = converter.pydantic_list_to_graph(models)
print(f"Graph Statistics:")
print(f" Nodes: {metadata.node_count}")
print(f" Edges: {metadata.edge_count}")
print(f" Source models: {metadata.source_models}")
if metadata.average_degree is not None:
print(f" Avg degree: {metadata.average_degree:.2f}")
print(f"\nNode Types:")
for node_type, count in metadata.node_types.items():
print(f" {node_type}: {count}")
print(f"\nEdge Types:")
for edge_type, count in metadata.edge_types.items():
print(f" {edge_type}: {count}")
Advanced Features¶
Reverse Edges¶
Create bidirectional relationships:
converter = GraphConverter(add_reverse_edges=True)
graph, metadata = converter.pydantic_list_to_graph(models)
# For each edge A -> B, creates B -> A
# Useful for graph traversal in both directions
Custom Node Identity¶
NodeIDRegistry has no public subclass hook for ID generation — identity is controlled entirely by graph_id_fields on the model's model_config (see ID Generation above), not by overriding registry internals:
from pydantic import BaseModel
class Invoice(BaseModel):
model_config = {"graph_id_fields": ["invoice_number"]} # drives the ID fingerprint
invoice_number: str
total: float
To share ID assignment across separate GraphConverter calls (e.g. batch runs), pass the same NodeIDRegistry instance — see Shared Registry for Batches above.
Performance Optimization¶
Batch Processing¶
# Process large model lists efficiently
converter = GraphConverter(auto_cleanup=True)
# Convert in single call (efficient)
graph, metadata = converter.pydantic_list_to_graph(all_models)
# Don't convert one by one (inefficient)
# for model in models:
# graph, _ = converter.pydantic_list_to_graph([model])
Memory Management¶
# For very large graphs
converter = GraphConverter(
auto_cleanup=True, # Remove unnecessary nodes
validate_graph=False # Skip validation for speed
)
graph, metadata = converter.pydantic_list_to_graph(models)
# NodeIDRegistry has no in-place reset; start a new one (and a new
# GraphConverter) for the next, unrelated document instead of reusing it.
Troubleshooting¶
🐛 Empty Graph¶
Solution:
# Check if models have entities
for model in models:
if hasattr(model, 'model_config'):
is_entity = model.model_config.get('is_entity', True)
print(f"{model.__class__.__name__}: is_entity={is_entity}")
🐛 Missing Edges¶
Solution:
# Ensure relationships are defined
class BillingDocument(BaseModel):
issued_by: Organization # Must be typed as entity
# Not: issued_by: dict # Won't create edge
🐛 Duplicate Nodes¶
Solution:
# Enable auto cleanup
converter = GraphConverter(auto_cleanup=True)
graph, metadata = converter.pydantic_list_to_graph(models)
🐛 Validation Fails¶
Solution:
# Check graph structure
print(f"Nodes: {graph.number_of_nodes()}")
print(f"Edges: {graph.number_of_edges()}")
# Inspect nodes
for node_id, data in list(graph.nodes(data=True))[:5]:
print(f"Node: {node_id}, Data: {data}")
Best Practices¶
👍 Use Shared Registry for Batches¶
# ✅ Good - Consistent IDs across batches
registry = NodeIDRegistry()
for batch in batches:
converter = GraphConverter(registry=registry)
graph, _ = converter.pydantic_list_to_graph(batch)
👍 Enable Auto Cleanup¶
👍 Validate in Development¶
👍 Disable Validation in Production¶
Next Steps¶
Now that you understand graph conversion:
- Data Grounding & Provenance - Trace nodes back to source chunks and pages
- Export Formats - Export graphs to CSV, Cypher, JSON
- Visualization - Visualize your graphs
- Neo4j Integration - Import into Neo4j