Upgrade to Pro — share decks privately, control downloads, hide ads and more …

PostgreSQL でもできる!GraphRAG

Avatar for hmatsu47 hmatsu47 PRO
September 11, 2025

PostgreSQL でもできる!GraphRAG

第 35 回 中国地方 DB 勉強会 in 岡山 2025/9/13

Avatar for hmatsu47

hmatsu47 PRO

September 11, 2025
Tweet

More Decks by hmatsu47

Other Decks in Technology

Transcript

  1. 自己紹介 松久裕保(@hmatsu47) • https://qiita.com/hmatsu47 • 現在: ◦ 名古屋で Web インフラのお守り係をしています

    ◦ SRE チームに所属しつつ AI 導入の後方支援などをしています ◦ 来月(10/11)開催の JAWS FESTA 2025 in 金沢で Aurora DSQL のトランザクションとの上手な付き合い方に関する話をします 2
  2. RAG(Retrieval Augmented Generation:検索拡張生成) • LLM が学習していない(弱い)知識を補う ◦ そのような知識について単純に質問すると、LLM は正しい答えを 返せない

    ◦ 質問に関連する知識を DB などから検索・取得し、コンテキスト として LLM に与えると、正しい答えを返せる 5
  3. RAG の種類 [1] Naive RAG • 初期に登場したシンプルな構成の RAG ◦ あらかじめ関連知識として与える文章を分割(チャンク化)→ベク

    トル化してベクトルストアに保存しておく ◦ 問い合わせ時には質問文に類似するチャンクをベクトルストアか らベクトル検索によって取得し、コンテキストとして質問文とと もに LLM に渡す 6
  4. Naive RAG の弱点 • チャンク化するときの分割方法が難しい ◦ 細かく分割してしまうと必要な情報を LLM に渡せない ◦

    大きすぎると埋め込みモデルで扱えない・検索精度が落ちる • ソースとなる情報が分散していると取りこぼす ◦ 脚注がある文章や図表・添付資料に補足があるケースなど • 抽出したコンテキストがうまく LLM に伝わらない ◦ 複雑・曖昧な文章をベクトル検索して LLM に渡すケースなど 9
  5. RAG の種類 [2] Advanced RAG • 関連知識の検索(Retrieval)に前処理と後処理を加え精度 を高める ◦ ハイブリッド検索、リランキングなど

    ◦ ほかにもいろいろな方法がある • GraphRAG も Advanced RAG の一種 ◦ グラフインデックスを使う 10
  6. GraphRAG(グラフインデックスを使う RAG) • 関連知識の格納と検索にグラフ構造を利用 ◦ よく使われる(言及される)グラフ構造には以下の 2 種類がある ▪ RDF

    グラフ ▪ プロパティグラフ ◦ よく対比されるが対立する概念ではない(と個人的に思っている) ▪ RDF グラフをプロパティグラフに変換(プロパティグラフ向けの仕組みの上 で RDF グラフに相当するものを表現)することもできる 12
  7. [1] RDF グラフ • RDF(Resource Description Framework) ◦ (元々は)ウェブ上のデータを一貫性のある方法で記述するための フレームワーク

    ◦ 主語・述語・目的語で構成されるトリプレット(トリプル)と呼ば れる構造を使ってデータの関連性を表す • RDF をグラフ化して表すもの→ RDF グラフ 13 私 パン 食べる
  8. 【再掲】Naive RAG の弱点 • チャンク化するときの分割方法が難しい ◦ 細かく分割してしまうと必要な情報を LLM に渡せない ◦

    大きすぎると埋め込みモデルで扱えない・検索精度が落ちる • ソースとなる情報が分散していると取りこぼす ◦ 脚注がある文章や図表・添付資料に補足があるケースなど • 抽出したコンテキストがうまく LLM に伝わらない ◦ 複雑・曖昧な文章をベクトル検索して LLM に渡すケースなど 15
  9. 【再掲】Naive RAG の弱点 • チャンク化するときの分割方法が難しい ◦ 細かく分割してしまうと必要な情報を LLM に渡せない ◦

    大きすぎると埋め込みモデルで扱えない・検索精度が落ちる • ソースとなる情報が分散していると取りこぼす ◦ 脚注がある文章や図表・添付資料に補足があるケースなど • 抽出したコンテキストがうまく LLM に伝わらない ◦ 複雑・曖昧な文章をベクトル検索して LLM に渡すケースなど 16 主に下2つの対処のために GraphRAGを使うと良さそう?
  10. ちなみに [1] • PostgreSQL のグラフ機能といえば ◦ PostGIS 関連の extension に

    pgRouting がある https://pgrouting.org/ ◦ ただし最短経路・最短パス探索に特化しているので GraphRAG 向けには使いづらい 17
  11. ちなみに [2] • Oracle Database 23c にはグラフを扱う機能がある ◦ SQL:2023 にはプロパティグラフ用の

    SQL 構文があり、Oracle Database 23c ではこれをサポート ▪ ISO/IEC 9075-16 によって定義 ▪ https://www.oracle.com/jp/a/ocom/docs/database/operational_property_graph_with_23c- ja.pdf 18
  12. 使ったフレームワークなど • LlamaIndex ◦ 本体(Core) ◦ Property Graph Index ◦

    PostgreSQL+pgvector 向けグラフストア実装 ▪ TiDB 用の実装を移植 • Streamlit ◦ チャット用の UI として 21
  13. Property Graph Index • プロパティグラフで構成されるインデックス ◦ ノードとエッジ(リレーション)で構成 ▪ エッジは方向性をもった矢印で表現(有向グラフ) ▪

    ノードとエッジはラベル(カテゴリ・タイプ)とプロパティ(メタデータ) を持つことが可能 ◦ 様々な情報を格納できるが、デフォルト(SimpleLLMPathExtractor & ImplicitPathExtractor)ではトリプレット(主語・述語・目的語)と、 文章チャンクの接続関係がインデックスに展開される 23
  14. PostgreSQL+pgvector 向けグラフストア実装 • Amazon Q Developer GitHub 統合で TiDB 用を移植

    ◦ AI コーディングエージェント(プレビュー提供中) 25
  15. 移植は思ったより難航 • トークン数の限界、過去作業に関するコンテキスト引き 継ぎなどでそこそこ苦労 ◦ ORM として SQLAlchemy を使っているが、PostgreSQL 用の

    Dialect(方言)と TiDB 用の(外部)Dialect ではベクトルの記述・ 比較演算子に加えて JSON や配列(リスト)値の埋め込み方が違う などのハマりポイントがいくつかあった ▪ JSON は JSONB に、配列を扱う IN 句は ANY に書き換えるなどして対処 ◦ コンテキスト引き継ぎは諦めて都度 Sub-Issue で細かく指示 26
  16. インデックス生成 [1] 文書のチャンク化→グラフ化 • 1,000 文字前後(デフォルト)の文章に分割して保存 ◦ 1 文書あたり 1

    つの親(node)ノードを生成 ◦ チャンク化した文章を text_chunk ノードとして保存 • チャンクの接続関係(前後・親)をグラフ化 ◦ text_chunk ノードから親ノードを指す SOURCE エッジを生成 ◦ text_chunk ノードに保存された文章の前後関係を表す PREVIOUS / NEXT エッジを生成 31
  17. インデックス生成 [2] トリプレットの抽出 • チャンク化した文章から「主語+述語+目的語」の組み 合わせをいくつか抽出 ◦ 主語と目的語を entity ノードとして個別に保存

    ◦ 主語・述語・目的語の関係性をエッジとして保存 ◦ 抽出元の文章チャンクを示す ID(識別子)をノード・エッジそれぞ れのプロパティに記録 32 私 パン 食べる
  18. 実際のテーブル構成 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) 34
  19. ノード用テーブル(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) 35 ノードは埋め込みベクトル を持てる
  20. エッジ用テーブル(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) 36
  21. ノード用テーブルに含まれる 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) 37 node は 1 文書あたり 1 行(レコード) text_chunk は文章をチャンク化(分割)したもの (親は node になる)
  22. 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 38 埋め込みベクトルも 持たない 埋め込みベクトルを 持たない 文章チャンクとnameは 持たない
  23. 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 39 文章チャンクの 埋め込みベクトルを持つ 埋め込みベクトルを 持たない 文章チャンクを持つ nameは持たない
  24. 文章チャンク関連のエッジ行の内訳 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) 40 文章チャンク関連の エッジの数
  25. チャンクの前後関係を示すエッジ行(レコード)の例 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 41 1つ前のチャンクの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 43 単語(主語・目的語)を主キー(id)に →同じ単語が複数登録されることはない
  27. ノードに含まれる 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 44 nameを持つ(idと同じ)
  28. ノードに含まれる 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 45 id:1「私」と id:2「文章を書くこと」が 埋め込みベクトル化されている
  29. トリプレットを示すエッジ行(レコード)の例 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 46 idはシーケンス値 →同じ組み合わせのトリプレットが複数存在し  うる(別の文章チャンクから抽出した場合)
  30. 検索時(デフォルトの Retriever 構成) • LLM に渡すコンテキストをグラフストアで検索・取得 ◦ VectorContextRetriever で entity

    ノードをベクトル検索 ▪ ベクトル類似度の高い entity ノードの単語を含むトリプレットを取得 ▪ あわせてトリプレット抽出元の text_chunk ノードを取得 ◦ LLMSynonymRetriever で類義語を複数(デフォルト 10 個)生成 し、それらを使って entity ノードを主キー検索 ▪ 同じ主キー値を持つ entity ノードの単語を含むトリプレットを取得 ▪ あわせてトリプレット抽出元の text_chunk ノードを取得 48
  31. entity ノードをベクトル検索(コード関連部分) with Session(self._engine) as session: result = ( session.query(

    self._node_model, self._node_model.embedding.cosine_distance( query.query_embedding ).label("embedding_distance"), ) .filter(self._node_model.name.is_not(None)) .order_by(sql.asc("embedding_distance")) .limit(query.similarity_top_k) .all() ) 49 nameがNone(null)ではないノード →entityノードに限定してベクトル検索
  32. グラフ構造を辿る SQL 文のテンプレート 50 WITH RECURSIVE PATH AS (SELECT 1

    AS depth, r.source_id, r.target_id, r.label, r.properties FROM {relation_table} r WHERE r.source_id = ANY(:ids) UNION ALL SELECT p.depth + 1, r.source_id, r.target_id, r.label, r.properties FROM PATH p JOIN {relation_table} r ON p.target_id = r.source_id WHERE p.depth < :depth ) (右上に続く) (左下から続く) SELECT e1.id AS e1_id, e1.name AS e1_name, e1.label AS e1_label, e1.properties AS e1_properties, p.label AS rel_label, p.properties AS rel_properties, e2.id AS e2_id, e2.name AS e2_name, e2.label AS e2_label, e2.properties AS e2_properties FROM PATH p JOIN {node_table} e1 ON p.source_id = e1.id JOIN {node_table} e2 ON p.target_id = e2.id ORDER BY p.depth LIMIT :limit; 再帰CTE (共通テーブル式)
  33. LLM に送信 • 取得したトリプレットと文章チャンクをコンテキストと して付加して質問文を LLM に送信 ◦ ここから先は通常の RAG

    と同じ • 文章チャンクのグラフ構造は使用していない(おそらく) ◦ トリプレットのエッジに保存された ID を使って text_chunk ノードを取得してコンテキストとして使っているのみ 51
  34. 実際の送信プロンプト例(コンテキストと質問文) • 質問文「学生時代にしたことは?」 52 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: 検索・取得したトリプレット 検索・取得した文章チャンク 質問文
  35. 試してみた感想 • 応答が少し遅い ◦ LLMSynonymRetriever で類義語抽出を LLM にさせている部分 の待ち時間が余分にかかっている ▪

    今回のケースではあまり有効に機能していない様子だったので LLMSynonymRetriever を外しても良かったかも? 54