☰
关键词商品搜索实现逻辑拆解:从索引到排序的完整链路
2026/10/13 7:23:50 网站建设 项目流程

1. 为什么关键词商品搜索要先拆成一条完整链路

有同事问过我一个问题:关键词商品搜索是不是就是在数据库里跑一个 LIKE 就可以了?我说如果目标只是“能用”,那确实一天就能上线,但用户不会买账。因为用户输入的关键词和商品标题之间,隔着分词、同义词、类目匹配、库存过滤、排序和缓存好几道坎。我后来在某大型零售平台的后台项目里把整个搜索模块重做了一遍,这里就把关键词商品搜索实现逻辑完整拆开讲清楚,包括商品索引怎么建、查询怎么处理、召回排序怎么做、缓存和降级怎么兜底。

为什么强调“链路”这个词?因为搜索不是一个孤立接口,它是数据、索引、查询、排序、展示共同组成的一条流水线。你只优化其中一环是没有用的。比如,你在召回阶段没有做同义词扩展,后面排序做得再精细,候选集本身已经漏了;你在排序阶段没有引入库存过滤,用户可能花 200 毫秒等到了结果,点进去才发现无货。搜索系统里最常见的失败,不是单个环节技术不行,而是环节之间没衔接好。

1.1 从搜索框到商品列表,要经过六个阶段

我习惯用一条一维链路来描述整个搜索过程:

  1. 查询输入:用户输入原始关键词,可能是中文、英文、数字、品牌名混在一起。
  2. 文本归一化:去掉多余空格、统一大小写、转全半角,避免同一个词因为格式不同而检索不到。
  3. 分词与词项扩展:把“保湿面霜”切成“保湿”和“面霜”,同时把同义词、拼音、简繁体关系接进来。
  4. 召回:根据词项去倒排索引里找候选商品,再从类目、品牌、属性等维度做多路召回。
  5. 过滤与排序:过滤掉下架、无库存的商品,再按相关性和业务目标计算最终顺序。
  6. 结果输出:做分页、去重、组装价格库存信息,返回到前端。

链路可以进一步压缩成几个字:归一化、分词、扩展、召回、过滤、排序。很多人一上来就写排序,觉得排序做好了搜索就好用了,这是本末倒置。排序只能对你召回到的候选集做重排,如果你压根没有召回某个商品,排序阶段无论怎么优化,它都不会出现在结果里。所以我做搜索重构时,第一件事永远是先画链路,把数据流跑通,再谈优化。

1.2 判断搜索效果好不好,先定三条标准

做技术的人容易陷入一个误区,就是追求算法上的“高深”。但搜索系统的评价其实很朴素,我一般只看三条:

  • 相关性:搜“保湿面霜”返回的商品,至少标题或属性里要与保湿、面霜相关。
  • 召回率:平台里明明有用户想要的东西,不能因为同义词没配、分词没切好,就搜不到。
  • 稳定性:接口耗时和成功率是底线,哪怕结果不是最优,也不能让搜索直接挂掉。

这三条标准是互相冲突的。比如,你想提高召回率,就得多路召回、多放同义词,候选集变大后排序耗时自然上升;你想保证相关性,就得加过滤条件,但过滤条件多了,无结果率也会上升。所以,我在设计时会把“硬约束”和“软约束”分开。硬约束是商品必须上架、必须有库存、必须在配送范围内;软约束是相关度阈值、类目偏好,这些在召回结果太少时可以适当放宽。

想清楚这些之后,后面的每一步实现才有依据。

2. 商品文档与索引:搜索质量的地基,比算法更值得花时间

搜索系统的地基不是代码,而是商品数据。你用什么字段去建索引,决定了用户能通过哪些维度找到商品。我在项目里看过太多次这样的情况:索引字段只有商品ID和标题,其他信息一概不管,结果用户搜品牌名、搜材质、搜规格时,什么都查不到。关键词商品搜索要覆盖的场景远不止标题匹配。

2.1 商品文档的字段设计

一份能支撑搜索的商品文档,至少要包含下面这些字段。这里我用一张表格说明字段用途,以及它对应的是检索、过滤还是排序。

字段示例主要用途说明
商品ID1002345主键唯一标识,排序时也可以做 tie-breaker
标题天然植物保湿面霜 50ml文本召回最重要,但必须清洗掉促销文案
副标题/卖点敏感肌可用 清爽不油腻文本召回辅助召回,权重低于标题
类目ID美妆护肤->面霜过滤/召回类目链要保留父类目,方便按大类目兜底
品牌某品牌过滤/召回品牌词命中时应该显著加权
属性规格:50ml, 适用肤质:干性召回/过滤JSON格式存储,用于属性筛选和精确词匹配
价格129.00排序/过滤参与价格带筛选,也参与业务排序
库存356过滤库存为0的商品必须过滤掉
上下架状态on_sale过滤待审核、已下架都不该出现在搜索结果里
销量12000排序平台销量,不是商家自填的刷单数据
服务分4.8排序店铺评级,可参与精排
创建时间时间戳排序新品加权

这些字段不是说建一个 JSON 塞进去就完了。我通常在文档里把它们拆成三块:search_text存文本召回信息,filter_fields存过滤用字段,sort_fields存排序用字段。这样索引和检索时都能快速定位,不需要在内存里对每一条结果做全字段扫描。

标题字段我还要单独说一句。很多平台会把“【第二件半价】【限时优惠】”这种营销词挂在标题前面,如果直接把原文拿去建索引,分词后全是一堆无意义词,还会稀释真实关键词的权重。我在项目里会在商品入库前做一次标题清洗,把括号、方括号里的促销文案删掉,只保留商品真实信息。

2.2 倒排索引就是一本“书的目录”,建得不好会偏

倒排索引的原理可以理解为:把每一件商品变成一个文档,然后给文档里的关键词建目录。比如有两件商品:

  • 商品A:天然植物保湿面霜 50ml
  • 商品B:男士控油洁面乳 150g

分词后大概得到这样的倒排关系:

商品A -> 天然, 植物, 保湿, 面霜, 50ml 商品B -> 男士, 控油, 洁面乳, 150g

倒排表则反过来:

保湿 -> 商品A 面霜 -> 商品A 洁面乳 -> 商品B 男士 -> 商品B

用户搜“面霜”的时候,系统先去倒排表里查“面霜”这个词,找到商品A,再根据商品A的ID去加载完整字段做排序。这就是为什么速度快:无论商品总量是十万还是一千万,倒排表都能在毫秒级定位到相关文档集合。

但这里有个容易被忽视的问题:分词器决定了一句话会被切成哪些词。如果你用的是通用分词词库,商品标题里的“洁面乳”和用户搜索的“洗面奶”就没有任何关系,因为它们不是同一个词。所以在建索引之前,一定要准备一份跟行业相关的词库和同义词表,这件事比选哪个分词器更重要。

2.3 全量索引加增量索引,别等半夜才能修数据

电商商品是动态变化的,商家会改标题、改价格、改库存,运营会上下架商品。如果索引跟不上变化,就会出现一种很拧巴的场景:用户通过搜索看到了商品,点进去却是已下架或者价格对不上。所以索引更新必须分两条线。

第一条是全量构建。平时可以每天凌晨跑一次,把商品库全量导出,重新建索引,然后切流。这种方式通常用于修正数据不一致,或者当天增量更新出了故障。

第二条是增量更新。商品发生变化时,通过消息队列把变更通知给索引服务,索引服务只更新变化的那一条文档。这个链路要特别注意:修改价格、库存、销量这类排序字段时,必须刷新索引;只修改描述文字时,也要刷新搜索字段,否则用户搜到的还是旧标题。

我当时在项目里用了一个很简单的策略:全量索引凌晨跑,增量消息队列实时更新;索引更新成功后再把数据写回缓存。有一次线上出了个问题,商家改了商品标题,搜索结果里却还是旧标题,排查后发现是上下架状态更新时的消息把标题更新消息给覆盖了。从那以后再也不敢把多种变更混在一条消息里处理,而是拆成独立的字段级更新。

3. 查询处理层:关键词从进来到变成检索条件的完整过程

查询处理层决定了一句话能否被系统正确理解。我见过很多团队把大部分精力花在排序上,却忽略了查询预处理,结果用户输入“欧莱雅 面霜 女”或者“揩脸巾”这种不标准说法时,系统直接返回空结果。这类问题靠调排序是救不回来的,必须在查询入口就解决。

3.1 文本归一化是小事情,但脏数据都在这里

原始关键词进到搜索服务时,不要直接拿去分词。先做一次文本归一化,动作很简单,但收益很大。要处理的事情包括:

  • 全角字符转半角,比如把“A”转成“A”。
  • 大写转小写,比如“iPhone”统一成“iphone”。
  • 合并多余空格,去掉首尾空格。
  • 删除肉眼看不见的零宽字符。

有一次线上出现一个诡异的问题:用户复制了一句带特殊换行的商品描述,搜索无结果。日志里看关键词和商品标题一模一样,后来发现是关键词里带了 Unicode 零宽空格,把词给切断了。归一化后这个问题就消失了。所以这一步看着低级,实际上非常重要。

3.2 分词与词项扩展:词典一致性才是关键

归一化完成后,系统要把查询词切开。中文没有天然空格,分词是必须的。比如“保湿面霜敏感肌”,切分出来可能是“保湿/面霜/敏感肌”。英文相对简单,按空格和标点切即可,但要处理大小写、单复数、时态变化。

分词做完之后,接下来是词项扩展。这一步要做的事很多:

用户输入: 揩脸巾 标准化: 揩脸巾 同义词扩展: 洗脸巾, 洁面巾, 棉柔巾, 擦脸巾

扩展后,检索条件从单个词变成了一个词集。搜索时,只要商品命中其中任何一个词,就算候选命中。如果没有同义词扩展,“揩脸巾”这个词在绝大多数商品标题里都不会出现,结果自然为空。

词项扩展必须和索引端保持一致。也就是说,商品标题分词用的词库,和用户查询分词用的词库,必须是同一套。假如索引端把“洁面乳”切成了一个词,但查询端在索引前的词库里没有“洁面乳”这个词,用户的查询就可能被切成“洁面”和“乳”两个词,导致召回逻辑完全混乱。索引端和查询端的词典不一致,是我排查无结果问题时最常遇到的原因之一。

3.3 纠错和兜底:什么时候必须降低标准

用户输入经常有错别字。比如把“柔纸巾”写成“柔湿巾”,把“蓝牙耳机”写成“兰牙耳机”。在搜索系统里做拼写纠错,简单的方式是用编辑距离,配合拼音转换。我的经验是,纠错不应该在用户刚开始输入时就介入,而是在“严格匹配结果太少”的时候才出发。

假设用户输入“兰牙耳机”,正规索引里“兰牙”这个term几乎不存在。系统第一步先跑了严格匹配,发现召回结果少于20个,再触发纠错逻辑,把“兰牙”改成“蓝牙”,重新召回。这样既不会在用户本来就搜对的情况下改变结果,也不会因为一次误纠错把结果带偏。

兜底设计也要分级,不要一次性把过滤条件全部放开。比如先放开“类目限制”,再放开“同义词”,最后才放开“关键词顺序要求”。一级一级降,直到结果数量达到一个可接受的水平。否则,用户搜“面霜”,你却把所有“乳液”也兜进来,相关性就没法看了。

4. 召回与粗排:从大量商品中先捞出“可能对”的候选集

召回是搜索链路中决定“能不能搜到”的关键环节。它的目标不是精确排序,而是快速筛出一个相对可靠的候选集合。这个阶段最重要的思想是多路召回——别把所有希望都押在标题关键词匹配上。

4.1 多路召回:标题、类目、品牌、属性各走一条路

只做标题召回会漏掉大量有效商品。举一个例子,有一款手机壳,标题叫“适用于iPhone 15 透明防摔壳”,用户搜“iPhone 15 壳”的时候,标题分词后能命中。但还有一些商品,手机壳的具体适配型号只写在属性里,标题只写了“透明防摔手机壳”。用户搜“iPhone 15 壳”,标题召回永远也找不到它。这时候就需要从属性字段做一路召回,属性里有“适配机型:iPhone 15”,就能被命中。

我把召回路数大致分成四类:

  • 标题召回:权重最高,命中标题的商品和用户意图最相关。
  • 类目召回:用户搜索“面霜”时,落在“面霜”类目下的商品都算候选,哪怕标题里没有“面霜”二字。
  • 品牌召回:用户输入品牌名,该品牌下的所有商品都作为候选。
  • 属性召回:针对规格、材质、适用场景、适配型号等属性词。

四路召回出来的结果通常会有重叠,需要做一次并集去重。去重按商品ID来,同时要记录每一个商品是通过哪一路召回来的。这个“来源信息”在粗排阶段有用:标题命中的商品,文本相关度得分就应该比属性命中的高;类目命中的商品,相关度又比品牌命中的低。

4.2 粗排用 BM25 就够,重点是理解两个参数

候选集可能很大,比如用户搜“面霜”,四路召回下来可能捞回几千个商品。这时候不能直接上精排,因为精排要做更多特征计算,耗时很高。所以需要先做一次粗排,用BM25这类经典文本相关性算法,把候选集压到一个合理的量级,比如前1000个。

BM25的核心思想不复杂:一个词在某个商品里出现得越多,这个商品越相关;但这个词如果在整个商品库里到处都出现,那它的区分度就低,权重应该被压低。同时,长标题和短标题在计算词频时要统一长度,不然短标题吃亏。

公式大致长这样:

score = sum( idf(term) * { tf(term) * (k1+1) / [ tf(term) + k1 * (1 - b + b * doc_len/avg_len) ] } )

其中k1控制词频的增长速度,b控制文档长度归一化的影响程度。默认取值通常是k1=1.2、b=0.75。如果你不是做搜索学术研究,直接用默认参数就好,不需要在上面反复调。真正影响体验的是词库质量和召回路径,不是这两个小数点的取舍。

粗排阶段还有一个容易踩的坑:不要在这个阶段就去做复杂的聚合统计。如果需要统计某个类目下有多少件商品,可以在粗排前先做一次字段级过滤,再计算总数,否则几千个候选全部做一次聚合,查询很快会超过200毫秒。

4.3 过滤条件放在召回后,但状态过滤必须前置

过滤条件,比如库存大于0、商品状态为上架,不能放在排序之后,那是严重的性能浪费。但也不是越前置越好。我一般把过滤分成两类:

  • 基础过滤:商品状态、库存、删除标记。这类条件必须在召回阶段就带上,因为这些是硬约束,不符合条件直接不要。
  • 业务过滤:价格区间、类目限定、配送范围。这类条件可以在召回后、粗排前使用,也可以做成可选开关。

有一个常见问题值得说:后台系统里商品的“状态”字段经常不只有两种。可能有“草稿”“待审核”“上架”“下架”等多个状态。如果你只过滤了状态不为“下架”,那么“待审核”和“草稿”也会被拉出来。正确写法是状态等于“上架”,而不是不等于“下架”。这个细节我在排查“搜索结果出现未上架商品”时踩过一次,提醒大家注意。

5. 精排与业务排序:为什么销量最高的商品不应该永远排第一

召回解决了“有哪些商品可以展示”的问题,精排解决的是“先给用户看哪些商品”的问题。精排不能只看文本相关性,还要结合电商的业务目标。毕竟用户搜“面霜”,不是想看到一屏全是相关但销量几乎为零的商品。

5.1 把相关性和业务目标拼成一个可调公式

我在项目里采用的精排思路,是给相关性得分和业务得分设定不同权重,最后算一个总分。核心排序公式可以简写成:

final_score = text_score * 0.50 + sales_score * 0.20 + price_score * 0.10 + shop_score * 0.15 + new_score * 0.05

这里的每一项都做了归一化处理。比如text_score在0到1之间,sales_score不能直接用销量数字,而要取对数后再归一化,否则爆款会永远霸占第一。price_score也不是价格越贵越好或者越便宜越好,而是根据用户所在价格带偏好做一个合理映射。

实现时的伪代码可以这样写:

import math def score_item(item, query_text_score): sales_log = math.log1p(item.sales) max_sales_log = math.log1p(100000) sales_score = sales_log / max_sales_log price_score = 1.0 - abs(item.price - target_price) / max_price price_score = max(0, price_score) shop_score = item.shop_score / 5.0 freshness = 0.3 if item.is_new else 0.8 final = ( query_text_score * 0.50 + sales_score * 0.20 + price_score * 0.10 + shop_score * 0.15 + freshness * 0.05 ) return final

这些权重不是固定的。不同类目、不同大促阶段,权重都应该能调整。我习惯把权重放到配置中心,而不是硬编码在代码里。比如大促期间,库存充足度和销量权重可以调高;新品首发阶段,新品加权可以提升到0.15。没有配置中心的,至少也要留一个可改的常量表。

为什么不直接按销量排序?因为用户搜“面霜”时,品牌词和目的词的意图差异很大。搜“某品牌 面霜”的用户,对品牌有明确偏好,品牌命中权重必须高;搜“保湿面霜”的用户,对功效词更敏感,标题命中权重必须高;搜“面霜”这类泛词的用户,没有明确偏好,销量和店铺分的权重要适当放大。单一销量排序只能满足一种场景,没法照顾所有意图。

5.2 去重、SKU聚合和稳定分页

电商搜索里有大量同一商品的不同SKU,比如一件衣服有黑、白、蓝三个颜色,每个颜色都是一个SKU。如果搜索结果不做去重,用户会看到好几条一模一样的商品,只是规格不同,这既浪费展示位,体验也很差。通常的做法是引入“商品组ID”:同一个商品组的所有SKU在召回阶段合并成一条默认展示记录,进入排序阶段时只保留一个主SKU。

排序分页也要单独设计。用户第一页看到的结果和第二页结果必须保持稳定。一种常见的错误是排序条件里没有唯一字段,两条商品得分完全一样,搜索引擎内部排序结果不稳定,导致用户翻页时商品顺序乱跳。解决方法是给排序条件加一个 tie-breaker,比如:

order by final_score desc, product_id asc

这个逻辑听起来很基础,但真的有很多搜索接口线上翻页乱跳,就是因为少写了这个唯一性字段。

6. 缓存、超时与降级:搜索接口在流量高峰怎么活下来

搜索接口是电商系统里 QPS 最高的接口之一。分词、召回、排序都做一遍,再快也扛不住所有流量都打到索引服务上。所以缓存和降级不是可选项,而是搜索系统必须有的能力。没有缓存兜底,一次大促流量进来,搜索服务很容易被打穿。

6.1 缓存分层:进程内缓存、分布式缓存、空结果缓存

我习惯把搜索缓存分成两层。第一层是进程内本地缓存,通常设在每个搜索节点里,缓存时长很短,比如2秒。这层缓存能挡住一部分完全相同的请求,但每个节点数据一致性问题很小,因为只缓存非常短的时间。

第二层是分布式缓存,比如 Redis。缓存key的设计很关键,我一般用一个字符串把搜索条件拼进去做哈希:

import hashlib def search_cache_key(query, category_id, sort_type, page, size): raw = f"{query}|{category_id}|{sort_type}|{page}|{size}" return f"search:v2:{hashlib.md5(raw.encode('utf-8')).hexdigest()}"

分布式缓存缓存时长相对更长一些,比如5分钟。大促期间可以对高频热词单独设置30分钟缓存,但一定不能对所有词都做长缓存,否则商家改了价格之后,用户看到的价格可能还是旧数据。

还有一个很容易忽略的点:空结果也要缓存。用户搜索一个生僻词或错别词,索引里没有结果,如果每次请求都穿透缓存去打索引服务,系统白白浪费大量资源。我在 Redis 里会专门存一份空结果标记:

search:empty:{hash}: 1 ttl: 60秒

这样同一个空结果词在一分钟内不会再穿透到底层,接口压力能降很多。

6.2 超时控制和降级开关

再好的系统也怕极端流量。搜索服务一定要有超时控制,我通常设置整体链路的目标是:

  • 平均耗时:200毫秒以内。
  • P95耗时:500毫秒以内。
  • 超过1000毫秒的请求,直接放弃部分非核心计算,用默认排序快速返回。

降级方案要在平时就写好,不能等线上出问题时再临时改代码。我一般准备三个档次的降级:

  • 一级降级:关闭同义词扩展、拼写纠错,只保留精确分词召回。这样能省掉一部分查询处理计算。
  • 二级降级:关闭精排中的业务特征计算,只按文本相关度和销量排序。这时候结果顺序可能不如平时精准,但搜索还能用。
  • 三级降级:直接切换到一份内存索引或商品表简单查询,只支持关键词精确匹配加库存过滤。这是最后一道防线,保证用户至少能看到搜索页,而不是页面报错。

降级开关可以用配置中心动态控制。监控指标我一般盯四个:搜索QPS、平均耗时、成功率、无结果率。无结果率突然飙升,通常是查询处理层出了问题;成功率下降,通常是索引集群或者依赖的服务出了问题。这些指标分开看,定位会快很多。

7. 三个真实踩坑记录,帮你少走弯路

搜索系统的坑,大多数藏在小细节里。单独看每一个都不复杂,但串起来就是一套排查经验。这里写三个我在实际项目里踩过的坑,都是花了不少时间才定位到根因的。

7.1 用户搜“揩脸巾”没有结果,问题出在词库而不是排序

线上反馈说,搜“揩脸巾”返回结果为空,但平台上明明有很多洗脸巾商品。初步排查时,我以为排序规则把商品压到了太后面,于是反复调排序权重,一直没效果。后来看了查询日志,才发现“揩脸巾”这个词在同义词表里根本没有映射。

系统把“揩脸巾”当成一个独立的词去倒排索引里查,可商品标题里写的是“洗脸巾”“棉柔巾”“洁面巾”,完全没有“揩脸巾”三个字,所以召回结果就是空的。这个问题的修复方式不是加排序,而是在同义词表里加上:揩脸巾 -> 洗脸巾, 洁面巾, 棉柔巾。修复后,查询词扩展成一组词集,召回路径一下子宽了。

这个案例给到我的教训是:无结果率升高时,第一步永远先看分词和词项扩展日志,确认系统到底拿哪些词去查了索引,而不是一上来就怀疑排序。

7.2 结果页排序一直在跳,问题出在缺少 tie-breaker

有一次线上反馈说用户翻页时商品顺序总在变,一会儿商品A在第一位,一会儿商品B在第一位。我们检查了排序公式,发现几个商品的精排分数完全相同,而排序SQL里只写了ORDER BY final_score。

当分数完全一样时,数据库和搜索索引返回的顺序是不确定的。商品A和商品B谁排前面,取决于底层数据当时的物理顺序,翻页时状态一变就可能互换。修复方式很简单,排序条件改为:

ORDER BY final_score DESC, product_id ASC

product_id 成为唯一性标记,保证排序稳定。看起来很小的改动,但对搜索结果页体验影响非常大,尤其是用户连续翻多页时。

7.3 空结果请求打穿缓存,搜索延迟飙到三秒

大促期间监控报警,搜索接口的P95耗时从200毫秒飙到3秒。排查发现,流量集中在几个生僻词上,这几个词在索引里都查不到任何商品。由于当时没有做空结果缓存,每个请求都穿透到索引集群,集群压力上来了,所有搜索请求都被拖慢。

修复方案就是前面提到的空结果缓存。我设置了一个60秒的TTL,对无结果的关键词做标记。上线后,这些生僻词的请求不再反复穿透底层,P95耗时很快降回了400毫秒以内。这个案例提醒我,缓存设计不能只缓存“有结果”的请求,也要缓存“没有结果”的请求。

7.4 留一个搜索回归测试集,每次改动都跑一遍

搜索系统的改动很容易“修一处坏一处”。我项目里维护了一个很小的回归测试集,大概几百条“查询词-预期商品集”的对照关系。比如“保湿面霜”预期要包含商品A、商品B;“某品牌 洁面乳”预期首选品牌下的商品。每次改了分词词典、排序公式、过滤规则,都先跑一遍这个回归集。

这个方法不复杂,但能拦住大多数回归问题。比如有一次我在调权重时,把标题命中权重从0.6改成了0.3,回归测试立刻发现“保湿面霜”这个查询词的首屏结果出现了一个不相干的商品。没有回归集的话,这类问题可能要线上用户反馈后才暴露出来。

最后说一个我在这套关键词商品搜索实现逻辑里验证过的细节:查询兜底不要一上来就把过滤条件全部放开,而是分级放宽。比如先放开类目限制,再放开同义词,最后放开关键词顺序要求。我按这个思路做下来,搜索无结果率大概降了三成,而且没有明显损伤相关性。你如果正在做类似的搜索模块,可以从这个开关开始试,成本很低,收益却比较直接。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询