Chico Notes
LLM Wiki / RAG

Elasticsearch Mapping 设计:object、flattened、nested 到关系投影

从 Elasticsearch 如何索引 JSON 出发,系统理解 object、flattened、nested、keyword、dynamic strict 与 mapping explosion,并用 Wiki relations 场景完成选型。

持续修订的工程笔记

很多 Elasticsearch 问题表面上是查询写错了,根源却是 mapping 选错了。

objectflattenednested 都能接收 JSON 对象,但它们表达的查询语义完全不同:

  • object 适合结构稳定的普通对象;
  • flattened 适合 key 多、key 不确定、主要做精确过滤的对象;
  • nested 适合对象数组,并且多个条件必须命中同一个数组元素;
  • 数量很大、持续增长、需要独立更新的子记录,通常应该拆成独立文档。

本文先建立 Elasticsearch 的 JSON 索引心智模型,再逐个拆解这些类型,最后用 Wiki 页面中的 relations 关系投影完成一次生产级选型。

先记住一句话

选择 mapping 时,不要问“这个 JSON 看起来是不是嵌套的”,而要问“查询时需要保留什么关系”。

1. Elasticsearch 不是按 JSON 树搜索

写入 Elasticsearch 的 _source 可以保持完整 JSON 层级:

{
  "user": {
    "name": "Chico",
    "address": {
      "city": "Beijing"
    }
  }
}

但普通对象在索引内部可以理解为一组点号字段:

user.name = Chico
user.address.city = Beijing

_source 的职责是保存原始文档,倒排索引和 doc values 的职责是支持搜索、过滤、排序和聚合。两者不能混为一谈。

这也是理解 objectnestedflattened 的起点。

Elasticsearch 没有专门的 array 类型

下面的字符串数组不需要额外声明为 array:

{
  "tags": ["RAG", "Elasticsearch", "Voice AI"]
}

mapping 只需要:

{
  "tags": {
    "type": "keyword"
  }
}

一个字段天然可以有零个、一个或多个同类型值。因此:

{
  "relation_target_ids": [
    "character:character_xxx",
    "scene:scene_001"
  ]
}

本质上只是一个多值 keyword 字段,不需要 nested

真正需要小心的是对象数组

{
  "variants": [
    {
      "color": "red",
      "size": "S"
    },
    {
      "color": "blue",
      "size": "L"
    }
  ]
}

这里不仅有多个值,还有“redS 属于同一个 variant”这种元素内关系。

2. 一张选型图

这张图里最容易被忽略的一步是:

数组里只有字符串或数字时,优先使用普通字段;不要因为它是数组就使用 nested


3. object:固定结构的默认选择

object 是普通 JSON 对象的默认类型。下面两种写法等价:

{
  "author": {
    "properties": {
      "name": {
        "type": "keyword"
      },
      "age": {
        "type": "integer"
      }
    }
  }
}
{
  "author": {
    "type": "object",
    "properties": {
      "name": {
        "type": "keyword"
      },
      "age": {
        "type": "integer"
      }
    }
  }
}

适合 object 的场景

  • 一个文档里只有一个这样的对象;
  • 子字段名称稳定;
  • 每个子字段需要明确的 keywordlongdatetext 等类型;
  • 即使是对象数组,也不需要保证多个条件来自同一个数组元素。

例如页面作者信息:

{
  "author": {
    "id": "user_001",
    "name": "Chico Gong",
    "role": "editor"
  }
}

mapping:

{
  "author": {
    "dynamic": "strict",
    "properties": {
      "id": {
        "type": "keyword"
      },
      "name": {
        "type": "keyword"
      },
      "role": {
        "type": "keyword"
      }
    }
  }
}

这个结构固定,类型明确,使用 object 最直接。

object 数组的交叉匹配问题

还是这个商品数据:

{
  "name": "T-Shirt",
  "variants": [
    {
      "color": "red",
      "size": "S"
    },
    {
      "color": "blue",
      "size": "L"
    }
  ]
}

如果 variants 使用普通 object,索引内部可以理解为:

variants.color = [red, blue]
variants.size  = [S, L]

原始数据中的组合关系丢失了。下面的查询会命中文档:

{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "variants.color": "red"
          }
        },
        {
          "term": {
            "variants.size": "L"
          }
        }
      ]
    }
  }
}

但真实数据里并不存在 red + L,只有:

red  + S
blue + L

两个查询条件分别命中了不同数组元素,这类问题通常被称为 cross-object match。

如果业务要求颜色与尺码必须来自同一个 variant,就要使用 nested


4. nested:保持对象数组的元素边界

nested 是一种特殊的对象类型。它把数组中的每个对象索引为一个隐藏的 Lucene 文档,使同一个 nested 查询中的多个条件只能在同一个元素内匹配。

Mapping

{
  "variants": {
    "type": "nested",
    "dynamic": "strict",
    "properties": {
      "color": {
        "type": "keyword"
      },
      "size": {
        "type": "keyword"
      },
      "stock": {
        "type": "integer"
      }
    }
  }
}

查询同一个 variant

{
  "query": {
    "nested": {
      "path": "variants",
      "query": {
        "bool": {
          "filter": [
            {
              "term": {
                "variants.color": "red"
              }
            },
            {
              "term": {
                "variants.size": "L"
              }
            }
          ]
        }
      }
    }
  }
}

这次不会命中,因为没有任何一个 variant 同时满足:

color = red
size  = L

inner_hits 返回真正命中的元素

{
  "_source": ["name"],
  "query": {
    "nested": {
      "path": "variants",
      "query": {
        "bool": {
          "filter": [
            {
              "term": {
                "variants.color": "red"
              }
            },
            {
              "range": {
                "variants.stock": {
                  "gte": 10
                }
              }
            }
          ]
        }
      },
      "inner_hits": {
        "_source": [
          "variants.color",
          "variants.size",
          "variants.stock"
        ]
      }
    }
  }
}

根文档告诉你“哪个商品命中”,inner_hits 告诉你“商品里的哪个 variant 命中”。

nested 配套的能力包括:

  • nested query
  • inner_hits
  • nested aggregation
  • reverse_nested aggregation
  • nested sorting。

nested 的代价

假设一个根文档里有 100 个 nested 对象,底层会产生:

1 个根 Lucene 文档
+
100 个 nested Lucene 文档
=
101 个 Lucene 文档

因此 nested 会增加:

  • Lucene 文档数量;
  • 索引体积与写入成本;
  • 查询、排序和聚合复杂度;
  • 大数组整体更新的成本;
  • 运维和可视化工具的使用限制。

nested 不是“更高级的 object”,而是为了保留对象数组元素关系而付出的额外成本。

nested 的使用边界

只有在“多个查询条件必须属于同一个数组对象”时才使用 nested。数组里只是字符串 ID,或者对象数量很大且需要独立更新时,都不应该默认使用 nested。


5. flattened:控制动态 key,而不是保存对象关系

普通 object 会为每个叶子字段创建 mapping。遇到日志属性、第三方 metadata、模型标签或插件扩展字段时,key 往往无法预先穷举:

{
  "attrs": {
    "provider": "minimax",
    "model": "speech-2.6-turbo",
    "region": "beijing",
    "experiment_group": "tts_exp_03",
    "customer_defined_key": "value"
  }
}

如果使用动态 object,这些 key 会逐个进入 mapping:

attrs.provider
attrs.model
attrs.region
attrs.experiment_group
attrs.customer_defined_key

当不同租户、设备或插件不断提交新 key 时,mapping 会持续膨胀。

flattened 把整个对象作为一个 mapping 字段处理:

{
  "attrs": {
    "type": "flattened"
  }
}

对象中的叶子值仍然可以搜索,但默认按类似 keyword 的字符串语义索引。

查询指定 key

{
  "query": {
    "term": {
      "attrs.provider": "minimax"
    }
  }
}

查询对象中的任意叶子值

{
  "query": {
    "term": {
      "attrs": "beijing"
    }
  }
}

直接查询顶层 flattened 字段,会在该对象的所有叶子值中匹配。

数组值也可以使用

{
  "relations": {
    "RELATED_CHARACTER": [
      "character:character_xxx",
      "character:character_yyy"
    ]
  }
}

查询:

{
  "query": {
    "term": {
      "relations.RELATED_CHARACTER": "character:character_xxx"
    }
  }
}

这里数组里的每个值都是同一种稳定实体引用,不需要 nested

flattened 的限制

默认情况下,flattened 叶子值按字符串 keyword 语义处理:

{
  "attrs": {
    "latency_ms": 428,
    "created_at": "2026-08-05T10:00:00+08:00"
  }
}

不要把它误认为真正的:

latency_ms → long
created_at → date

传统 flattened 查询中的数字范围、日期范围和排序仍然是字符串语义。例如字典序中:

"10" < "2"

因此以下字段应该提升为显式 typed field:

  • 数值范围查询;
  • 日期范围查询;
  • IP 查询;
  • 需要正确数值或日期排序的字段;
  • 高频聚合、过滤和排序字段;
  • 需要全文分词或高亮的文本字段。

常见生产设计是:

{
  "provider": {
    "type": "keyword"
  },
  "latency_ms": {
    "type": "integer"
  },
  "created_at": {
    "type": "date"
  },
  "attrs": {
    "type": "flattened"
  }
}

也就是:

核心查询字段 → 显式 typed mapping
长尾动态 metadata → flattened
完全不查询的原始 JSON → enabled: false

版本说明

当前 Elastic 文档已经展示了 flattened 的 typed sub-fields 和 passthrough 能力,可以给少数已知 key 声明 longdateip 等类型。但较早的 Elasticsearch 版本并不具备相同能力。

生产系统不要只看“current”文档就直接使用新参数。应先确认集群版本,并用对应版本文档和测试集群验证。为了兼容不同版本,把核心字段提升到根级 typed field 仍然是最稳妥的设计。


6. 四种常见方案对比

方案Mapping 数量保留对象数组元素关系类型能力典型场景
普通多值字段一个字段不涉及完整标签、ID 列表、语言列表
object每个叶子字段独立映射完整固定结构的单对象
flattened整个对象通常一个映射默认 keyword 语义任意属性、动态标签、关系 key → ID 数组
nested子字段有 mapping,每个元素变隐藏文档完整商品规格、实体提及、带属性的关系数组
独立文档每条记录独立天然保留完整弹幕、评论、事件、海量关系边
enabled: false内部不建 mapping不适用不可查询原始模型输出、审计 payload

7. dynamic: strict 与 mapping explosion

生产索引经常采用:

{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "id": {
        "type": "keyword"
      }
    }
  }
}

此时写入一个未声明的根字段:

{
  "id": "page_001",
  "relations": {}
}

文档会被拒绝。strict 的语义是:

检测到新字段时抛出异常,必须先显式增加 mapping。

这是一种很有价值的保护:拼错字段名、投影版本不一致、上游偷偷加字段,都不会无声进入索引。

为什么又需要 flattened

严格 schema 与动态业务属性并不矛盾。可以把边界设计成:

根级 schema:dynamic: strict
开放扩展点:显式声明 flattened

例如:

{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "id": {
        "type": "keyword"
      },
      "attrs": {
        "type": "flattened"
      },
      "relations": {
        "type": "flattened"
      }
    }
  }
}

根级字段仍然严格,attrsrelations 内部的 key 则可以扩展,且不会为每个 key 创建独立 mapping。

Mapping explosion 是什么

当普通动态对象不断产生新 key 时,mapping 会越来越大。字段、对象层级、multi-fields、field aliases 和 mapped runtime fields 都会占用 mapping 数量。

Elasticsearch 使用 index.mapping.total_fields.limit 防止 mapping 无限增长。这个限制不是容量目标,而是保护线。简单提高上限只能延后问题,不能修复错误的数据模型。

常见治理方式:

  • 已知核心字段使用显式 mapping;
  • 根级使用 dynamic: strictdynamic: false
  • 任意 key-value 使用 flattened
  • 只保存不搜索的数据使用 enabled: false
  • 从入口控制 key 数量、长度和命名;
  • 把超大、持续增长的子记录拆成独立文档。

flattened 也需要应用层校验

flattened 不会为内部 key 建立独立 mapping,因此 ES 也不会帮你发现这种拼写错误:

RELATED_CHARACTER
RELATED_CHARCTER
RELATED_CHARACTOR

如果关系类型是受控词表,应在 Go 代码、Schema Registry 或写入管道中校验:

var allowedRelationTypes = map[string]struct{}{
    "RELATED_CHARACTER":  {},
    "USED_IN_SCENE":      {},
    "USES_ASSET":         {},
    "BELONGS_TO_EPISODE": {},
}

如果关系类型允许插件扩展,也应限制格式与长度,例如:

^[A-Z][A-Z0-9_]{0,63}$

同时避免关系 key 包含点号,因为点号用于 flattened 子路径查询。


8. 周边 mapping 工具

enabled: false:只保存,不搜索

{
  "raw_model_output": {
    "type": "object",
    "enabled": false
  }
}

写入的 JSON 仍然保留在 _source,但 ES 会跳过内容解析,内部字段不可搜索、不可排序、不可聚合。

适合:

  • 原始模型输出;
  • 调试快照;
  • 第三方原始 payload;
  • 只用于回溯和审计的数据。

它与 flattened 的区别是:

flattened      → 有限搜索
enabled: false → 完全不搜索

dynamic: false:未知字段留在 _source

dynamic: false 下,未知字段不会进入 mapping,也不可搜索,但普通索引中仍可保留在 _source

它适合允许上游多带字段、但不希望索引失败的场景。与 strict 相比,它更宽松,也更容易掩盖字段拼写错误。

Runtime fields:低频计算,不急着 reindex

Runtime field 在查询时从 _source 或其他字段计算,适合:

  • 试验新的字段逻辑;
  • 临时修复 mapping;
  • 低频衍生字段;
  • 在正式 reindex 前验证设计。

代价是查询时计算。高频过滤、排序和聚合字段仍应优先建立正常索引字段。

subobjects: false:点号字段不展开

subobjects: false 解决的是:

metrics.time.min 是否被解释为多层对象路径。

它不等于 flattened。前者仍然为每个字段建立明确 mapping,只是不创建中间对象;后者把整个未知对象作为一个字段处理。

Parent-child join:最后再考虑

join 可以在同一个索引里建立父子文档关系,但会引入 routing、global ordinals 和查询开销。

多数搜索系统更适合:

独立文档
+
冗余必要的父级过滤字段

只有当父子数量差异极大、无法合理反范式、且确实需要父子查询时,才值得考虑 join


9. 实战:Wiki relations 应该怎么存

现在有一类 Wiki 页面关系数据。MySQL/Page 需要保留完整展示结构:

{
  "relations": {
    "RELATED_CHARACTER": [
      {
        "entity_id": "character_xxx",
        "entity_type": "character",
        "title": "齐夏"
      }
    ],
    "USED_IN_SCENE": [
      {
        "entity_id": "scene_001",
        "entity_type": "scene",
        "title": "雨夜对话"
      }
    ]
  }
}

这个结构适合作为业务事实和页面展示数据,但 ES 不需要复制所有可变字段。

搜索真正需要的是:

关系类型 + 稳定目标引用

因此 ES 投影可以简化为:

{
  "relations": {
    "RELATED_CHARACTER": [
      "character:character_xxx"
    ],
    "USED_IN_SCENE": [
      "scene:scene_001",
      "scene:scene_002"
    ],
    "USES_ASSET": [
      "asset:asset_001"
    ]
  },
  "relation_target_ids": [
    "character:character_xxx",
    "scene:scene_001",
    "scene:scene_002",
    "asset:asset_001"
  ]
}

为什么只保存稳定引用

关系里的 title 不是稳定事实:

  • 人物可能改名;
  • 页面标题可能调整;
  • 多语言环境中标题不唯一;
  • 同一个实体可能有别名。

如果把 title 冗余到每个关系中,就要处理级联更新和过期数据。ES 关系投影只保存稳定 ID,需要展示时再读取目标实体。

这里的:

character:character_xxx

严格说是带类型前缀的 entity reference,而不只是裸 ID。继续使用 relation_target_ids 没问题,但团队需要统一命名含义;新系统也可以命名为 relation_target_refs

推荐 mapping

{
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "id": {
        "type": "keyword"
      },
      "title": {
        "type": "text",
        "analyzer": "cjk"
      },
      "relations": {
        "type": "flattened",
        "depth_limit": 4
      },
      "relation_target_ids": {
        "type": "keyword"
      },
      "attrs": {
        "type": "flattened"
      }
    }
  }
}

为什么选择 flattened

  1. 关系类型 key 可能持续增加;
  2. 每个 key 下只是同类型的稳定字符串引用;
  3. 查询主要是精确过滤;
  4. 不需要保持对象内部多个属性的组合关系;
  5. 不希望每增加一种关系都修改 mapping。

后续新增:

{
  "relations": {
    "REFERENCES": [
      "asset:a1"
    ],
    "GENERATED_BY": [
      "task:t1"
    ],
    "BELONGS_TO_EPISODE": [
      "episode:e1"
    ]
  }
}

不需要新增 ES mapping 字段。

查询指定关系类型

查“RELATED_CHARACTER 指向齐夏”的页面:

{
  "query": {
    "term": {
      "relations.RELATED_CHARACTER": "character:character_xxx"
    }
  }
}

同时满足人物和场次

{
  "query": {
    "bool": {
      "filter": [
        {
          "term": {
            "relations.RELATED_CHARACTER": "character:character_xxx"
          }
        },
        {
          "term": {
            "relations.USED_IN_SCENE": "scene:scene_012"
          }
        }
      ]
    }
  }
}

这两个条件可以来自不同关系 key,因为业务问题本来就是:

页面关联了人物 A
并且
页面用于场次 B

不要求它们属于同一个关系对象,所以不需要 nested

多个人物任意一个命中

{
  "query": {
    "terms": {
      "relations.RELATED_CHARACTER": [
        "character:character_xxx",
        "character:character_yyy"
      ]
    }
  }
}

不区分关系类型

从技术能力上说,可以直接查询顶层 flattened 字段:

{
  "query": {
    "term": {
      "relations": "character:character_xxx"
    }
  }
}

这会搜索 relations 内所有叶子值。

因此,relation_target_ids 并不是完成“不区分关系类型查询”的硬性要求。

为什么仍然可以保留 relation_target_ids

保留它的价值不是弥补 flattened 无法查询,而是建立明确、稳定的搜索契约:

{
  "query": {
    "term": {
      "relation_target_ids": "character:character_xxx"
    }
  }
}

适合保留的情况:

  • 反向关系过滤是高频 API;
  • 不希望查询层依赖 relations 的内部存储方式;
  • 以后 relations 可能混入来源、置信度或其他非 ID 值;
  • 希望字段名明确表达“所有关系目标”;
  • 需要与旧的 out_links 查询能力兼容。

对当前 Wiki 系统,我倾向于保留它。它是一个有意设计的反范式投影字段,而不是无意义重复。


10. 为什么这里不用 nested

如果 ES 保存的是下面这种对象数组:

{
  "relations": [
    {
      "relation_type": "RELATED_CHARACTER",
      "target_id": "character_xxx",
      "target_type": "character",
      "source": "llm",
      "confidence": 0.95
    },
    {
      "relation_type": "USED_IN_SCENE",
      "target_id": "scene_001",
      "target_type": "scene",
      "source": "manual",
      "confidence": 1.0
    }
  ]
}

并且查询要求:

relation_type = RELATED_CHARACTER
AND target_id = character_xxx
AND source = llm
AND confidence >= 0.9

这些条件必须属于同一条关系记录,此时才应该使用 nested

当前结构已经把关系类型放在外层 key,数组值又只有稳定字符串引用:

{
  "RELATED_CHARACTER": [
    "character:character_xxx"
  ]
}

不存在需要维持的对象属性组合,使用 nested 不会增加查询能力,只会增加复杂度。


11. 什么时候升级成独立关系文档

当关系从“页面搜索投影”变成一等业务实体,并出现以下需求时,应考虑独立关系表或关系索引:

  • 每条关系有独立 ID;
  • 有来源、置信度、状态、创建时间、审核人;
  • 关系需要单独新增、修改、删除;
  • 单个页面包含大量关系;
  • 需要按关系边做统计;
  • 需要大量反向查询;
  • 需要图遍历、多跳分析或关系版本管理。

独立关系文档可以设计为:

{
  "relation_id": "rel_001",
  "source_ref": "page:page_001",
  "relation_type": "RELATED_CHARACTER",
  "target_ref": "character:character_xxx",
  "source": "llm",
  "confidence": 0.95,
  "status": "active",
  "created_at": "2026-08-05T10:00:00Z"
}

mapping:

{
  "relation_id": {
    "type": "keyword"
  },
  "source_ref": {
    "type": "keyword"
  },
  "relation_type": {
    "type": "keyword"
  },
  "target_ref": {
    "type": "keyword"
  },
  "source": {
    "type": "keyword"
  },
  "confidence": {
    "type": "float"
  },
  "status": {
    "type": "keyword"
  },
  "created_at": {
    "type": "date"
  }
}

页面索引仍可保留少量常用关系投影,用于低延迟过滤;关系索引负责完整关系生命周期。这种“双层模型”比把无限关系塞进一个 nested 数组更可控。


12. 在严格索引里上线新字段

当前索引是:

{
  "dynamic": "strict"
}

而 mapping 里没有 relations。因此应用代码先上线并直接写入新字段,会导致整个文档被拒绝。

发布顺序应该是:

1. 修改仓库 mapping

{
  "relations": {
    "type": "flattened",
    "depth_limit": 4
  },
  "relation_target_ids": {
    "type": "keyword"
  }
}

这保证未来创建 _v2_v3 索引时具备正确 mapping。

2. 更新当前物理索引

新增字段可以使用 Update Mapping API:

PUT kb_gateway_wiki_v1/_mapping
{
  "properties": {
    "relations": {
      "type": "flattened",
      "depth_limit": 4
    },
    "relation_target_ids": {
      "type": "keyword"
    }
  }
}

修改仓库里的 JSON 文件,不等于已经修改运行中的 ES 集群。两处都要处理。

3. 部署投影代码

投影器开始输出:

{
  "relations": {
    "RELATED_CHARACTER": [
      "character:character_xxx"
    ]
  },
  "relation_target_ids": [
    "character:character_xxx"
  ]
}

4. 回填历史文档

新增 mapping 不会让旧文档自动产生新字段。需要重新投影或执行可控的回填任务。

5. 类型变更要走新索引

如果字段已经被创建成 object,不能直接原地改成 flattenednested。标准流程是:

创建 kb_gateway_wiki_v2
→ 使用新 mapping
→ reindex 或重新投影
→ 校验文档数与查询结果
→ alias 从 v1 切到 v2
→ 延迟删除 v1

这也是业务代码只访问稳定 alias、物理索引使用 _v1_v2 的主要价值。


13. Go 投影结构

业务模型与搜索模型不要共用同一个结构体。

MySQL / Page 模型

type RelationRef struct {
    EntityID   string `json:"entity_id"`
    EntityType string `json:"entity_type"`
    Title      string `json:"title"`
}

type Page struct {
    Relations map[string][]RelationRef `json:"relations"`
}

它保留完整展示信息。

ES 文档模型

type WikiDocument struct {
    Relations         map[string][]string `json:"relations,omitempty"`
    RelationTargetIDs []string            `json:"relation_target_ids,omitempty"`
}

它只保留搜索需要的稳定引用。

投影函数

func ProjectRelations(
    input map[string][]RelationRef,
) (map[string][]string, []string, error) {
    relations := make(map[string][]string, len(input))
    targetSet := make(map[string]struct{})

    for relationType, refs := range input {
        if err := validateRelationType(relationType); err != nil {
            return nil, nil, err
        }

        values := make([]string, 0, len(refs))
        perTypeSet := make(map[string]struct{}, len(refs))

        for _, ref := range refs {
            if ref.EntityID == "" || ref.EntityType == "" {
                continue
            }

            target := ref.EntityType + ":" + ref.EntityID
            if _, exists := perTypeSet[target]; exists {
                continue
            }

            perTypeSet[target] = struct{}{}
            targetSet[target] = struct{}{}
            values = append(values, target)
        }

        if len(values) > 0 {
            sort.Strings(values)
            relations[relationType] = values
        }
    }

    targets := make([]string, 0, len(targetSet))
    for target := range targetSet {
        targets = append(targets, target)
    }
    sort.Strings(targets)

    return relations, targets, nil
}

排序不是搜索所必需的,但有助于:

  • 生成稳定投影哈希;
  • 减少无意义的文档更新;
  • 让测试结果可重复;
  • 方便 diff 和问题排查。

投影哈希必须包含规范化后的 relations,否则关系变化可能不会触发 ES 更新。


14. 应该补哪些测试

Mapping 测试

验证:

  • dynamic: strict 生效;
  • relationsflattened
  • relation_target_idskeyword
  • 未声明的根字段会被拒绝;
  • 新关系 key 可以正常写入;
  • 关系 key 不会创建新的 mapping 字段。

投影测试

至少覆盖:

  • 空 relations;
  • 一个关系类型、一个目标;
  • 多关系类型;
  • 重复目标去重;
  • 空 ID 或空类型;
  • 非法关系 key;
  • title 变化不会改变稳定目标引用;
  • 输出顺序稳定;
  • 投影哈希随关系变化而变化。

查询测试

至少验证:

  • 指定关系类型精确查询;
  • 不区分关系类型查询;
  • 多目标 terms 查询;
  • 人物与场次同时过滤;
  • 不存在目标时不命中;
  • 回填前后文档数量一致。

发布测试

检查:

mapping 文件
当前物理索引 mapping
写入投影
搜索 _source 白名单
查询参数
历史数据回填
alias 指向
监控与告警

任何一项漏掉,都可能出现“代码看起来完成了,但线上查不到”的状态。


15. 常见反模式

反模式一:看到 JSON 数组就用 nested

{
  "tags": ["RAG", "ES"]
}

这是普通 keyword 数组,不是 nested。

反模式二:所有 metadata 都用 object + dynamic true

短期写入方便,长期容易 mapping explosion。未知 key 应先判断是否适合 flattened

反模式三:把所有字段都塞进 flattened

这样会失去数值、日期、IP、全文检索和高亮等完整能力。核心字段仍应显式建模。

反模式四:为了展示方便,把可变 title 当关系事实

关系应保存稳定引用,展示名称由目标实体提供。

反模式五:把几万条弹幕 nested 到一集文档

弹幕、评论、事件等高数量子记录应该独立成文档,并冗余 drama_idepisode_idsecond 等过滤字段。

反模式六:只修改 mapping 文件,不修改在线索引

仓库配置、运行中的物理索引和历史数据是三个不同层面,必须分别处理。

反模式七:提高 total fields limit 代替数据建模

提高限制可能暂时消除报错,但会把 mapping 体积、集群状态和查询开销问题推迟到更难处理的阶段。


16. 最终选择口诀

字符串、数字或 ID 数组
→ 普通 keyword / long / date 等多值字段

固定结构的单个对象
→ object

动态 key-value,主要做精确匹配
→ flattened

对象数组,多个条件必须命中同一个元素
→ nested

只保存原始 JSON,不搜索
→ object + enabled: false

子记录很多、持续增长、独立更新
→ 独立文档 / 独立索引

父子差异极大且无法合理冗余
→ 最后再考虑 join

对本文的 Wiki relations 场景,结论是:

MySQL:
relations = 关系类型 → RelationRef 对象数组
作为完整事实和展示数据

Elasticsearch:
relations = 关系类型 → 稳定引用数组
类型使用 flattened

Elasticsearch:
relation_target_ids = 所有目标引用的去重并集
类型使用 keyword,作为稳定的跨关系查询契约

这个方案既保留了关系类型查询,又避免了 nested 成本和动态 mapping 膨胀,同时给未来的关系模型升级留出了边界。

参考资料

讨论

继续讨论这篇笔记

有问题、补充案例或不同观点,可以通过 GitHub Discussions 继续交流。

On this page

1. Elasticsearch 不是按 JSON 树搜索Elasticsearch 没有专门的 array 类型2. 一张选型图3. object:固定结构的默认选择适合 object 的场景object 数组的交叉匹配问题4. nested:保持对象数组的元素边界Mapping查询同一个 variantinner_hits 返回真正命中的元素nested 的代价5. flattened:控制动态 key,而不是保存对象关系查询指定 key查询对象中的任意叶子值数组值也可以使用flattened 的限制版本说明6. 四种常见方案对比7. dynamic: strict 与 mapping explosion为什么又需要 flattenedMapping explosion 是什么flattened 也需要应用层校验8. 周边 mapping 工具enabled: false:只保存,不搜索dynamic: false:未知字段留在 _sourceRuntime fields:低频计算,不急着 reindexsubobjects: false:点号字段不展开Parent-child join:最后再考虑9. 实战:Wiki relations 应该怎么存为什么只保存稳定引用推荐 mapping查询指定关系类型同时满足人物和场次多个人物任意一个命中不区分关系类型为什么仍然可以保留 relation_target_ids10. 为什么这里不用 nested11. 什么时候升级成独立关系文档12. 在严格索引里上线新字段1. 修改仓库 mapping2. 更新当前物理索引3. 部署投影代码4. 回填历史文档5. 类型变更要走新索引13. Go 投影结构MySQL / Page 模型ES 文档模型投影函数14. 应该补哪些测试Mapping 测试投影测试查询测试发布测试15. 常见反模式反模式一:看到 JSON 数组就用 nested反模式二:所有 metadata 都用 object + dynamic true反模式三:把所有字段都塞进 flattened反模式四:为了展示方便,把可变 title 当关系事实反模式五:把几万条弹幕 nested 到一集文档反模式六:只修改 mapping 文件,不修改在线索引反模式七:提高 total fields limit 代替数据建模16. 最终选择口诀参考资料