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

PostgreSQL+pgvector で GraphRAG に挑戦 & pgvectors...

PostgreSQL+pgvector で GraphRAG に挑戦 & pgvectorscale 0.7.x アップデート

第 53 回 PostgreSQL アンカンファレンス@オンライン 2025/6/24

Avatar for hmatsu47

hmatsu47

June 24, 2025
Tweet

More Decks by hmatsu47

Other Decks in Technology

Transcript

  1. PostgreSQL+pgvector で GraphRAG に挑戦 & pgvectorscale 0.7.x アップデート 第 53

    回 PostgreSQL アンカンファレンス@オンライン 2025/6/24 まつひさ(hmatsu47)
  2. 自己紹介 松久裕保(@hmatsu47) • https://qiita.com/hmatsu47 • 現在: ◦ 名古屋で Web インフラのお守り係をしています

    ◦ SRE チームに所属しつつ技術検証の支援をしています ◦ 普段カンファレンス・勉強会では DB の話しかしていません (ほぼ) 2
  3. 本日の内容/その 1 • PostgreSQL+pgvector で GraphRAG に挑戦してみた ◦ LlamaIndex にある

    TiDB 用グラフストア実装を AI コーディング エージェントの力を借りて PostgreSQL+pgvector に移植してみ た ◦ サンプルデータを投入して、生成されたデータ構造を眺めてみた 3
  4. ちなみに/その 1 • PostgreSQL のグラフ機能といえば ◦ PostGIS 関連の extension に

    pgRouting がある ▪ https://pgrouting.org/ ◦ ただし最短経路・最短パス探索に特化しているので今回の用途に は使えない 4
  5. ちなみに/その 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 5
  6. 復習/RAG • RAG(Retrieval Augmented Generation:検索拡張生成) ◦ 生成 AI が学習していない(弱い)知識について普通に質問する と、生成

    AI は正しい答えを返せない ◦ 関連知識を DB などから検索して持ってきてコンテキストとして 生成 AI に与えると、正しい答えを返せる ◦ 関連知識を DB で検索するときの標準的な方法はベクトル検索 6
  7. 復習/ベクトル検索 • 意味が近い文章などを探すときに使う ◦ 最近は生成 AI の埋め込みモデルを使って文章などをベクトル化 するのが主流 ◦ 一般的な埋め込みモデルでは長い文章をそのままベクトル化する

    ことはできないので、文章を分割(チャンク化)してチャンクごと にベクトル化して DB に入れる ◦ 質問文もベクトル化して、DB で「距離が近いベクトル」を探す ▪ 生成 AI にコンテキストとして渡すのはベクトルではなくて元の文章など 7
  8. ベクトル検索型 RAG の弱点 • チャンク化するときの分割方法が難しい ◦ 細かく分割してしまうと必要な情報を生成 AI に渡せない ◦

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

    大きすぎると埋め込みモデルで扱えない・検索精度が落ちる • ソースとなる情報が分散していると取りこぼす ◦ 脚注がある文章や図表・添付資料に補足があるケースなど • 抽出したコンテキストがうまく LLM に伝わらない ◦ 複雑・曖昧な文章をベクトル検索して LLM に渡すケースなど 9 主に下2つの対処のために GraphRAGを使うと良さそう?
  10. Property Graph Index • プロパティグラフで構成されるインデックス ◦ ノードとエッジ(リレーション)で構成 ▪ エッジは方向性をもった矢印で表現(有向グラフ) ▪

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

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

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

    チャンク化した文章を text_chunk ノードとして保存 • チャンクの接続関係(前後・親)をグラフ化 ◦ text_chunk ノードから親ノードを指す SOURCE エッジを生成 ◦ text_chunk ノードに保存された文章の前後関係を表す PREVIOUS / NEXT エッジを生成 18
  14. 実際のテーブル構成 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) 21
  15. ノード用テーブル(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) 22 ノードは埋め込みベクトル を持てる
  16. エッジ用テーブル(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) 23
  17. ノード用テーブルに含まれる 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) 24 node は 1 文書あたり 1 行(レコード) text_chunk は文章をチャンク化(分割)したもの (親は node になる)
  18. 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 25 埋め込みベクトルも 持たない 埋め込みベクトルを 持たない 文章チャンクとnameは 持たない
  19. 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 26 文章チャンクの 埋め込みベクトルを持つ 埋め込みベクトルを 持たない 文章チャンクを持つ nameは持たない
  20. 文章チャンク関連のエッジ行の内訳 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) 27 文章チャンク関連の エッジの数
  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 28 1つ前のチャンクのID
  22. ノードに含まれる 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 30 単語(主語・目的語)を主キー(id)に →同じ単語が複数登録されることはない
  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 31 nameを持つ(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 32 id:1「私」と id:2「文章を書くこと」が 埋め込みベクトル化されている
  25. トリプレットを示すエッジ行(レコード)の例 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 33 idはシーケンス値 →同じ組み合わせのトリプレットが複数存在し  うる(別の文章チャンクから抽出した場合)
  26. 検索時(デフォルトの Retriever 構成) • LLM に渡すコンテキストをグラフストアで検索・取得 ◦ VectorContextRetriever で entity

    ノードをベクトル検索 ▪ ベクトル類似度の高い entity ノードの単語を含むトリプレットを取得 ▪ あわせてトリプレット抽出元の text_chunk ノードを取得 ◦ LLMSynonymRetriever で類義語を複数(デフォルト 10 個)生成 し、それらを使って entity ノードを主キー検索 ▪ 同じ主キー値を持つ entity ノードの単語を含むトリプレットを取得 ▪ あわせてトリプレット抽出元の text_chunk ノードを取得 35
  27. 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() ) 36 nameがNone(null)ではないノード →entityノードに限定してベクトル検索
  28. グラフ構造を辿る SQL 文のテンプレート 37 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 (共通テーブル式)
  29. LLM に送信 • 取得したトリプレットと文章チャンクをコンテキストと して付加して質問文を LLM に送信 ◦ ここから先は通常の RAG

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

    今回のケースではあまり有効に機能していない様子だったので LLMSynonymRetriever を外しても良かったかも? 41
  32. pgvectorscale(0.6.x まで) • PostgreSQL+pgvector のベクトル検索機能を強化する ための extension ◦ pgvector と組み合わせて使う

    • StreamingDiskANN インデックスを実装 ◦ Microsoft が開発した DiskANN をベースにしたベクトル検索用 インデックス ◦ https://www.docswell.com/s/hmatsu47/5J4LWM-2024-08-20-013209 45
  33. たとえば • 「グローバルなソフトウェアエンジニア DB(仮) 」 ◦ 約 5,000 万レコード存在 ◦

    紹介文をベクトル検索したい ◦ 居住国・読み書き可能な言語(例:英語)・コーディングが可能な プログラミング言語(例:Java)などの分類で絞り込みたい • このケースでは ◦ 紹介文をベクトル検索してから、分類などで絞り込む流れになる ▪ 分類を先に絞り込んだほうが効率的だったとしても構造上無理 48
  34. Filtered DiskANN インデックス • ラベルを内包した ANN(近似最近傍探索)インデックス ◦ インデックス構造にフィルタ用のラベルを追加 ◦ ChatGPT(o3)先生曰く(論文の内容から)

    DiskANN(グラフ型 ANN インデックス+SSD ストレージ)の派生で、「ラベル(属性)で 絞り込む近似近傍検索」をインデックス構築段階から支援します。具体的には FilteredVamana(逐次構築) StitchedVamana(バッチ構築/サブグラフ合成) の 2 方式を実装し、いずれも Vamana グラフに「ベクトル距離だけでなくラベル共有も考慮 した辺」を張ります。 49
  35. 使い方/テーブル・インデックス作成 • SMALLINT 値の配列でラベルを指定 ◦ 配列構造だが「ラベル」なので「複数列」を示すものではない ▪ 複合インデックスとは違う 51 CREATE

    TABLE documents ( id SERIAL PRIMARY KEY, embedding VECTOR(1536), labels SMALLINT[], -- Array of category labels status TEXT, created_at TIMESTAMPTZ ); CREATE INDEX ON documents USING diskann (embedding vector_cosine_ops, labels);
  36. 使い方/検索 • ベクトル検索とラベルでのフィルタリングの併用例 ◦ ラベルは OR 検索 ◦ 意味づけを行うマスタテーブルのようなものを別に作ると良い ▪

    詳細は pgvectorscale の GitHub リポジトリの説明を参照 • https://github.com/timescale/pgvectorscale?tab=readme-ov-file#giving-semantic-m eaning-to-labels 52 SELECT * FROM documents WHERE labels && ARRAY[1, 3] -- Documents with label 1 OR 3 ORDER BY embedding <=> '[...]' LIMIT 10;
  37. 実際に試…せなかった • 性能比較するにはかなりのデータ量が必要 ◦ メモリを超える量をテストデータとして用意するのはしんどい ▪ Hugging Face で公開されている大容量データを加工するのは大変 ▪

    埋め込みベクトル化など LLM の API 使用料金も地味に嵩む • 代わりに ChatGPT(o3)先生に聞いてみた フィルタで絞り込まれた後のレコード数が数百~数千程度におさまると仮定します。そのケース で、ベクトルインデックスを作らずに事前にほかの属性値で絞り込んで数百~数千程度の少ないレ コード数に対してベクトル距離計算する場合と比較したメリットを教えてください。 53
  38. ChatGPT(o3)先生からの回答(まとめ部分) まとめ — 「いま速い」だけでは足りない場面が多い 1. 高同時接続・多テナントになると 300–2 000 本 ×

    QPS が CPU とメモリ帯域の支配項になる。 Filtered-DiskANN は候補数を 1 桁に抑え、10× 以上の QPS を同じコア数で処理。 2. SSD や分散ストレージでもレイテンシが安定し、キャッシュヒットを前提としない設計が可能。 3. フィルタ選択性が揺らいでも tail-latency を守れる(特異ラベル・複合フィルタ・データ増大)。 4. インデックス構築コストは “StitchedVamana でも 1.5–2×” 程度で、一度組めば ROI が大きい。 結論 「常に 1 000 本以下」という理想状態と単発 APIなら線形スキャンで十分。 しかし スループット・コスト・最悪時レイテンシ・将来拡張まで含めた 運用要件 がある場合、 Filtered-DiskANN は “グラフにラベルを書き込む” だけで そのリスクとコストを丸ごと吸収できます。 54