# pgvector で作る本番RAG：専用ベクトルDBを増やさず PostgreSQL に集約する設計（HNSW・ハイブリッド検索・冪等インジェスト）

> PostgreSQL + pgvector で本番RAGを構築する実装ガイド。距離演算子（<-> / <#> / <=>）、HNSW と IVFFlat の選択、埋め込み次元の決め方、ベクトル×全文検索のハイブリッド検索、チャンク設計、内容ハッシュによる冪等インジェスト、モデル変更時の再埋め込み運用までを実コードで解説します。

- 公開日: 2026-06-24
- 著者: 友田 陽大
- タグ: PostgreSQL, RAG, Python, アーキテクチャ設計, コスト最適化
- URL: https://tomodahinata.com/blog/pgvector-postgres-production-rag-hybrid-search

## 要点

- 埋め込みもドキュメントの属性の一つと捉え、専用ベクトルDBを増やさずPostgreSQL + pgvectorに集約する
- 業務行と埋め込みを同一トランザクションで更新でき、論理削除とベクトルの整合性事故を防げる
- 距離演算子はテキスト埋め込みなら<=>（コサイン距離）で、インデックスは継続インジェストに強いHNSWが第一候補
- 内容ハッシュ + UNIQUE + ON CONFLICT DO NOTHINGで冪等インジェストし、差分だけ埋め込んでコストを抑える
- ベクトル検索と全文検索をRRFで融合し、メタデータフィルタ/RLSでアクセス制御をDB側に閉じる

---

「社内ドキュメントを根拠にAIが答える仕組みが欲しい」——RAG（Retrieval-Augmented Generation）の要件は、最初の一言だけ見ればシンプルです。けれど本番に載せようとした瞬間、**最初の大きな分岐**が来ます。

**埋め込みベクトルをどこに置くのか。** 専用ベクトルDB（Pinecone など）を増設するのか、それとも**すでに業務データが入っている PostgreSQL に集約する**のか。

この記事は、後者——**`pgvector` を使い、業務データと埋め込みを単一の RDB に集約して本番RAGを組む**ための実装ガイドです。専用ベクトルDB側の設計は別記事 [LangChain × Pinecone の本番RAG](/blog/langchain-pinecone-production-rag-system) で扱っているので、本稿は**「専用ベクトルDBを増やさず、業務RDBに寄せる」選択肢**を、公式ドキュメントに忠実なコードで、公式より実践的に掘り下げます。題材として、私が構築した[生成AI音声チャットボット](/case-studies/ai-voice-chatbot)——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つに割る必要はありません。

私が[音声チャットボット](/case-studies/ai-voice-chatbot)で **専用ベクトルDBを増設せず PostgreSQL + pgvector に寄せた**のも、この判断です。業務データ（FAQ・商品・対応履歴）と、その意味検索用の埋め込みを**同じDBの同じトランザクション**で扱えることを、運用上の最優先事項に置きました。

---

## 1. 基本：拡張・テーブル・距離演算子・近傍検索

まず公式 README に忠実な最小構成です。`CREATE EXTENSION` から近傍検索まで、ここが土台になります。

```sql
-- 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`** という素直な形になります。

```sql
-- クエリ埋め込み $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

```sql
-- 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）。大きいほど高品質なグラフだが構築が遅い。
- 検索時の精度／速度は**ランタイムパラメータ**で調整します（インデックス再構築不要）：

```sql
SET hnsw.ef_search = 100;  -- 探索幅。デフォルト40。大きいほど高再現率・低速
```

**HNSW の最大の運用上の利点**は、公式が明記するとおり「**IVFFlatと違い学習ステップがないため、データが1行も無くてもインデックスを作成できる**」こと。つまり**テーブル作成時にインデックスを張っておき、後からデータを流し込める**。インジェストが継続的に発生する本番RAGと相性が良いのはこの性質です。

### IVFFlat

```sql
-- IVFFlat：転置ファイル。lists はクラスタ数。データ投入後に作成する
CREATE INDEX ON doc_chunks
    USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100);
```

- `lists`：クラスタ（リスト）数。公式の目安は **「最大100万行までは `rows / 1000`、100万行超は `sqrt(rows)`」**。
- 検索時のランタイムパラメータ：

```sql
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 を選びます。

[音声チャットボット](/case-studies/ai-voice-chatbot)では、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` で次元不一致エラーになります（型で守られるのは利点）。

[音声チャットボット](/case-studies/ai-voice-chatbot)では `text-embedding-3-large` を **1024次元**で運用しました。接客の意味検索に必要な精度を保ちつつ、専用ベクトルDBを増やさず Postgres 内にストレージを収める——次元を1024に抑えたのは、この「集約してなお軽い」を成立させるための判断です。

> **超高次元が要るなら `halfvec`**：半精度の `halfvec(n)` はインデックス時**最大4,000次元**まで対応し、ストレージも半分になります。1536や3072をそのまま持ちたいがメモリを抑えたい、というときの逃げ道として覚えておくと良いです（精度劣化は要評価）。

---

## 4. インジェスト：チャンク設計と「冪等」アップサート

検索の質は**8割がインジェスト設計**で決まります。そして本番で最も事故るのが「**同じドキュメントを再投入したら埋め込みが二重に入った**」「**途中で失敗して中途半端な状態になった**」というインジェストの非冪等性です。

### チャンク設計の原則

- **意味のまとまりで切る**：見出し・段落・箇条書きの境界で切る。固定文字数でぶつ切りにすると、文や概念が割れて検索精度が落ちる。
- **適度なオーバーラップ**：チャンク間を少し重ねると、境界をまたぐ文脈を拾える。重ねすぎは重複・コスト増。
- **メタデータを必ず持たせる**：`document_id`・テナント・カテゴリ・更新日時。これが第5章のフィルタとアクセス制御の土台になる。

### 冪等アップサート：内容ハッシュで二重投入を防ぐ

**「同じ内容のチャンクは、何度インジェストしても1行」** を保証します。鍵は**チャンク本文の内容ハッシュ**を一意キーにすること。再実行しても、内容が変わっていなければ埋め込みAPIすら叩きません（＝冪等＝コスト節約）。

```sql
-- (document_id, content_hash) を一意に。同内容の再投入は何度やっても1行
CREATE UNIQUE INDEX uq_doc_chunk
    ON doc_chunks (document_id, content_hash);
```

```python
"""ドキュメントを分割し、内容ハッシュで冪等にアップサートする。
再実行しても二重投入せず、変わっていないチャンクは埋め込み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()  # 業務行の更新と同一トランザクションに束ねれば整合性も担保
```

設計のキモを整理します。

1. **内容ハッシュをキーにする**：本文が同一なら同じハッシュ → `UNIQUE` 制約と `ON CONFLICT DO NOTHING` で**何度実行しても二重に入らない**。冪等性をアプリのロジックではなく**DB制約で保証**するのが堅い（信頼性）。
2. **差分だけ埋め込む**：既存ハッシュを先に引き、新規分だけ `client.embeddings.create` を叩く。埋め込みAPIは**トークン課金**なので、これがそのままコスト削減になる。
3. **同一トランザクションに束ねられる**：これが pgvector に集約した最大の果実です。元ドキュメントの業務行（`documents` テーブル等）の更新と、このチャンク投入を**1つの `commit`** に入れれば、「業務行は更新されたのに埋め込みが古い」という不整合が**構造的に起きない**。専用ベクトルDBでは別系統ゆえこの保証が得られません。

> **DRY/SRP**：`content_hash`（同一性判定）・`embed`（埋め込み生成）・`ingest`（永続化）を分離しておくと、再埋め込み（第6章）でも `embed` だけ差し替えられます。チャンク分割ロジックは案件ごとに変わるので、`ingest` の外（呼び出し側）に出しておくのが ETC です。

---

## 5. ハイブリッド検索：ベクトル × 全文検索 × メタデータフィルタ

**ベクトル検索だけでは本番品質に届きません。** 意味は近いのに「型番」「固有名詞」「日付」みたいな**完全一致してほしいキーワード**を取りこぼすからです。pgvector に集約した最大の強みは、ここで**ベクトル検索とPostgreSQLの全文検索・WHEREフィルタを同じSQLで組める**こと。専用ベクトルDBなら別システムを噛ませる部分が、1クエリで閉じます。

### 全文検索カラムを足す

```sql
-- 全文検索用の 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）** が実装が簡単で堅い。「各リストでの順位の逆数を足す」だけです。

```sql
-- $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レベルで強制できます。

```sql
-- 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` を持つと堅いです。

```sql
-- どのモデル・次元で作った埋め込みかを必ず記録する
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
```

再埋め込みの戦略は**オンライン切替（ブルー/グリーン）**が安全です。

1. **追加で埋める**：新バージョン用の列（例 `embedding_v2 vector(1536)`）かを増設し、既存チャンクを**バックグラウンドで再埋め込み**して埋めていく。本文や `content_hash` は不変なので、第4章の冪等インジェストの仕組みがそのまま**再埋め込みジョブ**に流用できる（`embed` 関数だけ差し替え）。
2. **両系統を並走**：再埋め込み完了まで、検索は旧バージョンを使い続ける。新旧を**同じトランザクション境界**で持てるのが集約の強み——専用DBの“別系統移行”のような綱渡りにならない。
3. **切替はフラグ1つ**：全件埋め終わったら、検索の `embedding_version` を新値に切り替えるだけ。問題があれば即ロールバック。
4. **旧列を落とす**：安定を確認してから旧列・旧インデックスを `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にならないなら冪等性が壊れている兆候。

### コストの締めどころ

1. **次元を抑える**：第3章のとおり `dimensions=1024`。ストレージ・メモリ・検索計算の**全部**に効く、最も費用対効果の高いレバー。
2. **差分インジェスト**：内容ハッシュで未変更チャンクの再埋め込みを回避。埋め込みAPIのトークン課金を最小化。
3. **インデックスをメモリに収める**：次元と行数を抑え、HNSWインデックスが `shared_buffers` に乗る範囲で設計する。スワップが始まると性能もコストも悪化する。
4. **専用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音声チャットボット](/case-studies/ai-voice-chatbot)で、**専用ベクトルDBを増設せず PostgreSQL + pgvector に業務データと埋め込みを集約**し、`text-embedding-3-large`（1024次元）で意味検索を回す本番RAGを、一人 × 生成AI（Claude Code）で設計・実装・運用まで通しました。冪等インジェスト・ハイブリッド検索・トランザクション整合性を、**“データストアを1つに保つ”という運用の単純さ**の上に成立させています。

**「RAGをどこに、どう載せるか」——専用ベクトルDBを足すべきか、Postgresに寄せるべきか。その判断から実装・運用まで、一気通貫で伴走できます。** 要件の整理段階からでも、お気軽にご相談ください。

---

### 参考（公式ドキュメント）

- [pgvector（GitHub・README）](https://github.com/pgvector/pgvector) — `CREATE EXTENSION vector`・`vector`/`halfvec`/`bit`/`sparsevec` 型・距離演算子（`<->` / `<#>` / `<=>` / `<+>` / `<~>` / `<%>`）・HNSW / IVFFlat の作成構文と trade-off・`ef_search` / `probes`・`lists` の目安・フィルタ戦略
- [OpenAI Embeddings ガイド](https://developers.openai.com/api/docs/guides/embeddings) — `text-embedding-3-large`（3072次元）/ `text-embedding-3-small`（1536次元）・`dimensions` パラメータによる次元短縮・`client.embeddings.create`
