从未降级的搜索技术-天猫SKU搜索

jopen 9年前

  前些天,五福老大的文章《从未降级的搜索技术》介绍了搜索双 11 的 5 件新式武器,其中就包括天猫 SKU 搜索。本文就对此做一些更详细的介绍:

  什么是 SKU

  SKU,Stock Keeping Unit,库存单元,是商品库存的最小单位。通俗的讲,一种商品可能有各种规格的货,每一种货就是一个 SKU。比如,iphone6 有白色 16G、金色 16G、白色 64G、金色 64G、等多种 SKU;再比如商家售卖的某款T恤有白色S码、黑色S码、白色M码、黑色S码、等等 SKU。

  SKU 的概念在 tmall 这个平台上其实已经存在很久了。在 tmall 上,当我们进入一个商品详情页时,会看到 SKU 相关的选项。比如进入某商家售卖的 iphone6 手机的商品详情页,你可以选择具体规格的 iphone6 手机:颜色是白色、金色、还是黑色?内存大小是 16G、64G、还是 128G?如果某种规格的手机没货,比如金色 64G 无货,那么页面会禁止你同时选中”金色”和”64G”,或者在你选中这样的组合之后提示你无货。当你选定了一种规格,如果有货,页面会显示对应规则的售价 (不同规则售价很可能是不一样的哦),这时你才能对此商品下单。而此时,其实你是选中的就是一个确定的 SKU。

  不过很遗憾,一直以来,你只有进入商品详情页才能看到这些关于 SKU 的选项,才知道什么样的 SKU 是有货的,具体售价是多少。而在商品搜索页面展示的结果只有商品维度的信息。

  SKU 搜索能带来什么

  如果你对于精细化的搜索没什么需求,只是想随便逛逛,那么原先的搜索体验对你来说应该问题不大。否则,你可能会遇到一些不方便,比如:

  • 你想买 64G 的 iphone6,想找一个价位合适一些的商家。但是搜索结果没法给你展现 64G 的 iphone6 卖什么价钱(一般为了吸引眼球展现的都是最便宜的 16G 的价格,或者直接展示一个总的价格区间),你只能在茫茫多的搜索结果中逐个点击进入商品详情页,然后再逐个比较。甚至不少商家可能暂没有 64G 的货,却也堂而皇之的出现在搜索结果中;
  • 跟上一个场景比较类似,可能你看到茫茫多的搜索结果被吓坏了,打算做一下价格过滤,只搜索售价低于 5000 块的 64G iphone6。但是结果再一次让你失望了,既然搜索结果没法将 64G iphone6 准确的价格区间展现给你,那么价格过滤必定也不是你所期望的结果;
  • 类似的,选择按价格排序的搜索结果也肯定让你直摇头;
  • 时间倒退半年,2014 巴西世界杯要开打了,你想买一件西班牙队的球衣以表支持。Tmall 上的西班牙球衣或者相关T恤简直五花八门,让你挑花了眼。于是你打算加一些筛选条件:红色、M号(可能你几次点击进入商品详情后已经发现有些商品并没有合 适你穿的M号)。可惜搜索又再一次令你失望了,很多商品并没有红色M号却也混进了搜索结果中。这是 BUG 么?其实不是,细心的你也发现虽然那些商品并没有你想要的红色M号,却有红色S号和白色M号,或者红色L号和黑色M号,诸如此类,反正红色是有的,M号也 是有的,就是未必能凑在一起;

  这些问题的的根源在于,搜索引擎是以商品作为检索单位,没法提供更细粒度(SKU 粒度)的检索功能。于是,为了提升用户的搜索体验,为了把搜索做得更好,搜索引擎需要支持 SKU 粒度的检索。

  有了 SKU 粒度的搜索引擎,不仅能解决上面提到的这些关于精准搜索的问题,也给搜索结果的组织和排序提供了更多的玩法与可能性。比如:

  • SKU 信息披露:搜索结果依然按商品来展示,但是满足搜索条件的 SKU 可以以小图的形式展现在商品下面,在搜索结果页面点击这些小图就能看到对应 SKU 的价格;有些类目下面,SKU 小图可以做成两个维度的。比如鞋子,小图展现的是不同颜色的各个版本,点击小图之后会展现该颜色对应有货的各种尺码信息。选中颜色和尺码再展现对应的价格 区间。另一方面,这些小图也可以按其对应的 SKU 受欢迎程度等逻辑来进行排序;
  • 搜索结果组织:普通的 query 按商品粒度展示、命中标类(SPU)的 query 按标类聚合展示,这都是原来就有的组织方式。有了 SKU 引擎,当 query 更为具体(满足条件的商品很少)时,可以以更细粒度来展示搜索结果,比如按子标类(CSPU)聚合展示、或直接按 SKU 展示;(关于子标类,举一个简单的例子,iphone6 是一个标类,白色 64G iphone6 就是一个子标类。当用户搜索”手机”时,搜索结果可以按标类聚合展示;当用户搜索”iphone6″时,如果依然按标类来聚合,那么搜索结果就只有 iphone6 自己孤零零的一个了,这时候更好的做法是按子标类聚合展示。)
  • 精准排序:现在既然引擎已经能认识到商品的某些 SKU 是不满足搜索条件的,那么提供给算法用来计算排序分的信息就应该只包含满足条件的那些 SKU 的信息,而不应该是整个商品的信息。这样可以做到更精准的排序;

  有了 SKU 引擎,有了精准搜索,一个叫”尺码个性化”的需求就信心满满地登上了台面。用户可以在 tmall 上设置自己以及家人朋友的各种尺码,在搜索服饰类商品的时候可以方便的进行尺码筛选。可以想象,在没有精准搜索之前,尺码个性化这样的功能简直就是自曝其 短(搜索结果不准确、价格展现也差强人意,用户肯定骂声一片)。

  SKU 引擎和尺码个性化功能上线之后,取得了不错的成绩。经过数据分析,CSPU 聚合场景下 IPVUV/UV 增长率为 2.16%、整个沙发类目平均 ipvuv 价值增长率为 8.50%、文胸类目平均 ipvuv 价值增长率为 3.23%、男鞋类目平均 ipvuv 价值增长率为 1.26%、女鞋类目平均 ipvuv 价值增长率为 1.20%、等等。

  目前 SKU 引擎才刚刚上线,大幕才刚刚拉开,在此基础上,后续必定会有更多的 feature 让我们的搜索变得更好用,大家可以拭目以待。

  SKU 搜索的实现

  讲了这么多 SKU 引擎的好处,为什么不早点把这个东西搞起来呢?其实实现 SKU 引擎的道路充满了波折……

  第一个时期,可以称为改良期。其实这个时侯并没有什么 SKU 引擎,但是为了实现个别场景下的精准搜索,我们利用搜索引擎插件来做了一些处理。最典型的就是价格展现,并不是直接展现商品的最小 SKU 价格或是所有 SKU 的价格区间,而是通过插件来判断哪些 SKU 是满足搜索条件的,从而决定应该展现什么样的价格区间。

  插件实现的精准搜索主要存在两方面的问题:

  • 没有统一的方案,各个改良点分散在不同的角落,难以维护;
  • 效率不尽如人意,一般需要在检索过程之后额外增加一个 SKU 的判断逻辑。特别是当 SKU 维度的搜索条件比较复杂时,插件判断起来会很费劲。比如用户搜索:64G、白色或金色的 iphone6,搜索条件是一个比较复杂的表达式,而不仅仅是一堆 AND 关系的条件叠加;

  第二个时期,是全面 SKU 化,将引擎数据由商品粒度改为 SKU 粒度。这是非常简单易行的 SKU 方案,并且能够很好的实现 SKU 引擎的各种需求。唯一,并且是致命,的问题是浪费太大。毕竟用于检索的数据,绝大部分还是在商品这个维度上的。将检索粒度直接改为 SKU 粒度,这意味着商品维度上的数据需要冗余到其对应的每一个 SKU 上。数据的冗余,造成了引擎索引量增大、造成了离线数据处理开销增大、造成了各种在线归并和聚合逻辑的开销增大。细算下来,tmall 引擎需要增加 5 倍以上的机器才能支撑网站的全部流量,离线处理的机器资源也需要相应的增加,这实在是不可接受。

  所以这个时期的 SKU 引擎只存在于 bts,它的功绩是验证了 SKU 引擎的功能和效果,更坚定了我们要做 SKU 引擎的决心。

  第三个时期,才是目前的 SKU 引擎。既要支持 SKU 的检索粒度,又要避免数据冗余,那么引擎就必须要支持两个维度的数据共存。

  为此,作为搜索引擎内核的 isearch5 引擎引入了”子表”的概念。”主表”和”子表”都拥有完整的检索能力,并且建立强一致的对应关系。商品作为主表、SKU 作为子表,检索结果就表现为”一主带多子”的结构。这些子表记录都一定是满足检索条件的,并且当一个主表记录对应 0 个子表记录时,说明这个主表记录不满足检索条件(回想之前”红色M号”的例子)。后续的 RANK、字段展现、等等都在这样的一主带多子的数据结构上工作,每个环节都明确知道哪些 SKU 是满足检索条件的。

  “子表”这个概念跟引擎里面早已存在的”辅表”似乎有些相似。一般来说,我们可以将商家信息作为辅表来提供检索,目的也是避免数据冗余。但是” 子表”与”辅表”还是有本质的不同:辅表并不提供查询功能,我们只能实现查询商品,然后从辅表中读取商品对应的商家信息,而不能查商家(比如说,我们不能 查询店铺名称含有”夕阳红”的商家所售卖的标题含有”保暖内衣”的商品)。基于这一点,主表与辅表虽然是两个表,但是引擎的处理逻辑还是一维的(主表维 度),并不存在一主带多辅的概念(一般来说一个主表记录只能对应到一个辅表记录,就算数据结构是一对多,在进行取值的时候也只能保留一个而舍弃其他)。

  而”子表”则是真正的两维概念,两个表可以同时查询(比如说,可以查询标题含有”iphone6″的商品,且这些商品需要拥有包含”白色” 和”16G”两个属性的 SKU),并且检索结果在引擎中表现为一主带多子的两维结构。这一点对 isearch 引擎来说冲击是非常大的,整个查询流程需要从一维数据结构变为两维结构。具体来说主要有以下几点:

  • 查询过程支持主表与子表的混合查询。了解 isearch 的同学都应该知道,引擎的查询过程是由语法树来描述的。比如查询 64G、白色或金色的 iphone6,写成查询语法大概是这样子:query=主表:iphone6 AND 子表:64G AND (子表:白色 OR 子表:金色)。查询过程中涉及不同条件召回的结果集的合并,不过显然主表与子表的结果是不能直接合并的。依赖于两个表之间强一致的对应关系,它们的结果可 以相互转换(比如 nid=123 转换为 skuid=256,257,258;skuid=258 转换为 nid=123),从而使得结果集合并成为可能;
  • 查询过程合并后的结果集直接形成一主带多子的二维结构。继续上面的例子:query=主表:iphone6 AND 子表:64G AND (子表:白色 OR 子表:金色),假设各个条件分别查到如下结果:nid=123 AND skuid=256,257 AND (skuid=256 OR skuid=257,258),且有 nid=123 <==> skuid=256,257,258 的对应关系,那么合并后的结果就是:nid=123:skuid=256,257;
  • 查询完成后,接下来是过滤、统计、排序、等过程。这些过程说白了都是在查询到的结果的基础上进行各种各样的计算,而这些计算最典型的做法就是使用 属性表达式。比如可能有这样的过滤语句:filter=realprice (skuprice,discount_conf)<5000,通过 realprice 这个 function 插件来计算每个 sku 的实际售卖价,而其中 skuprice 是 sku 的原价,discount_conf 是商品维度的打折时间段配置。这里又是一个主表与子表混用的地方,引擎能识别 skuprice 字段来源于子表,discount_conf 来源于主表,并且自动推导出它们的计算结果应该落在子表这个维度上。那么假设 skuid=256,257 的 skuprice 取值分别为:5288,4988,当前折扣为1,则 skuid=256 这个结果不满足售价<5000,将被过滤掉,最后剩下的结果是:nid=123:skuid=257;
  • 除了上面提到的 function 插件,引擎里面还有各种算分和排序插件,这些插件都有可能需要遍历子表的结果,然后将运算结果写在主表上。因为很多情况下,搜索引擎展示的结果是按商品聚 合的,需要把 SKU 维度的排序分整合到商品维度,实现商品之间的排序。为此,对于一个一主带多子的两维结果,引擎会给插件提供遍历”多子”记录的功能;
  • 在我们前面提到的搜索结果组织形式中,有按 SKU 展示,以及将 SKU 粒度聚合成 CSPU 展示的需求。为此,引擎实现了将”一主带多子”拆散成”独立多子”的功能,从而便于插件去实现子表维度的展示、打散及聚合等功能。这个拆散的过程其实并非 想象中的天翻地覆,假设有这样的结果:nid=123:skuid=256,257,拆散后会变成两个结果:nid=123:skuid=256 和 nid=123:skuid=257,其实描述查询结果的数据结构并没有发生变化,仅仅是将主表记录 clone 了几份,让原来的一主带多子变成多个一主带一子;

  有了 isearch5 引擎的子表功能做为基础,要打造 SKU 引擎还需要各个相关方的通力配合:

  • 离线:将原有的一维文档结构变为两维结构。其中有一个很麻烦的地方,因为并不是所有 tmall 商品都具有 SKU,而没有 SKU 的商品会被在线引擎过滤掉(因为引擎没法判断到底是该商品的 SKU 都不满足检索条件,还是它本身就没有 SKU。并且当需要使用 SKU 维度的字段时,你还不知道该怎么取值)。要解决这个问题,要么搭建两套引擎,分别维护有 SKU 和没有 SKU 的商品,但是这无疑增加了运维的复杂性;要么就给没有 SKU 的这些商品伪造一个 SKU,使用商品上的对应字段来填充 SKU 的字段(比如 SKU 价格就是商品价格)。最后我们采用的是第二种方案,伪造 SKU 的重任就落在了数据离线处理的身上;
  • 算法:要支持 SKU 维度的打分模型。为了提高效率,算法对”一主带多子”中的”一主”和多个”子”单独打分,然后将最终得分整合到商品维度或者 SKU 维度上;
  • 插件:为了兼容有 SKU 和没有 SKU 的各种应用场景、为了实现各种不同的聚合展现方式,各种脏活累活基本上都被插件干了。其中还有很重要的一点,目前 isearch5 引擎实现的子表功能并不是非常完备的,在结果展现(summary)阶段并没有子表的概念。这就得靠插件拿着”一主带多子”的检索结果,去识别各个商品具 体有哪些 SKU 满足检索条件,从而决定将哪些 SKU 纳入结果展现中;
  • SP:为了将”一主带多子”的对应关系带到 summary 供插件做判断,为了兼容搜索结果按子表拆散后 nid 可能重复的问题,SP 也是煞费苦心;

  可以看出,整个引擎团队能一起啃下 SKU 引擎这块硬骨头,实属不易。而后续我们也会把引擎做得更加完善和合理,为业务的持续发力打下坚实基础。