domingo, 22 de febrero de 2026

MySQL + Neo4j para cargas de trabajo de IA: Por qué las bases de datos relacionales todavía importan

This article was originally published in English at AnotherMySQLDBA.

Así que pensé que era hora de documentar cómo construir memoria persistente para agentes de IA usando las bases de datos que ya conoces. No bases de datos vectoriales - MySQL y Neo4j.

Esto no es teórico. Uso esta arquitectura diariamente, manejando la memoria de agentes de IA en múltiples proyectos. Aquí está el esquema y los patrones de consultas que realmente funcionan.

La Arquitectura

Los agentes de IA necesitan dos tipos de memoria:

  • Memoria estructurada - Qué pasó, cuándo, por qué (MySQL)
  • Memoria de patrones - Qué se conecta con qué (Neo4j)

Las bases de datos vectoriales son para búsqueda de similitud. No son para rastrear el estado del flujo de trabajo o el historial de decisiones. Para eso, necesitas transacciones ACID y relaciones adecuadas.

El Esquema de MySQL

Aquí está el esquema real para la memoria persistente de agentes de IA:

-- Architecture decisions the AI made
CREATE TABLE architecture_decisions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    project_id INT NOT NULL,
    title VARCHAR(255) NOT NULL,
    decision TEXT NOT NULL,
    rationale TEXT,
    alternatives_considered TEXT,
    status ENUM('accepted', 'rejected', 'pending') DEFAULT 'accepted',
    decided_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    tags JSON,
    INDEX idx_project_date (project_id, decided_at),
    INDEX idx_status (status)
) ENGINE=InnoDB;

-- Code patterns the AI learned
CREATE TABLE code_patterns (
    id INT AUTO_INCREMENT PRIMARY KEY,
    project_id INT NOT NULL,
    category VARCHAR(50) NOT NULL,
    name VARCHAR(255) NOT NULL,
    description TEXT,
    code_example TEXT,
    language VARCHAR(50),
    confidence_score FLOAT DEFAULT 0.5,
    usage_count INT DEFAULT 0,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME ON UPDATE CURRENT_TIMESTAMP,
    INDEX idx_project_category (project_id, category),
    INDEX idx_confidence (confidence_score)
) ENGINE=InnoDB;

-- Work session tracking
CREATE TABLE work_sessions (
    id INT AUTO_INCREMENT PRIMARY KEY,
    session_id VARCHAR(255) UNIQUE NOT NULL,
    project_id INT NOT NULL,
    started_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    ended_at DATETIME,
    summary TEXT,
    context JSON,
    INDEX idx_project_session (project_id, started_at)
) ENGINE=InnoDB;

-- Pitfalls to avoid (learned from mistakes)
CREATE TABLE pitfalls (
    id INT AUTO_INCREMENT PRIMARY KEY,
    project_id INT NOT NULL,
    category VARCHAR(50),
    title VARCHAR(255) NOT NULL,
    description TEXT,
    how_to_avoid TEXT,
    severity ENUM('critical', 'high', 'medium', 'low'),
    encountered_count INT DEFAULT 1,
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_project_severity (project_id, severity)
) ENGINE=InnoDB;

Claves foráneas. Restricciones de verificación. Indexación adecuada. Esto es lo que las bases de datos relacionales hacen bien.

Patrones de Consultas

Aquí está cómo realmente consultas esto para la memoria de agentes de IA:

-- Get recent decisions for context
SELECT title, decision, rationale, decided_at
FROM architecture_decisions
WHERE project_id = ?
  AND decided_at > DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY decided_at DESC
LIMIT 10;

-- Find high-confidence patterns
SELECT category, name, description, code_example
FROM code_patterns
WHERE project_id = ?
  AND confidence_score >= 0.80
ORDER BY usage_count DESC, confidence_score DESC
LIMIT 20;

-- Check for known pitfalls before implementing
SELECT title, description, how_to_avoid
FROM pitfalls
WHERE project_id = ?
  AND category = ?
  AND severity IN ('critical', 'high')
ORDER BY encountered_count DESC;

-- Track session context across interactions
SELECT context
FROM work_sessions
WHERE session_id = ?
ORDER BY started_at DESC
LIMIT 1;

Estas son consultas SQL sencillas. EXPLAIN muestra el uso de índices exactamente donde se espera. Sin sorpresas.

La Capa de Neo4j

MySQL maneja los datos estructurados. Neo4j maneja las relaciones:

// Create nodes for decisions
CREATE (d:Decision {
  id: 'dec_123',
  title: 'Use FastAPI',
  project_id: 1,
  embedding: [0.23, -0.45, ...]  // Vector for similarity
})

// Create relationships
CREATE (d1:Decision {id: 'dec_123', title: 'Use FastAPI'})
CREATE (d2:Decision {id: 'dec_45', title: 'Used Flask before'})
CREATE (d1)-[:SIMILAR_TO {score: 0.85}]->(d2)
CREATE (d1)-[:CONTRADICTS]->(d3:Decision {title: 'Avoid frameworks'})

// Query: Find similar past decisions
MATCH (current:Decision {id: $decision_id})
MATCH (current)-[r:SIMILAR_TO]-(similar:Decision)
WHERE r.score > 0.80
RETURN similar.title, r.score
ORDER BY r.score DESC

// Query: What outcomes followed this pattern?
MATCH (d:Decision)-[:LEADS_TO]->(o:Outcome)
WHERE d.title CONTAINS 'Redis'
RETURN d.title, o.type, o.success_rate

Cómo Funcionan Juntos

El flujo se ve así:

  1. El agente de IA genera contenido o toma una decisión
  2. Almacena datos estructurados en MySQL (qué, cuándo, por qué, contexto completo)
  3. Genera embedding, almacena en Neo4j con relaciones a elementos similares
  4. Siguiente sesión: Neo4j encuentra decisiones similares relevantes
  5. MySQL proporciona los detalles completos de esas decisiones

MySQL es la fuente de verdad. Neo4j es el buscador de patrones.

¿Por Qué No Solo Bases de Datos Vectoriales?

He visto equipos intentar construir memoria de agentes de IA solo con Pinecone o Weaviate. No funciona bien porque:

Las bases de datos vectoriales son buenas para:

  • Encontrar documentos similares a una consulta
  • Búsqueda semántica (RAG)
  • "Cosas como esta"

Las bases de datos vectoriales son malas para:

  • "¿Qué decidimos el 15 de marzo?"
  • "Muéstrame decisiones que llevaron a interrupciones"
  • "¿Cuál es el estado actual de este flujo de trabajo?"
  • "¿Qué patrones tienen confianza > 0.8 AND usage_count > 10?"

Esas consultas necesitan filtrado estructurado, uniones y transacciones. Ese es el territorio de las bases de datos relacionales.

MCP y el Futuro

El Protocolo de Contexto de Modelo (MCP) está estandarizando cómo los sistemas de IA manejan el contexto. Las implementaciones tempranas de MCP están descubriendo lo que ya sabíamos: necesitas tanto almacenamiento estructurado como relaciones de grafo.

``````html

MySQL maneja el catálogo de "resources" y "tools" de MCP. Neo4j maneja las "relationships" entre elementos de contexto. Los vector embeddings son solo una pieza del rompecabezas.

Notas de Producción

Sistema actual ejecutando esta arquitectura:

  • MySQL 8.0, 48 tables, ~2GB data
  • Neo4j Community, ~50k nodes, ~200k relationships
  • Query latency: MySQL <10ms, Neo4j <50ms
  • Backup: Standard mysqldump + neo4j-admin dump
  • Monitoring: Same Percona tools I've used for years

La complejidad operativa es baja porque son bases de datos maduras con patrones operativos bien comprendidos.

Cuándo Usar Qué

Use CaseDatabase
Workflow state, decisions, audit trailMySQL/PostgreSQL
Pattern detection, similarity, relationshipsNeo4j
Semantic document search (RAG)Vector DB (optional)

Comienza con MySQL para el estado. Agrega Neo4j cuando necesites reconocimiento de patrones. Solo agrega vector DBs si realmente estás haciendo recuperación semántica de documentos.

Resumen

Los agentes de IA necesitan memoria persistente. No solo embeddings en una vector database - memoria estructurada, relacional, temporal con reconocimiento de patrones.

MySQL maneja el estado estructurado. Neo4j maneja las relaciones de grafo. Juntos proporcionan lo que las vector databases solas no pueden.

No abandones las bases de datos relacionales para cargas de trabajo de IA. Usa la herramienta correcta para cada trabajo, que es usar ambas juntas.

Para más sobre la perspectiva de los agentes de IA en esta arquitectura, vea la publicación complementaria en 3k1o.

sábado, 21 de febrero de 2026

Funciones JSON de MySQL 8.0: Ejemplos Prácticos e Indexación

This article was originally published in English at AnotherMySQLDBA.

Esta publicación cubre una guía práctica de las funciones JSON de MySQL 8.0. El soporte para JSON ha estado en MySQL desde 5.7, pero 8.0 añadió un conjunto significativo de mejoras —mejores estrategias de indexación, nuevas funciones y índices multi-valor— que hacen que trabajar con datos JSON sea considerablemente más práctico. Lo siguiente documenta varios de los patrones más comúnmente necesarios, incluyendo la salida de EXPLAIN y observaciones de rendimiento que vale la pena conocer.

Esta no es una publicación de debate "JSON vs. relacional". Si estás almacenando JSON en MySQL, probablemente ya tienes tus razones. El objetivo aquí es asegurarte de que estás usando las herramientas disponibles de manera efectiva.

Entorno

mysql> SELECT @@version, @@version_comment\G
*************************** 1. row ***************************
        @@version: 8.0.36
@@version_comment: MySQL Community Server - GPL

Las pruebas se realizaron en una VM con 8GB de RAM y innodb_buffer_pool_size configurado en 4G. Una nota de mantenimiento que vale la pena mencionar: query_cache_type es irrelevante en 8.0 ya que la caché de consultas fue eliminada por completo. Si migraste una instancia de 5.7 y aún tienes esa variable en tu my.cnf, elimínala — MySQL 8.0 lanzará un error de inicio.

Configuración de una Tabla de Prueba

La tabla de prueba simula un patrón bastante común — una aplicación que almacena datos de perfil de usuario y metadatos de eventos como bloques JSON:

CREATE TABLE user_events (
  id          INT UNSIGNED NOT NULL AUTO_INCREMENT,
  user_id     INT UNSIGNED NOT NULL,
  event_data  JSON NOT NULL,
  created_at  DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  INDEX idx_user (user_id)
) ENGINE=InnoDB;

INSERT INTO user_events (user_id, event_data) VALUES
(1, '{"action":"login","ip":"192.168.1.10","tags":["mobile","vpn"],"score":88}'),
(1, '{"action":"purchase","ip":"192.168.1.10","tags":["desktop"],"score":72,"amount":49.99}'),
(2, '{"action":"login","ip":"10.0.0.5","tags":["mobile"],"score":91}'),
(3, '{"action":"logout","ip":"10.0.0.9","tags":["desktop","vpn"],"score":65}'),
(2, '{"action":"purchase","ip":"10.0.0.5","tags":["mobile"],"score":84,"amount":129.00}');

Extracción Básica: JSON_VALUE vs. JSON_EXTRACT

JSON_VALUE() fue introducida en MySQL 8.0.21 y es la forma más limpia de extraer valores escalares con conversión de tipo incorporada. Antes de eso, usabas JSON_EXTRACT() (o la abreviatura ->) y convertías manualmente, lo que funciona pero añade ruido a tus consultas.

-- Pre-8.0.21 approach
SELECT user_id,
       JSON_EXTRACT(event_data, '$.action') AS action,
       CAST(JSON_EXTRACT(event_data, '$.score') AS UNSIGNED) AS score
FROM user_events;

-- Cleaner 8.0.21+ approach
SELECT user_id,
       JSON_VALUE(event_data, '$.action') AS action,
       JSON_VALUE(event_data, '$.score' RETURNING UNSIGNED) AS score
FROM user_events;

Salida de la segunda consulta:

+---------+----------+-------+
| user_id | action   | score |
+---------+----------+-------+
|       1 | login    |    88 |
|       1 | purchase |    72 |
|       2 | login    |    91 |
|       3 | logout   |    65 |
|       2 | purchase |    84 |
+---------+----------+-------+
5 rows in set (0.00 sec)

La cláusula RETURNING es genuinamente útil. Elimina el patrón incómodo de doble conversión y hace que la intención sea más clara al leer el código de la consulta más tarde.

Índices Multi-Valor: El Verdadero Cambiador de Juego

Aquí es donde 8.0 realmente movió la aguja para las cargas de trabajo JSON. Los índices multi-valor, disponibles desde MySQL 8.0.17, te permiten indexar elementos de array dentro de una columna JSON directamente. Así es como se ve en la práctica:

ALTER TABLE user_events
  ADD INDEX idx_tags ((CAST(event_data->'$.tags' AS CHAR(64) ARRAY)));

Aquí es lo que muestra EXPLAIN antes y después en una consulta que filtra por valor de etiqueta:

-- Without the multi-valued index:
EXPLAIN SELECT * FROM user_events
WHERE JSON_CONTAINS(event_data->'$.tags', '"vpn"')\G

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: user_events
   partitions: NULL
         type: ALL
possible_keys: NULL
          key: NULL
      key_len: NULL
          ref: NULL
         rows: 5
     filtered: 100.00
        Extra: Using where

-- After adding the multi-valued index:
EXPLAIN SELECT * FROM user_events
WHERE JSON_CONTAINS(event_data->'$.tags', '"vpn"')\G

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: user_events
   partitions: NULL
         type: range
possible_keys: idx_tags
          key: idx_tags
      key_len: 67
          ref: NULL
         rows: 2
     filtered: 100.00
        Extra: Using where

Barrido completo de tabla reducido a un barrido de rango. En 5 filas esto es trivial, pero en una tabla con millones de filas y filtrado frecuente basado en etiquetas, esa diferencia es significativa. La mejora escala directamente con el tamaño de la tabla y la frecuencia de las consultas.

Un importante inconveniente: MEMBER OF() y JSON_OVERLAPS() también se benefician de los índices multi-valor, pero JSON_SEARCH() no. Esto importa al elegir tu patrón de consulta en tiempo de diseño:

-- This WILL use the multi-valued index:
SELECT * FROM user_events
WHERE 'vpn' MEMBER OF (event_data->'$.tags');

-- This will NOT use it:
SELECT * FROM user_events
WHERE JSON_SEARCH(event_data->'$.tags', 'one', 'vpn') IS NOT NULL;

Aggregating and Transforming JSON

``````html
-- Build a JSON array of actions per user
SELECT user_id,
       JSON_ARRAYAGG(JSON_VALUE(event_data, '$.action')) AS actions
FROM user_events
GROUP BY user_id;

+---------+----------------------+
| user_id | actions              |
+---------+----------------------+
|       1 | ["login","purchase"] |
|       2 | ["login","purchase"] |
|       3 | ["logout"]           |
+---------+----------------------+
3 rows in set (0.01 sec)

-- Summarize into a JSON object keyed by action
SELECT user_id,
       JSON_OBJECTAGG(
         JSON_VALUE(event_data, '$.action'),
         JSON_VALUE(event_data, '$.score' RETURNING UNSIGNED)
       ) AS score_by_action
FROM user_events
GROUP BY user_id;

+---------+--------------------------------+
| user_id | score_by_action                |
+---------+--------------------------------+
|       1 | {"login": 88, "purchase": 72}  |
|       2 | {"login": 91, "purchase": 84}  |
|       3 | {"logout": 65}                 |
+---------+--------------------------------+
3 rows in set (0.00 sec)

JSON_OBJECTAGG() lanzará un error si hay claves duplicadas dentro de un grupo. Esto es importante saberlo antes de encontrártelo en un pipeline ETL de producción. En ese caso, necesitarás desduplicar aguas arriba o manejarlo en la lógica de la aplicación antes de que los datos lleguen a este paso de agregación.

Comprobando SHOW STATUS después de consultas intensivas en JSON

Al evaluar patrones de consultas, comprobar las métricas de handler es un hábito útil:

FLUSH STATUS;

SELECT * FROM user_events
WHERE JSON_VALUE(event_data, '$.score' RETURNING UNSIGNED) > 80;

SHOW STATUS LIKE 'Handler_read%';

+----------------------------+-------+
| Variable_name              | Value |
+----------------------------+-------+
| Handler_read_first         | 1     |
| Handler_read_key           | 0     |
| Handler_read_last          | 0     |
| Handler_read_next          | 4     |
| Handler_read_prev          | 0     |
| Handler_read_rnd           | 0     |
| Handler_read_rnd_next      | 6     |
+----------------------------+-------+
7 rows in set (0.00 sec)

El valor de Handler_read_rnd_next confirma un escaneo completo — no es una sorpresa ya que no hay un índice funcional en el valor de score. Para filtrado basado en score a escala, una columna generada con un índice es la solución correcta:

ALTER TABLE user_events
  ADD COLUMN score_val TINYINT UNSIGNED
    GENERATED ALWAYS AS (JSON_VALUE(event_data, '$.score' RETURNING UNSIGNED)) VIRTUAL,
  ADD INDEX idx_score (score_val);

Después de agregarlo, la misma consulta cae a un escaneo de rango de índice adecuado. Las columnas generadas en campos JSON están disponibles tanto en MySQL 8.0 como en Percona Server 8.0, y siguen siendo el camino más confiable para filtrado de campos JSON escalares a cualquier escala significativa.

Si estás ejecutando Percona Server, pt-query-digest del Percona Toolkit sigue siendo la forma más práctica de identificar qué consultas intensivas en JSON están realmente causando problemas en producción antes de que empieces a agregar índices especulativamente.

Observaciones prácticas

  • Los índices multi-valor (8.0.17+) son una mejora muy esperada y funcionan bien cuando tus patrones de consulta se alinean con JSON_CONTAINS() o MEMBER OF()
  • JSON_VALUE() con RETURNING (8.0.21+) es más limpio que el viejo patrón de casteo después de extraer y vale la pena adoptarlo consistentemente
  • Las columnas generadas más índices siguen siendo el camino más confiable para filtrado de campos JSON escalares a escala
  • Vigila los errores de claves duplicadas en JSON_OBJECTAGG() en datos agrupados — aparece como un error duro en pipelines ETL y puede ser fácil de pasar por alto en pruebas si tus datos de muestra casualmente están limpios
  • Verifica siempre el uso de índices con EXPLAIN — el optimizador no siempre detecta índices multi-valor en cláusulas WHERE complejas, y vale la pena confirmarlo en lugar de asumirlo

Resumen

Las mejoras de JSON en MySQL 8.0 son genuinamente útiles, particularmente los índices multi-valor y JSON_VALUE() con casteo de tipo. No reemplazan un buen diseño de esquema, pero para casos donde el almacenamiento JSON es apropiado o heredado, ahora tienes herramientas reales con las que trabajar en lugar de solo esperar que el optimizador lo resuelva. El patrón de columna generada en particular vale la pena evaluarlo temprano si sabes que ciertos campos JSON se usarán regularmente en cláusulas WHERE.

Referencias útiles: