这将会是一篇概括类的文章,可能最终遗留的问题比解决的多得多~~不是很有自信推荐你看,但如果有空闲时间的话,不妨跟着我一起思考其中的相关问题!
注意: 本文章对解决问题的思考篇幅多于实战,且许多问题的解决方案也停留在理论阶段,未必有足够合理的可实施性。之所以分享出来,仅仅在于怕遗忘或错过思考过程中的火花,若能给你带来好的灵感,纯属巧合,也是我的荣幸。如若能将你的高见分享于我,那就太棒了!

需求背景是源于我们公司的一个电商网站的改版,功能参考的是JD平台,本次主题主要围绕着商品搜索方面。其实现在大一些的电商平台,搜索体验做的都非常的好。
换句话说,用户对于一个好的搜索体验已经达成了基本的共识,包括但不限于下面几点:

  1. 输入自动完成
  2. 可以对拼写自动纠错
  3. 支持拼音缩写检索
  4. 精准度和召回率上有一个比较好的平衡
  5. 返回结果能有一定的随机性提供惊喜

这些功能,如果打算直接依托于传统的关系型数据库(如mysql)来实现的话,实现成本和性能都很难满足预期。而目前在这主题上深耕多年并建树的开源框架也屈指可数,我们这里选择影响力最大“之一”的Elasticsearch来作为主要解决方案。

Elasticsearch入门

这篇文章不包含任何科普Elasticsearch的内容,强烈建议有兴趣的小伙伴精读《Elasticsearch in action》这本书,在这里我们就只列一下需要掌握的基本概念和术语:

  • 文档,类型和索引的概念和关系
  • 节点,分片和副本的概念和关系
  • Mapping的写法和作用
  • CURD的DSL语法,尤其是各种Search语法
  • 分析器,分词器和分词过滤器的概念和用法
  • 聚集和桶的概念和用法
  • 文档间的关系:对象型,嵌套型,父子型,反规范化
  • 优化segments的手段有哪些

除了上面列出的这些外,书中还有大量宝贵的内容,再次建议花时间阅读完整本书的内容~~

环境搭建

我们当然需要一个本地的测试环境来练手,对吧~~
当然Elasticsearch本身就已经是开箱即用了,大家去官网下载压缩包并按照说明就可以简单运行起来。
我这里还是推荐直接在docker中下载官方的镜像即可~~
本篇文章的所有内容都是在Elasticsearch6.5.1版本上做的(因为很早前搭建ELK环境的时候就装好了,懒得升级版本了~)。

强烈建议同时安装Kibana,它提供的Dev Tools非常方便我们调试语句。

中文分词 和 词库

Elasticsearch默认的分词器对中文不是很友好,不过社区早已填补了这个遗憾。GG一下就会发现很多文章都推荐使用iK中文分词,使用和配置也非常的简单。
我在文章末尾的“参考文献”中贴出了我当时找到的资料地址,按照步骤很容易就安装成功了~

如果你也是在docker里跑的本地环境的话,记得直接重启容器,安装的插件才会生效哦~

当然每年都会诞生一批新的流行词语,这会对分词器造成很多困扰,不过iK的分词器支持扩展词库,而且同时提供静态词库和热更新方案,非常贴心了算是!
我们可以从网上搜索到比较新的词库文件,然后直接以静态文件的方式配置在iK配置文件中,然后在我们的电商系统后台,提供一个功能来自定义词库,使用热更新来动态更新到iK中。

更新了词库文件还不够哦,因为那些已经索引起来的数据是无法自动使用新的分词结果的,所以你需要reindex一下。
如果系统中有海量的数据的话,这个步骤可能会花很久,而且也可能会造成系统资源溢出问题,建议多了解一下这方面的资料,已经有很多相关的文献供参考了,无需担心~~

至于前面提到的词库,网上不少人推荐去搜狗上下载,不过下载的文件并不是txt,所以我们需要在线转换一下~

这部分的最后,我们还需要提到同义词这个问题。一个好的同义词库,可以让搜索的结果产生更多惊喜,例如用户搜索“番茄”,搜索结果里还会自然的包含“西红柿”。
这种看似理所当然的逻辑,对于计算机来说其实是很麻烦的。还好Elasticsearch提供了synonym特性,我们可以优雅的解决这个问题~

不过如果想让synonym也拥有iK扩展词库那样的热更新能力,就得靠自己动手了,最后的“参考文献”中我贴了一个前辈自己封装的版本,不过可能需要根据你使用的具体版本进行调整,但是他的项目还是很有指导意义的~

filter vs. query

简单的说,并不是所有的场景都一味的使用query context的,如果我们是针对精确的值进行筛选,那我们使用filter context更加的合理。
它们的差别是,filter性能更好,不仅仅有缓存,也省去了为结果进行相关性打分的环节。

但在我们的场景中,其实相关性打分是很重要的,这并不是说明filter对我们来说就没有意义了。例如用户在某个分类下进行检索,甚至设定了价格区间范围,商品原产地条件等等,在这些情况下filter就非常的合适了。当然,这些用在filter中的条件,也都不是经过分析器处理后的原始内容。

排序,分页

搜索离不开分页,而Elasticsearch最基本的分页方法和SQL的用法没什么两样:fromsize 就搞定了。但是如果是海量的数据条件下,它的性能也和传统的SQL分页一样存在性能问题,如Mysql的limit 100000, 10这种。
Elasticsearch还是提供了对应的解决方案的:Scroll。这种我记得好像某些Nosql产品也有类似语法~~

在一些特定业务场景下,也可以根据数据的特定字段进行辅助的分页,例如自增的id,或更新时间等线性数据类型,且被依赖的字段必须保证足够的唯一性。但是这种场景对于前台系统复杂的检索条件,不是很适配。
为什么呢?这就涉及到另外一个话题:排序。

前面提到过,Elasticsearch默认是按照相关性得分进行排序的,这个得分是动态计算出来的,所以肯定不是线性的。即便是你的场景不需要依赖得分排序,但也很难固定的就使用id这种“毫无意义”的排序条件吧。一个好的排序,可能让用户得到更需要ta得到的结果!为何这么说呢?我们把商品列表页面比作一个大型商超的货架,卖家为了将一些利润更多的,或者急需要倾销的商品摆在更明显的地方,有助于达到销售目的。所以结果的排序,并不是单纯的基于相关性得分就能达到“双赢”的目的的。

这么做几乎是所有搜索系统的标配,直接将付钱的客户的页面放在置顶的位置,要脸点的系统会在对应的链接上打上“广告”的标签。不要脸的系统甚至不理会用户到底搜索的是什么~~
那Elasticsearch要怎么做到这种设置呢?别慌,其实sort是支持脚本的,这样就可以任意的影响搜索结果的排序问题,建议了解一下function_score(“参考文献”中有对应的文章)。

除了影响排序权重,还有个有趣的想法就是提供一定程度的随机性,这样在海量数据的时候能给用户带来某种神秘的惊喜感。这个也是可以通过function_score提供的random_score来实现的,算是比较方便了。

除此之外,还有像是根据地理位置距离,或者特定想使用多个字段经过复杂的计算来得到得分的诉求,Elasticsearch都是支持的,查看官方文档即可。

工具是都齐活了,难点在于根据自己所在的业务场景和数据性质,组合各种搜索条件和对应的权重来最终达到一个较为理想的检索效果。而且随着数据的变化,还要不断的调整参数来持续优化结果。这是一个漫长,但有趣的旅行~

页面上的搜索元素

文章开头就提到了,我们对标的是JD的搜索功能,意味着和搜索密切相关的有几块:

上图中黄色和蓝色方块,对标filter语句条件,随着用户的选择,全部会增加到query中去。蓝色区域的可选项,可以通过第一波query的结果进行聚集得到;而黄色方块包含的参数应该是每个商品都拥有的属性,其中比较有意思的是“出版时间”,应该是搜索结果都是图书类型导致的判断。如果我们将搜索关键字换成综合性质的内容,如“龙珠”,由于结果中包含各种类型的商品,所以对应地方显示的则是更加通用的“新品”~

红色方块对应的是检索结果,而有别于搜索结果的是绿色方块,为何这么讲呢?根据用户的输入,自动补全内容会给用户带来非常大的便利和鼓励。在这种刺激下用户输入更多内容的成本会非常低,而输入的线索越多,搜索结果就会越精准,所以这也是自动完成成为搜索必备特性的原因。

进一步思考就会发现,获取自动提示下来菜单中选项的语句和获取搜索结果的query语句应该是不同的,甚至所基于的索引和数据结构都可能是完全不同的。这又是为何呢?
用户在搜索框中输入的,多数情况下都是关键词(这不是废话吗),而不是根据你的数据原始内容来输入的,这意味着要想返回足够理想的结果,需要对输入的关键词进行分词,纠错,甚至拼音转换等等。那处理后得到的token要直接和索引中的token做匹配查询?

如果是这样,那你得到的查询结果集是一个一个具体的数据,而这些数据更适合出现在上图的红色方块中,而不是自动提示下拉菜单中,下拉菜单中的值来自哪里呢?
一种说法是,这些提示内容来自于一段时间的所有用户的搜索输入值,将所有用户在搜索框中输入的关键词收集,每个词的权重就是被输入的次数,并且定期进行关联条数的统计(对应上图中黑色椭圆形区域)。但还有一个初始化的问题,毕竟刚上线的时候没有历史搜索数据给你来收集分析~~一个解决方案是,基于现有数据自身的标签来生成第一批统计数据。

Elasticsearch针对搜索框自动提示,也有专门的语法:suggester,性能更好。

不过我开始反思一个问题,假如我们的电商系统中商品很少,我们是否还需要这套完整的方案?在自动提示下拉菜单中直接针对商品数据title使用phrase_prefix,有何不可吗?
我们也不需要像JD那样提醒对应的商品数量,毕竟我们的商品品类规模非常的小(差不多2K),且title相对保持很大的独特性。更多细节,可以在“参考文献”中《ELASTIC 搜索开发实战》中找到更详细的实现方法。

业务场景和数据模型

前面提到的全部内容,都离不开对索引的mapping的设计,一个好的mapping有助于索引的查询性能,存储空间的优化,查询的精准,等等。
我们并不需要把DB中的所有数据都存在Elasticsearch中,而是只需要将用于搜索和部分用于结果展示的字段存在Elasticsearch中一份即可。而用户点击特定结果进而查看其更详细信息时会直接从DB中获取全部信息,这样各司其职不仅有助于资源利用率,也让维护数据一致性能更容易一些,毕竟谁都不想因为一个与搜索不相关的属性值的变更就要维护多个数据源。

具体的说,就是可以像上面那个JD图一样,根据我们自身的系统的搜索页面需要展示的数据范围设计mapping,并在程序中编写当这些属性变更的时候顺带更新一下Elasticsearch中对应的文档即可,未必需要维护绝对的一致性,完全可以将更新事件丢入消息队列,异步的完成Elasticsearch的数据更新即可。

还有一个有趣的问题,我们一般都会依照业务场景建立数据模型,然后再将数据模型转换成满足关系型数据库范式要求的结构,这么一番操作后,我们的数据就会切成一块一块的,并且块之间会有不同的关系: 一对一,一对多,多对多。

理解和处理这种关系,在Elasticsearch中需要特别的留意,因为它会影响搜索结果的正确性,也会涉及到数据变更的成本。
啰嗦一句,在Elasticsearch中存储和检索条件有关的字段,而不是全部。那些详情里才会展示出的内容,还是放在原处吧~~
在这个前提下,如果还存在复杂的数据关系,再考虑如何优化这种关系为普通关系,实在不行了再搞嵌套类型,父子类型等等。
这部分和业务场景很紧密,还是要看自己对Elasticsearch的掌握程度和对业务的理解程度了。

最后的第一步

做完所有的预备工作,真的去线上环境部署,才是旅程真正的开始~~为何如此?
原因很简单,数据是在不断进化的,不仅是在数量上,质量上也在不断的变化。新的流行词,同一件东西的新别名,彻底的新东西等等,这些都可以上升的文化层面。
上面也有提到大体的思路,那就是不断的收集用户的行为日志,通过各种手段分析得到这种变化,再反馈到Elasticsearch的数据结构,分词算法,权重上。
这是一个无尽的旅程,干什么着急结束呢?

参考文献

Elasticsearch实战 - JD购买链接(买本看看吧,不亏)
Elasticsearch 索引设计实战指南
ELASTIC 搜索开发实战(强烈推荐)
Elasticsearch Suggester详解
Elasticsearch 默认分词器和中分分词器之间的比较及使用方法
修改IK分词器源码来基于mysql热更新词库
同义词 - 官方文档
基于mysql动态维护同义词的插件
Elasticsearch Java API如何使用search template
使用logstash同步MySQL数据到ES
使用 Elasticsearch 做一个好用的日语搜索引擎及自动补全
仿京东淘宝搜索框实战
基于Elasticsearch的地理位置简单搜索
Elasticsearch function_score使用
排序 - 官方文档
Elasticsearch 随机返回数据 API(但无法很好的结合分页)
Elasticsearch 7.x Nested 嵌套类型查询
父子文档
join类型 - 官方文档