Skip to main content
ClickHouse 中的文本索引 (也称为”倒排索引”) 可为字符串数据提供快速的全文检索能力。 索引会将列中的每个标记映射到包含该标记的行。 这些标记通过称为分词的过程生成。 例如,默认情况下,ClickHouse 会将英文句子 “All cat like mice.” 分词为 [“All”, “cat”, “like”, “mice”] (请注意,末尾的句点会被忽略) 。 此外,还提供了更高级的分词器,例如适用于日志数据的分词器。

创建文本索引

要创建文本索引,首先启用相应的 Experimental 设置:
可以使用以下语法在 StringFixedStringArray(String)Array(FixedString)Map (通过映射函数 mapKeysmapValues) 列上定义文本索引:
分词器参数tokenizer 参数用于指定分词器:
  • splitByNonAlpha 按非字母数字的 ASCII 字符拆分字符串 (另见函数 splitByNonAlpha) 。
  • splitByString(S) 按用户定义的特定分隔符字符串 S 拆分字符串 (另见函数 splitByString) 。 分隔符可通过可选参数指定,例如 tokenizer = splitByString([', ', '; ', '\n', '\\'])。 请注意,每个分隔符字符串都可以由多个字符组成 (如示例中的 ', ') 。 如果未显式指定,默认分隔符列表 (例如 tokenizer = splitByString) 为单个空格 [' ']
  • ngrams(N) 将字符串拆分为等长的 N-gram (另见函数 ngrams) 。 ngram 的长度可通过 2 到 8 之间的可选整数参数指定,例如 tokenizer = ngrams(3)。 如果未显式指定,默认 ngram 大小 (例如 tokenizer = ngrams) 为 3。
  • array 不执行分词,即每个行值都是一个标记 (另见函数 array) 。
  • sparseGrams(min_length, max_length, min_cutoff_length) — 使用与 sparseGrams 函数相同的算法,将字符串拆分为所有长度为 min_length 的 ngram,以及若干长度更大、最大可达 max_length (含) 的 ngram。如果指定了 min_cutoff_length,则只有长度大于或等于 min_cutoff_length 的 N-gram 会保存到索引中。与仅生成固定长度 N-gram 的 ngrams(N) 不同,sparseGrams 会在指定范围内生成一组可变长度的 N-gram,从而更灵活地表示文本上下文。例如,tokenizer = sparseGrams(3, 5, 4) 会从输入字符串生成 3-、4-、5-gram,但只将 4- 和 5-gram 保存到索引中。
splitByString 分词器会按从左到右的顺序应用分隔符。 这可能会产生歧义。 例如,分隔符字符串 ['%21', '%'] 会使 %21abc 被分词为 ['abc'];而如果将这两个分隔符字符串改为 ['%', '%21'],输出则会变为 ['21abc']。 大多数情况下,你会希望优先匹配更长的分隔符。 通常可以通过按长度降序传入分隔符字符串来实现。 如果这些分隔符字符串恰好构成前缀码,则可以按任意顺序传入。
目前不建议对非西方语言 (例如中文) 的文本构建文本索引。 当前支持的分词器可能会导致索引体积巨大、查询时间过长。 我们计划在未来添加专门针对特定语言的分词器,以更好地处理这些情况。
要测试分词器如何对输入字符串进行拆分,可以使用 ClickHouse 的 tokens 函数: 例如,
返回
预处理器参数。可选参数 preprocessor 是一个表达式,用于在分词前转换输入字符串。 preprocessor 参数的典型用法包括:
  1. 将输入字符串转换为小写 (或大写) ,以支持不区分大小写的匹配,例如 lowerlowerUTF8;请参见下面的第一个示例。
  2. UTF-8 规范化,例如 normalizeUTF8NFCnormalizeUTF8NFDnormalizeUTF8NFKCnormalizeUTF8NFKDtoValidUTF8
  3. 删除或转换不需要的字符或子字符串,例如 extractTextFromHTMLsubstringidnaEncode
preprocessor 表达式必须将类型为 StringFixedString 的输入值转换为相同类型的值。 示例:
  • 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))
此外,preprocessor 表达式只能引用定义 text index 时所基于的列。 不允许使用非确定性函数。 函数 hasTokenhasAllTokenshasAnyTokens 会先使用 preprocessor 转换搜索词,再对其进行分词。 例如:
等价于:
其他参数。ClickHouse 中的文本索引是作为二级索引实现的。 但与其他跳过索引不同,文本索引的默认索引 GRANULARITY 为 64。 这个值是基于经验选定的,在大多数使用场景下,能够在速度和索引大小之间取得良好的平衡。 高级用户也可以指定不同的索引粒度 (但我们不建议这样做) 。
以下高级参数的默认值在几乎所有情况下都适用。 我们不建议修改它们。可选参数 dictionary_block_size (默认值:128) 用于指定字典块的大小 (以行为单位) 。可选参数 dictionary_block_frontcoding_compression (默认值:1) 用于指定字典块是否使用前缀编码进行压缩。可选参数 max_cardinality_for_embedded_postings (默认值:16) 用于指定基数阈值;低于该阈值时,倒排列表会嵌入到字典块中。可选参数 bloom_filter_false_positive_rate (默认值:0.1) 用于指定字典布隆过滤器的误报率。
表创建完成后,也可以为列添加或移除文本索引:

使用文本索引

在 SELECT 查询中使用文本索引非常简单,因为常见的字符串搜索函数会自动利用该索引。 如果不存在索引,下面的字符串搜索函数将退回到速度较慢的暴力扫描。

支持的函数

如果在 SELECT 查询的 WHERE 子句中使用了文本函数,则可以使用文本索引:

= and !=

= (equals) 和 != (notEquals) 会匹配给定搜索词的完整内容。 示例:
文本索引支持 =!=,但只有在使用 array 分词器时,等值和不等值搜索才有意义 (这会使索引存储整行的值) 。

IN and NOT IN

IN (in) 和 NOT IN (notIn) 与函数 equalsnotEquals 类似,但它们分别匹配所有 (IN) 或完全不匹配 (NOT IN) 搜索词。 示例:
适用与 =!= 相同的限制;也就是说,INNOT IN 只有与 array 分词器配合使用时才有意义。

LIKENOT LIKEmatch

目前,只有当索引分词器为 splitByNonAlphangrams 时,这些函数才会使用文本索引进行过滤。
要让 LIKE likeNOT LIKE (notLike) 以及 match 函数与文本索引配合使用,ClickHouse 必须能够从搜索词中提取完整的标记。 示例:
示例中的 support 可以匹配 supportsupportssupporting 等。 这种查询属于子串查询,无法通过文本索引加速。 要让 LIKE 查询利用文本索引,必须将 LIKE 模式改写为以下形式:
support 左右两侧的空格可确保该术语能被提取为一个标记。

startsWithendsWith

LIKE 类似,startsWithendsWith 这两个函数只有在能从搜索词中提取出完整标记时,才能使用文本索引。 示例:
在该示例中,只有 clickhouse 会被视为一个标记。 support 不算标记,因为它可以匹配 supportsupportssupporting 等形式。 要查找所有以 clickhouse supports 开头的行,请在搜索模式末尾加上一个空格:
同样,endsWith 也应搭配前导空格使用:

hasToken and hasTokenOrNull

函数 hasTokenhasTokenOrNull 用于匹配单个给定的标记。 与前面提到的函数不同,它们不会对搜索词进行分词 (假定输入是单个标记) 。 示例:
函数 hasTokenhasTokenOrNull 是与 text 索引配合使用时性能最佳的函数。

hasAnyTokenshasAllTokens

函数 hasAnyTokenshasAllTokens 用于匹配给定标记中的任意一个或全部标记。 这两个函数接受搜索标记时,既可以传入一个字符串 (会使用与索引列相同的分词器进行分词) ,也可以传入一个已处理好的标记数组,搜索前不会再对其进行分词。 更多信息请参见函数文档。 示例:

has

数组函数 has 用于匹配字符串数组中的单个标记。 示例:

mapContains

函数 mapContains (mapContainsKey 的别名) 用于匹配 map 键中的单个标记。 示例:

operator[]

访问 operator[] 可与文本索引配合使用,用于过滤键和值。 示例:
请参阅以下示例,了解文本索引如何与 Array(T)Map(K, V) 配合使用。

文本索引对 ArrayMap 的支持示例。

为 Array(String) 创建索引

在一个简单的博客平台中,作者会给文章添加关键词,以便对内容进行分类。 一项常见功能是允许用户通过点击关键词或搜索主题来查找相关内容。 考虑以下表定义:
如果没有文本索引,要查找包含特定关键字 (例如 clickhouse) 的帖子,就需要扫描所有记录:
随着平台规模不断扩大,这种方式会越来越慢,因为查询必须检查每一行中的每个 keywords 数组。 为了解决这个性能问题,我们可以为 keywords 定义一个文本索引,构建针对搜索优化的结构,对所有关键词预先处理,从而实现即时查找:
重要:添加文本索引后,必须为现有数据将其重新构建:

为 Map 建立索引

在日志系统中,服务器请求通常会将元数据存储为键值对。运维团队需要高效搜索日志,以便进行调试、处理安全事件和监控。 考虑下面这个日志表:
如果没有文本索引,在 Map 数据中搜索需要进行全表扫描:
  1. 查找所有包含限流信息的日志:
  1. 查找特定 IP 的所有日志:
随着日志量增加,这些查询会变得很慢。 解决办法是为 Map 的键和值创建文本索引。 当你需要按字段名或属性类型查找日志时,请使用 mapKeys 创建文本索引:
如果需要在属性的实际内容中进行搜索,可使用 mapValues 创建文本索引:
重要:添加文本索引后,必须为现有数据重新构建索引:
  1. 查找所有被限流的请求:
  1. 查找特定 IP 的所有日志:

实现

索引布局

每个文本索引由两种 (抽象的) 数据结构组成:
  • 一个字典,将每个标记映射到对应的倒排列表;以及
  • 一组倒排列表,其中每个倒排列表都表示一组行号。
由于文本索引属于跳过索引,因此这些数据结构在逻辑上是按每个索引粒度组织的。 在创建索引时,会创建三个文件 (每个分片各一组) : 字典块文件 (.dct) 索引粒度中的标记会先排序,然后存储到字典块中,每个字典块包含 128 个标记 (块大小可通过参数 dictionary_block_size 配置) 。 字典块文件 (.dct) 包含一个分片中所有索引粒度的全部字典块。 索引粒度文件 (.idx) 索引粒度文件为每个字典块保存以下信息:该块的第一个标记、它在字典块文件中的相对偏移量,以及该块中所有标记的布隆过滤器。 这种稀疏索引结构类似于 ClickHouse 的稀疏主键索引)。 如果要查找的标记不在某个字典块中,布隆过滤器可以提前跳过该字典块。 倒排列表文件 (.pst) 所有标记对应的倒排列表都会按顺序存放在倒排列表文件中。 为了节省空间,同时仍支持快速执行交集和并集操作,倒排列表以 roaring bitmaps 的形式存储。 如果某个倒排列表的基数小于 16 (可通过参数 max_cardinality_for_embedded_postings 配置) ,则会将其直接嵌入字典中。

直接读取

某些类型的文本查询可通过一种称为“直接读取”的优化显著提速。 更具体地说,如果 SELECT 查询的结果中包含该文本列,就可以应用这种优化。 示例:
ClickHouse 中的直接读取优化仅通过文本索引 (即文本索引查找) 来响应查询,而无需访问底层文本列。 文本索引查找读取的数据量相对较少,因此比 ClickHouse 中常规的跳过索引快得多 (后者会先执行跳过索引查找,然后再加载并过滤未被跳过的粒度) 。 直接读取由两个设置控制:
  • 设置 query_plan_direct_read_from_text_index (默认值:1) ,用于指定是否全局启用直接读取。
  • 设置 use_skip_indexes_on_data_read (默认值:1) ,这是直接读取的另一个前置条件。请注意,在 compatibility < 25.10 的 ClickHouse 数据库中,use_skip_indexes_on_data_read 默认处于禁用状态,因此你需要提高 compatibility 设置值,或显式执行 SET use_skip_indexes_on_data_read = 1
此外,文本索引必须已完全物化,才能使用直接读取 (可通过 ALTER TABLE ... MATERIALIZE INDEX 完成) 。 支持的函数 直接读取优化支持 hasTokenhasAllTokenshasAnyTokens 函数。 这些函数也可以通过 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>。 如果存在此列,则表示使用了直接读取。

示例:Hackernews 数据集

下面我们来看看,文本索引在包含大量文本的大型数据集上能带来怎样的性能提升。 我们将使用热门网站 Hacker News 上的 2870 万行评论数据。 下面是未使用文本索引的表:
这 2870 万行数据位于 S3 中的一个 Parquet 文件里——我们把它们插入到 hackernews 表中:
我们将使用 ALTER TABLE 在 comment 列上添加文本索引,然后将其物化:
现在,我们来使用 hasTokenhasAnyTokenshasAllTokens 函数执行查询。 下面的示例将展示标准索引扫描与直接读取优化之间巨大的性能差异。

1. 使用 hasToken

hasToken 用于检查文本是否包含某个特定的单个标记。 我们将搜索区分大小写的标记 ‘ClickHouse’。 禁用直接读取 (标准扫描) 默认情况下,ClickHouse 会使用跳过索引筛选粒度,然后再读取这些粒度的列数据。 我们可以通过禁用直接读取来模拟这种行为。
启用直接读取 (快速索引读取) 现在我们在启用直接读取 (默认设置) 的情况下运行相同的查询。
直接读取查询的速度快了 45 倍以上 (0.362 秒 vs 0.008 秒) ,而且由于只需读取索引,处理的数据量也显著减少 (9.51 GB vs 3.15 MB) 。

2. 使用 hasAnyTokens

hasAnyTokens 用于检查文本是否包含给定标记中的至少一个。 我们将搜索包含 ‘love’ 或 ‘ClickHouse’ 的评论。 已禁用直接读取 (标准扫描)
已启用直接读取 (快速索引读取)
对于这种常见的 “OR” 搜索,性能提升更为显著。 通过避免扫描整列数据,该查询速度几乎提升了 89 倍 (1.329 秒 vs 0.015 秒) 。

3. 使用 hasAllTokens

hasAllTokens 用于检查文本是否包含给定的所有标记。 我们将搜索同时包含 ‘love’ 和 ‘ClickHouse’ 的评论。 禁用直接读取 (标准扫描) 即使禁用了直接读取,标准跳过索引依然有效。 它将 2870 万行过滤到仅 14.746 万行,但仍必须从该列读取 57.03 MB 数据。
已启用直接读取 (快速索引读取) 直接读取通过操作索引数据来响应查询,仅读取 147.46 KB。
对于这种 “AND” 搜索,直接读取优化比标准的跳过索引扫描快 26 倍以上 (0.184s vs 0.007s) 。

4. 复合搜索:OR、AND、NOT、…

直接读取优化也适用于复合布尔表达式。 这里,我们将执行一次不区分大小写的搜索,查找 ‘ClickHouse’ OR ‘clickhouse’。 禁用直接读取 (标准扫描)
直接读取已启用 (快速索引读取)
通过合并索引中的结果,直接读取查询快了 34 倍 (0.450s 对比 0.013s) ,并且无需读取 9.58 GB 的列数据。 对于这种情况,hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) 是更推荐、也更高效的写法。

调优文本索引

目前,针对已反序列化的字典块、头部以及文本索引的倒排列表,提供了缓存机制以减少 I/O。 可分别通过设置 use_text_index_dictionary_cacheuse_text_index_header_cacheuse_text_index_postings_cache 启用这些缓存。默认情况下,它们均处于禁用状态。 请参考以下服务器设置来配置缓存。

服务器设置

字典块缓存设置

头部缓存设置

倒排列表缓存设置

最后修改于 2026年6月25日