このガイドでは、次の内容を学びます。
- ベクトル検索 の基本を簡単に理解する
- 近似最近傍 (ANN) と Hierarchical Navigable Small World (HNSW) について学ぶ
- 量子化ビット (QBit) について学ぶ
- DBPedia dataset を使って、QBit で ベクトル検索 を実行する
数学や物理学では、ベクトルは大きさと向きの両方を持つ対象として定義されます。
多くの場合、空間内の線分や矢印として表され、速度、力、加速度といった量を表現するのに使われます。
コンピューターサイエンスでは、ベクトルは有限個の数値からなる列です。
つまり、数値を格納するためのデータ構造です。
機械学習におけるベクトルも、コンピューターサイエンスでいうものと同じデータ構造ですが、そこに格納される数値には特別な意味があります。
テキストのまとまりや画像を、その内容を表す重要な概念へと落とし込む処理をエンコードと呼びます。
その結果得られる出力は、それらの重要な概念を数値形式で表した、機械による表現です。
これが埋め込みであり、ベクトルに格納されます。
言い換えると、このような文脈上の意味がベクトルに埋め込まれたものを、埋め込みと呼びます。
ベクトル検索は、今やあらゆる場面で使われています。
音楽のレコメンデーションを支え、大規模言語モデルの回答精度を高めるために外部知識を取得する retrieval-augmented generation (RAG) でも使われており、Google 検索でさえ、ある程度はベクトル検索によって支えられています。
特化型データベースには利点がある一方で、ユーザーは完全に専用化されたベクトルストアよりも、アドホックにベクトル機能を利用できる通常のデータベースを好むことが少なくありません。
ClickHouse は、総当たりベクトル検索 と、現在の高速なベクトル検索の標準である HNSW を含む 近似最近傍 (ANN) 検索の手法 の両方をサポートしています。
ベクトル検索の仕組みを理解するために、簡単な例を見てみましょう。
単語の埋め込み (ベクトル表現) を考えてみます。
以下のように、いくつかのサンプル埋め込みを含むテーブルを作成します。
指定した埋め込みに最も近い単語を検索できます。
クエリ埋め込みは “apple” に最も近く (距離が最小) 、2 つの埋め込みを並べて見ると、それがよくわかります。
大規模なデータセットでは、総当たり検索では時間がかかりすぎます。
そこで、近似最近傍法が有効になります。
量子化では、より小さい数値型へダウンキャストします。
数値が小さくなるほどデータ量も小さくなり、データ量が小さいほど距離計算は高速になります。
ClickHouse のベクトル化クエリ実行エンジンでは、1 回の演算でプロセッサのレジスタにより多くの値を収められるため、スループットが直接向上します。
選択肢は 2 つあります。
- 量子化したコピーを元のカラムと併せて保持する - ストレージは 2 倍になりますが、いつでも完全な精度の値にフォールバックできるため安全です
- 元の値を完全に置き換える (INSERT 時にダウンキャストする) - 容量と I/O を節約できますが、後戻りはできません
Hierarchical Navigable Small World (HNSW)
HNSW は、複数のノード (ベクトル) からなる階層構造で構築されます。各ノードはランダムに 1 つ以上の層へ割り当てられ、上位の層に現れる確率は指数関数的に低くなります。
検索時には、最上位層のノードから開始し、最も近い近傍に向かって貪欲に移動します。これ以上近いノードが見つからなくなったら、次の、より高密度な層へ下ります。
このレイヤー構造により、HNSW はノード数に対して対数的な検索計算量を実現します。
HNSW の制限主なボトルネックはメモリです。ClickHouse は HNSW の usearch 実装を使用しています。これはインメモリのデータ構造で、分割をサポートしていません。
そのため、データセットが大きくなるほど、それに比例してより多くの RAM が必要になります。
QBit は、浮動小数点数がビット列として表現される仕組みを利用して、BFloat16、Float32、Float64 の値を格納できる新しいデータ構造です。
各数値を丸ごと格納するのではなく、QBit は値をビットプレーンに分割します。つまり、1 番目のビットをまとめたもの、2 番目のビットをまとめたもの、3 番目のビットをまとめたもの、という形です。
この手法により、従来の量子化における主な制約を解消できます。重複データを保存する必要がなく、値が意味を失うリスクもありません。また、QBit はインメモリ索引を維持するのではなく、保存されたデータを直接処理するため、HNSW の RAM ボトルネックも回避できます。
利点何より重要なのは、事前に判断を下す必要がないことです。
精度と性能はクエリ時に動的に調整できるため、ユーザーは精度と速度のバランスをほとんど手間なく探れます。
制限QBit はベクトル検索を高速化しますが、計算量は依然として O(n) のままです。つまり、データセットが十分に小さく、HNSW 索引が RAM に余裕を持って収まるのであれば、依然としてそれが最速の選択肢です。
QBitカラムは次のように作成できます。
データが QBit カラムに挿入されると、すべての1番目のビット、すべての2番目のビット、というように同じ位置のビット同士が並ぶように転置されます。これらをグループと呼びます。
各グループは、それぞれ別個の FixedString(N) カラムに格納されます。これは長さ N バイトの固定長文字列で、メモリ上では区切りなしで連続して格納されます。こうしたグループはすべて単一の Tuple にまとめられ、これが QBit の基盤となる構造を構成します。
例: 8×Float64 要素のベクトルから始めると、各グループには 8 ビットが含まれます。Float64 は 64 ビットなので、最終的に 64 個のグループ (各ビットに1つ) になります。したがって、QBit(Float64, 8) の内部レイアウトは 64×FixedString(1) カラムからなる Tuple のようになります。
元のベクトル長が 8 で割り切れない場合は、8 に揃うように不可視の要素でパディングされます。これにより、完全なバイト単位でのみ動作する FixedString との互換性が確保されます。
QBit でクエリを実行するには、L2DistanceTransposed 関数に精度パラメータを指定して使用します。
3番目のパラメータ (16) は、精度レベルをビット数で指定します。
距離を計算するには、その前に必要なデータをディスクから読み込み、さらにアン転置 (グループ化されたビット表現を完全なベクトルに戻す処理) を行う必要があります。QBit は値を精度レベルごとにビット転置して保存するため、ClickHouse は目的の精度で数値を復元するのに必要な上位ビットプレーンだけを読み取れます。
上記のクエリでは、精度レベル 16 を使用しています。Float64 は 64 ビットなので、先頭の 16 個のビットプレーンだけを読み込み、データの 75% をスキップできます。
読み込み後は、読み込んだビットプレーンから各数値の上位部分だけを復元し、読み込まれていないビットは 0 のままにします。
Float32 や BFloat16 のような、より小さな型にキャストすれば、この未使用部分をなくせるのではないかと思うかもしれません。実際それは可能ですが、すべての行に明示的なキャストを適用するとコストが高くなります。
その代わりに、参照ベクトルだけをダウンキャストし、QBit データはよりビット幅の小さい値を含んでいるものとして扱えます (つまり、一部のカラムの存在を「忘れる」わけです) 。というのも、そのレイアウトはそうした型を切り詰めたものに対応していることが多いためです。
BFloat16 は、Float32 を半分に切り詰めた形式です。符号ビットと 8 ビットの指数はそのままですが、23 ビットの仮数では上位 7 ビットだけを保持します。そのため、QBit カラムの先頭 16 個のビットプレーンを読み取ると、実質的に BFloat16 値のレイアウトを再現できます。したがって、この場合は参照ベクトルを安全に BFloat16 に変換でき、実際にそのようにしています。
ただし、Float64 は別物です。11 ビットの指数と 52 ビットの仮数を使用するため、単に Float32 のビット数を 2 倍にしたものではありません。構造も指数バイアスもまったく異なります。Float64 を Float32 のようなより小さいフォーマットにダウンキャストするには、実際に IEEE-754 の変換を行う必要があり、その際に各値は表現可能な最も近い Float32 に丸められます。この丸め処理は計算コストが高くなります。
Float32 の埋め込みで表現された 100 万件の Wikipedia 記事を含む DBpedia データセットを使った実際の例で、QBit の動作を見てみましょう。
まず、テーブルを作成します
コマンドラインからデータを挿入します:
データの挿入にはしばらく時間がかかる場合があります。
ここでコーヒーブレイクにしましょう!
または、以下のように個々のSQL文を実行して、25個のParquetファイルをそれぞれ読み込むこともできます。
dbpediaテーブルに100万行あることを確認します。
次に、QBit カラムを追加します:
月、アポロ11号、スペースシャトル、宇宙飛行士、ロケットといった宇宙関連の検索語すべてに最も関連する概念を探します。
このクエリは、5つの概念それぞれについて、意味的に近いエントリ上位1000件を検索します。
そのうち少なくとも3つの結果に含まれるエントリを返し、一致する概念数と、それらのいずれかへの最小距離 (元の項目は除外) に基づいて順位付けします。
わずか5ビット (符号1ビット + 指数4ビット、仮数はゼロ) を使うと:
パフォーマンス: 10 行が返されました。経過時間: 0.271 秒。846 万行、4.54 GB を処理しました (毎秒 3119 万行、16.75 GB/s) 。ピークメモリ使用量: 739.82 MiB。
パフォーマンス: 10 rows in set. Elapsed: 1.157 sec. 処理済み: 1,000万行、32.76 GB (864万行/秒、28.32 GB/秒) Peak memory usage: 6.05 GiB。
結果はどうだったでしょうか。単に良いだけではありません。驚くほど優れていました。浮動小数点数から仮数をすべて取り除き、指数の半分を削っても、なお意味のある情報を保てるとは一見するとわかりません。
QBit の重要な知見は、重要でないビットを無視してもベクトル検索は機能する、ということです。
優れたセマンティック検索の品質を維持しながら、メモリ使用量は 6.05 GB から 740 MB に削減されました。
QBit は、浮動小数点数をビットプレーンとして格納するカラム型です。
これにより、ベクトル検索時に読み取るビット数を選べるため、データを変更せずに再現率と性能を調整できます。
ベクトル検索の各手法には、再現率・精度・性能のトレードオフを左右する固有のパラメータがあります。
通常、これらは事前に決めておく必要があります。
選択を誤ると、多くの時間とリソースを無駄にし、後から方針を変えるのも容易ではありません。
QBit なら、初期段階でそうした判断を下す必要はありません。
精度と速度のトレードオフはクエリ時に直接調整できるため、試しながら最適なバランスを探れます。
2025年10月28日公開の、Raufs Dunamalijevs によるブログ記事をもとに編集