HNSWグラフのマージを高速化する

複数の HNSW グラフを構築する際のオーバーヘッドを削減するために、特にグラフのマージにかかるコストを削減するために私たちが行ってきた作業について説明します。

ベクトル検索から強力なREST APIまで、Elasticsearchは開発者に最も広範な検索ツールキットを提供します。GitHubのサンプルノートブックにアクセスして新しいことを試してみましょう。また、無料トライアルを始めるか、ローカルでElasticsearchを実行することもできます。

以前、複数の HNSW グラフを 検索する必要がある場合に生じるいくつかの課題と、その課題をどのように軽減できるか について説明しました 。当時、私たちは計画していたさらなる改善点についても触れました。この投稿はその作業の集大成です。

なぜ複数のグラフを使用するのかと疑問に思うかもしれません。これは、Lucene のアーキテクチャ上の選択である不変セグメントによる副作用です。ほとんどのアーキテクチャ上の選択と同様に、長所と短所があります。たとえば、最近 Serverless Elasticsearch が GA になりました。この文脈において、私たちは不変セグメントから、効率的なインデックス レプリケーションや、インデックスとクエリの計算を分離して個別に自動スケーリングする機能など、非常に大きなメリットを得ています。ベクトル量子化の場合、セグメントのマージにより、パラメータを更新してデータ特性に適合させることができます。これに沿って、データ特性を測定し、インデックスの選択を再検討する機会を持つことで得られる他の利点もあると考えています。

この投稿では、複数の HNSW グラフを構築する際のオーバーヘッドを大幅に削減し、特にグラフのマージにかかるコストを削減するために私たちが行ってきた作業について説明します。

背景

管理可能な数のセグメントを維持するために、Lucene はセグメントをマージする必要があるかどうかを定期的に確認します。これは、現在のセグメント数が、基本セグメント サイズとマージ ポリシーによって決定されるターゲット セグメント数を超えているかどうかを確認することになります。カウントを超過すると、Lucene は制約に違反しながらセグメントのグループを結合します。このプロセスについては他の場所で詳しく説明されています。

Lucene は、書き込み増幅の対数的増加を実現するため、同様のサイズのセグメントを結合することを選択します。ベクトル インデックスの場合、書き込み増幅はベクトルがグラフに挿入される回数です。Luceneはセグメントを約10個のグループにまとめ、マージしようとします。その結果、ベクトルはグラフに約1+910log10(nn0)1+\frac {9} {10} \log_ {10} \left(\frac {n} {n_0} \right)挿入されます。 ここで、 nnはインデックス ベクトル数、 n0n_0 は予想される基本セグメント ベクトル数です。対数増加のため、巨大なインデックスの場合でも書き込み増幅は 1 桁になります。ただし、グラフのマージに費やされる合計時間は、書き込み増幅に直線的に比例します。

HNSW グラフをマージする際には、最大セグメントのグラフを保持し、他のセグメントのベクトルをそのグラフに挿入するという、小さな最適化をすでに行っています。これが上記の 9/10 要因の理由です。以下では、マージするすべてのグラフの情報を使用することで、どのように大幅に改善できるかを示します。

HNSW グラフのマージ

これまでは、最大のグラフを保持し、他のグラフからベクトルを挿入し、それらを含むグラフは無視していました。以下で利用する重要な洞察は、破棄する各 HNSW グラフには、それに含まれるベクトルに関する重要な近接情報が含まれているということです。この情報を使用して、少なくとも一部のベクトルの挿入を高速化したいと考えています。

これは任意のマージポリシーを構築するために使用できるアトミック操作であるため、小さなグラフGs=(Vs,Es)G_s=(V_s,E_s)を大きなグラフGl=(Vl,El)G_l=(V_l,E_l)に挿入する問題に焦点を当てます。

戦略は、 JVsJ\subset V_sの頂点のサブセットを見つけて、大きなグラフに挿入することです。次に、小さなグラフ内のこれらの頂点の接続性を使用して、残りの頂点VsJV_s \setminus Jの挿入を高速化します。以下では、小さいグラフと大きいグラフの頂点u u の隣接点をそれぞれNs(u) N_s(u)Nl(u) N_l(u) で表します。図式的にプロセスは次のとおりです。

MERGE-HNSW

Inputs GsG_s and GlG_l

1    \;\;Find JVsJ\subset V_s to insert into GlG_l using COMPUTE-JOIN-SET
2    \;\;Insert each vertex uJu\in J into GlG_l
3    \;\;for uVsJu\in V_s \setminus J do
4        JuJNs(u)\;\;\;\;J_u\leftarrow J\cap N_s(u)
5        EuvJuNl(u)\;\;\;\;E_u\leftarrow \cup_{v\in J_u} N_l(u)
6        W\;\;\;\;W\leftarrow\,FAST-SEARCH-LAYER(Ju,Eu)(J_u, E_u)
7        neighbors\;\;\;\;neighbors \leftarrow\,SELECT-NEIGHBORS-HEURISTIC(u,W)(u, W)
8        JJ{u}\;\;\;\;J\leftarrow J \cup \{u\}

以下で説明する手順 (1 行目) を使用して、セットJJを計算します。次に、標準の HNSW 挿入手順 (2 行目) を使用して、 JJ内のすべての頂点を大きなグラフに挿入します。挿入していない各頂点については、挿入した隣接頂点と、大きなグラフ内でのその隣接頂点を見つけます (4 行目と 5 行目)。このセットでシードされたFAST-SEARCH-LAYER手順 (6 行目) を使用して、HNSW論文からSELECT-NEIGHBORS-HEURISTICの候補を検索します (7 行目)。実際には、 SEARCH-LAYER INSERTメソッド (論文のアルゴリズム 1) の候補セットを見つけるために置き換えていますが、それ以外は変更されていません。最後に、先ほど挿入した頂点をJJ (行 8) に追加します。

これが機能するためには、 VsJV_s \setminus J内のすべての頂点がJJ内に少なくとも 1 つの隣接頂点を持つ必要があることは明らかです。実際、 uVsJu\in V_s \setminus J内のすべての頂点に対して、 JNs(u)ku|J\cap N_s(u) |\geq k_u (あるku<Mk_u<Mに対して) が最大のレイヤー接続性であることが必要です。実際の HNSW グラフでは、頂点次数がかなり広がっていることがわかります。下の図は、Lucene HNSW グラフの最下層の頂点次数の典型的な累積密度関数を示しています。

kuk_uに固定値を使用することと、それを頂点次数の関数にすることを検討しました。この2番目の選択肢は、グラフの品質への影響を最小限に抑えながら大幅なスピードアップにつながるため、次の方法を採用しました。

ku=max(2,14Ns(u))k_u=\max\left(2,\frac{1}{4}|N_s(u)|\right)

定義により、 Ns(u)|N_s(u) | は小さなグラフの頂点uuの次数に等しいことに注意してください。下限が 2 の場合、次数が 2 未満のすべての頂点が挿入されます。

単純な計算の議論によれば、 JJを慎重に選択すれば、約15VsGl\frac {1} {5} |V_s|をG_lに直接挿入するだけでよいことがわかります。具体的には、グラフの端の頂点の 1 つをJJに挿入すると、グラフのエッジに色を付けます。すると、VsJ内のすべての頂点がJ V_s \setminus J 内のすべての頂点が J 内に少なくともku k_u 個の 隣接頂点を持つためには、少なくともuVsJku \sum_{u\in V_s\setminus J} k_u 個の 辺を色付けする必要があることがわかります。さらに、我々は

uVsJku(VsJ)14EU[Ns(U)]\sum_{u\in V_s\setminus J} k_u\approx \left(|V_s|-|J|\right)\frac{1}{4}\mathbb{E}_U\left[|N_s(U)|\right]

ここで、 EU[Ns(U)]\mathbb {E} _U\left[N_s(U)|\right]は小さなグラフ内の平均頂点次数です。J内の各頂点uJu\in Jに対して、最大でNs(u)|N_s(u)|の辺を色付けします。したがって、色付けすると予想されるエッジの総数は最大でJEU[Ns(U)]|J|\, \mathbb {E} _U\left[|N_s(U)|\right]となります。JJを慎重に選択することで、この辺の数に近い色を塗ることができると期待しており、すべての頂点をカバーするにはJ|J|が満たす必要がある。

JEU[Ns(U)]=(VsJ)14EU[Ns(U)]|J|\, \mathbb{E}_U\left[|N_s(U)|\right] =\left(|V_s|-|J|\right)\frac{1}{4}\mathbb{E}_U\left[|N_s(U)|\right]

これは、 J=14(VsJ)=4514Vs=15Vs|J|=\frac {1} {4} (|V_s|-|J|)=\frac {4} {5} \frac {1} {4} |V_s|=\frac {1} {5} |V_s|を意味します。

SEARCH-LAYERが実行時間を支配すると、マージ時間を最大55 倍高速化できる可能性があります。書き込み増幅の対数的増加を考慮すると、非常に大きなインデックスの場合でも、通常は 1 つのグラフを構築する場合と比べて構築時間が 2 倍になるだけです。

この戦略のリスクは、グラフの品質が損なわれることです。最初は何も実行しないFAST-SEARCH-LAYERを試しました。これにより、特に単一のセグメントにマージするときに、レイテンシの関数としてのリコールが影響を受ける程度にグラフの品質が低下することがわかりました。次に、グラフの限定的な検索を使用して、さまざまな代替案を検討しました。結局、最も効果的な選択は最も単純なものでした。SEARCH-LAYERを使用しますが、 ef_constructionは低くします。このパラメータ化により、優れた品質のグラフを実現しながらも、マージ時間を平均で 30% 強短縮することができました。

結合セットの計算

適切な結合セットを見つけることは、HNSW グラフ カバー問題として定式化できます。貪欲ヒューリスティックは、最適なグラフカバーを近似するためのシンプルで効果的なヒューリスティックです。私たちが採用するアプローチでは、ゲインが減少する順に頂点を 1 つずつ選択してJJに追加します。ゲインは次のように定義されます。

Gain(v)=max(kvc(v),0)+uNs(v)J1{c(u)<ku}Gain(v)=\max(k_v-c(v),0)+\sum_{u\in N_s(v)\setminus J} 1\left\{c(u)<k_u\right\}

ここで、c(v)J c(v)は J におけるベクトルv v の近傍の数を表し、1{} 1\{\cdot\} は 指示関数である。ゲインには、 JJに追加した頂点の数の変化、つまりmax(kvc(v),0)\max(k_v-c(v),0)が含まれます。これは、カバーされていない頂点を追加することで目標に近づくためです。中央のオレンジ色の頂点のゲイン計算を下の図に示します。

各頂点vvに対して次の状態を維持します。

  1. 古くなっても、
  2. そのゲインGain(v)Gain(v)
  3. JJ内の隣接頂点の数はc(v)c(v)と表され、
  4. タイブレークに使用される範囲 [0,1] 内の乱数。

結合セットを計算するための疑似コードは次のとおりです。

COMPUTE-JOIN-SET

Inputs GsG_s

1    C\;\;C\leftarrow\emptyset
2    Gainexit0\;\;Gain_{exit}\leftarrow 0
3    \;\;for uGsu\in G_s do
4        CC{(false,ku+deg(u),0,rand in [0,1])}\;\;\;\;C\leftarrow C \cup \{(\text{false}, k_u+deg(u), 0, \text{rand in }[0,1])\}
5        GainexitGainexit+ku\;\;\;\;Gain_{exit}\leftarrow Gain_{exit}+k_u
6    Gaintot0\;\;Gain_{tot}\leftarrow 0
7    \;\;while Gaintot<GainexitGain_{tot}<Gain_{exit} do
8        v\;\;\;\;v^*\leftarrow\, maximum gain vertex in CC

9        \;\;\;\;Remove the state for vv^* from CC
10      \;\;\;if vv^* is not stale then
11          JJ{v}\;\;\;\;\;J\leftarrow J\cup\{v^*\}
12          GaintotGaintot+Gain(v)\;\;\;\;\;Gain_{tot}\leftarrow Gain_{tot}+Gain(v^*)
13          \;\;\;\;\;for uNs(v)u \in N_s(v^*) do
14              \;\;\;\;\;\;\;mark uu as stale if c(v)<kvc(v^*)<k_{v^*}
15              \;\;\;\;\;\;\;mark neighbors of uu stale if c(u)=ku1c(u)=k_u-1
16              c(u)c(u)+1\;\;\;\;\;\;\;c(u)\leftarrow c(u)+1
17      \;\;\;else
18          Gain(v)max(kvc(v),0)+uNs(v)J1{c(u)<ku}\;\;\;\;\;Gain(v^*)\leftarrow \max(k_v-c(v^*),0)+\sum_{u\in N_s(v^*)\setminus J} 1\left\{c(u)<k_u\right\}
19          \;\;\;\;\;if Gain(v)>0Gain(v^*)>0 then
20              CC{(false,Gain(v),c(v),copy rand)}\;\;\;\;\;\;\;C\leftarrow C \cup \{(\text{false},Gain(v^*),c(v^*),\text{copy rand})\}
21    \;\;return JJ

まず 1 行目から 5 行目で状態を初期化します。

メイン ループの各反復では、最初に最大ゲイン頂点 (行 8) を抽出し、同点の場合はランダムに決定します。変更を加える前に、頂点のゲインが古くなっているかどうかを確認する必要があります。特に、 JJに頂点を追加するたびに、他の頂点のゲインに影響を与えます。

  1. すべての隣接ノードはJJに追加の隣接ノードを持つため、ゲインは変化する可能性がある(14行目)
  2. いずれかの隣接セルが完全にカバーされている場合、その隣接セルのゲインはすべて変化する可能性があります(14~16行目)

ゲインは遅延方式で再計算されるため、頂点をJJに挿入する場合にのみ、その頂点のゲインを再計算します (18 ~ 20 行目)。ゲインは常に減少するだけなので、挿入すべき頂点を見逃すことは決してありません。

いつ終了するかを決定するには、 JJに追加した頂点の合計増加を追跡するだけでよいことに注意してください。さらに、 Gaintot<GainexitGain_ {tot} <Gain_ {exit}の場合、少なくとも 1 つの頂点のゲインはゼロではないため、常に進歩します。

成果

私たちは、サポートされている 3 つの距離メトリック (ユークリッド、コサイン、内積) をカバーする 4 つのデータセットで実験を実行しました。

  1. quora-E5-small: 522931文書、384次元、コサイン類似度を使用、
  2. cohere-wikipedia-v2: 100万文書、768次元、コサイン類似度を使用。
  3. 要点: 100万文書、960次元、ユークリッド距離を使用、
  4. cohere-wikipedia-v3: 100 万件のドキュメント、1024 次元、最大内積を使用します。

各データセットに対して、2 つの量子化レベルを評価します。

  1. int8 – 次元ごとに1バイトの整数を使用し、
  2. BBQ – 次元ごとに 1 ビットを使用します。

最後に、各実験について、2 つの検索深度で検索品質を評価し、インデックスを構築した後と、単一のセグメントに強制的にマージした後を調べます。

要約すると、グラフの品質を維持しながら、インデックス作成とマージの速度を一貫して大幅に向上させ、すべてのケースで検索パフォーマンスを実現できます。

実験1: int8量子化

ベースラインから候補、つまり提案された変更までの平均的な高速化は次のとおりです。

インデックス時間の高速化: 1.28 [objectObject][object Object]

強制マージの高速化: 1.72

これは実行時間の次の内訳に対応する。

完全性のために正確な時間は

索引マージ
データセットベースライン候補者構築候補者
quora-E5-small112.41秒81.55秒113.81秒70.87秒
ウィキコヒアv2158.1秒122.95秒425.20秒239.28秒
要旨141.82秒119.26秒536.07秒279.05秒
ウィキコヒアv3211.86秒168.22秒654.97秒414.12秒

以下に、複数のセグメントを持つインデックス(すべてのベクトルをインデックスした後のデフォルトのマージ戦略の最終結果)と単一セグメントへの強制マージ後のインデックスの、2 つの検索深度(recall@10 と recall@100)と、単一セグメントへの強制マージ後の、候補(破線)とベースラインを比較したリコール対レイテンシのグラフを示します。曲線が高く、左に寄っているほど良好であり、これは、より低いレイテンシでより高い再現性を意味します。

ご覧のとおり、複数のセグメント インデックスの場合、候補は Cohere v3 データセットの方が優れており、他のすべてのデータセットではわずかに劣りますが、ほぼ同等です。単一セグメントに統合した後、リコール曲線はすべてのケースでほぼ同じになります。

実験2:BBQ量子化

ベースラインから候補までの平均的な高速化は次のとおりです。

インデックス時間の高速化: 1.33 [objectObject][object Object]

強制マージの高速化: 1.34

これは実行時間の次の内訳に対応する。

完全性のために正確な時間は

索引マージ
データセットベースライン候補者構築候補者
quora-E5-small70.71秒58.25秒59.38秒40.15秒
ウィキコヒアv2203.08秒142.27秒107.27秒85.68秒
要旨110.35秒105.52秒323.66秒202.2秒
ウィキコヒアv3313.43秒190.63秒165.98秒159.95秒

複数のセグメント インデックスの場合、ベースラインがわずかに優れている cohere v2 を除き、ほぼすべてのデータセットで候補の方が優れています。単一セグメントインデックスの場合、リコール曲線はすべてのケースでほぼ同じです。

まとめ

このブログで説明したアルゴリズムは、今後リリースされる Lucene 10.2 およびそれに基づく Elasticsearch リリースで利用できるようになります。ユーザーは、これらの新しいバージョンで改善されたマージ パフォーマンスと短縮されたインデックス構築時間を活用できるようになります。この変更は、Lucene と Elasticsearch をベクター検索とハイブリッド検索に高速かつ効率的にするための継続的な取り組みの一環です。

関連記事

最先端の検索体験を構築する準備はできましたか?

十分に高度な検索は 1 人の努力だけでは実現できません。Elasticsearch は、データ サイエンティスト、ML オペレーター、エンジニアなど、あなたと同じように検索に情熱を傾ける多くの人々によって支えられています。ぜひつながり、協力して、希望する結果が得られる魔法の検索エクスペリエンスを構築しましょう。

はじめましょう