🍇

Neo4jを使ったグラフDB入門

に公開

はじめに

こんにちは。タイムラボでカレンダーサービスLynxを開発している諸岡(@hakoten)です。

本記事では、代表的なグラフデータベースであるNeo4jについて、基本概念から実際のPythonコードでの操作方法を紹介します。

グラフデータベースとNeo4j

https://neo4j.com/docs/getting-started/whats-neo4j/

Neo4jは、リレーショナルデータベースとは異なり「グラフ構造」を扱うデータベースです。

リレーショナルデータベースでは、表(テーブル) と表同士の関係でデータを管理します。一方、グラフデータベースでは、オブジェクトを表す ノード と、それらを結ぶ エッジ でデータを表現します。

Neo4jは、このようなグラフデータを扱うためのアプリケーション(サービス)です。クラウドでのマネージド提供とオンプレミスのいずれでも利用できます。

AIの文脈では、2024年ごろからRAGのデータ構造にグラフを用いるGraphRAGが注目されていて、GraphRagのグラフ構築の手段としてNeo4jが採用されるケースも増えているようです。

Neo4jはこのようなGraphRAG関連の機能拡張も進めており、グラフ構造だけでなくベクトル検索などの機能もサポートしています。

Neo4jの基本コンポーネント

Neo4jは、次の4つの要素を使用してデータを保存および整理します。

コンポーネント 説明
ノード グラフの基本的なエンティティ
リレーションシップ ノード間のつながり
ラベル ノードのカテゴリまたはタイプ
プロパティ ノードとリレーションシップの属性データ

ノード

What is Neo4j?

ノードは、データ内のオブジェクト、エンティティを指します。例えば、特定の人や場所、モノがノードにあたります。

ラベル

What is Neo4j?

ラベルはノードを カテゴリグルーピング するためのものです。前述でいうと、「人物」「場所」「会社」「部署」などがそれにあたります。

ノードは複数のラベルを持つことができます。

リレーションシップ

What is Neo4j?

リレーションシップは、ノード同士の関連を指します。例えば、「ある人物が会社に所属している」「会社がある機材を所有している」など、動詞の関連を表現しています。

リレーションシップは方向を持っており、A→Bを表現することができます。また方向は、片方のみの場合も両方(双方向)の場合もあります。

  • Michael は、Neo4j で働いている: 単方向

  • Michael と Sarah はお互い好き合っている: 双方向

プロパティ

プロパティは、ノードやリレーションシップを補足するためのメタデータのような位置付けとなるデータです。リレーショナルデータベースのテーブルのカラムのようなイメージですが、スキーマレスのデータ構造になっており、同じラベルを持つデータにおいても同じプロパティを持つ必要はありません。

Cypher

https://neo4j.com/docs/getting-started/cypher/

前述の4つのコンポーネントを扱いデータを構築する操作言語として、Neo4jでは「Cypher」と呼ばれるクエリ言語を用います。Cypherは、グラフ用クエリ言語のISO規格であるGQLに準拠しており、リレーショナルデータベースにおけるSQLに相当します。

Neo4jではCypherを使って前述のノード、リレーションシップ、ラベル、プロパティを操作し、データの「参照」「挿入」「削除」などを行います。

Cypherのサンプル

(:nodes)-[:ARE_CONNECTED_TO]->(:otherNodes)

このサンプルは、2つのノード(:nodes)(:otherNodes)が、[:ARE_CONNECTED_TO]という関係(エッジ)で結ばれていることを表しています。矢印は関係の向きを示します。

次に簡単なCRUD操作について紹介します。

データの参照

MATCH

MATCH (p:Person)
RETURN p

データを参照するときは、MATCH句を使います。これはSQLにおけるSELECT句のようなもので、MATCHの後ろには参照する対象が続きます。

上記例では、「Person」というラベルを持つノードを参照しています。

p:Personpは変数で自由に定義することができます。例文では、参照したPersonノードを返却しています。

プロパティでの絞り込み

MATCH (p:Person {name: '山田 太郎'})
RETURN p

データを絞り込む時に、プロパティを使うことができます。上記は、Personラベルを持ち、nameプロパティが'山田 太郎'のノードを参照します。

WHERE

WHERE句は、SQLと同じくデータのフィルタリングを行います。

MATCH (p:Person)
WHERE p.name = '山田 太郎'
RETURN p

先程のプロパティでの絞り込みと同じようなフィルタリングをWHERE句でも行うことができます。上記のようなシンプルなクエリの場合は、おそらくクエリ最適化によりパフォーマンスに影響はそれほどないかもしれませんが、状況によってはWHERE句とプロパティでの絞り込みでパフォーマンスが変わる可能性があります。

データの作成

CREATE

CREATE (p:Person {name: '山田 太郎'})
RETURN p;

CREATE句を使うと、ノードおよびリレーションシップを作成することができます。

MATCH (a:Person {name: '山田 太郎'}), (b:Company {name: 'Timelab'})
CREATE (a)-[:WORKS_AT]->(b)
RETURN a, b

特定の条件でマッチしたノードに対して、リレーションシップを作成することができます。

MERGE

MERGE (p:Person {name: '山田 太郎'})
RETURN p;

データを作成するには、CREATE句を使う方法の他にMERGE句を使うことができます。MERGE句は、SQLにおける「INSERT... IF NOT EXISTS」「UPSERT」に近い概念の構文で、「パターンのノードが存在しなければ作成する」というものです。

MERGE (p:Person {name: '山田 太郎'})
  ON CREATE SET p.createdAt = timestamp()
  ON MATCH  SET p.lastSeen  = timestamp()
RETURN p;

存在しない時の作成時と、存在しているときの参照時で、挙動を分けることもできます。

データの削除

DELETE

MATCH (p:Person {name: '山田 太郎'})
DELETE p;

該当するノードを削除する場合は、DELETE句を使用します。

MATCH (p:Person {name: '山田 太郎'})
DETACH DELETE p;

ただし、ノードに関連するリレーションシップが作成されている場合、そのままだとリレーションシップは削除されません。リレーションシップごと削除するには、DETACHをつけます。

REMOVE

MATCH (p:Person {name: '山田 太郎'})
REMOVE p.age
RETURN p

ノードやリレーションシップのラベルまたはプロパティを削除する場合は、REMOVE句を使用します。この例では、「ラベルPersonのうちname = '山田 太郎'のノード」に対して、ageプロパティを削除しています。

Python上でのNeo4jの操作

ここからは、実際にプログラム上からNeo4jを操作して、データの作成や参照を行ってみます。

Dockerを使ってローカルで起動する

https://neo4j.com/docs/operations-manual/current/docker/introduction/

Neo4jには公式のDockerイメージが提供されているため、ローカルで動作を確認することが可能です。今回は公式で配布されているCommunity Editionイメージを使ってローカルに環境を構築します。

docker compose
compose.yml
services:
  neo4j:
    image: neo4j:5.24-community
    container_name: neo4j-sample
    ports:
      - "7474:7474"  # HTTP (Neo4j Browser)
      - "7687:7687"  # Bolt Protocol
    environment:
      - NEO4J_AUTH=${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-password}
      - NEO4J_ACCEPT_LICENSE_AGREEMENT=yes
      - NEO4J_dbms_memory_pagecache_size=${NEO4J_PAGECACHE_SIZE:-512M}
      - NEO4J_dbms_memory_heap_initial__size=${NEO4J_HEAP_INITIAL:-512M}
      - NEO4J_dbms_memory_heap_max__size=${NEO4J_HEAP_MAX:-1G}
      # Enable APOC plugin (optional)
      - NEO4J_PLUGINS=["apoc"]
    volumes:
      - neo4j_data:/data
      - neo4j_logs:/logs
      - neo4j_import:/var/lib/neo4j/import
      - neo4j_plugins:/plugins
    networks:
      - neo4j-network
    restart: unless-stopped
    healthcheck:
      test: ["CMD-SHELL", "wget --no-verbose --tries=1 --spider http://localhost:7474 || exit 1"]
      interval: 30s
      timeout: 10s
      retries: 5
      start_period: 40s

volumes:
  neo4j_data:
    driver: local
  neo4j_logs:
    driver: local
  neo4j_import:
    driver: local
  neo4j_plugins:
    driver: local

networks:
  neo4j-network:
    driver: bridge

このイメージを立ち上げて、http://localhost:7474 にアクセスするとNeo4jのブラウザが表示されます。

※ 初期パスワードは、id: neo4jpassword: passwordにしています。

PythonのライブラリからNeo4Jのデータベースにアクセスする

まずは、手元のPython環境でneo4jの公式ライブラリをインストールします。

from neo4j import GraphDatabase

uri = os.getenv("NEO4J_URI", "bolt://localhost:7687")
user = os.getenv("NEO4J_USER", "neo4j")
password = os.getenv("NEO4J_PASSWORD", "password")

driver = GraphDatabase.driver(uri, auth=(user, password))
with driver.session() as session:
    print("Neo4j に接続しました")

データベースは、GraphDatabaseモジュールからセッションをオープンするだけで使えます。bolt://は、Neo4j独自の通信プロトコルで、ポートは7687でアクセスできます。

Cypherの実行

session.run(
    """
    CREATE (momotaro:Character {name: '桃太郎', role: '主人公', type: '人間'})
    CREATE (ojiisan:Character {name: 'おじいさん', role: '育ての親', type: '人間'})
    CREATE (obaasan:Character {name: 'おばあさん', role: '育ての親', type: '人間'})
    CREATE (inu:Character {name: '犬', role: '仲間', type: '動物'})
    CREATE (saru:Character {name: '猿', role: '仲間', type: '動物'})
    CREATE (kiji:Character {name: '雉', role: '仲間', type: '動物'})
    CREATE (oni:Character {name: '鬼', role: '敵', type: '妖怪'})
    """
)

セッションを開いたら、runメソッドから、Cypherを実行することができます。このサンプルでは Character ラベルをつけた人物を作成しています。

作成されたデータは、Neo4jブラウザから確認することができます。

result = session.run(
    """
    MATCH (c:Character)
    RETURN c.name as name, c.role as role, c.type as type
    ORDER BY c.role
    """
)
for record in result:
    print(record)

MATCHクエリを実行するときも、runメソッドを使います。

<Record name='桃太郎' role='主人公' type='人間'>
<Record name='犬' role='仲間' type='動物'>
<Record name='猿' role='仲間' type='動物'>
<Record name='雉' role='仲間' type='動物'>
<Record name='鬼' role='敵' type='妖怪'>
<Record name='おじいさん' role='育ての親' type='人間'>
<Record name='おばあさん' role='育ての親' type='人間'>

runの戻りとして、このようにRecordオブジェクトが取得できます。

コードサンプル

今回のデータ作成とクエリで作成したコードサンプルは次になります。

データ作成のサンプルコード
create_sample_data.py
import os

from dotenv import load_dotenv
from neo4j import GraphDatabase


def create_sample_data() -> None:
    """
    Neo4j にサンプルデータを作成する.
    """
    load_dotenv()

    uri = os.getenv("NEO4J_URI", "bolt://localhost:7687")
    user = os.getenv("NEO4J_USER", "neo4j")
    password = os.getenv("NEO4J_PASSWORD", "password")

    # Neo4j に接続
    driver = GraphDatabase.driver(uri, auth=(user, password))

    try:
        with driver.session() as session:
            print("Neo4j に接続しました")

            # 既存のデータをクリア
            session.run("MATCH (n) DETACH DELETE n")
            print("既存のデータを削除しました")

            # 登場人物を作成
            session.run(
                """
                CREATE (momotaro:Character {name: '桃太郎', role: '主人公', type: '人間'})
                CREATE (ojiisan:Character {name: 'おじいさん', role: '育ての親', type: '人間'})
                CREATE (obaasan:Character {name: 'おばあさん', role: '育ての親', type: '人間'})
                CREATE (inu:Character {name: '犬', role: '仲間', type: '動物'})
                CREATE (saru:Character {name: '猿', role: '仲間', type: '動物'})
                CREATE (kiji:Character {name: '雉', role: '仲間', type: '動物'})
                CREATE (oni:Character {name: '鬼', role: '敵', type: '妖怪'})
                """
            )
            print("登場人物を作成しました")

            # 場所を作成
            session.run(
                """
                CREATE (mura:Place {name: '村', description: '桃太郎が育った場所'})
                CREATE (onigashima:Place {name: '鬼ヶ島', description: '鬼が住む島'})
                """
            )
            print("場所を作成しました")

            # リレーションシップを作成
            session.run(
                """
                MATCH (momotaro:Character {name: '桃太郎'})
                MATCH (ojiisan:Character {name: 'おじいさん'})
                MATCH (obaasan:Character {name: 'おばあさん'})
                MATCH (inu:Character {name: '犬'})
                MATCH (saru:Character {name: '猿'})
                MATCH (kiji:Character {name: '雉'})
                MATCH (oni:Character {name: '鬼'})
                MATCH (mura:Place {name: '村'})
                MATCH (onigashima:Place {name: '鬼ヶ島'})

                // 育ての親の関係
                CREATE (ojiisan)-[:RAISED {relation: '育ての親'}]->(momotaro)
                CREATE (obaasan)-[:RAISED {relation: '育ての親'}]->(momotaro)

                // 仲間の関係(きびだんごで仲間になった)
                CREATE (inu)-[:JOINED {kibidango: 1, skill: '嗅覚'}]->(momotaro)
                CREATE (saru)-[:JOINED {kibidango: 1, skill: '身のこなし'}]->(momotaro)
                CREATE (kiji)-[:JOINED {kibidango: 1, skill: '空からの偵察'}]->(momotaro)

                // 戦いの関係
                CREATE (momotaro)-[:FOUGHT {result: '勝利'}]->(oni)
                CREATE (inu)-[:FOUGHT {result: '勝利'}]->(oni)
                CREATE (saru)-[:FOUGHT {result: '勝利'}]->(oni)
                CREATE (kiji)-[:FOUGHT {result: '勝利'}]->(oni)

                // 場所の関係
                CREATE (momotaro)-[:BORN_AT]->(mura)
                CREATE (ojiisan)-[:LIVES_AT]->(mura)
                CREATE (obaasan)-[:LIVES_AT]->(mura)
                CREATE (oni)-[:LIVES_AT]->(onigashima)
                CREATE (momotaro)-[:TRAVELED_TO]->(onigashima)
                """
            )
            print("関係性を作成しました")

            result = session.run(
                "MATCH (c:Character) RETURN c.name as name, c.role as role ORDER BY c.role"
            )
            print("\n登場人物:")
            for record in result:
                print(f"  - {record['name']} ({record['role']})")

            result = session.run(
                """
                MATCH (c:Character)-[r:JOINED]->(momotaro:Character {name: '桃太郎'})
                RETURN c.name as name, r.kibidango as kibidango, r.skill as skill
                """
            )
            print("\n桃太郎の仲間:")
            for record in result:
                print(
                    f"  - {record['name']} "
                    f"(きびだんご: {record['kibidango']}個、特技: {record['skill']})"
                )

            result = session.run(
                """
                MATCH (c:Character)-[r:FOUGHT]->(oni:Character {name: '鬼'})
                RETURN c.name as name
                """
            )
            print("\n鬼と戦った者:")
            for record in result:
                print(f"  - {record['name']}")

            print("\nサンプルデータの作成が完了しました!")

    finally:
        driver.close()


if __name__ == "__main__":
    create_sample_data()
クエリのサンプルコード
query_sample_data.py
import os

from dotenv import load_dotenv
from neo4j import GraphDatabase


def query_sample_data() -> None:
    """
    データに対してクエリを実行する.
    """
    # 環境変数を読み込み
    load_dotenv()

    uri = os.getenv("NEO4J_URI", "bolt://localhost:7687")
    user = os.getenv("NEO4J_USER", "neo4j")
    password = os.getenv("NEO4J_PASSWORD", "password")

    # Neo4j に接続
    driver = GraphDatabase.driver(uri, auth=(user, password))

    try:
        with driver.session() as session:
            print("Neo4j に接続しました\n")

            # 1. すべての登場人物を取得
            print("=== すべての登場人物 ===")
            result = session.run(
                """
                MATCH (c:Character)
                RETURN c.name as name, c.role as role, c.type as type
                ORDER BY c.role
                """
            )
            for record in result:
                print(f"{record['name']} - {record['role']} ({record['type']})")

            # 2. 桃太郎の仲間を検索
            print("\n=== 桃太郎の仲間 ===")
            result = session.run(
                """
                MATCH (c:Character)-[r:JOINED]->(momotaro:Character {name: '桃太郎'})
                RETURN c.name as name, r.skill as skill, r.kibidango as kibidango
                """
            )
            for record in result:
                print(
                    f"  - {record['name']} (特技: {record['skill']}, "
                    f"きびだんご: {record['kibidango']}個)"
                )

            # 3. 人間のキャラクターを検索
            print("\n=== 人間のキャラクター ===")
            result = session.run(
                """
                MATCH (c:Character {type: '人間'})
                RETURN c.name as name, c.role as role
                """
            )
            for record in result:
                print(f"  - {record['name']} ({record['role']})")

            # 4. 鬼と戦った登場人物を検索
            print("\n=== 鬼と戦った登場人物 ===")
            result = session.run(
                """
                MATCH (c:Character)-[r:FOUGHT]->(oni:Character {name: '鬼'})
                RETURN c.name as name, r.result as result
                """
            )
            for record in result:
                print(f"  - {record['name']} (結果: {record['result']})")

            # 5. 桃太郎を育てた登場人物を検索
            print("\n=== 桃太郎を育てた登場人物 ===")
            result = session.run(
                """
                MATCH (c:Character)-[r:RAISED]->(momotaro:Character {name: '桃太郎'})
                RETURN c.name as name, r.relation as relation
                """
            )
            for record in result:
                print(f"  - {record['name']} ({record['relation']})")

            # 6. 各場所に関連する登場人物
            print("\n=== 場所と登場人物の関係 ===")
            result = session.run(
                """
                MATCH (c:Character)-[r]->(p:Place)
                RETURN c.name as character, type(r) as relation_type, p.name as place
                """
            )
            for record in result:
                relation_ja = {
                    "BORN_AT": "生まれた",
                    "LIVES_AT": "住んでいる",
                    "TRAVELED_TO": "旅した",
                }
                relation = relation_ja.get(record["relation_type"], record["relation_type"])
                print(f"  - {record['character']}{record['place']}{relation}")

            # 7. 桃太郎から鬼へのリレーションシップの経路
            print("\n=== 桃太郎から鬼への関係性 ===")
            result = session.run(
                """
                MATCH path = (momotaro:Character {name: '桃太郎'})-[*]-(oni:Character {name: '鬼'})
                WHERE length(path) <= 2
                WITH path, length(path) as len
                ORDER BY len
                LIMIT 3
                RETURN [node in nodes(path) | node.name] as characters,
                       [rel in relationships(path) | type(rel)] as relations
                """
            )
            for record in result:
                chars = record["characters"]
                rels = record["relations"]
                path_str = chars[0]
                for i, rel in enumerate(rels, 1):
                    if i < len(chars):
                        path_str += f" -[{rel}]-> {chars[i]}"
                print(f"  {path_str}")

            # 8. タイプ別の登場人物の数
            print("\n=== タイプ別の登場人物数 ===")
            result = session.run(
                """
                MATCH (c:Character)
                RETURN c.type as type, count(*) as count
                ORDER BY count DESC
                """
            )
            for record in result:
                print(f"  - {record['type']}: {record['count']}人")

            # 9. 桃太郎のリレーションシップの数
            print("\n=== 桃太郎の関係性 ===")
            result = session.run(
                """
                MATCH (momotaro:Character {name: '桃太郎'})-[r]-()
                RETURN type(r) as relation_type, count(*) as count
                """
            )
            relation_ja = {
                "RAISED": "育てられた",
                "JOINED": "仲間になった",
                "FOUGHT": "戦った",
                "BORN_AT": "生まれた",
                "TRAVELED_TO": "旅した",
            }
            for record in result:
                relation = relation_ja.get(record["relation_type"], record["relation_type"])
                print(f"  - {relation}: {record['count']}件")

            print("\nクエリの実行が完了しました!")

    finally:
        driver.close()


if __name__ == "__main__":
    query_sample_data()

おわりに

本記事では、グラフデータベースNeo4jの基本的な使い方を紹介しました。
Neo4jは、Cypherやデータブラウザなどエコシステムが十分に発達しており、非常に使いやすいサービスだと思います。ベクトルデータのデータ形式にも対応しており、GraphRAGなどを自由度高く開発するための有効な選択肢だなと感じました。

今後は、Neo4jを使ったグラフRAG関連の事例なども紹介できればと考えています。

タイムラボでは、一緒に働ける仲間を募集しています。もし、この記事に興味を持たれた方、カレンダーサービスの開発に興味がある方、AIの活用に関心がある方などがいれば、ぜひ私のXアカウント(@hakoten)やコメントで気軽にお声がけください。

まずはカジュアルにお話できることを楽しみにしています!

Timelabテックブログ

Discussion