Skip to content

Elasticsearch Mapping 与查询

如果说 ES 的核心思想是“建索引后再搜索”,那么 Mapping 回答的是:

字段应该怎么被索引?

Query DSL 回答的是:

用户的搜索条件应该怎么匹配这些索引?

这两者必须配套。字段类型设计错了,查询语句再复杂也救不回来;查询类型用错了,明明有数据也可能搜不到。

为什么 Mapping 很重要

同样一个字段,建模方式不同,搜索效果完全不同。

以商品名 无线蓝牙降噪耳机 Pro 为例:

  1. 如果按 text 建模,适合搜索“蓝牙耳机”“降噪”。
  2. 如果按 keyword 建模,适合完整商品名精确匹配、排序或聚合。
  3. 如果只建成 keyword,用户搜“蓝牙耳机”可能搜不到。
  4. 如果只建成 text,做品牌聚合、精确过滤、排序会很麻烦。

常用字段类型

类型是否分词适合场景示例字段
text全文搜索、高亮productNamedescription
keyword精确过滤、排序、聚合brandNamestatusorderId
longID、数量brandIdsaleCount
scaled_float金额pricepayAmount
date时间范围、排序createdAtupdatedAt
boolean是否类字段deletednewUserOnly
geo_point经纬度距离查询location
object视字段而定普通 JSON 对象extra
nested视字段而定数组对象需要保持内部关系skuListcoupons

金额建议使用 scaled_float 或整数分。不要用普通浮点承载核心金额判断,否则容易出现精度问题。

Mapping 设计原则

设计 Mapping 前先问六个问题:

问题决定什么
这个字段要不要被搜索是否需要 index
是全文搜索还是精确匹配text 还是 keyword
是否要排序或聚合是否需要 doc values 友好的类型
是否需要高亮通常需要 text 字段
是否会频繁更新更新频繁字段要谨慎放入 ES
是否来自多张表是否需要写入前反范式冗余

常见字段选择:

字段推荐类型为什么
商品名text + keyword 子字段全文搜索 + 必要时精确匹配
卖点text辅助召回
品牌 IDlong精确过滤
品牌名keyword展示和聚合
类目 IDlong精确过滤
标签keyword精确过滤、聚合、运营加权
价格scaled_float范围查询和排序
库存状态keyword是否有货过滤
销量long排序、业务加权
更新时间date增量同步、排查、排序

不要所有字段都无脑用 texttext 会分词,适合自然语言搜索;状态、ID、标签、枚举这类字段被分词反而会让过滤和聚合出问题。

商品搜索 Mapping Demo

json
PUT /product_search_v1
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "analysis": {
      "analyzer": {
        "product_text_analyzer": {
          "type": "standard"
        }
      }
    }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "id": { "type": "long" },
      "spuId": { "type": "long" },
      "skuId": { "type": "long" },
      "productName": {
        "type": "text",
        "analyzer": "product_text_analyzer",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 256 }
        }
      },
      "subTitle": {
        "type": "text",
        "analyzer": "product_text_analyzer"
      },
      "brandId": { "type": "long" },
      "brandName": { "type": "keyword" },
      "categoryId": { "type": "long" },
      "categoryName": { "type": "keyword" },
      "shopId": { "type": "long" },
      "tags": { "type": "keyword" },
      "price": { "type": "scaled_float", "scaling_factor": 100 },
      "stockStatus": { "type": "keyword" },
      "saleCount": { "type": "long" },
      "location": { "type": "geo_point" },
      "createdAt": { "type": "date" },
      "updatedAt": { "type": "date" }
    }
  }
}

这里的 dynamic: strict 表示不允许写入 Mapping 中没有定义的字段。它能防止字段名写错导致 ES 自动创建错误字段,例如把 stockStatus 写成 stockStats

动态 Mapping 的风险

ES 可以根据写入文档自动推断字段类型,但生产项目不建议完全依赖动态 Mapping。

json
POST /dynamic_demo/_doc/1
{
  "status": "1",
  "price": "100"
}

如果第一次写入时 price 是字符串,ES 可能把它映射成 textkeyword,后续再想按数值范围查询就很麻烦。

风险后果
字段类型自动推断错误范围查询、排序、聚合异常
字段名写错也被创建索引里出现脏字段
字段数量无限膨胀Mapping 爆炸,集群状态变大
后期修改成本高已有字段类型通常不能直接修改

推荐做法:核心业务索引手写 Mapping,日志类索引用 index template 管理。

textkeyword 的本质区别

mermaid
flowchart TD
    A["写入字段 productName"] --> B{"字段类型"}
    B -- "text" --> C["经过 analyzer 分词"]
    C --> D["多个 term 写入倒排索引"]
    B -- "keyword" --> E["整体值作为一个 term"]
    E --> F["适合精确过滤、排序、聚合"]

例如:

json
{
  "productName": "无线蓝牙降噪耳机 Pro"
}

text 可能得到:

text
无线, 蓝牙, 降噪, 耳机, pro

keyword 得到:

text
无线蓝牙降噪耳机 Pro

所以:

  1. 想搜“蓝牙耳机”,用 productNametext 字段。
  2. 想完整匹配商品名,使用 productName.keyword
  3. 想按品牌统计数量,使用 brandName 这种 keyword 字段。

ES 查询大致分两类

叶子查询

针对具体字段直接查:

  1. term
  2. terms
  3. match
  4. multi_match
  5. range
  6. exists

组合查询

把多个查询条件组合起来,最常见的是 bool

termmatch 的区别

term

更偏向精确匹配,不分析查询词。适合查 keyword、数值、日期、布尔等结构化字段。

match

更偏向全文检索,会对输入做分析。适合查 text 字段。

为什么 termtext 经常查不到

假设商品名字段是 text

json
{
  "productName": "无线蓝牙降噪耳机 Pro"
}

写入时可能被分成:

text
无线, 蓝牙, 降噪, 耳机, pro

如果你这样查:

json
GET /product_search/_search
{
  "query": {
    "term": {
      "productName": "无线蓝牙降噪耳机 Pro"
    }
  }
}

term 不会对查询词再分词,它会拿整句话去倒排索引里找完全一样的 term,自然很可能找不到。

全文搜索应该用:

json
GET /product_search/_search
{
  "query": {
    "match": {
      "productName": "无线蓝牙降噪耳机 Pro"
    }
  }
}

精确匹配则应该查 keyword 字段:

json
GET /product_search/_search
{
  "query": {
    "term": {
      "productName.keyword": "无线蓝牙降噪耳机 Pro"
    }
  }
}

bool 查询是组合核心

mermaid
flowchart TD
    A["用户搜索请求"] --> B["must: 关键词召回"]
    A --> C["filter: 品牌、价格、库存"]
    A --> D["should: 销量、标签、运营权重"]
    A --> E["must_not: 排除不可见内容"]
    B --> F["候选文档"]
    C --> F
    D --> G["提高相关性分数"]
    E --> F
    F --> H["排序和返回"]
    G --> H
子句是否必须匹配是否影响评分适合场景
must关键词全文搜索
filter状态、品牌、类目、价格、时间范围
should默认可选,可配合 minimum_should_match提升相关性、召回扩展
must_not不允许匹配排除下架、删除、黑名单数据

商品搜索典型组合查询

用户搜索“无线降噪耳机”,同时只看有货、品牌为 101 或 102、价格 100 到 300 元。

json
GET /product_search/_search
{
  "from": 0,
  "size": 20,
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "无线降噪耳机",
            "fields": ["productName^5", "subTitle^2", "tags"]
          }
        }
      ],
      "filter": [
        { "term": { "stockStatus": "IN_STOCK" } },
        { "terms": { "brandId": [101, 102] } },
        { "range": { "price": { "gte": 10000, "lte": 30000 } } }
      ],
      "should": [
        { "term": { "tags": { "value": "官方旗舰", "boost": 2 } } },
        { "range": { "saleCount": { "gte": 1000, "boost": 1.5 } } }
      ],
      "must_not": [
        { "term": { "tags": "隐藏商品" } }
      ]
    }
  },
  "sort": [
    { "_score": "desc" },
    { "saleCount": "desc" },
    { "id": "desc" }
  ],
  "highlight": {
    "fields": {
      "productName": {},
      "subTitle": {}
    }
  }
}

为什么品牌、价格、库存放 filter

  1. 它们是硬条件,不需要参与相关性评分。
  2. filter 语义清晰,排查时容易判断哪些条件影响召回。
  3. 相同过滤条件更容易被缓存。

为什么销量和旗舰标签放 should

  1. 它们不是必须条件。
  2. 命中后应该排得更靠前。
  3. 可以体现业务排序策略。

常见查询类型

查询用途商业示例
match全文搜索搜商品名、工单描述、日志 message
multi_match多字段全文搜索商品名、卖点、标签同时搜
term精确匹配状态、品牌、订单号
terms多值精确匹配多个品牌、多个类目
range范围查询价格、时间、耗时
prefix前缀匹配小规模输入提示
wildcard通配符小规模编码模糊匹配
match_phrase短语匹配要求词语顺序接近
exists字段存在过滤有图片、有地理位置的数据
geo_distance地理距离查询 5km 内门店

谨慎使用 wildcard,尤其是 *xxx 这种前缀通配,会导致查询成本很高。商业搜索里通常用分词、ngram、search_as_you_type 或搜索建议解决输入提示问题。

分页、排序和深分页

普通分页:

json
GET /product_search/_search
{
  "from": 0,
  "size": 20,
  "query": {
    "match": {
      "productName": "蓝牙耳机"
    }
  }
}

from + size 很大时会产生深分页问题。比如 from=10000&size=20,每个分片都可能要取很多候选结果,协调节点再合并丢弃前面的数据,成本很高。

深分页推荐使用 search_after

json
GET /product_search/_search
{
  "size": 20,
  "query": {
    "term": {
      "stockStatus": "IN_STOCK"
    }
  },
  "sort": [
    { "saleCount": "desc" },
    { "id": "desc" }
  ],
  "search_after": [5821, 1001]
}

注意:使用 search_after 必须提供稳定排序字段,通常会加一个唯一字段作为兜底排序,例如 id

聚合能做什么

聚合可以理解成“在搜索结果集上做统计”。商品列表页常见的品牌筛选、类目筛选、价格区间,就是聚合。

json
GET /product_search/_search
{
  "size": 0,
  "query": {
    "bool": {
      "must": [
        { "match": { "productName": "耳机" } }
      ],
      "filter": [
        { "term": { "stockStatus": "IN_STOCK" } }
      ]
    }
  },
  "aggs": {
    "brand_count": {
      "terms": {
        "field": "brandName",
        "size": 20
      }
    },
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 10000 },
          { "from": 10000, "to": 30000 },
          { "from": 30000 }
        ]
      }
    }
  }
}

size: 0 表示不返回具体文档,只返回聚合结果。聚合字段通常应该是 keyword、数值或日期类型,不建议直接对 text 字段聚合。

地理位置查询

附近门店、附近仓库、同城配送可以用 geo_point

json
GET /product_search/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "productName": "咖啡" } }
      ],
      "filter": [
        {
          "geo_distance": {
            "distance": "3km",
            "location": {
              "lat": 31.2304,
              "lon": 121.4737
            }
          }
        }
      ]
    }
  },
  "sort": [
    {
      "_geo_distance": {
        "location": {
          "lat": 31.2304,
          "lon": 121.4737
        },
        "order": "asc",
        "unit": "km"
      }
    }
  ]
}

经纬度适合距离计算,城市名适合行政区域过滤,两者不是一回事。

Explain 和 Profile

搜索结果不符合预期时,可以用 _explain 看某个文档为什么得分高或低:

json
GET /product_search/_explain/1001
{
  "query": {
    "match": {
      "productName": "无线降噪耳机"
    }
  }
}

查询慢时可以用 profile 看每个查询部分耗时:

json
GET /product_search/_search
{
  "profile": true,
  "query": {
    "match": {
      "productName": "蓝牙耳机"
    }
  }
}

profile 会增加查询开销,适合排查和测试环境,不建议长期在生产接口中开启。

修改 Mapping 后怎么办

ES 已有字段类型通常不能直接改。例如 productName 原来是 keyword,后来想改成 text,一般要新建索引并重建数据。

推荐流程:

mermaid
flowchart TD
    A["发现 Mapping 需要调整"] --> B["创建新索引 product_search_v2"]
    B --> C["从 MySQL 或旧索引重建数据"]
    C --> D["验证查询结果和聚合结果"]
    D --> E["切换别名 product_search"]
    E --> F["保留旧索引一段时间"]
    F --> G["确认无问题后删除旧索引"]

别名切换 Demo:

json
POST /_aliases
{
  "actions": [
    { "remove": { "index": "product_search_v1", "alias": "product_search" } },
    { "add": { "index": "product_search_v2", "alias": "product_search" } }
  ]
}

业务代码始终访问 product_search 这个别名,而不是直接写死 product_search_v1,这样重建索引时可以做到平滑切换。

代码 Demo:后台订单查询

订单查询通常精确条件更多,全文搜索只是辅助。

json
GET /order_search/_search
{
  "query": {
    "bool": {
      "must": [
        { "match": { "productName": "耳机" } }
      ],
      "filter": [
        { "term": { "orderStatus": "PAID" } },
        { "term": { "buyerMobileSuffix": "1823" } },
        {
          "range": {
            "createdAt": {
              "gte": "2026-07-01T00:00:00",
              "lte": "2026-07-01T23:59:59"
            }
          }
        }
      ]
    }
  },
  "sort": [
    { "createdAt": "desc" },
    { "orderId": "desc" }
  ]
}

订单检索要注意权限和数据范围。后台客服不能因为能搜 ES 就看到所有租户、所有店铺、所有用户的订单。

排查清单

现象优先检查
明明有数据却搜不到数据是否同步、字段类型、分词结果、match/term 是否用错
搜出来无关内容太多analyzer、同义词、minimum_should_match、字段权重
排序不符合预期_score、boost、sort 字段、是否用了 filter
聚合报错是否对 text 字段聚合,是否应该用 .keyword
查询很慢DSL、深分页、wildcard、聚合范围、分片数量、慢日志
修改 Mapping 不生效已有字段类型不能直接改,需要重建索引
商品下架还搜得到同步链路、删除策略、状态过滤

本章小结

Mapping 决定“数据怎么进索引”,Query DSL 决定“数据怎么被搜出来”。

商业搜索最重要的是先学会:

  1. 哪些字段要分词。
  2. 哪些字段要精确匹配。
  3. 哪些条件应该参与评分,哪些只是过滤。
  4. 聚合、排序、分页会对字段类型提出什么要求。
  5. Mapping 改错后为什么通常要重建索引。