使用文本索引进行全文搜索
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-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-gram,但仅返回 4- 和 5-gram。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))
此外,预处理器表达式只能引用定义文本索引的列。不允许使用非确定性函数。
预处理器也可以与 Array(String) 和 Array(FixedString) 列一起使用。在这种情况下,预处理器表达式将单独转换数组元素。
示例
函数 hasToken、hasAllTokens 和 hasAnyTokens 使用预处理器首先转换搜索词,然后再对其进行分词。
例如,
等同于
其他参数(可选)。ClickHouse 中的文本索引实现为 辅助索引。但是,与其他跳过索引不同,文本索引具有无限的粒度,即为整个部分创建文本索引,并且显式指定的索引粒度将被忽略。该值是经验性选择的,并且为大多数用例提供了速度和索引大小之间的良好权衡。高级用户可以指定不同的索引粒度(我们不建议这样做)。
可选的高级参数
以下高级参数的默认值在几乎所有情况下都能正常工作。我们不建议更改它们。
可选参数 dictionary_block_size(默认值:512)指定行中字典块的大小。
可选参数 dictionary_block_frontcoding_compression(默认值:1)指定字典块是否使用前向编码作为压缩。
可选参数 posting_list_block_size(默认值:1048576)指定行中发布列表块的大小。
可选参数 posting_list_codec(默认值:none)指定发布列表的编解码器
可以在创建表后将文本索引添加到列或从列中删除
使用文本索引
在 SELECT 查询中使用文本索引很简单,常用的字符串搜索函数会自动利用索引。如果不存在索引,以下字符串搜索函数将回退到缓慢的暴力扫描。
支持的函数
如果文本函数在 WHERE 子句或 PREWHERE 子句中使用,则可以使用文本索引
= 和 !=
示例
文本索引支持 = 和 !=,但相等和不相等搜索只有与 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 分词器的索引,如果通配符之间的搜索字符串的长度等于或长于 ngram 长度,则这是正确的。
例如,对于具有 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 的键中提取的词元。其行为类似于使用 String 列的 equals 函数。只有当它是在 mapKeys(map) 表达式上创建时,文本索引才会被使用。
示例
mapContainsValue
函数 mapContainsValue 匹配在 map 的值中提取的词元。其行为类似于使用 String 列的 equals 函数。只有当它是在 mapValues(map) 表达式上创建时,文本索引才会被使用。
示例
mapContainsKeyLike 和 mapContainsValueLike
函数 mapContainsKeyLike 和 mapContainsValueLike 匹配模式与 map 的所有键或值(分别)。
示例
operator[]
访问 operator[] 可以与文本索引一起使用,以过滤键和值。只有当它是在 mapKeys(map) 或 mapValues(map) 表达式上创建时,或者同时创建时,文本索引才会被使用。
示例
请参阅以下示例,了解如何使用类型为 Array(T) 和 Map(K, V) 的列与文本索引。
Array 和 Map 列的文本索引示例
索引 Array(String) 列
想象一个博客平台,作者使用关键字对他们的博客文章进行分类。我们希望用户通过搜索或点击主题来发现相关内容。
考虑以下表定义
如果没有文本索引,查找包含特定关键字(例如 clickhouse)的文章需要扫描所有条目
随着平台的发展,这将变得越来越慢,因为查询必须检查每一行中的每个关键字数组。为了克服此性能问题,我们为 keywords 列定义一个文本索引
索引 Map 列
在许多可观察性用例中,日志消息被拆分为“组件”并存储为适当的数据类型,例如日期时间用于时间戳,枚举用于日志级别等。指标字段最好存储为键值对。运营团队需要有效地搜索日志以进行调试、安全事件和监控。
考虑此日志表
如果没有文本索引,搜索 Map 数据需要全表扫描
随着日志量的增长,这些查询会变慢。
解决方案是为 Map 的键和值创建文本索引。使用 mapKeys 在需要按字段名称或属性类型查找日志时创建文本索引
使用 mapValues 在需要搜索属性的实际内容时创建文本索引
示例查询
性能调优
直接读取
某些类型的文本查询可以通过称为“直接读取”的优化显著加速。
示例
ClickHouse 中的直接读取优化完全使用文本索引(即文本索引查找)来回答查询,而无需访问基础文本列。文本索引查找读取的数据量相对较少,因此比 ClickHouse 中通常的跳跃索引(执行跳跃索引查找,然后加载和过滤剩余的颗粒)快得多。
直接读取由两个设置控制
- 设置 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。
支持的函数
直接读取优化支持函数 hasToken、hasAllTokens 和 hasAnyTokens。如果文本索引使用 array 分词器定义,则直接读取也支持函数 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 子句中使用的重型列,从而进一步减少要读取的数据量。
缓存
有不同的缓存可用于将文本索引的各个部分缓冲在内存中(请参阅 实现细节 部分):当前,有用于反序列化字典块、文本索引的标头和发布列表的缓存,以减少 I/O。它们可以通过设置 use_text_index_dictionary_cache、use_text_index_header_cache 和 use_text_index_postings_cache 启用。默认情况下,所有缓存均已禁用。要删除缓存,请使用语句 SYSTEM DROP TEXT INDEX CACHES
请参阅以下服务器设置以配置缓存。
字典块缓存设置
| 设置 | 描述 |
|---|---|
| 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 | 文本索引发布列表缓存中受保护队列的大小相对于总缓存大小的比例。 |
限制
文本索引当前具有以下限制
- 具有大量词元(例如 100 亿个词元)的文本索引的物化会消耗大量的内存。文本索引的物化可以直接发生(
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 个,则会发生这种情况。在实践中,表还包含其他列,阈值会小很多倍(具体取决于其他列的数量、类型和大小)。
实现细节
每个文本索引由两个(抽象)数据结构组成
- 一个字典,将每个 token 映射到发布列表,以及
- 一组发布列表,每个列表代表一组行号。
文本索引是为整个数据部分构建的。与其他跳跃索引不同,文本索引可以在数据部分合并时进行合并,而不是重建(见下文)。
在索引创建期间,会创建三个文件(每个数据部分一个)
字典块文件 (.dct)
文本索引中的 token 会被排序并存储在每个包含 512 个 token 的字典块中(块大小可以通过参数 dictionary_block_size 进行配置)。字典块文件 (.dct) 包含数据部分中所有索引颗粒的所有字典块。
索引头文件 (.idx)
索引头文件包含每个字典块的第一个 token 及其在字典块文件中的相对偏移量。
这种稀疏索引结构类似于 ClickHouse 的 稀疏主键索引)。
发布列表文件 (.pst)
所有 token 的发布列表按顺序排列在发布列表文件中。为了节省空间,同时仍然允许快速的交集和并集操作,发布列表存储为 roaring bitmaps。如果发布列表大于 posting_list_block_size,则将其拆分为多个块,这些块按顺序存储到发布列表文件中。
文本索引的合并
当数据部分合并时,文本索引不需要从头开始重建;相反,它可以作为合并过程的单独步骤有效地合并。在此步骤期间,读取每个输入部分的文本索引的排序字典,并将它们组合成一个新的统一字典。发布列表中行号也会被重新计算,以反映它们在合并后的数据部分中的新位置,使用在初始合并阶段创建的旧行号到新行号的映射。文本索引的这种合并方法类似于 带有 _part_offset 列的投影 的合并方式。如果索引未在源数据部分中物化,则会构建它,将其写入临时文件,然后与来自其他部分和来自其他临时索引文件的索引一起合并。
示例:Hackernews 数据集
让我们看看大型数据集上文本索引的性能改进,该数据集包含大量文本。我们将使用来自受欢迎的 Hacker News 网站的 2870 万条评论。这是没有文本索引的表
这 2870 万行数据位于 S3 中的 Parquet 文件中 - 让我们将它们插入到 hackernews 表中
我们将使用 ALTER TABLE 并添加 comment 列上的文本索引,然后将其物化
现在,让我们运行使用 hasToken、hasAnyTokens 和 hasAllTokens 函数的查询。以下示例将展示标准索引扫描和直接读取优化之间的巨大性能差异。
1. 使用 hasToken
hasToken 检查文本是否包含特定的单个 token。我们将搜索区分大小写的 token 'ClickHouse'。
禁用直接读取(标准扫描) 默认情况下,ClickHouse 使用跳跃索引来过滤颗粒,然后读取这些颗粒的列数据。我们可以通过禁用直接读取来模拟此行为。
启用直接读取(快速索引读取) 现在,我们以启用直接读取(默认设置)的方式运行相同的查询。
直接读取查询速度提高了 45 多倍(0.362s 对 0.008s),并且处理的数据量明显减少(9.51 GB 对 3.15 MB),仅通过从索引读取即可实现。
2. 使用 hasAnyTokens
hasAnyTokens 检查文本是否包含给定的 token 中的至少一个。我们将搜索包含 'love' 或 'ClickHouse' 的评论。
禁用直接读取(标准扫描)
启用直接读取(快速索引读取)
对于这种常见的“OR”搜索,速度提升更加显著。该查询速度快了近 89 倍(1.329s 对 0.015s),避免了对整个列的扫描。
3. 使用 hasAllTokens
hasAllTokens 检查文本是否包含给定的所有 token。我们将搜索包含 'love' 和 'ClickHouse' 的评论。
禁用直接读取(标准扫描) 即使禁用直接读取,标准跳跃索引仍然有效。它将 2870 万行过滤到仅 14.746 万行,但它仍然必须从列读取 57.03 MB。
启用直接读取(快速索引读取) 直接读取通过操作索引数据来回答查询,仅读取 147.46 KB。
对于这种“AND”搜索,直接读取优化比标准跳跃索引扫描快 26 多倍(0.184s 对 0.007s)。
4. 复合搜索:OR、AND、NOT、...
直接读取优化也适用于复合布尔表达式。在这里,我们将执行不区分大小写的搜索 'ClickHouse' OR '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
过时资料