Создание текстового индекса
Текстовые индексы можно использовать в любой версии ClickHouse >= 26.2 независимо от настройки compatibility.
Query
- String и FixedString,
- Array(String) и Array(FixedString),
- Map (через функции mapKeys и mapValues),
- JSON (через функции JSONAllPaths и
JSONAllValues).
Array(Nullable(String or FixedString)).
Кроме того, текстовый индекс можно добавить в существующую таблицу:
Query
Query
Query
tokenizer задаёт используемый токенизатор:
splitByNonAlphaразбивает строки по неалфавитно-цифровым ASCII-символам (см. функцию splitByNonAlpha).splitByString(S)разбивает строки по заданным пользователем строкам-разделителямS(см. функцию splitByString). Разделители можно задать с помощью необязательного параметра, например,tokenizer = splitByString([', ', '; ', '\n', '\\']). Обратите внимание, что каждая строка может состоять из нескольких символов (', 'в примере). Список разделителей по умолчанию, если он не указан явно (например,tokenizer = splitByString), — это один пробел[' '].asciiCJKразбивает строки на токены, используя правила границ слов Unicode (аналогично Unicode Text Segmentation (UAX #29)). ASCII-буквенно-цифровые символы и символы подчёркивания образуют токены с соединительными символами (ASCII:для букв,.и'для символов одного типа). Не-ASCII-символы Unicode, включая символы CJK, становятся односимвольными токенами.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'].
В большинстве случаев лучше, чтобы при сопоставлении сначала выбирались более длинные разделители.
Обычно этого можно добиться, передавая строки-разделители в порядке убывания длины.
Если строки-разделители образуют префиксный код, их можно передавать в произвольном порядке.Query
Response
asciiCJK, поскольку он корректно определяет границы слов в Unicode, включая символы CJK.
Аргумент препроцессора (необязательно). Препроцессор — это выражение, которое применяется к входной строке перед токенизацией.
Типичные сценарии использования аргумента препроцессора:
- Приведение к нижнему/верхнему регистру или свёртка регистра для регистронезависимого сопоставления, например lower, lowerUTF8, caseFoldUTF8.
- Нормализация UTF-8, например normalizeUTF8NFC, normalizeUTF8NFD, normalizeUTF8NFKC, normalizeUTF8NFKD, normalizeUTF8NFKCCasefold, toValidUTF8.
- Удаление или преобразование нежелательных символов или подстрок, например диакритических знаков, с помощью extractTextFromHTML, substring, idnaEncode, translate, removeDiacriticsUTF8.
Nullable(T) или LowCardinality(T), то выражение препроцессора должно принимать nullable- или low-cardinality-значения (то есть не должно сгенерировать исключение).
Примеры:
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 col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = removeDiacriticsUTF8(caseFoldUTF8(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))
Препроцессоры, по сути, эквивалентны оборачиванию индексируемого столбца или выражения выражением препроцессора.
Например, препроцессор
lower в INDEX idx col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(col)) можно эмулировать с помощью INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha').
Недостаток второй формы в том, что эмулированный препроцессор применяется только в том случае, если он соответствует условию фильтрации в предложении WHERE.
Например, WHERE hasAllTokens(lower(col), [...]) соответствует, а WHERE hasAllTokens(col, [...]) — нет.
Поэтому для оптимального пользовательского опыта мы рекомендуем использовать выражения препроцессора.SETTINGS use_skip_indexes = 0).
Например,
Query
Query
Query
Query
- Фильтрация стоп-слов (чрезвычайно частых токенов). Очень распространенные токены, такие как “the”, “a” и “is”, почти не влияют на релевантность поиска и раздувают индекс.
Вы можете использовать постпроцессор, чтобы отбрасывать их, преобразуя в пустые токены — пустые токены игнорируются, то есть не добавляются в индекс.
Пример:
if(str IN ('the', 'a', 'an', 'of', 'in', 'is', 'it'), '', str) - Удаление временных меток. Строки Log часто начинаются со структурированной временной метки, например
2024-01-15T10:23:45, или содержат ее. Индексация токенов временных меток раздувает индекс строками, не имеющими значения для релевантности поиска. Есть два взаимодополняющих способа игнорировать временные метки:- Подход с постпроцессором: используйте токенизатор
splitByString(разбиение по пробельным символам), чтобы вся временная метка стала одним токеном, а затем используйтеparseDateTimeOrNull, чтобы распознать и отбросить ее. Пример:if(isNull(parseDateTimeOrNull(str, '%Y-%m-%dT%H:%i:%S')), str, '')Для временных меток со смещением часового пояса или дробными секундами используйтеparseDateTimeBestEffortOrNull(str)без явной строки формата. - Подход с препроцессором: удалите временную метку из полной строки лога до токенизации с помощью regular expression.
Пример:
replaceRegexpAll(str, '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2} ', '')Это работает с любым токенизатором и эффективнее, поскольку символы временной метки вообще не токенизируются. Оба подхода можно комбинировать: препроцессор удаляет временную метку, а постпроцессор нормализует или фильтрует оставшиеся токены (например, приводит к нижнему регистру и отбрасывает слова уровня серьезности, такие какERRORилиINFO).
- Подход с постпроцессором: используйте токенизатор
- Стемминг. Сопоставление каждого токена с его основой улучшает полноту поиска, позволяя находить морфологические варианты с общим корнем.
Например, при английском стемминге “running”, “runs” и “run” приводятся к основе “run”, поэтому запрос по любому из этих вариантов найдет их все.
ClickHouse предоставляет встроенную функцию stem для нескольких языков.
Пример:
stem(str, 'en') - Нормализация регистра. Приведение токенов к нижнему или верхнему регистру для включения сопоставления, например lower, lowerUTF8. Для приведения к нижнему или верхнему регистру мы рекомендуем использовать препроцессор вместо постпроцессора.”
Array(String), постпроцессор по-прежнему оперирует отдельными токенами как обычными значениями типа String.
Использование недетерминированных функций запрещено.
Постпроцессор применяется к каждому сгенерированному токену при построении индекса (для токенизатора array каждый элемент массива является токеном). Во время выполнения запроса поведение зависит от функции:
- Для
hasToken,hasAllTokens,hasAnyTokensиhasPhrase(с любым поддерживаемым токенизатором): постпроцессор применяется и к токенам в haystack, и к поисковому needle, обеспечивая полностью нормализованное сопоставление (например, регистронезависимый поиск). ДляhasPhraseтокены после постобработки располагаются без промежутков, поэтому токен, который постпроцессор отбрасывает, не оставляет позиционного разрыва, и фраза всё равно сопоставляется через него — например, при постпроцессоре стоп-слов, отбрасывающемthe,hasPhrase(col, 'see cat')соответствует документуsee the cat. - Для всех остальных функций (
=,IN,has,hasAny,hasAll,mapContains*): для поиска с использованием индексной подсказки постобработке подвергается только needle; предикат на уровне строки по-прежнему сравнивается с исходными значениями столбца.
- Удаление стоп-слов с помощью выражения постпроцессора:
- Удалите временные метки с помощью выражения постпроцессора:
- Удалите временные метки с помощью выражения препроцессора:
- Удалите временные метки с помощью общего выражения для препроцессора и постпроцессора:
- Примените стемминг к токенам с помощью выражения постпроцессора:
=, IN, startsWith, endsWith, LIKE, mapContains*) текстовый индекс используется только для пропуска нерелевантных блоков данных; ClickHouse по-прежнему проверяет каждую оставшуюся строку по исходному предикату на исходных данных столбца.
Для функций поиска по токенам (hasToken, hasAllTokens, hasAnyTokens) текстовый индекс является основным способом вычисления: ClickHouse нормализует искомое значение с помощью того же препроцессора, токенизатора и постпроцессора, которые применялись при построении индекса, и использует эту нормализованную форму как для индексированных, так и для неиндексированных частей таблицы. Если задан постпроцессор, токены в haystack также нормализуются во время выполнения запроса (для любого токенизатора, а не только array), поэтому обе стороны сравнения преобразуются единообразно, и результат не зависит от того, читается ли индекс напрямую (настройка query_plan_direct_read_from_text_index) или у конкретной части есть материализованный индекс — например, это позволяет включить регистронезависимое сопоставление для hasAllTokens(col, ['FOO']) с постпроцессором lower.
Без support_phrase_search функция hasPhrase использует индекс только как подсказку и проверяет каждую оставшуюся строку по исходному предикату; постпроцессор дополнительно одинаково нормализует и фразу, и токены в haystack, поэтому результат не зависит от способа чтения, а токены, которые постпроцессор отбрасывает, не нарушают смежность фразы. При support_phrase_search = 1 функция hasPhrase использует точное прямое чтение (при этом постпроцессор, если он задан, всё равно применяется).
Поисковые токены, которые постпроцессор преобразует в пустую строку, игнорируются, то есть считаются отсутствующими в поисковой фразе.
¹
LIKE и match используют прямое чтение как подсказку для указанных токенизаторов; в остальных случаях используется сканирование полным перебором.
LIKE также поддерживает прямое чтение (без подсказки) (включается через use_text_index_like_evaluation_by_dictionary_scan) для токенизаторов splitByNonAlpha и array без препроцессора и постпроцессора.
² ILIKE поддерживается только через прямое чтение (без подсказки) (use_text_index_like_evaluation_by_dictionary_scan = 1, токенизатор splitByNonAlpha или array).
Перехода к использованию индекса как подсказки нет: если настройка отключена или токенизатор не входит в поддерживаемый набор, индекс для ILIKE не используется.
Препроцессор, если он задан, должен быть lower или upper; постпроцессоры не поддерживаются.
Экспериментально: аргумент для поддержки поиска по фразе (необязательный).
Экспериментальный параметр support_phrase_search (по умолчанию: 0) определяет, хранит ли индекс позиции токенов.
Если указать значение 1, индекс дополнительно сохраняет позиционные данные (в файле .pos), что позволяет выполнять точный поиск по фразе с помощью прямого чтения для функции hasPhrase.
Хранение позиций увеличивает размер индекса на диске и стоимость записи, поэтому эта возможность включается только явно.
Формат хранения на диске пока не является стабильным, поэтому этот параметр экспериментальный и может измениться в одном из будущих релизов.
Поэтому для создания индекса с support_phrase_search = 1 требуется, чтобы была включена настройка MergeTree allow_experimental_text_index_phrase_search.
Установите support_phrase_search = 0 (значение по умолчанию), чтобы сохранить хранение только списков вхождений; текстовые индексы, созданные без этого аргумента, остаются без позиций.
Гранулярность индекса.
Текстовые индексы реализованы в ClickHouse как тип индексов пропуска данных.
Однако, в отличие от других индексов пропуска данных, текстовые индексы используют бесконечную гранулярность (100 миллионов).
Это видно в определении таблицы для текстового индекса.
Пример:
Query
Response
Использование текстового индекса
Мы рекомендуем использовать функции
hasAnyTokens и hasAllTokens для поиска по текстовому индексу; см. ниже.
Эти функции работают со всеми доступными токенизаторами и всеми возможными выражениями препроцессора и постпроцессора.
Поскольку остальные поддерживаемые функции исторически появились раньше текстового индекса, во многих случаях им пришлось сохранить прежнее поведение (например, без поддержки препроцессора или постпроцессора).Поддерживаемые функции
WHERE или секциях PREWHERE:
=
= (equals) соответствует заданному поисковому запросу целиком.
Пример:
IN
IN (in) похожа на equals, но выполняет поиск по всем терминам запроса.
Пример:
Текстовый индекс не поддерживает
NOT IN (notIn).LIKE и match
В настоящее время эти функции используют текстовый индекс для фильтрации, только если в качестве токенизатора индекса используется
splitByNonAlpha, ngrams или sparseGrams.NOT LIKE (notLike) не поддерживается текстовым индексом.LIKE (like) и функцию match с текстовыми индексами, ClickHouse должен уметь извлекать полные токены из поискового выражения.
Для индекса с токенизатором ngrams это возможно, если длина искомых строк между подстановочными шаблонами равна длине n-граммы или больше неё.
Пример для текстового индекса с токенизатором splitByNonAlpha:
support в примере может соответствовать support, supports, supporting и т. д.
Такой запрос является поиском по подстроке, и его нельзя ускорить с помощью текстового индекса.
Чтобы использовать текстовый индекс для запросов LIKE, шаблон LIKE нужно переписать следующим образом:
support гарантируют, что этот термин можно извлечь как токен.
К счастью, есть особый случай, когда ClickHouse может использовать инвертированный индекс, чтобы значительно ускорить LIKE-запросы.
Подробнее см. в разделе Настройка производительности для LIKE/ILIKE.
multiSearchAny and multiMatchAny
LIKE и match (см. выше): ClickHouse должен иметь возможность извлечь полные токены из каждой искомой подстроки, а список искомых подстрок должен быть константным.
Гранула считывается, если в ней может присутствовать хотя бы одна искомая подстрока.
Для multiMatchAny, если отдельный шаблон нельзя свести к требованию наличия токена (например, .*, который соответствует любому документу), текстовый индекс использовать нельзя, и запрос переходит к полному сканированию.
Как и в случае с LIKE и match, поиск по подстрокам и регулярным выражениям лучше всего работает с токенизаторами ngrams и sparseGrams.
Эти токенизаторы индексируют перекрывающиеся символьные n-граммы, поэтому искомая подстрока раскладывается на n-граммы, которые присутствуют в индексе везде, где она встречается как подстрока, независимо от того, начинается она или заканчивается в середине слова.
Поэтому искомую подстроку можно использовать как есть, если её длина не меньше размера n-граммы.
Пример текстового индекса с токенизатором ngrams:
splitByNonAlpha, напротив, индексирует только полные токены (целые слова).
Поскольку искомая подстрока может начинаться или заканчиваться в середине слова, ClickHouse отбрасывает первый и последний токены каждой искомой подстроки, поэтому индекс может отсеивать гранулы, используя только полные токены.
Чтобы при поиске по подстроке и регулярному выражению использовался индекс с splitByNonAlpha, окружайте каждую искомую подстроку символами-разделителями (например, пробелами), чтобы она образовывала один или несколько полных токенов.
Пример текстового индекса с токенизатором splitByNonAlpha:
startsWith и endsWith
LIKE, функции startsWith и endsWith могут использовать текстовый индекс, только если из поискового выражения можно извлечь полные токены.
Для индекса с токенизатором ngrams это возможно, если длина искомых строк между подстановочными шаблонами равна длине n-граммы или больше неё.
Если текстовый индекс использует постпроцессор, эти функции всё равно могут использовать индекс в режиме Hint, если извлечённые hint-токены остаются непустыми после нормализации. Если нормализация удаляет все hint-токены, индекс не используется для этого предиката.
Пример для текстового индекса с токенизатором splitByNonAlpha:
clickhouse.
support не считается токеном, поскольку может соответствовать support, supports, supporting и т. д.
Чтобы найти все строки, начинающиеся с clickhouse supports, добавьте в конец шаблона поиска пробел:
endsWith следует использовать с пробелом в начале:
hasToken
Функция
hasToken на первый взгляд кажется простой в использовании, но при использовании для lookup-операций в текстовых индексах с токенизаторами, отличными от splitByNonAlpha, и/или выражениями препроцессора/постпроцессора у неё есть определённые подводные камни.
Вместо неё рекомендуем использовать hasAnyTokens и hasAllTokens.Регистронезависимые варианты hasTokenCaseInsensitive и hasTokenCaseInsensitiveOrNull не учитывают текстовый индекс — они всегда выполняются как полное сканирование строк, даже для столбцов с текстовым индексом. Для регистронезависимого сопоставления используйте препроцессор или постпроцессор lower(...) и комбинируйте его с hasToken / hasAllTokens / hasAnyTokens.hasAnyTokens and hasAllTokens
hasPhrase
hasAllTokens, для которой достаточно, чтобы все токены присутствовали где угодно, hasPhrase требует, чтобы они образовывали непрерывную последовательность.
Поисковая фраза токенизируется с помощью того же токенизатора, который настроен для столбца индекса.
Если текстовый индекс использует постпроцессор, поисковая фраза также нормализуется перед поиском по индексу.
Обратите внимание, что для функции требуется один из токенизаторов splitByNonAlpha, splitByString, ngrams или asciiCJK.
Пример:
has
hasAny и hasAll
mapContains
mapContainsKey) сопоставляет токены, извлечённые из искомой строки, с ключами map.
Поведение аналогично функции equals для столбца String.
Текстовый индекс используется только в том случае, если он был создан для выражения mapKeys(map).
Пример:
mapContainsValue
equals для столбца String.
Текстовый индекс используется только в том случае, если он был создан для выражения mapValues(map).
Пример:
mapContainsKeyLike и mapContainsValueLike
operator[]
mapKeys(map) или mapValues(map), либо для обоих.
Пример:
Array(T) и Map(K, V) с текстовым индексом.
Индексация столбцов Array(String)
clickhouse) требуется сканировать все записи:
keywords:
Индексация столбцов типа Map
Индексация JSON-столбцов
JSON-столбцами тремя способами:
- Индексы для конкретных подстолбцов — создайте текстовый индекс для известного JSON-пути, как и для обычного столбца. При этом индексируются значения по этому пути.
- Индексы на основе путей с JSONAllPaths — индексируют все пути, присутствующие в каждой грануле, чтобы пропускать гранулы, в которых не может быть запрашиваемого пути. Как и в случае со столбцами
Map. - Индексы на основе значений с JSONAllValues — индексируют все значения по всем JSON-путям, чтобы ускорить полнотекстовый поиск по любому подстолбцу JSON с помощью одного индекса.
Индексы для определённых подстолбцов
- Типизированный путь, объявленный в подсказке типа JSON, — прямой доступ по имени:
json.a. - Динамический путь с явным приведением типа — используйте синтаксис приведения
:::json.b::String.
Query
Query
Response
Query
Response
Индексы по путям с JSONAllPaths
Map, текстовые индексы можно создавать для столбцов JSON с помощью JSONAllPaths.
Индекс хранит набор JSON-путей, присутствующих в каждой грануле, и использует их, чтобы пропускать гранулы, в которых запрашиваемый путь отсутствует.
Пример определения индекса:
Query
EXPLAIN indexes = 1, чтобы убедиться, что индекс пропуска данных действительно используется.
Если путь существует только в одной части, индекс пропускает другую часть.
Пример:
Query
Response
Query
Response
IS NOT NULL также использует индекс — он пропускает гранулы, в которых путь отсутствует (поскольку в таком случае значение было бы NULL):
Пример:
Query
Response
Индексы по значениям с JSONAllValues
JSONAllValues.
JSONAllValues возвращает все значения из JSON-столбца в виде Array(String).
Значения нестроковых типов данных (например, целые числа и массивы) преобразуются в текстовое представление.
Текстовый индекс, построенный с использованием JSONAllValues, индексирует эти текстовые представления по всем JSON-путям в каждой строке.
Затем этот индекс может ускорять запросы с фильтрацией по отдельным подстолбцам JSON.
Когда запрос фильтрует по конкретному подстолбцу (например, data.user_name = 'alice'), текстовый индекс может быстро пропускать строки (и гранулы), в которых токены поиска отсутствуют во всех JSON-значениях.
Индекс может давать ложноположительные срабатывания, если одинаковые токены встречаются в разных JSON-путях.
Например, если строка 1 содержит
{"a": "hello", "b": "world"}, а запрос ищет data.a = 'world', текстовый индекс не сможет определить, что world относится к пути b, а не a.
В таких случаях индекс не будет пропускать строку, а окончательную проверку выполнит фильтр по фактическим данным столбца.
Это то же поведение, что и в других сценариях использования текстового индекса, где он выступает в роли быстрого предварительного фильтра.Создание индекса
Поддерживаемые шаблоны запросов
String, а также функции equals для всех столбцов.
Доступ к подстолбцам:
CAST:
IN:
Фразовый поиск
While she stayed in Tokyo, the weather was great. соответствует условию фильтрации.
Напротив, фразовый поиск подразумевает совпадение токенов в заданном порядке.
Например,
weather in Tokyo, например How is the weather in Tokyo??
Текстовый индекс ускоряет поиск по фразам, пересекая списки вхождений для всех токенов во фразе, чтобы определить гранулы-кандидаты.
Затем в пределах этих гранул ClickHouse проверяет точное соседство токенов.
Этот процесс относительно затратен и медленнее обычных запросов текстового поиска.
Чтобы ускорить запросы поиска по фразам, включите сохранение позиций в текстовом индексе (см. Optional parameters выше).
hasPhrase можно использовать вместе с токенизаторами splitByNonAlpha, splitByString, ngrams и asciiCJK.
Указанная строка фразы токенизируется токенизатором индекса.
Символы-разделители во фразе игнорируются: hasPhrase(text, 'quick+brown') эквивалентно hasPhrase(text, 'quick brown'), если в качестве токенизатора используется splitByNonAlpha.
Пример
Query
Response
'New weather in York') не подходит, потому что токены расположены в неправильном порядке.
Строка 3 ('weather in New Orleans') не подходит, потому что не содержит токен 'York'.
Настройка производительности
Прямое чтение
- Настройка query_plan_direct_read_from_text_index (по умолчанию true), которая определяет, включено ли прямое чтение в целом.
- Настройка use_skip_indexes_on_data_read была обязательным предварительным условием для прямого чтения в версиях ClickHouse < 26.4.
hasToken, hasAllTokens и hasAnyTokens.
Если для текстового индекса задан токенизатор array, прямое чтение также поддерживается для функций equals, has, hasAny, hasAll, mapContainsKey и mapContainsValue.
Эти функции также можно комбинировать с помощью операторов AND, OR и NOT.
Секции WHERE или PREWHERE также могут содержать дополнительные фильтры, не связанные с функциями текстового поиска (для текстовых или других столбцов) — в этом случае оптимизация прямого чтения все равно будет использоваться, но менее эффективно (она применяется только к поддерживаемым функциям текстового поиска).
Чтобы понять, использует ли запрос прямое чтение, выполните запрос с EXPLAIN PLAN actions = 1.
Например, запрос с отключенным прямым чтением
query_plan_direct_read_from_text_index = 1
__text_index_<index_name>_<function_name>_<id>.
Если этот столбец присутствует, значит используется прямое чтение.
Если условие WHERE содержит только функции текстового поиска, запрос может вовсе не читать данные столбца и получить максимальный прирост производительности за счет прямого чтения.
Однако даже если к текстовому столбцу обращаются в других частях запроса, прямое чтение все равно даст прирост производительности.
Прямое чтение в качестве подсказки
Прямое чтение в качестве подсказки основано на тех же принципах, что и обычное прямое чтение, но дополнительно добавляет фильтр, построенный на основе данных текстового индекса, не исключая при этом исходный текстовый столбец.
Оно используется для функций, для которых чтение только из текстового индекса приводило бы к ложноположительным срабатываниям.
Поддерживаются следующие функции: like, startsWith, endsWith, equals, has, hasPhrase, mapContainsKey и mapContainsValue.
Дополнительный фильтр может повысить селективность и в сочетании с другими фильтрами сильнее ограничить результирующий набор, помогая сократить объем данных, считываемых из других столбцов.
Прямое чтение в качестве подсказки управляется настройкой query_plan_text_index_add_hint (включена по умолчанию).
Пример запроса без подсказки:
query_plan_text_index_add_hint = 1
__text_index_...).
Благодаря оптимизации PREWHERE условие фильтрации разбивается на три отдельных конъюнкта, которые применяются в порядке возрастания вычислительной сложности.
Для этого запроса они применяются в следующем порядке: __text_index_..., затем greaterOrEquals(...) и, наконец, like(...).
Такой порядок позволяет пропускать ещё больше гранул данных, чем только за счёт текстового индекса и исходного фильтра, ещё до чтения тяжёлых столбцов, используемых в запросе после условия WHERE, что дополнительно уменьшает объём считываемых данных.
Запросы LIKE/ILIKE
%<буквенно-цифровые-символы-без-пробелов>%, а токенизатор текстового индекса — splitByNonAlpha или array, ClickHouse использует инвертированный индекс, чтобы существенно ускорить запросы LIKE/ILIKE. Для этого ClickHouse сканирует словарь инвертированного индекса вместо полного сканирования таблицы, чтобы найти совпадения с шаблоном.
Когда эта оптимизация включена, запросы LIKE/ILIKE должны выполняться значительно быстрее, чем при полном сканировании таблицы. Однако если шаблон соответствует большинству токенов в словаре, производительность может быть хуже, чем при полном сканировании таблицы. К счастью, существует fallback-механизм, который помогает этого избежать.
Эта оптимизация управляется настройкой:
Fallback-механизм управляется двумя настройками:
Эта оптимизация поддерживает только функции like и ilike.
Кэширование
Настройки кэша токенов
Настройки кэша заголовков
Настройки кэша списков вхождений
Ограничения
- Материализация текстовых индексов с большим количеством токенов (например, 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 символа на строку. На практике таблицы также содержат другие столбцы, и этот порог в несколько раз ниже (в зависимости от количества, типа и размера других столбцов).
Текстовые индексы и индексы на основе фильтра Блума
String можно ускорить с помощью текстовых индексов и индексов на основе фильтра Блума (типы индексов bloom_filter, ngrambf_v1, tokenbf_v1, sparse_grams), однако по устройству и предполагаемым сценариям использования они принципиально различаются:
Индексы на основе фильтра Блума
- Основаны на вероятностных структурах данных, которые могут давать ложноположительные срабатывания.
- Способны отвечать только на вопросы о принадлежности множеству, то есть столбец может содержать токен X или точно не содержать X.
- Хранят информацию на уровне гранул, что позволяет пропускать крупные диапазоны при выполнении запроса.
- Их сложно правильно настроить (пример см. здесь).
- Они довольно компактны (от нескольких килобайт до нескольких мегабайт на часть).
- Строят детерминированный инвертированный индекс по токенам. Сам индекс не может давать ложноположительных срабатываний.
- Специально оптимизированы для полнотекстового поиска.
- Хранят информацию на уровне строк, что обеспечивает эффективный поиск по терминам.
- Они довольно велики (от десятков до сотен мегабайт на часть).
- Они не поддерживают расширенную токенизацию и предобработку.
- Они не поддерживают поиск по нескольким токенам.
- Они не обеспечивают характеристик производительности, ожидаемых от инвертированного индекса.
- Они обеспечивают токенизацию и предобработку
- Они эффективно поддерживают
hasAllTokens,LIKE,matchи аналогичные функции текстового поиска. - Они значительно лучше масштабируются на больших текстовых корпусах.
Подробности реализации
- словаря, который сопоставляет каждому токену список вхождений, и
- набора списков вхождений, каждый из которых представляет собой множество номеров строк.
dictionary_block_size).
Файл блоков словаря (.dct) содержит все блоки словаря для всех гранул индекса в части.
Файл заголовка индекса (.idx)
Файл заголовка индекса содержит для каждого блока словаря первый токен блока и его относительное смещение в файле блоков словаря.
Эта структура разреженного индекса похожа на разреженный индекс первичного ключа ClickHouse).
Файл списков вхождений (.pst)
Списки вхождений для всех токенов располагаются последовательно в файле списков вхождений.
Чтобы экономить место и при этом обеспечивать быстрые операции пересечения и объединения, списки вхождений хранятся в виде roaring bitmaps.
Если список вхождений больше posting_list_block_size, он разбивается на несколько блоков, которые последовательно сохраняются в файле списков вхождений.
Файл позиций (.pos)
Необязательный, только если аргумент индекса support_phrase_search = 1.
Хранит позиции токенов в совпадающих строках.
Слияние текстовых индексов
При слиянии частей данных текстовый индекс не нужно перестраивать с нуля; вместо этого его можно эффективно слить на отдельном этапе процесса слияния.
На этом этапе сортированные словари текстовых индексов каждой входной части считываются и объединяются в новый общий словарь.
Номера строк в списках вхождений также пересчитываются, чтобы отразить их новые позиции в слитой части данных, с использованием сопоставления старых и новых номеров строк, которое создаётся на начальном этапе слияния.
Этот способ слияния текстовых индексов аналогичен тому, как сливаются проекции со столбцом _part_offset.
Если индекс не материализован в исходной части, он строится, записывается во временный файл, а затем сливается вместе с индексами из других частей и из других временных файлов индекса.
Отладка
Для анализа текстовых индексов можно использовать табличную функцию mergeTreeTextIndex.
Пример: датасет Hacker News
hackernews:
ALTER TABLE, добавим текстовый индекс для столбца comment, а затем материализуем его:
hasToken, hasAnyTokens и hasAllTokens.
Следующие примеры наглядно покажут существенную разницу в производительности между стандартным сканированием индекса и оптимизацией прямого чтения.
1. Использование hasToken
hasToken проверяет, содержит ли текст один конкретный токен.
Мы будем искать токен ‘ClickHouse’ с учётом регистра.
Прямое чтение отключено (стандартное сканирование)
По умолчанию ClickHouse использует индекс пропуска данных для фильтрации гранул, а затем читает данные столбца для этих гранул.
Мы можем смоделировать это поведение, отключив прямое чтение.
2. Использование hasAnyTokens
hasAnyTokens проверяет, содержит ли текст хотя бы один из указанных токенов.
Мы будем искать комментарии, содержащие ‘love’ или ‘ClickHouse’.
Прямое чтение отключено (стандартное сканирование)
3. Использование hasAllTokens
hasAllTokens проверяет, содержит ли текст все заданные токены.
Мы будем искать комментарии, содержащие и ‘love’, и ‘ClickHouse’.
Прямое чтение отключено (стандартное сканирование)
Даже при отключённом прямом чтении стандартный индекс пропуска данных всё равно остаётся эффективным.
Он сокращает выборку с 28.7M строк до всего 147.46K строк, но при этом всё равно требуется прочитать 57.03 MB из столбца.
4. Составной поиск: OR, AND, NOT, …
hasAnyTokens(comment, ['ClickHouse', 'clickhouse']).
- Блог: Объявляем о выходе полнотекстового поиска ClickHouse в General Availability
- Блог: Создание высокопроизводительного полнотекстового поиска для Объектного хранилища
- Видео: Введение в полнотекстовый поиск в ClickHouse
- Видео: Что внутри: полнотекстовый поиск в ClickHouse при его масштабе и скорости
- Презентация: Полнотекстовый поиск в ClickHouse изнутри: быстрый, нативный и столбцовый
- Презентация: Инвертированные индексы баз данных: зачем, что и как, FOSDEM 2026