テキスト索引を使用した全文検索
ClickHouse のテキスト索引("inverted indexes" としても知られています)は、文字列データに対して高速な全文検索機能を提供します。 この索引は、カラム内の各トークンを、そのトークンを含む行にマッピングします。 トークンはトークナイズと呼ばれる処理によって生成されます。 例えば、ClickHouse は英語の文 "All cat like mice." をデフォルトで ["All", "cat", "like", "mice"] のようにトークナイズします(末尾のドットは無視されることに注意してください)。 ログデータ向けなど、より高度なトークナイザーも利用できます。
テキスト索引の作成
テキスト索引を作成するには、まず対応する実験的な SETTING を有効化します。
テキスト索引は、次の構文を使用して、String、FixedString、Array(String)、Array(FixedString)、および Map(mapKeys および mapValues の map 関数経由)カラムに定義できます。
Tokenizer 引数(必須)。tokenizer 引数は使用するトークナイザーを指定します:
splitByNonAlphaは、英数字以外の ASCII 文字で文字列を分割します(関数 splitByNonAlpha も参照)。splitByString(S)は、ユーザー定義の区切り文字列Sごとに文字列を分割します(関数 splitByString も参照)。 区切り文字列はオプションのパラメータで指定できます。例えば、tokenizer = splitByString([', ', '; ', '\n', '\\'])のように指定します。 各区切り文字列は複数文字から構成されてもかまわず(例では', ')、そのまま 1 つの区切り文字列として扱われます。 区切り文字列のリストを明示的に指定しなかった場合(例えばtokenizer = splitByString)、デフォルトの区切り文字は単一の空白[' ']です。ngrams(N)は、文字列を固定長Nの N-gram に分割します(関数 ngrams も参照)。 N-gram の長さは 1 から 8 の整数のオプション引数で指定でき、例えばtokenizer = ngrams(3)のように指定します。 N-gram サイズを明示的に指定しなかった場合(例えばtokenizer = ngrams)、デフォルト値は 3 です。sparseGrams(min_length, max_length, min_cutoff_length)は、min_length文字以上max_length文字以下(両端を含む)の可変長 N-gram に文字列を分割します(関数 sparseGrams も参照)。 明示的に指定しない場合、min_lengthとmax_lengthのデフォルトはそれぞれ 3 と 100 です。 パラメータmin_cutoff_lengthを指定すると、長さがmin_cutoff_length以上の N-gram のみが返されます。ngrams(N)と比べて、sparseGramsトークナイザーは可変長 N-gram を生成するため、元テキストのより柔軟な表現が可能です。 例えば、tokenizer = sparseGrams(3, 5, 4)は内部的には入力文字列から 3-, 4-, 5-gram を生成しますが、返されるのは 4-gram と 5-gram のみです。arrayはトークナイズを行いません。すなわち、各行の値全体が 1 つのトークンになります(関数 array も参照)。
splitByString トークナイザーは、左から右へ順に区切り文字列を適用します。
これによりあいまいさが生じる場合があります。
例えば、区切り文字列を ['%21', '%'] とすると、%21abc は ['abc'] としてトークナイズされますが、区切り文字列の順序を ['%', '%21'] と入れ替えると、['21abc'] が出力されます。
多くの場合、マッチングではより長い区切り文字列を優先的にマッチさせたいはずです。
これは一般的に、区切り文字列を長い順(長さの降順)で渡すことで実現できます。
区切り文字列が prefix code を形成している場合には、任意の順序で渡しても問題ありません。
現時点では、中国語などの非西洋言語のテキストに対してテキスト索引を作成することは推奨されません。 現在サポートされているトークナイザでは、索引サイズが非常に大きくなり、クエリの実行時間が長くなる可能性があります。 今後、これらのケースをより適切に処理できる、言語ごとに特化したトークナイザを追加する予定です。
トークナイザが入力文字列をどのように分割するかをテストするには、ClickHouse の tokens 関数を使用できます。
例:
結果:
前処理引数(オプション)。引数 preprocessor は、トークン化の前に入力文字列に適用される式です。
preprocessor 引数の代表的なユースケースには次のようなものがあります。
- 大文字化/小文字化による大文字小文字を区別しないマッチングの実現。例: lower, lowerUTF8(以下の最初の例を参照)。
- UTF-8 正規化。例: normalizeUTF8NFC, normalizeUTF8NFD, normalizeUTF8NFKC, normalizeUTF8NFKD, toValidUTF8。
- 不要な文字や部分文字列の削除または変換。例: extractTextFromHTML, substring, idnaEncode。
preprocessor 式は、型 String または FixedString の入力値を、同じ型の値に変換しなければなりません。
例:
INDEX idx(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(col))INDEX idx(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = substringIndex(col, '\n', 1))INDEX idx(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(extractTextFromHTML(col))
また、preprocessor 式は、そのテキストインデックスが定義されているカラムのみを参照しなければなりません。
非決定的な関数を使用することはできません。
preprocessor は Array(String) および Array(FixedString) カラムでも使用できます。
この場合、preprocessor 式は配列要素を個別に変換します。
例:
hasToken、hasAllTokens、hasAnyTokens 関数は、検索語をトークン化する前に preprocessor を使って検索語を変換します。
例:
と同等です:
その他の引数 (オプション)。ClickHouse のテキスト索引はセカンダリ索引として実装されています。 ただし、他のスキップ索引と異なり、テキスト索引は実質的に無限の粒度を持ちます。つまり、テキスト索引はパーツ全体に対して作成され、明示的に指定された索引粒度は無視されます。 この既定値は経験的に選択されたもので、ほとんどのユースケースで速度と索引サイズの間の良好なトレードオフを提供します。 上級ユーザーは別の索引粒度を指定できますが、推奨はしません。
オプションの高度なパラメータ
以下の高度なパラメータのデフォルト値は、ほぼすべての状況で適切に機能します。 これらを変更することは推奨しません。
オプションのパラメータ dictionary_block_size (デフォルト: 512) は、Dictionary ブロックのサイズを行数で指定します。
オプションのパラメータ dictionary_block_frontcoding_compression (デフォルト: 1) は、Dictionary ブロックで圧縮方式として front coding を使用するかどうかを指定します。
オプションのパラメータ posting_list_block_size (デフォルト: 1048576) は、posting list ブロックのサイズを行数で指定します。
オプションのパラメータ posting_list_codec (デフォルト: none) は、posting list のコーデックを指定します:
none- posting list を追加の圧縮なしで保存します。bitpacking- 差分 (デルタ) 符号化 を適用し、その後に bit-packing を適用します (いずれも固定サイズのブロックごと)。
テキスト索引は、テーブル作成後にカラムへ追加したり、カラムから削除したりできます。
テキスト索引の使用
SELECT クエリでテキスト索引を利用するのは容易で、一般的な文字列検索関数は自動的にその索引を活用します。 索引が存在しない場合、以下の文字列検索関数は低速な総当たりスキャンを行います。
サポートされている関数
WHERE 句または PREWHERE 句でテキスト関数が使用されている場合、テキストインデックスを使用できます。
= と !=
= (equals) と != (notEquals) は、指定された検索語句全体と一致します。
例:
テキスト索引は = と != をサポートしますが、array tokenizer を使う場合にのみ、等号/不等号による検索が意味を持ちます(その場合、索引には行全体の値が格納されるためです)。
IN と NOT IN
IN (in) と NOT IN (notIn) は equals および notEquals 関数と似ていますが、IN はいずれかの検索語に一致するもの、NOT IN はいずれの検索語にも一致しないものにマッチします。
例:
= および != と同じ制限が適用されます。つまり、IN と NOT IN は array トークナイザーと併用する場合にのみ有効です。
LIKE, NOT LIKE および match
これらの関数がテキストインデックスをフィルタリングに利用するのは、インデックスの tokenizer が splitByNonAlpha、ngrams、または sparseGrams のいずれかである場合に限られます。
テキストインデックスで LIKE (like)、NOT LIKE (notLike)、および match 関数を使用するには、ClickHouse が検索語から完全なトークンを抽出できる必要があります。
ngrams tokenizer を持つインデックスでは、ワイルドカードの間にある検索文字列の長さが ngram の長さ以上であれば、この条件を満たします。
splitByNonAlpha tokenizer を持つテキストインデックスの例:
この例の support は、support、supports、supporting などにマッチし得ます。
この種のクエリは部分文字列クエリであり、テキスト索引によって高速化することはできません。
LIKE クエリでテキスト索引を活用するには、LIKE パターンを次のように書き換える必要があります。
support の左右に空白を入れておくことで、その語をトークンとして抽出できるようにします。
startsWith と endsWith
LIKE と同様に、関数 startsWith と endsWith は、検索語から完全なトークンを抽出できる場合にのみテキストインデックスを使用できます。
ngrams トークナイザーを使用するインデックスでは、ワイルドカードの間にある検索文字列の長さが ngram の長さ以上である場合に、この条件を満たします。
splitByNonAlpha トークナイザーを用いたテキストインデックスの例:
この例では、clickhouse だけがトークンとして扱われます。
support は support、supports、supporting などにマッチする可能性があるため、トークンとは見なされません。
clickhouse supports で始まるすべての行を検索するには、検索パターンの末尾にスペースを付けてください。
同様に、endsWith は先頭にスペース(空白)を付けて使用します。
hasToken および hasTokenOrNull
関数 hasToken と hasTokenOrNull は、指定された 1 つのトークンとの照合を行います。
前述の関数とは異なり、検索語句をトークン化しません(入力が 1 つのトークンであることを前提とします)。
例:
関数 hasToken と hasTokenOrNull は、text 索引と組み合わせて使用できる最も高性能な関数です。
hasAnyTokens と hasAllTokens
関数 hasAnyTokens と hasAllTokens は、指定されたトークンのいずれか、またはすべてにマッチします。
これら 2 つの関数では、検索トークンは、索引用カラムに対して使用されているものと同じトークナイザでトークナイズされる文字列として指定するか、あるいは検索前にトークナイズを行う必要のない、すでに処理済みのトークン配列として指定できます。 詳細は各関数のドキュメントを参照してください。
例:
has
配列関数 has は、文字列の配列に含まれる単一のトークンに対してマッチします。
例:
mapContains
mapContains 関数(mapContainsKey のエイリアス)は、検索対象の文字列から抽出されたトークンを map のキーと照合します。
動作は、String カラムに対する equals 関数と同様です。
テキストインデックスが使用されるのは、mapKeys(map) 式に対して作成されている場合のみです。
例:
mapContainsValue
mapContainsValue 関数は、検索対象文字列から抽出されたトークンがマップの値に含まれるかどうかを照合します。
挙動は、String カラムに対する equals 関数と類似しています。
テキストインデックスは、mapValues(map) 式に対して作成されている場合にのみ使用されます。
例:
mapContainsKeyLike と mapContainsValueLike
関数 mapContainsKeyLike と mapContainsValueLike は、マップのすべてのキー(または値)を対象に、パターンとの照合を行います。
例:
operator[]
アクセス用の operator[] は、テキスト索引と組み合わせて使用することで、キーや値をフィルタリングできます。テキスト索引が使用されるのは、mapKeys(map) または mapValues(map)、あるいはその両方の式に対して作成されている場合のみです。
例:
テキスト索引と併用する Array(T) 型および Map(K, V) 型カラムの例を以下に示します。
Array および Map カラムに対するテキストインデックスの例
Array(String) カラムのインデックス化
ブログプラットフォームを想像してください。そこでは、著者がブログ記事にキーワードを付けてカテゴリ分けしています。 ユーザーには、トピックを検索したりクリックしたりすることで、関連コンテンツを見つけてほしいと考えています。
次のテーブル定義を考えてみてください:
テキスト索引がない場合、特定のキーワード(例:clickhouse)を含む投稿を検索するには、すべてのエントリを走査する必要があります。
プラットフォームが成長するにつれて、クエリはすべての行の keywords 配列を走査しなければならないため、処理がますます遅くなっていきます。
このパフォーマンス上の問題を解消するために、カラム keywords に対してテキスト索引を定義します。
Map カラムのインデックス作成
多くのオブザーバビリティのユースケースでは、ログメッセージは「コンポーネント」に分割され、タイムスタンプには日時型、ログレベルには enum 型など、適切なデータ型として保存されます。 メトリクスフィールドは、キーと値のペアとして保存するのが最適です。 運用チームは、デバッグ、セキュリティインシデント、監視のために、ログを効率的に検索する必要があります。
次のような logs テーブルを考えてみます:
テキストインデックスがない場合、Map データの検索にはテーブル全体のフルスキャンが必要になります。
ログ量が増えるにつれて、これらのクエリは遅くなります。
解決策は、Map のキーおよび値に対してテキスト索引を作成することです。 フィールド名や属性タイプでログを検索したい場合は、mapKeys を使用してテキスト索引を作成します。
属性の実際の内容を検索する必要がある場合は、mapValues を使用してテキスト索引を作成します。
クエリ例:
パフォーマンスチューニング
ダイレクトリード
特定の種類のテキストクエリは、「ダイレクトリード」と呼ばれる最適化によって大幅に高速化されます。
例:
ClickHouse における direct read 最適化は、基盤となるテキストカラムにアクセスせず、テキスト索引(つまりテキスト索引ルックアップ)だけを用いてクエリに応答します。 テキスト索引ルックアップは比較的少量のデータしか読み込まないため、通常の ClickHouse のスキップ索引(スキップ索引のルックアップの後に、残りのグラニュールの読み込みとフィルタリングを行う)よりもはるかに高速です。
Direct read は次の 2 つの設定で制御されます。
- Setting query_plan_direct_read_from_text_index(デフォルトで true)。direct read を全般的に有効にするかどうかを指定します。
- Setting use_skip_indexes_on_data_read。direct read のもう一つの前提条件です。ClickHouse バージョン >= 26.1 では、この設定はデフォルトで有効です。以前のバージョンでは、明示的に
SET use_skip_indexes_on_data_read = 1を実行する必要があります。
また、direct read を使用するには、テキスト索引が完全にマテリアライズされている必要があります(そのためには ALTER TABLE ... MATERIALIZE INDEX を使用します)。
サポートされる関数
Direct read 最適化は、関数 hasToken、hasAllTokens、および hasAnyTokens をサポートします。
テキスト索引が array tokenizer で定義されている場合、関数 equals、has、mapContainsKey、mapContainsValue に対しても direct read がサポートされます。
これらの関数は、AND、OR、NOT 演算子で組み合わせることもできます。
WHERE または PREWHERE 句には、(テキストカラムまたは他のカラムに対する)追加の非テキスト検索関数によるフィルタを含めることもできます。この場合でも direct read 最適化は使用されますが、その効果は低くなります(サポートされているテキスト検索関数にのみ適用されるためです)。
あるクエリが direct read を利用しているかを確認するには、そのクエリを EXPLAIN PLAN actions = 1 付きで実行します。
例として、direct read を無効にしたクエリは
戻り値
一方、同じクエリを query_plan_direct_read_from_text_index = 1 の設定で実行すると
戻り値
2つ目の EXPLAIN PLAN の出力には、仮想カラム __text_index_<index_name>_<function_name>_<id> が含まれます。
このカラムが存在する場合は、direct read が使用されています。
WHERE 句のフィルタがテキスト検索関数のみで構成されている場合、クエリはカラムデータの読み取り自体を完全に回避でき、direct read によって最大のパフォーマンス向上が得られます。 ただし、クエリ内の他の箇所でテキストカラムにアクセスしている場合でも、direct read により依然としてパフォーマンス改善が見込めます。
ヒントとしての direct read
ヒントとしての direct read は、通常の direct read と同じ原理に基づきますが、基礎となるテキストカラムは削除せずに、テキストインデックスのデータから構築された追加フィルタを適用する点が異なります。 これは、テキストインデックスのみを読んだ場合に誤検出(false positive)が発生しうる関数に対して使用されます。
サポートされている関数は、like、startsWith、endsWith、equals、has、mapContainsKey、mapContainsValue です。
この追加フィルタにより、他のフィルタと組み合わせて結果セットをさらに絞り込むための追加の選択性が得られ、他のカラムから読み取るデータ量を削減するのに役立ちます。
ヒントとしての direct read は、query_plan_text_index_add_hint(デフォルトで有効)を設定することで制御できます。
ヒントを使用しないクエリの例:
戻り値
一方、同じクエリを query_plan_text_index_add_hint = 1 を有効にして実行した場合は
戻り値
2つ目の EXPLAIN PLAN の出力では、フィルター条件に追加の連言条件(__text_index_...)が加えられていることが分かります。
PREWHERE 最適化により、フィルター条件は3つの個別の連言条件に分解され、計算コストが低いものから順に適用されます。
このクエリでは、適用順序は __text_index_...、次に greaterOrEquals(...)、最後に like(...) となります。
この順序付けにより、テキスト索引と元のフィルター条件でスキップされるグラニュールに加えて、クエリの WHERE 句以降で使用される重いカラムを読み込む前に、さらに多くのデータグラニュールをスキップできるため、読み取るデータ量を一層削減できます。
キャッシュ
メモリ内でテキストインデックスの一部をバッファリングするために、さまざまなキャッシュを使用できます(Implementation Details セクションを参照)。 現在、I/O を削減するために、デシリアライズ済みの Dictionary ブロック、テキストインデックスのヘッダー、およびポスティングリスト用のキャッシュが用意されています。 これらは、設定 use_text_index_dictionary_cache、use_text_index_header_cache、および use_text_index_postings_cache によって有効化できます。 デフォルトでは、すべてのキャッシュは無効になっています。 キャッシュを破棄するには、文 SYSTEM DROP TEXT INDEX CACHES を使用します。
キャッシュを構成するには、以下のサーバー設定を参照してください。
Dictionary ブロックキャッシュの設定
| Setting | Description |
|---|---|
| text_index_dictionary_block_cache_policy | テキストインデックス用 Dictionary ブロックキャッシュのポリシー名。 |
| text_index_dictionary_block_cache_size | キャッシュの最大サイズ(バイト単位)。 |
| text_index_dictionary_block_cache_max_entries | キャッシュ内のデシリアライズ済み Dictionary ブロックの最大数。 |
| text_index_dictionary_block_cache_size_ratio | テキストインデックス用 Dictionary ブロックキャッシュにおける、キャッシュ全体サイズに対する保護キューサイズの比率。 |
ヘッダーキャッシュの設定
| Setting | 説明 |
|---|---|
| text_index_header_cache_policy | テキストインデックスヘッダーキャッシュのポリシー名。 |
| text_index_header_cache_size | キャッシュの最大サイズ(バイト単位)。 |
| text_index_header_cache_max_entries | キャッシュ内に保持されるデシリアライズ済みヘッダーの最大数。 |
| text_index_header_cache_size_ratio | テキストインデックスヘッダーキャッシュにおける、保護キューのサイズがキャッシュ全体のサイズに対して占める割合。 |
ポスティングリストキャッシュの設定
| Setting | 説明 |
|---|---|
| text_index_postings_cache_policy | テキストインデックスのポスティングリストキャッシュポリシー名。 |
| text_index_postings_cache_size | キャッシュの最大サイズ(バイト単位)。 |
| text_index_postings_cache_max_entries | キャッシュ内のデシリアライズ済みポスティングの最大数。 |
| text_index_postings_cache_size_ratio | テキストインデックスのポスティングリストキャッシュにおける保護キューのサイズが、キャッシュ全体サイズに占める割合。 |
制限事項
テキストインデックスには、現在次のような制限があります。
- トークン数が非常に多いテキストインデックス(例: 100億トークン)をマテリアライズすると、大量のメモリを消費する可能性があります。テキスト
インデックスのマテリアライズは、直接(
ALTER TABLE <table> MATERIALIZE INDEX <index>)行われる場合もあれば、パーツのマージ時に間接的に行われる場合もあります。 - 1つのパーツ内の行数が 4.294.967.296(= 2^32 ≒ 42億)を超える場合、そのパーツ上のテキストインデックスをマテリアライズすることはできません。テキストインデックスがマテリアライズされていない場合、クエリはそのパーツ内での低速な総当たり検索にフォールバックします。最悪の場合の見積もりとして、1つのパーツが String 型の単一カラムだけを持ち、MergeTree の設定項目
max_bytes_to_merge_at_max_space_in_pool(デフォルト: 150 GB)が変更されていないと仮定します。この場合、そのカラムの1行あたりの平均文字数が 29.5 文字未満であれば、上記の状況が発生します。実際には、テーブルは他のカラムも含んでいるため、閾値は(他のカラムの数、型、サイズに応じて)その何分の一にも小さくなります。
実装の詳細
各テキスト索引は、2 つの(抽象的な)データ構造から構成されます:
- 各トークンをポスティングリストにマッピングする dictionary と、
- 各々が行番号の集合を表す複数のポスティングリストの集合。
テキスト索引はパーツ全体に対して構築されます。 他のスキップ索引と異なり、テキスト索引はデータパーツをマージする際に再構築するのではなく、マージすることができます(下記参照)。
索引の作成時には、3 つのファイルが生成されます(パーツごと):
Dictionary blocks ファイル (.dct)
テキスト索引内のトークンはソートされ、各 512 トークンの dictionary ブロックに格納されます(ブロックサイズはパラメータ dictionary_block_size によって設定可能です)。
Dictionary blocks ファイル (.dct) は、そのパーツ内のすべての索引グラニュールに含まれる dictionary ブロックから構成されます。
Index header ファイル (.idx)
Index header ファイルには、各 dictionary ブロックについて、そのブロックの最初のトークンと dictionary blocks ファイル内での相対オフセットが含まれます。
このスパース索引構造は、ClickHouse の sparse primary key index と類似しています。
Postings lists ファイル (.pst)
すべてのトークンに対するポスティングリストは、postings lists ファイル内に連続して配置されます。
空間を節約しつつ高速な積集合および和集合演算を可能にするため、ポスティングリストは roaring bitmaps として格納されます。
ポスティングリストが posting_list_block_size より大きい場合、それは複数のブロックに分割され、postings lists ファイル内に連続して格納されます。
テキスト索引のマージ
データパーツがマージされるとき、テキスト索引を最初から再構築する必要はなく、代わりにマージ処理の別ステップで効率的にマージできます。
このステップでは、各入力パーツのテキスト索引のソート済み dictionary が読み込まれ、新しい統合 dictionary に結合されます。
ポスティングリスト内の行番号も、初期マージフェーズ中に作成される旧行番号から新行番号へのマッピングを用いて、マージ後のデータパーツ内での新しい位置を反映するよう再計算されます。
このテキスト索引のマージ方式は、_part_offset カラムを持つ projections がマージされる方法と似ています。
ソースパーツ内で索引がマテリアライズされていない場合、それは構築され、一時ファイルに書き出された後、他のパーツおよび他の一時索引ファイルの索引とともにマージされます。
例: Hackernews データセット
大量のテキストを含む大規模データセットに対するテキスト索引のパフォーマンス向上を確認します。 人気サイト Hacker News 上のコメント 2,870 万行を使用します。 以下はテキスト索引を定義していないテーブルです:
約 2,870 万行のデータは S3 上の Parquet ファイルに格納されています。これらを hackernews テーブルに挿入しましょう。
ALTER TABLE を使用して comment カラムにテキスト索引を追加し、その後マテリアライズします。
では、hasToken、hasAnyTokens、hasAllTokens 関数を使ってクエリを実行してみましょう。
次の例では、標準的な索引スキャンと直接読み取り最適化を比較した際の、劇的なパフォーマンス差を示します。
1. hasToken の使用
hasToken は、テキストに特定の 1 つのトークンが含まれているかどうかを確認します。
ここでは大文字小文字を区別してトークン 'ClickHouse' を検索します。
ダイレクトリード無効化(標準スキャン) デフォルトでは、ClickHouse はスキップ索引を使ってグラニュールをフィルタリングし、その後それらのグラニュールのカラムデータを読み取ります。 この挙動は、ダイレクトリードを無効にすることで再現できます。
ダイレクトリード有効時(Fast index read) ここでは、ダイレクトリードを有効にした状態(デフォルト)で、同じクエリを実行します。
ダイレクトリードクエリは 45 倍以上高速で (0.362 秒 対 0.008 秒)、インデックスだけを読み取ることで処理するデータ量も大幅に削減されます (9.51 GB 対 3.15 MB)。
2. hasAnyTokens を使用する
hasAnyTokens は、テキストに指定したトークンのうち少なくとも 1 つが含まれているかどうかを判定します。
ここでは、'love' または 'ClickHouse' のいずれかを含むコメントを検索します。
Direct read 無効(標準スキャン)
ダイレクトリードの有効化(索引の高速読み取り)
この一般的な「OR」検索では、高速化の度合いはさらに顕著です。 フルカラムスキャンを回避することで、クエリはほぼ89倍高速になります(1.329秒 vs 0.015秒)。
3. hasAllTokens の使用
hasAllTokens は、テキストが指定されたトークンをすべて含んでいるかどうかをチェックします。
ここでは、love と ClickHouse の両方を含むコメントを検索します。
ダイレクトリード無効(標準スキャン) ダイレクトリードを無効化していても、標準のスキップ索引は引き続き機能します。 28.7M 行をわずか 147.46K 行まで絞り込みますが、それでもカラムから 57.03 MB を読み取る必要があります。
ダイレクトリード有効(高速な索引読み取り) ダイレクトリードでは索引データ上で処理を行うことでクエリに応答し、147.46 KB 分のデータしか読み取りません。
この「AND」条件の検索では、ダイレクトリード最適化のほうが標準的なスキップ索引スキャンよりも 26 倍以上高速(0.184s 対 0.007s)です。
4. 複合検索: OR、AND、NOT、...
ダイレクトリードの最適化は、複合ブール式にも適用されます。 ここでは、「ClickHouse」または「clickhouse」を対象に、大文字と小文字を区別しない検索を行います。
ダイレクトリード無効(標準スキャン)
ダイレクトリードを有効化(索引の高速読み取り)
索引からの結果を組み合わせることで、直接読み出しクエリは 34 倍高速(0.450s 対 0.013s)になり、9.58 GB のカラムデータを読み取る必要がなくなります。
このケースでは、hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) が、より効率的で推奨される構文となります。