数据库基础体系 · 第 40/139 篇。文章以各产品官方稳定版本的公开语义为准;示例会明确引擎、事务与部署边界。

Elasticsearch Mapping 与分词:字段类型、Analyzer 和索引设计

在 Elasticsearch 中,“字段如何存储”和“文本如何被搜索”是两个相互关联、但不能混为一谈的问题:

  • Mapping 描述字段的数据类型、索引方式、排序与聚合能力,以及对象之间的关系。
  • Analyzer 描述文本进入倒排索引前如何被转换为词项,也描述查询文本如何被转换。
  • 索引设计 则决定字段是否应该拆分、是否需要多种搜索方式、是否需要保留原文,以及索引生命周期内如何演进。

如果只把字段声明为 textkeyword,而不理解倒排索引和分析过程,通常会在以下场景中遇到问题:

  • 搜索能命中,但排序或聚合失败;
  • 中文、英文、数字和标点被错误切分;
  • 修改 Mapping 后发现旧数据仍按旧规则搜索;
  • 数组对象之间发生“跨对象匹配”;
  • 动态映射把业务字段推断成不合适的类型;
  • 为了支持一种查询方式,重复创建大量索引字段。

下面从数据流开始,逐步说明这些机制。


一、从 JSON 文档到索引:Mapping 和 Analyzer 位于哪里

向 Elasticsearch 写入一条文档时,可以把过程抽象为:

JSON 文档
  │
  ├─ Mapping:确定字段类型和索引行为
  │
  ├─ text 字段 ── Analyzer ── 词项、位置、偏移量 ── 倒排索引
  │
  ├─ keyword、数值、日期等 ── 类型编码 ── 倒排索引 / Doc Values
  │
  └─ _source:保留原始 JSON,供返回和重建使用

这里有三个容易混淆的概念:

  1. _source 不是倒排索引
    _source 通常保存写入时的原始文档内容。搜索、排序和聚合主要依赖字段索引结构,而不是扫描 _source

  2. 字段是否出现在 Mapping 中,不等于字段是否可搜索
    一个字段可以设置 index: false,仍然保留在 _source 中,但不能通过普通倒排索引搜索。

  3. Analyzer 主要用于文本字段
    text 字段需要分析;keyword 字段通常作为一个完整词项索引。数值和日期使用自己的类型编码,不经过文本 Analyzer。


二、倒排索引:为什么 Mapping 和分词会直接影响查询

假设有三篇文档:

{ "id": 1, "title": "Quick brown fox" }
{ "id": 2, "title": "Quick red car" }
{ "id": 3, "title": "Brown dog" }

如果 title 使用某个 Analyzer 分析后得到:

文档 1:quick, brown, fox
文档 2:quick, red, car
文档 3:brown, dog

倒排索引可以概念化为:

quick -> 文档 1、2
brown -> 文档 1、3
fox   -> 文档 1
red   -> 文档 2
car   -> 文档 2
dog   -> 文档 3

查询 quick brown 时,Elasticsearch 不需要扫描每篇文档的完整字符串,而是读取 quickbrown 对应的倒排列表,再根据查询类型、布尔条件和相关性计算结果。

text 字段而言,至少需要考虑:

  • 词项是什么;
  • 词项是否区分大小写;
  • 标点是否被移除;
  • 一个词项在文本中的位置;
  • 是否保留词项偏移量;
  • 查询时是否使用与索引时相容的分析规则。

2.1 分词不是字符串替换

Analyzer 不是简单的 split(" ")。典型文本分析包含:

原始文本
  ↓
Character Filter:预处理字符流
  ↓
Tokenizer:把字符流切成初始词项
  ↓
Token Filter:修改、删除或增加词项
  ↓
最终词项流

例如,英文文本:

"The QUICK, brown foxes!"

经过一个可能的分析过程:

Tokenizer:
the
QUICK
brown
foxes

Lowercase:
the
quick
brown
foxes

Stop Filter(如果配置了停用词 the):
quick
brown
foxes

这意味着,查询 quick 是否能命中,取决于索引时是否把 QUICK 转换成了 quick。索引时和搜索时的分析规则必须在语义上匹配。


三、Mapping:字段类型决定可执行的操作

可以通过 mappings.properties 显式定义字段:

PUT products-v1
Content-Type: application/json

{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "id": {
        "type": "keyword"
      },
      "title": {
        "type": "text",
        "fields": {
          "keyword": {
            "type": "keyword",
            "ignore_above": 256
          }
        }
      },
      "price": {
        "type": "scaled_float",
        "scaling_factor": 100
      },
      "available": {
        "type": "boolean"
      },
      "published_at": {
        "type": "date",
        "format": "strict_date_optional_time||epoch_millis"
      }
    }
  }
}

这个 Mapping 表示:

  • id 用完整值匹配,不做分词;
  • title 用于全文检索;
  • title.keyword 用于精确匹配、排序和聚合;
  • price 按两位小数缩放存储;
  • available 是布尔值;
  • published_at 接受 ISO 日期时间或毫秒时间戳;
  • 未声明的字段会导致写入失败,因为 dynamicstrict

3.1 text:全文检索字段

text 适合自然语言,例如:

{
  "title": "Elasticsearch Mapping Guide"
}

它通常经过 Analyzer 后,将文本拆成多个词项。适合:

  • match
  • match_phrase
  • 相关性排序;
  • 全文检索。

它不适合直接承担:

  • 精确值聚合;
  • 精确值排序;
  • 作为唯一标识符。

例如:

GET products-v1/_search
Content-Type: application/json

{
  "query": {
    "match": {
      "title": "mapping guide"
    }
  }
}

match 会先使用该字段的搜索 Analyzer 分析查询文本,再根据词项执行查询。它不是简单的字符串相等比较。

3.2 keyword:完整值字段

keyword 把整个输入值作为一个词项,适合:

  • ID;
  • 状态;
  • 标签;
  • 邮箱;
  • SKU;
  • 精确过滤;
  • 排序;
  • 聚合。
GET products-v1/_search
Content-Type: application/json

{
  "query": {
    "term": {
      "id": "p-1001"
    }
  },
  "sort": [
    {
      "title.keyword": "asc"
    }
  ]
}

term 不会对查询字符串做普通文本分析。下面这个查询:

{
  "term": {
    "title.keyword": "Elasticsearch Mapping Guide"
  }
}

要求索引中的完整 keyword 值与查询值匹配;它不会自动把大小写、空格或词序转换成全文搜索语义。

对于大小写不敏感但仍希望保持单值语义的字段,可以使用 normalizer,后文会说明。

3.3 多字段:同一输入,多个索引表示

前面的 title 定义了:

"title": {
  "type": "text",
  "fields": {
    "keyword": {
      "type": "keyword"
    }
  }
}

写入一次:

{
  "title": "Elasticsearch Mapping Guide"
}

可以产生两个逻辑字段:

title         -> 分析后用于全文搜索
title.keyword -> 完整字符串用于排序和聚合

查询和聚合分别使用不同字段:

GET products-v1/_search
Content-Type: application/json

{
  "query": {
    "match": {
      "title": "mapping"
    }
  },
  "aggs": {
    "titles": {
      "terms": {
        "field": "title.keyword"
      }
    }
  }
}

这是最常见的 textkeyword 组合。需要注意:多字段不是把一个字段动态转换成另一种类型,而是在索引阶段为同一输入建立多个字段索引结构。增加多字段或改变已有字段的定义,通常需要通过新索引重建数据。

3.4 数值类型:不要用 keyword 模拟数值

常用数值类型包括:

  • integerlong
  • floatdouble
  • scaled_float
  • unsigned_long 等。

如果金额精度固定,例如价格精确到分,可以使用:

{
  "price": {
    "type": "scaled_float",
    "scaling_factor": 100
  }
}

写入 12.34 时,逻辑上按 1234 的缩放值处理。这里的 scaling_factor 必须符合业务精度要求;如果需要三位小数,使用 1000。它不是任意精度十进制类型,选择前应确认舍入和输入范围。

数字字段支持范围查询:

GET products-v1/_search
Content-Type: application/json

{
  "query": {
    "range": {
      "price": {
        "gte": 10,
        "lt": 100
      }
    }
  }
}

"12.34" 存成 keyword,虽然可以做字符串精确匹配,但不能得到正确的数值排序和范围语义。例如字符串 "100" 可能排在 "20" 之前,因为字符串比较不是数值比较。

3.5 日期类型:解析格式与内部值

date 接受配置格式定义的输入,但 Elasticsearch 内部会将日期转换为数值时间表示,用于排序和范围查询。

{
  "published_at": "2025-03-08T10:00:00Z"
}

查询:

GET products-v1/_search
Content-Type: application/json

{
  "query": {
    "range": {
      "published_at": {
        "gte": "now-30d/d",
        "lt": "now"
      }
    }
  }
}

日期字符串能否写入,取决于字段的 format。写入不符合格式的值会导致解析失败,而不是自动变成缺失值。

3.6 booleanipgeo_point 等专用类型

字段类型应尽量表达真实语义:

{
  "active": {
    "type": "boolean"
  },
  "client_ip": {
    "type": "ip"
  },
  "location": {
    "type": "geo_point"
  }
}

例如地理点可写为:

{
  "location": {
    "lat": 31.2304,
    "lon": 121.4737
  }
}

不要因为输入是字符串就统一使用 keyword。专用类型通常提供正确的校验、排序、范围或空间查询能力。


四、indexdoc_valuesnorms_source

字段类型之外,Mapping 还决定字段参与哪些索引结构。

4.1 index

{
  "internal_note": {
    "type": "keyword",
    "index": false
  }
}

index: false 表示该字段不建立用于普通查询的索引结构,但字段值仍可以保留在 _source 中。

因此下面查询无法按预期工作:

{
  "query": {
    "term": {
      "internal_note": "secret"
    }
  }
}

如果业务只需要返回字段、不需要搜索,可以关闭 index。但这不是“隐藏字段”;原始值仍可能出现在 _source、快照或日志流程中,安全需求不能依赖 Mapping 设置。

4.2 doc_values

doc_values 是面向列式访问的结构,常用于:

  • 排序;
  • 聚合;
  • 脚本访问字段值。

多数适合排序和聚合的字段默认启用。对不需要排序、聚合或脚本访问的字段,关闭它可以减少索引结构,但会牺牲对应能力。

例如:

{
  "large_text": {
    "type": "text",
    "index": true,
    "norms": false
  }
}

text 默认没有用于普通排序和聚合的 doc_values。即使有 title.keyword 子字段,也应使用子字段而不是直接对 title 聚合。

4.3 norms

norms 保存与字段长度和相关性评分有关的信息。全文字段通常需要它;如果某个 text 字段只做存在性或词项匹配,不参与相关性评分,可以在创建时关闭:

{
  "log_message": {
    "type": "text",
    "norms": false
  }
}

这属于明确的能力取舍。关闭后,不能再依赖该字段的正常长度归一化相关性行为。

4.4 _source

_source 是原始文档内容的存储。它常用于:

  • 返回搜索结果;
  • _update
  • Reindex;
  • 故障诊断;
  • 快照恢复后的数据重建。

即使某字段 index: false,它也可能仍出现在 _source。如果使用 _source 过滤,只是控制返回内容,不等于改变索引结构。


五、Analyzer:Character Filter、Tokenizer 和 Token Filter

5.1 Character Filter

Character Filter 在 Tokenizer 之前处理字符流。典型用途包括:

  • HTML 标签剥离;
  • 字符映射;
  • 正则替换。

例如 HTML 分析器可以先移除标签,但要注意:字符替换可能改变偏移量,Elasticsearch 会尽量维护偏移映射,以支持高亮等功能;复杂替换仍应通过 _analyze 和实际高亮结果验证。

5.2 Tokenizer

Tokenizer 将字符流拆成初始词项。常见选择包括:

  • standard:按 Unicode 文本分词规则处理,适合一般文本起点;
  • whitespace:按空白切分;
  • keyword:整个输入只生成一个词项;
  • ngram:生成连续字符片段;
  • edge_ngram:生成从词首开始的片段;
  • 语言或领域专用 Tokenizer。

Tokenizer 决定基本边界。例如 whitespace 不会像自然语言分析器那样处理复杂标点;keyword 则不会把句子拆开。

5.3 Token Filter

Token Filter 对词项流进行处理,例如:

  • lowercase:统一大小写;
  • stop:移除停用词;
  • stemmer:词干提取;
  • synonym / synonym_graph:同义词处理;
  • asciifolding:重音字符折叠;
  • ngram:进一步生成片段;
  • unique:去重。

不同 Filter 会影响精确性、相关性和索引体积。尤其是 ngram 与同义词配置,不能只看“能否命中”,还要验证结果数量和相关性。


六、索引 Analyzer 与搜索 Analyzer

一个文本字段至少有两个分析时机:

写入文档:
原文 ── index analyzer ── 倒排词项

执行查询:
查询文本 ── search analyzer ── 查询词项

可以用自定义 Analyzer:

PUT articles-v1
Content-Type: application/json

{
  "settings": {
    "analysis": {
      "analyzer": {
        "article_text": {
          "type": "custom",
          "char_filter": [],
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "asciifolding"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "title": {
        "type": "text",
        "analyzer": "article_text",
        "search_analyzer": "article_text",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

验证 Analyzer:

POST articles-v1/_analyze
Content-Type: application/json

{
  "analyzer": "article_text",
  "text": "The QUICK résumé"
}

典型结果会包含:

the
quick
resume

具体 Token 属性还可能包含 positionstart_offsetend_offset。验证 Analyzer 比凭名称猜测行为可靠,因为 Tokenizer、版本和配置都会影响结果。

6.1 为什么索引和搜索分析器通常要相容

假设索引时使用:

running -> run

而搜索时不做词干提取:

running -> running

那么查询 running 可能无法命中只保存 run 的索引词项。

反过来,如果索引时产生多个变体,而查询时只产生一个原词,也可能造成召回差异。一般原则是:

  • 大小写归一化通常在索引和搜索两端都保持一致;
  • 词干提取、同义词等规则应明确是否用于索引、搜索或两端;
  • 搜索端可以更灵活,但不能假设它能恢复索引阶段已经丢失的信息。

match 查询默认会分析查询文本;term 查询不会替你完成这种自然语言分析。因此,不能用 term 代替 match 来查询 text 字段。

6.2 search_quote_analyzer

短语查询有时需要不同的分析策略。例如普通查询允许同义词扩展,但短语查询使用同义词可能改变位置关系。可以在字段层设置 search_quote_analyzer,但应结合实际同义词规则和 match_phrase 测试,不应仅凭配置名称判断短语语义。


七、normalizerkeyword 字段的单词项归一化

keyword 不做多词分词,但可以使用 normalizer 做单词项级别的处理:

PUT users-v1
Content-Type: application/json

{
  "settings": {
    "analysis": {
      "normalizer": {
        "username_normalizer": {
          "type": "custom",
          "filter": [
            "lowercase",
            "asciifolding"
          ]
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "username": {
        "type": "keyword",
        "normalizer": "username_normalizer"
      }
    }
  }
}

写入:

POST users-v1/_doc/1
Content-Type: application/json

{
  "username": "Jöhn"
}

查询:

GET users-v1/_search
Content-Type: application/json

{
  "query": {
    "term": {
      "username": "john"
    }
  }
}

这里的关键是:

  • username 仍是一个完整值;
  • normalizer 不会把它拆成多个词;
  • 索引值和 term 查询值都按该字段的归一化规则处理。

normalizer 适合大小写或重音统一,不适合需要词边界的全文搜索。字段是否区分大小写、是否折叠重音,应先确定业务等价关系,因为归一化可能导致原本不同的输入在索引层变成同一个值。


八、中文分词:不要把 standard 当作中文语义分析器

中文没有以空格分隔的词边界。例如:

Elasticsearch索引设计

“索引设计”应当如何切分,取决于词典、算法和业务语料。standard 可以提供通用 Unicode 级别的处理,但不能自动等价于高质量中文分词器,也不能保证得到符合业务预期的“词”。

因此,中文搜索设计必须先回答:

  1. 需要按字匹配,还是按词匹配?
  2. 是否需要前缀搜索或联想搜索?
  3. 领域词,如产品名、医学术语、法律术语,是否需要自定义词典?
  4. 分词器来自 Elasticsearch 内置能力、官方插件还是第三方插件?
  5. 集群所有节点是否安装了相同插件和版本?

不要仅因为某个分词器能命中几个样例,就认为它适合生产。应通过 _analyze 检查:

POST articles-v1/_analyze
Content-Type: application/json

{
  "text": "Elasticsearch索引设计与分词",
  "analyzer": "standard"
}

然后使用真实业务语料覆盖:

  • 专有名词;
  • 连续数字;
  • 中英文混排;
  • 版本号;
  • URL;
  • 标点;
  • 同义词;
  • 长文本。

如果采用第三方中文分词插件,文章中的具体 Analyzer 名称、词典格式和升级兼容性取决于插件,而不是 Elasticsearch 核心 Mapping 的通用保证。插件必须在每个相关节点保持兼容安装,否则创建索引、分片分配或恢复可能失败。

8.1 字粒度与词粒度的取舍

假设文本是:

数据库索引

按字切分可能得到:

数 据 库 索 引

按词切分可能得到:

数据库 索引

字粒度通常召回更宽,但噪声可能更大;词粒度更接近自然语言,但依赖分词质量和词典。对于搜索框,常见设计是同时提供不同字段:

{
  "title": {
    "type": "text",
    "analyzer": "content_analyzer",
    "fields": {
      "keyword": {
        "type": "keyword"
      }
    }
  }
}

是否再增加 ngram 或前缀字段,取决于查询需求。ngram 会产生更多词项,增加索引体积和查询候选数量,不能作为“中文搜索默认方案”。


九、前缀、通配符和自动补全字段的设计

全文搜索和自动补全是不同问题。

9.1 prefix 查询与 edge_ngram

如果要搜索以 elas 开头的词,索引时可以使用 edge_ngram

PUT suggest-v1
Content-Type: application/json

{
  "settings": {
    "analysis": {
      "analyzer": {
        "prefix_index": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "prefix_filter"
          ]
        },
        "prefix_search": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase"
          ]
        }
      },
      "filter": {
        "prefix_filter": {
          "type": "edge_ngram",
          "min_gram": 2,
          "max_gram": 15
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "name": {
        "type": "text",
        "analyzer": "prefix_index",
        "search_analyzer": "prefix_search"
      }
    }
  }
}

输入 Elasticsearch 可能在索引中产生:

el
ela
elas
elast
...

查询 elas 只需生成 elas,即可命中对应前缀。

注意两个边界:

  • max_gram 必须覆盖业务查询的最大前缀长度,否则更长的前缀可能无法按预期匹配;
  • edge_ngram 适合前缀,不适合任意位置包含。任意位置匹配通常需要 ngramwildcard 或其他专门设计,代价不同。

9.2 wildcard 不等于全文搜索

通配符查询:

{
  "query": {
    "wildcard": {
      "sku": "ab-*"
    }
  }
}

适合结构化字符串模式,不会执行普通文本 Analyzer。对任意前导通配符,如 *abc,可能需要检查大量候选,生产使用前应通过实际数据和慢查询观察成本。


十、对象、数组与 nested:避免跨对象匹配

考虑订单中的商品数组:

{
  "order_id": "o-1",
  "items": [
    {
      "sku": "A",
      "color": "red"
    },
    {
      "sku": "B",
      "color": "blue"
    }
  ]
}

如果 items 使用默认 object,字段可能被逻辑上扁平化为:

items.sku   -> A、B
items.color -> red、blue

此时查询:

{
  "query": {
    "bool": {
      "must": [
        { "term": { "items.sku": "A" } },
        { "term": { "items.color": "blue" } }
      ]
    }
  }
}

可能命中订单,但它并不表示同一个商品同时满足 sku=Acolor=blue。这是典型的跨对象匹配

如果必须保持数组中每个对象的字段关联,应使用 nested

PUT orders-v1
Content-Type: application/json

{
  "mappings": {
    "properties": {
      "order_id": {
        "type": "keyword"
      },
      "items": {
        "type": "nested",
        "properties": {
          "sku": {
            "type": "keyword"
          },
          "color": {
            "type": "keyword"
          }
        }
      }
    }
  }
}

查询必须使用 nested

GET orders-v1/_search
Content-Type: application/json

{
  "query": {
    "nested": {
      "path": "items",
      "query": {
        "bool": {
          "must": [
            { "term": { "items.sku": "A" } },
            { "term": { "items.color": "red" } }
          ]
        }
      }
    }
  }
}

nested 的内部机制不是给普通对象加一个标记,而是把嵌套对象作为隐藏的内部文档处理,并通过父子关系维护边界。因此:

  • 查询必须指定正确的 path
  • 聚合也需要使用 nested 聚合;
  • 每个嵌套对象都会增加内部索引文档数量;
  • nested 适合需要对象内关联的数组,不应无条件使用。

如果数据结构很深、数组很大,或需要独立更新和查询子实体,应重新评估是否拆成独立索引,而不是单纯增加嵌套层级。

10.1 flattened 的另一种取舍

当对象是动态键值集合,例如:

{
  "labels": {
    "env": "prod",
    "team": "search",
    "region": "cn"
  }
}

如果每个键都建立独立字段,可能造成字段数量快速增长。flattened 可以把整个对象作为一种扁平化结构处理,适合键名不稳定、主要做简单键值检索的场景。

但它不是完整的动态 Mapping 替代品。需要精确数值范围、复杂对象关联或不同字段独立分析时,应使用明确字段或其他数据建模方式。


十一、动态映射:便利、风险与控制方式

如果没有显式 Mapping,Elasticsearch 会根据写入值尝试动态推断类型。例如:

{
  "name": "Alice",
  "age": 30,
  "active": true,
  "created_at": "2025-03-08T10:00:00Z"
}

可能被推断为:

name       -> text,并带 keyword 子字段
age        -> long
active     -> boolean
created_at -> date(若符合动态日期检测规则)

动态推断不是业务语义理解。它可能带来:

  • "00123" 被当成数字或日期;
  • 同一字段先写入字符串,后写入对象导致类型冲突;
  • 用户输入的任意键不断创建字段;
  • 日期字符串被错误识别;
  • 一次错误写入固定了后续字段类型。

11.1 dynamic 的三种常见策略

{
  "mappings": {
    "dynamic": "strict",
    "properties": {}
  }
}
  • true:自动加入新字段;
  • false:新字段不建立索引,但通常仍保留在 _source
  • strict:遇到未声明字段时拒绝文档。

可以在对象层单独设置:

{
  "mappings": {
    "properties": {
      "metadata": {
        "type": "object",
        "dynamic": false,
        "properties": {}
      }
    }
  }
}

这适合允许保留任意元数据、但不允许其无限生成索引字段的情况。

11.2 Dynamic Templates

当字段命名有稳定规则时,可以使用动态模板:

PUT logs-v1
Content-Type: application/json

{
  "mappings": {
    "dynamic_templates": [
      {
        "string_as_keyword": {
          "match_mapping_type": "string",
          "match": "id_*",
          "mapping": {
            "type": "keyword"
          }
        }
      },
      {
        "text_with_keyword": {
          "match_mapping_type": "string",
          "mapping": {
            "type": "text",
            "fields": {
              "keyword": {
                "type": "keyword",
                "ignore_above": 256
              }
            }
          }
        }
      }
    ]
  }
}

模板按顺序匹配,先匹配到的模板生效。因此模板顺序是配置语义的一部分。应使用实际写入验证:

GET logs-v1/_mapping

不要只检查模板 JSON 是否能创建。


十二、Mapping 的生命周期:为什么修改后不能直接“刷新旧数据”

索引中的字段类型和索引词项结构并不是普通配置。以下变更通常不能直接对已有字段原地完成:

  • text 改成 keyword
  • keyword 改成 text
  • 修改已有字段的 Analyzer;
  • 修改 date 格式;
  • 修改数值类型;
  • 改变对象与 nested 的关系;
  • 为已有文本重新生成不同词项。

原因可以按数据流推导:

  1. 文档写入时,旧 Analyzer 将文本转换为旧词项;
  2. 旧词项已经写入倒排索引;
  3. Mapping 变更只能改变未来写入规则,不能从旧词项反推出完整原文语义;
  4. _source 虽然保存原文,但 Elasticsearch 不会自动遍历所有旧文档并重建索引;
  5. 因此需要新索引和重建。

常见流程是:

创建 products-v2
  ↓
使用新的 Mapping 和 Analyzer
  ↓
从 products-v1 Reindex 到 products-v2
  ↓
验证 Mapping、查询、聚合和数据量
  ↓
切换别名
  ↓
保留或删除旧索引

示例:

POST _reindex
Content-Type: application/json

{
  "source": {
    "index": "products-v1"
  },
  "dest": {
    "index": "products-v2"
  }
}

生产环境还要处理:

  • 重建期间新写入如何同步;
  • 是否允许重复写入;
  • 失败文档如何记录;
  • 切换别名时是否暂停写入;
  • 新旧索引是否使用相同分片、生命周期和权限配置。

如果使用别名承载业务访问:

POST _aliases
Content-Type: application/json

{
  "actions": [
    {
      "remove": {
        "index": "products-v1",
        "alias": "products"
      }
    },
    {
      "add": {
        "index": "products-v2",
        "alias": "products",
        "is_write_index": true
      }
    }
  ]
}

别名切换是原子操作,但前提是应用始终通过别名访问,而不是把具体索引名写死。


十三、一个可运行的端到端示例

下面使用本地 Elasticsearch HTTP 接口,假定:

  • Elasticsearch 已启动;
  • 地址为 http://localhost:9200
  • 已完成认证配置,示例省略认证头;
  • 使用同一版本的 Elasticsearch 创建索引和写入文档。

13.1 创建索引和 Mapping

PUT library-v1
Content-Type: application/json

{
  "settings": {
    "analysis": {
      "analyzer": {
        "book_text": {
          "type": "custom",
          "tokenizer": "standard",
          "filter": [
            "lowercase",
            "asciifolding"
          ]
        }
      }
    }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "book_id": {
        "type": "keyword"
      },
      "title": {
        "type": "text",
        "analyzer": "book_text",
        "search_analyzer": "book_text",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      },
      "author": {
        "type": "keyword"
      },
      "description": {
        "type": "text",
        "analyzer": "book_text",
        "search_analyzer": "book_text"
      },
      "price": {
        "type": "scaled_float",
        "scaling_factor": 100
      },
      "published_at": {
        "type": "date"
      }
    }
  }
}

这里的设计意图是:

  • book_idauthor 需要精确匹配;
  • title 需要全文搜索,也需要按完整标题聚合或排序;
  • description 只需要全文搜索;
  • price 需要数值范围查询;
  • published_at 需要日期范围和排序;
  • dynamic: strict 防止拼写错误字段悄悄进入索引。

13.2 写入文档

POST library-v1/_bulk
Content-Type: application/x-ndjson

{"index":{"_id":"b1"}}
{"book_id":"b-001","title":"Elasticsearch Mapping Guide","author":"Alice","description":"A guide to text analysis and index design.","price":39.90,"published_at":"2025-01-10T00:00:00Z"}
{"index":{"_id":"b2"}}
{"book_id":"b-002","title":"Search Systems","author":"Bob","description":"Query relevance, ranking, and aggregations.","price":49.50,"published_at":"2025-02-20T00:00:00Z"}

Bulk 请求必须使用 NDJSON:

  • 每个动作和文档占一行;
  • 请求体最后应有换行;
  • Content-Type 必须是 application/x-ndjson
  • 响应中的顶层 errors 即使为 false,也应检查每个 item 的状态。

13.3 检查分析结果

POST library-v1/_analyze
Content-Type: application/json

{
  "analyzer": "book_text",
  "text": "The QUICK résumé"
}

可以看到小写化和重音折叠后的词项。这个 API 用来验证“实际分析结果”,特别适合诊断:

  • 为什么某个词搜不到;
  • 为什么短语查询位置不对;
  • 为什么同义词或词干化产生意外结果;
  • 为什么前缀字段产生太多词项。

13.4 全文搜索与聚合

GET library-v1/_search
Content-Type: application/json

{
  "query": {
    "match": {
      "description": {
        "query": "index analysis",
        "operator": "and"
      }
    }
  },
  "aggs": {
    "by_author": {
      "terms": {
        "field": "author"
      }
    }
  },
  "sort": [
    {
      "price": "asc"
    }
  ]
}

这里有三种不同机制同时工作:

  1. match 分析 index analysis,然后进行全文检索;
  2. terms 聚合读取 authorkeyword 值;
  3. sort 读取 price 的数值列式值。

如果错误地把聚合字段写成 title,通常会收到“Fielddata is disabled on text fields by default”一类错误。这不是聚合语法错误,而是字段索引能力与操作不匹配。正确做法通常是使用 title.keyword,而不是盲目开启 fielddata


十四、Analyzer 设计中的常见反例

14.1 只用 keyword 存标题

{
  "title": {
    "type": "keyword"
  }
}

查询:

{
  "query": {
    "term": {
      "title": "Elasticsearch Mapping Guide"
    }
  }
}

只能匹配完整值。用户搜索 mapping 时不会得到普通全文搜索语义。

适用情况:标题本身就是一个需要精确匹配的枚举或外部编码。
不适用情况:自然语言搜索框。

14.2 只用 text,然后对它聚合

{
  "title": {
    "type": "text"
  }
}

title 会被拆成多个词项。对它做 terms 聚合意味着希望把自然语言字段当作完整标签,这两种语义冲突。

应改为:

{
  "title": {
    "type": "text",
    "fields": {
      "keyword": {
        "type": "keyword"
      }
    }
  }
}

14.3 索引时使用 edge_ngram,搜索时也使用 edge_ngram

如果索引和搜索两端都使用 edge_ngram,查询 elas 可能被再次切成:

el
ela
elas

这会改变查询语义,增加不必要的匹配条件。通常索引端使用前缀生成器,搜索端使用普通分析器:

索引端:Elasticsearch -> el, ela, elas, ...
搜索端:elas -> elas

这不是所有自动补全需求的唯一方案,但体现了索引分析器与搜索分析器的不同职责。

14.4 使用 term 查询自然语言字段

{
  "query": {
    "term": {
      "description": "Mapping"
    }
  }
}

这不会像 match 那样自动完成普通文本分析。若索引时经过小写化,倒排索引中可能只有 mapping,而查询词项是 Mapping,自然无法匹配。

14.5 通过 _source 判断是否可搜索

字段出现在返回结果中,只说明它保存在 _source,不说明它建立了倒排索引。诊断可搜索性应查看:

GET library-v1/_mapping

并结合:

POST library-v1/_analyze

以及最小化查询验证,而不是只观察 _source


十五、如何诊断 Mapping 和分词问题

15.1 先看 Mapping

GET library-v1/_mapping

重点检查:

  • 字段实际类型;
  • 是否是 text 的多字段;
  • Analyzer 名称;
  • index 是否关闭;
  • 对象是否为 nested
  • 日期和数值格式;
  • 动态模板最终是否按预期匹配。

15.2 再看 Analyzer

POST library-v1/_analyze
Content-Type: application/json

{
  "field": "title",
  "text": "Elasticsearch Mapping"
}

使用 field 可以验证字段实际绑定的 Analyzer。也可以显式指定:

{
  "analyzer": "book_text",
  "text": "Elasticsearch Mapping"
}

两者用途不同:

  • field 检查字段配置;
  • analyzer 检查某个独立 Analyzer。

15.3 检查查询实际使用的词项

对于复杂查询,可以使用 validate API:

GET library-v1/_validate/query?explain=true
Content-Type: application/json

{
  "query": {
    "match": {
      "title": "Mapping Guide"
    }
  }
}

它可以帮助判断查询是否有效,并在需要时解释查询结构。但它不是完整的相关性调试工具。相关性还涉及查询类型、字段权重、BM25 参数、词频、文档长度和其他子句。

15.4 区分“没有词项”和“没有文档”

一次未命中可能来自不同原因:

  1. 字段未建立索引;
  2. Analyzer 删除了该词;
  3. 查询使用了不同大小写或词形;
  4. 查询字段写错;
  5. 数据尚未刷新到可搜索状态;
  6. nested 查询缺少正确的 path
  7. term 被错误用于 text
  8. 词项存在,但布尔条件排除了文档。

排查时可以按以下顺序缩小范围:

查看 Mapping
  ↓
用 _analyze 看索引/查询词项
  ↓
用最小 match 或 term 查询验证
  ↓
检查 refresh、索引名称和别名
  ↓
检查 nested 路径和查询结构

十六、索引设计:先按查询语义建模,再决定字段类型

一个字段的设计不能只由 JSON 输入类型决定,而应由查询方式决定。

可以用下面的判断过程:

第一步:这个字段是否需要自然语言搜索?

  • 是:考虑 text
  • 否:不要为了“以后可能搜索”就默认使用全文分析。

第二步:是否需要完整值过滤、排序或聚合?

  • 是:使用 keyword、数值、日期等适合结构化操作的类型;
  • 对同一自然语言字段有两种需求:使用 multi-field。

第三步:是否需要保留数组对象内部的关联?

  • 是:使用 nested 或重新建模;
  • 否:普通 object 可能足够。

第四步:字段名是否稳定?

  • 稳定:显式 Mapping;
  • 部分动态但规则稳定:Dynamic Templates;
  • 任意键值集合:考虑 flattened 或不建立索引;
  • 不确定且高风险:dynamic: strict 或局部 dynamic: false

第五步:是否需要未来改变搜索规则?

如果 Analyzer、字段类型和索引模板可能演进,应把索引命名为版本化资源,例如:

articles-v1
articles-v2

再使用别名:

articles-read
articles-write

这样新旧索引可以并存,查询和写入切换不必依赖修改现有字段定义。


十七、字段数量、词项数量与索引成本

索引设计不仅影响查询正确性,也影响资源消耗。

17.1 多字段会增加索引结构

一个字段同时建立:

title
title.keyword
title.autocomplete
title.sort

并不是“免费增加查询入口”。每个字段可能建立独立的倒排索引、Doc Values、Norms 或其他结构。

应区分:

  • 业务确实需要的查询能力;
  • 只是为了测试而临时增加的字段;
  • 可以在查询时处理,而不必在索引时展开的逻辑。

17.2 ngram 会扩大词项数量

如果每个词长度为 LL,生成从 min_grammax_gram 的所有连续片段,单个词可能产生大约:

n=min_grammin(L,max_gram)(Ln+1)\sum_{n=\text{min\_gram}}^{\min(L,\text{max\_gram})}(L-n+1)

个词项。

变量含义:

  • LL:原始词长度;
  • nn:当前 gram 长度;
  • min_grammax_gram:生成片段的范围。

例如长度为 10 的词,若生成 2 到 5 gram:

长度 2:9 个
长度 3:8 个
长度 4:7 个
长度 5:6 个
总计:30 个

这是单个词的近似数量,真实索引还会受到重复词项、位置、文档数量和压缩结构影响。它说明了为什么不应无条件对长文本启用大范围 ngram

17.3 Fielddata 不是默认的全文聚合方案

text 开启 fielddata 可以让某些操作读取分析后的词项,但可能需要在内存中构建字段数据,资源风险通常高于使用专门的 keyword 子字段。若业务需求是按原始标签或标题聚合,应在 Mapping 设计阶段增加正确的 keyword 字段,而不是把全文字段强行改造成聚合字段。


十八、与写入、ILM 和快照的边界

Mapping 和 Analyzer 决定单个索引的数据结构,但生产环境通常还要把它们放入索引生命周期中:

索引模板 / 组件模板
  ↓
创建新索引或数据流 backing index
  ↓
写入与 refresh
  ↓
Rollover
  ↓
ILM 生命周期阶段
  ↓
快照、删除或归档

这里有几个重要边界:

  • 新的 backing index 必须继承正确的模板,否则同名字段可能出现 Mapping 冲突;
  • Data Stream 的各个 backing index 应保持兼容 Mapping;
  • 修改模板不会自动修改已经创建的旧索引;
  • ILM 负责生命周期动作,不负责把旧索引按新 Analyzer 重新分析;
  • 快照保存索引数据和元数据,但不会把错误 Mapping 自动修正;
  • 升级或插件变更后,应验证自定义 Analyzer、模板和重建流程。

如果已经存在多个索引,跨索引查询要求同名字段类型相容。一个索引中的 pricelong,另一个索引中的 pricekeyword,查询、排序或聚合可能出现类型冲突或结果不一致。版本化索引和模板测试可以降低这种风险。


十九、规范保证、实现细节与工程建议的区别

最后需要区分三类判断。

规范保证

这些属于 Elasticsearch 对 Mapping 和查询语义的核心保证:

  • text 字段使用配置的分析流程生成词项;
  • keyword 作为单个值索引,可配合 normalizer
  • term 查询不执行普通全文分析;
  • nested 查询维护嵌套对象边界;
  • 字段类型冲突会影响写入或跨索引查询;
  • 改变已有字段的类型或索引语义不能通过普通 Mapping 更新完成。

常见实现

这些是通常的索引结构或默认行为,但不应替代版本文档和实际验证:

  • text 通常使用倒排索引和 Norms;
  • keyword、数值和日期通常使用 Doc Values 支持排序和聚合;
  • 动态字符串通常可能同时产生 textkeyword 子字段;
  • _source 通常保存原始 JSON。

具体默认值、可用分析组件和某些参数应以所部署版本为准。

工程建议

这些需要结合业务验证,而不是 Elasticsearch 的强制规则:

  • 是否使用 dynamic: strict
  • 中文使用何种分词器;
  • 是否增加自动补全字段;
  • 是否选择 nested 或拆分索引;
  • 是否使用 scaled_float 表示金额;
  • 何时通过新索引重建并切换别名。

一个可靠的索引设计,应同时通过四类验证:

  1. GET _mapping 验证字段结构;
  2. _analyze 验证实际词项;
  3. 代表性查询验证召回、精确匹配、短语和聚合;
  4. 使用真实数据量验证索引体积、查询延迟、刷新和重建成本。

Mapping 决定“字段能做什么”,Analyzer 决定“文本以什么词项被搜索”,而索引设计负责让这两种语义在数据模型、查询方式和生命周期演进中保持一致。


系列导航与关联阅读

官方资料

本文依据数据库官方文档重新梳理;正文、示例与生产检查清单由 WR BLOG 编写。