blog about dark mmfilesi
rag · ·12 min ·Beginner

Introducción a graph-RAG

Una introducción a los grafos y a Neo4j pensada como base para entender graph-RAG, la técnica que combina grafos de conocimiento con recuperación de información.

1. Introducción

1.1. Aviso preliminar

Esta entrada forma parte de una serie sobre graph-RAG, una técnica para combinar grafos de conocimiento con recuperación para mejorar las respuestas de los modelos. Sin embargo, antes de entrar en materia es importante advertir que para seguir este texto es importante tener ciertas nociones de qué es la técnica estándar de RAG, Retrieval-Augmented Generation. Por lo demás, como siempre, trataré de explicar todo desde cero. En este artículo veremos qué es un grafo y cómo funciona alguna base de datos de grafos como Neo4j; en la siguiente ya sí veremos cómo funciona una pipeline de esta técnica.

1.2. Anatomía de un grafo

1.2.1. Los dos pilares de un grafo

Se atribuye el origen matemático de los grafos a Leonhard Euler y el problema de los Siete Puentes de Königsberg, que hoy es Kaliningrado. Hacia 1736 Euler se preguntó si era posible cruzar los siete puentes que tenía la ciudad sobre el río Pregel sin pasar dos veces por el mismo. Y demostró que era imposible simplificando el mapa de la ciudad a puntos, nodos, y líneas, relaciones. Así nació la Teoría de Grafos, la base lógica sobre la que hoy se asientan estas bases de datos.

En esencia, un grafo es una estructura abstracta que se utiliza para representar y modelar las conexiones entre diferentes elementos. A diferencia de otros sistemas de almacenamiento que priorizan las entidades individuales, el grafo sitúa a las relaciones en el mismo nivel de importancia que a los datos mismos.

Como podemos observar viendo un plano de un Metro, cualquier sistema basado en grafos, por complejo que sea, se reduce a dos componentes fundamentales:

  • Los nodos o vértices, que representan a las entidades u objetos del dominio que se quiere modelar. En un sistema de comercio electrónico, un nodo puede ser un Cliente, un Producto o una Dirección de envío; en el Metro, a las estaciones.

  • Las aristas, llamadas relaciones en Cypher y enlaces en redes, que representan las conexiones existentes entre los nodos. Siguiendo el ejemplo anterior, una arista podría ser COMPRÓ, CALIFICÓ o VIVE_EN; o, volviendo al Metro, las líneas que unen las distintas estaciones.

Esquemáticamente:

ContextoNodoArista
Matemáticas / teoría de grafosVérticeArista
Neo4j / CypherNodoRelación
RedesNodoEnlace

1.2.2. Tipos básicos de grafos

Dependiendo de cómo se comporten las aristas, es decir, las conexiones, y de qué elementos compongan la estructura, los grafos se clasifican en diferentes categorías.

a. Según la dirección de las relaciones

La dirección es el criterio más básico y define si la relación entre dos entidades fluye en un único sentido o puede leerse indistintamente en ambos. En un grafo no dirigido las aristas representan relaciones simétricas y recíprocas, no existe origen ni destino, la conexión simplemente existe entre ambos nodos, si A está conectado con B, B está conectado con A. Dos primos son primos entre sí, por ejemplo, y esto se representa con una línea simple, sin flechas. En un grafo dirigido, o dígrafo, en cambio, las aristas tienen una orientación indicada por una flecha, la relación fluye desde un nodo origen hacia un nodo destino, y que A apunte a B no significa que B apunte a A. Es lo que ocurre con los seguidores en una red social como Instagram, donde hay quien no sigue a todo el mundo que le sigue, como suele pasar con las celebridades.

Conviene tener esto presente de cara a Neo4j. Aunque la teoría de grafos admite aristas sin dirección, en Neo4j toda relación se almacena con una dirección, no hay manera de crear una relación sin ella. Lo que sí se puede hacer es consultarla ignorando esa dirección con un patrón sin flecha en Cypher, pero eso afecta a cómo se lee el grafo, no a cómo está guardado. Volveremos sobre esto cuando lleguemos a Cypher.

b. Según el peso o coste de las conexiones

No todas las relaciones tienen el mismo valor o importancia, en un mapa, por ejemplo, las aristas entre ciudades pueden tener como peso los kilómetros de distancia o los minutos de tráfico. Para modelar estos escenarios, donde las conexiones implican un costo, distancia o afinidad, se introduce el concepto de peso, y atendiendo a este criterio se distingue entre grafos ponderados y grafos no ponderados. En los primeros, cada arista tiene un valor numérico asignado, es el caso de los sistemas de GPS como Google Maps, donde los nodos son intersecciones o ciudades, las aristas son las carreteras, y el peso puede ser la distancia en kilómetros o el tiempo estimado de tráfico; para calcular la ruta más rápida, el algoritmo busca el camino cuyos pesos sumados den el menor valor posible. En los segundos, en cambio, todas las conexiones valen lo mismo, solo importa si existe la conexión o no, como en Wikipedia, una red de hipervínculos entre páginas donde no hay enlaces más pesados que otros.

1.2.3. Tipos especiales

Cuando pasamos de la teoría matemática pura a la implementación de software y bases de datos NoSQL, encontramos estructuras más complejas orientadas a optimizar las consultas. Algunos ejemplos son:

Grafos de propiedades (Property Graphs)

Como vamos a ver, los grafos se pueden volver aún más sofisticados cuando se permite que tanto los nodos como las aristas contengan atributos internos (clave-valor), como hace Neo4j. Así, por ejemplo, un nodo Persona puede tener las propiedades nombre: "Ana", edad: 29 y la arista TRABAJA_EN puede tener la propiedad desde: "2022-01-15", lo cual permite hacer consultas quirúrgicas sumamente específicas.

Multigrafos

Son grafos que permiten que exista más de una arista entre los mismos dos nodos. Esto es crucial en las bases de datos de grafos, ya que dos entidades pueden estar conectadas por muchas razones diferentes. Por ejemplo, el Nodo A (Persona) y el Nodo B (Persona) pueden estar conectados por una arista tipo ES_FAMILIAR_DE, pero al mismo tiempo estar conectados por una arista tipo CONDUCE_EL_MISMO_COCHE_QUE.

Grafos cíclicos y acíclicos

Los grafos cíclicos permiten seguir un camino de aristas que sale de un nodo y puede regresar al mismo nodo de origen; mientras que los acíclicos no contienen ningún ciclo, lo cual es muy útil para modelar jerarquías, dependencias de desarrollo de software, árboles genealógicos, etcétera.

Árboles

Por último podemos ver el grafo tipo árbol, que es una estructura de datos que representa una jerarquía pura y estricta, caracterizada por ser un grafo conectado que no contiene ciclos ni bucles de ningún tipo.

Dentro de la programación, esta estructura suele organizarse a partir de un punto de origen único llamado nodo raíz, del cual descienden los demás elementos mediante relaciones dirigidas de padre e hijo.

La regla matemática fundamental de este modelo dicta que si el sistema posee un número N de nodos, contará exactamente con N − 1 aristas, y esto garantiza que exista un único camino posible para trasladarse de un nodo a otro, lo que elimina cualquier redundancia o ruta alternativa.

A diferencia de los grafos genéricos, donde las conexiones pueden ser caóticas, bidireccionales y multidireccionales, el árbol impone la restricción de que cada nodo hijo puede tener un solo padre, lo que impide la formación de redes interconectadas complejas. Por esta rigidez estructural no son útiles para modelar sistemas interconectados horizontalmente, como las redes sociales o sistemas de recomendación, pero los convierte en una buena herramienta para organizar datos que siguen un orden jerárquico natural, como el sistema de archivos de un sistema operativo o el árbol genealógico de una familia.

1.2.4. Cómo se representan

Para terminar este breve repaso por los grafos vamos a ver algunas de las maneras en las que se representan.

a. Matriz de adyacencia

La matriz de adyacencia es una representación bidimensional (una tabla o mapa de bits) de tamaño V × V, donde V es el número total de nodos (vértices) del grafo. Tanto las filas como las columnas representan los nodos, y las celdas la intersección entre ellos.

Si hay una conexión directa entre el nodo A y el nodo B, se coloca un 1 (o el peso de la arista si es un grafo ponderado) en la celda correspondiente. Si no hay conexión, se coloca un 0. El resultado es una cuadrícula perfecta que condensa la estructura completa del grafo sin necesidad de trazar una sola línea.

Una de sus virtudes es que transforma la geometría del dibujo en álgebra pura, lo que facilita la resolución de problemas complejos mediante operaciones aritméticas simples. Por ejemplo, si se multiplica esta matriz por sí misma, la tabla resultante revela de forma automática cuántos caminos de exactamente dos pasos existen entre cualquier par de nodos, sin necesidad de rastrear las rutas visualmente.

Cuando el grafo tiene miles de elementos que apenas se relacionan entre sí, sin embargo, este tipo de representaciones son muy poco útiles, ya que obliga a trabajar con una matriz inmensa cubierta casi en su totalidad por ceros.

b. Lista de adyacencia

En este tipo de representaciones, en lugar de una tabla gigante, se utiliza un array o lista donde cada nodo del grafo tiene asociada su propia lista con los nodos a los que está conectado directamente. Si el nodo A está conectado con el nodo B y el nodo C, la entrada de A en la lista simplemente guardará los apuntadores hacia [B, C].

Es más eficiente para representar sistemas dispersos, ya que solo registra las conexiones que realmente existen. Por ejemplo, si tuviéramos una red de 1.000 personas donde cada una tiene solo 3 amigos, la matriz de adyacencia nos obligaría a dibujar una cuadrícula de un millón de celdas llena de ceros inútiles. La lista de adyacencia, en cambio, consistirá simplemente en 1.000 líneas cortas, cada una con solo 3 nombres.

c. Lista de aristas

La lista de aristas es la forma más simple y directa de representar un grafo, ya que prescinde de estructuras complejas o tablas fijas para centrarse únicamente en las conexiones. Para construirla a mano, se crea un listado plano donde cada elemento es un par o tupla que agrupa a los dos nodos que están unidos entre sí; por ejemplo, si la ciudad A conecta con la ciudad B, esto se registra sencillamente como (A, B). En el caso de que el grafo sea ponderado, es decir, que las relaciones tengan un costo o distancia, simplemente se añade un tercer valor numérico a la tupla, quedando representado como (A, B, 10) para indicar que el trayecto entre ambas tiene un peso de diez unidades.

Y ahora que conocemos ya algunos fundamentos teóricos básicos, vamos a llevarlos a la práctica.

2. Neo4j

2.1. Hola mundo

Antes de ver cómo funciona graph-RAG tenemos que conocer cómo funcionan las bases de datos de grafos y para eso podemos preparar una primera instancia con el panteón mitológico griego con la base de Neo4j.

Neo4j es, en esencia, una base de datos diseñada para proyectos donde las conexiones entre los datos importan más que los datos aislados. En lugar de usar tablas de filas y columnas que se vuelven lentas cuando intentas cruzar información, Neo4j conecta los registros directamente en el disco duro mediante apuntadores. Y esto hace que consultar redes complejas, como sistemas de recomendación o rutas logísticas, sea muy rápido, ya que el sistema solo tiene que seguir los caminos que ya están dibujados.

Para aprender, lo mejor es Neo4j AuraDB Free, que permite trabajar online de forma gratuita. Vamos a neo4j.com/product/auradb, creamos una cuenta y en apenas unos segundos tenemos una instancia lista en la nube. Eso sí, no es indefinida del todo: si la dejas sin actividad durante 30 días, Aura la borra automáticamente, así que conviene entrar de vez en cuando aunque sea solo para lanzar una consulta.

Si preferimos trabajar en local, podemos instalar Neo4j Desktop, pero de momento no es necesaria.

Le damos a crear instancia y le ponemos un nombre. En mi caso, como será una bbdd sobre mitología griega:

greek-mythology

Escogemos la opción Free y con la opción AuraDB marcada, le damos a Create instance. Ojo cuidado, es muy importante copiar el password.

A partir de ahora, vamos a ir trabajando con Cypher, que es el lenguaje de consulta de Neo4j, diseñado específicamente para bases de datos de grafos.

Una de sus características más interesantes es que la sintaxis representa visualmente los patrones del grafo, lo que hace que las consultas sean muy intuitivas de leer y escribir. Los nodos se escriben con paréntesis () y las relaciones con flechas -[:TIPO]->.

Neo4j lo abrió como especificación pública en 2015 bajo el nombre openCypher, lo que con el tiempo lo convirtió en el estándar de facto del mundo de los grafos. El estándar oficial, sin embargo, llegó después: en 2024 el comité ISO publicó GQL, que se apoya en gran medida en Cypher. Es al mundo de los grafos lo que SQL es al mundo relacional.

Vamos a empezar a poblar el panteón con divinidades y para eso seleccionamos la opción Query del panel lateral y luego podemos utilizar uno de estos dos comandos principales:

  • CREATE es el comando básico para crear un nodo. No comprueba nada, simplemente inserta, por lo que si lo ejecutas dos veces, te quedan dos nodos idénticos. Es el equivalente a un INSERT de SQL puro y solo conviene usarlo cuando sabemos con certeza que el dato es nuevo.
  • MERGE, en cambio, antes de crear nada, busca en la base de datos si ya existe un nodo que coincida con el patrón que le damos. Si lo encuentra, no crea nada nuevo y trabaja con el que ya existe. Si no lo encuentra, entonces sí lo crea.

Una sentencia MERGE tiene esta estructura:

MERGE (variable:Etiqueta {propiedad: "valor", propiedad2: valor2})

Donde:

  • MERGE es el comando.
  • (...) los paréntesis indican que es un nodo.
  • variable es un alias opcional para referenciarlo después en el caso de que se necesite.
  • Etiqueta es el tipo de nodo, equivale a la tabla en SQL.
  • {...} son pares clave:valor con las propiedades del nodo, equivalen a las columnas en SQL.

Sabiendo esto, podemos incorporar ya nuestros primeros tres dioses al panteón y para eso vamos al input superior derecho:

Escribimos las sentencias. Por ejemplo, algo así:

MERGE (:God {name: "Zeus", domain: "Sky and thunder", olympian: true})
MERGE (:God {name: "Poseidon", domain: "Sea", olympian: true})
MERGE (:God {name: "Hades", domain: "Underworld", olympian: false})

Y pulsamos el botón run, la flecha que está al lado, para que se ejecute.

Ahora, para ver estos primeros tres nodos, podemos hacer una consulta a la bbdd con la fórmula:

MATCH (origen)-[opcional: relacion]->(opcional: destino) RETURN origen, relacion, destino

MATCH busca el patrón que le indiques, y RETURN es lo que de verdad decide qué se muestra, de forma parecida a un SELECT de SQL.

En nuestro caso, para que nos muestre todos los nodos que hemos creado, sería algo así:

MATCH (n:God) RETURN n

Donde:

  • (n) es el nodo origen, cualquiera.
  • con RETURN le decimos a Cypher qué quieres que nos muestre del resultado. Sin eso la consulta no sería válida.

Luego volvemos con las consultas, ahora simplemente vamos a ejecutarla para ver cómo han quedado los nodos del panteón, que deberían ser algo parecido a esto.

2.2. Relaciones

Una vez que hemos definido un par de nodos, podemos establecer sus relaciones, sus aristas. La sintaxis básica para eso es:

-- Con dirección hacia la derecha
(a)-[:TIPO]->(b)

-- Con dirección hacia la izquierda
(a)<-[:TIPO]-(b)

-- Bidireccional
(a)-[:TIPO]-(b)

Por ejemplo, así podríamos definir la relación de un padre hacia un hijo, que va en una dirección:

(a)-[:FATHER_OF]->(b)

Así sería la de un hijo con su padre en dirección inversa:

(a)<-[:SON_OF]-(b)

Y así la de tres hermanos, como es nuestro caso. Observa que primero hay que seleccionar los nodos, como cuando hacemos un WHERE en SQL para acotar los registros con los que vamos a trabajar. Y para esa selección podemos utilizar como id el campo name.

MATCH (zeus:God {name: "Zeus"})
MATCH (poseidon:God {name: "Poseidon"})
MATCH (hades:God {name: "Hades"})
MERGE (zeus)-[:BROTHER_OF]->(poseidon)
MERGE (zeus)-[:BROTHER_OF]->(hades)
MERGE (poseidon)-[:BROTHER_OF]->(hades)

Y ahora podemos definir al padre de los tres hermanos, al terrible Cronos. Para poder ejecutar en la misma tanda la creación del nodo y la definición de sus relaciones usamos la partícula WITH, un punto de control entre cláusulas, que es la forma de pasar variables de una cláusula a la siguiente. Como Cronos ya no es un dios olímpico definimos la propiedad olympian a false.

MERGE (cronus:God {name: "Cronus", domain: "Time", olympian: false})
WITH cronus
MATCH (zeus:God {name: "Zeus"})
MATCH (poseidon:God {name: "Poseidon"})
MATCH (hades:God {name: "Hades"})
MERGE (cronus)-[:FATHER_OF]->(zeus)
MERGE (cronus)-[:FATHER_OF]->(poseidon)
MERGE (cronus)-[:FATHER_OF]->(hades)

Y para ver cómo va la cosa, ya podemos añadir la relación en la query.

MATCH (n)-[r]->(m) RETURN n, r, m

A continuación podemos incorporar algunos de los numerosos hijos que tuvo Zeus, incluido al semi-héroe Heracles, que no fue una divinidad olímpica.

MERGE (:God {name: "Athena", domain: "Wisdom and war", olympian: true})
MERGE (:God {name: "Apollo", domain: "Sun and arts", olympian: true})
MERGE (:God {name: "Artemis", domain: "Moon and hunt", olympian: true})
MERGE (:God {name: "Ares", domain: "War", olympian: true})
MERGE (:God {name: "Hermes", domain: "Travel and messengers", olympian: true})
MERGE (:God {name: "Heracles", domain: "Strength", olympian: false})

Y ya que estamos indicamos quién fue su padre.

MATCH (zeus:God {name: "Zeus"})
MATCH (athena:God {name: "Athena"})
MATCH (apollo:God {name: "Apollo"})
MATCH (artemis:God {name: "Artemis"})
MATCH (ares:God {name: "Ares"})
MATCH (hermes:God {name: "Hermes"})
MATCH (heracles:God {name: "Heracles"})
MERGE (zeus)-[:FATHER_OF]->(athena)
MERGE (zeus)-[:FATHER_OF]->(apollo)
MERGE (zeus)-[:FATHER_OF]->(artemis)
MERGE (zeus)-[:FATHER_OF]->(ares)
MERGE (zeus)-[:FATHER_OF]->(hermes)
MERGE (zeus)-[:FATHER_OF]->(heracles)

Ahora podríamos lanzar otra consulta indicando a mano que todos esos dioses son hermanos entre sí.

MATCH (athena:God {name: "Athena"})
MATCH (apollo:God {name: "Apollo"})
MATCH (artemis:God {name: "Artemis"})
[...]
MERGE (athena)-[:BROTHER_OF]-(apollo)
MERGE (athena)-[:BROTHER_OF]-(artemis)
MERGE (athena)-[:BROTHER_OF]-(ares)
[...]

Pero da pereza solo de pensarlo. Mucho mejor si buscamos todos los pares de nodos que comparten el mismo padre y a partir de ahí creamos la relación de hermandad entre cada par.

MATCH (padre:God)-[:FATHER_OF]->(hijo1:God)
MATCH (padre:God)-[:FATHER_OF]->(hijo2:God)
WHERE hijo1 <> hijo2
MERGE (hijo1)-[:BROTHER_OF]-(hijo2)

De esta forma, podemos buscar todos los pares posibles de nodos que comparten el mismo padre. La variable padre es la misma en las dos líneas y eso es lo que fuerza que sean hijos del mismo nodo. El resultado es una tabla interna con todas las combinaciones posibles de hijo1 y hijo2.

Con el WHERE evitamos que Cypher genere pares donde hijo1 y hijo2 son el mismo nodo, como Zeus con Zeus, por ejemplo. <> es el operador distinto de, igual que en SQL.

Y, finalmente, para cada par válido, creamos la relación de hermandad si no existe ya.

Con esto, el grafo comienza a complicarse, pero lo vamos a enredar aún más.

2.3. Características fundamentales

Para terminar esta introducción por Neo4j, vamos a ver algunas características fundamentales de las bases de datos de grafos.

En SQL cada fila pertenece a una sola tabla. Un registro es una cosa y punto. En grafos un nodo puede tener múltiples etiquetas a la vez, lo que te permite modelar la realidad de forma más rica.

Por ejemplo, el Olimpo es un lugar, pero también es el hogar de los dioses olímpicos. En SQL tendríamos que elegir una tabla o crear una tabla intermedia. En Neo4j simplemente le ponemos las dos etiquetas:

MERGE (olimpo:Place:Home {name: "Mount Olympus", realm: "Sky"})
MERGE (underworld:Place {name: "Underworld", realm: "Below"})
MERGE (sea:Place {name: "The Sea", realm: "Water"})

Y de esta manera podríamos buscar por cualquiera de las etiquetas:

MATCH (n:Place) RETURN n    // devuelve todos los lugares
MATCH (n:Home) RETURN n     // devuelve solo los hogares

Otra característica importante es que sigue un esquema flexible. En SQL si queremos añadir una propiedad nueva tenemos que hacer un ALTER TABLE para toda la tabla. En Neo4j cada nodo puede tener sus propias propiedades independientemente de los demás nodos de la misma etiqueta, como ocurre con las bbdd NoSQL, donde cada elemento puede tener sus propias propiedades no normalizadas.

Así, por ejemplo, añadiríamos dos propiedades únicas para Aquiles.

MERGE (:Mortal {name: "Achilles", death: "Arrow to the heel", age_at_death: 21})
MERGE (:Mortal {name: "Odysseus"})
MERGE (:Mortal {name: "Perseus"})

Otra tercera característica que podemos destacar es la rapidez con que se puede realizar una consulta aunque tenga muchos saltos, gracias a lo que denominan index-free adjacency. Cuando hacemos un JOIN en SQL, la base de datos recorre una tabla entera buscando las filas que coinciden, por lo que, cuanto más grande la tabla, más lento va.

En Neo4j, en cambio, cada nodo almacena físicamente punteros directos a sus vecinos. Para ir de Zeus a sus hijos no busca en ningún índice, sino que simplemente sigue el puntero. Es como la diferencia entre buscar un número en una agenda telefónica página a página, versus tener el número guardado directamente en tu móvil.

Podemos comprobar esto haciendo una consulta de varios saltos, como la que hay desde Cronus hasta sus nietos:

MATCH (cronus:God {name: "Cronus"})-[:FATHER_OF]->(hijo)-[:FATHER_OF]->(nieto)
RETURN cronus.name, hijo.name, nieto.name

Son dos saltos encadenados sin ningún JOIN. Neo4j sigue los punteros directamente de Cronus a sus hijos y de ahí a sus nietos. En SQL necesitaríamos dos JOINs y el rendimiento tiende a empeorar con cada salto adicional.

Otra característica es que las relaciones siempre tienen tipo y dirección. No podemos crear una relación sin nombre. Siempre sabemos qué significa: FATHER_OF, BROTHER_OF, LIVES_IN. Esto hace el modelo muy expresivo y autodocumentado. La dirección además queda almacenada aunque luego se ignore al consultar.

Así, por ejemplo, podemos preguntar cosas muy específicas gracias al tipo:

MATCH (zeus:God {name: "Zeus"})-[:FATHER_OF]->(hijo) RETURN hijo.name

Por último, podemos destacar que el modelo refleja el dominio. En SQL normalizamos, separamos todo en tablas para evitar redundancia, aunque eso aleje el modelo de la realidad. En grafos modelamos como pensamos. Si en la mitología griega Zeus es padre de Atenea, creamos exactamente eso, un nodo Zeus, un nodo Atenea, y una relación FATHER_OF entre ellos. Sin tablas intermedias, sin foreign keys, sin joins. El grafo se lee como una historia.

(zeus)-[:FATHER_OF]->(athena)

Y eso es todo. No hay tabla intermedia, no hay foreign key, no hay JOIN. Si mañana queremos indicar que Zeus también es enemigo de alguien, simplemente basta con añadirlo sin tocar ninguna tabla existente, sin ALTER TABLE ni migraciones.

(zeus)-[:ENEMY_OF]->(titan)

Y hasta aquí la explicación sobre Neo4j y los grafos. En la siguiente entrada veremos la manera de aplicar todo esto para enriquecer el contexto de los modelos.

mmfilesi · 2026