パフォーマンスの背後にある最適化
Apache Dorisの初期バージョンはオンライン分析処理(OLAP)に焦点を当て、主にレポートと集約ワークロード(典型的なクエリは複数テーブルのJOINとGROUP BY)を対象としていました。2.xでは、Dorisは転置インデックスによるテキスト検索を追加し、効率的なJSON処理のためのVariant型を導入しました。3.xでは、ストレージとコンピューティングの分離により、オブジェクトストレージを活用してストレージコストを大幅に削減できるようになりました。4.xでは、Dorisはベクトルインデックスとハイブリッド検索(ベクトル + テキスト)を導入してAI時代に入り、DorisをエンタープライズAI分析プラットフォームとして位置づけています。この文書では、Dorisが4.xでベクトルインデックスをどのように実装するか、そして最先端のパフォーマンスを達成するために行われたエンジニアリングの取り組みについて説明します。
ベクトルインデックスを2つのステージに分けます:インデックス化とクエリです。インデックス化ステージは1)データシャーディング、2)高品質インデックスの効率的な構築、3)インデックス管理に焦点を当てます。クエリステージには単一の目標があります:クエリパフォーマンスの向上—冗長な計算と不要なIOを排除し、並行性を最適化することです。
インデックス化ステージ
インデックス化パフォーマンスはインデックスハイパーパラメータと強く結びついています:より高いインデックス品質は通常、より長い構築時間を意味します。取り込みパスでの最適化により、Dorisは取り込みスループットを向上させながら高いインデックス品質を維持できます。
768D 10Mデータセットにおいて、Apache Dorisは業界トップレベルの取り込みパフォーマンスを実現しています。

多段階シャーディング
Apache Dorisの内部テーブルは本質的に分散されています。クエリと取り込みの際、ユーザーは単一の論理テーブルと対話し、Dorisカーネルがテーブル定義に基づいて必要な数の物理タブレットを作成します。取り込み時、データはパーティションとバケットキーによって適切なBEタブレットにルーティングされます。複数のタブレットが一緒になって、ユーザーに見える論理テーブルを形成します。各取り込みリクエストはトランザクションを形成し、対応するタブレット上にrowset(バージョニングユニット)を作成します。各rowsetは複数のセグメントを含み、セグメントが実際のデータキャリアです;ANNインデックスはセグメント粒度で動作します。

ベクトルインデックス(例:HNSW)は、インデックス品質とクエリパフォーマンスを直接決定する重要なハイパーパラメータに依存し、通常は特定のデータスケールに対してチューニングされます。Apache Dorisの多段階シャーディングは「インデックスパラメータ」を「全テーブルデータスケール」から分離します:ユーザーは全データが増加してもインデックスを再構築する必要がなく、バッチ取り込みサイズごとにパラメータをチューニングするだけです。テストから、異なるバッチサイズでのHNSW推奨パラメータは以下の通りです:
| batch_size | max_degree | ef_construction | ef_search | recall@100 |
|---|---|---|---|---|
| 250000 | 100 | 200 | 50 | 89% |
| 250000 | 100 | 200 | 100 | 93% |
| 250000 | 100 | 200 | 150 | 95% |
| 250000 | 100 | 200 | 200 | 98% |
| 500000 | 120 | 240 | 50 | 91% |
| 500000 | 120 | 240 | 100 | 94% |
| 500000 | 120 | 240 | 150 | 96% |
| 500000 | 120 | 240 | 200 | 99% |
| 1000000 | 150 | 300 | 50 | 90% |
| 1000000 | 150 | 300 | 100 | 93% |
| 1000000 | 150 | 300 | 150 | 96% |
| 1000000 | 150 | 300 | 200 | 98% |
つまり、「バッチ取り込みサイズごと」に焦点を当て、適切なインデックスパラメータを選択して品質と安定したクエリ動作を維持します。
高性能インデックス構築
並列高品質インデックス構築
Dorisは2段階の並列処理でインデックス構築を加速します:BEノード間のクラスタレベル並列処理と、グループ化されたバッチデータでのノード内マルチスレッド距離計算です。速度に加えて、Dorisはインメモリバッチング経由でインデックス品質を向上させます:総ベクトル数が固定されているが、バッチングが細かすぎる場合(頻繁な増分構築)、グラフ構造が疎になりrecallが低下します。例えば、768D10Mで10バッチで構築すると99%のrecallに到達する可能性がありますが、100バッチでは95%に低下する可能性があります。インメモリバッチングは、同じハイパーパラメータの下でメモリ使用量とグラフ品質のバランスを取り、過度なバッチングによる品質低下を回避します。
SIMD
ANNインデックス構築でのコアコストは大規模距離計算—CPU集約型ワークロードです。Dorisはこの作業をBEノードに集中させ、C++で実装し、Faissの自動および手動ベクトル化最適化を活用します。L2距離について、Faissはコンパイラプラグマを使用して自動ベクトル化をトリガーします:
FAISS_PRAGMA_IMPRECISE_FUNCTION_BEGIN
float fvec_L2sqr(const float* x, const float* y, size_t d) {
size_t i; float res = 0;
FAISS_PRAGMA_IMPRECISE_LOOP
for (i = 0; i < d; i++) {
const float tmp = x[i] - y[i];
res += tmp * tmp;
}
return res;
}
FAISS_PRAGMA_IMPRECISE_FUNCTION_END
FAISS_PRAGMA_IMPRECISE_*を使用すると、コンパイラは自動ベクトル化を行います:
#define FAISS_PRAGMA_IMPRECISE_LOOP \
_Pragma("clang loop vectorize(enable) interleave(enable)")
Faissは、ElementOpL2/ElementOpIPと次元特化のfvec_op_ny_D{1,2,4,8,12}と組み合わせて、#ifdef SSE3/AVX2/AVX512Fブロックで_mm*/_mm256*/_mm512*を使用した明示的なSIMDを適用し、以下を実現します:
- 1回の反復で複数のサンプルを処理し(例:8/16)、レジスタレベルの転置を実行してメモリアクセスの局所性を改善
- FMA(例:
_mm512_fmadd_ps)を使用して乗算加算を融合し、命令数を削減 - 水平加算を実行してスカラーを効率的に生成
- 非整列サイズに対してマスクされた読み取りによりテール要素を処理 これらの最適化により、命令とメモリのコストが削減され、インデックス作成のスループットが大幅に向上します。
Querying Stage
検索はレイテンシーに敏感です。数千万のレコードで高い並行性を持つ場合、P99レイテンシーは通常500ms未満である必要があり、オプティマイザー、実行エンジン、インデックス実装のハードルを上げます。デフォルトのテストでは、DorisはメインストリームのベクトルDB専用データベースと同等の性能を達成しています。下のチャートは、Performance768D10MにおいてDorisを他のシステムと比較したものです。同業他社のデータはZillizのオープンソースVectorDBBenchから取得しています。

注意:チャートにはデフォルトの結果のサブセットが含まれています。OpenSearchとElastic Cloudは、インデックスファイル数を最適化することでクエリ性能を改善できます。
Prepare Statement
従来の経路では、DorisはすべてのSQLに対して完全な最適化(構文解析、意味解析、RBO、CBO)を実行します。一般的なOLAPには不可欠ですが、シンプルで高度に反復的な検索クエリにはオーバーヘッドが加わります。Doris 4.0では、Prepare Statementをポイントルックアップを超えて、ベクトル検索を含むすべてのSQLタイプに拡張しています:
- コンパイルと実行の分離
- Prepareは構文解析、意味解析、最適化を一度実行し、再利用可能なLogical Planを生成
- Executeは実行時にパラメータをバインドし、事前構築されたプランを実行、オプティマイザーを完全にスキップ
- プランキャッシュ
- SQLフィンガープリント(正規化されたSQL + スキーマバージョン)によって再利用を決定
- 同じ構造で異なるパラメータ値を持つものはキャッシュされたプランを再利用し、再最適化を回避
- スキーマバージョンチェック
- 実行時にスキーマバージョンを検証して正確性を保証
- 変更なし → 再利用、変更あり → 無効化して再Prepare
- オプティマイザーをスキップすることによる高速化
- ExecuteはもはやRBO/CBOを実行せず、オプティマイザー時間はほぼ削除
- テンプレートが多いベクトルクエリは、エンドツーエンドのレイテンシーが大幅に低下して恩恵を受ける
Index Only Scan
Dorisはベクトルインデックスを外部(プラガブル)インデックスとして実装しており、管理を簡素化し非同期ビルドをサポートしますが、冗長な計算とIOを回避するなどの性能上の課題が生じます。ANNインデックスは行IDに加えて距離を返すことができます。DorisはScanオペレーター内で「仮想カラム」を介して距離式をショートサーキットすることでこれを活用し、Ann Index Only Scanは距離関連の読み取りIOを完全に削除します。 ナイーブなフローでは、Scanが述語をインデックスにプッシュし、インデックスが行IDを返し、その後Scanがデータページを読み取り、式を計算してからN行を上流に返します。

Index Only Scanを適用すると、フローは次のようになります:

例えば、SELECT l2_distance_approximate(embedding, [...]) AS dist FROM tbl ORDER BY dist LIMIT 100;はデータファイルに触れることなく実行されます。
Ann TopN Searchを超えて、Range SearchとCompound Searchも同様の最適化を採用しています。Range Searchはより微妙です:インデックスがdistを返すかどうかはコンパレーターに依存します。以下は、Ann Index Only Scanに関連するクエリタイプとIndex Scanが適用されるかどうかをリストしています:
-- Sql1: Range + proj
-- Index returns dist; no need to recompute dist
-- Virtual column for CSE avoids dist recomputation in proj
-- IndexScan: True
select id, dist(embedding, [...]) from tbl where dist <= 10;
-- Sql2: Range + no-proj
-- Index returns dist; no need to recompute
-- IndexScan: True
select id from tbl where dist <= 10 order by id limit N;
-- Sql3: Range + proj + no-dist-from index
-- Index cannot return dist (only updates rowid map)
-- proj requires dist → embedding must be reread
-- IndexScan: False
select id, dist(embedding, [...]) from tbl where dist > 10;
-- Sql4: Range + proj + no-dist-from index
-- Index cannot return dist, but proj does not need dist → embedding not reread
-- IndexScan: True
select id from tbl where dist > 10;
-- Sql5: TopN
-- Index returns dist; virtual slot for CSE uploads dist to proj
-- embedding column not read
-- IndexScan: True
select id[, dist(embedding, [...])] from tbl order by dist(embedding, [...]) asc limit N;
-- Sql6: TopN + IndexFilter
-- 1) comment not read (inverted index already optimizes this)
-- 2) embedding not read (same reason as Sql5)
-- IndexScan: True
select id[, dist(embedding, [...])] from tbl where comment match_any 'olap' ORDER BY dist(embedding, [...]) LIMIT N;
-- Sql7: TopN + Range
-- IndexScan: True (combination of Sql1 and Sql5)
select id[, dist(embedding, [...])] from tbl where dist(embedding, [...]) > 10 order by dist(embedding, [...]) limit N;
-- Sql8: TopN + Range + IndexFilter
-- IndexScan: True (combination of Sql7 and Sql6)
select id[, dist(embedding, [...])] from tbl where comment match_any 'olap' and dist(embedding, [...]) > 10 ORDER BY dist(embedding, [...]) LIMIT N;
-- Sql9: TopN + Range + CommonFilter
-- Key points: 1) dist < 10 (not > 10); 2) common filter reads dist, not embedding
-- Index returns dist; virtual slot for CSE ensures all reads refer to the same column
-- In theory embedding need not materialize; in practice it still does due to residual predicates on the column
-- IndexScan: False
select id[, dist(embedding, [...])] from tbl where comment match_any 'olap' and dist(embedding, [...]) < 10 AND abs(dist(embedding) + 10) > 10 ORDER BY dist(embedding, [...]) LIMIT N;
-- Sql10: Variant of Sql9, dist < 10 → dist > 10
-- Index cannot return embedding; to compute abs(dist(embedding,...)) embedding must materialize
-- IndexScan: False
select id[, dist(embedding, [...])] from tbl where comment match_any 'olap' and dist(embedding, [...]) > 10 AND abs(dist(embedding) + 10) > 10 ORDER BY dist(embedding, [...]) LIMIT N;
-- Sql11: Variant of Sql9, abs(dist(...)+10) > 10 → array_size(embedding) > 10
-- array_size requires embedding materialization
-- IndexScan: False
select id[, dist(embedding, [...])] from tbl where comment match_any 'olap' and dist(embedding, [...]) < 10 AND array_size(embedding) > 10 ORDER BY dist(embedding, [...]) LIMIT N;
CSE用の仮想列
Index Only Scanは主にIO(embeddingのランダム読み取り)を排除します。冗長な計算をさらに除去するため、Dorisはインデックスから返されるdistを列として式エンジンに渡す仮想列を導入しています。
設計の特徴:
- 式ノード
VirtualSlotRef - 列イテレータ
VirtualColumnIterator
VirtualSlotRefは計算時に生成される列です:1つの式によって具体化され、多くの場所で再利用可能で、初回使用時に一度だけ計算されることで、ProjectionとpredicatesにわたるCSEを排除します。VirtualColumnIteratorはインデックスから返される距離を式に具体化し、距離計算の繰り返しを回避します。当初はANNクエリのCSE排除のために構築されましたが、このメカニズムはProjection + Scan + Filterに一般化されました。
ClickBenchデータセットを使用して、以下のクエリはGoogleクリック数による上位20のWebサイトをカウントします:
set experimental_enable_virtual_slot_for_cse=true;
SELECT counterid,
COUNT(*) AS hit_count,
COUNT(DISTINCT userid) AS unique_users
FROM hits
WHERE ( UPPER(regexp_extract(referer, '^https?://([^/]+)', 1)) = 'GOOGLE.COM'
OR UPPER(regexp_extract(referer, '^https?://([^/]+)', 1)) = 'GOOGLE.RU'
OR UPPER(regexp_extract(referer, '^https?://([^/]+)', 1)) LIKE '%GOOGLE%' )
AND ( LENGTH(regexp_extract(referer, '^https?://([^/]+)', 1)) > 3
OR regexp_extract(referer, '^https?://([^/]+)', 1) != ''
OR regexp_extract(referer, '^https?://([^/]+)', 1) IS NOT NULL )
AND eventdate = '2013-07-15'
GROUP BY counterid
HAVING hit_count > 100
ORDER BY hit_count DESC
LIMIT 20;
コアな式regexp_extract(referer, '^https?://([^/]+)', 1)はCPU集約的で、述語間で再利用されます。仮想カラムが有効化されている場合(set experimental_enable_virtual_slot_for_cse=true;):
- 有効: 0.57秒
- 無効: 1.50秒
エンドツーエンドのパフォーマンスは約3倍向上します。
スキャン並列性最適化
DorisはAnn TopN検索のスキャン並列性を見直しました。元のポリシーでは行数によって並列性を設定していました(デフォルト:スキャンタスクあたり2,097,152行)。セグメントはサイズベースであるため、高次元ベクターカラムではセグメントあたりの行数が大幅に少なくなり、複数のセグメントが1つのスキャンタスク内で逐次的にスキャンされることになっていました。Dorisは「セグメントあたり1つのスキャンタスク」に切り替え、インデックススキャンでの並列性を向上させました。Ann TopNは高いフィルター率を持つため(N行のみ返される)、back-to-tableフェーズはパフォーマンスを損なうことなくシングルスレッドのままにできます。SIFT 1Mでは:
set optimize_index_scan_parallelism=true;
TopNシングルスレッドクエリーのレイテンシーは230msから50msに低下します。
さらに、4.0では動的並列性を導入:各スケジューリングラウンド前に、Dorisはスレッドプールの負荷に基づいて送信されるスキャンタスク数を調整します—高負荷時はタスクを減らし、アイドル時は増やします—これにより、逐次ワークロードと並行ワークロードの両方でリソース使用とスケジューリングオーバーヘッドのバランスを取ります。
グローバルTopN遅延マテリアライゼーション
典型的なAnn TopNクエリーは2段階で実行されます:
- スキャンがインデックスを通じてセグメントごとのTopN距離を取得;
- グローバルソートがセグメントごとのTopNをマージして最終TopNを生成。
プロジェクションが多くのカラムや大きな型(例:String)を返す場合、段階1で各セグメントからN行を読み込むと重いIOが発生する可能性があり、段階2のグローバルソート中に多くの行が破棄されます。Dorisはグローバル TopN遅延マテリアライゼーションを通じて段階1のIOを最小化します。
SELECT id, l2_distance_approximate(embedding, [...]) AS dist FROM tbl ORDER BY dist LIMIT 100;の場合:段階1はAnn Index Only Scan + 仮想カラムを通じてセグメントあたり100個のdist値とrowidのみを出力します。Mセグメントがある場合、段階2は100 * M個のdist値をグローバルソートして最終TopNとrowidを取得し、その後Materializeオペレーターが対応するtablet/rowset/segmentからrowidによって必要なカラムを取得します。