「社内ドキュメントを根拠にAIが答える仕組みが欲しい」——RAG(Retrieval-Augmented Generation)の要件は、最初の一言だけ見ればシンプルです。けれど本番に載せようとした瞬間、最初の大きな分岐が来ます。
埋め込みベクトルをどこに置くのか。 専用ベクトルDB(Pinecone など)を増設するのか、それともすでに業務データが入っている PostgreSQL に集約するのか。
この記事は、後者——pgvector を使い、業務データと埋め込みを単一の RDB に集約して本番RAGを組むための実装ガイドです。専用ベクトルDB側の設計は別記事 LangChain × Pinecone の本番RAG で扱っているので、本稿は**「専用ベクトルDBを増やさず、業務RDBに寄せる」選択肢**を、公式ドキュメントに忠実なコードで、公式より実践的に掘り下げます。題材として、私が構築した生成AI音声チャットボット——PostgreSQL + pgvector に業務データと埋め込みを集約したRAG接客システム——での設計判断も交えます。
この記事のルール:SQL構文・演算子・インデックスのパラメータは pgvector 公式 README、埋め込みモデルの仕様は OpenAI 公式ドキュメント(いずれも2026年6月時点) に基づきます。バージョンや価格は改定されるため、本番投入前に必ず公式で最新値を要確認。コードは実運用で使える形に整えていますが、シークレットは環境変数前提(接続文字列・APIキーのハードコードは厳禁)です。
0. 最初の分岐:なぜ専用ベクトルDBではなく Postgres + pgvector なのか
ここを曖昧にしたまま専用ベクトルDBを導入すると、運用するデータストアが1つ増えるという、後から効いてくるコストを背負います。まず判断の軸を整理します。
| 判断軸 | 専用ベクトルDB(Pinecone等) | PostgreSQL + pgvector |
|---|---|---|
| トランザクション整合性 | 業務データとは別系統。二相コミットできず、書き込みがズレ得る | 業務行と埋め込みを同一トランザクションで更新できる |
| 運用負荷 | DBがもう1つ増える(監視・バックアップ・権限・障害対応) | 既存のPostgres運用に集約。バックアップもPITRも一括 |
| メタデータフィルタ | 製品固有のフィルタDSL | SQLそのもの(JOIN・WHERE・全文検索が全部使える) |
| アクセス制御 | 別途設計 | **行レベルセキュリティ(RLS)**や既存の権限設計を流用 |
| コスト | 専用サービスの月額が積み上がる | Postgresの中。次元を抑えればストレージ増も限定的 |
| 超大規模・極低レイテンシ | 専用DBが有利(数億ベクトル/一桁ms) | 数百万〜数千万行までは十分実用。それ以上は要設計 |
「埋め込みも、結局はそのドキュメントの属性の一つ」 と捉えると、専用DBに切り出す必然性は多くの案件で消えます。ユーザー、注文、ドキュメント、その埋め込み——これらが1つのトランザクション境界の中にいることの価値は、本番運用で効いてきます。RAGの参照元ドキュメントを論理削除したのに、ベクトルだけ別DBに残って“消したはずの情報”が回答に混ざる——専用DBを別系統で持つと、こういう整合性事故が構造的に起こり得ます。同一RDBなら、DELETE も UPDATE も同じコミットで閉じます。
pgvector に「向かないケース」も正直に
- 数億ベクトル規模・サブミリ秒のレイテンシSLA:この領域は専用ベクトルDBや専用ANNサービスの土俵です。pgvector でも数千万行までは十分戦えますが、極端なスケールでは無理をしない。
- チームがそもそもPostgresを運用していない:集約のメリット(運用の一本化)が効かないなら、優位性は薄れます。
逆に言えば、「すでにPostgresがあり、ベクトルが数百万〜数千万行に収まる」案件——つまり大多数のB2B SaaS・社内ツールでは、pgvector への集約が第一候補になります。これは YAGNI そのものです。来るかどうか分からない数億ベクトルのために、今からデータストアを2つに割る必要はありません。
私が音声チャットボットで 専用ベクトルDBを増設せず PostgreSQL + pgvector に寄せたのも、この判断です。業務データ(FAQ・商品・対応履歴)と、その意味検索用の埋め込みを同じDBの同じトランザクションで扱えることを、運用上の最優先事項に置きました。
1. 基本:拡張・テーブル・距離演算子・近傍検索
まず公式 README に忠実な最小構成です。CREATE EXTENSION から近傍検索まで、ここが土台になります。
-- 1. 拡張を有効化(スーパーユーザー or 権限が必要)
CREATE EXTENSION IF NOT EXISTS vector;
-- 2. チャンク(分割したドキュメント片)と埋め込みを格納するテーブル
CREATE TABLE doc_chunks (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
document_id bigint NOT NULL, -- 元ドキュメントへの参照(業務行とJOINできる)
content text NOT NULL, -- チャンク本文
content_hash bytea NOT NULL, -- 冪等インジェスト用(第4章)
embedding vector(1024) NOT NULL, -- 埋め込み。次元は固定(第3章で1024を選ぶ理由)
embed_model text NOT NULL, -- 再埋め込み運用のためのモデル名(第6章)
metadata jsonb NOT NULL DEFAULT '{}',-- テナント・カテゴリ等のフィルタ用
created_at timestamptz NOT NULL DEFAULT now()
);
vector(1024) の 1024 が次元数です。これは埋め込みモデルの出力次元と一致させる必要があります(理由は第3章)。pgvector の vector 型は、インデックスを張る場合最大2,000次元まで対応します(より高次元が必要なら後述の halfvec で最大4,000次元)。
距離演算子(ここを間違えると検索が壊れる)
pgvector の距離演算子は次のとおりです。埋め込みモデルが想定する距離と、演算子・インデックスの演算子クラスを揃えるのが鉄則です。
| 演算子 | 距離 | 主な用途 |
|---|---|---|
<-> | L2(ユークリッド)距離 | 正規化されていないベクトル全般 |
<#> | 負の内積(negative inner product) | 内積ベースのモデル |
<=> | コサイン距離 | テキスト埋め込みの定番(向きで意味を測る) |
<+> | L1(タクシー)距離 | L1空間 |
<~> | ハミング距離 | bit 型(バイナリベクトル) |
<%> | ジャッカード距離 | bit 型 |
OpenAI の text-embedding-3-* のようなテキスト埋め込みはコサイン類似度で扱うのが定番なので、本稿は一貫して <=>(コサイン距離)を使います。
注意:
<#>は「負の内積」を返します。距離として小さいほど近い、という並び順を保つための符号です。スコアを人に見せるときは符号の解釈に注意してください。
k近傍検索(kNN)
近傍検索は ORDER BY <距離演算子> LIMIT k という素直な形になります。
-- クエリ埋め込み $1 に意味が近いチャンクを上位5件
SELECT id, document_id, content,
embedding <=> $1 AS distance -- 小さいほど近い(コサイン距離)
FROM doc_chunks
ORDER BY embedding <=> $1 -- インデックスはこの ORDER BY に効く
LIMIT 5;
ポイントは ORDER BY にそのまま距離演算子を書くことです。後述のHNSW/IVFFlatインデックスは、まさにこの「ORDER BY embedding <演算子> ... LIMIT k」の形に対して効きます。SELECT 句に距離を出すのは表示用で、インデックスを効かせるのは ORDER BY 側だと理解しておくと混乱しません。
2. インデックス:HNSW と IVFFlat をどう選ぶか
数百万行を毎回フルスキャンしていてはレイテンシが破綻します。pgvector の近似最近傍(ANN)インデックスは2種類。この選択がRAGの検索性能と運用性を決めます。
HNSW
-- HNSW:グラフ構造のインデックス。コサイン距離なら vector_cosine_ops
CREATE INDEX ON doc_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
m:各ノードがつなぐ最大エッジ数(デフォルト16)。大きいほど再現率が上がるがメモリ増。ef_construction:構築時の探索幅(デフォルト64)。大きいほど高品質なグラフだが構築が遅い。- 検索時の精度/速度はランタイムパラメータで調整します(インデックス再構築不要):
SET hnsw.ef_search = 100; -- 探索幅。デフォルト40。大きいほど高再現率・低速
HNSW の最大の運用上の利点は、公式が明記するとおり「IVFFlatと違い学習ステップがないため、データが1行も無くてもインデックスを作成できる」こと。つまりテーブル作成時にインデックスを張っておき、後からデータを流し込める。インジェストが継続的に発生する本番RAGと相性が良いのはこの性質です。
IVFFlat
-- IVFFlat:転置ファイル。lists はクラスタ数。データ投入後に作成する
CREATE INDEX ON doc_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
lists:クラスタ(リスト)数。公式の目安は 「最大100万行まではrows / 1000、100万行超はsqrt(rows)」。- 検索時のランタイムパラメータ:
SET ivfflat.probes = 10; -- 探索するリスト数。デフォルト1。大きいほど高再現率・低速
IVFFlat の決定的な制約:公式が「良い再現率の鍵の一つは、テーブルにある程度データが入った後にインデックスを作ること」と述べるとおり、IVFFlat は学習ステップ(クラスタリング)を持つため、データ投入後に作る必要があります。空テーブルに張ると再現率が壊れます。
決定表:HNSW vs IVFFlat
公式の表現は「HNSW はクエリ性能が良いが、構築が遅くメモリを多く使う」「IVFFlat は構築が速くメモリが少ないが、クエリ性能は劣る」。これを運用の言葉に翻訳します。
| 観点 | HNSW | IVFFlat |
|---|---|---|
| クエリ性能(再現率×速度) | 高い | 相対的に低い |
| 構築コスト | 遅い・メモリ多い | 速い・メモリ少ない |
| 空テーブルへの作成 | 可能(学習ステップなし) | 不可(データ投入後に作る) |
| 継続的な追加・更新への耐性 | 強い(先に張れる) | 弱い(再構築・再学習が要る場面が出る) |
| データ規模の見えにくさへの強さ | 強い | lists をデータ量に合わせる前提 |
| 主な使いどころ | 本番RAGの第一候補 | 大量データを一括ロードしバッチ更新する用途 |
実務上の結論:迷ったら HNSW。理由は ETC(Easy To Change)です。HNSW なら空テーブルに先にインデックスを張ってからインジェストを始められるので、運用フローが「作る→入れる」という素直な順になり、データ量の見積もりに依存しません。IVFFlat は「ある程度入れてから張る/量が変わったら lists を見直す」という手順依存が生まれ、継続更新のあるRAGでは運用負債になりがちです。構築速度やメモリが厳しく、かつデータを一括ロードしてバッチ更新する明確な事情があるときだけ IVFFlat を選びます。
音声チャットボットでは、FAQや商品情報が随時更新される——つまりインジェストが継続発生する——前提だったため、HNSW を選択しました。
メモリの注意:HNSW はインデックスがメモリに乗ってこそ速い。
maintenance_work_memを構築時に十分確保し、運用ではインデックスサイズがshared_buffersに収まるかを意識してください。次元を抑える(第3章)のは、ここのコストにも直結します。
3. 埋め込み次元の選び方:1024 を“デフォルト”にする理由
vector(1024) の 1024 をどう決めるか。ここはコストと精度のトレードオフを設計する場所です。
OpenAI の埋め込みモデルの出力次元は公式に次のとおりです。
text-embedding-3-large:3072次元text-embedding-3-small:1536次元text-embedding-ada-002:1536次元
そして text-embedding-3-* には dimensions パラメータがあり、公式いわく「埋め込みを短縮しても(末尾の数値を削っても)、概念を表現する性質を失わない」。さらに「text-embedding-3-large を256次元に短縮しても、短縮していない text-embedding-ada-002(1536次元)を上回る」とまで述べています。
これが意味するのは——フル3072次元をそのまま持つのは多くの場合オーバースペックだということです。次元はストレージ・インデックスメモリ・検索計算の全部に効きます。3072次元を1024次元に落とせば、ベクトルのストレージとインデックスメモリはおおよそ1/3になります。これは pgvector への集約戦略——「Postgresの中に収める」——と完全に噛み合います。
設計判断:
text-embedding-3-largeをdimensions=1024で使うのを本稿の標準にします。large の表現力を活かしつつ、1024次元なら pgvector のインデックス可能な2,000次元の枠に余裕で収まり、コストも抑えられる。精度がシビアなら1536や3072に上げ、コスト最優先なら small や 512 へ下げる——いずれにせよ列定義の次元とdimensionsパラメータは必ず一致させます。ここがズレるとINSERTで次元不一致エラーになります(型で守られるのは利点)。
音声チャットボットでは text-embedding-3-large を 1024次元で運用しました。接客の意味検索に必要な精度を保ちつつ、専用ベクトルDBを増やさず Postgres 内にストレージを収める——次元を1024に抑えたのは、この「集約してなお軽い」を成立させるための判断です。
超高次元が要るなら
halfvec:半精度のhalfvec(n)はインデックス時最大4,000次元まで対応し、ストレージも半分になります。1536や3072をそのまま持ちたいがメモリを抑えたい、というときの逃げ道として覚えておくと良いです(精度劣化は要評価)。
4. インジェスト:チャンク設計と「冪等」アップサート
検索の質は8割がインジェスト設計で決まります。そして本番で最も事故るのが「同じドキュメントを再投入したら埋め込みが二重に入った」「途中で失敗して中途半端な状態になった」というインジェストの非冪等性です。
チャンク設計の原則
- 意味のまとまりで切る:見出し・段落・箇条書きの境界で切る。固定文字数でぶつ切りにすると、文や概念が割れて検索精度が落ちる。
- 適度なオーバーラップ:チャンク間を少し重ねると、境界をまたぐ文脈を拾える。重ねすぎは重複・コスト増。
- メタデータを必ず持たせる:
document_id・テナント・カテゴリ・更新日時。これが第5章のフィルタとアクセス制御の土台になる。
冪等アップサート:内容ハッシュで二重投入を防ぐ
「同じ内容のチャンクは、何度インジェストしても1行」 を保証します。鍵はチャンク本文の内容ハッシュを一意キーにすること。再実行しても、内容が変わっていなければ埋め込みAPIすら叩きません(=冪等=コスト節約)。
-- (document_id, content_hash) を一意に。同内容の再投入は何度やっても1行
CREATE UNIQUE INDEX uq_doc_chunk
ON doc_chunks (document_id, content_hash);
"""ドキュメントを分割し、内容ハッシュで冪等にアップサートする。
再実行しても二重投入せず、変わっていないチャンクは埋め込みAPIを叩かない。"""
import hashlib
import os
import psycopg
from openai import OpenAI
client = OpenAI() # APIキーは環境変数 OPENAI_API_KEY から(ハードコード禁止)
EMBED_MODEL = "text-embedding-3-large"
EMBED_DIM = 1024 # ← vector(1024) と必ず一致させる
def content_hash(text: str) -> bytes:
"""正規化した本文の決定的ハッシュ。同じ内容 → 同じキー → 重複しない。"""
normalized = text.strip()
return hashlib.sha256(normalized.encode("utf-8")).digest()
def embed(texts: list[str]) -> list[list[float]]:
"""OpenAI 埋め込み。dimensions で 1024 に短縮(コストとストレージを抑える)。"""
resp = client.embeddings.create(
model=EMBED_MODEL,
input=texts,
dimensions=EMBED_DIM, # text-embedding-3-* のみ対応
)
return [d.embedding for d in resp.data]
def ingest(conn: psycopg.Connection, document_id: int, chunks: list[str]) -> None:
"""チャンク群を冪等にアップサート。既存(同ハッシュ)は埋め込みを再生成しない。"""
hashes = [content_hash(c) for c in chunks]
# 1) 既に入っているハッシュを先に引く(= 埋め込みAPIの無駄打ちを避ける)
with conn.cursor() as cur:
cur.execute(
"SELECT content_hash FROM doc_chunks "
"WHERE document_id = %s AND content_hash = ANY(%s)",
(document_id, hashes),
)
existing = {row[0] for row in cur.fetchall()}
# 2) 新規チャンクだけ埋め込み生成(コスト効率:差分だけ課金)
new_items = [
(c, h) for c, h in zip(chunks, hashes) if h not in existing
]
if not new_items:
return # 全部既存 → 何もしない(完全に冪等)
vectors = embed([c for c, _ in new_items])
# 3) ON CONFLICT で二重投入を構造的に排除(競合時も1行に収束)
with conn.cursor() as cur:
cur.executemany(
"""
INSERT INTO doc_chunks
(document_id, content, content_hash, embedding, embed_model)
VALUES (%s, %s, %s, %s, %s)
ON CONFLICT (document_id, content_hash) DO NOTHING
""",
[
(document_id, c, h, v, EMBED_MODEL)
for (c, h), v in zip(new_items, vectors)
],
)
conn.commit() # 業務行の更新と同一トランザクションに束ねれば整合性も担保
設計のキモを整理します。
- 内容ハッシュをキーにする:本文が同一なら同じハッシュ →
UNIQUE制約とON CONFLICT DO NOTHINGで何度実行しても二重に入らない。冪等性をアプリのロジックではなくDB制約で保証するのが堅い(信頼性)。 - 差分だけ埋め込む:既存ハッシュを先に引き、新規分だけ
client.embeddings.createを叩く。埋め込みAPIはトークン課金なので、これがそのままコスト削減になる。 - 同一トランザクションに束ねられる:これが pgvector に集約した最大の果実です。元ドキュメントの業務行(
documentsテーブル等)の更新と、このチャンク投入を1つのcommitに入れれば、「業務行は更新されたのに埋め込みが古い」という不整合が構造的に起きない。専用ベクトルDBでは別系統ゆえこの保証が得られません。
DRY/SRP:
content_hash(同一性判定)・embed(埋め込み生成)・ingest(永続化)を分離しておくと、再埋め込み(第6章)でもembedだけ差し替えられます。チャンク分割ロジックは案件ごとに変わるので、ingestの外(呼び出し側)に出しておくのが ETC です。
5. ハイブリッド検索:ベクトル × 全文検索 × メタデータフィルタ
ベクトル検索だけでは本番品質に届きません。 意味は近いのに「型番」「固有名詞」「日付」みたいな完全一致してほしいキーワードを取りこぼすからです。pgvector に集約した最大の強みは、ここでベクトル検索とPostgreSQLの全文検索・WHEREフィルタを同じSQLで組めること。専用ベクトルDBなら別システムを噛ませる部分が、1クエリで閉じます。
全文検索カラムを足す
-- 全文検索用の tsvector を生成列で持つ(本文から自動生成 → 同期ズレが起きない)
ALTER TABLE doc_chunks
ADD COLUMN content_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED;
CREATE INDEX idx_doc_chunks_tsv ON doc_chunks USING gin (content_tsv);
-- メタデータ(テナント・カテゴリ等)でのフィルタ用
CREATE INDEX idx_doc_chunks_meta ON doc_chunks USING gin (metadata);
日本語の全文検索は
to_tsvector('simple', ...)だと語の切り出しが弱いことがあります。本番ではpg_bigm(バイグラム)や形態素解析(textsearch_ja系)の導入を検討してください。ここは要件と言語に依存するので、まずsimpleで動かして精度を計測してから判断します(YAGNI/計測ファースト)。
ハイブリッド検索クエリ:RRF で2系統を融合する
ベクトル類似と全文検索はスコアのスケールが違うため、単純な加重和は不安定です。順位ベースで融合する RRF(Reciprocal Rank Fusion) が実装が簡単で堅い。「各リストでの順位の逆数を足す」だけです。
-- $1: クエリ埋め込み, $2: クエリ語(websearch構文), $3: テナントID
WITH vector_search AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
FROM doc_chunks
WHERE metadata->>'tenant_id' = $3 -- メタデータフィルタ=アクセス制御の一部
ORDER BY embedding <=> $1
LIMIT 50
),
keyword_search AS (
SELECT id,
ROW_NUMBER() OVER (
ORDER BY ts_rank_cd(content_tsv, websearch_to_tsquery('simple', $2)) DESC
) AS rank
FROM doc_chunks
WHERE metadata->>'tenant_id' = $3
AND content_tsv @@ websearch_to_tsquery('simple', $2)
LIMIT 50
)
SELECT c.id, c.content,
COALESCE(1.0 / (60 + v.rank), 0.0)
+ COALESCE(1.0 / (60 + k.rank), 0.0) AS rrf_score -- RRF: 順位の逆数を融合
FROM doc_chunks c
LEFT JOIN vector_search v ON v.id = c.id
LEFT JOIN keyword_search k ON k.id = c.id
WHERE v.id IS NOT NULL OR k.id IS NOT NULL
ORDER BY rrf_score DESC
LIMIT 10;
このクエリが1本のSQLで完結していることが本質です。
- ベクトル側(
vector_search):意味の近さで上位50件。 - キーワード側(
keyword_search):全文検索で上位50件。型番・固有名詞の完全一致を拾う。 - RRF で融合:
1 / (60 + rank)の和で再ランク。60は定石の平滑化定数で、特定リストの突出を抑える。 - メタデータフィルタ=アクセス制御:
metadata->>'tenant_id' = $3を両系統のWHEREに入れることで、他テナントの行はそもそも候補に入らない。
メタデータフィルタとアクセス制御を“DB側”で閉じる
専用ベクトルDBだと「全テナント横断で検索してからアプリ側でフィルタ」になりがちで、情報漏えいの温床です。Postgres に集約していれば、**行レベルセキュリティ(RLS)**で「自テナントの行しか見えない」をDBレベルで強制できます。
-- RLS:アプリがどう間違えても、他テナントのチャンクは物理的に返らない
ALTER TABLE doc_chunks ENABLE ROW LEVEL SECURITY;
CREATE POLICY tenant_isolation ON doc_chunks
USING (metadata->>'tenant_id' = current_setting('app.tenant_id', true));
これは pgvector 集約ならではの安全性です。アクセス制御の境界を、アプリのコードではなくDBの不変条件に置ける(最小権限の原則)。公式もフィルタには「フィルタ列への通常インデックス」「部分インデックス」「テーブルパーティショニング」「iterative index scans の有効化」を状況に応じて使い分けよ、と述べています。マルチテナントで少数の固定値で絞るなら部分インデックス、多数の値で絞るならパーティショニングが効きます。
フィルタとANNの相性:HNSW/IVFFlat は近似インデックスなので、強いWHEREで候補が大きく削られると再現率が落ちることがあります。公式が挙げる iterative index scans を有効にすると「必要に応じて自動的にインデックスを多めに走査」してくれます。フィルタ選択率が低い(=ヒット率が低い)クエリでは、フィルタ列への通常インデックスの方が速いこともある——まず計測してから決めます。
6. 再埋め込み運用:モデルを変えても壊れないために
本番RAGで必ず来るのが「埋め込みモデルを変えたい」という日です。text-embedding-3-large の新版が出た、次元を1024→1536に上げたい、別プロバイダに乗り換える——。このとき致命的なのは、新旧の埋め込みが同じ空間にないのに混在することです。距離計算が無意味になり、検索が静かに壊れます。
対策はシンプルで、埋め込みにバージョンを持たせること。第1章の embed_model 列がそれです。さらにモデル+次元+正規化方針をまとめた embedding_version を持つと堅いです。
-- どのモデル・次元で作った埋め込みかを必ず記録する
ALTER TABLE doc_chunks
ADD COLUMN embedding_version text NOT NULL DEFAULT 'te3-large-1024-v1';
-- 検索は「現行バージョン」だけを対象にする(新旧混在を構造的に防ぐ)
-- WHERE embedding_version = 'te3-large-1024-v1' ... ORDER BY embedding <=> $1
再埋め込みの戦略は**オンライン切替(ブルー/グリーン)**が安全です。
- 追加で埋める:新バージョン用の列(例
embedding_v2 vector(1536))かを増設し、既存チャンクをバックグラウンドで再埋め込みして埋めていく。本文やcontent_hashは不変なので、第4章の冪等インジェストの仕組みがそのまま再埋め込みジョブに流用できる(embed関数だけ差し替え)。 - 両系統を並走:再埋め込み完了まで、検索は旧バージョンを使い続ける。新旧を同じトランザクション境界で持てるのが集約の強み——専用DBの“別系統移行”のような綱渡りにならない。
- 切替はフラグ1つ:全件埋め終わったら、検索の
embedding_versionを新値に切り替えるだけ。問題があれば即ロールバック。 - 旧列を落とす:安定を確認してから旧列・旧インデックスを
DROP。
コスト:再埋め込みは全チャンク分のトークン課金が発生します。差分だけ(変更チャンクだけ)で済むよう内容ハッシュで管理し、モデル変更時のフル再埋め込みは夜間バッチ+レート制御で回す。次元を1024に抑えてあること自体が、再埋め込みのコストとストレージの両方を軽くします(第3章の判断がここでも効く)。
7. 可観測性とコスト:何を測り、どこを締めるか
pgvector RAG を本番で運用するなら、**「検索が遅くないか」「精度が落ちていないか」「コストが妥当か」**を継続的に見える化します。
測るべきもの
- 検索レイテンシ:
EXPLAIN (ANALYZE, BUFFERS)でインデックスが効いているか(Index Scan using ... hnsw)を確認。ef_search/probesを上げた時の再現率と速度のカーブを把握しておく。 - 再現率の代理指標:ANNインデックスの結果と、**フルスキャン(インデックスを無効化した正確なkNN)**の結果を一部クエリで突き合わせ、Top-k の一致率をモニタする。再現率が落ちたら
ef_search/mを見直す。 - インデックスサイズ:
\di+やpg_relation_sizeで監視。shared_buffersに収まらなくなると急に遅くなる。次元・行数の増加に対する先行指標。 - 埋め込みコスト:インジェスト時に「新規埋め込みトークン数」をログ。冪等化(第4章)が効いていれば、再実行でこの値は0に近づくはず——0にならないなら冪等性が壊れている兆候。
コストの締めどころ
- 次元を抑える:第3章のとおり
dimensions=1024。ストレージ・メモリ・検索計算の全部に効く、最も費用対効果の高いレバー。 - 差分インジェスト:内容ハッシュで未変更チャンクの再埋め込みを回避。埋め込みAPIのトークン課金を最小化。
- インデックスをメモリに収める:次元と行数を抑え、HNSWインデックスが
shared_buffersに乗る範囲で設計する。スワップが始まると性能もコストも悪化する。 - 専用DBを増やさない:そもそもの集約判断(第0章)が、月額の専用サービス費・運用人件費を丸ごと不要にしている——これが一番大きい。
可観測性とPII:検索クエリ本文には個人情報が含まれ得ます。可観測性のためにクエリやチャンク本文を生ログに残さない。記録するのは「クエリID・Top-kのチャンクID・距離・レイテンシ・トークン数」といったメタデータに留め、本文ログにPIIを残さないのは内部統制案件の絶対条件です。
8. まとめ:pgvector 本番RAG チートシート
最後に、迷ったときの早見表です。
- 置き場所:すでにPostgresがあり、数百万〜数千万行なら pgvector に集約。数億ベクトル・サブミリ秒SLAなら専用ベクトルDBを検討。
- 距離演算子:テキスト埋め込みは
<=>(コサイン距離) +vector_cosine_ops。演算子・演算子クラス・モデルの距離を必ず揃える。 - インデックス:迷ったら HNSW(空テーブルに先に張れる=継続インジェストに強い)。一括ロード+バッチ更新で構築コストが効くなら IVFFlat。
- 次元:
text-embedding-3-largeをdimensions=1024で。列のvector(1024)と必ず一致。 - インジェスト:内容ハッシュ +
UNIQUE+ON CONFLICT DO NOTHINGで冪等に。差分だけ埋め込んでコストを抑える。業務行と同一トランザクションで束ねる。 - 検索:ベクトル + 全文検索(
tsvector)を RRF で融合。メタデータ/RLSでDB側にアクセス制御を閉じる。 - 再埋め込み:
embedding_version列でバージョン管理。新旧を混ぜない。ブルー/グリーンで切替。 - 運用:再現率・レイテンシ・インデックスサイズ・埋め込みトークン数を計測。本文ログにPIIを残さない。
RAGは「一行の要件」に見えて、置き場所・距離・インデックス・冪等性・整合性・コストのトレードオフを設計する仕事です。そして本番では、専用ベクトルDBを増やすかどうかという最初の一手が、その後の運用負荷とコストを大きく左右します。
私は生成AI音声チャットボットで、専用ベクトルDBを増設せず PostgreSQL + pgvector に業務データと埋め込みを集約し、text-embedding-3-large(1024次元)で意味検索を回す本番RAGを、一人 × 生成AI(Claude Code)で設計・実装・運用まで通しました。冪等インジェスト・ハイブリッド検索・トランザクション整合性を、“データストアを1つに保つ”という運用の単純さの上に成立させています。
「RAGをどこに、どう載せるか」——専用ベクトルDBを足すべきか、Postgresに寄せるべきか。その判断から実装・運用まで、一気通貫で伴走できます。 要件の整理段階からでも、お気軽にご相談ください。
参考(公式ドキュメント)
- pgvector(GitHub・README) —
CREATE EXTENSION vector・vector/halfvec/bit/sparsevec型・距離演算子(<->/<#>/<=>/<+>/<~>/<%>)・HNSW / IVFFlat の作成構文と trade-off・ef_search/probes・listsの目安・フィルタ戦略 - OpenAI Embeddings ガイド —
text-embedding-3-large(3072次元)/text-embedding-3-small(1536次元)・dimensionsパラメータによる次元短縮・client.embeddings.create