2026年2月22日日曜日

AI ワークロードのための MySQL + Neo4j:なぜリレーショナルデータベースが今なお重要か

This article was originally published in English at AnotherMySQLDBA.

AIエージェント用の永続メモリを構築する方法を、既にお使いのデータベースを使って文書化する時期が来たと考えました。ベクトルデータベースではありません - MySQLとNeo4jです。

これは理論ではありません。私はこのアーキテクチャを日常的に使用し、複数のプロジェクトでAIエージェントのメモリを管理しています。実際に機能するスキーマとクエリパターンを紹介します。

アーキテクチャ

AIエージェントには2種類のメモリが必要です:

  • 構造化メモリ - 何が起こったか、いつ、なぜ(MySQL)
  • パターンメモリ - 何が何に繋がっているか(Neo4j)

ベクトルデータベースは類似性検索用です。ワークフローの状態や決定履歴を追跡するには適していません。それにはACIDトランザクションと適切なリレーションシップが必要です。

MySQLスキーマ

AIエージェントの永続メモリのための実際のスキーマはこちらです:

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

外部キー。チェック制約。適切なインデックス。これはリレーショナルデータベースが得意とするところです。

クエリパターン

AIエージェントのメモリを実際にクエリする方法はこちらです:

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

これらはストレートフォワードなSQLクエリです。EXPLAINで期待通りのインデックス使用が確認できます。サプライズはありません。

Neo4jレイヤー

MySQLが構造化データを扱い、Neo4jがリレーションシップを扱います:

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

それらが連携して動作する仕組み

フローは以下のようになります:

  1. AIエージェントがコンテンツを生成するか決定を下す
  2. MySQLに構造化データを保存(何、いつ、なぜ、完全なコンテキスト)
  3. エンベディングを生成し、類似アイテムとのリレーションシップ付きでNeo4jに保存
  4. 次のセッション:Neo4jが関連する類似決定を見つけ出す
  5. MySQLがその決定の詳細情報を提供

MySQLが真実の源です。Neo4jがパターン発見者です。

なぜベクトルデータベースだけではダメなのか?

PineconeやWeaviateだけでAIエージェントのメモリを構築しようとするチームを見てきましたが、うまく行きません。理由は:

ベクトルDBが得意なこと:

  • クエリに似たドキュメントを見つける
  • セマンティック検索(RAG)
  • "これに似たもの"

ベクトルDBが苦手なこと:

  • "3月15日に何を決定したか?"
  • "障害を引き起こした決定を表示"
  • "このワークフローの現在の状態は?"
  • "confidence > 0.8 AND usage_count > 10のパターンはどれ?"

これらのクエリには構造化フィルタリング、結合、トランザクションが必要です。それはリレーショナルデータベースの領域です。

MCPと未来

Model Context Protocol (MCP)は、AIシステムがコンテキストを扱う方法を標準化しています。初期のMCP実装でも、私たちがすでに知っていたことが明らかになっています:構造化ストレージとグラフリレーションシップの両方が必要です。

``````html

MySQL は MCP の「resources」と「tools」カタログを扱います。Neo4j はコンテキスト項目間の「relationships」を扱います。ベクター埋め込みはパズルの一部に過ぎません。

Production Notes

このアーキテクチャを実行している現在のシステム:

  • 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

運用上の複雑さは低いです。これらは成熟したデータベースで、よく理解された運用パターンを持っています。

When to Use What

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

状態には MySQL から始めましょう。パターン認識が必要になったら Neo4j を追加します。セマンティックドキュメント検索を実際にしている場合にのみ、ベクターデータベースを追加してください。

Summary

AI エージェントには永続的なメモリが必要です。単なるベクターデータベースの埋め込みではなく、構造化された、リレーショナルで、時間的なメモリにパターン認識を備えたものです。

MySQL は構造化された状態を扱います。Neo4j はグラフリレーションシップを扱います。これらを組み合わせることで、ベクターデータベース単独では提供できないものを提供します。

AI ワークロードのためにリレーショナルデータベースを放棄しないでください。それぞれの仕事に適したツールを使用し、それは両方を一緒に使うことです。

このアーキテクチャに関する AI エージェントの視点の詳細については、3k1o の関連投稿をご覧ください。

MySQL 8.0 JSON 関数:実践的な例とインデクシング

This article was originally published in English at AnotherMySQLDBA.

この投稿では、MySQL 8.0 の JSON 関数のハンズオン解説を扱います。JSON サポートは MySQL 5.7 から存在しますが、8.0 では重要な改善が追加されました — より優れたインデックス戦略、新しい関数、マルチバリューインデックス — これにより JSON データの取り扱いが大幅に実用的になりました。以下の内容では、最も一般的に必要なパターンのいくつかを文書化し、EXPLAIN の出力と知っておくべきパフォーマンス観察を含めています。

これは「JSON vs. リレーショナル」の議論投稿ではありません。MySQL に JSON を保存している場合、すでに理由をお持ちでしょう。ここでの目標は、利用可能なツールを効果的に使用していることを確認することです。

環境

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

テストは 8GB RAM の VM で行われ、innodb_buffer_pool_size を 4G に設定しました。言及する価値のあるメンテナンス上の注意点として:query_cache_type は 8.0 では無関係です。なぜなら query cache が完全に削除されたからです。5.7 インスタンスから移行し、my.cnf にその変数が残っている場合、削除してください — MySQL 8.0 は起動エラーを投げます。

テストテーブルの設定

テストテーブルは、かなり一般的なパターンをシミュレートしています — アプリケーションがユーザー profile データとイベントメタデータを JSON blob として保存するものです:

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

基本的な抽出:JSON_VALUE vs. JSON_EXTRACT

JSON_VALUE() は MySQL 8.0.21 で導入され、スカラー値を抽出するよりクリーンな方法で、組み込みの型キャストが可能です。それ以前は JSON_EXTRACT()(または -> 省略形)を使用し、手動でキャストする必要があり、動作はしますがクエリにノイズを追加します。

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

2 番目のクエリの出力:

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

RETURNING 句は本当に有用です。煩わしいダブルキャストパターンを排除し、後でクエリコードを読む際に意図を明確にします。

マルチバリューインデックス:真のゲームチェンジャー

ここが 8.0 で JSON ワークロードの本当の進化点です。MySQL 8.0.17 以降利用可能なマルチバリューインデックスにより、JSON カラム内の配列要素を直接インデックス付けできます。実際の動作は以下のようになります:

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

タグ値でフィルタリングするクエリに対する、EXPLAIN の前後を示します:

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

フルテーブルスキャンからレンジスキャンへ。5 行では些細なことですが、数百万行のテーブルで頻繁なタグベースのフィルタリングを行う場合、その差は顕著です。改善効果はテーブルサイズとクエリ頻度に比例してスケールします。

重要な注意点として:MEMBER OF()JSON_OVERLAPS() もマルチバリューインデックスから恩恵を受けますが、JSON_SEARCH() は受けません。設計時にクエリパターンを選択する際に重要です:

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

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() は、グループ内に重複するキーが存在する場合にエラーをスローします。これは、本番の ETL パイプラインで遭遇する前に知っておく価値があります。その場合、上流で重複を除去するか、データがこの集計ステップに到達する前にアプリケーション logic で処理する必要があります。

JSON を多用したクエリ実行後の SHOW STATUS の確認

クエリパターンを評価する際、ハンドラーメトリクスの確認は有用な習慣です:

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)

Handler_read_rnd_next の値はフルスキャンを確認しています — score 値に機能的インデックスがないため驚くことではありません。スケールでの score ベースのフィルタリングには、インデックス付きの生成カラムが正しい解決策です:

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

これを追加した後、同じクエリは適切なインデックス範囲スキャンに低下します。JSON フィールド上の生成カラムは MySQL 8.0 と Percona Server 8.0 の両方で利用可能であり、いかなる意味のあるスケールでもスカラー JSON フィールドのフィルタリングに対して最も信頼性の高い方法です。

Percona Server を使用している場合、Percona Toolkitpt-query-digest が、本番環境で実際に問題を引き起こしている JSON を多用したクエリを特定するための最も実践的な方法であり、推測的にインデックスを追加する前に使用することをお勧めします。

実践的な観察

  • マルチバリューインデックス (8.0.17+) は長らく待ち望まれた改善であり、クエリパターンが JSON_CONTAINS() または MEMBER OF() と一致する場合にうまく機能します
  • JSON_VALUE() with RETURNING (8.0.21+) は、古い抽出後のキャストパターンよりもクリーンで、一貫して採用する価値があります
  • 生成カラムとインデックスは、スケールでのスカラー JSON フィールドフィルタリングに対して最も信頼性の高い方法です
  • グループ化されたデータでの JSON_OBJECTAGG() の重複キーエラーを監視してください — ETL パイプラインでハードエラーとして表面化し、サンプルデータがたまたまクリーンであればテストで簡単に逃すことがあります
  • 常に EXPLAIN でインデックスの使用を確認してください — オプティマイザは複雑な WHERE 句でマルチバリューインデックスを常に選択するわけではなく、仮定するよりも確認する価値があります

まとめ

MySQL 8.0 の JSON 改善は本当有用であり、特にマルチバリューインデックスと型キャスト付きの JSON_VALUE() がそうです。これらは良好なスキーマ設計に代わるものではありませんが、JSON ストレージが適切または継承された場合、オプティマイザが勝手に解決してくれることを願うのではなく、本物のツールが利用可能になりました。特に、特定の JSON フィールドが WHERE 句で頻繁に使用されることがわかっている場合は、生成カラムパターンを早い段階で評価する価値があります。

有用な参考資料: