🐵

LLMでテキストからグラフ構造を抽出する(LLMGraphTransformer)

に公開

はじめに

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

本記事では、LangChainのLLMGraphTransformerを参考に、LLMを使ってテキスト情報からグラフ構造を抽出する方法について紹介します。

環境

この記事では、以下のような環境で行っています。

  • Python: 3.12
  • openai>=1.0.0
  • langchain-openai>=0.2.0
  • langchain-experimental>=0.0.60

LLMGraphTransformerによるグラフ構造の抽出

LLMGraphTransformerについて

まずは、langchain_experimental パッケージにある LLMGraphTransformer を使ってグラフの抽出を行います。

※ langchain_experimental は名前の通り実験用のコードのため、本番運用などは想定されていません。利用時はよく注意してください。

LLMGraphTransformerは、LLMを使って特定のテキストをグラフ構造に変換するためのクラスです。簡単に言うと、Documentとして表現されているテキストを、LLMを使ってNodeとRelationshipに変換してくれます。

実装サンプル

今回は、桃太郎の童話を例文として、次のようなサンプルコードで動作を確認します。

from dotenv import load_dotenv
from langchain_core.documents import Document
from langchain_experimental.graph_transformers import LLMGraphTransformer
from langchain_openai import ChatOpenAI

load_dotenv()


def main() -> None:
    # グラフ変換で使うLLMの初期化
    llm = ChatOpenAI(
        model="gpt-5-nano",
        temperature=0,
        extra_body={"reasoning_effort": "minimal"},
    )

    graph_transformer = LLMGraphTransformer(
        llm=llm
    )

    # サンプルテキスト
    text = """
    昔々、ある村におじいさんとおばあさんが住んでいました。
    おじいさんは山へ芝刈りに、おばあさんは川へ洗濯に行きました。
    おばあさんが川で洗濯をしていると、大きな桃が流れてきました。
    おばあさんがその桃を持ち帰ると、中から元気な男の子が生まれました。
    おじいさんとおばあさんは、その子を桃太郎と名付けて大切に育てました。
    桃太郎は大きくなると、鬼ヶ島に住む鬼を退治しに行くことにしました。
    旅の途中、桃太郎は犬と出会い、きびだんごを与えて仲間にしました。
    その後、猿と雉も同じようにきびだんごをもらって桃太郎の仲間になりました。
    桃太郎と仲間たちは鬼ヶ島に到着し、鬼と戦って勝利しました。
    鬼は桃太郎たちに宝物を差し出し、二度と悪さをしないと約束しました。
    桃太郎は村に帰り、おじいさんとおばあさんに宝物を分けました。
    """

    # Documentオブジェクトに変換
    documents = [Document(page_content=text)]

    print("=== 入力テキスト ===")
    print(text)
    print("\n=== グラフ変換開始 ===")

    # LLMGraphTransformerでグラフ構造を抽出
    graph_documents = graph_transformer.convert_to_graph_documents(documents)

    print("\n=== 変換結果 ===")
    print(f"抽出されたグラフドキュメント数: {len(graph_documents)}")

    # 抽出されたノードとリレーションシップを表示
    for i, graph_doc in enumerate(graph_documents):
        print(f"\n--- グラフドキュメント {i + 1} ---")
        print(f"ノード数: {len(graph_doc.nodes)}")
        print(f"リレーションシップ数: {len(graph_doc.relationships)}")

        print("\n【ノード】")
        for node in graph_doc.nodes:
            print(f"  - ID: {node.id}, Type: {node.type}")
            if node.properties:
                print(f"    Properties: {node.properties}")

        print("\n【リレーションシップ】")
        for rel in graph_doc.relationships:
            print(f"  - ({rel.source.id}) --[{rel.type}]--> ({rel.target.id})")
            if rel.properties:
                print(f"    Properties: {rel.properties}")


if __name__ == "__main__":
    main()

LLMGraphTransformerを使ったグラフ構造の抽出は非常に簡単で、次のようにインスタンスを生成し、convert_to_graph_documentsで変換するだけです。

...
graph_transformer = LLMGraphTransformer(
        llm=llm,
        additional_instructions=additional_instructions,
    )
...
graph_transformer.convert_to_graph_documents(documents)

このコードを実行すると次のような出力になります。

uv run python src/document_rag/simple_graph_transformer.py
=== 入力テキスト ===

    昔々、ある村におじいさんとおばあさんが住んでいました。
    おじいさんは山へ芝刈りに、おばあさんは川へ洗濯に行きました。
    おばあさんが川で洗濯をしていると、大きな桃が流れてきました。
    おばあさんがその桃を持ち帰ると、中から元気な男の子が生まれました。
    おじいさんとおばあさんは、その子を桃太郎と名付けて大切に育てました。
    桃太郎は大きくなると、鬼ヶ島に住む鬼を退治しに行くことにしました。
    旅の途中、桃太郎は犬と出会い、きびだんごを与えて仲間にしました。
    その後、猿と雉も同じようにきびだんごをもらって桃太郎の仲間になりました。
    桃太郎と仲間たちは鬼ヶ島に到着し、鬼と戦って勝利しました。
    鬼は桃太郎たちに宝物を差し出し、二度と悪さをしないと約束しました。
    桃太郎は村に帰り、おじいさんとおばあさんに宝物を分けました。


=== グラフ変換開始 ===

=== 変換結果 ===
抽出されたグラフドキュメント数: 1

--- グラフドキュメント 1 ---
ノード数: 9
リレーションシップ数: 17

【ノード】
  - ID: 村, Type: Place
  - ID: おじいさん, Type: Person
  - ID: おばあさん, Type: Person
  - ID: 桃太郎, Type: Person
  - ID: 鬼ヶ島, Type: Place
  - ID: 犬, Type: Person
  - ID: 猿, Type: Person
  - ID: 雉, Type: Person
  - ID: 宝物, Type: Item

【リレーションシップ】
  - (おじいさん) --[GO_TO]--> ()
  - (おばあさん) --[GO_TO]--> ()
  - (おばあさん) --[FIND]--> ()
  - () --[TRANSFORM_TO]--> (桃太郎)
  - (桃太郎) --[GO_TO]--> (鬼ヶ島)
  - (桃太郎) --[MEET]--> ()
  - () --[PARTNER]--> (桃太郎)
  - () --[PARTNER]--> (桃太郎)
  - () --[PARTNER]--> (桃太郎)
  - (桃太郎) --[FIGHT]--> ()
  - () --[FIGHT]--> (桃太郎)
  - () --[OFFER]--> (宝物)
  - (桃太郎) --[SHARE]--> (宝物)
  - (おじいさん) --[RECEIVE_FROM]--> (宝物)
  - (おばあさん) --[RECEIVE_FROM]--> (宝物)
  - (桃太郎) --[GIVE]--> (おじいさん)
  - (桃太郎) --[GIVE]--> (おばあさん)

このままでも、概ね正しく分解できているようですね。

ただし、LLMで変換する関係で、そのまま使うだけでは出力が安定しない場合があります。そのため、LLMGraphTransformerでは、パラメータによって出力形式を指定したり、出力するNodeやRelationshipを限定したりすることができます。

    additional_instructions = """
    NodeのID,Typeは日本語で表現してください。
    RelationshipのTypeは日本語で表現してください。
    """
    allowed_nodes = ["登場人物", "場所", "動物"]
    graph_transformer = LLMGraphTransformer(
        llm=llm,
        allowed_nodes=allowed_nodes,
        additional_instructions=additional_instructions,
    )

このように additional_instructionsallowed_nodes を指定することで、出力をカスタマイズすることができます。

カスタマイズした結果は次のとおりです。

【ノード】
  - ID: おじいさん, Type: 登場人物
  - ID: おばあさん, Type: 登場人物
  - ID: 桃太郎, Type: 登場人物
  - ID: 犬, Type: 登場人物
  - ID: 猿, Type: 登場人物
  - ID: 雉, Type: 登場人物
  - ID: 鬼, Type: 登場人物
  - ID: 村, Type: 場所
  - ID: 山, Type: 場所
  - ID: 川, Type: 場所
  - ID: 鬼ヶ島, Type: 場所
  - ID: 桃, Type: 動物
  - ID: きびだんご, Type: 動物
  - ID: 宝物, Type: 動物

【リレーションシップ】
  - (おじいさん) --[住む]--> (村)
  - (おばあさん) --[住む]--> (村)
  - (おじいさん) --[行く]--> (山)
  - (おばあさん) --[行く]--> (川)
  - (おばあさん) --[見つける]--> (桃)
  - (おばあさん) --[育てる]--> (桃太郎)
  - (おじいさん) --[育てる]--> (桃太郎)
  - (桃太郎) --[行く]--> (鬼ヶ島)
  - (桃太郎) --[仲間にする]--> (犬)
  - (桃太郎) --[与える]--> (きびだんご)
  - (犬) --[仲間になる]--> (桃太郎)
  - (猿) --[受け取る]--> (きびだんご)
  - (雉) --[受け取る]--> (きびだんご)
  - (猿) --[仲間になる]--> (桃太郎)
  - (雉) --[仲間になる]--> (桃太郎)
  - (桃太郎) --[戦う]--> (鬼)
  - (桃太郎) --[勝つ]--> (鬼)
  - (鬼) --[渡す]--> (宝物)
  - (鬼) --[約束する]--> (桃太郎)
  - (桃太郎) --[帰る]--> (村)
  - (桃太郎) --[分ける]--> (宝物)
  - (桃太郎) --[与える]--> (おじいさん)
  - (桃太郎) --[与える]--> (おばあさん)

ノードやリレーションシップの名前が日本語になり、指定したノードタイプのみが使用されるようになりました。
(※ 宝物が「動物」に分類されてしまっていますね。この辺はチューニングが必要なポイントのようです。)

LLMGraphTransformerで行われていること

LLMGraphTransformerで行われていることはシンプルで、入力テキストに対して次のようなプロンプトを使用してノードとリレーションシップを抽出しています。

プロンプトの内容(日本語訳)

# GPT-4 知識グラフ構築ガイドライン

## 1. 概要

あなたは、テキストから構造化情報を抽出し、**知識グラフ(Knowledge Graph)** を構築するために設計された高性能アルゴリズムです。
テキストに明示的に記載された情報を最大限に取り込み、**正確性を損なわない範囲で情報を網羅的に抽出**してください。
ただし、**テキストに書かれていない推測的な情報を追加してはいけません。**

* **ノード(Nodes)** は「実体(エンティティ)」または「概念(コンセプト)」を表します。
* 知識グラフの目的は、**シンプルで明快かつ広く理解できる構造**を実現することです。

---

## 2. ノードのラベル付け(Labeling Nodes)

### 一貫性の維持

* ノードラベルには、**常に基本的で汎用的な型**を使用してください。
  例:ある人物を表す場合は、常にラベルを **"person"** とします。
  "mathematician"(数学者)や "scientist"(科学者)などの特定分野の肩書きをラベルに使ってはいけません。

### ノードID

* **ノードIDに整数(数値)を使わないこと。**
  ノードIDは、人間が読める形の名前またはテキスト中に登場する識別子を使用します。

---

## 3. リレーションシップ(Relationships)

* **リレーションシップ** は、ノード間の関係を表します。
* リレーションシップの型も、**一般的かつ永続的なものを使用**してください。
  例:
  × `"BECAME_PROFESSOR"`(教授になった)
  ○ `"PROFESSOR"`(教授という関係)
* 一時的・特定的な関係を避け、**時代や文脈に左右されない表現**を使います。

---

## 4. 照応解決(Coreference Resolution)

### 実体の一貫性保持

* 同じ実体が複数の名前や代名詞で言及される場合、**最も完全な識別名を使って統一**してください。
  例:
  `"John Doe"`、`"Joe"`、`"he"` が同一人物を指している場合、グラフ上では常に `"John Doe"` を使用します。

### 一貫性の目的

* 知識グラフは一貫性があり、読み手が容易に理解できる構造であることが求められます。
  実体名の統一は、その可読性と意味的整合性を維持するために不可欠です。

---
## 5. 厳格な遵守(Strict Compliance)
上記のルールには**厳密に従ってください。**
違反した場合、処理は即座に終了します。

実際のコード実装は、こちらで確認できます。

LLMGraphTransformerを使わずに実装する

LLMGraphTransformerの仕組みを理解したところで、ここからは同様の機能を独自に実装してみます。

データモデルの定義

まず、グラフ構造を表現するためのデータモデルをPydanticで定義します。

from pydantic import BaseModel, Field

class Node(BaseModel):
    """グラフのノードを表すクラス"""
    id: str = Field(description="ノードの一意な識別子")
    type: str = Field(description="ノードの型")
    model_config = {"extra": "forbid"}

class Relationship(BaseModel):
    """グラフのリレーションシップを表すクラス"""
    source: str = Field(description="リレーションシップの始点ノードID")
    target: str = Field(description="リレーションシップの終点ノードID")
    type: str = Field(description="リレーションシップの型")
    model_config = {"extra": "forbid"}

class GraphData(BaseModel):
    """グラフデータを表すクラス(StructuredOutput用)"""
    nodes: list[Node] = Field(default_factory=list, description="抽出されたノードのリスト")
    relationships: list[Relationship] = Field(
        default_factory=list, description="抽出されたリレーションシップのリスト"
    )

LLMGraphTransformerと同様に、NodeRelationshipGraphDataの3つのクラスを定義します。これらのクラスは、LLMからの出力を構造化して受け取るために使用します。

プロンプトの定義

次に、LLMに知識グラフを構築させるためのプロンプトを定義します。こちらはLLMGraphTransformerで使用されているプロンプトを日本語化して使用します。

system_prompt = """
# 知識グラフ構築ガイドライン

## 1. 概要
あなたは、テキストから構造化情報を抽出し、**知識グラフ(Knowledge Graph)** を構築するために設計された高性能アルゴリズムです。
テキストに明示的に記載された情報を最大限に取り込み、**正確性を損なわない範囲で情報を網羅的に抽出**してください。

## 2. ノードのラベル付け
* ノードラベルには、**常に基本的で汎用的な型**を使用してください。

## 3. リレーションシップ
* **リレーションシップ** は、ノード間の関係を表します。
* リレーションシップの型も、**一般的かつ永続的なものを使用**してください。

## 4. 照応解決
* 同じ実体が複数の名前や代名詞で言及される場合、**最も完全な識別名を使って統一**してください。
"""

このプロンプトでは、LLMに対して知識グラフ構築のガイドラインを提示しています。ノードやリレーションシップをどのように抽出すべきかを具体的に指示する内容となっています。

Structured Outputの活用

LangChainの with_structured_output メソッドを使用することで、LLMからの出力を先ほど定義したPydanticモデルの形式で受け取ることができます。

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

prompt = ChatPromptTemplate.from_messages([
    ("system", system_prompt),
    ("user", user_template),
])

structured_llm = llm.with_structured_output(GraphData)
chain = prompt | structured_llm

# グラフ構造を抽出
result: GraphData = cast(GraphData, chain.invoke({"text": text}))

with_structured_output(GraphData) により、LLMの出力は自動的に GraphData 型にパースされます。これにより型安全性が保たれ、バリデーションやエラーハンドリングも容易になります。

実際のコード

これらを組み合わせた実装例は以下のとおりです。

コード全文
from __future__ import annotations

from typing import cast

from dotenv import load_dotenv
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import ChatOpenAI
from pydantic import BaseModel, Field

load_dotenv()


class Node(BaseModel):
    """グラフのノードを表すクラス"""

    id: str = Field(description="ノードの一意な識別子")
    type: str = Field(description="ノードの型")

    model_config = {"extra": "forbid"}


class Relationship(BaseModel):
    """グラフのリレーションシップを表すクラス"""

    source: str = Field(description="リレーションシップの始点ノードID")
    target: str = Field(description="リレーションシップの終点ノードID")
    type: str = Field(description="リレーションシップの型")

    model_config = {"extra": "forbid"}


class GraphData(BaseModel):
    """グラフデータを表すクラス(StructuredOutput用)"""

    nodes: list[Node] = Field(default_factory=list, description="抽出されたノードのリスト")
    relationships: list[Relationship] = Field(
        default_factory=list, description="抽出されたリレーションシップのリスト"
    )


def main() -> None:
    """メイン処理"""
    # LLMの初期化
    llm = ChatOpenAI(
        model="gpt-5-mini",
        temperature=0,
        extra_body={"reasoning_effort": "minimal"},
    )

    # システムプロンプト
    system_prompt = """
# 知識グラフ構築ガイドライン

## 1. 概要

あなたは、テキストから構造化情報を抽出し、**知識グラフ(Knowledge Graph)** を構築するために設計された高性能アルゴリズムです。
テキストに明示的に記載された情報を最大限に取り込み、**正確性を損なわない範囲で情報を網羅的に抽出**してください。
ただし、**テキストに書かれていない推測的な情報を追加してはいけません。**

* **ノード(Nodes)** は「実体(エンティティ)」または「概念(コンセプト)」を表します。
* 知識グラフの目的は、**シンプルで明快かつ広く理解できる構造**を実現することです。

---

## 2. ノードのラベル付け(Labeling Nodes)

### 一貫性の維持

* ノードラベルには、**常に基本的で汎用的な型**を使用してください。
  例:ある人物を表す場合は、常にラベルを **"人物"** とします。
  "数学者"や "科学者"などの特定分野の肩書きをラベルに使ってはいけません。

### ノードID

* **ノードIDに整数(数値)を使わないこと。**
  ノードIDは、人間が読める形の名前またはテキスト中に登場する識別子を使用します。

---

## 3. リレーションシップ(Relationships)

* **リレーションシップ** は、ノード間の関係を表します。
* リレーションシップの型も、**一般的かつ永続的なものを使用**してください。
  例:
  × `"教授になった"`
  ○ `"教授という関係"`
* 一時的・特定的な関係を避け、**時代や文脈に左右されない表現**を使います。

---

## 4. 照応解決(Coreference Resolution)

### 実体の一貫性保持

* 同じ実体が複数の名前や代名詞で言及される場合、**最も完全な識別名を使って統一**してください。
  例:
  `"桃太郎"`、`"おじいさん"`、`"おばあさん"` が同一人物を指している場合、グラフ上では常に `"桃太郎"` を使用します。

### 一貫性の目的

* 知識グラフは一貫性があり、読み手が容易に理解できる構造であることが求められます。
  実体名の統一は、その可読性と意味的整合性を維持するために不可欠です。

---
## 5. 厳格な遵守(Strict Compliance)
上記のルールには**厳密に従ってください。**
違反した場合、処理は即座に終了します。
"""

    user_template = """以下のテキストからノードとリレーションシップを抽出してください。

    ノードのid、type、リレーションシップのtypeは日本語で表現してください。

テキスト:
{text}
"""

    # プロンプトとStructuredOutputの設定
    prompt = ChatPromptTemplate.from_messages(
        [
            ("system", system_prompt),
            ("user", user_template),
        ]
    )
    structured_llm = llm.with_structured_output(GraphData)
    chain = prompt | structured_llm

    # サンプルテキスト
    text = """
    昔々、ある村におじいさんとおばあさんが住んでいました。
    おじいさんは山へ芝刈りに、おばあさんは川へ洗濯に行きました。
    おばあさんが川で洗濯をしていると、大きな桃が流れてきました。
    おばあさんがその桃を持ち帰ると、中から元気な男の子が生まれました。
    おじいさんとおばあさんは、その子を桃太郎と名付けて大切に育てました。
    桃太郎は大きくなると、鬼ヶ島に住む鬼を退治しに行くことにしました。
    旅の途中、桃太郎は犬と出会い、きびだんごを与えて仲間にしました。
    その後、猿と雉も同じようにきびだんごをもらって桃太郎の仲間になりました。
    桃太郎と仲間たちは鬼ヶ島に到着し、鬼と戦って勝利しました。
    鬼は桃太郎たちに宝物を差し出し、二度と悪さをしないと約束しました。
    桃太郎は村に帰り、おじいさんとおばあさんに宝物を分けました。
    """

    print("=== 入力テキスト ===")
    print(text)
    print("\n=== グラフ変換開始 ===")

    # グラフ構造を抽出
    result: GraphData = cast(GraphData, chain.invoke({"text": text}))

    print("\n=== 変換結果 ===")
    print(f"ノード数: {len(result.nodes)}")
    print(f"リレーションシップ数: {len(result.relationships)}")

    print("\n【ノード】")
    for node in result.nodes:
        print(f"  - ID: {node.id}, Type: {node.type}")

    print("\n【リレーションシップ】")
    for rel in result.relationships:
        print(f"  - ({rel.source}) --[{rel.type}]--> ({rel.target})")


if __name__ == "__main__":
    main()

おわりに

本記事では、LLMを使ってテキストからグラフ構造を抽出する方法を紹介しました。

グラフ構造の抽出には一定のコンテキスト理解が必要ですが、LLMの言語理解能力を活用することで、非構造化データから効率的にグラフを構築できます。

抽出したグラフ構造は、Neo4jなどのグラフデータベースに保存することで、GraphRAGとして活用することができます。GraphRAGの構築時に参考にしていただければ幸いです。

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

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

Timelabテックブログ

Discussion