1. 项目概述与核心痛点
最近在复盘一个电商项目的检索服务重构,正好做到第六章,也就是最核心的查询与聚合部分。这章内容太密,拆成了上、中、下三篇来写,今天这篇“中篇”,我们聚焦在从用户输入关键词,到最终返回排序结果这个“黑盒”里,到底发生了什么。很多团队在做搜索时,容易陷入两个极端:要么过度依赖ES/OpenSearch等引擎的默认配置,结果就是召回率、精准度总差那么点意思;要么就是自己从头造轮子,把大量精力花在了分词、倒排索引这些底层实现上,反而忽略了业务场景的适配。我这次重构的核心思路,就是在这两者之间找到一个平衡点,用相对可控的复杂度,实现业务收益的最大化。
简单来说,一个电商搜索框背后,远不止是“匹配关键词”那么简单。它需要理解用户的模糊意图(比如“夏季连衣裙”可能隐含“透气”、“轻薄”的需求)、处理复杂的商品属性(品牌、型号、规格、SKU)、并在一两百毫秒内,从百万甚至千万级商品中,找到最相关的那几十个,并且按“好”的顺序排好。这个“好”字,就是业务的核心:可能是销量最高、可能是利润最大、也可能是平台最想推的新品。检索服务,就是把这个商业目标,翻译成搜索引擎能理解的查询语言和排序规则的过程。接下来,我会拆解这个翻译过程的几个关键环节:查询理解、查询构建、以及相关性排序的初探。
2. 查询理解:从关键词到用户意图
用户输入搜索框的文字,我们称之为“查询串”(Query String)。这串文字通常很短,且充满歧义。查询理解(Query Understanding)的任务,就是尽可能准确地解读这串文字背后的真实意图。
2.1 查询预处理与归一化
这是第一步,也是最基础的一步,目的是清洗和标准化原始输入。
- 空格处理与特殊字符过滤:去除首尾空格,将多个连续空格合并为一个。过滤掉大多数无意义的特殊字符(如
!@#$%),但保留一些可能有语义的字符,如“+”、“-”、“()”,这些可能在后续的查询语法中用到。对于中文,我们通常直接进行分词,空格处理相对次要。 - 大小写归一化:对于英文或拼音,通常统一转为小写(lowercase),确保“iPhone”和“iphone”能被同等对待。但要注意品牌词等专有名词,是否需要在特定场景下保持原样。
- 纠错与拼写检查:用户可能会输错字,比如“连衣裙”打成“连衣群”。我们可以维护一个常见错别字词典,或者使用开源库(对于中文,可以用PySpellChecker的适配或类似算法)进行自动纠正。这一步的挑战在于平衡纠错的准确性和对生僻词、新词(如网络流行语、新品名)的包容性。
- 同义词扩展:这是提升召回率的关键。我们需要建立一个业务相关的同义词库。例如,用户搜“手机”,应该也能匹配到“智能手机”、“移动电话”;搜“NB”,应该能联想到“New Balance”这个品牌。同义词库可以是静态的(人工维护),也可以是动态的(通过用户点击、购买日志挖掘)。在查询时,将原始词条及其同义词一起加入查询条件,通常用
OR逻辑连接。
注意:同义词扩展需要谨慎,过度扩展会导致召回结果不相关,稀释排序效果。通常我们会给原始词更高的权重,同义词较低的权重。
2.2 查询词权重分析与业务词典注入
不是所有分词后的词条都同等重要。我们需要识别出查询中的核心实体和修饰词。
- 词性标注与命名实体识别:利用NLP工具(如HanLP、LTP或jieba的简单词性标注)对分词后的结果进行分析。识别出名词(通常是商品类目、品牌、型号)、形容词(颜色、尺寸、材质等属性)、动词(如“防水”、“保暖”这类功能需求)。例如,对于“华为黑色防水智能手机”,“华为”(品牌)、“黑色”(颜色)、“防水”(功能)、“智能手机”(类目)的权重和后续处理策略是不同的。
- 业务词典优先:电商搜索有强烈的领域特性。通用分词器可能把“iPhone 15 Pro Max”切分成“iPhone”、“15”、“Pro”、“Max”四个词,这不利于精准匹配。我们必须将品牌词库、型号词库、品类词库等业务词典提前加载到分词器中,确保“iPhone 15 Pro Max”能作为一个整体词条被识别出来。这能极大提升品牌、型号等精确查询的体验。
- 停用词过滤:过滤掉“的”、“了”、“吗”等对搜索无实质贡献的虚词。但在电商场景下要小心,有些词如“新款”、“2024年”可能具有时效性筛选意义,不应简单过滤。
经过查询理解,我们把“夏季轻薄连衣裙”这样一个查询,转化成了结构化的意图表示:核心类目是“连衣裙”,属性要求包含“季节:夏季”和“材质风格:轻薄”,并且我们可能还附上了“裙子”、“夏裙”等同义词。这个结构化的表示,就是下一步构建搜索引擎查询的蓝图。
3. 查询构建:将意图翻译为引擎指令
查询理解产出的结构化意图,需要被“翻译”成搜索引擎(如Elasticsearch)能够执行的查询语句(Query DSL)。这个过程的核心是多策略查询融合。
3.1 基础查询策略:Match与Term的权衡
搜索引擎提供了多种查询类型,我们需要根据词条的性质进行选择。
- 对核心类目、品牌、型号等精确字段使用
term查询:例如,品牌ID、分类ID、确定的型号名称。term查询不做分词,完全匹配,可以保证结果的绝对精准。例如,{"term": {"brand_id": 1001}}。 - 对商品标题、描述等文本字段使用
match查询:match查询会对输入文本进行分词,然后默认以OR逻辑搜索各分词。为了平衡召回率和精准度,我们通常会采用match_phrase(短语匹配,要求分词顺序一致)或给match查询设置operator:and(要求所有分词都出现)。例如,对于“黑色手机”,{"match": {"title": {"query": "黑色手机", "operator": "and"}}}会比默认的OR逻辑更精准。 - 多字段查询:一个用户的查询意图,可能需要在多个字段中寻找。例如,“华为手机”既可能在
title字段,也可能在brand_name字段。我们可以使用multi_match查询,同时搜索多个字段,并可以指定字段的权重(^符号)。例如,{"multi_match": {"query": "华为", "fields": ["brand_name^3", "title^2", "keywords^1"]}},这表示在品牌名字段匹配的权重最高。
3.2 构建复合布尔查询:Must、Should、Filter、Must_Not
真实的电商搜索查询几乎都是复杂的组合。我们使用bool查询来组装。
must子句:必须满足的条件,参与相关性算分。通常用于表达用户明确的核心需求。例如,类目为“手机”,并且标题中包含“华为”。should子句:应该满足的条件,满足的越多,分数越高。常用于同义词扩展、多字段匹配。例如,标题中应该包含“华为”或“HUAWEI”。should子句在bool查询中如果没有must或filter,则至少需要满足一条;如果存在must或filter,则作为加分项。filter子句:必须满足的条件,但不参与相关性算分。这是性能优化的关键!用于那些非文本的、确定性的筛选条件,如价格区间、库存状态(是否有货)、商品状态(是否上架)、品牌、分类ID等。因为不计算分数,且可以利用缓存,filter的性能远高于must。务必把能放进filter的条件都放进去。must_not子句:必须不满足的条件,同样不参与算分。用于排除某些商品,如排除已下架商品。
一个典型的电商商品搜索的bool查询结构如下:
{ "query": { "bool": { "filter": [ // 确定性筛选,不参与算分,性能好 {"term": {"status": "ON_SALE"}}, {"term": {"has_stock": true}}, {"range": {"price": {"gte": 100, "lte": 500}}} ], "must": [ // 核心文本匹配,参与算分 {"match": {"category_name": "智能手机"}} ], "should": [ // 加分项,提升相关度 {"match_phrase": {"title": {"query": "华为旗舰", "slop": 2}}}, // 允许中间间隔2个词 {"match": {"attributes": "5G"}} // 匹配属性字段 ], "minimum_should_match": 1 // 在存在must时,should至少满足1条才加分 } } }3.3 处理复杂场景:Function Score与自定义排序
当基础的文本相关性(TF-IDF/BM25算法算出的分数)无法满足业务排序需求时,我们就需要介入,修改最终得分。这就是function_score查询的用武之地。 假设我们的业务需求是:在文本相关的基础上,优先展示销量高、好评率高、且是新上架的商品。
{ "query": { "function_score": { "query": {...}, // 上面bool查询的全部内容 "functions": [ { "filter": {"range": {"sold_count": {"gte": 100}}}, // 仅对销量大于100的商品应用此函数 "weight": 2 // 权重因子 }, { "field_value_factor": { // 使用字段值影响分数 "field": "rating", "factor": 1.2, "modifier": "log1p" // 使用log(1+rating)来平滑影响,避免极高评分商品分数爆炸 } }, { "exp": { // 指数衰减函数,用于时间因素 "publish_time": { "scale": "30d", // 30天衰减一半 "decay": 0.5, "offset": "7d" // 7天内不衰减 } } } ], "score_mode": "sum", // 多个函数分如何组合:求和 "boost_mode": "multiply" // 函数分如何与原始查询分组合:相乘 } } }通过function_score,我们可以将销量、评分、上新时间等业务指标,巧妙地融合到最终的排序分数中,实现复杂的业务排序规则。
4. 排序策略初探:从相关性到业务目标
查询构建解决了“找出来”的问题,而排序策略要解决“怎么排”的问题。排序是电商搜索的终极战场,直接关系到转化率和GMV。
4.1 文本相关性排序:BM25算法及其调优
Elasticsearch默认使用BM25算法计算文本相关性分数。理解其核心参数对调优至关重要:
k1:控制词频饱和度的参数。值越大,词频对分数的影响越大。对于标题这类短文本,词频通常不高,可以适当调高k1(如1.5-2.0),让出现关键词的商品分数更高。对于描述这类长文本,可以保持默认(1.2)或调低,避免词频过度影响。b:控制字段长度归一化的参数。值在0到1之间。设为0则禁用长度归一化,长文本字段(如商品详情)会占便宜;设为1则完全启用,短文本字段(如标题)会占便宜。电商标题通常较短,可以适当调低b(如0.3-0.5),削弱长度的影响,让标题匹配更精准。 我们可以在索引映射(mapping)中为特定字段设置BM25参数:
{ "mappings": { "properties": { "title": { "type": "text", "similarity": "my_bm25", "fields": {...} } } }, "settings": { "index": { "similarity": { "my_bm25": { "type": "BM25", "k1": 1.6, "b": 0.4 } } } } }4.2 业务权重排序:非文本因素的融合
纯文本相关性的排序常常不符合业务预期。我们需要引入业务权重。除了前面提到的function_score,还有更直接的方式:
- 直接按字段排序:对于“按价格从低到高”、“按销量从高到低”这类明确需求,可以直接在查询中使用
sort参数。但要注意,这完全抛弃了相关性,只适用于用户明确选择了排序方式的场景。 - 混合排序:更常见的是将相关性分数与业务分数进行线性加权。例如,最终得分 = 0.6 * 文本相关性分(归一化后) + 0.3 * 销量分(归一化后) + 0.1 * 新品分。这需要在应用层(检索服务内部)进行计算,因为ES原生的
function_score的boost_mode虽然灵活,但进行精细的加权求和不如在应用层控制方便。 - 个性化排序:根据用户的历史行为(点击、购买、浏览时长)实时调整排序权重。例如,对经常购买高端品牌的用户,在其搜索“手机”时,提高“价格”字段的权重。这需要实时用户画像和在线计算能力的支持,是搜索排序的进阶领域。
4.3 排序稳定性与多样性
一个好的排序系统,不仅要准,还要“稳”和“丰富”。
- 稳定性:避免搜索结果在短时间内(用户无新操作)发生剧烈跳动,这会让用户感到困惑。可以在计算分数时,加入一个微小的随机因子,或者对分数非常接近的结果(如分差小于0.1)保持相对顺序。
- 多样性:避免同一店铺或同一相似商品霸屏。可以在排序后处理阶段,对结果列表进行重排,确保前几页能展示不同品牌、不同款式、不同价位的商品,给用户更多选择。这可以通过分组(bucket)后再在每个组内取Top N来实现,但会牺牲一定的全局最优性。
实操心得:排序策略没有银弹,必须进行A/B测试。任何权重调整、新排序因子的加入,都必须通过线上小流量实验,核心观察指标包括:点击率(CTR)、转化率(CVR)、平均订单金额(AOV)、以及更宏观的GMV。切忌凭感觉调整参数。
5. 性能优化与查询调试
一个设计再精妙的查询,如果响应时间超过500ms,用户体验也是灾难性的。性能优化贯穿检索服务始终。
5.1 索引设计优化
查询的性能,很大程度上在索引设计阶段就决定了。
- 字段类型选择:精确匹配用
keyword,全文检索用text(并配置合适的分析器)。数值范围查询用integer、float等。避免用text类型做精确匹配,效率极低。 - 索引映射优化:
- 禁用不必要的字段:对于确定不需要被搜索或聚合的字段,设置
"index": false。 - 规范命名:避免使用动态映射(dynamic mapping),明确指定每个字段的类型和属性,防止字段爆炸。
- 使用
copy_to:如果经常需要跨多个字段进行搜索(如同时搜标题和副标题),可以使用copy_to将这些字段的内容复制到一个组合字段中,然后只对这个组合字段进行搜索,减少查询条件数量。
- 禁用不必要的字段:对于确定不需要被搜索或聚合的字段,设置
- 分片与副本:分片数在索引创建时设定,后期修改成本极高。需要根据数据总量和硬件资源预估。单个分片大小建议在20GB-50GB。副本数(
number_of_replicas)提供高可用和读取吞吐,可以根据读压力动态调整。
5.2 查询DSL优化
- 善用
filter上下文:如前所述,将不参与算分的条件全部放入filter。filter条件会被缓存,速度极快。 - 避免深度分页:
from + size方式的分页,在深度翻页时(如from=10000)性能损耗巨大,因为需要全局排序并跳过大量结果。对于深度翻页,应使用search_after参数,配合上一页最后一个结果的排序值进行查询。 - 限制返回字段:使用
_source过滤,只返回前端渲染必需的字段,减少网络传输和数据序列化开销。 - 避免脚本查询:尽可能避免在查询中使用Painless脚本,脚本执行非常耗时。尽量通过索引设计(如将计算好的值索引为一个字段)来避免运行时脚本计算。
- 设置查询超时:使用
timeout参数,避免个别慢查询拖垮整个服务。
5.3 调试与分析工具
当查询结果不符合预期或性能不佳时,需要工具来诊断。
- 使用
explainAPI:在查询URL后加上?explain=true,可以返回每个文档得分的详细计算过程,理解为什么这个文档被召回以及分数如何构成。这是调试相关性问题的利器。 - 使用Profile API:在查询体中设置
"profile": true,可以获取查询执行过程中各个组件(如Query、Rewrite、Collector)的详细耗时,精准定位性能瓶颈。 - 使用Kibana Dev Tools:提供一个交互式控制台,方便地编写、测试和调试查询DSL。
- 慢查询日志:在ES集群配置中开启慢查询日志,定期分析那些执行时间过长的查询,进行针对性优化。
6. 容错与降级策略
检索服务作为核心链路,必须具备高可用性。当依赖的搜索引擎出现故障或性能下降时,需要有降级方案。
- 超时与重试:客户端调用检索服务,以及检索服务调用搜索引擎,都必须设置合理的连接超时和读取超时。对于可重试的错误(如网络抖动、引擎暂时过载),可以实现有间隔的指数退避重试机制。
- 熔断与降级:
- 熔断:当调用搜索引擎的失败率或慢请求比例超过阈值时,熔断器打开,后续请求直接失败,避免雪崩。经过一段时间后,进入半开状态尝试放行部分请求。
- 降级:当搜索引擎完全不可用或熔断器打开时,触发降级。降级策略可以包括:
- 返回缓存结果:如果之前对热门查询结果有缓存(注意缓存时效性),可以返回缓存数据。
- 返回简化结果:切换到备用数据源(如数据库),执行简单的SQL查询,只返回最基本的商品信息(ID、标题、主图、价格),并明确提示用户“当前为简化搜索模式”。
- 返回空结果并友好提示:这是最后的选择,比返回错误页面或长时间等待要好。
- 限流:在服务入口对请求进行限流,防止突发流量击垮搜索引擎。可以根据用户ID、IP或查询类型设置不同的限流策略。
7. 监控与指标体系建设
没有度量,就无法优化。必须建立完善的监控体系。
- 核心业务指标:
- 查询量(QPS):反映服务压力。
- 平均响应时间(RT)、P95/P99响应时间:衡量服务性能。
- 错误率:请求失败的比例。
- 召回率与精准率:需要离线抽样评估,通过人工标注或利用点击数据近似计算,衡量搜索结果的质量。
- 用户体验指标:
- 无结果率:返回结果数为0的查询占比。过高可能意味着查询理解或索引数据有问题。
- 首位点击率:用户点击第一条结果的比例,反映排序效果。
- 搜索退出率:用户在搜索结果页未发生任何点击就离开的比例。
- 系统资源指标:
- ES集群健康状态:green/yellow/red。
- 节点CPU、内存、磁盘IO。
- JVM堆内存使用率与GC情况。
- 告警:对核心指标(如P99 RT > 1s, 错误率 > 1%, 集群状态非green)设置告警,确保问题能第一时间被发现。
构建一个健壮、高效、智能的电商检索服务,是一个持续迭代的过程。从精准的查询理解,到高效的查询构建,再到融合业务的智能排序,每一个环节都需要紧密结合实际业务数据进行打磨和调优。这次重构让我深刻体会到,搜索不仅仅是技术,更是技术与商业理解的结合。在“中篇”我们搭建了核心的查询与排序框架,在接下来的“下篇”,我们会探讨更高级的主题:聚合分析(Facet)实现高效的筛选导航、搜索建议(Suggest)与自动补全、以及基于向量检索的语义搜索和个性化推荐如何与现有系统结合,让搜索体验再上一个台阶。