Полнотекстовый поиск с использованием текстовых индексов
Текстовые индексы в 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‑граммы одинакового размера (см. также функцию 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 — это выражение, которое применяется к входной строке перед токенизацией.
Типичные варианты использования аргумента препроцессора включают:
- Преобразование в нижний или верхний регистр для обеспечения регистронезависимого сопоставления, например 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 просто, так как распространённые строковые функции поиска автоматически используют индекс. Если индекс отсутствует, перечисленные ниже строковые функции поиска будут выполнять медленное сканирование по принципу полного перебора.
Поддерживаемые функции
Текстовый индекс можно использовать, если в предложениях 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 и 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
Во многих сценариях обсервабилити лог-сообщения разбиваются на «компоненты» и сохраняются с использованием соответствующих типов данных, например DateTime для временной метки, Enum для уровня логирования и т. д. Поля метрик лучше всего хранить в виде пар ключ–значение. Командам эксплуатации необходимо эффективно искать по логам для отладки, расследования инцидентов безопасности и мониторинга.
Рассмотрим следующую таблицу логов:
Без текстового индекса поиск в данных типа Map требует полного сканирования таблицы:
По мере увеличения объёма логов эти запросы становятся медленными.
Решение — создание текстового индекса для ключей и значений типа Map. Используйте mapKeys для создания текстового индекса, когда вам нужно находить логи по именам полей или типам атрибутов:
Используйте mapValues, чтобы создать текстовый индекс, когда нужно искать по содержимому самих атрибутов:
Примеры запросов:
Настройка производительности
Прямое чтение
Некоторые типы текстовых запросов могут быть существенно ускорены благодаря оптимизации, называемой "direct read".
Пример:
Оптимизация прямого чтения в ClickHouse обрабатывает запрос, используя исключительно текстовый индекс (т.е. обращения к текстовому индексу) без доступа к исходному текстовому столбцу. Обращения к текстовому индексу читают относительно мало данных и поэтому существенно быстрее, чем обычные 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.
Кроме того, текстовый индекс должен быть полностью материализован для использования прямого чтения (для этого используйте ALTER TABLE ... MATERIALIZE INDEX).
Поддерживаемые функции
Оптимизация прямого чтения поддерживает функции 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 DROP 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 | Размер защищённой очереди в кэше списков вхождений текстового индекса относительно общего размера кэша. |
Подробности реализации
Каждый текстовый индекс состоит из двух (абстрактных) структур данных:
- словаря, который отображает каждый токен в список вхождений, и
- набора списков вхождений, каждый из которых представляет собой множество номеров строк.
Текстовый индекс строится для всей части. В отличие от других пропускающих индексов, текстовый индекс при слиянии частей данных может быть объединён, а не перестроен (см. ниже).
Во время создания индекса для каждой части создаются три файла:
Файл блоков словаря (.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']).
Связанные материалы
- Статья в блоге: Introducing Inverted Indices in ClickHouse
- Статья в блоге: Inside ClickHouse full-text search: fast, native, and columnar
- Видео: Full-Text Indices: Design and Experiments