Closed10

LlamaIndexのProperty Graph Indexを試す

kun432kun432

LlamaIndexでナレッジグラフを使ったインデックス(Knowledge Graph Index)は以前試してみたことがあるが、、、

https://zenn.dev/link/comments/4265fb2ee007b4

うまく欲しい情報にたどり着けない場合があって、期待してたほどではなかった。

今回リリースされたProperty Graph Indexは、Knowledge Graph Indexの課題を解決するものらしいので、試してみたい。

kun432kun432

LlamaIndexの公式ブログからポイントをまとめてみる。

https://www.llamaindex.ai/blog/introducing-the-property-graph-index-a-powerful-new-way-to-build-knowledge-graphs-with-llms

  • 知識トリプル(主語、述語、目的語)のような従来のナレッジグラフ表現は、表現力に限界がある。具体的には:
    • ノードと(ノード間の)関係性にラベルとプロパティを割り当てれない
    • テキストノードをベクトル埋め込みとして表現できない
    • ベクトル検索と記号検索の両方を実行できない
  • 既存のKnowledgeGraphIndexは、これらの制限+インデックス自体のアーキテクチャに関する一般的な制限も受けていた。
  • Property Graph Indexはこれらの問題を解決する。
    • ラベル付きプロパティグラフの表現を使用することで、知識グラフのモデル化、保存、およびクエリの機能が大幅に向上。
    • プロパティグラフを使用することで以下が実現できる:
      • ノードと(ノード間の)関係性を、関連するメタデータを持つタイプに分類する。
      • グラフをベクトルデータベースのスーパーセットとして扱い、ハイブリッド検索を行う。
      • グラフ・クエリー言語Cypherを使って複雑なクエリーを表現する。

このプロパティグラフってのなんぞや?と思って調べてみたら、どうやらモデリング手法の違いみたいなものらしい。

https://qiita.com/s-egami/items/e7f2153015a1f79e4dd6

最近では今年出版されたHoganらの定義が最も無難かなと思います。簡単に訳したものがこちらです。

実世界の知識を蓄積し、伝達することを目的とした「データのグラフ」であり、そのノードは関心のあるエンティティを表し、エッジはこれらのエンティティの間の様々な関係を表す。
その「データのグラフ」はグラフベースのデータモデルに準拠し、エッジラベル付き有向グラフ、異種グラフ、プロパティグラフなどがある。

この記事では、このナレッジグラフのデータモデルとして特に<subject, predicate, object>の三つ組でエンティティの関係性を表現するResource Description Framework (RDF)を扱います。

ChatGPTに聞いてみた。

ナレッジグラフのモデリング手法まとめ

ナレッジグラフのモデリング手法には、データの構造や関係性をどのように表現するかによって様々な手法が存在します。以下に主要なモデリング手法とその具体例を示します。

  1. RDF (Resource Description Framework)

    • 概要:データを「主語-述語-目的語」のトリプル形式で表現。
    • 特徴:セマンティックなクエリとリレーションシップの明確な定義。
    • 具体例:図書館の蔵書データ
      • 主語:本(例:「The Great Gatsby」)
      • 述語:著者(例:「written by」)
      • 目的語:著者の名前(例:「F. Scott Fitzgerald」)
  2. プロパティグラフ (Property Graph)

    • 概要:ノードとエッジを持ち、それぞれにプロパティ(属性)を付与できるグラフ構造。
    • 特徴:ノードやエッジにラベルやタイプを付与し、複雑な関係性を表現。
    • 具体例:ソーシャルネットワーク
      • ノード:ユーザー(例:「Alice」)
      • エッジ:友達関係(例:「is friends with」)
      • プロパティ:ユーザーの属性(例:「age: 25」)
  3. タキソノミー (Taxonomy)

    • 概要:階層的な分類体系で、概念を階層構造で表現。
    • 特徴:単純な階層構造。親子関係を持つツリー構造。
    • 具体例:動物の分類
      • 上位概念:動物(例:「動物界」)
      • 下位概念:哺乳類(例:「哺乳綱」)、鳥類(例:「鳥綱」)
      • 具体的な分類:犬(例:「犬科」)、猫(例:「猫科」)
  4. フォークソノミー (Folksonomy)

    • 概要:ユーザーが自由にタグ付けすることでデータを分類。
    • 特徴:非階層的で柔軟な分類方法。ユーザー生成のメタデータ。
    • 具体例:写真共有サイト
      • 写真:風景写真
      • タグ:ユーザーが付けるタグ(例:「山」、「夕日」、「旅行」)
  5. トピックマップ (Topic Map)

    • 概要:トピックとトピック間の関係を表現する手法。
    • 特徴:複雑な関係性を視覚的に表現。トピック、アソシエーション、オカレンスなどの要素を含む。
    • 具体例:知識管理システム
      • トピック:プロジェクト
      • アソシエーション:関連するタスクやメンバー
      • オカレンス:プロジェクトのドキュメントや進捗レポート
  6. ERモデル (Entity-Relationship Model)

    • 概要:データベース設計に用いられる、エンティティとその間の関係を表現するモデル。
    • 特徴:エンティティ、アトリビュート、リレーションシップを用いたデータの構造化。
    • 具体例:大学のデータベース
      • エンティティ:学生、コース
      • リレーションシップ:学生が履修するコース
      • アトリビュート:学生の名前、コースの名称
  7. オブジェクトプロパティグラフ (Object Property Graph)

    • 概要:オブジェクト指向の概念を取り入れたプロパティグラフ。
    • 特徴:ノードやエッジにクラスやインスタンスの概念を導入。
    • 具体例:企業の人事データベース
      • クラス:従業員、部署
      • インスタンス:具体的な従業員(例:「John Doe」)、部署(例:「IT部門」)
      • プロパティ:従業員の役職、部署の所在地

まとめ

ナレッジグラフの構築には、目的やデータの性質に応じて最適なモデリング手法を選択することが重要です。各手法は特定のユースケースやニーズに応じて適しています。

kun432kun432

ドキュメントが複数あるのでさっきの公式ブログの記事も含めて横断しながら、試してみる。Colaboratoryで。

https://docs.llamaindex.ai/en/stable/module_guides/indexing/lpg_index_guide/

https://docs.llamaindex.ai/en/stable/examples/property_graph/graph_store/

https://colab.research.google.com/github/run-llama/llama_index/blob/main/docs/docs/examples/property_graph/property_graph_basic.ipynb#scrollTo=06wsSLLSCW6O

https://docs.llamaindex.ai/en/stable/examples/property_graph/property_graph_advanced/

https://docs.llamaindex.ai/en/stable/examples/property_graph/property_graph_neo4j/

インストール。トレーシング用にArize Phoenixも使う。

!pip install llama-index llama-index-callbacks-arize-phoenix
!pip freeze | egrep "llama-|arize"
arize-phoenix==4.2.4
llama-index==0.10.42
llama-index-agent-openai==0.2.7
llama-index-callbacks-arize-phoenix==0.1.5
llama-index-cli==0.1.12
llama-index-core==0.10.42
llama-index-embeddings-openai==0.1.10
llama-index-indices-managed-llama-cloud==0.1.6
llama-index-legacy==0.9.48
llama-index-llms-openai==0.1.22
llama-index-multi-modal-llms-openai==0.1.6
llama-index-program-openai==0.1.6
llama-index-question-gen-openai==0.1.3
llama-index-readers-file==0.1.23
llama-index-readers-llama-parse==0.1.4
llama-parse==0.4.4
openinference-instrumentation-llama-index==1.4.1

OpenAIのAPIキーをセット

import os
from google.colab import userdata

os.environ["OPENAI_API_KEY"] = userdata.get("OPENAI_API_KEY")

Arize Phoenixのトレーシングを有効化。

import phoenix as px
import llama_index.core

px.launch_app()
llama_index.core.set_global_handler("arize_phoenix")

notebookなのでイベントループのネストを有効化。

import nest_asyncio

nest_asyncio.apply()

RAGのコンテンツはこれを使う。

https://ja.wikipedia.org/wiki/イクイノックス

https://ja.wikipedia.org/wiki/ドウデュース

テキストデータとしてダウンロード。

from pathlib import Path
import requests
import re

def replace_heading(match):
    level = len(match.group(1))
    return '#' * level + ' ' + match.group(2).strip()

# Wikipediaからのデータ読み込み
wiki_titles = ["イクイノックス", "ドウデュース"]
for title in wiki_titles:
    response = requests.get(
        "https://ja.wikipedia.org/w/api.php",
        params={
            "action": "query",
            "format": "json",
            "titles": title,
            "prop": "extracts",
            # 'exintro': True,
            "explaintext": True,
        },
    ).json()
    page = next(iter(response["query"]["pages"].values()))
    wiki_text = f"# {title}\n\n## 概要\n\n"
    wiki_text += page["extract"]

    wiki_text = re.sub(r"(=+)([^=]+)\1", replace_heading, wiki_text)
    wiki_text = re.sub(r"\t+", "", wiki_text)
    wiki_text = re.sub(r"\n{3,}", "\n\n", wiki_text)
    data_path = Path("data")
    if not data_path.exists():
        Path.mkdir(data_path)

    with open(data_path / f"{title}.txt", "w") as fp:
        fp.write(wiki_text)

ドキュメントを読み込み

from llama_index.core import VectorStoreIndex, SimpleDirectoryReader

documents = SimpleDirectoryReader("data").load_data()

でこのドキュメントを使ってグラフインデックスを作成するのだけど、データからナレッジグラフを抽出する方法(kg_extractors)が3種類用意されている。

  1. SimpleLLMPathExtractor
    • LLMを使って、エンティティ(ノード)と関係性(エッジ)を抽出する
    • 既存のKnowledgeGraphIndexと近しい
  2. ImplicitPathExtractor
    • LlamaIndexのnode.relationshiopsを使って、関係性を抽出する
    • node.relationshipsはドキュメントのチャンク時に以下を設定する。
      • 元となるドキュメントをSOURCEと定義する
      • チャンク分割時に連続するチャンク同時をPREVIOUS/NEXTで定義する
  3. SchemaLLMPathExtractor
    • エンティティ(ノード)と関係性(エッジ)をスキーマとして定義する
    • LLMを使ってこのスキーマに従って、ノードとエッジのラベリングを行う。

Property Graphらしさはおそらく3だと思うのだけど、Property Graph Indexでは、これらのどれか一つだけを使うこともできるし、どうやら複数組み合わせるということもできる様子。

ということでインデックスを定義する。デフォルトの設定で一番ミニマムに書くとこれだけ。

from llama_index.core import PropertyGraphIndex

index = PropertyGraphIndex.from_documents(documents)

ただしデフォルトの設定を踏まえて明示的に書くと、だいたいこんな感じっぽい。実際にはもっといろいろパラメータはあるのだけど、SimpleLLMPathExtractorとImplicitPathExtractorが有効になっているというところがポイントだと思う。

from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from llama_index.core import PropertyGraphIndex
from llama_index.core.indices.property_graph import (
    ImplicitPathExtractor,
    SimpleLLMPathExtractor,
)

llm = OpenAI(model="gpt-3.5-turbo")
embed_model = OpenAIEmbedding(model_name="text-embedding-ada-002")

index = PropertyGraphIndex.from_documents(
    documents,
    llm=llm,
    embed_model=embed_model,
    kg_extractors=[
        SimpleLLMPathExtractor(llm=llm),
        ImplicitPathExtractor(),
    ],
    show_progress=True,
)

こんな感じで処理される。

Parsing nodes: 100%
 2/2 [00:00<00:00, 22.21it/s]
Extracting paths from text: 100%|██████████| 18/18 [00:21<00:00,  1.21s/it]
Extracting implicit paths: 100%|██████████| 18/18 [00:00<00:00, 2653.13it/s]
Generating embeddings: 100%|██████████| 1/1 [00:00<00:00,  1.22it/s]
Generating embeddings: 100%|██████████| 4/4 [00:00<00:00,  6.89it/s]
  • Parsing nodes...で、ドキュメントがチャンク≒ノードに分割されている
  • Extracting paths from text: ...で、SimpleLLMPathExtractorを使った知識トリプルの抽出が行われている
  • Extracting implicit pathsで、ImplicitPathExtractorによるnode.relationshipsを使った関係性抽出が行われている。
  • Generating embeddingsでベクトル化されている。2回行われているのは、テキストノードとグラフノードそれぞれのベクトルが生成されるため。

で、retriever/query_engineを定義する前に、一旦グラフの状態を可視化してみる。

pyvisが必要。

!pip install pyvis

HTMLファイルに出力

index.property_graph_store.save_networkx_graph(name="./kg.html")

作成されたファイルをブラウザで開くとこんな感じになっている。

中身を少し見てみると、まず、SimpleLLMPathExtractorで生成されたグラフはこんな感じ。知識トリプルで表現されているのがわかる。

ImplicitPathExtractorによって生成されたグラフはこんな感じ。こちらはノード・ドキュメントのIDに対して、SOURCE/PREVIOUS/NEXTでエッジが表現されている。

ちなみにSimpleLLMPathExtractorはLLMを使っているのでトレースを見るとプロンプトなどがわかる。

ではretrievalしてみる。とりあえず検索で引っ張ってきたいテキストは置いておいて、グラフの検索結果がどのように返されるのかを見てみる。

retriever = index.as_retriever(
    include_text=False,  # 根拠となる元のテキストをretrievalに含めるか?デフォルトはTrue
)

nodes = retriever.retrieve("ドウデュースの主な勝ち鞍は?")

for node in nodes:
    print(node.text)

ドウデュース -> 朝日杯フューチュリティステークスの -> 勝ち鞍
ドウデュース -> 評価され -> 日本ダービー
ドウデュース -> 2021年の -> Jra賞最優秀2歳牡馬
ドウデュース -> 3歳初戦として -> 出走
ドウデュース -> 失速 -> 後方
ドウデュース -> ノーザンファームで -> 育成
ドウデュース -> 松島及びキーファーズにとっては -> 初の単独所有馬によるgi勝利
ドウデュース -> 1番人気に -> 推される
ドウデュース -> 勝ちタイムは -> 2分21秒9のダービーレコード
ドウデュース -> 弥生賞ディープインパクト記念に -> 出走
ドウデュース -> は -> 競走馬
ドウデュース -> 2019年5月7日に -> 誕生
ドウデュース -> 勝利した功績を評価された -> 日本ダービー
ドウデュース -> 発表した -> ロンジンワールドベストレースホースランキング
ドウデュース -> 勝ちタイムは -> コースレコードは2018年ジャパンカップでアーモンドアイが記録した2分20秒6
ドウデュース -> 位置づけられた -> 第15位タイに
ドウデュース -> 日本の中央競馬の -> 平地gi完全制覇
イクイノックス -> は -> 競走馬
ヒシイグアス -> 失速 -> 後方
日本ダービー -> 敗れた -> 前年
イクイノックス -> 追い込む -> 勝ち馬

知識トリプルが取得できているのがわかる。ざっくり理解は出来そうに思える。
include_text=Trueにした場合はこうなる(チャンク情報が多いので1件だけ抜粋)。

Here are some facts extracted from the provided text:

ドウデュース -> 朝日杯フューチュリティステークスの -> 勝ち鞍
ドウデュース -> 評価され -> 日本ダービー
ドウデュース -> 2021年の -> Jra賞最優秀2歳牡馬
ドウデュース -> 3歳初戦として -> 出走
ドウデュース -> 失速 -> 後方
ドウデュース -> ノーザンファームで -> 育成
ドウデュース -> 松島及びキーファーズにとっては -> 初の単独所有馬によるgi勝利
ドウデュース -> 1番人気に -> 推される
ドウデュース -> 勝ちタイムは -> 2分21秒9のダービーレコード
ドウデュース -> 弥生賞ディープインパクト記念に -> 出走
ドウデュース -> は -> 競走馬
ドウデュース -> 2019年5月7日に -> 誕生
ドウデュース -> 勝利した功績を評価された -> 日本ダービー
ドウデュース -> 発表した -> ロンジンワールドベストレースホースランキング
ドウデュース -> 勝ちタイムは -> コースレコードは2018年ジャパンカップでアーモンドアイが記録した2分20秒6
ドウデュース -> 位置づけられた -> 第15位タイに
ドウデュース -> 日本の中央競馬の -> 平地gi完全制覇
ドウデュース -> 朝日杯フューチュリティステークスの -> 勝ち鞍
ドウデュース -> 評価され -> 日本ダービー
ドウデュース -> 2021年の -> Jra賞最優秀2歳牡馬
ドウデュース -> 3歳初戦として -> 出走
ドウデュース -> 失速 -> 後方
ドウデュース -> ノーザンファームで -> 育成
ドウデュース -> 松島及びキーファーズにとっては -> 初の単独所有馬によるgi勝利
ドウデュース -> 1番人気に -> 推される
ドウデュース -> 勝ちタイムは -> 2分21秒9のダービーレコード
ドウデュース -> 弥生賞ディープインパクト記念に -> 出走
ドウデュース -> は -> 競走馬

6月10日に国際競馬統括機関連盟が発表した「ロンジンワールドベストレースホースランキング」において、ドウデュースは日本ダービーを勝利した功績を評価され、シャフリヤールやエンブレムロードと並ぶレーティング120で第15位タイに位置づけられた。
次走として凱旋門賞への出走を表明した。ドウデュースののんびりとした性格上、フランスへ赴いた場合に放牧と勘違いする可能性を踏まえ、日本ダービー直後の時点で友道は直行する予定と語っていたが、のちに松島はニエル賞を前哨戦として使ってから凱旋門賞に出走するプランもあり、その後はアメリカのブリーダーズカップ・ターフへの出走も検討していると述べた。最終的に友道は凱旋門賞に直行ではなくニエル賞を経由して凱旋門賞に出走すると発表した。いずれも後方からの競馬となったが休み明けや重馬場の影響もあり十分なパフォーマンスを発揮出来ず、それぞれ4着、19着に終わった。

### 4歳(2023年)
2023年2月12日に京都競馬場の改修により、阪神競馬場(芝内回り・2200m)で行われた京都記念に斤量58キロで出走。まずまずのスタートを決めると、道中は馬群を見て最後方グループで待機し、向正面から外へ出て徐々に捲りをかけ、直線に入ると早々と馬群から抜け出して後続を突き放し、3馬身半差の快勝でGIを含む重賞3勝目を挙げた。日本ダービーを勝利した馬が京都記念を勝利するのは、1948年春の京都記念のマツミドリ以来75年ぶりとなった。
次走は3月25日にドバイのメイダン競馬場で行われるドバイターフとし、同月15日(現地時間同月14日)にメイダン競馬場に到着した。しかし出馬投票後の同月24日、調教後に左前肢跛行を発症しドバイターフへの出走を取り消した。友道は「調教後に左腕節に違和感を認め、競馬に向けて進めておりましたが、将来のある馬なのでここでは無理をせず、取り消すことを決断いたしました」と語った。
その後夏は治療と休養にあて、秋初戦として10月29日に東京競馬場で開催される天皇賞(秋)に出走。
Here are some facts extracted from the provided text:
(snip)

この「根拠」となるテキストがどのように抽出されているのかはちょっとよくわからないな。知識トリプルとは関連性があるようには思えないし。そこは一旦置いておく。

次にquery engineを定義して聞いてみる。

query_engine = index.as_query_engine(
    include_text=True,
)

response = query_engine.query("ドウデュースの主な勝ち鞍について教えて。")

print(str(response))

朝日杯フューチュリティステークス

一応あってはいるんだけども、このあたりも読み取ってほしい感はある。

6月10日に国際競馬統括機関連盟が発表した「ロンジンワールドベストレースホースランキング」において、ドウデュースは日本ダービーを勝利した功績を評価され、シャフリヤールやエンブレムロードと並ぶレーティング120で第15位タイに位置づけられた。

2023年2月12日に京都競馬場の改修により、阪神競馬場(芝内回り・2200m)で行われた京都記念に斤量58キロで出走。まずまずのスタートを決めると、道中は馬群を見て最後方グループで待機し、向正面から外へ出て徐々に捲りをかけ、直線に入ると早々と馬群から抜け出して後続を突き放し、3馬身半差の快勝でGIを含む重賞3勝目を挙げた。日本ダービーを勝利した馬が京都記念を勝利するのは、1948年春の京都記念のマツミドリ以来75年ぶりとなった。

とりあえず以前からあるKnowledgeGraphIndexを使った場合よりはいろいろグラフの情報量は増えているように思えるけども、結果や精度的なところはそれほど大きな違いがあるようにも印象はない。

kun432kun432

SchemaLLMPathExtractor

Property Graphの最もウリだと思うSchemaLLMPathExtractorを使ってみる。

from typing import Literal
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from llama_index.core import PropertyGraphIndex
from llama_index.core.indices.property_graph import SchemaLLMPathExtractor

llm = OpenAI(model="gpt-3.5-turbo")
embed_model = OpenAIEmbedding(model_name="text-embedding-ada-002")

# 全て大文字&アンダースコアでつなげるのが推奨
entities = Literal["HORSE", "PERSON", "PLACE", "RACE_NAME", "THING"]
relations = Literal["PART_OF", "HAS", "IS_A"]
schema = {
    "HORSE": ["PART_OF", "HAS", "IS_A"],
    "PERSON": ["PART_OF", "HAS", "IS_A"],
    "PLACE": ["PART_OF", "HAS"],
    "RACE_NAME": ["PART_OF", "HAS", "IS_A"],
    "THING": ["IS_A"],
}

index = PropertyGraphIndex.from_documents(
    documents,
    llm=llm,
    embed_model=embed_model,
    kg_extractors=[
        SchemaLLMPathExtractor(
            llm=llm,
            possible_entities=entities,
            possible_relations=relations,
            kg_validation_schema=schema,
            strict=True,  # Falseの場合は定義していないスペックの値も許容する
        ),
    ],
    show_progress=True,
)

ノードのラベル(possible_entities)とエッジのラベル(possible_relations)とそれらをまとめたスキーマ(kg_validation_schema)を定義する。ドキュメントにあったものから適当に増やしてみた。 strict=Trueはどうやら定義されたものだけに限定するか、それ以外の値も許容してLLMに提案させるか?という感じっぽい?

可視化してみる。

index.property_graph_store.save_networkx_graph(name="./kg.html")

生成されたHTMLはこんな感じ。

ではretrieverを定義してみる。一旦include_text=Falseで。

retriever = index.as_retriever(
    include_text=False,  # 根拠となるテキストをretrievalに含めるか?デフォルトはTrue
)

nodes = retriever.retrieve("ドウデュースの主な勝ち鞍は?")

for node in nodes:
    print(node.text)

ドウデュース -> HAS -> ノーザンファーム
ドウデュース -> HAS -> 弥生賞ディープインパクト記念
ドウデュース -> HAS -> 京都記念
ドウデュース -> HAS -> 松島正昭
ドウデュース -> HAS -> JRA賞最優秀2歳牡馬
ドウデュース -> HAS -> 皐月賞
ドウデュース -> HAS -> 東京優駿(日本ダービー)
ドウデュース -> HAS -> ジャパンカップ
ドウデュース -> HAS -> 東京優駿
ドウデュース -> HAS -> 有馬記念
ドウデュース -> HAS -> 日本ダービー
ドウデュース -> HAS -> 北海道安平町
ドウデュース -> IS_A -> 競走馬
ドウデュース -> HAS -> 天皇賞(秋)
ドウデュース -> HAS -> 友道康夫
ドウデュース -> HAS -> 戸崎圭太
ドウデュース -> HAS -> 武豊
ドウデュース -> HAS -> ドバイターフ
ドウデュース -> HAS -> 凱旋門賞
ドウデュース -> HAS -> 栗東トレーニングセンター
ドウデュース -> HAS -> 朝日杯フューチュリティステークス
パンサラッサ -> HAS -> 史上5頭目の天皇賞(秋)・ジャパンカップ連勝
天皇賞(秋) -> PART_OF -> イクイノックスの勝利

んー、これだけだと関係性の情報としてはさすがに足りなくないかなぁ?という気がする。

include_text=Trueでも。

retriever = index.as_retriever(
    include_text=True,  # 根拠となるテキストをretrievalに含めるか?デフォルトはTrue
)

nodes = retriever.retrieve("ドウデュースの主な勝ち鞍は?")

for node in nodes:
    print(node.text)

Here are some facts extracted from the provided text:

ドウデュース -> HAS -> ノーザンファーム
ドウデュース -> HAS -> 弥生賞ディープインパクト記念
ドウデュース -> HAS -> 京都記念
ドウデュース -> HAS -> 松島正昭
ドウデュース -> HAS -> JRA賞最優秀2歳牡馬
ドウデュース -> HAS -> 皐月賞
ドウデュース -> HAS -> 東京優駿(日本ダービー)
ドウデュース -> HAS -> ジャパンカップ
ドウデュース -> HAS -> 東京優駿
ドウデュース -> HAS -> 有馬記念
ドウデュース -> HAS -> 日本ダービー
ドウデュース -> HAS -> 北海道安平町
ドウデュース -> IS_A -> 競走馬
ドウデュース -> HAS -> 天皇賞(秋)
ドウデュース -> HAS -> 友道康夫
ドウデュース -> HAS -> 戸崎圭太
ドウデュース -> HAS -> 武豊
ドウデュース -> HAS -> ドバイターフ
ドウデュース -> HAS -> 凱旋門賞
ドウデュース -> HAS -> 栗東トレーニングセンター
ドウデュース -> HAS -> 朝日杯フューチュリティステークス

しかし出馬投票後の同月24日、調教後に左前肢跛行を発症しドバイターフへの出走を取り消した。友道は「調教後に左腕節に違和感を認め、競馬に向けて進めておりましたが、将来のある馬なのでここでは無理をせず、取り消すことを決断いたしました」と語った。
その後夏は治療と休養にあて、秋初戦として10月29日に東京競馬場で開催される天皇賞(秋)に出走。当日の第5競走で武豊が騎乗馬に右膝を蹴られ負傷したため、急遽戸崎圭太に乗り替わりとなった。レースは中団6番手からチャンスを狙ったものの、最終直線で伸び切れず7着に終わった。
次走のジャパンカップでは騎乗予定だった武豊が上述の負傷の回復が遅く、療養に専念するために引き続き戸崎で出走。急な乗り替わりとなった前走と異なり戸崎が騎乗する想定での調整を行ったものの4着。勝ち馬のイクイノックスは本レースがラストランとなった。

2023年最終戦として有馬記念へ出走。鞍上には武豊が復帰した。
鞍上横山和生と本レースがラストランとなっており、レースを牽引して尚直線粘るタイトルホルダーと、二番手から先に仕掛けていた鞍上クリストフ・ルメールのスターズオンアースを抜き去り、先頭でゴール板を駆け抜けて見事復活の勝利を挙げた。武豊は2017年のキタサンブラック以来、6年ぶりの制覇となった。また、54歳9カ月10日での有馬記念勝利は最年長勝利記録であり、同時に自身が持つJRA・GI最年長勝利記録(54歳19日)を更新。ドリームジャーニーやオルフェーヴル、ブラストワンピースに騎乗し歴代最多の有馬記念4勝を挙げている池添謙一に並ぶ勝利数となった。馬番5番での優勝は、1970年のスピードシンボリ、1972年のイシノヒカルに次いで、51年ぶり3度目となった。さらに、ダービー馬による有馬記念制覇はオルフェーヴル以来10年ぶり9頭目で、三冠馬以外ではハクチカラ、ダイナガリバー、トウカイテイオーに次いで30年ぶり4頭目となったほか、父ハーツクライとの本競走親子制覇を達成した。
Here are some facts extracted from the provided text:

ドウデュース -> HAS -> ノーザンファーム
ドウデュース -> HAS -> 弥生賞ディープインパクト記念
ドウデュース -> HAS -> 京都記念
ドウデュース -> HAS -> 松島正昭
ドウデュース -> HAS -> JRA賞最優秀2歳牡馬
ドウデュース -> HAS -> 皐月賞
ドウデュース -> HAS -> 東京優駿(日本ダービー)
ドウデュース -> HAS -> ジャパンカップ
ドウデュース -> HAS -> 東京優駿
ドウデュース -> HAS -> 有馬記念
ドウデュース -> HAS -> 日本ダービー
ドウデュース -> HAS -> 北海道安平町
ドウデュース -> IS_A -> 競走馬
ドウデュース -> HAS -> 天皇賞(秋)
ドウデュース -> HAS -> 友道康夫
ドウデュース -> HAS -> 戸崎圭太
ドウデュース -> HAS -> 武豊
ドウデュース -> HAS -> ドバイターフ
ドウデュース -> HAS -> 凱旋門賞
ドウデュース -> HAS -> 栗東トレーニングセンター
ドウデュース -> HAS -> 朝日杯フューチュリティステークス
天皇賞(秋) -> PART_OF -> イクイノックスの勝利

しかし出馬投票後の同月24日、調教後に左前肢跛行を発症しドバイターフへの出走を取り消した。友道は「調教後に左腕節に違和感を認め、競馬に向けて進めておりましたが、将来のある馬なのでここでは無理をせず、取り消すことを決断いたしました」と語った。
その後夏は治療と休養にあて、秋初戦として10月29日に東京競馬場で開催される天皇賞(秋)に出走。当日の第5競走で武豊が騎乗馬に右膝を蹴られ負傷したため、急遽戸崎圭太に乗り替わりとなった。レースは中団6番手からチャンスを狙ったものの、最終直線で伸び切れず7着に終わった。
次走のジャパンカップでは騎乗予定だった武豊が上述の負傷の回復が遅く、療養に専念するために引き続き戸崎で出走。急な乗り替わりとなった前走と異なり戸崎が騎乗する想定での調整を行ったものの4着。勝ち馬のイクイノックスは本レースがラストランとなった。

2023年最終戦として有馬記念へ出走。鞍上には武豊が復帰した。
鞍上横山和生と本レースがラストランとなっており、レースを牽引して尚直線粘るタイトルホルダーと、二番手から先に仕掛けていた鞍上クリストフ・ルメールのスターズオンアースを抜き去り、先頭でゴール板を駆け抜けて見事復活の勝利を挙げた。武豊は2017年のキタサンブラック以来、6年ぶりの制覇となった。また、54歳9カ月10日での有馬記念勝利は最年長勝利記録であり、同時に自身が持つJRA・GI最年長勝利記録(54歳19日)を更新。ドリームジャーニーやオルフェーヴル、ブラストワンピースに騎乗し歴代最多の有馬記念4勝を挙げている池添謙一に並ぶ勝利数となった。馬番5番での優勝は、1970年のスピードシンボリ、1972年のイシノヒカルに次いで、51年ぶり3度目となった。さらに、ダービー馬による有馬記念制覇はオルフェーヴル以来10年ぶり9頭目で、三冠馬以外ではハクチカラ、ダイナガリバー、トウカイテイオーに次いで30年ぶり4頭目となったほか、父ハーツクライとの本競走親子制覇を達成した。
Here are some facts extracted from the provided text:

パンサラッサ -> HAS -> 史上5頭目の天皇賞(秋)・ジャパンカップ連勝

前半1000mを57秒6で通過する大逃げを図ったパンサラッサを追う形で3番手から追走。慌てることなく前を追いかけ、坂の途中で先頭に立つと、2着の三冠牝馬リバティアイランドに4馬身差をつけて完勝。GI6連勝とした。この時の勝ちタイムは2:21.8で、前年に敗れた日本ダービーの優勝タイム及び自身のタイム2:21.9を0.1秒上回った。1着賞金5億円を加え、総獲得賞金は22億1544万6100円となり、史上初となる総賞金額が20億円を超えたと同時に、それまで1位だったアーモンドアイを上回って歴代1位となった。また、父キタサンブラックとのジャパンカップ親子制覇を達成した。ジャパンカップの親子制覇は史上7度目である。天皇賞(秋)・ジャパンカップを連勝するのは史上5頭目で、宝塚記念を含む3連勝はテイエムオペラオー以来23年ぶり2頭目となる。2着にリバティアイランドが入ったため、馬連のオッズが1.8倍となり、2005年の秋華賞を制したエアメサイアと2着馬のラインクラフトと並び、馬連の低額配当タイの記録となった。GI6連勝はグレード制が導入された1984年以降では、テイエムオペラオー、ロードカナロアのGI競走の連勝記録に並んだ。芝平地の古馬GⅠ6勝は日本馬歴代最多タイ。木村哲也調教師は本競走初制覇。また、クリストフ・ルメールは武豊と並ぶジャパンカップ最多4勝目。そのほかにも、3着に5番人気のスターズオンアースが入ったため、3連単のオッズが11.3倍となったのは、第40回を制したアーモンドアイと2着馬コントレイル、3着馬デアリングタクトの13.4倍を更新し、歴代JRA・GIにおける最低額配当となった他、イクイノックス、リバティアイランドのワイドのオッズが1.3倍となり、2000年の天皇賞(春)を制したテイエムオペラオーと2着馬ラスカルスズカ、3着馬ナリタトップロードなどと並び、ワイドの低額配当タイの記録となった。
鞍上のルメールはレース後のインタビューで「この馬の走りは信じられません。ペースが速かったけど、直線はすぐに反応して、自分でもびっくりした。アクセレーション(加速)がすごかったです。1番枠にリバティアイランドがいての2番枠。

query engineでも。

query_engine = index.as_query_engine(
    include_text=True,
)

response = query_engine.query("ドウデュースの主な勝ち鞍は?")

print(str(response))

ドウデュースの主な勝ち鞍は弥生賞ディープインパクト記念、京都記念、JRA賞最優秀2歳牡馬、皐月賞、東京優駿(日本ダービー)、ジャパンカップ、有馬記念、日本ダービー、天皇賞(秋)、朝日杯フューチュリティステークスです。

情報量は前回良り増えたけども、いくつか間違いが含まれている。まあこれはスキーマの定義が全然足りないのではないかという気がする。トレーシングを見てみると、きちんとラベル付は行われているように思える。

kun432kun432

ここまではシンプルにretrievalを行っていたが、Property Graph Indexには複数のretrieverが用意されており、kg_extractorsと同様に、複数のretrieverを組み合わせることができる。

  • LLMSynonymRetriever
    • LLMを使って、クエリからキーワード・シノニムを生成して、ノードを検索する
  • VectorContextRetriever
    • ノードのベクトル類似度から、ノードを検索する
  • TextToCypherRetriever
    • LLMを使って、Cypherというグラフデータベース用クエリ言語に変換して、検索する
    • PropertyGraphStoreが対応している場合のみ
  • CypherTemplateRetriever
    • TextToCypherRetrieverと同じくCypherを使うが、Cypherの定義をテンプレートで渡す限定版的なもの

ここまではデフォルトで進めてきたので、

  • PropertyGraphStoreはSimplePropertyGraphStore(LlamaIndexデフォルトのオンメモリグラフストア)
  • retrievalでは、LLMSynonymRetrieverとVectorContextRetrieverが組み合わせられていた

になっていたみたい。

retrieverの機能をフルに使おうと思うと、Neo4jやNeburaGraphを使う必要がある模様。あと、グラフストア側っでネイティブにベクトルストアに対応しているかどうか、ってところで別のベクトルデータベースが必要になるかというところも変わる。

https://docs.llamaindex.ai/en/stable/module_guides/indexing/lpg_index_guide/#storage

このあたりを踏まえると、ローカルでNeo4jを動かしてやってみるのが良さそう。

kun432kun432

Neo4jとのインテグレーション

Neo4jもAura DBというクラウドサービスをやっていて、無料でも1インスタンス立てれるようなのでこれを使ってColaboratoryでやってみる。

https://neo4j.com/

  • アカウント作成する
  • Create Free Instanceでインスタンス作成する。制限は以下の通りだった。
    • インスタンス数: 1
    • メモリ: 1GB
    • グラフサイズ: ノード200000、エッジ400000
    • 3日間アクセスがないとインスタンスが削除される
  • インスタンス作成後にクレデンシャルが生成・ダウンロードされる

パッケージインストール。Neo4jインテグレーション用パッケージを追加。

!pip install llama-index llama-index-graph-stores-neo4j llama-index-callbacks-arize-phoenix pyvis
!pip freeze | egrep "llama-|arize"
arize-phoenix==4.2.4
llama-index==0.10.43
llama-index-agent-openai==0.2.7
llama-index-callbacks-arize-phoenix==0.1.5
llama-index-cli==0.1.12
llama-index-core==0.10.43
llama-index-embeddings-openai==0.1.10
llama-index-graph-stores-neo4j==0.2.2
llama-index-indices-managed-llama-cloud==0.1.6
llama-index-legacy==0.9.48
llama-index-llms-openai==0.1.22
llama-index-multi-modal-llms-openai==0.1.6
llama-index-program-openai==0.1.6
llama-index-question-gen-openai==0.1.3
llama-index-readers-file==0.1.23
llama-index-readers-llama-parse==0.1.4
llama-parse==0.4.4
openinference-instrumentation-llama-index==1.4.1

OpenAI APIキーの設定やドキュメント読み込み以下はこれまでと同じなので以下。

事前準備
import os
from google.colab import userdata

os.environ["OPENAI_API_KEY"] = userdata.get("OPENAI_API_KEY")
import phoenix as px
import llama_index.core

px.launch_app()
llama_index.core.set_global_handler("arize_phoenix")
from pathlib import Path
import requests
import re

def replace_heading(match):
    level = len(match.group(1))
    return '#' * level + ' ' + match.group(2).strip()

# Wikipediaからのデータ読み込み
wiki_titles = ["イクイノックス", "ドウデュース"]
for title in wiki_titles:
    response = requests.get(
        "https://ja.wikipedia.org/w/api.php",
        params={
            "action": "query",
            "format": "json",
            "titles": title,
            "prop": "extracts",
            # 'exintro': True,
            "explaintext": True,
        },
    ).json()
    page = next(iter(response["query"]["pages"].values()))
    wiki_text = f"# {title}\n\n## 概要\n\n"
    wiki_text += page["extract"]

    wiki_text = re.sub(r"(=+)([^=]+)\1", replace_heading, wiki_text)
    wiki_text = re.sub(r"\t+", "", wiki_text)
    wiki_text = re.sub(r"\n{3,}", "\n\n", wiki_text)
    data_path = Path("data")
    if not data_path.exists():
        Path.mkdir(data_path)

    with open(data_path / f"{title}.txt", "w") as fp:
        fp.write(wiki_text)
import nest_asyncio

nest_asyncio.apply()
from llama_index.core import VectorStoreIndex, SimpleDirectoryReader

documents = SimpleDirectoryReader("data").load_data()

Neo4jのグラフストアを定義。Neo4jのクラウドでインスタンス作成した際のクレデンシャルを引数に設定する。

from llama_index.graph_stores.neo4j import Neo4jPropertyGraphStore

graph_store = Neo4jPropertyGraphStore(
    username="neo4j",
    password="XXXXXXXXXX",
    url="neo4j+s://XXXXXXXXXX.databases.neo4j.io",
)

Property Graph Indexを作成。まずはNeo4jのクラウドサービスの使い勝手を見てみたいので、デフォルトに近い設定で。

from llama_index.core import PropertyGraphIndex
from llama_index.embeddings.openai import OpenAIEmbedding
from llama_index.llms.openai import OpenAI
from llama_index.core.indices.property_graph import (
    ImplicitPathExtractor,
    SimpleLLMPathExtractor,
)

llm = OpenAI(model="gpt-3.5-turbo")
embed_model = OpenAIEmbedding(model_name="text-embedding-ada-002")

index = PropertyGraphIndex.from_documents(
    documents,
    llm=llm,
    embed_model=embed_model,
    kg_extractors=[
        SimpleLLMPathExtractor(llm=llm),
        ImplicitPathExtractor(),
    ],
    property_graph_store=graph_store, # Neo4jPropertyGraphStoreを使用
    show_progress=True,
)

Parsing nodes: 100%
 2/2 [00:00<00:00, 30.75it/s]
Extracting paths from text: 100%|██████████| 18/18 [00:23<00:00, 1.30s/it]
Extracting implicit paths: 100%|██████████| 18/18 [00:00<00:00, 4438.94it/s]
Generating embeddings: 100%|██████████| 1/1 [00:01<00:00, 1.15s/it]
Generating embeddings: 100%|██████████| 4/4 [00:00<00:00, 7.58it/s]

Neo4jのクラウドの管理画面から見てみる。

ここにCypherでクエリを書くらしい。ChatGPTに全てのノードとエッジを表示するクエリを聞いてみた。

MATCH (n)-[r]->(m) RETURN n, r, m

拡大するとこんな感じになる。

ベクトルデータも含まれていることがわかる。

ちなみにNeo4jのクラウドのこの画面、結構すぐにセッションが切れるので再ログインが必要になる。。。

これで準備ができたので、それぞれのretrieverを見ていく。

LLMSynonymRetriever

プロンプトやパース用関数は指定しなくても同様のものが設定されているので、特に設定しなくてもよい。ここでは理解のためドキュメントどおりにやっている。

from llama_index.core.indices.property_graph import LLMSynonymRetriever

DEFAULT_SYNONYM_EXPAND_TEMPLATE = """\
最初のクエリが与えられた場合、大文字、複数形、一般的な表現などの可能性を考慮し、合計{max_keywords}までの同義語や関連キーワードを生成しなさい。
'keyword1^keyword2^keyword_n^...'のように、すべての同義語/キーワードを'^'記号で区切って指定すること。
結果は1行にまとめ、'^'記号で区切ること。
----
QUERY: {query_str}
----
KEYWORDS: \
"""

# キーワード・同義語のパース用関数
def parse_fn(output: str) -> list[str]:
    matches = output.strip().split("^")

    # 投入時の正規化のために大文字にする(日本語だとそれほど意味はないかも)
    return [x.strip().capitalize() for x in matches if x.strip()]


synonym_retriever = LLMSynonymRetriever(
    index.property_graph_store,
    llm=llm,
    # 根拠となるテキストをretrievalに含めるか?デフォルトはTrue
    include_text=False,
    synonym_prompt=DEFAULT_SYNONYM_EXPAND_TEMPLATE,
    output_parsing_fn=parse_fn,
    max_keywords=10,
    # ノード検索後に辿る関係性の深さ
    path_depth=1,
)

retriever = index.as_retriever(
    sub_retrievers=[synonym_retriever],
)

retrievalしてみる。

nodes = retriever.retrieve("ドウデュースの主な勝ち鞍は?")

for node in nodes:
    print(node.text)

ドウデュース -> 位置づけられた -> 第15位タイ
ドウデュース -> 評価され -> 日本ダービー
ドウデュース -> 勝ちタイムは -> 2分20秒6
ドウデュース -> 勝ちタイムは -> コースレコードは2018年ジャパンカップでアーモンドアイが記録した2分20秒6
ドウデュース -> 勝ちタイムは -> 2分21秒9のダービーレコード
ドウデュース -> 日本ダービーを勝利した功績を評価された -> ロンジンワールドベストレースホースランキング
ドウデュース -> 凱旋門賞への出走を表明した -> 次走
ドウデュース -> 選択 -> アイビーステークス
ドウデュース -> 勝ち -> ガイアフォース
ドウデュース -> 入厩した -> 友道康夫厩舎
ドウデュース -> 育成の後 -> ノーザンファーム空港牧場
ドウデュース -> 所有 -> 株式会社キーファーズ
ドウデュース -> 生まれた -> 北海道安平町
ドウデュース -> 失速 -> 後方
ドウデュース -> 勝利 -> グランシエロ
ドウデュース -> 出走 -> 朝日杯フューチュリティステークス
ドウデュース -> 出走 -> 2歳新馬戦
ドウデュース -> は -> 競走馬

トレースを見るとクエリからキーワードが生成されており、これでノードを検索しているのだろうと思う。

VectorContextRetriever

from llama_index.core.indices.property_graph import VectorContextRetriever

vector_retriever = VectorContextRetriever(
    index.property_graph_store,
    # GraphStoreがベクトルクエリをサポートしない場合は以下も必要になる(Neo4jでは不要)
    # vector_store=index.vector_store,
    embed_model=embed_model,
    # 根拠となるテキストをretrievalに含めるか?デフォルトはTrue
    include_text=False,
    # 取得するノードの数
    similarity_top_k=2,
    # ノード検索後に辿る関係性の深さ
    path_depth=1,
    # VectorStoreQueryクラスのキーワード引数を追加できる
    # ...,
)

retriever = index.as_retriever(
    sub_retrievers=[vector_retriever],
)

retrieval

nodes = retriever.retrieve("ドウデュースの主な勝ち鞍は?")

for node in nodes:
    print(node.text)

ドウデュース -> 位置づけられた -> 第15位タイ
ドウデュース -> 評価され -> 日本ダービー
ドウデュース -> 勝ちタイムは -> 2分20秒6
ドウデュース -> 勝ちタイムは -> コースレコードは2018年ジャパンカップでアーモンドアイが記録した2分20秒6
ドウデュース -> 勝ちタイムは -> 2分21秒9のダービーレコード
ドウデュース -> 日本ダービーを勝利した功績を評価された -> ロンジンワールドベストレースホースランキング
ドウデュース -> 凱旋門賞への出走を表明した -> 次走
ドウデュース -> 選択 -> アイビーステークス
ドウデュース -> 勝ち -> ガイアフォース
ドウデュース -> 入厩した -> 友道康夫厩舎
ドウデュース -> 育成の後 -> ノーザンファーム空港牧場
ドウデュース -> 所有 -> 株式会社キーファーズ
ドウデュース -> 生まれた -> 北海道安平町
ドウデュース -> 失速 -> 後方
ドウデュース -> 勝利 -> グランシエロ
ドウデュース -> 出走 -> 朝日杯フューチュリティステークス
ドウデュース -> 出走 -> 2歳新馬戦
ドウデュース -> は -> 競走馬

あまり違いがなくてわかりにくいのだけど、トレースを見る限り、シンプルにクエリでベクトル検索しているように見える。

TextToCypherRetriever

ここはドキュメントどおりに実行するとなぜかエラーになるのと、デフォルトの設定の説明が中途半端に説明していたりしていなかったり、ってのがものすごく曖昧に思えたので、分解して書き直した。

from llama_index.core.indices.property_graph import TextToCypherRetriever
from llama_index.core.prompts import PromptTemplate

DEFAULT_RESPONSE_TEMPLATE = (
    "生成されたCypherクエリ:\n{query}\n\n"
    "Cypherの結果:\n{response}"
)
DEFAULT_ALLOWED_FIELDS = ["text", "label", "type"]

# text_to_cypher_templateは、index.property_graph_store.text_to_cypher_templateでアクセスできるが、説明のため分解。
DEFAULT_TEXT_TO_CYPHER_PROMPT = """\
タスク:グラフデータベースに問い合わせるためのCypherステートメントを生成する。
指示:
スキーマで提供されているリレーションタイプとプロパティのみを使用すること。
提供されていない他のリレーションタイプやプロパティは使用しないこと。
スキーマ:
{schema}
注意:回答に説明や謝罪を含めないこと。
Cypherステートメントを作成すること以外を問うような質問には回答しないこと。
生成されたCypher文以外のテキストを含めないこと。

質問:
{question}\
"""
DEFAULT_TEXT_TO_CYPHER_TEMPLATE = PromptTemplate(DEFAULT_TEXT_TO_CYPHER_PROMPT)


cypher_retriever = TextToCypherRetriever(
    index.property_graph_store,
    # LLMのカスタマイズ。デフォルトはSettings.llm。
    llm=llm,
    # テキストをCypherに変換するプロンプトテンプレート。
    # テンプレート引数`schema`と`question`が必須
    text_to_cypher_template=DEFAULT_TEXT_TO_CYPHER_TEMPLATE,
    # Cypherの結果がどのようにテキストノードに挿入されるかのプロンプトテンプレート
    # テンプレート引数`query`と`response`が必須
    response_template=DEFAULT_RESPONSE_TEMPLATE,
    # 生成されたCypherをクリーニング/検証するためのオプションのcallable
    cypher_validator=None,
    # 結果で許可されるフィールド
    allowed_output_field=DEFAULT_ALLOWED_FIELDS,
)

retriever = index.as_retriever(
    sub_retrievers=[cypher_retriever],
)

retrieval

生成されたCypherクエリ:
MATCH (n:Entity {id: 'ドウデュース'})-[:HAS_TRIPLET]->(t:Triplet)
RETURN t.win_title

Cypherの結果:
[]

んー、該当するノードが得られていないように見える。生成されたCypherクエリをNeo4j側で入力してみたけども、Warningが出るし、結果も得られていない。

gpt-3.5-turboじゃ理解度が足りないのかなと思って、gpt-4やgpt-4oにしてみたり、プロンプトもデフォルトのものを設定するようにしてみたけど、むしろ悪化というかエラーになってしまう。

CypherSyntaxError: {code: Neo.ClientError.Statement.SyntaxError} {message: Invalid input '`cypher
MATCH (e:entity {name: 'ドウデュース'})-[:RELATIONSHIP_TYPE]->(c:Chunk)
RETURN c.text
`': expected
  "ALTER"
  "CALL"
  "CREATE"
  "DEALLOCATE"
  "DELETE"
  "DENY"
  "DETACH"
  "DROP"
  "DRYRUN"
  "ENABLE"
  "FINISH"
  "FOREACH"
  "GRANT"
  "INSERT"
  "LOAD"
  "MATCH"
  "MERGE"
  "NODETACH"
  "OPTIONAL"
  "REALLOCATE"
  "REMOVE"
  "RENAME"
  "RETURN"
  "REVOKE"
  "SET"
  "SHOW"
  "START"
  "STOP"
  "TERMINATE"
  "UNWIND"
  "USE"
  "USING"
  "WITH" (line 1, column 1 (offset: 0))
"```cypher"
 ^}

コード追っかけていくとTEMPLATETEMPALTEとかtypoしてたりとかもしてて(ただ全部typoしてるので問題なさそうに見える)、ここはちょっと品質的にどうなのかなという感がある。

CypherTemplateRetriever

まるっとCypherクエリを生成させるってのはうまくいかなかったが、じゃあある程度テンプレートにしたがってならできるだろうということでこちら。

from pydantic.v1 import BaseModel, Field
from llama_index.core.indices.property_graph import CypherTemplateRetriever

# テンプレートパラメータを使ってクエリを書く
cypher_query = """
MATCH (c:Chunk)-[:MENTIONS]->(o)
WHERE o.name IN $names
RETURN c.text, o.name, o.label;
"""


# クエリのパラメータを表現するためにpydanticクラスを作成する
# クラスのフィールドはCypherクエリの実行時のパラメータとして直接使用される
class TemplateParams(BaseModel):
    """Cypherクエリ用のテンプレートパラメータ"""

    names: list[str] = Field(
        description="ナレッジグラフの検索に使用するための、エンティティの名前やキーワードのリスト"
    )


template_retriever = CypherTemplateRetriever(
    index.property_graph_store,
    llm=llm,
    output_cls=TemplateParams,
    cypher_query=cypher_query
)

retriever = index.as_retriever(
    sub_retrievers=[template_retriever],
)

retrieval

nodes = retriever.retrieve("ドウデュースの主な勝ち鞍は?")

for node in nodes:
    print(node)

Node ID: 13e10fbe-f86d-4a48-a1d1-0d059136c5f9
Text: [{'c.text': 'ジャックドールによる前半1000m通過57秒7という数字は、前年(2022年)のパンサラッサの大逃げに
よる同57秒4とコンマ3秒しか違わず、前年度と同じように全体的にタフな流れであった。前年と同じようなレース展開に対して、イクイノックスは、昨
年は中団馬群の後方から追い込み勝利したのに対し、今年はジャックドール、ガイアフォースの後ろの3番手でレースを進めた。イクイノックスが直線半ば
で先頭に立ったのと対照的に、直後にいたドウデュース(7着)やヒシイグアス(9着)らは失速し後方へ下がった。2着のジャスティンパレス、3着のプ
ログノーシスは最後方にいた2頭であり、本来は典型的な追い込み決着となるはずであったレースにおいて、イクイノックスだけが先行して勝利したことか
ら、よ...
Score: 1.000

まあここはCypherをちゃんと定義しないと正しいものは取れないよな。ここまで簡素化しちゃうとさすがにクエリの意味がなくなっている。

kun432kun432

所感

とりあえずざっと動かしてみて、個人的に感じたメリット・デメリット。あくまでも個人の意見

メリット

  • ドキュメントからのグラフ抽出を行うkg_extractors、グラフ検索で使えるretrieverが共に「複数」を「組み合わせて」使える。ベクトル検索とも併用できるのは良い。自分はハイブリッド的なイメージを持った。
  • SchemaLLMPathExtractorはラベリング頑張れば細かくカスタマイズができそうな気がする
  • これらの点については既存のKnowledgeGraphIndexに比べると柔軟になったのではないかと思う。

デメリット

  • 結局のところ、モデリングが大変というのはこれまでと変わらない、というかむしろできることが増えたのでさらに大変になった気がする。ユースケースを選びそう。
    • すくなくとも雑にドキュメントを大量にいれてRAGで、、、みたいなケースではしんどそう。
  • kg_extractors、retrieverがそれぞれ「複数」「組み合わせて」使える分、バリエーションが増えている
    • それぞれ単体で挙動を確認した上で、組み合わせての精度などを評価しておく必要がありそう
  • 前提としてナレッジグラフの知識が以前よりも必要になってる気がする。Cypherとか。
  • ちょっとドキュメントやコードの品質が一部怪しい印象を持った。

ナレッジグラフってかなり手間がかかる割にそれに見合った精度が得られているか?というところについては、少なくとも現時点の自分は懐疑的。RAGのドキュメントの場合、ボリュームとかバリエーションが多いし、これを表現するにはRDFでは表現力が足りない、じゃあプロパティグラフならクリアできるか?というとクリアできるのかもしれないけどそれはそれで大変だよね、という。

なのでこれに見合うユースケースがあるならばいいんだろうとは思う、どんなユースケースがそうなのか、自分の知識や経験はそれほど多くはないので、全然思いつかないのだけど。

あと、なんとなく思ってるのは、グラフ側の表現力を上げるアプローチ、よりも、元のドキュメントの前/後処理的なものをきちんとやってRDFでもちゃんと表現できるようにしてからナレッジグラフにいれたらいいんじゃないかなぁという気がする。個人的にはDense X Retrievalみたいなので非コンテキスト化した上でグラフ作るとかが良いのではないかなぁという気がしている。あまり枝分かれしないグラフになりそうな懸念もあるけど。

https://zenn.dev/kun432/scraps/a3cc525a092f20

なのでLlamaIndexの実装みたいにドキュメントからまるっと簡単にグラフ作成ってのは正直精度に限界があると思う。

kun432kun432

会話メモリとかで使うのはいいと思う。シンプルに柔軟に表現できそうな印象がある、しらんけど。

kun432kun432

多分、自分の中でナレッジグラフに対する期待値が高すぎるのだと思う。

このスクラップは5ヶ月前にクローズされました