Полнотекстовый поиск с использованием текстовых индексов
Текстовые индексы в ClickHouse (также известные как "обратные индексы") обеспечивают быстрые полнотекстовые возможности для строковых данных. Индекс сопоставляет каждый токен в колонке с теми строками, которые содержат этот токен. Токены генерируются в процессе, называемом токенизацией. Например, ClickHouse токенизирует английское предложение "All cat like mice." по умолчанию как ["All", "cat", "like", "mice"] (обратите внимание, что конечная точка игнорируется). Доступны более сложные токенизаторы, например, для логов.
Создание текстового индекса
Чтобы создать текстовый индекс, сначала активируйте соответствующую экспериментальную настройку:
Текстовый индекс может быть определен на String, FixedString, Array(String), Array(FixedString) и Map (через mapKeys и mapValues функции) колонок с использованием следующего синтаксиса:
Аргумент tokenizer
указывает токенизатор:
splitByNonAlpha
разбивает строки по неалфавитным ASCII символам (также см. функцию splitByNonAlpha).splitByString(S)
разбивает строки по определенным разделителям, заданным пользователемS
(также см. функцию splitByString). Разделители могут быть указаны с помощью необязательного параметра, например,tokenizer = splitByString([', ', '; ', '\n', '\\'])
. Обратите внимание, что каждая строка может состоять из нескольких символов (', '
в примере). Список разделителей по умолчанию, если он не указан явно (например,tokenizer = splitByString
), составляет один пробел[' ']
.ngrams(N)
разбивает строки на равные по размеруN
-граммы (также см. функцию ngrams). Длина ngram может быть указана с помощью необязательного целочисленного параметра от 2 до 8, например,tokenizer = ngrams(3)
. Стандартный размер ngram по умолчанию, если он не указан явно (например,tokenizer = ngrams
), составляет 3.array
не выполняет токенизацию, т.е. каждое значение строки является токеном (также см. функцию array).
Токенизатор splitByString
применяет разделители сверху вниз.
Это может создать неоднозначности.
Например, строковые разделители ['%21', '%']
приведут к тому, что %21abc
будет токенизирован как ['abc']
, в то время как переключение двух строковых разделителей ['%', '%21']
выдаст ['21abc']
.
В большинстве случаев вы хотите, чтобы совпадение предпочитало более длинные разделители в первую очередь.
Это можно сделать, передав строковые разделители в порядке убывания длины.
Если строковые разделители случайно образуют префиксный код, их можно передавать в произвольном порядке.
Чтобы протестировать, как токенизаторы разбивают входную строку, вы можете использовать функцию ClickHouse tokens:
Например,
возвращает
Текстовые индексы в ClickHouse реализованы как вторичные индексы. Однако, в отличие от других индексных индексов, текстовые индексы имеют стандартную ГРАНУЛЯРНОСТЬ индекса 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
являются самыми производительными функциями для использования с текстовым индексом.
hasAnyTokens
и hasAllTokens
Функции hasAnyTokens и hasAllTokens соответствуют одному или всем заданным токенам.
Как и hasToken
, токенизация поисковых терминов не происходит.
Пример:
has
Функция массива has соответствует одному токену в массиве строк.
Пример:
mapContains
Функция mapContains(алиас: mapContainsKey
) соответствует одному токену в ключах карты.
Пример:
operator[]
Оператор доступа operator[] может быть использован с текстовым индексом для фильтрации ключей и значений.
Пример:
Смотрите следующие примеры использования Array(T)
и Map(K, V)
с текстовым индексом.
Примеры поддержки текстового индекса Array
и Map
.
Индексация Array(String)
На простой платформе для ведения блогов авторы назначают ключевые слова своим публикациям для категоризации контента. Общая функция позволяет пользователям находить связанный контент, кликая по ключевым словам или ища темы.
Рассмотрим следующее определение таблицы:
Без текстового индекса поиск публикаций с определенным ключевым словом (например, clickhouse
) требует сканирования всех записей:
По мере роста платформы это становится все более медленным, так как запрос должен просмотреть каждый массив ключевых слов в каждой строке.
Чтобы преодолеть эту проблему производительности, мы можем определить текстовый индекс для keywords
, который создает структуру, оптимизированную для поиска и обрабатывающую все ключевые слова, позволяя мгновенные поиски:
Важно: После добавления текстового индекса вы должны перестроить его для существующих данных:
Индексация Map
В системе логирования запросы серверов часто хранят метаданные в парах ключ-значение. Операционным командам необходимо эффективно искать по логам для отладки, инцидентов безопасности и мониторинга.
Рассмотрим эту таблицу логов:
Без текстового индекса поиск по данным Map требует полных сканирований таблицы:
- Находит все логи с ограничением по скорости:
- Находит все логи с определенного IP:
По мере роста объема логов эти запросы становятся медленными.
Решением является создание текстового индекса для ключей и значений Map.
Используйте mapKeys для создания текстового индекса, когда вам нужно находить логи по именам полей или типам атрибутов:
Используйте mapValues для создания текстового индекса, когда вам нужно искать в фактическом содержании атрибутов:
Важно: После добавления текстового индекса вы должны перестроить его для существующих данных:
- Найдите все запросы с ограничениями по скорости:
- Находит все логи с определенного IP:
Реализация
Макет индекса
Каждый текстовый индекс состоит из двух (абстрактных) структур данных:
- словарь, который сопоставляет каждый токен со списком публикаций, и
- набор списков публикаций, каждый из которых представляет собой набор номеров строк.
Поскольку текстовый индекс является индексом пропуска, эти структуры данных логически существуют на каждый гранулярный индекс.
Во время создания индекса создаются три файла (на каждую часть):
Файл блоков словаря (.dct)
Токены в грануле индекса сортируются и хранятся в блоках словаря по 128 токенов каждый (размер блока настраивается с помощью параметра dictionary_block_size
).
Файл блоков словаря (.dct) содержит все блоки словаря всех гранул индекса в части.
Файл гранул индекса (.idx)
Файл гранул индекса содержит для каждого блока словаря первый токен блока, ее относительное смещение в файле блоков словаря и фильтр Блума для всех токенов в блоке. Эта разреженная структура индекса аналогична разреженному первичному индексу ClickHouse. Фильтр Блума позволяет пропустить блоки словаря на раннем этапе, если искомый токен не содержится в блоке словаря.
Файл списков публикаций (.pst)
Списки публикаций для всех токенов расположены последовательно в файле списков публикаций.
Чтобы сэкономить место, обеспечивая при этом быстрые операции пересечения и объединения, списки публикаций хранятся в виде roaring bitmaps.
Если кардинальность списка публикаций меньше 16 (настраиваемый параметр max_cardinality_for_embedded_postings
), он встраивается в словарь.
Прямое чтение
Некоторые типы текстовых запросов могут быть значительно ускорены с помощью оптимизации, называемой "прямое чтение". Более конкретно, оптимизацию можно применить, если запрос SELECT не проецирует из текстовой колонки.
Пример:
Оптимизация прямого чтения в ClickHouse отвечает на запрос исключительно с использованием текстового индекса (т.е. запросы текстового индекса) без доступа к основной текстовой колонке. Запросы текстового индекса читают относительно мало данных, и поэтому гораздо быстрее, чем обычно индексы пропуска в ClickHouse (которые выполняют запрос индекса пропуска, за которым следует загрузка и фильтрация оставшихся гранул).
Поддерживаемые функции
Оптимизация прямого чтения поддерживает функции hasToken
, searchAll
и searchAny
.
Эти функции также могут комбинироваться с операторами AND, OR и NOT.
В предложении WHERE также могут содержаться дополнительные фильтры, не связанные с текстовым поиском (для текстовых колонок или других колонок) - в этом случае оптимизация прямого чтения по-прежнему будет использоваться, но менее эффективно (она применяется только к поддерживаемым функциям текстового поиска).
Пример: Набор данных Hackernews
Давайте посмотрим на улучшения производительности текстовых индексов на большом наборе данных с большим объемом текста. Мы будем использовать 28.7M строк комментариев на популярном сайте Hacker News. Вот таблица без текстового индекса:
28.7M строк находятся в файле Parquet в S3 - давайте вставим их в таблицу hackernews
:
Рассмотрим следующий простой поиск по термину ClickHouse
(и его различным верхнему и нижнему регистрам) в колонке comment
:
Обратите внимание, что выполнение запроса занимает 3 секунды:
Мы используем ALTER TABLE
и добавляем текстовый индекс на строчные символы колонки comment
, затем материализуем его (это может занять некоторое время - ждите его материализации):
Мы выполняем тот же запрос...
...и замечаем, что запрос выполняется в 4 раза быстрее:
Мы также можем искать один или все несколько терминов, т.е. дизъюнкции или конъюнкции: