- AI ⚙️
pgvector HNSWとIVFFlatの選び方|ef_searchと再現率を実測
結論
pgvector のインデックスは、行の追加・更新があり、速さと再現率を両立したいなら HNSW、構築時間と容量を抑えたい静的なデータなら IVFFlat が基本です。5万件・384次元のクラスタ型ベクトルで測ると、HNSW(既定の m=16)は ef_search=80 で再現率@10 が 0.988・p50 1.0ms、IVFFlat は同じくらいの速さでは 0.85〜0.89 にとどまり、構築は IVFFlat が 2.5 秒・HNSW が 16 秒でした。
この記事でわかること
- HNSW の m・ef_construction・ef_search、IVFFlat の lists・probes ごとの再現率@10・p50/p95・構築時間・インデックス容量の実測表
- フィルタ付き検索で件数が 10 件に届かない問題(1% の条件で平均 0.4 件)と、部分インデックスなど 0.6.0 でも使える対策
- 一様乱数のベクトルでは再現率が大きく違う理由、maintenance_work_mem の NOTICE、量子化(float16・int8・1bit)の再現率
この記事で実際に動かしたもの:自分で initdb した PostgreSQL 16.15 に pgvector 0.6.0 を入れ、合成ベクトル 5万件で HNSW 4 通り・IVFFlat 3 通りのインデックスを作り、それぞれ 200 問の検索を厳密検索の答えと突き合わせました。
flowchart TD
A[索引を選ぶ] --> B{追加・更新が<br>多い?}
B -->|はい| H[HNSW]
B -->|いいえ| C{1ms 台で<br>recall 0.95+?}
C -->|はい| H
C -->|いいえ| I[IVFFlat]
H --> E[ef_search を測る]
I --> F[probes を測る]
よく調べられることの答えを先にまとめます。
| 知りたいこと | 答え | 詳しい節 |
|---|---|---|
| hnsw ivfflat 違い | HNSW はグラフをたどる。IVFFlat は k-means の区画を probes 個だけ調べる。HNSW は速さと再現率が上、IVFFlat は構築が速い | 仕組みと版の差 |
| pgvector hnsw の既定値 | m=16・ef_construction=64・hnsw.ef_search=40(0.6.0 では 1〜1000) | HNSW の実測 |
| ef_search はいくつにするか | この実測では 40 で 0.948、80 で 0.988、160 で 0.994。実データで 1 問ごとの最小値も見て決める | HNSW の実測・平均に隠れる 0 件 |
| IVFFlat の lists と probes | 公式の目安は lists=行数÷1000(100万行まで)、probes=√lists から。probes の既定は 1 | IVFFlat の実測 |
| pgvector インデックスが使われない | 演算子と opclass の不一致・LIMIT なし・類似度の式で並べ替え・結合した列を距離に使う | 演算子と opclass |
| WHERE を付けると件数が減る | 索引が返した候補(最大 ef_search 件)を後から絞るため。部分インデックスか、0.8.0 以降の iterative scan | フィルタ付き検索 |
| 構築が遅い・NOTICE が出る | グラフが maintenance_work_mem に収まらない。32MB では 16 秒→86 秒に伸びた | maintenance_work_mem と並列構築 |
| halfvec や binary quantize を使いたい | 0.7.0 以降。0.6.0 では type "halfvec" does not exist | 量子化 |
確認環境:Ubuntu 24.04.5 LTS(CPU 2 コア・メモリ 8GB の仮想マシン。他の処理も同時に動いているため、時間は数十%ぶれます)。PostgreSQL 16.15 を自分のデータディレクトリ(tmpfs 上)で initdb して 127.0.0.1 だけで起動し、Ubuntu の apt にある postgresql-16-pgvector 0.6.0-1 を入れました。主な設定は shared_buffers = 192MB・maintenance_work_mem = 256MB(節ごとに変更)・max_parallel_maintenance_workers = 1・max_parallel_workers_per_gather = 0(検索は 1 プロセス)です。クライアントは Python 3.13.18 + psycopg 3.3.7 + numpy 2.5.3。データは 5万件 × 384 次元の合成ベクトル(作り方は次の節)、質問は別に作った 200 件で、numpy の総当たりで求めた上位 10 件を正解にしました。インデックスの容量と構築時間を数分以内に抑えるため件数は 5万件にしています。pgvector 0.7.0 以降の機能(halfvec・bit・iterative scan など)と最新版 0.8.6 の内容は、GitHub の README と CHANGELOG で確かめた範囲で、この環境では動かしていません。図は mermaid で描画を確かめています(確認日 2026年11月4日)。
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「pgvector HNSWとIVFFlatの選び方|ef_searchと再現率を実測」
pgvector の HNSW と IVFFlat の違い:仕組みと版ごとの差
この節では、2 つのインデックスが何をしているかと、調整する値がどこに効くか、pgvector の版でできることがどう違うかが分かります。
どちらも「近似」の索引で、厳密な上位 k 件の一部を取りこぼす代わりに速く返します。pgvector の README にも「通常のインデックスと違い、近似インデックスを付けるとクエリの結果が変わる」と書かれています。取りこぼしの割合を測るのが再現率(recall@k:返した k 件のうち本当の上位 k 件に入っていた割合)です。
HNSW(階層型の近傍グラフ)
各ベクトルを最大 m 本の辺でつないだ多層のグラフ。上の層で大まかに、下の層で細かくたどる
作るとき:m(既定 16)と ef_construction(既定 64、2×m 以上)
検索するとき:hnsw.ef_search(既定 40)。候補を何件抱えながらたどるか
空の表にも作れて、行を足すとグラフに挿入される
IVFFlat(k-means の区画)
作るときに k-means で lists 個の中心を求め、各行を最も近い中心のリストに入れる
作るとき:lists(目安は行数÷1000、100万行を超えたら √行数)
検索するとき:ivfflat.probes(既定 1、目安は √lists から)。近い中心から何リスト調べるか
中心は作り直すまで変わらない。データを入れてから作る
使っている版によって、使える型と機能が変わります。Ubuntu 24.04 の apt で入るのは 0.6.0 で、2026年11月4日時点の最新リリースは CHANGELOG によると 0.8.6(2026年7月29日)です。
| 版 | この記事に関係する変更(CHANGELOG より) | 0.6.0 で試した結果 |
|---|---|---|
| 0.5.0(2023-08) | HNSW を追加。IVFFlat の並列構築 | 使える |
| 0.6.0(2024-01) | HNSW の並列構築。構築時のメモリと WAL を削減。GUC の値の検査 | この記事の環境 |
| 0.7.0(2024-04) | halfvec・sparsevec 型、bit の索引、L1 距離(<+>)の索引、binary_quantize | type "halfvec" does not exist |
| 0.8.0(2024-10) | iterative index scan。フィルタ時のコスト見積もりを改善 | invalid configuration parameter name "hnsw.iterative_scan" |
| 0.8.3〜0.8.6(2026) | HNSW の VACUUM 中の索引破損の修正、IVFFlat 構築が maintenance_work_mem を超える問題の修正など | 動かしていない |
インストールと基本の使い方(テーブル作成・検索・Python からの登録)は、関連記事「pgvectorの使い方|PostgreSQLでベクトル検索をコピペで構築」で扱っています。この記事はその先の「どの索引を、どの値で使うか」を実測で決めることに絞ります。
実測の条件:合成ベクトルをクラスタ型にした理由と、厳密検索の速さ
この節では、ベンチマークのデータの作り方が結果をどれだけ左右するかと、正解(厳密検索)の求め方・速さが分かります。
一様乱数のベクトルで測ると、近似索引の再現率は実際の埋め込みよりずっと低く出ます。一様乱数では「いちばん近いもの」と「100 番目に近いもの」の差がほとんど無く、どの索引も近傍を見分けられないためです。実際の文章の埋め込みは、話題ごとにまとまり(クラスタ)があり、まとまりの中も少ない方向にしか広がっていません。そこで、次のように作りました。
import numpy as np
N, D = 50_000, 384
def make(n, seed, n_clusters=100, latent=16):
g = np.random.default_rng(0) # 中心と形は共通
centers = g.standard_normal((n_clusters, D)).astype(np.float32)
centers /= np.linalg.norm(centers, axis=1, keepdims=True)
bases = g.standard_normal((n_clusters, latent, D)).astype(np.float32) / np.sqrt(D)
w = 1.0 / np.arange(1, n_clusters + 1) ** 0.8 # クラスタの大きさを Zipf 風に偏らせる
w /= w.sum()
r = np.random.default_rng(seed)
k = r.choice(n_clusters, size=n, p=w)
z = r.standard_normal((n, latent)).astype(np.float32)
x = centers[k] + 0.9 * np.einsum('nl,nld->nd', z, bases[k]) # 16 次元の広がり
x += 0.15 / 8 * r.standard_normal((n, D)).astype(np.float32) # 小さな等方ノイズ
return x / np.linalg.norm(x, axis=1, keepdims=True), k # L2 正規化
data, label = make(N, seed=1) # 登録する 5万件(最大のクラスタ 6,189 件、最小 135 件)
query, _ = make(200, seed=2) # 質問 200 件(登録データには含まれない)
2 種類のデータで、質問ごとのコサイン類似度の分布を比べた結果です(質問 200 件の平均)。
| データ | 1 位の類似度 | 10 位 | 全体の中央値 | 10 位と 100 位の差 |
|---|---|---|---|---|
| クラスタ型(この記事) | 0.716 | 0.573 | 0.001 | 0.231 |
| 一様乱数(各成分 0〜1 を正規化) | 0.809 | 0.800 | 0.750 | 0.009 |
一様乱数では 10 位と 100 位の差が 0.009 しかなく、「上位 10 件」がほぼ偶然で決まります。同じ HNSW(m=16・ef_construction=64)でも、再現率は次のように大きく違いました。
| 設定 | クラスタ型 recall@10 | 一様乱数 recall@10 | 一様乱数の p50 |
|---|---|---|---|
| HNSW ef_search=40 | 0.948 | 0.320 | 1.08ms |
| HNSW ef_search=320 | 0.996 | 0.730 | 4.72ms |
| HNSW ef_search=1000 | ― | 0.893 | 12.19ms |
| IVFFlat lists=50 probes=1 | 0.477 | 0.051 | 0.97ms |
| IVFFlat lists=50 probes=20 | 0.940 | 0.637 | 13.05ms |
反対に、クラスタの間が離れすぎた合成データでは再現率がほぼ 1.0 になり、差が見えません。前述の pgvector の使い方の記事(10万件・128 次元・中心 1,000 個)では、HNSW の既定で 1.000 でした。合成データの数値は「傾向」を見るためのもので、最終的な値は自分の埋め込みと質問で測り直す前提です。測り方は後の「自分で確かめる」手順に載せます。
正解は厳密検索:索引なしで 1 問 32ms
正解は numpy の総当たり(内積)で求め、PostgreSQL の索引なしの検索と 50 問で突き合わせて一致(recall 1.000)を確かめました。索引なしの検索は表全体(10,048 ページ)を読んで top-N heapsort をします。
Limit (actual time=39.101..39.104 rows=10 loops=1)
Buffers: shared hit=10048
-> Sort (actual time=39.100..39.101 rows=10 loops=1)
Sort Key: ((embedding <=> '[-0.015004,...]'::vector))
Sort Method: top-N heapsort Memory: 25kB
-> Seq Scan on items (actual time=0.005..33.162 rows=50000 loops=1)
Execution Time: 39.135 ms
-- HNSW の索引があるとき(同じ質問)
Limit (actual time=7.178..7.194 rows=10 loops=1)
Buffers: shared hit=694
-> Index Scan using bench_idx on items
5万件なら厳密検索でも 30ms 台です。件数が数万件までで、質問の数も多くないなら、索引を付けずに厳密検索で始め、遅くなってから索引を足す判断もできます。
HNSW の ef_search・m・ef_construction と再現率・応答時間
この節では、HNSW の 3 つの値をそれぞれ動かしたとき、再現率@10 と p50/p95、構築時間、容量がどう変わるかが分かります。
再現率は ef_search を上げるほど上がり、応答時間はほぼ比例して伸びます。上限を決めるのは作るときの m と ef_construction です。m=8 の索引は ef_search を 320 にしても 0.913 で止まりました。
| ef_search | m=8・efc=32 | m=16・efc=64(既定) | m=16・efc=128 | m=32・efc=128 |
|---|---|---|---|---|
| 10 | 0.493(0.43ms) | 0.790(0.50ms) | 0.865(0.50ms) | 0.955(0.80ms) |
| 20 | 0.594(0.54ms) | 0.880(0.59ms) | 0.946(0.63ms) | 0.990(0.82ms) |
| 40(既定) | 0.681(0.62ms) | 0.948(0.75ms) | 0.990(0.89ms) | 1.000(1.23ms) |
| 80 | 0.812(0.99ms) | 0.988(0.99ms) | 1.000(1.22ms) | 1.000(1.63ms) |
| 160 | 0.888(1.41ms) | 0.994(1.61ms) | 1.000(1.99ms) | 1.000(3.60ms) |
| 320 | 0.913(2.59ms) | 0.996(2.75ms) | 1.000(3.86ms) | 1.000(7.82ms) |
数値は recall@10(括弧内は p50)。p95 は既定の索引で ef_search=40 が 1.17ms、80 が 1.40ms、320 が 4.13ms でした。作る側の値の違いは次のとおりです。
| 作るときの値 | 構築時間 | 索引の容量 | recall 0.99 に届く ef_search |
|---|---|---|---|
| m=8・ef_construction=32 | 7.8 秒 | 97.7MB | 届かない(320 で 0.913) |
| m=16・ef_construction=64(既定) | 15.6 秒 | 97.7MB | 160(0.994) |
| m=16・ef_construction=128 | 30.5 秒 | 97.7MB | 40(0.990) |
| m=32・ef_construction=128 | 78.3 秒 | 101.4MB | 20(0.990) |
1384 次元では容量は m でほぼ変わらない
索引の大部分はベクトル本体(1 件 1.5KB 強)で、辺の情報は小さい。m=8 と m=16 は同じ 97.7MB、m=32 でも 101.4MB。
2ef_construction は「先払い」
既定の 2 倍(128)で構築は 2 倍の 30.5 秒。その代わり ef_search=40 のまま 0.948→0.990 になり、検索側の時間を増やさずに済む。
3m を上げると 1 歩が重くなる
m=32 は少ない ef_search で高い再現率になるが、同じ ef_search では遅い(320 で 7.82ms)。高い再現率が要る場合に効く。
同じ設定で作り直しても、再現率は少しぶれます。既定の m=16・ef_construction=64・ef_search=40 を 6 回作り直したところ、0.932・0.940・0.948・0.951・0.958・0.985 でした。並列構築で挿入の順序が変わり、グラフの形が毎回違うためと考えられます。0.01〜0.05 の差で設定を比べるときは、作り直しのぶれより大きい差かを確かめます。
ef_search はセッションごとに変えられます。1 つのトランザクションだけで変えるなら SET LOCAL を使います。0.6.0 では範囲外の値はエラーになります。
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id FROM items ORDER BY embedding <=> $1 LIMIT 10;
COMMIT;
SET hnsw.ef_search = 1001;
ERROR: 1001 is outside the valid range for parameter "hnsw.ef_search" (1 .. 1000)
IVFFlat の lists と probes の再現率・応答時間
この節では、IVFFlat の lists(作るとき)と probes(検索するとき)の組み合わせで、再現率と速さがどう変わるかが分かります。
IVFFlat は probes を増やすと再現率が上がりますが、調べる行数に比例して遅くなり、このデータでは 0.9 を超えるあたりから HNSW より遅くなりました。公式の目安(5万件なら lists=50)と、√行数(224)、多め(500)を比べました。
| probes | lists=50(行数÷1000) | lists=224(√行数) | lists=500 |
|---|---|---|---|
| 1(既定) | 0.477(0.71ms) | 0.484(0.32ms) | 0.462(0.31ms) |
| 2 | 0.639(1.42ms) | 0.627(0.43ms) | 0.629(0.34ms) |
| 5 | 0.782(2.82ms) | 0.787(0.78ms) | 0.824(0.53ms) |
| 10 | 0.853(5.46ms) | 0.846(1.42ms) | 0.891(0.80ms) |
| 20 | 0.940(11.49ms) | 0.882(2.46ms) | 0.923(1.45ms) |
| 50 | 1.000(29.10ms) | 0.932(6.11ms) | 0.940(2.80ms) |
構築時間は lists=50 が 1.3 秒、224 が 2.5 秒、500 が 10.7 秒、容量はいずれも 78〜80MB(表とほぼ同じ)でした。lists=50・probes=50 は全リストを調べるので厳密検索と同じ 1.000 ですが、時間も索引なしに近づきます。
- 1probes=1 のままにしない既定の 1 では再現率 0.46〜0.48。まず √lists(lists=50 なら 7 前後)から測る
- 2lists は「1 リストの行数」で考えるlists=50 は 1 リスト約 1,000 行、500 は約 100 行。probes を同じにすると lists が多いほど速く、再現率は下がりやすい
- 3クラスタが k-means の境目で割れるlists=224 は probes=50 でも 0.932。近い行が隣の区画に入ると、その区画を調べるまで見つからない
- 4少ないデータで作らない2 行の表に作ると
NOTICE: ivfflat index created with little data・DETAIL: This will cause low recall.
HNSW と IVFFlat を同じ応答時間で比べる:再現率・構築時間・容量
この節では、2 つの索引を「同じくらいの速さ」にそろえたとき、どちらがどれだけ再現率で上回るかと、その代わりに何を払うかが分かります。
p50 がほぼ 1ms の点で比べると、HNSW(既定)は 0.988、IVFFlat は最良の lists=500 でも 0.891 でした。IVFFlat が勝つのは構築時間で、HNSW の約 6 分の 1 です。
| 設定 | recall@10 | p50 / p95 | 構築時間 | 容量 |
|---|---|---|---|---|
| 索引なし(厳密) | 1.000 | 32.4 / 44.1ms | ― | 0(表 79MB) |
| HNSW m=16・efc=64・ef_search=80 | 0.988 | 0.99 / 1.40ms | 15.6 秒 | 97.7MB |
| HNSW m=16・efc=128・ef_search=40 | 0.990 | 0.89 / 1.26ms | 30.5 秒 | 97.7MB |
| IVFFlat lists=500・probes=10 | 0.891 | 0.80 / 1.03ms | 10.7 秒 | 80.4MB |
| IVFFlat lists=224・probes=10 | 0.846 | 1.42 / 1.98ms | 2.5 秒 | 79.1MB |
上位の英語の解説でも、100万件・50 次元の ANN-benchmarks で「IVFFlat は構築 128 秒・257MB、HNSW は 4,065 秒・729MB だが、検索は HNSW が約 15 倍速い」という同じ向きの結果が示されています。AWS の解説(1,536 次元・58,634 件)では、HNSW の構築が 0.5.0 の約 81 秒から 0.6.0 で約 30 秒に縮んだと報告されています。規模が大きくなるほど HNSW の構築時間とメモリが効いてくるので、次の節の maintenance_work_mem が大事になります。
「IVFFlat は軽いので、まず IVFFlat」は成り立つか
見分け方:作り直しの頻度と、許せる構築時間
maintenance_work_mem と並列構築:HNSW の構築が数倍遅くなる NOTICE
この節では、HNSW の構築時間がメモリの設定でどれだけ変わるか、出るメッセージの原文と、構築の進み具合の見方が分かります。
HNSW は構築中のグラフを maintenance_work_mem の中に置き、収まらなくなるとディスク上で組み立てる方式に切り替わって遅くなります。そのとき次の NOTICE が出ます(README にある DETAIL・HINT も同じ文面です)。
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 14907 tuples
DETAIL: Building will take significantly more time.
HINT: Increase maintenance_work_mem to speed up builds.
| maintenance_work_mem | 並列ワーカー | 構築時間 | NOTICE |
|---|---|---|---|
| 256MB | 1(リーダー+1) | 11.9〜15.6 秒 | なし |
| 256MB | 0 | 23.3〜43.2 秒 | なし |
| 32MB | 1 | 86.3 秒 | 14,907 件で収まらなくなった |
| 8MB | 0 | 96.8 秒 | 3,727 件で収まらなくなった |
5万件の HNSW 索引は 98MB なので、グラフ全体を載せるには 100MB 強が要ります。範囲のある値は同じ条件を 2 回測った幅で、他の処理と CPU を分け合っているためぶれています。IVFFlat は k-means のための領域が足りないと、作り始める前にエラーで止まりました。
SET maintenance_work_mem = '16MB';
CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 224);
ERROR: memory required is 28 MB, maintenance_work_mem is 16 MB
必要な量を見積もる
表と同じ件数で小さく作った索引の大きさ(pg_relation_size)から、全件の索引の大きさを比例で見積もり、それより多めにする。
構築するセッションだけ上げる
SET maintenance_work_mem = '2GB'; をその接続で実行してから CREATE INDEX。サーバー全体の値は変えない。並列ワーカーもそれぞれメモリを使う点に注意。
並列ワーカーを指定する
SET max_parallel_maintenance_workers = 7;(README の例)。上限は max_parallel_workers(既定 8)にも縛られる。0.6.0 から HNSW も並列で作れる。
進み具合を見る
別の接続から pg_stat_progress_create_index を引く。HNSW は blocks_done / blocks_total で進む。
並列ワーカー 0 で HNSW を作りながら、別の接続から 6 秒ごとに引いた結果です。
SELECT phase, blocks_done, blocks_total, tuples_done FROM pg_stat_progress_create_index;
0.3s building index: loading tuples | 196 | 10048 | 983
6.3s building index: loading tuples | 2550 | 10048 | 12754
12.3s building index: loading tuples | 4662 | 10048 | 23312
18.3s building index: loading tuples | 6613 | 10048 | 33065
24.4s building index: loading tuples | 8418 | 10048 | 42092
blocks_total は表のページ数(10,048)です。本番の表で作るときは CREATE INDEX CONCURRENTLY にすると書き込みを止めずに作れますが、そのぶん時間がかかります(PostgreSQL の通常の索引と同じ扱いです)。
平均の再現率に隠れる「1 問だけ 0 件ヒット」を見る
この節では、平均の recall@10 が高くても一部の質問で全く当たらないことがある理由と、その見つけ方が分かります。
既定の HNSW(ef_search=40、平均 0.984 の回)でも、200 問のうち 2 問は recall 0.0 で、正解の 10 件を 1 件も返しませんでした。実測表の各設定でも、ef_search=80 までは「最小 0.0」の質問が残っていました。
| 質問の属するクラスタの件数 | 質問数 | ef_search=40 平均 / 最小 | ef_search=200 平均 / 最小 |
|---|---|---|---|
| 200 件未満 | 20 | 0.990 / 0.9 | 1.000 / 1.0 |
| 200〜399 件 | 48 | 0.952 / 0.0 | 0.996 / 0.9 |
| 400〜999 件 | 52 | 0.996 / 0.9 | 0.998 / 0.9 |
| 1,000 件以上 | 80 | 0.995 / 0.9 | 0.997 / 0.9 |
0 件の 2 問は、どちらも 229 件・328 件の中くらいのクラスタに属していました。グラフをたどる途中で別のクラスタの「それらしい近さ」の点に入り込み、候補(ef_search 件)がそちらで埋まると、本来のクラスタに戻れません。m=8 の索引では ef_search=320 でも最小 0.0 が消えませんでした。
RAG などで「この質問だけ関係ない文書が返る」と言われたときは、この形を疑います。平均だけでなく、質問ごとの recall の最小値と、0.5 未満の質問の数を見ます。ef_search を上げる・ef_construction を上げて作り直す、のどちらかで消えるかを確かめます。RAG の検索部分の組み立ては、関連記事「RAGの作り方|Pythonで動かす最小構成と精度の上げ方」で扱っています。
自分で確かめる:1 問の recall@10 を SQL だけで出す
自分の表で、ある行と似た行を索引あり・なしで取り、重なりを数えます。厳密検索は SET LOCAL enable_indexscan = off でそのトランザクションだけ索引を使わせません。
CREATE TEMP TABLE approx AS
SELECT id FROM items
ORDER BY embedding <=> (SELECT embedding FROM items WHERE id = 4242) LIMIT 10;
BEGIN;
SET LOCAL enable_indexscan = off;
CREATE TEMP TABLE exact AS
SELECT id FROM items
ORDER BY embedding <=> (SELECT embedding FROM items WHERE id = 4242) LIMIT 10;
COMMIT;
SELECT count(*) AS hit, count(*) / 10.0 AS recall_at_10 FROM approx JOIN exact USING (id);
hit | recall_at_10
-----+------------------------
10 | 1.00000000000000000000
-- 時間は approx 4.8ms、exact 33.6ms
これを本番の質問ログから選んだ 100〜200 問で回せば、自分のデータでの再現率になります。質問のベクトルは表の行ではなく、実際の質問文の埋め込みを使うほうが本番に近くなります。
距離演算子(<->・<#>・<=>)と opclass の対応:インデックスが使われない書き方
この節では、距離の演算子と索引の opclass の組み合わせ、索引が使われない典型的な SQL と、EXPLAIN での確かめ方が分かります。
索引は作ったときの opclass と同じ演算子で、ORDER BY 列 演算子 定数 LIMIT n の形のときだけ使われます。
| 距離 | 演算子 | opclass | 0.6.0 |
|---|---|---|---|
| L2(ユークリッド) | <-> | vector_l2_ops | 使える |
| 内積(符号を反転した値) | <#> | vector_ip_ops | 使える |
| コサイン距離(1 − 類似度) | <=> | vector_cosine_ops | 使える |
| L1 | <+> | vector_l1_ops | 0.7.0 から(operator does not exist) |
| ハミング・ジャッカード(bit) | <~>・<%> | bit_hamming_ops・bit_jaccard_ops | 0.7.0 から |
opclass を省くと、0.6.0 では data type vector has no default operator class for access method "hnsw" で止まります。正規化済みのベクトル(多くの埋め込みモデルの出力)なら、内積・コサイン・L2 の順位は同じになるので、どれか 1 つに決めて、索引と SQL をそろえます。cosine の索引を作った表で、6 通りの SQL を EXPLAIN した結果です。
| SQL の形 | 計画 | 理由・直し方 |
|---|---|---|
ORDER BY embedding <=> '[..]' LIMIT 10 | Index Scan | 正しい形 |
ORDER BY embedding <-> '[..]' LIMIT 10 | Seq Scan → Sort | 索引は cosine。演算子を合わせるか、L2 の索引を別に作る |
| LIMIT なし | Seq Scan → Sort | 近似索引は LIMIT 付きの上位 k 件のためのもの |
ORDER BY 1 - (embedding <=> '[..]') DESC | Seq Scan → Sort | 類似度に変換して並べると使われない。距離で ASC に並べ、表示用に類似度を SELECT で出す |
SELECT embedding <=> '[..]' AS d ... ORDER BY d | Index Scan | 別名で並べるのは使われる |
結合した表の列を距離に使う(FROM items i, (SELECT embedding v ...) q ORDER BY i.embedding <=> q.v) | Nested Loop → Seq Scan → Sort | 定数でないため。スカラー副問い合わせ (SELECT embedding FROM ... ) にすると InitPlan で Index Scan(40ms → 2.3ms) |
最後の行は見落としやすい形です。「ある記事に似た記事」を出すとき、結合で書くと索引を使わず、件数が増えるほど遅くなります。
EXPLAIN (ANALYZE, COSTS OFF)
SELECT id FROM items ORDER BY embedding <=> (SELECT embedding FROM items WHERE id = 4242) LIMIT 10;
Limit (actual time=2.206..2.294 rows=10 loops=1)
InitPlan 1 (returns $0)
-> Index Scan using items_pkey on items items_1
Index Cond: (id = 4242)
-> Index Scan using items_hnsw on items (actual time=2.205..2.290 rows=10 loops=1)
Order By: (embedding <=> $0)
Execution Time: 2.320 ms
自分で確かめるときは、検索の SQL の前に EXPLAIN を付け、Index Scan using 索引名 と Order By: の行が出ているかを見ます。Seq Scan と Sort が出ていれば索引は使われていません。コサイン類似度そのものの意味と計算は、関連記事「エンベディングとは?ベクトル検索とコサイン類似度を計算例で解説」で扱っています。
フィルタ付き検索で件数が足りなくなる問題と対策(iterative scan は 0.8.0 から)
この節では、WHERE 条件を付けたベクトル検索で返る件数が LIMIT に届かない理由と、0.6.0 でも使える対策の効き目が分かります。
近似索引は「距離の近い順に最大 ef_search 件(HNSW)」の候補を出し、WHERE はその候補に後からかかります。条件に合う行が 1% しかないと、40 件の候補のうち平均 0.4 件しか残りませんでした。クラスタと無関係な属性 cat(0〜99 の一様。1 値あたり約 1%)を付けて測りました。
| 条件と方法 | 返った件数 平均 / 最小 | 0 件の質問 | recall@10 | p50 |
|---|---|---|---|---|
| 1%(cat = 5)・ef_search=40 | 0.4 / 0 | 135 / 200 | 0.039 | 0.99ms |
| 1%・ef_search=400 | 3.9 / 0 | 2 | 0.391 | 4.87ms |
| 1%・ef_search=1000(上限) | 8.6 / 3 | 0 | 0.837 | 11.60ms |
| 10%(cat < 10)・ef_search=40 | 3.9 / 0 | 3 | 0.383 | 0.92ms |
| 10%・ef_search=200 | 10.0 / 10 | 0 | 0.975 | 2.35ms |
| 50%(cat < 50)・ef_search=40 | 10.0 / 10 | 0 | 0.985 | 1.02ms |
| 1%・部分インデックス(WHERE cat = 5、968kB) | 10.0 / 10 | 0 | 0.995 | 0.43ms |
| 1%・B-tree(cat) を追加しただけ | 0.4 / 0 | 135 | 0.039 | 0.93ms |
1%・B-tree(cat)+OFFSET 0 の副問い合わせで厳密検索 | 10.0 / 10 | 0 | 1.000 | 0.86ms |
B-tree を足しただけでは、0.6.0 のプランナーは HNSW の Index Scan を選び続け、結果は変わりませんでした(0.8.0 の CHANGELOG に「フィルタ時のコスト見積もりを改善」とあるのはこの点です)。絞り込んだ行が少ないなら、条件で先に絞ってから厳密に並べるほうが速く正確です。OFFSET 0 は副問い合わせを外に展開させないための書き方です。
SELECT id FROM (
SELECT id, embedding FROM items WHERE cat = 5 OFFSET 0
) s
ORDER BY embedding <=> $1 LIMIT 10;
-- Limit → Sort → Subquery Scan → Bitmap Heap Scan on items → Bitmap Index Scan on items_cat
- 1条件で残る行が数千〜数万件までB-tree で絞って厳密検索。この環境では 500 件で 0.86ms・recall 1.000
- 2条件の値が少数で固定(テナント・言語・公開状態)値ごとの部分インデックス、または値でパーティション分割(README の方法)
- 3条件に合う行が全体の数十%ef_search を上げる。10% で 200、50% なら既定でも 10 件そろった
- 4条件が多様で上のどれも合わない0.8.0 以降に上げて iterative scan(README の範囲で、この環境では動かしていない)
0.8.0 以降の iterative scan は、件数がそろうまで索引の走査を続ける機能です。README によると SET hnsw.iterative_scan = strict_order;(または relaxed_order)、IVFFlat は ivfflat.iterative_scan = relaxed_order で有効にし、HNSW は hnsw.max_scan_tuples(既定 20,000)、IVFFlat は ivfflat.max_probes で上限を決めます。relaxed_order は順序が少し入れ替わることがあるため、厳密な順序が要るなら外側で距離順に並べ直すと README にあります。0.6.0 で設定しようとすると、名前がまだ無いためエラーになります。
SET hnsw.iterative_scan = relaxed_order;
ERROR: invalid configuration parameter name "hnsw.iterative_scan"
DETAIL: "hnsw" is a reserved prefix.
あとから追加した行と再現率・挿入速度(HNSW と IVFFlat)
この節では、索引を作ったあとに行を追加すると、再現率と挿入の速さがどう変わるかが分かります。
HNSW は追加しても再現率が落ちませんが、挿入は索引なしの約 15 倍遅くなりました。IVFFlat は挿入が速い代わりに、作ったときの区画のまま使うため再現率が下がります。2万5千件の表で、5%(1,250 件)だけ入れて索引を作り、残り 23,750 件を COPY で追加しました。
| 索引と順序 | 挿入にかかった時間 | recall@10 |
|---|---|---|
| 索引なしで 23,750 件を COPY | 3.5 秒 | ― |
| HNSW:先に作って 23,750 件を追加 | 52.0 秒 | 0.985 |
| HNSW:25,000 件を入れてから作る | 4.0 秒+構築 5.8 秒 | 0.985 |
| IVFFlat(lists=25・probes=5):全件を入れてから作る | ― | 0.795 |
| IVFFlat:ランダムな 5% で作って残りを追加 | 3.8 秒 | 0.737 |
| IVFFlat:大きい 5 クラスタだけの 5% で作って残りを追加 | ― | 0.718 |
| HNSW:大きい 5 クラスタだけの 5% で作って残りを追加 | ― | 0.990 |
大量の初期投入は「全部入れてから索引を作る」ほうが、HNSW でも合計 9.8 秒と速く済みます。IVFFlat は、初期データと後から入るデータの傾向が違うほど下がります(ここでは 0.795 → 0.718)。IVFFlat を使うなら、件数が大きく増えたとき・傾向が変わったときに REINDEX INDEX CONCURRENTLY で作り直す運用を決めておきます。
量子化(halfvec・int8・1bit)の再現率を numpy で実測
この節では、ベクトルを小さくしたときに再現率がどれだけ落ちるかと、pgvector の版で使える方法が分かります。
float16(halfvec 相当)と int8 はほぼ落ちず、1bit は単独では 0.719 ですが、上位 100 件を元の float で並べ直すと 1.000 に戻りました。halfvec と binary_quantize は 0.7.0 以降の機能なので、この環境(0.6.0)では使えず、同じ計算を numpy で再現しました。
bx = np.packbits(data > 0, axis=1) # 384 次元 → 48 バイト(符号だけ)
bq = np.packbits(query > 0, axis=1)
lut = np.array([bin(i).count('1') for i in range(256)], dtype=np.uint16)
ham = lut[bx[None] ^ bq[:, None]].sum(2) # ハミング距離(実際は 10 問ずつ計算)
cand = np.argsort(ham, axis=1)[:, :100] # 1bit で候補 100 件
score = np.einsum('qd,qkd->qk', query, data[cand]) # 元の float で並べ直し
| 方法 | 1 件の大きさ | クラスタ型 recall@10 | 一様乱数 recall@10 |
|---|---|---|---|
| float32(vector) | 1,536 バイト | 1.000(基準) | 1.000(基準) |
| float16(halfvec 相当) | 768 バイト | 1.000 | 0.998 |
| int8(次元ごとの最小・最大で 256 段階) | 384 バイト | 0.999 | 0.983 |
| 1bit(符号)・並べ直しなし | 48 バイト | 0.719 | 0.000 |
| 1bit → 上位 40 件を float で並べ直し | 48 バイト+元データ | 0.990 | 0.001 |
| 1bit → 上位 100 件を float で並べ直し | 48 バイト+元データ | 1.000 | 0.002 |
一様乱数(各成分が 0〜1)では全成分が正なので、符号の 1bit はすべて 1 になり、何も区別できません。符号で量子化するには、成分が 0 を中心に正負に散らばっている必要があります。自分の埋め込みで使う前に、次元ごとの平均が 0 から大きくずれていないかを見ます。
README には、0.7.0 以降で 1bit の HNSW 索引を作り、候補を元のベクトルで並べ直す例が載っています。
-- pgvector 0.7.0 以降(README の例。この環境では動かしていない)
CREATE INDEX ON items USING hnsw ((binary_quantize(embedding)::bit(384)) bit_hamming_ops);
SELECT * FROM (
SELECT * FROM items
ORDER BY binary_quantize(embedding)::bit(384) <~> binary_quantize($1)
LIMIT 100
) t ORDER BY t.embedding <=> $1 LIMIT 10;
索引にできる次元数は vector が 2,000、halfvec が 4,000、bit が 64,000 までです(README)。0.6.0 で 2,001 次元に作ると column cannot have more than 2000 dimensions for hnsw index になります。1,536 次元や 3,072 次元の埋め込みを使う場合は、版と型の選択が先に決まります。
よくあるエラー/つまずき(pgvector 0.6.0 の原文)
この節では、この環境で実際に出たエラーと NOTICE の原文、原因、直し方が分かります。
| 原文 | 原因 | 直し方 |
|---|---|---|
expected 384 dimensions, not 3 | 列の次元と違うベクトルを入れた | モデルの出力次元と vector(n) をそろえる。モデルを替えたら列ごと作り直す |
different vector dimensions 3 and 2 | 次元の違うベクトル同士で距離を計算 | 質問側の埋め込みのモデルを確かめる |
data type vector has no default operator class for access method "hnsw" | opclass を省いた | vector_cosine_ops などを付ける |
ef_construction must be greater than or equal to 2 * m | m=16 に ef_construction=16 など | ef_construction を 2×m 以上に |
value 1 out of bounds for option "m"(Valid values are between "2" and "100") | m の範囲外 | 2〜100。多くは 16〜48 で試す |
0 is outside the valid range for parameter "ivfflat.probes" (1 .. 32768) | probes の範囲外 | 1 以上、lists 以下で意味がある |
memory required is 28 MB, maintenance_work_mem is 16 MB | IVFFlat の k-means の領域が足りない | そのセッションで maintenance_work_mem を上げる |
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 14907 tuples | HNSW のグラフがメモリに収まらない | エラーではないが構築が数倍遅くなる。メモリを上げて作り直す |
NOTICE: ivfflat index created with little data | データの少ない表で IVFFlat を作った | データを入れてから作り直す |
type "halfvec" does not exist/operator does not exist: vector <+> vector | 0.7.0 以降の機能 | SELECT extversion FROM pg_extension WHERE extname = 'vector'; で版を確かめる |
エラーにならないつまずきのほうが厄介です。「索引を付けたら結果が変わった」「WHERE を付けたら件数が減った」「1 問だけ全く違う結果が返る」は、どれも近似索引の仕様どおりの動きでした。
判断表:HNSW と IVFFlat の選び方と最初の設定値
この節では、自分の条件に当てはめて、索引の種類と最初に試す値、測って動かす値が分かります。
迷ったら HNSW の既定(m=16・ef_construction=64)で作り、ef_search を 40・80・160 で測って、質問ごとの最小値が許せる値にします。
| 条件 | 選ぶもの・最初の値 | 測って動かすもの |
|---|---|---|
| 数万件まで・質問が少ない | 索引なし(厳密検索)。5万件で 32ms | 応答時間が目標を超えたら HNSW を足す |
| 行が日々追加・更新される(RAG の文書・商品) | HNSW 既定、ef_search=80〜100 | recall の平均と最小。足りなければ ef_construction=128 で作り直す |
| 再現率 0.99 以上を 1ms 台で | HNSW m=16・ef_construction=128、ef_search=40〜80 | 構築時間(既定の約 2 倍)とメモリ |
| まとめて入れ替える静的なデータ・構築を最短に | IVFFlat lists=行数÷1000、probes=√lists から | probes。入れ替えのたびに作り直す |
| WHERE で強く絞る(1% 以下) | B-tree で絞って厳密検索、または部分インデックス | 返る件数が LIMIT に届くか |
| テナントごとに検索(マルチテナント) | テナント列でパーティション分割+各パーティションに HNSW | パーティション数と索引の合計サイズ |
| 1,536 次元以上・メモリが厳しい | 0.7.0 以降で halfvec(4,000 次元まで)か 1bit+並べ直し | 並べ直す候補の件数(この実測では 100 件で 1.000) |
| apt の版が 0.6.0 | 上の判断はそのまま使える。フィルタは部分インデックスか厳密検索 | iterative scan が要るなら 0.8 系へ上げる計画 |
正解を用意する
本番の質問 100〜200 問について、索引なしの上位 10 件を保存する。
合成データで決めない
平均と最小を見る
recall の平均に加え、0.5 未満の質問数を数える。
0 件ヒットを見逃さない
p95 で決める
ef_search を倍にすると p50 はおよそ 1.3〜1.7 倍。p95 で目標に収まる最大の値を選ぶ。
1 回に 1 つ変える
作り直しのぶれを知る
同じ設定でも 0.93〜0.99 の幅があった。比べる差はこれより大きいか。
2 回作って測る
PostgreSQL の外にベクトル専用のデータベースを置く選択肢もあります。コレクションの作成と検索の流れは、関連記事「Qdrant入門|ベクトル検索のコレクション作成と検索(Python)」で扱っています。既存の PostgreSQL に業務データと一緒に置ける点、トランザクションや結合と同じ SQL で扱える点が pgvector を選ぶ主な理由になります。
よくある質問
Q. HNSW と IVFFlat、迷ったらどちらですか。
HNSW です。この実測では同じ速さで再現率が 0.1 前後高く、行の追加で再現率が落ちませんでした。IVFFlat は構築時間や容量が制約になる場合、データをまとめて入れ替える場合に選びます。
Q. ef_search はいくつにすればよいですか。
既定の 40 から 80・160 と上げて、自分の質問で recall の平均と最小、p95 を測って決めます。この合成データでは 80 で 0.988・p50 1.0ms でした。トランザクション単位なら SET LOCAL hnsw.ef_search で変えられ、0.6.0 では 1〜1000 です。
Q. 索引を作ったら検索結果が変わりました。壊れていますか。
近似索引の仕様です。README にも、近似インデックスを付けると結果が変わると書かれています。索引なしの結果と重なりを数えて、再現率として扱います。
Q. WHERE を付けると 10 件返りません。
HNSW は最大 ef_search 件の候補に後から条件をかけるためです。1% の条件では平均 0.4 件でした。条件で絞って厳密検索・部分インデックス・ef_search を上げる・0.8.0 以降の iterative scan のどれかで直します。
Q. IVFFlat の lists はいくつにしますか。
README の目安は 100万行までは行数÷1000、それを超えたら √行数、probes は √lists からです。この実測では lists=500・probes=10 が 0.891・p50 0.80ms と、同じ速さでは lists が多めのほうが良い結果でした。
Q. 索引の構築が終わりません。
NOTICE の hnsw graph no longer fits into maintenance_work_mem が出ていないか確かめます。32MB では 256MB の 5 倍以上(15.6 秒→86.3 秒)かかりました。構築するセッションだけ maintenance_work_mem と max_parallel_maintenance_workers を上げ、pg_stat_progress_create_index で進み具合を見ます。
Q. コサイン・内積・L2 のどれを使えばよいですか。
埋め込みモデルの推奨に合わせます。正規化済みのベクトルなら順位は同じなので、内積(<#>)かコサイン(<=>)のどちらかに決め、索引の opclass と SQL の演算子をそろえます。
Q. Ubuntu の apt の 0.6.0 のままでよいですか。
HNSW・IVFFlat の基本と、この記事の判断はそのまま使えます。halfvec・bit・iterative scan・フィルタ時のコスト見積もりの改善、HNSW の VACUUM まわりの修正(0.8.3・0.8.4)は 0.6.0 には入っていないので、本番で長く使うなら新しい版への更新を計画します。版は SELECT extversion FROM pg_extension WHERE extname = 'vector'; で確かめられます。
Q. 合成データの結果を本番に当てはめてよいですか。
絶対値は当てはまりません。一様乱数では ef_search=40 で 0.320、クラスタ型では 0.948 と、データの形だけでこれだけ違いました。持ち帰るのは傾向(ef_search と再現率・時間の関係、フィルタで件数が減る形、追加で IVFFlat が下がる形)で、値は自分の埋め込みで測り直します。
Q. 件数が 100万件を超えたらどうなりますか。
この環境では測っていません。上位の英語の解説の 100万件・50 次元の例では、HNSW の構築は IVFFlat の約 32 倍・容量は約 2.8 倍でした。HNSW を選ぶ場合は、構築時間とメモリ(索引の大きさ以上の maintenance_work_mem)を先に見積もります。
まとめ
- pgvector の HNSW は速さと再現率、IVFFlat は構築時間で勝つ。5万件・384 次元では p50 約 1ms で HNSW 0.988、IVFFlat 最良 0.891、構築は 15.6 秒と 2.5 秒だった。
- HNSW は ef_search で再現率と時間を動かし、上限は m・ef_construction で決まる。ef_construction=128 なら ef_search=40 のまま 0.990。m=8 は 0.913 で頭打ち。
- 平均だけでなく質問ごとの最小を見る。平均 0.98 でも 200 問中 2 問が recall 0.0 だった。合成データは一様乱数にせず、最終的には本番の質問で測る。
- WHERE で強く絞ると件数が足りなくなる(1% で平均 0.4 件)。0.6.0 では B-tree で絞って厳密検索・部分インデックス、0.8.0 以降は iterative scan。
- 索引は opclass と同じ演算子・
ORDER BY 距離 LIMIT・定数の質問ベクトルで使われる。構築はそのセッションだけ maintenance_work_mem を上げ、NOTICE が出ていないか確かめる。
参考・出典
確認日はいずれも 2026年11月4日です。
- pgvector README(Indexing・HNSW・IVFFlat・Filtering・Iterative Index Scans・Half-Precision Indexing・Binary Quantizing):https://github.com/pgvector/pgvector
- pgvector CHANGELOG(0.5.0〜0.8.7):https://github.com/pgvector/pgvector/blob/master/CHANGELOG.md
- PostgreSQL 16「Resource Consumption」(maintenance_work_mem・max_parallel_maintenance_workers):https://www.postgresql.org/docs/16/runtime-config-resource.html
- PostgreSQL 16「Progress Reporting」(pg_stat_progress_create_index):https://www.postgresql.org/docs/16/progress-reporting.html
- PostgreSQL 16「EXPLAIN」:https://www.postgresql.org/docs/16/sql-explain.html
- Malkov, Yashunin「Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs」:https://arxiv.org/abs/1603.09320
本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。
(記載例)出典:株式会社RJC「pgvector HNSWとIVFFlatの選び方|ef_searchと再現率を実測」
株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。
WE ARE HIRING
RJCで一緒に開発しながら、
成長を楽しみませんか?
RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!
- 127日年間休日
- 12時間平均残業時間
- 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!
ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。