テキストインデックスを使用した全文検索
ClickHouse のテキストインデックス("inverted indexes" とも呼ばれます)は、文字列データに対して高速な全文検索機能を提供します。 インデックスは、列内の各トークンを、そのトークンを含む行に対応付けます。 トークンは、トークン化と呼ばれる処理によって生成されます。 例えば、ClickHouse は英語の文 "All cat like mice." を、デフォルトでは ["All", "cat", "like", "mice"] のようにトークン化します(末尾のドットは無視される点に注意してください)。 ログデータ向けなど、より高度なトークナイザーも利用できます。
テキストインデックスの作成
テキストインデックスを作成するには、まず対応する実験的な設定を有効にしてください。
テキストインデックスは、次の構文を使用して、String、FixedString、Array(String)、Array(FixedString)、および Map(mapKeys および mapValues マップ関数を通じて)列に定義できます。
トークナイザー引数(必須)。tokenizer 引数は使用するトークナイザーを指定します:
splitByNonAlphaは、英数字以外の ASCII 文字で文字列を分割します(関数 splitByNonAlpha も参照してください)。splitByString(S)は、ユーザー定義の区切り文字列Sに従って文字列を分割します(関数 splitByString も参照してください)。 区切り文字列はオプションのパラメータで指定できます。たとえばtokenizer = splitByString([', ', '; ', '\n', '\\'])のように指定します。 それぞれの区切り文字列は、複数文字(例では', ')から構成されていてもかまいません。 区切り文字リストを明示的に指定しない場合(たとえばtokenizer = splitByString)、既定の区切り文字リストは単一の空白[' ']です。ngrams(N)は、文字列を同じ長さのN-gram に分割します(関数 ngrams も参照してください)。 n-gram の長さは 2 から 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引数は、トークン化の前に入力文字列に適用される式です。
プリプロセッサ引数の典型的な使用例には以下が含まれます
- 大文字小文字を区別しないマッチングを可能にするための小文字化または大文字化。例: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のテキストインデックスはセカンダリインデックスとして実装されています。 ただし、他のスキップインデックスとは異なり、テキストインデックスのデフォルトのインデックスGRANULARITYは64です。 この値は経験的に選択されており、ほとんどの使用例において速度とインデックスサイズの適切なトレードオフを提供します。 上級ユーザーは異なるインデックス粒度を指定できます(ただし、推奨しません)。
オプションの高度なパラメータ
以下の高度なパラメータのデフォルト値は、ほぼすべての状況で適切に機能します。 これらの変更は推奨しません。
オプションパラメータdictionary_block_size(デフォルト:128)は、辞書ブロックのサイズを行数で指定します。
オプションパラメータdictionary_block_frontcoding_compression(デフォルト:1)は、辞書ブロックが圧縮としてフロントコーディングを使用するかどうかを指定します。
オプションパラメータmax_cardinality_for_embedded_postings(デフォルト:16)は、ポスティングリストを辞書ブロックに埋め込むべきカーディナリティの閾値を指定します。
オプションパラメータ bloom_filter_false_positive_rate(デフォルト: 0.1)は、辞書ブルームフィルタの偽陽性率を指定します。
テキストインデックスは、テーブル作成後にカラムへ追加または削除することができます:
テキストインデックスの使用
SELECT クエリでテキストインデックスを使用するのは簡単で、一般的な文字列検索関数では自動的にインデックスが利用されます。 インデックスが存在しない場合、以下の文字列検索関数は低速な総当たりスキャンによる処理にフォールバックします。
サポートされている関数
SELECT クエリの WHERE 句でテキスト関数が使用されている場合、テキストインデックスを利用できます。
= と !=
= (equals) と != (notEquals) は、指定された検索語全体と一致します。
例:
テキストインデックスは = と != をサポートしますが、等値・不等値検索が意味を持つのは array トークナイザを使用する場合のみです(このトークナイザではインデックスに行全体の値が保存されます)。
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 は、指定された単一のトークンを対象にマッチングを行います。
前述の関数とは異なり、これらは検索語句をトークン化せず、入力が単一のトークンであると仮定します。
例:
関数 hasToken と hasTokenOrNull は、text インデックスと組み合わせて使用する関数として最も高いパフォーマンスを発揮します。
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 列のインデックス作成
多くのオブザーバビリティのユースケースでは、ログメッセージは「コンポーネント」に分割され、タイムスタンプには日時型、ログレベルには enum 型など、適切なデータ型で保存されます。 メトリクスのフィールドはキーと値のペアとして保存するのが最適です。 運用チームは、デバッグ、セキュリティインシデント、監視のために、ログを効率的に検索する必要があります。
次のような logs テーブルを考えます:
テキストインデックスがない場合、Map 型データを検索するには、テーブルのフルスキャンが必要になります。
ログ量が増加すると、これらのクエリは遅くなります。
解決策は、Map のキーと値に対してテキストインデックスを作成することです。 フィールド名や属性タイプでログを検索する必要がある場合は、mapKeys を使用してテキストインデックスを作成します。
属性の実際の内容を検索する必要がある場合は、mapValues を使用してテキストインデックスを作成します。
クエリの例:
パフォーマンスチューニング
直接読み取り
特定の種類のテキストクエリは、「direct read」と呼ばれる最適化によって大幅に高速化されます。
より正確には、SELECT クエリがテキスト列を 選択しない 場合に、この最適化を適用できます。
例:
ClickHouse におけるダイレクトリード最適化は、基盤となるテキスト列にアクセスせずに、テキストインデックスのみ(すなわちテキストインデックスのルックアップ)の使用によってクエリに応答します。 テキストインデックスのルックアップは比較的少量のデータしか読み取らないため、ClickHouse の通常のスキップインデックス(スキップインデックスのルックアップの後に、残った granule の読み込みとフィルタリングを行う)よりもはるかに高速です。
ダイレクトリードは次の 2 つの設定で制御されます。
- 設定 query_plan_direct_read_from_text_index は、ダイレクトリードを全体として有効にするかどうかを指定します。
- 設定 use_skip_indexes_on_data_read は、ダイレクトリードのもう 1 つの前提条件です。ClickHouse データベースで compatibility < 25.10 の場合、
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 を削減するために、テキストインデックスのデシリアライズ済みディクショナリブロック、ヘッダー、およびポスティングリスト用のキャッシュが用意されています。 これらは設定 use_text_index_dictionary_cache、use_text_index_header_cache、および use_text_index_postings_cache によって有効化できます。 デフォルトでは、すべてのキャッシュは無効になっています。
キャッシュを設定するには、以下のサーバー設定を参照してください。
ディクショナリブロックキャッシュの設定
| Setting | Description |
|---|---|
| text_index_dictionary_block_cache_policy | テキストインデックス辞書ブロックキャッシュのポリシー名。 |
| text_index_dictionary_block_cache_size | キャッシュの最大サイズ(バイト単位)。 |
| text_index_dictionary_block_cache_max_entries | キャッシュ内のデシリアライズ済み辞書ブロックの最大数。 |
| text_index_dictionary_block_cache_size_ratio | テキストインデックス辞書ブロックキャッシュにおける保護キューのサイズの、キャッシュ全体サイズに対する割合。 |
ヘッダーキャッシュ設定
| Setting | Description |
|---|---|
| text_index_header_cache_policy | テキストインデックスヘッダーキャッシュのポリシー名。 |
| text_index_header_cache_size | キャッシュの最大サイズ(バイト単位)。 |
| text_index_header_cache_max_entries | キャッシュ内のデシリアライズ済みヘッダーの最大数。 |
| text_index_header_cache_size_ratio | テキストインデックスヘッダーキャッシュにおける保護キューのサイズの、キャッシュ全体サイズに対する割合。 |
ポスティングリストキャッシュ設定
| Setting | Description |
|---|---|
| text_index_postings_cache_policy | テキストインデックスのポスティングリストキャッシュのポリシー名。 |
| text_index_postings_cache_size | キャッシュの最大サイズ(バイト単位)。 |
| text_index_postings_cache_max_entries | キャッシュ内のデシリアライズ済みポスティングの最大数。 |
| text_index_postings_cache_size_ratio | テキストインデックスのポスティングリストキャッシュにおける保護キューのサイズの、キャッシュ全体サイズに対する割合。 |
実装の詳細
各テキストインデックスは、2 つの(抽象的な)データ構造から構成されます:
- 各トークンをポスティングリストにマッピングする辞書
- 各々が行番号の集合を表すポスティングリストの集合
テキストインデックスはスキップインデックスであるため、これらのデータ構造は論理的にはインデックスグラニュール単位で存在します。
インデックス作成時には、3 つのファイルが(パートごとに)作成されます。
Dictionary blocks file (.dct)
インデックスグラニュール内のトークンはソートされ、128 トークンごとの辞書ブロックに格納されます(ブロックサイズはパラメータ dictionary_block_size で設定可能です)。
Dictionary blocks file (.dct) は、あるパート内のすべてのインデックスグラニュールに対するすべての辞書ブロックから構成されます。
Index granules file (.idx)
Index granules file (.idx) には、各辞書ブロックについて、そのブロックの先頭トークン、dictionary blocks file 内での相対オフセット、そしてブロック内のすべてのトークンに対するブルームフィルタが含まれます。 この疎なインデックス構造は、ClickHouse の sparse primary key index と類似しています。 ブルームフィルタにより、検索対象のトークンが辞書ブロックに含まれていない場合、その辞書ブロックを早期にスキップできます。
Postings lists file (.pst)
すべてのトークンに対するポスティングリストは、postings lists file 内に連続して配置されます。
ストレージ容量を節約しつつ、高速な積集合および和集合の演算を可能にするため、ポスティングリストは roaring bitmaps として保存されます。
ポスティングリストの基数が 16 未満の場合(パラメータ max_cardinality_for_embedded_postings で設定可能)、そのリストは辞書内に埋め込まれます。
例:Hackernews データセット
大量のテキストを含む大規模なデータセットに対するテキストインデックスのパフォーマンス向上を確認していきます。 ここでは、人気サイトである Hacker News 上のコメント 2,870 万件を使用します。 以下はテキストインデックスを作成していないテーブルです:
この 2,870 万行は S3 上の Parquet ファイルに格納されています。これらを hackernews テーブルに挿入しましょう:
ALTER TABLE を使用して comment 列にテキストインデックスを追加し、次にそれをマテリアライズします:
では、hasToken、hasAnyTokens、hasAllTokens 関数を使ってクエリを実行してみます。
次の例では、標準的なインデックススキャンとダイレクトリード最適化の間で、どれほど大きな性能差が生じるかを示します。
1. hasToken を使用する
hasToken は、テキストに特定の単一トークンが含まれているかどうかをチェックします。
ここでは大文字小文字を区別して、ClickHouse というトークンを検索します。
ダイレクトリード無効(標準スキャン) デフォルトでは、ClickHouse はスキップインデックスを使ってグラニュールをフィルタリングし、そのグラニュールに対してカラムデータを読み込みます。 ダイレクトリードを無効にすることで、この動作をシミュレートできます。
ダイレクトリード有効(高速インデックス読み取り) 次に、デフォルトで有効になっているダイレクトリードを使用して同じクエリを実行します。
直接読み取りクエリは 45 倍以上高速 (0.362s 対 0.008s) で、インデックスのみを読み取ることで処理するデータ量も大幅に削減されます (9.51 GB 対 3.15 MB)。
2. hasAnyTokens を使用する
hasAnyTokens は、テキストに指定したトークンのうち少なくとも 1 つが含まれているかどうかをチェックします。
ここでは、'love' または 'ClickHouse' のいずれかを含むコメントを検索します。
直接読み取り無効(標準スキャン)
ダイレクト読み取り有効(高速インデックス読み出し)
1 行がセットに含まれています。経過時間: 0.015 秒。27.99 百万行、27.99 MB を処理しました。
ダイレクトリード有効(高速インデックス読み取り) ダイレクトリードではインデックスデータに対してクエリを実行し、147.46 KB 分のみを読み取って結果を返します。
この AND 検索では、ダイレクトリード最適化は標準のスキップインデックススキャンと比べて 26 倍以上高速です (0.184s 対 0.007s)。
4. 複合検索: OR, AND, NOT, ...
ダイレクトリード最適化は、複合ブール式にも適用されます。 ここでは、大文字小文字を区別しない検索で「ClickHouse」または「clickhouse」を検索します。
ダイレクトリード無効時 (標準スキャン)
ダイレクトリードが有効(高速インデックス読み取り)
インデックスの結果を組み合わせることで、直接読み取りクエリは 34 倍高速 (0.450 秒対 0.013 秒) になり、9.58 GB のカラムデータを読み込む必要がなくなります。
この特定のケースでは、hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) が推奨される、より効率的な構文です。