sábado, 21 de fevereiro de 2026

MySQL + Neo4j para Workloads de IA: Por Que Bancos de Dados Relacionais Ainda Importam

This article was originally published in English at AnotherMySQLDBA.

Então eu pensei que era hora de documentar como construir memória persistente para agentes de IA usando os bancos de dados que você já conhece. Não bancos de dados vetoriais - MySQL e Neo4j.

Isso não é teórico. Eu uso essa arquitetura diariamente, gerenciando memória de agente de IA em vários projetos. Aqui está o schema e os padrões de query que realmente funcionam.

A Arquitetura

Agentes de IA precisam de dois tipos de memória:

  • Memória estruturada - O que aconteceu, quando, por quê (MySQL)
  • Memória de padrões - O que se conecta a quê (Neo4j)

Bancos de dados vetoriais são para busca de similaridade. Eles não servem para rastrear estado de workflow ou histórico de decisões. Para isso, você precisa de transações ACID e relacionamentos adequados.

O Schema do MySQL

Aqui está o schema real para memória persistente de agente 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;

Chaves estrangeiras. Restrições de verificação. Indexação adequada. É isso que bancos de dados relacionais fazem bem.

Padrões de Query

Aqui está como você realmente faz query nisso para memória de agente 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;

Essas são queries SQL diretas. EXPLAIN mostra uso de índices exatamente onde esperado. Sem surpresas.

A Camada Neo4j

MySQL lida com os dados estruturados. Neo4j lida com os relacionamentos:

// 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

Como Eles Funcionam Juntos

O fluxo é assim:

  1. O agente de IA gera conteúdo ou toma uma decisão
  2. Armazena dados estruturados no MySQL (o quê, quando, por quê, contexto completo)
  3. Gera embedding, armazena no Neo4j com relacionamentos para itens similares
  4. Próxima sessão: Neo4j encontra decisões similares relevantes
  5. MySQL fornece os detalhes completos dessas decisões

MySQL é a fonte da verdade. Neo4j é o descobridor de padrões.

Por Que Não Apenas Bancos de Dados Vetoriais?

Eu vi equipes tentarem construir memória de agente de IA apenas com Pinecone ou Weaviate. Não funciona bem porque:

Bancos de dados vetoriais são bons para:

  • Encontrar documentos similares a uma query
  • Busca semântica (RAG)
  • "Coisas como esta"

Bancos de dados vetoriais são ruins para:

  • "O que decidimos em 15 de março?"
  • "Mostre decisões que levaram a quedas"
  • "Qual é o status atual deste workflow?"
  • "Quais padrões têm confidence > 0.8 AND usage_count > 10?"

Essas queries precisam de filtragem estruturada, joins e transações. Isso é território de banco de dados relacional.

MCP e o Futuro

O Model Context Protocol (MCP) está padronizando como sistemas de IA lidam com contexto. Implementações iniciais de MCP estão descobrindo o que já sabíamos: você precisa de armazenamento estruturado e relacionamentos de grafo.

``````html

MySQL lida com o catálogo de "resources" e "tools" do MCP. Neo4j lida com as "relationships" entre itens de contexto. Embeddings vetoriais são apenas uma peça do quebra-cabeça.

Notas de Produção

Sistema atual executando esta arquitetura:

  • 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

A complexidade operacional é baixa porque são bancos de dados maduros com padrões operacionais bem compreendidos.

Quando Usar Cada Um

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

Comece com MySQL para estado. Adicione Neo4j quando precisar de reconhecimento de padrões. Só adicione vector DBs se você realmente estiver fazendo recuperação semântica de documentos.

Resumo

Agentes de IA precisam de memória persistente. Não apenas embeddings em um vector database - memória estruturada, relacional, temporal com reconhecimento de padrões.

MySQL lida com o estado estruturado. Neo4j lida com as relações de grafo. Juntos, eles fornecem o que vector databases sozinhos não podem.

Não abandone bancos de dados relacionais para cargas de trabalho de IA. Use a ferramenta certa para cada trabalho, que é usar ambos juntos.

Para mais sobre a perspectiva do agente de IA nesta arquitetura, veja o post complementar em 3k1o.

Funções JSON do MySQL 8.0: Exemplos Práticos e Indexação

This article was originally published in English at AnotherMySQLDBA.

Este post apresenta um guia prático sobre as funções JSON do MySQL 8.0. O suporte a JSON existe no MySQL desde a versão 5.7, mas a 8.0 trouxe um conjunto significativo de melhorias — estratégias de indexação melhores, novas funções e índices multi-valorizados — que tornam o trabalho com dados JSON consideravelmente mais prático. O conteúdo a seguir documenta vários padrões comumente necessários, incluindo saídas do EXPLAIN e observações de desempenho importantes.

Este não é um post de debate "JSON vs. relacional". Se você está armazenando JSON no MySQL, provavelmente já tem seus motivos. O objetivo aqui é garantir que você esteja usando as ferramentas disponíveis de forma eficaz.

Ambiente

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

Os testes foram realizados em uma VM com 8GB de RAM e innodb_buffer_pool_size configurado para 4G. Uma nota importante de configuração: query_cache_type é irrelevante no 8.0, pois o cache de consultas foi removido completamente. Se você migrou uma instância 5.7 e ainda tem essa variável no seu my.cnf, remova-a — o MySQL 8.0 gerará um erro de inicialização.

Configurando uma Tabela de Teste

A tabela de teste simula um padrão bastante comum — uma aplicação armazenando dados de perfil de usuário e metadados de eventos como blobs 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}');

Extração Básica: JSON_VALUE vs. JSON_EXTRACT

JSON_VALUE() foi introduzida no MySQL 8.0.21 e é a forma mais limpa de extrair valores escalares com conversão de tipo integrada. Antes disso, você usava JSON_EXTRACT() (ou a abreviação ->) e fazia a conversão manualmente, o que funciona, mas adiciona ruído às suas 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;

Saída da 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)

A cláusula RETURNING é genuinamente útil. Ela elimina o padrão desajeitado de dupla conversão e torna a intenção mais clara ao ler o código da consulta posteriormente.

Índices Multi-Valorizados: A Verdadeira Revolução

É aqui que o 8.0 realmente fez a diferença para cargas de trabalho JSON. Os índices multi-valorizados, disponíveis desde o MySQL 8.0.17, permitem indexar elementos de array dentro de uma coluna JSON diretamente. Veja como isso funciona na prática:

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

Aqui está o que o EXPLAIN mostra antes e depois em uma consulta filtrando por valor de tag:

-- 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

Varredura completa da tabela reduzida a uma varredura de intervalo. Em 5 linhas isso é trivial, mas em uma tabela com milhões de linhas e filtragem frequente por tags, essa diferença é significativa. A melhoria escala diretamente com o tamanho da tabela e a frequência das consultas.

Um ponto importante a observar: MEMBER OF() e JSON_OVERLAPS() também se beneficiam de índices multi-valorizados, mas JSON_SEARCH() não. Isso importa ao escolher o padrão da sua consulta no momento do design:

-- 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() lançará um erro se houver chaves duplicadas dentro de um grupo. Isso vale a pena saber antes de encontrá-lo em um pipeline ETL de produção. Nesse caso, você precisará desduplicar upstream ou lidar com isso na lógica da aplicação antes que os dados cheguem a esta etapa de agregação.

Verificando SHOW STATUS Após Consultas Pesadas em JSON

Ao avaliar padrões de consultas, verificar as métricas de handler é um 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)

O valor de Handler_read_rnd_next confirma uma varredura completa — sem surpresa, já que não há índice funcional no valor da pontuação. Para filtragem baseada em pontuação em escala, uma coluna gerada com índice é a solução correta:

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);

Após adicionar isso, a mesma consulta cai para uma varredura de intervalo de índice adequada. Colunas geradas em campos JSON estão disponíveis tanto no MySQL 8.0 quanto no Percona Server 8.0, e permanecem sendo o caminho mais confiável para filtragem de campos JSON escalares em qualquer escala significativa.

Se você estiver executando Percona Server, o pt-query-digest do Percona Toolkit ainda é a maneira mais prática de identificar quais consultas pesadas em JSON estão realmente causando problemas em produção antes de começar a adicionar índices de forma especulativa.

Observações Práticas

  • Índices multi-valorados (8.0.17+) são uma melhoria há muito aguardada e funcionam bem quando seus padrões de consulta se alinham com JSON_CONTAINS() ou MEMBER OF()
  • JSON_VALUE() com RETURNING (8.0.21+) é mais limpo que o antigo padrão de conversão após extração e vale a pena adotar consistentemente
  • Colunas geradas mais índices permanecem sendo o caminho mais confiável para filtragem de campos JSON escalares em escala
  • Fique atento a erros de chaves duplicadas em JSON_OBJECTAGG() em dados agrupados — isso surge como um erro grave em pipelines ETL e pode ser fácil de perder em testes se seus dados de amostra acontecerem de estar limpos
  • Sempre verifique o uso de índices com EXPLAIN — o otimizador nem sempre detecta índices multi-valorados em cláusulas WHERE complexas, e vale a pena confirmar em vez de assumir

Resumo

As melhorias no JSON do MySQL 8.0 são genuinamente úteis, particularmente os índices multi-valorados e o JSON_VALUE() com conversão de tipo. Elas não substituem um bom design de esquema, mas para casos em que o armazenamento em JSON é apropriado ou herdado, agora você tem ferramentas reais para trabalhar em vez de apenas esperar que o otimizador descubra. O padrão de coluna gerada, em particular, vale a pena avaliar cedo se você souber que certos campos JSON serão usados regularmente em cláusulas WHERE.

Referências úteis: