# pgvector チューニング完全ガイド：HNSW/IVFFlat の再現率×レイテンシ最適化と量子化（halfvec・バイナリ量子化）で速く・安く・正確に

> PostgreSQL + pgvector のベクトル検索を本番品質に仕上げるチューニング実装ガイド。HNSW/IVFFlat のパラメータ（m・ef_construction・ef_search・lists・probes）と再現率の測り方、halfvec・バイナリ量子化・subvector でメモリを削る方法、過剰フィルタを防ぐ反復スキャン（iterative scan・0.8.0+）、構築の高速化と運用までを、pgvector 公式ドキュメントに忠実な実コードで解説します。

- 公開日: 2026-06-25
- 著者: 友田 陽大
- タグ: PostgreSQL, RAG, パフォーマンス, コスト最適化, Python
- URL: https://tomodahinata.com/blog/pgvector-index-tuning-hnsw-ivfflat-quantization-iterative-scan-guide

## 要点

- ベクトル検索のチューニングは『再現率 × レイテンシ × メモリ』の三角形のどこに置くかを選ぶ作業。まず再現率を測り、それからパラメータを動かす
- 継続インジェストの本番RAGは HNSW が第一候補。検索品質は再構築不要の hnsw.ef_search で、構築品質は m / ef_construction で調整する
- 再現率は『ANNの結果 vs インデックスを切った厳密kNN』の一致率で必ず計測する。EXPLAIN ANALYZE で Index Scan を確認し、ef_search を上げた時の速度カーブを把握する
- メモリとコストは量子化で削る。halfvec で半分、binary_quantize + 元ベクトル再ランクで約1/30、subvector(Matryoshka)で粗く絞ってから精密化する二段検索が効く
- 強いWHEREでの『過剰フィルタ』は pgvector 0.8.0 の反復スキャン（hnsw.iterative_scan）で解決。部分インデックス・パーティショニングと使い分ける

---

pgvector でベクトル検索を動かすところまでは、驚くほど簡単です。`CREATE EXTENSION vector`、`vector(1024)` の列、`ORDER BY embedding <=> $1 LIMIT 10` ——これで“それっぽい”意味検索は動きます。

問題はその先です。データが**数百万行**に育ち、ユーザーが**同時に叩き**、**「この回答、なぜ的外れなの？」**という声が運用から上がってきたとき。ここで効いてくるのは、SQL の書き方ではなく **インデックスと量子化のチューニング** です。

この記事は、**pgvector を“動く”から“本番で速く・安く・正確に動く”へ引き上げる**ための実装ガイドです。「どこに埋め込みを置くか」「RAGの全体設計・ハイブリッド検索・冪等インジェスト」は姉妹記事 [pgvector で作る本番RAG](/blog/pgvector-postgres-production-rag-hybrid-search) で扱っているので、本稿はその先——**HNSW/IVFFlat の再現率×レイテンシ最適化、量子化によるメモリ削減、過剰フィルタの解決**に集中します。題材として、私が構築した [生成AI音声チャットボット](/case-studies/ai-voice-chatbot)（PostgreSQL + pgvector に業務データと埋め込みを集約したRAG接客システム）での設計判断も交えます。

> **この記事のルール**：SQL構文・パラメータ・既定値・量子化の構文は **pgvector 公式 README / ドキュメント（v0.8.x 系・2026年6月時点）** に基づきます。pgvector は活発に更新されるため（反復スキャンは 0.8.0、`hamming_distance` / L1のHNSW対応は 0.7.0 で追加）、本番投入前に必ず**お使いのバージョンの公式ドキュメントで最新値を要確認**してください。コードは実運用で使える形に整えていますが、**接続文字列・APIキーは環境変数前提**（ハードコード厳禁）です。

---

## 0. メンタルモデル：チューニングとは「三角形のどこに立つか」を選ぶこと

近似最近傍（ANN）検索のチューニングで最初に持つべきは、**3つの量はトレードオフの関係にある**という地図です。

```
        再現率 (Recall)
         ／      ＼
        ／        ＼
  レイテンシ ──── メモリ / コスト
```

- **再現率（Recall）**：本来返すべき「正解の近傍」のうち、何割を取れているか。RAGの回答品質に直結する。
- **レイテンシ**：1クエリの応答速度。ユーザー体験とスループットを決める。
- **メモリ / コスト**：インデックスがRAMに乗るか。次元・行数・量子化で決まる。

**3つすべてを同時に最大化することはできません。** ANN検索は「正確さ（厳密kNN）」を少し諦める代わりに「速度」を得る技術だからです。チューニングとは、**この三角形のどこに自分の案件を立たせるかを、感覚ではなく計測で決める作業**にほかなりません。

だからこの記事の順番は意図的です。**(1) インデックスの内部を理解 → (2) 再現率を“測る” → (3) 構築を速くする → (4) 量子化でコストを削る → (5) フィルタの罠を外す → (6) 運用**。とくに **(2) の「測る」を飛ばしてパラメータをいじるのは、目隠しでダイヤルを回す行為**です。検証パスを先に作る——これが本番品質の最大のレバーです。

---

## 1. 2つのANNインデックスの内部：HNSW と IVFFlat

pgvector の近似インデックスは **HNSW** と **IVFFlat** の2種類。パラメータを正しく動かすには、**それぞれが何をしているか**を一段だけ深く知る必要があります。

### HNSW：多層グラフをたどる

HNSW（Hierarchical Navigable Small World）は、ベクトルを**ノード**、近いベクトル同士を**エッジ**でつないだ多層グラフです。検索は上層の粗いグラフから入り、近いノードへ貪欲にたどって下層へ降りていきます。

```sql
-- コサイン距離なら 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 | **構築時**の候補リスト幅 | グラフ品質↑・構築時間↑ |
| `hnsw.ef_search` | 40 | **検索時**の候補リスト幅（ランタイム） | 再現率↑・速度↓ |

ここで決定的に重要なのが、**検索品質はランタイムパラメータ `hnsw.ef_search` で後から調整できる**ことです。インデックスを作り直さずに、セッション単位・クエリ単位で精度↔速度のダイヤルを回せます。

```sql
SET hnsw.ef_search = 100;   -- 既定40。大きいほど高再現率・低速
SELECT id, content
FROM doc_chunks
ORDER BY embedding <=> $1
LIMIT 10;
```

**HNSW の最大の運用上の利点**は、公式が明記するとおり「**学習ステップがないため、1行もデータが無くてもインデックスを作成できる**」こと。つまり「**テーブルを作る → 先にインデックスを張る → 後からデータを流し込む**」という素直な順序が成立します。インジェストが継続的に発生する本番RAGと相性が良いのはこの性質です。[音声チャットボット](/case-studies/ai-voice-chatbot)でも、FAQ・商品情報が随時更新される（＝インジェストが止まらない）前提から HNSW を選びました。

### IVFFlat：クラスタに分けて、近いクラスタだけ探す

IVFFlat（Inverted File with Flat compression）は、ベクトル空間を `lists` 個のクラスタに**事前に分割**し、検索時はクエリに近いクラスタ（`probes` 個）だけを探します。

```sql
-- lists はクラスタ数。データを投入した“後”に作るのが鉄則
CREATE INDEX ON doc_chunks
    USING ivfflat (embedding vector_cosine_ops)
    WITH (lists = 100);
```

| パラメータ | 既定値 | 公式の目安 |
| --- | --- | --- |
| `lists` | （必須） | **〜100万行：`rows / 1000`** / **100万行超：`sqrt(rows)`** |
| `ivfflat.probes` | 1 | 探索するクラスタ数。大きいほど高再現率・低速 |

```sql
SET ivfflat.probes = 10;   -- 既定1。lists に対して何個のクラスタを見るか
```

**IVFFlat の決定的な制約**：クラスタリングという**学習ステップ**を持つため、**データがある程度入った後に作らないと再現率が壊れます**。空テーブルに張ってはいけません。さらに、データ量が大きく変われば `lists` の見直し（＝再構築）が要ります。

### 決定表：迷ったら HNSW

| 観点 | HNSW | IVFFlat |
| --- | --- | --- |
| クエリ性能（再現率×速度） | **高い** | 相対的に低い |
| 構築コスト | 遅い・メモリ多い | **速い・メモリ少ない** |
| 空テーブルへの作成 | **可能**（学習なし） | 不可（投入後に作る） |
| 継続的な追加・更新への耐性 | **強い** | 弱い（`lists` 再調整が要る） |
| 主な使いどころ | **本番RAGの第一候補** | 大量データを一括ロードしバッチ運用する用途 |

**結論はシンプルです：迷ったら HNSW。** 理由は ETC（Easy To Change）。空テーブルに先に張れるので運用フローが「作る→入れる」で固定でき、データ量の見積もりに依存しません。構築速度とメモリが厳しく、かつ**一括ロード＋バッチ更新**という明確な事情があるときだけ IVFFlat を選びます。

> **次元の上限に注意**：`vector` 型でインデックスを張れるのは**最大2,000次元**まで。これを超える埋め込みを使うなら、後述の `halfvec`（最大4,000次元）への式インデックスが逃げ道になります。

---

## 2. 再現率を「測ってから」チューニングする（検証ファースト）

**ここが本記事で最も重要な章です。** `ef_search` や `probes` を当てずっぽうで上下させる前に、**自分のデータでの再現率を数値化する**仕組みを作ります。ANN検索は「速いが、たまに正解を取りこぼす」もの。その“たまに”が許容範囲かは、測らなければ分かりません。

### 厳密kNN（正解）を得る

再現率の分母＝「本当の正解」は、**インデックスを使わない総当たり（厳密kNN）**で得ます。pgvector では、プランナのインデックススキャンを切れば、距離計算の総当たり＝厳密な並びになります。

```sql
-- このトランザクション内だけインデックスを無効化し、厳密kNN（総当たり）を得る
BEGIN;
SET LOCAL enable_indexscan = off;
SET LOCAL enable_bitmapscan = off;
SELECT id FROM doc_chunks ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;
```

> 厳密kNNは行数に比例して重くなります。再現率の評価は**代表クエリ100〜200本**のサンプルで回すのが現実的です。総当たりを速くしたいときは公式どおり `SET max_parallel_workers_per_gather = 4;` で並列化できます。

### 再現率ハーネス：ANN vs 厳密kNN の一致率

「同じクエリを、ANNインデックス有りと厳密kNNで叩き、返ってきたIDの重なり（recall@k）を測る」だけです。これを `ef_search` を変えながら回せば、**自分のデータでの再現率↔レイテンシのカーブ**が手に入ります。

```python
"""pgvector の recall@k を計測するハーネス。
ANN（hnsw.ef_search を可変）と、インデックスを切った厳密kNN を突き合わせ、
ef_search ごとの再現率と p95 レイテンシを出す。チューニングは“この表”を見て決める。"""
from __future__ import annotations

import time
from statistics import quantiles

import psycopg  # 接続情報は環境変数 PGHOST/PGUSER/... 経由（ハードコード禁止）

K = 10
EF_GRID = (40, 80, 120, 200)  # 試す hnsw.ef_search の格子


def exact_topk(conn: psycopg.Connection, qvec: str, k: int) -> set[int]:
    """インデックスを切った厳密kNN ＝ 再現率の“正解”。"""
    with conn.cursor() as cur:
        cur.execute("SET LOCAL enable_indexscan = off")
        cur.execute("SET LOCAL enable_bitmapscan = off")
        cur.execute(
            "SELECT id FROM doc_chunks ORDER BY embedding <=> %s LIMIT %s",
            (qvec, k),
        )
        return {r[0] for r in cur.fetchall()}


def ann_topk(conn: psycopg.Connection, qvec: str, k: int, ef: int) -> tuple[set[int], float]:
    """ANN（HNSW）で取得し、IDの集合と所要時間(ms)を返す。"""
    with conn.cursor() as cur:
        cur.execute("SET LOCAL hnsw.ef_search = %s", (ef,))
        t0 = time.perf_counter()
        cur.execute(
            "SELECT id FROM doc_chunks ORDER BY embedding <=> %s LIMIT %s",
            (qvec, k),
        )
        rows = cur.fetchall()
        elapsed_ms = (time.perf_counter() - t0) * 1_000
    return {r[0] for r in rows}, elapsed_ms


def evaluate(conn: psycopg.Connection, query_vecs: list[str], k: int = K) -> None:
    """ef_search ごとに recall@k 平均と p95 レイテンシを表示。"""
    # 厳密kNN は ef に依存しないので一度だけ計算してキャッシュ
    truth = [exact_topk(conn, q, k) for q in query_vecs]

    for ef in EF_GRID:
        recalls: list[float] = []
        latencies: list[float] = []
        for q, gold in zip(query_vecs, truth):
            got, ms = ann_topk(conn, q, k, ef)
            recalls.append(len(got & gold) / k)  # 重なり / k ＝ recall@k
            latencies.append(ms)
        recall = sum(recalls) / len(recalls)
        p95 = quantiles(latencies, n=20)[18]  # 95パーセンタイル
        print(f"ef_search={ef:>3}  recall@{k}={recall:.3f}  p95={p95:6.1f}ms")
```

出力はたとえばこうなります（値はデータ依存・例示）。

```text
ef_search= 40  recall@10=0.892  p95=  3.1ms
ef_search= 80  recall@10=0.961  p95=  5.4ms
ef_search=120  recall@10=0.984  p95=  8.2ms
ef_search=200  recall@10=0.995  p95= 14.7ms
```

**この表があれば、チューニングは意思決定になります。** 「RAGの回答品質には recall@10 ≥ 0.95 が要る、レイテンシ予算は10ms」なら `ef_search=80〜120` を選ぶ——という具合に。再現率がどうしても伸びないなら、構築側の `m` / `ef_construction` を上げてインデックスを作り直します。

### EXPLAIN でインデックスが効いているか確認する

「速くならない」の大半は、**そもそもANNインデックスが使われていない**ことが原因です。必ず `EXPLAIN` で確認します。

```sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM doc_chunks ORDER BY embedding <=> $1 LIMIT 10;
--   → 出力に "Index Scan using ..._hnsw_..." が出ていればOK。
--     "Seq Scan" なら、距離演算子と演算子クラスの不一致、
--     ORDER BY の形崩れ、または式の不一致を疑う。
```

> **落とし穴**：インデックスは「`ORDER BY embedding <演算子> $1 LIMIT k`」という形にだけ効きます。`SELECT` 句に距離を出すのは表示用。`ef_search` を上げても `EXPLAIN` が `Seq Scan` なら、まず**形**を直します。

---

## 3. インデックス構築を速くする

数百万行への HNSW 構築は、放っておくと数十分〜数時間かかります。公式が挙げる**3つのレバー**で短縮します。

```sql
-- 1) 構築用メモリを増やす。グラフ全体が収まると劇的に速い。
--    収まらないと "graph no longer fits into maintenance_work_mem" の NOTICE が出る＝遅くなる合図。
SET maintenance_work_mem = '8GB';

-- 2) 並列ワーカーを増やす（既定2）。+リーダーで構築を並列化。
SET max_parallel_maintenance_workers = 7;
-- ワーカー数を大きくするときは全体上限も忘れずに（既定8）
SET max_parallel_workers = 8;

-- 3) 初期データはロード後にインデックスを張る（公式の基本方針）
CREATE INDEX ON doc_chunks USING hnsw (embedding vector_cosine_ops);
```

ポイントを公式に忠実に整理します。

1. **`maintenance_work_mem`**：HNSWグラフが**丸ごと収まる**サイズを確保するのが理想。収まらなくなると公式は NOTICE で警告します。次元を抑える（[姉妹記事](/blog/pgvector-postgres-production-rag-hybrid-search)の `dimensions=1024` 戦略）のは、ここのコストにも直接効きます。
2. **`max_parallel_maintenance_workers`**：既定の2から増やすと並列構築されます。CPUコアと相談して設定。
3. **初期ロードは「入れてから張る」**：大量の初期データがある場合、空インデックスに1行ずつ入れるより、`COPY` で投入してから一括構築する方が速い。継続インジェストとは別の“初期移行”の話です。

### 構築の進捗を監視する

長い構築を「ただ待つ」のは不安です。pgvector は標準ビューで進捗を出せます。

```sql
SELECT phase,
       round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;
--   HNSW のフェーズ: "initializing" → "loading tuples"
```

> **大量初期ロードの定番**：テキスト埋め込みの初期投入は `COPY doc_chunks (...) FROM STDIN WITH (FORMAT BINARY)` が最速。`INSERT` の1行ずつより桁違いに速く、構築前のロードを縮められます。

---

## 4. 量子化でメモリとコストを削る（pgvector集約戦略の心臓部）

ここが**「Postgresに集約してなお軽い」を成立させる**章です。`vector(n)` は1次元あたり4バイト（float32）。1024次元なら本体だけで約4KB/行、これにHNSWグラフが乗る。行数が増えるとRAMを圧迫し、`shared_buffers` から溢れた瞬間に性能が崖を落ちます。

対策は**量子化**——精度をわずかに譲ってストレージを大きく削る。pgvector は**式インデックス**でこれを実現します。

### 4-1. halfvec：半精度でストレージ半分

`halfvec` は1次元2バイト（float16）。**ストレージはほぼ半分**、インデックスは**最大4,000次元**まで対応（`vector` は2,000次元まで）。テキスト埋め込みでは精度劣化はごく小さいことが多く、**最初に試すべき低リスクな手**です。

```sql
-- 列ごと halfvec にする場合
CREATE TABLE items (id bigserial PRIMARY KEY, embedding halfvec(1024));

-- 既存の vector 列はそのまま、インデックスだけ halfvec にキャストして作る（式インデックス）
CREATE INDEX ON doc_chunks
    USING hnsw ((embedding::halfvec(1024)) halfvec_cosine_ops);

-- 検索もキャストを合わせる（インデックスの式と一致させるのが条件）
SELECT id, content
FROM doc_chunks
ORDER BY embedding::halfvec(1024) <=> $1::halfvec(1024)
LIMIT 10;
```

### 4-2. バイナリ量子化：約1/30のサイズ＋再ランクで精度回復

最も攻めたコスト削減が**バイナリ量子化**です。各次元の符号だけを1ビットに落とす（`binary_quantize`）。`bit(1024)` は約128バイト——**`vector(1024)` の約4KBに対しておよそ1/30**。ハミング距離（`<~>`）で超高速に粗く絞り、**元のベクトルで上位だけ再ランク**して精度を取り戻す、という二段構えが定石です。

```sql
-- 粗い検索用：符号を1ビットに量子化した式インデックス（bit_hamming_ops + <~>）
CREATE INDEX ON doc_chunks
    USING hnsw ((binary_quantize(embedding)::bit(1024)) bit_hamming_ops);
```

```sql
-- 二段検索：① bit で広めに20件 → ② 元ベクトルのコサイン距離で上位10件に精密化
SELECT id, content
FROM (
    SELECT id, content, embedding
    FROM doc_chunks
    ORDER BY binary_quantize(embedding)::bit(1024) <~> binary_quantize($1)
    LIMIT 20                                   -- 粗いふるい（ハミング距離・激速）
) AS coarse
ORDER BY embedding <=> $1                       -- 精密な再ランク（元ベクトル・コサイン）
LIMIT 10;
```

**なぜ効くか**：1段目のビット検索は計算もメモリも桁違いに軽い。取りこぼしを避けるため**広め（LIMIT 20〜数倍）**に拾い、2段目で元ベクトルの正確な距離で並べ直す。インデックスはビット版だけがRAMに乗れば済むので、**大規模でもインデックスをメモリに収めやすい**。再ランク幅（`LIMIT 20`）は第2章のハーネスで再現率を見ながら決めます。

### 4-3. subvector（Matryoshka）：先頭次元で粗く、全次元で精密に

OpenAI の `text-embedding-3-*` のような **Matryoshka（MRL）対応**の埋め込みは「**先頭ほど重要な情報が詰まる**」性質を持ちます。これを使い、`subvector` で**先頭の数百次元だけ**でインデックスして粗く絞り、**全次元**で再ランクできます。

```sql
-- 先頭256次元だけでインデックス（粗い・軽い）
CREATE INDEX ON doc_chunks
    USING hnsw ((subvector(embedding, 1, 256)::vector(256)) vector_cosine_ops);

-- 二段検索：先頭256次元で20件 → 全1024次元で再ランク
SELECT id, content
FROM (
    SELECT id, content, embedding
    FROM doc_chunks
    ORDER BY subvector(embedding, 1, 256)::vector(256) <=> subvector($1, 1, 256)
    LIMIT 20
) AS coarse
ORDER BY embedding <=> $1
LIMIT 10;
```

### 量子化の早見表

| 手法 | ストレージ目安（1024次元） | 精度 | 使いどころ |
| --- | --- | --- | --- |
| `vector`（無圧縮・float32） | 約4KB/行 | 基準 | 行数が中規模で素直に精度を出したい |
| `halfvec`（float16） | 約2KB/行 | ほぼ同等 | **まず試す**。低リスクにメモリ半減 |
| `subvector`＋再ランク | 粗い段が小さい | 高（再ランクで回復） | MRL埋め込み（text-embedding-3 等）で大規模 |
| `binary_quantize`＋再ランク | 約128B/行（粗い段） | 中〜高（再ランクで回復） | **超大規模**。インデックスをRAMに収めたい |

> **設計判断**：量子化は「いきなり最強（バイナリ）」ではなく、**`halfvec` → 再現率を測る → 足りなければ二段検索（subvector / binary）** の順で。第2章のハーネスがそのまま判断材料になります。次元を1024に抑えてある [音声チャットボット](/case-studies/ai-voice-chatbot) の設計は、この量子化レバーと噛み合い、専用ベクトルDBを増やさず Postgres 内にインデックスを収め続けることを可能にしました。

---

## 5. フィルタの罠：「過剰フィルタ」と反復スキャン（0.8.0+）

本番RAGはほぼ必ず**メタデータでの絞り込み**を伴います。「このテナントの」「このカテゴリの」「公開済みの」ドキュメントだけを意味検索したい。ところがここに、ANNインデックス特有の**最も誤解されやすい罠**があります。

### 過剰フィルタ（over-filtering）とは

公式が挙げる例がそのまま核心です。

> **条件が全体の10%にマッチする場合、HNSW で既定の `hnsw.ef_search = 40` だと、平均で約4行しか条件を満たさない。**

理由は、ANNインデックスは「クエリに近い候補を `ef_search` 件**先に集め**、そのあとで `WHERE` を適用する」順で動くから。候補40件のうちフィルタ条件を満たすのが10%なら、手元に残るのは平均4件——**`LIMIT 10` に足りません**。これが「フィルタを足したら急に結果が減った／精度が落ちた」の正体です。

### 解き方を選択率で使い分ける

公式が示す対処を、**フィルタの選択率（何割が残るか）**で整理します。

```sql
-- (a) フィルタ列に通常インデックス：選択率が低い（=ほとんど落ちる）クエリで有効。
--     プランナが ANN ではなく B-tree を選び、絞ってから距離計算する方が速いことがある。
CREATE INDEX ON doc_chunks (tenant_id);
CREATE INDEX ON doc_chunks (location_id, category_id);   -- 複合条件なら複合インデックス

-- (b) 部分インデックス：少数の固定値で絞るとき（例：特定テナント専用）。
CREATE INDEX ON doc_chunks
    USING hnsw (embedding vector_cosine_ops)
    WHERE (tenant_id = 123);

-- (c) パーティショニング：多数の値で分割するとき（テナントが多い等）。
CREATE TABLE doc_chunks (embedding vector(1024), tenant_id int /* ... */)
    PARTITION BY LIST (tenant_id);
```

### 反復スキャン（iterative scans・pgvector 0.8.0）

そして 0.8.0 の目玉が**反復スキャン**です。過剰フィルタが起きたとき、**十分な結果が集まるまでインデックスを自動で追加走査**してくれます。既定は **off（オプトイン）**。

```sql
-- HNSW：strict_order（距離順を厳密に保つ） or relaxed_order（多少前後するが再現率↑）
SET hnsw.iterative_scan = strict_order;
SET hnsw.max_scan_tuples = 20000;       -- 走査する最大タプル数（既定20000）
SET hnsw.scan_mem_multiplier = 2;       -- 使用メモリを work_mem の倍数で（既定1）

-- IVFFlat：relaxed_order をサポート
SET ivfflat.iterative_scan = relaxed_order;
SET ivfflat.max_probes = 100;           -- 反復で探索するクラスタ数の上限
```

`relaxed_order` は再現率を稼げますが、結果が**厳密な距離順から少しズレる**ことがあります。厳密な並びが必要なら、公式どおり **MATERIALIZED CTE で受けて並べ直す**のが定石です。

```sql
-- relaxed_order の結果を、確実に距離順へ整え直す
SET hnsw.iterative_scan = relaxed_order;

WITH relaxed AS MATERIALIZED (
    SELECT id, content, embedding <=> $1 AS distance
    FROM doc_chunks
    WHERE tenant_id = 123
    ORDER BY distance
    LIMIT 20
)
SELECT id, content, distance
FROM relaxed
ORDER BY distance + 0      -- "+ 0" は Postgres 17+ で並べ直しが消えるのを防ぐためのイディオム
LIMIT 10;
```

> **判断の指針**：①まず**フィルタ列にインデックス**を張って `EXPLAIN` を見る。プランナが賢く B-tree で絞れているなら、それで十分なことも多い。②それでも過剰フィルタが残るなら **`iterative_scan` を strict_order で**有効化。③再現率がさらに要るなら relaxed_order ＋ MATERIALIZED CTE。**どの段でも第2章のハーネスで再現率を必ず確認**します。

### アクセス制御は「DB側」に閉じる

メタデータフィルタは**性能の問題であると同時に、セキュリティの境界**でもあります。専用ベクトルDBだと「全件検索してアプリでフィルタ」になりがちで情報漏えいの温床ですが、Postgres集約なら**行レベルセキュリティ（RLS）**で「自テナントの行しか物理的に返らない」をDB側で強制できます（[Supabase RLSの設計](/blog/supabase-rls-production-multi-tenancy-patterns)も参照）。

```sql
ALTER TABLE doc_chunks ENABLE ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON doc_chunks
    USING (tenant_id = current_setting('app.tenant_id', true)::int);
```

アプリがどう間違えても他テナントの行は候補に入らない——**最小権限の原則をDBの不変条件として実装する**わけです。

---

## 6. 運用：HNSW の VACUUM・REINDEX・レプリケーション

チューニングは「作って終わり」ではありません。更新・削除が続く本番では、運用面の癖を知っておく必要があります。

- **VACUUM が重くなりがち**：公式いわく「**HNSWインデックスの VACUUM は時間がかかることがある。先に REINDEX すると速くなる**」。削除が多いテーブルでは、`REINDEX INDEX CONCURRENTLY` でインデックスを作り直してから `VACUUM` する運用が有効です。

```sql
REINDEX INDEX CONCURRENTLY doc_chunks_embedding_idx;   -- 無停止で再構築
VACUUM doc_chunks;
```

- **レプリケーション対応**：pgvector のインデックスは WAL に乗るため、**ストリーミングレプリケーションでそのまま複製**されます。リードレプリカに検索を逃がす構成が素直に組めます。
- **継続的な監視項目**：
  - **インデックスサイズ**（`\di+` / `pg_relation_size`）が `shared_buffers` に収まっているか。溢れると性能が崖を落ちる先行指標。
  - **再現率の代理指標**：第2章のハーネスを定期実行し、データ分布の変化で再現率が落ちていないか。落ちたら `ef_search` / `m` を見直す。
  - **`EXPLAIN (ANALYZE, BUFFERS)`** のバッファヒット率：ディスク読みが増えていればメモリ不足のサイン。

> **PIIの注意**：検索クエリ本文には個人情報が含まれ得ます。可観測性のために**クエリやチャンク本文を生ログに残さない**。記録するのは「クエリID・Top-kのID・距離・レイテンシ・再現率」といったメタデータに留めます。

---

## 7. まとめ：pgvector チューニング・チートシート

迷ったときの早見表です。

- **メンタルモデル**：再現率 × レイテンシ × メモリの三角形。**測ってから**動かす。
- **インデックス**：迷ったら **HNSW**（空テーブルに先に張れる＝継続インジェストに強い）。検索品質は再構築不要の **`hnsw.ef_search`**、構築品質は **`m` / `ef_construction`**。一括ロード＋バッチ運用なら IVFFlat（`lists` はデータ量に合わせる）。
- **再現率は必ず測る**：ANN vs **インデックスを切った厳密kNN** の recall@k。`EXPLAIN` で **Index Scan** を確認。`Seq Scan` ならまず**形**を直す。
- **構築の高速化**：`maintenance_work_mem` を大きく、`max_parallel_maintenance_workers` を増やし、**ロード後に構築**。進捗は `pg_stat_progress_create_index`。
- **量子化でコスト削減**：**`halfvec`（半分）から試す** → 足りなければ **`binary_quantize`＋元ベクトル再ランク（約1/30）** や **`subvector`（Matryoshka）＋再ランク**。
- **フィルタの罠**：**過剰フィルタ**は「候補を先に集めてから WHERE」で起きる。フィルタ列インデックス／部分インデックス／パーティショニングを選択率で使い分け、**反復スキャン（0.8.0）** で自動追加走査。厳密順が要るなら relaxed_order ＋ MATERIALIZED CTE（`+ 0`）。
- **アクセス制御**：メタデータフィルタ＝セキュリティ境界。**RLSでDB側に閉じる**。
- **運用**：HNSW の VACUUM は重い → 先に `REINDEX CONCURRENTLY`。インデックスサイズと再現率を継続監視。

ベクトル検索のチューニングは、魔法のパラメータを探す作業ではありません。**「再現率を測る検証パスを先に作り、三角形のどこに立つかを意思決定する」**——これに尽きます。pgvector が優れているのは、その意思決定を**使い慣れた PostgreSQL の中**で、`EXPLAIN`・式インデックス・RLS といった**既存の道具で完結できる**ことです。

私は [生成AI音声チャットボット](/case-studies/ai-voice-chatbot) で、**専用ベクトルDBを増設せず PostgreSQL + pgvector に業務データと埋め込みを集約**し、`text-embedding-3-large`（1024次元）・HNSW・top-10検索の本番RAGを、一人 × 生成AI（Claude Code）で設計・実装・運用まで通しました。次元・インデックス・量子化・フィルタを**計測に基づいて**選び、“データストアを1つに保つ”運用の単純さの上に、速さと正確さを両立させています。

**「pgvector が遅い／精度が出ない／コストが膨らむ」——その原因の切り分けから、インデックス設計・量子化・フィルタ最適化・運用までを一気通貫で伴走できます。** RAGの全体設計から入りたい方は姉妹記事 [pgvector で作る本番RAG](/blog/pgvector-postgres-production-rag-hybrid-search) もどうぞ。要件整理の段階からでも、お気軽にご相談ください。

---

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

- [pgvector（GitHub・README）](https://github.com/pgvector/pgvector) — `vector` / `halfvec` / `bit` / `sparsevec` 型と次元上限・距離演算子・HNSW / IVFFlat の作成構文とパラメータ（`m` / `ef_construction` / `hnsw.ef_search` / `lists` / `ivfflat.probes`）・**反復スキャン**（`hnsw.iterative_scan` / `max_scan_tuples` / `scan_mem_multiplier` / `ivfflat.max_probes`）・**量子化**（`halfvec` 式インデックス / `binary_quantize` + `bit_hamming_ops` / `subvector`）・フィルタと過剰フィルタ・構築高速化（`maintenance_work_mem` / `max_parallel_maintenance_workers`）・`pg_stat_progress_create_index`・VACUUM/REINDEX
- [pgvector CHANGELOG](https://github.com/pgvector/pgvector/blob/master/CHANGELOG.md) — 反復スキャン（0.8.0）、`hamming_distance` / `jaccard_distance` と L1 の HNSW 対応（0.7.0）など、バージョンごとの追加機能
- [OpenAI Embeddings ガイド](https://platform.openai.com/docs/guides/embeddings) — `text-embedding-3-large` / `-small`・`dimensions` パラメータによる次元短縮（Matryoshka）・`subvector` 再ランクの前提
