最終更新 3 days ago Unlisted

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

atareao's Avatar atareao revised this gist 3 days ago. Go to revision

1 file changed, 394 insertions

knowledge_graph.py(file created)

@@ -0,0 +1,394 @@
1 + #!/usr/bin/env python3
2 + """
3 + knowledge_graph.py — Visualización de un grafo de conocimiento con NetworkX
4 +
5 + Construye un grafo de conocimiento con las herramientas del stack del podcast,
6 + muestra consultas sobre el grafo, y genera una imagen PNG del mismo.
7 +
8 + Propó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 +
12 + Uso:
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 +
17 + Requisitos:
18 + pip install networkx matplotlib
19 + """
20 +
21 + import argparse
22 + import sys
23 + from typing import Dict, List, Tuple
24 +
25 + try:
26 + import networkx as nx
27 + except ImportError:
28 + print("❌ Falta networkx. Instala con: pip install networkx matplotlib")
29 + sys.exit(1)
30 +
31 + try:
32 + from networkx.drawing.nx_pydot import graphviz_layout
33 + except ImportError:
34 + print("❌ Falta pydot. Instala con: pip install pydot")
35 + sys.exit(1)
36 +
37 + try:
38 + import matplotlib.pyplot as plt
39 + except 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)
47 + ENTIDADES: 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)
69 + RELACIONES: 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)
105 + COLORES_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 +
116 + def 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 +
137 + def 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 +
171 + def 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 +
199 + def 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 +
226 + def 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?: {'Sí' 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 +
244 + def 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 +
342 + def 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 +
393 + if __name__ == "__main__":
394 + main()
Newer Older