☠️

ONE PIECEで学ぶグラフDB入門

に公開

グラフデータベースは、データとデータの関係性をそのまま表現するための技術です。ノード(頂点)とエッジ(関係)を使って情報を表現するので、人物同士のつながりや物事の時間的変化など、複雑な構造を素直に記述できます。リレーショナルデータベースは表形式のデータに強みがありますが、複数テーブルにまたがる関係を辿る場合には JOIN が増えて複雑になりがちです。一方、グラフDBはノードとエッジのパターンをそのままクエリに書けるので、高度に連結されたデータの探索に適しています。

今回は ONE PIECE の世界を題材に、「クルーと役職」「船の利用履歴」「物語の時系列(Arc)」といった関係をグラフDBでどう表現するかを順番に見ていきます。具体的な Cypher コードとともに、考え方や設計のポイントを解説します。

第1章 土台づくり:クルー・役職・船

まずは、登場人物や船といった大まかな枠組み(ノード)と、その関係を表現するエッジを用意します。ここで扱うノードは「クルー」「役職」「船」の3種類です。

登場する構成要素

  • クルー:麦わらの一味という単一のクルーを代表するノードを用意します。将来的に他のクルーが登場する場合も同様に追加できます。
  • 役職:船長・剣士・航海士など、キャラクターが担う職責を表すノードです。あくまで役割であり、誰がその役職に就いているかは別途エッジで表現します。
  • :ゴーイング・メリー号(キャラベル)とサウザンド・サニー号(ブリガンティン)をノードとして登録します。船は時期によってクルーが使うものが変わるため、クルーノードとは結び付けずに単独で用意しておき、後から履歴として結び付けます。

モデリングのポイント

  • 「この役職はどのクルーに属するのか?」という文脈を IN_CREW というエッジで明示します。これにより、役職自体は使いまわしつつ、どのクルーに属する役職なのかを区別できます。
  • 船はクルーに直接紐付けずに単独のノードとしておきます。どの船にいつ乗っていたかは後の章で履歴としてエッジで表現します。

Cypherコード(世界の土台)

以下の Cypher クエリは、クルー・役職・船のノードを作成し、役職とクルーの関係(IN_CREW)を張る処理です。MERGE を使って重複登録を避けている点に注目してください。

// クルー
MERGE (crew:Crew {id:'crew_strawhat'})
  ON CREATE SET crew.name='Straw Hat Pirates', crew.sea='Grand Line';

// 役職 → クルー(IN_CREW で文脈を固定)
UNWIND [
  ['role_captain','Captain'],
  ['role_swordsman','Swordsman'],
  ['role_navigator','Navigator'],
  ['role_sniper','Sniper'],
  ['role_cook','Cook'],
  ['role_doctor','Doctor'],
  ['role_archaeologist','Archaeologist'],
  ['role_shipwright','Shipwright'],
  ['role_musician','Musician'],
  ['role_helmsman','Helmsman']
] AS row
MERGE (r:Role {id: row[0]})
  ON CREATE SET r.name = row[1]
MERGE (r)-[:IN_CREW]->(crew);

// 船
MERGE (:Ship {id:'ship_merry', name:'Going Merry', type:'caravel'});
MERGE (:Ship {id:'ship_sunny', name:'Thousand Sunny', type:'brigantine'});

コードでは、UNWIND で役職一覧を展開しつつ、Role ノードを作成して IN_CREW エッジを張っています。船のノードは名前とタイプを属性に持ちます。

第2章 ルール宣言:期間はエッジに、表現は半開区間

物語の中では、キャラクターがクルーに加入・脱退したり、役職が変わったり、船が変わったりといった期間情報が重要です。グラフDBでは、ノードに期間属性を持たせるのではなく、関係(エッジ)に期間を持たせるのが一般的です。そうすることで「誰がどの役職にいつからいつまで就いていたか」を直接表現できます。

期間表現の設計

  • 半開区間 [from_ch, to_ch) を採用します。これは開始点を含み、終了点は含まない区間です。例えば [1, 50) は 1 話から 49 話までを表すことになります。
  • この表現を使う理由は、隣接する期間を隙間なくつなげられるからです。終端側の値を共有してもオーバーラップが起きないため、期間が連続していることが自明になります。SQL の世界でも、半開区間モデルは二つの期間がちょうど接する場合に重複が起きないという利点があると説明されています。
  • 人間にとっては [start_chapter, end_chapter] という閉区間の方が分かりやすいので、表示用には別途ラベル(Arc)を用意して補助的に使います。

関係の定義

ONE PIECE の世界を表現するための主要な関係は次のように定義します。

/*
(:Character)-[:MEMBER_OF {from_ch, to_ch, from_arc, to_arc}]->(:Crew)
(:Character)-[:HAS_ROLE  {from_ch, to_ch, from_arc, to_arc}]->(:Role)-[:IN_CREW]->(:Crew)
(:Crew)-[:USES {from_ch, to_ch, from_arc, to_arc}]->(:Ship)
*/

from_ch は開始話数、to_ch は終了話数(継続中なら null)を表します。from_arcto_arc は後述する Arc ラベルを格納するフィールドです。

第3章 Arc(時間のラベル)を登録する

Arc とは、「東の海(East Blue)編」「ウォーターセブン編」といった時間範囲に対する人間向けのラベルです。グラフDBの検索・推論では from_chto_ch の数値を使いますが、可視化やレポートでは Arc 名を添えることで理解しやすくします。

登録コード

Arc ノードをまとめて登録するコードは次の通りです。

UNWIND [
  {id:'arc_east_blue', name:'East Blue',     saga:'East Blue',   start_chapter:1,   end_chapter:100},
  {id:'arc_water7',    name:'Water 7',       saga:'Water 7',     start_chapter:321, end_chapter:430},
  {id:'arc_post_w7',   name:'Post Enies',    saga:'Enies Lobby', start_chapter:431, end_chapter:490},
  {id:'arc_thriller',  name:'Thriller Bark', saga:'Thriller',    start_chapter:491, end_chapter:530}
] AS row
MERGE (a:Arc {id:row.id})
  ON CREATE SET a.name=row.name, a.saga=row.saga,
                a.start_chapter=row.start_chapter, a.end_chapter=row.end_chapter;

これにより、Arc ノードには始まりと終わりの話数 (start_chapterend_chapter) と表示用の名前 (name) が登録されます。

第4章 キャラクターを投入(ID≠名前)

キャラクターは、一意の ID(スラッグ)と表示名を別々に管理します。これは多言語対応や同名キャラの区別に役立ちます。ID と名前を同一にしてしまうと、表記揺れや翻訳で別扱いになってしまう可能性があります。

登録コード

UNWIND [
  {id:'char_luffy',   name_jp:'モンキー・D・ルフィ'},
  {id:'char_zoro',    name_jp:'ロロノア・ゾロ'},
  {id:'char_nami',    name_jp:'ナミ'},
  {id:'char_usopp',   name_jp:'ウソップ'},
  {id:'char_sanji',   name_jp:'サンジ'},
  {id:'char_chopper', name_jp:'トニートニー・チョッパー'},
  {id:'char_robin',   name_jp:'ニコ・ロビン'},
  {id:'char_franky',  name_jp:'フランキー'},
  {id:'char_brook',   name_jp:'ブルック'},
  {id:'char_jinbe',   name_jp:'ジンベエ'}
] AS row
MERGE (c:Character {id: row.id})
  ON CREATE SET c.name_jp = row.name_jp;

MERGE は既に同じ ID のノードがあれば再利用し、なければ新規作成します。これにより、キャラの登録が冪等になります。

第5章 期間付きの関係を追加する

ここでは、キャラクターがクルーに所属した期間・役職に就いていた期間、クルーが船を使っていた期間を時系列付きで表現します。from_chto_ch の値を用意し、継続中の場合は to_chnull にしておくことで「現在も継続中」であることを示します。

ポイント

  • to_ch = null は「継続中」を意味します。終了話数を記録していないので、最新話まで所属・就任が続いていると解釈されます。
  • 一つの船から別の船への切り替えでは、前の期間の to_ch が後の期間の from_ch と一致するように設定し、隙間なく接続します(半開区間なのでこの値は共有しても重複しません)。

追加コード

// 船の履歴
MATCH (crew:Crew {id:'crew_strawhat'}), (merry:Ship {id:'ship_merry'}), (sunny:Ship {id:'ship_sunny'})
CREATE (crew)-[:USES {from_arc:'arc_east_blue', from_ch:1,   to_arc:'arc_water7', to_ch:431}]->(merry)
CREATE (crew)-[:USES {from_arc:'arc_post_w7',   from_ch:431, to_arc:null,         to_ch:null}]->(sunny);

// ルフィ
MATCH (c:Character {id:'char_luffy'}), (crew:Crew {id:'crew_strawhat'}), (r:Role {id:'role_captain'})
CREATE (c)-[:MEMBER_OF {from_arc:'arc_east_blue', from_ch:1, to_ch:null}]->(crew)
CREATE (c)-[:HAS_ROLE  {from_arc:'arc_east_blue', from_ch:1, to_ch:null}]->(r);

// 他メンバー(サンプル値)
UNWIND [
  ['char_zoro','role_swordsman',      10],
  ['char_nami','role_navigator',      20],
  ['char_usopp','role_sniper',        30],
  ['char_sanji','role_cook',          40],
  ['char_chopper','role_doctor',     200],
  ['char_robin','role_archaeologist',217],
  ['char_franky','role_shipwright',  431],
  ['char_brook','role_musician',     491],
  ['char_jinbe','role_helmsman',    1000]
] AS row
MATCH (c:Character {id:row[0]}), (r:Role {id:row[1]}), (crew:Crew {id:'crew_strawhat'})
CREATE (c)-[:MEMBER_OF {from_ch: row[2], to_ch:null}]->(crew)
CREATE (c)-[:HAS_ROLE  {from_ch: row[2], to_ch:null}]->(r);

これにより、各キャラが何話から加入したか、どの役職に就いたかをエッジとして登録できます。実際の話数はあくまで例なので、必要に応じて更新してください。

第6章 Arc の自動補完

前章では from_arcto_arc を手動で指定していましたが、実際には話数 (from_ch / to_ch) から Arc 名を自動で付与する方が楽です。次のクエリは、未設定の from_arcto_arc を Arc ノードの start_chapterend_chapter に基づいて自動補完します。

// from_arc の補完
MATCH (:Character)-[m:MEMBER_OF]->(:Crew {id:'crew_strawhat'}), (a:Arc)
WHERE m.from_arc IS NULL AND a.start_chapter <= m.from_ch AND m.from_ch <= a.end_chapter
SET m.from_arc = a.id;

// to_arc の補完
MATCH (:Character)-[m:MEMBER_OF]->(:Crew {id:'crew_strawhat'}), (a:Arc)
WHERE m.to_ch IS NOT NULL AND m.to_arc IS NULL AND a.start_chapter <= m.to_ch - 1 AND m.to_ch - 1 <= a.end_chapter
SET m.to_arc = a.id;

to_arc の計算では to_ch - 1 を使う点に注意してください。半開区間 [from_ch, to_ch) では終了話数自体は含まれないため、その一つ前の話数に含まれる Arc を to_arc として設定します。

第7章 クエリで「今」「その時」「これまで」に答える

登録したグラフに対してクエリを実行してみましょう。Cypher のパターンマッチは、ノード・エッジのつながりをそのまま表現できるので読みやすいのが特徴です。複数テーブルを JOIN する SQL に比べ、関係探索が簡潔に書けるという利点があります。

現在の麦わらメンバー

MEMBER_OFto_chnull になっているキャラクターを探すと、現在のクルーのメンバーを取得できます。

MATCH (:Crew {id:'crew_strawhat'})<-[m:MEMBER_OF]-(c:Character)
WHERE m.to_ch IS NULL
RETURN c.name_jp AS member
ORDER BY member;

話数 $ch 時点のクルー(例:450話)

特定の話数 $ch(たとえば450話)時点で誰がクルーに所属しているか、またどの役職に就いているかを調べます。from_ch$ch < to_ch という条件でフィルタすると、その時点で有効なエッジのみが抽出されます。

:param ch => 450;
MATCH (c:Character)-[m:MEMBER_OF]->(:Crew {id:'crew_strawhat'})
WHERE m.from_ch <= $ch AND (m.to_ch IS NULL OR m.to_ch > $ch)
MATCH (c)-[h:HAS_ROLE]->(r:Role)-[:IN_CREW]->(:Crew {id:'crew_strawhat'})
WHERE h.from_ch <= $ch AND (h.to_ch IS NULL OR h.to_ch > $ch)
RETURN r.name AS role, c.name_jp AS member
ORDER BY role, member;

このように、時系列に沿った状態変化を簡単に追跡できるのがグラフDBの強みです。

第8章 RDBと比較してみる

グラフDBでは関係性をノードとエッジのパターンで表現します。これはリレーショナルデータベースにおける JOIN 操作に相当しますが、クエリの表現力と実行効率が大きく異なります。

グラフDB

以下の Cypher クエリは、麦わらの一味の現在のメンバーを取得する例です。ノードとエッジのパターンをそのまま記述できるため、直感的なクエリになります。

MATCH (:Crew {id:'crew_strawhat'})<-[m:MEMBER_OF]-(c:Character)
WHERE m.to_ch IS NULL
RETURN c.name_jp;

RDB

同じ処理をリレーショナルデータベースで行う場合は、メンバーシップを表すテーブルとキャラクターを表すテーブルを JOIN する必要があります。

SELECT c.name_jp
FROM membership m
JOIN characters c ON c.id = m.character_id
WHERE m.crew_id = 'crew_strawhat' AND m.to_ch IS NULL;

リレーショナルデータベースは行と列の構造化されたデータに強みがあり、トランザクション処理や定型的な検索には適しています。しかし、データが高度に接続されていたり、複雑な関係を辿る必要がある場合には、テーブル間の結合が増え、パフォーマンスや記述性の面で負担が大きくなることがあります。グラフDBはそのような状況において、ノード間の直接的な関係を辿ることで効率的な探索ができるのが特徴です。

最後に:グラフDBの強みは「関係の一次情報化」

ONE PIECEのように登場人物が増え、役職や関係が複雑に絡み合い、時系列が長く続く世界では、グラフDBが非常に自然な選択肢となります。キャラクター同士の関係性や役割の変化、船の乗り換えといった情報をノードとエッジのパターンでそのまま表せるため、複雑な JOIN 操作を介さずに直感的なクエリを書けます。また、半開区間モデルを用いることで期間の切れ目が明確になり、時点指定の検索や履歴管理が容易になります。

グラフDBは万能ではなく、単純なトランザクションや表形式の集計にはリレーショナルデータベースが適している場合もあります。しかし、データ間のつながりや変化を追跡することが主目的であれば、グラフDBの柔軟性と表現力は大きな力になります。今回の例を参考に、ぜひグラフDBの活用を検討してみてください。

Discussion