RAGの限界 ── ベクトル検索では解けない問い
RAGは外部知識をLLMに注入する手法として定着しました。
しかし、ベクトル検索には構造的な弱点があります。
具体例で確認します。
2ホップ推論の壁
社内ドキュメントをRAGで検索するシステムを考えます。
以下の情報がそれぞれ別のドキュメントに記載されているとします。
ドキュメントA: 「佐藤さんは認証基盤チームのリーダーである」
ドキュメントB: 「認証基盤チームはプロジェクトAlphaを担当している」
ドキュメントC: 「プロジェクトAlphaはRustで実装されている」
ここで「佐藤さんはRust案件に関与している可能性があるか?」と質問します。
ベクトル検索は「佐藤」や「Rust」に意味的に近い文書を返します。
しかし、ドキュメントAにはRustの記述がなく、ドキュメントCには佐藤さんの記述がありません。
この2つは意味的に類似しないため、同時に検索結果に現れにくいのです。
人間であれば「佐藤→認証基盤チーム→Alpha→Rust」と関係を辿って推論できます。
ベクトル検索にはこの「関係の連鎖を辿る」能力がありません。
RAGが苦手な質問パターン
| パターン | 質問例 | なぜ難しいか |
|---|---|---|
| 多段推論 | 「AさんはC技術に詳しいか?」 | A→B→Cの間接関係を辿る必要がある |
| 集約 | 「Python経験者は社内に何人いるか?」 | 類似文書の返却では網羅性を保証できない |
| 比較 | 「AチームとBチームで共通する技術は?」 | 2つの検索結果を構造的に突き合わせる必要がある |
| 経路探索 | 「Aさんとのつながりが最も近い人は?」 | グラフ的な最短経路計算が必要 |
これらはすべて「エンティティ間の関係」を構造的に保持していれば解ける問題です。
ここにナレッジグラフの出番があります。
この記事は ナレッジグラフ×生成AI 実践入門(Zenn) を参考にしています。
より詳細な内容はそちらを参照してください。
ナレッジグラフの歴史 ── なぜ今LLMとの組み合わせが注目されるのか
ナレッジグラフは新しい概念ではありません。
LLMとの組み合わせが注目される背景を歴史的な流れで整理します。
セマンティックWebからGoogle Knowledge Graphへ
2001年、Tim Berners-LeeがセマンティックWebを提唱しました。
機械が理解できる構造化データでWebを記述するという構想です。
RDF、OWL、SPARQLといった標準仕様が整備されましたが、普及は限定的でした。
データの構造化コストが高すぎたためです。
転機は2012年のGoogle Knowledge Graphです。
Googleは検索結果の右側にエンティティ情報を表示し始めました。
「Things, not strings」というコンセプトのもと、検索クエリを文字列ではなくエンティティとして解釈する方向へ舵を切りました。
この成功により、大規模なナレッジグラフの実用性が証明されました。
企業での活用と「構築コスト」の壁
Google以降、Meta、Amazon、Microsoftなど大手企業がナレッジグラフを構築しました。
しかし、一般企業への普及は進みませんでした。
最大の障壁は構築コストです。
ナレッジグラフの構築には以下の作業が必要です。
- オントロジー設計(エンティティの型と関係の定義)
- 非構造化テキストからのエンティティ抽出
- エンティティの正規化と曖昧性解消
- 関係の抽出と検証
従来、これらは専門家による手作業か、精度の限られたNLPパイプラインに依存していました。
LLMが「構築コスト」を劇的に下げた
2023年以降、LLMの能力向上がこの状況を変えました。
GPT-4クラスのモデルは、テキストからのエンティティ・関係抽出を高い精度でこなします。
従来数ヶ月かかったグラフ構築が、数時間で実現可能になりました。
同時に、LLMの「ハルシネーション問題」がナレッジグラフの価値を再認識させました。
構造化された知識をLLMの外部記憶として使うことで、回答の根拠を明示できます。
「LLMがグラフを作り、グラフがLLMを支える」という相互補完の関係が成立したのです。
ナレッジグラフの基本概念
ノード、エッジ、プロパティ
ナレッジグラフは3つの要素で構成されます。
| 要素 | 説明 | 例 |
|---|---|---|
| ノード(Node) | エンティティを表す |
田中太郎、株式会社ABC
|
| エッジ(Edge) | ノード間の関係を表す |
所属している、開発した
|
| プロパティ(Property) | ノードやエッジの属性 |
name: "田中太郎"、since: 2020
|
トリプル(主語-述語-目的語)
ナレッジグラフの最小単位はトリプルです。
このトリプルを大量に蓄積することで、知識のネットワークが形成されます。
グラフを辿れば「田中太郎→株式会社ABC→製品X」という間接的な関係も取得できます。
RDFとプロパティグラフの違い
ナレッジグラフの実装方式は主に2つあります。
| 観点 | RDF | プロパティグラフ |
|---|---|---|
| データモデル | トリプル(主語-述語-目的語) | ノードとエッジにプロパティを付与 |
| クエリ言語 | SPARQL | Cypher(Neo4j)、Gremlin |
| 標準化 | W3C標準 | ISO GQL(2024年策定) |
| 適用領域 | オープンデータ、学術、LOD | アプリケーション開発、社内システム |
| 学習コスト | 高い | 比較的低い |
実務でLLMと組み合わせる場合は、プロパティグラフの採用例が多く、中でもNeo4jは学習資料とSDKが豊富なため導入しやすい選択肢です。
CypherクエリがSQLに近く、開発者にとって直感的に扱えることも利点です。
LLMとの統合パターン
ナレッジグラフとLLMの組み合わせ方は大きく3パターンあります。
パターン1: GraphRAG(グラフ検索でコンテキスト取得)
通常のRAGではベクトル検索でドキュメントチャンクを取得します。
広義のGraphRAGは、グラフ構造を活用してエンティティとその関係を取得し、LLMのコンテキストとして渡す手法全般を指します。
一方、後述する「Microsoft GraphRAG」は、コミュニティ検出と階層的要約を組み合わせた特定の実装です。
本記事では、特に断りがない限り広義のGraphRAGを指します。
冒頭の「佐藤さんはRust案件に関与している可能性があるか?」という質問であれば、GraphRAGは以下のように動作します。
- 質問から「佐藤」を抽出
- グラフ上で佐藤ノードから2ホップ先まで走査
- 「佐藤→認証基盤チーム→Alpha→Rust」の経路を発見
- この関係情報をLLMに渡して回答を生成
パターン2: LLMによるグラフ構築
非構造化テキストからLLMを使ってエンティティと関係を自動抽出し、グラフを構築します。
手動でのグラフ構築は膨大なコストがかかるため、LLMによる自動化は実用上の大きな進歩です。
パターン3: ハイブリッド検索(ベクトル+グラフ)
ベクトル検索とグラフ検索を組み合わせるパターンです。
意味的類似性による検索と、構造化された関係性の走査を両方活用します。
ハイブリッド検索は精度向上が期待できますが、2つの検索結果のマージロジックが複雑になります。
まずはパターン1のGraphRAG単体で検証し、必要に応じてハイブリッド化を検討するのが現実的です。
Microsoft GraphRAGの仕組み
2024年に発表されたMicrosoftの論文「From Local to Global」は、GraphRAGの具体的な手法を提案しました。
従来の単純なグラフ走査とは異なるアプローチを取っています。
従来手法の課題
単純なGraphRAGには2つの課題があります。
- 局所検索の限界: エンティティ起点の走査では、質問に直接登場するエンティティの周辺しか見えない
- 大域的な質問への対応: 「このデータセット全体のテーマは何か?」のような要約的な質問に答えられない
コミュニティ検出 → 要約 → 階層的検索
Microsoft GraphRAGは以下の手順で動作します。
Step 1: テキストからグラフを構築する
ソースドキュメントをチャンクに分割し、LLMで各チャンクからエンティティと関係を抽出します。
抽出されたトリプルを統合し、1つの大きなグラフを構築します。
Step 2: コミュニティを検出する
構築したグラフに対してLeidenアルゴリズムを適用します。
密に接続されたノード群を「コミュニティ」として自動的にグルーピングします。
コミュニティは階層構造を持ちます。
レベル0(最も細かい): [佐藤, 認証基盤チーム, Alpha] [田中, 検索チーム, Beta]
レベル1(中間): [認証・検索部門全体]
レベル2(最も粗い): [エンジニアリング本部]
Step 3: コミュニティごとに要約を生成する
各コミュニティについて、LLMが要約テキストを生成します。
この要約が、大域的な質問に答えるための「地図」になります。
Step 4: クエリ時に階層を使い分ける
局所的な質問(例: 佐藤さんの担当技術は?)
→ エンティティ起点のグラフ走査(Level 0)
大域的な質問(例: 組織全体の技術スタックの傾向は?)
→ 上位コミュニティの要約を参照(Level 1-2)
Microsoft GraphRAGのコードはOSSとして公開されています。
ただし、グラフ構築時にLLMを大量に呼び出すため、APIコストに注意が必要です。
1万トークンのドキュメントでも数百回のLLM呼び出しが発生することがあります。
実装例: 技術ブログからナレッジグラフを構築する
ここからは実践的な例として、技術ブログの記事群からナレッジグラフを自動構築し、「Reactに詳しい著者は誰か?」のような関係性の質問に答えるシステムを実装します。
環境構築
Neo4jはDockerで起動するのが最も手軽です。
# Neo4jの起動
docker run \
--name neo4j-graphrag \
-p 7474:7474 -p 7687:7687 \
-e NEO4J_AUTH=neo4j/password123 \
-e NEO4J_PLUGINS='["apoc"]' \
-d neo4j:5.26
# Pythonパッケージのインストール
pip install neo4j langchain langchain-openai pydantic
Neo4jのブラウザUI(http://localhost:7474)で接続を確認できます。
ステップ1: データモデルの定義
まず、抽出するエンティティと関係の型をPydanticで定義します。
スキーマを明示することで、LLMの抽出精度が向上します。
from pydantic import BaseModel
class Triple(BaseModel):
"""ナレッジグラフのトリプル(主語-述語-目的語)"""
subject: str
subject_type: str # Author, Article, Technology, Topic
predicate: str # WROTE, USES_TECH, COVERS_TOPIC, DEPENDS_ON
object: str
object_type: str
class ExtractionResult(BaseModel):
"""1つの記事から抽出されたトリプル群"""
triples: list[Triple]
ステップ2: 記事からのエンティティ・関係の自動抽出
LLMにスキーマ情報とともに記事テキストを渡し、構造化データを抽出します。
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
# LLMの出力をPydanticモデルで構造化する
structured_llm = llm.with_structured_output(ExtractionResult)
EXTRACTION_PROMPT = ChatPromptTemplate.from_messages([
("system", """あなたは技術記事からエンティティと関係を抽出する専門家です。
## 抽出ルール
- エンティティの型: Author, Article, Technology, Topic
- 関係の型: WROTE(著者→記事), USES_TECH(記事→技術),
COVERS_TOPIC(記事→トピック), DEPENDS_ON(技術→技術)
- 技術名は正式名称に正規化する(例: React.js → React)
- 1つの記事から複数のトリプルを抽出してよい"""),
("human", "記事タイトル: {title}\n著者: {author}\n本文:\n{body}")
])
extraction_chain = EXTRACTION_PROMPT | structured_llm
def extract_from_article(
title: str, author: str, body: str
) -> list[Triple]:
"""記事からトリプルを抽出する"""
result: ExtractionResult = extraction_chain.invoke({
"title": title,
"author": author,
"body": body,
})
return result.triples
実際の記事データで試してみます。
# サンプルの技術ブログ記事
articles = [
{
"title": "React Server Componentsで変わるデータ取得パターン",
"author": "山田太郎",
"body": (
"React Server Components(RSC)はNext.js 13以降で"
"利用可能になりました。従来のuseEffectによるクライアント"
"サイドフェッチから、サーバーサイドでの直接的なデータ取得へ"
"移行できます。TypeScriptとの型安全な組み合わせにより、"
"開発体験が大きく向上します。"
),
},
{
"title": "FastAPIとSQLAlchemyで作る型安全なAPI",
"author": "鈴木花子",
"body": (
"PythonのFastAPIフレームワークとSQLAlchemy ORMを"
"組み合わせたREST APIの構築方法を解説します。"
"Pydanticによるバリデーションと型ヒントを活用して、"
"堅牢なバックエンドを実装します。"
),
},
{
"title": "Next.js 14のApp Routerにおけるキャッシュ戦略",
"author": "山田太郎",
"body": (
"Next.js 14ではキャッシュの仕組みが大きく変更されました。"
"fetch関数のキャッシュオプション、Route Segment Config、"
"revalidateTagを使い分ける方法を、React Server Components"
"の文脈で解説します。TypeScriptでの型定義も併せて紹介します。"
),
},
{
"title": "PydanticとFastAPIで実現するOpenAPI駆動開発",
"author": "鈴木花子",
"body": (
"FastAPIが自動生成するOpenAPIスキーマを活用して、"
"フロントエンドとバックエンドの型を同期する方法を紹介します。"
"Pydanticモデルの設計パターンとReactフロントエンドとの"
"連携についても触れます。"
),
},
]
# 全記事からトリプルを抽出
all_triples: list[Triple] = []
for article in articles:
triples = extract_from_article(**article)
all_triples.extend(triples)
print(f"[{article['title']}] → {len(triples)}件のトリプルを抽出")
ステップ3: Neo4jへのグラフ格納
抽出したトリプルをNeo4jに格納します。
APOCプラグインを使い、動的にラベルを設定します。
from neo4j import GraphDatabase
URI = "bolt://localhost:7687"
AUTH = ("neo4j", "password123")
driver = GraphDatabase.driver(URI, auth=AUTH)
def run_query(query: str, params: dict = None) -> list[dict]:
"""Cypherクエリを実行し、結果をdictのリストで返す"""
with driver.session() as session:
result = session.run(query, params or {})
return [record.data() for record in result]
def store_triples(triples: list[Triple]) -> None:
"""抽出したトリプルをNeo4jに格納する"""
for t in triples:
query = """
CALL apoc.merge.node(
[$subject_type], {name: $subject}
) YIELD node AS s
CALL apoc.merge.node(
[$object_type], {name: $object}
) YIELD node AS o
CALL apoc.merge.relationship(
s, $predicate, {}, {}, o
) YIELD rel
RETURN s, rel, o
"""
run_query(query, {
"subject": t.subject,
"subject_type": t.subject_type,
"predicate": t.predicate,
"object": t.object,
"object_type": t.object_type,
})
store_triples(all_triples)
print(f"合計 {len(all_triples)} 件のトリプルを格納しました")
格納後のグラフ構造をCypherで確認します。
// 全ノードと関係を表示
MATCH (n)-[r]->(m)
RETURN n.name, type(r), m.name
LIMIT 30
ステップ4: GraphRAGクエリの実装
グラフ検索の結果をコンテキストとしてLLMに渡す、GraphRAGの中核部分です。
from langchain_core.prompts import ChatPromptTemplate
answer_llm = ChatOpenAI(model="gpt-4o", temperature=0)
def graph_context_search(
question: str, depth: int = 2
) -> str:
"""質問からエンティティを特定し、周辺ノードを取得する"""
# Step 1: LLMで質問からエンティティ名を抽出
entity_prompt = ChatPromptTemplate.from_messages([
("system",
"ユーザーの質問から検索すべき人名・技術名・"
"トピック名を抽出し、カンマ区切りで返してください。"
"それ以外のテキストは出力しないでください。"),
("human", "{question}")
])
entity_chain = entity_prompt | answer_llm
entities_str = entity_chain.invoke(
{"question": question}
).content
entities = [e.strip() for e in entities_str.split(",")]
# Step 2: 各エンティティの周辺を可変深度で走査
all_context = []
for entity in entities:
# 1ホップの関係
query_1hop = """
MATCH (n {name: $name})-[r]-(neighbor)
RETURN n.name AS source,
type(r) AS relation,
neighbor.name AS target,
labels(neighbor)[0] AS target_type
"""
results = run_query(query_1hop, {"name": entity})
for row in results:
all_context.append(
f"{row['source']} --[{row['relation']}]-- "
f"{row['target']} ({row['target_type']})"
)
# 2ホップの関係(間接的なつながり)
if depth >= 2:
query_2hop = """
MATCH (n {name: $name})-[r1]-(mid)-[r2]-(far)
WHERE far <> n
RETURN n.name AS source,
type(r1) AS rel1,
mid.name AS middle,
type(r2) AS rel2,
far.name AS target,
labels(far)[0] AS target_type
LIMIT 30
"""
results = run_query(query_2hop, {"name": entity})
for row in results:
all_context.append(
f"{row['source']} --[{row['rel1']}]-- "
f"{row['middle']} --[{row['rel2']}]-- "
f"{row['target']} ({row['target_type']})"
)
# 重複を除去
return "\n".join(sorted(set(all_context)))
def graphrag_answer(question: str) -> str:
"""GraphRAGで質問に回答する"""
context = graph_context_search(question)
answer_prompt = ChatPromptTemplate.from_messages([
("system", """以下のナレッジグラフの情報を基に質問に回答してください。
回答の根拠となるグラフの経路を明示してください。
グラフに含まれない情報については「情報がありません」と答えてください。
--- ナレッジグラフ情報 ---
{context}
---"""),
("human", "{question}")
])
answer_chain = answer_prompt | answer_llm
response = answer_chain.invoke({
"context": context,
"question": question,
})
return response.content
実行結果
# 2ホップ推論が必要な質問
print(graphrag_answer("Reactに詳しい著者は誰ですか?"))
山田太郎さんがReactに詳しいと考えられます。
根拠:
- 山田太郎 --[WROTE]-- 「React Server Componentsで変わるデータ取得パターン」
- 山田太郎 --[WROTE]-- 「Next.js 14のApp Routerにおけるキャッシュ戦略」
- 上記の記事はいずれも --[USES_TECH]-- React の関係を持っています
また、鈴木花子さんも「PydanticとFastAPIで実現するOpenAPI駆動開発」で
Reactに言及していますが、主たるテーマはバックエンドです。
ベクトル検索だけでは「著者」と「技術」を結びつけることは困難です。
グラフの「WROTE→USES_TECH」という関係の連鎖を辿ることで、初めて回答できます。
# 比較を求める質問
print(graphrag_answer(
"山田太郎と鈴木花子に共通する技術は何ですか?"
))
共通する技術はReactです。
根拠:
- 山田太郎 --[WROTE]-- 記事A/B --[USES_TECH]-- React
- 鈴木花子 --[WROTE]-- 記事D --[USES_TECH]-- React
(記事D「PydanticとFastAPIで実現するOpenAPI駆動開発」でReactフロントエンドとの連携に言及)
なお、TypeScriptは山田太郎の記事でのみ明示されており、
鈴木花子の記事には記載がないため共通技術には含まれません。
パフォーマンスの現実
ナレッジグラフ+LLMの導入を検討する際に知っておくべきコストと制約を整理します。
グラフ構築のコスト
| 工程 | コスト要因 | 目安 |
|---|---|---|
| エンティティ抽出 | LLM API呼び出し | 1記事あたり1-3回(チャンクサイズ依存) |
| 関係抽出 | LLM API呼び出し | エンティティ抽出と同時に可能 |
| グラフ格納 | Neo4jの書き込み | 1000トリプルで数秒(バッチ処理) |
| 全体 | API費用 | 1000記事で約$5-15(gpt-4o-mini使用時、下記注参照) |
上記のコスト目安は、2026年3月時点の一例です。gpt-4o-miniの入力$0.15/1Mトークン・出力$0.60/1Mトークンを前提に、1記事あたり平均2000トークン・抽出1-3回で試算しています。モデルや記事の長さにより大きく変動します。
Microsoft GraphRAGのようにコミュニティ要約まで行う場合、LLM呼び出し回数は大幅に増えます。
1000記事に対してGraphRAGフルパイプラインを実行すると、$50-100程度になることがあります。
初回構築は大量の記事を一括処理するため、最もコストがかかるフェーズです。
クエリのレイテンシ
| 処理 | 所要時間の目安 |
|---|---|
| エンティティ抽出(LLM) | 200-500ms |
| グラフ走査(Neo4j、2ホップ) | 5-50ms |
| 回答生成(LLM) | 500-2000ms |
| 合計 | 700ms-2.5秒 |
通常のRAG(ベクトル検索+LLM)と比較すると、エンティティ抽出のLLM呼び出しが追加される分、200-500msほど遅くなります。
グラフ走査自体はベクトル検索より高速なケースが多いため、全体のレイテンシ差は限定的です。
スケーラビリティの課題
| ノード数 | 現実的な運用 |
|---|---|
| 数百 | NetworkX(インメモリ)で十分 |
| 数千-数万 | Neo4j Community Editionで対応可能 |
| 数十万以上 | インデックス設計、クエリ最適化が必要 |
| 数百万以上 | Neo4j Enterprise、シャーディング検討 |
グラフの成長に伴い、以下の問題が顕在化します。
- エンティティの重複: 「React」「React.js」「ReactJS」が別ノードになる
- 関係の陳腐化: 人事異動や技術変更が反映されない
- クエリの爆発: 2ホップ走査で返るノード数が膨大になる
これらに対処するには、定期的なグラフのメンテナンス(正規化、プルーニング、再構築)が必要です。
この運用コストを見落とすと、グラフの品質が劣化し、回答精度が低下します。
適用判断フローチャート
「自分のユースケースにナレッジグラフは必要か?」を判断するためのフローです。
Q1. 回答に複数エンティティ間の「関係」を辿る必要があるか?
│
├─ No → 通常のRAG(ベクトル検索)で十分
│ 例: FAQ検索、文書要約、類似文書検索
│
└─ Yes
│
Q2. エンティティと関係の型を事前に定義できるか?
│
├─ No → ベクトル検索 + メタデータフィルタを検討
│ 例: 自由記述のチャットボット、雑多な質問対応
│
└─ Yes
│
Q3. データの更新頻度は?
│
├─ 高頻度(日次以上)→ グラフの自動更新パイプラインが必要
│ 構築・運用コストが高いため、ROIを慎重に検討
│
└─ 低〜中頻度(週次以下)
│
Q4. ノード数の想定規模は?
│
├─ 数百以下 → NetworkX等のインメモリグラフで十分
│ Neo4jのような外部DBは不要
│
└─ 数千以上 → Neo4j + GraphRAGの導入を推奨
初期は小さなスコープで検証してから拡大する
「とりあえずナレッジグラフを入れてみよう」は避けてください。
グラフの構築・維持には継続的なコストがかかります。
まず通常のRAGで検証し、「関係を辿る質問に答えられない」という具体的な課題が出てから導入を検討するのが現実的です。
まとめ
ナレッジグラフ×LLMのポイントを整理します。
- ベクトル検索(通常のRAG)は「意味的類似性」は得意だが、「2ホップ以上の関係推論」は構造的に苦手
- ナレッジグラフはエンティティ間の構造化された関係を保持し、多段推論を可能にする
- LLMの登場により、グラフ構築のコストが劇的に下がった。これが今注目される理由
- Microsoft GraphRAGはコミュニティ検出と階層的検索により、局所・大域両方の質問に対応する
- LLMとの統合パターンは3つ: GraphRAG、LLMによるグラフ構築、ハイブリッド検索
- 導入にはグラフの構築・維持コストが伴う。適用判断を慎重に行い、小さなスコープから始める
参考資料
- ナレッジグラフ×生成AI 実践入門(Zenn) -- 本記事の主要参考書籍
- Neo4j 公式ドキュメント -- Cypherクエリの詳細リファレンス
- LangChain Neo4j Integration -- LangChainからNeo4jを扱う方法
- Microsoft GraphRAG -- MicrosoftによるGraphRAGのOSS実装
- From Local to Global: A Graph RAG Approach to Query-Focused Summarization -- GraphRAGの原論文
- Google Knowledge Graph Search API -- Google Knowledge Graphの概要