最后活跃于 3 days ago Unlisted

Visualización de un grafo de conocimiento con NetworkX — demo del episodio 828 de atareao con Linux

修订 9c963947f51e3a4408d2d302451f1b1928715c43

knowledge_graph.py 原始文件
1#!/usr/bin/env python3
2"""
3knowledge_graph.py — Visualización de un grafo de conocimiento con NetworkX
4
5Construye un grafo de conocimiento con las herramientas del stack del podcast,
6muestra consultas sobre el grafo, y genera una imagen PNG del mismo.
7
8Propósito para el episodio 828:
9 Demostrar visualmente qué es un "grafo de conocimiento" — la base de GraphRAG.
10 Se ejecuta después de la comparativa RAG vs GraphRAG para mostrar un grafo real.
11
12Uso:
13 python knowledge_graph.py # Modo interactivo (consulta + PNG)
14 python knowledge_graph.py --no-png # Solo consultas, sin guardar imagen
15 python knowledge_graph.py --png-only # Solo generar PNG
16
17Requisitos:
18 pip install networkx matplotlib
19"""
20
21import argparse
22import sys
23from typing import Dict, List, Tuple
24
25try:
26 import networkx as nx
27except ImportError:
28 print("❌ Falta networkx. Instala con: pip install networkx matplotlib")
29 sys.exit(1)
30
31try:
32 from networkx.drawing.nx_pydot import graphviz_layout
33except ImportError:
34 print("❌ Falta pydot. Instala con: pip install pydot")
35 sys.exit(1)
36
37try:
38 import matplotlib.pyplot as plt
39except ImportError:
40 print("❌ Falta matplotlib. Instala con: pip install matplotlib")
41 sys.exit(1)
42
43
44# ─── Datos del grafo ─────────────────────────────────────────────────────────
45
46# Nodos: (id, nombre, tipo, descripcion)
47ENTIDADES: List[Tuple[int, str, str, str]] = [
48 (1, "Traefik", "software", "Reverse proxy para servicios Docker"),
49 (2, "Docker", "software", "Plataforma de contenedores"),
50 (3, "Podman", "software", "Alternativa a Docker sin daemon"),
51 (4, "Ollama", "software", "Ejecución local de modelos LLM"),
52 (5, "Open WebUI", "software", "Interfaz web para LLMs locales"),
53 (6, "LightRAG", "software", "GraphRAG ligero con actualización incremental"),
54 (7, "sqlite-vec", "libreria", "Extensión vectorial para SQLite"),
55 (8, "Whisper", "software", "Speech-to-text local"),
56 (9, "Piper TTS", "software", "Text-to-speech local"),
57 (10, "LLaVA", "modelo", "Modelo de visión local"),
58 (11, "MCP", "protocolo", "Model Context Protocol"),
59 (12, "OpenCode", "software", "CLI/TUI para desarrollo asistido con IA"),
60 (13, "Hermes Agent", "software", "Agente autónomo con skills y MCP"),
61 (14, "Anacleto", "software", "Motor de orquestación de agentes en Rust"),
62 (15, "CrewAI", "libreria", "Framework Python para equipos de agentes"),
63 (16, "systemd", "software", "Init system y gestor de servicios"),
64 (17, "Quadlets", "software", "Contenedores gestionados por systemd"),
65 (18, "NetworkX", "libreria", "Biblioteca Python para análisis de grafos"),
66]
67
68# Aristas: (origen_id, destino_id, relacion)
69RELACIONES: List[Tuple[int, int, str]] = [
70 # Traefik
71 (1, 2, "gestiona tráfico de"),
72 (1, 3, "también funciona con"),
73 (1, 17, "se despliega con"),
74 # Docker
75 (2, 3, "es alternativa de"),
76 (2, 17, "gestionado por"),
77 # Ollama
78 (4, 5, "tiene interfaz en"),
79 (4, 11, "expone API compatible"),
80 # Open WebUI
81 (5, 11, "soporta"),
82 (5, 6, "integra RAG con"),
83 # LightRAG
84 (6, 7, "puede usar"),
85 (6, 18, "construye grafos con"),
86 # Agentes
87 (12, 11, "implementa"),
88 (13, 11, "implementa"),
89 (14, 11, "implementa"),
90 (12, 4, "usa modelos de"),
91 (13, 4, "usa modelos de"),
92 (14, 4, "usa modelos de"),
93 (15, 12, "comparable con"),
94 # Multimedia
95 (8, 4, "se integra con"),
96 (9, 4, "se integra con"),
97 (10, 4, "se ejecuta vía"),
98 # Infraestructura
99 (16, 17, "gestiona"),
100 (17, 2, "gestiona"),
101 (17, 3, "gestiona"),
102]
103
104# Colores por tipo de entidad (para el gráfico)
105COLORES_TIPO = {
106 "software": "#4A90D9", # azul
107 "libreria": "#50C878", # verde
108 "modelo": "#E8A838", # naranja
109 "protocolo": "#9B59B6", # púrpura
110}
111
112
113# ─── Construcción del grafo ──────────────────────────────────────────────────
114
115
116def construir_grafo() -> nx.DiGraph:
117 """Construye el grafo dirigido con entidades y relaciones."""
118 G = nx.DiGraph()
119
120 # Añadir nodos con atributos
121 for eid, nombre, tipo, desc in ENTIDADES:
122 G.add_node(nombre, tipo=tipo, descripcion=desc, id=eid)
123
124 # Añadir aristas con atributos
125 for origen_id, destino_id, relacion in RELACIONES:
126 # Buscar nombres por ID
127 origen_nombre = next(n for eid, n, _, _ in ENTIDADES if eid == origen_id)
128 destino_nombre = next(n for eid, n, _, _ in ENTIDADES if eid == destino_id)
129 G.add_edge(origen_nombre, destino_nombre, relacion=relacion)
130
131 return G
132
133
134# ─── Consultas sobre el grafo ────────────────────────────────────────────────
135
136
137def consultar_relaciones(G: nx.DiGraph, entidad: str) -> None:
138 """
139 Consulta todas las relaciones de una entidad: qué sale de ella y qué llega.
140 Esta es la consulta equivalente a la del SQL en demo-graphrag.sh.
141 """
142 print(f"\n{'=' * 60}")
143 print(f" CONSULTA: ¿Qué está relacionado con «{entidad}»?")
144 print(f"{'=' * 60}\n")
145
146 if entidad not in G:
147 print(f" ⚠ La entidad «{entidad}» no existe en el grafo.\n")
148 return
149
150 # Relaciones salientes (entidad → destino)
151 salientes = list(G.out_edges(entidad, data=True))
152 if salientes:
153 print(f"{entidad} se relaciona CON:")
154 for _, destino, data in salientes:
155 print(f"{data['relacion']}{destino}")
156 else:
157 print(f"{entidad} no tiene relaciones salientes.")
158
159 # Relaciones entrantes (origen → entidad)
160 entrantes = list(G.in_edges(entidad, data=True))
161 if entrantes:
162 print(f"\n{entidad} es relacionado POR:")
163 for origen, _, data in entrantes:
164 print(f"{origen}{data['relacion']}")
165 else:
166 print(f"\n{entidad} no tiene relaciones entrantes.")
167
168 print()
169
170
171def consultar_camino(G: nx.DiGraph, origen: str, destino: str) -> None:
172 """
173 Encuentra el camino más corto entre dos entidades en el grafo.
174 Demuestra que el grafo permite navegación semántica.
175 """
176 print(f"\n{'=' * 60}")
177 print(f" CONSULTA: Camino de «{origen}» a «{destino}»")
178 print(f"{'=' * 60}\n")
179
180 if origen not in G:
181 print(f" ⚠ La entidad «{origen}» no existe.\n")
182 return
183 if destino not in G:
184 print(f" ⚠ La entidad «{destino}» no existe.\n")
185 return
186
187 try:
188 camino = nx.shortest_path(G, origen, destino)
189 print(f" Ruta encontrada ({len(camino) - 1} saltos):")
190 for i in range(len(camino) - 1):
191 a, b = camino[i], camino[i + 1]
192 relacion = G[a][b]["relacion"]
193 print(f" {a} ──[{relacion}]──→ {b}")
194 print()
195 except nx.NetworkXNoPath:
196 print(f" ⚠ No hay camino entre «{origen}» y «{destino}».\n")
197
198
199def consultar_vecinos_comunes(G: nx.DiGraph, entidad_a: str, entidad_b: str) -> None:
200 """
201 Encuentra vecinos comunes entre dos entidades.
202 Útil para descubrir conexiones indirectas.
203 """
204 print(f"\n{'=' * 60}")
205 print(f" CONSULTA: Vecinos comunes de «{entidad_a}» y «{entidad_b}»")
206 print(f"{'=' * 60}\n")
207
208 if entidad_a not in G or entidad_b not in G:
209 print(" ⚠ Una de las entidades no existe.\n")
210 return
211
212 # Vecinos: predecesores + sucesores
213 vecinos_a = set(G.predecessors(entidad_a)) | set(G.successors(entidad_a))
214 vecinos_b = set(G.predecessors(entidad_b)) | set(G.successors(entidad_b))
215 comunes = vecinos_a & vecinos_b
216
217 if comunes:
218 print(f" Entidades conectadas tanto con «{entidad_a}» como con «{entidad_b}»:")
219 for v in sorted(comunes):
220 print(f"{v}")
221 else:
222 print(f" No hay vecinos comunes entre «{entidad_a}» y «{entidad_b}».")
223 print()
224
225
226def mostrar_estadisticas(G: nx.DiGraph) -> None:
227 """Muestra estadísticas básicas del grafo."""
228 print(f"\n{'=' * 60}")
229 print(f" ESTADÍSTICAS DEL GRAFO DE CONOCIMIENTO")
230 print(f"{'=' * 60}\n")
231 print(f" • Nodos (entidades): {G.number_of_nodes()}")
232 print(f" • Aristas (relaciones): {G.number_of_edges()}")
233 print(f" • Densidad: {nx.density(G):.4f}")
234 print(f" • ¿Es conexo?: {'' if nx.is_weakly_connected(G) else 'No'}")
235 print(
236 f" • Diámetro: {nx.diameter(G.to_undirected()) if nx.is_weakly_connected(G) else 'N/A'}"
237 )
238 print()
239
240
241# ─── Visualización ───────────────────────────────────────────────────────────
242
243
244def generar_png(G: nx.DiGraph, archivo: str = "grafo_conocimiento.png") -> str:
245 """
246 Genera una imagen PNG del grafo usando matplotlib.
247 Los nodos se colorean por tipo y las aristas se etiquetan con la relación.
248 """
249 print(f" Generando visualización: {archivo} ...", end=" ")
250
251 plt.figure(figsize=(16, 12))
252
253 # Layout: usar Graphviz si está disponible, sino spring_layout
254 try:
255 pos = graphviz_layout(G, prog="dot")
256 except Exception:
257 pos = nx.spring_layout(G, k=1.5, seed=42, iterations=50)
258
259 # Dibujar nodos por tipo (cada tipo con su color)
260 for tipo in COLORES_TIPO:
261 nodos_tipo = [n for n, attr in G.nodes(data=True) if attr["tipo"] == tipo]
262 nx.draw_networkx_nodes(
263 G,
264 pos,
265 nodelist=nodos_tipo,
266 node_color=COLORES_TIPO[tipo],
267 node_size=2500,
268 node_shape="o",
269 edgecolors="white",
270 linewidths=1.5,
271 alpha=0.95,
272 )
273
274 # Dibujar aristas con flechas
275 nx.draw_networkx_edges(
276 G,
277 pos,
278 edge_color="#888888",
279 arrows=True,
280 arrowsize=20,
281 arrowstyle="->",
282 width=1.5,
283 alpha=0.7,
284 connectionstyle="arc3,rad=0.1",
285 )
286
287 # Etiquetas de los nodos
288 nx.draw_networkx_labels(
289 G,
290 pos,
291 font_size=11,
292 font_weight="bold",
293 font_family="sans-serif",
294 )
295
296 # Etiquetas de las aristas (relaciones)
297 edge_labels = {(a, b): data["relacion"] for a, b, data in G.edges(data=True)}
298 nx.draw_networkx_edge_labels(
299 G,
300 pos,
301 edge_labels=edge_labels,
302 font_size=8,
303 font_family="sans-serif",
304 alpha=0.8,
305 label_pos=0.5,
306 )
307
308 # Leyenda
309 legend_elements = []
310 for tipo, color in COLORES_TIPO.items():
311 legend_elements.append(
312 plt.scatter(
313 [], [], c=color, s=150, label=tipo, edgecolors="white", linewidths=1
314 )
315 )
316 plt.legend(
317 handles=legend_elements,
318 title="Tipo de entidad",
319 loc="upper right",
320 fontsize=10,
321 title_fontsize=12,
322 )
323
324 plt.title(
325 "Grafo de conocimiento — Stack de herramientas del podcast",
326 fontsize=16,
327 fontweight="bold",
328 pad=20,
329 )
330 plt.axis("off")
331 plt.tight_layout()
332 plt.savefig(archivo, dpi=150, bbox_inches="tight", facecolor="#FAFAFA")
333 plt.close()
334
335 print("")
336 return archivo
337
338
339# ─── Main ────────────────────────────────────────────────────────────────────
340
341
342def main():
343 parser = argparse.ArgumentParser(
344 description="Demo de grafo de conocimiento con NetworkX",
345 )
346 parser.add_argument(
347 "--no-png",
348 action="store_true",
349 help="No generar PNG, solo mostrar consultas",
350 )
351 parser.add_argument(
352 "--png-only",
353 action="store_true",
354 help="Solo generar PNG sin consultas",
355 )
356 args = parser.parse_args()
357
358 print()
359 print("╔══════════════════════════════════════════════════════════╗")
360 print("║ GRAFO DE CONOCIMIENTO — Demo para el episodio 828 ║")
361 print("║ Construido con NetworkX + matplotlib ║")
362 print("╚══════════════════════════════════════════════════════════╝")
363
364 # Construir el grafo
365 print("\n Construyendo grafo...")
366 G = construir_grafo()
367 print(
368 f" ✓ Grafo creado: {G.number_of_nodes()} nodos, {G.number_of_edges()} aristas"
369 )
370
371 if not args.png_only:
372 # Mostrar estadísticas
373 mostrar_estadisticas(G)
374
375 # Consulta principal: ¿Qué está relacionado con Traefik?
376 consultar_relaciones(G, "Traefik")
377
378 # Consulta: camino de OpenCode a Quadlets
379 consultar_camino(G, "OpenCode", "Quadlets")
380
381 # Consulta: vecinos comunes de Ollama y Open WebUI
382 consultar_vecinos_comunes(G, "Ollama", "Open WebUI")
383
384 if not args.no_png:
385 archivo = generar_png(G)
386 print(f"\n 📁 Imagen guardada: {archivo}")
387 print(f" Ábrela para ver el grafo visualmente.")
388
389 print("\n ✅ Demo completada.")
390 print()
391
392
393if __name__ == "__main__":
394 main()
395