メインコンテンツまでスキップ

IVFとApache Dorisでの使用方法

IVFインデックスは、近似最近傍(ANN)検索に使用される効率的なデータ構造です。検索時にベクトルの範囲を絞り込むことで、検索速度を大幅に向上させます。Apache Doris 4.x以降、IVFベースのANNインデックスがサポートされています。本ドキュメントでは、IVFアルゴリズム、主要なパラメータ、エンジニアリング実践について説明し、本番Dorisクラスターでの IVFベースのANNインデックスの構築と調整方法を解説します。

IVFインデックスとは?

完全性のため、歴史的背景を説明します。IVF(inverted file)という用語は、情報検索に由来します。

いくつかのテキスト文書の簡単な例を考えてみましょう。特定の単語を含む文書を検索するために、転置インデックスは各文書の単語リストを保存します。関連する文書を見つけるには、各文書を明示的に読む必要があります。

DocumentWords
Document 1the,cow,says,moo
Document 2the,cat,and,the,hat
Document 3the,dish,ran,away,with,the,spoon

対照的に、転置インデックスは、検索可能なすべての単語の辞書を含み、各単語に対して、その単語が出現する文書インデックスのリストを持ちます。これが転置リスト(inverted file)であり、選択されたリストに検索を制限することを可能にします。

WordDocuments
theDocument 1, Document 3, Document 4, Document 5, Document 7
cowDocument 2, Document 3, Document 4
saysDocument 5
mooDocument 7

今日、テキストデータはベクトル埋め込みとして表現されることがよくあります。IVF方式はクラスターセンターを定義し、これらのセンターは前述の例の単語辞書に類似しています。各クラスターセンターに対して、そのクラスターに属するベクトルインデックスのリストを持ち、選択されたクラスターのみを検査すればよいため、検索が高速化されます。

効率的なベクトル検索のためのIVFインデックスの使用

データセットが数百万、さらには数十億のベクトルに成長すると、網羅的な完全k最近傍(kNN)検索を実行し、クエリとデータベース内のすべてのベクトル間の距離を計算することは、計算量的に禁止的になります。この総当たり手法は、大規模な行列乗算に相当し、スケールしません。

幸い、多くのアプリケーションでは、わずかな精度と引き換えに速度の大幅な向上を図ることができます。これが近似最近傍(ANN)検索の領域であり、Inverted File(IVF)インデックスは最も広く使用され効果的なANN手法の一つです。

IVFの基本原理は「分割統治」です。データセット全体を検索する代わりに、IVFは検索範囲を有望ないくつかの領域に巧妙に絞り込み、必要な比較回数を劇的に減らします。

IVFは、大規模なベクトルデータセットをより小さく管理しやすいクラスターに分割し、各クラスターを「セントロイド」と呼ばれる中心点で表現することで動作します。これらのセントロイドは、それぞれのパーティションのアンカーとして機能します。検索中、システムはクエリベクトルに最も近いセントロイドを持つクラスターを素早く特定し、それらの中でのみ検索を行い、データセットの残りを無視します。

ivf search

Apache DorisでのIVF

Apache Dorisはバージョン4.x以降、IVFベースのANNインデックスの構築をサポートしています。

インデックス構築

ここで使用されるインデックスタイプはANNです。ANNインデックスを作成する方法は2つあります:テーブル作成時に定義する方法と、CREATE/BUILD INDEX構文を使用する方法です。この2つのアプローチは、インデックスの構築方法とタイミングが異なるため、異なるシナリオに適しています。

アプローチ1:テーブル作成時にベクトルカラムにANNインデックスを定義します。データが読み込まれると、各セグメントが作成される際にANNインデックスが構築されます。利点は、データ読み込みが完了すると、インデックスが既に構築されており、クエリが即座にそれを高速化に使用できることです。欠点は、同期インデックス構築によりデータ取り込みが遅くなり、コンパクション中に追加のインデックス再構築が発生し、リソースの無駄につながる可能性があることです。

CREATE TABLE sift_1M (
id int NOT NULL,
embedding array<float> NOT NULL COMMENT "",
INDEX ann_index (embedding) USING ANN PROPERTIES(
"index_type"="ivf",
"metric_type"="l2_distance",
"dim"="128",
"nlist"="1024"
)
) ENGINE=OLAP
DUPLICATE KEY(id) COMMENT "OLAP"
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES (
"replication_num" = "1"
);

INSERT INTO sift_1M
SELECT *
FROM S3(
"uri" = "https://selectdb-customers-tools-bj.oss-cn-beijing.aliyuncs.com/sift_database.tsv",
"format" = "csv");

CREATE/BUILD INDEX

アプローチ2:CREATE/BUILD INDEX

CREATE TABLE sift_1M (
id int NOT NULL,
embedding array<float> NOT NULL COMMENT ""
) ENGINE=OLAP
DUPLICATE KEY(id) COMMENT "OLAP"
DISTRIBUTED BY HASH(id) BUCKETS 1
PROPERTIES (
"replication_num" = "1"
);

INSERT INTO sift_1M
SELECT *
FROM S3(
"uri" = "https://selectdb-customers-tools-bj.oss-cn-beijing.aliyuncs.com/sift_database.tsv",
"format" = "csv");

データが読み込まれた後、CREATE INDEXを実行できます。この時点でインデックスはテーブルに定義されますが、既存のデータに対してはまだインデックスが構築されていません。

CREATE INDEX idx_test_ann ON sift_1M (`embedding`) USING ANN PROPERTIES (
"index_type"="ivf",
"metric_type"="l2_distance",
"dim"="128",
"nlist"="1024"
);

SHOW DATA ALL FROM sift_1M;

mysql> SHOW DATA ALL FROM sift_1M;
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
| TableName | IndexName | ReplicaCount | RowCount | LocalTotalSize | LocalDataSize | LocalIndexSize | RemoteTotalSize | RemoteDataSize | RemoteIndexSize |
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
| sift_1M | sift_1M | 10 | 1000000 | 170.093 MB | 170.093 MB | 0.000 | 0.000 | 0.000 | 0.000 |
| | Total | 10 | | 170.093 MB | 170.093 MB | 0.000 | 0.000 | 0.000 | 0.000 |
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+

その後、BUILD INDEX文を使用してインデックスを構築できます:

BUILD INDEX idx_test_ann ON sift_1M;

BUILD INDEXは非同期で実行されます。SHOW BUILD INDEX(一部のバージョンではSHOW ALTER)を使用してジョブのステータスを確認できます。

SHOW BUILD INDEX WHERE TableName = "sift_1M";

mysql> SHOW BUILD INDEX WHERE TableName = "sift_1M";
+---------------+-----------+---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------+-------------------------+---------------+----------+------+----------+
| JobId | TableName | PartitionName | AlterInvertedIndexes | CreateTime | FinishTime | TransactionId | State | Msg | Progress |
+---------------+-----------+---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------+-------------------------+---------------+----------+------+----------+
| 1764392359610 | sift_1M | sift_1M | [ADD INDEX idx_test_ann (`embedding`) USING ANN PROPERTIES("dim" = "128", "index_type" = "ivf", "metric_type" = "l2_distance", "nlist" = "1024")], | 2025-12-01 14:18:22.360 | 2025-12-01 14:18:27.885 | 5036 | FINISHED | | NULL |
+---------------+-----------+---------------+-----------------------------------------------------------------------------------------------------------------------------------------------------+-------------------------+-------------------------+---------------+----------+------+----------+
1 row in set (0.00 sec)

mysql> SHOW DATA ALL FROM sift_1M;
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
| TableName | IndexName | ReplicaCount | RowCount | LocalTotalSize | LocalDataSize | LocalIndexSize | RemoteTotalSize | RemoteDataSize | RemoteIndexSize |
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
| sift_1M | sift_1M | 10 | 1000000 | 671.084 MB | 170.093 MB | 500.991 MB | 0.000 | 0.000 | 0.000 |
| | Total | 10 | | 671.084 MB | 170.093 MB | 500.991 MB | 0.000 | 0.000 | 0.000 |
+-----------+-----------+--------------+----------+----------------+---------------+----------------+-----------------+----------------+-----------------+
2 rows in set (0.00 sec)

DROP INDEX

不適切なANNインデックスは ALTER TABLE sift_1M DROP INDEX idx_test_ann でドロップできます。インデックスのドロップと再作成は、ハイパーパラメータチューニング中によく行われ、desired recallを達成するために異なるパラメータの組み合わせをテストする必要がある場合に使用されます。

Querying

ANNインデックスはTop‑N searchとrange searchの両方をサポートします。

ベクトル列が高次元の場合、クエリベクトル自体のリテラル表現により、追加の解析オーバーヘッドが発生する可能性があります。したがって、完全なクエリベクトルを生のSQLに直接埋め込むことは、特に高い同時実行性の下では本番環境では推奨されません。より良い方法は、繰り返しのSQL解析を回避するprepared statementsを使用することです。

Dorisにおけるベクトル検索に必要な操作をprepared statementsベースでラップし、Dorisクエリ結果をPandas DataFrameにマッピングするデータ変換ユーティリティを含むdoris-vector-search pythonライブラリの使用を推奨します。これにより、downstream AIアプリケーション開発が便利になります。

from doris_vector_search import DorisVectorClient, AuthOptions

auth = AuthOptions(
host="127.0.0.1",
query_port=9030,
user="root",
password="",
)

client = DorisVectorClient(database="test", auth_options=auth)

tbl = client.open_table("sift_1M")

query = [0.1] * 128 # Example 128-dimensional vector

# SELECT id FROM sift_1M ORDER BY l2_distance_approximate(embedding, query) LIMIT 10;
result = tbl.search(query, metric_type="l2_distance").limit(10).select(["id"]).to_pandas()

print(result)

サンプル出力:

       id
0 123911
1 926855
2 123739
3 73311
4 124493
5 153178
6 126138
7 123740
8 125741
9 124048

Recall最適化

ベクトル検索において、recallは最も重要な指標です。パフォーマンス数値は、与えられたrecallレベルの下でのみ意味を持ちます。recallに影響する主な要因は以下の通りです:

  1. IVFのインデックス時パラメータ(nlist)とクエリ時パラメータ(nprobe)。
  2. ベクトル量子化。
  3. セグメントサイズとセグメント数。

この記事では、(1)と(3)がrecallに与える影響に焦点を当てます。ベクトル量子化については別の文書で扱われます。

インデックスハイパーパラメータ

IVFインデックスは、ベクトルを複数のクラスタに整理します。インデックス構築時に、ベクトルはクラスタリングを使用してグループに分割されます。検索プロセスは、最も関連性の高いクラスタのみに焦点を当てます。ワークフローは大まかに以下の通りです:

インデックス時:

  1. クラスタリング: すべてのベクトルは、クラスタリングアルゴリズム(例:k-means)を使用してnlist個のクラスタに分割されます。各クラスタの重心が計算され、保存されます。
  2. ベクトル割り当て: 各ベクトルは、重心が最も近いクラスタに割り当てられ、そのクラスタの転置リストに追加されます。

クエリ時:

  1. nprobeを使用したクラスタ選択: クエリベクトルに対して、すべてのnlist個の重心への距離が計算されます。検索のために、最も近いnprobe個のクラスタのみが選択されます。
  2. 選択されたクラスタ内での全数検索: クエリは、選択されたnprobe個のクラスタ内のすべてのベクトルと比較され、最近傍を見つけます。

要約すると:

nlistはクラスタ数(転置リスト数)を定義します。これはrecall、メモリオーバーヘッド、構築時間に影響します。より大きなnlistはより細かい粒度のクラスタを作成し、クエリの最近傍がよく局在化されている場合は検索速度を向上させることができますが、クラスタリングのコストと近傍が複数のクラスタに分散するリスクも増加させます。

nprobeはクエリ中に検索するクラスタ数を定義します。より大きなnprobeはrecallとクエリレイテンシを増加させます(より多くのベクトルが調べられます)。より小さなnprobeはクエリを高速化しますが、検索されないクラスタに存在する近傍を見逃す可能性があります。

デフォルトで、Dorisはnlist = 1024nprobe = 64を使用します。

上記はこれら2つのハイパーパラメータの定性的分析です。以下の表は、SIFT_1Mデータセットでの実証結果を示しています:

nlistnproberecall_at_100
1024640.9542
1024320.9034
1024160.8299
102480.7337
512320.9384
512160.8763
51280.7869

事前に単一の最適設定を提供することは困難ですが、ハイパーパラメータ選択のための実用的なワークフローに従うことができます:

  1. インデックスなしでテーブルtable_multi_indexを作成します。2つまたは3つのベクトルカラムを含むことができます。
  2. Stream Loadや他の取り込み方法を使用してtable_multi_indexにデータを読み込みます。
  3. CREATE INDEXBUILD INDEXを使用して、すべてのベクトルカラムにANNインデックスを構築します。
  4. 異なるカラムで異なるインデックスパラメータ設定を使用します。インデックス構築が完了した後、各カラムでrecallを計算し、最適なパラメータの組み合わせを選択します。

例えば:

ALTER TABLE tbl DROP INDEX idx_embedding;
CREATE INDEX idx_embedding ON tbl (`embedding`) USING ANN PROPERTIES (
"index_type"="ivf",
"metric_type"="inner_product",
"dim"="768",
"nlist"="1024"
);
BUILD INDEX idx_embedding ON tbl;

インデックスごとにカバーされる行数

内部的に、Dorisはデータを複数の層で組織化しています。

  • 最上位はテーブルで、これは分散キーを使ってN個のタブレットにパーティション分割されます。タブレットは、データのシャーディング、再配置、リバランスの単位として機能します。
  • 各データ取り込みやコンパクションは、タブレット下に新しいrowsetを生成します。rowsetは、データのバージョン管理されたコレクションです。
  • rowset内のデータは、実際にはsegmentファイルに格納されます。

転置インデックスと同様に、ベクターインデックスはsegmentレベルで構築されます。segmentサイズは、write_buffer_sizevertical_compaction_max_segment_sizeなどのBE設定オプションによって決定されます。取り込みとコンパクション中、インメモリのmemtableが一定のサイズに達すると、segmentファイルとしてディスクにフラッシュされ、そのsegmentに対してベクターインデックス(または複数のベクター列に対する複数のインデックス)が構築されます。インデックスは、そのsegment内の行のみをカバーします。

IVFパラメータの固定セットが与えられた場合、インデックスが高いrecallを維持できるベクター数には常に制限があります。segment内のベクター数がその制限を超えると、recallは低下し始めます。

SHOW TABLETS FROM tableを使用してテーブルのコンパクション状態を調べることができます。対応するURLをたどることで、segment数を確認できます。

Recallに対するコンパクションの影響

コンパクションは、より大きなsegmentを作成する可能性があり、これが元のハイパーパラメータによって暗示される「カバレッジ容量」を超える可能性があるため、recallに影響を与える可能性があります。結果として、コンパクション前に達成されたrecallレベルは、コンパクション後には保たれない場合があります。

BUILD INDEXを実行する前に、フルコンパクションをトリガーすることを推奨します。完全にコンパクションされたsegment上でインデックスを構築することで、recallが安定し、インデックスの再構築によって引き起こされる書き込み増幅も削減されます。

クエリパフォーマンス

インデックスファイルのコールドローディング

DorisのIVF ANNインデックスは、MetaのオープンソースライブラリFaissを使用して実装されています。IVFインデックスは、メモリにロードされた後に有効になります。そのため、高並行性ワークロードを実行する前に、関連するすべてのsegmentインデックスがメモリにロードされることを確認するためのウォームアップクエリを実行することを推奨します。そうしないと、ディスクI/Oオーバーヘッドがクエリパフォーマンスを大幅に悪化させる可能性があります。

メモリフットプリント vs. パフォーマンス

量子化や圧縮を行わない場合、IVFインデックスのメモリフットプリントは、インデックス対象のすべてのベクターのメモリフットプリントのおよそ1.02~1.1倍です。

例えば、100万個の128次元ベクターの場合、IVF-FLATインデックスは次のメモリを必要とします:

128 * 4 * 1,000,000 * 1.02 ≈ 500 MB

いくつかの参考値:

dimrowsestimated memory
1281M496 MB
7681M2.9 GB

安定したパフォーマンスを維持するため、各BEに十分なメモリがあることを確認してください。そうしないと、頻繁なスワッピングとインデックスファイルでのI/Oによりクエリレイテンシが大幅に悪化します。

ベンチマーク

ベンチマーク時には、デプロイメントモデルは本番環境の設定に従い、FEとBEを別々にデプロイし、クライアントは別の独立したマシンで実行する必要があります。

ベンチマークフレームワークとしてVectorDBBenchを使用できます。

Performance768D1M

ベンチマークコマンド:

# load
NUM_PER_BATCH=1000000 python3 -m vectordbbench doris --host 127.0.0.1 --port 9030 --case-type Performance768D1M --db-name Performance768D1M --stream-load-rows-per-batch 500000 --index-prop index_type=ivf,nlist=1024 --skip-search-serial --skip-search-concurrent

# search
NUM_PER_BATCH=1000000 python3 -m vectordbbench doris --host 127.0.0.1 --port 9030 --case-type Performance768D1M --db-name Performance768D1M --search-concurrent --search-serial --num-concurrency 10,40,80 --stream-load-rows-per-batch 500000 --index-prop index_type=ivf,nlist=1024 --session-var ivf_nprobe=64 --skip-load --skip-drop-old