第一版是最常见的做法:一个 title 字段,挂标准分析器,查询用 match。中文和英文场景勉强能用,扩到其他语言之后彻底崩了。
一是泰语压根搜不到。泰语词与词之间不用空格,标准分析器按空白切分,整句话被当成一个词条。用户搜其中一个词,永远匹配不上。
二是日语的问题类似但更微妙。日语混用汉字、平假名、片假名,同一个词有多种写法(片假名和罗马字),按字符切分会把词切碎,按空白切分又切不开,需要专门的形态素分析。
三是阿拉伯语有词形变化。同一个词根加不同前后缀表示不同语法形态,不做词干还原的话「书」和「书们」是两个完全不同的词条。而且阿拉伯语是从右往左书写,但这只影响展示不影响索引,容易被误当成分词问题。
四是精确词被分词毁掉。用户搜商品型号「iPhone15ProMax」或者游戏点卡的面额编码,分词器把它切成几段,结果召回一堆不相关的东西,而真正精确匹配的那个反而排在后面。
核心认识是:分词是语言特定的,不存在一个通用分析器能处理所有语言。所以设计成「一个逻辑字段拆成多个语言子字段,各挂各自的分析器」,再加一个不分词字段保住精确匹配。
filter 不走 query。filter 不参与相关性打分,结果可以被缓存,性能差异很明显。「在售状态」「地区可售」这些是布尔判断,没有「更相关」的概念,放进 query 纯属浪费。子字段多了索引会膨胀,这是明确的代价。一个标题拆八个子字段,索引体积会显著增长,写入也更慢。控制手段是按文档自身语言只写需要的子字段——一个只有中文和英文标题的商品,不需要填泰语和阿拉伯语字段。另外正文这种长文本不做多语言拆分,只挂主分析器,因为正文的召回权重低,不值得为它付索引成本。
为什么不直接用云搜索服务或者数据库全文索引。数据库的全文索引(MySQL 的 fulltext)对中日韩泰的支持很差,而且没法做相关性调优和聚合,压根撑不住。云搜索服务省运维但定制受限——自定义词典、打分函数、多语言分析器的组合往往受平台限制。我们的量级和定制需求下自建 ES 更合适。要能说出「什么量级下我不会自建」:如果只有几万条数据、查询量很低,数据库加缓存就够了,上 ES 是过度设计。
踩过的坑:改了自定义词典之后老数据搜不到。往中文词典里加了一批品牌词,新写入的文档能按新词切分,但已经索引的文档还是按旧词典切的,导致同一个品牌有的商品搜得到有的搜不到,表现得像是数据丢了。原因是分析器是在写入时作用的,改词典不会重写已有倒排索引。修法是改词典必须伴随索引重建(下一个模块讲怎么不停机重建)。这个坑几乎是必踩的,能讲出来说明真运维过 ES。
踩过的坑二:语言识别错导致召回为空。按字符集判断查询语言,用户用英文搜中文商品(搜品牌的英文名),被判定为英语,只查了英文字段,而商品只有中文标题,结果为空。修法是永远保留跨语言兜底字段并且不做硬路由——用户语言字段给高权重,但其他字段也参与召回,只是权重低。硬路由到单一字段是这里最容易犯的错。
没做的部分:没做向量检索(语义搜索)。它能解决「用户描述需求但不知道商品名」的场景,但需要模型、向量索引和额外算力,当时的投入产出不划算。这是明确的能力缺口,被问到要承认而不是硬说不需要。
搜索接口 P95:用千万级的真实数据集(或者按真实分布造的数据),压测 100 / 200 / 300 QPS 梯度,查询词要用线上真实的搜索词分布——热门词大量重复(会命中缓存)、长尾词各不相同,两者的耗时差很多。只用随机词压测会偏悲观,只用热门词会偏乐观。300 QPS 下 P95 约 120ms。要说清索引规模、分片数、集群规格,这三个不说的话延迟数字没有参考价值。
「泰语阿拉伯语搜不到」怎么验证:整理一批真实的泰语和阿拉伯语搜索词(从线上无结果日志里捞),改造前后各跑一遍,对比有结果的条数。用「大量搜不到 → 可正常召回」这种定性描述,因为「召回率」需要人工标注每个查询的正确答案作为分母,没做标注就报不出准确率。
可以补充的硬指标:无结果查询的绝对数量——从搜索日志里统计每天返回 0 条结果的查询次数,改造前后对比。这个数字分母清晰(就是搜索请求数)、口径明确、可复现,比「准确率」可信得多。
不要报「搜索转化率提升 X%」或者「搜索准确率 95%」。前者是业务指标受太多因素影响;后者需要人工标注的评测集,没建评测集就说不出这个数,一问「你的标注集多大、谁标的」就穿。
multi_match 或者 bool should,让中文子字段、英文子字段、主字段、精确字段都参与,各自不同权重。混合语言的查询词经过各语言分析器时,能被识别的部分会产生词条,不能识别的部分产生噪音词条但权重低,不影响结果。关键是不要试图先判断语言再决定查哪个字段——判断一定会错,而多字段召回本身就有容错性。这也是为什么必须保留一个跨语言兜底字段。
第一版的索引更新是业务代码里双写:商品保存成功后调一次 ES 的更新接口。这个做法的问题不是不能用,而是会慢慢烂掉。
一是漏更新。商品能被修改的入口有十几个——运营后台、批量导入、定时下架任务、供应商接口同步。每个入口都要记得写 ES,只要有一个人漏了,那条数据就永久不一致,而且很难发现。
二是双写不一致。数据库写成功、ES 写失败(网络抖动、ES 短暂不可用),这条数据就搜不到了。加重试的话又要考虑重试期间的顺序问题——先改成 A 后改成 B,如果 A 的重试比 B 晚成功,索引里就是 A。
三是业务代码被搜索逻辑污染。商品保存的方法里混着构造 ES 文档的代码,改索引结构要改业务代码。
第二个更痛的问题是重建索引。改 mapping、换分词器、加字段都需要重建,而重建期间如果直接往线上索引操作,搜索就废了。第一次改分词器时我们选了凌晨停机重建,用户投诉「凌晨搜不了东西」,而且随着数据量涨,停机窗口越来越不够。
所以两个方向:更新改成订阅数据库变更(不侵入业务代码,天然不漏)、重建改成双索引加别名切换(线上无感知)。
binlog 订阅引入了新的依赖和延迟。相比双写的「同步立即可见」,订阅链路有秒级延迟——商品改完立刻搜可能搜到旧的。大部分场景可接受,但有例外:商品下架必须立刻从搜索结果消失(否则用户点进去发现下架,体验很差,甚至有合规风险)。对这类强实时要求的状态变更,我们额外保留了一条同步的「立即失效」通道——下架时直接调 ES 更新状态字段,不等 binlog。「主链路异步,个别强实时场景开同步小口子」,这个折中要说出来,声称全部异步没问题是不诚实的。
反查数据库带来的压力必须控制。大批量变更时(比如批量下架一万个商品)会产生一万次反查。控制手段:聚合去重(前面讲的)、批量反查(一次 IN 查询取多个 ID 而不是逐个查)、反查走只读库、消费端限速。最后一条很关键——宁可同步慢一点,也不能让搜索的同步把主库压垮。
踩过的坑:重建时追增量永远追不平。灌全量花了 20 分钟,这 20 分钟积压了大量增量消息,开始追增量时又有新的变更不断产生,消费速度和产生速度接近,延迟一直降不下来。修法是提高追增量阶段的消费并发,并且判定标准改成「延迟持续低于阈值一段时间」而不是「延迟为 0」——永远等不到 0,因为线上一直有变更。这个坑说明「追平」需要一个可达成的定义。
踩过的坑二:切换别名后立刻删旧索引,出问题无法回滚。有一次新索引的 mapping 有个字段类型配错,切换后部分查询报错,而旧索引已经删了,只能紧急重建,故障持续了一个多小时。修法就是那条纪律:切换后旧索引至少保留一天。这是花代价学到的。
没做的部分:没做重建的自动化编排。整个重建流程(建索引、灌数据、追增量、校验、切换)是靠脚本加人工确认串起来的,每次重建要人盯着。理想状态是做成一个可观测的工作流,一键触发、自动校验、异常自动中止。
全量重建耗时:从「开始灌数据」到「别名切换完成」的墙钟时间,取几次重建的实际记录。约 25 分钟。必须同时说明索引规模、并行度、集群规格、重建期间的参数调整(副本数 0、刷新间隔调大)——不说这些的话 25 分钟这个数字没有可比性。
「线上查询不受影响」怎么验证:重建期间持续压测查询接口,对比重建前后的 P95 是否有明显抬升。要诚实:重建会占用集群资源,查询延迟通常会有小幅上升,说「完全无影响」不可信。正确的表述是「查询延迟无明显抬升」或者给出具体的抬升幅度。
增量同步延迟 P95:在文档里埋一个「数据库更新时间」字段,消费端写入时对比当前时间,上报差值。取 P95 约 3 秒。要区分平峰和批量变更时的延迟——批量导入期间延迟会明显变大,只报平峰数字是选择性呈现。
一致性偏差:每日校验发现的差异记录数。用绝对数(每日个位数到十几条)而不是「一致率 99.99%」——千万级分母下任何比率都好看得没有意义。
version,ES 会拒绝写入版本更旧的文档。三层里至少要有前两层。
第一版排序就是 ES 默认的相关性打分。问题分两类。
一是排序不符合业务预期。默认打分只看文本相关性,结果是一个标题里堆满关键词的低质商品排在正品前面——因为它的词频高。而销量好、评价高、真正该被推荐的商品排在后面。运营天天来反馈「搜这个词第一个出来的是什么垃圾」。
二是没有兜底,搜不到就是白屏。用户搜了个错别字、或者搜了个平台没有的品类,返回 0 条,页面一片空白。更糟的是 ES 集群抖动时,搜索接口直接报错,用户看到的是错误页——而搜索是电商最核心的入口之一,它挂了等于店关门。
所以两个方向:把排序拆成可解释可配置的几层(文本相关性只是其中一层,业务质量必须参与),给「无结果」和「服务故障」都设计兜底路径(搜索入口永远要有内容返回)。
第二点尤其重要,它体现的是一个通用判断:用户高频入口的可用性优先于结果质量。返回质量差一些的结果远好于返回错误。
相关性和业务目标是有冲突的,必须承认。纯相关性排序对用户最「诚实」,但平台希望优质高毛利的商品靠前,运营希望活动商品置顶。我的处理是把这些诉求显式化而不是偷偷塞进打分——质量分是一层可配置权重、置顶是一条可审计的强制规则。这样至少能解释「为什么这个排第一」,出问题能定位。最糟的做法是把各种业务诉求都糅进一个巨大的打分公式,最后没人知道为什么。
为什么不上机器学习排序(LTR)。它确实是相关性的天花板,但需要标注数据、特征工程、模型训练与在线推理一整套基建,而且效果依赖数据量。我们的阶段用「规则加可配置权重」已经能解决大部分明显问题,投入产出更划算。要能说出「什么条件下我会上 LTR」:有稳定的点击行为数据、有评测集、有算法同学配合的时候。答不出条件就说明只是听说过这个词。
降级到数据库前缀匹配的能力是很弱的。它做不了分词、做不了相关性排序,只能支持最简单的标题包含查询,而且要小心不能用左模糊(like '%词%' 无法走索引,会全表扫描把数据库拖垮)。所以这一级只作为短时间兜底,而且要限流。要明确说这是「保证有结果」而不是「保证质量」,把降级说得很美好反而不可信。
踩过的坑:质量分权重调大之后长尾商品完全搜不到了。为了压制低质商品把质量分权重调高,结果新上架、没销量的商品分数极低,即使标题完全匹配也排在几百名之后,新商家反馈「我的商品搜不到」。修法是给新商品一个保护期的初始质量分(按品类均值而不是 0),并且限制质量分的影响上限。这个坑说明排序调优必须看长尾而不只看头部,只检查热门词的结果会漏掉这类问题。
踩过的坑二:熔断打开后一直不恢复。熔断器的半开探测配置有问题,ES 恢复了但熔断没关,搜索一直在降级状态跑了几个小时才被发现。修法是熔断状态必须有独立的监控和告警,而不是只监控接口成功率——降级状态下接口是「成功」的,成功率看不出问题。降级是一种需要被主动观测的状态,它不会自己报警。
没做的部分:没建搜索评测集。所有排序调优都是靠运营反馈和人工抽查,没有量化的评测基线,导致「这次调整到底是变好还是变坏」说不清。这是最应该补但一直没补的基础工作。
无结果查询次数:从搜索日志统计每天返回 0 条结果的查询次数。这个指标的口径必须说清——是降级前的原始查询算,还是降级后仍无结果才算?我们统计的是「四级降级都走完仍然没有任何内容返回」的次数,从每天数千次降到数百次。用绝对数而不是「无结果率」,因为率的分母(总搜索量)每天波动,看不出改善。
「ES 故障期间搜索仍可用」怎么验证:必须做故障演练——把 ES 地址指到不可用端点,或者直接在测试环境停掉集群,验证:接口不返回 5xx、能返回兜底内容、熔断正常打开、告警正常触发、ES 恢复后熔断正常关闭。最后一条最容易漏,也正是我们踩过坑的地方。没演练过的降级不能写进简历,一问「你怎么知道它有效」就答不上来。
排序效果怎么量化:诚实的答案是我们没有量化基线,只有运营反馈和人工抽查。可以报的是过程性指标:「搜索结果首屏无相关商品」的投诉工单数量变化。不要报「相关性提升 X%」——没有评测集算不出这个数,编一个一问就穿。
接口 P95:加了打分函数和后处理之后延迟会略增,要报出来。主动给出优化的代价,比只报好处可信。
没有匹配的内容,换个关键词试试。
项目拆解 · 搜索服务(后端)· 共 3 个模块 · 数字均为示例,需替换成自己项目的真实数据