搜索是什么
对于初学者来说,搜索引擎就是一个神秘的黑盒。只包含少数几种交互模式:将内容加入搜索引擎,然后允许用户对内容进行查询。而在底层,搜索引擎的数据结构其实执行着相当机械的词匹配操作,然后对结果用简单的方式加以排名。
搜索引擎的核心功能是存储、查询并返回结果。存储、搜索和返回的都是文档。
一般我们有很多数据来源如:社区UGC数据、互联网爬取数据、广告数据、电商数据等等。其中有结构化数据、半结构化数据、非结构化数据等等。我们要事先对这些数据做预处理,将其转换成搜索引擎所需要的文档结构,然后在经过一系列的动作(分词、倒排索引等)最终存储到搜索引擎中。
文档
文档是一组字段的集合。字段是有类型的,可以分为标准类型和特殊类型。标准类型如string、int、float、boolean、date等,特殊类型:数组、IP、经纬度、对象等。其中字符串类型尤其受搜索青睐。人们经常在字符串中进行搜索。那么如何更好的进行字符串搜索呢,这里就需要提到分词这个动作了。
分词
分词将较长的句子分割成一个一个的单词或词组,并对分割后的词做一些加工处理:去除停用词、大写转小写、复数转单数、繁转简、中文转拼音等。
分词过程如图二所示,目前常用的中文分词有很多,如:IK分词、结巴分词、Jcseg4j等等。
倒排索引
搜索引擎的核心部分是一种被称为倒排索引的数据结构,它类似于图书后面的索引。由两个主要部分组成:词典和倒排表。这样的数据结构让文档能快速的匹配查询条件。
除了这两部分,倒排索引中还包含有文档频率、词频、词位置、词偏移量、储存字段、文档值等。这些信息能更好的提高搜索的相关性和排序性能。
lucene 的相关度评分
TF:词频,词语在文档中出现的频率。如果一个词频繁的出现在一篇文档的某个字段,那么我们可以认为这个字段很可能涉及到该词。
IDF:逆向文件频率,是一个词语普遍重要性的度量。IDF为文档频率的倒数。如果该词很常见,文档频率就会很高。稀有的词被认为更有价值,而常见的词则相反。
TF*IDF貌似是一个很直观的权重计算公式。单词被提到的次数越多确实会与相关性有关系,但这种关系并不是线性的。一个搜索词在文本中可能出现10次以上,但并不能说明它具有10倍的相关性。在Lucene中通过相似度类来对TF和IDF的影响进行抑制。
抑制TF、IDF:
TF Weight = sqrt(tf)
IDF Weight = log(numDoc /(df +1)) + 1
文本长度归一化因子
字段中包含的项数量.该值在索引期间计算,并保存在索引norm中.对于该因子,更短的字段(或更少的语汇单元)能获得更大的加权
fieldNorm = 1 / sqrt(fieldlength)
Lucene中的TF*IDF公式
TF Weight * IDF Weight * fieldNorm
<lst name="params">
<str name="q">taboo_title:苹果</str>
<str name="fl">taboo_title</str>
<str name="rows">2</str>
<str name="wt">xml</str>
<str name="debugQuery">true</str>
</lst>
<result name="response" numFound="3" start="0">
<doc>
<str name="taboo_title">苹果</str></doc>
<doc>
<str name="taboo_title">苹果醋</str></doc>
</result>
<lst name="explain">
<str name="124">
6.7861304 = weight(taboo_title:苹果 in 110) [DefaultSimilarity], result of:
6.7861304 = fieldWeight in 110, product of:
1.0 = tf(freq=1.0), with freq of:
1.0 = termFreq=1.0
6.7861304 = idf(docFreq=3, maxDocs=1303)
1.0 = fieldNorm(doc=110)
</str>
<str name="312">
4.2413316 = weight(taboo_title:苹果 in 286) [DefaultSimilarity], result of:
4.2413316 = fieldWeight in 286, product of:
1.0 = tf(freq=1.0), with freq of:
1.0 = termFreq=1.0
6.7861304 = idf(docFreq=3, maxDocs=1303)
0.625 = fieldNorm(doc=286)
</str>
</lst>
如上述Solr代码展示的。对于单字段搜索Solr返回的相关度得分满足Lucene的TF*IDF公式。当然对于较为复杂的查询其相关度得分的计算公式会更复杂些。大家有兴趣的话可以看看这一篇博文:lucene 的评分机制
Lucene在4.0版本之后也实现了另一种相似度评分算法:BM25。这是一种不同于TFxIDF的概率模型算法。大家有兴趣的话可以了解下这两篇博文:BM25算法在Lucene中的应用、BM25和Lucene Default Similarity比较
搜索排序
上述篇幅简单介绍了搜索引擎的底层储存结构和相关度评分机制。而搜索的最终目的是根据用户输入的搜索词,将与搜索词相匹配的搜索结果(文档)返回给用户。用户最终看到的是搜索引擎返回的搜索结果,一个好的搜索结果能给用户带来较好的搜索体验。那么如何衡量一个搜索结果的好坏呢?说起来很简单,在搜索结果中优先展示与用户搜索最相关的数据。那么如何做呢?这些就是搜索排序需要做的工作。搜索排序是搜索服务中最为复杂的一个部分。搜索排序逻辑的制定一般需要结合具体的业务场景甚至需要用户的特征和行为偏好数据。以下结合一个搜索场景分别介绍下在Solr和ElasticSearch上的搜索排序实践。
场景描述
在资讯搜索场景中,假如我们对优质搜索结果定义满足如下几个标准:
- 与用户输入搜索词相关性越高排序越靠前
- 越新资讯排序越靠前
- 优质资讯排序靠前
- 用户感兴趣的资讯排序靠前
假设一份资讯文档有三个主要字段,分别是标题(title)、正文(content)、关键字(keywords)。另外有发布时间(published_date)、是否加精(is_elite)、是否推荐(is_recommend)、资讯标签(topic_id)等其他字段。还有我们通过资讯的自身属性(标题正文长度、图片个数、视频时长等)和用户浏览行为(CTR、评论、点赞、收藏、分享等)数据计算资讯的质量得分(item_score)。
我们可以通过搜索词与标题、正文、关键字的匹配程度得到相关度。通过发布时间来确定资讯的新旧。通过是否加精、是否推荐、质量得分等数据来确定一篇资讯的优劣。通过用户历史浏览行为获得其感兴趣的资讯标签,从而确认用户感兴趣的资讯分类。然而这四个维度的信息如何整合才能得到我们想要的排序结果呢?
我们知道搜索引擎的对搜索结果的默认排序逻辑是按相关度得分降序,这显然不满足我们的要求。另外也可以对某个字段做升降序操作(例如发布时间字段),不过这种排序也不能满足当前场景,而且可能造成顶部数据与搜索词相关度较低的现象。那么如何实现这样的排序逻辑呢?下面通过两个例子来讲解下。
一个使用Solr edismax查询的例子
edismax中将查询字段和查询词项分开了,如我们在标准用法中使用name:周杰伦,那么在edismax中,需要在qf中设置查询字段为name,然后在q中输入周杰伦。查询词项会去所有的qf列(query field,可以设置多列,也可以配置为每列配置权重)上进行查询,如果要针对每个字段有不同的查询,如name:周杰伦,age:30,那么需要将其中一个移入到fq中,但是注意,fq中的查询是不影响评分的。
edismax的查询参数详见Solr官方文档介绍:https://lucene.apache.org/solr/guide/6_6/the-extended-dismax-query-parser.html
其中boost参数可以设置一个函数表达式,其表达式计算出来的得分会乘以主查询的相似度得分作为edismax查询的最终得分。可用函数详见Solr官方文档介绍:https://wiki.apache.org/solr/FunctionQuery
查询代码如下:
<lst name="params">
<str name="q">宝宝长牙</str>
<str name="defType">edismax</str>
<str name="qf">title^5 content^0.5 keywords^0.5</str>
<str name="fl">title,keywords,published_date,item_score</str>
<str name="pf">title content keywords</str>
<str name="boost">product(sum(item_score,map(is_elite,1,3,0.2),
map(is_recommend,1,1,0.5)),pow(recip(ms(NOW/HOUR,published_date),3.16e-11,1,1),10))</str>
<str name="wt">xml</str>
<str name="debugQuery">true</str>
</lst>
我们在查询中对标题加了5倍的权重,而又分别下降了正文和关键字一半的权重。因为在这个场景中我们觉得标题匹配的相关度更高。
查询结果如下:
<result name="response" numFound="741" start="0" maxScore="2.8703315">
<doc>
<str name="title">宝宝几个月长牙?宝宝长牙的症状护理</str>
<!-- content字段太长 未列出 -->
<arr name="keywords">
<str>长牙</str>
<str>宝宝</str>
<str>乳牙</str>
</arr>
<date name="published_date">2018-05-11T13:18:11Z</date>
<float name="item_score">0.75903535</float></doc>
<doc>
<str name="title">宝宝几个月才的会长牙 宝宝长牙的症状!</str>
<arr name="keywords">
<str>长牙</str>
<str>宝宝</str>
<str>乳牙</str>
</arr>
<date name="published_date">2018-04-25T02:27:21Z</date>
<float name="item_score">0.7273869</float></doc>
<doc>
<str name="title">宝宝长牙期间的一些小动态,宝妈一定要知道,不然容易遭罪!</str>
<arr name="keywords">
<str>宝妈</str>
<str>长牙</str>
<str>宝宝</str>
</arr>
<date name="published_date">2018-04-19T07:53:10Z</date>
<float name="item_score">0.8170003</float></doc>
<doc>
....
</result>
从结果可以看出发布时间越近的文章排序越靠前,质量分越高的文章排序越靠前。我们分析下boost函数
product(sum(item_score,map(is_elite,1,3,0.2),map(is_recommend,1,1,0.5)),pow(recip(ms(NOW/HOUR,published_date),3.16e-11,1,1),10))
函数表达式就是质量分加上推荐、加精属性再乘以发布时间的一个衰减函数。
recip(ms(NOW/HOUR,published_date),3.16e-11,1,1)
该函数时间的衰减比较平缓,比如昨天的权重是0.999,前天是0.998,一年前的今天是0.5...我们在表达式中对该函数做了10次方运算来放大其衰减速度。
在查询debug模式中我们可以看出最终的文档得分为表达式计算出来的得分会乘以主查询的相似度得分。
1.7736654 = boost(+(title:"宝宝 长牙"^5.0 | keywords:宝宝长牙^0.5 | content:"宝宝 长牙"^0.5) (title:"宝宝 长牙" | content:"宝宝 长牙"),product(sum(float(item_score),map(int(is_elite),1.0,3.0,const(0.2)),map(int(is_recommend),1.0,1.0,const(0.5))),pow(1.0/(3.16E-11*float(ms(const(1526634000000),date(published_date)))+1.0),const(10))))
edismax算出的最终得分在某些应用场景下还是比较尴尬的。例如在一些比较看重文本相关度的搜索场景中。由于用乘积的方式计算出来的最终得分受boost得分影响比较大,可能会出现文本相关度较高的结果排名比较靠后的情况。相对于Solr的edismax查询,ElasticSearch提供了更加灵活的相关性打分机制。
一个使用ElasticSearch function_score查询的例子
function_score是用于处理文档分值的DSL,它会在查询结束后对每一个匹配的文档进行一系列的重打分操作,最后以生成的最终分数进行排序。它提供了多种计算分值的函数:
- script_score:自定义脚本计算
- weight:权重计算
- field_value_factor:根据某个字段计算
- random_score:随机计算
- decay functions:衰减函数,支持gauss, linear, exp等类型
其最终得分计算也相对丰富,通过boost_mode可以指定计算后的分数与原始的_score如何合并,有以下选项:
- multiply:将结果乘以_score
- sum:将结果加上_score
- min:取结果与_score的较小值
- max:取结果与_score的较大值
- replace:使结果替换掉_score
- avg:取结果与_score的平均值
下面还是宝宝长牙这个搜索为例来想想描述下ElasticSearch的function score查询,其索引文档结构与上例一致。查询代码如下,使用ElasticSearch的DSL查询语法:
{
"explain":false,
"_source":["item_id","item_type","type","title","item_score","published_date","topic_id","keywords","is_recommend","is_elite"],
"query": {
"function_score": {
"query": {
"bool":{
"must":{
"multi_match":{
"query":"宝宝长牙",
"type":"cross_fields",
"fields":["title","title_standard","title_pinyin","content","keywords"],
"minimum_should_match": "100%" // should查询最小匹配值
}
},
"filter":[
{
"term":{
"status":"1" // 过滤条件,限定有效数据
}
}
]
}
},
"functions":[
{
"weight":"10",
"filter":{
"match":{
"topic_id":{
"query":"42" // 权重计算,这里对标签是婴儿期的查询结果加10分。假设搜索用户是一个辣妈
}
}
}
},
{
"gauss": {
"published_date": {
"origin": "now",
"scale": "7d",
"decay": "0.5" // 衰减函数 对日期做高斯衰减,提高新数据得分
}
}
},
{
"field_value_factor": {
"field": "item_score",
"modifier": "sqrt",
"factor": 100 // 根据质量得分计算 函数为 sqrt(100 * item_score)
}
},
{
"script_score": {
"script": "return 1 + doc['is_recommend'].value * 1 + doc['is_elite'].value * 0.5" // 自定义脚本函数
}
}
],
"score_mode": "multiply",
"boost_mode":"avg" // 最终结果得分计算方式 这里采用avg
}
},
"from":"0",
"size":"10"
}
查询中使用的minimum_should_match属性详见ES官方文档:minimum-should-match
查询结果如下:
"hits": [
{
"_index": "news",
"_type": "item",
"_id": "20009070_1",
"_score": 39.12396,
"_source": {
"keywords": [
"长牙",
"宝宝",
"乳牙"
],
"item_id": 20009070,
"item_type": 2,
"is_recommend": 0,
"is_elite": 0,
"item_score": 0.2600713,
"topic_id": [
42
],
"title": "宝宝几个月才的会长牙 宝宝长牙的症状!",
"type": 1,
"published_date": 1524594441000
}
},
{
"_index": "news",
"_type": "item",
"_id": "19589591_1",
"_score": 37.407104,
"_source": {
"keywords": [
"出牙",
"长牙",
"宝宝"
],
"item_id": 19589591,
"item_type": 2,
"is_recommend": 0,
"is_elite": 0,
"item_score": 0.7617033,
"topic_id": [
42
],
"title": "宝宝几个月长牙?长牙有什么症状以及长牙晚怎么办?",
"type": 1,
"published_date": 1523958754000
}
}
...
]
ElasticSearch 还提供另一种重打分机制rescore。该打分机制对query和post_filter阶段返回的顶部文档(TOP N)使用更复杂的算法进行重排打分,而不需要计算所有的文档。可以较大的提高查询性能和精准度。详见ES官方文档Rescore
本文简单的介绍了下在资讯搜索场景下,Solr、ElasticSearch两个主流搜索引擎服务的相关度排序与重排调整的一些机制,比较粗浅。大家若想深入了解,推荐阅读Solr和ElasticSearch上的官方文档。另外对于一个好的搜索服务来说,如何能更好收集用户行为、特征和偏好,达到了解您的用户,从而针对不同的用户做个性化的搜索也是至关重要的。本文也只是简单提了下。对于这方面大家有兴趣的话可以去了解一下下面这两篇博文,讲的是目前业界比较流行的Learning To Rank。
LTR(Learning To Rank)在个性化电商搜索领域的应用
在Elasticsearch中应用机器学习排序LTR