GraphRAG 入門 #5 byokg-rag × Neptune Database でナレッジグラフに自然言語で質問する RAG を検証してみた【Amazon Neptune】

GraphRAG 入門 #5 byokg-rag × Neptune Database でナレッジグラフに自然言語で質問する RAG を検証してみた【Amazon Neptune】

GraphRAG 入門シリーズの 5 本目です。EMNLP 2025 採録論文の実装である OSS「byokg-rag」を使い、#4 で構築した Neptune Database のナレッジグラフに自然言語で質問できるか(KGQA)を検証します。LLM に 4 種類のグラフアーティファクトを生成させて複数の検索戦略で照合する仕組みを整理したうえで、エンティティ・リンク化からグラフクエリ検索までの各戦略を 1 つずつ動かして中間結果を確認し、多ホップ・否定条件・集計を含む 7 つの質問で回答精度と実行時間・LLM 呼び出し回数を評価します。最後に、生成された openCypher クエリをノートブックで再実行・可視化して、回答の根拠を自分で確かめる方法も紹介します。
2026.07.20

はじめに

こんにちは!AI 事業本部のこーすけです。

GraphRAG 入門シリーズも 5 本目、いよいよ最終回です。

  • #3 では、パターン ①(文書からグラフを自動構築するマネージド GraphRAG)を検証しました。エンティティ抽出の表記ゆれや未連結でグラフが分断され、多段ホップの質問への回答が難しい場合があることを確認しました。

https://dev.classmethod.jp/articles/graphrag-intro-03-bedrock-kb-graphrag-vs-vector-rag/

  • 前回(#4)では、同じ事実関係を構造化データとして Neptune Database に投入し、ナレッジグラフを構築しました。

https://dev.classmethod.jp/articles/graphrag-intro-04-neptune-bulk-loader-knowledge-graph/

今回は、#2 で整理したパターン ②(KGQA)の検証です。#4 で構築したナレッジグラフに対して、自然言語の質問をそのまま LLM に投げて答えさせられるかを、OSS の byokg-rag で検証します。

byokg-rag はまだ検証記事も少ない OSS なので、今回は精度比較だけでなく、中心機能を一通り実際に動かして検証してみます。マルチストラテジー検索(エンティティ・リンク化、トリプレット検索、パス検索)に加えて、Cypher クエリ生成、ユーザーヒントや反復回数といった実行時オプションまで、半分ハンズオン形式で触っていきます。

byokg-rag とは

byokg-rag は、Amazon の研究チームが発表した論文「BYOKG-RAG: Multi-Strategy Graph Retrieval for Knowledge Graph Question Answering」(EMNLP 2025 採録)をベースにした OSS ツールキットです。awslabs/graphrag-toolkit という AWS 公式リポジトリのサブプロジェクトとして Apache-2.0 ライセンスで公開されています。

https://github.com/awslabs/graphrag-toolkit/tree/main/examples/byokg-rag

本記事では、論文で提案されている手法を BYOKG-RAG(大文字)、その実装である Python ライブラリを byokg-rag(小文字)と表記して区別します。

論文タイトルにもある KGQA(Knowledge Graph Question Answering) とは、自然言語の質問をグラフ探索に変換し、その結果を回答文として返す仕組みの総称です。人間(自然言語)とグラフ DB(クエリ言語)の間に立つ通訳のような役割で、「グラフ DB に蓄積したデータを、クエリ言語を知らないユーザーでも自然言語で照会できるようにしたい」というニーズに応える技術領域です。BYOKG-RAG は、この KGQA をユーザーが用意した既存のナレッジグラフに対して実現するための手法です。

https://arxiv.org/abs/2507.04127

名前の「BYO」は "Bring Your Own"、つまり「(構築済みの)ナレッジグラフを持ち込んでください」という意味です。BYOKG-RAG はグラフを作る部分には関与せず、既にあるグラフに対して自然言語の質問をどう投げてどう答えを引き出すか、という「検索・推論」の部分だけを担当します。ライブラリの byokg-rag が現時点で対応しているグラフストアは Neptune Database・Neptune Analytics・ローカルのグラフストアで、いずれもプロパティグラフ形式が対象です(RDF 形式は未対応)。

解こうとしている問題

論文は、LLM が見たことのない任意のナレッジグラフに対して質問応答する際に生じる 2 つの弱点を指摘しています。

1 つ目は、エンティティ・リンク化の脆弱性です。例えばユーザーの質問に「北風高地の豆」という言葉があっても、グラフ上の対応ノードは「北風高地農園」という名前だったり、内部的には「F04」という ID だったりします。LLM は質問中の言葉とグラフ上のノードの対応関係を正確には知らないため、この紐付け(エンティティ・リンク)に失敗すると探索の起点を誤り、以降の検索がすべて外れてしまいます。

2 つ目は、スキーマ変動への非適応性です。ナレッジグラフごとに、ノードのラベルやエッジの名前(リレーション名)はバラバラです。ある企業のグラフでは商品と注文の関係が ORDERS かもしれませんし、別のグラフでは PART_OF かもしれません。LLM が一般的にありそうな名前を推測してクエリを書くと、存在しないリレーション名を使ってしまい、検索結果がゼロ件になることがあります。論文の Northwind データセットのケーススタディでは、まさに LLM が PART_OF という尤もらしいが実際には存在しないリレーションを仮定してクエリを書いてしまい、検索結果が空になる例が紹介されています。

BYOKG-RAG はこの 2 つの弱点に対し、従来の LLM に単一のクエリを書かせてそれを実行させる方法ではなく、複数の検索戦略を組み合わせ、実際にグラフを調査した結果を使って反復的に精度を上げていくことで解決しようとしています。

LLM が生成する 4 つの「グラフアーティファクト」

BYOKG-RAG の中核は、質問を受け取った LLM に、4 種類の構造化された情報(論文では「グラフアーティファクト」と呼んでいます)を同時に生成させることです。

アーティファクト 内容
質問エンティティ(Question Entities) 質問文から抽出した固有名詞。グラフ検索の起点として使う
候補回答(Draft Answers) LLM が現時点の情報から挙げる暫定的な回答候補。パス検索のゴール側のノードとして使う
推論パス(Relationship Paths) 回答に到達するためにたどるリレーションの並びの仮説(例: HARVESTED → USED_IN → PACKAGED_AS
openCypher クエリ(Graph Queries) グラフに対してそのまま実行できるクエリ。集計やフィルタを含む質問に対応する

それぞれのアーティファクトは、後述する別々の検索戦略の入力になります。クエリを 1 回生成して終わりにするのではなく、性質の異なる複数の手がかりを同時に生成し、それぞれを実際のグラフと照合する設計です。

4 つの検索戦略

生成された 4 つのアーティファクトは、それぞれ対応する検索処理に渡され、実際のグラフに対して検証されます。

  • エンティティ・リンク化(Entity Linking): 質問エンティティの文字列を、あいまい文字列マッチまたは埋め込みベクトルの類似度でグラフ上の実ノードに対応付けます
  • パス検索(Path Retrieval): 推論パスをグラフ上で幅優先探索でたどる方法と、質問エンティティと候補回答の間の最短経路を計算する方法の 2 種類があります
  • トリプレット検索(Triplet Retrieval): 質問エンティティから 1 ホップずつ探索し、各ホップで質問に関連するリレーションを LLM に選択させるエージェント型と、埋め込みの類似度で関連度の高いトリプレットを取得するスコア型の 2 種類があります
  • グラフクエリ検索(Graph Query Retrieval): 生成された openCypher クエリをグラフ DB で実行します。集計やフィルタを含む質問に対応できます

論文では、グラフの種類によって有効な戦略が異なることが示されており、複数の戦略の結果を統合することで安定した精度を得ています。

反復精緻化ループ: 検索結果をフィードバックして生成をやり直す

BYOKG-RAG のもう 1 つの特徴は、この一連の処理を 1 回で終わらせず、2 回以上繰り返すことです(LLM が回答に十分な情報が集まったと判断した場合は途中で終了します)。

1 回目の検索で得られたノード・エッジの情報を、2 回目の LLM 呼び出しに入力として渡します。1 回目に誤った前提(存在しないリレーション名など)でアーティファクトを生成していても、2 回目は実際のグラフの内容を踏まえて生成し直せます。

論文の Northwind データセットの例では、1 回目に PART_OF という存在しないリレーションを仮定して検索が 0 件になった後、2 回目で実際に取得できた ORDERS リレーションの情報をもとにクエリを修正し、正解に到達しています。

質問から回答までのフローを図示するとこのようになります。
スクリーンショット 2026-07-20 200344

ベンチマークでの評価

論文では、Freebase 系(WebQSP・CWQ)、時間推論を含む Wikidata(CronQuestions)、医療ドメイン(MedQA)、エンタープライズ想定(Northwind)という、性質の異なるナレッジグラフのベンチマークで評価されています。2 番手のグラフ検索手法を平均 4.5 ポイント上回り、特に Northwind では text-to-Cypher 単体の手法に対して約 10 ポイントの差をつけています。学習データを使わないゼロショットの手法でありながら、LLM 呼び出し回数は既存の手法より少ないことも報告されています。

ここからは、この検索・推論の仕組みを、実際に Neptune Database 上のグラフに対して動かして確認していきます。

今回使うデータ: 「サンプル珈琲商事」のグラフ

byokg-rag の機能を検証するにあたって、対象となるグラフ構造を先におさらいしておきます。#3・#4 と同じ、架空のスペシャルティコーヒー商社「サンプル珈琲商事」のデータです(詳しい設計意図は #4 を参照してください)。

ノード

ノード 主なプロパティ
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)

農園 → 収穫ロット → 焙煎バッチ → 商品 → 注文 → 顧客、という 5 ホップの構造になっており、品質インシデントは出荷を経由して商品につながっています。規模はノード 107 個・エッジ 137 本です。

検証環境を準備する

前提

  • AWS アカウント(Neptune・Bedrock・IAM を利用できる権限)
  • Bedrock のモデルアクセスが有効化済みであること
  • 実行環境は Neptune ノートブックを利用します

💡 本稿のクラス名・メソッド名は執筆時点で GitHub 上に公開されている Neptune Database 用デモノートブック(examples/byokg-rag/byokg_rag_neptune_db_cluster_demo.ipynb)を参考にしていますが、バージョンアップで変わる可能性があります。実際に動かす際は最新のノートブックと見比べながら進めてください。

ステップ 1: #4 のクラスタに Bedrock の呼び出し権限を追加する

検証には、#4 で構築した Neptune Database クラスタ(coffee-trading-demo)とノートブック(aws-neptune-coffee-trading-demo)をそのまま使います。#4 の後片付けでクラスタを削除した場合は、#4 の手順(クラスタ作成 → ノートブック作成 → CSV 生成と S3 アップロード → Bulk Loader でロード)で再構築してください。

ノートブック作成時に自動生成される IAM ロール(例:AWSNeptuneNotebookRole-coffee-trading-demo)は Neptune・SageMaker まわりの権限のみで、Bedrock の呼び出し権限は含まれていません。後述の byokg-rag のセットアップで LLM を呼び出す際に必要になるので、ここで先に IAM コンソールから以下のインラインポリシーを追加しておきます。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "BedrockInvokeModelInferenceProfile",
      "Effect": "Allow",
      "Action": ["bedrock:InvokeModel"],
      "Resource": [
        "arn:aws:bedrock:*:123456789012:inference-profile/jp.anthropic.claude-sonnet-4-6",
        "arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-6"
      ]
    }
  ]
}

ポリシー中の 123456789012 は自身のアカウント ID に置き換えてください。また、この例は東京リージョンのクロスリージョン推論プロファイル経由で Claude Sonnet 4.6 を使う想定です。Resource は実際に使用するモデル(推論プロファイル)に合わせて書き換えてください。

クラスタが用意できたら、グラフの件数を確認しておきます。

%%oc
MATCH (n) RETURN count(n) AS node_count
%%oc
MATCH ()-[r]->() RETURN count(r) AS edge_count

ノード数 107・エッジ数 137 になっていれば OK です。

byokg-rag の機能を検証する

ここからは byokg-rag の中心機能であるマルチストラテジー検索(エンティティ・リンク化、トリプレット検索、パス検索、グラフクエリ検索)を、実際の Neptune Database に対して動かしてみます。

ステップ 2: byokg-rag をセットアップする

ノートブックのセルから、byokg-rag をインストールします。

!pip install https://github.com/awslabs/graphrag-toolkit/archive/refs/tags/v3.17.1.zip#subdirectory=byokg-rag

つづいて、クラスタへの接続に必要な変数と、使用する LLM を設定します。graph_db_endpoint_url は、Neptune コンソールの Databases → 対象クラスタ → Connectivity & security タブにある書き込みタイプのエンドポイントとポート番号から組み立てます。model_name には、ステップ 1 の IAM ポリシーで許可したモデル ID(クロスリージョン推論の場合は推論プロファイル ID)を指定します。
スクリーンショット 2026-07-20 192809

region = "ap-northeast-1"
graph_db_endpoint_url = "https://<Writer endpoint>:8182"
model_name = "<Bedrockで有効化したモデルID>"

グラフストアに接続します。

from graphrag_toolkit.byokg_rag.graphstore import NeptuneDBGraphStore

graph_store = NeptuneDBGraphStore(endpoint_url=graph_db_endpoint_url, region=region)

接続できたら、グラフのスキーマ(ラベル・プロパティの一覧)を取得しておきます。この情報は後述の KGLinker に渡され、LLM がグラフの構造を理解するための材料になります。

import json

schema = graph_store.get_schema()
print(json.dumps(schema, indent=4))

実際の出力(抜粋)はこうなりました。ノード数 107・エッジ数 137、#4 で設計した通りの 10 ラベル・9 リレーションが取得できています。

{
  "numNodes": 107,
  "numEdges": 137,
  "numNodeLabels": 10,
  "numEdgeLabels": 9,
  "nodeLabels": [
    "Order",
    "Customer",
    "RoastBatch",
    "QualityIncident",
    "HarvestLot",
    "Farm",
    "Product",
    "Shipment",
    "Exporter",
    "Certification"
  ],
  "edgeLabels": [
    "HAS_CERTIFICATION",
    "FULFILLED_BY",
    "EXPORTED_BY",
    "HARVESTED",
    "USED_IN",
    "PACKAGED_AS",
    "PLACED",
    "REPORTED_FOR",
    "FOR_PRODUCT"
  ],
  "labelTriples": [
    { "~type": "HAS_CERTIFICATION", "~from": "Farm", "~to": "Certification" },
    { "~type": "FULFILLED_BY", "~from": "Order", "~to": "Shipment" },
    { "~type": "EXPORTED_BY", "~from": "HarvestLot", "~to": "Exporter" },
    { "~type": "HARVESTED", "~from": "Farm", "~to": "HarvestLot" },
    { "~type": "USED_IN", "~from": "HarvestLot", "~to": "RoastBatch" },
    { "~type": "PACKAGED_AS", "~from": "RoastBatch", "~to": "Product" },
    { "~type": "PLACED", "~from": "Customer", "~to": "Order" },
    { "~type": "REPORTED_FOR", "~from": "QualityIncident", "~to": "Shipment" },
    { "~type": "FOR_PRODUCT", "~from": "Order", "~to": "Product" }
  ]
  // nodeLabelDetails / edgeLabelDetails にラベルごとのプロパティ名・型も含まれる
}

つづいて、各ノードラベルについて「人間が読む用の代表プロパティ」を指定します。

Neptune はノードを内部的に不透明な ID(vertex ID)で管理しており、名前という概念を持ちません。assign_text_repr_prop_for_nodes() でラベルごとに代表プロパティを指定すると、nodes() はその ID の代わりにプロパティの値(例: "北風高地農園")を返すようになります。byokg-rag は文書からのエンティティ自動抽出を行わないため、この対応表は人間が用意する必要があります。

text_repr_prop_for_node = {
    "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",
}
graph_store.assign_text_repr_prop_for_nodes(text_repr_prop_for_node)

ステップ 3: マルチストラテジー検索で質問に答える

Neptune Database 用デモノートブックの手順をベースに、エンティティ・リンク化、トリプレット検索(エージェント型)、パス検索、グラフクエリ検索の 4 戦略を組み合わせたマルチストラテジー検索を試します。

検証には、次の質問(Q1)を使います。

question = "QI01(品質インシデント)の対象商品は、どの農園のどの認証のロットに由来しますか?"

答え合わせのために、この質問の正解を先に確認しておきます。グラフ上の経路は QI01 → 出荷 S04 → 注文 O04 → 商品「北風高地シングルオリジン」(P04) → 焙煎バッチ RB04・RB10 → 収穫ロット L10・L11 → 北風高地農園(F04) → 認証 FairTrade・RainforestAlliance とつながっているため、期待する回答は次の通りです。

  • 農園: 北風高地農園(F04)
  • 認証: FairTrade、RainforestAlliance
  • 由来ロット: L10、L11

後述する ByoKGQueryEngine を使えばこの 4 戦略は 1 回の呼び出しでまとめて実行できますが、それだと各戦略が何を返しているのかが見えません。ここでは各戦略を 1 つずつ手動で実行し、それぞれの中間結果が上の正解にどこまで近づけているかを確認しながら進めます。

KGLinker: 質問からグラフアーティファクトを生成する

KGLinker は、質問エンティティ候補回答推論パスopenCypher クエリの 4 つのアーティファクトをまとめて生成する中心的なコンポーネントです。生成されたアーティファクトは、質問エンティティ候補回答EntityLinker に、推論パスPathRetriever に、openCypher クエリGraphQueryRetriever に、というようにそれぞれ対応する検索戦略へ渡していきます。

まずは KGLinker に質問文を渡して、4 つのアーティファクトを生成させます。

from graphrag_toolkit.byokg_rag.graph_connectors import KGLinker
from graphrag_toolkit.byokg_rag.llm import BedrockGenerator

llm_generator = BedrockGenerator(model_name=model_name, region_name=region)

kg_linker = KGLinker(graph_store=graph_store, llm_generator=llm_generator)
response = kg_linker.generate_response(question=question, schema=schema)
artifacts = kg_linker.parse_response(response)
artifacts

generate_response() には、検索で取得済みのグラフ情報を渡すための graph_context という引数もあります。まだ何も検索していない 1 回目の呼び出しでは渡すものがないため省略しています(省略時はライブラリ側がフォールバックの文言を補います)。この引数は、後述する ByoKGQueryEngine の反復で「1 回目の検索結果を 2 回目の生成に渡す」ためのフィードバック経路として使われます。

実際の出力はこうなりました。

{
    'entity-extraction': ['QI01', 'QualityIncident', 'Farm', 'Certification', 'HarvestLot', 'Product', 'Shipment'],
    'path-extraction': [
        'QualityIncident -> REPORTED_FOR -> Shipment -> FULFILLED_BY <- Order -> FOR_PRODUCT -> Product -> PACKAGED_AS <- RoastBatch -> USED_IN <- HarvestLot -> HARVESTED <- Farm -> HAS_CERTIFICATION -> Certification',
        'QualityIncident -> REPORTED_FOR -> Shipment',
        'Farm -> HARVESTED -> HarvestLot -> USED_IN -> RoastBatch -> PACKAGED_AS -> Product',
        'Farm -> HAS_CERTIFICATION -> Certification',
    ],
    'draft-answer-generation': ['QI01に関連する農園、認証、およびロット情報はグラフコンテキストが提供されていないため、具体的な回答はクエリ実行結果に依存します。'],
    'opencypher': [
        "MATCH (qi:QualityIncident {incident_id: 'QI01'})",
        '      -[:REPORTED_FOR]->(s:Shipment)',
        '      <-[:FULFILLED_BY]-(o:Order)',
        '      -[:FOR_PRODUCT]->(p:Product)',
        '      <-[:PACKAGED_AS]-(rb:RoastBatch)',
        '      <-[:USED_IN]-(hl:HarvestLot)',
        '      <-[:HARVESTED]-(f:Farm)',
        '      -[:HAS_CERTIFICATION]->(c:Certification)',
        'RETURN',
        '    qi.incident_id        AS incident_id,',
        '    p.product_id          AS product_id,',
        '    p.name                AS product_name,',
        '    f.farm_id             AS farm_id,',
        '    f.name                AS farm_name,',
        '    f.region              AS farm_region,',
        '    f.country             AS farm_country,',
        '    hl.lot_id             AS harvest_lot_id,',
        '    hl.grade              AS lot_grade,',
        '    hl.harvest_date       AS harvest_date,',
        '    c.cert_id             AS cert_id,',
        '    c.type                AS certification_type',
    ],
}

それぞれのキーは、「byokg-rag とは」で紹介した 4 つのアーティファクトにそのまま対応しています。実際の出力と照らし合わせながら、各キーの中身を確認します。

  • entity-extraction(質問エンティティ): 質問文から抽出した、グラフ検索の起点にする語の一覧です。今回は QI01 のようなインスタンスの ID に加えて、FarmCertification のようなラベル名も抽出されています。この一覧は次の EntityLinker に渡され、グラフ上の実ノードと照合されます。
  • draft-answer-generation(候補回答): LLM が現時点の情報から仮に立てる「答えの候補」です。本来は候補となるエンティティ名が入り、パス検索のゴール地点として使われます。今回は 1 回目の呼び出しでグラフの実データを渡していないため、「クエリ実行結果に依存します」という説明文になっており、候補としては機能していません。
  • path-extraction(推論パス): 「この関係をこの順にたどれば答えに着くはず」という関係チェーンの仮説です。今回は QualityIncident から Certification までの完全な経路に加えて、その部分経路も複数生成されています。次の PathRetriever が、この仮説を実際のグラフ上でたどって検証します。
  • opencypher(openCypher クエリ): 質問への回答をそのまま取得できる、実行可能な openCypher クエリです。後述の GraphQueryRetriever がこれを Neptune Database に対して実行します。

EntityLinker: 質問エンティティ・候補回答をグラフの実ノードに紐付ける

あいまい文字列マッチで、LLM が生成した文字列をグラフ上の実ノードにリンクします。

from graphrag_toolkit.byokg_rag.indexing import FuzzyStringIndex
from graphrag_toolkit.byokg_rag.graph_retrievers import EntityLinker

string_index = FuzzyStringIndex()
string_index.add(graph_store.nodes())
entity_linker = EntityLinker(retriever=string_index.as_entity_matcher())

linked_entities = entity_linker.link(artifacts["entity-extraction"], return_dict=False)
linked_answers = entity_linker.link(artifacts["draft-answer-generation"], return_dict=False)
linked_entities, linked_answers

実際の出力はこうなりました。

linked_entities = [
    'QI01', 'QI03', 'QI04', 'FairTrade', 'African Highlands Trading', 'Pacific Bean Logistics',
    'Andes Export Co.', 'RainforestAlliance', 'African Highlands Trading', 'RainforestAlliance',
    'RainforestAlliance', 'Andes Export Co.', 'Pacific Bean Logistics', 'Pacific Bean Logistics', 'O16',
]
linked_answers = []

linked_answers が空なのは、draft-answer-generation の中身が候補の名前ではなく「グラフコンテキストが提供されていないため…」という説明文だったためです。あいまい文字列マッチのしようがありません。

linked_entities も 15 件とノイズが多めです。entity-extraction の中身(QI01QualityIncidentFarmCertificationHarvestLotProductShipment)を見ると、実際のインスタンス値は QI01 だけで、残りはノードのラベル名(型名)でした。ラベル名をあいまい文字列マッチにかけた結果、FairTradeや輸出業者名など無関係な値まで拾ってしまっています。肝心の QI01 は含まれているので、後続のトリプレット検索・パス検索がそこを起点にできるかがポイントです。

このノイズは、ByoKGQueryEngine の反復(iterations)で改善される可能性があります。ソースコードを見ると、2 回目以降は graph_context に 1 回目の検索結果を渡した上で、entity-extraction 専用の別プロンプト(entity-extraction-iterative)で再生成する作りになっています。ただし保証ではなく、1 回目の検索がうまくいかなければ 2 回目も改善材料が無いままです。

AgenticRetriever: トリプレット検索

EntityLinker で生成されたリンク済みのエンティティを起点に、LLM が関係の関連性を判断しながらグラフを 1 ホップずつたどります。

from graphrag_toolkit.byokg_rag.graph_retrievers import AgenticRetriever, GTraversal, TripletGVerbalizer

triplet_retriever = AgenticRetriever(
    llm_generator=llm_generator,
    graph_traversal=GTraversal(graph_store),
    graph_verbalizer=TripletGVerbalizer(),
)
triplet_context = triplet_retriever.retrieve(query=question, source_nodes=linked_entities)
triplet_context

実際の出力はこうなりました。

triplet_context = [
    'O16 -> FULFILLED_BY -> S16',
    'QI03 -> REPORTED_FOR -> S05',
    'O16 -> FOR_PRODUCT -> モンテベルデシングルオリジン',
    'QI01 -> REPORTED_FOR -> S04',
    'QI04 -> REPORTED_FOR -> S01',
]

正解の 1 ホップ目(QI01 -> REPORTED_FOR -> S04)は取得できていますが、そこから先(S04Order → … → FarmCertification)へは伸びていません。代わりに、linked_entities に混入していたノイズ(QI03QI04O16)からの、Q1 とは無関係な 1 ホップ探索が結果に含まれています。

PathRetriever: パス検索

KGLinker が生成した推論パスを、メタパス(たどるリレーション名の並び)に変換して、実際のグラフ上でたどります。

ソースコード(graph_traversal.pyfollow_paths)を確認すると、メタパスの各要素はリレーション名そのものとして実際のエッジタイプと直接比較される実装になっています。つまり期待されている形式は ["REPORTED_FOR", "FULFILLED_BY", ...] のような、リレーション名だけの並びです。一方、path-extraction の出力は "QualityIncident -> REPORTED_FOR -> Shipment -> ..." のようにノードラベルを含んでいるため、そのまま分割して渡すことはできません。スキーマの edgeLabels を正解リストとして使い、リレーション名だけを残す形に変換してから渡します。フルパスが見つからない場合の保険として、先頭 1 要素・先頭 2 要素だけの短縮パスも候補に加えます。

from graphrag_toolkit.byokg_rag.graph_retrievers import PathRetriever, PathVerbalizer
import re

path_retriever = PathRetriever(
    graph_traversal=GTraversal(graph_store),
    path_verbalizer=PathVerbalizer(),
)

edge_labels = set(schema["edgeLabels"])  # ステップ2で取得済みのスキーマから

metapaths = []
for path in artifacts["path-extraction"]:
    tokens = [t.strip() for t in re.split(r"->|<-", path)]
    relations = [t for t in tokens if t in edge_labels]  # リレーション名だけ残す
    metapaths.append(relations)

shortened_paths = [p[:1] for p in metapaths if len(p) > 1] + [p[:2] for p in metapaths if len(p) > 2]
metapaths += shortened_paths

path_context = path_retriever.retrieve(linked_entities, metapaths, linked_answers)
path_context

⚠️ デモノートブックのメタパス組み立ては path.split("->") で分割するだけの実装で、ノードラベルがメタパスに混入します。この場合 1 ホップ目のリレーション名比較が必ず不一致になり、実際に試したところ path_context は空になりました。上記のリレーション名だけを残す前処理は、これを回避するために本記事で加えたものです。

実行結果はこうなりました。

[
    'QI01 -> REPORTED_FOR -> S04 | S04',
    'QI04 -> REPORTED_FOR -> S01 | S01',
    'QI03 -> REPORTED_FOR -> S05 | S05',
]

linked_entities に含まれていた品質インシデント 3 件を起点とする順方向のパス(REPORTED_FOR)が取得でき、正解の 1 ホップ目である QI01 -> REPORTED_FOR -> S04 も入っています。

一方、7 ホップのフルチェーンは取得できていません。get_one_hop_edges(Neptune 側の実装)は出ていく方向のエッジ(-[e]->)のみを取得する作りで、follow_paths 側にも逆方向をたどる仕組みがないため、2 ホップ目の FULFILLED_BYShipment に入ってくる逆向きのエッジ)から先に進めないためです。path-extraction 自体は <- を含む経路も正確に生成できていましたが、この向きは現行のライブラリ実装では扱えない、という制約になります。

GraphQueryRetriever: 生成された openCypher クエリを実行する

KGLinker が生成した opencypher クエリを、GraphQueryRetriever にそのまま渡して実行します。

from graphrag_toolkit.byokg_rag.graph_retrievers import GraphQueryRetriever

graph_query_executor = GraphQueryRetriever(graph_store)
graph_query = " ".join(artifacts["opencypher"])
cypher_context, cypher_answers = graph_query_executor.retrieve(graph_query, return_answers=True)
cypher_context, cypher_answers

実際の cypher_answers はこうなりました(4 件、収穫ロット × 認証の組み合わせ分です)。

[
    {'incident_id': 'QI01', 'product_id': 'P04', 'product_name': '北風高地シングルオリジン',
     'farm_id': 'F04', 'farm_name': '北風高地農園', 'farm_region': 'Nyeri', 'farm_country': 'Kenya',
     'harvest_lot_id': 'L11', 'lot_grade': 'A', 'harvest_date': '2025-12-10',
     'cert_id': 'C02', 'certification_type': 'FairTrade'},
    {'incident_id': 'QI01', 'product_id': 'P04', 'product_name': '北風高地シングルオリジン',
     'farm_id': 'F04', 'farm_name': '北風高地農園', 'farm_region': 'Nyeri', 'farm_country': 'Kenya',
     'harvest_lot_id': 'L10', 'lot_grade': 'AA', 'harvest_date': '2025-11-05',
     'cert_id': 'C02', 'certification_type': 'FairTrade'},
    {'incident_id': 'QI01', 'product_id': 'P04', 'product_name': '北風高地シングルオリジン',
     'farm_id': 'F04', 'farm_name': '北風高地農園', 'farm_region': 'Nyeri', 'farm_country': 'Kenya',
     'harvest_lot_id': 'L11', 'lot_grade': 'A', 'harvest_date': '2025-12-10',
     'cert_id': 'C03', 'certification_type': 'RainforestAlliance'},
    {'incident_id': 'QI01', 'product_id': 'P04', 'product_name': '北風高地シングルオリジン',
     'farm_id': 'F04', 'farm_name': '北風高地農園', 'farm_region': 'Nyeri', 'farm_country': 'Kenya',
     'harvest_lot_id': 'L10', 'lot_grade': 'AA', 'harvest_date': '2025-11-05',
     'cert_id': 'C03', 'certification_type': 'RainforestAlliance'},
]

冒頭で確認した正解(農園: 北風高地農園、認証: FairTrade・RainforestAlliance、ロット: L10・L11)と完全に一致しています。KGLinker が生成した opencypher クエリを直接実行する経路だけで正解が得られました。

セキュリティ面の補足として、GraphQueryRetriever にはグラフを変更してしまうクエリからの保護機能が備わっています。LLM が生成したクエリやユーザーが与えたクエリの中に CREATEMERGEDELETESETREMOVEDROPDETACHCALL といった変更系キーワードが含まれていないかをチェックし、含まれていればそのクエリの実行をブロックします。ただし、これはあくまでアプリケーション側の検証です。Neptune Database・Neptune Analytics のどちらも、クエリを読み取り専用に制限するサーバー側のパラメータは提供していません。サーバー側でもより強い保証が欲しい場合は、実行ロールに与える Neptune の権限を neptune-db:ReadDataViaQuery(Neptune Analytics の場合は neptune-graph:ReadDataViaQuery)のみに絞っておくと安心です。

ByoKGQueryEngine: 集めたコンテキストから回答を生成する

ここまで手動で集めた各戦略のコンテキストを使って、回答を生成してみます。デモノートブックでは、まず ByoKGQueryEngine を作成し、query() は呼ばずに、手動で集めたコンテキストを generate_response() に直接渡して「そのコンテキストだけで回答に足りるか」を戦略ごとに確認しています。この構成に沿って進めます。

from graphrag_toolkit.byokg_rag.byokg_query_engine import ByoKGQueryEngine

byokg_query_engine = ByoKGQueryEngine(
    graph_store=graph_store,
    llm_generator=llm_generator,
    kg_linker=kg_linker,
    entity_linker=entity_linker,
    triplet_retriever=triplet_retriever,
    path_retriever=path_retriever,
)

まず、トリプレット検索のコンテキストだけを渡してみます。先ほど見た通り、triplet_context には正解の 1 ホップ目(QI01 -> REPORTED_FOR -> S04)までしか入っていないので、農園や認証までは答えられないはずです。

answers, response = byokg_query_engine.generate_response(question, "\n".join(triplet_context))
print(answers)

実際の回答はこうなりました。

answers = ['QI01はS04(ロットS04)に関連する品質インシデントです。S04の農園および認証情報はグラフコンテキストから直接特定できません。']

期待どおり、コンテキストに含まれる 1 ホップ目(QI01S04 の関係)までは特定できたものの、農園・認証については「特定できない」という回答になりました。渡したコンテキストに無い情報を推測で埋めず、不足を明示している点は、RAG の回答として正しい挙動です(なお S04 は出荷 ID ですが、回答では「ロット S04」と誤って言い換えられています。コンテキストに S04 が何のノードかという情報が無いためだと思われます)。

次に、パス検索のコンテキストだけを渡してみます。path_context の中身は 1 ホップ目の情報のみで triplet_context と実質同等なので、こちらも農園・認証までは答えられないはずです。

answers, response = byokg_query_engine.generate_response(question, "\n".join(path_context))
print(answers)

実際の回答はこうなりました。

answers = ['QI01はS04に対して報告されており、S04の農園および認証ロットの詳細情報はグラフコンテキストに含まれていません', 'S04']

こちらも期待どおり、QI01S04 の関係までは特定できたものの、農園・認証は「コンテキストに含まれていない」という回答になりました。triplet_context のときとほぼ同じ結果で、同等の情報量のコンテキストからは同等の回答になることが確認できます。

最後に、openCypher クエリ実行のコンテキストだけを渡してみます。こちらは 4 件の正確な検索結果が入っているので、これだけで正解できるはずです。

answers, response = byokg_query_engine.generate_response(question, "\n".join(cypher_context))
print(answers)

実際の回答はこうなりました。

answers = [
    '農園:北風高地農園(F04)、ケニア・ニエリ地域',
    'ハーベストロット:L10(グレードAA、収穫日2025-11-05)',
    'ハーベストロット:L11(グレードA、収穫日2025-12-10)',
    '認証:FairTrade(C02)',
    '認証:RainforestAlliance(C03)',
]

冒頭で確認した正解(農園・認証 2 件・ロット 2 件)と一致しています。トリプレットのコンテキストでは答えられなかった質問に、openCypher クエリの実行結果を渡すだけで完全に回答できました。「どの戦略のコンテキストが回答に効くかは質問によって異なる」ことが、同じ質問・同じ生成処理でコンテキストだけを差し替えた比較で確認できた形です。

なお、EntityLinker の出力(linked_entities)はエンティティ名の一覧であってノード間の関係を含まないため、回答生成のコンテキストとしては使えません。トリプレット検索・パス検索の起点として使うのが本来の役割です。

ByoKGQueryEngine: フルパイプラインで実行する

最後に、ここまで手動でやってきた一連の流れを query() で自動実行します。ByoKGQueryEngine 自体は新しい検索機能を持っておらず、query() の中身はここまでの手動実行の自動化です。具体的には次の処理を行います。

  1. KGLinker で 4 つのアーティファクト(質問エンティティ・候補回答・推論パス・openCypher クエリ)を生成する
  2. それぞれを対応するリトリーバー(EntityLinkerAgenticRetriever / PathRetriever / GraphQueryRetriever)に配り、検索結果を 1 つのコンテキストに蓄積する
  3. 蓄積したコンテキストを graph_context として 2 回目の KGLinker 呼び出しに渡し、アーティファクトの生成と検索をやり直す(default: iterations=2

手動実行との違いは、(2) で全戦略の結果を 1 つのコンテキストにまとめることと、(3) の反復実行することです。

(2) が重要なのは、先ほどのコンテキスト別の比較で見た通り、どの戦略のコンテキストが回答に効くかは質問によって異なるためです。今回の質問ではグラフクエリ検索だけが正解に到達しましたが、別の質問ではトリプレット検索やパス検索が効くこともあります。どれが当たるかを事前に選別するのではなく、全戦略の結果をまとめて LLM に渡すことで、いずれか 1 つでも正解に到達していれば回答できる、という形で精度を担保しています。

なお、エンジン作成時に graph_query_executor を渡していませんが、query() の中では、KGLinker が生成した openCypher クエリの実行も行われ、その結果もコンテキストに含まれます。

retrieved_context = byokg_query_engine.query(question)
answers, response = byokg_query_engine.generate_response(question, "\n".join(retrieved_context))

print(answers)
print(response)

実際の answers はこうなりました。

answers = [
    '北風高地農園(Farm ID: F04、ケニア・ニエリ地域)',
    '認証: FairTrade(C02)および RainforestAlliance(C03)',
    'ロット L10(グレード AA、収穫日: 2025-11-05)',
    'ロット L11(グレード A、収穫日: 2025-12-10)',
]

冒頭で確認した正解のうち、農園・認証 2 件・ロット 2 件を正確に回答できています。

掲載はしませんが、retrieved_context の中身を見ると、1 回目の手動実行では取得できなかった 北風高地農園 -> HARVESTED -> L10 | L11 | L12北風高地農園 -> HAS_CERTIFICATION -> RainforestAlliance | FairTrade というトリプレットが増えていました。1 回目の Cypher 実行結果で「北風高地農園」が判明したことを受けて、2 回目のイテレーションがそれを起点にトリプレット検索をやり直したためだと考えられます。graph_context を経由した反復精緻化ループが、実際に機能していることも確認できました。

ここまでのステップ 3 全体を振り返ると、個々の検索戦略を単体で見れば、EntityLinker はノイズを拾い、AgenticRetriever は 1 ホップで止まり、PathRetriever は順方向 1 ホップまででした。しかしそれでも最終回答が正解になったのは、ByoKGQueryEngine が 4 つの戦略の結果をまとめて LLM に渡すため、どれか 1 つの戦略(今回は GraphQueryRetriever)が正解に到達していれば回答できるからだと思います。グラフや質問のタイプによってどの戦略が効くかは変わる前提で、複数の戦略を並走させて冗長性で精度を安定させるマルチストラテジーの設計の素晴らしさを感じました。

その他の機能

今回は検証を割愛しますが、byokg-rag には以下の機能も用意されています。

  • Cypher 特化のリンカー(CypherKGLinker: Cypher 生成に特化した KGLinker の別実装です。エンティティ・リンク化もあいまい文字列マッチではなく専用の Cypher クエリ(opencypher-linking)で行い、マルチストラテジー側とは独立した反復回数(cypher_iterations)を持ちます。ByoKGQueryEnginecypher_kg_linker として渡し、kg_linker=None にすると Cypher 生成だけに絞った構成にできます。
  • ユーザーヒント(user_input: query() の引数として補足情報(例: 「対象の出荷 ID は S04 です」)を渡し、自動リンクがうまくいかないときに検索を人手で補正できます。
  • 反復回数(iterations: query() の反復回数の上限を指定できます。冒頭で紹介した反復精緻化ループの回数そのものです。なお上限まで必ず回るわけではなく、2 回目以降の反復では LLM が「集まったコンテキストで回答に十分」と判断すると、ループを早期終了する仕組みになっています。

性能評価

ここまでの検証は Q1(QI01 の由来をたどる多ホップの質問)を使ってきました。性質の異なる残り 2 つの質問も ByoKGQueryEngine のフルパイプラインに投げて、結果をまとめます。

Q2(1 ノードで完結する質問)

モンテベルデ農園の保有認証は?

Farm ノードから HAS_CERTIFICATION を 1 ホップたどるだけで答えられる質問です。

question_q2 = "モンテベルデ農園の保有認証は?"
retrieved_context = byokg_query_engine.query(question_q2)
answers, response = byokg_query_engine.generate_response(question_q2, "\n".join(retrieved_context))
print(answers)
answers = ['RainforestAlliance']

正解です。

Q3(複数エンティティをまたぐ質問)

RainforestAlliance 認証の農園由来の豆を使った商品を、直近で発注した顧客は?

RainforestAlliance 認証を持つ農園(モンテベルデ・北風高地)から商品・注文までを広くたどった上で、注文日の新旧比較も必要になる質問です。正解は、北風高地シングルオリジンを 2026-03-01 に発注したサンプルカフェ 5 号店(O25)です。

question_q3 = "RainforestAlliance 認証の農園由来の豆を使った商品を、直近で発注した顧客は?"
retrieved_context = byokg_query_engine.query(question_q3)
answers, response = byokg_query_engine.generate_response(question_q3, "\n".join(retrieved_context))
print(answers)
answers = ['サンプルカフェ5号店']

複数農園をまたぐ探索と日付の比較が必要な質問でも、正解にたどり着けました。

3 問すべて正解だったので、性質の異なる問題を 4 つ追加で試してみました。実行方法は Q2・Q3 と同様のため、以降は質問と回答のみ載せます。

Q4(否定条件: エッジの不在)

どの認証も持っていない農園の豆を使った商品を注文した顧客をすべて教えてください。

HAS_CERTIFICATION エッジが存在しない」という否定条件は、存在する関係しかたどれないトリプレット検索・パス検索では原理的に扱えません。Cypher の WHERE NOT を正しく生成できるかが試されます。正解は、無認証のリオドラド農園 → リオドラドシングルオリジン → 注文とたどった、サンプルカフェ 2・5・9 号店です。

answers = ['サンプルカフェ2号店', 'サンプルカフェ5号店', 'サンプルカフェ9号店']

正解です。

Q5(2 つの起点の交差)

エスペランサ農園とサンライズヒル農園、両方の豆を使っている商品はありますか?

2 つの農園それぞれから商品への経路をたどり、その積集合を取る必要がある質問です。ブレンド商品はもう 1 つ(モーニングブレンド)ありますが、そちらは北風高地とモンテベルデの豆なので、「ブレンドだから」と安易に答えると不正解になります。正解はサンライズブレンドのみです。

answers = ['サンライズブレンド']

正解です。

Q6(属性フィルタ + 未使用エッジ)

重大度が「高」の品質インシデントの対象商品について、その原料ロットの輸出業者はどこですか?

severity プロパティでのフィルタと、ここまでの検証で一度も登場していない EXPORTED_BY エッジへの分岐が必要です。正解は、QI03 → 出荷 S05 → リオドラドシングルオリジン → ロット L13・L14 → Andes Export Co. です。

answers = ['Andes Export Co.']

正解です。

Q7(集計)

総収穫量が最も多い農園はどこですか?

全 15 ロットの quantity_kg を農園ごとに合算して比較する集計問題です。トリプレットをいくら列挙しても答えは出ず、SUM を含む Cypher を生成できるかが試されます。正解はリオドラド農園(3,054kg)です。

answers = ['リオドラド農園']

正解です。

結果まとめ

質問 byokg-rag の結果 LLM 呼び出し回数 実行時間
Q1(多ホップ) 正解 11 回 32.6 秒
Q2(1 ノードで完結) 正解 7 回 16.2 秒
Q3(複数エンティティ) 正解 10 回 26.7 秒
Q4(否定条件) 正解 8 回 34.6 秒
Q5(2 起点の交差) 正解 11 回 46.0 秒
Q6(属性フィルタ + 別エッジ) 正解 5 回 22.6 秒
Q7(集計) 正解 15 回 48.0 秒

7 問すべてに正解できました。否定条件・積集合・集計といった、トリプレットやパスの列挙だけでは原理的に答えられないタイプの質問にも回答できているのは、マルチストラテジー検索に openCypher クエリの生成・実行が組み込まれているためと考えられます。

試す際のコスト感の参考として、各質問のフルパイプライン(query() + generate_response())の所要時間と、その間の LLM 呼び出し回数(BedrockGenerator.generate() の実行回数)もあわせて計測しました。1 問あたり LLM 呼び出しは 5〜15 回(平均約 10 回)、所要時間は 16〜48 秒でした。

呼び出し回数の内訳は、反復ごとの KGLinker 呼び出しと最終の回答生成が計 3 回程度で固定、残りはエージェント型トリプレット検索(AgenticRetriever)がホップごとに行う関連リレーションの選択です。質問によって回数が変動するのは、リンクされたエンティティの数や反復の早期終了によって探索量が変わるためです。論文では、1 問あたりの LLM 呼び出し回数はスコア型のトリプレット検索を使う構成で 2〜3 回、エージェント型を使う構成で平均 4.5〜6.3 回(WebQSP・CWQ ベンチマーク)と報告されています。

補足: 回答の根拠を自分で検証する

回答を鵜呑みにせず根拠を確認したい場合、2 つの方法があります。

1 つ目は、retrieved_context を見ることです。Graph Query: ... の項目に、実際に実行された openCypher クエリと、その生の実行結果(Execution Result)が両方含まれています。回答がどのクエリ・どのデータに基づいているかを、そのまま確認できます。

2 つ目は、生成されたクエリを Neptune ノートブックの %%oc セルに貼って再実行することです。MATCH 部分を流用して MATCH p=(...) RETURN p の形に書き換えれば、Workbench の可視化機能で経路をグラフ表示できます。

例として、Q4(否定条件)で生成されたクエリを可視化してみます。retrieved_context に含まれていた生成クエリは次の通りで、WHERE NOT で認証エッジの不在を条件にできています。

MATCH (c:Customer)-[:PLACED]->(o:Order)-[:FOR_PRODUCT]->(p:Product)
      <-[:PACKAGED_AS]-(rb:RoastBatch)
      <-[:USED_IN]-(hl:HarvestLot)
      <-[:HARVESTED]-(f:Farm)
WHERE NOT (f)-[:HAS_CERTIFICATION]->(:Certification)
RETURN DISTINCT c.customer_id AS CustomerID, c.name AS CustomerName ORDER BY c.name

この MATCH パターンをパス変数で包んで RETURN p に変えます(元のクエリは pProduct の変数に使っているため、pr にリネームしています)。

%%oc -d {"Customer":"name","Order":"order_id","Product":"name","RoastBatch":"batch_id","HarvestLot":"lot_id","Farm":"name"}
MATCH p=(c:Customer)-[:PLACED]->(o:Order)-[:FOR_PRODUCT]->(pr:Product)<-[:PACKAGED_AS]-(rb:RoastBatch)<-[:USED_IN]-(hl:HarvestLot)<-[:HARVESTED]-(f:Farm)
WHERE NOT (f)-[:HAS_CERTIFICATION]->(:Certification)
RETURN p

スクリーンショット 2026-07-18 212901

リオドラド農園(紫色)から L13・L14 → RB05・RB09 → リオドラドシングルオリジンに合流し、そこに O05・O12・O19 経由でサンプルカフェ 5・2・9 号店がつながる経路が描画されました。そしてリオドラド農園から認証ノードへのエッジが 1 本も存在しないことも、図から直接確認できます。「エッジの不在」という否定条件の答え合わせが、グラフ構造を見ることでできました。

わかったこと

今回の検証で分かったことをまとめます。

  • 回答精度: 多ホップのトレース、否定条件、積集合、集計を含む 7 問すべてに正解できました。表記ゆれや ID の欠落がない正確なグラフなら、スキーマから生成した openCypher クエリがそのまま通ることが効いています。
  • 戦略ごとの挙動: Q1 で各戦略の中間結果を確認した範囲では、エンティティ・リンク化はラベル名を実エンティティと誤認してノイズを拾い、トリプレット検索・パス検索は 1 ホップ止まりで、正解に届いたのはグラフクエリ検索だけでした。それでも最終回答が正解になるのは、全戦略の結果をまとめて LLM に渡す設計のおかげです。
  • 回答の検証性: retrieved_context に実行されたクエリと結果がそのまま残るので、生成クエリを %%oc で再実行・可視化して確認ができます。挙動を後から追跡できるのは、既存グラフを使う方式ならではの利点です。

後片付け

忘れずに削除しておきましょう。

  • Neptune Database クラスタ(coffee-trading-demo)— 削除保護が有効な場合は先に無効化してから削除
  • ノートブックインスタンス
  • S3 バケット(example-neptune-coffee-demo
  • 作成した IAM ロール(Bulk Loader 用・ノートブック用)・S3 VPC エンドポイント

おわりに

今回は OSS の byokg-rag を使い、#4 で構築した Neptune Database のグラフに対して自然言語で質問応答する検証を行いました。

#3 の Bedrock Knowledge Bases のマネージド GraphRAG では、文書からのエンティティ自動抽出が不完全(出荷 ID が未認識、農園名が表記ゆれで分裂)だったため、リランキングを併用するまでは多ホップの質問に回答できませんでした。今回、同じ質問に byokg-rag で正解できたことから、#3 の苦戦の原因は検索・推論の処理ではなくグラフの品質にあったと整理できますね。
また、正確なグラフがあれば、要件を満たすクエリを LLM が自然言語から生成できることも確認できました。

検証を通して、BYOKG-RAG の設計思想の良さも実感しました。LLM には仮説(エンティティ・パス・クエリ)の生成だけを任せ、その検証はグラフへの実際の検索に委ねる、という役割分担が徹底されています。Q1 では 4 つの戦略のうち 3 つがうまく機能しませんでしたが、複数の戦略を並走させてどれか 1 つでも正解に届けば回答できる冗長な作りのおかげで、最終回答は正解になりました。LLM の出力を鵜呑みにせず、実データでの検証とフィードバックを前提に組み立てる構想は、RAG に限らず LLM を組み込むシステム全般の参考になりそうだと感じました。

また、Bedrock Knowledge Bases のマネージド GraphRAG と byokg-rag の使い分けは、「正確なグラフ資産を持っているか」が判断基準になると思います。文書しかない場合は前者、構造化済みのデータをグラフ化できる場合は後者が候補になります。これは #2 で整理した判断基準そのものですが、実際に両方を動かした上でも同じ結論になりました。

次回?この仕組みを Amazon Bedrock AgentCore 経由でエージェントのツールとして使わせるところまで検証してみたいと思います。

最後まで読んでいただきありがとうございました!

参考文献

この記事をシェアする

関連記事