
接手这个电商搜索项目的时候,我最头疼的不是推荐算法,不是高并发,而是最基础的一个环节:用户输入水果手机能不能搜到iPhone 15 Pro Max?输入HUAWEI Mate 60能不能匹配到华为Mate 60 Pro 5G手机?这些问题全部压在一个核心组件上——分词。当时线上已经有千万级商品数据和亿级搜索量,Elasticsearch集群跑了快一年,搜索无结果率一直徘徊在15%左右,用户体验反馈最多的就是搜不到搜不准。后来我做了一轮完整的分词优化和查询链路改造,把无结果率降到3%以内,搜索平均响应时间稳定在30毫秒以内。这篇就把整个过程拆开讲,包括我走过的弯路、比过的方案、以及千万级数据下不得不考虑的索引和查询成本,给做Java电商搜索的朋友一条可以直接抄作业的路径。1. 先算清电商搜索的账:默认分词器为什么撑不起千万级商品很多团队把ES当作一个带搜索功能的数据库来用,索引建好、数据灌进去、前端一查就完事。但电商搜索和普通的关键词检索完全是两码事。在动手调分词之前,得先把商品标题、用户行为、以及搜索质量这几个账算清楚。1.1 商品标题的本质是品牌产品属性的混合体随便看一个电商平台的商品标题:华为 HUAWEI Mate 60 Pro 手机 5G 鸿蒙系统 卫星通信 昆仑玻璃 12G256G。这种标题在搜索引擎眼里不是一个句子,而是几类信息的拼接:品牌词(华为/HUAWEI)、产品线词(Mate 60/Pro)、品类词(手机)、属性词(5G/鸿蒙/卫星通信)、规格词(12G256G)。用户搜索时可能只带其中一部分,比如华为手机Mate 60华为5G手机,或者干脆搜遥遥领先这种网络梗词。如果索引侧的分词粒度不对,后面无论查询怎么写都是白搭。默认的standard分词器对英文和数字是按空格和标点切分的,对中文则退化成逐字切分。你猜华为手机会被切成什么?大概率是华为手机四个单字,或者好一点按Unicode分词切成华为手机。搜索Mate 60的时候,标题里的Mate 60如果没被完整保留,就会被拆成mate和60两个token,查询侧再被拆一次,匹配就变得非常随机。1.2 用户怎么搜:拼音、简称、错别字、中英混输我统计过线上搜索日志,用户的输入习惯比想象中复杂得多。以下是抽样分析的真实情况:用户输入类型示例占比完整商品词华为Mate 60 Pro约35%品类属性词5G手机大屏智能手表约28%拼音输入huaweimate60约18%简称/网络词遥遥领先菊厂手机约12%错别字/同音字苹果手机写成苹菓手机约7%这意味着分词优化不能只解决正确切分,还得解决用户搜的词根不在标题里的情况。比如搜huawei得能匹配华为,搜菊厂也得能匹配到华为相关商品——后面两个需求分别靠拼音分词器和同义词词典解决。1.3 有了ES还不够,搜索质量要能量化做分词优化之前,先建立一套搜索质量指标,不然你改完了也不知道自己是变好了还是变差了。我用的核心指标是这三项:无结果率:返回0条结果的搜索请求占比。这个必须压到5%以下,最好3%以内。点击率(CTR):搜索结果中前10条被点击的比率,能间接反映排序和召回的相关性。平均响应时间:这个不用多说,P99最好控制在80毫秒以内。另外强烈建议把每天的搜索词记录下来,按无结果率排序,拉出Top 100无结果词。在排查阶段,这些词就是你的需求清单,每一项都能对应到一个分词或同义词配置。2. 集群与索引规划:分词方案没定之前,分片和mapping先别拍脑袋这一节我放在分词前面讲,是因为很多人吃了顺序的亏:先按默认配置建好索引、同步了千万级数据,再回来改分词,结果发现mapping里的analyzer改不了,只能reindex。ES的设计里,分词器是在索引创建时写进field mapping的,后面想改只能重建索引。所以集群容量、分片数、字段类型、分词方案,必须在一开始就一起规划。2.1 千万级数据下的容量估算经验值下面是我实际用下来的估算公式,不同业务字段不同,但量级差不多:原始数据量:1000万条商品,平均每个商品文档约2KB,不算图片,纯JSON约20GB。索引膨胀系数:加上倒排索引、doc values、_source,实际占比通常是原始数据的1.3到1.8倍。如果上了NGram、拼音分词,膨胀系数会飙到2.5到3倍,后面细讲。内存预算:ES的JVM堆建议给到32GB封顶(超过这个值不如多分节点),堆内要给fielddata和查询缓存留空间。我们的经验是数据总量和堆内存保持10:1左右,20GB索引量配2GB以上堆内存比较稳,但这只是底线,集群JVM堆至少16GB起步。磁盘:机械硬盘就算了,SSD是底线,而且建议预留总数据量的30%作为merge缓冲。2.2 分片数设计:不是越多越好分片是ES分布式的最小单元,分片数一旦定下来基本很难改。很多人分片数是拍脑袋定的,或者照着网上的主分片数节点数来搞,但这不太适合电商搜索这种读多写少的场景。我的经验公式是:单分片数据量控制在20GB到40GB之间,分片总数别超过节点数乘以3(否则每个分片太小,查询聚合时协调开销反而变大)。举例:1000万商品、索引膨胀后约40GB,那就给2个主分片,每个20GB,再配1个副本,总共4个分片。如果后期数据翻倍,得提前规划到4个主分片。有个易错的点:主分片数由索引创建时决定,扩副本容易、扩主分片难。2.3 mapping字段类型:先决定哪些字段参与搜索、哪些只做展示电商商品文档常见字段我建议这么定:字段类型理由productIdlong/keyword过滤、聚合、业务关联titletext keyword子字段text走分词检索,keyword子字段用于精确匹配和排序brandkeyword text品牌词建议keyword精确匹配,同时加一个text字段做分词召回categoryPathkeyword类目树用keyword,避免被分词拆散priceinteger/float范围过滤,不需要分词saleCountinteger排序用,不需要分析tagskeyword后台配置的标签,精确匹配这里有个常见误区:有人把title只配成keyword,搜索时用match_phrase_prefix硬怼,导致华为手机必须完全连续出现才搜得到。也有人把brand配成text用了默认分词器,结果搜华为能出,搜HUAWEI就出不来。正确的做法是品牌字段用keyword保存标准中文名,同时单独建一个brand_alias字段走同义词和拼音分词,这样两端兼顾。2.4 分词方案直接决定mapping怎么建,顺序不能反我的习惯是:先拉出搜索日志,统计Top 200的热搜词,再用这些词逐个测试各种分词器组合,确定该配套哪些分析器,然后才写mapping。这样一来,哪些字段挂IK、哪些字段挂拼音、哪些字段加同义词,都是一开始就定好的,后面不用反复重建。一个黄金法则:一个字段的分析链在创建索引后不可修改,但你可以通过给同一个字段配多个子字段来组合多种分词方式。比如title字段可以这样设计:title直接走标准检索引擎分析,title.pinyin走拼音分词供拼音搜索,title.ngram走NGram兜底模糊匹配。这也是我推荐的做法——用空间换灵活性,但每个子字段都会增加索引体积,千万级数据上要克制,够用就行。3. 分词优化四板斧:自定义词典、同义词、拼音方案与NGram兜底这一节是全文的核心。我按投入产出比从高到低排序,每一板斧都给出了能落地的配置和实际效果,你可以按需裁剪。3.1 第一板斧:IK分词器 自定义词典,解决品牌词、型号词被拆飞默认分词器对中文几乎不可用,所以第一步就是换成IK分词器。IK有两种粒度:ik_max_word(最大切分,尽可能切出更多词)和ik_smart(智能切分,只保留最合理的粗粒度)。搜索场景下,索引用ik_max_word、查询用ik_smart是经典组合,前者保证召回率,后者保证精准度。但光装IK不够,核心是自定义词典。IK自带的主词典里面没有电商领域的品牌词和型号词,比如华为Mate这些它认识,但麒麟鸿蒙昆仑玻璃星闪这些新词它根本不认识。不维护词典的话,ik_max_word会把华为Mate60Pro拆成华为/Mate/60/Pro或者干脆拆成单字,查询时用户搜Mate60就没有完全匹配的term。自定义词典的做法分两步。第一步,在IK的配置目录下新建一个电商词库文件,比如config/custom/mall.dic,每行一个词:华为Mate60Pro 鸿蒙系统 昆仑玻璃 星闪 遥遥领先 纯棉 雪纺 连衣裙第二步,在IKAnalyzer.cfg.xml里把词典挂载进去:?xml version1.0 encodingUTF-8? !DOCTYPE properties SYSTEM http://java.sun.com/dtd/properties.dtd properties commentIK Analyzer 扩展配置/comment entry keyext_dictcustom/mall.dic/entry entry keyext_stopwordscustom/mall_stopword.dic/entry /properties我强烈建议把电商行业里的品牌、产品线、通用属性词都维护到这个词典里,而且要建立更新机制。我们的做法是每周从搜索日志里拉Top无结果词,人工过一遍后追加进词典,然后走索引热更新。IK本身支持词典热更新,只要配置了远程词库,比如从HTTP或MongoDB拉取,修改后不需要重启节点。踩过的一个坑:词典里词条越加越多,会把ik_max_word的切分结果撑爆,导致索引体积暴涨。后来我限制每个商品标题的索引分词token数不超过80个,超过部分丢弃,索引体积降了20%左右,召回率几乎没有损失,因为标题前40个字已经包含了核心信息。3.2 第二板斧:同义词搜索,让苹果手机也能找到iPhone电商搜索里有一个经典需求:用户搜苹果手机,你希望他搜到iPhone;用户搜连衣裙,你最好也召回长裙连身裙。这靠分词器解决不了,因为分词器只管切分,不管词义等价。这个需求用同义词过滤器实现。ES自带的synonym token filter配置很简单,但有几个关键细节。以苹果手机为例,同义词有两种写法:苹果手机,iphone,苹果 苹果手机,iphone带箭头的是不可逆同义词:左边所有词都会映射到右边两个词,意思是用户搜苹果手机和搜iphone苹果都会建索引或查询到苹果手机和iphone这两个标准形式。不带箭头的写法是可逆同义词:所有词互相等价,索引时把左边全部展开。我推荐在查询侧配置同义词,尽量不要在索引侧配。原因很简单:索引侧配同义词,词库改一次就要全量重建索引;查询侧配同义词,词库更新即时生效。代价是查询时多了同义词展开的计算量,但在千万级数据下,这个代价远小于重建索引的代价。实际配置示例,放在analyzer的filter里:{ filter: { mall_synonym: { type: synonym, synonyms_path: analysis/mall_synonym.txt, updateable: true } } }注意上面用了updateable: true,我用的ES版本支持热更新同义词词库,但默认是关闭的,很多人在这一步踩坑——改了词库文件发现不生效,要手动reload或重启节点。热更新还有一个细节:同义词的term都要求是底层分词器输出的小写规范化形式,所以原词和同义词如果包含大写英文,统一转小写,否则匹配不上。用同义词还要想清楚副作用:过度配置同义词会明显降低相关性。比如苹果如果配成苹果水果apple,用户搜苹果手机壳会召回水果手机壳这种牛头不对马嘴的结果。所以电商同义词表一定要按类目隔离,比如3C数码类目配苹果iPhone,服饰类目配苹果就保持原义,跨类目的模糊同义词宁缺毋滥。3.3 第三板斧:拼音搜索,应对HUAWEIhuawei mate60这类输入用户输入拼音的比例接近两成,很多是年长用户不习惯切换键盘,也有不少是手机端输入法自动带出来的英文联想。要让huawei匹配华为,索引侧或者查询侧都要有拼音映射。实现方案是给需要支持拼音搜索的字段加一个子字段,使用拼音分析器。我这里用的是基于pinyin插件的分析器,配置如下:{ analysis: { analyzer: { mall_pinyin_analyzer: { type: custom, tokenizer: keyword, filter: [lowercase, pinyin_first_letter, pinyin_full] } }, filter: { pinyin_first_letter: { type: pinyin, keep_first_letter: true, keep_full_pinyin: false, keep_joined_full_pinyin: true, keep_original: true, lowercase: true }, pinyin_full: { type: pinyin, keep_first_letter: false, keep_full_pinyin: true, keep_joined_full_pinyin: false, keep_original: true } } } }这个配置会同时保留全拼(huawei)和首字母缩写(hw),索引体积会多出一些,但能覆盖hw搜索华为的场景。拼音分词器我用的时候有一个非常坑的地方:它会对所有中文字段都做拼音转换,如果字段里混有英文或数字,比如Mate60Pro,拼音过滤器会把mate又当成拼音输出一次的,导致term里出现重复和杂音。解决方案是拼音子字段只建立在title、brand上,不要全字段覆写。再说索引和查询的粒度:索引侧用keep_original full_pinyin保留拼音和原词,查询侧直接对用户输入做同样的分析,这样搜huawei和搜华为都会命中同一个term。如果用户输入的是华为而索引里只有拼音,那就反过来搜不到,所以一定要保留原词,两边都要能互查。3.4 第四板斧:NGram兜底,解决错别字、变体、边界切分的最后一公里前三板斧覆盖了大部分正常搜索,但总有漏网之鱼:用户搜苹菓手机(错别字)、搜迪奥但商品标题里只有Dior(翻译变体)、搜连衣裙雪纺但标题是雪纺连衣裙词序颠倒。同义词和拼音解决不了这些,NGram兜底可以。NGram的原理是把文本按字符切成N-gram,比如连衣裙切2-gram会得到连衣衣裙,切3-gram会得到连衣裙。这样搜裙或衣裙都能命中。下面是常用的edge_ngram和普通ngram配置,分别用于搜索前缀匹配和内部模糊匹配:{ analysis: { analyzer: { mall_ngram_analyzer: { type: custom, tokenizer: standard, filter: [lowercase, mall_ngram_filter] } }, filter: { mall_ngram_filter: { type: ngram, min_gram: 2, max_gram: 3 } } } }NGram是双刃剑:召回率上去了,索引体积也涨得飞快。一个100字的标题,如果用2-3gram,生成的term数比ik_max_word多一个数量级。在千万级数据上,这个体积膨胀是不可接受的,所以NGram只能作为一种低频兜底,不能全字段开。我最后的取舍是:专门开一个搜索词兜底字段,索引所有商品的搜索热词和别名(这些词从搜索日志里挖出来,每个商品挂几个),这个字段用NGram分词,并且仅在无结果或低结果时二次查询。这样既覆盖了错别字、变体,又把体积控制在可控范围——兜底字段数据量只有核心字段的十分之一。实际上NGram这块我用的比预想少得多,更多时候是同义词顶上去的,但它作为保底方案,在无结果率降到最低点的后期作用很明显。4. 改分词器等于重建索引:千万级数据的平滑过渡与reindex实战分词优化做完了,接下来要落地到线上。前面说过,mapping和analyzer一旦创建便不可改动,所以这一步没得选:建新索引、迁数据、切别名。如果跳过这一步直接把线上索引的analyzer改了再强制刷新,大概率会遇到字段类型冲突或analyzer not found的报错。4.1 双索引Alias切换的零停机方案我这里采用的是经典的双索引零停机迁移方案:创建一个带新分词配置的索引,比如product_search_v2。用reindex把老索引product_search_v1的数据全量迁过去。校验新索引的数据量、搜索效果、响应时间。把业务方查询的索引别名product_search从v1切到v2。Java服务端不需要改任何代码,因为代码里写的都是别名product_search,切换只发生在ES层。这里有一个细节:索引别名一次只能指向一个索引,切到v2后v1就暂时处于无别名状态,但不删除,留着当回滚用。别名的创建和切换指令:POST /_aliases { actions: [ { remove: { index: product_search_v1, alias: product_search } }, { add: { index: product_search_v2, alias: product_search } } ] }4.2 reindex的性能调优:千万级数据怎么在窗口期内跑完reindex看起来简单,但直接裸跑会非常慢。第一步优化是调整reindex的批量大小和刷新策略:POST _reindex?wait_for_completionfalsescroll_size5000 { source: { index: product_search_v1 }, dest: { index: product_search_v2, refresh: false } }在reindex期间,我把v2的副本数临时设为0,refresh_interval设为-1(禁用刷新),reindex完成后再恢复。这个组合拳能让写入性能提升大概3到5倍,因为省去了副本复制和refresh的IO开销。另外,千万级数据建议用sliced分片reindex,将任务切分成多个子任务并行执行:POST _reindex?wait_for_completionfalseslices4 { source: { index: product_search_v1 }, dest: { index: product_search_v2 } }slices数量一般建议等于主分片数或节点CPU核数,不是越大越好,因为每个slice都对应一个scroll,太大会把协调节点的内存打满。4.3 新索引的校验清单切换之前,我建议按下面这个清单逐项核对,任何一项不过都不能切:文档数是否一致:reindex完成后的_count和源索引的_count对得上。无结果率抽样:用线上最近一周的真实搜索词,在新老索引分别跑一遍,对比召回数和排序前10的结果。响应时间:新索引因为加了不少分词子字段,查询可能变慢,先用压测工具或直接对线上20%流量做灰度验证。聚合与排序:确认keyword字段的doc_values正常,排序价格、销量的查询没报错。这个清单我基本每次都会玩一遍,因为reindex后最常出现的问题就是field类型解析失败,或者doc_values没开导致排序报错,这些问题在query阶段才暴露,排查起来很费劲。4.4 灰度切流的实操建议如果对全量切换没有百分之百的把握,可以做灰度:给别名同时指向两个索引,利用ES别名路由功能,按用户ID或随机百分比把部分流量路由到v2。ES别名本身不支持分配流量比例,但可以通过业务侧加一层路由:Java查询代码里根据搜索词hash值决定走v1还是v2。具体做法是:String indexName product_search_v1; if (Math.abs(query.hashCode() % 100) 10) { indexName product_search_v2; } SearchRequest request new SearchRequest(indexName);灰度1到3天,看新索引的无结果率、点击率、响应时间,确认稳定后再全量切换。这个灰度方案虽然老土,但胜在可控、可回滚。5. 查询链路优化:分词效果落地到千万级检索的最后一公里分词优化只解决能不能召回的问题,千万级数据下查得快、排得准是另一套功夫。我把它分成查询构造、缓存策略、聚合控制三个层面。5.1 bool query minimum_should_match:别让召回变得又大又乱很多搜索查询写成一长串multi_match或用match_all兜底,在千万级数据下非常危险。我推荐用bool query把所有条件陈列清楚:must负责强制条件(类目、上下架状态),should负责相关度加分项(品牌、属性、搜索词),filter负责范围过滤(价格区间、库存状态)。minimum_should_match用来控制至少要命中的should权重,避免用户搜华为手机时,系统只匹配到手机就返回大量非华为商品。举个实际查询示例:POST /product_search/_search { size: 20, query: { bool: { must: [ { term: { status: 1 } } ], should: [ { match: { title: { query: 华为 Mate 60, minimum_should_match: 70% } } }, { term: { brand: 华为 } }, { match: { title.pinyin: huawei mate 60 } } ], minimum_should_match: 1, filter: [ { range: { price: { gte: 1000, lte: 10000 } } }, { term: { categoryPath: 手机 } } ] } } }这里我把华为Mate 60的match查询放在title字段,minimum_should_match设成70%,保证能召回同时命中华为和mate的文档;品牌字段用term精确匹配作为高权重的should项。再用filter把价格和类目筛掉,filter走缓存,对性能很友好。有个易错点:should和must混用的时候,minimum_should_match默认是0或1取决于上下文,很多人在这里把相关度调废了——一搜华为手机全返回手机配件,因为must只有状态过滤,而should没有强制约束。经验是把minimum_should_match在查询侧设成1,防止召回太发散。5.2 把热门查询交给Redis,把冷查询交给ES千万级数据下,单纯靠ES扛所有查询,即使100毫秒内完成,高峰期也会把CPU和IO打满。我加上了一层Redis热词缓存,命中率在40%左右,直接省掉这40%的ES压力。方案很简单:搜索结果的前3页(比如每页20条)序列化成JSON缓存到Redis,key是search:query:md5,过期时间5到15分钟。商品数据变动频繁的类目,过期时间设短一点;价格、库存敏感的,缓存策略要谨慎,防止用户看到过期价格。Java侧用Spring Boot的CacheManager或者直接RedisTemplate都行,重点不是代码,而是缓存key的粒度:必须包括搜索词、过滤条件、排序方式、分页参数。漏掉排序或分页的key,会出现严重的串数据事故,我踩过一次,被商品运营投诉了半天,那之后我把key的生成专门提了一个工具类,强制拼全所有参数。5.3 深分页一定是性能陷阱:从from/size到search_after电商搜索用户很少翻到第100页,但总有爬虫或者运营把页面往后翻。ES的from/size深分页在千万级数据下是很危险的:from10000,size50,会先把10050条数据全部拉出来再截取,耗内存不说,协调节点CPU也扛不住。我的处理方式:用户端普通翻页:限制最大fromsize不超过10000,超出即禁止。无限翻页场景(运营后台导出、用户加载更多):改用search_after,利用上一页最后一个文档的排序值继续翻页。POST /product_search/_search { size: 50, sort: [ { saleCount: desc }, { productId: asc } ], search_after: [1024, 8001234] }search_after有个隐藏要求:排序值必须唯一,否则翻页时会出现重复或丢失。价格、销量都可能有重复,所以我在排序字段最后固定加一个productId作为tie-breaker,确保唯一。5.4 聚合性能:千万别对千万级document做全量聚合电商搜索页经常要显示筛选条件的聚合结果,比如某品类下所有品牌、价格区间分布、每个品牌的商品数。ES的terms聚合在千万级数据上如果对全字段做,单次聚合可能消耗上百毫秒甚至秒级,直接影响搜索页首屏。我的优化策略是聚合只查热门品牌、热门属性这些高频且基数低的字段,并且带上查询条件之后再用子聚合,不要做全索引聚合。聚合字段尽量用keyword类型,预构建doc values,这才扛得住并发。如果对实时性要求不高,可以定期把聚合结果离线算好放到Redis或MySQL,搜索页直接读缓存,只是牺牲一点点实时性。这里我也想提醒:custom analyzer越来越多,索引体积膨胀,聚合和排序的性能都会受影响。所以分词优化这件事并不是做得越多越好。我在项目里最终只保留了title、brand两个字段的多分析器子字段,NGram兜底字段单独管理,才把索引体积控制在可接受范围。写在最后:分词优化是一场持续迭代,不是一次性的技术决战做完整轮优化,我最深的体会是整个搜索系统没有一劳永逸的配置。电商的玩法一直在变,新品牌、新网络热词、新商品属性层出不穷,词典、同义词、拼音库至少要按周维度迭代。我第一次上线自定义词典之后无结果率降到5%,觉得很不错,结果两周后新品上线,一批新词又让无结果率悄悄回升到7%。后来我把每周从搜索日志挖无结果词、人工审核、更新词典变成了固定的SOP,才把这套体系稳住。另外,ES版本这块我要多说一句:不同版本对同义词、拼音、热更新的支持有差异,我这边用的是7.x,如果你用的是8.x或更新的版本,配置语法可能略有不同。遇到why my synonym not work这类问题,第一件事不是改配置,而是用ES的_analyze API直接看分析结果,一步步排查token到底是哪里断的。分析器问题用分析器排查,千万不要凭感觉瞎调,这是我这轮优化里最值钱的一条经验。