📊

グラフデータモデリング入門【Neo4j】

に公開

グラフデータモデリングとは何か

データモデリングとは、さまざまなデータソースを整理し、アプリケーションが扱いやすい形にマッピングするプロセスです。

グラフデータモデリングは、その中でも

  • ノード(頂点) … グラフ上の「モノ(実体/エンティティ)」
  • リレーションシップ(エッジ) … ノード同士の「関係性」
  • プロパティ … ノードやリレーションシップにつく属性情報

で世界を表現するやり方です。

ポイントは、**「何が何とどう繋がっているか」**を第一クラスの概念として扱えること。
ここではグラフデータベースとして Neo4j を想定し、公式の GraphAcademy の内容をベースに考えていきます。

どう進めるか(モデリングの流れ)

Neo4j でグラフデータモデルを作るときに使う主なコンポーネントは次の4つです。

  • Nodes … モノ(エンティティ)
  • Labels … ノードの種類(カテゴリ)
  • Relationships … ノード同士のつながり
  • Properties … ノード/リレーションシップの属性(キー=値)

これらを使って、次のようなサイクルでモデリングを進めます。

  1. ドメインの理解とユースケースの洗い出し

  2. 初期のデータモデルを作る

    • どんなノード(ラベル)が必要か
    • どんなリレーションシップが必要か
  3. インスタンスモデル(小さなテストデータ)を Cypher で作る

  4. ユースケースをクエリ(Cypher)で投げてみる

  5. パフォーマンスや表現力を確認する

  6. モデルをリファクタリングする(ノード分割・ラベル追加・関係の見直しなど)

  7. 実データに反映し、再度ユースケースをテスト

つまり、Neo4j のモデリングは

ユースケースから始めて、小さく試しながらモデルを育てていく

という アジャイル的な反復プロセス と考えるとイメージしやすいです。

モデルの2層構造:データモデルとインスタンスモデル

Neo4j でモデリングするときは、最低でも次の2つの“モデル”を使い分けます。

データモデル:設計図

どんなノード/リレーション/プロパティを持つのかを定義した設計図 です。
実データは含まず、例えば次のようなことを決めます。

  • Person ノードには name, born プロパティを持たせる
  • PersonMovie の間には ACTED_IN リレーションを張る
  • Movie には title, released, genres を持たせる

アプリケーション全体で共通言語として使われるので、命名が重要になります。

ネーミングのスタイルガイド(Neo4j 推奨)

  • Labels:先頭大文字単数形 CamelCase
    例:Person, GitHubRepo
  • Relationships:全て大文字、アンダースコア区切り
    例:FOLLOWS, MARRIED_TO
  • Properties:先頭小文字単数形 camelCase
    例:deptId, firstName

Cypher キーワードは大文字・小文字を区別しませんが、
ラベル名・リレーションタイプ・プロパティ名はケースセンシティブ なので注意です。

インスタンスモデル:小さな実験用グラフ

データモデルに基づいて 少量の実データを作ったグラフ です。ここでやりたいことは:

  • ユースケースのクエリが自然に書けるか
  • 必要なリレーションが足りているか
  • 冗長な構造や足りないプロパティが見つからないか
  • 将来のスケールを考えたとき、パフォーマンスに問題なさそうか

といった 設計の“手触り”を確かめること です。

ユースケースから Nodes / Relationships / Properties を決める

自然言語からグラフへのマッピング

ユースケースの文章を読むときは、品詞で分解するとグラフに落とし込みやすくなります。

  • 名詞 → Node / Label
  • 動詞 → Relationship
  • 形容詞・副詞 → Property

例:

“What people acted in a movie?”

  • 名詞:people, movie → Person, Movie ノード
  • 動詞:acted in → ACTED_IN リレーション

この対応を意識すると、「何をノードにするか/関係にするか」の判断がかなり楽になります。

どのエンティティをノードにするか

「世界のどれを“モノ”として扱いたいか」 がノード設計の核心です。一般的な指針として:

  • ユースケースの中で 主役として頻出する名詞 → ノード候補
  • 検索の起点になりそうなもの → ノード候補
  • 複数の場所で 共有される値(都市、会社、スキルなど) → ノードに切り出すと再利用しやすい

逆に、個々のエンティティにべったり張り付いた値(年齢、メールアドレス、UUID など)はプロパティのままで良いことが多いです。

Nodes にどんな Properties を持たせるか

Neo4j では、プロパティの役割はだいたい次の3つに収まります。

  1. ノードを一意に識別するため(userId, tmdbId など)
  2. ユースケースの質問に答えるため(クエリの条件や返却値になる情報)
  3. そのノードを画面に表示するときに使う値

例:Person ノード

  • name
  • born
  • died(任意)

例:Movie ノード

  • title
  • released
  • imdbRating
  • genres(文字列リスト)

これらはすべて「何をしたいか」から逆算して選びます。

Properties は「状態量」として考える

物理で物体を記述するとき、「質量」「位置」「速度」などの状態量を持たせます。
ノードのプロパティも同様に、ある時点におけるノードの状態を表すものとして考えると整理しやすいです。

  • ノード = 世界の“物体”
  • プロパティ = その物体の“状態”

Relationships の考え方

Relationships はグラフの価値を決める中心要素です。ユースケースの「動詞」をそのままリレーションタイプにするイメージで設計します。

例:

  • 「誰が映画に 出演したか」→ ACTED_IN
  • 「誰が映画を 監督したか」→ DIRECTED

Relationship プロパティ

リレーションシップ自体に情報を持たせたいこともあります。

(p:Person)-[:ACTED_IN {role: "Jim Lovell"}]->(m:Movie)

ここでは「どの役を演じたか」は Person でも Movie でもなく、「Person と Movie の関係そのもの」に属する情報 なので、リレーションプロパティとして持ちます。

Cypher の最低限

Cypher は Neo4j 用の 宣言的クエリ言語 です。
「どんなパターンのグラフが欲しいか」をそのまま図のように書けます。

MATCH (p:Person)-[:ACTED_IN]->(m:Movie)
RETURN m.title

パターンの基本

ノードパターン

(n:Person {name: 'Tom Hanks'})
  • n … 変数名
  • :Person … ラベル
  • {...} … プロパティ条件

リレーションパターン

(p:Person)-[r:ACTED_IN]->(m:Movie)
  • r … リレーションの変数名
  • :ACTED_IN … リレーションタイプ
  • -> … 方向(無向なら -[]-

プロパティアクセス

  • パターン内では {key: value}
  • 取り出すときは n.name, m.released のように . でアクセス

よく使う句

MATCH / WHERE / RETURN

MATCH (m:Movie)
WHERE m.imdbRating >= 7.0 AND 'Drama' IN m.genres
RETURN m.title, m.imdbRating
ORDER BY m.imdbRating DESC
LIMIT 10
  • MATCH … パターンでグラフを検索
  • WHERE … 条件で絞り込み
  • RETURN … 返すカラムを指定
  • ORDER BY / LIMIT … 並び替えと件数制限

CREATE / MERGE

ノード作成:

CREATE (m:Movie {
  title: 'Apollo 13',
  tmdbId: 568,
  released: '1995-06-30',
  imdbRating: 7.6,
  genres: ['Drama', 'Adventure', 'IMAX']
})

リレーション作成:

MATCH (p:Person {name: 'Tom Hanks'}),
      (m:Movie  {title: 'Apollo 13'})
CREATE (p)-[:ACTED_IN]->(m)

アップサート:

MERGE (p:Person {tmdbId: 31})
ON CREATE SET p.name = 'Tom Hanks', p.born = '1956-07-09'
ON MATCH  SET p.lastSeenAt = datetime()

DELETE / DETACH DELETE

MATCH (m:Movie {title: 'Hoffa'})
DELETE m              // リレーションが残っているとエラー

MATCH (n)
DETACH DELETE n       // ノードとそのリレーションをまとめて削除

集約

MATCH (m:Movie)-[:ACTED_IN]<-(p:Person)
RETURN m.title, count(p) AS actorCount
ORDER BY actorCount DESC

Cypher では GROUP BY を書かずに、
集約関数以外の項目が自動でグループキー になります。

ラベル設計の基本

ラベルは

「どのノードから検索を始めるか」を制御するフィルタ

として機能します。グラフが大きくなるほど、ラベル設計はそのまま性能に響きます。

どういうときにラベルを増やすか

  • そのラベルだけを起点にするクエリが多いとき

    • 例:俳優だけをよく検索する → Actor ラベルを導入

逆に、単に値の種類を表したいだけならプロパティで十分です。

  • 例:国籍ごとに US, UK などのラベルを作るのはやりすぎ
    WHERE p.country = 'US' で柔軟に扱えます。

ベストプラクティス

  • 1ノードのラベルは最大4つまで(Neo4j の推奨)
  • 後から変わりやすい分類はプロパティで持つ(ラベルはパラメータ化できない)
  • ラベルは「検索起点として意味があるか?」を基準にする

ラベル設計でハマるパターンやアンチパターンは、後述の「リファクタリングパターン集」 の中でまとめて扱います。

モデルは育てるもの:典型的なリファクタリングパターン集

ここからは、実際に運用していく中で出てくることが多い

  • ラベル設計の見直し
  • 重複データの排除
  • Specific Relationships(用途特化リレーション)
  • Intermediate Nodes(中間ノード)

といった リファクタリングパターン を、まとめて整理します。

1. ラベルの追加・見直し

1-1. 役割に応じてラベルを増やす(User の例)

まず、次のようなインスタンスモデルがあるとします。

MATCH (n) DETACH DELETE n;

MERGE (:Movie {title: 'Apollo 13', tmdbId: 568, released: '1995-06-30', imdbRating: 7.6, genres: ['Drama', 'Adventure', 'IMAX']})
MERGE (:Person {name: 'Tom Hanks', tmdbId: 31, born: '1956-07-09'})
MERGE (:Person {name: 'Meg Ryan', tmdbId: 5344, born: '1961-11-19'})
MERGE (:Person {name: 'Danny DeVito', tmdbId: 518, born: '1944-11-17'})
MERGE (:Person {name: 'Jack Nicholson', tmdbId: 514, born: '1937-04-22'})
MERGE (:Movie {title: 'Sleepless in Seattle', tmdbId: 858, released: '1993-06-25', imdbRating: 6.8, genres: ['Comedy', 'Drama', 'Romance']})
MERGE (:Movie {title: 'Hoffa', tmdbId: 10410, released: '1992-12-25', imdbRating: 6.6, genres: ['Crime', 'Drama']})

ここに新しいユースケースが追加されるとします。

「何人のユーザーが映画を評価しているか?」

このときの「User」は Actor/Director と同じ“人”ですが、ドメイン上の役割が違う ので、Person に混ぜてしまうとクエリがややこしくなります。

そこで、新しい User ラベルを追加します。

MERGE (s:User {userId: 534})
SET s.name = "Sandy Jones"

MERGE (c:User {userId: 105})
SET c.name = "Clinton Spencer"

「同じ“見た目のもの”でも、ユースケース上の役割が違うならラベルを分ける」 という感覚が持てると設計が安定します。

1-2. セマンティックに「直交していない」ラベルは避ける

「セマンティックに直交している(semantically orthogonal)」というのは、

ラベル同士が意味的に混ざり合っておらず、
互いに別の切り口でノードを分類している

という状態のことです。

よくないパターンは、同じ種類の情報を、異なる文脈のノードで雑に共有ラベルとして使ってしまうこと。

例:

  • :Person ノードにも
  • :User ノードにも

どちらにも :Europe:Asia のような「地域ラベル」を貼るケース。

もし、「Person でも User でもまとめて地域別に集計したい」ようなユースケースが無いなら、この共通ラベルはほぼ役に立ちません。むしろ、

  • クエリで :Europe と書いても、それが Person なのか User なのか判別しづらい
  • 意味的な切り口が混ざっていて読みづらい

といった問題を生みます。

ラベルは 「この集合を一気に絞り込めたら便利か?」という観点から付けるべきで、
単に「同じ属性を持っているから」といって何でも共通ラベルにはしない方がよい、ということです。

1-3. クラス階層をラベルで表現しない

もう 1 つのアンチパターンは、継承階層(IS-A関係)をラベルで表現することです。

たとえば、Screen Actors Guild(俳優組合)のメンバーシップを

  • :Person
  • :Performer
  • :Actor
  • :SAG_Member

のように、上位〜下位のクラス階層をどんどんラベルで積み上げて表現するやり方です。

一見きれいに見えますが、ラベルはあくまで「クエリの絞り込みのためのタグ」であって、オブジェクト指向のクラス継承をそのまま投影するためのものではありません。

このような場合は、

  • (:Person)
  • (:Membership) (SAG や他の団体のメンバーシップ)
  • (:Organization)

といったノードを作り、

(:Person)-[:MEMBER_OF]->(:Membership)-[:OF]->(:Organization)

のように リレーションシップで階層や属性を表現する 方が、柔軟で分かりやすくなります。

2. 重複・複雑なデータの分離(Language / Genre / 住所など)

2-1. Language / Genre をノードにする

Movie ノードに次のようなプロパティを持たせているとします。

{
  "title": "Apollo 13",
  "released": "1995",
  "languages": ["English"],
  "genres": ["Drama", "Adventure", "IMAX"]
}

映画が増えるほど "English""Drama" の文字列がどんどん複製されていきます。

これを解消する典型的なパターンが、共通データを専用ノードとして切り出す リファクタリングです。

(:Movie)-[:IN_LANGUAGE]->(:Language {name: 'English'})
(:Movie)-[:IN_GENRE]->(:Genre {name: 'Drama'})

効果として:

  • 重複データを減らせる
  • 「英語の映画を全部欲しい」などのクエリが書きやすくなる
  • Language / Genre ノードに追加メタ情報(コード、説明など)を持たせられる

リファクタリングの流れは例えばこんな感じです(Language の場合)。

MATCH (m:Movie)
UNWIND m.languages AS language
MERGE (l:Language {name: language})
MERGE (m)-[:IN_LANGUAGE]->(l)
SET m.languages = null

Before:

After:

同じ要領で genresGenre ノードに切り出します。

MATCH (m:Movie)
UNWIND m.genres AS genre
MERGE (g:Genre {name: genre})
MERGE (m)-[:IN_GENRE]->(g)
SET m.genres = null

これにより、次のようなクエリもスッキリ書き換えられます。

リファクタ前

MATCH (p:Actor)-[:ACTED_IN]-(m:Movie)
WHERE p.name = 'Tom Hanks' AND 'Drama' IN m.genres
RETURN m.title AS Movie

リファクタ後

MATCH (p:Actor)-[:ACTED_IN]-(m:Movie)-[:IN_GENRE]->(g:Genre)
WHERE p.name = 'Tom Hanks' AND g.name = 'Drama'
RETURN m.title AS Movie

2-2. 複雑な値(住所など)を別ノードに切り出す

重複しているのは配列プロパティだけではありません。

例えば、Production ノードに

  • 住所

などを丸ごと埋め込んでいると、

  • 同じ州・同じ国にあるプロダクション会社が多い場合、その情報がノードごとに何度も繰り返される
  • 「ある州の会社だけ」を探したいときに、すべてのノードの state プロパティをチェックする必要がある

といった問題が生じます。

そこで、StateCity ノードを導入し、

(:Production)-[:LOCATED_IN]->(:City)-[:IN_STATE]->(:State)

のような構造にします。

方針まとめ:
よく使う属性で重複が多い・クエリの条件に頻繁に使うものは、ノードに切り出す

3. Specific Relationships(用途特化リレーション)

次のテーマは、リレーションシップ自体を用途別に「特化」させる話です。

Neo4j はリレーションシップのトラバースが非常に高速なので、場合によっては「ノードのプロパティを条件検索する」よりも

必要なリレーションシップタイプだけを辿る

ほうが圧倒的に速くなります。

3-1. 年度で絞るクエリの問題点

ユースケース例:

  • ある俳優が「特定の年」に出演した映画は?
  • 特定の年に「出演または監督」した人は?

元のモデルでは、公開年は Movie.released プロパティとして持っています。

リファクタ前

MATCH (p:Actor)-[:ACTED_IN]-(m:Movie)
WHERE p.name = 'Tom Hanks' AND m.released STARTS WITH '1995'
RETURN m.title AS Movie

このクエリは、

  1. トム・ハンクスが出演したすべての映画ノードを辿る
  2. 各ノードの released プロパティを 1 件ずつチェックする

という手順になり、映画数が増えるほど重くなります。

3-2. 年度ごとの Specific Relationship を追加する

そこで、次のような 年ごとに特化したリレーションシップタイプ を追加します。

  • :ACTED_IN_1992
  • :ACTED_IN_1993
  • :ACTED_IN_1995
  • :DIRECTED_1992
  • :DIRECTED_1995

既存の :ACTED_IN:DIRECTED はそのまま残しつつ、
ユースケース上重要な「年」という軸をリレーションシップタイプ側に埋め込む イメージです。

APOC の apoc.merge.relationship を使うと、
ACTED_IN_1995 のようなリレーションタイプを動的に生成できます。

MATCH (n:Actor)-[:ACTED_IN]->(m:Movie)
CALL apoc.merge.relationship(
  n,
  'ACTED_IN_' + left(m.released, 4),  // 先頭4桁を年として使う
  {},
  {},
  m,
  {}
)
YIELD rel
RETURN count(*) AS created

3-3. リファクタ後のクエリ

ユースケース 1:俳優が 1995 年に出演した映画

MATCH (p:Actor)-[:ACTED_IN_1995]-(m:Movie)
WHERE p.name = 'Tom Hanks'
RETURN m.title AS Movie

ユースケース 2:1995 年に出演または監督した人物

MATCH (p:Person)-[:ACTED_IN_1995|DIRECTED_1995]-()
RETURN p.name AS `Actor or Director`

2つめのクエリでは、Movie ノードを一切読む必要がありません。
「1995 年に関わった」という事実が、リレーションシップタイプに埋め込まれているからです。

まとめ:Specific Relationships によって

  • 取得すべきノードの数を減らせる
  • プロパティチェックを減らせる
    ⇒ 大規模グラフになるほど、パフォーマンス改善の効果が大きい

4. Intermediate Nodes(中間ノード)

通常、Neo4j のリレーションシップは 2 つのノードを結ぶ線 です。

現実世界の関係には、

  • 「3 つ以上の要素が 1 つの文脈でつながっている」
  • 「リレーションシップ自体にさらに情報をぶら下げたい」

といったケースがよくあります。
数学的にはこうしたものを ハイパーエッジ(hyperedge) と呼びます。

Neo4j ではハイパーエッジは直接サポートしていないので、

リレーションシップの真ん中にノードを 1 個挟む

ことで表現します。この「真ん中のノード」が Intermediate Node(中間ノード) です。

4-1. Person と Company の「勤務履歴」を表現する

Before:

(:Person)-[:WORKS_AT {from: 2015, to: 2020, role: "Engineer"}]->(:Company)

WORKS_AT に、期間や役職など、多くの情報が詰め込まれています。

After:Employment ノードを挟む例

(:Person)-[:HAS_EMPLOYMENT]->(:Employment)-[:AT_COMPANY]->(:Company)
(:Employment)-[:HAS_ROLE]->(:Role)

Employment ノードに

  • 期間
  • 役職
  • 給与
  • 雇用形態

などを好きなだけ持たせられ、
役職やキャリアパスをノードとして辿れるようになります。

4-2. メールの内容を共有する

Before:

(:User)-[SENT_EMAIL {subject:..., body:...}]->(:User)

複数宛先に同じメールを送ると、本文がリレーションごとに重複してしまいます。

After:Email ノードを導入

(:User)-[:SENT]->(:Email {subject:..., body:...})-[:TO]->(:User)

本文や件名は Email ノード 1 つにだけ保存すればよく、
「特定のメール内容を受け取った全ユーザー」を簡単に辿れます。

4-3. 映画グラフでの Role ノード

映画グラフの例では、元々はこうでした。

(:Actor)-[:ACTED_IN {role: 'Jim Lovell'}]->(:Movie)

ここでも role プロパティを、中間ノード Role として切り出します。

(:Actor)-[:PLAYED]->(:Role {name: 'Jim Lovell'})-[:IN_MOVIE]->(:Movie)

リファクタリングの例:

// 1. Actor と Movie を見つける
MATCH (a:Actor)-[r:ACTED_IN]->(m:Movie)

// 2. Role ノードを作る(または再利用)
MERGE (role:Role {name: r.role})

// 3. Actor -> Role
MERGE (a)-[:PLAYED]->(role)

// 4. Role -> Movie
MERGE (role)-[:IN_MOVIE]->(m)

こうしておくと:

  • 役名ごとに 1 つの Role ノードを共有できる
  • 「この役を演じた俳優は誰か?」が自然に書ける
  • 今後 (:Role)-[:INTERACTED_WITH]->(:Role) など、役同士の関係もモデリングできる

といった形で、役という概念を一段リッチに扱えるようになります。

5. PROFILE でパフォーマンスをざっくり見る

モデルが複雑になってきたら、クエリがどれくらいグラフを舐めているか を確認したくなります。
そのときに使うのが PROFILE です。

PROFILE
MATCH (p:Person)-[:ACTED_IN]-()
WHERE p.born < '1950'
RETURN p.name

実行結果には主に次の情報が表示されます。

  • DB Hits:ストレージからの読み出し回数の指標
  • Rows:各ステップで流れている行数
  • オペレータNodeByLabelScan / NodeIndexSeek / Expand / Filter / Projection など

PROFILE を見て:

  • 起点が NodeByLabelScan で重い → インデックス or ラベル設計を見直す
  • Expand の後の Rows が巨大 → リレーションを絞る or モデルを変える

といった具合に、モデルとクエリの両方をチューニングしていくのが基本スタンスです。

おわりに

ここまで見てきたように、グラフデータモデリングは

  • ノード/リレーションシップ/プロパティ で世界を表現し、
  • ユースケースから逆算してモデルを組み立て
  • そして 実データとクエリを通して少しずつ育てていく

という、かなり“生き物っぽい”設計プロセスです。

ポイントだけ振り返ると:

  • データモデルとインスタンスモデル
    まずは設計図(データモデル)を作り、それを小さなインスタンスモデルで試しながら、クエリの書き心地や表現力を確かめる。

  • Labels / Relationships / Properties の役割分担
    ラベル は「どこから探索を始めるか」を決めるフィルタ、
    リレーションシップ は「どのようにつながっているか」を表す動詞、
    プロパティ は「その時点の状態」を持つ値、として整理するとブレにくくなります。

  • 重複データと複雑なデータの分離
    LanguageGenre のような 共有される値 はノードに切り出し、
    住所やステータスのような 複雑で再利用される情報 も別ノード化することで、表現力とパフォーマンスの両方が上がります。

  • Specific Relationships / Intermediate Nodes による最適化
    よく使う軸(年など)をリレーションタイプに埋め込んだり、
    RoleEmployment のような中間ノードを挟んだりすることで、
    単なる「線」だったリレーションを、よりリッチな「構造」として活用できます。

そして、どんなに綺麗に見えるモデルでも、ユースケースが増えれば必ずどこかでズレが出てきます。

そのときに「最初の設計が間違っていた」と捉えるのではなく、

新しい問いに合わせて、グラフをリファクタリングしていく

という前提で付き合っていくと、Neo4j の強みを一番引き出せます。

グラフは、触れば触るほど “つながりの設計センス” が育っていく世界ですね。

Discussion