Полнотекстовый поиск с использованием текстовых индексов
Текстовые индексы в ClickHouse (также известные как "обратные индексы") обеспечивают быстрый полнотекстовый поиск по строковым данным. Индекс сопоставляет каждый токен в столбце со строками, которые содержат этот токен. Токены генерируются процессом, называемым токенизацией. Например, по умолчанию ClickHouse разбивает английское предложение "All cat like mice." на токены ["All", "cat", "like", "mice"] (обратите внимание, что завершающая точка игнорируется). Также доступны более продвинутые токенизаторы, например для данных журналов (логов).
Создание текстового индекса
Чтобы создать текстовый индекс, сначала включите соответствующую экспериментальную настройку:
Текстовый индекс можно определить для столбца следующих типов: 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-граммы длиной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).
Токенизатор splitByString применяет разделители слева направо.
Это может создавать неоднозначности.
Например, строки-разделители ['%21', '%'] приведут к тому, что %21abc будет токенизировано как ['abc'], тогда как при перестановке разделителей ['%', '%21'] результатом будет ['21abc'].
В большинстве случаев требуется, чтобы при сопоставлении преимущество отдавалось более длинным разделителям.
Обычно этого можно добиться, передавая строки-разделители в порядке убывания их длины.
Если строки-разделители образуют префиксный код, их можно передавать в произвольном порядке.
На данный момент не рекомендуется строить текстовые индексы поверх текста на не‑западных языках, например китайском. Поддерживаемые сейчас токенизаторы могут приводить к огромным размерам индекса и длительному выполнению запросов. В будущем мы планируем добавить специализированные токенизаторы для отдельных языков, которые будут лучше обрабатывать такие случаи.
Чтобы проверить, как токенизаторы разбивают входную строку, можно использовать функцию tokens ClickHouse:
Пример:
Результат:
Аргумент preprocessor (необязательный). Аргумент preprocessor — это выражение, которое применяется к входной строке перед токенизацией.
Типичные сценарии использования аргумента препроцессора включают
- Приведение к нижнему или верхнему регистру для обеспечения сопоставления без учета регистра, например lower, lowerUTF8, см. первый пример ниже.
- Нормализация в UTF-8, например normalizeUTF8NFC, normalizeUTF8NFD, normalizeUTF8NFKC, normalizeUTF8NFKD, toValidUTF8.
- Удаление или преобразование нежелательных символов или подстрок, например extractTextFromHTML, substring, idnaEncode.
Выражение препроцессора должно преобразовывать входное значение типа String или FixedString в значение того же типа.
Примеры:
INDEX idx(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(col))INDEX idx(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = substringIndex(col, '\n', 1))INDEX idx(col) TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(extractTextFromHTML(col))
Кроме того, выражение препроцессора должно ссылаться только на столбец, на основе которого определён текстовый индекс. Использование недетерминированных функций не допускается.
Функции hasToken, hasAllTokens и hasAnyTokens используют препроцессор для преобразования поискового термина перед его токенизацией.
Например,
эквивалентно:
Прочие аргументы (необязательные). Текстовые индексы в ClickHouse реализованы как вторичные индексы. Однако, в отличие от других индексов с пропуском данных, текстовые индексы имеют бесконечную гранулярность, то есть текстовый индекс создаётся для всей части, а явно заданная гранулярность индекса игнорируется. Это значение было выбрано эмпирически и обеспечивает хороший баланс между скоростью и размером индекса для большинства случаев использования. Опытные пользователи могут указать другую гранулярность индекса (мы не рекомендуем этого делать).
Необязательные расширенные параметры
Значения по умолчанию для следующих расширенных параметров подойдут практически во всех случаях. Мы не рекомендуем их изменять.
Необязательный параметр dictionary_block_size (по умолчанию: 512) задаёт размер блоков словаря в строках.
Необязательный параметр dictionary_block_frontcoding_compression (по умолчанию: 1) определяет, используют ли блоки словаря front coding в качестве метода сжатия.
Необязательный параметр posting_list_block_size (по умолчанию: 1048576) задаёт размер блоков списков постингов в строках.
Текстовые индексы можно добавить к столбцу или удалить из него после создания таблицы:
Использование текстового индекса
Использовать текстовый индекс в запросах SELECT достаточно просто, так как стандартные строковые функции поиска автоматически задействуют индекс. Если индекс отсутствует, приведённые ниже строковые функции поиска будут выполнять медленное полное (brute-force) сканирование.
Поддерживаемые функции
Текстовый индекс можно использовать, если в условиях 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 это верно, если искомый префикс или суффикс имеет длину, большую либо равную длине n-граммы.
Пример для текстового индекса с токенизатором splitByNonAlpha:
В этом примере только clickhouse считается отдельным токеном.
support не считается токеном, потому что ему могут соответствовать support, supports, supporting и т.д.
Чтобы найти все строки, которые начинаются с clickhouse supports, завершите шаблон поиска пробелом на конце:
Аналогично, функцию endsWith следует использовать с пробелом в начале:
hasToken и hasTokenOrNull
Функции hasToken и hasTokenOrNull выполняют поиск по одному заданному токену.
В отличие от ранее упомянутых функций, они не выполняют токенизацию искомого значения (предполагается, что на вход передаётся один токен).
Пример:
Функции hasToken и hasTokenOrNull являются наиболее производительными при использовании с индексом text.
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
Во многих сценариях обсервабилити сообщения логов разбиваются на «компоненты» и сохраняются с соответствующими типами данных, например дата-время для временной метки, enum для уровня логирования и т. д. Поля метрик оптимально хранить в виде пар ключ-значение. Командам, отвечающим за эксплуатацию, необходимо эффективно искать по логам для отладки, расследования инцидентов информационной безопасности и мониторинга.
Рассмотрим следующую таблицу логов:
Без текстового индекса поиск по данным типа Map требует полного сканирования таблицы:
По мере увеличения объёма логов такие запросы начинают работать медленно.
Решение — создать текстовый индекс для ключей и значений Map. Используйте mapKeys, чтобы создать текстовый индекс, когда нужно находить логи по именам полей или типам атрибутов:
Используйте mapValues, чтобы создать текстовый индекс, когда нужно выполнять поиск по самим значениям атрибутов:
Примеры запросов:
Настройка производительности
Прямое чтение
Некоторые типы текстовых запросов могут быть значительно ускорены с помощью оптимизации, называемой «прямое чтение».
Пример:
Оптимизация прямого чтения в ClickHouse обрабатывает запрос, используя исключительно текстовый индекс (т.е. обращения к текстовому индексу) без доступа к базовому текстовому столбцу. Обращения к текстовому индексу читают относительно небольшой объём данных и поэтому гораздо быстрее, чем обычные skip-индексы в ClickHouse (которые выполняют обращение к skip-индексу, а затем загружают и фильтруют оставшиеся гранулы).
Прямое чтение управляется двумя настройками:
- Настройка query_plan_direct_read_from_text_index, которая определяет, включено ли прямое чтение в целом.
- Настройка use_skip_indexes_on_data_read, которая является дополнительным требованием для прямого чтения. Обратите внимание, что в базах данных ClickHouse с compatibility < 25.10
use_skip_indexes_on_data_readотключена, поэтому вам либо нужно повысить значение настройки compatibility, либо явно выполнитьSET use_skip_indexes_on_data_read = 1.
Также текстовый индекс должен быть полностью материализован, чтобы использовать прямое чтение (для этого используйте ALTER TABLE ... MATERIALIZE INDEX).
Поддерживаемые функции
Оптимизация прямого чтения поддерживает функции hasToken, hasAllTokens и hasAnyTokens.
Если текстовый индекс создан с токенизатором array, прямое чтение также поддерживается для функций 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>.
Если этот столбец присутствует, используется прямое чтение.
Наибольший выигрыш в производительности от оптимизации прямого чтения достигается, когда текстовый столбец используется исключительно внутри функций текстового поиска, поскольку это позволяет запросу полностью избежать чтения данных столбца. Однако даже если текстовый столбец используется в других частях запроса и его необходимо прочитать, оптимизация прямого чтения всё равно обеспечит прирост производительности.
Прямое чтение как подсказка
Прямое чтение как подсказка использует те же принципы, что и обычное прямое чтение, но при этом добавляет дополнительный фильтр, построенный на основе данных текстового индекса, не удаляя базовый текстовый столбец. Оно используется для функций, при которых доступ только к текстовому индексу может приводить к ложноположительным срабатываниям.
Поддерживаются следующие функции: 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). В настоящее время существуют кэши для десериализованных блоков словаря, заголовков и списков вхождений (posting lists) текстового индекса, позволяющие сократить количество операций ввода-вывода (I/O). Эти кэши включаются с помощью настроек use_text_index_dictionary_cache, use_text_index_header_cache и use_text_index_postings_cache. По умолчанию все кэши отключены. Для сброса кэшей используйте команду SYSTEM DROP TEXT INDEX CACHES.
Для их настройки воспользуйтесь следующими параметрами сервера.
Настройки кэша блоков словаря
| Параметр | Описание |
|---|---|
| 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 | Размер защищённой очереди в кэше заголовков текстового индекса по отношению к общему размеру кэша. |
Настройки кэша списков вхождений
| Настройка | Описание |
|---|---|
| text_index_postings_cache_policy | Имя политики кэша списков вхождений текстового индекса. |
| text_index_postings_cache_size | Максимальный размер кэша в байтах. |
| text_index_postings_cache_max_entries | Максимальное количество десериализованных списков вхождений в кэше. |
| text_index_postings_cache_size_ratio | Размер защищённой очереди в кэше списков вхождений текстового индекса относительно общего размера кэша. |
Подробности реализации
Каждый текстовый индекс состоит из двух (абстрактных) структур данных:
- словаря, который отображает каждый токен на список вхождений, и
- набора списков вхождений, каждый из которых представляет набор номеров строк.
Текстовый индекс строится для всей части данных. В отличие от других пропускающих индексов, текстовый индекс при слиянии частей данных может быть объединён, а не перестроен.
Во время создания индекса создаются три файла (на часть):
Файл блоков словаря (.dct)
Токены в текстовом индексе сортируются и хранятся в блоках словаря по 512 токенов в каждом (размер блока настраивается параметром dictionary_block_size).
Файл блоков словаря (.dct) содержит все блоки словаря всех гранул индекса в данной части.
Файл заголовка индекса (.idx)
Файл заголовка индекса содержит для каждого блока словаря первый токен блока и его относительное смещение в файле блоков словаря.
Эта разреженная структура индекса аналогична разреженному индексу первичного ключа) в ClickHouse.
Файл списков вхождений (.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 использует пропускающий индекс для фильтрации гранул, а затем читает данные столбца для этих гранул. Мы можем эмулировать это поведение, отключив прямое чтение.
Прямое чтение включено (быстрое чтение индекса) Теперь запустим тот же запрос с включённым прямым чтением (это поведение по умолчанию).
Запрос с прямым чтением более чем в 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'.
Прямое чтение отключено (стандартное сканирование) Даже при отключённом прямом чтении стандартный пропускающий индекс остаётся эффективным. Он сокращает выборку с 28.7M строк до всего 147.46K строк, но при этом всё равно должен прочитать 57.03 MB из столбца.
Прямое чтение включено (быстрое чтение индекса) Прямое чтение отвечает на запрос, используя данные индекса и считывая только 147,46 КБ.
Для такого поиска по условию AND оптимизация прямого чтения более чем в 26 раз быстрее (0,184 с против 0,007 с), чем стандартное сканирование индекса-пропуска.
4. Составной поиск: OR, AND, NOT, ...
Оптимизация прямого чтения также применяется к составным логическим выражениям. Здесь мы выполним поиск без учета регистра для 'ClickHouse' ИЛИ 'clickhouse'.
Прямое чтение отключено (стандартное сканирование)
Включено прямое чтение (быстрое чтение по индексу)
Комбинируя результаты работы индекса, прямой запрос на чтение выполняется в 34 раза быстрее (0,450 с против 0,013 с) и позволяет избежать чтения 9,58 ГБ данных столбца.
Для этого конкретного случая hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) будет предпочтительным, более эффективным синтаксисом.