为什么需要全文搜索

一般的网站都会包含搜索功能,它能帮助用户发现没有找到想要的东西,甚至能帮助用户挖掘到兴趣,这对提升用户对网站的黏性和用户体验有非常大的帮助。举个豆瓣的例子,用户可以在主站的搜索里面找到电影、书籍、音乐、用户、小站、小组、游戏等相关内容。

传统的数据库系统设计成可进行「增删改查」等操作,我们都知道,需要存储到数据库的内容都是经过深思熟虑的,如果用户规模和数据很大,多增加一个字段就意味着要增加很多额外的空间、多个索引,并影响到执行效率,这意味着我们无法把全部数据都存进数据库。举个例子,写一篇日记,可以把作者、发表时间、标题等字段存入数据库,但是日记正文无法也放进去,太占空间了。

现在的一个最佳实践是把访问比较集中的、频繁的数据直接放到内存和键值数据库中。但是再进行搜索,就要对不同的存储内容整理,并根据一定的算法把符合的内容排序后返回。而且这些数据非常有可能是由不同的开发团队来维护和开发的,首先这些产品间的网络通信就是一笔不小的开销,还要从不同的产品获取到结果之后再排序。这显然不合理。

全文搜索软件如 Elasticsearch 则有如下特点:

  1. 查询速度。全文搜索的数据存取方式只考虑快速读取,相比数据库的查询,要快的多得多。
  2. 支持复杂的查询表达式。数据库系统的查询通常只支持 AND/OR 等有限的模式,全文检索支持多得多的查询方式。
  3. 灵活排序。数据库系统一般按照内置的排序规则来排序,有什么字段并且有对应的索引才可能按什么字段排序。而全文搜索除了能够支持数据库的排序规则外,还支持按照结果的相关度排序,比如 Elasticsearch 内置了文本相关性、衰减、分词等高级特性,对搜索的效果有非常大的帮助。虽然 Elasticsearch 自带的中文分词不好,但是提供良好的插件机制,我们可以安装第三方中文分词插件,可以达到非常好的中文搜索效果。

ElasticSearch(简称 ES)是一个提供 Restful API 的、实时的分布式搜索和分析引擎,稳定,可靠,快速,功能强大。 我最早关注到 ElasticSearch 是因为 Github 抛弃了 Solr,采取 ElasticSearch 来做 PB 级的搜索。而现在 ES 已经是变得非常流行了。除此之外,基于 ELK (ElasticSearch, Logstash, Kibana) 的实时日志分析平台应用也非常广泛。

在上篇文章中我们已经抓取了需要的 Live 数据。我们先感受一条:

In : from models import Live
In : live = Live.get(789840559912009728)
In : live
Out: Live(index='live', doc_type='live', id='789840559912009728')
In : for k, v in vars(live)['_d_'].items():
...:     print('{}: {}'.format(k, '{}{}'.format(v[:30], '...') if isinstance(v, str) and len(v) > 30
...: else v))
...:
subject: Python 工程师的入门和进阶
feedback_score: 4.5
status: False
description: 我是董伟明豆瓣高级产品开发工程师 2011 年开始接触...
speaker_message_count: 208
liked_num: 1134
outline:
starts_at: 2016-12-27 21:00:00
speaker_id: 314
topic_names: Python
seats_taken: 3562
amount: 9.99
tag_names: 互联网

其实对于我这个需求,算是把 ES 当做 NoSQL 数据库来用了。

假如用户要搜索 Python 相关的 Live,也就是需要从 ES 中各个需要覆盖到的字段中找 Python 这个词,最后把符合的文档按照相关度等因素排好序返回给我们。

排序规则

默认的 ES 会根据文档的相关度进行排序的,多字段查询的写法如下:

In : SEARCH_FIELDS = ['subject', 'description', 'outline', 'tag_names',
                     'topic_names']
In : lives = s.query('multi_match', query='python', fields=SEARCH_FIELDS).execute()
In : lives[0]._score
Out: 9.952918
In : lives[1]._score
Out: 9.250518

排在前面的结果获得的分数要更高,也就是被 ES 认为更相关。

但对于很多场景这是有问题的,比如上面这个文档,不同字段应该要不同的权重。举个例子,2 个文档包含 Python 关键词的数量一致,但是第一个 topic_names 是 Python,另外一个 topic_names 是 Ruby,显然第一个文档要排在之前。因为知乎话题(Topic)是比较可信的一种划分,而相对的 description 里面\的文本就算有 Python 也不一定说明这个一个 Python 相关的 Live, 有可能只是作者提自己曾经学习过 Python 而已。所以不同字段我们需要设计不同的权重,我不是算法工程师,只是按自己的理解来调整下:

In : from elasticsearch_dsl.query import Q
In : a = Q('multi_match', query='python', fields=['subject^5', 'outline^2', 'description', 'topic_nam
...: es^10', 'tag_names^5'])
In : lives = s.query(a).execute()

In : lives[0]._score
Out: 73.56763

可以看到,这次评分获得了很大的提升,是因为我给 subject 的权重5,outline2...... 注意,topic_name 的权重成了之前的 10 倍,这样很容易岔开不同 Live 的得分。

还有一点,我使用了 Q,这和上面的区别不大,只是随着本文越来越深入,用 Q 来包装接口会让代码更好理解。

得分衰减

有了权重还是不够,因为我们还要考虑给新的 Live 机会。如果一个大 V 在很久之前举办了一场 Live,文本相关度很高、收入很多、评价也很好。那么是不是这个 Live 就应该一直排在前面甚至第一呢?这对用户的积极性有很多的伤害,因为后来的人很难有机会有足够的曝光。所以当一段「保护期」之后,这个 Live 的得分会随着时间不断的减低。

ES 内置了衰减函数(Decay Function)的支持。对于数值、日期和地理位置类型,可以设置一个理想的值,如果实际的值越偏离这个理想值(无论是增大还是减小),就越不符合期望,分数就越低。

它支持如下参数:

  • origin:原点,该字段最理想的值,这个值可以得到满分(1.0)
  • offset:偏移量,与原点相差在偏移量之内的值也可以得到满分
  • scale:衰减规模,当值超出了原点到偏移量这段范围,它所得的分数就开始进行衰减了,衰减规模决定了这个分数衰减速度的快慢
  • decay:衰减值,该字段可以被接受的值(默认为 0.5),相当于一个分界点,具体的效果与衰减的模式有关

衰减函数可以指定三种不同的模式:线性函数(linear)、以 e 为底的指数函数(Exp)和高斯函数(gauss),它们拥有不同的衰减曲线,我盗用官方的图:

可以感受到:

  1. linear 直线衰减,在 0 分外的值都是 0 分
  2. exp 衰减速度先快后慢
  3. gauss 衰减速度先慢后快再慢

对于 Live 搜索和排序的需求,我选择了 gauss,希望对 live 开始的前后 7 天以外的时间点都让这个 live 的得分变低。

我们通过一个例子,来感受下时间衰减对得分的影响。

In : from elasticsearch_dsl.query import Q, SF
In : lives = s.query('multi_match', query='python', fields=SEARCH_FIELDS).execute()
In : live = lives[0]
In : live._score, live.subject, live.starts_at
Out: (9.952918, 'Python 工程师的入门和进阶', datetime.datetime(2016, 12, 27, 21, 0))
In : sf = SF('gauss', starts_at={'origin': 'now', 'offset': '60d', 'scale': '10d'})
In : sf2 = SF('gauss', starts_at={'origin': 'now', 'offset': '7d', 'scale': '10d'})

我的 Live 在 sf 的 60 天偏移量范围内,但是不在 sf2 的 7 天范围内。现在感受下随着时间衰减对得分的影响:

In : lives = s.query(Q('function_score', boost_mode='multiply', functions=[sf])).execute()
In : lives[0]._score, lives[0].subject
Out: (10.952918, 'Python 工程师的入门和进阶')

In : lives = s.query(Q('function_score', boost_mode='multiply', functions=[sf2])).execute()
In : lives[0]._score, lives[0].subject
Out: (10.065867, 'Python 工程师的入门和进阶')

用 boost_mode 可以指定计算后的分数与原始的_score 如何合并,有以下选项:

  • multiply:将结果乘以_score
  • sum:将结果加上_score
  • min:取结果与_score 的较小值
  • max:取结果与_score 的较大值
  • replace:使结果替换掉_score

看看使用 sum 的效果:

In : lives = s.query(Q('function_score', boost_mode='sum', functions=[sf])).execute()
In : lives[0]._score, lives[0].subject
Out: (11.952918, 'Python 工程师的入门和进阶')

In : lives = s.query(Q('function_score', boost_mode='sum', functions=[sf2])).execute()
In : lives[0]._score, lives[0].subject
Out: (11.065802, 'Python 工程师的入门和进阶')

由于我们对不同的字段做了比较大的权重的加成,使用相乘更有意义。

数据归一化

相信知乎 Live 在排序的时候也考虑了 Live 的收入(也就是单价* 购买人数)这个因素,所谓「市场经济下价格基本准确反映供需关系」,一个 Live 是不是受到大家的欢迎从收入以及单价上市可以体现出来的。但是就算我们对文本相关性做了权重的提高,得分也不外乎几十到几百。如果直接和收入相乘,显然是有问题的:小众优秀的主题由于受众小,票价上不去,得分会很低。同理一个低质量的 Live 由于大家一股脑被吸引进来由于收入较高而排在前面。顺便说,知乎 Live 的年度精选就有多个 3 星、三星半的 Live 的存在,有种想不开的感觉。对我这种长期受豆瓣评分体系价值观的影响,无法理解这种评分怎么入选的...

我看 Live 少的一场才收入 2-3k, 而有的一场 10 几 w。怎么对这个指标做标准化处理,让指标之间更具有可比性呢?

这就是数据归一化,也就是把原始数据经过数据标准化处理后,各指标处于同一数量级,适合进行综合对比评价。

归一化的方法很多,我这里为了简单实用,选择了「对数函数转换」。通过 Jupyter Notebook+Matplotlib+Numpy 看一下转换的效果:

这样 2k 被转化为 3.3,10w 会被转化成 5。收入会有影响,但是被极大的缩小了。

再验证下,被归一化之前:

In : s = Live.search()
In : s = s.query('multi_match', query='python', fields=SEARCH_FIELDS)
In : lives = s.query(Q('function_score', boost_mode='multiply', functions=[SF('script_score', script=
...: "doc['seats_taken'].value * doc['amount'].value")])).execute()

In : lives[0]._score
Out: 75663.34

这个得分,我想静静..

ES 内置了脚本语言 painless 和 groovy,我们能直接在 ES 内部进行一些简单的编程。当然也支持 Python 但是需要额外安装了。我觉得完全用不到 Python,尤其是 5.0 开始 painless 成为了默认编程语言。原因是 painless 有如下特点:

  1. 支持 List、map 和 array 等高级数据结构
  2. 性能接近 Java
  3. 内置正则表达式
  4. 支持匿名函数 lambda
  5. 语法简单,学过 Java、Python、Javascript 的开发者很容易就可以学会

随便提一下,编程脚本的时候尽量不要出现一些受外界影响大的变量,比如用户 id 这种,因为 ES 可以缓存脚本,如果使用一些每次查询都不一样的条件去生成脚本会影响性能。

现在我们把 log10 引入再看看:

In : sf = SF('script_score', script={'lang': 'painless', 'inline': "Math.log10(doc['seats_taken'].value* doc['amount'].value)"})
In : lives = s.query(Q('function_score', functions=[sf])).execute()
In : lives[0]._score
Out: 14.504177

这样会合理很多。

今天先讲到这里。下一篇将介绍聚合分析