なぜPostgreSQLにベクトルを保存するのか
RAG(検索拡張生成)は、質問と意味の近い文書の断片を探し出し、LLMに一緒に渡す方式です。このとき「意味が近い」ことを計算するために、文書の断片を数値ベクトル(埋め込み/エンベディング)に変換して保存します。
pgvectorは、PostgreSQLでベクトルを保存し、類似度で検索できるようにするオープンソースの拡張機能です(2026年10月1日に0.8.7がリリース)。専用のベクトルデータベースの代わりにPostgreSQLに置けば、文書の権限・テナント・更新日時といった既存のデータと同じトランザクション、同じJOINで扱えるという利点があります。そのぶん、テーブル設計が検索品質とパフォーマンスを左右します。
1. 文書とチャンクを分けた構造
埋め込みは通常、文書全体ではなく、一定の長さに区切った**チャンク(chunk)**単位で作成します。文書とチャンクを1:Nに分けます。
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
tenant_id BIGINT NOT NULL,
title TEXT NOT NULL,
source_url TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE document_chunks (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
document_id BIGINT NOT NULL REFERENCES documents (id) ON DELETE CASCADE,
tenant_id BIGINT NOT NULL,
chunk_index INT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536) NOT NULL,
model TEXT NOT NULL,
embedded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (document_id, chunk_index)
);
設計のポイントです。
vector(1536)の次元は、埋め込みモデルの出力次元と同じでなければなりません。異なる次元のベクトルを同じカラムに入れることはできませんmodelカラム:異なるモデルが生成したベクトル同士は比較できません。モデルを切り替えるときに、どの行を作り直すべきかをこのカラムで特定しますtenant_idをチャンクにも持たせます:検索時にはほぼ常にテナントで絞り込むため、JOINなしですぐにフィルタリングでき、後述する部分インデックス・パーティショニングにも使えますON DELETE CASCADE:文書を削除するとそのチャンクとベクトルも一緒に削除され、削除した文書が検索に残りませんembedded_atと文書のupdated_atを比較すれば、原文が変更された後に埋め込みを作り直していないチャンクを見つけられます
2. ベクトル型と次元制限
pgvectorは4つの型を提供しており、保存できる次元とインデックスを作成できる次元が異なります。vector と halfvec は16,000次元まで保存できますが、インデックスの上限はそれより低くなります。
| 型 | インデックスを作成できる上限 |
|---|---|
vector | 2,000次元 |
halfvec(半精度) | 4,000次元 |
bit(バイナリ) | 64,000次元 |
sparsevec(疎ベクトル) | 非ゼロ要素1,000個(保存は16,000個まで) |
埋め込みが2,000次元を超えると、vector カラムにそのままインデックスを作成することはできません。この場合は、半精度に変換した式インデックスを使います。クエリでもインデックスと同じ式で並べ替えなければ、インデックスは使われません。
-- 3072次元の埋め込みを半精度でインデックス化
CREATE INDEX ON document_chunks_large
USING hnsw ((embedding::halfvec(3072)) halfvec_cosine_ops);
SELECT id, content
FROM document_chunks_large
ORDER BY embedding::halfvec(3072) <=> $1::halfvec(3072)
LIMIT 5;
3. 距離演算子とHNSWインデックス
pgvectorの距離演算子は、<->(L2)、<#>(内積の負値)、<=>(コサイン距離)、<+>(L1)、そしてバイナリベクトル用の <~>(ハミング)、<%>(ジャカード)です。埋め込みモデルが推奨する距離を選び、インデックスの演算子クラス(opclass)とクエリの演算子を合わせる必要があります。インデックスは、ORDER BY が距離演算子の昇順のときに使われます。
インデックスの種類は2つです。
- HNSW:多層グラフ。検索性能は高いものの、構築が遅く、メモリも多く使います。学習ステップは不要です
- IVFFlat:ベクトルをリストに分割します。構築が速く、メモリ使用量も少ないものの、検索性能はHNSWより劣ります
-- コサイン距離のHNSWインデックス(デフォルト値: m = 16, ef_construction = 64)
CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops);
-- 検索時の候補数(デフォルト40)。上げると再現率↑ 速度↓
SET hnsw.ef_search = 100;
SELECT id, document_id, content
FROM document_chunks
WHERE tenant_id = 42
ORDER BY embedding <=> $1
LIMIT 5;
HNSWインデックスは、グラフが maintenance_work_mem に収まるとはるかに速く構築されます。大きなテーブルにインデックスを作成するときは、この値もあわせて検討してください。
4. フィルタリング:結果が足りなくなる問題と解決策
近似インデックスでは、インデックスを先に探索してからフィルターが適用されます。pgvectorのドキュメントの例では、条件に合う行が全体の10%で、hnsw.ef_search がデフォルト値の40の場合、平均4行しか残りません。LIMIT 5 を指定しても、5件に満たない可能性があるということです。
解決策は状況に応じて選びます。
反復インデックススキャン(0.8.0から)。 結果が十分に集まるまで、インデックスをさらに探索します。
SET hnsw.iterative_scan = strict_order; -- 距離の順序を厳密に維持
-- または
SET hnsw.iterative_scan = relaxed_order; -- 順序が多少ずれることがあるが、再現率はより高い
フィルター対象のカラムに通常のインデックス。 条件が全行のごく一部しか選ばないのであれば、B-treeインデックスで先に絞り込むほうが有利です。
CREATE INDEX ON document_chunks (tenant_id);
よく使う値が少数なら部分インデックス、値が多ければパーティショニング。
-- 特定テナント専用のベクトルインデックス
CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops)
WHERE (tenant_id = 42);
値の種類が多い場合は、PARTITION BY LIST (tenant_id) のようにフィルター対象のカラムでパーティションを分ける方法を、pgvectorのドキュメントが推奨しています。
5. 運用チェックリスト
- 埋め込みモデルを変更するとき:
modelカラムで対象を選び、新しいベクトルを別のカラムやテーブルに作成してから切り替えれば、検索が途切れません - 原文が変更されたとき:該当する文書のチャンクを削除して作り直すか、
embedded_at < updated_atのチャンクを定期的に探して更新します - 次元制限の確認:インデックスが必要なカラムは、型ごとのインデックス次元の上限内に収まっている必要があります
- 検索品質の測定:
ef_search・反復スキャンの設定は、精度と速度のトレードオフです。実際のクエリで再現率を測ってから決めてください
Erdmatesで設計する
RAGスキーマの要は、文書–チャンクの1:N関係、削除が連鎖する外部キー、そしてフィルターに使うテナントキーです。Erdmatesでこの関係を先にERDとして描いておけば、ベクトルカラムをどのテーブルに置き、どのカラムで絞り込むかを、チームが同じ図を見ながら決められます。
まとめ
pgvectorの設計で性能と品質を分けるのは、結局のところごく普通のデータベース設計です。文書とチャンクを分け、モデルと更新日時を記録し、次元の上限に合った型を選び、フィルター条件に合ったインデックス戦略(反復スキャン・部分インデックス・パーティショニング)を立てること。この4つを押さえれば、ベクトル検索も既存のデータと同じ水準で管理できます。