4. Elasticsearch数据建模与映射设计

在 Elastic 机器学习数据平台中,特征工程的第一步往往不是写代码,而是决定数据在 Elasticsearch 里“长什么样”。映射(Mapping)定义了索引中每个字段的类型、分词方式、是否可聚合、是否参与排序,而这些属性会直接决定后续异常检测、分类、回归和推理任务能拿到什么形态的特征。换句话说,映射设计是特征质量的“上游闸门”:字段类型选错,特征可能被静默丢弃;嵌套关系处理不当,特征会丢失层级语义;父子文档设计不合理,聚合特征会变得昂贵甚至不可用。

字段类型:特征语义的第一道分水岭

Elasticsearch 的字段类型决定了值如何被索引、存储和检索。对机器学习特征而言,最关键的区分是:数值型(long、integer、double、float、scaled_float)可直接作为连续或离散特征;keyword 适合作为分类特征或聚合维度;text 经过分词后适合全文检索,但默认不能直接用于聚合和排序,也不适合作为分类特征;boolean 适合二值特征;date 是时间序列异常检测的基石;ip、geo_point 等则承载空间或网络特征。

一个常见误区是把本应是数值的字段写成 keyword。例如把 response_time_ms 写成字符串,虽然能检索,但无法参与 avg、max 等聚合,异常检测作业会直接报“字段不是数值类型”。反过来,把用户 ID 写成 long 虽然能聚合,但会失去精确匹配的语义,且在高基数场景下容易造成内存压力。因此,字段类型的选择要同时考虑“特征用途”和“查询/聚合成本”。

动态映射:便利与风险并存

动态映射(Dynamic Mapping)让 Elasticsearch 在写入文档时自动推断字段类型。默认规则是:JSON 字符串映射为 text 并附带 keyword 子字段,数字映射为 long 或 float,布尔映射为 boolean,对象映射为 object,数组则根据第一个非空元素推断。这在快速原型阶段非常方便,但对机器学习特征却是双刃剑。

风险主要有三:第一,类型漂移。如果第一批文档中 temperature 是整数 25,映射为 long;后续写入 25.5 时会被强制转换或直接拒绝,导致特征值失真。第二,字符串误判。像 "2024-01-01" 这样的日期字符串可能被推断为 date,也可能被推断为 text,取决于格式识别,结果不可控。第三,字段爆炸。动态映射会为每个新字段创建映射,如果文档中有大量动态键(如用户自定义标签),会导致 mapping 膨胀,拖慢集群并影响特征抽取性能。

在机器学习场景中,建议对用于特征抽取的索引关闭动态映射,或至少设置 dynamic: "strict",强制所有字段显式声明。这样可以在写入阶段就暴露类型问题,而不是等到训练作业失败才发现。

显式映射:为特征“定契约”

显式映射(Explicit Mapping)是在创建索引时明确声明每个字段的类型和参数。对机器学习特征而言,显式映射的价值在于“契约稳定”:特征抽取作业可以依赖固定的字段类型,不必担心某天写入的数据改变了映射。下面是一个面向异常检测的示例映射,用于记录服务器指标:

PUT metrics-ml
{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "timestamp":   { "type": "date" },
      "host":        { "type": "keyword" },
      "cpu_percent": { "type": "double" },
      "mem_used_mb": { "type": "long" },
      "disk_io":     { "type": "double" },
      "status":      { "type": "keyword" },
      "tags":        { "type": "keyword" }
    }
  }
}

这里 timestamp 是时间字段,异常检测作业会用它做时间分桶;host 和 status 是分类维度,可用于按主机分组;cpu_percent、mem_used_mb、disk_io 是数值特征,直接进入特征向量;tags 是关键词数组,可用于过滤或作为多值分类特征。注意 dynamic: "strict" 会让任何未声明字段的写入失败,从而保证映射不被意外污染。

多字段:一份数据,多种特征视角

多字段(Multi-fields)允许同一个字段以不同方式索引。最典型的是 text 主字段加 keyword 子字段:主字段用于全文检索,子字段用于聚合和排序。对机器学习而言,多字段的意义在于“同一份原始数据可以派生出不同用途的特征”。例如商品名称 product_name 可以同时有 text 用于语义搜索,keyword 用于精确分类,甚至再加一个 text 子字段使用不同分词器用于关键词提取。

"product_name": {
  "type": "text",
  "fields": {
    "raw":    { "type": "keyword" },
    "ik":     { "type": "text", "analyzer": "ik_max_word" }
  }
}

在特征工程中,product_name.raw 可以作为分类特征输入,而 product_name 本身可以用于计算文本相似度或关键词权重。需要注意的是,多字段会增加索引体积和写入开销,因此只对真正需要多视角的字段使用。

嵌套与父子文档:层级关系的特征表达

当文档包含对象数组时,Elasticsearch 默认使用 object 类型,但 object 数组会“扁平化”内部字段,丢失元素之间的关联。例如订单文档中有多个商品,每个商品有 name 和 price,如果使用 object,查询“name 为 A 且 price 大于 100”可能匹配到不同商品组合,导致特征语义错误。nested 类型通过为每个数组元素建立独立文档来解决这个问题,保持元素内部字段的关联性。

对机器学习特征而言,nested 的价值在于可以精确计算“每个订单中高价商品的数量”这类特征,而不会跨商品混淆。但 nested 查询和聚合成本较高,且不能直接用于某些 ML 作业的字段抽取,通常需要先用 nested 聚合或脚本字段展开成扁平特征。

父子文档(Parent-Child)则用于表达一对多关系,且父子文档可以独立更新。例如“主机”是父文档,“指标采样”是子文档。父子文档适合需要频繁更新子文档而不重建父文档的场景,但在机器学习中,父子文档的聚合需要通过 has_child 或 has_parent 查询,特征抽取链路更复杂,通常不如 nested 直接。选择时需权衡更新频率与特征抽取复杂度。

映射设计对机器学习特征的影响链路 原始 JSON 文档 timestamp: "2024-06-01T10:00" host: "web-01" cpu: 72.5 tags: ["prod","api"] items: [{name:"A",price:120}, {name:"B",price:80}] 映射决策层 动态映射:自动推断 风险:类型漂移 / 字段爆炸 显式映射:契约稳定 推荐:dynamic: strict 多字段:一源多视角 text + keyword 子字段 嵌套 / 父子:层级语义 nested 保关联,父子可独立更新 机器学习特征 • 时间特征:timestamp • 分类特征:host / status • 数值特征:cpu / mem • 多值特征:tags • 层级特征:items.price • 文本特征:product_name 映射选择对特征质量的具体影响 字段类型 数值可聚合,keyword 可分类 text 默认不可直接做特征 动态映射 类型漂移导致特征失真 字段爆炸拖慢抽取作业 多字段 同一字段派生多种特征 增加索引体积与写入开销 嵌套 / 父子 nested 保元素关联 父子聚合链路更复杂 核心原则:先明确特征用途,再反向设计映射;对 ML 索引优先显式映射 + strict 动态策略 层级数据优先 nested;需要独立更新子文档时再考虑父子文档

实践建议:从特征需求反推映射

在实际项目中,建议按以下顺序设计映射:第一,列出所有机器学习作业需要的特征字段及其类型(数值、分类、时间、文本、层级);第二,为每个字段选择最合适的 Elasticsearch 类型,数值特征避免使用 keyword,分类特征优先 keyword;第三,对需要多视角的字段使用多字段;第四,对对象数组使用 nested,并评估聚合成本;第五,关闭动态映射或设置为 strict,用显式映射锁定契约。这样,特征抽取作业才能在稳定的映射基础上运行,减少因映射变化导致的训练失败或特征漂移。

如果索引已经存在且映射不理想,可以通过 _reindex 配合新映射重建索引。对于机器学习作业,建议在重建后重新运行特征抽取,并对比新旧特征分布,确认映射调整没有引入意外的特征偏移。