テキスト索引の作成
テキスト索引は、compatibility 設定に関係なく、ClickHouse バージョン >= 26.2 であれば使用できます。
Query
- String と FixedString
- Array(String) と Array(FixedString)
- Map (mapKeys および mapValues 関数経由)
- JSON (JSONAllPaths および
JSONAllValues関数経由)
Array(Nullable(String or FixedString)) も含まれます。
また、既存のテーブルにテキスト索引を追加するには:
Query
Query
Query
tokenizer 引数では、トークナイザーを指定します。
splitByNonAlphaは、ASCII の英数字以外の文字で String を分割します (関数 splitByNonAlpha を参照) 。splitByString(S)は、ユーザー定義の区切り StringSで String を分割します (関数 splitByString を参照) 。 区切り文字は省略可能なパラメータで指定できます。たとえば、tokenizer = splitByString([', ', '; ', '\n', '\\'])のように指定します。 各 String は複数文字で構成することもできます (例では', ') 。 明示的に指定しない場合 (たとえばtokenizer = splitByString) 、デフォルトの区切り文字リストは単一の空白文字[' ']です。asciiCJKは、Unicode の単語境界規則を使用して String をトークンに分割します (Unicode Text Segmentation (UAX #29) に類似) 。 ASCII の英数字とアンダースコアは、コネクタ (文字に対する ASCII:、同種の文字に対する.および') を含むトークンを構成します。非 ASCII の Unicode 文字は、CJK 文字を含め、1 文字のトークンになります。ngrams(N)は、String を同じ長さのN-gram に分割します (関数 ngrams を参照) 。 ngram の長さは、1 から 8 までの省略可能な整数パラメータで指定できます。たとえば、tokenizer = ngrams(3)のように指定します。 明示的に指定しない場合 (たとえばtokenizer = ngrams) 、デフォルトの ngram サイズは 3 です。sparseGrams(min_length, max_length, min_cutoff_length)は、min_length文字以上max_length文字以下 (両端を含む) の可変長 n-gram に String を分割します (関数 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-gram、4-gram、5-gram を生成しますが、返されるのは 4-gram と 5-gram のみです。arrayはトークン化を行いません。つまり、各行の値がトークンになります (関数 array を参照) 。
splitByString トークナイザーは、分割区切り文字を左から右の順に適用します。
そのため、曖昧さが生じることがあります。
たとえば、区切り String ['%21', '%'] を指定すると、%21abc は ['abc'] としてトークン化されます。一方、区切り String の順序を ['%', '%21'] に入れ替えると、出力は ['21abc'] になります。
多くの場合、より長い区切り文字が優先的に一致するようにするのが望ましいでしょう。
通常は、区切り String を長さの降順で渡すことでこれを実現できます。
区切り String がたまたま prefix code を構成している場合は、任意の順序で渡せます。Query
Response
asciiCJK トークナイザーの使用を推奨します。
プリプロセッサ引数 (任意) 。プリプロセッサとは、トークン化の前に入力文字列へ適用される式を指します。
プリプロセッサ引数の一般的な用途としては、次のようなものがあります
- 大文字/小文字の変換、または大文字小文字を区別しないマッチングを可能にするケースフォールディング。例: lower, lowerUTF8, caseFoldUTF8。
- UTF-8 の正規化。例: normalizeUTF8NFC, normalizeUTF8NFD, normalizeUTF8NFKC, normalizeUTF8NFKD, normalizeUTF8NFKCCasefold, toValidUTF8。
- アクセント記号など、不要な文字や部分文字列の削除または変換。例: extractTextFromHTML, substring, idnaEncode, translate, removeDiacriticsUTF8。
Nullable(T) または LowCardinality(T) 型のカラムに対して構築されている場合、プリプロセッサ式は nullable または low-cardinality の値を受け入れられる必要があります (つまり、例外をスローしてはいけません) 。
例:
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)))INDEX idx col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = removeDiacriticsUTF8(caseFoldUTF8(col)))
INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = upper(lower(col)))INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = concat(lower(col), lower(col)))- 許可されません:
INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = concat(col, col))
プリプロセッサは原則として、索引対象のカラムまたは式をプリプロセッサ式でラップすることと同等です。
例えば、
INDEX idx col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(col)) における lower プリプロセッサは、INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha') によって再現できます。
後者の形式には、再現されたプリプロセッサが WHERE 句のフィルタ条件に一致する場合にのみ適用されるという欠点があります。
例えば、WHERE hasAllTokens(lower(col), [...]) は一致しますが、WHERE hasAllTokens(col, [...]) は一致しません。
そのため、最適なユーザー体験のために、プリプロセッサ式の使用を推奨します。SETTINGS use_skip_indexes = 0) とで異なる場合があることに注意してください。
例えば、
Query
Query
Query
Query
- ストップワード (極めて高頻度なトークン) のフィルタリング。“the”、“a”、“is” のような非常によく出現するトークンは、検索との関連性が低いうえ、索引を肥大化させます。
ポストプロセッサを使えば、それらを空トークンに変換して除外できます。空トークンは無視され、つまり索引には追加されません。
例:
if(str IN ('the', 'a', 'an', 'of', 'in', 'is', 'it'), '', str) - タイムスタンプの除去。ログ行は、
2024-01-15T10:23:45のような構造化されたタイムスタンプで始まっていることや、それを含んでいることがよくあります。 タイムスタンプのトークンを索引化すると、検索上の関連性を持たない文字列によって索引が肥大化します。 タイムスタンプを無視するための相補的な方法は 2 つあります:- ポストプロセッサ方式:
splitByStringトークナイザー (空白で分割) を使用してタイムスタンプ全体を 1 つのトークンにし、その後parseDateTimeOrNullを使って検出して除外します。 例:if(isNull(parseDateTimeOrNull(str, '%Y-%m-%dT%H:%i:%S')), str, '')タイムゾーンオフセットや小数秒を含むタイムスタンプには、明示的なフォーマット文字列を使わずにparseDateTimeBestEffortOrNull(str)を使用します。 - プリプロセッサ方式: トークン化の 前 に、正規表現でログ行全体からタイムスタンプを取り除きます。
例:
replaceRegexpAll(str, '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2} ', '')この方法はどのトークナイザーでも機能し、タイムスタンプ文字列がまったくトークン化されないため、より効率的です。 両方の方法を組み合わせることもできます。プリプロセッサでタイムスタンプを除去しつつ、ポストプロセッサで残りのトークンを正規化またはフィルタリングします (たとえば、小文字化 +ERRORやINFOのような重大度を表す語の除去)。
- ポストプロセッサ方式:
- ステミング。各トークンをその語幹に対応付けることで、同じ語根を共有する語形の違いにも一致するようになり、検索の再現率が向上します。
たとえば、英語のステミングでは “running”、“runs”、“run” はすべて “run” に語幹化されるため、これらの異形のいずれかに対するクエリですべてに一致します。
ClickHouse には、複数の言語向けの組み込み stem 関数があります。
例:
stem(str, 'en') - 大文字小文字の正規化。たとえば lower や lowerUTF8 を使って、トークンを小文字化または大文字化し、大文字小文字を区別しないマッチングを可能にします。 小文字化および大文字化には、ポストプロセッサではなくプリプロセッサを推奨します。
Array(String) の場合でも、ポストプロセッサは個々のトークンをプレーンな String 値として処理します。
非決定論的関数の使用は許可されていません。
ポストプロセッサは、index のビルド時に生成される各トークンに適用されます (array トークナイザーでは、各配列要素が 1 つのトークンです) 。クエリ時の動作は、関数によって異なります。
hasToken、hasAllTokens、hasAnyTokens、hasPhraseの場合 (サポートされている任意のトークナイザーを使用) : ポストプロセッサは、haystack 側のトークンと検索 needle の両方に適用されるため、完全に正規化されたマッチング (たとえば、大文字と小文字を区別しない検索) が可能になります。hasPhraseでは、ポストプロセッサ適用後のトークンは詰めて配置されるため、ポストプロセッサによって削除されたトークンがあっても位置上のギャップは生じず、その箇所をまたいでフレーズが一致します。たとえば、theを削除するストップワード用ポストプロセッサを使用すると、hasPhrase(col, 'see cat')はsee the catという文書に一致します。- それ以外のすべての関数 (
=,IN,has,hasAny,hasAll,mapContains*) : index ヒントのルックアップでは検索 needle にのみポストプロセッサが適用され、行レベルの述語では引き続き元のカラム値と比較されます。
- ポストプロセッサ式を使用してストップワードを削除します:
- ポストプロセッサ式を使用してタイムスタンプを削除します:
- プリプロセッサ式でタイムスタンプを削除します:
- プリプロセッサとポストプロセッサを組み合わせた式でタイムスタンプを削除します:
- 後処理式でトークンをステミングします:
=, IN, startsWith, endsWith, LIKE, mapContains*) では、テキスト索引は無関係なデータブロックをスキップするためにのみ使用されます。ClickHouse はその後も、元のカラムデータに対して元の述語で、残った各行を検証します。
トークン検索関数 (hasToken, hasAllTokens, hasAnyTokens) では、テキスト索引が主な評価経路になります。ClickHouse は、索引構築時に適用されたものと同じプリプロセッサ、トークナイザー、ポストプロセッサを通して needle を正規化し、この正規化形を索引付き・索引なしの両方のテーブルパーツに対して使用します。ポストプロセッサがある場合は、haystack のトークンもクエリ時に正規化されるため (array に限らず、どのトークナイザーでも同様) 、比較の両側が一貫して変換され、結果は索引を直接読み取るかどうか (設定 query_plan_direct_read_from_text_index) 、または特定のパーツにマテリアライズされた索引があるかどうかに依存しません。たとえば、lower ポストプロセッサを使うと、hasAllTokens(col, ['FOO']) で大文字と小文字を区別しないマッチングを有効にできます。
support_phrase_search がない場合、hasPhrase は索引をヒントとしてのみ使用し、残った各行を元の述語で検証します。さらに、ポストプロセッサはフレーズと haystack のトークンの両方を同じ方法で正規化するため、結果は読み取り経路に依存せず、ポストプロセッサが削除するトークンによってフレーズの隣接関係が崩れることもありません。support_phrase_search = 1 の場合、hasPhrase は正確な直接読み取りを使用します (存在する場合は、ポストプロセッサも引き続き適用されます) 。
ポストプロセッサによって空文字列にマップされる検索トークンは無視され、つまり検索フレーズに存在しないものとして扱われます。
¹
LIKE と match は、記載されたトークナイザーではヒントとして direct read を使用し、それ以外では 総当たりスキャン にフォールバックします。
LIKE はさらに、プリプロセッサやポストプロセッサを使わない splitByNonAlpha および array トークナイザーに対して、direct read (ヒントなし) もサポートします (use_text_index_like_evaluation_by_dictionary_scan で有効化) 。
² ILIKE は、direct read (ヒントなし) でのみサポートされます (use_text_index_like_evaluation_by_dictionary_scan = 1、splitByNonAlpha または array トークナイザー) 。
索引をヒントとして使うフォールバックはありません。設定が無効になっている場合、またはトークナイザーがサポート対象外の場合、ILIKE では索引は使用されません。
プリプロセッサがある場合は lower または upper である必要があり、ポストプロセッサはサポートされません。
Experimental: フレーズ検索引数のサポート (任意) 。
実験的なパラメータ support_phrase_search (デフォルト: 0) は、索引にトークン位置を保存するかどうかを制御します。
1 に設定すると、索引は位置データ (.pos ファイル内) も追加で保存し、これにより hasPhrase 関数で direct read を介した完全なフレーズ一致が可能になります。
位置情報を保存すると、索引のディスク上のサイズと書き込みコストが増加するため、これはオプトインです。
ディスク上フォーマットはまだ stable ではないため、このパラメータは Experimental であり、将来の release で変更される可能性があります。
そのため、support_phrase_search = 1 を指定して索引を作成するには、MergeTree setting allow_experimental_text_index_phrase_search を有効にする必要があります。
ポスティングリストのみの保存を維持するには support_phrase_search = 0 (デフォルト) を設定してください。この引数を指定せずに作成されたテキスト索引には位置情報は含まれません。
索引の粒度。
テキスト索引は、ClickHouse では スキップ索引 の一種として実装されています。
ただし、他のスキップ索引とは異なり、テキスト索引では無限粒度 (1 億) が使用されます。
これは、テキスト索引のテーブル定義を見ると確認できます。
例:
Query
Response
テキスト索引の使用
テキスト索引の検索には、関数
hasAnyTokens および hasAllTokens の使用を推奨します。詳しくは以下を参照してください。
これらの関数は、利用可能なすべてのトークナイザーと、あらゆるプリプロセッサ式およびポストプロセッサ式に対応しています。
一方、その他のサポート対象の関数は歴史的にテキスト索引より前から存在していたため、多くの場合で従来の動作を維持する必要がありました (例: プリプロセッサまたはポストプロセッサをサポートしない) 。サポートされている関数
WHERE 句または PREWHERE 句で使用している場合は、テキスト索引を利用できます。
=
= (equals) は、指定された検索語全体と一致します。
例:
IN
IN (in) は equals と似ていますが、すべての検索語句に一致します。
例:
テキスト索引では、
NOT IN (notIn) はサポートされていません。LIKE と match
現在、これらの関数でフィルタリングにテキスト索引が使用されるのは、索引のトークナイザーが
splitByNonAlpha、ngrams、または sparseGrams のいずれかである場合に限られます。NOT LIKE (notLike) はテキスト索引ではサポートされていません。LIKE (like) および match 関数を使用するには、ClickHouse が検索語から完全なトークンを抽出できる必要があります。
ngrams トークナイザーを使用する索引では、ワイルドカードに挟まれた検索文字列の長さが N-gram の長さ以上であれば、これに該当します。
splitByNonAlpha トークナイザーを使用するテキスト索引の例:
support はこの例では、support、supports、supporting などに一致する可能性があります。
この種のクエリは部分文字列クエリであり、テキスト索引で高速化することはできません。
LIKE クエリでテキスト索引を活用するには、LIKE パターンを次のように書き換える必要があります。
support の左右に空白があることで、その語を token として抽出できます。
幸い、ClickHouse が転置索引を活用して LIKE クエリを大幅に高速化できる特別なケースがあります。
詳しくは、LIKE/ILIKE パフォーマンスチューニングのセクションを参照してください。
multiSearchAny と multiMatchAny
LIKE および match と同じ条件でテキスト索引を使用します (上記参照) 。つまり、ClickHouse が各 needle から完全なトークンを抽出でき、かつ needles のリストが定数である必要があります。
いずれかの needle が含まれている可能性があるグラニュールは読み取られます。
multiMatchAny では、1 つの pattern をトークン要件に還元できない場合 (たとえば任意の document に一致する .* など) 、テキスト索引は使用できず、クエリは完全走査にフォールバックします。
LIKE や match と同様に、部分文字列検索と正規表現検索は ngrams および sparseGrams トークナイザーで最も効果的に機能します。
これらのトークナイザーは、互いに重なり合う文字 N-gram を索引化します。そのため needle は N-gram に分解され、単語の途中で始まるか終わるかにかかわらず、needle が部分文字列として現れる箇所であれば索引内に存在します。
したがって、needle は N-gram サイズ以上の長さがあれば、そのまま使用できます。
ngrams トークナイザーを使ったテキスト索引の例:
splitByNonAlpha トークナイザーは完全なトークン (単語全体) だけを索引付けします。
needle は単語の途中で始まったり終わったりすることがあるため、ClickHouse は各 needle の先頭と末尾のトークンを除外します。そのため、索引がグラニュールを絞り込めるのは、完全なトークンを使う場合に限られます。
splitByNonAlpha で substring 検索や正規表現検索に索引を利用させるには、各 needle を区切り文字 (スペースなど) で囲み、1 つ以上の完全なトークンになるようにします。
splitByNonAlpha トークナイザーを使用したテキスト索引の例:
startsWith and endsWith
LIKE と同様に、関数 startsWith と endsWith も、検索語から完全なトークンを抽出できる場合にのみ、テキスト索引を利用できます。
ngrams トークナイザーを使用する索引では、ワイルドカードに挟まれた検索文字列の長さが N-gram 長以上の場合に該当します。
テキスト索引で ポストプロセッサ を使用している場合でも、正規化後に抽出されたヒント トークン が空でなければ、これらの関数は Hint モードでその索引を利用できます。正規化によってすべてのヒント トークン が削除される場合、その predicate では索引は使用されません。
splitByNonAlpha トークナイザーを使用するテキスト索引の例:
clickhouse のみです。
support は support、supports、supporting などに一致する可能性があるため、トークンではありません。
clickhouse supports で始まるすべての行を検索するには、検索パターンの末尾にスペースを入れてください:
endsWith も先頭にスペースを付けて使用する必要があります。
hasToken
hasToken は、非 splitByNonAlpha トークナイザーやプリプロセッサ/ポストプロセッサ式を使用したテキスト索引でのルックアップに用いる場合、いくつか注意点があります。
代わりに、hasAnyTokens と hasAllTokens を使用することを推奨します。大文字小文字を区別しないバリアントである hasTokenCaseInsensitive と hasTokenCaseInsensitiveOrNull はテキスト索引を考慮しません。テキスト索引付きのカラムであっても、常に全行スキャンとして実行されます。大文字小文字を区別しない照合を行うには、lower(...) のプリプロセッサまたはポストプロセッサを使用し、それを hasToken / hasAllTokens / hasAnyTokens と組み合わせてください。hasAnyTokens と hasAllTokens
hasPhrase
hasAllTokens とは異なり、hasPhrase ではそれらが連続した並びとして出現する必要があります。
検索フレーズは、索引対象のカラムに設定されているものと同じトークナイザーでトークン化されます。
テキスト索引で ポストプロセッサ を使用している場合、検索フレーズも索引のルックアップ前に正規化されます。
この関数を使用するには、splitByNonAlpha、splitByString、ngrams、asciiCJK のいずれかのトークナイザーが必要です。
例:
has
hasAny and hasAll
mapContains
mapContainsKey のエイリアス) は、マップのキーに対して、検索文字列から抽出されたトークンとの照合を行います。
この動作は、String カラムに対する equals 関数と似ています。
テキスト索引が使用されるのは、mapKeys(map) 式に対して作成されている場合のみです。
例:
mapContainsValue
String カラムに対する equals 関数に似ています。
テキスト索引が使用されるのは、mapValues(map) 式に対して作成されている場合のみです。
例:
mapContainsKeyLike and mapContainsValueLike
operator[]
mapKeys(map) または mapValues(map) 式、あるいはその両方に対して作成されている場合のみです。
例:
Array(T) 型および Map(K, V) 型のカラムを使用する方法については、以下の例を参照してください。
Array(String) カラムの索引作成
clickhouse) を含む投稿を見つけるには、すべてのエントリをスキャンする必要があります。
keywords 配列をすべて調べる必要があるため、これは次第に遅くなります。
このパフォーマンス上の問題を解決するため、カラム keywords にテキスト索引を定義します。
Mapカラムの索引付け
JSONカラムの索引付け
JSONカラムに対して次の 3 つの方法で使用できます。
- 特定のサブカラムに対する索引 — 通常のカラムと同じように、既知の JSON パスにテキスト索引を作成します。これにより、そのパスにある値が索引化されます。
- JSONAllPaths を使用したパスベースの索引 — 各グラニュールに存在するすべてのパスを索引化し、クエリ対象のパスを含み得ないグラニュールをスキップします。
Mapカラムの場合と同様です。 - JSONAllValues を使用した値ベースの索引 — すべての JSON パスにまたがるすべての値を索引化し、単一の索引で任意の JSON サブカラムに対する全文検索を高速化します。
特定のサブカラムに対する索引
- JSON 型ヒントで宣言された 型付きパス — 名前で直接アクセスします:
json.a - 明示的にキャストする 動的パス —
::キャスト構文を使用します:json.b::String
Query
Query
Response
Query
Response
JSONAllPaths を使用したパスベースの索引
Map カラムと同様に、JSON カラムでも JSONAllPaths を使ってテキスト索引を作成できます。
この索引は各グラニュールに存在する JSON パスの集合を格納し、クエリされたパスが存在しないグラニュールをスキップするために利用されます。
索引定義の例:
Query
EXPLAIN indexes = 1 を使用すると、スキップ索引が使われていることを確認できます。
あるパスが一方のパートにしか存在しない場合、索引によってもう一方のパートはスキップされます。
例:
Query
Response
Query
Response
IS NOT NULL でも索引が使用され、path が存在しないグラニュールはスキップされます (その場合、値は NULL になるためです) :
例:
Query
Response
JSONAllValues を使用した値ベースの索引
JSONAllValues を介して JSON カラムに対する検索を高速化できます。
JSONAllValues は、JSON カラム内のすべての値を Array(String) として返します。
文字列以外のデータ型の値 (たとえば整数や配列) は、テキスト表現に変換されます。
JSONAllValues を使って構築したテキスト索引は、各行のすべての JSON パスにまたがるこれらのテキスト表現に索引を作成します。
この索引により、個々の JSON サブカラムで絞り込むクエリを高速化できます。
クエリが特定のサブカラムでフィルタする場合 (例: data.user_name = 'alice') 、テキスト索引は、どの JSON 値にも検索トークンが含まれていない行 (およびグラニュール) をすばやくスキップできます。
異なる JSON パスに同じトークンが含まれている場合、この索引で偽陽性が発生することがあります。
たとえば、行 1 が
{"a": "hello", "b": "world"} で、クエリが data.a = 'world' を検索する場合、テキスト索引では world がパス a ではなく b に属していることを区別できません。
このような場合、索引はその行をスキップせず、実際のカラムデータに対するフィルタで最終的な評価が行われます。
これは、索引が高速な事前フィルタとして機能する、他のテキスト索引のユースケースと同じ動作です。索引の作成
サポートされるクエリパターン
String カラムで使うものと同じ関数、およびすべてのカラムで使える関数 equals です。
サブカラムへのアクセス:
CAST を使用したサブカラムへのアクセス:
IN 演算子:
フレーズ検索
While she stayed in Tokyo, the weather was great. という行がフィルタに一致します。
これに対して、フレーズ検索では、指定された順序どおりに並んだトークンに一致します。
例えば、
weather in Tokyo というトークン列を含む任意の行 (たとえば How is the weather in Tokyo?) に一致しますか?
テキスト索引は、フレーズ内のすべてのトークンの posting list の積集合を求めて候補となる グラニュール を特定することで、フレーズ検索を高速化します。
その後、ClickHouse はそれらの グラニュール 内で、トークンが正確に隣接していることを検証します。
この処理は比較的コストが高く、通常のテキスト検索クエリより低速です。
フレーズ検索クエリを高速化するには、テキスト索引で位置情報の保存を有効にしてください (上記の Optional parameters を参照) 。
hasPhrase は、トークナイザー splitByNonAlpha、splitByString、ngrams、asciiCJK とあわせて使用できます。
指定したフレーズ文字列は、索引のトークナイザーを使ってトークン化されます。
フレーズ内の区切り文字は無視されます。splitByNonAlpha をトークナイザーとして使用している場合、hasPhrase(text, 'quick+brown') は hasPhrase(text, 'quick brown') と同等です。
例
Query
Response
'New weather in York') は、トークンの順序が正しくないため一致しません。
3 行目 ('weather in New Orleans') は、トークン 'York' を含まないため一致しません。
パフォーマンスチューニング
Direct read
- 設定 query_plan_direct_read_from_text_index (デフォルトは true) は、direct read を全体として有効にするかどうかを指定します。
- 設定 use_skip_indexes_on_data_read は、ClickHouse バージョン < 26.4 では direct read の前提条件でした。
hasToken、hasAllTokens、hasAnyTokens 関数をサポートします。
テキスト索引が array トークナイザーで定義されている場合、direct read は equals、has、hasAny、hasAll、mapContainsKey、mapContainsValue 関数でもサポートされます。
これらの関数は、AND、OR、NOT 演算子で組み合わせることもできます。
WHERE 句または PREWHERE 句には、追加の非テキスト検索関数のフィルタ (テキストカラムまたは他のカラムに対するフィルタ) を含めることもできます。この場合でも direct read 最適化は使用されますが、効果はやや低下します (適用されるのはサポート対象のテキスト検索関数のみです) 。
クエリが direct read を利用しているか確認するには、EXPLAIN PLAN actions = 1 を付けてクエリを実行します。
例として、direct read を無効にしたクエリは
query_plan_direct_read_from_text_index = 1 を指定して実行すると
__text_index_<index_name>_<function_name>_<id> が含まれます。
このカラムが存在する場合、direct read が使用されています。
WHERE フィルタ句にテキスト検索関数しか含まれていない場合、クエリはカラムデータをまったく読み取らずに済むため、direct read によるパフォーマンス上のメリットを最大限に得られます。
ただし、クエリ内のほかの箇所でテキストカラムにアクセスしている場合でも、direct read によってパフォーマンス改善は得られます。
ヒントとしての direct read
ヒントとしての direct read は、通常の direct read と同じ原理に基づきますが、基になるテキストカラムを除外する代わりに、テキスト索引データから構築した追加のフィルタを加えます。
これは、テキスト索引だけを読み取ると偽陽性が発生する関数で使用されます。
サポートされている関数は次のとおりです: like, startsWith, endsWith, equals, has, hasPhrase, mapContainsKey, mapContainsValue。
この追加フィルタは、ほかのフィルタと組み合わせることで結果セットをさらに絞り込むための選択性を高め、他のカラムから読み取るデータ量の削減に役立ちます。
ヒントとしての direct read は、設定 query_plan_text_index_add_hint で制御されます (デフォルトで有効) 。
ヒントなしのクエリの例:
query_plan_text_index_add_hint = 1 を指定して同じクエリを実行した場合は
__text_index_...) がフィルタ条件に加えられていることがわかります。
PREWHERE の最適化により、フィルタ条件は3つの個別の論理積条件に分解され、計算コストの低い順に適用されます。
このクエリでは、適用順は __text_index_...、次に greaterOrEquals(...)、最後に like(...) です。
この順序により、WHERE 句の後でクエリ内で使用される重いカラムを読み取る前に、テキスト索引と元のフィルタでスキップされるグラニュールよりもさらに多くのデータグラニュールをスキップでき、読み取るデータ量をいっそう削減できます。
LIKE/ILIKE クエリ
%<スペースを含まない英数字文字>% で、テキスト索引のトークナイザーが splitByNonAlpha または array の場合、ClickHouse は転置索引を利用して LIKE/ILIKE クエリを大幅に高速化します。これを実現するために、ClickHouse は一致するパターンを見つける際、テーブル全体をスキャンする代わりに転置索引の Dictionary をスキャンします。
この最適化が有効な場合、LIKE/ILIKE クエリはテーブル全体のスキャンより大幅に高速になるはずです。ただし、パターンが Dictionary 内のトークンの大半に一致する場合は、テーブル全体のスキャンと比べて性能が悪化することがあります。幸い、それを防ぐためのフォールバックの仕組みがあります。
この最適化は、次の設定で制御されます。
フォールバックの仕組みは、次の 2 つの設定で制御されます。
この最適化でサポートされるのは、関数 like と ilike のみです。
キャッシュ
テキスト索引トークンキャッシュの設定
ヘッダーキャッシュの設定
ポスティングリスト cache の設定
制限事項
- トークン数が非常に多いテキスト索引 (例: 100 億トークン) のマテリアライズでは、大量のメモリを消費する可能性があります。テキスト
索引のマテリアライズは、直接 (
ALTER TABLE <table> MATERIALIZE INDEX <index>) 行われる場合と、パーツのマージで間接的に行われる場合があります。 - 4,294,967,296 (= 2^32 = 約 42 億) 行を超えるパーツでは、テキスト索引をマテリアライズできません。テキスト索引がマテリアライズされていない場合、クエリはそのパーツ内での低速な総当たり検索にフォールバックします。最悪ケースの見積もりとして、パーツには String 型のカラムが 1 つだけ含まれ、MergeTree setting
max_bytes_to_merge_at_max_space_in_pool(デフォルト: 150 GB) が変更されていないと仮定してください。この場合、そのカラムの 1 行あたりの平均文字数が 29.5 文字未満であれば、この状況が発生します。実際には、テーブルにはほかのカラムも含まれるため、しきい値はこれより何倍も小さくなります (ほかのカラムの数、型、サイズに依存します) 。
テキスト索引とブルームフィルタベースの索引の違い
bloom_filter、ngrambf_v1、tokenbf_v1、sparse_grams) によって高速化できますが、両者は設計と想定ユースケースの点で本質的に異なります。
ブルームフィルタ索引
- 偽陽性が発生しうる確率的データ構造に基づいています。
- 集合への所属判定、つまりそのカラムにトークン X が含まれている可能性があるか、あるいは確実に含まれていないか、ということしか判定できません。
- クエリ実行時に大まかな範囲をスキップできるよう、granule レベルの情報を格納します。
- 適切にチューニングするのが難しいです (例は こちら を参照) 。
- 比較的コンパクトです (1 パーツあたり数 KB ~数 MB) 。
- トークンに対して決定論的な転置索引を構築します。索引自体による偽陽性は発生しません。
- テキスト検索ワークロード向けに特化して最適化されています。
- 効率的な用語ルックアップを可能にするため、行レベルの情報を格納します。
- 比較的大きくなります (1 パーツあたり数十~数百 MB) 。
- 高度なトークン化や前処理には対応していません。
- 複数トークンの検索には対応していません。
- 転置索引に期待されるような性能特性は得られません。
- トークン化と前処理を提供します
hasAllTokens、LIKE、matchなどのテキスト検索関数を効率的にサポートします。- 大規模なテキストコーパスに対して、はるかに優れたスケーラビリティを発揮します。
実装の詳細
- 各トークンをポスティングリストに対応付けるDictionary
- それぞれが行番号の集合を表す、ポスティングリストの集合
dictionary_block_size で設定できます) 。
Dictionaryブロックファイル (.dct) には、パーツ内のすべてのインデックスグラニュールに含まれるすべてのDictionaryブロックが格納されます。
索引ヘッダーファイル (.idx)
索引ヘッダーファイルには、各Dictionaryブロックについて、そのブロックの先頭トークンと、Dictionaryブロックファイル内での相対オフセットが格納されます。
このスパースインデックス構造は、ClickHouse のスパース主キー索引) に似ています。
ポスティングリストファイル (.pst)
すべてのトークンのポスティングリストは、ポスティングリストファイル内に順番に配置されます。
容量を節約しつつ高速な積集合およびユニオン操作を可能にするため、ポスティングリストは roaring bitmaps として格納されます。
ポスティングリストが posting_list_block_size より大きい場合は、複数のブロックに分割され、ポスティングリストファイルに順番に格納されます。
位置ファイル (.pos)
任意。索引引数 support_phrase_search = 1 の場合のみ作成されます。
一致した行内におけるトークンの位置を格納します。
テキスト索引のマージ
データパーツがマージされる際、テキスト索引を最初から再構築する必要はありません。代わりに、マージ処理内の別ステップで効率的にマージできます。
このステップでは、各入力パーツのテキスト索引にあるソート済みDictionaryを読み込み、新しい統合Dictionaryへ結合します。
また、ポスティングリスト内の行番号も、初期マージフェーズで作成された旧行番号から新行番号への対応関係を用いて、マージ後のデータパーツ内での新しい位置を反映するよう再計算されます。
このテキスト索引のマージ方法は、_part_offset カラムを持つ projections のマージ方法に似ています。
ソースパーツ内で索引がマテリアライズされていない場合は、索引を構築して一時ファイルに書き込み、その後、ほかのパーツの索引およびほかの一時索引ファイルの索引とともにマージされます。
デバッグ
テーブル関数 mergeTreeTextIndex を使用すると、テキスト索引の内部を調査できます。
例: Hacker News データセット
hackernewsテーブルに挿入してみましょう:
ALTER TABLE を使用して comment カラムにテキスト索引を追加し、その後マテリアライズします:
hasToken、hasAnyTokens、hasAllTokens 関数を使ってクエリを実行してみましょう。
以下の例では、通常の索引スキャンと direct read 最適化の間にある大きな性能差を示します。
1. hasToken を使用する
hasToken は、テキストに特定の単一トークンが含まれているかどうかを確認します。
大文字と小文字を区別するトークン ‘ClickHouse’ を検索します。
direct read 無効 (標準スキャン)
デフォルトでは、ClickHouse はスキップ索引を使ってグラニュールをフィルタリングし、その後、それらのグラニュールのカラムデータを読み取ります。
この動作は、direct read を無効にすることで再現できます。
2. hasAnyTokens の使用
hasAnyTokens は、テキストに指定したトークンのうち少なくとも 1 つが含まれているかどうかを判定します。
‘love’ または ‘ClickHouse’ を含むコメントを検索します。
Direct read 無効 (標準スキャン)
3. hasAllTokens の使用
hasAllTokens は、テキストに指定したすべてのトークンが含まれているかどうかを判定します。
‘love’ と ‘ClickHouse’ の両方を含むコメントを検索します。
Direct read 無効時 (標準スキャン)
Direct read が無効でも、標準のスキップ索引は引き続き有効です。
28.7M 行を 147.46K 行まで絞り込めますが、それでもカラムから 57.03 MB を読み取る必要があります。
4. 複合検索: OR, AND, NOT, …
hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) を使うほうが、より効率的で推奨される構文です。
- ブログ: ClickHouse 全文検索の一般提供を発表
- ブログ: オブジェクトストレージ向けの高性能な全文検索の構築
- ビデオ: ClickHouse における全文検索の紹介
- ビデオ: 舞台裏: ClickHouse のスケールと速度を支える全文検索
- プレゼンテーション: ClickHouse 全文検索の内部: 高速・ネイティブ・列指向
- プレゼンテーション: 転置データベース索引: 必要性、概要、実装方法、FOSDEM 2026
- ブログ: ClickHouse における転置索引の紹介
- ブログ: ClickHouse 全文検索の内部: 高速・ネイティブ・列指向
- ビデオ: 全文索引: 設計と実験