El Modelo de Datos
Una feature de red social requiere: usuarios, follows, posts, likes, comentarios e "intereses" (temas con los que los usuarios se etiquetan). El modelo de grafo:
(:User)-[:FOLLOWS]->(:User)
(:User)-[:POSTED]->(:Post)
(:User)-[:LIKED]->(:Post)
(:User)-[:COMMENTED {text: "..."}]->(:Post)
(:User)-[:INTERESTED_IN]->(:Topic)
(:Post)-[:TAGGED_WITH]->(:Topic)
Unos 500K usuarios, 2M relaciones de follow, 1.5M posts, 8M likes. No es masivo de ninguna manera, pero suficiente para exponer diferencias de performance.
Neo4j: El Grafo Nativo
Neo4j almacena datos como nodos y relaciones en un formato de grafo nativo, index-free adjacency significa que cada nodo apunta físicamente a sus vecinos. Esto hace que las operaciones de traversal sean constant-time por hop, independientemente del tamaño total del grafo.
Setup e Ingesta
// Crear constraints e índices
CREATE CONSTRAINT user_email IF NOT EXISTS
FOR (u:User) REQUIRE u.email IS UNIQUE;
CREATE INDEX user_name IF NOT EXISTS
FOR (u:User) ON (u.name);
CREATE INDEX post_created IF NOT EXISTS
FOR (p:Post) ON (p.createdAt);
// Import batch con LOAD CSV
LOAD CSV WITH HEADERS FROM 'file:///users.csv' AS row
CREATE (u:User {
id: row.id,
name: row.name,
email: row.email,
joinedAt: datetime(row.joinedAt)
});
La ingesta de 500K usuarios + relaciones tomó unos 90 segundos usando neo4j-admin import (bulk load offline). El approach con LOAD CSV tardó 12 minutos. Para migraciones de producción, siempre utilizar la herramienta admin.
Ejemplos de Queries
El pan y la manteca, "¿a quién debería seguir este usuario?" basado en conexiones mutuas e intereses compartidos:
// Recomendación amigo-de-amigo excluyendo follows existentes
MATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)
WHERE NOT (me)-[:FOLLOWS]->(suggestion)
AND suggestion <> me
WITH suggestion, count(friend) AS mutualFriends
ORDER BY mutualFriends DESC
LIMIT 20
RETURN suggestion.name, suggestion.id, mutualFriends
Esto corrió en 15ms en nuestro dataset. El query planner usa el índice en User.id, después traversa dos hops a través de la relación FOLLOWS. La agregación count(friend) es donde pasa el ranking.
Una más compleja, "encontrá el camino más corto entre dos usuarios a través de su grafo de follows":
MATCH path = shortestPath(
(a:User {id: $userA})-[:FOLLOWS*..6]-(b:User {id: $userB})
)
RETURN [node IN nodes(path) | node.name] AS names,
length(path) AS degrees
Ese *..6 es crítico. Sin un límite de profundidad, esta query va a explorar todo el componente conectado, que en un grafo social significa potencialmente millones de nodos. Se establece en 6 por la teoría de los seis grados de separación, si no hay camino dentro de 6 hops, los usuarios no están significativamente conectados para propósitos típicos de UX.
Optimización de Performance
La optimización de mayor impacto en Neo4j es profilear tus queries:
PROFILE MATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)
WHERE NOT (me)-[:FOLLOWS]->(suggestion)
RETURN suggestion.name, count(friend) AS mutual
ORDER BY mutual DESC LIMIT 20
El prefijo PROFILE te muestra el plan de ejecución de la query con conteos de filas reales. El análisis reveló que una query de "posts trending en tu red" estaba haciendo un AllNodesScan en Post en vez de usar el índice de fecha, agregar un filtro WHERE p.createdAt > datetime() - duration('P7D') antes del traversal de relaciones cortó la query de 2.3s a 80ms.
SurrealDB: El Enfoque Multi-Modelo
SurrealDB maneja grafos a través de su sistema de record links, cualquier campo puede referenciar otro registro usando sintaxis record_id, y es posible traversar estos links con notación de flecha en SurrealQL.
-- Definir esquema
DEFINE TABLE user SCHEMAFULL;
DEFINE FIELD name ON user TYPE string;
DEFINE FIELD email ON user TYPE string;
DEFINE INDEX email_idx ON user FIELDS email UNIQUE;
DEFINE TABLE follows SCHEMAFULL TYPE RELATION
FROM user TO user;
DEFINE FIELD since ON follows TYPE datetime DEFAULT time::now();
DEFINE TABLE post SCHEMAFULL;
DEFINE FIELD content ON post TYPE string;
DEFINE FIELD author ON post TYPE record<user>;
DEFINE FIELD createdAt ON post TYPE datetime DEFAULT time::now();
Traversal de Grafo en SurrealQL
La sintaxis de flechas es sorprendentemente limpia:
-- Follows directos
SELECT name FROM user:alice->follows->user;
-- Amigo-de-amigo (2 hops)
SELECT name, count() AS mutual FROM
user:$userId->follows->user->follows->user
WHERE id NOT IN (SELECT VALUE out FROM follows WHERE in = user:$userId)
AND id != user:$userId
GROUP BY id
ORDER BY mutual DESC
LIMIT 20;
La misma query de recomendación que tardó 15ms en Neo4j corrió en 45ms en SurrealDB con hardware equivalente. No está mal para una base de datos que también maneja queries de documentos y relacionales, pero la brecha se ensancha en traversals más profundos.
Donde SurrealDB brilla es en workloads mixtos. Considérese una query de feed que necesita tanto traversal de grafo (¿a quién sigo?) como agregación estilo documento (¿cuáles son sus posts recientes, rankeados por engagement?):
-- Feed: posts de usuarios seguidos, rankeados por engagement
SELECT
content,
author.name AS authorName,
createdAt,
count(->liked_by) AS likes,
count(->comments) AS commentCount
FROM post
WHERE author IN (SELECT VALUE out FROM follows WHERE in = user:$userId)
AND createdAt > time::now() - 7d
ORDER BY likes DESC, createdAt DESC
LIMIT 50;
En Neo4j, esto requeriría combinar traversal Cypher con búsqueda full-text y agregación, se puede pero es menos natural.
Apache AGE: Grafo sobre PostgreSQL
Apache AGE (A Graph Extension) agrega soporte de openCypher a PostgreSQL. Tus datos de grafo viven en tablas de PostgreSQL con un formato interno específico, pero los se consulta con sintaxis Cypher envuelta en SQL.
-- Habilitar la extensión
CREATE EXTENSION IF NOT EXISTS age;
LOAD 'age';
SET search_path = ag_catalog, "$user", public;
-- Crear un grafo
SELECT create_graph('social');
-- Crear nodos
SELECT * FROM cypher('social', $$
CREATE (u:User {id: 'alice', name: 'Alice', email: 'alice@example.com'})
RETURN u
$$) AS (u agtype);
-- Crear relaciones
SELECT * FROM cypher('social', $$
MATCH (a:User {id: 'alice'}), (b:User {id: 'bob'})
CREATE (a)-[:FOLLOWS {since: '2025-01-15'}]->(b)
$$) AS (result agtype);
El Puente SQL-Cypher
La feature asesina es mezclar SQL y Cypher en la misma query:
-- Usar tablas regulares de PostgreSQL junto con queries de grafo
SELECT
u.display_name,
u.avatar_url,
graph_data.mutual_count
FROM users u
JOIN (
SELECT * FROM cypher('social', $$
MATCH (me:User {id: $userId})-[:FOLLOWS]->(friend)-[:FOLLOWS]->(suggestion)
WHERE NOT (me)-[:FOLLOWS]->(suggestion) AND suggestion <> me
RETURN suggestion.id AS user_id, count(friend) AS mutual_count
ORDER BY mutual_count DESC
LIMIT 20
$$, $1) AS (user_id agtype, mutual_count agtype)
) AS graph_data ON u.id = graph_data.user_id::text;
Este es el superpoder de AGE, es posible agregar queries de grafo a una aplicación PostgreSQL existente sin migrar datos a una base de datos separada. La tabla users con datos de perfil se queda en PostgreSQL regular; el grafo social vive en AGE.
Realidad de Performance
La misma recomendación amigo-de-amigo: 85ms en AGE vs 15ms en Neo4j. El overhead viene del executor de PostgreSQL planeando la query Cypher como una subquery, cada hop pasa por el planner regular en vez de traversal nativo de grafo.
Para queries de hasta 3 hops, AGE es absolutamente competitivo. Más allá de eso, el overhead se compone:
1 hop: AGE 8ms vs Neo4j 3ms (2.7x)
2 hops: AGE 35ms vs Neo4j 15ms (2.3x)
3 hops: AGE 85ms vs Neo4j 25ms (3.4x)
4 hops: AGE 340ms vs Neo4j 55ms (6.2x)
5 hops: AGE 1.8s vs Neo4j 120ms (15x)
El ratio empeora porque el index-free adjacency de Neo4j es O(1) por hop mientras AGE hace lookups de índice a través del B-tree de PostgreSQL para cada nivel.
GQL: El Estándar del Futuro
ISO publicó GQL (ISO/IEC 39075) en 2024 como el lenguaje de query de grafos estándar. Toma mucho de Cypher pero agrega features como tipos de grafo, patrones de camino y un sistema de tipos más riguroso.
-- Sintaxis GQL (en evolución, esto es aproximado)
GRAPH social
MATCH (me:User WHERE me.id = $userId)
-/:FOLLOWS/-> (friend)
-/:FOLLOWS/-> (suggestion)
WHERE NOT EXISTS (me -/:FOLLOWS/-> suggestion)
AND suggestion <> me
RETURN suggestion.name, COUNT(friend) AS mutual
ORDER BY mutual DESC
LIMIT 20;
La diferencia clave con Cypher: GQL usa sintaxis de patrones de camino (-/:FOLLOWS/->) y selección explícita de grafo (GRAPH social). Neo4j se comprometió con soporte GQL, y bases de datos más nuevas como Memgraph y Kuzu lo están implementando.
Por ahora, Cypher sigue siendo la opción práctica. La adopción de GQL está donde estaba SQL a principios de los 90, el estándar existe, pero las implementaciones están poniéndose al día.
Recomendaciones por Caso de Uso
Para una feature de grafo social greenfield: Neo4j si las queries de grafo son el workload principal y es posible justificar el costo operacional de una base de datos separada. La performance de queries y el tooling (Neo4j Browser, biblioteca APOC) no tienen rival.
Para agregar queries de grafo a una app PostgreSQL existente: Apache AGE sin dudar. La capacidad de hacer JOIN de resultados de grafo con tablas regulares es una ventaja masiva, y la performance es suficientemente buena para queries de 3 hops que cubre la mayoría de case es de uso reales.
Para un proyecto nuevo con requisitos mixtos (grafo + documento + real-time): SurrealDB vale la pena evaluarlo. El enfoque multi-modelo reduce la complejidad operacional. Solo entrá con los ojos abiertos sobre la madurez del optimizador de queries.
El modelo de grafo no es la parte difícil. La parte difícil es saber cuándo tus datos son realmente un grafo, y cuándo se está forzando un problema relacional en una forma de grafo porque el lenguaje de queries se ve más lindo.