HNSWのスケーリング
HNSWの開発は数週間ほど一区切りしています(今は別のデータ構造に取り組んでいて、近くご報告します)。Redisに追加した新しい型も十分に安定し、機能も揃ってきました。そこで、これまでにHNSWについて学んだことを整理してブログ記事にまとめるには絶好のタイミングだと考えました。AI以前にはよく見かけた、いわば脳内のダンプのような記事ですが、今では少し珍しくなったかもしれません。ほぼ一年にわたってHNSWやベクトル類似度について考え、実装してきたので、そろそろ文章にする時が来ました。ただし、これはHNSWの入門記事ではありません。入門記事はすでにたくさんあります。ここでお届けするのは、その先の「もう一歩」です。もしあなたがHNSWをご存じなら、より進んだ知見を共有したいと思います。特に「Redisらしい」体験を実現できるほど高速にすることにまつわる知見です。ご存じのとおりRedisは低レイテンシーと高パフォーマンスのために設計されていますが、HNSWはどちらかといえばそれに抗う性質を持っています。そのため、HNSWを抽象データ構造として提供するにはさまざまな課題がありました。
このブログ記事はいくつかのセクションに分かれています。同じ本のページ、同じ体験の異なる章のようなものだと考えてください。ところで、実はこの記事は一度書き上げてから失くしてしまいました :D [macOSと悪い習慣にまつわる長くて悲しい話です――90年代の停電以来、こんな形でデータを失ったことはありませんでした]。ですので今回は、数日前に書いた内容を思い出しつつ、あまり気に入っていなかった部分をよりうまく書き直すことが主な課題になります。
HNSWの現状について一言
HNSWの内部や最適化について掘り下げる前に、HNSWそのものについて少し述べておきたいと思います。HNSWを初めて紹介した論文は素晴らしい計算機科学の文献であり、HNSW自体も驚くべきデータ構造です。しかし、私はそれが距離関数に基づいて近傍ベクトルを貪欲に探索するための最終的な答えだとは思っていません。論文にはどこか「欠けているピース」があるような感触があり、研究者の方々にもう半年あれば、さらに多くのことを探求し、語ることがあったのではないかと感じます。たとえば、私自身が論文を拡張してエントリの削除に対応させました。単に削除済みとしてマークして後で回収するtombstone方式ではなく、実際に削除する方式です。要素の削除については論文ではまったく触れられていません。同様に、現在ではHNSWの「H」が本当に必要なのか、むしろ単一レイヤーのフラットなデータ構造でも性能は大差ないのではないか、という検証も進められています(この点については今後もう少し詳しく触れたいと思います。私の感触では真実はその中間にあり、レベル選択関数を調整して一定の閾値以上のレベルのみを持たせるようにするのが理にかなっていると考えています)。
こうしたことを申し上げるのは、もしあなたがデータ構造の研究に携わっているなら、HNSWの進化や改良を考えることは非常に有望な領域だと考えているからです。しかも「ディスク対応にしてみよう」(Microsoftの取り組みなどを参照)のような方向だけに囚われない進化です。さて、前置きはこれくらいにして、実際の低レベルな話に入りましょう :)
メモリのスケーリング
Redisはインメモリシステムであり、HNSWもベクトルも残念ながら非常にメモリを消費します。理由は三つあります。1. HNSWは多くのポインタを持ちます。16本、32本、あるいはそれ以上(これはHNSWの調整可能なパラメータです)の隣接ノードへのポインタです。2. HNSWは多くのレイヤーを持ちます。スキップリストに似たデータ構造だからです。これにより一つ目の問題がさらに悪化します。3. HNSWの衛星データは浮動小数点数のベクトルです。素の状態では1要素あたり4バイトで、通常は300~3000要素を持ちます。これが一般的な範囲です。
では、ここから得られた教訓は何でしょうか。ポインタを圧縮する手法を取る方もいます。64ビットシステムでは8バイトのポインタの上位4バイトが同じ値になることが非常に多いからです。賢い方法だと思います。私はまだRedisでは実装していません。高速に動作させる必要があり、これは空間と時間のトレードオフだからです。ただ、やる価値があるかもしれませんし、ないかもしれません。さらに掘り下げてみるつもりです。
しかし、計算してみると、レイヤーが多いことは見た目ほどひどい話ではありません。平均すると、ノードあたりの複数レイヤーによるオーバーヘッドは約1.3倍に過ぎません(レベル選択関数でレベルが上がる確率が0.25の場合)。多くのノードはレベル0にしか存在しないからです。とはいえ1.3は1より大きく、もしHNSWの「H」がそこまで有用でないのだとしたら……[ネタバレすると、私が確認したところでは、すべてをレベル0に置いた場合、探索時間は長くなります。貪欲探索のメインループがより最適でない地点から開始し、最終的には正しいクラスタに到達するものの、計算時間が余計にかかるのです。ただしこれはまだ初期的な結果です]
ですので、ここでの本当の手軽な改善策はベクトルの量子化です。私が確認したところ、8ビット量子化を使うと約4倍の高速化が得られ、ベクトル自体は4分の1のサイズになります(ただしノード全体が4分の1になるわけではありません。ポインタはそのまま残り、多くの領域を占めるからです)。そして再現率(recall)は実際のユースケースではほぼ変わりません。これがRedis Vector Setsがデフォルトで8ビット量子化を採用している理由です。VADDのオプションで完全精度のベクトルやバイナリ量子化されたベクトルを指定することもできます。バイナリ量子化では符号だけを取ります。ただ、私は完全精度のベクトルとバイナリ量子化ベクトルの両方を使うことには懐疑的です。それらについて触れる前に、8ビットでどのような量子化を使っているかを見てみましょう。
私がやっているのは、各ベクトルの成分の絶対値の最大値を計算し(つまり量子化はベクトルごとに行います)、符号付き8ビットの値で-127から127の範囲に量子化して表現する方法です。最小値と最大値の両方を保存する方法ほど精度は高くありませんが、コサイン類似度を計算する際には高速です。次のようにできるからです。
/* Each vector is quantized from [-max_abs, +max_abs] to [-127, 127]
* where range = 2*max_abs. */
const float scale_product = (range_a/127) * (range_b/127);そして、実際にはコード内のメインループはアンロールされ、複数のアキュムレータを使って現代のCPUをより忙しく働かせていますが、整数領域で次のように掛け合わせます。
for (; i < dim; i++) dot0 += ((int32_t)x[i]) * ((int32_t)y[i]);そして最後に、浮動小数点の距離に戻すことができます。
float dotf = dot0 * scale_product;詳しくはvectors_distance_q8()をご覧ください。おおよその考え方はお分かりいただけたかと思います。整数の量子化領域から量子化されていない内積へ、ごく単純な演算で戻せるということです。
つまり、8ビット量子化は非常にお得であり、完全精度が必要な機能だったのは、ベクトルの生成方法によってはわずかな差が結果を左右する使い方をする方もいるからです(いえ、学習済みベクトルではそうはなりませんが……)。では、なぜバイナリ量子化なのでしょうか。ユーザーが元の情報自体がすでにバイナリである場合に、無駄に容量を使わないシンプルな方法を提供したかったからです。たとえば、ユーザーの集合があり、それぞれがyes/noの属性を持っていて、似たユーザーやアイテムなどを見つけたいとします。そういった場合にこそバイナリ量子化を使うべきです。これもまたVADDコマンドのオプションとして用意されています。
速度のスケーリング:スレッドと局所性
ご自身について少しお話ししなければなりません。私は、シングルコアでできる限りのことをやり、複数のコアはシェアードナッシングなアーキテクチャで使うことが可能なら、スレッドを用いるシステムはあまり好みではありません。しかしHNSWは別です。HNSWは遅く、しかもほとんどのユースケースではほぼ常に読み取り専用でアクセスされます。そのため、私のVector Setsの実装は完全にスレッド化されています。読み取りだけでなく、書き込みも部分的にスレッド化されており、Redisのようなシステムで、キーがバックグラウンドの保存プロセスやクライアントなどさまざまな経路からアクセスされる中で、これがどうやって混乱を招かずに実現できているのか不思議に思われるかもしれません。
まず、読み取りに焦点を当てましょう。データ構造への書き込みが誰も行っていない限り、近傍ベクトルを貪欲に収集するスレッドを立ち上げ、ブロックされたクライアントに結果を返すことができます。ただ、私のHNSW実装はゼロから書き起こしたもので、文字通りvimで空のCファイルを開くところから始め、他の多くのシステムが使っている二つの実装とはコードの共有が0%です。そのため、いくつか「新しい」点があります。その一つが、すでに訪問したノードを再訪しないようにするために、(ハッシュテーブルのように)別のデータ構造でマークするのではなく、各ノードに保存された「epoch」と呼ぶ整数を使う点です。ハッシュテーブルでマークする方法はかなり遅いと考えています。epochはノードに局所的なもので、グローバルなデータ構造側で探索ごとにepochをインクリメントします。ですので各探索の文脈では、訪問済みかどうかを現在のepochを使ってマークでき、現在のepoch以下のepochだけが存在することが保証されます。
しかしスレッドを使うと、複数の探索が同時に発生します! そこで必要になったのがepochの配列でした。
typedef struct hnswNode {
uint32_t level; /* Node's maximum level */
… many other stuff …
uint64_t visited_epoch[HNSW_MAX_THREADS];
}これはhnsw.hでご覧いただける内容です。これもまた空間と時間のトレードオフであり、ここでも時間が空間に勝ちました。
では、書き込みのスレッド化はどのように実現したのでしょうか。コツは、HNSWへの挿入では候補となる近傍を探すのに多くの時間が費やされる点にあります。そこで書き込みを読み取り側の前半とコミット側の後半に分割し、後半だけが書き込みロックを必要とします。そして、前半で集めた候補が、その間にHNSWが変化して一部のノードが無効になっていた場合に破棄されるようにするなどの工夫があります。ただ、他にも問題があります。バックグラウンドスレッドが値に対する処理を行っている最中に、ユーザーがキーを削除したらどうなるでしょうか。このシナリオのために、実際にオブジェクトを解放する前にバックグラウンド操作が完了するのを待つ関数を用意しています。こうした工夫により、現実のベクトルワークロードで毎秒5万オペレーションを容易に達成できます。これはredis-benchmark自体から得られた数値で、すべてのオーバーヘッドを含んだものです。フラットなHNSWライブラリ単体の生の数値ははるかに高くなります。
メモリのスケーリング:適切な解放
複数のインスタンスにまたがってHNSWをスケールさせる方法や、なぜRedis Vector Setsがデータ構造そのものをユーザーの目の前に晒すのか(私はプログラマは賢く、過保護にする必要はないと考えていますが、理由はそれだけではありません)について話す前に、もう一度メモリの話に戻りたいと思います。この点について語るべき興味深い話があるからです。
ほとんどのHNSW実装は、グラフからノードを削除した際にメモリを直接解放することができません。主な理由は二つあると考えています。
1. 人々がHNSWの原論文をある点で誤解していること。すなわち、隣接ノード間のリンクは相互でなくてもよいと思い込んでいる点です。そして、そう思わせる明確な理由があります。
2. 論文ではノードの削除や、ノードがなくなって接続の「網」に欠損が生じた後にグラフをどう修復するかについて何も述べられていないことです。
一つ目の問題は、(私の考えでは)論文の記述の明確さの不足と、HNSWを実装する際に人々が直面する具体的な問題が組み合わさって生じています。新しいノードを挿入し、既存のノードの中から良い近傍を探索する際、候補となるノードがすでに出力リンクの上限に達していることがよくあります。この場合どうすべきでしょうか。この問題は、挿入しようとしている新しいノードから、すでに出力リンクが「満杯」の候補ノードへ一方向にリンクすることで解決されることがよくあります。しかし、ノードを削除する必要が生じたとき、そのノードへのすべての入力リンクを解決できなくなるため、メモリを本当に解放することができません。フラグで削除済みとしてマークし、後でグラフを再構築して古いノードをガベージコレクトしたり、時には単にメモリリークさせたりします。
そこで、まずRedisの実装ではリンクを双方向に強制することで異なるアプローチを取っています。AがBにリンクするなら、BもAにリンクします。しかし、Aがビジーな場合にそれをどう実現するのでしょうか。ここから先は複雑な領域になりますが、実際には既存のノードから、他によく接続されている別の隣接ノードへのリンクを外すといったヒューリスティクスが用いられます。対象ノードにとっても自分がより良い候補である場合や、そうでない場合でも新しいノードが少なくとも最小限のリンク数を確保できるようにする別の方法があり、常にグラフのスモールワールド性を満たすように努めています。
この方法により、RedisがVector Setからノードを削除する際には、常にそのノードへのすべてのポインタを取り除く手段が確保されます。しかし、リンクを一つ失った残りのノードはどうすればよいでしょうか。私が行っているのは、それらの間に距離行列を作成し、古いノードの隣接ノード同士を、平均距離が最小になるようにリンクし直そうと試みることです。基本的に、行列内の各ペアi,jについて、その接続がどれだけ良いか(ベクトルがどれだけ類似しているか)と、それらをリンクすることが残りの可能なペアにどれだけ悪影響を与えるか(特定の2ノードをリンクすることで、良いペアを持てない要素が残ってしまう可能性があるため)を計算します。このスコアの行列を構築した後、貪欲なペアリングのステップに進みます。
これは非常によく機能し、数百万要素を持つ大きなHNSWを構築した後で要素の95%を削除しても、残りのグラフは依然として良好な再現率を保ち、孤立したノードも生じません。
私がHNSWには新しい論文がその研究を継続する余地があると言うとき、それはこういうことを意味しています。
HNSWを複数プロセスにスケールさせる
私がRedis Vector Setsに取り組み始めたとき、Redisの世界にはすでにベクトル類似度の一つの実装がありました。具体的にはRediSearchのインデックス型の一つとしての実装です。そして、これがほとんどの人がHNSWを捉えている見方です。すなわち既存データに対する一種のインデックスだと。
しかし私は、まったく異なる形でRedisに新しいHNSW実装を提供したいと考えました。どのようにかお分かりでしょうか。もちろん、データ構造として、です。そしてこれは、長年のRedisとの関わりで私の頭がどれだけRedis的に形作られてきたか、あるいは最初から私の頭がRedis的で、Redisの方が私の頭の形に合わせて作られてきたのか、という物語を物語っています。HNSWをユーザーに直接公開するRedisデータ構造をどのように設計するかというアイデアがすぐに思い浮かび、Redisにおけるベクトルの扱いがまさにそのように行われていなかったことに私は戸惑いさえ覚えました。
同時に、設計書をRedisの同僚たちに渡したとき、彼らがすぐにそれを「自明なこと」として理解したとは言えません。私の考えはこうでした。ベクトルはRedisのSorted Setにおけるスコアのようなものですが、全順序を持つスカラーのスコアではないという違いがあります。それでもVADDやVREMで要素を追加・削除し、ZRANGEの代わりにVSIMを呼び出すことで類似した要素を得ることができます。これはAPIとして筋が通っているだけでなく、HNSWは非常に合成可能で、特定のユースケース(テキスト埋め込みや画像埋め込み、あるいは必ずしも学習済み埋め込みでなくても)に紐づかないと考えたからです。次のように実行します。
VADD my_vector_set VALUES [… components …] my_element_stringつまり、componentsに何が入っていようとRedisは気にせず、VSIMを呼び出せば類似した要素を返してくれます。
しかしこれは同時に、同じユースケースに関する異なるベクトルを複数のインスタンスやキーに分割して持っている場合、すべてのインスタンスに対して同じクエリベクトルでVSIMを問い合わせ、WITHSCORESオプション(コサイン距離を返します)を付けて結果をクライアント側でマージすれば、数億のベクトルを複数のインスタンスにN分割して魔法のようにスケールできることも意味します[このユースケースで興味深いのは、クライアントライブラリが十分に賢ければ、N個のインスタンスへの問い合わせを多重化で並列に行える点です]。
このようにHNSWを生の形で公開することのもう一つの非常に注目すべき点は、書き込みを非常に簡単にスケールできることです。要素をNで割った余りでハッシュし、結果のRedisキー/インスタンスをターゲットにするだけです。複数のインスタンスが(HNSWの基準では遅いものの、それでも高速な)書き込みを同時に吸収し、本来は非常に遅い処理を並列化できます。
HNSWをこのように公開する方法は、小さなスケールでも非常に大きな意味を持ちます。ユーザー/アイテム/プロダクトなど、扱っているものごとにHNSWを持ちたい場合があります。何かの上に構築されたインデックスを持っている場合、このモデル化は非常に困難ですが、HNSWがデータ構造であれば話は別です。各アイテムごとに、ほんの少数の要素を持つVector Setキーを単に持てばよいのです。そしてもちろん、他のRedisキーと同様に、キーに有効期限を設定して後で自動的に削除されるようにすることもできます。
こうしたことはすべて、業界でもっと意識されるべき一つの原則に集約できます。多くのプログラマは賢明であり、アクセスできない魔法のようなシステムを作る代わりに、データ構造やトレードオフを率直に示せば、彼らはより多くのものを作り、ユースケースを特定の形でモデル化できるようになります。そしてあなたのシステムもよりシンプルになるのです。
読み込み時間のスケーリング
スレッドを使わない場合、私のHNSWライブラリはword2vec(各ベクトル300要素)をシングルスレッドで毎秒5000要素の速度でHNSWに追加でき、結果として得られたHNSWに対しては毎秒9万クエリで問い合わせができます。ご覧のとおり、大きな差があります。
これは、数百万要素を持つHNSWをRedisのダンプファイルからメモリにロードし直すのに非常に時間がかかることを意味します。そしてこの時間はレプリケーションにも影響します。あまり良い状況ではありません。しかし、これはディスクからメモリへ要素を追加する最も単純な方法、すなわち「要素、ベクトル」をディスクに保存し、メモリ内でHNSWを再構築しようとする場合にのみ当てはまります。ここにも学ぶべき教訓があります。HNSWを使う際には、ノードと隣接ノードをそのままの形でシリアライズし、メモリ内で単に領域を確保して隣接IDをポインタに変換するだけで再構築できるようにする必要があります。これにより100倍の高速化が得られました。
しかし、話はここで終わると思いますか? ふふ。最近のRedisはより強力なセキュリティ機能を備え、RDBファイルが攻撃者によって破損された場合でも危険な動作をしないようにしています。そこで私がしなければならなかったのは、シリアライズされたデータ構造にエラーや破損があっても、ロード後にHNSWが有効であることを保証することでした。これには多くの工夫が必要でしたが、ここでは私が書いたコメントの一つを自由に引用させてください。相互性のチェックが特にクールだと思うからです。
/* Second pass: fix pointers of all the neighbors links.
* As we scan and fix the links, we also compute the accumulator
* register "reciprocal", that is used in order to guarantee that all
* the links are reciprocal.
*
* This is how it works, we hash (using a strong hash function) the
* following key for each link that we see from A to B (or vice versa):
*
* hash(salt || A || B || link-level)
*
* We always sort A and B, so the same link from A to B and from B to A
* will hash the same. Then we xor the result into the 128 bit accumulator.
* If each link has its own backlink, the accumulator is guaranteed to
* be zero at the end.
*
* Collisions are extremely unlikely to happen, and an external attacker
* can't easily control the hash function output, since the salt is
* unknown, and also there would be to control the pointers.
*
* This algorithm is O(1) for each node so it is basically free for
* us, as we scan the list of nodes, and runs on constant and very
* small memory. */ユースケースのスケーリング:JSONフィルタ
Vector Setsの最初の動作する実装が完成したと感じた日を覚えています。すべてが期待通りに動き、改良や追加機能に取り掛かる出発点となりました。
しかし、ここ数週間から数ヶ月の間に、内部から多くのユースケースでは何らかの混合検索が必要だというフィードバックを受け取りました。特定のクエリベクトルに近いベクトルを求めつつ、同時に何らかのフィルタリングも行いたいというものです(たとえば2000年から2010年の間に公開された映画の中で最も似ているもの)。私の感覚では、プロダクトの担当者が考えているほど頻繁に異なるパラメータで問い合わせる必要はないことが多く、ほとんどの場合、この特定の例でいえば各年を異なるVector Setキーに追加することで、より効率的に実現できると考えています(これも、インデックスという形ではなくデータ構造として表現されたHNSWの合成可能性の一例です)。
とはいえ、私はHNSWの貪欲探索のメインループについて考えていました。それは次のようなものです。
// Simplified HNSW greedy search algorithm. Don’t trust it too much.
while(candidates.len() > 0) {
c = candidates.pop_nearest(query);
worst_distance = results.get_worst_dist(query);
if (distance(query,c) > worst_distance) break;
foreach (neighbor from c) {
if (neighbor.already_visited()) continue;
neighbor.mark_as_visited();
if (results.has_space() OR neighbor.distance(query) < worst_distance) {
candidates.add(neighbor);
results.add(neighbor);
}
}
}
return results;そこで、各ノードにJSON形式のメタデータのセットを追加するアイデアで遊び始めました。たとえば {“year”: 1999} のようなものがあれば、貪欲探索を実行しながらフィルタリングするのに十分ではないでしょうか。もちろん探索は制限される必要がありますが、ここに重要な洞察があります。私はまずクエリベクトルに近い要素が欲しいのです。ですから、JSON属性の条件を多くのノードが満たさないとしても、グラフ全体を探索する必要は本当はありません。ユーザーが労力(effort)を指定できるようにし、いずれにせよフィルタに一致する非常に遠い結果は役に立ちません。
これもまた、私のHNSWが異なる点の一つです。プログラミング言語のif文の中に書けるような式によるフィルタリングをサポートしているのです。そしてVector Set内の要素には、その特性を表すJSONブロブを関連付けることができます。すると次のようなことができるようになります。
VSIM movies VALUES … your vector components here… FILTER '.year >= 1980 and .year < 1990'メモリ使用量について一言
HNSWの致命的な問題は、理論上は通常メモリから提供されることにある――と言われています。実際にはHNSWをディスク上に実装することもできますが、ディスクアクセスのレイテンシーという観点ではより優れたデータ構造があります。しかし、RedisとVector Setsという特定のケースでは、非常に高速で扱いやすいものを提供するという考えがあります。インメモリデータ構造の柔軟性はその点で役立ちます。そこで問題は、メモリ使用量は本当にそれほどひどいのか、ということに帰着します。
300万件のWord2Vecエントリをデフォルトのint8量子化でRedisにロードすると、3GBのRAMを消費します。1エントリあたり1KBです。多くのユースケースでは数千万件、あるいはそれよりずっと少ない件数しか持ちません。そして、適切に実装されたインメモリのHNSWから得られるものは非常に優れたパフォーマンスであり、これは定義上低速なデータ構造およびワークロードにおいて極めて重要です。私のMacBookでは、このword2vecデータセットを保持するキーに対してredis-benchmarkとVSIMで毎秒48,000オペレーションを達成しています。インメモリHNSWのメモリ使用量は、多くのユースケースで十分に許容可能だというのが私の感触です。そして、ベクトルの大部分をディスクに置きたいユースケースであっても、たとえパフォーマンスの低下という代償を払うとしても、ホットセットはおそらくRAMから提供されるべきでしょう。
これが、HNSWの研究に携わることが良い考えだと私が考える理由の一つです。HNSWが近いうちにほとんどのユースケースで置き換えられるとは思えません。ユースケースやデータサイズに応じて、RAM用とディスク用でそれぞれ理想的な異なるデータ構造を持ち続ける可能性の方が高いように思われます。さらに、最近Hacker Newsのフロントページを眺めているだけでも、数百万件のアイテムを抱えながら、必要以上に遅かったり複雑だったりするシステムに苦労している人々を見かけます。HNSWを適切な方法で慎重に公開すれば、そうした手間をすべて省くことができます。
おわりに
HNSWが好きで、その実装に取り組むことは本当に楽しいものでした。ベクトルはAIのない世界でもRedisに非常によく合うと考えています(たとえば数ヶ月前には、過去にHNで公開された研究を再現して、Hacker Newsユーザーのフィンガープリント作成に使いました)。HNSWは数多くのユースケースにとって単純にクールで強力すぎる存在であり、AIや学習済み埋め込みによってその可能性は無数のユースケースへと拡大します。しかし、Redisのほとんどの機能と同様に、人々がその有用性や強力さ、使い方に気づくまでには多くの時間がかかるだろうと予想しています(単なるRAGの問題ではありません)。Streamsでも同じことが起こりました。長い年月を経て、ようやく大規模な普及に至っています。
もしHNSWや私が書いた実装により興味があるなら、コードはかなり読みやすく、十分にコメントも書かれていると思います。
https://github.com/redis/redis/blob/unstable/modules/vector-sets/hnsw.c
Redis Vector Setsについてさらに学びたい場合は、私自身が書いたREADMEファイルから始めることをお勧めします。公式のRedisドキュメントもありますが、まずはここからどうぞ。
https://github.com/redis/redis/tree/unstable/modules/vector-sets
こんなに長いブログ記事を最後まで読んでいただきありがとうございます。良い一日をお過ごしください。
参考文献:HNSWの「H」がどれだけ有用かについての論文はこちら -> https://arxiv.org/abs/2412.01940
記事をランダムに読む