テキストインデックスを使用した全文検索
ClickHouse のテキストインデックス("inverted indexes" としても知られています)は、文字列データに対して高速な全文検索機能を提供します。 この索引は、カラム内の各トークンを、そのトークンを含む行に対応付けます。 トークンは、トークナイゼーション(トークン化)と呼ばれる処理によって生成されます。 たとえば、ClickHouse は英語の文 "All cat like mice." をデフォルトで ["All", "cat", "like", "mice"] のようにトークン化します(末尾のドットは無視される点に注意してください)。 ログデータ向けなど、さらに高度なトークナイザーも利用可能です。
テキスト索引の作成
テキスト索引を作成するには、まず対応する実験的なSETTINGを有効化します。
テキストインデックスは、次の構文を使用して String、FixedString、Array(String)、Array(FixedString)、および Map(map 関数 mapKeys および mapValues を介して)のカラムに定義できます。
トークナイザー引数(必須)。tokenizer 引数で使用するトークナイザーを指定します。
splitByNonAlphaは、英数字以外の ASCII 文字で文字列を分割します(関数 splitByNonAlpha も参照してください)。splitByString(S)は、ユーザー定義のセパレーター文字列Sに基づいて文字列を分割します(関数 splitByString も参照してください)。 セパレーターはオプションのパラメーターで指定できます。例えば、tokenizer = splitByString([', ', '; ', '\n', '\\'])のように指定します。 各文字列は複数文字から構成できます(例では', ')。 セパレーターが明示的に指定されていない場合(例えばtokenizer = splitByString)、デフォルトのセパレーターリストは 1 つの空白[' ']です。ngrams(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が指定されている場合、その長さ以上の 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 など。
プリプロセッサ式は、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))
また、プリプロセッサ式は、テキスト索引が定義されているカラムのみを参照する必要があります。 非決定的関数の使用は許可されていません。
関数 hasToken、hasAllTokens、および hasAnyTokens は、プリプロセッサを使用して検索語をトークン化する前に変換を行います。
例:
は以下と同等です:
その他の引数(オプション)。ClickHouseのテキスト索引はセカンダリ索引として実装されています。 ただし、他のスキップ索引とは異なり、テキスト索引のデフォルトのINDEX GRANULARITYは64です。 この値は経験的に選択されており、ほとんどのユースケースにおいて速度と索引サイズの適切なトレードオフを提供します。 上級ユーザーは異なる索引粒度を指定できますが、推奨しません。
オプションの高度なパラメータ
以下の高度なパラメータのデフォルト値は、ほぼすべての状況で適切に動作します。 これらを変更することは推奨しません。
オプションのパラメータ dictionary_block_size(デフォルト: 128)は、Dictionary ブロックのサイズ(行数)を指定します。
オプションのパラメータ dictionary_block_frontcoding_compression(デフォルト: 1)は、Dictionary ブロックが圧縮方式としてフロントコーディングを使用するかどうかを指定します。
オプションのパラメータ max_cardinality_for_embedded_postings(デフォルト: 16)は、ポスティングリストを Dictionary ブロック内に埋め込むかどうかを決めるカーディナリティのしきい値を指定します。
オプションのパラメータ bloom_filter_false_positive_rate(デフォルト: 0.1)は、Dictionary の Bloom フィルタにおける偽陽性率を指定します。
テーブル作成後でも、カラムに対してテキスト索引を追加・削除できます。
テキスト索引の使用
SELECT クエリでテキスト索引を利用するのは容易で、一般的な文字列検索関数は索引を自動的に利用します。 索引が存在しない場合、以下の文字列検索関数は遅いフルスキャンにフォールバックします。
サポートされている関数
テキストインデックスは、SELECT クエリの WHERE 句でテキスト関数が使用されている場合に使用できます。
= と !=
= (equals) と != (notEquals) は、指定した検索語と完全に一致するものを対象とします。
例:
テキストインデックスは = と != をサポートしますが、等価検索や不等価検索が有効なのは array tokenizer を使用する場合のみです(array tokenizer により、索引には行全体の値が保存されます)。
IN および NOT IN
IN (in) と NOT IN (notIn) は、関数 equals および notEquals と似ていますが、検索語のいずれかに一致するもの(IN)、または検索語のどれにも一致しないもの(NOT IN)を検索します。
例:
= および != と同じ制約が適用されます。つまり、IN と NOT IN は array トークナイザーと組み合わせて使用する場合にのみ有効です。
LIKE、NOT LIKE、match
これらの関数がフィルタリング時にテキスト索引を利用できるのは、索引トークナイザが splitByNonAlpha または ngrams の場合のみです。
LIKE(like)、NOT LIKE(notLike)、および match 関数をテキスト索引と併用するには、ClickHouse が検索語句から完全なトークンを抽出できる必要があります。
例:
この例の support は、support、supports、supporting などにマッチします。
この種のクエリは部分一致クエリであり、テキストインデックスによって高速化することはできません。
LIKE クエリでテキストインデックスを活用するには、LIKE パターンを次のように書き換える必要があります。
support の左右に空白を入れておくことで、その語をトークンとして抽出できるようにしています。
startsWith と endsWith
LIKE と同様に、startsWith 関数と endsWith 関数は、検索語句から完全なトークンを抽出できる場合に限り、テキストインデックスを利用できます。
例:
この例では、clickhouse だけがトークンとして扱われます。
support は support、supports、supporting などにマッチする可能性があるため、トークンとは見なされません。
clickhouse supports で始まるすべての行を検索するには、検索パターンの末尾にスペースを 1 つ追加してください。
同様に、endsWith も先頭にスペースを付けて使用します。
hasToken と hasTokenOrNull
hasToken 関数と hasTokenOrNull 関数は、指定された 1 つのトークンに対して照合を行います。
前述の関数とは異なり、検索語句をトークン化しません(入力が 1 つのトークンであると仮定します)。
例:
text 索引と組み合わせて使用する場合、hasToken 関数と hasTokenOrNull 関数が最も効率的です。
hasAnyTokens と hasAllTokens
関数 hasAnyTokens および hasAllTokens は、指定されたトークンの一部またはすべてにマッチします。
これら 2 つの関数は、検索トークンを、索引用カラムで使用されているものと同じトークナイザーでトークナイズされる文字列として指定するか、あるいは検索前に追加のトークナイズを行わない、既に処理済みのトークン配列として指定できます。 詳細については関数のドキュメントを参照してください。
例:
has
配列関数 has は、文字列配列内の特定のトークンに対して一致判定を行います。
例:
mapContains
mapContains 関数(mapContainsKey のエイリアス)は、マップのキーに含まれる単一のトークンにマッチします。
例:
operator[]
アクセス演算子 operator[] をテキスト索引と組み合わせて使用し、キーと値をフィルタリングできます。
例:
テキスト索引で Array(T) 型および Map(K, V) 型のカラムを使用する例を以下に示します。
テキスト索引付き Array および Map カラムの例
Array(String) カラムのインデックス作成
ブログプラットフォームを想像してください。著者はキーワードを使ってブログ記事にカテゴリを付けます。 ユーザーには、トピックを検索したりクリックしたりして関連コンテンツを見つけられるようにしたいとします。
次のテーブル定義を考えてみます。
テキスト索引がない場合、特定のキーワード(例:clickhouse)を含む投稿を見つけるには、すべてのエントリを走査する必要があります。
プラットフォームが成長するにつれて、クエリではすべての行の keywords 配列を走査する必要があるため、処理がますます遅くなります。
このパフォーマンスの問題を解決するために、カラム keywords に対してテキスト索引を定義します。
Map カラムへのインデックス作成
多くのオブザーバビリティ関連のユースケースでは、ログメッセージは「コンポーネント」に分割され、タイムスタンプには datetime 型、ログレベルには enum 型など、適切なデータ型として保存されます。 メトリクスのフィールドはキー・バリューのペアとして保存するのが最適です。 運用チームは、デバッグやセキュリティインシデント、監視のために、ログを効率的に検索する必要があります。
次のような logs テーブルを考えてみましょう:
テキストインデックスがない場合、Map データを検索するにはテーブル全体をスキャンする必要があります。
ログ量が増えると、これらのクエリは遅くなります。
解決策は、Map のキーと値に対してテキスト索引を作成することです。 フィールド名や属性タイプでログを検索する必要がある場合は、mapKeys を使用してテキスト索引を作成します。
属性の実際のテキスト内容を検索する必要がある場合は、mapValues を使用してテキスト索引を作成します。
クエリの例:
パフォーマンスのチューニング
ダイレクトリード
特定の種類のテキストクエリは、「ダイレクトリード」と呼ばれる最適化によって大幅に高速化されます。
より具体的には、SELECT クエリがテキストカラムを 投影していない 場合に、この最適化を適用できます。
例:
ClickHouse のダイレクトリード最適化は、基盤となるテキストカラムにアクセスせず、テキストインデックス(すなわちテキストインデックスルックアップ)のみを用いてクエリに応答します。 テキストインデックスルックアップは読み取るデータ量が比較的少ないため、ClickHouse の通常のスキップインデックス(スキップインデックスのルックアップを行った後に、残ったグラニュールを読み込み・フィルタリングする)よりもはるかに高速です。
ダイレクトリードは 2 つの設定で制御されます:
- ダイレクトリードを全体として有効にするかどうかを指定する query_plan_direct_read_from_text_index 設定。
- ダイレクトリードのもう 1 つの前提条件である use_skip_indexes_on_data_read 設定。なお、compatibility < 25.10 の ClickHouse データベースでは
use_skip_indexes_on_data_readは無効化されているため、compatibility 設定値を引き上げるか、明示的にSET use_skip_indexes_on_data_read = 1を実行する必要があります。
また、ダイレクトリードを使用するにはテキストインデックスが完全にマテリアライズされている必要があります(そのためには ALTER TABLE ... MATERIALIZE INDEX を使用します)。
サポートされている関数
ダイレクトリード最適化は hasToken、hasAllTokens、hasAnyTokens 関数をサポートします。
これらの関数は AND、OR、NOT 演算子で組み合わせることもできます。
WHERE 句には、(テキストカラムやその他のカラムに対する)追加の非テキスト検索関数によるフィルタも含めることができます。この場合でもダイレクトリード最適化は使用されますが、その効果は小さくなります(サポートされているテキスト検索関数にのみ適用されるためです)。
クエリがダイレクトリードを利用しているかを確認するには、EXPLAIN PLAN actions = 1 を付けてクエリを実行します。
例として、ダイレクトリードを無効にしたクエリは次のようになります。
戻り値
一方、query_plan_direct_read_from_text_index = 1 に設定して同じクエリを実行した場合
戻り値
2つ目の EXPLAIN PLAN の出力には、仮想カラム __text_index_<index_name>_<function_name>_<id> が含まれています。
このカラムが存在する場合、直接読み取りが使用されます。
キャッシュ
テキストインデックスの一部をメモリ上でバッファするために、複数のキャッシュが利用できます(Implementation Details セクションを参照してください)。 現在、I/O を削減するために、テキストインデックスのデシリアライズ済みの Dictionary ブロック、ヘッダー、およびポスティングリスト向けのキャッシュが用意されています。 これらは設定 use_text_index_dictionary_cache、use_text_index_header_cache、および use_text_index_postings_cache で有効化できます。 デフォルトでは、すべてのキャッシュは無効になっています。
キャッシュを構成するには、以下のサーバー設定を参照してください。
Dictionary ブロックキャッシュ設定
| Setting | 説明 |
|---|---|
| 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 ブロックキャッシュにおける、保護キューのサイズ(キャッシュ全体に対する比率)。 |
ヘッダーキャッシュの設定
| 設定 | 説明 |
|---|---|
| text_index_header_cache_policy | テキストインデックスヘッダーキャッシュのポリシー名。 |
| text_index_header_cache_size | キャッシュの最大サイズ(バイト単位)。 |
| text_index_header_cache_max_entries | キャッシュ内のデシリアライズ済みヘッダーの最大数。 |
| text_index_header_cache_size_ratio | テキストインデックスヘッダーキャッシュにおける、保護キューのサイズがキャッシュ全体サイズに対して占める割合。 |
ポスティングリストキャッシュ設定
| 設定 | 説明 |
|---|---|
| text_index_postings_cache_policy | テキストインデックスのポスティングリストキャッシュポリシー名。 |
| text_index_postings_cache_size | キャッシュの最大サイズ(バイト単位)。 |
| text_index_postings_cache_max_entries | キャッシュ内のデシリアライズ済みポスティングリストの最大件数。 |
| text_index_postings_cache_size_ratio | テキストインデックスのポスティングリストキャッシュにおける保護キューサイズの、キャッシュ全体サイズに対する比率。 |
実装の詳細
各テキストインデックスは 2 つの(抽象的な)データ構造から構成されます。
- 各トークンをポスティングリストに対応付ける dictionary と、
- 行番号の集合を表す各ポスティングリストの集合。
テキストインデックスはスキップインデックスであるため、これらのデータ構造は論理的にはインデックスグラニュールごとに存在します。
インデックス作成時には、3 つのファイルが(パートごとに)作成されます。
Dictionary blocks ファイル (.dct)
インデックスグラニュール内のトークンはソートされ、128 トークンずつの dictionary ブロック(ブロックサイズはパラメータ dictionary_block_size で設定可能)に保存されます。
Dictionary blocks ファイル (.dct) は、あるパート内のすべてのインデックスグラニュールに含まれる dictionary ブロックをすべてまとめたものです。
Index granules ファイル (.idx)
Index granules ファイルには、各 dictionary ブロックについて、そのブロックの最初のトークン、dictionary blocks ファイル内での相対オフセット、およびブロック内のすべてのトークンに対する Bloom フィルターが格納されます。 このスパースな索引構造は、ClickHouse の sparse primary key index に似ています。 Bloom フィルターにより、検索対象のトークンが dictionary ブロックに含まれていない場合には、その dictionary ブロックを早期にスキップできます。
Postings lists ファイル (.pst)
すべてのトークンに対するポスティングリストは、postings lists ファイル内に連続して配置されます。
ストレージ効率を保ちつつ、共通部分や和集合の高速な演算を可能にするため、ポスティングリストは roaring bitmaps として保存されます。
ポスティングリストの基数が 16 未満(パラメータ max_cardinality_for_embedded_postings で設定可能)の場合、そのリストは dictionary 内に埋め込まれます。
例:Hackernews データセット
大量のテキストを含む大規模データセットに対するテキスト索引のパフォーマンス向上を見ていきます。 ここでは、人気サイト「Hacker News」のコメント 2,870 万行を使用します。 以下はテキスト索引なしのテーブルです。
この 2,870 万行は S3 上の Parquet ファイルに格納されています — これらを hackernews テーブルに挿入しましょう:
ALTER TABLE を使用して comment カラムにテキスト索引を追加し、その索引をマテリアライズします。
では、hasToken、hasAnyTokens、hasAllTokens 関数を使ってクエリを実行してみましょう。
次の例では、通常の索引スキャンとダイレクトリード最適化を比較した場合の、劇的なパフォーマンス差を示します。
1. hasToken を使用する
hasToken は、テキストに特定の単一トークンが含まれているかどうかを確認します。
ここでは、大文字・小文字を区別するトークン 'ClickHouse' を検索します。
ダイレクトリード無効化(標準スキャン) デフォルトでは、ClickHouse はスキップインデックスを使用してグラニュールをフィルタリングし、そのグラニュールのカラムデータを読み取ります。 ダイレクトリードを無効にすることで、この動作をシミュレートできます。
ダイレクトリード有効(高速索引読み出し) 次に、ダイレクトリードを有効にした状態(これがデフォルト)で同じクエリを実行します。
直接読み取りクエリは、索引だけを読み取ることで 45 倍以上高速 (0.362 秒 対 0.008 秒) であり、処理するデータ量も大幅に少なくなります (9.51 GB 対 3.15 MB)。
2. hasAnyTokens の使用
hasAnyTokens は、テキストに指定されたトークンのうち少なくとも 1 つが含まれているかどうかをチェックします。
ここでは、'love' または 'ClickHouse' のいずれかを含むコメントを検索します。
ダイレクトリード無効(標準スキャン)
ダイレクト読み取りの有効化(索引の高速読み取り)
この一般的な「OR」検索では、性能向上はさらに顕著です。 フルカラムスキャンを回避することで、クエリはほぼ 89 倍高速になります(1.329秒 対 0.015秒)。
3. hasAllTokens の使用
hasAllTokens は、テキストが指定されたすべてのトークンを含んでいるかどうかをチェックします。
ここでは、'love' と 'ClickHouse' の両方を含むコメントを検索します。
ダイレクトリード無効(標準スキャン) ダイレクトリードを無効にしても、標準のスキップ索引は引き続き有効です。 28.7M 行を 147.46K 行まで絞り込みますが、それでもカラムから 57.03 MB を読み取る必要があります。
ダイレクトリード有効(高速な索引読み取り) ダイレクトリードは索引データのみを操作してクエリに応答し、読み取るデータ量は 147.46 KB にとどまります。
この「AND」検索では、ダイレクトリード最適化は標準的なスキップ索引スキャンに比べて 26 倍以上高速です(0.184 秒に対して 0.007 秒)。
4. 複合検索: OR、AND、NOT、...
ダイレクトリードの最適化は、複合的なブール式にも適用されます。 ここでは、大文字小文字を区別せずに「ClickHouse」または「clickhouse」を検索します。
ダイレクトリード無効(標準スキャン)
ダイレクト読み取りの有効化(高速な索引読み取り)
索引からの結果を組み合わせることで、直接読み取りクエリは 34 倍高速になり(0.450 秒から 0.013 秒へ短縮され)、9.58 GB 分のカラムデータの読み取りを回避できます。
このケースでは、hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) が、より効率的で推奨される構文となります。