DBC Tech Academy / AI / 上級

pgvector
HNSW のチューニング再現率・レイテンシ・サイズを測って決める

RAG の答えの質は、生成の前段にある検索でほぼ決まります。そしてベクトル検索用のインデックスは、速さと引き換えに正解の一部を静かに取りこぼします。エラーも警告も出ません。ここでは pgvector の HNSW を題材に、その取りこぼしをどう測り、どう詰めるのかを扱います。

same sql, different answers
-- インデックスを使わず、全件を突き合わせた正解 id: 8821, 3140, 771, 9903, 15, 6612, 402, 5580, 231, 47 Execution Time: 1841.2 ms -- 同じ SQL。HNSW インデックス経由 id: 8821, 3140, 2204, 771, 8890, 9903, 15, 6612, 1177, 402 Execution Time: 2.9 ms recall@10 = 0.70 3 件が入れ替わっている

実行時間は 約 630 倍速くなりました。同時に、上位 10 件のうち 3 件が本来とは違うものに入れ替わっています。実行計画にも、アプリケーションのログにも、この事実は現れません。

A new axis

チューニングの軸が、
1 本増えます。

通常のインデックスは、結果を変えずに速さだけを変えます。ところがベクトル検索用のインデックスは近似(Approximate Nearest Neighbor)であり、結果そのものが変わります。したがって、速さとサイズだけを見て調整することはできません。

再現率を先に決める

再現率とは、本来の上位 k 件のうち何件を取り戻せたかの割合です。

インデックスを使わずに全件を突き合わせれば、必ず正しい上位 k 件が得られます。これを正解とし、インデックス経由の結果と重ね合わせた一致率が再現率(recall@k)です。再現率 0.7 は、10 件中 3 件が本来より遠いものに置き換わっていることを意味します。

チューニングは、この 1 つを決めることから始まります。「レイテンシ 20 ms 以内で、再現率 0.95 以上」のように目標を先に置き、それを満たす設定を探す。逆に設定から入ると、再現率は測られないまま放置されます。

recall@10 = |近似で得た上位10件 ∩ 正解の上位10件| / 10 -- 正解の作り方(インデックスを無効にして全件走査) BEGIN; SET LOCAL enable_indexscan = off; SET LOCAL enable_bitmapscan = off; SELECT id FROM items ORDER BY embedding <=> :q LIMIT 10; ROLLBACK;

この正解集合を、実際の検索文 100 件ほどについて作っておきます。以後、設定を変えるたびに突き合わせれば、再現率の変化が数値で追えます。

再現率

正解をどれだけ取り戻せたか。上げるとレイテンシが伸びます。RAG では、ここが落ちると生成側がどれだけ優秀でも答えが出ません。

レイテンシ

1 回の検索にかかる時間。探索中に保持する候補の数と、1 件あたりの距離計算のコスト(次元数)でほぼ決まります。

インデックスサイズ

HNSW はベクトルの実体をインデックス内に持ちます。メモリに載りきるかどうかが、レイテンシを桁で左右します。

First decision

最初の分岐は、
HNSW か IVFFlat か。

つまみを触る前に決めることがあります。pgvector には近似検索用のインデックスが 2 つあり、どちらを選ぶかで運用の形が変わります。再現率を測る枠組みはどちらでも同じですが、作り方と壊れ方が違います。

HNSW:グラフをたどる

点と点を辺で結んだグラフを作り、近い方へたどって目的地に近づきます。この講座で扱ってきた方式です。

空のテーブルにも作れます。行を入れるたびにグラフへ組み込まれるため、投入を続けながら検索できます。

同じ再現率を得るための検索が速い一方、構築に時間がかかり、インデックスはベクトルの実体を含むぶん大きくなります。

IVFFlat:区画を絞る

ベクトルの空間を lists 個の区画に分け、検索時は近い区画を probes 個だけ調べます。probes を増やすと再現率が上がりレイテンシが伸びる関係は、HNSW の ef_search と同じ形です。

データを入れ終えてからでないと作れません。区画の中心をデータから学習するためです。空のテーブルに作ると区画が偏り、再現率が上がらなくなります。

構築が速くサイズも小さい代わりに、投入が進んで分布が変わると区画が実態からずれ、再現率が落ちます。回復には作り直しが必要です。

  1. 投入や更新が続き、その間も検索するなら HNSW です。作り直しの段取りを運用に組み込まなくて済みます。
  2. 一括で投入して以後はほとんど変わらないのであれば、IVFFlat が選択肢になります。構築時間とサイズの差は、件数が増えるほど効いてきます。
  3. IVFFlat の lists は、100 万行までなら行数 ÷ 1000、それを超えるなら行数の平方根が目安です。区画を細かくするほど 1 区画あたりが小さくなり、同じ再現率により多くの probes が要ります。
  4. どちらを選んでも、先に再現率の目標を決めて測る手順は変わりません。方式の比較も、同じ目標再現率のもとでレイテンシとサイズを見比べます。
-- IVFFlat は、行を入れ終えてから作る CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 1000); SET ivfflat.probes = 10; -- 再現率とレイテンシのつまみ

How HNSW walks

グラフをたどって、
近いほうへ歩く。

HNSW(Hierarchical Navigable Small World)は、ベクトル同士を近いもの同士でつないだグラフです。検索は、ある地点から出発し、隣を見て、より近いほうへ進むことを繰り返します。全件と距離を測るのではなく、ごく一部だけを訪問して答えを出します。下の図をクリックすると、その地点を検索点として探索が走ります。

訪問したノード
全体に対する割合
たどった辺
再現率@8
訪問していない点 訪問した点 見つけた上位 8 件 本来の上位 8 件

m を小さくすると点と点のつながりが減り、近いはずの点へたどり着けなくなります。ef_search を大きくすると、探索中に「あとで見に行く候補」を多く抱えられるので、行き止まりから抜け出せます。どちらも、訪問するノードの数、つまり所要時間と引き換えです。

階層があるのは、出発点を良くするため

HNSW の H は階層(Hierarchical)です。全ノードのうち一部だけを上の層にも登録し、上の層ほど点をまばらにします。検索はいちばん上の層から始め、その層で最も近い点まで進んだら、1 つ下の層へ降ります。

まばらな層では、1 歩で遠くまで移動できます。密な層では、細かく近づけます。粗いところから細かいところへ降りていくことで、どこから始めても少ない歩数で目的地の近くに着けます。上の図では、点線の丸が最初の出発点です。

それでも取りこぼす理由

探索は「隣を見て、近いほうへ進む」という局所的な判断の繰り返しです。そのため、周囲のどの隣よりも自分が近いという地点に着くと、そこで止まります。ところがその外側に、グラフ上ではつながっていない、もっと近い点が存在することがあります。

ef_search は、この行き止まりへの保険です。探索中に有望な候補を複数抱えておき、行き止まりに着いても別の候補から探索を再開します。抱える数が多いほど取りこぼしは減り、そのぶん距離計算の回数が増えます。

The curse of dimensionality

次元が上がると、
近さの差が消える。

ひとつ上の図は 2 次元でした。実際の埋め込みは 768 次元、1536 次元、3072 次元といった世界です。次元が増えると、探索が難しくなるどころか、そもそも「近い」という判断そのものが効きにくくなります。近似検索の設定を詰める前に、この事実を押さえておきます。

いちばん近い点と、100 番目の差が縮みます。

次元が増えるほど、任意の 2 点間の距離は平均値の周りに集まります。近いものは相対的にそれほど近くなくなり、遠いものも極端には遠くなりません。

影響が出るのは、最近傍の探索そのものです。1 位と 100 位の距離差が 2 次元では歴然としているのに対し、高次元では数パーセントしか違わない、ということが起きます。順位の差が、わずかな数値の差に押し込められるのです。

グラフ探索は「隣を見て、より近いほうへ進む」という比較の連続です。その比較の材料になる差が縮むのですから、判断は当然難しくなります。同じ再現率を保つのに、次元が高いほど大きな m と ef_search が要るのは、このためです。

2 次元の図では、探索は実際よりやさしく見えます。前の節の図は、たどり方の仕組みを示すためのものです。うまく歩けている様子がそのまま高次元でも成り立つ、とは考えないでください。

それでも近似検索が実用になる理由。

もし埋め込みが高次元の空間に一様にばらまかれていたら、近似検索はほとんど機能しません。実際に機能するのは、埋め込みが空間全体に散らばっていないからです。

意味の似た文が近くに集まり、話題ごとにかたまりを作る。データは、名目上の次元数よりずっと低い次元の広がりの上に載っています。この、実質的な広がりの大きさを内在次元と呼びます。HNSW が効くかどうかを決めるのは、1536 という数字ではなく、こちらです。

同じ 1536 次元でも、扱う話題が絞られた社内文書のコレクションと、雑多な内容を集めたコレクションでは、必要な設定が変わります。他社の事例で 0.95 が出た設定が、自社のデータでは 0.85 にしかならないことは普通に起きます。設定値は借りられず、測るしかありません。

  1. 次元数は、レイテンシに直接効きます。距離計算 1 回は、次元の数だけ掛け算と足し算を行う処理です。1536 次元なら 1 回あたり 1536 回。訪問するノードが 500 個なら、1 検索で 76 万回を超える演算になります。ef_search を 2 倍にすればこの回数も概ね 2 倍です。
  2. 次元を削ると、この演算量がそのまま減ります。1536 を 512 にすれば距離計算は 3 分の 1、インデックスサイズも 3 分の 1 です。ただし削れるのは、先頭の次元ほど多くの情報を持つように学習されたモデルの場合だけです。圧縮の節で扱います。
  3. コサイン距離を使うなら、長さの正規化を投入時に済ませます。高次元では、ベクトルの長さの違いが順序を大きく揺らします。正規化しておけば、内積演算子に切り替える選択肢も残せます。
  4. 埋め込みモデルを変えたら、すべて測り直します。次元数が同じでも、値の分布と内在次元は別物です。正解集合の作り直しから始めてください。モデルの差し替えは、インデックスの設定変更より影響が大きい変更です。

Three knobs

つまみは 3 つ。
効く先が違う。

pgvector の HNSW で調整できるのは、インデックスを作るときの m と ef_construction、検索するときの hnsw.ef_search の 3 つです。前の 2 つはインデックスの作り直しが必要で、最後の 1 つはクエリごとに変えられます。それぞれを動かして、4 つの指標がどう動くかを確かめてみましょう。

指標現在の設定読み方

曲線は m ごとに ef_search を動かしたときの軌跡です。左上へ行くほど良い設定を表します。m が低い曲線は、右へいくら伸ばしても一定の高さで頭打ちになります。ここが、ef_search だけでは越えられない天井です。表示される 4 つの指標は、つまみを動かしたときにどちらへどれだけ動くかを示すためのモデルで、実際の値はデータの分布とハードウェアで変わります。動く向きを掴んだうえで、値は自分のデータで測ってください。

作るときの 2 つ

m は 1 点がつなぐ隣の数です。既定は 16。大きくすると探索の逃げ道が増えて再現率の天井が上がり、インデックスサイズと構築時間が増えます。次元数が高く、データが密なほど大きめが向きます。

ef_construction は、インデックスを作るときに各点の隣を選ぶための候補リストの長さです。既定は 64。大きくすると隣の選び方が良くなり、同じ m でも再現率が上がります。構築時間だけが増え、インデックスサイズは変わりません。まずここを上げるのが定石です。

CREATE INDEX CONCURRENTLY items_emb_hnsw ON items USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 200);

検索するときの 1 つ

hnsw.ef_search は、検索中に保持する候補リストの長さです。既定は 40。クエリごとに変えられるので、実運用ではここで再現率とレイテンシのバランスを取ります。

返したい件数と同じ値では足りません。候補リストが LIMIT と同じ長さしかなければ、より近い点へ枝を伸ばす余地が残らず、取りこぼしが増えます。LIMIT の数倍を目安に取り、実測で決めます。

これはセッション変数です。接続を使い回す構成では、前のリクエストの値が残らないよう、トランザクション内で SET LOCAL を使います。

BEGIN; SET LOCAL hnsw.ef_search = 100; SELECT id, title FROM items ORDER BY embedding <=> :q LIMIT 10; COMMIT;

How the graph is built

グラフは、
1 件ずつ育つ。

ef_construction が何をしているのかは、構築の手順を追うといちばんはっきりします。HNSW のグラフは、できあがった図を一度に描くのではなく、行が入るたびに 1 点ずつ組み込んで育てていくものです。

1 点を挿入する 4 つの手順

  1. その点が住む最上層を、くじで決めます。大半の点は最下層だけに置かれ、確率的にごく一部が上の層まで登録されます。上の層ほど点がまばらになるのは、この配分の結果です。層の数はデータ量から自動的に決まり、設定するものではありません。
  2. いちばん上の層から、貪欲に降ります。その層で最も近い点まで進んだら 1 つ下へ。自分の最上層に着くまでは、候補を 1 つだけ持って進みます。ここは出発点を良くするための工程です。
  3. 自分の層から下では、ef_construction の長さの候補リストで近傍を探します。検索時の ef_search とまったく同じ探索です。違うのは、探すのが検索文ではなく、いま挿入しようとしている点自身だという点だけです。リストが長いほど、より良い隣の候補が集まります。
  4. 集まった候補から、つなぐ相手を m 本選びます。ここが HNSW の肝です。単純に近い順ではありません。

近い順に選ばないのは、なぜか。

候補を近い順にそのままつなぐと、辺は同じ方向のかたまりに集中します。似た点どうしが密に結ばれ、そこから外へ出る道がなくなり、探索は行き止まりだらけになります。

そこで HNSW は、候補を近い順に見ながら、すでに選んだどの相手よりも自分に近い候補だけを採用します。ある方向にすでに辺が伸びていれば、同じ方向のより遠い候補は捨てられます。結果として、m 本の辺は異なる方向へ散らばります。

この間引きが、遠くへ渡る辺を残します。少ない歩数で空間を横断できるグラフ、つまり Navigable Small World が生まれるのは、この規則によるものです。

ひとつ上の図は、全点を置いてから辺を張っています。そのため ef_construction にあたるつまみがありません。ただし、隣を選ぶときに方向の偏りを間引く規則だけは、本来と同じものを使っています。

辺は双方向で、あふれたら刈られます。

点 A が点 B を隣に選ぶと、B の側にも A への辺が張られます。このとき B の隣の数が上限を超えたら、B もまた同じ規則で自分の隣を選び直します。あとから来た点のせいで、既存の点の隣が入れ替わるわけです。

上限は層によって違います。上の層では m、最下層では m の 2 倍です。最下層はすべての点が住む層なので、ここだけ多めに辺を持たせて到達性を確保しています。m = 16 なら、最下層の 1 点は最大 32 本の辺を持ちます。

インデックスサイズを決めるのは、この辺の数とベクトルの実体です。ef_construction はどちらにも入りません。候補リストは構築中にしか存在しないため、上げても増えるのは構築時間だけです。まずここを上げるのが定石だ、というのはこの意味です。

投入の順序と、削除の跡。

グラフは 1 件ずつ育つので、作り方によって出来上がりが変わります。空のテーブルに INSERT を重ねて育てたグラフより、データを入れ終えてから CREATE INDEX で一括構築したグラフのほうが、同じ設定でも質が上がります。一括構築では並列ワーカーを使えるうえ、点の配置が最初から分かっているためです。大量投入のあとに REINDEX をかける価値は、ここにあります。

削除は、さらに直接的に効きます。行を消してもグラフの構造は組み替えられず、その点は通り道として残り続けます。探索は消えた点を経由して遠回りし、隣の枠もそのぶん無駄になります。更新や削除が多いテーブルで再現率が下がっていくのは、この蓄積です。

-- 大量の投入や削除のあと、グラフを作り直す REINDEX INDEX CONCURRENTLY items_emb_hnsw;

What the plan tells you

実行計画には、
正しさが出てこない。

ベクトル検索の実行計画には、決定的な特徴があります。返す行数は常に LIMIT と同じなので、見積もりと実測のずれが手掛かりにならないのです。取りこぼしの有無は、実行計画からは一切わかりません。では何を見るのか。行をクリックして確かめてみましょう。

行をクリックすると解説が切り替わります。

  1. ノード名が Index Scan using ..._hnsw になっているかを見ます。Seq Scan と Sort が並んでいれば、インデックスは使われていません。
  2. その下の Order By: を見ます。Index Cond: ではなく Order By: が出るのが、順序を決めるためにインデックスを使っている印です。
  3. Buffers: の値を見ます。触ったページ数は、探索の重さそのものです。ef_search を上げると、ここが増えます。
  4. read= が大きければ、インデックスがキャッシュに載っていません。同じクエリを 2 回実行し、値の差を確かめます。
  5. 再現率だけは、別途 enable_indexscan = off で作った正解と突き合わせて測ります。実行計画からは読めません。

インデックスが使われなくなる書き方

HNSW インデックスが使われるのは、ORDER BY の式がインデックスと完全に一致し、かつ LIMIT がある場合だけです。次のいずれかに当てはまると、全件を読んで並べ替える計画に落ちます。

-- LIMIT がない ORDER BY embedding <=> :q -- 距離を式で包んでいる(類似度に直している) ORDER BY 1 - (embedding <=> :q) DESC -- 演算子がインデックスの演算子クラスと違う -- vector_cosine_ops のインデックスに <-> を使っている ORDER BY embedding <-> :q LIMIT 10

類似度の値そのものが欲しい場合は、SELECT 側で計算し、ORDER BY には距離演算子をそのまま書きます。並べ替えの向きは変わりません。

演算子と演算子クラスの対応

インデックスを作るときの演算子クラスと、クエリで使う演算子は必ず一致させます。両方を使い分けたければ、インデックスも 2 つ必要です。

<=> コサイン距離 vector_cosine_ops <-> ユークリッド距離 vector_l2_ops <#> 内積の符号反転 vector_ip_ops <+> マンハッタン距離 vector_l1_ops

埋め込みが長さ 1 に正規化されているなら、コサイン距離と内積は同じ順序を返します。正規化済みであることが確実なら、正規化の計算を省ける <#> がわずかに有利です。ただし、正規化されていないベクトルが 1 件でも混ざると順序が崩れるため、投入時の保証とセットで選びます。

Filtering breaks it

WHERE を足すと、
10 件返らなくなる。

実務のベクトル検索は、たいてい何かで絞り込みます。テナント、公開状態、期間。ところが HNSW は、絞り込み条件を知らないままグラフ全体を歩き、条件は歩き終わったあとに適用されます。条件に合う行が少ないほど、手元に残る件数は減ります。条件に合う行の割合を動かして、3 つのやり方を比べてみましょう。

条件に合う行
該当行数(1000万行中)
ef_search
要求件数

バーの長さは、要求した 10 件に対して実際に返る件数を表します。斜線は件数が足りていない状態です。反復スキャンは、候補が尽きたら探索を再開して不足を埋めますが、そのぶん走査する行数とレイテンシが増えます。

反復スキャンで不足を埋める

pgvector 0.8 以降では、候補が条件で落とされて件数が足りないとき、探索を続けて不足を埋められます。既定は無効なので、明示的に有効にします。

SET LOCAL hnsw.iterative_scan = strict_order; -- 距離の昇順を厳密に保つ SET LOCAL hnsw.iterative_scan = relaxed_order; -- 順序をわずかに崩す代わりに速い SET LOCAL hnsw.max_scan_tuples = 20000; -- 走査を打ち切る上限。既定は 2 万行

上限に達すると、件数が足りないまま打ち切られます。条件が極端に厳しい場合、この設定だけでは解決しません。

絞り込みが固定なら、インデックスを分ける

テナントごと、状態ごとのように条件のパターンが決まっているなら、条件を含めた部分インデックスを作るのが最も確実です。探索するグラフ自体が条件を満たす行だけで構成されるため、件数も順序も落ちません。

CREATE INDEX CONCURRENTLY items_pub_hnsw ON items USING hnsw (embedding vector_cosine_ops) WHERE status = 'published';

テナント数が多く、部分インデックスを並べきれない場合は、テナント列でテーブルをパーティション化し、パーティションごとに HNSW を作ります。検索は 1 つのパーティションに閉じるため、同じ効果が得られます。

逆に、条件に合う行が全体の数十パーセントを占めるなら、そのまま検索して問題ありません。困るのは、条件が厳しいときだけです。

Smaller vectors

1 次元あたりの
ビット数を削る。

3 つの指標を最も大きく動かせるのは、つまみではなくベクトルそのものの持ち方です。1536 次元のベクトルは 1 件あたり約 6 KB あり、HNSW はこれをインデックス内に持ちます。インデックスがメモリに載るかどうかは、レイテンシを桁で左右します。

halfvec:32 ビットを 16 ビットに

各次元を半精度の浮動小数で持ちます。サイズはほぼ半分になり、距離計算も軽くなります。埋め込みの値はもともと小さな範囲に収まるため、再現率の低下はわずかにとどまることが多い方法です。

CREATE INDEX ON items USING hnsw ((embedding::halfvec(1536)) halfvec_cosine_ops);

検索時も同じ形に揃えます。式インデックスなので、問い合わせ側の式が一致しないとインデックスが使われません。

binary_quantize:1 次元 1 ビットに

各次元を符号だけに落とします。サイズは 32 分の 1、距離はハミング距離で求めるため非常に高速です。ただし情報の落ち方が大きく、これだけで最終結果を決めると再現率が大きく下がります。

絞り込みに使い、元のベクトルで並べ直す 2 段構えにします。

CREATE INDEX ON items USING hnsw ((binary_quantize(embedding)::bit(1536)) bit_hamming_ops);
-- 1 段目でビット列で広めに絞り、2 段目で元のベクトルで並べ直す SELECT id FROM (SELECT id, embedding FROM items ORDER BY binary_quantize(embedding)::bit(1536) <~> binary_quantize(:q) LIMIT 200) AS candidates ORDER BY embedding <=> :q LIMIT 10;
  1. 1 段目で取る件数が、再現率を決めます。上の例の 200 が小さすぎると、正解が 2 段目に届きません。この件数も、ef_search と同じように再現率を測りながら決めます。
  2. 次元そのものを削る手もあります。先頭の次元ほど多くの情報を持つように学習された埋め込みモデルであれば、embedding::vector(512) のように前半だけを使えます。モデルがその性質を備えているかを確認してから使ってください。
  3. 削るたびに再現率を測り直します。サイズとレイテンシは削った瞬間に改善が見えますが、再現率の低下は測らないかぎり現れません。
  4. 判断の順序は、まずインデックスがメモリに載る形にすることです。載らない状態で ef_search を下げて速さを稼いでも、再現率を捨てているだけになります。

Keywords, too

意味の近さだけでは、
拾えないものがある。

ベクトル検索は言い換えに強く、語の一致に弱い方式です。型番、人名、社内の略語、エラーコード。こうした「その語そのもの」を探す問い合わせは、意味の近さでは取りこぼします。実務の検索が、キーワード検索との併用に落ち着くのはこのためです。

2 つの検索は、外し方が違います。

ベクトル検索は「解約したい」と「退会の方法」を結び付けられますが、ERR-4021 のような文字列を、意味の似た別のコードと取り違えます。埋め込みは、めったに現れない語の情報をほとんど保持しないためです。

キーワード検索はその逆です。ERR-4021 は確実に当てますが、「解約」で「退会」の文書は出ません。

両方が同時に外すことは、比較的まれです。片方が拾えた文書を、もう片方の順位が低くても救い上げる。これがハイブリッド検索の値打ちです。

日本語では、語の切れ目の判定に拡張が要ります。PostgreSQL 標準の全文検索は、空白で語を区切る言語を前提としています。日本語では pg_bigm や PGroonga といった拡張を使います。マネージドサービスでは使える拡張が限られるため、構成を決める前に確認してください。

スコアは、足し算できません。

コサイン距離は 0 から 2 の範囲に収まる値ですが、キーワード検索の ts_rank は上限のない相対値で、語の希少さや文書の長さで大きく変わります。単位も分布も違うものを足すと、たまたま値の大きいほうが常に勝ちます。

そのクエリで得た値の最大最小で正規化する方法もありますが、取得した集合の中身に依存するため、外れ値が 1 件混ざるだけで全体が歪みます。同じ文書の点数が、一緒に取れた他の文書によって変わってしまいます。

実務の既定は、点数を捨てて順位だけを使う方法です。順位なら尺度に依存しません。これが RRF(Reciprocal Rank Fusion)です。

score(d) = Σ 1 / (k + rank(d)) 系統 k は上位の突出を和らげる定数。60 が広く使われる
-- ベクトルとキーワード、それぞれ 60 件を取り、順位で統合する WITH vec AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY embedding <=> :q) AS rank FROM items ORDER BY embedding <=> :q LIMIT 60 ), kw AS ( SELECT id, ROW_NUMBER() OVER (ORDER BY ts_rank_cd(tsv, query) DESC) AS rank FROM items, websearch_to_tsquery(:text) AS query WHERE tsv @@ query ORDER BY ts_rank_cd(tsv, query) DESC LIMIT 60 ) SELECT COALESCE(v.id, k.id) AS id, COALESCE(1.0 / (60 + v.rank), 0) + COALESCE(1.0 / (60 + k.rank), 0) AS score FROM vec v FULL OUTER JOIN kw k ON v.id = k.id ORDER BY score DESC LIMIT 10;
  1. 各系統から取る件数は、最終的に返す件数より多くします。10 件返すなら 50 から 100 件が目安です。片方の 40 位が、統合後に 3 位へ上がることがあります。取る件数を絞りすぎると、その救い上げが起きません。
  2. k は、上位の重みの強さを決めます。小さくすると 1 位と 2 位の差が開き、片方の系統の 1 位が強く効きます。大きくすると順位差がならされ、両方に出てきた文書が有利になります。まず 60 で組み、必要なら動かします。
  3. 系統ごとに重みを掛けられます。0.7 / (60 + v.rank) + 0.3 / (60 + k.rank) のように書けば、ベクトル寄りに振れます。扱う文書が固有名詞中心ならキーワード側を厚くします。
  4. 後段に再ランクを置くなら、ここは候補を集める工程になります。検索文と文書を一緒に読んで関連度を出すモデルを通す構成では、ハイブリッドで 50 件ほど集め、そこから 10 件に絞ります。精度の高いモデルほど 1 件あたりのコストが高いので、この段で候補を減らしておく意味があります。
  5. ベクトル側の設定は、統合しても変わりません。1 段目で 60 件を取るなら、その 60 件の再現率が問題になります。ef_search は LIMIT の数倍という原則のまま、60 件に対して取り直してください。

統合したあとは、測る対象が変わります。ここまで扱ってきた再現率は、「全件を突き合わせた結果」を正解とするものでした。ハイブリッドの狙いは、その正解自体を超えて、人が求めている文書を上位に出すことです。したがって評価の正解集合は、人が「この問いにはこの文書」と判断したものになります。近似の取りこぼしを測る再現率と、検索そのものの良し悪しを測る指標は、別のものとして両方を持ってください。

In production

本番で使うときの、
注意と手順。

ベクトル検索の事故は、落ちる形ではなく、静かに質が下がる形で起きます。作るときに押さえる点と、動き出してから見続ける点を分けて整理します。

作るときに

  • 再現率を測らずに本番へ出さないでください。近似検索は、遅くならずに間違えます。ログにもエラーにも現れず、検索の質だけが下がります。代表的な検索文 100 件ほどで正解集合を作り、設定を変えるたびに突き合わせてください。
  • インデックス作成はテーブルをロックします。CREATE INDEX CONCURRENTLY を使ってください。ただし時間は数倍かかり、途中で失敗すると無効なインデックスが残るため、pg_index.indisvalid の確認と作り直しまでを手順に含めます。
  • 構築時のメモリ不足に気付けるようにします。グラフが maintenance_work_mem に収まらなくなると、pgvector は二段階の構築に切り替え、所要時間が桁で変わります。切り替わる際にサーバーログへ通知が出るので、大きなインデックスを作るときは必ず確認してください。max_parallel_maintenance_workers を上げると構築が並列化されます。
  • インデックスがメモリに載る大きさに収めます。pg_relation_size で実サイズを確認し、共有バッファと OS のキャッシュに収まるかを見ます。載らなければ、圧縮の節の手を先に打ちます。つまみの調整はそのあとです。

検証環境の再現率は、本番と一致しません。再現率はデータの分布そのものに左右されます。件数を減らした環境で 0.95 が出ても、本番で同じ値になる保証はありません。本番と同等の件数・同等の分布で測ってください。

動き出してから

  • ベクトル列を SELECT で返さないでください。1536 次元のベクトルは 1 行あたり約 6 KB あり、行外の領域へ格納されます。SELECT * にすると、返す 10 件のためだけにその読み出しが発生します。必要な列だけを列挙してください。
  • 更新や削除が多いテーブルでは、グラフが劣化します。削除された点はグラフ上の通り道として残り続けるため、時間とともに探索が遠回りになり、再現率も落ちます。再現率を定期的に測り、下がってきたら REINDEX INDEX CONCURRENTLY で作り直します。
  • 再現率の測定を、定期実行の仕組みに載せます。人が思い出したときに測る運用では、劣化に気付けません。正解集合と測定を日次のバッチにして、値を記録し続けてください。下がった日と、投入や更新があった日を突き合わせられます。
  • 接続の使い回しに注意します。hnsw.ef_search はセッション変数です。接続プールを使う構成では、SET LOCAL でトランザクションに閉じないと、前のリクエストの値が次の検索に効きます。再現率が測るたびに違う、という現象の原因になります。
  • pgvector のバージョンを把握しておきます。反復スキャンのように、版によって使える手が違います。SELECT extversion FROM pg_extension WHERE extname = 'vector'; で確認できます。

Myth vs. reality

ベクトル検索で、
よくある 3 つの勘違い。

通常のインデックスの感覚をそのまま持ち込むと、判断を誤りやすいポイントです。

MISCONCEPTION 01

「インデックスを張っても結果は同じ」

通常のインデックスは結果を変えずに速くしますが、HNSW は近似です。張った瞬間から、返る上位 k 件は変わりえます。どれだけ変わったかが再現率であり、これを測っていなければ、検索の質は誰も把握していないことになります。

MISCONCEPTION 02

「ef_search を上げれば再現率は上がり続ける」

ef_search が効くのは、グラフの上に正解へ至る道がある場合だけです。m と ef_construction が小さいと道そのものが存在せず、いくら候補を増やしても届きません。再現率が頭打ちなら、上げるべきは検索側ではなく、インデックスの作り直しです。

MISCONCEPTION 03

「WHERE で絞ってから近傍を探してくれる」

HNSW は条件を知らないままグラフを歩き、条件は歩き終わったあとに適用されます。条件に合う行が少ないほど、手元に残る件数は減ります。反復スキャンを有効にするか、条件を含んだ部分インデックスを用意してください。

Read the symptom

実行計画から、
原因を見抜く。

ベクトル検索でよく持ち込まれる 3 つの症状です。それぞれ、どこに原因があるのかを考えてみましょう。

Knowledge check

10問で、理解を
確かめよう。

答えを当てるだけでなく、なぜそう判断できるのかを説明できれば合格です。

YOUR PROGRESS 01 / 10

選択肢を1つ選んでください。

Where to look first

速さより先に、
取りこぼしを測る。

目標を決める
正解と突き合わせる
ef_search で寄せる
天井なら作り直す