Полнотекстовый поиск с использованием текстовых индексов
Текстовые индексы в 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-граммы можно задать с помощью необязательного целочисленного параметра от 2 до 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'].
В большинстве случаев нужно, чтобы при сопоставлении более длинные разделители имели приоритет.
Обычно это можно обеспечить, передавая строки-разделители в порядке убывания длины.
Если строки-разделители образуют префиксный код, их можно передавать в произвольном порядке.
В настоящее время не рекомендуется создавать текстовые индексы для текста на незападных языках, например китайском. Поддерживаемые в данный момент токенизаторы могут привести к чрезмерно большим размерам индексов и длительному времени выполнения запросов. В будущем планируется добавить специализированные языково-специфичные токенизаторы, которые будут лучше обрабатывать такие случаи.
Чтобы проверить, как токенизаторы разбивают входную строку, можно использовать функцию ClickHouse tokens:
Пример:
Результат:
Аргумент препроцессора (необязательный). Аргумент 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 реализованы как вторичные индексы. Однако, в отличие от других индексов с пропуском, текстовые индексы имеют значение GRANULARITY по умолчанию, равное 64. Это значение выбрано эмпирически и обеспечивает хороший баланс между скоростью и размером индекса для большинства случаев использования. Опытные пользователи могут указать другую гранулярность индекса (мы не рекомендуем этого делать).
Необязательные расширенные параметры
Значения по умолчанию следующих расширенных параметров будут хорошо работать практически во всех ситуациях. Мы не рекомендуем их изменять.
Необязательный параметр dictionary_block_size (по умолчанию: 128) задаёт размер блоков словаря в строках.
Необязательный параметр dictionary_block_frontcoding_compression (по умолчанию: 1) указывает, используют ли блоки словаря фронтальное кодирование в качестве сжатия.
Необязательный параметр max_cardinality_for_embedded_postings (по умолчанию: 16) задаёт порог кардинальности, ниже которого списки постингов должны быть встроены в блоки словаря.
Необязательный параметр bloom_filter_false_positive_rate (по умолчанию: 0.1) задаёт вероятность ложноположительных срабатываний фильтра Блума словаря.
Текстовые индексы можно добавлять в столбец или удалять из него после создания таблицы:
Использование текстового индекса
Использовать текстовый индекс в запросах SELECT просто, так как стандартные функции строкового поиска автоматически используют индекс. Если индекс отсутствует, приведённые ниже функции строкового поиска будут выполнять медленный полный перебор по всем данным.
Поддерживаемые функции
Текстовый индекс может быть использован, если текстовые функции применяются в условии WHERE запроса SELECT:
= и !=
= (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.
Чтобы использовать LIKE (like), NOT LIKE (notLike) и функцию match с текстовыми индексами, ClickHouse должен иметь возможность извлечь полные токены из поискового термина.
Пример:
support в примере может соответствовать support, supports, supporting и т. д.
Такой запрос является запросом по подстроке, и его нельзя ускорить с помощью текстового индекса.
Чтобы использовать текстовый индекс для запросов с LIKE, шаблон LIKE нужно изменить следующим образом:
Пробелы слева и справа от support гарантируют, что термин может быть извлечён как токен.
startsWith и endsWith
Аналогично оператору LIKE, функции startsWith и endsWith могут использовать текстовый индекс только в том случае, если из поискового выражения могут быть извлечены полные токены.
Пример:
В этом примере только clickhouse считается токеном.
support не является токеном, так как он может соответствовать support, supports, supporting и т.д.
Чтобы найти все строки, которые начинаются с clickhouse supports, завершите шаблон поиска пробелом в конце:
Аналогично, endsWith следует использовать с пробелом в начале строки:
hasToken и hasTokenOrNull
Функции hasToken и hasTokenOrNull сопоставляют значение с одним заданным токеном.
В отличие от упомянутых выше функций, они не токенизируют искомое значение (предполагается, что на вход подаётся один токен).
Пример:
Функции hasToken и hasTokenOrNull являются наиболее эффективными при использовании с индексом text.
hasAnyTokens и hasAllTokens
Функции hasAnyTokens и hasAllTokens сопоставляют строку соответственно с одним или со всеми указанными токенами.
Обе функции принимают поисковые токены либо в виде строки, которая будет разбита на токены с использованием того же токенизатора, что и для столбца, по которому построен индекс, либо в виде массива уже подготовленных токенов, к которым перед поиском токенизация применяться не будет. См. документацию по функциям для получения дополнительной информации.
Пример:
has
Функция для работы с массивами has проверяет наличие отдельного токена в массиве строк.
Пример:
mapContains
Функция mapContains (псевдоним mapContainsKey) проверяет, содержится ли заданный токен среди ключей отображения.
Пример:
operator[]
Оператор доступа operator[] можно использовать с текстовым индексом для фильтрации по ключам и значениям.
Пример:
Рассмотрим следующие примеры использования столбцов типов Array(T) и Map(K, V) с текстовым индексом.
Примеры для столбцов Array и Map с текстовыми индексами
Индексирование столбцов Array(String)
Представьте платформу для ведения блогов, где авторы классифицируют свои записи с помощью ключевых слов. Мы хотим, чтобы пользователи могли находить похожие материалы, выполняя поиск или переходя по темам.
Рассмотрим следующее определение таблицы:
Без текстового индекса поиск постов с определённым ключевым словом (например, clickhouse) требует сканирования всех постов:
По мере роста платформы выполнение запроса становится всё более медленным, поскольку ему приходится просматривать каждый массив keywords в каждой строке.
Чтобы решить эту проблему с производительностью, мы создаём текстовый индекс для столбца keywords:
Индексация столбцов типа Map
Во многих сценариях систем наблюдаемости сообщения логов разбиваются на отдельные «компоненты» и сохраняются в виде соответствующих типов данных, например тип DateTime для временной метки, Enum для уровня логирования и т. д.
Поля метрик оптимально хранить в виде пар «ключ–значение».
Операционным командам нужно эффективно искать по логам для отладки, расследования инцидентов информационной безопасности и мониторинга.
Рассмотрим следующую таблицу логов:
Без текстового индекса поиск по данным типа Map требует полного сканирования таблицы:
По мере роста объёма логов эти запросы начинают работать медленнее.
Решение — создать текстовый индекс для ключей и значений типа Map. Используйте mapKeys для создания текстового индекса, когда нужно находить логи по именам полей или типам атрибутов:
Используйте mapValues, чтобы создать текстовый индекс, когда вам нужно выполнять поиск по самому содержимому атрибутов:
Примеры запросов:
Оптимизация производительности
Прямое чтение
Некоторые типы текстовых запросов можно значительно ускорить с помощью оптимизации, называемой «прямое чтение». Более точно, эту оптимизацию можно применить, если в запросе SELECT текстовый столбец не выбирается.
Пример:
Оптимизация прямого чтения в 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.
Эти функции также могут комбинироваться операторами AND, OR и NOT.
Условие WHERE также может содержать дополнительные фильтры, не являющиеся функциями полнотекстового поиска (как для текстовых, так и для других столбцов) — в этом случае оптимизация прямого чтения всё равно будет использоваться, но менее эффективно (она применяется только к поддерживаемым функциям текстового поиска).
Чтобы убедиться, что запрос использует прямое чтение, выполните его с EXPLAIN PLAN actions = 1.
В качестве примера — запрос с отключённым прямым чтением
возвращает
тогда как тот же запрос, выполненный при query_plan_direct_read_from_text_index = 1
возвращает
Вывод второго EXPLAIN PLAN содержит виртуальный столбец __text_index_<index_name>_<function_name>_<id>.
Если этот столбец присутствует, используется прямое чтение.
Кэширование
Доступны различные кэши для буферизации частей текстового индекса в памяти (см. раздел Сведения о реализации): В настоящее время доступны кэши для десериализованных блоков словаря, заголовков и списков постингов текстового индекса для снижения объема операций ввода-вывода (I/O). Их можно включить с помощью настроек use_text_index_dictionary_cache, use_text_index_header_cache и use_text_index_postings_cache. По умолчанию все кэши отключены.
Для настройки кэшей используйте следующие параметры сервера.
Настройки кэша блоков словаря
| Параметр | Описание |
|---|---|
| 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 | Размер защищённой очереди в кэше списков вхождений текстового индекса относительно общего размера кэша. |
Детали реализации
Каждый текстовый индекс состоит из двух (абстрактных) структур данных:
- словаря, который отображает каждый токен на список вхождений, и
- набора списков вхождений, каждый из которых представляет собой набор номеров строк.
Поскольку текстовый индекс является skip-индексом, эти структуры данных логически существуют для каждой гранулы индекса.
Во время создания индекса создаются три файла (на каждую part):
Файл блоков словаря (.dct)
Токены в грануле индекса сортируются и сохраняются в блоках словаря по 128 токенов в каждом (размер блока настраивается параметром dictionary_block_size).
Файл блоков словаря (.dct) содержит все блоки словаря всех гранул индекса в одной part.
Файл гранул индекса (.idx)
Файл гранул индекса содержит для каждого блока словаря первый токен блока, его относительное смещение в файле блоков словаря и фильтр Блума для всех токенов в блоке. Эта разреженная структура индекса аналогична разреженному индексу первичного ключа в ClickHouse. Фильтр Блума позволяет заранее отбрасывать блоки словаря, если разыскиваемый токен не содержится в блоке словаря.
Файл списков вхождений (.pst)
Списки вхождений для всех токенов располагаются последовательно в файле списков вхождений.
Чтобы экономить место и при этом обеспечивать быстрые операции пересечения и объединения, списки вхождений хранятся в виде roaring bitmaps.
Если кардинальность списка вхождений меньше 16 (настраивается параметром max_cardinality_for_embedded_postings), он встраивается в словарь.
Пример: датасет Hacker News
Рассмотрим, как текстовые индексы улучшают производительность на большом текстовом наборе данных. Мы будем использовать 28,7 млн строк комментариев с популярного сайта Hacker News. Вот таблица без текстового индекса:
28,7 млн строк хранятся в Parquet-файле в S3 — давайте загрузим их в таблицу hackernews:
Используем ALTER TABLE, создадим текстовый индекс по столбцу comment, затем материализуем его:
Теперь давайте выполним запросы с использованием функций hasToken, hasAnyTokens и hasAllTokens.
Следующие примеры покажут существенную разницу в производительности между стандартным сканированием индекса и оптимизацией прямого чтения.
1. Использование hasToken
hasToken проверяет, содержит ли текст указанный одиночный токен.
Мы будем искать регистрозависимый токен ClickHouse.
Прямое чтение отключено (стандартное сканирование) По умолчанию ClickHouse использует skip-индекс для фильтрации гранул, а затем читает данные столбца для этих гранул. Мы можем смоделировать это поведение, отключив прямое чтение.
Включено прямое чтение (Fast index read) Теперь запустим тот же запрос с включённым режимом прямого чтения (это значение по умолчанию).
Запрос прямого чтения более чем в 45 раз быстрее (0,362 с против 0,008 с) и обрабатывает значительно меньше данных (9,51 ГБ против 3,15 МБ), поскольку читает только из индекса.
2. Использование hasAnyTokens
hasAnyTokens проверяет, содержит ли текст хотя бы один из заданных токенов.
Мы будем искать комментарии, содержащие либо «love», либо «ClickHouse».
Прямое чтение отключено (стандартное сканирование)
Включено прямое чтение (быстрое чтение индекса)
1 строка в наборе. Прошло: 0.015 сек. Обработано 27.99 млн строк, 27.99 МБ
Прямое чтение (Fast index read) включено Прямое чтение обрабатывает запрос, работая только с данными индекса и считывая всего 147,46 КБ.
Для этого поиска с оператором "AND" оптимизация прямого чтения более чем в 26 раз быстрее (0.184 s против 0.007 s), чем стандартное сканирование с использованием skip-индекса.
4. Составной поиск: OR, AND, NOT, ...
Оптимизация прямого чтения также применима к составным булевым выражениям. Здесь мы выполним поиск без учета регистра для 'ClickHouse' OR 'clickhouse'.
Прямое чтение отключено (стандартное сканирование)
Прямое чтение включено (быстрый доступ к индексу)
Благодаря объединению результатов из индекса прямой запрос на чтение выполняется в 34 раза быстрее (0,450 s против 0,013 s) и позволяет избежать чтения 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