从未降级的搜索技术-天猫SKU搜索
前些天,五福老大的文章《 从未降级的搜索技术》介绍了搜索双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引擎这块硬骨头,实属不易。而后续我们也会把引擎做得更加完善和合理,为业务的持续发力打下坚实基础。