neo4j-modeling-skill について
neo4j-modeling-skillは、Neo4jの新規グラフモデル設計や既存スキーマのレビュー、移行を支援します。ノードとリレーションシップの選択、制約・インデックス、スーパーノード対策を扱います。
主な機能
- 01ユースケースからモデルを設計
- 02ノード・リレーションシップ・プロパティの選択
- 03リレーショナルスキーマをグラフへ移行
- 04スーパーノードの検出と軽減
- 05制約とインデックスの計画
活用シーン
- 01新しい領域のNeo4jスキーマ設計
- 02既存モデルのアンチパターン確認
- 03リレーショナルまたはドキュメントスキーマの移行
- 04高ファンアウトノードと性能リスクへの対処
MCP 対応
READMEでは、get-schemaによるスキーマ確認、read-cypherによる制約の確認、write-cypherによる制約作成をMCPツールに対応付けています。
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ neo4j-contrib/ neo4j-skills --skill neo4j-modeling-skillインストール後、Claude Code で/neo4j-modeling-skillを入力して読み込まれたか確認します。
同梱ファイルREADME.md
詳細
使用する場面
- グラフモデルをゼロから設計する場合(ドメイン → ノード、リレーションシップ、プロパティ)
- 既存モデルのアンチパターンを確認する場合
- ノード、プロパティ、リレーションシップ、ラベルのどれを使うか判断する場合
- リレーショナルスキーマまたはドキュメントスキーマをグラフに移行する場合
- n項関係や複雑なリレーションシップの中間ノードを設計する場合
- スーパーノードや高ファンアウトの問題を検出し、軽減する場合
- モデル用の制約とインデックスを選択して作成する場合
使用しない場面
- Cypher の記述や最適化 →
neo4j-cypher-skill - Spring Data Neo4j(@Node、@Relationship) →
neo4j-spring-data-skill - GraphQL の型定義 →
neo4j-graphql-skill - データのインポート(LOAD CSV、APOC import) →
neo4j-import-skill
設計前の確認
既存のデータベースでは、最初に以下を実行してください。現在の状態を確認せずに変更を提案してはいけません。
CALL db.schema.visualization() YIELD nodes, relationships RETURN nodes, relationships;
SHOW CONSTRAINTS YIELD name, type, labelsOrTypes, properties RETURN name, type, labelsOrTypes, properties;
SHOW INDEXES YIELD name, type, labelsOrTypes, state WHERE state = 'ONLINE' RETURN name, type, labelsOrTypes;
APOC を利用できる場合:
CALL apoc.meta.schema() YIELD value RETURN value;
MCP ツールの対応表:
| 操作 | ツール |
|---|---|
| スキーマを確認する | get-schema |
SHOW CONSTRAINTS、SHOW INDEXES | read-cypher |
CREATE CONSTRAINT ... IF NOT EXISTS | write-cypher(最初に表示して確認を得る) |
デフォルト設定 — すべてのモデルに適用
- まずユースケースを考える — 設計前に、モデルで回答すべきクエリを5つ以上列挙します
- ノード = アイデンティティを持つエンティティ(名詞)、リレーションシップ = 方向性を持つつながり(動詞)
- ラベルはパスカルケース、リレーションシップタイプはスクリーム・スネーク・ケース、プロパティはキャメルケースにします
MERGEで使用するすべてのノードタイプについて、キー・プロパティに一意性制約を設定します- 型が分かっている場合は、プロパティ型制約(
REQUIRE n.prop IS :: STRING)を追加します。これにより、不正な書き込みを早期に検出できます。Enterprise のみ。Community では一意性制約を使用し、アプリで型を検証します - 汎用ラベル(
:Entity、:Node、:Thing)や、汎用リレーションシップタイプ(:RELATED_TO、:HAS)は使用しません - 行レベルのアクセス制御に使用するセキュリティラベルは、アプリケーションコードがドメインスキーマから確実に除外できるよう、共通のプレフィックス(例:
Sec)で始めます - リレーションシップの方向は任意ではなく、意味を表します
- 既存のデータベースに変更を提案する前に、スキーマを確認します
- すべての制約/インデックスの DDL には
IF NOT EXISTSを使用し、再実行しても安全にします - Neo4j 2026.06 以降(Enterprise/Aura、GA)の場合: 個別の
CREATE CONSTRAINT文ではなく、ALTER CURRENT GRAPH TYPE SET { … }でモデル全体を1つのブロックとして宣言し、ALTER CURRENT GRAPH TYPE ADD { … }で拡張します。詳細はneo4j-cypher-skill(GRAPH TYPE リファレンス)を参照してください。Community では使用できません(ALTER CURRENT GRAPH TYPE SET→ 「community edition ではサポートされていません」)。個別の制約を使用してください。2026.02~2026.05 では構文はプレビュー版です。
主なパターン
ノード、リレーションシップ、プロパティのどれを選ぶか — 判断表
| 質問 | 回答 | モデル化 |
|---|---|---|
| アイデンティティを持ち、エントリーポイントとしてクエリされるものですか? | はい | ノード |
| 方向性を持ち、2つのものをつなぐ関係ですか? | はい | リレーションシップ |
| そのつながりに独自のプロパティがある、または複数の接続先がありますか? | はい | 中間ノード |
| 常に親とともに返され、それ単独では絞り込みに使わないスカラー値ですか? | はい | 親のプロパティ |
| 型による絞り込みやパストラバーサルに使うカテゴリですか? | はい | ラベル(プロパティではない) |
| 同じ属性値が多くのノードで繰り返されますか(カーディナリティが低い)? | はい | ラベル(プロパティノードではない) |
| 3つ以上のエンティティをつなぐ事実ですか? | はい | 中間ノード |
プロパティとラベルの使い分け — 判断表
| ラベルを使う場合 | プロパティを使う場合 |
|---|---|
値の種類が少なく固定されており、トラバーサルのフィルターとして使用する場合(WHERE n:Active) | 値の種類が多い、動的である、またはノードごとに固有の場合 |
タイプでトラバーサルする場合(MATCH (n:VIPCustomer)) | 値でフィルターする場合(WHERE n.tier = 'vip') |
| カテゴリによってインデックスの選択が決まる場合 | 細かな値によって範囲スキャンが決まる場合 |
例::Active、:Verified、:Premium | 例:status、score、email |
ルール:ラベルの追加は低コストです。すべての :Label ノードのスキャンも高速です。カーディナリティの高い値をラベルとしてモデル化しないでください。
status のようなカーディナリティの低い状態は、トラバーサルやフィルターに使う安定したカテゴリであればラベル(:Active)、頻繁に変化する場合やノードとともに返すだけの場合はプロパティにします。(:Status) ノードは決して使いません。
中間ノードパターン
リレーションシップに独自のプロパティが必要な場合、2 を超えるエンティティを接続する場合、または独立してクエリ可能にする場合に使用します。
変更前(プロパティ付きリレーションシップ — 制約あり):
(Person)-[:ACTED_IN {role: "Neo"}]->(Movie)
// Cannot query roles independent of movies
変更後(中間ノード — クエリ可能で拡張可能):
(Person)-[:PLAYED]->(Role {name: "Neo"})-[:IN]->(Movie)
// MATCH (r:Role) WHERE r.name STARTS WITH 'Neo' RETURN r
雇用期間の重複の例:
// Find colleagues who overlapped at same company
MATCH (p1:Person)-[:WORKED_AT]->(e1:Employment)-[:AT]->(c:Company)<-[:AT]-(e2:Employment)<-[:WORKED_AT]-(p2:Person)
WHERE elementId(p1) < elementId(p2)
AND e1.startDate <= coalesce(e2.endDate, date())
AND e2.startDate <= coalesce(e1.endDate, date())
RETURN DISTINCT p1.name, p2.name, c.name
次の場合は、リレーションシップを中間ノードにします。
- リレーションシップに 2 つを超えるプロパティがある
- リレーションシップ自体が別のクエリの対象となる
- 複数のエンティティが同じ接続コンテキストを共有する
- 1 つの事実で 2 を超えるエンティティを接続する必要がある
リレーショナルからグラフへの移行表
| リレーショナルの構成要素 | グラフでの同等要素 | 備考 |
|---|---|---|
| テーブルの行 | ノード | テーブルごとに 1 つのラベル(必要に応じて追加) |
| 列(スカラー) | ノードプロパティ | |
| 主キー | 一意性制約のプロパティ | 単なる id より tmdbId/personId を推奨 |
| 外部キー | リレーションシップ | 方向:依存元 → 参照先 |
| 多対多の中間テーブル | 中間ノード | 特に、中間テーブルに独自の列がある場合 |
| 中間テーブル(独自の列なし) | 直接のリレーションシップ | よりシンプルです。後から中間ノードにアップグレードできます |
| NULL の外部キー(オプションのリレーション) | 存在しないリレーションシップ | ノードは作成されません。不在であることがシグナルです |
| ポリモーフィックな外部キー(Rails スタイル) | 複数のラベルまたはリレーションシップタイプ | タイプごとのリレーションに分割します |
| 自己参照外部キー | 同じラベルのリレーションシップ | :Employee {managerId} → (e)-[:REPORTS_TO]->(m) |
| 監査/履歴の列 | 中間のバージョン管理ノード | バージョン管理パターンについては「参考資料」を参照してください |
スーパーノードの検出と対処
検出:
// Find top-10 highest-degree nodes
MATCH (n)
RETURN labels(n) AS labels, elementId(n) AS id, count{ (n)--() } AS degree
ORDER BY degree DESC LIMIT 10
ラベル内の次数が中央値を大きく上回るノードは、スーパーノードの候補です。10 万を超えるリレーションシップを持つノードがあると、そのノードを通過するトラバーサルクエリの性能が低下します。
原因:
- ドメイン上のスーパーノード:空港、有名人、人気のハッシュタグなど — 避けられません
- モデリングによるスーパーノード:性別、国、ステータスを数百万のエッジを持つノードとしてモデル化したもの — 回避できます
対処方法(優先順位順):
| 方法 | 使用する状況 | 実装 |
|---|---|---|
| クエリの方向 | 方向に非対称性がある | 次数の低い側からクエリし、方向性を活用します |
| リレーションシップタイプの分割 | スーパーノードが複数の役割を担う | 1 つの :RELATED_TO の代わりに :FOLLOWS と :FAN を使います |
| ラベルによる分類 | スーパーノードに異なるエンティティタイプが混在している | :Celebrity と :User を分け、関連するサブタイプだけをクエリします |
| バケットパターン | 時系列または大量のイベントノード | 以下を参照してください |
| モデル化を避ける | カーディナリティの低いカテゴリ値 | ノード((:Status {name:"Active"}))ではなくラベルを使います(:Active) |
| 結合ヒント | クエリチューニングの最終手段 | Cypher で USING JOIN ON n を使います |
バケットパターン(時系列/大量データ):
// Instead of: (:User)-[:VIEWED]->(:Page) (millions of rels per user)
// Bucket by hour:
(u:User)-[:VIEWED_IN]->(b:ViewBucket {userId: u.id, hour: '2025-04-28T14'})-[:VIEWED]->(p:Page)
// Query last hour's views without traversing full history:
MATCH (u:User {id: $uid})-[:VIEWED_IN]->(b:ViewBucket {hour: $hour})-[:VIEWED]->(p)
RETURN p.url
命名規則
| 要素 | 規則 | 良い例 | 悪い例 |
|---|---|---|---|
| ノードラベル | PascalCase、単数名詞 | :Person, :BlogPost | :person, :blog_posts, :Entity |
| リレーションシップタイプ | SCREAMING_SNAKE_CASE、動詞句 | :ACTED_IN, :WORKS_FOR | :actedin, :relatedTo, :HAS |
| プロパティキー | camelCase | firstName, createdAt | FirstName, first_name |
| 制約名 | 説明的な snake_case | person_id_unique | constraint1 |
| インデックス名 | 説明的な snake_case | person_name_idx | index2 |
スキーマの適用 — 各要素に作成するもの
すべての DDL で IF NOT EXISTS を実行してください。データをインポートする前に適用します。
// 1. Uniqueness constraint — every node type used in MERGE
CREATE CONSTRAINT person_id_unique IF NOT EXISTS
FOR (p:Person) REQUIRE p.id IS UNIQUE;
// 2. Existence constraint (Enterprise) — mandatory properties
CREATE CONSTRAINT person_name_exists IF NOT EXISTS
FOR (p:Person) REQUIRE p.name IS NOT NULL;
// 3. Property type constraint (Enterprise) — enforce data type
CREATE CONSTRAINT person_born_integer IF NOT EXISTS
FOR (p:Person) REQUIRE p.born IS :: INTEGER;
// 4. Key constraint (Enterprise) — unique + exists in one
CREATE CONSTRAINT movie_tmdbid_key IF NOT EXISTS
FOR (m:Movie) REQUIRE m.tmdbId IS NODE KEY;
// 4b. Community alternative to NODE KEY — composite uniqueness (existence not enforced)
CREATE CONSTRAINT movie_title_released_unique IF NOT EXISTS
FOR (m:Movie) REQUIRE (m.title, m.released) IS UNIQUE;
// 5. Range index — equality and range filters on properties
CREATE INDEX person_name_idx IF NOT EXISTS
FOR (p:Person) ON (p.name);
// 6. Fulltext index — ranked free-text search via `db.index.fulltext.queryNodes` / `SEARCH` (Lucene syntax, wildcards like `An*`); not used for CONTAINS / STARTS WITH predicates
CREATE FULLTEXT INDEX person_fulltext IF NOT EXISTS
FOR (n:Person) ON EACH [n.name, n.bio];
// 6b. Text index — serves CONTAINS / STARTS WITH / ENDS WITH (range index preferred for STARTS WITH when present)
CREATE TEXT INDEX person_bio_text IF NOT EXISTS
FOR (n:Person) ON (n.bio);
// 7. Vector index — embedding similarity search
CREATE VECTOR INDEX chunk_embedding_idx IF NOT EXISTS
FOR (c:Chunk) ON (c.embedding)
OPTIONS { indexConfig: { `vector.dimensions`: 1536, `vector.similarity_function`: 'cosine' } };
// 8. Relationship index — filter on rel properties
CREATE INDEX acted_in_year_idx IF NOT EXISTS
FOR ()-[r:ACTED_IN]-() ON (r.year);
インデックスを作成した後、ONLINE になるまでポーリングしてください:
SHOW INDEXES YIELD name, state WHERE state <> 'ONLINE' RETURN name, state;
状態が ONLINE になるまでは、インデックスを使用しないでください。
ベクトル/埋め込みプロパティのモデル化
長文テキストには専用の :Chunk ノードを使用してください。短文の埋め込みはエンティティ上に保持できます。
(:Document)-[:HAS_CHUNK]->(c:Chunk {text: "...", embedding: [...]})
ルール:
- Chunk ノード:
text(ソーステキスト)、embedding(浮動小数点配列)、chunkIndex(整数) - 親ドキュメント:メタデータのみ(title、url、createdAt)
c.embeddingのみにベクトルインデックスを作成- チャンクサイズは 200~500 トークン、オーバーラップは 20% が本番環境でのデフォルトです [field]
- チャンク化したドキュメントでは、
:Documentに埋め込みを配置しないでください。ノードが大きくなり、トラバーサルに余計な負荷がかかります
アンチパターン一覧
| アンチパターン | 問題 | 対処方法 |
|---|---|---|
汎用ラベル :Entity、:Node | フィルタリングの利点がなく、すべてのノードをスキャンする | ドメインラベル :Person、:Product を使用する |
汎用リレーションシップタイプ :RELATED_TO、:HAS | リレーションシップタイプでフィルタリングできない | 意味の明確なタイプ :PURCHASED、:AUTHORED を使用する |
| カーディナリティの低い値をノードとして扱う | スーパーノードになる(:Status {name:"active"} → 数百万のエッジ) | 代わりにラベル :Active を使用する |
プロパティをラベルとして使用(n.type = 'VIP' と :VIP ラベルが両方存在する) | 不整合、重複 | どちらか一方を選び、トラバーサルで使う場合はラベルを優先する |
| ビジネスノードに埋め込みを保存する | ノードが肥大化し、トラバーサルが遅くなる | 専用の :Chunk ノードを使用する |
| 一意性制約なしで MERGE を使用する | 重複ノードが気づかないうちに作成される | MERGE の前に制約を追加する |
| リレーションシップの方向に意味がない | 方向が恣意的になり、モデルが分かりにくくなる | 方向をアクションの意味上の流れに合わせる |
| 中間テーブルを単なるプロパティとしてモデル化する | 履歴と拡張性が失われる | 独自のプロパティを持つ中間ノードを使用する |
プロパティ名として id を使用する | id(n) は非推奨の Cypher 関数です(elementId(n) を使用してください)。実際には単独の id もプロパティ名として問題ありませんが、ドメインを示す名前(personId、movieId)のほうが明確で、将来の曖昧さも避けられます | 可読性が向上する場合は、personId、movieId、tmdbId を優先する |
| すべての日付を文字列として扱う | 範囲クエリや時間演算子を使用できない | Neo4j の date() または datetime() 型を使用する |
出力形式 — スキーマ評価
既存のモデルをレビューする場合:
## Schema Assessment
### Compliant
- [constraint / pattern that is correct]
### Issues Found
#### [Title] — Severity: ERROR / WARNING / INFO
- **Current**: what the model does
- **Problem**: why it is an issue
- **Fix**: specific Cypher DDL or model change
## Recommended Schema
### Node Labels
- :Label {key: TYPE, prop: TYPE, ...} → constraints: [list]
### Relationships
- (:LabelA)-[:TYPE {prop: TYPE}]->(:LabelB)
### Constraints to Create
[CREATE CONSTRAINT ... statements]
### Indexes to Create
[CREATE INDEX ... statements]
重大度の意味:
| 重大度 | 意味 | 対応 |
|---|---|---|
ERROR | モデルの正確性に関する問題(重複の可能性、データ損失のリスク) | 中断し、先に修正する |
WARNING | パフォーマンスまたは拡張性に関するリスク | 報告し、続行する前にユーザーに確認する |
INFO | スタイルまたは規則からの逸脱 | 明示し、続行する |
出典ラベル
[official]— Neo4j のドキュメントに直接記載[derived]— ドキュメントに記載された動作から導かれる[field]— コミュニティの経験則。デフォルトとして扱いつつ、妥当性を確認する
チェックリスト
- モデリング前にユースケース(5件以上のクエリ)を定義する
- 変更案を提示する前に、既存データベースのスキーマを確認する
- MERGE の対象となるすべてのノードラベルに一意性制約を設定する
- 汎用ラベル(
:Entity、:Node、:Thing)を使用しない - 汎用リレーションシップタイプ(
:RELATED_TO、:HAS、:CONNECTED_TO)を使用しない - リレーションシップの方向に意味を持たせる
- 多項リレーションシップまたはプロパティを持つリレーションシップには、中間ノードを使用する
- カーディナリティの高い値はノードではなくプロパティとして保存する
- カーディナリティの低いカテゴリ値はプロパティノードではなくラベルとして使用する
- 埋め込みはビジネスノードではなく、専用の
:Chunkノードに格納する - スーパーノードの候補を特定し、対策を講じる
- すべての DDL で
IF NOT EXISTSを使用する - インデックスがオンラインになるまでポーリングしてから使用する
- 評価結果は上記の構造化された形式に従う
- 禁止事項すべてに具体的な修正方法を添える
参考資料
必要に応じて読み込んでください: