
先说个我自己的经历。之前维护过一个商户后台订单明细快200万行的时候运营开始在后台用“商品名称”搜订单。当时底层是MySQL查询用的是LIKE %关键词%一次搜索下来接口稳定耗时四五秒运营截图来投诉说“后台没法用了”。我当时的第一个反应是加索引但排查之后发现这类LIKE查询的前置通配符让索引用不上全表扫描在所难免瓶颈基本无解。后来我把订单相关数据同步到Elasticsearch以下简称ES同样的关键词搜索从秒级降到了几十到几百毫秒。从那以后我对“搜索”这件事的理解就变成了数据库的LIKE只是兜底真正的全文搜索应该交给专门的检索引擎。这篇内容就是围绕“用ES做搜索”来写的核心是告诉你一个搜索需求从分析、建模到写查询语句的完整思路顺带把我踩过的坑也整理出来。文章面向两类人一是刚接触ES想在项目里实现搜索但不知道从哪入手的开发者二是已经在用ES但经常遇到“搜不到”“搜不准”“搜得慢”的同行。下面所有的命令我都尽量给出可直接复制的完整写法方便你对照着试。1. 搜索需求分析先用几句话搞清楚你到底在搜什么1.1 为什么数据库LIKE解决不了全文搜索很多人一开始会把“搜索”简单理解成“在字符串里找子串”于是顺手就写了WHERE name LIKE %耳机%。在数据量小的时候这个方法没问题一旦数据量上来问题就非常明显LIKE的前置通配符会导致索引失效走全表扫描每一行都要做子串匹配性能随数据量线性下降。数据库的LIKE是纯字符串匹配不理解“词”的含义。你搜“红色手机壳”它只能按固定字符串去匹配搜不到“红色的手机壳”这种词序不同但语义相同的记录。对中文来说问题更突出因为没有分词概念“苹果手机”和“手机苹果”在LIKE眼里是两回事但在人类语义里可能指同一类东西。ES解决这个问题靠的是“倒排索引”。核心思想是建立索引阶段把原始文本拆成一个个词分词记录每个词出现在哪些文档里搜索阶段直接查词对应的文档列表合并得到结果。你可以把它理解成书的“目录”或者“索引页”找关键词时不用从头翻书直接按页码翻到对应位置。1.2 搜索类型决定了你该用哪种查询方式ES的查询能力很丰富但能力多也意味着初学者容易选错。我在项目里习惯先把搜索需求拆成几个基础类型再对应到ES的查询语法上需求类型典型场景ES对应写法字段建议全文检索搜商品名称、文章标题、正文match / match_phrasetext配分词器精确匹配按订单号、状态、SKU查term / termskeyword范围筛选价格区间、时间区间range数值型、date结构过滤类目限定、上下架状态term bool filterkeyword前缀/通配输入提示、编码前缀prefix / wildcardkeyword慎用聚合统计统计销量、类目分布aggskeyword或开启fielddata真实业务场景很少是单一类型。比如“搜索在售的蓝牙耳机价格100到500元按销量排序”这里既有全文检索蓝牙耳机、又有过滤在售、还有范围价格区间和排序。所以我在动手前会先把需求列一遍再设计查询结构。1.3 不是所有搜索需求都值得上ES说句实在话如果你的数据量只有几万条功能要求也只是简单过滤MySQL完全够用没必要为了“用ES”而上ES。ES本身有集群、分片、内存这些运维成本小项目引入反而增加复杂度。我判断的标准很简单一是看数据量级有没有到百万级以上二是看查询复杂度是不是需要分词、模糊匹配、相关度排序、聚合分析三是看响应要求是不是要求亚秒级。只要命中其中两点再考虑上ES。否则先把数据库本身的查询优化做好更实际。2. 索引映射设计搜索能不能搜准一半在这张表上2.1 先建一个可直接跑起来的环境索引演示用的ES环境我在本机习惯用Docker或直接下发行包跑。Windows下启动方式是解压后运行bin\elasticsearch.bat浏览器访问http://localhost:9200能看到返回JSON就说明服务正常了。如果你还需要一个顺手的调试界面可以再启动Kibana它的Dev Tools面板是我调试DSL最常用的地方。下面是一个商品索引的完整创建示例包含了中文分词器配置PUT /products { settings: { number_of_shards: 1, number_of_replicas: 0, analysis: { analyzer: { ik_max_word_analyzer: { type: ik_max_word } } } }, mappings: { properties: { id: { type: keyword }, name: { type: text, analyzer: ik_max_word_analyzer }, category: { type: keyword }, brand: { type: keyword }, price: { type: double }, status: { type: keyword }, description: { type: text, analyzer: ik_max_word_analyzer }, created_at: { type: date } } } }单机演示环境我把分片数设为1、副本数设为0。如果你用默认配置建5个分片1个副本单节点集群会显示yellow状态虽然不影响查询但看着难受。分片的数量后面搜索性能会受影响小数据量下1个分片就够了多了反而增加查询合并的开销。2.2 text和keyword的选择逻辑决定你后面少踩多少坑这是ES里最容易被忽略但又最关键的基础选择之一。简单记一句话text会分词建立倒排索引支持全文检索。适合放文章、标题、描述这类需要“模糊匹配”的内容。keyword不会分词整体作为一整个词存储支持精确匹配、排序、聚合。适合放订单号、状态码、类目、品牌这些“枚举值”或“标识符”。很多新手把name和status都设成text结果做聚合统计或精确筛选时各种报错或者明明存了“on_sale”却搜不出来。我的经验是核心字段的类型在产品设计阶段就要定下来。上架状态用keyword或boolean类目用keyword价格用double或integer真正需要“搜内容”的字段才用text。如果你不确定某个字段到底要不要分词先问自己一个问题用户会用它做模糊搜索还是只用来做精确筛选前者用text后者用keyword。2.3 控制字段的索引行为不是所有字段都值得被搜索ES默认对每个字段都建索引但真实业务里有些字段根本不需要被搜索。一个常见的例子是商品内部备注只有管理员查看不参与前台搜索。这时候可以在映射里关掉索引internal_remark: { type: text, index: false }这样字段内容仍然会存在_source里、查询可以返回但不会建立倒排索引搜索时也搜不到它。好处是节省存储和索引时间同时避免一些“脏数据”被误搜出来。另一个比较实用的技巧是copy_to。比如商品搜索框需要同时匹配名称、描述、品牌、类目常规做法是multi_match指定多个字段字段一多查询语句就很啰嗦。更清爽的做法是在映射里加一个组合字段search_text: { type: text, analyzer: ik_max_word_analyzer }然后在各字段映射里配置name: { type: text, analyzer: ik_max_word_analyzer, copy_to: search_text }, description: { type: text, analyzer: ik_max_word_analyzer, copy_to: search_text }, brand: { type: keyword, copy_to: search_text }这样搜索时就只查一个search_text字段实现简单性能也更集中。copy_to字段虽然会参与索引但默认不会出现在_source返回结果里不会给你的接口响应增加额外负担。2.4 导入几条测试数据先把流程跑通没有数据谈查询都是空话。我用Bulk接口批量写入几条商品数据POST /products/_bulk?refreshtrue { index: { _id: 1 } } { id: P1001, name: 华为蓝牙耳机, category: 数码配件, brand: 华为, price: 299, status: on_sale, description: 主动降噪支持无线充电, created_at: 2024-01-01T10:00:00Z } { index: { _id: 2 } } { id: P1002, name: 苹果手机壳, category: 手机配件, brand: Apple, price: 129, status: on_sale, description: 硅胶材质防摔保护, created_at: 2024-01-02T10:00:00Z } { index: { _id: 3 } } { id: P1003, name: 小米充电宝, category: 数码配件, brand: 小米, price: 89, status: off_sale, description: 20000毫安时大容量, created_at: 2024-01-03T10:00:00Z } { index: { _id: 4 } } { id: P1004, name: 索尼蓝牙音箱, category: 影音设备, brand: 索尼, price: 699, status: on_sale, description: 360度环绕声IPX5防水, created_at: 2024-01-04T10:00:00Z }这里加了?refreshtrue意思是写入后立即刷新马上就可以搜到。ES默认的刷新间隔是1秒也就是说平时写入后最多等1秒数据才可见这个特性叫“近实时搜索”。线上业务不用改这个默认值但如果你在做数据校验、自动化测试可以显式加refreshtrue避免写完立刻查询落空。3. 核心查询DSL从简单匹配到组合查询的完整拆解3.1 match查询全文搜索的入门写法match是ES里最常用的全文查询它会先对查询词做分词再拿分出来的词去倒排索引里匹配。比如搜索“蓝牙耳机”GET /products/_search { query: { match: { name: 蓝牙耳机 } } }在IK分词器下“蓝牙耳机”可能会被分成“蓝牙”和“耳机”只要商品名称里包含其中任意一个词文档就会被命中等。这是一个“或”语义。如果你希望搜索词里的所有分词都必须出现可以加operator参数{ query: { match: { name: { query: 蓝牙耳机, operator: and } } } }minimum_should_match是另一个实用参数比如设置成80%意思是可以容忍少数分词未命中。这个参数在搜索长文本时调节相关性很有用但具体阈值需要根据你的数据反复试没有标准答案。3.2 match_phrase需要保持词序时用match只关心词是否出现不关心词的顺序。比如搜索“耳机蓝牙”match也能搜出“蓝牙耳机”的结果因为词都在。如果你希望结果里必须包含“蓝牙耳机”这个完整短语、且顺序挨着就用match_phrase{ query: { match_phrase: { name: 蓝牙耳机 } } }它和match的核心区别是增加了“词序”和“词间距”约束。比如搜索“耳机 蓝牙”结果就搜不到“蓝牙耳机”。知道了这个差异你就能在“宽松匹配”和“精准匹配”之间做选择。商品搜索场景里用户一般都希望宽松一些match更合适但如果是搜索固定型号、固定短语match_phrase会更准确。3.3 term与terms精确匹配的核心姿势term查询用于精确值匹配搜的是字段在倒排索引里的“完整词条”。它有两个容易踩坑的点下面这个示例先看正确用法{ query: { term: { status: on_sale } } }status是keyword类型所以term可以精准匹配到on_sale。多值匹配用terms相当于SQL的IN{ query: { terms: { category: [数码配件, 影音设备] } } }如果你对text字段直接跑term大概率会搜不到。原因后面我会专门讲。这里先记住term只适合keyword、数值、日期、布尔这类“不可再分”的字段。3.4 bool查询一个骨架搞定大部分业务搜索真实项目的搜索条件通常是组合式的。bool查询就是ES里的组合容器它有四种子句must必须满足且会参与相关度评分。must_not必须不满足不参与评分。should满足则加分用于提升相关度。filter必须满足但不参与评分并且结果可以被缓存。我一般在设计时把“纯过滤条件”都放进filter比如状态、类目、价格区间把“关键词匹配”放进must把一些“加分项”放进should。下面是一个比较典型的组合查询{ query: { bool: { must: [ { match: { name: 耳机 } } ], filter: [ { term: { status: on_sale } }, { range: { price: { gte: 100, lte: 500 } } } ], must_not: [ { term: { category: 二手 } } ], should: [ { match: { description: 降噪 } } ] } } }这个查询的含义是名称里包含“耳机”状态为在售价格100到500类目不是二手如果描述里还有“降噪”相关度会更高排序更靠前。filter里的条件不参与打分但过滤本身是全量生效的性能更好。有些资料建议把所有过滤条件都放filter而不是must这是有道理的因为打分计算有额外开销不加分的条件没必要参与算分。3.5 multi_match一个关键词搜多个字段用户在前台搜索框输入一个词你希望能同时匹配商品名称、类目、品牌、描述等多个字段。multi_match就是干这个的{ query: { multi_match: { query: 蓝牙, fields: [name^3, brand^2, description] } } }这里^3和^2是字段加权命中name的文档相关度分数会乘以3命中brand的乘以2命中description的按原分。加权的效果是让“标题命中”的文档排在“描述命中”的文档前面这非常符合一般搜索用户对相关性的预期。如果你之前用match只搜了名称字段很多包含关键词但名称里没有的优质商品永远排不上来multi_match能解决这类召回不足的问题。3.6 一个完整的搜索请求长什么样把上面的知识凑起来一个完整的前台商品搜索请求大概长这样GET /products/_search { query: { bool: { must: [ { multi_match: { query: 蓝牙, fields: [name^3, brand^2, description] } } ], filter: [ { term: { status: on_sale } }, { range: { price: { gte: 0, lte: 800 } } } ] } }, sort: [ { created_at: desc } ], from: 0, size: 20 }如果你是用Python开发对应的请求代码是from elasticsearch import Elasticsearch es Elasticsearch(http://localhost:9200) body { query: { bool: { must: [ {multi_match: {query: 蓝牙, fields: [name^3, brand^2, description]}} ], filter: [ {term: {status: on_sale}}, {range: {price: {gte: 0, lte: 800}}} ] } }, sort: [{created_at: desc}], from: 0, size: 20 } resp es.search(indexproducts, bodybody) for hit in resp[hits][hits]: print(hit[_source][name], hit[_score])提前把这些DSL结构想清楚后面接接口、做前端几乎不用改逻辑只要透传查询参数就能实现一个搜索页面的核心功能。4. 搜索体验的细节排序、分页、高亮和聚合4.1 相关度评分与排序为什么有的结果排在前面ES默认按_score倒序返回结果_score越大越靠前。这个分数受到词频词在文档中出现次数越多分数越高、逆文档频率越稀有的词权重越高、字段长度字段越短命中越珍贵等因子影响。查询时可以在结果里看到每个命中的_score但如果你想知道为什么是这个分数可以用explain参数GET /products/_search?explaintrue { query: { match: { name: 蓝牙耳机 } } }返回里会带算分的详细步骤比如“weight(name:蓝牙 in 1) [PerFieldSimilarity]”“tfNorm”等。我看这个主要是为了排查“为什么某个文档排这么后”的问题比如明明是精确命中但没有排第一那就可能是其他文档词频更高或者分词太碎。如果你要实现业务排序规则比如按销量、发布时间排序就不需要_score参与主排序直接在sort里指定字段{ query: { match: { name: 耳机 } }, sort: [ { price: asc }, { _score: desc } ] }这种写法表示价格优先价格相同再按相关度。如果排序里用了sortES默认不再为文档算分除非你显式加track_scores: true。这条细节很容易被忽略导致你要用分数时发现是null。4.2 分页的三种方式别再傻傻用深分页最常见的分页是from size对应SQL里的LIMIT offset, limit。但from越往后查询越慢因为每个分片都要先取from size条数据到内存里排序合并再丢掉前面的。线上如果用户翻到第1000页内存和延迟都是灾难。三种分页方案对比方案适用场景注意事项from size前几十页的数据不要翻太深默认最大10000scroll全量导出、后台批量处理生成快照不适合实时数据展示search_after业务系统“下一页”式加载排序字段必须唯一避免漏数据和重复search_after的写法是先查一页结果然后拿到最后一条数据的排序值作为下一页的查询条件GET /products/_search { query: { match_all: {} }, sort: [ { created_at: asc }, { _id: asc } ], size: 5, search_after: [2024-01-03T10:00:00Z, 3] }注意search_after数组里的值要和sort字段对应并且排序字段要包含一个唯一值比如_id否则相同排序值的数据会在翻页时重复或丢失。我用这个方案在后台列表做过几十万级的翻页实测比from size稳定太多。4.3 高亮让搜索结果告诉用户“为什么命中”搜索页面几乎都有的一个功能是关键词高亮。ES的highlight参数可以自动提取匹配片段并加标签{ query: { match: { name: 蓝牙 } }, highlight: { fields: { name: {}, description: {} }, pre_tags: [em], post_tags: [/em] } }返回结果里会多出一个highlight字段highlight: { name: [华为em蓝牙/em耳机] }前端拿到这个字段后直接通过v-html、dangerouslySetInnerHTML之类的方式渲染就能看到红色或高亮的效果。可以灵活设置fragment_size限制片段长度避免描述很长时返回太多内容。4.4 聚合搜索框之外的统计能力搜索和聚合经常一起用。比如用户在搜索“耳机”的同时页面侧边栏要展示每个类目下的商品数量这就是一个terms聚合GET /products/_search { query: { match: { name: 耳机 } }, aggs: { category_count: { terms: { field: category } } } }聚合结果在返回的aggregations里aggregations: { category_count: { buckets: [ { key: 数码配件, doc_count: 3 }, { key: 影音设备, doc_count: 1 } ] } }这里有个容易踩的小坑如果category字段是text类型聚合会报错或者结果不符合预期。因为text会分词聚合应该基于原始值所以要求字段是keyword类型。通用做法是映射里给字段同时配置text用于搜索和keyword子字段用于聚合/排序。这类问题在深入搜索功能时几乎一定会碰到。5. 搜索性能与排查我在生产环境踩过的那些坑5.1 用term查text字段为什么查不到这是被新手问得最多的问题“我明明存了这个值为什么term查不到”比如商品名称里有个值叫“蓝牙耳机”term查询{ query: { term: { name: 蓝牙耳机 } } }结果可能为空。原因是name是text字段索引时已经被IK分词器拆分成了“蓝牙”“耳机”等多个词条倒排索引里并没有“蓝牙耳机”这个完整词条只有拆开后的词。term查询是精确匹配词条不去分词自然匹配不到“蓝牙耳机”整体。验证方法很简单用_analyze接口看看这个文本到底被拆成了什么POST /products/_analyze { field: name, text: 华为蓝牙耳机 }你会看到分词结果。这就是ES里最核心的一个认知差异索引端处理过的数据和存进去的原文不是一回事。也别把term改成match就完事得先想清楚这个字段到底要不要分词。如果要做精确匹配字段类型就应该是keyword或者用name.keyword这种子字段来查询。5.2 wildcard和regexp别乱用前导通配符是性能杀手wildcard查询可以实现“包含某段内容”的模糊搜索比如{ query: { wildcard: { name: *蓝牙* } } }看起来和LIKE很像但代价非常大。带前导*的wildcard查询需要遍历索引里的每一个词条来逐个比对本质上就是全量扫描数据量大时直接拖垮集群。regexp有类似的问题。我在一个日志搜索项目里踩过这个坑某个接口用了wildcard搜日志数据量一到千万级单次查询耗时从几十毫秒变成十几秒直接把ES集群CPU打满。如果业务确实需要这种模糊搜索能力比如“输入提示”“错字容忍”更靠谱的方案是上ngram分词器把输入拆成多个字符片段建索引配合match查询既能实现包含搜索又能走倒排索引。简单说别让查询去“扫描”要让索引“存储所有可能”。5.3 中文搜索不准很多是分词器的锅ES自带的standard分词器对英文友好英文按空格和标点切分就能得到词但中文用standard会把整句话按单个字拆成一个个“字”。比如“手机壳”会变成“手”“机”“壳”搜索时匹配很不准相关度也很差。解决方式是使用中文分词器国内用得最多的就是IK分词器。安装后把analyzer配成ik_max_word它会尽量切出更多更细的词“手机壳”会被切为“手机壳”“手机”“壳”等召回率高ik_smart切分更保守适合搜索时用。我在索引创建里同时配置了ik_max_word作为索引分析器和ik_smart作为搜索分析器这样索引阶段保证召回搜索阶段减少噪音。一个更简单的做法是直接用ik_max_word两头用很多项目也都这么跑效果够用。没有IK环境时中文搜索效果会明显变差这一点在评估ES中文搜索效果时必须注意。不是ES不行是分词器没配好。5.4 慢查询定位从慢日志到Kibana的调优思路搜索慢的时候我一般按下面这个顺序排查先把慢日志打开看看慢在哪类请求上PUT /products/_settings { index.search.slowlog.threshold.query.info: 1s }在Kibana的Dev Tools里打开Search Profiler跑一遍具体查询能看到每个查询子句耗时占比。有一次我定位到一个接口慢是因为should子句过多每个文档都要算一堆分数优化方式是把其中不需要加分的条件挪到filter耗时直接降了一个数量级。检查核心查询是否用了前导通配符、是否存在深分页、分片数是否过少或过多。单机数据量小时分片数建议控制在1到3个分片太多会导致一个查询在多个分片上执行再合并反而更慢。检查映射设计是否有大量无意义的字段也建了索引是否该用keyword却用了text是否在聚合查询中对text字段做操作。很多性能问题不是查询本身写得差而是映射阶段埋下的雷。调优这件事没有银弹核心思路是“让ES少干活”。少算分、少扫描、少翻页、少建无用的索引性能自然就上去了。结尾一点个人建议我在实际项目里最大的体会是用ES做搜索学习曲线最陡的部分不是Query DSL的语法而是“先想清楚每个字段的用途”。很多后来让人头疼的“搜不到”“聚合报错”“查询变慢”源头都在一开始图省事、直接导入了动态映射、没有设计字段类型。等你发现字段类型错了数据量已经很大重建索引就是一次伤筋动骨的操作。所以最后分享一个自己的小习惯每写一条查询DSL先在Kibana的Dev Tools里用真实数据跑一遍重点看两样东西——返回结果是否符合预期以及_explain或Search Profiler里有没有异常耗时。确认没问题再写到代码里。这个习惯帮我避免了很多“测试环境能查、上生产就翻车”的尴尬场面。ES搜索这个方向并不难只要把前面的基础打牢后面越用越顺手。