Skip to main content
Текстовые индексы (также известные как инвертированные индексы) обеспечивают быстрый полнотекстовый поиск по текстовым данным. Текстовый индекс хранит сопоставление токенов с номерами строк, в которых встречается каждый токен. Токены создаются в процессе, называемом токенизацией. Например, стандартный токенизатор ClickHouse преобразует английское предложение “The cat likes mice.” в токены [“The”, “cat”, “likes”, “mice”]. В качестве примера рассмотрим таблицу с одним столбцом и тремя строками
Соответствующие токены:
Обычно мы предпочитаем регистронезависимый поиск, поэтому приводим токены к нижнему регистру:
Мы также удалим стоп-слова, такие как “I”, “the” и “and”, поскольку они встречаются почти в каждой строке:
Текстовый индекс в таком случае (концептуально) содержит следующую информацию:
По заданному поисковому токену эта структура индекса позволяет быстро находить все совпадающие строки.

Создание текстового индекса

Текстовые индексы стали общедоступными (GA) в ClickHouse 26.2 и более новых версиях. В этих версиях для использования текстового индекса не требуется настраивать какие-либо специальные параметры. Мы настоятельно рекомендуем использовать ClickHouse версии >= 26.2 в продакшне.
Текстовые индексы можно использовать в любой версии ClickHouse >= 26.2 независимо от настройки compatibility.
Чтобы создать текстовый индекс, используйте следующий синтаксис:
Query
Текстовые индексы можно создавать для столбцов следующих типов: Также поддерживаются столбцы типа Nullable(T) и LowCardinality(), включая Array(Nullable(String or FixedString)). Кроме того, текстовый индекс можно добавить в существующую таблицу:
Query
Если вы добавляете индекс в существующую таблицу, мы рекомендуем материализовать его для существующих частей таблицы (иначе при поиске по частям без индекса будет использоваться медленное полное сканирование).
Query
Чтобы удалить текстовый индекс, выполните
Query
Аргумент tokenizer (обязательный). Аргумент tokenizer задаёт используемый токенизатор:
  • splitByNonAlpha разбивает строки по неалфавитно-цифровым ASCII-символам (см. функцию splitByNonAlpha).
  • splitByString(S) разбивает строки по заданным пользователем строкам-разделителям S (см. функцию splitByString). Разделители можно задать с помощью необязательного параметра, например, tokenizer = splitByString([', ', '; ', '\n', '\\']). Обратите внимание, что каждая строка может состоять из нескольких символов (', ' в примере). Список разделителей по умолчанию, если он не указан явно (например, tokenizer = splitByString), — это один пробел [' '].
  • asciiCJK разбивает строки на токены, используя правила границ слов Unicode (аналогично Unicode Text Segmentation (UAX #29)). ASCII-буквенно-цифровые символы и символы подчёркивания образуют токены с соединительными символами (ASCII : для букв, . и ' для символов одного типа). Не-ASCII-символы Unicode, включая символы CJK, становятся односимвольными токенами.
  • ngrams(N) разбивает строки на N-граммы одинаковой длины (см. функцию ngrams). Длину n-граммы можно задать с помощью необязательного целочисленного параметра от 1 до 8, например, tokenizer = ngrams(3). Размер n-граммы по умолчанию, если он не указан явно (например, tokenizer = ngrams), равен 3.
  • sparseGrams(min_length, max_length, min_cutoff_length) разбивает строки на n-граммы переменной длины, содержащие не менее min_length и не более max_length (включительно) символов (см. функцию sparseGrams). Если не указано явно, значения min_length и max_length по умолчанию равны 3 и 100. Если передан параметр min_cutoff_length, возвращаются только n-граммы длиной не меньше min_cutoff_length. По сравнению с ngrams(N), токенизатор sparseGrams создаёт N-граммы переменной длины, что позволяет более гибко представлять исходный текст. Например, tokenizer = sparseGrams(3, 5, 4) внутренне генерирует из входной строки 3-, 4- и 5-граммы, но возвращаются только 4- и 5-граммы.
  • array не выполняет токенизацию, то есть каждое значение строки является токеном (см. функцию array).
Все доступные токенизаторы перечислены в system.tokenizers.
Токенизатор splitByString применяет разделители слева направо. Это может создавать неоднозначности. Например, строки-разделители ['%21', '%'] приведут к тому, что %21abc будет токенизировано как ['abc'], тогда как при перестановке разделителей на ['%', '%21'] результатом будет ['21abc']. В большинстве случаев лучше, чтобы при сопоставлении сначала выбирались более длинные разделители. Обычно этого можно добиться, передавая строки-разделители в порядке убывания длины. Если строки-разделители образуют префиксный код, их можно передавать в произвольном порядке.
Чтобы понять, как токенизатор разбивает входную строку, можно использовать функции tokens и tokensForLikePattern: Пример:
Query
Response
Работа с не-ASCII-входными данными. Текстовые индексы можно строить на основе текстовых данных на любом языке и в любой кодировке. Для не-ASCII-текста рекомендуется токенизатор asciiCJK, поскольку он корректно определяет границы слов в Unicode, включая символы CJK. Аргумент препроцессора (необязательно). Препроцессор — это выражение, которое применяется к входной строке перед токенизацией. Типичные сценарии использования аргумента препроцессора:
  1. Приведение к нижнему/верхнему регистру или свёртка регистра для регистронезависимого сопоставления, например lower, lowerUTF8, caseFoldUTF8.
  2. Нормализация UTF-8, например normalizeUTF8NFC, normalizeUTF8NFD, normalizeUTF8NFKC, normalizeUTF8NFKD, normalizeUTF8NFKCCasefold, toValidUTF8.
  3. Удаление или преобразование нежелательных символов или подстрок, например диакритических знаков, с помощью extractTextFromHTML, substring, idnaEncode, translate, removeDiacriticsUTF8.
Выражение препроцессора должно преобразовывать входное значение типа String или FixedString в значение того же типа. Если текстовый индекс был создан по столбцу типа 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))
Использование недетерминированных функций не допускается.
Препроцессоры, по сути, эквивалентны оборачиванию индексируемого столбца или выражения выражением препроцессора. Например, препроцессор lower в INDEX idx col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(col)) можно эмулировать с помощью INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha'). Недостаток второй формы в том, что эмулированный препроцессор применяется только в том случае, если он соответствует условию фильтрации в предложении WHERE. Например, WHERE hasAllTokens(lower(col), [...]) соответствует, а WHERE hasAllTokens(col, [...]) — нет. Поэтому для оптимального пользовательского опыта мы рекомендуем использовать выражения препроцессора.
Функции hasToken, hasAllTokens, hasAnyTokens и hasPhrase используют препроцессор, чтобы сначала преобразовать поисковый запрос перед его токенизацией. Обратите внимание, что, поскольку препроцессор применяется только по пути текстового индекса, результаты этих функций могут различаться между запросами, использующими текстовый индекс, и запросами, которые его не используют (например, SETTINGS use_skip_indexes = 0). Например,
Query
эквивалентно:
Query
В этом случае выражение препроцессора преобразует каждый элемент массива отдельно. Пример:
Query
Чтобы определить препроцессор в текстовом индексе, создаваемом для столбцов типа Map, пользователям нужно решить, строится ли индекс по ключам или по значениям map. Пример:
Query
Аргумент постпроцессор (необязательно). Постпроцессор — это выражение, применяемое к каждому выходному токену после токенизации. В отличие от препроцессора, который преобразует всю входную строку до того, как токенизатор разобьет ее на токены, постпроцессор работает с самими токенами — по одному за раз. Это естественное место для преобразований, которые по своей сути выполняются на уровне токенов. Типичные сценарии использования аргумента постпроцессор включают:
  1. Фильтрация стоп-слов (чрезвычайно частых токенов). Очень распространенные токены, такие как “the”, “a” и “is”, почти не влияют на релевантность поиска и раздувают индекс. Вы можете использовать постпроцессор, чтобы отбрасывать их, преобразуя в пустые токены — пустые токены игнорируются, то есть не добавляются в индекс. Пример: if(str IN ('the', 'a', 'an', 'of', 'in', 'is', 'it'), '', str)
  2. Удаление временных меток. Строки Log часто начинаются со структурированной временной метки, например 2024-01-15T10:23:45, или содержат ее. Индексация токенов временных меток раздувает индекс строками, не имеющими значения для релевантности поиска. Есть два взаимодополняющих способа игнорировать временные метки:
    • Подход с постпроцессором: используйте токенизатор splitByString (разбиение по пробельным символам), чтобы вся временная метка стала одним токеном, а затем используйте parseDateTimeOrNull, чтобы распознать и отбросить ее. Пример: if(isNull(parseDateTimeOrNull(str, '%Y-%m-%dT%H:%i:%S')), str, '') Для временных меток со смещением часового пояса или дробными секундами используйте parseDateTimeBestEffortOrNull(str) без явной строки формата.
    • Подход с препроцессором: удалите временную метку из полной строки лога до токенизации с помощью regular expression. Пример: replaceRegexpAll(str, '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2} ', '') Это работает с любым токенизатором и эффективнее, поскольку символы временной метки вообще не токенизируются. Оба подхода можно комбинировать: препроцессор удаляет временную метку, а постпроцессор нормализует или фильтрует оставшиеся токены (например, приводит к нижнему регистру и отбрасывает слова уровня серьезности, такие как ERROR или INFO).
  3. Стемминг. Сопоставление каждого токена с его основой улучшает полноту поиска, позволяя находить морфологические варианты с общим корнем. Например, при английском стемминге “running”, “runs” и “run” приводятся к основе “run”, поэтому запрос по любому из этих вариантов найдет их все. ClickHouse предоставляет встроенную функцию stem для нескольких языков. Пример: stem(str, 'en')
  4. Нормализация регистра. Приведение токенов к нижнему или верхнему регистру для включения сопоставления, например lower, lowerUTF8. Для приведения к нижнему или верхнему регистру мы рекомендуем использовать препроцессор вместо постпроцессора.”
Выражение постпроцессора преобразует токены типа String в токены того же типа. Кроме того, выражение постпроцессора должно ссылаться только на столбец или выражение, на основе которого определён текстовый индекс. Если столбец имеет тип Array(String), постпроцессор по-прежнему оперирует отдельными токенами как обычными значениями типа String. Использование недетерминированных функций запрещено. Постпроцессор применяется к каждому сгенерированному токену при построении индекса (для токенизатора array каждый элемент массива является токеном). Во время выполнения запроса поведение зависит от функции:
  • Для hasToken, hasAllTokens, hasAnyTokens и hasPhrase (с любым поддерживаемым токенизатором): постпроцессор применяется и к токенам в haystack, и к поисковому needle, обеспечивая полностью нормализованное сопоставление (например, регистронезависимый поиск). Для hasPhrase токены после постобработки располагаются без промежутков, поэтому токен, который постпроцессор отбрасывает, не оставляет позиционного разрыва, и фраза всё равно сопоставляется через него — например, при постпроцессоре стоп-слов, отбрасывающем the, hasPhrase(col, 'see cat') соответствует документу see the cat.
  • Для всех остальных функций (=, IN, has, hasAny, hasAll, mapContains*): для поиска с использованием индексной подсказки постобработке подвергается только needle; предикат на уровне строки по-прежнему сравнивается с исходными значениями столбца.
Примеры:
  • Удаление стоп-слов с помощью выражения постпроцессора:
  • Удалите временные метки с помощью выражения постпроцессора:
  • Удалите временные метки с помощью выражения препроцессора:
  • Удалите временные метки с помощью общего выражения для препроцессора и постпроцессора:
  • Примените стемминг к токенам с помощью выражения постпроцессора:
Поддержка функций. Для предикатов, использающих текстовый индекс, препроцессор и постпроцессор применяются к искомому значению перед проверкой на уровне гранулы, чтобы при обращении к индексу использовались те же токены, которые были сохранены при его построении. Для большинства функций (=, IN, startsWith, endsWith, LIKE, mapContains*) текстовый индекс используется только для пропуска нерелевантных блоков данных; ClickHouse по-прежнему проверяет каждую оставшуюся строку по исходному предикату на исходных данных столбца. Для функций поиска по токенам (hasToken, hasAllTokens, hasAnyTokens) текстовый индекс является основным способом вычисления: ClickHouse нормализует искомое значение с помощью того же препроцессора, токенизатора и постпроцессора, которые применялись при построении индекса, и использует эту нормализованную форму как для индексированных, так и для неиндексированных частей таблицы. Если задан постпроцессор, токены в haystack также нормализуются во время выполнения запроса (для любого токенизатора, а не только array), поэтому обе стороны сравнения преобразуются единообразно, и результат не зависит от того, читается ли индекс напрямую (настройка query_plan_direct_read_from_text_index) или у конкретной части есть материализованный индекс — например, это позволяет включить регистронезависимое сопоставление для hasAllTokens(col, ['FOO']) с постпроцессором lower. Без support_phrase_search функция hasPhrase использует индекс только как подсказку и проверяет каждую оставшуюся строку по исходному предикату; постпроцессор дополнительно одинаково нормализует и фразу, и токены в haystack, поэтому результат не зависит от способа чтения, а токены, которые постпроцессор отбрасывает, не нарушают смежность фразы. При support_phrase_search = 1 функция hasPhrase использует точное прямое чтение (при этом постпроцессор, если он задан, всё равно применяется). Поисковые токены, которые постпроцессор преобразует в пустую строку, игнорируются, то есть считаются отсутствующими в поисковой фразе. ¹ LIKE и match используют прямое чтение как подсказку для указанных токенизаторов; в остальных случаях используется сканирование полным перебором. LIKE также поддерживает прямое чтение (без подсказки) (включается через use_text_index_like_evaluation_by_dictionary_scan) для токенизаторов splitByNonAlpha и array без препроцессора и постпроцессора. ² ILIKE поддерживается только через прямое чтение (без подсказки) (use_text_index_like_evaluation_by_dictionary_scan = 1, токенизатор splitByNonAlpha или array). Перехода к использованию индекса как подсказки нет: если настройка отключена или токенизатор не входит в поддерживаемый набор, индекс для ILIKE не используется. Препроцессор, если он задан, должен быть lower или upper; постпроцессоры не поддерживаются. Экспериментально: аргумент для поддержки поиска по фразе (необязательный). Экспериментальный параметр support_phrase_search (по умолчанию: 0) определяет, хранит ли индекс позиции токенов. Если указать значение 1, индекс дополнительно сохраняет позиционные данные (в файле .pos), что позволяет выполнять точный поиск по фразе с помощью прямого чтения для функции hasPhrase. Хранение позиций увеличивает размер индекса на диске и стоимость записи, поэтому эта возможность включается только явно. Формат хранения на диске пока не является стабильным, поэтому этот параметр экспериментальный и может измениться в одном из будущих релизов. Поэтому для создания индекса с support_phrase_search = 1 требуется, чтобы была включена настройка MergeTree allow_experimental_text_index_phrase_search. Установите support_phrase_search = 0 (значение по умолчанию), чтобы сохранить хранение только списков вхождений; текстовые индексы, созданные без этого аргумента, остаются без позиций.
Этот аргумент является экспериментальным и должен использоваться только для тестирования. Чтобы включить хранение позиций, установите настройку MergeTree allow_experimental_text_index_phrase_search.
Гранулярность индекса. Текстовые индексы реализованы в ClickHouse как тип индексов пропуска данных. Однако, в отличие от других индексов пропуска данных, текстовые индексы используют бесконечную гранулярность (100 миллионов). Это видно в определении таблицы для текстового индекса. Пример:
Query
Response
Очень большая гранулярность индекса гарантирует, что текстовый индекс создаётся для всей части. Явно заданная гранулярность индекса игнорируется.

Использование текстового индекса

Использовать текстовый индекс в запросах SELECT просто: распространённые функции поиска по строкам автоматически задействуют индекс. Если на столбце или части таблицы нет индекса, функции поиска по строкам будут выполнять медленное полное сканирование.
Мы рекомендуем использовать функции 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-граммы или больше неё. Пример для текстового индекса с токенизатором splitByNonAlpha:
support в примере может соответствовать support, supports, supporting и т. д. Такой запрос является поиском по подстроке, и его нельзя ускорить с помощью текстового индекса. Чтобы использовать текстовый индекс для запросов LIKE, шаблон LIKE нужно переписать следующим образом:
Пробелы слева и справа от support гарантируют, что этот термин можно извлечь как токен. К счастью, есть особый случай, когда ClickHouse может использовать инвертированный индекс, чтобы значительно ускорить LIKE-запросы. Подробнее см. в разделе Настройка производительности для LIKE/ILIKE.

multiSearchAny and multiMatchAny

multiSearchAny и его вариант для UTF-8 multiSearchAnyUTF8 проверяют, встречается ли в строке хотя бы одна из нескольких буквальных подстрок, а multiMatchAny проверяет, соответствует ли строка хотя бы одному из нескольких регулярных выражений. Эти функции используют текстовый индекс при тех же условиях, что и LIKE и match (см. выше): ClickHouse должен иметь возможность извлечь полные токены из каждой искомой подстроки, а список искомых подстрок должен быть константным. Гранула считывается, если в ней может присутствовать хотя бы одна искомая подстрока. Для multiMatchAny, если отдельный шаблон нельзя свести к требованию наличия токена (например, .*, который соответствует любому документу), текстовый индекс использовать нельзя, и запрос переходит к полному сканированию. Как и в случае с LIKE и match, поиск по подстрокам и регулярным выражениям лучше всего работает с токенизаторами ngrams и sparseGrams. Эти токенизаторы индексируют перекрывающиеся символьные n-граммы, поэтому искомая подстрока раскладывается на n-граммы, которые присутствуют в индексе везде, где она встречается как подстрока, независимо от того, начинается она или заканчивается в середине слова. Поэтому искомую подстроку можно использовать как есть, если её длина не меньше размера n-граммы. Пример текстового индекса с токенизатором ngrams:
Токенизатор splitByNonAlpha, напротив, индексирует только полные токены (целые слова). Поскольку искомая подстрока может начинаться или заканчиваться в середине слова, ClickHouse отбрасывает первый и последний токены каждой искомой подстроки, поэтому индекс может отсеивать гранулы, используя только полные токены. Чтобы при поиске по подстроке и регулярному выражению использовался индекс с splitByNonAlpha, окружайте каждую искомую подстроку символами-разделителями (например, пробелами), чтобы она образовывала один или несколько полных токенов. Пример текстового индекса с токенизатором splitByNonAlpha:

startsWith и endsWith

Как и LIKE, функции startsWith и endsWith могут использовать текстовый индекс, только если из поискового выражения можно извлечь полные токены. Для индекса с токенизатором ngrams это возможно, если длина искомых строк между подстановочными шаблонами равна длине n-граммы или больше неё. Если текстовый индекс использует постпроцессор, эти функции всё равно могут использовать индекс в режиме Hint, если извлечённые hint-токены остаются непустыми после нормализации. Если нормализация удаляет все hint-токены, индекс не используется для этого предиката. Пример для текстового индекса с токенизатором splitByNonAlpha:
В этом примере токеном считается только clickhouse. support не считается токеном, поскольку может соответствовать support, supports, supporting и т. д. Чтобы найти все строки, начинающиеся с clickhouse supports, добавьте в конец шаблона поиска пробел:
Аналогично, endsWith следует использовать с пробелом в начале:

hasToken

Функция hasToken на первый взгляд кажется простой в использовании, но при использовании для lookup-операций в текстовых индексах с токенизаторами, отличными от splitByNonAlpha, и/или выражениями препроцессора/постпроцессора у неё есть определённые подводные камни. Вместо неё рекомендуем использовать hasAnyTokens и hasAllTokens.Регистронезависимые варианты hasTokenCaseInsensitive и hasTokenCaseInsensitiveOrNull не учитывают текстовый индекс — они всегда выполняются как полное сканирование строк, даже для столбцов с текстовым индексом. Для регистронезависимого сопоставления используйте препроцессор или постпроцессор lower(...) и комбинируйте его с hasToken / hasAllTokens / hasAnyTokens.
Функция hasToken выполняет сопоставление с одним указанным токеном. В отличие от ранее упомянутых функций, они не токенизируют поисковый запрос (предполагается, что входное значение — это один токен). Пример:

hasAnyTokens and hasAllTokens

Функции hasAnyTokens и hasAllTokens проверяют наличие любого или всех указанных токенов. Эти две функции принимают поисковые токены либо в виде строки, которая будет разбита на токены с помощью того же токенизатора, что и для индексного столбца, либо в виде массива уже обработанных токенов, к которым перед поиском токенизация применяться не будет. Дополнительные сведения см. в документации по функциям. Пример:

hasPhrase

Функция hasPhrase выполняет поиск по фразе: все токены должны идти подряд и в том же порядке, что и в поисковой строке. В отличие от hasAllTokens, для которой достаточно, чтобы все токены присутствовали где угодно, hasPhrase требует, чтобы они образовывали непрерывную последовательность. Поисковая фраза токенизируется с помощью того же токенизатора, который настроен для столбца индекса. Если текстовый индекс использует постпроцессор, поисковая фраза также нормализуется перед поиском по индексу. Обратите внимание, что для функции требуется один из токенизаторов splitByNonAlpha, splitByString, ngrams или asciiCJK. Пример:

has

Функция Array has проверяет наличие одного токена в массиве строк. Пример:

hasAny и hasAll

Функции для работы с массивами hasAny и hasAll проверяют, содержит ли индексируемый столбец типа Array хотя бы одну или все строки из постоянного набора. Пример:

mapContains

Функция mapContains (алиас mapContainsKey) сопоставляет токены, извлечённые из искомой строки, с ключами map. Поведение аналогично функции equals для столбца String. Текстовый индекс используется только в том случае, если он был создан для выражения mapKeys(map). Пример:

mapContainsValue

Функция mapContainsValue сопоставляет токены, извлечённые из искомой строки, со значениями map. Поведение аналогично функции equals для столбца String. Текстовый индекс используется только в том случае, если он был создан для выражения mapValues(map). Пример:

mapContainsKeyLike и mapContainsValueLike

Функции mapContainsKeyLike и mapContainsValueLike сопоставляют шаблон регулярного выражения со всеми ключами или значениями (соответственно) типа Map. Пример:

operator[]

Оператор доступа operator[] можно использовать с текстовым индексом для фильтрации по ключам и значениям. Текстовый индекс используется только в том случае, если он создан для выражений mapKeys(map) или mapValues(map), либо для обоих. Пример:
См. примеры ниже, чтобы узнать, как использовать столбцы типа Array(T) и Map(K, V) с текстовым индексом.

Индексация столбцов Array(String)

Представьте платформу для ведения блога, где авторы помечают свои записи ключевыми словами. Важно, чтобы пользователи могли находить связанный контент, выполняя поиск по топикам или нажимая на них. Рассмотрим следующее определение таблицы:
Без текстового индекса для поиска постов по определённому ключевому слову (например, clickhouse) требуется сканировать все записи:
По мере роста платформы это становится всё медленнее, поскольку запросу приходится проверять каждый массив keywords в каждой строке. Чтобы решить эту проблему с производительностью, мы определяем текстовый индекс для столбца keywords:

Индексация столбцов типа Map

Во многих сценариях использования обсервабилити сообщения логов разбиваются на “компоненты” и сохраняются в подходящих типах данных, например: дата-время для временной метки, enum для уровня логирования и т. д. Поля метрик лучше всего хранить как пары ключ-значение. Командам эксплуатации необходимо эффективно искать в журналах для отладки, расследования инцидентов безопасности и мониторинга. Рассмотрим эту таблицу журналов:
Без текстового индекса поиск по данным типа Map требует полного сканирования всей таблицы:
По мере роста объёма логов эти запросы начинают выполняться медленнее. Решение — создать текстовый индекс для ключей и значений Map. Используйте mapKeys, чтобы создать текстовый индекс, если вам нужно находить логи по именам полей или типам атрибутов:
Используйте mapValues, чтобы создать текстовый индекс, если вам нужно выполнять поиск по фактическому содержимому атрибутов:
Примеры запросов:

Индексация JSON-столбцов

Текстовые индексы можно использовать с JSON-столбцами тремя способами:
  1. Индексы для конкретных подстолбцов — создайте текстовый индекс для известного JSON-пути, как и для обычного столбца. При этом индексируются значения по этому пути.
  2. Индексы на основе путей с JSONAllPaths — индексируют все пути, присутствующие в каждой грануле, чтобы пропускать гранулы, в которых не может быть запрашиваемого пути. Как и в случае со столбцами Map.
  3. Индексы на основе значений с JSONAllValues — индексируют все значения по всем JSON-путям, чтобы ускорить полнотекстовый поиск по любому подстолбцу JSON с помощью одного индекса.

Индексы для определённых подстолбцов

Вы можете создать индекс пропуска данных для любого подстолбца 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 также использует индекс — он пропускает гранулы, в которых путь отсутствует (поскольку в таком случае значение было бы NULL): Пример:
Query
Response

Индексы по значениям с JSONAllValues

Текстовый индекс можно использовать для ускорения поиска по JSON столбцам с помощью функции JSONAllValues. JSONAllValues возвращает все значения из JSON-столбца в виде Array(String). Значения нестроковых типов данных (например, целые числа и массивы) преобразуются в текстовое представление. Текстовый индекс, построенный с использованием JSONAllValues, индексирует эти текстовые представления по всем JSON-путям в каждой строке. Затем этот индекс может ускорять запросы с фильтрацией по отдельным подстолбцам JSON. Когда запрос фильтрует по конкретному подстолбцу (например, data.user_name = 'alice'), текстовый индекс может быстро пропускать строки (и гранулы), в которых токены поиска отсутствуют во всех JSON-значениях.
Индекс может давать ложноположительные срабатывания, если одинаковые токены встречаются в разных JSON-путях. Например, если строка 1 содержит {"a": "hello", "b": "world"}, а запрос ищет data.a = 'world', текстовый индекс не сможет определить, что world относится к пути b, а не a. В таких случаях индекс не будет пропускать строку, а окончательную проверку выполнит фильтр по фактическим данным столбца. Это то же поведение, что и в других сценариях использования текстового индекса, где он выступает в роли быстрого предварительного фильтра.
Создание индекса
Пример определения индекса:
Поддерживаемые шаблоны запросов
После создания индекс может ускорять выполнение запросов по подстолбцам JSON с использованием тех же функций, что и для столбцов String, а также функции equals для всех столбцов. Доступ к подстолбцам:
Доступ к подстолбцу с явным CAST:
Оператор IN:
Например, обычный поиск по текстовому индексу
соответствует всем строкам, содержащим заданные токены в произвольном порядке. В примере строка While she stayed in Tokyo, the weather was great. соответствует условию фильтрации. Напротив, фразовый поиск подразумевает совпадение токенов в заданном порядке. Например,
соответствует любой строке, содержащей последовательность токенов weather in Tokyo, например How is the weather in Tokyo?? Текстовый индекс ускоряет поиск по фразам, пересекая списки вхождений для всех токенов во фразе, чтобы определить гранулы-кандидаты. Затем в пределах этих гранул ClickHouse проверяет точное соседство токенов. Этот процесс относительно затратен и медленнее обычных запросов текстового поиска. Чтобы ускорить запросы поиска по фразам, включите сохранение позиций в текстовом индексе (см. Optional parameters выше). hasPhrase можно использовать вместе с токенизаторами splitByNonAlpha, splitByString, ngrams и asciiCJK. Указанная строка фразы токенизируется токенизатором индекса. Символы-разделители во фразе игнорируются: hasPhrase(text, 'quick+brown') эквивалентно hasPhrase(text, 'quick brown'), если в качестве токенизатора используется splitByNonAlpha.

Пример

Query
Response
Строка 2 ('New weather in York') не подходит, потому что токены расположены в неправильном порядке. Строка 3 ('weather in New Orleans') не подходит, потому что не содержит токен 'York'.

Настройка производительности

Прямое чтение

Некоторые типы текстовых запросов можно значительно ускорить благодаря оптимизации, называемой “прямое чтение”. Пример:
Оптимизация прямого чтения выполняет запрос исключительно с использованием текстового индекса (то есть обращений к текстовому индексу), без доступа к исходному текстовому столбцу. При обращениях к текстовому индексу считывается сравнительно небольшой объем данных, поэтому они работают значительно быстрее, чем обычные индексы пропуска данных в ClickHouse (которые сначала выполняют обращение к индексу пропуска данных, а затем загружают и фильтруют оставшиеся гранулы). Прямое чтение управляется двумя настройками:
  • Настройка query_plan_direct_read_from_text_index (по умолчанию true), которая определяет, включено ли прямое чтение в целом.
  • Настройка use_skip_indexes_on_data_read была обязательным предварительным условием для прямого чтения в версиях ClickHouse < 26.4.
Поддерживаемые функции Оптимизация прямого чтения поддерживает функции hasToken, hasAllTokens и hasAnyTokens. Если для текстового индекса задан токенизатор array, прямое чтение также поддерживается для функций equals, has, hasAny, hasAll, mapContainsKey и mapContainsValue. Эти функции также можно комбинировать с помощью операторов AND, OR и NOT. Секции WHERE или PREWHERE также могут содержать дополнительные фильтры, не связанные с функциями текстового поиска (для текстовых или других столбцов) — в этом случае оптимизация прямого чтения все равно будет использоваться, но менее эффективно (она применяется только к поддерживаемым функциям текстового поиска). Чтобы понять, использует ли запрос прямое чтение, выполните запрос с EXPLAIN PLAN actions = 1. Например, запрос с отключенным прямым чтением
возвращает
в то время как тот же запрос, выполненный с параметром query_plan_direct_read_from_text_index = 1
возвращает
Второй вывод EXPLAIN PLAN содержит виртуальный столбец __text_index_<index_name>_<function_name>_<id>. Если этот столбец присутствует, значит используется прямое чтение. Если условие WHERE содержит только функции текстового поиска, запрос может вовсе не читать данные столбца и получить максимальный прирост производительности за счет прямого чтения. Однако даже если к текстовому столбцу обращаются в других частях запроса, прямое чтение все равно даст прирост производительности. Прямое чтение в качестве подсказки Прямое чтение в качестве подсказки основано на тех же принципах, что и обычное прямое чтение, но дополнительно добавляет фильтр, построенный на основе данных текстового индекса, не исключая при этом исходный текстовый столбец. Оно используется для функций, для которых чтение только из текстового индекса приводило бы к ложноположительным срабатываниям. Поддерживаются следующие функции: like, startsWith, endsWith, equals, has, hasPhrase, mapContainsKey и mapContainsValue. Дополнительный фильтр может повысить селективность и в сочетании с другими фильтрами сильнее ограничить результирующий набор, помогая сократить объем данных, считываемых из других столбцов. Прямое чтение в качестве подсказки управляется настройкой query_plan_text_index_add_hint (включена по умолчанию). Пример запроса без подсказки:
возвращает
в то время как тот же запрос, выполненный при query_plan_text_index_add_hint = 1
возвращает
Во втором выводе EXPLAIN PLAN видно, что в условие фильтрации добавлен дополнительный конъюнкт (__text_index_...). Благодаря оптимизации PREWHERE условие фильтрации разбивается на три отдельных конъюнкта, которые применяются в порядке возрастания вычислительной сложности. Для этого запроса они применяются в следующем порядке: __text_index_..., затем greaterOrEquals(...) и, наконец, like(...). Такой порядок позволяет пропускать ещё больше гранул данных, чем только за счёт текстового индекса и исходного фильтра, ещё до чтения тяжёлых столбцов, используемых в запросе после условия WHERE, что дополнительно уменьшает объём считываемых данных.

Запросы LIKE/ILIKE

Если шаблон запроса LIKE/ILIKE имеет вид %<буквенно-цифровые-символы-без-пробелов>%, а токенизатор текстового индекса — splitByNonAlpha или array, ClickHouse использует инвертированный индекс, чтобы существенно ускорить запросы LIKE/ILIKE. Для этого ClickHouse сканирует словарь инвертированного индекса вместо полного сканирования таблицы, чтобы найти совпадения с шаблоном. Когда эта оптимизация включена, запросы LIKE/ILIKE должны выполняться значительно быстрее, чем при полном сканировании таблицы. Однако если шаблон соответствует большинству токенов в словаре, производительность может быть хуже, чем при полном сканировании таблицы. К счастью, существует fallback-механизм, который помогает этого избежать. Эта оптимизация управляется настройкой: Fallback-механизм управляется двумя настройками: Эта оптимизация поддерживает только функции like и ilike.

Кэширование

Существуют различные кэши уровня сервера, позволяющие хранить части текстового индекса в памяти (см. раздел Подробности реализации): В настоящее время доступны кэши для десериализованных заголовков, токенов и списков вхождений текстового индекса, чтобы сократить I/O. Используйте настройки use_text_index_header_cache, use_text_index_tokens_cache и use_text_index_postings_cache, чтобы отключить для запросов чтение из отдельных кэшей и запись в них. Чтобы очистить кэши, используйте оператор SYSTEM CLEAR TEXT INDEX CACHES Чтобы настроить кэши, обратитесь к следующим настройкам сервера.

Настройки кэша токенов

Настройки кэша заголовков

Настройки кэша списков вхождений

Ограничения

У текстового индекса на данный момент есть следующие ограничения:
  • Материализация текстовых индексов с большим количеством токенов (например, 10 миллиардов токенов) может потреблять значительный объём памяти. Материализация текстового индекса может происходить напрямую (ALTER TABLE <table> MATERIALIZE INDEX <index>) или косвенно во время слияния частей.
  • Невозможно материализовать текстовые индексы для частей, содержащих более 4.294.967.296 (= 2^32 = около 4,2 миллиарда) строк. Без материализованного текстового индекса запросы переходят к медленному полному перебору внутри части. Для оценки наихудшего случая предположим, что часть содержит один столбец типа String и настройка MergeTree max_bytes_to_merge_at_max_space_in_pool (по умолчанию: 150 GB) не изменялась. В этом случае такая ситуация возникает, если в столбце в среднем содержится менее 29,5 символа на строку. На практике таблицы также содержат другие столбцы, и этот порог в несколько раз ниже (в зависимости от количества, типа и размера других столбцов).

Текстовые индексы и индексы на основе фильтра Блума

Предикаты над String можно ускорить с помощью текстовых индексов и индексов на основе фильтра Блума (типы индексов bloom_filter, ngrambf_v1, tokenbf_v1, sparse_grams), однако по устройству и предполагаемым сценариям использования они принципиально различаются: Индексы на основе фильтра Блума
  • Основаны на вероятностных структурах данных, которые могут давать ложноположительные срабатывания.
  • Способны отвечать только на вопросы о принадлежности множеству, то есть столбец может содержать токен X или точно не содержать X.
  • Хранят информацию на уровне гранул, что позволяет пропускать крупные диапазоны при выполнении запроса.
  • Их сложно правильно настроить (пример см. здесь).
  • Они довольно компактны (от нескольких килобайт до нескольких мегабайт на часть).
Текстовые индексы
  • Строят детерминированный инвертированный индекс по токенам. Сам индекс не может давать ложноположительных срабатываний.
  • Специально оптимизированы для полнотекстового поиска.
  • Хранят информацию на уровне строк, что обеспечивает эффективный поиск по терминам.
  • Они довольно велики (от десятков до сотен мегабайт на часть).
Индексы на основе фильтра Блума поддерживают полнотекстовый поиск лишь как «побочный эффект»:
  • Они не поддерживают расширенную токенизацию и предобработку.
  • Они не поддерживают поиск по нескольким токенам.
  • Они не обеспечивают характеристик производительности, ожидаемых от инвертированного индекса.
Текстовые индексы, напротив, изначально предназначены для полнотекстового поиска:
  • Они обеспечивают токенизацию и предобработку
  • Они эффективно поддерживают hasAllTokens, LIKE, match и аналогичные функции текстового поиска.
  • Они значительно лучше масштабируются на больших текстовых корпусах.

Подробности реализации

Каждый текстовый индекс состоит из двух (абстрактных) структур данных:
  • словаря, который сопоставляет каждому токену список вхождений, и
  • набора списков вхождений, каждый из которых представляет собой множество номеров строк.
Текстовый индекс строится для всей части. В отличие от других индексов пропуска данных, текстовый индекс можно слить при слиянии частей данных вместо того, чтобы перестраивать его заново (см. ниже). При создании индекса для каждой части создаются три файла: Файл блоков словаря (.dct) Токены в текстовом индексе сортируются и сохраняются в блоках словаря по 512 токенов в каждом (размер блока настраивается параметром dictionary_block_size). Файл блоков словаря (.dct) содержит все блоки словаря для всех гранул индекса в части. Файл заголовка индекса (.idx) Файл заголовка индекса содержит для каждого блока словаря первый токен блока и его относительное смещение в файле блоков словаря. Эта структура разреженного индекса похожа на разреженный индекс первичного ключа ClickHouse). Файл списков вхождений (.pst) Списки вхождений для всех токенов располагаются последовательно в файле списков вхождений. Чтобы экономить место и при этом обеспечивать быстрые операции пересечения и объединения, списки вхождений хранятся в виде roaring bitmaps. Если список вхождений больше posting_list_block_size, он разбивается на несколько блоков, которые последовательно сохраняются в файле списков вхождений. Файл позиций (.pos) Необязательный, только если аргумент индекса support_phrase_search = 1. Хранит позиции токенов в совпадающих строках. Слияние текстовых индексов При слиянии частей данных текстовый индекс не нужно перестраивать с нуля; вместо этого его можно эффективно слить на отдельном этапе процесса слияния. На этом этапе сортированные словари текстовых индексов каждой входной части считываются и объединяются в новый общий словарь. Номера строк в списках вхождений также пересчитываются, чтобы отразить их новые позиции в слитой части данных, с использованием сопоставления старых и новых номеров строк, которое создаётся на начальном этапе слияния. Этот способ слияния текстовых индексов аналогичен тому, как сливаются проекции со столбцом _part_offset. Если индекс не материализован в исходной части, он строится, записывается во временный файл, а затем сливается вместе с индексами из других частей и из других временных файлов индекса. Отладка Для анализа текстовых индексов можно использовать табличную функцию mergeTreeTextIndex.

Пример: датасет Hacker News

Давайте посмотрим, какой прирост производительности дают текстовые индексы на большом датасете с большим объёмом текста. Мы будем использовать 28,7 млн строк комментариев с популярного сайта Hacker News. Вот таблица без текстового индекса:
28,7 млн строк находятся в файле Parquet в S3 — давайте выполним их вставку в таблицу 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 проверяет, содержит ли текст хотя бы один из указанных токенов. Мы будем искать комментарии, содержащие ‘love’ или ‘ClickHouse’. Прямое чтение отключено (стандартное сканирование)
Прямое чтение включено (быстрое чтение по индексу)
Ускорение для этого распространённого поиска с оператором “OR” ещё более впечатляющее. Запрос выполняется почти в 89 раз быстрее (1.329s против 0.015s), поскольку удаётся избежать полного сканирования столбца.

3. Использование hasAllTokens

hasAllTokens проверяет, содержит ли текст все заданные токены. Мы будем искать комментарии, содержащие и ‘love’, и ‘ClickHouse’. Прямое чтение отключено (стандартное сканирование) Даже при отключённом прямом чтении стандартный индекс пропуска данных всё равно остаётся эффективным. Он сокращает выборку с 28.7M строк до всего 147.46K строк, но при этом всё равно требуется прочитать 57.03 MB из столбца.
Прямое чтение включено (Быстрое чтение по индексу) Прямое чтение выполняет запрос по данным индекса, считывая всего 147.46 KB.
Для этого поиска по “AND” оптимизация прямого чтения работает более чем в 26 раз быстрее (0.184s против 0.007s), чем стандартное сканирование с использованием индекса пропуска данных. Оптимизация прямого чтения также применяется к составным булевым выражениям. Здесь мы выполним регистронезависимый поиск по ‘ClickHouse’ OR ‘clickhouse’. Прямое чтение отключено (стандартное сканирование)
Включено прямое чтение (быстрое чтение по индексу)
За счёт объединения результатов из индекса запрос с прямым чтением выполняется в 34 раза быстрее (0.450s против 0.013s) и позволяет избежать чтения 9.58 GB данных из столбца. Для этого конкретного случая предпочтительным и более эффективным вариантом будет синтаксис hasAnyTokens(comment, ['ClickHouse', 'clickhouse']). Устаревшие материалы
Последнее изменение 23 июля 2026 г.