创建文本索引
无论 compatibility 设置如何,任何 ClickHouse >= 26.2 版本都可以使用文本索引。
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-grams (参见函数 ngrams) 。 可以使用 1 到 8 之间的可选整数参数指定 ngram 长度,例如tokenizer = ngrams(3)。 如果未显式指定,默认 ngram 大小 (例如tokenizer = ngrams) 为 3。sparseGrams(min_length, max_length, min_cutoff_length)将字符串拆分为长度可变的 n-grams,长度至少为min_length个字符、至多为max_length个字符 (含边界) (参见函数 sparseGrams) 。 除非显式指定,否则min_length和max_length默认分别为 3 和 100。 如果提供了参数min_cutoff_length,则只返回长度大于或等于min_cutoff_length的 n-grams。 与ngrams(N)相比,sparseGrams分词器会生成长度可变的 N-grams,因此能更灵活地表示原始文本。 例如,tokenizer = sparseGrams(3, 5, 4)会在内部从输入字符串生成 3-、4-、5-grams,但只返回 4- 和 5-grams。array不执行分词,也就是说,每行的值都是一个标记 (参见函数 array) 。
splitByString 分词器会按从左到右的顺序应用这些拆分分隔符。
这可能会导致歧义。
例如,分隔符字符串 ['%21', '%'] 会将 %21abc 分词为 ['abc'];而如果把两个分隔符字符串的顺序改为 ['%', '%21'],则会输出 ['21abc']。
大多数情况下,你会希望优先匹配更长的分隔符。
通常可以通过按长度降序传入分隔符字符串来实现。
如果这些分隔符字符串恰好构成 prefix code,则可以按任意顺序传入。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))
Query
Query
Query
Query
Query
Response
使用文本索引
我们建议使用函数
hasAnyTokens 和 hasAllTokens 来搜索文本索引,请参见下文。
这些函数适用于所有可用的分词器以及所有可能的预处理表达式。
由于其他受支持的函数在历史上早于文本索引,因此在很多情况下必须保留其原有行为 (例如不支持预处理) 。支持的函数
WHERE 子句或 PREWHERE 子句中使用了文本函数,则可以使用文本索引:
=
= (等于) 会完整匹配给定的搜索词。
示例:
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 pattern 按如下方式改写:
support 左右两侧的空格可确保该术语能被提取为一个标记。
幸运的是,在一种特殊情况下,ClickHouse 可以利用倒排索引显著加速 LIKE 查询。
详见 LIKE/ILIKE 性能调优章节。
startsWith and endsWith
LIKE 类似,startsWith 和 endsWith 函数只有在能够从搜索词中提取出完整标记时,才能使用文本索引。
对于使用 ngrams 分词器的索引,如果通配符之间搜索字符串的长度等于或大于 ngram 长度,则满足这一条件。
使用 splitByNonAlpha 分词器的文本索引示例:
clickhouse 会被视为一个标记。
support 不是标记,因为它可以匹配 support、supports、supporting 等形式。
要查找所有以 clickhouse supports 开头的行,请在搜索模式末尾添加一个尾随空格:
endsWith 也应在前面加上空格:
hasToken 和 hasTokenOrNull
函数
hasToken 看起来很容易使用,但在使用非默认分词器和预处理表达式时存在一些陷阱。
我们建议改用 hasAnyTokens 和 hasAllTokens 函数。hasAnyTokens and hasAllTokens
hasPhrase
hasAllTokens 只要求所有标记出现在任意位置,hasPhrase 要求它们按连续序列出现。
搜索短语会使用为索引列配置的同一个分词器进行分词。
请注意,该函数要求使用 splitByNonAlpha、splitByString、ngrams 或 asciiCJK 分词器之一。
示例:
has
has 数组函数 has 用于匹配字符串数组中的单个标记。
示例:
hasAny 和 hasAll
mapContains
mapContainsKey 的别名) 会在 map 的键中,将从搜索字符串中提取出的标记进行匹配。
其行为类似于作用于 String 列的 equals 函数。
仅当文本索引创建在 mapKeys(map) 表达式上时,才会使用该索引。
示例:
mapContainsValue
String 列使用 equals 函数。
只有在 mapValues(map) 表达式上创建了文本索引时,才会使用该文本索引。
示例:
mapContainsKeyLike 和 mapContainsValueLike
operator[]
mapKeys(map) 或 mapValues(map) 表达式上创建了文本索引,或同时在两者上创建时,才会使用该文本索引。
示例:
Array(T) 和 Map(K, V) 类型的列使用文本索引。
为 Array(String) 列创建索引
clickhouse) 的帖子,就需要扫描所有条目:
keywords 数组。
为了解决这个性能问题,我们为列 keywords 定义一个文本索引:
为 Map 列创建索引
为 JSON 列建立索引
JSON 列:
- 特定子列上的索引 — 在已知的 JSON 路径上创建文本索引,就像对普通列那样。这会对该路径上的值建立索引。
- 基于路径的索引,使用 JSONAllPaths — 对每个粒度中存在的所有路径建立索引,以跳过不可能包含所查询路径的粒度。类似于
Map列。 - 基于值的索引,使用 JSONAllValues — 对所有 JSON 路径中的所有值建立索引,从而通过单个索引加速对任何 JSON 子列的全文搜索。
特定子列上的索引
- 在 JSON type hint 中声明的 类型化路径 —— 可直接通过名称访问:
json.a。 - 带显式 cast 的 动态路径 —— 使用
::cast 语法:json.b::String。
Query
Query
Response
Query
Response
使用 JSONAllPaths 的基于路径的索引
Map 列类似,可借助 JSONAllPaths 在 JSON 列上创建文本索引。
该索引会存储每个粒度中包含的 JSON 路径集合,并据此跳过不包含查询路径的粒度。
示例索引定义:
Query
EXPLAIN indexes = 1 来验证跳过索引是否已生效。
当某个路径只存在于一个分片中时,索引会跳过另一个分片。
示例:
Query
Response
Query
Response
IS NOT NULL 也会使用索引——它会跳过不存在该 path 的粒度 (因为其值将为 NULL) :
示例:
Query
Response
使用 JSONAllValues 的基于值的索引
JSONAllValues 在 JSON 列上使用文本索引,以加速搜索。
JSONAllValues 会以 Array(String) 的形式返回 JSON 列中的所有值。
非字符串数据类型的值 (例如整数和数组) 会被转换为对应的文本表示。
使用 JSONAllValues 构建的文本索引会为每一行中所有 JSON 路径上的这些文本表示建立索引。
这样,该索引就可以加速对单个 JSON 子列进行过滤的查询。
当查询针对某个特定子列进行过滤时 (例如 data.user_name = 'alice') ,文本索引可以快速跳过那些在任意 JSON 值中都不包含搜索标记的行 (以及粒度) 。
当不同的 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')。
示例
Query
Query
Response
'New weather in York') 不匹配,因为标记顺序不正确。
第 3 行 ('weather in New Orleans') 不匹配,因为其中不包含标记 'York'。
性能调优
直接读取
- 设置 query_plan_direct_read_from_text_index (默认值为 true) ,用于指定是否通常启用直接读取。
- 在 ClickHouse 版本 < 26.4 中,设置 use_skip_indexes_on_data_read 是直接读取的前置条件。
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 查询
%<alpha-numeric-characters-without-spaces>%,且文本索引分词器为 splitByNonAlpha 或 array 时,ClickHouse 会利用倒排索引显著加速 LIKE/ILIKE 查询。为此,ClickHouse 会扫描倒排索引字典,而不是执行全表扫描来查找匹配项。
启用此优化后,LIKE/ILIKE 查询通常会比全表扫描快得多。不过,当该模式匹配字典中的大多数标记时,其性能反而可能不如全表扫描。幸运的是,系统提供了回退机制来避免这种情况。
该优化由以下设置控制:
该回退机制由以下两个设置控制:
此优化仅支持 like 和 ilike 函数。
缓存
标记缓存设置
头部缓存设置
倒排列表缓存设置
局限性
- 对包含大量标记的文本索引进行物化 (例如 100 亿个标记) 可能会消耗大量内存。文本索引的物化
既可能直接发生 (
ALTER TABLE <table> MATERIALIZE INDEX <index>) ,也可能在 分片 merge 过程中间接发生。 - 无法对包含超过 4,294,967,296 (= 2^32 = 约 42 亿) 行的 分片 进行文本索引物化。如果没有 materialized 文本索引,查询会回退为在该 分片 内进行缓慢的暴力搜索。作为最坏情况估算,假设一个 分片 只包含一个 String 类型的列,并且未修改 MergeTree 设置
max_bytes_to_merge_at_max_space_in_pool(默认值:150 GB) 。在这种情况下,如果该列平均每行包含的字符数少于 29.5,就会出现这种情况。在实际场景中,表通常还包含其他列,因此该阈值会比这小很多倍 (具体取决于其他列的数量、类型和大小) 。
文本索引与基于布隆过滤器的索引对比
bloom_filter、ngrambf_v1、tokenbf_v1、sparse_grams) 来加速,但两者在设计和预期使用场景上有本质区别:
布隆过滤器索引
- 基于概率型数据结构,可能会产生误报。
- 只能回答集合成员关系问题,也就是说,某列可能包含标记 X,或者可以确定不包含 X。
- 存储粒度级别的信息,以便在查询执行期间跳过较粗粒度的数据范围。
- 很难正确调优 (示例请参见这里) 。
- 相对紧凑 (每个分片仅几 KB 到几 MB) 。
- 基于标记构建确定性的倒排索引。索引本身不会产生误报。
- 专门针对文本搜索场景进行了优化。
- 存储行级别的信息,从而能够高效进行词项查找。
- 体积相对较大 (每个分片几十到几百 MB) 。
- 它们不支持高级分词和预处理。
- 它们不支持多标记搜索。
- 它们无法提供倒排索引应有的性能特征。
- 它们提供分词和预处理
- 它们为
hasAllTokens、LIKE、match以及类似的文本搜索函数提供高效支持。 - 对于大型文本语料,它们具有显著更好的可扩展性。
实现细节
- 一个字典,将每个标记映射到一个倒排列表;以及
- 一组倒排列表,每个倒排列表表示一组行号。
dictionary_block_size 配置) 。
字典块文件 (.dct) 包含一个 分片 中所有索引粒度的全部字典块。
索引头文件 (.idx)
索引头文件包含每个字典块的首个标记,以及该块在字典块文件中的相对偏移量。
这种稀疏索引结构类似于 ClickHouse 的稀疏主键索引)。
倒排列表文件 (.pst)
所有标记的倒排列表都按顺序存放在倒排列表文件中。
为了节省空间,同时仍支持快速的交集和并集操作,倒排列表以 roaring bitmaps 的形式存储。
如果倒排列表大于 posting_list_block_size,则会将其拆分为多个块,并按顺序写入倒排列表文件。
文本索引的合并
当数据分区片段合并时,文本索引无需从头重新构建;相反,它可以在合并过程中的独立步骤里高效完成合并。
在这一步中,会读取每个输入 分片 的文本索引中已排序的字典,并将它们合并为一个新的统一字典。
倒排列表中的行号也会重新计算,以反映它们在合并后数据分区片段中的新位置;这会用到初始合并阶段生成的旧行号到新行号映射。
这种文本索引的合并方式,类似于带有 _part_offset 列的 projections 的合并方式。
如果索引在源 分片 中尚未 materialized,则会先构建该索引,将其写入临时文件,然后再与其他 分片 中的索引以及其他临时索引文件中的索引一并合并。
调试
表函数 mergeTreeTextIndex 可用于内省文本索引。
示例:Hackernews 数据集
hackernews 表中:
ALTER TABLE,在 comment 列上添加文本索引,然后将其物化:
hasToken、hasAnyTokens 和 hasAllTokens 函数来执行查询。
以下示例将展示标准索引扫描与直接读取优化之间巨大的性能差异。
1. 使用 hasToken
hasToken 用于检查文本是否包含某个特定的单个标记。
我们将搜索区分大小写的标记 ‘ClickHouse’。
禁用直接读取 (标准扫描)
默认情况下,ClickHouse 会使用跳过索引过滤粒度,然后再读取这些粒度的列数据。
我们可以通过禁用直接读取来模拟这种行为。
2. 使用 hasAnyTokens
hasAnyTokens 用于检查文本是否包含给定标记中的至少一个。
我们将搜索包含 ‘love’ 或 ‘ClickHouse’ 的评论。
禁用直接读取 (标准扫描)
3. 使用 hasAllTokens
hasAllTokens 用于检查文本是否包含给定的所有标记。
我们将搜索同时包含 ‘love’ 和 ‘ClickHouse’ 的评论。
禁用直接读取 (标准扫描)
即使禁用了直接读取,标准跳过索引仍然依然有效。
它会将 2870 万行缩小到仅 14.746 万行,但仍必须从该列读取 57.03 MB 数据。
4. 复合搜索:或、AND、NOT、…
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
- 博客: ClickHouse 倒排索引简介
- 博客: 深入了解 ClickHouse 全文搜索:快速、原生、列式
- 视频: 全文索引:设计与实验