Claude Media
Claude CodeでpgvectorのHNSWを調整する — recallとレイテンシの測り方

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. 1

    ef_searchを上げて伸びを見る

    同じインデックスのまま、ef_searchを上げたときのrecallの伸びを見ます。伸びが止まる値が、そのインデックスの上限に近い目安です。

  2. 2

    足りなければef_constructionを上げる

    recallが目標に届かない場合のみ、ef_constructionを上げて作り直します。作成時間が延びるので、build_sも一緒に見ます。

  3. 3

    それでも足りなければmを変える

    サイズが増えるのでsize_mbを確認します。

  4. 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で扱える上限
vectorHNSWで扱える上限2,000次元
halfvecHNSWで扱える上限4,000次元
bitHNSWで扱える上限64,000次元
sparsevecHNSWで扱える上限非ゼロ要素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とレイテンシは用途次第なので、決める基準は自分のデータで出した表から取ってください。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/best-practices
  2. github.com/pgvector/pgvector
  3. postgresql.org/docs/current/runtime-config-query.html
この記事を共有:XはてブLinkedIn