这是《Elasticsearch 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 商品搜索是 Elasticsearch 最经典的应用场景。它不只是“能搜到商品”,而是一个完整的搜索产品:要理解用户输入,召回合适商品,过滤不可售商品,提供筛选项,给出符合业务目标的排序,并能持续治理坏结果。
本章以一个电商商品搜索为例,完成从需求分析、文档设计、查询构造到上线治理的完整流程。
19.1 需求拆解
一个典型的商品搜索页面包含五个区域:
| 区域 | 内容 | Elasticsearch 能力 |
|---|---|---|
| 搜索框 | 关键词、品牌、规格、错别字、同义词 | Analyzer、multi_match、synonym |
| 结果列表 | 商品卡片、价格、销量、标签 | Query DSL、function_score、source filtering |
| 筛选区 | 品牌、分类、价格、属性、库存 | term、range、bool filter、aggregation |
| 排序区 | 综合、销量、价格、上新 | sort、function_score、业务评分 |
| 搜索运营 | 置顶、屏蔽、活动加权 | 运营文档、filter、衰减函数 |
在设计前先明确几个问题:
- 搜索的是 SKU 还是 SPU?两者更新频率和展示粒度不同。
- 是否要聚合库存、价格区间、销量分布?
- 是否要支持拼音、错别字、同义词和多语言?
- 不可售商品是物理删除,还是搜索时过滤?
- 排序目标是什么:成交、点击、毛利,还是用户体验?
没有这些答案,搜索系统很容易变成“接口能用,业务不满意”。
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"
}
}
}
}
几个关键设计:
title、keywords、combined_text分工不同:标题强相关,运营词负责补召回,组合字段兜底。attrs使用nested,避免“颜色=红色 AND 尺寸=XL”这类条件串到同一商品的其他 SKU 上。- 价格使用
scaled_float,避免浮点展示和过滤边界不稳定。 status保留商品生命周期,不可售商品通常过滤而不是立即删除。embedding是可选能力,适合在关键词搜索稳定后再引入。
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
}
搜索服务先按关键词查询规则,再决定是否追加置顶或屏蔽列表:
- 置顶:单独查一批运营商品,拼在自然结果前;
- 沉底:用
must_not或降权处理; - 换词:用户搜索“番茄”时补充“西红柿”;
- 类目定向:把词路由到某个类目,减少错误召回;
- 活动加权:只影响排序,不影响可见性,避免搜索结果突兀。
置顶结果建议不要硬编码进主查询的 should 里。单独查询更容易控制位置、数量和过期时间。
19.7 无结果与坏结果治理
搜索质量治理至少要看四类指标:
| 指标 | 含义 | 常见原因 |
|---|---|---|
| 无结果率 | 搜索后结果为空的比例 | 分词错误、词表缺失、过滤条件过严 |
| 点击率 | 有结果但用户不点击 | 排序错误、标题劣化、价格不合适 |
| 换词率 | 用户快速改写查询 | 召回不相关、类目识别错误 |
| 首屏转化 | 首屏商品带来的下单比例 | 排序与业务目标不匹配 |
无结果降级链路:
精确策略
-> 同义词扩展
-> AND 改 OR
-> 去掉低置信度过滤
-> 类目或热销商品兜底
-> 明确提示无结果并推荐搜索词
坏结果治理流程:
- 收集用户反馈、客服工单和搜索日志;
- 按 query 分组,统计曝光、点击、下单和换词;
- 复现请求,查看
_score、命中字段和分词结果; - 判断是召回问题、过滤问题还是排序问题;
- 修改词表、Mapping、权重或规则;
- 用固定评测集回归,避免修复一个 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[(本地检查点)]
同步策略:
- 商品主信息变更:全量替换 SKU 文档;
- 库存高频变更:合并更新,必要时只在 0 与非 0 之间变化时同步;
- 销量统计:分钟级聚合后更新,避免每个订单都打一次 ES;
- 下架商品:保留文档并更新
status,便于审计和快速重新上架; - 词表和 Mapping 变更:新索引重建,通过别名切换。
19.9 上线清单
- 明确 SKU/SPU 建模口径,避免结果重复或遗漏;
status、库存、类目、品牌等过滤条件必须有默认值;- 标题、运营词、组合字段的 Analyzer 已经过真实 query 验证;
- 同义词词表有版本管理和回滚机制;
- 聚合字段关闭
doc_values的情况已确认; - 不可售商品不会进入默认结果;
- 搜索接口有超时、降级和限流;
- 无结果率、慢查询率、空聚合率有监控;
- 排序版本可灰度、可回滚;
- 建立固定评测集,任何相关性改动都跑回归。
本章小结
商品搜索的核心不是一条复杂 DSL,而是一条完整链路:文档建模决定能搜什么,分词和召回决定能找到什么,过滤和聚合决定页面能用什么,排序决定用户先看到什么,监控和评测决定系统能不能持续变好。
思考题
- 为什么商品属性适合用
nested,而不是把属性拍平成对象数组? - 如果某个关键词无结果率突然升高,你会按什么顺序排查?
post_filter和普通filter在搜索页中的区别是什么?- 搜索阶段同义词和索引阶段同义词各适合什么场景?
- 如何证明一次排序优化是有效的,而不是只让某个 query 看起来更好?