ElasticsearchNotes

第 19 章:商品搜索实战

zjc 于 2026-01-19 发布

这是《Elasticsearch 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 商品搜索是 Elasticsearch 最经典的应用场景。它不只是“能搜到商品”,而是一个完整的搜索产品:要理解用户输入,召回合适商品,过滤不可售商品,提供筛选项,给出符合业务目标的排序,并能持续治理坏结果。

本章以一个电商商品搜索为例,完成从需求分析、文档设计、查询构造到上线治理的完整流程。

19.1 需求拆解

一个典型的商品搜索页面包含五个区域:

区域 内容 Elasticsearch 能力
搜索框 关键词、品牌、规格、错别字、同义词 Analyzer、multi_match、synonym
结果列表 商品卡片、价格、销量、标签 Query DSL、function_score、source filtering
筛选区 品牌、分类、价格、属性、库存 term、range、bool filter、aggregation
排序区 综合、销量、价格、上新 sort、function_score、业务评分
搜索运营 置顶、屏蔽、活动加权 运营文档、filter、衰减函数

在设计前先明确几个问题:

  1. 搜索的是 SKU 还是 SPU?两者更新频率和展示粒度不同。
  2. 是否要聚合库存、价格区间、销量分布?
  3. 是否要支持拼音、错别字、同义词和多语言?
  4. 不可售商品是物理删除,还是搜索时过滤?
  5. 排序目标是什么:成交、点击、毛利,还是用户体验?

没有这些答案,搜索系统很容易变成“接口能用,业务不满意”。

19.2 商品文档设计

本章选择以 SKU 为搜索主文档,并冗余 SPU 级信息。这样可以直接返回商品卡片,也能按品牌、类目、销量过滤。

PUT products-v3
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "refresh_interval": "1s",
    "analysis": {
      "filter": {
        "product_synonym": {
          "type": "synonym_graph",
          "synonyms": [
            "手机,智能手机,移动电话",
            "电脑,笔记本,laptop",
            "显视器,显示器"
          ]
        }
      },
      "analyzer": {
        "product_search_analyzer": {
          "type": "custom",
          "tokenizer": "ik_max_word",
          "filter": ["lowercase", "cjk_width", "product_synonym"]
        },
        "product_index_analyzer": {
          "type": "custom",
          "tokenizer": "ik_max_word",
          "filter": ["lowercase", "cjk_width"]
        }
      }
    }
  },
  "mappings": {
    "dynamic": "strict",
    "properties": {
      "product_id": { "type": "keyword" },
      "sku_id": { "type": "keyword" },
      "title": {
        "type": "text",
        "analyzer": "product_index_analyzer",
        "search_analyzer": "product_search_analyzer",
        "fields": {
          "keyword": { "type": "keyword", "ignore_above": 256 }
        }
      },
      "brand_id": { "type": "keyword" },
      "brand_name": {
        "type": "text",
        "analyzer": "product_index_analyzer",
        "fields": { "keyword": { "type": "keyword" } }
      },
      "category_id": { "type": "keyword" },
      "category_path": { "type": "keyword" },
      "price": { "type": "scaled_float", "scaling_factor": 100 },
      "original_price": { "type": "scaled_float", "scaling_factor": 100 },
      "sales_30d": { "type": "long" },
      "sales_7d": { "type": "long" },
      "comment_count": { "type": "long" },
      "good_comment_rate": { "type": "float" },
      "stock": { "type": "integer" },
      "status": { "type": "keyword" },
      "shop_id": { "type": "keyword" },
      "attrs": {
        "type": "nested",
        "properties": {
          "name": { "type": "keyword" },
          "value": { "type": "keyword" },
          "value_text": { "type": "text", "copy_to": "combined_text" }
        }
      },
      "tags": { "type": "keyword" },
      "keywords": { "type": "text", "analyzer": "product_index_analyzer" },
      "combined_text": { "type": "text", "analyzer": "product_index_analyzer" },
      "on_shelf_at": { "type": "date" },
      "updated_at": { "type": "date" },
      "weight": { "type": "double" },
      "embedding": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine"
      }
    }
  }
}

几个关键设计:

19.3 召回策略

搜索请求通常由三层召回组成:

用户输入
  |
  |-- 文本召回:title / brand / keywords / combined_text
  |-- 结构化召回:类目、品牌、属性、价格、库存
  |-- 向量召回:标题、类目、商品图片或详情的语义向量
  |
  +-- 合并去重 -> 业务过滤 -> 排序 -> 返回

基础查询可以先用 bool 表达:

GET products-v3/_search
{
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "轻薄笔记本",
            "fields": [
              "title^4",
              "keywords^2",
              "brand_name^1.5",
              "combined_text^1"
            ],
            "type": "best_fields",
            "operator": "and",
            "fuzziness": "AUTO"
          }
        }
      ],
      "filter": [
        { "term": { "status": "on_sale" } },
        { "range": { "stock": { "gt": 0 } } }
      ]
    }
  }
}

如果 operator: and 导致召回过少,可以降级为 or,并用 minimum_should_match 控制匹配强度:

{
  "multi_match": {
    "query": "轻薄笔记本 16G 独显",
    "fields": ["title^4", "keywords^2", "combined_text"],
    "type": "best_fields",
    "minimum_should_match": "2<75%"
  }
}

中文搜索常见问题是同义词放在索引阶段还是搜索阶段。推荐默认放在搜索阶段:

19.4 筛选与聚合

搜索页的筛选条件要同时支持“过滤当前结果”和“展示可选聚合值”。这两者并不总是同一个请求。

下面的查询展示:搜索“笔记本”,过滤品牌和价格,同时返回品牌、价格区间和属性聚合。

GET products-v3/_search
{
  "size": 20,
  "query": {
    "bool": {
      "must": [
        {
          "multi_match": {
            "query": "笔记本",
            "fields": ["title^4", "keywords^2", "combined_text"]
          }
        }
      ],
      "filter": [
        { "term": { "status": "on_sale" } },
        { "term": { "brand_id": "brand_1001" } },
        { "range": { "price": { "gte": 3000, "lte": 8000 } } }
      ]
    }
  },
  "aggs": {
    "category": {
      "terms": { "field": "category_id", "size": 20 }
    },
    "price_ranges": {
      "range": {
        "field": "price",
        "ranges": [
          { "to": 3000 },
          { "from": 3000, "to": 5000 },
          { "from": 5000, "to": 8000 },
          { "from": 8000 }
        ]
      }
    },
    "attrs": {
      "nested": { "path": "attrs" },
      "aggs": {
        "names": {
          "terms": { "field": "attrs.name", "size": 20 },
          "aggs": {
            "values": {
              "terms": { "field": "attrs.value", "size": 20 }
            }
          }
        }
      }
    }
  }
}

注意:如果希望筛选项统计“应用当前筛选前的搜索结果”,需要把聚合放到 post_filter 外侧,或者单独发送聚合请求。post_filter 只影响命中结果,不影响聚合。

GET products-v3/_search
{
  "size": 20,
  "query": {
    "multi_match": {
      "query": "笔记本",
      "fields": ["title^4", "keywords^2", "combined_text"]
    }
  },
  "aggs": {
    "brands": {
      "terms": { "field": "brand_id", "size": 20 }
    }
  },
  "post_filter": {
    "bool": {
      "filter": [
        { "term": { "brand_id": "brand_1001" } },
        { "range": { "price": { "gte": 3000, "lte": 8000 } } }
      ]
    }
  }
}

19.5 排序设计

综合排序通常不是纯 _score,而是相关性、销量、信誉、库存、上新时间和商业因子的组合。

GET products-v3/_search
{
  "query": {
    "function_score": {
      "query": {
        "bool": {
          "must": [
            {
              "multi_match": {
                "query": "笔记本",
                "fields": ["title^4", "keywords^2", "combined_text"]
              }
            }
          ],
          "filter": [
            { "term": { "status": "on_sale" } }
          ]
        }
      },
      "functions": [
        {
          "filter": { "term": { "tags": "hot" } },
          "weight": 1.3
        },
        {
          "gauss": {
            "on_shelf_at": {
              "origin": "now",
              "scale": "30d",
              "decay": 0.5
            }
          },
          "weight": 1.1
        },
        {
          "field_value_factor": {
            "field": "sales_30d",
            "modifier": "log1p",
            "factor": 1.2,
            "missing": 0
          }
        }
      ],
      "score_mode": "sum",
      "boost_mode": "multiply"
    }
  }
}

排序公式要可解释、可回放、可灰度。建议把每次搜索请求的排序版本、特征值和最终得分写入日志,方便排查“为什么这个商品排第一”。

19.6 搜索运营能力

搜索运营可以单独设计一个索引,例如 search_rules-v1

PUT search_rules-v1/_doc/rule_10001
{
  "rule_id": "rule_10001",
  "query": "手机",
  "type": "top",
  "sku_ids": ["sku_1", "sku_2", "sku_3"],
  "start_time": "2026-08-01T00:00:00Z",
  "end_time": "2026-08-31T23:59:59Z",
  "enabled": true
}

搜索服务先按关键词查询规则,再决定是否追加置顶或屏蔽列表:

置顶结果建议不要硬编码进主查询的 should 里。单独查询更容易控制位置、数量和过期时间。

19.7 无结果与坏结果治理

搜索质量治理至少要看四类指标:

指标 含义 常见原因
无结果率 搜索后结果为空的比例 分词错误、词表缺失、过滤条件过严
点击率 有结果但用户不点击 排序错误、标题劣化、价格不合适
换词率 用户快速改写查询 召回不相关、类目识别错误
首屏转化 首屏商品带来的下单比例 排序与业务目标不匹配

无结果降级链路:

精确策略
  -> 同义词扩展
  -> AND 改 OR
  -> 去掉低置信度过滤
  -> 类目或热销商品兜底
  -> 明确提示无结果并推荐搜索词

坏结果治理流程:

  1. 收集用户反馈、客服工单和搜索日志;
  2. 按 query 分组,统计曝光、点击、下单和换词;
  3. 复现请求,查看 _score、命中字段和分词结果;
  4. 判断是召回问题、过滤问题还是排序问题;
  5. 修改词表、Mapping、权重或规则;
  6. 用固定评测集回归,避免修复一个 query 损坏一批 query。

可以用 _explain 分析单条商品得分:

GET products-v3/_explain/sku_10001
{
  "query": {
    "match": { "title": "轻薄笔记本" }
  }
}

19.8 写入与更新链路

商品数据通常来自商品主库、库存服务、价格服务、评价服务和搜索运营系统。推荐使用消息队列解耦:

flowchart LR
    A[商品主库] --> K[Kafka]
    B[价格/库存] --> K
    C[评价统计] --> K
    D[运营平台] --> K
    K --> W[搜索同步服务]
    W --> E[Elasticsearch]
    W --> F[(本地检查点)]

同步策略:

19.9 上线清单

本章小结

商品搜索的核心不是一条复杂 DSL,而是一条完整链路:文档建模决定能搜什么,分词和召回决定能找到什么,过滤和聚合决定页面能用什么,排序决定用户先看到什么,监控和评测决定系统能不能持续变好。

思考题

  1. 为什么商品属性适合用 nested,而不是把属性拍平成对象数组?
  2. 如果某个关键词无结果率突然升高,你会按什么顺序排查?
  3. post_filter 和普通 filter 在搜索页中的区别是什么?
  4. 搜索阶段同义词和索引阶段同义词各适合什么场景?
  5. 如何证明一次排序优化是有效的,而不是只让某个 query 看起来更好?