使用文本索引进行全文搜索
ClickHouse 中的文本索引(也称为"倒排索引")为字符串数据提供快速的全文检索能力。 该索引将列中的每个 token(词元)映射到包含该 token 的行。 这些 token(词元)是通过一个称为 tokenization(分词)的过程生成的。 例如,ClickHouse 默认会将英文句子 "All cat like mice." 分词为 ["All", "cat", "like", "mice"](注意末尾的点号会被忽略)。 还提供了更高级的分词器,例如专用于日志数据的分词器。
创建文本索引
要创建文本索引,首先启用对应的实验性设置:
可以使用以下语法在 String、FixedString、Array(String)、Array(FixedString) 以及 Map(通过 mapKeys 和 mapValues map 函数)列上定义文本索引:
tokenizer 参数(必选)。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-gram 和 5-gram 会存储到索引中。array不执行任何分词操作,即每一行的值本身就是一个 token(参见函数 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)指定字典块是否使用 front coding 作为压缩方式。
可选参数 max_cardinality_for_embedded_postings(默认值:16)指定基数阈值,低于该阈值时,posting 列表会被内嵌到字典块中。
可选参数 bloom_filter_false_positive_rate(默认值:0.1)指定字典 Bloom 过滤器的误报率。
在创建表之后,可以在列上添加或移除文本索引:
使用文本索引
在 SELECT 查询中使用文本索引很简单,因为常见的字符串搜索函数会自动利用该索引。 如果不存在索引,下面这些字符串搜索函数将回退为较慢的暴力扫描。
支持的函数
如果在 SELECT 查询的 WHERE 子句中使用了文本函数,则可以使用文本索引:
= 和 !=
= (equals) 和 != (notEquals) 会对给定的搜索词进行完全匹配(匹配整个搜索词)。
示例:
文本索引支持 = 和 !=,但只有在使用 array 分词器时,等值和不等值查询才有意义(因为它会让索引存储整行的值)。
IN 和 NOT IN
IN(in)和 NOT IN(notIn)与函数 equals 和 notEquals 类似,但它们分别匹配所有(IN)或不匹配任何(NOT IN)搜索词。
示例:
与 = 和 != 相同的限制也适用,也就是说,只有在配合 array 分词器使用时,IN 和 NOT IN 才有意义。
LIKE、NOT LIKE 和 match
目前只有当索引 tokenizer 为 splitByNonAlpha 或 ngrams 时,这些函数才会使用文本索引进行过滤。
要在文本索引中使用 LIKE(like)、NOT LIKE(notLike)以及 match 函数,ClickHouse 必须能够从搜索词中提取完整的 token(词元)。
示例:
示例中的 support 可以匹配 support、supports、supporting 等。
这类查询属于子串查询,无法通过文本索引加速。
要在 LIKE 查询中利用文本索引,必须按如下方式改写 LIKE 模式:
support 两侧的空格可以确保该术语能够被单独提取为一个 token。
startsWith 和 endsWith
与 LIKE 类似,当且仅当可以从搜索词中提取出完整的 token 时,函数 startsWith 和 endsWith 才能使用文本索引。
示例:
在本示例中,只有 clickhouse 被视为一个 token。
support 不是 token,因为它可以匹配 support、supports、supporting 等。
要查找所有以 clickhouse supports 开头的行,请在搜索模式末尾加上一个空格字符:
类似地,endsWith 也应与前置空格一起使用:
hasToken 和 hasTokenOrNull
函数 hasToken 和 hasTokenOrNull 用于匹配给定的单个 token。
与前面提到的函数不同,它们不会对搜索词进行分词(假定输入本身就是一个 token)。
示例:
函数 hasToken 和 hasTokenOrNull 是在配合 text 索引使用时性能最优的函数。
hasAnyTokens 和 hasAllTokens
hasAnyTokens 和 hasAllTokens 函数会匹配给定 token 集合中的任意一个或全部。
这两个函数接收的搜索 token 可以是字符串(将使用与索引列相同的 tokenizer 进行分词),也可以是已处理好的 token 数组(搜索前不会再对其进行分词)。 更多信息请参阅函数文档。
示例:
has
数组函数 has 会对字符串数组中的单个 token 进行匹配。
示例:
mapContains
函数 mapContains(是 mapContainsKey 的别名)用于在 map 的键中匹配单个词元。
示例:
operator[]
operator[] 访问运算符可以与文本索引配合使用,以过滤键和值。
示例:
以下示例演示如何在文本索引中使用 Array(T) 和 Map(K, V) 类型的列。
带有文本索引的 Array 和 Map 列示例
为 Array(String) 列建立索引
设想一个博客平台,作者使用关键词为博客文章进行分类。 我们希望用户可以通过搜索或点击这些主题来发现相关内容。
考虑下面的表定义:
如果没有文本索引,要查找包含特定关键字(例如 clickhouse)的帖子,就必须扫描所有记录:
随着平台规模的扩大,这会变得越来越慢,因为查询必须扫描每一行中的 keywords 数组。
为了解决这一性能问题,我们为 keywords 列建立一个文本索引:
为 Map 类型列建立索引
在许多可观测性用例中,日志消息会被拆分为「组件」,并以合适的数据类型存储,例如时间戳使用 DateTime 类型,日志级别使用 Enum 类型等。 指标字段最好以键值对的形式存储。 运维团队需要能够高效地搜索日志,用于调试、安全事件分析和监控。
考虑下面这个 logs 表:
如果没有文本索引,在 Map 数据中进行搜索就必须对整张表做全表扫描:
随着日志量增加,这些查询会变慢。
可以通过为 Map 的键和值创建文本索引来解决。 当需要按字段名或属性类型查找日志时,可使用 mapKeys 来创建文本索引:
在需要在属性的实际内容中执行搜索时,使用 mapValues 来创建文本索引:
查询示例:
性能优化
直接读取
某些类型的文本查询可以通过一种称为“直接读取”的优化显著提速。 更具体地说,当 SELECT 查询中没有选出文本列时,可以应用此优化。
示例:
ClickHouse 中的直接读取优化仅使用文本索引(即基于文本索引的查找)来响应查询,而不访问底层的文本列。 文本索引查找读取的数据量相对较少,因此通常比 ClickHouse 中常见的 skip 索引(跳过索引)要快得多(后者会先进行一次 skip 索引查找,然后再加载并过滤存活的 granule(粒度块))。
直接读取由两个设置控制:
- 设置 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 来启用这些缓存。 默认情况下,所有缓存均处于禁用状态。
请参考以下服务器设置来配置这些缓存。
字典块缓存设置
| Setting | Description |
|---|---|
| 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 | 文本索引字典块缓存中受保护队列大小相对于缓存总大小的比例。 |
Header 缓存设置
| Setting | 描述 |
|---|---|
| text_index_header_cache_policy | 文本索引 header 缓存策略名称。 |
| text_index_header_cache_size | 最大缓存大小(字节)。 |
| text_index_header_cache_max_entries | 缓存中已反序列化 header 的最大数量。 |
| text_index_header_cache_size_ratio | 文本索引 header 缓存中受保护队列的大小占缓存总大小的比例。 |
倒排列表缓存设置
| Setting | 描述 |
|---|---|
| text_index_postings_cache_policy | 文本索引倒排列表缓存策略名称。 |
| text_index_postings_cache_size | 最大缓存大小(字节)。 |
| text_index_postings_cache_max_entries | 缓存中反序列化倒排项的最大数量。 |
| text_index_postings_cache_size_ratio | 文本索引倒排列表缓存中受保护队列占缓存总大小的比例。 |
实现细节
每个文本索引由两个(抽象)数据结构组成:
- 一个字典,用于将每个 token 映射到一个倒排列表(postings list),以及
- 一组倒排列表,每个倒排列表表示一组行号。
由于文本索引是一种跳跃索引(skip index),这些数据结构在逻辑上是按索引粒度划分的。
在创建索引期间,会创建三个文件(针对每个 part):
字典块文件 (.dct)
一个索引粒度内的所有 token 会被排序,并按每 128 个 token 分块存储在字典块中(块大小可通过参数 dictionary_block_size 配置)。
字典块文件 (.dct) 由该 part 中所有索引粒度的全部字典块组成。
索引粒度文件 (.idx)
索引粒度文件对于每个字典块存储该块的第一个 token、其在字典块文件中的相对偏移量,以及该块中所有 token 的 Bloom 过滤器。 这种稀疏索引结构类似于 ClickHouse 的 sparse primary key index。 Bloom 过滤器可以在确认待查找的 token 不包含在某个字典块中时,提前跳过该字典块。
倒排列表文件 (.pst)
所有 token 的倒排列表会顺序存储在倒排列表文件中。
为节省空间并仍然支持快速的交集和并集操作,倒排列表采用 roaring bitmaps 进行存储。
如果某个倒排列表的基数小于 16(可通过参数 max_cardinality_for_embedded_postings 配置),则会将其直接嵌入到字典中。
示例:Hacker News 数据集
我们来看一下在包含大量文本的大型数据集上使用文本索引所带来的性能提升情况。 我们将使用来自知名网站 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 vs 0.007s)。
4. 复合搜索:OR、AND、NOT 等
直接读取优化同样适用于复合布尔表达式。 这里,我们将执行不区分大小写的搜索,匹配 “ClickHouse” 或 “clickhouse”。
已禁用直接读取(标准扫描)
直接读取已启用(快速索引读取)
通过结合使用索引返回的结果,直接读取的查询速度提升了 34 倍(0.450s 对比 0.013s),并且避免读取 9.58 GB 的列数据。
在这个特定场景下,hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) 是更推荐且更高效的写法。