グラフデータモデリング入門【Neo4j】
グラフデータモデリングとは何か
データモデリングとは、さまざまなデータソースを整理し、アプリケーションが扱いやすい形にマッピングするプロセスです。
グラフデータモデリングは、その中でも
- ノード(頂点) … グラフ上の「モノ(実体/エンティティ)」
- リレーションシップ(エッジ) … ノード同士の「関係性」
- プロパティ … ノードやリレーションシップにつく属性情報
で世界を表現するやり方です。
ポイントは、**「何が何とどう繋がっているか」**を第一クラスの概念として扱えること。
ここではグラフデータベースとして Neo4j を想定し、公式の GraphAcademy の内容をベースに考えていきます。
どう進めるか(モデリングの流れ)
Neo4j でグラフデータモデルを作るときに使う主なコンポーネントは次の4つです。
- Nodes … モノ(エンティティ)
- Labels … ノードの種類(カテゴリ)
- Relationships … ノード同士のつながり
- Properties … ノード/リレーションシップの属性(キー=値)
これらを使って、次のようなサイクルでモデリングを進めます。
-
ドメインの理解とユースケースの洗い出し
-
初期のデータモデルを作る
- どんなノード(ラベル)が必要か
- どんなリレーションシップが必要か
-
インスタンスモデル(小さなテストデータ)を Cypher で作る
-
ユースケースをクエリ(Cypher)で投げてみる
-
パフォーマンスや表現力を確認する
-
モデルをリファクタリングする(ノード分割・ラベル追加・関係の見直しなど)
-
実データに反映し、再度ユースケースをテスト
つまり、Neo4j のモデリングは
ユースケースから始めて、小さく試しながらモデルを育てていく
という アジャイル的な反復プロセス と考えるとイメージしやすいです。
モデルの2層構造:データモデルとインスタンスモデル
Neo4j でモデリングするときは、最低でも次の2つの“モデル”を使い分けます。
データモデル:設計図
どんなノード/リレーション/プロパティを持つのかを定義した設計図 です。
実データは含まず、例えば次のようなことを決めます。
-
Personノードにはname,bornプロパティを持たせる -
PersonとMovieの間には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つに収まります。
- ノードを一意に識別するため(
userId,tmdbIdなど) - ユースケースの質問に答えるため(クエリの条件や返却値になる情報)
- そのノードを画面に表示するときに使う値
例:Person ノード
nameborn-
died(任意)
例:Movie ノード
titlereleasedimdbRating-
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:
同じ要領で genres も Genre ノードに切り出します。
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プロパティをチェックする必要がある
といった問題が生じます。
そこで、State や City ノードを導入し、
(: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
このクエリは、
- トム・ハンクスが出演したすべての映画ノードを辿る
- 各ノードの
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 の役割分担
ラベル は「どこから探索を始めるか」を決めるフィルタ、
リレーションシップ は「どのようにつながっているか」を表す動詞、
プロパティ は「その時点の状態」を持つ値、として整理するとブレにくくなります。 -
重複データと複雑なデータの分離
LanguageやGenreのような 共有される値 はノードに切り出し、
住所やステータスのような 複雑で再利用される情報 も別ノード化することで、表現力とパフォーマンスの両方が上がります。 -
Specific Relationships / Intermediate Nodes による最適化
よく使う軸(年など)をリレーションタイプに埋め込んだり、
RoleやEmploymentのような中間ノードを挟んだりすることで、
単なる「線」だったリレーションを、よりリッチな「構造」として活用できます。
そして、どんなに綺麗に見えるモデルでも、ユースケースが増えれば必ずどこかでズレが出てきます。
そのときに「最初の設計が間違っていた」と捉えるのではなく、
新しい問いに合わせて、グラフをリファクタリングしていく
という前提で付き合っていくと、Neo4j の強みを一番引き出せます。
グラフは、触れば触るほど “つながりの設計センス” が育っていく世界ですね。
Discussion