Skip to main content
文本索引 (也称为倒排索引) 可在文本数据上实现快速全文搜索。 文本索引存储了从标记到包含该标记的行号的映射。 标记通过称为分词的过程生成。 例如,ClickHouse 的默认分词器会将英文句子 “The cat likes mice.” 转换为标记 [“The”, “cat”, “likes”, “mice”]。 例如,假设有一张只有一列和三行的表
对应的标记如下:
我们通常会按不区分大小写的方式进行搜索,因此会先将标记转换为小写:
我们还会移除诸如 “I”、“the” 和 “and” 之类的常见虚词,因为它们几乎出现在每一行中:
从概念上讲,文本索引包含以下信息:
给定一个搜索标记,该索引结构可快速找到所有匹配的行。

创建文本索引

文本索引在 ClickHouse 26.2 及更高版本中已正式发布 (GA) 。 在这些版本中,使用文本索引无需配置任何特殊设置。 我们强烈建议在生产环境中使用 ClickHouse >= 26.2 版本。
无论 compatibility 设置如何,任何 ClickHouse >= 26.2 版本都可以使用文本索引。
要创建文本索引,请使用以下语法:
Query
可在以下类型的列上定义文本索引: 同样支持 Nullable(T)LowCardinality() 类型的列,包括 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_lengthmax_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) 。
所有可用的分词器都列在 system.tokenizers 中。
splitByString 分词器会按从左到右的顺序应用这些拆分分隔符。 这可能会导致歧义。 例如,分隔符字符串 ['%21', '%'] 会将 %21abc 分词为 ['abc'];而如果把两个分隔符字符串的顺序改为 ['%', '%21'],则会输出 ['21abc']。 大多数情况下,你会希望优先匹配更长的分隔符。 通常可以通过按长度降序传入分隔符字符串来实现。 如果这些分隔符字符串恰好构成 prefix code,则可以按任意顺序传入。
要了解分词器如何拆分输入字符串,可以使用 tokenstokensForLikePattern 函数: 示例:
Query
Response
处理非 ASCII 输入。 文本索引可基于任何语言和字符集的文本数据构建。 对于非 ASCII 文本,建议使用 asciiCJK 分词器,因为它能正确处理 Unicode 的词边界,包括 CJK 字符。 预处理器参数 (可选)。预处理器是指在分词前应用于输入字符串的表达式。 预处理器参数的典型用例包括
  1. 转换为小写/大写,或进行大小写折叠以启用不区分大小写的匹配,例如 lowerlowerUTF8caseFoldUTF8
  2. UTF-8 规范化,例如 normalizeUTF8NFCnormalizeUTF8NFDnormalizeUTF8NFKCnormalizeUTF8NFKDnormalizeUTF8NFKCCasefoldtoValidUTF8
  3. 删除或转换不需要的字符或子字符串 (例如重音符号) ,可使用 extractTextFromHTMLsubstringidnaEncodetranslateremoveDiacriticsUTF8
预处理器表达式必须将 StringFixedString 类型的输入值转换为相同类型的值。 如果文本索引是基于 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))
禁止使用非确定性函数。
预处理器原则上等价于用预处理器表达式包装索引列或表达式。 例如,INDEX idx col TYPE text(tokenizer = 'splitByNonAlpha', preprocessor = lower(col)) 中的 lower 预处理器可以用 INDEX idx lower(col) TYPE text(tokenizer = 'splitByNonAlpha') 来模拟。 后一种形式的缺点是,只有当模拟的预处理器与 WHERE 子句中的过滤条件匹配时,才会应用它。 例如,WHERE hasAllTokens(lower(col), [...]) 会匹配,而 WHERE hasAllTokens(col, [...]) 则不会。 因此,为了获得最佳用户体验,我们建议使用预处理器表达式。
函数 hasTokenhasAllTokenshasAnyTokenshasPhrase 会先使用预处理器转换搜索词,再对其进行分词。 请注意,由于预处理器只会在文本索引路径上应用,因此这些函数在使用文本索引的查询与不使用文本索引的查询之间,结果可能会不同 (例如 SETTINGS use_skip_indexes = 0) 。 例如,
Query
等价于:
Query
在这种情况下,预处理器 表达式会逐个转换数组中的元素。 示例:
Query
要在针对 Map 类型列构建的文本索引中定义预处理器,用户需要决定索引是基于 map 的键构建,还是基于其值构建。 示例:
Query
后处理器参数 (可选)。后处理器是指在分词后应用于每个输出标记的表达式。 与预处理器不同,预处理器会在分词器将整个输入字符串拆分为标记之前对其进行转换,而后处理器则是直接对标记本身逐个进行处理。 因此,那些天然属于标记级别的转换,最适合放在这里完成。 后处理器参数的典型用例包括:
  1. 过滤停用词 (极其频繁的标记)。像 “the”、“a” 和 “is” 这样的常见标记几乎没有搜索相关性,还会使索引膨胀。 你可以使用后处理器通过将它们转换为空标记来丢弃它们——空标记会被忽略,也就是说,不会被添加到索引中。 示例:if(str IN ('the', 'a', 'an', 'of', 'in', 'is', 'it'), '', str)
  2. 移除时间戳。日志行通常以结构化时间戳开头,或包含结构化时间戳,例如 2024-01-15T10:23:45。 为时间戳标记建立索引会让索引充斥着不具备搜索相关性的字符串。 有两种互补的方法可用于忽略时间戳:
    • 后处理器方法:使用 splitByString 分词器 (按空白字符拆分) ,这样整个时间戳会成为一个单独的标记,然后使用 parseDateTimeOrNull 检测并丢弃它。 示例:if(isNull(parseDateTimeOrNull(str, '%Y-%m-%dT%H:%i:%S')), str, '') 对于带有时区偏移或秒以下小数部分的时间戳,使用 parseDateTimeBestEffortOrNull(str),无需显式格式字符串。
    • 预处理器方法:在分词之前,使用正则表达式从完整日志行中去除时间戳。 示例:replaceRegexpAll(str, '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2} ', '') 这种方式适用于任何分词器,而且效率更高,因为时间戳字符根本不会被分词。 这两种方法可以结合使用:预处理器去除时间戳,而后处理器对剩余标记进行规范化或过滤 (例如,转为小写 + 去掉像 ERRORINFO 这样的严重级别词) 。
  3. 词干提取。将每个标记映射为其词干,可以通过匹配共享相同词根的形态变体来提升搜索召回率。 例如,在英文词干提取中,“running”、“runs” 和 “run” 都会被提取为词干 “run”,因此对这些变体中的任意一个发起查询都能匹配到全部。 ClickHouse 为多种语言提供了内置的 stem 函数。 示例:stem(str, 'en')
  4. 大小写规范化。将标记转换为小写或大写,以实现不区分大小写的匹配,例如 lowerlowerUTF8。 对于大小写转换,我们建议使用预处理器而不是后处理器。
后处理器表达式将 String 类型的标记转换为相同类型的标记。 此外,后处理器表达式只能引用定义文本索引所基于的列或表达式。 当列的类型为 Array(String) 时,后处理器仍以普通 String 值的形式对各个标记进行操作。 禁止使用非确定性函数。 构建索引时,后处理器会应用于生成的每个标记 (对于 array 分词器,每个数组元素都是一个标记) 。在查询时,具体行为取决于所使用的函数:
  • 对于 hasTokenhasAllTokenshasAnyTokenshasPhrase (使用任意受支持的分词器) :后处理器会同时应用于 haystack 标记和搜索 needle,从而实现完全归一化的匹配 (例如,不区分大小写的搜索) 。对于 hasPhrase,后处理后的标记位置会紧密排列,因此即使后处理器丢弃了某个标记,也不会留下位置空隙,短语仍然可以跨过该位置完成匹配——例如,使用会丢弃 the 的停用词后处理器时,hasPhrase(col, 'see cat') 也能匹配文档 see the cat
  • 对于所有其他函数 (=INhashasAnyhasAllmapContains*) :仅对搜索 needle 应用后处理器以执行索引提示查找;行级谓词仍会与原始列值进行比较。
示例:
  • 使用后处理器表达式移除停用词:
  • 使用后处理器表达式删除时间戳:
  • 使用预处理表达式移除时间戳:
  • 使用预处理器和后处理器的组合表达式移除时间戳:
  • 使用后处理器表达式对标记进行词干化:
函数支持 对于会查询文本索引的谓词,在进行粒度级检查之前,会先对搜索值应用预处理器和后处理器,以便索引查找使用与构建索引时存储的相同标记。 对于大多数函数 (=, IN, startsWith, endsWith, LIKE, mapContains*) ,文本索引仅用于跳过无关的数据块;ClickHouse 仍会针对原始列数据,使用原始谓词验证每一条保留下来的行。 对于标记搜索函数 (hasToken, hasAllTokens, hasAnyTokens) ,文本索引是主要的求值路径:ClickHouse 会通过与索引构建时相同的预处理器、分词器和后处理器对 needle 进行归一化,并将这一规范化结果同时用于带索引和不带索引的 table 分片。使用后处理器时,haystack 标记也会在查询时被归一化 (适用于任何分词器,而不仅仅是 array) ,因此比较两侧都会以一致的方式进行转换,结果也不依赖于索引是通过直接读取 (设置 query_plan_direct_read_from_text_index) 访问,还是某个分片是否具有 materialized 索引——例如,配合 lower 后处理器,可为 hasAllTokens(col, ['FOO']) 启用不区分大小写的匹配。 在未使用 support_phrase_search 时,hasPhrase 仅将索引用作提示,并使用原始谓词验证每一条保留下来的行;后处理器还会以相同方式对短语和 haystack 标记进行归一化,因此结果与读取路径无关,而且被后处理器丢弃的标记也不会破坏短语的相邻关系。在 support_phrase_search = 1 时,hasPhrase 会使用精确的直接读取 (如果有后处理器,仍会应用) 。 被后处理器映射为空字符串的搜索标记会被忽略,也就是视为在搜索短语中不存在。 ¹ 对于列出的分词器,LIKEmatch 会将直接读取作为提示使用;否则会回退为穷举扫描。 此外,对于不带预处理器或后处理器的 splitByNonAlphaarray 分词器,LIKE 还支持 直接读取 (不使用提示) (通过 use_text_index_like_evaluation_by_dictionary_scan 启用) 。 ² ILIKE 仅支持通过直接读取 (不使用提示) 来执行 (use_text_index_like_evaluation_by_dictionary_scan = 1,且分词器为 splitByNonAlphaarray) 。 不会回退到将索引用作提示的方式:如果该设置被禁用,或分词器不在支持范围内,则不会对 ILIKE 使用索引。 如果存在预处理器,则必须为 lowerupper;不支持后处理器。 Experimental:支持短语搜索参数 (可选) 实验性参数 support_phrase_search (默认值:0) 用于控制索引是否存储标记位置。 设置为 1 时,索引会额外存储位置数据 (保存在 .pos 文件中) ,从而使 hasPhrase 函数能够通过直接读取实现精确短语匹配。 存储位置会增加索引的磁盘占用和写入成本,因此该功能默认不启用。 其磁盘格式目前尚未稳定,因此该参数属于实验性功能,未来版本中可能会发生变化。 因此,使用 support_phrase_search = 1 创建索引时,需要启用 MergeTree 设置 allow_experimental_text_index_phrase_search。 将 support_phrase_search = 0 (默认值) 可保持仅倒排列表存储;未使用此参数创建的文本索引将不包含位置信息。
此参数为实验性功能,仅应用于测试。 设置 MergeTree allow_experimental_text_index_phrase_search 以启用位置存储。
索引粒度。 文本索引在 ClickHouse 中实现为一种跳过索引。 不过,与其他跳过索引不同,文本索引使用近乎无限的粒度 (1 亿) 。 这一点可以在文本索引的表定义中看到。 示例:
Query
Response
较大的索引粒度可确保为整个分片创建文本索引。 显式指定的索引粒度会被忽略。

使用文本索引

在 SELECT 查询中使用文本索引非常直接,因为常见的字符串搜索函数会自动利用该索引。 如果某一列或表分片上没有索引,字符串搜索函数就会回退为低效的穷举扫描。
我们建议使用函数 hasAnyTokenshasAllTokens 来搜索文本索引,请参见下文。 这些函数适用于所有可用的分词器以及所有可能的预处理和后处理表达式。 由于其他受支持的函数在历史上早于文本索引,因此在很多情况下必须保留其原有行为 (例如不支持预处理或后处理) 。

支持的函数

如果在 WHERE 子句或 PREWHERE 子句中使用了文本函数,则可以使用文本索引:
= (等于) 会完整匹配给定的搜索词。 示例:

IN

IN (in) 与 equals 类似,但会匹配全部搜索词。 示例:
文本索引不支持 NOT IN (notIn)。

LIKEmatch

目前,只有当索引分词器为 splitByNonAlphangramssparseGrams 时,这些函数才会使用文本索引进行过滤。
文本索引不支持 NOT LIKE (notLike)。
要将 LIKE (like) 和 match 函数与文本索引配合使用,ClickHouse 必须能够从搜索词中提取出完整的标记。 对于使用 ngrams 分词器的索引,如果通配符之间待搜索字符串的长度等于或大于 ngram 长度,则满足这一条件。 使用 splitByNonAlpha 分词器的文本索引示例:
示例中的 support 可以匹配 supportsupportssupporting 等。 这种查询属于子串查询,无法通过 文本索引 来加速。 要让 LIKE 查询利用 文本索引,必须将 LIKE pattern 按如下方式改写:
support 左右两侧的空格可确保该术语能被提取为一个标记。 幸运的是,在一种特殊情况下,ClickHouse 可以利用倒排索引显著加速 LIKE 查询。 详见 LIKE/ILIKE 性能调优章节

multiSearchAny and multiMatchAny

multiSearchAny 及其 UTF-8 变体 multiSearchAnyUTF8 用于检测多个字面子串中是否有任意一个出现在 haystack 中,而 multiMatchAny 用于检测多个正则表达式中是否有任意一个匹配。 这些函数在与 LIKEmatch 相同的条件下使用文本索引 (见上文) :ClickHouse 必须能够从每个 needle 中提取出完整的标记,并且 needles 列表必须为常量。 如果某个粒度中可能包含任意一个 needle,就会读取该粒度。 对于 multiMatchAny,如果某个 pattern 无法归约为对标记的要求 (例如 .*,它可以匹配任何文档) ,则无法使用文本索引,查询会退回为全扫描。 LIKEmatch 一样,子串和正则表达式搜索最适合配合 ngramssparseGrams 分词器使用。 这些分词器会为相互重叠的字符 n-gram 建立索引,因此 needle 会被拆分为 n-gram;只要 needle 作为子串出现,这些 n-gram 就会出现在索引中,而不受其是否始于或终于单词中间的影响。 因此,只要 needle 的长度不小于 n-gram 的大小,就可以直接按原样使用。 使用 ngrams 分词器的文本索引示例:
相比之下,splitByNonAlpha 分词器只为完整的标记 (整个单词) 建立索引。 由于 needle 可能从单词中间开始或结束,ClickHouse 会丢弃每个 needle 的首尾标记,因此索引只能利用完整标记来裁剪粒度。 要让子串和正则表达式搜索在使用 splitByNonAlpha 时也能利用索引,请用分隔符字符 (例如空格) 将每个 needle 包裹起来,使其构成一个或多个完整标记。 splitByNonAlpha 分词器的 文本索引 示例:

startsWith and endsWith

LIKE 类似,startsWithendsWith 函数只有在能够从搜索词中提取出完整标记时,才能使用文本索引。 对于使用 ngrams 分词器的索引,如果通配符之间搜索字符串的长度等于或大于 ngram 长度,则满足这一条件。 当文本索引使用后处理器时,如果提取出的提示标记在归一化后仍非空,这些函数仍可在 Hint 模式下使用该索引。如果归一化后所有提示标记都被移除,则该索引不会用于该谓词。 使用 splitByNonAlpha 分词器的文本索引示例:
在该示例中,只有 clickhouse 会被视为一个标记。 support 不是标记,因为它可以匹配 supportsupportssupporting 等形式。 要查找所有以 clickhouse supports 开头的行,请在搜索模式末尾添加一个尾随空格:
同样,endsWith 也应在前面加上空格:

hasToken

当在文本索引中使用非 splitByNonAlpha 分词器和/或预处理器/后处理器表达式进行查找时,hasToken 存在一些陷阱。 我们建议改用 hasAnyTokenshasAllTokens 函数。不区分大小写的变体 hasTokenCaseInsensitivehasTokenCaseInsensitiveOrNull 无法识别文本索引——即使在建立了文本索引的列上,它们也始终会执行全行扫描。对于不区分大小写的匹配,请使用 lower(...) 预处理器或后处理器,并将其与 hasToken / hasAllTokens / hasAnyTokens 结合使用。
函数 hasToken 用于匹配单个给定标记。 与前面提到的函数不同,它不会对搜索词进行分词 (即假定输入为单个标记) 。 示例:

hasAnyTokens and hasAllTokens

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

hasPhrase

函数 hasPhrase 用于按短语匹配:所有标记都必须连续出现,且顺序与搜索字符串中的顺序一致。 不同于 hasAllTokens 只要求所有标记出现在任意位置,hasPhrase 要求它们按连续序列出现。 搜索短语会使用为索引列配置的同一个分词器进行分词。 当文本索引使用后处理器时,搜索短语在索引查找之前也会先进行归一化。 请注意,该函数要求使用 splitByNonAlphasplitByStringngramsasciiCJK 分词器之一。 示例:

has

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

hasAnyhasAll

数组函数 hasAnyhasAll 用于测试已建立索引的数组列是否包含常量 needle 字符串集合中的任意字符串或全部字符串。 示例:

mapContains

函数 mapContains (mapContainsKey 的别名) 会在 map 的键中,将从搜索字符串中提取出的标记进行匹配。 其行为类似于作用于 String 列的 equals 函数。 仅当文本索引创建在 mapKeys(map) 表达式上时,才会使用该索引。 示例:

mapContainsValue

函数 mapContainsValue 会在 map 的值中,针对从待搜索字符串中提取出的标记进行匹配。 其行为类似于对 String 列使用 equals 函数。 只有在 mapValues(map) 表达式上创建了文本索引时,才会使用该文本索引。 示例:

mapContainsKeyLikemapContainsValueLike

函数 mapContainsKeyLikemapContainsValueLike 分别对 map 的所有键或值执行模式匹配。 示例:

operator[]

访问 operator[] 可与文本索引配合使用,以过滤键和值。只有在 mapKeys(map)mapValues(map) 表达式上创建了文本索引,或同时在两者上创建时,才会使用该文本索引。 示例:
请参见以下示例,了解如何对 Array(T)Map(K, V) 类型的列使用文本索引。

为 Array(String) 列创建索引

设想有一个博客平台,作者使用关键词对博客文章进行分类。 我们希望用户能通过搜索或点击 topic 来发现相关内容。 请看下面的表定义:
如果没有文本索引,要查找包含特定关键字 (例如 clickhouse) 的帖子,就需要扫描所有条目:
随着平台规模不断扩大,这种方式会越来越慢,因为查询必须检查每一行中的每个 keywords 数组。 为了解决这个性能问题,我们为列 keywords 定义一个文本索引:

为 Map 列创建索引

在许多可观测性场景中,日志消息会被拆分为 “components”,并按合适的数据类型存储,例如将 timestamp 存储为日期时间,将日志级别存储为枚举等。 指标字段最适合以键值对形式存储。 运维团队需要高效搜索日志,以便进行调试、处理安全事件和监控。 考虑下面这张日志表:
如果没有文本索引,搜索 Map 数据时需要进行全表扫描:
随着日志量的增长,这些查询会变慢。 解决方案是为 Map 的键和值创建文本索引。 当你需要按字段名或属性类型查找日志时,可使用 mapKeys 创建文本索引:
当你需要在属性的实际值中搜索时,可使用 mapValues 创建文本索引:
查询示例:

为 JSON 列建立索引

文本索引可通过以下三种方式用于 JSON 列:
  1. 特定子列上的索引 — 在已知的 JSON 路径上创建文本索引,就像对普通列那样。这会对该路径上的建立索引。
  2. 基于路径的索引,使用 JSONAllPaths — 对每个粒度中存在的所有路径建立索引,以跳过不可能包含所查询路径的粒度。类似于 Map 列。
  3. 基于值的索引,使用 JSONAllValues — 对所有 JSON 路径中的所有值建立索引,从而通过单个索引加速对任何 JSON 子列的全文搜索。

特定子列上的索引

你可以像对普通列一样,使用相同的语法在任何 JSON 子列上创建跳过索引。 在索引表达式中引用 JSON 子列有两种方式:
  • 在 JSON type hint 中声明的 类型化路径 —— 可直接通过名称访问:json.a
  • 带显式 cast 的 动态路径 —— 使用 :: cast 语法:json.b::String
示例索引定义:
Query
示例查询:
Query
Response
示例查询:
Query
Response

使用 JSONAllPaths 的基于路径的索引

Map 列类似,可借助 JSONAllPathsJSON 列上创建文本索引。 该索引会存储每个粒度中包含的 JSON 路径集合,并据此跳过不包含查询路径的粒度。 示例索引定义:
Query
你可以使用 EXPLAIN indexes = 1 来验证跳过索引是否已生效。 当某个路径只存在于一个分片中时,索引会跳过另一个分片。 示例:
Query
Response
当某个路径在任何分片中都不存在时,将跳过所有分片和粒度。 示例:
Query
Response
IS NOT NULL 也会使用索引——它会跳过不存在该 path 的粒度 (因为其值将为 NULL) : 示例:
Query
Response

使用 JSONAllValues 的基于值的索引

可以通过函数 JSONAllValuesJSON 列上使用文本索引,以加速搜索。 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 运算符:
例如,普通的文本索引搜索
匹配所有以任意顺序包含给定标记的行。 在此示例中,行 While she stayed in Tokyo, the weather was great. 符合该过滤器。 相比之下,短语搜索是指按给定顺序匹配这些标记。 例如,
匹配任何包含标记序列 weather in Tokyo 的行,例如 How is the weather in Tokyo? 文本索引通过对短语中所有标记的倒排列表求交来确定候选粒度,从而加速短语搜索。 在这些粒度内,ClickHouse 随后会验证这些标记是否确实彼此相邻。 这一过程开销相对较高,也比常规文本搜索查询更慢。 要加快短语搜索查询,请在文本索引中启用位置存储 (参见上文的 Optional parameters) 。 hasPhrase 可与分词器 splitByNonAlphasplitByStringngramsasciiCJK 搭配使用。 给定的短语字符串会使用该索引的分词器进行分词。 短语中的分隔符字符会被忽略:hasPhrase(text, 'quick+brown') 等同于 hasPhrase(text, 'quick brown'),前提是使用 splitByNonAlpha 作为分词器。

示例

Query
Response
第 2 行 ('New weather in York') 不匹配,因为标记顺序不正确。 第 3 行 ('weather in New Orleans') 不匹配,因为其中不包含标记 'York'

性能调优

直接读取

某些类型的文本查询可通过一种称为 “直接读取” 的优化显著提速。 示例:
直接读取优化仅通过文本索引 (即文本索引查找) 来响应查询,而无需访问底层文本列。 文本索引查找读取的数据量相对较少,因此比 ClickHouse 中常规的跳过索引快得多 (后者会先执行跳过索引查找,然后加载并过滤剩余粒度) 。 直接读取由两个设置控制: 支持的函数 直接读取优化支持 hasTokenhasAllTokenshasAnyTokens 函数。 如果文本索引是使用 array 分词器定义的,则直接读取还支持 equalshashasAnyhasAllmapContainsKeymapContainsValue 函数。 这些函数也可以通过 ANDORNOT 运算符进行组合。 WHEREPREWHERE 子句中也可以包含额外的非文本搜索函数过滤器 (针对文本列或其他列) ——在这种情况下,仍会使用直接读取优化,但效果会打一些折扣 (它仅适用于受支持的文本搜索函数) 。 要确认某个查询是否使用了直接读取,请使用 EXPLAIN PLAN actions = 1 运行该查询。 例如,一个禁用了直接读取的查询
返回
而同一查询在设置 query_plan_direct_read_from_text_index = 1 的情况下运行时
返回
第二个 EXPLAIN PLAN 的输出包含一个虚拟列 __text_index_<index_name>_<function_name>_<id>。 如果存在该列,则表示使用了直接读取。 如果 WHERE 过滤条件仅包含文本搜索函数,则查询可以完全避免读取列数据,并通过直接读取获得最大的性能收益。 不过,即使查询中的其他位置访问了文本列,直接读取仍然可以带来性能提升。 作为提示的直接读取 作为提示的直接读取与普通直接读取基于相同的原理,但它会额外基于文本索引数据构建一个过滤器,而不会去除底层文本列。 它适用于那些如果仅从文本索引读取会产生误报的函数。 支持的函数包括:likestartsWithendsWithequalshashasPhrasemapContainsKeymapContainsValue 这个额外的过滤器可以与其他过滤器结合,进一步提高选择性、限制结果集,并帮助减少从其他列读取的数据量。 作为提示的直接读取由设置 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>%,且文本索引分词器为 splitByNonAlphaarray 时,ClickHouse 会利用倒排索引显著加速 LIKE/ILIKE 查询。为此,ClickHouse 会扫描倒排索引字典,而不是执行全表扫描来查找匹配项。 启用此优化后,LIKE/ILIKE 查询通常会比全表扫描快得多。不过,当该模式匹配字典中的大多数标记时,其性能反而可能不如全表扫描。幸运的是,系统提供了回退机制来避免这种情况。 该优化由以下设置控制: 该回退机制由以下两个设置控制: 此优化仅支持 likeilike 函数。

缓存

存在不同的全服务器范围缓存,可将文本索引的部分内容缓存在内存中 (请参见实现细节一节) : 目前提供了针对文本索引中反序列化后的头部信息、标记和倒排列表的缓存,以减少 I/O。 使用设置 use_text_index_header_cacheuse_text_index_tokens_cacheuse_text_index_postings_cache,可按查询禁用各个缓存的读写。 如需清除缓存,请使用语句 SYSTEM CLEAR TEXT INDEX CACHES 请参考以下服务器设置来配置这些缓存。

标记缓存设置

头部缓存设置

倒排列表缓存设置

局限性

文本索引当前存在以下局限性:
  • 对包含大量标记的文本索引进行物化 (例如 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_filterngrambf_v1tokenbf_v1sparse_grams) 来加速,但两者在设计和预期使用场景上有本质区别: 布隆过滤器索引
  • 基于概率型数据结构,可能会产生误报。
  • 只能回答集合成员关系问题,也就是说,某列可能包含标记 X,或者可以确定不包含 X。
  • 存储粒度级别的信息,以便在查询执行期间跳过较粗粒度的数据范围。
  • 很难正确调优 (示例请参见这里) 。
  • 相对紧凑 (每个分片仅几 KB 到几 MB) 。
文本索引
  • 基于标记构建确定性的倒排索引。索引本身不会产生误报。
  • 专门针对文本搜索场景进行了优化。
  • 存储行级别的信息,从而能够高效进行词项查找。
  • 体积相对较大 (每个分片几十到几百 MB) 。
基于布隆过滤器的索引对全文搜索的支持仅仅是一种“副作用”:
  • 它们不支持高级分词和预处理。
  • 它们不支持多标记搜索。
  • 它们无法提供倒排索引应有的性能特征。
相比之下,文本索引则是专为全文搜索而设计的:
  • 它们提供分词和预处理
  • 它们为 hasAllTokensLIKEmatch 以及类似的文本搜索函数提供高效支持。
  • 对于大型文本语料,它们具有显著更好的可扩展性。

实现细节

每个文本索引由两种 (抽象) 数据结构组成:
  • 一个字典,将每个标记映射到一个倒排列表;以及
  • 一组倒排列表,每个倒排列表表示一组行号。
文本索引是针对整个 分片 构建的。 与其他跳过索引不同,文本索引在数据分片合并时可以直接合并,而不必在合并时重新构建 (见下文) 。 在创建索引期间,会创建三个文件 (每个 分片 一组) : 字典块文件 (.dct) 文本索引中的标记会先排序,再存储到字典块中,每个字典块包含 512 个标记 (块大小可通过参数 dictionary_block_size 配置) 。 字典块文件 (.dct) 包含一个 分片 中所有索引粒度的全部字典块。 索引头文件 (.idx) 索引头文件包含每个字典块的首个标记,以及该块在字典块文件中的相对偏移量。 这种稀疏索引结构类似于 ClickHouse 的稀疏主键索引)。 倒排列表文件 (.pst) 所有标记的倒排列表都按顺序存放在倒排列表文件中。 为了节省空间,同时仍支持快速的交集和并集操作,倒排列表以 roaring bitmaps 的形式存储。 如果倒排列表大于 posting_list_block_size,则会将其拆分为多个块,并按顺序写入倒排列表文件。 位置文件 (.pos) 可选,仅当索引参数 support_phrase_search = 1 时才会创建。 用于存储标记在匹配行中的位置。 文本索引的合并 当数据分片合并时,文本索引无需从头重新构建;相反,它可以在合并过程中的独立步骤里高效完成合并。 在这一步中,会读取每个输入 分片 的文本索引中已排序的字典,并将它们合并为一个新的统一字典。 倒排列表中的行号也会重新计算,以反映它们在合并后数据分片中的新位置;这会用到初始合并阶段生成的旧行号到新行号映射。 这种文本索引的合并方式,类似于带有 _part_offset 列的 projections 的合并方式。 如果索引在源 分片 中尚未 materialized,则会先构建该索引,将其写入临时文件,然后再与其他 分片 中的索引以及其他临时索引文件中的索引一并合并。 调试 表函数 mergeTreeTextIndex 可用于内省文本索引。

示例: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’ 的评论。 禁用直接读取 (标准扫描)
已启用直接读取 (快速索引读取)
对于这种常见的 “或” 搜索,提速效果更加明显。 由于避免了对普通列的全列扫描,查询速度几乎提升了 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) 。 直接读取优化同样适用于复合布尔表达式。 这里,我们将执行一次不区分大小写的搜索,查找 ‘ClickHouse’ 或 ‘clickhouse’。 禁用直接读取 (标准扫描)
直接读取已启用 (快速索引读取)
通过组合索引返回的结果,直接读取查询快了 34 倍 (0.450s 对比 0.013s) ,并且无需读取 9.58 GB 的列数据。 对于这种特定情况,hasAnyTokens(comment, ['ClickHouse', 'clickhouse']) 是更推荐、也更高效的写法。 过期内容
最后修改于 2026年7月23日