123 Views
June 22, 25
スライド概要
JAWS-UG AI/ML #27 2025/6/23 LT
Qiita や Zenn でいろいろ書いてます。 https://qiita.com/hmatsu47 https://zenn.dev/hmatsu47 MySQL 8.0 の薄い本 : https://github.com/hmatsu47/mysql80_no_usui_hon Aurora MySQL v1 → v3 移行計画 : https://zenn.dev/hmatsu47/books/aurora-mysql3-plan-book https://speakerdeck.com/hmatsu47
LlamaIndex の Property Graph Index を PostgreSQL 上に構築して データ構造を見てみる JAWS-UG AI/ML #27 2025/6/23 まつひさ(hmatsu47)
自己紹介 松久裕保(@hmatsu47) ● https://qiita.com/hmatsu47 ● 現在: ○ 名古屋で Web インフラのお守り係をしています ○ SRE チームに所属しつつ技術検証の支援をしています ○ 普段カンファレンス・勉強会では DB の話しかしていません (ほぼ) 2
本日の内容 ● LlamaIndex の Property Graph Index ○ Bedrock ナレッジベースの GraphRAG とよく比較される ● PostgreSQL 上に構築 ○ TiDB 用の実装を Amazon Q Developer の力を借りて移植 ● サンプル文書のインデックスを作成し生成されたデータ の内容を確認 ● 検索時にデータがどのように使われるかを確認 3
なぜこの話を? ● 多くの人にとってグラフデータベースは馴染みがない ○ Neo4j や Neptune などを使っている人はそんなに多くないはず ● RDBMS なら多くの人が使っている ○ 少しはとっつきやすい? ○ RDBMS のテーブル上にグラフ構造を展開したほうがイメージが つきやすいかも? 4
おことわり ● RAG および GraphRAG については説明しません ○ おそらく前のほうの LT で説明されているはずなので ● ベクトル検索による RAG との比較についても触れません ○ 同上 5
LLamaIndex ● 主に RAG 向けの検索インデックス用フレームワーク ○ LangChain と比較されることがよくあるが、LangChain よりも インデックスに特化 ■ インデックスの構築と検索(retrieve)・データの投入など 6
Property Graph Index ● プロパティグラフで構成されるインデックス ○ ノードとエッジ(リレーション)で構成 ■ エッジは方向性をもった矢印で表現(有向グラフ) ■ ノードとエッジはラベル(カテゴリ・タイプ)とプロパティ(メタデータ) を持つことが可能 ○ 様々な情報を格納できるが、デフォルト(SimpleLLMPathExtractor & ImplicitPathExtractor)ではトリプレット(主語・述語・目的語)と、 文章チャンクの接続関係がインデックスに展開される 7
ただし PostgreSQL + pgvector は非対応なので ● Amazon Q Developer GitHub 統合で TiDB 用を移植 ○ トークン数の限界、過去作業に関するコンテキスト引き継ぎなど でそこそこ苦労 ■ 詳細は省略 8
文書のチャンク化→グラフ化 ● 1,000 文字前後(デフォルト)の文章に分割して保存 ○ 1 文書あたり 1 つの親(node)ノードを生成 ○ チャンク化した文章を text_chunk ノードとして保存 ● チャンクの接続関係(前後・親)をグラフ化 ○ text_chunk ノードから親ノードを指す SOURCE エッジを生成 ○ text_chunk ノードに保存された文章の前後関係を表す PREVIOUS / NEXT エッジを生成 9
トリプレットの抽出 ● チャンク化した文章から「主語+述語+目的語」の組み 合わせをいくつか抽出 ○ 主語と目的語を entity ノードとして個別に保存 ○ 主語・述語・目的語の関係性をエッジとして保存 ○ 抽出元の文章チャンクを示す ID(識別子)をノード・エッジそれぞ れのプロパティに記録 私 食べる パン 10
各ノードに埋め込みベクトルを保存 ● ベクトル検索用の埋め込みベクトルを保存 ○ text_chunk ノードには文章チャンクの埋め込みベクトル ○ entity ノードにはキーワード(主語・目的語)の埋め込みベクトル ○ node ノードには保存せず(null) 11
サンプルデータを投入して試してみた ● LlamaIndex のサンプル文書の日本語訳 ○ ポール・グレアムのエッセイ ■ https://github.com/hmatsu47/llama_index_property_graph_test/blob/main/dat a/example_ja.txt 12
サンプルデータを投入して試してみた ● Streamlit で単答チャットアプリ化 13
実際のテーブル構成 postgres=# \x auto Expanded display is used automatically. postgres=# \d List of relations Schema | Name | Type | Owner --------+---------------------+----------+---------public | pg_nodes | table | postgres public | pg_relations | table | postgres public | pg_relations_id_seq | sequence | postgres (3 rows) 14
ノード用テーブル(pg_nodes)の定義 postgres=# \d pg_nodes Table "public.pg_nodes" Column | Type | Collation | Nullable | Default ------------+-----------------------------+-----------+----------+--------id | character varying(512) | | not null | text | text | | | name | character varying(512) | | | label | character varying(512) | | not null | properties | jsonb | | | embedding | vector(1024) | | | created_at | timestamp without time zone | | not null | now() updated_at | timestamp without time zone | | not null | now() Indexes: "pg_nodes_pkey" PRIMARY KEY, btree (id) Referenced by: TABLE "pg_relations" CONSTRAINT "pg_relations_source_id_fkey" FOREIGN KEY (source_id) REFERENCES pg_nodes(id) TABLE "pg_relations" CONSTRAINT "pg_relations_target_id_fkey" FOREIGN KEY (target_id) REFERENCES pg_nodes(id) ノードは埋め込みベクトル を持てる 15
エッジ用テーブル(pg_relations)の定義 postgres=# \d pg_relations Table "public.pg_relations" Column | Type | Collation | Nullable | Default ------------+-----------------------------+-----------+----------+----------------------------------------id | integer | | not null | nextval('pg_relations_id_seq'::regclass) label | character varying(512) | | not null | source_id | character varying(512) | | | target_id | character varying(512) | | | properties | jsonb | | | created_at | timestamp without time zone | | not null | now() updated_at | timestamp without time zone | | not null | now() Indexes: "pg_relations_pkey" PRIMARY KEY, btree (id) Foreign-key constraints: "pg_relations_source_id_fkey" FOREIGN KEY (source_id) REFERENCES pg_nodes(id) "pg_relations_target_id_fkey" FOREIGN KEY (target_id) REFERENCES pg_nodes(id) 16
ノード用テーブルに含まれる label(タイプ)の内訳 postgres=# SELECT label, COUNT(*) AS label_count FROM pg_nodes GROUP BY label ORDER BY label; label | label_count ------------+------------entity | 242 node | 1 text_chunk | 20 (3 rows) node は 1 文書あたり 1 行(レコード) text_chunk は文章をチャンク化(分割)したもの (親は node になる) 17
node 行(レコード)の例
postgres=# SELECT id, length(text) AS text_length, name, label, properties, (embedding IS NOT NULL) AS
embedding_exists, created_at, updated_at FROM pg_nodes WHERE label = 'node';
-[ RECORD 1 ]----+------------------------------------id
| c29a6201-5921-4a01-bf6c-5cbf13f246dd
text_length
|
name
|
label
| node
properties
| {}
embedding_exists | f
created_at
| 2025-06-21 13:47:11.327101
updated_at
| 2025-06-21 13:47:11.327101
埋め込みベクトルを
文章チャンクは持たない
持たない
埋め込みベクトルも
持たない
18
text_chunk 行(レコード)の例
postgres=# SELECT id, length(text) AS text_length, name, label, properties, (embedding IS NOT NULL) AS
embedding_exists, created_at, updated_at FROM pg_nodes WHERE label = 'text_chunk' ORDER BY created_at LIMIT
1;
-[ RECORD 1 ]----+------------------------------------------------------------------------------------(略)
id
| 74b585c0-6889-46eb-9c3c-75d4e68dae78
text_length
| 975
name
|
label
| text_chunk
properties
| {"doc_id": "c29a6201-5921-4a01-bf6c-5cbf13f246dd", (略)}
embedding_exists | t
created_at
| 2025-06-21 13:47:09.82389
updated_at
| 2025-06-21 13:47:09.835153
文章チャンクを持つ
埋め込みベクトルを
nameは持たない
持たない
文章チャンクの
埋め込みベクトルを持つ
19
文章チャンク関連のエッジ行の内訳 postgres=# SELECT COUNT(*) FROM pg_relations; count ------253 (1 row) postgres=# SELECT label, COUNT(label) FROM pg_relations WHERE label IN('SOURCE', 'PREVIOUS', 'NEXT') GROUP BY label ORDER BY label; label | count ----------+------NEXT | 19 PREVIOUS | 19 SOURCE | 20 (3 rows) 文章チャンク関連の エッジの数 20
子(チャンク)→親を示すエッジ行(レコード)の例
postgres=# SELECT id, label, source_id, target_id, properties, created_at, updated_at FROM pg_relations
WHERE label = 'SOURCE' ORDER BY created_at LIMIT 2;
-[ RECORD 1 ]-----------------------------------------------------------------------------------------(略)
id
| 11
label
| SOURCE
source_id | 74b585c0-6889-46eb-9c3c-75d4e68dae78
target_id | c29a6201-5921-4a01-bf6c-5cbf13f246dd
properties | {(略), "triplet_source_id": "74b585c0-6889-46eb-9c3c-75d4e68dae78", (略)}
created_at | 2025-06-21 13:47:11.329644
updated_at | 2025-06-21 13:47:11.331238
-[ RECORD 2 ]-----------------------------------------------------------------------------------------(略)
id
| 22
label
| SOURCE
source_id | 927e5ae7-a57b-4681-8737-86fc99fa2cb8
target_id | c29a6201-5921-4a01-bf6c-5cbf13f246dd
properties | {(略), "triplet_source_id": "927e5ae7-a57b-4681-8737-86fc99fa2cb8", (略)}
created_at | 2025-06-21 13:47:11.403122
updated_at | 2025-06-21 13:47:11.407789
親(node)のIDは同じ
21
チャンクの前後関係を示すエッジ行(レコード)の例
postgres=# SELECT id, label, source_id, target_id, properties, created_at, updated_at FROM pg_relations
WHERE label = 'PREVIOUS' ORDER BY created_at LIMIT 2;
-[ RECORD 1 ]-----------------------------------------------------------------------------------------(略)
id
| 23
label
| PREVIOUS
source_id | 927e5ae7-a57b-4681-8737-86fc99fa2cb8
target_id | 74b585c0-6889-46eb-9c3c-75d4e68dae78
properties | {(略), "triplet_source_id": "927e5ae7-a57b-4681-8737-86fc99fa2cb8", (略)}
created_at | 2025-06-21 13:47:11.409412
updated_at | 2025-06-21 13:47:11.413127
-[ RECORD 2 ]-----------------------------------------------------------------------------------------(略)
id
| 36
label
| PREVIOUS
source_id | d5580129-a61c-41db-8003-25187e473c0b
target_id | 927e5ae7-a57b-4681-8737-86fc99fa2cb8
properties | {(略), "triplet_source_id": "d5580129-a61c-41db-8003-25187e473c0b", (略)}
created_at | 2025-06-21 13:47:11.488719
updated_at | 2025-06-21 13:47:11.493809
1つ前のチャンクのID
22
文章チャンクのグラフ構造 ● node(黄)が中心にある ○ 全ての text_chunk の親 23
ノードに含まれる entity 行(レコード)の例
postgres=# SELECT id, length(text) AS text_length, name, label, properties, (embedding IS NOT NULL) AS
embedding_exists, created_at, updated_at FROM pg_nodes WHERE label = 'entity' ORDER BY created_at LIMIT 2;
-[ RECORD 1 ]----+------------------------------------------------------------------------------------(略)
id
| 私
text_length
|
name
| 私
label
| entity
properties
| {(略), "triplet_source_id": "64ce47cd-969f-4bdc-9eda-ee18e7caf20c", (略)}
embedding_exists | t
created_at
| 2025-06-21 13:47:09.913373
updated_at
| 2025-06-21 13:47:10.518213
-[ RECORD 2 ]----+------------------------------------------------------------------------------------(略)
id
| 文章を書くこと
text_length
|
name
| 文章を書くこと
label
| entity
properties
| {(略), "triplet_source_id": "1775422f-573d-4ade-8fce-50a4fcf1a463", (略)}
embedding_exists | t
created_at
| 2025-06-21 13:47:09.916022
updated_at
| 2025-06-21 13:47:10.570029
単語(主語・目的語)を主キー(id)に
→同じ単語が複数登録されることはない
24
ノードに含まれる entity 行(レコード)の例
postgres=# SELECT id, length(text) AS text_length, name, label, properties, (embedding IS NOT NULL) AS
embedding_exists, created_at, updated_at FROM pg_nodes WHERE label = 'entity' ORDER BY created_at LIMIT 2;
-[ RECORD 1 ]----+------------------------------------------------------------------------------------(略)
id
| 私
text_length
|
name
| 私
label
| entity
properties
| {(略), "triplet_source_id": "64ce47cd-969f-4bdc-9eda-ee18e7caf20c", (略)}
embedding_exists | t
created_at
| 2025-06-21 13:47:09.913373
updated_at
| 2025-06-21 13:47:10.518213
-[ RECORD 2 ]----+------------------------------------------------------------------------------------(略)
id
| 文章を書くこと
text_length
|
name
| 文章を書くこと
label
| entity
properties
| {(略), "triplet_source_id": "1775422f-573d-4ade-8fce-50a4fcf1a463", (略)}
embedding_exists | t
created_at
| 2025-06-21 13:47:09.916022
updated_at
| 2025-06-21 13:47:10.570029
同じ単語が別の文章チャンクに出てきたら
どんどん上書き(UPSERT)される
25
ノードに含まれる entity 行(レコード)の例
postgres=# SELECT id, length(text) AS text_length, name, label, properties, (embedding IS NOT NULL) AS
embedding_exists, created_at, updated_at FROM pg_nodes WHERE label = 'entity' ORDER BY created_at LIMIT 2;
-[ RECORD 1 ]----+------------------------------------------------------------------------------------(略)
id
| 私
text_length
|
name
| 私
label
| entity
properties
| {(略), "triplet_source_id": "64ce47cd-969f-4bdc-9eda-ee18e7caf20c", (略)}
embedding_exists | t
created_at
| 2025-06-21 13:47:09.913373
updated_at
| 2025-06-21 13:47:10.518213
-[ RECORD 2 ]----+------------------------------------------------------------------------------------(略)
id
| 文章を書くこと
text_length
|
name
| 文章を書くこと
label
| entity
properties
| {(略), "triplet_source_id": "1775422f-573d-4ade-8fce-50a4fcf1a463", (略)}
embedding_exists | t
created_at
| 2025-06-21 13:47:09.916022
updated_at
| 2025-06-21 13:47:10.570029
nameを持つ(idと同じ)
26
ノードに含まれる entity 行(レコード)の例
postgres=# SELECT id, length(text) AS text_length, name, label, properties, (embedding IS NOT NULL) AS
embedding_exists, created_at, updated_at FROM pg_nodes WHERE label = 'entity' ORDER BY created_at LIMIT 2;
-[ RECORD 1 ]----+------------------------------------------------------------------------------------(略)
id
| 私
text_length
|
name
| 私
label
| entity
properties
| {(略), "triplet_source_id": "64ce47cd-969f-4bdc-9eda-ee18e7caf20c", (略)}
embedding_exists | t
created_at
| 2025-06-21 13:47:09.913373
updated_at
| 2025-06-21 13:47:10.518213
-[ RECORD 2 ]----+------------------------------------------------------------------------------------(略)
id
| 文章を書くこと
text_length
|
name
| 文章を書くこと
label
| entity
properties
| {(略), "triplet_source_id": "1775422f-573d-4ade-8fce-50a4fcf1a463", (略)}
embedding_exists | t
created_at
| 2025-06-21 13:47:09.916022
updated_at
| 2025-06-21 13:47:10.570029
id:1「私」と id:2「文章を書くこと」が
埋め込みベクトル化されている
27
トリプレットを示すエッジ行(レコード)の例
postgres=# SELECT id, label, source_id, target_id, properties, created_at, updated_at FROM pg_relations
ORDER BY created_at LIMIT 2;
-[ RECORD 1 ]-----------------------------------------------------------------------------------------(略)
id
| 1
label
| 取り組んできた
source_id | 私
target_id | 文章を書くこと
properties | {(略), "triplet_source_id": "74b585c0-6889-46eb-9c3c-75d4e68dae78", (略)}
created_at | 2025-06-21 13:47:11.275447
updated_at | 2025-06-21 13:47:11.282648
-[ RECORD 2 ]-----------------------------------------------------------------------------------------(略)
id
| 2
label
| 取り組んできた
source_id | 私
target_id | プログラミング
properties | {(略), "triplet_source_id": "74b585c0-6889-46eb-9c3c-75d4e68dae78", (略)}
created_at | 2025-06-21 13:47:11.284701
updated_at | 2025-06-21 13:47:11.287974
idはシーケンス値
→同じ組み合わせのトリプレットが複数存在し
うる(別の文章チャンクから抽出した場合)
28
トリプレットを示すエッジ行(レコード)の例
postgres=# SELECT id, label, source_id, target_id, properties, created_at, updated_at FROM pg_relations
ORDER BY created_at LIMIT 2;
-[ RECORD 1 ]-----------------------------------------------------------------------------------------(略)
id
| 1
label
| 取り組んできた
source_id | 私
target_id | 文章を書くこと
properties | {(略), "triplet_source_id": "74b585c0-6889-46eb-9c3c-75d4e68dae78", (略)}
created_at | 2025-06-21 13:47:11.275447
updated_at | 2025-06-21 13:47:11.282648
-[ RECORD 2 ]-----------------------------------------------------------------------------------------(略)
id
| 2
label
| 取り組んできた
source_id | 私
target_id | プログラミング
properties | {(略), "triplet_source_id": "74b585c0-6889-46eb-9c3c-75d4e68dae78", (略)}
created_at | 2025-06-21 13:47:11.284701
updated_at | 2025-06-21 13:47:11.287974
同じ組み合わせが別の文章チャンクに現れ
ても上書き(UPSERT)されない
29
トリプレットのグラフ構造(一部) ● 「私」を中心に見てみる 30
検索時(デフォルトの Retriever 構成) ● LLM に渡すコンテキストをグラフストアで検索・取得 ○ VectorContextRetriever で entity ノードをベクトル検索 ■ ベクトル類似度の高い entity ノードの単語を含むトリプレットを取得 ■ あわせてトリプレット抽出元の text_chunk ノードを取得 ○ LLMSynonymRetriever で類義語を複数(デフォルト 10 個)生成 し、それらを使って entity ノードを主キー検索 ■ 同じ主キー値を持つ entity ノードの単語を含むトリプレットを取得 ■ あわせてトリプレット抽出元の text_chunk ノードを取得 31
検索時(デフォルトの Retriever 構成) ● 取得したトリプレットと文章チャンクをコンテキストと して付加して質問文を LLM に送信 ○ ここから先は通常の RAG と同じ ● 文章チャンクのグラフ構造は使用していない(おそらく) ○ トリプレットのエッジに保存された ID を使って text_chunk ノードを取得してコンテキストとして使っているのみ 32
実際の送信プロンプト例 ● 質問文「学生時代にしたことは?」 Context information is below. --------------------file_path: (略) 検索・取得したトリプレット Here are some facts extracted from the provided text: 卒業証書 -> 記載 -> Artificial intelligence 学生 -> 独学 -> 問題なかった 学生 -> 意識 -> 進むべき道 (略) 授業の中でではなく、独学という形ではあったが、それでも問題なかった。この数年間、私は自分が進むべき道をはっきりと意識していた。 検索・取得した文章チャンク 学部の卒業論文では、SHRDLUをリバースエンジニアリングした。私はこのプログラムを作ることが本当に好きだった。 (略) --------------------Given the context information and not prior knowledge, answer the query. Query: 学生時代にしたことは? Answer: 質問文 33
試してみた感想 ● 応答内容が絞り込まれている印象 ○ ハルシネーションが軽減される代わりに少しそっけない? ■ プロンプトとパラメータのチューニング次第? →取得トリプレット数や辿るグラフ階層の数、取得チャンク数など 34
試してみた感想 ● 応答が少し遅い ○ LLMSynonymRetriever で類義語抽出を LLM にさせている部分 の待ち時間が余分にかかっている ■ 今回のケースではあまり有効に機能していない様子だったので LLMSynonymRetriever を外しても良かったかも? 35
試してみた感想 ● 条件次第で RDBMS もグラフストアとして使用可能? ○ 辿るグラフ階層数が 1(デフォルト)であれば通常の JOIN で十分 ■ 2 〜 3 階層になってくるとエッジの数が格段に増えそうなので厳しい? 36
参考(今回使ったコードなど) ● GitHub リポジトリ ○ https://github.com/hmatsu47/llama-index-graph-stores-postgres ○ https://github.com/hmatsu47/llama_index_property_graph_test ○ https://github.com/hmatsu47/llama_index/issues?q=is%3Aissue%20state %3Aclosed 37