👨‍🎓

Neo4jで始めるGraphRAG入門

に公開

はじめに

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

本記事では、Neo4jを使って、GraphRAGを実装する方法を紹介します。

Neo4jの基本的な使い方については、別記事で解説していますので、あわせてご覧ください。

https://zenn.dev/timelab/articles/1850842bf4fa18

データの準備

まずは、RAGで使用するデータを準備していきます。

今回は、料理とその材料、レシピなどを使ってRAGのデータベースを作成します。

データJSON
{
  "recipes": [
    {
      "id": "recipe_001",
      "name": "トマトとバジルのカプレーゼ",
      "description": "新鮮なトマトとモッツァレラチーズ、バジルを使ったシンプルで爽やかなイタリアの前菜。オリーブオイルとバルサミコ酢で味付けします。夏にぴったりの一品です。",
      "cooking_time_minutes": 10,
      "servings": 2,
      "difficulty": "簡単",
      "type": "イタリアン",
      "chef": "マリオ・ロッシ",
      "instructions": "1. トマトを薄切りにする 2. モッツァレラチーズも同様に切る 3. 交互に並べてバジルを飾る 4. オリーブオイルと塩をかける",
      "ingredients": [
        {"name": "トマト", "amount": "2個", "unit": "個"},
        {"name": "モッツァレラチーズ", "amount": "200", "unit": "g"},
        {"name": "バジル", "amount": "10", "unit": "枚"},
        {"name": "オリーブオイル", "amount": "大さじ2", "unit": "大さじ"},
        {"name": "バルサミコ酢", "amount": "大さじ1", "unit": "大さじ"}
      ],
      "tags": ["ヘルシー", "簡単", "前菜", "冷製"]
    },
    {
      "id": "recipe_002",
      "name": "和風きのこパスタ",
      "description": "しめじとしいたけを使った和風の醤油ベースパスタ。バターと醤油の風味が食欲をそそります。にんにくの香りも効いた、和と洋の融合料理です。",
      "cooking_time_minutes": 20,
      "servings": 2,
      "difficulty": "簡単",
      "type":"和風イタリアン",
      "chef": "田中健一",
      "instructions": "1. パスタを茹でる 2. にんにくをオリーブオイルで炒める 3. きのこを加えて炒める 4. 醤油とバターで味付け 5. パスタと和える",
      "ingredients": [
        {"name": "スパゲッティ", "amount": "200", "unit": "g"},
        {"name": "しめじ", "amount": "100", "unit": "g"},
        {"name": "しいたけ", "amount": "4", "unit": "個"},
        {"name": "にんにく", "amount": "2", "unit": "片"},
        {"name": "オリーブオイル", "amount": "大さじ2", "unit": "大さじ"},
        {"name": "醤油", "amount": "大さじ2", "unit": "大さじ"},
        {"name": "バター", "amount": "20", "unit": "g"}
      ],
      "tags": ["パスタ", "和風", "きのこ"]
    },
    {
      "id": "recipe_003",
      "name": "鶏肉とブロッコリーの炒め物",
      "description": "高タンパクでヘルシーな鶏むね肉とビタミン豊富なブロッコリーを使った中華風炒め物。オイスターソースのコクが決め手です。ダイエット中の方にもおすすめの一品。",
      "cooking_time_minutes": 15,
      "servings": 2,
      "difficulty": "簡単",
      "type":"中華",
      "chef": "李明",
      "instructions": "1. 鶏肉を一口大に切る 2. ブロッコリーを下茹でする 3. 鶏肉を炒める 4. ブロッコリーを加える 5. オイスターソースで味付け",
      "ingredients": [
        {"name": "鶏むね肉", "amount": "300", "unit": "g"},
        {"name": "ブロッコリー", "amount": "1", "unit": "株"},
        {"name": "にんにく", "amount": "1", "unit": "片"},
        {"name": "ごま油", "amount": "大さじ1", "unit": "大さじ"},
        {"name": "オイスターソース", "amount": "大さじ2", "unit": "大さじ"},
        {"name": "醤油", "amount": "小さじ1", "unit": "小さじ"}
      ],
      "tags": ["ヘルシー", "高タンパク", "中華", "炒め物"]
    },
    {
      "id": "recipe_004",
      "name": "クリーミートマトパスタ",
      "description": "トマトソースに生クリームを加えた濃厚でまろやかなパスタ。トマトの酸味とクリームのコクが絶妙にマッチします。子供から大人まで人気の定番料理です。",
      "cooking_time_minutes": 25,
      "servings": 2,
      "difficulty": "普通",
      "type": "イタリアン",
      "chef": "マリオ・ロッシ",
      "instructions": "1. パスタを茹でる 2. にんにくとトマトを炒める 3. トマト缶を加えて煮込む 4. 生クリームを加える 5. パスタと和える",
      "ingredients": [
        {"name": "スパゲッティ", "amount": "200", "unit": "g"},
        {"name": "トマト缶", "amount": "1", "unit": "缶"},
        {"name": "生クリーム", "amount": "100", "unit": "ml"},
        {"name": "にんにく", "amount": "2", "unit": "片"},
        {"name": "オリーブオイル", "amount": "大さじ2", "unit": "大さじ"},
        {"name": "塩", "amount": "適量", "unit": "適量"},
        {"name": "バジル", "amount": "5", "unit": "枚"}
      ],
      "tags": ["パスタ", "クリーミー", "トマト"]
    },
    {
      "id": "recipe_005",
      "name": "サーモンのムニエル レモンバターソース",
      "description": "外はカリッと、中はふっくらと焼き上げたサーモンに、爽やかなレモンバターソースをかけた上品な一品。おもてなし料理にも最適です。白ワインとの相性も抜群。",
      "cooking_time_minutes": 20,
      "servings": 2,
      "difficulty": "普通",
      "type":"フレンチ",
      "chef": "ピエール・デュボワ",
      "instructions": "1. サーモンに塩胡椒し、小麦粉をまぶす 2. バターで両面を焼く 3. レモン汁とバターでソースを作る 4. サーモンにかける",
      "ingredients": [
        {"name": "サーモン", "amount": "2", "unit": "切れ"},
        {"name": "小麦粉", "amount": "大さじ2", "unit": "大さじ"},
        {"name": "バター", "amount": "40", "unit": "g"},
        {"name": "レモン", "amount": "1", "unit": "個"},
        {"name": "塩", "amount": "適量", "unit": "適量"},
        {"name": "黒胡椒", "amount": "適量", "unit": "適量"}
      ],
      "tags": ["魚料理", "フレンチ", "おもてなし"]
    },
    {
      "id": "recipe_006",
      "name": "野菜たっぷりミネストローネ",
      "description": "トマトベースに色とりどりの野菜をたっぷり入れた栄養満点のイタリアンスープ。体が温まり、食物繊維も豊富で健康的。作り置きにも最適です。",
      "cooking_time_minutes": 30,
      "servings": 4,
      "difficulty": "簡単",
      "type": "イタリアン",
      "chef": "マリオ・ロッシ",
      "instructions": "1. 野菜を角切りにする 2. オリーブオイルでにんにくを炒める 3. 野菜を加えて炒める 4. トマト缶と水を加えて煮込む 5. 塩胡椒で味を整える",
      "ingredients": [
        {"name": "トマト缶", "amount": "1", "unit": "缶"},
        {"name": "玉ねぎ", "amount": "1", "unit": "個"},
        {"name": "にんじん", "amount": "1", "unit": "本"},
        {"name": "セロリ", "amount": "1", "unit": "本"},
        {"name": "じゃがいも", "amount": "2", "unit": "個"},
        {"name": "にんにく", "amount": "2", "unit": "片"},
        {"name": "オリーブオイル", "amount": "大さじ2", "unit": "大さじ"},
        {"name": "塩", "amount": "適量", "unit": "適量"}
      ],
      "tags": ["スープ", "ヘルシー", "野菜たっぷり", "作り置き"]
    },
    {
      "id": "recipe_007",
      "name": "ガパオライス",
      "description": "ひき肉とバジルを使ったタイの定番料理。ナンプラーの香りと唐辛子のピリ辛さが食欲をそそります。目玉焼きを乗せて、黄身を絡めて食べるのが本場スタイル。",
      "cooking_time_minutes": 20,
      "servings": 2,
      "difficulty": "簡単",
      "type":"タイ料理",
      "chef": "ソムチャイ・プラサート",
      "instructions": "1. にんにくと唐辛子を炒める 2. ひき肉を加えて炒める 3. ナンプラーと砂糖で味付け 4. バジルを加える 5. ご飯に盛り、目玉焼きを乗せる",
      "ingredients": [
        {"name": "豚ひき肉", "amount": "300", "unit": "g"},
        {"name": "バジル", "amount": "20", "unit": "枚"},
        {"name": "にんにく", "amount": "3", "unit": "片"},
        {"name": "唐辛子", "amount": "2", "unit": "本"},
        {"name": "ナンプラー", "amount": "大さじ2", "unit": "大さじ"},
        {"name": "砂糖", "amount": "小さじ1", "unit": "小さじ"},
        {"name": "卵", "amount": "2", "unit": "個"},
        {"name": "ご飯", "amount": "2", "unit": "杯"}
      ],
      "tags": ["エスニック", "ピリ辛", "ご飯もの"]
    },
    {
      "id": "recipe_008",
      "name": "きのこのリゾット",
      "description": "数種類のきのこの旨味が凝縮された本格イタリアンリゾット。パルメザンチーズとバターでクリーミーに仕上げます。贅沢な味わいで特別な日にもぴったり。",
      "cooking_time_minutes": 35,
      "servings": 2,
      "difficulty": "普通",
      "type": "イタリアン",
      "chef": "ジョバンニ・ビアンキ",
      "instructions": "1. きのこを炒める 2. 米を加えて炒める 3. ブイヨンを少しずつ加えながら煮る 4. パルメザンチーズとバターを加える 5. 塩胡椒で味を整える",
      "ingredients": [
        {"name": "米", "amount": "150", "unit": "g"},
        {"name": "しめじ", "amount": "100", "unit": "g"},
        {"name": "しいたけ", "amount": "4", "unit": "個"},
        {"name": "マッシュルーム", "amount": "6", "unit": "個"},
        {"name": "玉ねぎ", "amount": "1/2", "unit": "個"},
        {"name": "にんにく", "amount": "1", "unit": "片"},
        {"name": "白ワイン", "amount": "50", "unit": "ml"},
        {"name": "パルメザンチーズ", "amount": "50", "unit": "g"},
        {"name": "バター", "amount": "30", "unit": "g"},
        {"name": "オリーブオイル", "amount": "大さじ2", "unit": "大さじ"}
      ],
      "tags": ["リゾット", "きのこ", "本格イタリアン"]
    },
    {
      "id": "recipe_009",
      "name": "豆腐とわかめの味噌汁",
      "description": "日本の食卓に欠かせない定番の味噌汁。豆腐とわかめのシンプルな組み合わせで、ほっとする優しい味わい。朝食にも夕食にも合う万能な一品です。",
      "cooking_time_minutes": 10,
      "servings": 4,
      "difficulty": "簡単",
      "type":"和食",
      "chef": "鈴木花子",
      "instructions": "1. 出汁を温める 2. 豆腐を角切りにする 3. わかめを戻す 4. 豆腐とわかめを加える 5. 味噌を溶き入れる",
      "ingredients": [
        {"name": "豆腐", "amount": "1/2", "unit": "丁"},
        {"name": "乾燥わかめ", "amount": "大さじ1", "unit": "大さじ"},
        {"name": "出汁", "amount": "600", "unit": "ml"},
        {"name": "味噌", "amount": "大さじ3", "unit": "大さじ"}
      ],
      "tags": ["和食", "味噌汁", "簡単", "ヘルシー"]
    },
    {
      "id": "recipe_010",
      "name": "アボカドとエビのサラダ",
      "description": "クリーミーなアボカドとプリプリのエビを使った贅沢なサラダ。レモンドレッシングで爽やかに仕上げます。ヘルシーで栄養バランスも良く、美容にも効果的な一品。",
      "cooking_time_minutes": 15,
      "servings": 2,
      "difficulty": "簡単",
      "type":"洋食",
      "chef": "佐藤美穂",
      "instructions": "1. エビを茹でる 2. アボカドを角切りにする 3. レタスをちぎる 4. トマトを切る 5. 全て混ぜてレモンドレッシングをかける",
      "ingredients": [
        {"name": "エビ", "amount": "10", "unit": "尾"},
        {"name": "アボカド", "amount": "1", "unit": "個"},
        {"name": "レタス", "amount": "1/4", "unit": "玉"},
        {"name": "トマト", "amount": "1", "unit": "個"},
        {"name": "レモン", "amount": "1/2", "unit": "個"},
        {"name": "オリーブオイル", "amount": "大さじ2", "unit": "大さじ"},
        {"name": "塩", "amount": "適量", "unit": "適量"}
      ],
      "tags": ["サラダ", "ヘルシー", "エビ", "アボカド"]
    }
  ],
  "types": [
    {"id": "type_001", "name": "イタリアン", "description": "地中海料理の代表格。パスタ、ピザ、リゾットなど"},
    {"id": "type_002", "name": "和食", "description": "日本の伝統的な料理"},
    {"id": "type_003", "name": "中華", "description": "中国料理。炒め物、点心など多彩"},
    {"id": "type_004", "name": "フレンチ", "description": "フランス料理。洗練された技法"},
    {"id": "type_005", "name": "タイ料理", "description": "エスニックな香りとスパイスが特徴"},
    {"id": "type_006", "name": "和風イタリアン", "description": "和の食材とイタリアンの融合"},
    {"id": "type_007", "name": "洋食", "description": "西洋風の料理"}
  ],
  "chefs": [
    {"id": "chef_001", "name": "マリオ・ロッシ", "specialty": "イタリアン", "years_experience": 25},
    {"id": "chef_002", "name": "田中健一", "specialty": "和風イタリアン", "years_experience": 15},
    {"id": "chef_003", "name": "李明", "specialty": "中華", "years_experience": 20},
    {"id": "chef_004", "name": "ピエール・デュボワ", "specialty": "フレンチ", "years_experience": 30},
    {"id": "chef_005", "name": "ソムチャイ・プラサート", "specialty": "タイ料理", "years_experience": 18},
    {"id": "chef_006", "name": "ジョバンニ・ビアンキ", "specialty": "イタリアン", "years_experience": 22},
    {"id": "chef_007", "name": "鈴木花子", "specialty": "和食", "years_experience": 12},
    {"id": "chef_008", "name": "佐藤美穂", "specialty": "洋食", "years_experience": 10}
  ]
}

ラベル

これらのデータを以下の分類で、ラベルをつけます。

ラベル 対象データ例
Recipe(レシピ) トマトとバジルのカプレーゼ、和風きのこパスタ、鶏肉とブロッコリーの炒め物、クリーミートマトパスタ、など
Chef(シェフ) マリオ・ロッシ、田中健一、李明、ピエール・デュボワ、ソムチャイ・プラサート、など
Ingredient(食材) トマト、モッツァレラチーズ、オリーブオイル、にんにく、醤油、バター、ブロッコリー、など
Type(料理タイプ) イタリアン、和食、中華、フレンチ、タイ料理、洋食、和風イタリアン、スープ、サラダ、パスタ、など

プロパティ

各ノードに付与するプロパティは次の通りです。

ラベル プロパティ名 説明例
Recipe(レシピ) name レシピ名(例: トマトとバジルのカプレーゼ)
description レシピの説明文
cooking_time_minutes 調理時間(分)
servings 何人分か
difficulty 難易度(例: 簡単、普通、難しい)
instructions 手順の詳細
tags タグ(文字列または配列)
Chef(シェフ) name シェフ名(例: マリオ・ロッシ)
specialty 得意料理または専門分野
years_experience 経験年数
Type(料理タイプ) name 料理タイプ名(例: イタリアン、和食)
Ingredient(食材) name 食材名(例: トマト、にんにく)

リレーションシップ

リレーションシップ名 説明
CONTAINS レシピがどの食材を含むかを表す関係(例: 「トマトとバジルのカプレーゼ」→「トマト」)
CREATED_BY レシピを作成したシェフを示す関係(例: 「トマトとバジルのカプレーゼ」→「マリオ・ロッシ」)
BELONGS_TO_TYPE レシピが属する料理タイプを示す関係(例: 「トマトとバジルのカプレーゼ」→「イタリアン」)

Python経由でデータを作成

前述のJSONデータを使ってNeo4Jにデータを登録します。

JSONファイルを読み込み、Neo4jへのドライバを準備して、Cypherクエリでデータを挿入します。

import json
import os
from pathlib import Path
from typing import Any

from dotenv import load_dotenv
from neo4j import GraphDatabase
from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings

neo4j_uri = os.getenv("NEO4J_URI", "bolt://localhost:7687")
neo4j_user = os.getenv("NEO4J_USER", "neo4j")
neo4j_password = os.getenv("NEO4J_PASSWORD", "password")

# データのJSONを読み込む
project_root = Path(__file__).parent.parent.parent
data_path = project_root / "data" / "recipe_data.json"

data = load_json_data(str(data_path))

# Neo4jに接続
driver = GraphDatabase.driver(neo4j_uri, auth=(neo4j_user, neo4j_password))

今回の検索機能では、レシピの説明文に対してベクトル検索を行えるようにします。そのために、Neo4jのベクトルプロパティ(カラム)を活用します。

ベクトルインデックスの作成

with driver.session() as session:
    try:
        session.run("DROP INDEX recipe_description_vector IF EXISTS")
        session.run(
            """
            CREATE VECTOR INDEX recipe_description_vector IF NOT EXISTS
            FOR (r:Recipe)
            ON r.embedding
            OPTIONS {indexConfig: {
                `vector.dimensions`: 1536,
                `vector.similarity_function`: 'cosine'
            }}
            """
        )
    except Exception as e:
        print(f"ベクトルインデックス作成時にエラーが発生しました: {e}")

recipe_description_vector という名前のインデックスを、Recipeノードの embedding プロパティに対して作成します。

埋め込みには、OpenAIの「text-embedding-3-small」モデルを使用するため、ベクトルの次元数は1536、類似度の計算方法は「コサイン類似度」を指定します。

ベクトルプロパティの保存

続いて、実際にベクトルデータを生成してNeo4jに保存する処理を見ていきましょう。

from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings

recipes = data["recipes"]
openai_api_key = os.getenv("OPENAI_API_KEY")
# OpenAI Embeddingsを初期化
embedding_provider = OpenAIEmbeddings(api_key=openai_api_key, model="text-embedding-3-small")

# レシピごとにエンベディングを生成してNeo4jに保存
with driver.session() as session:
    for i, recipe in enumerate(recipes, 1):
        try:
            # エンベディングを生成
            embedding = embedding_provider.embed_query(recipe["description"])

            # Neo4jに保存
            session.run(
                """
                MATCH (r:Recipe {id: $recipe_id})
                SET r.embedding = $embedding
                """,
                recipe_id=recipe["id"],
                embedding=embedding,
            )
        except Exception as e:
            print(f"エンベディング生成時にエラーが発生しました ({recipe['name']}): {e}")
            continue

埋め込みベクトルの生成には、neo4j_graphrag パッケージの OpenAIEmbeddings を使用していますが、OpenAI公式ライブラリやその他のライブラリを使用しても問題ありません。

コード全体

コード全体は以下を参照ください。

データ作成のコード
import json
import os
from pathlib import Path
from typing import Any

from dotenv import load_dotenv
from neo4j import GraphDatabase
from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings


def load_json_data(json_path: str) -> dict[str, Any]:
    """JSONファイルからレシピデータを読み込む

    Args:
        json_path: JSONファイルのパス

    Returns:
        レシピ、料理タイプ、シェフのデータを含む辞書
    """
    with open(json_path) as f:
        data = json.load(f)
    print(f"データを読み込みました: {json_path}")
    return data


def clear_database(driver: Any) -> None:
    """データベースから全てのノードとリレーションシップを削除

    Args:
        driver: Neo4jドライバーインスタンス
    """
    with driver.session() as session:
        session.run("MATCH (n) DETACH DELETE n")
        print("データベースをクリアしました")


def create_constraints(driver: Any) -> None:
    """ノードのユニーク制約を作成

    Args:
        driver: Neo4jドライバーインスタンス
    """
    constraints = [
        "CREATE CONSTRAINT recipe_id IF NOT EXISTS FOR (r:Recipe) REQUIRE r.id IS UNIQUE",
        "CREATE CONSTRAINT chef_name IF NOT EXISTS FOR (c:Chef) REQUIRE c.name IS UNIQUE",
        "CREATE CONSTRAINT type_name IF NOT EXISTS FOR (t:Type) REQUIRE t.name IS UNIQUE",
        "CREATE CONSTRAINT ingredient_name IF NOT EXISTS FOR (i:Ingredient) REQUIRE i.name IS UNIQUE",
    ]

    with driver.session() as session:
        for constraint in constraints:
            try:
                session.run(constraint)
                print(f"制約を作成しました: {constraint.split()[2]}")
            except Exception as e:
                print(f"制約は既に存在するか、エラーが発生しました: {e}")


def create_vector_index(driver: Any) -> None:
    """レシピの説明文用のベクトルインデックスを作成

    レシピの説明文に対するベクトル類似度検索を可能にします。
    OpenAI text-embedding-3-small モデル(1536次元)を使用します。

    Args:
        driver: Neo4jドライバーインスタンス
    """
    with driver.session() as session:
        # 既存のインデックスがあれば削除
        try:
            session.run("DROP INDEX recipe_description_vector IF EXISTS")
            print("既存のベクトルインデックスを削除しました")
        except Exception as e:
            print(f"削除する既存のベクトルインデックスがありません: {e}")

        # 新しいベクトルインデックスを作成
        try:
            session.run(
                """
                CREATE VECTOR INDEX recipe_description_vector IF NOT EXISTS
                FOR (r:Recipe)
                ON r.embedding
                OPTIONS {indexConfig: {
                    `vector.dimensions`: 1536,
                    `vector.similarity_function`: 'cosine'
                }}
                """
            )
            print("レシピ説明文用のベクトルインデックスを作成しました")
        except Exception as e:
            print(f"ベクトルインデックス作成時にエラーが発生しました: {e}")


def create_types(driver: Any, types: list[dict[str, Any]]) -> None:
    """料理タイプノードを作成

    Args:
        driver: Neo4jドライバーインスタンス
        types: 料理タイプの辞書のリスト
    """
    with driver.session() as session:
        for recipe_type in types:
            session.run(
                """
                MERGE (t:Type {name: $name})
                SET t.description = $description
                """,
                name=recipe_type["name"],
                description=recipe_type["description"],
            )
        print(f"{len(types)}個の料理タイプノードを作成しました")


def create_chefs(driver: Any, chefs: list[dict[str, Any]]) -> None:
    """シェフノードを作成

    Args:
        driver: Neo4jドライバーインスタンス
        chefs: シェフの辞書のリスト
    """
    with driver.session() as session:
        for chef in chefs:
            session.run(
                """
                MERGE (c:Chef {name: $name})
                SET c.specialty = $specialty,
                    c.years_experience = $years_experience
                """,
                name=chef["name"],
                specialty=chef["specialty"],
                years_experience=chef["years_experience"],
            )
        print(f"{len(chefs)}人のシェフノードを作成しました")


def create_ingredients(driver: Any, recipes: list[dict[str, Any]]) -> None:
    """全レシピから材料ノードを作成

    Args:
        driver: Neo4jドライバーインスタンス
        recipes: レシピの辞書のリスト
    """
    ingredients = set()
    for recipe in recipes:
        for ingredient in recipe["ingredients"]:
            ingredients.add(ingredient["name"])

    with driver.session() as session:
        for ingredient_name in ingredients:
            session.run(
                """
                MERGE (i:Ingredient {name: $name})
                """,
                name=ingredient_name,
            )
        print(f"{len(ingredients)}個の材料ノードを作成しました")


def create_recipes(driver: Any, recipes: list[dict[str, Any]]) -> None:
    """レシピノードを作成(エンベディングなし)

    Args:
        driver: Neo4jドライバーインスタンス
        recipes: レシピの辞書のリスト

    Note:
        エンベディングは別途、エンベディング生成スクリプトを使用して追加する必要があります
    """
    with driver.session() as session:
        for recipe in recipes:
            session.run(
                """
                MERGE (r:Recipe {id: $id})
                SET r.name = $name,
                    r.description = $description,
                    r.cooking_time_minutes = $cooking_time_minutes,
                    r.servings = $servings,
                    r.difficulty = $difficulty,
                    r.instructions = $instructions,
                    r.tags = $tags
                """,
                id=recipe["id"],
                name=recipe["name"],
                description=recipe["description"],
                cooking_time_minutes=recipe["cooking_time_minutes"],
                servings=recipe["servings"],
                difficulty=recipe["difficulty"],
                instructions=recipe["instructions"],
                tags=recipe["tags"],
            )
        print(f"{len(recipes)}個のレシピノードを作成しました")


def add_recipe_embeddings(driver: Any, recipes: list[dict[str, Any]]) -> None:
    """レシピの説明文からエンベディングを生成してNeo4jに保存

    Args:
        driver: Neo4jドライバーインスタンス
        recipes: レシピの辞書のリスト

    Note:
        OpenAI APIキーが必要です(環境変数 OPENAI_API_KEY)
    """
    openai_api_key = os.getenv("OPENAI_API_KEY")
    if not openai_api_key:
        print("警告: OPENAI_API_KEY が設定されていません。エンベディングをスキップします。")
        return

    print("エンベディングを生成中...")

    # OpenAI Embeddingsを初期化
    embedding_provider = OpenAIEmbeddings(api_key=openai_api_key, model="text-embedding-3-small")

    # レシピごとにエンベディングを生成してNeo4jに保存
    with driver.session() as session:
        for i, recipe in enumerate(recipes, 1):
            try:
                # エンベディングを生成
                embedding = embedding_provider.embed_query(recipe["description"])

                # Neo4jに保存
                session.run(
                    """
                    MATCH (r:Recipe {id: $recipe_id})
                    SET r.embedding = $embedding
                    """,
                    recipe_id=recipe["id"],
                    embedding=embedding,
                )
                print(f"[{i}/{len(recipes)}] {recipe['name']} のエンベディングを追加しました")
            except Exception as e:
                print(f"エンベディング生成時にエラーが発生しました ({recipe['name']}): {e}")
                continue

    print(f"\n{len(recipes)}個のレシピにエンベディングを追加完了")


def create_relationships(driver: Any, recipes: list[dict[str, Any]]) -> None:
    """レシピと他のノード間のリレーションシップを作成

    Args:
        driver: Neo4jドライバーインスタンス
        recipes: レシピの辞書のリスト

    作成するリレーションシップ:
        - (Recipe)-[:CREATED_BY]->(Chef) - シェフが作成
        - (Recipe)-[:BELONGS_TO_TYPE]->(Type) - タイプに属する
        - (Recipe)-[:CONTAINS {amount, unit}]->(Ingredient) - 材料を含む
    """
    with driver.session() as session:
        for recipe in recipes:
            # レシピ -> シェフ
            session.run(
                """
                MATCH (r:Recipe {id: $recipe_id})
                MATCH (c:Chef {name: $chef_name})
                MERGE (r)-[:CREATED_BY]->(c)
                """,
                recipe_id=recipe["id"],
                chef_name=recipe["chef"],
            )

            # レシピ -> 料理タイプ
            session.run(
                """
                MATCH (r:Recipe {id: $recipe_id})
                MATCH (t:Type {name: $type_name})
                MERGE (r)-[:BELONGS_TO_TYPE]->(t)
                """,
                recipe_id=recipe["id"],
                type_name=recipe["type"],
            )

            # レシピ -> 材料(分量と単位付き)
            for ingredient in recipe["ingredients"]:
                session.run(
                    """
                    MATCH (r:Recipe {id: $recipe_id})
                    MATCH (i:Ingredient {name: $ingredient_name})
                    MERGE (r)-[rel:CONTAINS]->(i)
                    SET rel.amount = $amount,
                        rel.unit = $unit
                    """,
                    recipe_id=recipe["id"],
                    ingredient_name=ingredient["name"],
                    amount=ingredient["amount"],
                    unit=ingredient["unit"],
                )
        print(f"{len(recipes)}個のレシピのリレーションシップを作成しました")


def main() -> None:
    """レシピデータベースを作成するメイン関数"""
    load_dotenv()
    print("データベース作成を開始します...")

    # 環境変数から設定を取得
    neo4j_uri = os.getenv("NEO4J_URI", "bolt://localhost:7687")
    neo4j_user = os.getenv("NEO4J_USER", "neo4j")
    neo4j_password = os.getenv("NEO4J_PASSWORD", "password")

    # レシピデータのパス
    project_root = Path(__file__).parent.parent.parent
    data_path = project_root / "data" / "recipe_data.json"

    if not data_path.exists():
        print(f"エラー: データファイルが見つかりません: {data_path}")
        return

    # データを読み込み
    data = load_json_data(str(data_path))

    # Neo4jに接続
    driver = GraphDatabase.driver(neo4j_uri, auth=(neo4j_user, neo4j_password))

    try:
        # 既存データをクリア
        clear_database(driver)

        # 制約とインデックスを作成
        create_constraints(driver)
        create_vector_index(driver)

        # ノードを作成
        create_types(driver, data["types"])
        create_chefs(driver, data["chefs"])
        create_ingredients(driver, data["recipes"])
        create_recipes(driver, data["recipes"])

        # リレーションシップを作成
        create_relationships(driver, data["recipes"])

        # エンベディングを生成・追加
        add_recipe_embeddings(driver, data["recipes"])

        print("\nデータベースの作成が完了しました!")
    finally:
        driver.close()


if __name__ == "__main__":
    main()

作成データの確認

Cypherクエリを実行して、データが正しく登録されているか確認してみましょう。

レシピ

MATCH (r:Recipe) return r

レシピに紐づく材料

MATCH (r:Recipe)-[rel:CONTAINS]->(i:Ingredient) return r, rel, i

シェフが作ったレシピ

MATCH (r:Recipe)-[rel:CREATED_BY]->(c:Chef) return r, rel, c

RAGの作成

ここからは、構築したデータベースを活用して実際にRAGを実装していきます。
今回は、検索手法の異なる3種類のRAGを紹介します。

No. RAGの種類 概要
1 グラフ検索型RAG 自然言語のクエリをグラフ構造(ノードとエッジ)に変換し、関連データを探索する方式
2 ベクトル検索型RAG 自然言語のクエリをベクトル化し、類似度計算によって最も関連性の高い情報を検索する方式
3 グラフ検索 + ベクトル検索RAG ベクトル検索で初期候補を取得した後、グラフ構造を辿って関連情報を拡張する方式

グラフ検索

import os

from dotenv import load_dotenv
from neo4j import GraphDatabase
from neo4j_graphrag.generation import GraphRAG
from neo4j_graphrag.llm import OpenAILLM
from neo4j_graphrag.retrievers import Text2CypherRetriever

load_dotenv()

# Neo4jに接続
driver = GraphDatabase.driver(
    os.getenv("NEO4J_URI", "bolt://localhost:7687"),
    auth=(os.getenv("NEO4J_USER", "neo4j"), os.getenv("NEO4J_PASSWORD", "password")),
)

# Text2Cypher用のLLMを作成
t2c_llm = OpenAILLM(model_name="gpt-4o-mini", model_params={"temperature": 0})

# Text2CypherRetrieverを作成
retriever = Text2CypherRetriever(
    driver=driver,
    llm=t2c_llm,
)

# 回答生成用のLLMを作成
llm = OpenAILLM(model_name="gpt-4o-mini")

# GraphRAGを作成
rag = GraphRAG(retriever=retriever, llm=llm)

# 質問
query_text = "トマトを使うレシピを教えてください"

print(f"質問: {query_text}\n")
print("=" * 70)

# RAG検索を実行
response = rag.search(query_text=query_text, return_context=True)

# 生成されたCypherクエリを表示
if response.retriever_result and response.retriever_result.metadata:
    print("\nCYPHER :", response.retriever_result.metadata["cypher"])
    print("\n" + "=" * 70)

# 回答を表示
print(f"\n回答:\n{response.answer}\n")
print("=" * 70)

# 検索結果を表示
if response.retriever_result and response.retriever_result.items:
    print(f"\n検索結果 ({len(response.retriever_result.items)}件):")
    for i, item in enumerate(response.retriever_result.items, 1):
        print(f"\n{i}. {item.content}")

driver.close()

ここでは、Text2CypherRetrieverという、自然言語をCypherクエリに自動変換するRetrieverを使用しています。

このRetrieverは、指定したLLMを使って質問文を解釈し、適切なCypherクエリを生成してくれます。
内部的には、このようなプロンプトによってデータベーススキーマと質問文をLLMに渡し、Cypherクエリに変換しています。

このコードの実行結果は次のとおりです。

実行結果
 uv run python src/graph_rag/cypher_rag.py
質問: トマトを使うレシピを教えてください

======================================================================

CYPHER :
 MATCH (r:Recipe)-[:CONTAINS]->(i:Ingredient {name: 'トマト'})
RETURN r.name, r.description, r.cooking_time_minutes, r.servings, r.instructions

======================================================================

回答:
トマトを使うレシピは「トマトとバジルのカプレーゼ」です。この料理は新鮮なトマトとモッツァレラチーズ、バジルを使ったシンプルで爽やかなイタリアの前菜です。オリーブオイルとバルサミコ酢で味付けし、夏にぴったりの一品です。調理時間は10分で、2人分のレシピです。

作り方は以下の通りです:
1. トマトを薄切りにする
2. モッツァレラチーズも同様に切る
3. 交互に並べてバジルを飾る
4. オリーブオイルと塩をかける

======================================================================

検索結果 (2):

1. <Record r.name='アボカドとエビのサラダ' r.description='クリーミーなアボカドとプリプリのエビを使った贅沢なサラダ。レモンドレッシングで爽やかに仕上げます。ヘルシーで栄養バランスも良く、美容にも効果的な一品。' r.cooking_time_minutes=15 r.servings=2 r.instructions='1. エビを茹でる 2. アボカドを角切りにする 3. レタスをちぎる 4. トマトを切る 5. 全て混ぜてレモンドレッシングをかける'>

2. <Record r.name='トマトとバジルのカプレーゼ' r.description='新鮮なトマトとモッツァレラチーズ、バジルを使ったシンプルで爽やかなイタリアの前菜。オリーブオイルとバルサミコ酢で味付けします。夏にぴったりの一品です。' r.cooking_time_minutes=10 r.servings=2 r.instructions='1. トマトを薄切りにする 2. モッツァレラチーズも同様に切る 3. 交互に並べてバジルを飾る 4. オリーブオイルと塩をかける'>

「トマトを使うレシピを教えてください」というクエリから、以下のCypherクエリに変換されます。

MATCH (r:Recipe)-[:CONTAINS]->(i:Ingredient {name: 'トマト'})
RETURN r.name, r.description, r.cooking_time_minutes, r.servings, r.instructions

ベクトル検索

次に、一般的なRAGで広く使われているベクトル類似度検索を実装していきます。
実装コードは次のとおりです。

import os

from dotenv import load_dotenv
from neo4j import GraphDatabase
from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings
from neo4j_graphrag.generation import GraphRAG
from neo4j_graphrag.llm import OpenAILLM
from neo4j_graphrag.retrievers import VectorRetriever

load_dotenv()

# Neo4jに接続
driver = GraphDatabase.driver(
    os.getenv("NEO4J_URI", "bolt://localhost:7687"),
    auth=(os.getenv("NEO4J_USER", "neo4j"), os.getenv("NEO4J_PASSWORD", "password")),
)

# Embeddingsを作成
embedder = OpenAIEmbeddings(api_key=os.getenv("OPENAI_API_KEY"), model="text-embedding-3-small")

# VectorRetrieverを作成
retriever = VectorRetriever(
    driver=driver,
    index_name="recipe_description_vector",
    embedder=embedder,
    return_properties=["name", "description", "cooking_time_minutes", "difficulty"],
)

# 回答生成用のLLMを作成
llm = OpenAILLM(model_name="gpt-4o-mini")

# GraphRAGを作成
rag = GraphRAG(retriever=retriever, llm=llm)

# 質問
query_text = "辛い料理を教えてください"

print(f"質問: {query_text}\n")
print("=" * 70)

# RAG検索を実行
response = rag.search(query_text=query_text, return_context=True)

# 回答を表示
print(f"\n回答:\n{response.answer}\n")
print("=" * 70)

# 検索結果を表示
if response.retriever_result and response.retriever_result.items:
    print(f"\n検索結果 ({len(response.retriever_result.items)}件):")
    for i, item in enumerate(response.retriever_result.items, 1):
        if item.metadata:
            content = eval(item.content)
            print(f"\n{i}. レシピ名: {content.get('name', 'N/A')}")
            print(f"説明: {content.get('description', 'N/A')}")
            print(f"スコア: {item.metadata.get('score', 'N/A'):.4f}")

driver.close()

ベクトル検索を行う場合は、Retrieverに VectorRetriever を指定するだけで簡単に実装できます。データ作成時に作成したベクトルインデックスの名前を指定することで、embeddingプロパティに対して検索を実行できます。

このコードの実行結果は次のとおりです。

実行結果
uv run python src/graph_rag/vector_rag.py
質問: 辛い料理を教えてください

======================================================================

回答:
「ガパオライス」は、ひき肉とバジルを使ったタイの定番料理で、ナンプラーの香りと唐辛子のピリ辛さが特徴の料理です。

======================================================================

検索結果 (5):

1. レシピ名: クリーミートマトパスタ
説明: トマトソースに生クリームを加えた濃厚でまろやかなパスタ。トマトの酸味とクリームのコクが絶妙にマッチします。子供から大人まで人気の定番料理です。
スコア: 0.6993

2. レシピ名: 和風きのこパスタ
説明: しめじとしいたけを使った和風の醤油ベースパスタ。バターと醤油の風味が食欲をそそります。にんにくの香りも効いた、和と洋の融合料理です。
スコア: 0.6877

3. レシピ名: ガパオライス
説明: ひき肉とバジルを使ったタイの定番料理。ナンプラーの香りと唐辛子のピリ辛さが食欲をそそります。目玉焼きを乗せて、黄身を絡めて食べるのが本場スタイル。
スコア: 0.6871

4. レシピ名: サーモンのムニエル レモンバターソース
説明: 外はカリッと、中はふっくらと焼き上げたサーモンに、爽やかなレモンバターソースをかけた上品な一品。おもてなし料理にも最適です。白ワインとの相性も抜群。
スコア: 0.6858

5. レシピ名: トマトとバジルのカプレーゼ
説明: 新鮮なトマトとモッツァレラチーズ、バジルを使ったシンプルで爽やかなイタリアの前菜。オリーブオイルとバルサミコ酢で味付けします。夏にぴったりの一品です。
スコア: 0.6828

今回のサンプル質問では、ベクトル類似度のスコア自体はそれほど高くありませんが、回答生成用のLLMが検索結果から適切に「ガパオライス」を抽出してくれています。

このように、Neo4jのベクトルプロパティを活用することで、一般的なベクトルデータベースと同様の類似度検索を実現できます。

グラフ + ベクトル検索

最後に、グラフ構造とベクトル検索を組み合わせたハイブリッド検索を実装してみます。

実装コードは次のとおりです。

import os

from dotenv import load_dotenv
from neo4j import GraphDatabase
from neo4j_graphrag.embeddings.openai import OpenAIEmbeddings
from neo4j_graphrag.generation import GraphRAG
from neo4j_graphrag.llm import OpenAILLM
from neo4j_graphrag.retrievers import VectorCypherRetriever

load_dotenv()

# Neo4jに接続
driver = GraphDatabase.driver(
    os.getenv("NEO4J_URI", "bolt://localhost:7687"),
    auth=(os.getenv("NEO4J_USER", "neo4j"), os.getenv("NEO4J_PASSWORD", "password")),
)

# Embeddingsを作成
embedder = OpenAIEmbeddings(api_key=os.getenv("OPENAI_API_KEY"), model="text-embedding-3-small")

# ベクトル検索で初期候補を取得し、Cypherクエリで関連情報を拡張
# nodeはベクトル検索で見つかったRecipeノードを参照
retrieval_query = """
OPTIONAL MATCH (node)-[rel:CONTAINS]->(ingredient:Ingredient)
WITH node,
     collect({
         name: ingredient.name,
         amount: rel.amount,
         unit: rel.unit
     }) as ingredient_list
RETURN {
    name: node.name,
    description: node.description,
    cooking_time: node.cooking_time_minutes,
    difficulty: node.difficulty,
    ingredients: ingredient_list
} AS recipe_info
"""

retriever = VectorCypherRetriever(
    driver=driver,
    index_name="recipe_description_vector",
    embedder=embedder,
    retrieval_query=retrieval_query,
)

# 回答生成用のLLMを作成
llm = OpenAILLM(model_name="gpt-4o-mini")

# GraphRAGを作成
rag = GraphRAG(retriever=retriever, llm=llm)

# 質問
query_text = "簡単に作れる料理を教えてください"

print(f"質問: {query_text}\n")
print("=" * 70)

# RAG検索を実行
response = rag.search(query_text=query_text, return_context=True)

# 回答を表示
print(f"\n回答:\n{response.answer}\n")
print("=" * 70)

# 検索結果を表示
if response.retriever_result and response.retriever_result.items:
    print(f"\n検索結果 ({len(response.retriever_result.items)}件):")
    for i, item in enumerate(response.retriever_result.items, 1):
        print(f"\n{i}. {item.content}")

driver.close()

この検索では、Retrieverに VectorCypherRetriever を使用しています。VectorCypherRetrieverは、まず質問に対してベクトル検索を行い、その結果を起点として、Cypherクエリによるグラフの拡張検索を実行します。

このコードの実行結果は次のとおりです。

実行結果
uv run python src/graph_rag/vector_cypher_rag.py
質問: 簡単に作れる料理を教えてください

======================================================================

回答:
簡単に作れる料理として「トマトとバジルのカプレーゼ」や「和風きのこパスタ」、「豆腐とわかめの味噌汁」をおすすめします。これらはどれも手軽に作れて、シンプルながら美味しい一品です。

======================================================================

検索結果 (5):

1. <Record recipe_info={'ingredients': [{'amount': '大さじ1', 'unit': '大さじ', 'name': 'バルサミコ酢'}, {'amount': '大さじ2', 'unit': '大さじ', 'name': 'オリーブオイル'}, {'amount': '10', 'unit': '枚', 'name': 'バジル'}, {'amount': '200', 'unit': 'g', 'name': 'モッツァレラチーズ'}, {'amount': '2個', 'unit': '個', 'name': 'トマト'}], 'cooking_time': 10, 'description': '新鮮なトマトとモッツァレラチーズ、バジルを使ったシンプルで爽やかなイタリアの前菜。オリーブオイルとバルサミコ酢で味付けします。夏にぴったりの一品です。', 'name': 'トマトとバジルのカプレーゼ', 'difficulty': '簡単'}>

2. <Record recipe_info={'ingredients': [{'amount': '5', 'unit': '枚', 'name': 'バジル'}, {'amount': '適量', 'unit': '適量', 'name': '塩'}, {'amount': '大さじ2', 'unit': '大さじ', 'name': 'オリーブオイル'}, {'amount': '2', 'unit': '片', 'name': 'にんにく'}, {'amount': '100', 'unit': 'ml', 'name': '生クリーム'}, {'amount': '1', 'unit': '缶', 'name': 'トマト缶'}, {'amount': '200', 'unit': 'g', 'name': 'スパゲッティ'}], 'cooking_time': 25, 'description': 'トマトソースに生クリームを加えた濃厚でまろやかなパスタ。トマトの酸味とクリームのコクが絶妙にマッチします。子供から大人まで人気の定番料理です。', 'name': 'クリーミートマトパスタ', 'difficulty': '普通'}>

3. <Record recipe_info={'ingredients': [{'amount': '適量', 'unit': '適量', 'name': '黒胡椒'}, {'amount': '適量', 'unit': '適量', 'name': '塩'}, {'amount': '1', 'unit': '個', 'name': 'レモン'}, {'amount': '40', 'unit': 'g', 'name': 'バター'}, {'amount': '大さじ2', 'unit': '大さじ', 'name': '小麦粉'}, {'amount': '2', 'unit': '切れ', 'name': 'サーモン'}], 'cooking_time': 20, 'description': '外はカリッと、中はふっくらと焼き上げたサーモンに、爽やかなレモンバターソースをかけた上品な一品。おもてなし料理にも最適です。白ワインとの相性も抜群。', 'name': 'サーモンのムニエル レモンバターソース', 'difficulty': '普通'}>

4. <Record recipe_info={'ingredients': [{'amount': '20', 'unit': 'g', 'name': 'バター'}, {'amount': '大さじ2', 'unit': '大さじ', 'name': '醤油'}, {'amount': '大さじ2', 'unit': '大 さじ', 'name': 'オリーブオイル'}, {'amount': '2', 'unit': '片', 'name': 'にんにく'}, {'amount': '4', 'unit': '個', 'name': 'しいたけ'}, {'amount': '100', 'unit': 'g', 'name': 'しめじ'}, {'amount': '200', 'unit': 'g', 'name': 'スパゲッティ'}], 'cooking_time': 20, 'description': 'しめじとしいたけを使った和風の醤油ベースパスタ。バターと醤油の風味が食欲をそそり ます。にんにくの香りも効いた、和と洋の融合料理です。', 'name': '和風きのこパスタ', 'difficulty': '簡単'}>

5. <Record recipe_info={'ingredients': [{'amount': '大さじ3', 'unit': '大さじ', 'name': '味噌'}, {'amount': '600', 'unit': 'ml', 'name': '出汁'}, {'amount': '大さじ1', 'unit': '大 さじ', 'name': '乾燥わかめ'}, {'amount': '1/2', 'unit': '丁', 'name': '豆腐'}], 'cooking_time': 10, 'description': '日本の食卓に欠かせない定番の味噌汁。豆腐とわかめのシンプルな組み合わせで、ほっとする優しい味わい。朝食にも夕食にも合う万能な一品です。', 'name': '豆腐とわかめの味噌汁', 'difficulty': '簡単'}>

その他の検索(全文検索とのハイブリッド)

https://neo4j.com/blog/developer/enhancing-hybrid-retrieval-graphrag-python-package/

今回は取り扱っていませんが、Neo4jのライブラリには、全文検索(キーワード検索)とグラフ構造を組み合わせた HybridCypherRetriever という検索方式も用意されています。

おわりに

本記事では、Neo4jを使ったGraphRAGの実装方法を、料理レシピを題材に解説しました。

Neo4jを使えば、ベクトル検索、全文検索、そしてグラフ構造を、用途に応じて柔軟に組み合わせることができます。
これからRAGシステムを構築しようとしている方の参考になれば幸いです。

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

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

Timelabテックブログ

Discussion