メインコンテンツへスキップ
メインコンテンツへスキップ

テキストインデックスによる全文検索

Beta feature. Learn more.

テキストインデックス(inverted indexes とも呼ばれます)は、テキストデータに対する高速な全文検索を可能にします。 テキストインデックスは、トークンから、それぞれのトークンを含む行番号への対応関係を格納します。 トークンは、トークナイゼーションと呼ばれる処理によって生成されます。 たとえば、ClickHouse のデフォルトトークナイザーは、英語の文 "The cat likes mice." をトークン ["The", "cat", "likes", "mice"] に変換します。

例として、1 つのカラムと 3 行を持つテーブルを考えます。

1: The cat likes mice.
2: Mice are afraid of dogs.
3: I have two dogs and a cat.

対応するトークンは以下のとおりです:

1: The, cat, likes, mice
2: Mice, are, afraid, of, dogs
3: I, have, two, dogs, and, a, cat

通常、検索は大文字小文字を区別しない形で行うため、トークンを小文字化します。

1: the, cat, likes, mice
2: mice, are, afraid, of, dogs
3: i, have, two, dogs, and, a, cat

また、「I」「the」「and」など、ほぼすべての行に含まれるフィラーワードも削除します。

1: cat, likes, mice
2: mice, afraid, dogs
3: have, two, dogs, cat

テキスト索引には(概念的には)次のような情報が含まれます。

afraid : [2]
cat    : [1, 3]
dogs   : [2, 3]
have   : [3]
likes  : [1]
mice   : [1]
two    : [3]

検索トークンが与えられると、この索引構造によって一致するすべての行を高速に検索できます。

テキストインデックスの作成

テキストインデックスは ClickHouse バージョン 26.2 以降で一般提供 (GA) されています。 これらのバージョンでは、テキストインデックスを使用するために特別な設定は不要です。 本番環境では ClickHouse バージョン 26.2 以上の使用を強く推奨します。

注記

ClickHouse バージョン 26.2 より古いバージョンからアップグレードした場合 (または、たとえば ClickHouse Cloud で自動的にアップグレードされた場合)、compatibility 設定が存在すると、インデックスが無効化されたままになったり、テキストインデックス関連のパフォーマンス最適化が無効化されたりする可能性があります。

If query

SELECT value FROM system.settings WHERE name = 'compatibility';

返り値

25.4

または値を 26.2 より小さくする場合は、テキスト索引を使用するために、さらに 3 つの設定を行う必要があります。

SET enable_full_text_index = true;
SET query_plan_direct_read_from_text_index = true;
SET use_skip_indexes_on_data_read = true;

または、compatibility 設定を 26.2 以降に引き上げることもできます。ただし、これは多くの設定に影響し、通常は事前のテストが必要です。

テキスト索引は、次の構文を使用して、StringFixedStringArray(String)Array(FixedString)、および MapmapKeys および mapValues の map 関数を通じて)のカラムに定義できます。

CREATE TABLE table
(
    key UInt64,
    str String,
    INDEX text_idx(str) TYPE text(
                                -- Mandatory parameters:
                                tokenizer = splitByNonAlpha
                                            | splitByString[(S)]
                                            | ngrams[(N)]
                                            | sparseGrams[(min_length[, max_length[, min_cutoff_length]])]
                                            | array
                                -- Optional parameters:
                                [, preprocessor = expression(str)]
                                -- Optional advanced parameters:
                                [, dictionary_block_size = D]
                                [, dictionary_block_frontcoding_compression = B]
                                [, posting_list_block_size = C]
                                [, posting_list_codec = 'none' | 'bitpacking' ]
                            )
)
ENGINE = MergeTree
ORDER BY key

別の方法として、既存のテーブルにテキスト索引を追加するには、次のようにします:

ALTER TABLE table
    ADD INDEX text_idx(str) TYPE text(
                                -- Mandatory parameters:
                                tokenizer = splitByNonAlpha
                                            | splitByString[(S)]
                                            | ngrams[(N)]
                                            | sparseGrams[(min_length[, max_length[, min_cutoff_length]])]
                                            | array
                                -- Optional parameters:
                                [, preprocessor = expression(str)]
                                -- Optional advanced parameters:
                                [, dictionary_block_size = D]
                                [, dictionary_block_frontcoding_compression = B]
                                [, posting_list_block_size = C]
                                [, posting_list_codec = 'none' | 'bitpacking' ]
                            )

既存のテーブルに索引を追加する場合、既存テーブルのパーツに対してその索引をマテリアライズすることを推奨します(そうしないと、索引のないパーツでの検索は低速な総当たりスキャンにフォールバックします)。

ALTER TABLE table MATERIALIZE INDEX text_idx SETTINGS mutations_sync = 2;

テキスト索引を削除するには、次のコマンドを実行します。

ALTER TABLE table DROP INDEX text_idx;

Tokenizer 引数(必須)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_lengthmax_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 を参照)。

利用可能なすべてのトークナイザーは system.tokenizers に一覧表示されています。

注記

splitByString トークナイザーは、左から右へ順にセパレーターを適用します。 これにより曖昧さが生じる場合があります。 たとえば、セパレーター文字列を ['%21', '%'] と指定すると、%21abc['abc'] にトークナイズされますが、両方のセパレーター文字列を入れ替えて ['%', '%21'] とすると、出力は ['21abc'] になります。 多くの場合、より長いセパレーターが優先的にマッチすることが望ましいです。 これは一般に、セパレーター文字列を長さの降順で指定することで実現できます。 セパレーター文字列が prefix code を構成している場合は、任意の順序で指定できます。

トークナイザーが入力文字列をどのように分割したかを確認するには、tokens 関数を使用できます。

例:

SELECT tokens('abc def', 'ngrams', 3);

結果:

['abc','bc ','c d',' de','def']

非 ASCII 入力の扱い。 テキスト索引は、原理的には任意の言語や文字セットのテキストデータに対して構築できますが、現時点では拡張 ASCII 文字セット、すなわち西欧言語での入力に対してのみ利用することを推奨します。 特に、中国語、日本語、韓国語については、現時点では包括的なインデックス作成サポートが存在しないため、索引サイズが非常に大きくなり、クエリの実行時間も長くなる可能性があります。 これらのケースをより適切に処理するために、今後は言語固有のトークナイザーを追加する予定です。 :::

Preprocessor 引数 (オプション)。Preprocessor とは、トークナイズの前に入力文字列に適用される式を指します。

Preprocessor 引数の典型的なユースケースには次のようなものがあります。

  1. 小文字化または大文字化を行い、大文字小文字を区別しないマッチングを有効にします。例: lowerlowerUTF8(以下の最初の例を参照)。
  2. UTF-8 正規化。例: normalizeUTF8NFCnormalizeUTF8NFDnormalizeUTF8NFKCnormalizeUTF8NFKDtoValidUTF8
  3. 不要な文字や部分文字列の削除または変換。例: extractTextFromHTMLsubstringidnaEncodetranslate

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 式は、テキストインデックスが定義されているカラムまたは式のみを参照しなければなりません。

例:

  • 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))

非決定的関数の使用は許可されていません。

関数 hasTokenhasAllTokenshasAnyTokens は、トークン化する前に検索語句を変換するために preprocessor を使用します。

例えば、

CREATE TABLE table
(
    str String,
    INDEX idx(str) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(str))
)
ENGINE = MergeTree
ORDER BY tuple();

SELECT count() FROM table WHERE hasToken(str, 'Foo');

は以下と同等です:

CREATE TABLE table
(
    str String,
    INDEX idx(lower(str)) TYPE text(tokenizer = 'splitByNonAlpha')
)
ENGINE = MergeTree
ORDER BY tuple();

SELECT count() FROM table WHERE hasToken(str, lower('Foo'));

プリプロセッサは、Array(String) および Array(FixedString) カラムに対しても使用できます。 この場合、プリプロセッサ式は配列の各要素を個別に変換します。

例:

CREATE TABLE table
(
    arr Array(String),
    INDEX idx arr TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(arr))

    -- This is not legal:
    INDEX idx_illegal arr TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = arraySort(arr))
)
ENGINE = MergeTree
ORDER BY tuple();

SELECT count() FROM tab WHERE hasAllTokens(arr, 'foo');

Map 型カラム上のテキスト索引用プリプロセッサを定義するには、索引を Map のキーに対して作成するか、値に対して作成するかを決める必要があります。

例:

CREATE TABLE table
(
    map Map(String, String),
    INDEX idx mapKeys(map)  TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(mapKeys(map)))
)
ENGINE = MergeTree
ORDER BY tuple();

SELECT count() FROM tab WHERE hasAllTokens(mapKeys(map), 'foo');

その他の引数(任意)

オプションの高度なパラメータ

以下の高度なパラメータのデフォルト値は、ほぼすべての状況で適切に機能します。 これらを変更することは推奨しません。

オプションのパラメータ 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 クエリを低速化するため、現時点では推奨されません。

索引の粒度。 テキスト索引は、ClickHouse 内では skip indexes の一種として実装されています。 ただし、他の skip 索引とは異なり、テキスト索引は無限の粒度 (1 億) を使用します。 これはテキスト索引のテーブル定義で確認できます。

例:

CREATE TABLE table(
    k UInt64,
    s String,
    INDEX idx(s) TYPE text(tokenizer = ngrams(2)))
ENGINE = MergeTree()
ORDER BY k;

SHOW CREATE TABLE table;

結果:

┌─statement──────────────────────────────────────────────────────────────┐
│ CREATE TABLE default.table                                            ↴│
│↳(                                                                     ↴│
│↳    `k` UInt64,                                                       ↴│
│↳    `s` String,                                                       ↴│
│↳    INDEX idx s TYPE text(tokenizer = ngrams(2)) GRANULARITY 100000000↴│ <-- here
│↳)                                                                     ↴│
│↳ENGINE = MergeTree                                                    ↴│
│↳ORDER BY k                                                            ↴│
│↳SETTINGS index_granularity = 8192                                      │
└────────────────────────────────────────────────────────────────────────┘

非常に大きな索引の粒度により、テキスト索引はパーツ全体に対して作成されます。 明示的に指定された索引の粒度は無視されます。

テキスト索引の使用

SELECT クエリでテキスト索引を利用するのは容易で、一般的な文字列検索関数は自動的にその索引を活用します。 カラムまたはテーブルパーツに索引が存在しない場合、文字列検索関数は低速な総当たりスキャンにフォールバックします。

注記

テキスト索引の検索には hasAnyTokenshasAllTokens 関数の使用を推奨します。詳しくは下記を参照してください。 これらの関数は、利用可能なすべてのトークナイザーおよびあらゆるプリプロセッサ式と連携して動作します。 他の対応関数は、歴史的にテキスト索引より先に存在していたため、多くの場合で従来の動作(例: プリプロセッサの未対応)を維持する必要があります。

サポートされている関数

WHERE 句または PREWHERE 句でテキスト関数が使用されている場合、テキストインデックスを使用できます。

SELECT [...]
FROM [...]
WHERE string_search_function(column_with_text_index)

=!=

= (equals) と != (notEquals) は、指定された検索語句全体と一致します。

例:

SELECT * from table WHERE str = 'Hello';

テキスト索引は =!= をサポートしますが、array tokenizer を使う場合にのみ、等号/不等号による検索が意味を持ちます(その場合、索引には行全体の値が格納されるためです)。

INNOT IN

IN (in) と NOT IN (notIn) は equals および notEquals 関数と似ていますが、IN はいずれかの検索語に一致するもの、NOT IN はいずれの検索語にも一致しないものにマッチします。

例:

SELECT * from table WHERE str IN ('Hello', 'World');

= および != と同じ制限が適用されます。つまり、INNOT INarray トークナイザーと併用する場合にのみ有効です。

LIKE, NOT LIKE および match

注記

これらの関数がテキストインデックスをフィルタリングに利用するのは、インデックスの tokenizer が splitByNonAlphangrams、または sparseGrams のいずれかである場合に限られます。

テキストインデックスで LIKE (like)、NOT LIKE (notLike)、および match 関数を使用するには、ClickHouse が検索語から完全なトークンを抽出できる必要があります。 ngrams tokenizer を持つインデックスでは、ワイルドカードの間にある検索文字列の長さが ngram の長さ以上であれば、この条件を満たします。

splitByNonAlpha tokenizer を持つテキストインデックスの例:

SELECT count() FROM table WHERE comment LIKE 'support%';

この例の support は、supportsupportssupporting などにマッチし得ます。 この種のクエリは部分文字列クエリであり、テキスト索引によって高速化することはできません。

LIKE クエリでテキスト索引を活用するには、LIKE パターンを次のように書き換える必要があります。

SELECT count() FROM table WHERE comment LIKE ' support %'; -- or `% support %`

support の左右に空白を入れておくことで、その語をトークンとして抽出できるようにします。

startsWithendsWith

LIKE と同様に、関数 startsWithendsWith は、検索語から完全なトークンを抽出できる場合にのみテキストインデックスを使用できます。 ngrams トークナイザーを使用するインデックスでは、ワイルドカードの間にある検索文字列の長さが ngram の長さ以上である場合に、この条件を満たします。

splitByNonAlpha トークナイザーを用いたテキストインデックスの例:

SELECT count() FROM table WHERE startsWith(comment, 'clickhouse support');

この例では、clickhouse だけがトークンとして扱われます。 supportsupportsupportssupporting などにマッチする可能性があるため、トークンとは見なされません。

clickhouse supports で始まるすべての行を検索するには、検索パターンの末尾にスペースを付けてください。

startsWith(comment, 'clickhouse supports ')`

同様に、endsWith を使用する場合は、先頭にスペース(空白)を付けてください。

SELECT count() FROM table WHERE endsWith(comment, ' olap engine');

hasToken および hasTokenOrNull

注記

関数 hasToken は一見すると扱いやすそうに見えますが、デフォルト以外のトークナイザや preprocessor 式を使用する場合にいくつかの落とし穴があります。 代わりに関数 hasAnyTokens および hasAllTokens を使用することを推奨します。

関数 hasTokenhasTokenOrNull は、指定された 1 つのトークンとの照合を行います。

前述の関数とは異なり、検索語句をトークン化しません(入力が 1 つのトークンであることを前提とします)。

例:

SELECT count() FROM table WHERE hasToken(comment, 'clickhouse');

hasAnyTokenshasAllTokens

関数 hasAnyTokenshasAllTokens は、指定されたトークンのいずれか、またはすべてにマッチします。

これら 2 つの関数では、検索トークンは、索引用カラムに対して使用されているものと同じトークナイザでトークナイズされる文字列として指定するか、あるいは検索前にトークナイズを行う必要のない、すでに処理済みのトークン配列として指定できます。 詳細は各関数のドキュメントを参照してください。

例:

-- Search tokens passed as string argument
SELECT count() FROM table WHERE hasAnyTokens(comment, 'clickhouse olap');
SELECT count() FROM table WHERE hasAllTokens(comment, 'clickhouse olap');

-- Search tokens passed as Array(String)
SELECT count() FROM table WHERE hasAnyTokens(comment, ['clickhouse', 'olap']);
SELECT count() FROM table WHERE hasAllTokens(comment, ['clickhouse', 'olap']);

has

配列関数 has は、文字列の配列に含まれる単一のトークンに対してマッチします。

例:

SELECT count() FROM table WHERE has(array, 'clickhouse');

mapContains

mapContains 関数(mapContainsKey のエイリアス)は、検索対象の文字列から抽出されたトークンを map のキーと照合します。 動作は、String カラムに対する equals 関数と同様です。 テキストインデックスが使用されるのは、mapKeys(map) 式に対して作成されている場合のみです。

例:

SELECT count() FROM table WHERE mapContainsKey(map, 'clickhouse');
-- OR
SELECT count() FROM table WHERE mapContains(map, 'clickhouse');

mapContainsValue

mapContainsValue 関数は、検索対象文字列から抽出されたトークンがマップの値に含まれるかどうかを照合します。 挙動は、String カラムに対する equals 関数と類似しています。 テキストインデックスは、mapValues(map) 式に対して作成されている場合にのみ使用されます。

例:

SELECT count() FROM table WHERE mapContainsValue(map, 'clickhouse');

mapContainsKeyLikemapContainsValueLike

関数 mapContainsKeyLikemapContainsValueLike は、マップのすべてのキー(または値)を対象に、パターンとの照合を行います。

例:

SELECT count() FROM table WHERE mapContainsKeyLike(map, '% clickhouse %');
SELECT count() FROM table WHERE mapContainsValueLike(map, '% clickhouse %');

operator[]

アクセス用の operator[] は、テキスト索引と組み合わせて使用することで、キーや値をフィルタリングできます。テキスト索引が使用されるのは、mapKeys(map) または mapValues(map)、あるいはその両方の式に対して作成されている場合のみです。

例:

SELECT count() FROM table WHERE map['engine'] = 'clickhouse';

テキスト索引と併用する Array(T) 型および Map(K, V) 型カラムの例を以下に示します。

Array および Map カラムに対するテキストインデックスの例

Array(String) カラムのインデックス化

ブログプラットフォームを想像してください。そこでは、著者がブログ記事にキーワードを付けてカテゴリ分けしています。 ユーザーには、トピックを検索したりクリックしたりすることで、関連コンテンツを見つけてほしいと考えています。

次のテーブル定義を考えてみてください:

CREATE TABLE posts
(
    post_id UInt64,
    title String,
    content String,
    keywords Array(String)
)
ENGINE = MergeTree
ORDER BY (post_id);

テキスト索引がない場合、特定のキーワード(例:clickhouse)を含む投稿を検索するには、すべてのエントリを走査する必要があります。

SELECT count() FROM posts WHERE has(keywords, 'clickhouse'); -- slow full-table scan - checks every keyword in every post

プラットフォームが成長するにつれて、クエリはすべての行の keywords 配列を走査しなければならないため、処理がますます遅くなっていきます。 このパフォーマンス上の問題を解消するために、カラム keywords に対してテキスト索引を定義します。

ALTER TABLE posts ADD INDEX keywords_idx(keywords) TYPE text(tokenizer = splitByNonAlpha);
ALTER TABLE posts MATERIALIZE INDEX keywords_idx; -- Don't forget to rebuild the index for existing data

Map カラムのインデックス作成

多くのオブザーバビリティのユースケースでは、ログメッセージは「コンポーネント」に分割され、タイムスタンプには日時型、ログレベルには enum 型など、適切なデータ型として保存されます。 メトリクスフィールドは、キーと値のペアとして保存するのが最適です。 運用チームは、デバッグ、セキュリティインシデント、監視のために、ログを効率的に検索する必要があります。

次のような logs テーブルを考えてみます:

CREATE TABLE logs
(
    id UInt64,
    timestamp DateTime,
    message String,
    attributes Map(String, String)
)
ENGINE = MergeTree
ORDER BY (timestamp);

テキストインデックスがない場合、Map データを検索するにはテーブル全体をフルスキャンする必要があります。

-- Finds all logs with rate limiting data:
SELECT * FROM logs WHERE has(mapKeys(attributes), 'rate_limit'); -- slow full-table scan

-- Finds all logs from a specific IP:
SELECT * FROM logs WHERE has(mapValues(attributes), '192.168.1.1'); -- slow full-table scan

ログ量が増えるにつれて、これらのクエリは遅くなります。

解決策は、Map のキーおよび値に対してテキスト索引を作成することです。 フィールド名や属性タイプでログを検索したい場合は、mapKeys を使用してテキスト索引を作成します。

ALTER TABLE logs ADD INDEX attributes_keys_idx mapKeys(attributes) TYPE text(tokenizer = array);
ALTER TABLE posts MATERIALIZE INDEX attributes_keys_idx;

属性の実際の内容を検索する必要がある場合は、mapValues を使用してテキスト索引を作成します。

ALTER TABLE logs ADD INDEX attributes_vals_idx mapValues(attributes) TYPE text(tokenizer = array);
ALTER TABLE posts MATERIALIZE INDEX attributes_vals_idx;

クエリの例:

-- Find all rate-limited requests:
SELECT * FROM logs WHERE mapContainsKey(attributes, 'rate_limit'); -- fast

-- Finds all logs from a specific IP:
SELECT * FROM logs WHERE has(mapValues(attributes), '192.168.1.1'); -- fast

-- Finds all logs where any attribute includes an error:
SELECT * FROM logs WHERE mapContainsValueLike(attributes, '% error %'); -- fast

パフォーマンスチューニング

ダイレクトリード

特定の種類のテキストクエリは、「ダイレクトリード」と呼ばれる最適化によって大幅に高速化されます。

例:

SELECT column_a, column_b, ...
FROM [...]
WHERE string_search_function(column_with_text_index)

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 最適化は、関数 hasTokenhasAllTokens、および hasAnyTokens をサポートします。 テキスト索引が array tokenizer で定義されている場合、関数 equalshasmapContainsKeymapContainsValue に対しても direct read がサポートされます。 これらの関数は、ANDORNOT 演算子で組み合わせることもできます。 WHERE または PREWHERE 句には、(テキストカラムまたは他のカラムに対する)追加の非テキスト検索関数によるフィルタを含めることもできます。この場合でも direct read 最適化は使用されますが、その効果は低くなります(サポートされているテキスト検索関数にのみ適用されるためです)。

あるクエリが direct read を利用しているかを確認するには、そのクエリを EXPLAIN PLAN actions = 1 付きで実行します。 例として、direct read を無効にしたクエリは

EXPLAIN PLAN actions = 1
SELECT count()
FROM table
WHERE hasToken(col, 'some_token')
SETTINGS query_plan_direct_read_from_text_index = 0, -- disable direct read
         use_skip_indexes_on_data_read = 1;

戻り値

[...]
Filter ((WHERE + Change column names to column identifiers))
Filter column: hasToken(__table1.col, 'some_token'_String) (removed)
Actions: INPUT : 0 -> col String : 0
         COLUMN Const(String) -> 'some_token'_String String : 1
         FUNCTION hasToken(col :: 0, 'some_token'_String :: 1) -> hasToken(__table1.col, 'some_token'_String) UInt8 : 2
[...]

一方、同じクエリを query_plan_direct_read_from_text_index = 1 を指定して実行すると

EXPLAIN PLAN actions = 1
SELECT count()
FROM table
WHERE hasToken(col, 'some_token')
SETTINGS query_plan_direct_read_from_text_index = 1, -- enable direct read
         use_skip_indexes_on_data_read = 1;

戻り値

[...]
Expression (Before GROUP BY)
Positions:
  Filter
  Filter column: __text_index_idx_hasToken_94cc2a813036b453d84b6fb344a63ad3 (removed)
  Actions: INPUT :: 0 -> __text_index_idx_hasToken_94cc2a813036b453d84b6fb344a63ad3 UInt8 : 0
[...]

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)が発生しうる関数に対して使用されます。

サポートされている関数は、likestartsWithendsWithequalshasmapContainsKeymapContainsValue です。

この追加フィルタにより、他のフィルタと組み合わせて結果セットをさらに絞り込むための追加の選択性が得られ、他のカラムから読み取るデータ量を削減するのに役立ちます。

ヒントとしての direct read は、query_plan_text_index_add_hint(デフォルトで有効)を設定することで制御できます。

ヒントを使用しないクエリの例:

EXPLAIN actions = 1
SELECT count()
FROM table
WHERE (col LIKE '%some-token%') AND (d >= today())
SETTINGS use_skip_indexes_on_data_read = 1, query_plan_text_index_add_hint = 0
FORMAT TSV

戻り値

[...]
Prewhere filter column: and(like(__table1.col, \'%some-token%\'_String), greaterOrEquals(__table1.d, _CAST(20440_Date, \'Date\'_String))) (removed)
[...]

一方、同じクエリを query_plan_text_index_add_hint = 1 に設定して実行した場合は

EXPLAIN actions = 1
SELECT count()
FROM table
WHERE col LIKE '%some-token%'
SETTINGS use_skip_indexes_on_data_read = 1, query_plan_text_index_add_hint = 1

戻り値

[...]
Prewhere filter column: and(__text_index_idx_col_like_d306f7c9c95238594618ac23eb7a3f74, like(__table1.col, \'%some-token%\'_String), greaterOrEquals(__table1.d, _CAST(20440_Date, \'Date\'_String))) (removed)
[...]

2つ目の EXPLAIN PLAN の出力では、フィルター条件に追加の連言条件(__text_index_...)が加えられていることが分かります。 PREWHERE 最適化により、フィルター条件は3つの個別の連言条件に分解され、計算コストが低いものから順に適用されます。 このクエリでは、適用順序は __text_index_...、次に greaterOrEquals(...)、最後に like(...) となります。 この順序付けにより、テキスト索引と元のフィルター条件でスキップされるグラニュールに加えて、クエリの WHERE 句以降で使用される重いカラムを読み込む前に、さらに多くのデータグラニュールをスキップできるため、読み取るデータ量を一層削減できます。

キャッシュ

メモリ内でテキストインデックスの一部をバッファリングするために、さまざまなキャッシュを使用できます(Implementation Details セクションを参照)。 現在、I/O を削減するために、デシリアライズ済みの Dictionary ブロック、テキストインデックスのヘッダー、およびポスティングリスト用のキャッシュが用意されています。 これらは、設定 use_text_index_dictionary_cacheuse_text_index_header_cache、および use_text_index_postings_cache によって有効化できます。 デフォルトでは、すべてのキャッシュは無効になっています。 キャッシュをクリアするには、文 SYSTEM CLEAR TEXT INDEX CACHES を使用します。

キャッシュを構成するには、以下のサーバー設定を参照してください。

Dictionary ブロックキャッシュの設定

SettingDescription
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 文字未満であれば、上記の状況が発生します。実際には、テーブルは他のカラムも含んでいるため、閾値は(他のカラムの数、型、サイズに応じて)その何分の一にも小さくなります。

テキストインデックスと Bloom Filter ベースのインデックス

文字列述語はテキストインデックスと Bloom Filter ベースのインデックス(インデックスタイプ bloom_filter, ngrambf_v1, tokenbf_v1, sparse_grams)を使って高速化できますが、両者は設計と想定されるユースケースが根本的に異なります。

Bloom Filter インデックス

  • 確率的データ構造に基づいており、偽陽性が発生する可能性があります。
  • 集合への所属、すなわち「カラムにトークン X が含まれる可能性があるか vs. X を含まないことが確実か」といった問いにのみ答えることができます。
  • クエリ実行時に大まかな範囲スキップを可能にするため、granule 単位の情報を保存します。
  • 適切にチューニングするのが難しいです(例についてはこちらを参照)。
  • 比較的コンパクトです(パートあたり数キロバイトから数メガバイト程度)。

テキストインデックス

  • トークンに対して決定論的な転置インデックスを構築します。インデックス自体が偽陽性を発生させることはありません。
  • テキスト検索ワークロード向けに特化して最適化されています。
  • 行レベルの情報を保持し、効率的なトークンのルックアップを可能にします。
  • 比較的大きくなります(パートあたり数十〜数百メガバイト)。

Bloom Filter ベースのインデックスは、フルテキスト検索をあくまで「副次的な結果」としてのみサポートします。

  • 高度なトークナイズや前処理をサポートしません。
  • 複数トークンの検索をサポートしません。
  • 転置インデックスに期待される性能特性を提供しません。

一方でテキストインデックスは、フルテキスト検索のために専用設計されています。

  • トークナイズおよび前処理を提供します。
  • hasAllTokens, LIKE, match などのテキスト検索関数を効率的にサポートします。
  • 大規模なテキストコーパスに対して、はるかに優れたスケーラビリティを持ちます。

実装の詳細

各テキスト索引は、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 万行を使用します。 以下はテキスト索引を定義していないテーブルです:

CREATE TABLE hackernews (
    id UInt64,
    deleted UInt8,
    type String,
    author String,
    timestamp DateTime,
    comment String,
    dead UInt8,
    parent UInt64,
    poll UInt64,
    children Array(UInt32),
    url String,
    score UInt32,
    title String,
    parts Array(UInt32),
    descendants UInt32
)
ENGINE = MergeTree
ORDER BY (type, author);

約 2,870 万行のデータは S3 上の Parquet ファイルに格納されています。これらを hackernews テーブルに挿入しましょう。

INSERT INTO hackernews
    SELECT * FROM s3Cluster(
        'default',
        'https://datasets-documentation.s3.eu-west-3.amazonaws.com/hackernews/hacknernews.parquet',
        'Parquet',
        '
    id UInt64,
    deleted UInt8,
    type String,
    by String,
    time DateTime,
    text String,
    dead UInt8,
    parent UInt64,
    poll UInt64,
    kids Array(UInt32),
    url String,
    score UInt32,
    title String,
    parts Array(UInt32),
    descendants UInt32');

ALTER TABLE を使用して comment カラムにテキスト索引を追加し、その後マテリアライズします。

-- Add the index
ALTER TABLE hackernews ADD INDEX comment_idx(comment) TYPE text(tokenizer = splitByNonAlpha);

-- Materialize the index for existing data
ALTER TABLE hackernews MATERIALIZE INDEX comment_idx SETTINGS mutations_sync = 2;

では、hasTokenhasAnyTokenshasAllTokens 関数を使ってクエリを実行してみましょう。 次の例では、標準的な索引スキャンと直接読み取り最適化を比較した際の、劇的なパフォーマンス差を示します。

1. hasToken の使用

hasToken は、テキストに特定の 1 つのトークンが含まれているかどうかを確認します。 ここでは大文字小文字を区別してトークン 'ClickHouse' を検索します。

ダイレクトリード無効化(標準スキャン) デフォルトでは、ClickHouse はスキップ索引を使ってグラニュールをフィルタリングし、その後それらのグラニュールのカラムデータを読み取ります。 この挙動は、ダイレクトリードを無効にすることで再現できます。

SELECT count()
FROM hackernews
WHERE hasToken(comment, 'ClickHouse')
SETTINGS query_plan_direct_read_from_text_index = 0;

┌─count()─┐
│     516 │
└─────────┘

1 row in set. Elapsed: 0.362 sec. Processed 24.90 million rows, 9.51 GB

ダイレクトリード有効時(Fast index read) ここでは、ダイレクトリードを有効にした状態(デフォルト)で、同じクエリを実行します。

SELECT count()
FROM hackernews
WHERE hasToken(comment, 'ClickHouse')
SETTINGS query_plan_direct_read_from_text_index = 1;

┌─count()─┐
│     516 │
└─────────┘

1 row in set. Elapsed: 0.008 sec. Processed 3.15 million rows, 3.15 MB

ダイレクトリードクエリは 45 倍以上高速で (0.362 秒 対 0.008 秒)、インデックスだけを読み取ることで処理するデータ量も大幅に削減されます (9.51 GB 対 3.15 MB)。

2. hasAnyTokens を使用する

hasAnyTokens は、テキストに指定したトークンのうち少なくとも 1 つが含まれているかどうかを判定します。 ここでは、'love' または 'ClickHouse' のいずれかを含むコメントを検索します。

Direct read 無効(標準スキャン)

SELECT count()
FROM hackernews
WHERE hasAnyTokens(comment, 'love ClickHouse')
SETTINGS query_plan_direct_read_from_text_index = 0, use_skip_indexes_on_data_read = 0;

┌─count()─┐
│  408426 │
└─────────┘

1 row in set. Elapsed: 1.329 sec. Processed 28.74 million rows, 9.72 GB

ダイレクトリードの有効化(索引の高速読み取り)

SELECT count()
FROM hackernews
WHERE hasAnyTokens(comment, 'love ClickHouse')
SETTINGS query_plan_direct_read_from_text_index = 1, use_skip_indexes_on_data_read = 1;

┌─count()─┐
│  408426 │
└─────────┘

1 row in set. Elapsed: 0.015 sec. Processed 27.99 million rows, 27.99 MB

この一般的な「OR」検索では、高速化の度合いはさらに顕著です。 フルカラムスキャンを回避することで、クエリはほぼ89倍高速になります(1.329秒 vs 0.015秒)。

3. hasAllTokens の使用

hasAllTokens は、テキストが指定されたトークンをすべて含んでいるかどうかをチェックします。 ここでは、loveClickHouse の両方を含むコメントを検索します。

ダイレクトリード無効(標準スキャン) ダイレクトリードを無効化していても、標準のスキップ索引は引き続き機能します。 28.7M 行をわずか 147.46K 行まで絞り込みますが、それでもカラムから 57.03 MB を読み取る必要があります。

SELECT count()
FROM hackernews
WHERE hasAllTokens(comment, 'love ClickHouse')
SETTINGS query_plan_direct_read_from_text_index = 0, use_skip_indexes_on_data_read = 0;

┌─count()─┐
│      11 │
└─────────┘

1 row in set. Elapsed: 0.184 sec. Processed 147.46 thousand rows, 57.03 MB

ダイレクトリード有効(高速な索引読み取り) ダイレクトリードでは索引データ上で処理を行うことでクエリに応答し、147.46 KB 分のデータしか読み取りません。

SELECT count()
FROM hackernews
WHERE hasAllTokens(comment, 'love ClickHouse')
SETTINGS query_plan_direct_read_from_text_index = 1, use_skip_indexes_on_data_read = 1;

┌─count()─┐
│      11 │
└─────────┘

1 row in set. Elapsed: 0.007 sec. Processed 147.46 thousand rows, 147.46 KB

この「AND」条件の検索では、ダイレクトリード最適化のほうが標準的なスキップ索引スキャンよりも 26 倍以上高速(0.184s 対 0.007s)です。

ダイレクトリードの最適化は、複合ブール式にも適用されます。 ここでは、「ClickHouse」または「clickhouse」を対象に、大文字と小文字を区別しない検索を行います。

ダイレクトリード無効(標準スキャン)

SELECT count()
FROM hackernews
WHERE hasToken(comment, 'ClickHouse') OR hasToken(comment, 'clickhouse')
SETTINGS query_plan_direct_read_from_text_index = 0, use_skip_indexes_on_data_read = 0;

┌─count()─┐
│     769 │
└─────────┘

1 row in set. Elapsed: 0.450 sec. Processed 25.87 million rows, 9.58 GB

ダイレクトリードを有効化(索引の高速読み取り)

SELECT count()
FROM hackernews
WHERE hasToken(comment, 'ClickHouse') OR hasToken(comment, 'clickhouse')
SETTINGS query_plan_direct_read_from_text_index = 1, use_skip_indexes_on_data_read = 1;

┌─count()─┐
│     769 │
└─────────┘

1 row in set. Elapsed: 0.013 sec. Processed 25.87 million rows, 51.73 MB

索引からの結果を組み合わせることで、直接読み出しクエリは 34 倍高速(0.450s 対 0.013s)になり、9.58 GB のカラムデータを読み取る必要がなくなります。 このケースでは、hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) が、より効率的で推奨される構文となります。

古い資料