Elasticsearch 系列 · 第三篇

同一个字符串字段,为什么有时候搜不到,有时候聚合直接报错?

Elasticsearch 里存字符串,其实有两种完全不同的存法:text(会被拆词、用来做全文检索)和 keyword(原样存一整份、只能精确匹配)。选错了类型,轻则搜索结果莫名其妙,重则一执行聚合就报错。

这篇不是背概念——是在本机真实跑起来的 Elasticsearch 8.15.0 上,用真实的分词结果、真实的查询命中/不命中、真实的报错信息,把这个区别讲透。

5 个 token
"Apple MacBook Pro 16-inch" 被 text 字段拆成的真实分词结果
1 个 token
同样的字符串放进 keyword 字段,原样不动

先看真实的分词结果

同一个索引里建两个字段,一个 text、一个 keyword,分别丢进同一句话,看 Elasticsearch 真实存下来的是什么。

真实实测 PUT /products/_analyze { "field": "name", "text": "Apple MacBook Pro 16-inch" } → apple · macbook · pro · 16 · inch (5 个小写 token) PUT /products/_analyze { "field": "brand", "text": "Apple Inc." } → Apple Inc. (原样 1 个 token,大小写、标点全部保留)
applemacbookpro16inch "Apple Inc."(整体一个)

text 字段会经过一个"分析器"(analyzer):先分词、再小写化(默认的 standard 分析器还会去掉大部分标点)。keyword 字段完全跳过这一步,存的就是原始字符串本身——这也是名字的来源:它更像一个"标签",不是一段"文章"。

text

为搜索而生

拆成词,建倒排索引(上一篇的话题)。适合"这句话里有没有出现某个词"这种模糊匹配。

keyword

为精确匹配 / 排序 / 聚合而生

整体存一份,不拆词。适合"这个字段的值恰好等于 X"、按这个字段排序、按这个字段分组统计。

multi-field

都要?那就两个都建

同一份数据,同时建一个 text 版本(搜索用)和一个 keyword 版本(精确匹配/聚合用)——这是 Elasticsearch 的默认行为,下面会展示。

真实测出来的四种查询结果

用 term 查询(要求精确匹配某个具体的 token)分别打在这两个字段上。

查询字段查询值结果为什么
brand (keyword)"Apple Inc."命中和存储的原始值完全一致
brand (keyword)"apple inc."不命中keyword 精确匹配,大小写都算数
name (text)"macbook"命中是分词后 5 个 token 之一
name (text)"Apple MacBook Pro 16-inch"不命中这个完整字符串从来没有作为单独一个 token 存在过
最后一行最容易让人困惑:明明这句话就是原文,用 term 查询却找不到——因为 text 字段压根没存过"整句话"这个 token,只存了拆开的 5 个词。想用完整原文去匹配,得用 match 查询(帮你把查询词也过一遍分词器)或者去查 keyword 字段。

聚合:keyword 直接成功,text 直接报错

对同一份数据做 terms 聚合(按字段值分组计数)。

真实实测 POST /products/_search { "aggs": { "by_brand": { "terms": { "field": "brand" }}}} → 成功: { "Apple Inc.": 1 } POST /products/_search { "aggs": { "by_name": { "terms": { "field": "name" }}}} → illegal_argument_exception: Fielddata is disabled on [name] in [products]. Text fields are not optimised for operations that require per-document field data like aggregations and sorting, so these operations are disabled by default. Please use a keyword field instead.

这不是一个模糊的"不建议",是真实会抛出来的错误,而且原文直接告诉你怎么修——换用 keyword 字段。为什么 text 字段做聚合要专门被禁用?接着往下看,把它强行打开就知道了。

真实实测 -- 手动给 text 字段打开 fielddata,再跑一次同样的聚合 PUT /products/_mapping { "properties": { "name": { "type": "text", "fielddata": true }}} → 这次成功了,但结果是: { "16": 1, "apple": 1, "inch": 1, "macbook": 1, "pro": 1 }
打开之后聚合确实能跑,但按的是每一个词分组,不是按整个字段值分组——"Apple MacBook Pro 16-inch"这一件商品,在聚合结果里被拆成了 5 条记录,分别落在 5 个桶里。这几乎从来不是你想要的统计口径,这也是为什么 Elasticsearch 默认直接禁用而不是悄悄给你一个"看起来能用但语义错误"的结果。

默认行为:其实两个都会帮你建好

如果不手动指定映射,直接写一条数据进去,Elasticsearch 的动态映射(dynamic mapping)默认会怎么处理字符串字段?

真实实测 POST /autodemo/_doc { "title": "Hello World" } GET /autodemo/_mapping → { "title": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } }

默认行为其实是"两个都要"——自动建一个 text 主字段(搜索用)加一个 title.keyword 子字段(精确匹配/排序/聚合用)。ignore_above: 256 也是真实默认值:超过 256 字符的字符串不会存进 keyword 子字段(避免一个巨长的值把词典撑爆),但完全不影响它继续存进 text 字段参与搜索。

演示:同一句话,两种存法

改一改下面的文本,看 text vs keyword 分别会存成什么

text 字段(分词后)

keyword 字段(原样)

这里的分词规则(小写化 + 按空格/标点切分)是本文实测用的 standard 分析器规则的简化还原,用于演示;真实 Elasticsearch 的 standard 分析器基于 Unicode 文本分段算法,对中文等语言的处理比这里复杂得多(中文默认会被逐字拆开,不是按词语切分——这也是为什么中文全文检索通常需要额外装 IK 等分词插件,不在本文范围内)。

参考与说明

  • 本文所有查询结果、分词输出、报错信息均为本机真实运行的 Elasticsearch 8.15.0(单节点,security 关闭)真实执行得到,通过 curl 直接调用 REST API,未做任何删改。
  • 演示区的分词规则是简化版本(仅按空白/标点切分 + 小写化),用来直观展示"拆词 vs 不拆词"这个核心区别,不是对 Elasticsearch standard 分析器(基于 Lucene 的 StandardTokenizer,遵循 Unicode UAX#29 文本分段规则)的精确复刻。
  • 没有涉及:自定义分析器(自己组合 tokenizer + filter)、中文分词插件、keyword 字段的 normalizer(给 keyword 也做一点轻量处理,比如统一小写但不拆词)、text 字段的 fields 多字段除了 keyword 之外还能加别的分析器变体。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电