Construir un Knowledge Graph desde Texto con LLMs: Pipeline Completo
Transforma datos no estructurados en grafos de conocimiento interactivos usando Python y modelos de lenguaje
¿Por Qué Knowledge Graphs?
Los datos no estructurados (artículos, documentos, biografías) contienen información valiosa, pero difícil de consultar programáticamente. Un Knowledge Graph (KG) estructura esa información como una red de entidades conectadas por relaciones, permitiendo:
- Consultas tipo “¿Qué descubrió Marie Curie?”
- Navegación visual de conexiones entre conceptos
- Inferencia de nuevos hechos a partir de relaciones existentes
- Integración con sistemas de RAG (Retrieval-Augmented Generation)
Este artículo presenta un pipeline completo que usa LLMs para extraer automáticamente hechos de texto y construir un Knowledge Graph interactivo.
El Concepto: Triples SPO
La unidad fundamental de un Knowledge Graph es el triple SPO (Subject-Predicate-Object):
Cada hecho del texto se descompone en tres partes:
| Componente | Rol | Ejemplo |
|---|---|---|
| Subject | La entidad principal | marie curie |
| Predicate | La relación/acción | discovered |
| Object | La entidad relacionada | radium |
Esta estructura se mapea directamente al grafo:
- Subject y Object → Nodos
- Predicate → Edge dirigido (con label)
Arquitectura del Pipeline
El proceso completo sigue estos pasos:
Resumen de etapas:
- Input: Texto no estructurado (cualquier documento)
- Chunking: Dividir en fragmentos manejables con overlap
- Extracción LLM: Enviar cada chunk al LLM con prompt SPO
- Normalización: Limpiar, lowercase, deduplicar triples
- Construcción: Crear el grafo con NetworkX
- Visualización: Renderizar interactivamente
Setup: Dependencias
pip install openai networkx ipycytoscape ipywidgets pandas
import openai
import json
import networkx as nx
import ipycytoscape
import pandas as pd
import os
import re
Configuración del LLM
El pipeline es compatible con cualquier proveedor que use la API de OpenAI:
# Variables de entorno
# export OPENAI_API_KEY='tu-api-key'
# export OPENAI_API_BASE='https://api.openai.com/v1' # Opcional
api_key = os.getenv("OPENAI_API_KEY")
base_url = os.getenv("OPENAI_API_BASE") # None para OpenAI estándar
# Crear cliente
client = openai.OpenAI(
api_key=api_key,
base_url=base_url
)
# Configuración
llm_model = "gpt-4o" # o "claude-3-sonnet", "deepseek-v3", etc.
llm_temperature = 0.0 # Determinístico para extracción
llm_max_tokens = 4096
Opciones de modelo:
- OpenAI:
gpt-4o,gpt-4o-mini - Anthropic:
claude-3-5-sonnet(via API compatible) - Local:
ollamacon cualquier modelo - Otros: DeepSeek, Mistral, etc.
Paso 1: Texto de Entrada
Para este ejemplo, usaremos una biografía de Marie Curie:
texto = """
Marie Curie, born Maria Skłodowska in Warsaw, Poland, was a pioneering
physicist and chemist. She conducted groundbreaking research on radioactivity.
Together with her husband, Pierre Curie, she discovered the elements polonium
and radium. Marie Curie was the first woman to win a Nobel Prize, the first
person and only woman to win the Nobel Prize twice, and the only person to
win the Nobel Prize in two different scientific fields. She won the Nobel
Prize in Physics in 1903 with Pierre Curie and Henri Becquerel. Later, she
won the Nobel Prize in Chemistry in 1911 for her work on radium and polonium.
"""
print(f"Palabras: {len(texto.split())}")
# Palabras: ~120
Paso 2: Chunking con Overlap
Los LLMs tienen límites de contexto. Dividir el texto en chunks permite procesar documentos largos, y el overlap preserva contexto entre fragmentos:
def chunk_text(text: str, chunk_size: int = 150, overlap: int = 30) -> list:
"""
Divide texto en chunks con overlap.
Args:
text: Texto a dividir
chunk_size: Palabras por chunk
overlap: Palabras de solapamiento entre chunks
Returns:
Lista de dicts con 'text' y 'chunk_number'
"""
words = text.split()
chunks = []
start = 0
chunk_num = 1
while start < len(words):
end = min(start + chunk_size, len(words))
chunk_text = " ".join(words[start:end])
chunks.append({
"text": chunk_text,
"chunk_number": chunk_num
})
# Siguiente chunk con overlap
next_start = start + chunk_size - overlap
if next_start <= start:
next_start = start + 1
start = next_start
chunk_num += 1
# Safety: evitar loops infinitos
if chunk_num > len(words):
break
return chunks
# Aplicar chunking
chunks = chunk_text(texto, chunk_size=150, overlap=30)
print(f"Chunks generados: {len(chunks)}")
# Visualizar
for c in chunks:
words = len(c['text'].split())
print(f" Chunk {c['chunk_number']}: {words} palabras")
Output:
Chunks generados: 1
Chunk 1: 120 palabras
Para textos cortos, puede resultar en un solo chunk. En documentos largos, verás múltiples chunks con el overlap preservando contexto.
Paso 3: Prompt de Extracción SPO
El prompt es crítico. Debe especificar exactamente el formato de salida esperado:
SYSTEM_PROMPT = """
You are an AI expert specialized in knowledge graph extraction.
Your task is to identify and extract factual Subject-Predicate-Object (SPO)
triples from the given text.
Focus on accuracy and adhere strictly to the JSON output format requested.
"""
USER_PROMPT_TEMPLATE = """
Extract Subject-Predicate-Object (S-P-O) triples from the text below.
**RULES:**
1. Output ONLY a valid JSON array. Each element must have keys:
"subject", "predicate", "object"
2. NO text before or after the JSON. NO markdown code fences.
3. Keep predicates concise (1-3 words, verbs preferred)
4. ALL values must be LOWERCASE
5. Replace pronouns (she, he, it) with the actual entity name
6. Be specific (e.g., "nobel prize in physics" not just "nobel prize")
7. Extract ALL distinct factual relationships
**Text:**
{text_chunk}
**Required format:**
[
{{"subject": "entity1", "predicate": "relation", "object": "entity2"}},
...
]
Your JSON:
"""
Reglas clave explicadas:
| Regla | Razón |
|---|---|
| Solo JSON | Facilita parsing automático |
| Lowercase | Normalización para deduplicación |
| Resolver pronombres | Evita “she discovered” sin saber quién es “she” |
| Predicados concisos | Grafos más limpios y navegables |
| Especificidad | Preserva información importante |
Paso 4: Extracción con el LLM
def extract_triples_from_chunk(client, chunk: dict, model: str) -> list:
"""
Extrae triples SPO de un chunk usando el LLM.
Returns:
Lista de triples validados con 'chunk' source
"""
prompt = USER_PROMPT_TEMPLATE.format(text_chunk=chunk['text'])
try:
response = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": prompt}
],
temperature=0.0,
max_tokens=4096
)
raw = response.choices[0].message.content.strip()
except Exception as e:
print(f"Error en chunk {chunk['chunk_number']}: {e}")
return []
# Parsear JSON
try:
data = json.loads(raw)
if isinstance(data, dict):
# Algunos LLMs devuelven {"triples": [...]}
data = next((v for v in data.values() if isinstance(v, list)), [])
except json.JSONDecodeError:
# Fallback: buscar array con regex
match = re.search(r'\[.*\]', raw, re.DOTALL)
if match:
try:
data = json.loads(match.group())
except:
return []
else:
return []
# Validar estructura
valid_triples = []
for t in data:
if isinstance(t, dict):
s = t.get('subject', '')
p = t.get('predicate', '')
o = t.get('object', '')
if all(isinstance(x, str) and x.strip() for x in [s, p, o]):
valid_triples.append({
'subject': s,
'predicate': p,
'object': o,
'chunk': chunk['chunk_number']
})
return valid_triples
# Procesar todos los chunks
all_triples = []
for chunk in chunks:
triples = extract_triples_from_chunk(client, chunk, llm_model)
all_triples.extend(triples)
print(f"Chunk {chunk['chunk_number']}: {len(triples)} triples extraídos")
print(f"\nTotal triples raw: {len(all_triples)}")
Output ejemplo:
Chunk 1: 18 triples extraídos
Total triples raw: 18
Paso 5: Normalización y Deduplicación
Los triples raw necesitan limpieza antes de construir el grafo:
def normalize_triples(raw_triples: list) -> list:
"""
Normaliza y deduplica triples.
Pasos:
1. Lowercase y trim
2. Filtrar vacíos
3. Deduplicar usando Set
"""
normalized = []
seen = set()
stats = {
'original': len(raw_triples),
'empty_removed': 0,
'duplicates_removed': 0
}
for t in raw_triples:
# Normalizar
s = t.get('subject', '').strip().lower()
p = t.get('predicate', '').strip().lower()
p = re.sub(r'\s+', ' ', p) # Espacios múltiples → uno
o = t.get('object', '').strip().lower()
# Filtrar vacíos
if not all([s, p, o]):
stats['empty_removed'] += 1
continue
# Deduplicar
key = (s, p, o)
if key in seen:
stats['duplicates_removed'] += 1
continue
seen.add(key)
normalized.append({
'subject': s,
'predicate': p,
'object': o,
'source_chunk': t.get('chunk', '?')
})
print(f"Normalización:")
print(f" Original: {stats['original']}")
print(f" Vacíos removidos: {stats['empty_removed']}")
print(f" Duplicados removidos: {stats['duplicates_removed']}")
print(f" Final: {len(normalized)}")
return normalized
# Aplicar normalización
clean_triples = normalize_triples(all_triples)
Output ejemplo:
Normalización:
Original: 18
Vacíos removidos: 0
Duplicados removidos: 2
Final: 16
Paso 6: Construcción del Grafo
Con los triples limpios, construimos el grafo usando NetworkX:
def build_knowledge_graph(triples: list) -> nx.DiGraph:
"""
Construye un DiGraph de NetworkX desde los triples.
- Subject → Nodo
- Object → Nodo
- Predicate → Edge label
"""
G = nx.DiGraph()
for t in triples:
subject = t['subject']
predicate = t['predicate']
obj = t['object']
# add_edge automáticamente crea los nodos si no existen
G.add_edge(subject, obj, label=predicate)
return G
# Construir
kg = build_knowledge_graph(clean_triples)
print(f"Knowledge Graph creado:")
print(f" Nodos (entidades): {kg.number_of_nodes()}")
print(f" Edges (relaciones): {kg.number_of_edges()}")
Output ejemplo:
Knowledge Graph creado:
Nodos (entidades): 15
Edges (relaciones): 16
Paso 7: Visualización Interactiva
Usando ipycytoscape para renderizar el grafo en Jupyter:
def visualize_kg(G: nx.DiGraph):
"""
Crea visualización interactiva del Knowledge Graph.
"""
# Convertir a formato Cytoscape
nodes = []
edges = []
# Calcular grados para sizing
degrees = dict(G.degree())
max_degree = max(degrees.values()) if degrees else 1
for node_id in G.nodes():
degree = degrees.get(node_id, 0)
size = 20 + (degree / max_degree) * 40
nodes.append({
'data': {
'id': str(node_id),
'label': str(node_id),
'size': size
}
})
for i, (u, v, data) in enumerate(G.edges(data=True)):
edges.append({
'data': {
'id': f'edge_{i}',
'source': str(u),
'target': str(v),
'label': data.get('label', '')
}
})
# Crear widget
cyto = ipycytoscape.CytoscapeWidget()
cyto.graph.add_graph_from_json({
'nodes': nodes,
'edges': edges
})
# Estilo
cyto.set_style([
{
'selector': 'node',
'style': {
'label': 'data(label)',
'background-color': '#6366f1',
'color': '#ffffff',
'text-valign': 'center',
'width': 'data(size)',
'height': 'data(size)',
'font-size': '10px'
}
},
{
'selector': 'edge',
'style': {
'label': 'data(label)',
'curve-style': 'bezier',
'target-arrow-shape': 'triangle',
'line-color': '#94a3b8',
'target-arrow-color': '#94a3b8',
'font-size': '8px',
'color': '#64748b'
}
},
{
'selector': 'node:selected',
'style': {
'background-color': '#22c55e',
'border-width': 2,
'border-color': '#16a34a'
}
}
])
# Layout
cyto.set_layout(name='cose', nodeRepulsion=8000)
return cyto
# Visualizar
widget = visualize_kg(kg)
display(widget)
El resultado es un grafo interactivo donde puedes:
- Arrastrar nodos para reorganizar
- Click en nodos para seleccionar
- Zoom in/out con scroll
- Ver las relaciones (predicados) en los edges
Ejemplo de Output
Para el texto de Marie Curie, el grafo resultante muestra:
Nodos centrales:
marie curie(hub principal con múltiples conexiones)pierre curienobel prize in physicsnobel prize in chemistryradium,poloniumwarsaw, poland
Relaciones típicas extraídas:
(marie curie) —[born in]→ (warsaw, poland)
(marie curie) —[discovered]→ (radium)
(marie curie) —[discovered]→ (polonium)
(marie curie) —[won]→ (nobel prize in physics)
(marie curie) —[won]→ (nobel prize in chemistry)
(marie curie) —[married to]→ (pierre curie)
(pierre curie) —[discovered]→ (radium)
Código Completo
# kg_pipeline.py - Pipeline completo de Knowledge Graph
import openai
import json
import networkx as nx
import os
import re
def chunk_text(text, chunk_size=150, overlap=30):
words = text.split()
chunks = []
start = 0
num = 1
while start < len(words):
end = min(start + chunk_size, len(words))
chunks.append({"text": " ".join(words[start:end]), "chunk_number": num})
start = start + chunk_size - overlap
if start <= 0: start = 1
num += 1
if num > len(words): break
return chunks
def extract_triples(client, chunk, model):
SYSTEM = "You are an AI expert in knowledge graph extraction."
USER = f"""Extract SPO triples from this text as JSON array.
Rules: lowercase, no markdown, concise predicates, resolve pronouns.
Format: [{{"subject": "x", "predicate": "y", "object": "z"}}]
Text: {chunk['text']}
JSON:"""
try:
r = client.chat.completions.create(
model=model,
messages=[{"role": "system", "content": SYSTEM},
{"role": "user", "content": USER}],
temperature=0.0, max_tokens=4096
)
data = json.loads(r.choices[0].message.content.strip())
if isinstance(data, dict):
data = next((v for v in data.values() if isinstance(v, list)), [])
except:
return []
return [
{**t, 'chunk': chunk['chunk_number']}
for t in data
if all(t.get(k) for k in ['subject', 'predicate', 'object'])
]
def normalize(triples):
seen = set()
out = []
for t in triples:
key = tuple(t[k].strip().lower() for k in ['subject', 'predicate', 'object'])
if all(key) and key not in seen:
seen.add(key)
out.append({'subject': key[0], 'predicate': key[1], 'object': key[2]})
return out
def build_graph(triples):
G = nx.DiGraph()
for t in triples:
G.add_edge(t['subject'], t['object'], label=t['predicate'])
return G
# Main
if __name__ == "__main__":
client = openai.OpenAI()
text = "..." # Tu texto aquí
chunks = chunk_text(text)
raw = [t for c in chunks for t in extract_triples(client, c, "gpt-4o")]
clean = normalize(raw)
kg = build_graph(clean)
print(f"Nodos: {kg.number_of_nodes()}, Edges: {kg.number_of_edges()}")
Próximos Pasos
Este pipeline básico puede extenderse con:
| Mejora | Descripción |
|---|---|
| Entity Linking | Conectar “Marie Curie” y “M. Curie” al mismo ID |
| Relationship Clustering | Agrupar “born in” y “was born at” |
| Persistencia | Guardar en Neo4j o ArangoDB |
| Evaluación | Medir precision/recall de extracción |
| Multi-hop Queries | “¿Qué descubrió la esposa de Pierre Curie?” |
| RAG Integration | Usar el KG para mejorar respuestas de LLMs |
Recursos
Publicado en yoDEV.dev — La comunidad de desarrolladores de Latinoamérica


