使用文本索引进行全文搜索
文本索引(也称为倒排索引)可以对文本数据进行快速全文搜索。 文本索引存储从词元到包含该词元的行号的映射关系。 词元由称为分词(tokenization)的过程生成。 例如,ClickHouse 的默认分词器会将英文句子 "The cat likes mice." 转换为词元 ["The", "cat", "likes", "mice"]。
例如,假设有一个只有一列且包含三行的表
相应的词元为:
我们通常更倾向于进行不区分大小写的搜索,因此会先将这些标记转换为小写:
我们还将移除诸如 "I"、"the" 和 "and" 之类的填充词,因为它们几乎在每一行中都会出现:
从概念上讲,文本索引包含以下信息:
在给定搜索 token 的情况下,该索引结构可以快速定位所有匹配的行。
创建文本索引
文本索引在 ClickHouse 26.2 及更高版本中已进入 GA (正式发布) 阶段。 在这些版本中,无需配置任何特殊设置即可使用文本索引。 我们强烈建议在生产环境场景中使用 ClickHouse 版本 >= 26.2。
无论 compatibility 设置如何,任何 ClickHouse 版本 >= 26.2 都可以使用文本索引。
要创建文本索引,请使用以下语法:
文本索引可以在下列类型的列上定义:
- String 和 FixedString,
- Array(String) 和 Array(FixedString),
- Map (通过 mapKeys 和 mapValues 函数) ,以及
- JSON (通过 JSONAllPaths 和
JSONAllValues函数)。
Nullable(T) 和 LowCardinality() 类型的列也受支持,包括 Array(Nullable(String or FixedString))。
或者,要为现有表添加一个文本索引:
如果你向已有表添加一个索引,我们建议为表中现有的parts物化此索引 (否则,在这些尚未建立索引的parts上进行搜索时,将会回退到较慢的穷举扫描方式) 。
要删除文本索引,请运行
分词器 参数 (必填) 。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-gram (参见函数 ngrams) 。 ngram 的长度可以通过 1 到 8 之间的可选整数参数指定,例如:tokenizer = ngrams(3)。 如果未显式指定 (例如tokenizer = ngrams) ,则默认的 ngram 大小为 3。sparseGrams(min_length, max_length, min_cutoff_length)将字符串拆分为长度在min_length到max_length(含) 之间的可变长度 n-gram (参见函数 sparseGrams) 。 如果未显式指定,min_length和max_length的默认值分别为 3 和 100。 如果提供了参数min_cutoff_length,则只返回长度大于或等于min_cutoff_length的 n-gram。 与ngrams(N)相比,sparseGrams分词器会生成可变长度的 N-gram,从而可以更灵活地表示原始文本。 例如,tokenizer = sparseGrams(3, 5, 4)在内部会从输入字符串生成长度为 3、4、5 的 n-gram,但只返回长度为 4 和 5 的 n-gram。array不执行任何分词操作,即每一行的值都是一个 标记 (参见函数 array) 。
所有可用的 分词器 都列在 system.tokenizers 中。
splitByString 分词器 会从左到右应用分隔符。
这可能会产生歧义。
例如,分隔字符串 ['%21', '%'] 会导致 %21abc 被分词为 ['abc'],而如果交换两个分隔字符串为 ['%', '%21'],则输出为 ['21abc']。
在大多数情况下,通常希望匹配时优先选择更长的分隔符。
通常可以通过按分隔字符串的长度降序传递它们来实现。
如果这些分隔字符串碰巧构成一个 prefix code,则可以以任意顺序传递。
要理解 分词器 如何拆分输入字符串,可以使用 tokens 和 tokensForLikePattern 函数:
示例:
结果:
处理非 ASCII 输入。
可以在任何语言和字符集的文本数据上构建文本索引。
对于非 ASCII 文本,建议使用 asciiCJK 分词器,因为它能够正确处理 Unicode 单词边界,包括中日韩字符。
:::
预处理器 参数 (可选) 。预处理器 指的是在分词之前应用于输入字符串的一个表达式。
预处理器 参数的典型用例包括
- 转换为小写/大写,或进行大小写折叠以实现大小写不敏感匹配,例如 lower、lowerUTF8、caseFoldUTF8。
- UTF-8 归一化,例如 normalizeUTF8NFC、normalizeUTF8NFD、normalizeUTF8NFKC、normalizeUTF8NFKD、normalizeUTF8NFKCCasefold、toValidUTF8。
- 删除或转换不需要的字符或子串,例如重音符号等,例如 extractTextFromHTML、substring、idnaEncode、translate、removeDiacriticsUTF8。
预处理器表达式必须将类型为 String 或 FixedString 的输入值转换为相同类型的值。
如果文本索引是建立在类型为 Nullable(T) 或 LowCardinality(T) 的列上,那么预处理器表达式应当能够接受可为空或低基数的值 (即不会抛出异常) 。
示例:
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))
不允许使用非确定性函数。
函数 hasToken、hasAllTokens 和 hasAnyTokens 会使用预处理器先对搜索词进行转换,然后再进行分词。
例如,
等价于:
在这种情况下,预处理器表达式会逐个转换数组元素。
示例:
要在针对 Map 类型列构建的文本索引中定义预处理器,用户需要先决定该索引是基于 Map 的键还是值构建的。
示例:
其他参数 (可选) 。
可选高级参数
以下高级参数的默认值在几乎所有场景下都能很好地工作。 我们不建议修改它们。
可选参数 dictionary_block_size (默认值:512) 指定字典块的大小 (以行数计) 。
可选参数 dictionary_block_frontcoding_compression (默认值:1) 指定字典块是否使用 front coding 作为压缩方式。
可选参数 posting_list_block_size (默认值:1048576) 指定倒排列表 (posting list) 块的大小 (以行数计) 。
可选参数 posting_list_codec (默认值:`none) 指定倒排列表使用的编解码器:
none- 倒排列表在存储时不进行额外压缩。bitpacking- 先应用差分 (delta) 编码,然后进行bit-packing (均在固定大小的块内完成) 。会减慢 SELECT 查询的速度,目前不推荐使用。
索引粒度。 文本索引在 ClickHouse 内部实现为一种跳过索引类型。 但是,与其他跳过索引不同,文本索引使用“无限粒度” (1 亿) 。 这一点可以从文本索引的表定义中看出。
示例:
结果:
较大的索引粒度可确保为整个 part 创建文本索引。 显式指定的索引粒度将被忽略。
使用文本索引
在 SELECT 查询中使用文本索引很简单,常见的字符串搜索函数会自动利用该索引。
如果在某个列或表分片上不存在索引,这些字符串搜索函数将退化为较慢的暴力扫描。
我们建议使用 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 分词器 的索引,如果通配符之间所搜索字符串的长度大于或等于 ngram 的长度,则满足该条件。
使用 splitByNonAlpha 分词器 的文本索引示例:
示例中的 support 可以匹配 support、supports、supporting 等等。
这种查询属于子串查询,无法通过文本索引加速。
要在 LIKE 查询中利用文本索引,必须将 LIKE 模式字符串按如下方式改写:
support 左右的空格能够确保该词被提取为一个单独的 标记。
幸运的是,有一种特殊情况,ClickHouse 可以利用倒排索引显著加速 LIKE 查询。
startsWith 和 endsWith
与 LIKE 类似,当且仅当能够从搜索词中提取出完整的 token 时,函数 startsWith 和 endsWith 才能使用文本索引。
对于使用 ngrams tokenizer 的索引,如果通配符之间待搜索字符串的长度大于或等于 ngram 长度,则满足这一条件。
使用 splitByNonAlpha tokenizer 的文本索引示例:
在此示例中,只有 clickhouse 被视为一个 token。
support 不算 token,因为它还可以匹配 support、supports、supporting 等。
要查找所有以 clickhouse supports 开头的行,请在搜索模式末尾添加一个尾随空格:
类似地,使用 endsWith 时应在前面加一个空格:
hasToken 和 hasTokenOrNull
函数 hasToken 看起来使用起来很简单,但在使用非默认 tokenizer 和预处理表达式时存在一些陷阱。
我们推荐改用函数 hasAnyTokens 和 hasAllTokens。
函数 hasToken 和 hasTokenOrNull 用于匹配单个给定的 token。
与前面提到的函数不同,它们不会对搜索词进行分词(假定输入本身就是单个 token)。
示例:
hasAnyTokens 和 hasAllTokens
函数 hasAnyTokens 和 hasAllTokens 用于匹配任意或全部给定的 token。
这两个函数接受的搜索 token 可以是字符串(将使用与索引列相同的 tokenizer 进行分词),也可以是已处理好的 token 数组(在搜索前不会再进行分词)。 更多信息请参阅函数文档。
示例:
hasPhrase
函数 hasPhrase 用于按短语匹配:所有标记必须按与搜索字符串相同的顺序连续出现。
与 hasAllTokens 只要求所有标记出现在任意位置不同,hasPhrase 要求它们作为一个连续序列出现。
搜索短语会使用为索引列配置的同一分词器进行分词。
请注意,该函数需要使用 splitByNonAlpha、splitByString、ngrams 或 asciiCJK 分词器之一。
示例:
has
数组函数 has 用于在字符串数组中匹配单个 token。
示例:
mapContains
函数 mapContains(mapContainsKey 的别名)会在 map 的键中,匹配从待搜索字符串中提取的 token。
其行为类似于在 String 列上使用 equals 函数。
只有当文本索引是基于 mapKeys(map) 表达式创建时,才会被使用。
示例:
mapContainsValue
函数 mapContainsValue 会在 map 的值中,针对从被搜索的字符串中提取出的 token 进行匹配。
其行为类似在 String 列上使用 equals 函数。
只有当文本索引是基于 mapValues(map) 表达式创建时才会被使用。
示例:
mapContainsKeyLike 和 mapContainsValueLike
函数 mapContainsKeyLike 和 mapContainsValueLike 用于将给定模式分别匹配到 map 的所有键或所有值上。
示例:
operator[]
operator[] 访问运算符可以与文本索引配合使用,用于过滤键和值。只有当文本索引建立在 mapKeys(map) 或 mapValues(map) 表达式(或二者同时)上时,才会被使用。
示例:
请参考以下示例,了解如何在文本索引中使用类型为 Array(T) 和 Map(K, V) 的列。
为 Array(String) 列建立索引
假设有一个博客平台,作者使用关键词为他们的博客文章进行分类。 我们希望用户能够通过搜索或点击主题来发现相关内容。
考虑如下表定义:
如果没有文本索引,要查找包含特定关键字 (例如 clickhouse) 的帖子,就必须扫描所有记录:
随着平台规模的增长,这会变得越来越慢,因为查询必须检查每一行中的 keywords 数组。
为了解决这个性能问题,我们为 keywords 列定义一个文本索引:
为 Map 列建立索引
在许多可观测性用例中,日志消息通常会被拆分为“组件”,并按合适的数据类型存储,例如时间戳使用日期时间类型、日志级别使用 enum 等。 指标字段通常最好存储为键值对。 运维团队需要高效地搜索日志,用于调试、安全事件分析和监控。
考虑如下日志表:
如果没有文本索引,对 Map 数据的搜索需要执行全表扫描:
随着日志量的增长,这些查询会变得很慢。
一种解决方案是为 Map 的键和值创建文本索引。 当需要根据字段名称或属性类型查找日志时,使用 mapKeys 来创建文本索引:
当需要在属性的实际内容中进行搜索时,使用 mapValues 来创建文本索引:
查询示例:
为 JSON 列建立索引
文本索引可通过以下三种方式用于 JSON 列:
- 针对特定子列的索引 —— 在已知的 JSON 路径上创建文本索引,就像对普通列所做的那样。这会为该路径上的值建立索引。
- 使用 JSONAllPaths 的基于路径的索引 —— 对每个粒度中存在的所有路径建立索引,以跳过不可能包含所查询路径的粒度。与
Map列类似。 - 使用 JSONAllValues 的基于值的索引 —— 对所有 JSON 路径中的所有值建立索引,从而只需一个索引即可加速对任意 JSON 子列的全文搜索。
特定子列上的索引
您可以对任意 JSON 子列创建跳过索引,其语法与普通列相同。
在索引表达式中引用 JSON 子列有两种方式:
- 在 JSON 类型提示中声明的 类型化路径 — 直接通过名称访问:
json.a。 - 带显式类型转换的 动态路径 — 使用
::类型转换语法:json.b::String。
示例索引定义:
示例查询:
结果:
示例查询:
结果:
使用 JSONAllPaths 的基于路径的索引
与 Map 列类似,也可以使用 JSONAllPaths 在 JSON 列上创建文本索引。
该索引会存储每个粒度中存在的 JSON 路径集合,并利用这些路径跳过不包含查询路径的粒度。
示例索引定义:
您可以使用 EXPLAIN indexes = 1 来验证是否使用了跳过索引。
当某个路径仅存在于一个 parts 中时,索引会跳过另一个 parts。
示例:
结果:
当某一路径在任何 part 中都不存在时,将跳过所有 parts 和 granules。
示例:
结果:
IS NOT NULL 也会使用索引——它会跳过路径不存在的그래뉼 (因为此时该值会是 NULL) :
示例:
结果:
使用 JSONAllValues 的基于值的索引
可以通过 JSONAllValues 函数在 JSON 列上使用文本索引来加速搜索。
JSONAllValues 会将 JSON 列中的所有值作为 Array(String) 返回。
非字符串数据类型的值 (例如整数和数组) 会被转换为其文本表示形式。
JSONAllValues 上的文本索引会对每一行中所有 JSON 路径上的这些文本表示建立索引。
随后,该索引可以加速对各个 JSON 子列进行筛选的查询。
当查询按特定子列进行筛选时 (例如 data.user_name = 'alice') ,文本索引可以快速跳过那些在任意 JSON 值中都不包含搜索标记的行 (以及 granules) 。
当不同的 JSON 路径包含相同的标记时,该索引可能会产生误报。
例如,如果第 1 行为 {"a": "hello", "b": "world"},而查询搜索 data.a = 'world',文本索引无法区分 world 属于路径 b 而不是 a。
在这种情况下,索引不会跳过该行,最终会由实际列数据上的筛选条件完成判断。
这种行为与文本索引的其他使用场景相同,即索引充当快速预筛选器。
创建索引
索引定义示例:
支持的查询模式
索引创建后,可使用与 String 列相同的函数来加速对 JSON 子列的查询;对于所有列,则可使用 equals 函数。
子列访问:
通过显式 CAST 访问子列:
IN 运算符:
短语搜索
文本索引支持通过 hasPhrase 函数执行短语搜索。
短语中的所有标记都必须在文档中按相同顺序连续出现。
文本索引通过对短语中所有标记的倒排列表求交来确定候选粒度,从而加速短语搜索。 在这些粒度内,ClickHouse 随后会验证标记是否精确相邻。
hasPhrase 支持与 splitByNonAlpha、splitByString、ngrams 和 asciiCJK 分词器配合使用。
短语字符串会使用索引所配置的分词器进行分词。
短语中的分词器分隔符字符会被忽略:对于 splitByNonAlpha 分词器,hasPhrase(text, 'quick+brown') 等价于 hasPhrase(text, 'quick brown')。
示例
结果:
第 2 行 ('New weather in York') 不匹配,因为这些标记的顺序不对。
第 3 行 ('weather in New Orleans') 不匹配,因为其中不包含标记 'York'。
性能调优
直接读取
某些类型的文本查询可以通过一种名为“直接读取”的优化显著提升性能。
示例:
直接读取优化会仅使用文本索引来回答查询 (即通过文本索引查找) ,而无需访问底层文本列。 文本索引查找读取的数据量相对较少,因此比 ClickHouse 中常规的 跳过索引快得多 (后者会先执行 跳过索引查找,然后再加载并过滤剩余的数据颗粒 granules) 。
直接读取由两个设置控制:
- 设置 query_plan_direct_read_from_text_index (默认值为 true) ,用于指定是否全局启用直接读取。
- 设置 use_skip_indexes_on_data_read 在 ClickHouse 版本 < 26.4 中是启用直接读取的前提条件。
支持的函数
直接读取优化支持函数 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 子句中使用的开销较大的列之前,就能在文本索引和原始过滤条件已经跳过的数据粒度基础上,进一步跳过更多数据粒度,从而减少需要读取的数据量。
LIKE/ILIKE 查询
当 LIKE/ILIKE 查询模式为 %<alpha-numeric-characters-without-spaces>%,且 文本索引 的分词器为 splitByNonAlpha 或 array 时,ClickHouse 会利用转置索引显著加速 LIKE/ILIKE 查询。为实现这一点,ClickHouse 会扫描转置索引字典来查找匹配模式,而不是执行全表扫描。
启用该优化后,LIKE/ILIKE 查询通常会比全表扫描快得多。不过,当该模式匹配字典中的大多数标记时,其性能反而可能不如全表扫描。幸运的是,系统提供了回退机制来避免这种情况。
该优化由以下设置控制:
回退机制由以下两个设置控制:
此优化仅支持函数 like 和 ilike。
缓存
有多种缓存可用于在内存中缓冲文本索引的部分内容(参见实现细节部分)。 当前,对文本索引的反序列化头部信息、tokens(标记)以及 posting lists(倒排列表)都提供了缓存,以减少 I/O。 可以通过以下 SETTING 启用这些缓存:use_text_index_header_cache、use_text_index_tokens_cache 和 use_text_index_postings_cache。 默认情况下,所有缓存均为禁用状态。 要清除这些缓存,请使用语句 SYSTEM CLEAR TEXT INDEX CACHES。
请参考以下服务端 SETTING 来配置这些缓存。
词元缓存设置
| 设置 | 说明 |
|---|---|
| text_index_tokens_cache_policy | 文本索引词元缓存策略名称。 |
| text_index_tokens_cache_size | 最大缓存大小(以字节为单位)。 |
| text_index_tokens_cache_max_entries | 缓存中反序列化的词元最大数量。 |
| text_index_tokens_cache_size_ratio | 文本索引词元缓存中受保护队列相对于缓存总大小的比例。 |
头部缓存设置
| Setting | 描述 |
|---|---|
| text_index_header_cache_policy | 文本索引头部缓存策略名称。 |
| text_index_header_cache_size | 最大缓存大小(字节)。 |
| text_index_header_cache_max_entries | 缓存中反序列化头部的最大数量。 |
| text_index_header_cache_size_ratio | 文本索引头部缓存中受保护队列占缓存总大小的比例。 |
Posting 列表缓存设置
| Setting | Description |
|---|---|
| text_index_postings_cache_policy | 文本索引 posting 列表缓存策略的名称。 |
| text_index_postings_cache_size | 最大缓存大小(以字节为单位)。 |
| text_index_postings_cache_max_entries | 缓存中已反序列化 posting 的最大数量。 |
| text_index_postings_cache_size_ratio | 文本索引 posting 列表缓存中受保护队列大小相对于缓存总大小的比例。 |
限制
当前文本索引具有以下限制:
- 对包含大量 tokens(例如 100 亿 tokens)的文本索引进行物化时,可能会消耗大量内存。文本索引的物化可以直接进行(
ALTER TABLE <table> MATERIALIZE INDEX <index>),也可以在分区片段合并时通过间接方式发生。 - 无法在包含超过 4.294.967.296(= 2^32 ≈ 42 亿)行的分区片段上物化文本索引。如果没有物化的文本索引,查询会退回到在该分区片段内执行低效的暴力搜索。作为一种最坏情况的估算,假设某个分区片段只包含一个类型为 String 的列,并且 MergeTree 设置
max_bytes_to_merge_at_max_space_in_pool(默认值:150 GB)未被修改。在这种假设下,只要该列平均每行少于 29.5 个字符,就会出现上述情况。实际上,表中通常还包含其他列,因此阈值通常会小好几倍(具体取决于其他列的数量、类型和大小)。
文本索引 vs 基于 Bloom Filter 的索引
可以通过使用文本索引和基于 Bloom Filter 的索引(索引类型 bloom_filter、ngrambf_v1、tokenbf_v1、sparse_grams)来加速字符串谓词,但两者在设计和预期使用场景上从根本上是不同的:
Bloom Filter 索引
- 基于可能产生假阳性的概率型数据结构。
- 只能回答集合成员关系问题,即:该列可能包含 token X,或可以确定不包含 X。
- 存储粒度级信息,以便在查询执行期间跳过粗粒度范围。
- 难以正确调优(示例参见此处)。
- 相对紧凑(每个 part 通常只有几 KB 或几 MB)。
文本索引
- 在 token 之上构建确定性的倒排索引,索引本身不会产生假阳性。
- 专门针对文本搜索类工作负载进行了优化。
- 存储行级信息,从而支持高效的词项查找。
- 体积相对较大(每个 part 通常为数十到数百 MB)。
基于 Bloom Filter 的索引对全文搜索的支持只是一种“副作用”:
- 不支持高级分词和预处理。
- 不支持多 token 搜索。
- 无法提供倒排索引所期望的性能特征。
相比之下,文本索引是为全文搜索专门构建的:
- 提供分词和预处理能力。
- 高效支持
hasAllTokens、LIKE、match以及类似的文本搜索函数。 - 在处理大型文本语料库时具有显著更好的可扩展性。
Implementation Details
每个文本索引由两个 (抽象的) 数据结构组成:
- 一个字典,将每个 标记 映射到一个倒排列表 (postings list) ,以及
- 一组倒排列表,每个倒排列表表示一组行号。
文本索引是针对整个 parts 构建的。 与其他跳过索引不同,在合并数据 parts 时,文本索引可以通过合并来处理,而无需重新构建 (见下文) 。
在创建索引期间,会创建三个文件 (每个 parts 一个) :
Dictionary blocks file (.dct)
文本索引中的 标记 会被排序,并以每 512 个 标记 为一组存储到字典块中 (块大小可通过参数 dictionary_block_size 配置) 。
字典块文件 (.dct) 由某个 parts 内所有索引粒度 (granule) 的全部字典块组成。
Index 头部信息 file (.idx)
索引头部信息文件为每个字典块存储该块的第一个 标记 以及它在字典块文件中的相对偏移量。
这种稀疏索引结构类似于 ClickHouse 的 稀疏主键索引。
Postings lists file (.pst)
所有 标记 的倒排列表按顺序存储在倒排列表文件 (.pst) 中。
为了节省空间,同时仍然支持快速的交集和并集操作,倒排列表以 roaring bitmaps 的形式存储。
如果某个倒排列表大于 posting_list_block_size,则会被拆分为多个块,并按顺序写入倒排列表文件。
Merging of text indexes
当数据 parts 被合并时,无需从头重建文本索引;相反,可以在合并流程的单独步骤中高效地对其进行合并。
在该步骤中,会读取并合并每个输入 parts 中文本索引的有序字典,生成一个新的统一字典。
倒排列表中的行号也会被重新计算,以反映它们在合并后数据 parts 中的新位置,这个过程使用在初始合并阶段创建的旧行号到新行号的映射。
这种合并文本索引的方法类似于带有 _part_offset 列的 projections 的合并方式。
如果源 parts 中索引尚未物化 (materialized) ,则会先构建该索引,将其写入一个临时文件,然后与来自其他 parts 和其他临时索引文件的索引一起合并。
Debugging
可以使用表函数 mergeTreeTextIndex 来查看和分析文本索引。
示例:Hacker News 数据集
我们来看一下在包含大量文本的大型数据集上,使用文本索引带来的性能提升。 我们将使用来自知名网站 Hacker News 的 2870 万行评论数据。 下面是未使用文本索引的表:
这 2870 万行数据位于 S3 上的一个 Parquet 文件中——让我们将它们插入到 hackernews 表中:
我们将使用 ALTER TABLE,在评论列上添加一个文本索引,然后将其物化:
现在,让我们运行一些使用 hasToken、hasAnyTokens 和 hasAllTokens 函数的查询。
下面的示例将展示标准索引扫描与直接读取优化之间巨大的性能差异。
1. 使用 hasToken
hasToken 用于检查文本中是否包含某个特定的单个 token。
我们将在搜索中查找区分大小写的 token 'ClickHouse'。
禁用直接读取(标准扫描) 默认情况下,ClickHouse 使用跳过索引(skip index)来过滤 granule,然后读取这些 granule 的列数据。 我们可以通过禁用直接读取来模拟这种行为。
已启用直接读取(快速索引读取) 现在我们在启用直接读取(默认启用)的情况下运行相同的查询。
直接读取的查询速度快了 45 倍以上(0.362s 对比 0.008s),并且由于仅从索引中读取数据,处理的数据量大幅减少(9.51 GB 对比 3.15 MB)。
2. 使用 hasAnyTokens
hasAnyTokens 用于检查文本是否至少包含任意一个指定的 token。
我们将搜索包含 'love' 或 'ClickHouse' 的评论。
已禁用直接读取 (标准扫描)
已启用直接读取 (快速索引读取)
对于这种常见的“OR”搜索,加速效果更加显著。 通过避免对整列进行扫描,该查询速度提升了近 89 倍 (1.329s vs 0.015s) 。
3. 使用 hasAllTokens
hasAllTokens 会检查文本是否包含给定的所有 token。
我们将搜索同时包含 'love' 和 'ClickHouse' 的评论。
禁用直接读取 (标准扫描) 即使禁用了直接读取,标准跳过索引依然有效。 它将 2870 万行过滤到仅 14.746 万行,但仍然必须从列中读取 57.03 MB 的数据。
已启用直接读取 (快速索引读取) 直接读取通过仅操作索引数据来完成该查询,只需读取 147.46 KB 的数据。
对于这种“AND”搜索,直接读取优化比标准跳过索引的扫描快 26 倍以上 (0.184 秒 vs 0.007 秒) 。
4. 复合搜索:OR、AND、NOT 等
直接读取优化同样适用于复合布尔表达式。 在这里,我们将执行对 'ClickHouse' 或 'clickhouse' 的不区分大小写的搜索。
已禁用直接读取 (标准扫描)
已启用直接读取 (快速索引读取)
通过结合索引过滤的结果,直接读取的查询快了 34 倍 (0.450s 对比 0.013s) ,并且避免了读取 9.58 GB 的列数据。
对于这个特定场景,hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) 将是更推荐使用的、更高效的写法。
相关内容
- 演示文稿:https://github.com/ClickHouse/clickhouse-presentations/blob/master/2025-tumuchdata-munich/ClickHouse_%20full-text%20search%20-%2011.11.2025%20Munich%20Database%20Meetup.pdf
- 演示文稿:https://presentations.clickhouse.com/2026-fosdem-inverted-index/Inverted_indexes_the_what_the_why_the_how.pdf
已过时的内容