Claude CodeでpgvectorのHNSWを調整する — recallとレイテンシの測り方
m・ef_construction・ef_searchを変えながら、厳密検索を正解にしてrecall@kと応答時間を表にする手順です。比較スクリプトをClaude Codeに書かせ、結果の読み方までを示します。
pgvectorのHNSWインデックスは、m・ef_construction・ef_searchの3つで速度と取りこぼしのバランスが決まります。既定値のままでも動きますが、検索結果が少なくなったり、期待した文書が上位に出なくなったりしたときは、手元のデータで測り直すのが近道です。測り方は単純で、インデックスを使わない厳密検索の上位k件を正解にして、近似検索の結果がどれだけ重なるか(recall@k)をレイテンシと並べます。
この記事では、その比較スクリプトをClaude Codeに書かせ、設定ごとの表を出して読むところまでを扱います。
3つのパラメータが効く場所
HNSWのパラメータは、作るときに効くものと探すときに効くものに分かれます。
| 名前 | 効く場面 | 既定値 | 上げたときの効果 |
|---|---|---|---|
m | 効く場面インデックス作成 | 既定値16 | 上げたときの効果層ごとの最大接続数が増える |
ef_construction | 効く場面インデックス作成 | 既定値64 | 上げたときの効果グラフ構築時の候補リストが広がり、recallが上がる。作成時間と挿入速度は落ちる |
hnsw.ef_search | 効く場面検索 | 既定値40 | 上げたときの効果検索時の候補リストが広がり、recallが上がる。速度は落ちる |
mは層ごとの最大接続数を決める値です。recallや索引サイズへの影響は、手元のデータで測って確かめる対象になります。
作るときの指定と、探すときの指定は次の形です。
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
SET hnsw.ef_search = 100;READMEは、ef_constructionなどの作成オプションは「低いrecallが出たときを除き既定値を使う」よう勧めています。作成時間への影響が大きいためです。したがって調整の順は、まずef_searchだけを振り、それで足りないときにmとef_constructionを触る、になります。
正解は厳密検索で作る
pgvectorはインデックスがなければ厳密な最近傍検索をします。これは「完全なrecall」です。インデックスを作った後でも厳密検索の結果を取りたいときは、ORDER BYを距離演算子の素の結果にしない書き方にします。次のように式で包むと、インデックスは使われません。
-- インデックスを使う
ORDER BY embedding <=> '[3,1,2]' LIMIT 5;
-- インデックスを使わない
ORDER BY 1 - (embedding <=> '[3,1,2]') DESC LIMIT 5;同じ質問ベクトルに、片方は厳密側の書き方、片方は近似側の書き方で投げます。この2本の結果の重なりがrecall@kです。インデックスを付け外しする必要がなく、同じ接続で正解と近似を取れます。
別の方法として、PostgreSQLのプランナー設定(enable_indexscanなど)で切る手もあります。ただし設定を戻し忘れると近似側まで巻き込みます。SQLの書き方で分ければ、戻し忘れの心配がありません。
質問ベクトルの選び方
質問ベクトルは、テーブルの行から無作為に抜く方法が手軽です。実運用の質問ベクトルが手元にあればそちらを優先します。埋め込みモデルが違えば距離の分布も変わるため、本番と同じモデルで作った質問を使います。
行から抜く場合の落とし穴が1つあります。自分自身が必ず最上位に来るので、k件に数えると全設定でrecallが底上げされます。WHERE id <> :selfで除くと、k件に満たない結果が返ることがあります。近似インデックスでは、フィルタがインデックスのスキャン後に適用されるためです。
代わりに、k+1件を取ってからPythonで自分を落とします。厳密側と近似側で同じ処理を通せば、条件が揃います。
Claude Codeに比較スクリプトを書かせる
テスト用のテーブルを用意したうえで、依頼は次のように具体的に書きます。
items(id, embedding vector(1536))を対象に、HNSWの設定ごとのrecall@10とレイテンシを比べる bench_hnsw.py を書いてください。
- 質問は items から200件を無作為に抜く(seed固定)
- 各質問について、厳密側(ORDER BY 1 - (embedding <=> q) DESC LIMIT 11)と近似側(ORDER BY embedding <=> q LIMIT 11)を実行する
- 両方とも自分自身を結果から除き、10件にそろえてから比べる
- 設定は m と ef_construction の組を引数で受け、インデックスを作り直して計測する
- 各組で、作成時間・pg_relation_size・ef_search ごとの recall@10 と p50/p95 レイテンシを表で出す
- インデックスは CREATE INDEX CONCURRENTLY ではなく通常の作成でよい次元数や距離関数は、実際のテーブルに合わせて書き換えます。Claudeには設計と検査を任せ、実行結果は必ず出力ごと貼らせます。公式のベストプラクティスも、Claudeが「完了した」と言う根拠として、実行したコマンドとその出力を求めるよう勧めています。
出来上がるスクリプトの骨格は、次のような形になります。以下は書き方の例で、実行結果ではありません。
import time, statistics, psycopg
K = 10
def topk(cur, sql, q):
cur.execute(sql, (q,))
return [r[0] for r in cur.fetchall()]
EXACT = "SELECT id FROM items ORDER BY 1 - (embedding <=> %s::vector) DESC LIMIT 11"
APPROX = "SELECT id FROM items ORDER BY embedding <=> %s::vector LIMIT 11"
def drop_self(ids, self_id):
return [i for i in ids if i != self_id][:K]
def run(conn, queries, ef_search):
cur = conn.cursor()
cur.execute(f"SET hnsw.ef_search = {int(ef_search)}")
hits, times = 0, []
for self_id, q in queries:
truth = drop_self(topk(cur, EXACT, q), self_id)
t0 = time.perf_counter()
got = drop_self(topk(cur, APPROX, q), self_id)
times.append((time.perf_counter() - t0) * 1000)
hits += len(set(truth) & set(got))
recall = hits / (K * len(queries))
return recall, statistics.median(times), sorted(times)[int(len(times) * 0.95)]厳密側は全件走査なので、質問数が多いと時間がかかります。正解は一度だけ計算して保存し、設定を変えるたびに使い回す形に直させると、計測が速くなります。
表の出し方と読み方
出力は、設定の組ごとにindexのビルド時間とサイズ、ef_searchごとのrecall@10とp50/p95がそろう表にします。
| 列 | 見るもの |
|---|---|
| m / ef_construction | 見るもの作成時の設定 |
| build_s | 見るものCREATE INDEXの所要秒 |
| size_mb | 見るものpg_relation_sizeで取ったインデックスサイズ |
| ef_search | 見るもの検索時の設定 |
| recall@10 | 見るもの厳密検索の上位10件との重なり |
| p50_ms / p95_ms | 見るもの近似検索の応答時間 |
読むときの順序は次のとおりです。
表を読む順序
- 1
ef_searchを上げて伸びを見る
同じインデックスのまま、
ef_searchを上げたときのrecallの伸びを見ます。伸びが止まる値が、そのインデックスの上限に近い目安です。 - 2
足りなければef_constructionを上げる
recallが目標に届かない場合のみ、
ef_constructionを上げて作り直します。作成時間が延びるので、build_sも一緒に見ます。 - 3
それでも足りなければmを変える
サイズが増えるので
size_mbを確認します。 - 4
p95で絞る
p50だけでなくp95も見ます。同じrecallなら、p95の小さい設定が候補になります。
目標のrecallは用途で変わります。検索結果をそのまま見せる画面と、後段で再ランキングする構成とでは、許容できる取りこぼしが違います。再ランキングを置くなら、測るkは再ランキングに渡す候補数に合わせます。考え方はRAGのチャンクサイズをrecall@kで比べる記事と同じです。
結果が少なくなるときの切り分け
測ってみたら、そもそもk件返ってこない設定が混ざることがあります。HNSWが返す行数はhnsw.ef_searchで頭打ちになります。既定は40なので、ef_searchをkより小さくすると、返る行数がkに満たなくなります。
ほかにも、行数が減る原因は2つあります。
- 削除済みの行(dead tuple)が候補に残っている場合
- クエリにフィルタ条件がある場合。フィルタはインデックススキャンの後に適用される
フィルタ付きの検索では、反復スキャン(iterative index scans)を有効にすると、必要な件数が出るまでインデックスを広く走査します。
SET hnsw.iterative_scan = strict_order;反復スキャンはバージョン0.8.0以降の機能です。上限を決めるhnsw.max_scan_tuplesもあります。フィルタ付きの検索を使うなら、ベンチの質問にも同じ条件を入れて、recallと件数の両方を測ってください。
作成時間とメモリを抑える
ビルドを何度も繰り返すので、作成時間も無視できません。短縮の手段は次のとおりです。
- グラフが
maintenance_work_memに収まると、ビルドは大幅に速くなる。収まらなくなると通知が出る - 並列ワーカーは
max_parallel_maintenance_workersで増やせる(既定は2) - 初期データを読み込んだ後にインデックスを作るほうが速い
- 本番では
CREATE INDEX CONCURRENTLYで書き込みを止めない
SET maintenance_work_mem = '8GB';
SET max_parallel_maintenance_workers = 7;maintenance_work_memはサーバーのメモリを使い切らない範囲に収めます。ベンチ用の小さなテーブルなら、このあたりを変えなくても比較はできます。
進み具合は、次のクエリで確認できます。
SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS "%"
FROM pg_stat_progress_create_index;次元数とインデックスの型で測り直す対象が変わる
mとef_constructionを詰めても足りないときは、インデックスの型を変えるほうが効くことがあります。HNSWが扱える次元数は、型ごとに上限があります。
| 型 | HNSWで扱える上限 |
|---|---|
vector | HNSWで扱える上限2,000次元 |
halfvec | HNSWで扱える上限4,000次元 |
bit | HNSWで扱える上限64,000次元 |
sparsevec | HNSWで扱える上限非ゼロ要素1,000個 |
2,000次元を超える埋め込みは、vectorのままではHNSWを張れません。列はvectorのまま、インデックスだけhalfvecにキャストする方法があります。
CREATE INDEX ON items USING hnsw ((embedding::halfvec(3)) halfvec_l2_ops);この場合、検索側のORDER BYも同じキャストの式に合わせる必要があります。保存時にhalfvecを使うと、作業セットが小さくなります。型を変えたら、厳密検索の正解は元のvectorで取ったまま、近似側だけを新しいインデックスで測ります。精度を落としていないかは、同じ表のrecall@10で確かめられます。
採用した値を本番のクエリに反映する
ef_searchは接続単位の設定なので、全クエリに効かせるとレイテンシも全体に響きます。トランザクション内でSET LOCALを使うと、1クエリだけに適用できます。
BEGIN;
SET LOCAL hnsw.ef_search = 100;
SELECT id FROM items ORDER BY embedding <=> '[3,1,2]' LIMIT 10;
COMMIT;検索の種類によって求めるrecallが違うなら、この形で値を使い分けられます。反映後はEXPLAIN (ANALYZE, BUFFERS)で、インデックスが実際に使われているか、ベンチと同じ計画になっているかを確認します。小さなテーブルや条件によってはプランナーがテーブルスキャンを選びます。インデックスを使わせたいときの確認用には、トランザクション内でSET LOCAL enable_seqscan = offを試す方法があります。本番の設定にはせず、計画を調べるときだけに使います。
結果の検査をStop hookに載せる
計測を繰り返すと、「質問が前回と違う」「正解の計算を飛ばした」といった手順のずれが混ざります。ずれた結果で設定を決めると、本番で効かない値を選びます。検査はClaudeが作業を終えようとするたびに走らせられます。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python bench_hnsw.py --check 1>&2 || exit 2"
}
]
}
]
}
}bench_hnsw.py --checkには、質問のseedと件数が前回と一致するか、結果に必ずk件含まれるか、といった確認を入れさせます。終了コード2で終わると停止がブロックされ、標準エラーの内容が理由としてClaudeに返ります。ブロックが8回連続でツール呼び出しも挟まない場合は、Claude Codeが次のブロックを無視してターンを終えます。
よくあるつまずき
- 質問を毎回変えてしまう: seedを固定しないと、設定間の差が質問の偏りか設定の効果か分かりません
- 自分自身を数えてしまう: 質問を行から抜くときは、結果から自分を除いてk件にそろえる
- ベンチ用の小さいテーブルで決める: 件数が少ないと、テーブルスキャンのほうが速く、インデックスが使われないこともあります。
EXPLAIN (ANALYZE, BUFFERS)で、インデックスが使われたかを確かめます - 質問が
ORDER BYの式になっている: 式で並べるとインデックスが使われません。近似側はORDER BY embedding <=> q LIMIT nの形を保つ - 作り直しのたびにキャッシュ状態が違う: 初回の計測は捨て、同じ条件で数回回してから比べる
スキーマや型の設計はpg-aiguideにPostgresのスキーマ設計を任せる記事が扱っています。作ったインデックスが使われているかや重複の確認には、未使用インデックスと重複の洗い出しの手順が使えます。
まとめ
HNSWの調整は、ef_searchだけを振って伸びを見るところから始め、足りないときだけef_constructionとmに進みます。正解を厳密検索で作り、質問のseedを固定し、自分自身を除いて表にすれば、設定の優劣は数字で比べられます。Claude Codeには、スクリプトの作成と、実行結果の貼り付けまでを任せます。目標のrecallとレイテンシは用途次第なので、決める基準は自分のデータで出した表から取ってください。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。