LayerX エンジニアブログ

LayerX の エンジニアブログです。

【Agent Memory】Graphitiで法文書のインデックスを構築する

こんにちは!LayerX Ai Workforce事業部FDEの恩田(さいぺ)です。

この記事はLayerX AI Agent ブログリレー20日目の記事になります。昨日は澁井さんのLLMの出力構造を推論して自動的に構造化するでした。LLMの不確実性は本番システムを構築する上で避けて通れない課題なので、動的にStructured Outputを生成してくれるのはとても嬉しいですね。

さて、このブログリレーも早20日目を迎えたわけですが、皆さん、初日のponさんのTemporal Knowledge Graphで作る!時間変化するナレッジを扱うAI Agentの世界は覚えていらっしゃいますでしょうか。私もAgent Memoryはいろいろ論文も読んでいて、GitHubでも各フレームワークの実装を読んだりしており、その中でもponさんが紹介していたZep(Graphiti)は個人的にも注目していたので、今回はGraphitiを触ってみた結果を共有します。

ちなみにAgent Memoryの全体像を掴む上では、Rethinking Memory in AI: Taxonomy, Operations, Topics, and Future Directions という論文がとてもよくまとまっています。ぜひここから孫引きをしてみてください。

arxiv.org

Graphitiとは

GraphitiはTemporal Knowledge Graphと呼ばれる時間変化を考慮したKnowledge Graphを構築するためのフレームワークです。詳細の説明はponさんのブログをご覧ください。

tech.layerx.co.jp

最近のZep関連のニュースではAmazon Neptuneにも統合されたようです。

aws.amazon.com

実際にGraphitiを動かしてみる

今回はGraphiti+neo4jをdockerで立てて動かしてみます。 なお、現時点ではAzure OpenAIのコードは互換性が保たれていないため、ローカルで修正を施しています。 手元では動作しているので、余裕があればPull Requestを作ろうと思います。

version: '3.8'

services:
    graph:
        image: zepai/graphiti:latest
        ports:
            - "8000:8000"
        environment:
            - AZURE_OPENAI_ENDPOINT=${AZURE_OPENAI_ENDPOINT}
            - AZURE_OPENAI_API_VERSION=2025-04-01-preview
            - AZURE_OPENAI_DEPLOYMENT_NAME=gpt-4.1
            - OPENAI_API_KEY=${OPENAI_API_KEY}
            - NEO4J_URI=bolt://localhost:7687
            - NEO4J_USER=neo4j
            - NEO4J_PASSWORD=${NEO4J_PASSWORD}

    neo4j:
        image: neo4j:5.26.0
        ports:
            - "7474:7474"  # HTTP
            - "7687:7687"  # Bolt
        volumes:
            - neo4j_data:/data
        environment:
            - NEO4J_AUTH=neo4j/${NEO4J_PASSWORD}

volumes:
    neo4j_data:

実験1: 挙動の確認

Graphiti.build_indices_and_constraints メソッドを使って、グラフを構築すると、以下のように3つのグラフが構築されていることがわかります。

neo4j$ call db.labels();
"Episodic"
"Entity"
"Community"

続いて、Graphiti.add_episode メソッドを使って、episodeを追加していきます。

当事業部で開発しているAi Workforceでは、契約書などの文書を扱うことがあります。今回は契約書の代わりにサンプルデータとして個人情報保護法の法律文書を用います。このサンプルを選んだ理由としては、契約書などの文書は数百ページを有に超えることも多く、context windowが大きくなった現在でも、適切なサイズに文書を分割したいことがあるためです。さらに、文書を分割した際に発生する問題として、第5章のテキストに「第3章の〜〜」といった記述があり、章を跨った参照が発生します。そのため、文書全体のインデックスを構築したいモチベーションがあり、法律文書にも同様の性質があると考え、選定しました。

まず、実験1では、第二条第一項、第二項を号単位に分割したものを1エピソードとして入力します。

[
"第二条 1 この法律において「個人情報」とは、生存する個人に関する情報であって、次の各号のいずれかに該当するものをいう。",
"第二条 1 一 当該情報に含まれる氏名、生年月日その他の記述等(文書、図画若しくは電磁的記録(電磁的方式(電子的方式、磁気的方式その他人の知覚によっては認識することができない方式をいう。次項第二号において同じ。)で作られる記録をいう。以下同じ。)に記載され、若しくは記録され、又は音声、動作その他の方法を用いて表された一切の事項(個人識別符号を除く。)をいう。以下同じ。)により特定の個人を識別することができるもの(他の情報と容易に照合することができ、それにより特定の個人を識別することができることとなるものを含む。)",
"第二条 1 二 個人識別符号が含まれるもの",
"第二条 2 この法律において「個人識別符号」とは、次の各号のいずれかに該当する文字、番号、記号その他の符号のうち、政令で定めるものをいう。",
"第二条 2 一 特定の個人の身体の一部の特徴を電子計算機の用に供するために変換した文字、番号、記号その他の符号であって、当該特定の個人を識別することができるもの",
"第二条 2 二 個人に提供される役務の利用若しくは個人に販売される商品の購入に関し割り当てられ、又は個人に発行されるカードその他の書類に記載され、若しくは電磁的方式により記録された文字、番号、記号その他の符号であって、その利用者若しくは購入者又は発行を受ける者ごとに異なるものとなるように割り当てられ、又は記載され、若しくは記録されることにより、特定の利用者若しくは購入者又は発行を受ける者を識別することができるもの"
]

Entityを確認すると、以下のような28個のEntityが抽出されました。

個人情報
生存する個人
この法律
当該情報
氏名
生年月日
文書
図画
電磁的記録
電磁的方式
電子的方式
磁気的方式
記載
記録
音声
動作
個人識別符号
他の情報
第二条
文字
番号
記号
符号
政令
個人
利用者
購入者
発行を受ける者

Agent MemoryのKnowledge Graphを構築するにあたって、Entityの抽出は肝です。上記の結果では「この法律」や「他の情報」などの指示代名詞が存在し、グラフが入力テキストの増大に伴って過度に大きくなってしまいそうです。また、Zepの原論文では、チャット形式のアプリケーションを念頭としており、 Graphiti.add_episode 内のextract_textthe speaker and other significant entitiesを抽出・分類するタスクであり、ガイドラインにも mentioned in the conversation と指示されているなど、やはり会話が前提です。

もともとのモチベーションとしては文書全体のインデックスをKnowledge Graphとして構築したいというものだったので、次の実験ではEntityを条文番号とするチューニングを行ってみます。

実験2: 条文番号の抽出

前述の通り、Zepはチャットアプリケーションを対象としているため、extract_text関数を以下のように直接書き換えて検証しました。また、指示代名詞の課題を解決するために、TEXT_EXAMPLETHOUGH PROCESS for TEXT_EXAMPLEでどのように条文番号を抽出すればよいかを示しています。

def extract_text(context: dict[str, Any]) -> list[Message]:
    sys_prompt = """You are an AI assistant that extracts entity nodes from a law text. 
    Your primary task is to extract the number of 条, 項 or 号 mentioned in the law text as a ENTITY."""

    user_prompt = f"""
<ENTITY TYPES>
{context['entity_types']}
</ENTITY TYPES>

<TEXT>
{context['episode_content']}
</TEXT>

Given the above law text, extract the 条, 項 or 号 as entities from the TEXT.
For each entity extracted, also determine its entity type based on the provided ENTITY TYPES and their descriptions.
Indicate the classified entity type by providing its entity_type_id.

{context['custom_prompt']}

## Guidelines
1. Find relative expression like 「前項」、「次の号」、「次項」, and resolve it as absolute expression like 第一条二項三号.
2. Extract ALL the numbers of 条, 項 or 号 as entities from the TEXT.
3. Rule of numbering for 条, 項 or 号:
    - The head of TEXT indicated 第何条, you should recoginze what you are in 第何条.
    - The 項 is given in the form of Arabic numerals like 2, 3, and so on, but 第1項 is omitted (you should infer it).
    - The 号 is given as a form of 漢数字 like 一, 二, 三 and so on.
4. Do not omit sections or clauses, include numbers from the law to the clause. ex: 第一条二項三号
5. The form of entity name should be either of following:
    - 「第x条x項x号」
    - 「第x条x項」
    - 「第x条」
    - 「第x条x号」
6. The format 「第x条x項x号」 and 「第x条x号」 cannot exist simultaneously. 「第x条x項」 is used only when there is no 項 (and only the first 項 exists).

## Example of Guidelines:

<TEXT_EXAMPLE>
第七十六条 何人も、この法律の定めるところにより、行政機関の長等に対し、当該行政機関の長等の属する行政機関等の保有する自己を本人とする保有個人情報の開示を請求することができる。
2 未成年者若しくは成年被後見人の法定代理人又は本人の委任による代理人(以下この節において「代理人」と総称する。)は、本人に代わって前項の規定による開示の請求(以下この節及び第百二十七条において「開示請求」という。)をすることができる。
</TEXT_EXAMPLE>

THOUGH PROCESS for TEXT_EXAMPLE:
1. The head of TEXT indicated this is 第七十六条.
2. The head of second line begin with `2` (Arabic numerals), so 一項 would be omitted. The full text of 第七十六条第一項 is 「第七十六条第一項 何人も、この法律の定めるところにより、行政機関の長等に対し、当該行政機関の長等の属する行政機関等の保有する自己を本人とする保有個人情報の開示を請求することができる。」
3. We found 「前項の規定」, which is relative expression and this text appeard in 第七十六条第二項. so 「前項」 means 「第七十六条一項」.
4. Eventually, the entities are `第七十六条, `第七十六条第一項` and `第七十六条第二項`

Another Example of Guidelines:

<TEXT_EXAMPLE>
第九十八条 何人も、自己を本人とする保有個人情報が次の各号のいずれかに該当すると思料するときは、この法律の定めるところにより、当該保有個人情報を保有する行政機関の長等に対し、当該各号に定める措置を請求することができる。ただし、当該保有個人情報の利用の停止、消去又は提供の停止(以下この節において「利用停止」という。)に関して他の法令の規定により特別の手続が定められているときは、この限りでない。
一 第六十一条第二項の規定に違反して保有されているとき、第六十三条の規定に違反して取り扱われているとき、第六十四条の規定に違反して取得されたものであるとき、又は第六十九条第一項及び第二項の規定に違反して利用されているとき 当該保有個人情報の利用の停止又は消去
二 第六十九条第一項及び第二項又は第七十一条第一項の規定に違反して提供されているとき 当該保有個人情報の提供の停止
2 代理人は、本人に代わって前項の規定による利用停止の請求(以下この節及び第百二十七条において「利用停止請求」という。)をすることができる。
3 利用停止請求は、保有個人情報の開示を受けた日から九十日以内にしなければならない。
</TEXT_EXAMPLE>

THOUGH PROCESS for TEXT_EXAMPLE:
1. The head of TEXT indicated this is 第九十八条.
2. We found 「次の各号」 and 「当該各号」, these are used in same sentence, so these indicate same 号s.
3. The head of second line begin with `一` (漢数字), and we are in 第九十八条, so this is 第九十八条第一項第一号.
4. The head of third line begin with `二` (漢数字), and we are in 第九十八条, so this is 第九十八条第一項第二号.
5. We cannot find `三` at the head of any lines, so 「次の各号」 (also 「当該各号」) means 第九十八条第一項第一号 and 第九十八条第一項第二号.
6. The second line contains 「第六十九条第一項及び第二項」, which means 第六十九条第一項 and 第六十九条第二項.
7. We found `2` (Arabic numerals), so 二項 would be omitted. The full text of 第九十八条第二項 is 「第九十八条第二項 代理人は、本人に代わって前項の規定による利用停止の請求(以下この節及び第百二十七条において「利用停止請求」という。)をすることができる。」
8. We found `前項の規定`, which is relative expression and this text appeard in 第九十八条第二項. so 「前項」 means 「第九十八条一項」.
9. We found `3` (Arabic numerals), so 三項 would be omitted. The full text of 第九十八条第三項 is 「第九十八条第三項 利用停止請求は、保有個人情報の開示を受けた日から九十日以内にしなければならない。」
10. Eventually, the entities are `第九十八条`, `第九十八条第一項第一号`, `第九十八条第一項第二号`, `第九十八条第一項`, `第九十八条第二項`, `第九十八条第三項`, `第六十九条第一項`, `第六十九条第二項`, `第六十一条第二項`, `第六十三条`, `第六十四条`, `第七十一条第一項`.

"""
    return [
        Message(role='system', content=sys_prompt),
        Message(role='user', content=user_prompt),
    ]

入力テキストとしては、以下の第二条の第一項と第二項の原文を利用します。

第二条 この法律において「個人情報」とは、生存する個人に関する情報であって、次の各号のいずれかに該当するものをいう。
一 当該情報に含まれる氏名、生年月日その他の記述等(文書、図画若しくは電磁的記録(電磁的方式(電子的方式、磁気的方式その他人の知覚によっては認識することができない方式をいう。次項第二号において同じ。)で作られる記録をいう。以下同じ。)に記載され、若しくは記録され、又は音声、動作その他の方法を用いて表された一切の事項(個人識別符号を除く。)をいう。以下同じ。)により特定の個人を識別することができるもの(他の情報と容易に照合することができ、それにより特定の個人を識別することができることとなるものを含む。)
二 個人識別符号が含まれるもの
2 この法律において「個人識別符号」とは、次の各号のいずれかに該当する文字、番号、記号その他の符号のうち、政令で定めるものをいう。
一 特定の個人の身体の一部の特徴を電子計算機の用に供するために変換した文字、番号、記号その他の符号であって、当該特定の個人を識別することができるもの
二 個人に提供される役務の利用若しくは個人に販売される商品の購入に関し割り当てられ、又は個人に発行されるカードその他の書類に記載され、若しくは電磁的方式により記録された文字、番号、記号その他の符号であって、その利用者若しくは購入者又は発行を受ける者ごとに異なるものとなるように割り当てられ、又は記載され、若しくは記録されることにより、特定の利用者若しくは購入者又は発行を受ける者を識別することができるもの

この結果、以下のようなEntityが抽出されました。プロンプトのチューニングに少し苦労したのですが、指示代名詞もなく、条・項・号のすべてが取得できています。

第二条
第二条第一項
第二条第一項第一号
第二条第一項第二号
第二条第二項
第二条第二項第一号
第二条第二項第二号

実験3: エッジの抽出

実は実験2の入力テキストを第二条の第一項と第二項としたのには理由があります。項をまたいだ第二条第一項第一号 - [RELATES_TO] - 第二条第二項第二号という参照を正しく抽出させたかったのです。 エッジの抽出はedge関数で定義されているため、これを以下のように書き換えました。

こちらも指示代名詞の明確化と条や項を跨いだ参照を正しく抽出させるためにプロンプトをチューニングしています。

def edge(context: dict[str, Any]) -> list[Message]:
    return [
        Message(
            role='system',
            content='You are an expert fact extractor that extracts fact triples from text. '
            '1. Extracted fact triples should also be extracted with relevant date information, especially explicitly mentioned in the law text.'
            '2. Treat the CURRENT TIME as the time the CURRENT MESSAGE was sent. All temporal information should be extracted relative to this time.',
        ),
        Message(
            role='user',
            content=f"""
<FACT TYPES>
{context['edge_types']}
</FACT TYPES>

<PREVIOUS_MESSAGES>
{to_prompt_json([ep for ep in context['previous_episodes']], ensure_ascii=context.get('ensure_ascii', False), indent=2)}
</PREVIOUS_MESSAGES>

<CURRENT_MESSAGE>
{context['episode_content']}
</CURRENT_MESSAGE>

<ENTITIES>
{context['nodes']} 
</ENTITIES>

<REFERENCE_TIME>
{context['reference_time']}  # ISO 8601 (UTC); used to resolve relative time mentions
</REFERENCE_TIME>

# TASK
Extract ALL reference relationships between the given ENTITIES(条, 項, 号) based on the CURRENT MESSAGE.
Only extract facts that:
- Find relative expression like 「前項」、「次の号」、「次項」, and resolve it as absolute expression like 第一条二項三号.
- Detect the structure of the text, typically 条, 項 and 号 are structured as a tree.
- involve two DISTINCT ENTITIES from the ENTITIES list, and can be represented as edges in a knowledge graph.
- Facts should include entity names like 第一条二項三号 rather than pronouns whenever possible.
- Don't miss the reference relationship (typically appeared with 「による」「次に」「前項の」「次項において」「次項第一号において」) between 項s or 号s, and resolve absolute expression like 第一条二項三号. 
- Don't miss the reference relationship between 条s or 項s, like 第一条二項三号 - [RELATES_TO] - 第一条三項一号.

You may use information from the PREVIOUS MESSAGES only to disambiguate references or support continuity.

{context['custom_prompt']}

# Example
<CURRENT_MESSAGE_EXAMPLE>
第七十六条 何人も、この法律の定めるところにより、行政機関の長等に対し、当該行政機関の長等の属する行政機関等の保有する自己を本人とする保有個人情報の開示を請求することができる。
2 未成年者若しくは成年被後見人の法定代理人又は本人の委任による代理人(以下この節において「代理人」と総称する。)は、本人に代わって前項の規定による開示の請求(以下この節及び第百二十七条において「開示請求」という。)をすることができる。
</CURRENT_MESSAGE_EXAMPLE>

THOUGH PROCESS for CURRENT_MESSAGE_EXAMPLE:
1. The head of TEXT indicated this is 第七十六条.
2. The head of second line begin with `2` (Arabic numerals), so 一項 would be omitted. The full text of 第七十六条第一項 is 「第七十六条第一項 何人も、この法律の定めるところにより、行政機関の長等に対し、当該行政機関の長等の属する行政機関等の保有する自己を本人とする保有個人情報の開示を請求することができる。」
3. We found 「前項の規定」, which is relative expression and this text appeard in 第七十六条第二項. so 「前項」 means 「第七十六条一項」.
4. Eventually, the relationships are `第七十六条第一項 - [RELATES_TO] - 第七十六条第二項`.


# Another Example
<CURRENT_MESSAGE_EXAMPLE>
第七十七条 開示請求は、次に掲げる事項を記載した書面(第三項において「開示請求書」という。)を行政機関の長等に提出してしなければならない。
一 開示請求をする者の氏名及び住所又は居所
二 開示請求に係る保有個人情報が記録されている行政文書等の名称その他の開示請求に係る保有個人情報を特定するに足りる事項
2 前項の場合において、開示請求をする者は、政令で定めるところにより、開示請求に係る保有個人情報の本人であること(前条第二項の規定による開示請求にあっては、開示請求に係る保有個人情報の本人の代理人であること)を示す書類を提示し、又は提出しなければならない。
</CURRENT_MESSAGE_EXAMPLE>

THOUGH PROCESS for CURRENT_MESSAGE_EXAMPLE:
1. The head of TEXT indicated this is 第七十七条.
2. The head of second line begin with `2` (Arabic numerals), so 一項 would be omitted. The full text of 第七十七条第一項 is 「第七十七条第一項 開示請求は、次に掲げる事項を記載した書面(第七十七条第三項において「開示請求書」という。)を行政機関の長等に提出してしなければならない。」. We got a relationship `第七十七条第一項 - [RELATES_TO] - 第七十七条第三項`.
3. We found 「次に掲げる事項」, which is relative expression and this text appeard in 第七十七条第一項. And before we found `2` (第七十七条第二項), we can find 「一」 (第七十七条第一項第一号) and 「二」 (第七十七条第一項第二号). We got relationships `第七十七条第一項 - [RELATES_TO] - 第七十七条第一項第一号` and `第七十七条第一項 - [RELATES_TO] - 第七十七条第一項第二号`.
4. We found 「前項の場合」, which is relative expression and this text appeard in 第七十七条第二項. so 「前項」 means 「第七十七条一項」. We got a relationship `第七十七条第二項 - [RELATES_TO] - 第七十七条第一項`.
5. We found 「前条第二項の規定」, which is relative expression and this text appeard in 第七十七条. so 「前条第二項」 means 「第七十六条第二項」. We got a relationship `第七十七条第二項 - [RELATES_TO] - 第七十六条第二項`.
6. 第七十七条 have 第七十七条第一項, 第七十七条第二項 and 第七十七条第三項, so we got relationships `第七十七条 - [RELATES_TO] - 第七十七条第一項`, `第七十七条 - [RELATES_TO] - 第七十七条第二項` and `第七十七条 - [RELATES_TO] - 第七十七条第三項`.
7. Eventually, the relationships are followings:
  - `第七十七条 - [RELATES_TO] - 第七十七条第一項`
  - `第七十七条 - [RELATES_TO] - 第七十七条第二項`
  - `第七十七条 - [RELATES_TO] - 第七十七条第三項`
  - `第七十七条第一項 - [RELATES_TO] - 第七十七条第三項`
  - `第七十七条第二項 - [RELATES_TO] - 第七十七条第一項`
  - `第七十七条第二項 - [RELATES_TO] - 第七十六条第二項`
  - `第七十七条第一項 - [RELATES_TO] - 第七十七条第一項第一号`
  - `第七十七条第一項 - [RELATES_TO] - 第七十七条第一項第二号`

# EXTRACTION RULES

1. Only emit facts where both the subject and object match IDs in ENTITIES.
2. Each fact must involve two **distinct** entities.
3. Use a SCREAMING_SNAKE_CASE string as the `relation_type` (e.g., FOUNDED, WORKS_AT).
4. Do not emit duplicate or semantically redundant facts.
5. The `fact_text` should quote or closely paraphrase the original source sentence(s).
6. Ensure you do not miss reference relationships that span ACROSS DIFFERENT 条 OR 項. Please refer to the explicitly mentioned text and output the most detailed relationships, rather than just those between 条. Reffer REMARKE.

REMARKES:
- In reference relationships, use the 条, 項 and 号 explicitly mentioned in the structure resolved from the CURRENT_MESSAGE. It is important to note where they are described.
- If `前条第二項の規定` in CURRENT_MESSAGE was `前条第二項第一号の規定`, you should extract `第七十六条第二項 - [RELATES_TO] - 第七十六条第一項第一号`.
- You should check no missed reference relationships by seeing whole CURRENT_MESSAGE, especially relative expression like 「次に掲げる事項」, 「前項の規定」, 「前条第二項の規定」.
""",
        ),
    ]

実はこれだけだと第二条第一項第一号 - [RELATES_TO] - 第二条第二項第二号という参照を正しく抽出するには至りませんでした。 Graphitiはデフォルトで、gpt-5-mini,reasoning = 'minimal' を使う設定なのですが、reasoning = 'medium'とすることで以下のようなedgeを抽出することができました。 条→項→号の構造を正しく捉えられており、項を跨いだ参照も正しく抽出されています。

graph TD
    第二条 <--> 第二条第一項
    第二条 <--> 第二条第二項
    第二条第一項 <--> 第二条第一項第一号
    第二条第一項 <--> 第二条第一項第二号
    第二条第二項 <--> 第二条第二項第一号
    第二条第二項 <--> 第二条第二項第二号
    第二条第一項第一号 <--> 第二条第二項第二号

終わりに

今回は、Graphitiを書き換えて、法律文書の条文番号をEntityにした文書構造のインデックスをKnowledge Graphとして構築しました。 Graphity.add_episodeの処理は、この後、resolve_edgeextract_summaryと続き、時系列的な変化や情報の更新を行うTempporal Knowledge Graphが構築されていきます。次回以降、そちらについてもまたまとめられればと思いますが、Agent Memoryの実装を学ぶのに、Graphitiの設計はとても良い教材だと思うので、同じように手を動かす方の一助になれば幸いです。

open.talentio.com

jobs.layerx.co.jp