Полнотекстовый поиск с текстовыми индексами
Текстовые индексы (также известные как обратные индексы) обеспечивают быстрый полнотекстовый поиск по текстовым данным. Текстовый индекс хранит отображение от токенов к номерам строк, содержащих каждый токен. Токены создаются процессом, называемым токенизацией. Например, стандартный токенизатор ClickHouse преобразует английское предложение «The cat likes mice.» в токены [«The», «cat», «likes», «mice»].
В качестве примера предположим, что имеется таблица с одним столбцом и тремя строками
Соответствующие токены:
Как правило, мы выполняем поиск без учета регистра, поэтому переводим токены в нижний регистр:
Мы также удалим стоп-слова, такие как "I", "the" и "and", поскольку они встречаются почти в каждой строке:
Текстовый индекс (с концептуальной точки зрения) содержит следующую информацию:
При заданном поисковом токене эта структура индекса позволяет быстро находить все соответствующие строки.
Создание текстового индекса
Текстовые индексы доступны в статусе General Availability (GA) в ClickHouse версии 26.2 и новее. В этих версиях не требуется настраивать какие-либо специальные параметры для использования текстового индекса. Мы настоятельно рекомендуем использовать ClickHouse версии >= 26.2 для производственных сценариев.
Если вы выполнили обновление (или вас обновили, например, в ClickHouse Cloud) с более ранней версии ClickHouse, чем 26.2, наличие настройки compatibility по-прежнему может приводить к отключению индекса и/или отключению оптимизаций производительности, связанных с текстовыми индексами.
Если запрос
возвращает
или любое другое значение, меньшее 26.2, вам потребуется настроить три дополнительных параметра для использования текстового индекса:
В качестве альтернативы вы можете увеличить параметр compatibility до 26.2 или новее, однако это затрагивает множество настроек и обычно требует предварительного тестирования.
Текстовые индексы можно определять для столбцов типов String, FixedString, Array(String), Array(FixedString) и Map (через функции работы с Map mapKeys и mapValues) с использованием следующего синтаксиса:
Также можно добавить текстовый индекс к существующей таблице:
Если вы добавите индекс к существующей таблице, мы рекомендуем материализовать индекс для уже имеющихся частей этой таблицы (иначе поиск по частям без индекса будет сводиться к медленному полному перебору).
Чтобы удалить текстовый индекс, выполните следующую команду
Аргумент tokenizer (обязательный). Аргумент tokenizer задаёт токенизатор:
splitByNonAlphaразбивает строки по небуквенно-цифровым ASCII-символам (см. функцию splitByNonAlpha).splitByString(S)разбивает строки по заданным пользователем строкам-разделителямS(см. функцию splitByString). Разделители можно задать с помощью необязательного параметра, например,tokenizer = splitByString([', ', '; ', '\n', '\\']). Обратите внимание, что каждая строка может состоять из нескольких символов (в примере —', '). Если список разделителей явно не указан (например,tokenizer = splitByString), по умолчанию используется один пробел[' '].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:
Пример:
Результат:
Работа с не-ASCII входными данными. Хотя текстовые индексы в принципе могут быть построены поверх текстовых данных на любом языке и с любым набором символов, в данный момент мы рекомендуем делать это только для входных данных в расширенном наборе символов ASCII, то есть для западноевропейских языков. В частности, для китайского, японского и корейского языков в настоящее время отсутствует полноценная поддержка индексации, что может приводить к потенциально огромным размерам индекса и длительному времени выполнения запросов. В будущем мы планируем добавить специализированные языко-специфичные токенизаторы, чтобы лучше обрабатывать такие случаи. :::
Аргумент препроцессора (необязательный). Под препроцессором здесь понимается выражение, которое применяется к входной строке перед токенизацией.
Типичные варианты использования аргумента препроцессора включают следующее:
- Приведение к нижнему или верхнему регистру для обеспечения регистронезависимого сопоставления, например lower, lowerUTF8 (см. первый пример ниже).
- Нормализация UTF-8, например normalizeUTF8NFC, normalizeUTF8NFD, normalizeUTF8NFKC, normalizeUTF8NFKD, toValidUTF8.
- Удаление или преобразование нежелательных символов или подстрок, например extractTextFromHTML, substring, idnaEncode, translate.
Выражение препроцессора должно преобразовывать входное значение типа 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))
Кроме того, выражение препроцессора должно ссылаться только на столбец или выражение, на основе которых определён текстовый индекс.
Примеры:
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))
Использование недетерминированных функций не допускается.
Функции hasToken, hasAllTokens и hasAnyTokens используют препроцессор для предварительного преобразования поискового запроса перед его токенизацией.
Например,
эквивалентно следующему:
Препроцессор также может использоваться со столбцами типа Array(String) и Array(FixedString). В этом случае выражение препроцессора применяется к элементам массива по отдельности.
Пример:
Чтобы задать препроцессор в текстовом индексе по столбцам типа Map, пользователям необходимо решить, строится ли индекс по ключам или по значениям Map.
Пример:
Дополнительные аргументы (необязательно).
Необязательные расширенные параметры
Значения по умолчанию для следующих расширенных параметров будут хорошо работать практически во всех ситуациях. Мы не рекомендуем их менять.
Необязательный параметр dictionary_block_size (по умолчанию: 512) указывает размер блоков словаря в строках.
Необязательный параметр dictionary_block_frontcoding_compression (по умолчанию: 1) указывает, используют ли блоки словаря front coding для сжатия.
Необязательный параметр posting_list_block_size (по умолчанию: 1048576) указывает размер блоков списка вхождений в строках.
Необязательный параметр posting_list_codec (по умолчанию: none) указывает кодек для списка вхождений:
none- списки вхождений сохраняются без дополнительного сжатия.bitpacking- применяется дифференциальное (дельта) кодирование, за которым следует bit-packing (каждое в пределах блоков фиксированного размера). Замедляет запросы SELECT и в настоящий момент не рекомендуется.
Гранулярность индекса. Текстовые индексы реализованы в ClickHouse как разновидность пропускающих индексов. Однако в отличие от других пропускающих индексов, текстовые индексы используют «бесконечную» гранулярность (100 миллионов). Это можно увидеть в определении таблицы с текстовым индексом.
Пример:
Результат:
Большое значение гранулярности индекса гарантирует, что текстовый индекс создаётся для всей части. Явно указанная гранулярность индекса игнорируется.
Использование текстового индекса
Использование текстового индекса в запросах SELECT просто, так как распространённые строковые функции поиска автоматически используют индекс. Если индекс отсутствует для столбца или части таблицы, строковые функции поиска будут выполнять медленное сканирование по принципу полного перебора.
Мы рекомендуем использовать функции hasAnyTokens и hasAllTokens для поиска по текстовому индексу, см. ниже.
Эти функции работают со всеми доступными токенизаторами и со всеми возможными выражениями препроцессора.
Поскольку другие поддерживаемые функции исторически появились раньше текстового индекса, во многих случаях им пришлось сохранить устаревшее поведение (например, отсутствие поддержки препроцессора).
Поддерживаемые функции
Текстовый индекс можно использовать, если в предложениях WHERE или PREWHERE используются текстовые функции:
= и !=
= (equals) и != (notEquals) сопоставляют весь указанный поисковый термин целиком.
Пример:
Текстовый индекс поддерживает = и !=, однако поиск по равенству и неравенству имеет смысл только с токенизатором array (он приводит к тому, что индекс хранит значения всей строки целиком).
IN и NOT IN
IN (in) и NOT IN (notIn) аналогичны функциям equals и notEquals, но они совпадают либо со всеми (IN), либо ни с одним (NOT IN) из указанных поисковых терминов.
Пример:
Действуют те же ограничения, что и для = и !=, то есть использовать IN и NOT IN имеет смысл только в сочетании с токенизатором array.
LIKE, NOT LIKE и match
В настоящее время эти функции используют текстовый индекс для фильтрации только в том случае, если токенизатор индекса — splitByNonAlpha, ngrams или sparseGrams.
Чтобы использовать LIKE (like), NOT LIKE (notLike) и функцию match с текстовыми индексами, ClickHouse должен иметь возможность извлекать полные токены из поискового шаблона.
Для индекса с токенизатором ngrams это возможно, если длина искомых подстрок между символами подстановки не меньше длины n-граммы.
Пример для текстового индекса с токенизатором splitByNonAlpha:
support в примере может соответствовать support, supports, supporting и т. д.
Такой запрос является запросом по подстроке, и его нельзя ускорить с помощью текстового индекса.
Чтобы использовать текстовый индекс для запросов с LIKE, шаблон LIKE должен быть переформулирован следующим образом:
Пробелы слева и справа от support гарантируют, что термин корректно распознаётся как отдельный токен.
startsWith и endsWith
Аналогично LIKE, функции startsWith и endsWith могут использовать текстовый индекс только в том случае, если из поискового термина можно извлечь полные токены.
Для индекса с токенизатором ngrams это возможно, если длина искомых строк между подстановочными символами не меньше длины ngram.
Пример для текстового индекса с токенизатором splitByNonAlpha:
В этом примере токеном считается только clickhouse.
support не является токеном, поскольку он может совпадать со строками support, supports, supporting и т.д.
Чтобы найти все строки, которые начинаются с clickhouse supports, завершите шаблон поиска пробелом в конце:
Аналогично, функцию endsWith следует использовать с пробелом в начале:
hasToken и hasTokenOrNull
Функция hasToken кажется простой в использовании, но имеет определённые подводные камни при работе с нестандартными токенизаторами и выражениями препроцессора.
Мы рекомендуем вместо неё использовать функции hasAnyTokens и hasAllTokens.
Функции hasToken и hasTokenOrNull выполняют поиск по одному указанному токену.
В отличие от ранее упомянутых функций, они не разбивают поисковый термин на токены (предполагается, что на вход подается один токен).
Пример:
hasAnyTokens и hasAllTokens
Функции hasAnyTokens и hasAllTokens выполняют поиск по одному или всем указанным токенам.
Эти две функции принимают поисковые токены либо в виде строки, которая будет разбита на токены с использованием того же токенизатора, что и для столбца с индексом, либо в виде массива уже обработанных токенов, к которым перед поиском токенизация применяться не будет. См. документацию по функциям для получения дополнительной информации.
Пример:
has
Функция для работы с массивами has проверяет наличие одного токена в массиве строк.
Пример:
mapContains
Функция mapContains (псевдоним mapContainsKey) сопоставляет токены, извлечённые из искомой строки, с ключами в map.
Поведение аналогично функции equals со столбцом типа String.
Текстовый индекс используется только в том случае, если он был создан для выражения mapKeys(map).
Пример:
mapContainsValue
Функция mapContainsValue сопоставляет токены, извлечённые из искомой строки, со значениями отображения (map).
Поведение похоже на функцию equals со столбцом типа String.
Текстовый индекс используется только в том случае, если он был создан на выражении mapValues(map).
Пример:
mapContainsKeyLike и mapContainsValueLike
Функции mapContainsKeyLike и mapContainsValueLike сопоставляют шаблон со всеми ключами или, соответственно, значениями карты.
Пример:
operator[]
Оператор доступа operator[] можно использовать с текстовым индексом при фильтрации ключей и значений. Текстовый индекс используется только в том случае, если он создан для выражений mapKeys(map) или mapValues(map), или для обоих.
Пример:
См. следующие примеры использования столбцов типа Array(T) и Map(K, V) с текстовым индексом.
Примеры для столбцов типа Array и Map с текстовыми индексами
Индексирование столбцов Array(String)
Представьте платформу для блогов, где авторы помечают свои публикации в блоге ключевыми словами. Мы хотим, чтобы пользователи находили похожий контент, выполняя поиск по темам или нажимая на них.
Рассмотрим следующее определение таблицы:
Без текстового индекса поиск постов с определённым ключевым словом (например, clickhouse) требует полного сканирования всех записей:
По мере роста платформы выполнение запроса становится всё более медленным, потому что ему приходится просматривать каждый массив keywords в каждой строке.
Чтобы устранить эту проблему с производительностью, мы определяем текстовый индекс для столбца keywords:
Индексация столбцов Map
Во многих сценариях обсервабилити лог-сообщения разбиваются на «компоненты» и сохраняются с использованием соответствующих типов данных, например DateTime для временной метки, Enum для уровня логирования и т. д. Поля метрик лучше всего хранить в виде пар ключ–значение. Командам эксплуатации необходимо эффективно искать по логам для отладки, расследования инцидентов безопасности и мониторинга.
Рассмотрим следующую таблицу логов:
Без текстового индекса поиск в данных типа Map требует полного сканирования таблицы:
По мере увеличения объёма логов эти запросы становятся медленными.
Решение — создание текстового индекса для ключей и значений типа Map. Используйте mapKeys для создания текстового индекса, когда вам нужно находить логи по именам полей или типам атрибутов:
Используйте mapValues, чтобы создать текстовый индекс, когда нужно искать по содержимому самих атрибутов:
Примеры запросов:
Настройка производительности
Прямое чтение
Некоторые типы текстовых запросов могут быть существенно ускорены благодаря оптимизации, называемой "direct read".
Пример:
Оптимизация прямого чтения обрабатывает запрос, используя исключительно текстовый индекс (т.е. обращения к текстовому индексу) без доступа к исходному текстовому столбцу. Обращения к текстовому индексу читают относительно мало данных и поэтому существенно быстрее, чем обычные skip-индексы в ClickHouse (которые выполняют обращение к skip-индексу, а затем загружают и фильтруют оставшиеся гранулы).
Прямое чтение управляется двумя настройками:
- Настройка query_plan_direct_read_from_text_index (по умолчанию true), которая определяет, включено ли прямое чтение в целом.
- Настройка use_skip_indexes_on_data_read, ещё одно обязательное условие для прямого чтения. В версиях ClickHouse >= 26.1 эта настройка включена по умолчанию. В более ранних версиях вам нужно явно выполнить
SET use_skip_indexes_on_data_read = 1.
Поддерживаемые функции
Оптимизация прямого чтения поддерживает функции hasToken, hasAllTokens и hasAnyTokens.
Если текстовый индекс определён с array tokenizer, прямое чтение также поддерживается для функций equals, has, 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, 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, что дополнительно уменьшает объём данных для чтения.
Кэширование
Доступны различные кэши для буферизации частей текстового индекса в памяти (см. раздел Implementation Details). В настоящее время доступны кэши для десериализованных блоков словаря, заголовков и списков вхождений текстового индекса для снижения объёма операций ввода-вывода. Их можно включить с помощью настроек use_text_index_dictionary_cache, use_text_index_header_cache и use_text_index_postings_cache. По умолчанию все кэши отключены. Чтобы очистить кэши, используйте команду SYSTEM CLEAR TEXT INDEX CACHES.
Для настройки кэшей используйте следующие параметры сервера.
Настройки кэша блоков словаря
| Setting | Description |
|---|---|
| text_index_dictionary_block_cache_policy | Имя политики кэширования блоков словаря текстового индекса. |
| text_index_dictionary_block_cache_size | Максимальный размер кэша в байтах. |
| text_index_dictionary_block_cache_max_entries | Максимальное количество десериализованных блоков словаря в кэше. |
| text_index_dictionary_block_cache_size_ratio | Размер защищённой очереди в кэше блоков словаря текстового индекса относительно общего размера кэша. |
Настройки кэша заголовков
| Настройка | Описание |
|---|---|
| text_index_header_cache_policy | Имя политики кэша заголовков текстового индекса. |
| text_index_header_cache_size | Максимальный размер кэша в байтах. |
| text_index_header_cache_max_entries | Максимальное количество десериализованных заголовков в кэше. |
| text_index_header_cache_size_ratio | Размер защищённой очереди в кэше заголовков текстового индекса относительно общего размера кэша. |
Настройки кэша списков вхождений
| Setting | Description |
|---|---|
| text_index_postings_cache_policy | Имя политики кэша списков вхождений текстового индекса. |
| text_index_postings_cache_size | Максимальный размер кэша в байтах. |
| text_index_postings_cache_max_entries | Максимальное количество десериализованных списков вхождений в кэше. |
| text_index_postings_cache_size_ratio | Размер защищённой очереди в кэше списков вхождений текстового индекса относительно общего размера кэша. |
Ограничения
В настоящий момент текстовый индекс имеет следующие ограничения:
- Материализация текстовых индексов с большим количеством токенов (например, 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 символов на строку. На практике таблицы также содержат другие столбцы, и порог в несколько раз меньше (в зависимости от количества, типа и размера других столбцов).
Текстовые индексы и индексы на основе фильтра Блума
Строковые предикаты можно ускорить с помощью текстовых индексов и индексов на основе фильтра Блума (тип индекса bloom_filter, ngrambf_v1, tokenbf_v1, sparse_grams), однако эти подходы принципиально различаются по своему устройству и целевым сценариям использования:
Индексы на основе фильтра Блума
- Основаны на вероятностных структурах данных, которые могут давать ложноположительные срабатывания.
- Способны отвечать только на вопросы о принадлежности множеству, то есть столбец может содержать токен X или же точно не содержит X.
- Хранят информацию на уровне гранул, что позволяет пропускать крупные диапазоны во время выполнения запроса.
- Сложны для корректной настройки (см. здесь для примера).
- Довольно компактны (несколько килобайт или мегабайт на часть).
Текстовые индексы
- Строят детерминированный инвертированный индекс по токенам. Ложноположительные срабатывания со стороны индекса невозможны.
- Специально оптимизированы для нагрузок полнотекстового поиска.
- Хранят информацию на уровне строк, что обеспечивает эффективный поиск по терминам.
- Довольно крупные (от десятков до сотен мегабайт на часть).
Индексы на основе фильтра Блума поддерживают полнотекстовый поиск лишь как «побочный эффект»:
- Они не поддерживают продвинутую токенизацию и предобработку.
- Они не поддерживают поиск по нескольким токенам.
- Они не обеспечивают характеристик производительности, ожидаемых от инвертированного индекса.
Текстовые индексы, напротив, специально предназначены для полнотекстового поиска:
- Они обеспечивают токенизацию и предобработку.
- Они обеспечивают эффективную поддержку функций
hasAllTokens,LIKE,matchи аналогичных функций текстового поиска. - Они обладают значительно лучшей масштабируемостью для больших текстовых корпусов.
Подробности реализации
Каждый текстовый индекс состоит из двух (абстрактных) структур данных:
- словаря, который отображает каждый токен в список вхождений, и
- набора списков вхождений, каждый из которых представляет собой множество номеров строк.
Текстовый индекс строится для всей части. В отличие от других пропускающих индексов, текстовый индекс при слиянии частей данных может быть объединён, а не перестроен (см. ниже).
Во время создания индекса для каждой части создаются три файла:
Файл блоков словаря (.dct)
Токены в текстовом индексе сортируются и сохраняются в блоках словаря по 512 токенов в каждом (размер блока настраивается параметром dictionary_block_size).
Файл блоков словаря (.dct) состоит из всех блоков словаря всех гранул индекса в части.
Файл заголовка индекса (.idx)
Файл заголовка индекса содержит для каждого блока словаря первый токен блока и его относительное смещение в файле блоков словаря.
Эта разреженная структура индекса аналогична разреженному индексу первичного ключа в ClickHouse (sparse primary key index).
Файл списков вхождений (.pst)
Списки вхождений для всех токенов располагаются последовательно в файле списков вхождений.
Чтобы экономить место и при этом обеспечивать быстрые операции пересечения и объединения, списки вхождений хранятся как roaring bitmaps.
Если список вхождений больше, чем posting_list_block_size, он разбивается на несколько блоков, которые хранятся последовательно в файле списков вхождений.
Слияние текстовых индексов
При слиянии частей данных текстовый индекс не нужно полностью перестраивать; вместо этого его можно эффективно объединить на отдельном этапе процесса слияния.
На этом этапе отсортированные словари текстовых индексов каждой входной части считываются и объединяются в новый единый словарь.
Номера строк в списках вхождений также пересчитываются, чтобы отразить их новые позиции в объединённой части данных, с использованием отображения старых номеров строк в новые, которое создаётся на начальной фазе слияния.
Этот метод слияния текстовых индексов подобен тому, как объединяются проекции со столбцом _part_offset.
Если индекс не материализован в исходной части, он строится, записывается во временный файл и затем объединяется с индексами из других частей и из других временных файлов индексов.
Пример: набор данных Hacker News
Рассмотрим, как текстовые индексы улучшают производительность на большом наборе данных с большим объёмом текста. Мы будем использовать 28,7 млн строк комментариев с популярного сайта Hacker News. Вот таблица без текстового индекса:
Эти 28,7 млн строк содержатся в файле Parquet в S3 — давайте вставим их в таблицу hackernews:
Мы воспользуемся ALTER TABLE, добавим текстовый индекс для столбца comment, а затем материализуем его:
Теперь давайте выполним запросы с использованием функций hasToken, hasAnyTokens и hasAllTokens.
Следующие примеры покажут существенную разницу в производительности между стандартным сканированием индекса и механизмом оптимизации прямого чтения.
1. Использование hasToken
hasToken проверяет, содержит ли текст указанный один токен.
Мы будем искать токен «ClickHouse» с учетом регистра.
Прямое чтение отключено (стандартное сканирование) По умолчанию ClickHouse использует skip-индекс для фильтрации гранул, а затем читает данные столбца для этих гранул. Мы можем смоделировать это поведение, отключив прямое чтение.
Direct read включён (быстрое чтение по индексу) Теперь мы запускаем тот же запрос с включённым режимом прямого чтения (это значение по умолчанию).
Запрос прямого чтения более чем в 45 раз быстрее (0,362 с против 0,008 с) и обрабатывает значительно меньше данных (9,51 ГБ против 3,15 МБ) за счёт чтения только из индекса.
2. Использование hasAnyTokens
hasAnyTokens проверяет, содержит ли текст по крайней мере один из указанных токенов.
Мы будем искать комментарии, содержащие либо 'love', либо 'ClickHouse'.
Прямое чтение отключено (стандартное сканирование)
Прямое чтение включено (быстрое чтение из индекса)
Ускорение становится ещё более заметным для такого распространённого поиска с оператором "OR". Запрос выполняется почти в 89 раз быстрее (1.329s против 0.015s) за счёт отказа от полного сканирования столбца.
3. Использование hasAllTokens
hasAllTokens проверяет, содержит ли текст все заданные токены.
Мы будем искать комментарии, содержащие и «love», и «ClickHouse».
Прямое чтение отключено (стандартное сканирование) Даже при отключённом прямом чтении стандартный skip-индекс остаётся эффективным. Он отфильтровывает 28,7 млн строк до всего 147,46 тыс. строк, но при этом всё равно приходится прочитать 57,03 МБ из столбца.
Прямое чтение включено (быстрое чтение индекса) Прямое чтение отвечает на запрос, используя данные индекса и читая всего 147,46 КБ.
Для этого поиска с оператором "AND" оптимизация прямого чтения более чем в 26 раз быстрее (0,184 с против 0,007 с), чем стандартное сканирование пропускающего индекса.
4. Составной поиск: OR, AND, NOT, ...
Оптимизация прямого чтения также применяется к составным логическим выражениям. Здесь мы выполним регистронезависимый поиск по 'ClickHouse' или 'clickhouse'.
Прямое чтение отключено (стандартное сканирование)
Включено прямое чтение (быстрое чтение по индексу)
Объединяя результаты из индекса, прямой запрос на чтение выполняется в 34 раза быстрее (0.450s против 0.013s) и не читает 9.58 GB данных столбца.
В этом конкретном случае предпочтительнее использовать более эффективный синтаксис hasAnyTokens(comment, ['ClickHouse', 'clickhouse']).
Связанные материалы
- Презентация: https://github.com/ClickHouse/clickhouse-presentations/blob/master/2025-tumuchdata-munich/ClickHouse_%20full-text%20search%20-%2011.11.2025%20Munich%20Database%20Meetup.pdf
- Презентация: https://presentations.clickhouse.com/2026-fosdem-inverted-index/Inverted_indexes_the_what_the_why_the_how.pdf
Устаревшие материалы
- Статья в блоге: Introducing Inverted Indices in ClickHouse
- Статья в блоге: Inside ClickHouse full-text search: fast, native, and columnar
- Видео: Full-Text Indices: Design and Experiments