GraphRAG 入門 #4 Neptune Database に Bulk Loader でナレッジグラフを構築して Neptune Analytics と連携してみた【Amazon Neptune】
はじめに
こんにちは!AI 事業本部のこーすけです。
GraphRAG 入門シリーズも 4 本目です。前回(#3)では、パターン ①(文書からグラフを自動構築するマネージド GraphRAG)を検証し、エンティティ抽出の表記ゆれや ID の未連結によって回答が難しい質問があることを確認しました。
今回は、#2 で整理したパターン ②(KGQA)の準備回です。パターン ② の前提である「正確なナレッジグラフ」を、Neptune Database 上に自分の手で構築します。#3 と同じ事実関係を構造化データとして用意し、まとめてデータを投入できる Bulk Loader でロードして、多段ホップのトレースクエリで正確にたどれることを確認します。あわせて後半では、同じデータを Neptune Analytics にも取り込み、Database と Analytics が同じデータ・同じクエリで行き来できることも確かめてみます。
Neptune Database とは
シリーズ 1 本目で紹介した表を再掲します。
| Neptune Database | Neptune Analytics | |
|---|---|---|
| 得意なこと | 日々の読み書き(トランザクション処理) | 大量データの分析・アルゴリズム処理 |
| イメージ | Web アプリ・業務システムの裏側で常時稼働する DB | 手元のグラフ全体を高速にガッと分析するエンジン |
Neptune Database は、トランザクション処理向けのグラフデータベースで、クラスターという単位で構成されます。読み書きを担うプライマリインスタンスと読み取り専用のレプリカからなり、コンピュートとストレージが分離した構成は Amazon Aurora に近いイメージです。接続先も、書き込み用のクラスターエンドポイントと読み取り用のリーダーエンドポイントに分かれています。
容量は、インスタンスタイプを固定で選ぶほかに、負荷に応じて NCU 単位で自動スケールする Neptune Serverless も選べます(今回の検証ではこれを使います)。
一方、前回使った Neptune Analytics には、このクラスター・インスタンスという概念がありません。グラフそのものがリソースの単位で、割り当てるメモリ量を m-NCU で指定すると、グラフ全体をメモリ上に展開して分析クエリやアルゴリズムを高速に実行することができます。
常時稼働のデータベースと、メモリ上の分析エンジンという違いになっています。
今回使うデータセット: 架空のスペシャルティコーヒー商社「サンプル珈琲商事」
データセットには、#3 で文書として用意した架空のスペシャルティコーヒー商社「サンプル珈琲商事」と同じ事実関係を使います。#3 では農園プロフィールや注文記録などの自然文の文書群として用意しましたが、今回は同じ内容を、農園から顧客までをトレースできる**構造化データ(ノード・エッジ)**として用意します。
ノード
| ノード | プロパティ |
|---|---|
| Farm(農園) | farm_id, name, country, region, altitude_m |
| Certification(認証) | cert_id, type(Organic / FairTrade / RainforestAlliance) |
| HarvestLot(収穫ロット) | lot_id, harvest_date, quantity_kg, grade |
| Exporter(輸出業者) | exporter_id, name, country |
| RoastBatch(焙煎バッチ) | batch_id, roast_date, roast_level, weight_kg |
| Product(商品/ブレンド) | product_id, name, category |
| Customer(卸先カフェ・店舗) | customer_id, name, type |
| Order(注文) | order_id, order_date, quantity_kg |
| Shipment(出荷) | shipment_id, ship_date, tracking_no |
| QualityIncident(品質インシデント) | incident_id, report_date, severity, description |
エッジ
(Farm)-[:HAS_CERTIFICATION]->(Certification)(Farm)-[:HARVESTED]->(HarvestLot)(HarvestLot)-[:EXPORTED_BY]->(Exporter)(HarvestLot)-[:USED_IN]->(RoastBatch)(複数ロットをブレンドする場合は多対多になる)(RoastBatch)-[:PACKAGED_AS]->(Product)(Customer)-[:PLACED]->(Order)-[:FOR_PRODUCT]->(Product)(Order)-[:FULFILLED_BY]->(Shipment)(QualityIncident)-[:REPORTED_FOR]->(Shipment)
エッジは openCypher の記法で表しています。() がノード、-[]-> がエッジを表し、矢印の向きが関係の方向を示します。ノードやエッジの種類(ラベル)は : に続けて書きます。
たとえば (Farm)-[:HAS_CERTIFICATION]->(Certification) は、「Farm ノードから Certification ノードへ、HAS_CERTIFICATION(認証を持っている)という関係が張られている」と読みます。
例えば、農園 → 収穫ロット → 焙煎バッチ → 商品という関係をグラフで表すと以下のようになります。

Neptune Database を構築してみる
ステップ 1: Neptune Database クラスターを作成する
- Neptune コンソールを開き、左メニューの Databases → Clusters から Create database をクリックします

- 以下の項目を指定します
- インスタンスタイプ: Serverless を選択します
- エンジンバージョン: 今回の検証時点で最新である
1.4.7.0を選択 - テンプレート: 開発とテスト を選択します。本番向けの高可用性構成は今回は不要です
- 名前: DB クラスター識別子を入力します(例:
coffee-trading-demo)。DB インスタンス識別子は自動で<クラスター名>-instance-1になります(例:coffee-trading-demo-instance-1) - クラスターストレージ設定: Neptune Standard を選択します。「Neptune I/O 最適化」は I/O 量がコストの 25%を超えるような読み書きの多いワークロード向けの料金体系で、インスタンス・ストレージの単価が Standard より高く設定されています。今回のような検証用の小規模データでは I/O 課金がほとんど発生しないため、Standard のほうが安く済みます
- キャパシティセクション: 最小キャパシティ 1 NCU、最大キャパシティ 16 NCU に設定します
- 可用性と耐久性: マルチ AZ 配置のチェックは外したままにします
- ネットワークとセキュリティ: サブネットグループが無ければ自動作成されます。セキュリティグループは前回と同じ
defaultを選択し、パブリックアクセスは無効のままにします - IAM DB 認証: 有効のままにします



- 設定を確認して Create database をクリックします
5 分ほどで作成されました。

ステップ 2: ノートブックを作成してクラスターに接続する
作成したクラスターに対してクエリを実行するため、Neptune ノートブック(Workbench)を作成します。ノートブックの実体は SageMaker がホストする Jupyter で、SageMaker の通常利用料金が別途かかります。シリーズ 1 本目で詳しく説明したので、そちらを参照してください。
- Neptune コンソールの左メニューから Notebooks → ノートブックを作成 を選択します
- 以下の項目を指定して、ノートブックを作成します
- Neptune サービス: Database を選択
- クラスター: ステップ 1 で作成したクラスター(例:
coffee-trading-demo)をプルダウンから選択します - ノートブックインスタンスタイプ: ml.t3.medium を選択
- ノートブック名:
aws-neptune-に続けて任意の名前を入力します(例:aws-neptune-coffee-trading-demo) - IAM ロール名: 初回は IAM ロールを作成 を選択します(ロール名は自動で
AWSNeptuneNotebookRole-<入力名>になります。例:AWSNeptuneNotebookRole-coffee-trading-demo) - ライフサイクル設定: Neptune のデフォルト設定を使用する のままで OK です
- ネットワーク設定: VPC・サブネットはステップ 1 で作成したクラスターと同じものが自動的に選択されます。セキュリティグループはステップ 1 と同じ
defaultを選択し、インターネットアクセスは 1 本目と同様 Amazon SageMaker を介した直接アクセス にします


- ノートブックを作成 をクリックします
こちらも 5 分ほどで作成されました。

Jupyter Lab を開き、セルで %status を実行して疎通することを確認しておきます。

ステップ 3: Bulk Loader でデータを投入する
1 本目では openCypher で直接データを流し込みましたが、今回は Neptune の一括投入機能である Bulk Loader を使いました。Bulk Loader は、クエリでデータを 1 件ずつ送り込むのではなく、S3 バケットに置いたデータファイルを Neptune クラスター自身が読み取ってロードする仕組みです。
そのため、投入までの流れは次の 4 段階になります。
- 所定のフォーマットで CSV ファイルを用意する(3-1)
- CSV を S3 バケットにアップロードする(3-2)
- Neptune が S3 を読み取れるように、IAM ロールと S3 VPC エンドポイントを準備する(3-3、3-4)
- ノートブックからロードを実行する(3-5)
本記事では、%loadマジックを使ったBulk Loader 方式でデータを投入しますが、Bulk Loader の実行方法には 3 つの選択肢があります。
%loadマジック(本記事で採用): ノートブックのセルに%loadと書くだけでフォームが生成される、対話的な方法- REST API: Neptune エンドポイントに直接
POST /loaderを叩く方法 - CLI/SDK: CLI なら
aws neptunedata start-loader-job/ boto3 ならstart_loader_job
📖 参考:
・Amazon Neptune ノートブックでのマジックの使用(AWS)
・Neptune ローダーコマンド(AWS)
3-1. CSV データを生成する
Bulk Loader の openCypher 形式は、ノード用ファイルとリレーションシップ用ファイルを別々に用意するというルールがあります。ノードファイルには:IDと:LABEL、リレーションシップファイルには:ID・:START_ID・:END_ID・:TYPEというシステム列を持たせ、それ以外はプロパティ名の後ろに:Stringや:Intのように型を書きます。
以下の Python スクリプトを実行すると、上記のデータセットを CSV として生成できます(下敷きの事実関係は #3 の文書生成スクリプトと共通です)。ノートブックのセルで実行してください。
import csv
import os
OUT = "coffee_data"
os.makedirs(OUT, exist_ok=True)
def write_csv(filename, header, rows):
with open(os.path.join(OUT, filename), "w", newline="", encoding="utf-8") as f:
w = csv.writer(f)
w.writerow(header)
w.writerows(rows)
# ---------- Farm ----------
farms = [
("F01", "エスペランサ農園", "Colombia", "Huila", 1800),
("F02", "モンテベルデ農園", "Costa Rica", "Tarrazu", 1700),
("F03", "サンライズヒル農園", "Ethiopia", "Yirgacheffe", 2000),
("F04", "北風高地農園", "Kenya", "Nyeri", 1900),
("F05", "リオドラド農園", "Brazil", "Cerrado", 1100),
]
write_csv("farms.csv",
[":ID", ":LABEL", "farm_id:String", "name:String", "country:String", "region:String", "altitude_m:Int"],
[(fid, "Farm", fid, name, country, region, alt) for fid, name, country, region, alt in farms])
# ---------- Certification ----------
certifications = [
("C01", "Organic"),
("C02", "FairTrade"),
("C03", "RainforestAlliance"),
]
write_csv("certifications.csv",
[":ID", ":LABEL", "cert_id:String", "type:String"],
[(cid, "Certification", cid, ctype) for cid, ctype in certifications])
# ---------- Farm -[:HAS_CERTIFICATION]-> Certification ----------
has_certification = [
("F01", "C01"), ("F01", "C02"),
("F02", "C03"),
("F03", "C01"),
("F04", "C02"), ("F04", "C03"),
# F05は認証なし(意図的な比較対象)
]
write_csv("has_certification.csv",
[":ID", ":START_ID", ":END_ID", ":TYPE"],
[(f"hc-{i+1}", f, c, "HAS_CERTIFICATION") for i, (f, c) in enumerate(has_certification)])
# ---------- Exporter ----------
exporters = [
("EX01", "Andes Export Co.", "Colombia"),
("EX02", "Pacific Bean Logistics", "Costa Rica"),
("EX03", "African Highlands Trading", "Ethiopia"),
]
write_csv("exporters.csv",
[":ID", ":LABEL", "exporter_id:String", "name:String", "country:String"],
[(eid, "Exporter", eid, name, country) for eid, name, country in exporters])
farm_exporter = {"F01": "EX01", "F05": "EX01", "F02": "EX02", "F03": "EX03", "F04": "EX03"}
# ---------- HarvestLot: 農園ごとに3ロット = 15 ----------
harvest_dates = ["2025-11-05", "2025-12-10", "2026-01-15"]
grades = ["AA", "A", "Specialty"]
harvest_lots, farm_harvested, lot_exported_by = [], [], []
lot_id_counter = 1
for farm_id, *_ in farms:
for i in range(3):
lot_id = f"L{lot_id_counter:02d}"
qty = 500 + lot_id_counter * 37
harvest_lots.append((lot_id, harvest_dates[i], qty, grades[i]))
farm_harvested.append((farm_id, lot_id))
lot_exported_by.append((lot_id, farm_exporter[farm_id]))
lot_id_counter += 1
write_csv("harvest_lots.csv",
[":ID", ":LABEL", "lot_id:String", "harvest_date:Date", "quantity_kg:Int", "grade:String"],
[(lid, "HarvestLot", lid, d, q, g) for lid, d, q, g in harvest_lots])
write_csv("farm_harvested.csv",
[":ID", ":START_ID", ":END_ID", ":TYPE"],
[(f"fh-{i+1}", f, l, "HARVESTED") for i, (f, l) in enumerate(farm_harvested)])
write_csv("lot_exported_by.csv",
[":ID", ":START_ID", ":END_ID", ":TYPE"],
[(f"le-{i+1}", l, e, "EXPORTED_BY") for i, (l, e) in enumerate(lot_exported_by)])
# ---------- RoastBatch (10) + Product (7) ----------
products = [
("P01", "エスペランサシングルオリジン", "single_origin"),
("P02", "モンテベルデシングルオリジン", "single_origin"),
("P03", "サンライズヒルシングルオリジン", "single_origin"),
("P04", "北風高地シングルオリジン", "single_origin"),
("P05", "リオドラドシングルオリジン", "single_origin"),
("P06", "サンライズブレンド", "blend"),
("P07", "モーニングブレンド", "blend"),
]
write_csv("products.csv",
[":ID", ":LABEL", "product_id:String", "name:String", "category:String"],
[(pid, "Product", pid, name, cat) for pid, name, cat in products])
roast_batches = [
("RB01", "2026-01-05", "medium", 40), ("RB02", "2026-01-06", "light", 35),
("RB03", "2026-01-07", "medium", 45), ("RB04", "2026-01-08", "medium", 38),
("RB05", "2026-01-09", "dark", 42), ("RB06", "2026-01-10", "medium", 40),
("RB07", "2026-01-11", "light", 30), ("RB08", "2026-01-12", "medium", 33),
("RB09", "2026-01-13", "medium", 36), ("RB10", "2026-01-14", "dark", 28),
]
write_csv("roast_batches.csv",
[":ID", ":LABEL", "batch_id:String", "roast_date:Date", "roast_level:String", "weight_kg:Int"],
[(bid, "RoastBatch", bid, d, lvl, w) for bid, d, lvl, w in roast_batches])
# RB03とRB08は2つのロットをブレンドしている(多対多の例)
lot_used_in = [
("L01", "RB01"), ("L04", "RB02"),
("L02", "RB03"), ("L07", "RB03"), # ブレンド → P06 サンライズブレンド
("L10", "RB04"), ("L13", "RB05"),
("L01", "RB06"), ("L08", "RB07"),
("L10", "RB08"), ("L05", "RB08"), # ブレンド → P07 モーニングブレンド
("L14", "RB09"), ("L11", "RB10"),
]
write_csv("lot_used_in.csv",
[":ID", ":START_ID", ":END_ID", ":TYPE"],
[(f"ui-{i+1}", l, b, "USED_IN") for i, (l, b) in enumerate(lot_used_in)])
batch_packaged_as = [
("RB01", "P01"), ("RB02", "P02"), ("RB03", "P06"), ("RB04", "P04"),
("RB05", "P05"), ("RB06", "P01"), ("RB07", "P03"), ("RB08", "P07"),
("RB09", "P05"), ("RB10", "P04"),
]
write_csv("batch_packaged_as.csv",
[":ID", ":START_ID", ":END_ID", ":TYPE"],
[(f"pa-{i+1}", b, p, "PACKAGED_AS") for i, (b, p) in enumerate(batch_packaged_as)])
# ---------- Customer (10), Order (25), Shipment (25) ----------
customers = [(f"CUST{i:02d}", f"サンプルカフェ{i}号店", "cafe" if i % 2 == 0 else "retail") for i in range(1, 11)]
write_csv("customers.csv",
[":ID", ":LABEL", "customer_id:String", "name:String", "type:String"],
[(cid, "Customer", cid, name, ctype) for cid, name, ctype in customers])
order_dates = ["2026-02-01", "2026-02-08", "2026-02-15", "2026-02-22", "2026-03-01"]
orders, shipments, customer_placed, order_for_product, order_fulfilled_by = [], [], [], [], []
product_ids = [p[0] for p in products]
for i in range(1, 26):
order_id, ship_id = f"O{i:02d}", f"S{i:02d}"
cust_id = f"CUST{((i - 1) % 10) + 1:02d}"
product_id = product_ids[(i - 1) % len(product_ids)]
qty = 5 + (i % 8) * 2
odate = order_dates[(i - 1) % len(order_dates)]
orders.append((order_id, odate, qty))
shipments.append((ship_id, odate, f"TRK-{1000+i}"))
customer_placed.append((cust_id, order_id))
order_for_product.append((order_id, product_id))
order_fulfilled_by.append((order_id, ship_id))
write_csv("orders.csv",
[":ID", ":LABEL", "order_id:String", "order_date:Date", "quantity_kg:Int"],
[(oid, "Order", oid, d, q) for oid, d, q in orders])
write_csv("shipments.csv",
[":ID", ":LABEL", "shipment_id:String", "ship_date:Date", "tracking_no:String"],
[(sid, "Shipment", sid, d, trk) for sid, d, trk in shipments])
write_csv("customer_placed.csv",
[":ID", ":START_ID", ":END_ID", ":TYPE"],
[(f"cp-{i+1}", c, o, "PLACED") for i, (c, o) in enumerate(customer_placed)])
write_csv("order_for_product.csv",
[":ID", ":START_ID", ":END_ID", ":TYPE"],
[(f"fp-{i+1}", o, p, "FOR_PRODUCT") for i, (o, p) in enumerate(order_for_product)])
write_csv("order_fulfilled_by.csv",
[":ID", ":START_ID", ":END_ID", ":TYPE"],
[(f"fb-{i+1}", o, s, "FULFILLED_BY") for i, (o, s) in enumerate(order_fulfilled_by)])
# ---------- QualityIncident (4) ----------
quality_incidents = [
("QI01", "2026-02-10", "中", "焙煎ムラの報告", "S04"), # 北風高地シングルオリジンの出荷
("QI02", "2026-02-17", "低", "香りが弱いとの顧客指摘", "S06"), # サンライズブレンドの出荷
("QI03", "2026-02-24", "高", "異物混入の報告", "S05"), # リオドラドシングルオリジンの出荷
("QI04", "2026-03-03", "低", "賞味期限表示の誤り", "S01"), # エスペランサシングルオリジンの出荷
]
write_csv("quality_incidents.csv",
[":ID", ":LABEL", "incident_id:String", "report_date:Date", "severity:String", "description:String"],
[(qid, "QualityIncident", qid, d, sev, desc) for qid, d, sev, desc, _ in quality_incidents])
write_csv("incident_reported_for.csv",
[":ID", ":START_ID", ":END_ID", ":TYPE"],
[(f"rf-{i+1}", qid, ship, "REPORTED_FOR") for i, (qid, _, _, _, ship) in enumerate(quality_incidents)])
print("done:", os.listdir(OUT))
実行すると、coffee_data/ フォルダに 19 個の CSV ファイル(ノード用 10 個、リレーションシップ用 9 個)が生成されます。

例えばノード用のファイルであるfarms.csvはこうなります。

ノードのラベル(:LABEL)にFarm、プロパティ(farm_id, name, country, region, altitude_m)がそれぞれ設定されています。この CSV の 1 行が、前章の図でみた、プロパティを持つノード 1 つに相当しています。
つまり、この CSV ファイルには、5 つの農園を定義したノードが記述されています。

次に、リレーションシップ用のファイルを見てみます。農園(Farm)と認証(Certification)を結ぶ has_certification.csv はこうなります。

エッジの始点・終点となるノードを:START_ID / :END_IDで表し、ノードファイルの:IDの値で指定します。
1 行目の関係は 「F01(エスペランサ農園)から C01(Organic)へ」 という意味となります。
つまり、リレーションシップファイルの 1 行が、バラバラに存在するノード同士を結ぶエッジ 1 本に相当します。この CSV をグラフにすると、次のようになります。

リオドラド農園(F05)はどの認証とも結ばれていませんね。
実際には他のラベルが付いたノード、他のノード間を結ぶリレーションシップが張り巡らされているので複雑なデータ構造になっていることを想像していただければと思います。
3-2. IAM 権限を準備する
これから先の作業では、ノートブックから S3 へのデータのアップロードと、Neptune クラスターからの S3 上のデータのロードを行うので、事前に必要な権限をアタッチしていきます。
(1) ノートブックの IAM ロールに S3 バケットの作成権限と、書き込み権限を追加する
ノートブックのセルで実行するコマンドは、ノートブック作成時に作られた IAM ロール(AWSNeptuneNotebookRole-coffee-trading-demo)の権限で動きます。このロールは S3 への権限を持っていないため、IAM コンソールから以下のインラインポリシーを追加します。

{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "CreateBucket",
"Effect": "Allow",
"Action": "s3:CreateBucket",
"Resource": "arn:aws:s3:::example-neptune-coffee-demo"
},
{
"Sid": "UploadObjects",
"Effect": "Allow",
"Action": ["s3:PutObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::example-neptune-coffee-demo",
"arn:aws:s3:::example-neptune-coffee-demo/*"
]
}
]
}
(2) Bulk Loader 用の読み取りロールを作成してクラスターにアタッチする
Bulk Loader は「Neptune クラスター自身が S3 からデータを読みに行く」仕組みなので、こちらはノートブックではなくクラスターに S3 読み取り権限を持つロールをアタッチします。
IAM コンソールから以下のロールを作成します。詳細な手順は下記の参考文献をご覧ください。
信頼ポリシー:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": { "Service": "rds.amazonaws.com" },
"Action": "sts:AssumeRole"
}
]
}
アクセス許可ポリシー:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": [
"arn:aws:s3:::example-neptune-coffee-demo",
"arn:aws:s3:::example-neptune-coffee-demo/*"
]
}
]
}
ロールを作成したら、Neptune コンソールの Databases → 対象クラスター → Connectivity & Security タブ → IAM roles から、作成したロールを選んで Add role します。

📖 参考:
・ Amazon Neptune から Amazon S3 リソースにアクセスするための IAM ロールの作成(AWS)
・Amazon Neptune クラスターへの IAM ロールの追加(AWS)
3-3. CSV を S3 にアップロードする
以下のコマンドをノートブックで実行します。
S3 バケットはデータをロードするクラスターと同じ AWS リージョンに存在する必要があることに注意してください。
!aws s3 mb s3://example-neptune-coffee-demo --region ap-northeast-1
!aws s3 cp coffee_data/ s3://example-neptune-coffee-demo/coffee_data/ --recursive
このようになっていれば OK です。

3-4. S3 VPC エンドポイントを作成する
Neptune Database はプライベートエンドポイントのみで動いているため、そのままでは VPC 内から S3 に到達できません。Gateway 型の VPC エンドポイント(サービス名: com.amazonaws.ap-northeast-1.s3)を作成し、クラスターのサブネットのルートテーブルに関連付けます。詳細な手順は参考文献をご覧ください。
3-5. %loadマジックでロードを実行する
ようやく下準備が完了しました!
ここまで準備できたら、ノートブックのセルで%loadマジックを実行し、データのロードを行います。
%load
セルを実行するとフォームが表示されるので、以下を入力します。それ以外の項目はデフォルト値のままにしておきます。
- Source:
s3://example-neptune-coffee-demo/coffee_data/ - Format:
opencypher - Load ARN: ステップ 3-2 (2)で作成したロールの ARN
- Region:
ap-northeast-1

Submit ボタンを押すとすぐにロードが始まり、あっという間に完了しました。


ロードされた件数を確認しておきます。
%%oc
MATCH (n) RETURN count(n) AS node_count
%%oc
MATCH ()-[r]->() RETURN count(r) AS edge_count
ノードの数: 107、エッジの数: 137 となっていれば OK です。

ここまでで用意したデータを正しくグラフ化することができました!!
ノードとリレーションシップを表すファイルをそれぞれ用意すれば、大量のデータでも素早くロードできることを確認できました!!
ステップ 4: クエリを実行して多段トレースを確認する
ロードしたデータに対して、たどる関係(ホップ)を増やしながらクエリを実行していきます。各クエリは結果の確認用と、経路を可視化する -d オプション付きの 2 種類を実行します。
4-1. グラフ全体を可視化する
はじめに、ロードされたグラフの全体像を確認します。
%%oc -d {"Farm":"name","Certification":"type","HarvestLot":"lot_id","Exporter":"name","RoastBatch":"batch_id","Product":"name","Customer":"name","Order":"order_id","Shipment":"shipment_id","QualityIncident":"incident_id"}
MATCH p=(n)-[r]->(m)
RETURN p
ノード 107 個・エッジ 137 本のネットワークが描画されます。この時点では全体の構造を把握するのは難しいですが、ここから条件を絞って経路を切り出していくのが以降のステップです。

4-2. 1 ホップ: 商品から焙煎バッチをたどる
商品「北風高地シングルオリジン」がどの焙煎バッチから作られたかを確認します。
%%oc
MATCH (p:Product {name: '北風高地シングルオリジン'})<-[:PACKAGED_AS]-(rb:RoastBatch)
RETURN rb.batch_id, rb.roast_date, rb.roast_level

RB04 と RB10 の 2 件が返り、この商品が 2 つの焙煎バッチから作られていることが分かります。経路は次のクエリで可視化できます。
%%oc -d {"Product":"name","RoastBatch":"batch_id"}
MATCH p=(:Product {name: '北風高地シングルオリジン'})<-[:PACKAGED_AS]-(:RoastBatch)
RETURN p

4-3. エッジの有無を条件にする: 認証を持たない農園を探す
データセットの章で触れたとおり、リオドラド農園(F05)はどの認証とも結ばれていません。HAS_CERTIFICATION エッジを持たない農園を検索し、その豆が使われている商品までたどってみます。
%%oc
MATCH (f:Farm)
WHERE NOT (f)-[:HAS_CERTIFICATION]->(:Certification)
MATCH (f)-[:HARVESTED]->(:HarvestLot)-[:USED_IN]->(:RoastBatch)-[:PACKAGED_AS]->(p:Product)
RETURN DISTINCT f.name, p.name

リオドラド農園と、その豆を使った商品である「リオドラドシングルオリジン」が返ってきました。
%%oc -d {"Farm":"name","HarvestLot":"lot_id","RoastBatch":"batch_id","Product":"name"}
MATCH (f:Farm)
WHERE NOT (f)-[:HAS_CERTIFICATION]->(:Certification)
MATCH p=(f)-[:HARVESTED]->(:HarvestLot)-[:USED_IN]->(:RoastBatch)-[:PACKAGED_AS]->(:Product)
RETURN p

リオドラドシングルオリジン(P05)という商品は、RB05 と RB09 という 2 つの焙煎バッチから作られていて、その時使用された豆の収穫ロットはそれぞれ L13、L14 であり、この 2 つのロットはいずれもリオドラド農園産ということが読み取れますね。ちゃんとシングルオリジンの商品であることが確認できました。
4-4. シングルオリジンとブレンドの構造を比較する
4-3 の結果は、リオドラドシングルオリジンが「2 つの焙煎バッチから作られ、原料はどちらも同一農園に遡る」という構造であることを示していました。では、複数農園の豆を混ぜるブレンド商品はどのような形になるでしょうか。今度は商品を起点に、原料の農園まで逆向きにたどってみます。
%%oc -d {"Farm":"name","HarvestLot":"lot_id","RoastBatch":"batch_id","Product":"name"}
MATCH p=(:Product {name: 'サンライズブレンド'})<-[:PACKAGED_AS]-(:RoastBatch)<-[:USED_IN]-(:HarvestLot)<-[:HARVESTED]-(:Farm)
RETURN p

サンライズブレンドは焙煎バッチ RB03 の 1 つから作られていますが、RB03 には L02(エスペランサ農園)と L07(サンライズヒル農園)の 2 つのロットが合流しています。
一目でブレンド商品なのかどうかがわかるのは、グラフならではで非常に面白いですね。
4-5. 5 ホップ: 農園から顧客までたどる
エスペランサ農園の豆が最終的にどの顧客に届いているかを、順方向に 5 ホップたどって確認します。
%%oc
MATCH (f:Farm {name: 'エスペランサ農園'})-[:HARVESTED]->(:HarvestLot)-[:USED_IN]->(:RoastBatch)-[:PACKAGED_AS]->(:Product)<-[:FOR_PRODUCT]-(:Order)<-[:PLACED]-(c:Customer)
RETURN DISTINCT c.name

サンプルカフェ 1・2・3・5・6・8・10 号店の 7 店が返ってきました。
%%oc -d {"Farm":"name","HarvestLot":"lot_id","RoastBatch":"batch_id","Product":"name","Order":"order_id","Customer":"name"}
MATCH p=(:Farm {name: 'エスペランサ農園'})-[:HARVESTED]->(:HarvestLot)-[:USED_IN]->(:RoastBatch)-[:PACKAGED_AS]->(:Product)<-[:FOR_PRODUCT]-(:Order)<-[:PLACED]-(:Customer)
RETURN p

エスペランサ農園の豆は「エスペランサシングルオリジン(P01)」と「サンライズブレンド(P06)」の 2 商品に使われているため、二つの経路で顧客に届いていることがわかりますね。
4-6. 7 ホップ: 品質インシデントから農園とその認証までたどる
品質インシデント QI01(焙煎ムラの報告)を起点に、対象商品の原料がどの農園のロット由来か、そしてその農園がどの認証を持つかをたどります。
%%oc
MATCH (qi:QualityIncident {incident_id: 'QI01'})-[:REPORTED_FOR]->(:Shipment)<-[:FULFILLED_BY]-(:Order)-[:FOR_PRODUCT]->(:Product)<-[:PACKAGED_AS]-(:RoastBatch)<-[:USED_IN]-(:HarvestLot)<-[:HARVESTED]-(f:Farm)-[:HAS_CERTIFICATION]->(cert:Certification)
RETURN DISTINCT f.name, cert.type

北風高地農園 × FairTrade、北風高地農園 × RainforestAlliance の 2 件が返ってきました。
インシデントが書かれているファイルであるquality_incidents.csvには農園名も認証名も含まれていませんが、関係をたどっていくことで、原因の農園とその農園が持っている認証まで明らかにすることができました。
実はこの質問は、#3 でマネージド GraphRAG が(リランキング併用前は)回答できなかった Q1 と同じ内容です。#3 では自動構築されたグラフの分断が原因で経路をたどれませんでしたが、正確に構築したグラフでは、関係をたどるだけで答えが確定します。
%%oc -d {"Farm":"name","Product":"name","QualityIncident":"description","Certification":"type"}
MATCH p=(qi:QualityIncident {incident_id: 'QI01'})-[:REPORTED_FOR]->(:Shipment)<-[:FULFILLED_BY]-(:Order)-[:FOR_PRODUCT]->(:Product)<-[:PACKAGED_AS]-(:RoastBatch)<-[:USED_IN]-(:HarvestLot)<-[:HARVESTED]-(:Farm)-[:HAS_CERTIFICATION]->(:Certification)
RETURN p

ステップ 5: 同じデータを Neptune Analytics に取り込んで確認する
最後に、Neptune Database に投入したのと同じデータを Neptune Analytics にも取り込み、同じクエリが動くことを確認します。
Neptune Analytics には、既存の Neptune Database クラスターから直接グラフをインポートする機能もありますが、こちらは 25 GB 以上の中〜大規模グラフ向けとされており、小規模なグラフには S3 経由でのインポートが推奨されています。今回のデータはノード 107 個・エッジ 137 本と小規模なので、ドキュメントの推奨に従い、ステップ 3-3 でアップロードした S3 の CSV をそのまま Analytics 側でも読み込む方法を採用します。Bulk Loader 用に作った CSV が、Analytics のインポートにもそのまま使えるということです。
📖 参考: Neptune クラスターまたはスナップショットから Neptune Analytics グラフを作成する
(AWS)
5-1. 読み取りロールの信頼ポリシーに Neptune Analytics を追加する
S3 の読み取りには 3-2 (2) で作成したロールを流用しますが、1 点だけ修正が必要です。このロールの信頼ポリシーは rds.amazonaws.com(Neptune Database)だけを許可しているため、このままでは Neptune Analytics がロールを引き受けられません。IAM コンソールからロールの 信頼関係 タブを開き、Service に neptune-graph.amazonaws.com を追加します。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": ["rds.amazonaws.com", "neptune-graph.amazonaws.com"]
},
"Action": "sts:AssumeRole"
}
]
}
5-2. S3 をデータソースにして Analytics グラフを作成する
グラフの作成手順はシリーズ 1 本目のステップ 1 とほぼ同じなので、詳細はそちらを参照してください。今回変わるのはデータソース部分だけです。
- グラフ名:
coffee-trading-demo-analytics - データソース: Amazon S3 からのデータのロード を選択し、S3 URI に
s3://example-neptune-coffee-demo/coffee_data/を指定します - IAM ロール: 5-1 で信頼ポリシーを修正したロールを選択します
- その他(m-NCU 16、レプリカ 0、ベクトル検索なし)は前回と同じ設定です

続いて、この Analytics グラフにプライベートエンドポイントを作成します。

5-3. ノートブックを作成し、同じクエリを実行する
Analytics グラフへの接続には、1 本目と同様に Analytics 用のノートブックを作成します。手順は 1 本目のステップ 2 を参照してください。
接続できたら、ノード数とエッジ数を確認し、ステップ 4-6 と同じ 7 ホップのクエリを実行してみます。
%%oc
MATCH (n) RETURN count(n) AS node_count
%%oc
MATCH (qi:QualityIncident {incident_id: 'QI01'})-[:REPORTED_FOR]->(:Shipment)<-[:FULFILLED_BY]-(:Order)-[:FOR_PRODUCT]->(:Product)<-[:PACKAGED_AS]-(:RoastBatch)<-[:USED_IN]-(:HarvestLot)<-[:HARVESTED]-(f:Farm)-[:HAS_CERTIFICATION]->(cert:Certification)
RETURN DISTINCT f.name, cert.type


Database 側と同じく、ノード 107 個と、北風高地農園 × FairTrade / RainforestAlliance の 2 件が返ります。同じデータ・同じ openCypher のクエリが、Database と Analytics のどちらでもそのまま動くことが確認できました。
常時稼働するクラスターとして動く Database に対し、Analytics はグラフ全体をメモリ上に展開して分析処理を高速に実行します。トランザクション処理は Database、データの分析は Analytics という使い分けを、同じデータ・同じクエリ言語のまま行き来できるのが Neptune の特徴と言えます。
後片付け
今回作成したリソースは、次回(#5)でも引き続き使うため、基本的に削除せずに残します。#5 では、このクラスターのグラフに自然言語で質問を投げる検証を行います。
ただし、Neptune Database クラスターと Neptune Analytics グラフはどちらも稼働時間に応じた課金が発生します。次回まで時間が空く場合は一度削除し、#5 に進む際に本記事の手順で再構築することをおすすめします(ノートブックは「停止」にしておけば課金を抑えられます)。削除する場合の対象は以下の通りです。
- Neptune Analytics グラフ(
coffee-trading-demo-analytics) - Neptune Database クラスター(
coffee-trading-demo) — 削除保護が有効な場合は先に無効化してから削除 - ノートブックインスタンス
- S3 バケット(
example-neptune-coffee-demo) - 作成した IAM ロール
- S3 用 VPC エンドポイント
おわりに
今回は Neptune Database の構築から Bulk Loader でのデータ投入、多段ホップのトレースクエリ、そして Neptune Analytics への同一データの取り込みまでを扱いました。
実際に触ってみた所感として、関係の探索が中心になる処理はグラフ DB が優れているなと感じました。RDB で同等の処理を書くとしたら恐ろしい数の JOIN が発生しそうです。
これで、#2 で整理したパターン ②「正確なナレッジグラフが整備されている前提」が整いました。次回(#5)は、このグラフに byokg-rag で自然言語の質問を投げ、#3 でマネージド GraphRAG が苦戦した質問に答えられるかを検証します。
最後まで読んでいただきありがとうございました!
参考文献
Neptune Database の構築
- Launching a Neptune DB cluster using the AWS Management Console(AWS)
- Amazon Neptune Serverless(AWS) — NCU によるキャパシティ設定の仕様
- Amazon Neptune ノートブックでのマジックの使用(AWS) —
%status・%load・%%ocなど本記事で使用したマジックの一覧
Bulk Loader によるデータ投入
- Amazon Neptune 一括ローダーを使用したデータの取り込み(AWS)
- openCypher データのロード形式(AWS) — ノードファイル / リレーションシップファイルの CSV ヘッダ仕様
- Amazon Neptune から Amazon S3 リソースにアクセスするための IAM ロールの作成(AWS)
- Amazon Neptune クラスターへの IAM ロールの追加(AWS)
- Amazon S3 VPC エンドポイントの作成(AWS) — Bulk Loader に Gateway 型 S3 エンドポイントが必要な理由
- Neptune ローダーコマンド(AWS) —
%loadの裏で呼ばれる Loader API のリファレンス
Neptune Analytics へのインポート
- Create a graph from Amazon S3, a Neptune cluster, or a snapshot(AWS)
- Creating a Neptune Analytics graph from Neptune cluster or snapshot(AWS) — 小規模データには S3 経由を推奨する旨の記載
- Creating a Neptune Analytics graph from Amazon S3(AWS) — Neptune Analytics 用の IAM ロール(信頼関係)の要件
- Create an empty Neptune graph(AWS) — プライベートグラフエンドポイントの作成(
create-private-graph-endpoint)を含むグラフ作成手順





