この記事ではDevOps・自動化文脈で、Postgresに座ったままLLM処理を完結させるトリガー型OSSを扱います。AI時代の自動化ツール全体像は AI自動化ツール完全ガイド2026|ノーコードからコードまで徹底比較 をご覧ください。
30秒で理解するPostgres LLM
Postgres LLMは、Postgresのトリガーを使ってINSERTやUPDATEのタイミングでLLMを呼び出し、結果を同じ行の別カラムへ自動で書き戻すpgSQL関数群だ。JigsawStackが2025年10月にOSS公開し、PLpgSQL で実装された薄いコードベースながらGitHubで28スターを獲得(2026年5月時点)している。
何が新しいか。従来「LLMで行を加工する」処理はアプリ側の責務だった。Pythonワーカーがレコードを取り出し、OpenAI APIを叩き、結果をUPDATEで書き戻す。Postgres LLMはこの3ステップを Postgres本体に閉じ込める。アプリは普通に INSERT INTO user_reviews (review_text) VALUES (...) を発行するだけで、sentiment カラムが数秒後に勝手に埋まる。
INSERT INTO user_reviews (review_text)
VALUES ('I love this hackathon!')
RETURNING *;
-- INSERT直後はsentimentがNULL。
-- 数秒後、pg_cronワーカーが処理してsentiment='positive'を書き戻す。
公式ブログ「Run LLMs Inside Postgres」では設計思想が明示されている。曰く、v2はキュー型に再設計され「writes return instantly, jobs flow through a Postgres-native queue, and a background worker calls the LLM and writes the result back into the row」。INSERTの応答性を犠牲にせず、LLM呼び出しの非同期化と再試行をDBレイヤで完結させる──これが本ツールの核心だ。
「LLM呼び出しコードを書かない」が変える運用負荷
アプリ側でLLM呼び出しを実装すると、リトライ・タイムアウト・APIキー管理・並列度制御・キュー導入・冪等性の全責務がプロダクトコードに乗ってしまう。Postgres LLMはこれらを llm.process_queue() 関数1つに集約し、運用境界をPostgresの内側に引き戻す。Edge Functions/Cron Workerを動かしているチームほど、削れる依存が多い。
なぜトリガー+pg_cron構成なのか:3つの設計判断
Postgres LLMがアプリ層でやらない選択をした理由は、機能の便利さよりも 運用境界をどこに置くか の問題だ。
1. トランザクション一貫性をDBに寄せる
LLM呼び出しを BEFORE INSERT 同期実行にすると、HTTPリクエストの遅延がそのままINSERT文のレスポンスタイムになる。また失敗時のロールバック粒度も曖昧になる。Postgres LLMは AFTER INSERT/UPDATE でジョブを llm.queue に積むだけなので、書き込みトランザクションは即座にコミットでき、LLM処理は別トランザクションで完結する。
2. キューを「ただのテーブル」にする
専用キューサービス(SQS、Redis、RabbitMQ)を導入せず、llm.queue というPostgresテーブル1枚で完結させた点が運用上大きい。status、attempts、last_error を直接 SELECT で確認でき、ダッシュボードも普通のSQLクエリで作れる。バックアップもDB全体に乗る。
3. ワーカーをDB内のpg_cron関数にする
pg_cronはPostgres拡張として動くスケジューラで、ワーカーをホスト側のプロセスとして動かす必要がない。SELECT cron.schedule('llm-worker', '5 seconds', $$SELECT llm.process_queue(20)$$); の1行で5秒ごとに最大20件のジョブを処理し続ける。コンテナ運用やKubernetes側のCronJob定義が不要になる。
運用境界の引き直し
| 構成 | LLM呼び出しコード | 失敗時の責務 | キュー基盤 |
|---|---|---|---|
| アプリ層実装 | アプリ | アプリ | 別途必要(SQS等) |
| Postgres LLM | DB(pgSQL関数) | DB(attempts/last_error) | llm.queue テーブル |
DBに寄せきると、アプリは「INSERTする」役割だけ持てばよく、変更デリバリの単位がスキーママイグレーションに揃う。
アーキテクチャ:3つの拡張機能と非同期キューの流れ
Postgres LLMは3つのPostgres拡張に依存する。init.sql の最初の3行から動作の前提が読める。
CREATE EXTENSION IF NOT EXISTS http; -- HTTP POSTでLLM APIを叩く
CREATE EXTENSION IF NOT EXISTS hstore; -- プロンプト用変数の格納
CREATE EXTENSION IF NOT EXISTS pg_cron; -- ワーカーの定期実行
それぞれの役割は明確だ。http 拡張(pramsey/pgsql-http)はpgSQL関数からHTTPリクエストを発行するために必要。hstore はプロンプトに渡す {column_name} プレースホルダーの値をキー・バリューで保持する。pg_cron はワーカー関数 llm.process_queue() を5秒間隔で呼び出す。
ジョブの流れは次のとおり。
INSERT INTO user_reviews"] --> TRIG["llm.call()
トリガー関数"] TRIG -->|hstore変換
+ PK抽出| QUEUE[("llm.queue
status='pending'")] CRON["pg_cron
5秒間隔"] --> WORKER["llm.process_queue(20)
SKIP LOCKED"] WORKER -->|HTTP POST
response_format=json_schema| LLM["LLM API
Interfaze/OpenAI互換"] LLM -->|JSON結果| WORKER WORKER -->|UPDATE target_column| TABLE[("対象テーブル
user_reviews")] WORKER -->|status='done'| QUEUE WORKER -.->|失敗時| RETRY["attempts++
再キュー or status='error'"]
ポイントは、トリガー関数 llm.call() 自体はHTTPリクエストを発行しないこと。プロンプト本文と参照カラム値、テーブルの主キー値を llm.queue に積んで即座にreturnする。INSERT/UPDATEはこのキューINSERTのコストしか負わない。
トリガー関数の中身:プロンプトプレースホルダーと主キー検出
llm.call() トリガー関数は、テーブル定義に依存せず汎用的に動くよう設計されている。init.sql から要点を抜き出すと次のとおり。
-- プロンプト内の {column_name} を正規表現で抽出
SELECT array_agg(m[1]) INTO matches
FROM regexp_matches(TG_ARGV[0], '\{(\w+)\}', 'g') AS m;
-- 抽出したカラム名が実テーブルに存在するかチェック
IF NOT row_hstore ? col_name THEN
RAISE EXCEPTION 'Column % referenced in prompt does not exist in table %.%',
col_name, TG_TABLE_SCHEMA, TG_TABLE_NAME;
END IF;
-- 主キー列を pg_index から動的に取得
SELECT array_agg(a.attname ORDER BY a.attnum) INTO pk_cols
FROM pg_index i
JOIN pg_attribute a ON a.attrelid = i.indrelid AND a.attnum = ANY(i.indkey)
WHERE i.indrelid = TG_RELID AND i.indisprimary;
挙動は3段階に分けられる。
- プロンプトのカラム参照を抽出:
'Translate {review_text} to Spanish'のような文字列から{review_text}を正規表現で抜き、対応する値をNEWから取り出す。 - 存在しないカラム参照は即エラー:
{nonexistent_column}のようなタイプミスはINSERT時点で例外になる。後段のワーカーで失敗してから気付くのを防ぐ。 - 主キーを動的検出してジョブに添付:
pg_indexカタログから主キー列名を取り出し、row_pkJSONBに格納。ワーカーが結果を書き戻すときのWHERE句に使う。
複合主キーにも対応している点が地味に効いている。pk_cols を配列で持ち、WHERE 句を動的に組み立てるため、(tenant_id, post_id) のような実用的なスキーマでも追加実装なしで動く。
デデュプ機構:連続更新を1回のLLM呼び出しに畳み込む
実運用で問題になりやすいのが「同じ行が立て続けにUPDATEされる」ケースだ。ユーザーがフォームを編集して0.5秒おきにオートセーブする、IoTセンサーが秒間データを書き込む、といった状況で素朴に実装するとLLM呼び出しが爆発する。
Postgres LLMは llm.queue のINSERT前に UPDATE を試みる構造でこれを抑える。
UPDATE llm.queue
SET prompt_values = prompt_vals, created_at = now()
WHERE table_schema = TG_TABLE_SCHEMA
AND table_name = TG_TABLE_NAME
AND row_pk = pk_jsonb
AND status = 'pending';
IF NOT FOUND THEN
INSERT INTO llm.queue (table_schema, table_name, row_pk, prompt_values, prompt, target_columns)
VALUES (TG_TABLE_SCHEMA, TG_TABLE_NAME, pk_jsonb, prompt_vals, TG_ARGV[0], target_columns);
END IF;
つまり、同じ (table, pk) の pending ジョブが既にあれば、新しい値で 上書き するだけで新規INSERTしない。Interfaze公式ブログも「Rapid updates to the same row collapse into a single LLM call against the latest values」と明記しており、最終値だけがLLMに渡る挙動を保証する。
加えて専用インデックスが組まれている。
CREATE INDEX IF NOT EXISTS queue_dedup_idx
ON llm.queue (table_schema, table_name, row_pk) WHERE status = 'pending';
部分インデックスを status = 'pending' に絞ることで、デデュプUPDATEのコストを pending 件数だけに抑え、done 状態の履歴行が膨らんでもデデュプ性能が劣化しない。
ワーカー関数:FOR UPDATE SKIP LOCKEDと構造化出力
llm.process_queue() はワーカーの本体だ。pg_cronから5秒ごとに呼ばれ、デフォルトで20件を1バッチ処理する。
FOR job IN
SELECT * FROM llm.queue
WHERE status = 'pending'
ORDER BY created_at
FOR UPDATE SKIP LOCKED
LIMIT batch_size
LOOP
UPDATE llm.queue SET status = 'running', attempts = attempts + 1 WHERE id = job.id;
-- プロンプト組み立て・LLM呼び出し・UPDATE・status='done'
END LOOP;
FOR UPDATE SKIP LOCKED がワーカー並列化の鍵だ。複数のワーカーを動かしても、互いに同じ行をロックしようとした時点で次の行へスキップするため、デッドロックなしで線形にスケールする。Postgres 9.5以降の標準機能で、本来 PgQ・Sidekiq・Resque相当のキュー基盤で頻繁に使われるパターンを、そのままPostgres上で再現している。
LLMへの送信ペイロードは構造化出力を強制する設計だ。
response_format := json_build_object(
'type', 'json_schema',
'json_schema', json_build_object(
'name', 'column_values',
'strict', true,
'schema', json_build_object(
'type', 'object',
'properties', schema_properties::json,
'required', to_json(target_columns),
'additionalProperties', false
)
)
);
response_format に JSON Schema をstrictモードで指定することで、LLMの返却JSONが必ず target_columns をキーに持つことを保証する。これがあるおかげで、ワーカー側は次のような単純なUPDATE生成で済む。
update_sql := 'UPDATE ' || quote_ident(job.table_schema) || '.' || quote_ident(job.table_name) || ' SET ';
FOR i IN 1..array_length(target_columns, 1) LOOP
IF i > 1 THEN update_sql := update_sql || ', '; END IF;
update_sql := update_sql || quote_ident(target_columns[i]) || ' = ' || quote_literal(llm_json ->> target_columns[i]);
END LOOP;
quote_ident と quote_literal でカラム名・値をエスケープしているため、SQLインジェクションの懸念も最小化されている。
失敗時の挙動:3回までリトライ、その後はerrorに固定
LLM API は429やネットワーク断で失敗する。Postgres LLMは MAX_ATTEMPTS = 3 をデフォルトに、失敗時は次のように分岐する。
EXCEPTION
WHEN OTHERS THEN
IF job.attempts + 1 >= MAX_ATTEMPTS THEN
UPDATE llm.queue SET status = 'error', last_error = SQLERRM, processed_at = now() WHERE id = job.id;
ELSE
UPDATE llm.queue SET status = 'pending', last_error = SQLERRM WHERE id = job.id;
END IF;
3回未満の失敗は status = 'pending' に戻されて再試行対象になり、3回到達時点で status = 'error' に固定される。last_error には SQLERRM(直近の例外メッセージ)が保存されるため、運用者は SELECT * FROM llm.queue WHERE status = 'error' で何が起きたかを直接確認できる。
status = 'done' の行は処理ループの最後で削除されるため、キューテーブルが肥大化しない。失敗のみが残るので、エラー監視ダッシュボードはこの1テーブルだけ見ればよい。
実装パターン4選:センチメント・翻訳・OCR・複数カラム
READMEには公式の使用例が並ぶ。それぞれの典型を抜粋し、設計上のポイントを補足する。
パターン1:センチメント分析
CREATE TRIGGER analyze_sentiment
AFTER INSERT OR UPDATE OF review_text ON user_reviews
FOR EACH ROW
WHEN (NEW.review_text IS NOT NULL)
EXECUTE FUNCTION llm.call(
'Analyze the sentiment of this text and respond with only "positive", "negative", or "neutral". return value in lowercase. Text: {review_text}',
'sentiment'
);
AFTER INSERT OR UPDATE OF review_text の OF句 が肝。review_text が変わったときだけトリガーが発火するため、sentiment を含む他カラムの更新では呼ばれない。これが無限ループ回避の第一の防波堤になる。
パターン2:翻訳(多言語展開)
CREATE TRIGGER translate_es
AFTER INSERT OR UPDATE OF user_review_og ON user_reviews
FOR EACH ROW
WHEN (NEW.user_review_og IS NOT NULL)
EXECUTE FUNCTION llm.call(
'Translate the following text to spanish (es). Only return the spanish text with no additional text. Text: {user_review_og}',
'user_review_es'
);
複数言語に展開したい場合、トリガーを言語ごとに作る。1つのプロンプトで多言語化することもできるが、後述の構造化出力を使うほうがLLMコストを節約できる。
パターン3:画像URLからのOCR
CREATE TRIGGER vision_ocr
AFTER INSERT OR UPDATE OF attached_image_url ON user_reviews
FOR EACH ROW
WHEN (NEW.attached_image_url IS NOT NULL)
EXECUTE FUNCTION llm.call(
'Extract all text from this image: {attached_image_url}',
'image_description'
);
InterfazeのVisionモデルを前提にした例。OpenAI互換APIで gpt-4o-mini などのVision対応モデルを指定すれば、URL指定だけで画像内テキストを抽出してカラムに書き戻せる。
パターン4:1回のLLM呼び出しで複数カラム更新
CREATE TRIGGER analyze_and_translate
AFTER INSERT OR UPDATE OF user_review_og ON user_reviews
FOR EACH ROW
WHEN (NEW.user_review_og IS NOT NULL)
EXECUTE FUNCTION llm.call(
'Analyze the sentiment and translate the following review to Spanish. Text: {user_review_og}',
'emotion',
'user_review_es'
);
これがJSON Schema構造化出力の本領発揮ポイント。1リクエストで emotion と user_review_es を同時に取得できるため、LLMコストとレイテンシを2回分→1回分に圧縮できる。
競合との比較:pg_ai・LangChain・Edge Functionsとの違い
「Postgresの中でLLM」という発想自体は新しくない。代表的な選択肢を比較する。
| 観点 | Postgres LLM | TimescaleDB pgai | LangChain (アプリ層) | Supabase Edge Functions |
|---|---|---|---|---|
| 動作場所 | Postgres内(pgSQL関数) | Postgres内(pgvector前提) | アプリプロセス | Deno Edge |
| 主用途 | 行ごとのLLM変換 | RAG・エンベディング | 汎用LLMオーケストレーション | HTTPトリガーで任意処理 |
| 非同期化 | pg_cron + キュー | 同期/非同期両対応 | アプリ実装次第 | キュー別途必要 |
| 構造化出力 | JSON Schema strict | あり(pgaiBETA) | プロバイダ依存 | プロバイダ依存 |
| 必要拡張 | http/hstore/pg_cron | pgvector/pgai | なし | なし |
| 学習コスト | SQLのみ | SQL+pgai DSL | Python/JS | TypeScript |
| ベンダー依存 | 低(Chat Completions互換なら何でも) | TimescaleDB寄り | 高(フレームワーク仕様) | Supabase寄り |
Postgres LLMが刺さるのは「行加工に閉じる軽量タスク」。RAG検索やマルチステップエージェントを組むなら他の選択肢が向くが、「INSERTした行を1回のLLM呼び出しで加工する」用途では、依存の少なさと運用境界の明快さで他より優位に立つ。
LLMの選択肢を整理したい場合は LLM完全ガイド2026:選び方・運用・ベンチマーク も合わせて確認するとよい。
落とし穴:3つの実運用注意点
公式ブログとREADMEから読み取れる、踏み抜きやすい落とし穴を整理する。
1. 入出力カラムを同じにすると無限ループ
-- 危険な例:summaryを監視してsummaryを書き戻す
CREATE TRIGGER bad_loop
AFTER INSERT OR UPDATE OF summary ON articles
FOR EACH ROW
EXECUTE FUNCTION llm.call('Summarize again: {summary}', 'summary');
これはトリガーが自分自身を発火させ続けて止まらない。Interfaze公式ブログも「a trigger that watches summary and writes back to summary will loop forever」と明示警告している。ソース列とターゲット列は必ず分離するのが鉄則だ。
2. プレースホルダの未定義カラムは即例外
-- {nonexistent} は実テーブルに存在しない
EXECUTE FUNCTION llm.call('Process {nonexistent}', 'output');
llm.call は regexp_matches で抽出したカラム名がテーブルに無い場合 RAISE EXCEPTION する。INSERT文がエラーで失敗するため、本番投入前のスキーマ整合性確認が必要。逆に言えば、プロンプトのタイポはランタイムでなくINSERT時点で検出できる安全設計でもある。
3. APIキーは init.sql のCONSTANTにベタ書き
API_KEY CONSTANT TEXT := '<your-api-key>';
これはサンプル実装としては割り切った設計だが、本番では pgcrypto + 環境変数注入か、AWS Secrets Manager / Vault からの動的取得に置き換えるのが望ましい。pg_dump でスキーマを出力すると関数定義ごとAPIキーが出てしまうため、IaCに混入しないよう注意する。
運用前チェックリスト
- ソース列とターゲット列が完全分離されているか
- プロンプト内の
{column_name}がすべて存在するか MAX_ATTEMPTSをAPIプロバイダのレート制限に合わせて調整したかAPI_KEYをinit.sqlから環境/Secrets管理に移したかllm.queueのstatus='error'を監視するアラートを仕込んだか
導入5ステップ:Supabaseでも素のPostgresでも動く
Supabase・Neon・素のPostgres(≥13)どれでも基本手順は同じだ。
Step 1: 拡張機能の有効化
CREATE EXTENSION IF NOT EXISTS http;
CREATE EXTENSION IF NOT EXISTS hstore;
CREATE EXTENSION IF NOT EXISTS pg_cron;
Supabaseの場合はDashboardの「Database → Extensions」から有効化できる。素のPostgresでは pgsql-http を別途ビルド・インストールする必要がある。
Step 2: init.sql を実行
init.sql をコピーし、API_KEY、BASE_URL、MODEL_NAME を自分の環境に書き換えて実行。デフォルトはInterfaze API、OpenAI互換なので任意プロバイダに差し替え可能。
Step 3: 対象テーブルにトリガーを追加
センチメント分析の例(再掲):
CREATE TRIGGER analyze_sentiment
AFTER INSERT OR UPDATE OF review_text ON user_reviews
FOR EACH ROW
WHEN (NEW.review_text IS NOT NULL)
EXECUTE FUNCTION llm.call(
'Analyze the sentiment of this text and respond with only "positive", "negative", or "neutral". return value in lowercase. Text: {review_text}',
'sentiment'
);
Step 4: INSERTして動作確認
INSERT INTO user_reviews (review_text)
VALUES ('I love this hackathon!');
-- 数秒待ってから
SELECT id, review_text, sentiment FROM user_reviews ORDER BY id DESC LIMIT 1;
Step 5: キューを監視
-- 滞留している pending ジョブ
SELECT count(*) FROM llm.queue WHERE status = 'pending';
-- 失敗したジョブと最後のエラー
SELECT id, table_name, attempts, last_error FROM llm.queue WHERE status = 'error';
status = 'done' は自動削除されるため、エラーと未処理ジョブだけが見えていれば正常。
何に向き、何に向かないか
Postgres LLMの設計上の強み・弱みを整理する。
向いている用途
- ユーザー投稿の自動分類・タグ付け・センチメント分析
- 多言語翻訳の事前生成
- 画像URLからのOCR・キャプション生成
- 自由記述フィールドの構造化抽出(住所→都道府県/市区町村など)
- LLMをアプリから切り離したい既存システムの段階的AI化
向かない用途
- ベクトル検索・RAG(pgvector + pgaiの方が適切)
- リアルタイム応答(5秒間隔のpg_cronでは要件未達)
- 大規模バッチ処理(数十万行/時規模ならアプリ側の専用ワーカーが有利)
- マルチステップエージェント(LangChain等のフレームワーク向き)
「DBに座ったまま完結させたい軽量LLM変換」というニッチに焦点を絞った分、刺さる場面では他の選択肢より圧倒的にシンプルだ。
エージェント側の選択肢を比較したい場合は AIエージェント フレームワーク比較2026:自律実行とマルチエージェント も参考になる。
まとめ:DBに寄せるとアプリが薄くなる
Postgres LLMは派手な機能ではなく 責務の置き場所 を提案するOSSだ。LLM呼び出しコード・リトライ・キュー・冪等性管理をすべてPostgres側に寄せることで、アプリは「INSERT/UPDATEするだけ」の単純な存在に戻る。
トリガー型ゆえの制約(入出力カラム分離・5秒間隔遅延・APIキー管理)はあるが、それらを受け入れられるユースケースでは恩恵が大きい。28スターという規模感はまだ小さいが、init.sql 1ファイル・約350行で全機能が読みきれるシンプルさは、コードを覗いて自分の環境に合わせて調整する敷居が低い。
OSSの自動化ツール群は他にも、組織内のAI利用可視化を扱う Eracle Openoutreach:組織内のAI利用を可視化・管理するオープンソースプラットフォーム や、プロダクト計測側の PostHog徹底解説:GA4の代替になるオープンソースのプロダクトアナリティクス と組み合わせて、LLM処理・利用観測・プロダクト分析をすべてOSSで構成するスタックが現実味を帯びてきている。
参照ソース
- JigsawStack/postgres-llm(GitHub公式リポジトリ) — README、
init.sql、example/user_reviews.sql - Run LLMs Inside Postgres(Interfaze公式ブログ) — v2の設計判断、無限ループ警告、観測性に関する記述
- pgsql-http(pramsey/pgsql-http) — Postgres内HTTPクライアント拡張
- pg_cron(citusdata/pg_cron) — Postgres内cronスケジューラ
- hstore公式ドキュメント — キーバリュー型のhstore拡張