ElasticSearch 2 (18) - 深入搜索系列之控制相关度 - Richaaaard - 博客园
ElasticSearch 2 (18) - 深入搜索系列之控制相关度
摘要
处理结构化数据(比如:时间、数字、字符串、枚举)的数据库只需要检查一个文档(或行,在关系数据库)是否与查询匹配。
布尔是/非匹配是全文搜索的基础部分,但不止这些,我们也同样需要知道每个文档与查询的相关度,在全文搜索引擎中我们不仅需要找到匹配的文档,还需要根据他们相关度的高低,对他们进行排序。
全文相关的公式或相似算法(similarity algorithms)会将多个因素合并起来,为每个文档生成一个相关度分数 *_score*。本章中,我们会验证这些不确定的部分,然后讨论如何来控制它们。
当然,相关度不只与全文查询有关,它也需要将结构化的数据考虑其中。可能我们正在找一个度假屋,它需要有一些的详细特征(空调、海景、免费WiFi),匹配的特征越多,越有可能是我们想要找的度假地。可能我们还希望有一些其他的考虑因素,如回头率,价格,流行度,或距离,当然也同时考虑全文查询的相关度。
所有的这些都可以通过ElasticSearch强大的计分基础来实现。
我们会先从理论上介绍Lucene是如何计算相关度的,然后通过实际例子说明如何控制相关度的计算过程的。
版本
elasticsearch版本: elasticsearch-2.x
内容
背后的理论(Theory Behind Relevance Scoring)
Lucene(即ElasticSearch)使用布尔模型(Boolean model)查找匹配文档,并用一个名为实用分数函数(practical scoring function)来计算相关度。这个公式借鉴了词频/逆向文档频率(term frequency/inverse document frequency)和向量空间模型(vector space model),同时也加入了一些现代的新特性,如协调性因素(coordination factor),字段长度正则化(field length normalization),以及术语或查询语句权重提升(term or query clause boosting)。
不要紧张!这些概念并没有像他们字面看起来那么复制,尽管本部分提到了算法、公式和数学模型,但内容还是让人容易理解的,与了解算法本身相比,了解这些因素是如何影响结果要重要得多。
布尔模型(Boolean Model)
布尔模型(Boolean Model)只是将 AND,OR,和 NOT这样的条件在查询中使用以匹配文档,一个下面这样的查询:
full AND text AND search AND (elasticsearch OR lucene)
会将所有包括术语 full,text和search,以及elasticsearch或lucene的文档作为结果集。
这个过程简单且快速,将所有不匹配的文档排除在外。
词频/逆向文档频率(TF/IDF)
当我们匹配到一组文档后,需要排列这些文档的相关度,不是所有的文档都包含所有术语,有些术语也比其他术语重要。一个文档的相关度分数部分取决于每个查询术语在文档中的权重。
这个术语的权重由三个因素决定,在什么是相关(What Is Relevance)中介绍过,有兴趣的可以了解下面的公式,但并不要求记住。
词频(Term frequency)
术语在文档中出现的频度是多少?频度越高,权重越大。一个5次提到同一术语的字段比一个只有1次提到的更相关。词频的计算方式如下:
tf(t in d) = √frequency #1
- #1 术语t在文件d的词频(tf)是这个术语在文档中出现次数的平方根。
如果不在意术语在某个字段中出现的频次,而只在意术语是否出现过,则可以在字段映射中关掉词频统计:
PUT /my_index
{
"mappings": {
"doc": {
"properties": {
"text": {
"type": "string",
"index_options": "docs" #1
}
}
}
}
}
- #1 将参数index_options设置为docs可以关掉词频统计以及词频位置,这个映射的字段不会计算术语出现的次数,对于短语或近似查询也不可以用,默认使用准确值not_analyzed的字符串。
逆向文档频率(Inverse document frequency)
术语在集合所有文档里出现的频次。频次越高,权重越低。常用词如and或the对于相关度贡献非常低,因为他们在多数文档中都会出现,一些不常见术语如elastic或hippopotamus可以帮助我们快速缩小范围找到感兴趣的文档。逆向文档频率的计算公式如下:
idf(t) = 1 + log ( numDocs / (docFreq + 1)) #1
- #1 术语t的逆向文档频率(Inverse document frequency)是:索引中文档数量除以所有包含该术语文档数量后的对数值。
字段长度正则值(Field-length norm)
字段的长度是多少?字段越短,字段的权重越高。如果术语出现在类似标题title这样的字段,要比它出现在内容body这样的字段中的相关度更高。字段长度的正则值公式如下:
norm(d) = 1 / √numTerms #1
- #1 字段长度正则值是字段中术语数平方根的倒数。
字段长度的正则值对全文搜索非常重要,许多其他字段不需要有正则值。无论文档是否包括这个字段,索引中每个文档的每个string字段都大概占用1个byte的空间。准确值not_analyzed字符串字段的正则值默认是打开的,但是可以通过修改字段映射将其关闭:
PUT /my_index
{
"mappings": {
"doc": {
"properties": {
"text": {
"type": "string",
"norms": { "enabled": false } #1
}
}
}
}
}
- #1 这个字段不会将字段长度正则值考虑在内,长字段和短字段会以相同长度计算分数。
对于有些应用场景(比如:日志),正则值不是很有用,所需要关心的只是一个字段是否包含一个特殊的错误码或者一个特定的浏览器。字段的长度对结果没有影响,将正则值关闭可以节省大量内存空间。
结合(Putting it together)
下面三个因素——词频(term frequency)、逆向文档频率(inverse document frequency)和字段长度正则值(field-length norm)——是在索引时计算并存储的。将他们放一起用来计算单个术语在特定文档中的权重。
我们前面公式中提到的文档指的实际上是文档里的某个字段,每个字段都有它自己的反向索引,因此字段的TF/IDF值就是文档的TF/IDF值。
当我们用explain查看一个简单的term查询时,我们可以发现与计算相关度分数的因子就是我们前面介绍的这些:
PUT /my_index/doc/1
{ "text" : "quick brown fox" }
GET /my_index/doc/_search?explain
{
"query": {
"term": {
"text": "fox"
}
}
}
上面请求的简化的explaination如下:
weight(text:fox in 0) [PerFieldSimilarity]: 0.15342641 #1
result of:
fieldWeight in 0 0.15342641
product of:
tf(freq=1.0), with freq of 1: 1.0 #2
idf(docFreq=1, maxDocs=1): 0.30685282 #3
fieldNorm(doc=0): 0.5 #4
- #1 术语fox在文档的内部Lucene doc ID 为0,字段是text里的最终分数。
- #2 术语fox在该文档的text字段中只出现了1次。
- #3fox在所有文档text字段索引的逆向文档频率。
- #4 该字段的字段长度正则值
当然,查询通常包含不止一个术语,所以我们需要一种合并多个术语权重的方式,向量空间(Vector Space Model)可以回答这个问题。
向量空间(Vector Space Model)
向量空间(Vector Space Model)提供比较多术语查询的一种方式,单个分数代表文档与查询的匹配程度,为了做到这点,这个模型将文档和查询都以向量的形式表示:
一个向量实际上就是包含很多数字的一个一维数组,例如:
[1,2,5,22,3,8]
在向量空间模型里,向量里的每个数字都代表一个术语的权重,和TF/IDF计算方式类似。
尽管 TF/IDF 是向量空间模型计算术语权重的默认方式,但不是唯一的方式,ElasticSearch还有其他模型(如:Okapi-BM25)。TF/IDF是默认的因为它是个经检验过的简单、高效算法,可以提供高质量的搜索结果。
如果我们有查询“happy hippopotamus”,常见词happy的权重较低,不常见词hippopotamus权重较高,假设happy的权重是2,hippopotamus的权重是5,我们可以将这个二维向量——[2,5]——在坐标系下作条直线,线起于(0,0)点终于(2,5)点:
现在,假想我们有三个文档:
- I am happy in summer.
- After Christmas I'm a hippopotamus.
- The happy hippopotamus helped Harry.
我们可以为每个文档都创建一个包括每个查询术语(happy 和 hippopotamus)权重的向量,然后将这些向量置入同一个坐标系中:
- 文档 1:(happy,____________)——[2,0]
- 文档 2:(_____,hippopotamus)——[0,5]
- 文档 3:(happy,hippopotamus)——[2,5]
向量之间是可以比较的,只需要测量查询向量和文档向量之间的角度就可以得到每个文档的相关度,文档1与查询之间的角度最大,所以相关度低;文档2与查询间的角度较小,所以更相关;文档3与查询的角度正好吻合,完全匹配。
在实际中,只有二维向量(两个术语的查询)可以在平面上表示,幸运的是,线性代数——作为数学中处理向量的一个分支——为我们提供了计算两个多维向量间角度工具,这意味着我们可以使用如上同样的方式来解释多个术语的查询。
关于比较两个向量的更多信息可以在余弦近似度(cosine similarity)中看到
现在我们讨论了分数计算的基本理论,我们可以继续了解Lucene是如何实现分数计算的。
Lucene的计分函数(Lucene’s Practical Scoring Function)
对于多术语查询,Lucene采用布尔模型(Boolean model)、TF/IDF、以及向量空间模型(Vector Space Model),然后将他们合并到单个包中来收集匹配文档和分数计算。
一个多术语查询如下:
GET /my_index/doc/_search
{
"query": {
"match": {
"text": "quick fox"
}
}
}
会在内部被重写为:
GET /my_index/doc/_search
{
"query": {
"bool": {
"should": [
{"term": { "text": "quick" }},
{"term": { "text": "fox" }}
]
}
}
}
bool查询实现了布尔模型,在这个例子中,它会将包括术语quick和fox或两者兼有的文档作为查询结果。
只要一个文档与查询匹配,Lucene就会为查询计算分数,然后合并每个匹配术语的分数。这里使用的分数计算公式叫做实用计分函数(practical scoring function)。看似很高达上,但是别担心——多数的组件都已经介绍过,我们下一步会介绍它引入的一些新元素。
score(q,d) = #1
queryNorm(q) #2
· coord(q,d) #3
· ∑ ( #4
tf(t in d) #5
· idf(t)² #6
· t.getBoost() #7
· norm(t,d) #8
) (t in q) #9
- #1 score(q, d) 是文档d与 查询q的相关度分数
- #2 queryNorm(q) 是查询正则因子(query normalization factor)(新)。
- #3 coord(q, d) 是协调因子(coordination factor)(新)。
- #4 #9 查询q中每个术语t对于文档d的权重和。
- #5 tf(t in d) 是术语t在文档d中的词频。
- #6 idf(t) 是术语t的逆向文档频次。
- #7 t.getBoost() 是查询中使用的boost(新)。
- #8 norm(t,d) 是字段长度正则值,与索引时字段级的boost的和(如果存在)(新)。
我们应该认识 score,tf 和 idf。queryNorm,coord,t.getBoost和norm是新的。
我们会在本章后面继续探讨查询时的权重提升问题,但是首先让我们了解查询正则化、协调和索引时字段层面的权重提升等概念。
查询正则因子(Query Normalization Factor)
查询正则因子(queryNorm)试图将查询正则化,这样就能比较两个不同查询结果。
尽管查询正则值的目的是为了使查询结果之间能够相互比较,但是它并不十分有效,因为相关度分数 *_score* 的目的是为了将当前查询的结果进行排序,比较不同查询结果的相关度分数没有太大意义。
这个因子是在查询开始前计算的,具体的计算依赖于具体查询,一个典型的实现如下:
queryNorm = 1 / √sumOfSquaredWeights #1
- #1sumOfSquaredWeights是查询里每个术语的IDF的平方和。
相同查询正则因子会被应用到每个文档,我们无法改变它,总而言之,可以被忽略。
查询协调(Query Coordination)
协调因子(coord)可以为那些查询术语包含度高的文档提供“奖励”,文档里出现的查询术语越多,它越有机会成为一个好的匹配结果。
假如我们有个查询是“quick brown fox”,每个术语的权重都是1.5。如果没有协调因子,最终分数会是文档里所有术语权重之和。例如:
- 文档里有 fox -> 分数是: 1.5
- 文档里有 quick fox -> 分数是: 3.0
- 文档里有 quick brown fox -> 分数是: 4.5
协调因子将分数与文档里匹配术语的数量相乘,然后除以查询里所有术语的数量,如果使用协调因子,分数会变成:
- 文档里有 fox -> 分数是: 1.5 * 1 / 3 = 0.5
- 文档里有 quick fox -> 分数是: 3.0 * 2 / 3
- 文档里有 quick brown fox -> 分数是: 4.5 * 3 / 3 = 4.5
协调因子能使包含所有三个术语的文档比只包含两个术语的文档得分要高处许多。
将quick brown fox查询写成bool查询:
GET /_search
{
"query": {
"bool": {
"should": [
{ "term": { "text": "quick" }},
{ "term": { "text": "brown" }},
{ "term": { "text": "fox" }}
]
}
}
}
bool查询默认会对所有should语句使用协调功能,不过我们也可以将其关闭。为什么要这样做?通常的回答是——无须这样。查询协调通常是件好事,当我们使用bool查询将多个高级查询如match查询包裹的时候,让协调功能打开是有意义的,匹配的语句越多,查询请求与返回文档间的重叠度就越高。
但在某些高级应用中,我们将协调功能关闭可能更好。假如我们正在查找同义词jump、leap和hop。我们并不关心会出现多少个同义词,因为它们都表示一个意思,实际上,只有其中一个同义词会出现,这是不使用协调功能的一个好的例子:
GET /_search
{
"query": {
"bool": {
"disable_coord": true,
"should": [
{ "term": { "text": "jump" }},
{ "term": { "text": "hop" }},
{ "term": { "text": "leap" }}
]
}
}
}
当我们使用同义词的时候(参照:同义词(Synonyms)),Lucene内部是这样的:重写的的查询会为同义词关闭协调功能,大多数关闭操作的应用场景是自动处理的,无须为此担心。
索引时字段层权重提升(Index-Time Field-Level Boosting)
我们将会讨论在查询时,让字段(Field)的权重提升(即让某个字段比其他字段更重要),当然在索引时也能做到如此。实际上,权重的提升会被应用到字段的每个词条(term)中,而不是字段本身。
将提升值存储与索引中不需要使用更多空间,这个字段层次索引时的提升值与字段长度正则值(参照:字段长度正则值(Field-length norm))一起作为单个byte被存放于索引中,norm(t,d)是前面公式的返回值。
由于以下原因,我们不建议使用字段层索引时的权重提升方法:
- 将提升值与字段长度正则值合在一个byte中存储会丢失字段长度正则值的精度,这样会导致ElasticSearch不知如何区分包含三个词的字段和包含五个词的字段。
- 我要想改变索引时的提升值,就必须重新为所有文档建立索引,与此不同的是,查询时的提升值可以随着每次查询的不同而更改。
- 如果一个索引时权重提升的字段有多个值,提升值会按照每个值来自乘,这会导致该字段的权重会急剧上升。
查询时的权重提升(Querying-time boosting)更为简单、清楚,而且有灵活的选择。
了解了查询正则化,协同,和索引时权重提升这些方式后,我们可以进一步了解相关度计算的最有用工具:查询时权重提升(query-time boosting)。
查询时权重提升(Query-Time Boosting)
在语句优先级(Prioritizing Clauses)中,我们解释了如何在搜索时使用权重提升参数让一个查询语句比其他语句更重要。如:
GET /_search
{
"query": {
"bool": {
"should": [
{
"match": {
"title": {
"query": "quick brown fox",
"boost": 2 #1
}
}
},
{
"match": { #2
"content": "quick brown fox"
}
}
]
}
}
}
- #1title查询语句的重要性是content的2倍,因为它的权重提升值为2。
- #2 一个没有设置权重提升的查询语句的值为1。
查询时的权重提升是我们可以用来影响相关度的主要工具,任意一种类型的查询都能接受权重提升(boost)参数。将权重提升值设置为2,并不代表最终的分数会是原值的2倍;权重提升值会经过正则化和一些其他内部优化过程。尽管如此,它确实想要表明一个提升值为2的句子的重要性是提升值为1句子的2倍。
实际应用中,无法通过一个简单的公式得出某个特定查询语句的正确权重提升值,只能通过不同尝试获得。需要明白的是权重提升值只是影响相关度分数的其中一个因子。在前面的例子中,title字段相对content字段可能已经有一个隐性的权重提升值,因为在字段长度正则值中,标题往往比相关内容要短,所以不要想当然的去盲目提升一些字段的权重。选择权重,检查结果,如此往复。
提升索引权重(Boosting an Index)
当在多个索引中进行搜索时,我们可以使用参数indices_boost来提升整个索引的权重,在下面这个例子中,当我们想要为最近索引的文档分配更高权重时,可以这么做:
GET /docs_2014_*/_search #1
{
"indices_boost": { #2
"docs_2014_10": 3,
"docs_2014_09": 2
},
"query": {
"match": {
"text": "quick brown fox"
}
}
}
- #1 这个多索引查询涵盖了所有以字符串docs_2014_开始的索引
- #2 其中,索引docs_2014_10中的所有文件的权重提升值是3,索引docs_2014_10中是2,其他所有匹配的索引的提升值为1。
t.getBoost()
这些提升值在Lucene实用计分函数(Lucene's Practical Scoring Function)中可以通过t.getBoost()获得。权重提升不会被应用于它在查询DSL中出现的层次,而是会被合并下转至每个词项中。t.getBoost()始终返回当前词项的权重或者当前分析链上当前查询的权重。
实际上,要想解读 explain 的输出是非常复杂的,我们看不到提升值,也无法访问上面提到的t.getBoost()方法,权重值融合在queryNorm中并应用到每个词项。尽管我们说,queryNorm对于每个术语都是相同的,我们还是会发现一个权重提升过的词项的queryNorm值要高于一个没有提升过的。
使用查询结构修改相关度(Manipulating Relevance with Query Structure)
ElasticSearch的查询DSL相当灵活,我们可以通过在查询结构中,移动查询语句的位置来或多或少的改变它们的重要性,比如,我们有下面这个查询:
quick OR brown OR red OR fox
我们可以将所有词项都放在bool查询的同一层中:
GET /_search
{
"query": {
"bool": {
"should": [
{ "term": { "text": "quick" }},
{ "term": { "text": "brown" }},
{ "term": { "text": "red" }},
{ "term": { "text": "fox" }}
]
}
}
}
这个查询可能最终给包含 quick、red 和 brown词项的文档的分数与包含 quick、red和fox词项文档的分数相同,这里red和brown是同义词,可能只需要保留其中一个,而我们真正要表达的意思是想查询:
quick OR (brown OR red) OR fox
根据标准的布尔逻辑,这与原始的查询是完全一样的,但是我们已经在组合查询(Combining Queries)看到,一个bool查询不关心文档的相关性,而关心文档是否能匹配。
上述查询有个更好的方式:
GET /_search
{
"query": {
"bool": {
"should": [
{ "term": { "text": "quick" }},
{ "term": { "text": "fox" }},
{
"bool": {
"should": [
{ "term": { "text": "brown" }},
{ "term": { "text": "red" }}
]
}
}
]
}
}
}
现在,red和brown处于相互竞争的层次,quick、fox以及red OR brown则是处于顶层且相互竞争的词项。
我们已经讨论过是如何使用match、multi_match、term、bool和dis_max查询来修改相关度分数的。本章后面的内容会介绍另外三个与相关度分数有关的查询:boosting查询、constant_score查询和function_score查询。
不完全不(Not Quite Not)
在互联网上搜索“Apple”,返回的结果很可能是一个公司、水果和各种食谱。我们可以将查询结果的范围缩小至只返回公司,然后排除pie、tart、crumble和tree这样的词,在bool查询中用must_not语句来实现:
GET /_search
{
"query": {
"bool": {
"must": {
"match": {
"text": "apple"
}
},
"must_not": {
"match": {
"text": "pie tart fruit crumble tree"
}
}
}
}
}
但是谁又敢保证在排除tree或crumble这种词后,不会让我们错失一个与苹果公司特别相关的文档呢?有时,must_not条件会过于严格。
权重提升查询(boosting Query)
权重提升查询(boosting Query)恰恰解决了我们的问题。它仍然允许我们将关于水果或甜点的结果包含到结果中,但是将他们降级——即降低他们原来可能占据的排名。
GET /_search
{
"query": {
"boosting": {
"positive": {
"match": {
"text": "apple"
}
},
"negative": {
"match": {
"text": "pie tart fruit crumble tree"
}
},
"negative_boost": 0.5
}
}
}
它接受postive和negative查询,将只有与postive查询匹配的文档包括在结果急中,而将与negative查询匹配的文档的原始分数 *_score* 与negative_boost相乘。
为了达到效果,negative_boost的值必须小于1.0。在这个例子中,所有包含负向词项的文档的分数都会减半。
忽略TF/IDF(Ignoring TF/IDF)
有些时候我们根本不关心 TF/IDF,我们只想知道一个词是否在某个字段中出现过。我们可能回想搜索一个可以度假的屋子并希望它能有以下特性:
- WiFi
- Garden
- Pool
这个度假屋的文档如下:
{ "description": "A delightful four-bedroomed house with ... " }
我们可以用简单的 match 查询进行匹配:
GET /_search
{
"query": {
"match": {
"description": "wifi garden pool"
}
}
}
但这并不是真正的全文搜索,此种情况下,TF/IDF并无卵用。我们既不关心wifi是否为一个普通词项,也不关心它在文档中出现是否频繁,我们关心的只是它出现过就行。实际上,我们希望根据房屋的特性对其排名——特性越多越好。如果特性出现,则记1分,不出现记0分。
constant_score查询
在constant_score查询中,它可以包含一个查询或一个过滤,为任意一个匹配的文档指定分数0,忽略TF/IDF信息:
GET /_search
{
"query": {
"bool": {
"should": [
{ "constant_score": {
"query": { "match": { "description": "wifi" }}
}},
{ "constant_score": {
"query": { "match": { "description": "garden" }}
}},
{ "constant_score": {
"query": { "match": { "description": "pool" }}
}}
]
}
}
}
可能不是所有的特性都同等重要——尽管对于用户来说有些特性更具价值,所以我们可以为更重要的特性指定权重提升值,让它更具相关性:
GET /_search
{
"query": {
"bool": {
"should": [
{ "constant_score": {
"query": { "match": { "description": "wifi" }}
}},
{ "constant_score": {
"query": { "match": { "description": "garden" }}
}},
{ "constant_score": {
"boost": 2 #1
"query": { "match": { "description": "pool" }}
}}
]
}
}
}
pool语句的权重提升值为2,而其他的语句为1。
最终的分数并不是所有匹配语句的简单加和,协调因子(coordination factor)和查询规则化因子(query normalization factor)仍然会被考虑。
我们可以加一个 not_analyzed features 字段来改进度假屋文档:
{ "features": [ "wifi", "pool", "garden" ] }
默认情况下,一个not_analyzed字段会关闭 字段长度正则功能(field-length norms)并且将index_options设为docs选项,关闭词频统计(term frequencies),但还有个问题:每个词项的逆向文档频率(inverse document frequency)仍然会被考虑。
我们在constant_score查询中用前面用过的方法来解决这个问题:
GET /_search
{
"query": {
"bool": {
"should": [
{ "constant_score": {
"query": { "match": { "features": "wifi" }}
}},
{ "constant_score": {
"query": { "match": { "features": "garden" }}
}},
{ "constant_score": {
"boost": 2
"query": { "match": { "features": "pool" }}
}}
]
}
}
}
实际上,每个特性都应该看成一个过滤器,对于度假屋来说要么具有某个特性要么没有——过滤器因为其特性正合适。而且,如果我们使用过滤器,还可以从缓存那里得到好处。
这里的问题是:过滤器无法计算分数。这样我们就需要寻求一种方式将过滤器和查询间的坑填平。function_score查询不仅正好可以扮演这个角色,而且有更强大的功能。
function_score查询(function_score Query)
function_score 查询(function_score query)是用来控制算分过程的终极武器,它允许我们为每个与主查询匹配的文档应用一个函数,以达到改变甚至完全替换原始分数的目的。
实际上,我们也能用过滤器对结果的子集应用不同的函数,这让我们一箭双雕:既能高效算分,也能利用过滤器的缓存特性。
ElasticSearch预定义了一些函数:
weight
为每个文档应用一个简单的而不被正则化的权重提升值:当weight为2时,最终结果为2_score*
field_value_factor
使用这个值来修改 _score,如将流行度或赞成数作为考虑因素。
random_score
为每个用户都使用一个不同的随机分数来对结果排序,但对某一具体用户来说,看到的顺序始终是一致的。
Decay functions — linear, exp, gauss
将分数与浮动值一起使用(如:publish_date、geo_location、或 price)提供偏好结果:最近发布的文档,某一具体位置附近的文档,或某一具体价格附近的文档。
script_score
如果需求超出以上范围时,用自定义脚本完全控制分数计算的逻辑。
如果没有function_score查询,我们就不能将全文查询与最近时间这种因子一起结合,而需要对分数或时间进行排序;这会相互影响抵消两种排序各自效果。这个查询可以让我们将两个效果融合,仍然按照全文相关度进行排序,只是会更多考虑:最近发布的文档、流行的文档、或用户在意的价格区间。正如我们想象的一样,一个查询要想考虑所有这些因素会非常复杂,让我们先从简单的例子开始,然后顺着梯子慢慢向上爬。
流行度提升权重(Boosting by Popularity)
假设我们有一个网站供用户发布博客并且可以让他们为自己喜欢的博客点赞,我们希望将更受欢迎的博客放在搜索结果列表中相对较上的位置,但是全文搜索的分数仍然作为相关度的主要部分,简单的,我们可以根据每个博客的点赞数进行排序:
PUT /blogposts/post/1
{
"title": "About popularity",
"content": "In this post we will talk about...",
"votes": 6
}
在搜索时,我们可以将function_score查询与field_value_factor结合使用,即将点赞数量与全文相关度分数结合:
GET /blogposts/post/_search
{
"query": {
"function_score": { #1
"query": { #2
"multi_match": {
"query": "popularity",
"fields": [ "title", "content" ]
}
},
"field_value_factor": { #3
"field": "votes" #4
}
}
}
}
- #1function_score查询将主查询和函数包括在内。
- #2 主查询优先执行。
- #3field_value_factor函数会被应用到每个与主查询匹配的文档。
- #4 每个文档的votes字段都必须有值供function_score计算。
在前面的例子中,每个文档的最终分数会被修改:
new_score = old_score * number_of_votes
然而这并不会给我们带来出人意料的结果,全文分数通常处于0到10之间,如下图中,即使有10个赞的博客也会被全文分数掩盖,而0个赞的博客的分数会被置为0。
Figure 29. Linear popularity based on an original _score of 2.0
修饰语(modifier)
修饰语(modifier)是一种可以将受欢迎度(即点赞数)平滑融合的一种方式。换句话说,我们希望最开始的一些赞更重要,但是其重要性会随着数字的增加而降低。0个赞与1个赞的区别应该比10个赞与11个赞的区别大很多。
modifier的一个典型的应用场景是log1p,它的公式为:
new_score = old_score * log(1 + number_of_votes)
对数函数使赞这个字段的分数曲线更平滑:
Figure 30. Logarithmic popularity based on an original _score of 2.0
使用modifier的请求如下:
GET /blogposts/post/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "popularity",
"fields": [ "title", "content" ]
}
},
"field_value_factor": {
"field": "votes",
"modifier": "log1p"
}
}
}
}
- #1modifier为log1p
修饰语(modifier)的值可以为:none(默认状态)、log、log1p、log2p、ln、ln1p、ln2p、square、sqrt以及reciprocal。想要了解更多信息请参照文档:field_value_factor。
因子(factor)
可以通过将点赞数与一个因子(factor)相乘来调节受欢迎程度效果的高低:
GET /blogposts/post/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "popularity",
"fields": [ "title", "content" ]
}
},
"field_value_factor": {
"field": "votes",
"modifier": "log1p",
"factor": 2 #1
}
}
}
}
- #1 双倍效果。
将factor加入到公式中:
new_score = old_score * log(1 + factor * number_of_votes)
factor值大于1会提升效果,factor值小于1会降低效果,如下图:
Figure 31. Logarithmic popularity with different factors
提升模式(boost_mode)
可能将全文分数与field_value_factor函数值相乘的效果仍然可能会是放大的,我们可以通过 参数boost_mode来控制函数与查询分数合并后的结果,参数接受的值为:
multiply
将分数与函数值相乘(默认)
sum
将分数与函数值相加
min
分数与函数值的较小值
max
分数与函数值的较大值
replace
函数值替代分数
与使用相乘的方式相比,使用分数与函数值相加的方式可以让我们弱化最终效果,特别是当我们用一个较小的因子时:
GET /blogposts/post/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "popularity",
"fields": [ "title", "content" ]
}
},
"field_value_factor": {
"field": "votes",
"modifier": "log1p",
"factor": 0.1
},
"boost_mode": "sum" #1
}
}
}
- #1 将分数与函数值相乘。
之前请求的公式现在变成下面这样(参考图:Figure 32. Combining popularity with sum):
new_score = old_score + log(1 + 0.1 * number_of_votes)
Figure 32. Combining popularity with sum
最大提升值(max_boost)
最后,我们可以使用max_boost参数限制一个函数的最大效果。
GET /blogposts/post/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "popularity",
"fields": [ "title", "content" ]
}
},
"field_value_factor": {
"field": "votes",
"modifier": "log1p",
"factor": 0.1
},
"boost_mode": "sum",
"max_boost": 1.5 #1
}
}
}
- /#1 无论field_value_factor函数的结果如何,最终结果都不会大于1.5。
注意:
max_boost只对函数的结果进行限制,不会对最终分数产生直接影响。
提升过滤集权重(Boosting Filtered Subsets)
让我们回到忽略TF/IDF里提到的问题,我们希望根据每个度假屋的特性数量来算分,当时我们希望能用缓存的过滤器来影响分数,现在function_score查询正好可以达成我们的愿望。
到目前为止,我们展现的都是为所有文档使用单个函数的使用方式,现在我们会用过滤器将结果划分为多个子集(每个特性一个过滤器),并为每个子集使用不同的函数。
在下面这个例子中,我们会使用weight函数,它与boost参数类似可以用于任何查询。有一点区别是weight没有被Lucene正则成生涩的浮点数,而是使用它自己。
查询的结构需要做相应调整以适应多个函数:
GET /_search
{
"query": {
"function_score": {
"filter": { #1
"term": { "city": "Barcelona" }
},
"functions": [ #2
{
"filter": { "term": { "features": "wifi" }}, #3
"weight": 1
},
{
"filter": { "term": { "features": "garden" }}, #4
"weight": 1
},
{
"filter": { "term": { "features": "pool" }}, #5
"weight": 2 #6
}
],
"score_mode": "sum", #7
}
}
}
- /#1function_score查询有一个过滤器而不是查询。
- /#2functions关键字存储着一个将被应用的函数列表。
- /#3 #4 #5 函数会被应用于和(可选的)过滤器匹配的文档。
- /#6pool比其他特性更重要,所以它有更高的权重。
- /#7score_mode指明每个函数的值是如何合并计算的。
这个新特性需要注意的地方会在下面介绍:
过滤vs.查询(filter Versus query)
首先要注意的事情是我们用过滤器(filter)替代了查询(query),在这个例子中,我们无须使用全文搜索,我们只想找到所有city字段是Barcelona的文档,逻辑用filter而不是query来表达会更清晰。过滤器返回的所有文档的分数值为1。function_score查询接受query或filter,如果没有特别申明,则默认使用match_all查询。
函数(functions)
functions关键字保持着一个将要被使用的函数列表。可以为数组里的每个项都指定一个过滤器(filter),这种情况下,函数只会被应用到那些与过滤器匹配的文档,例子中,我们为与过滤器匹配的文档指定权重值为1(为与pool匹配的文档指定权重值为2)。
score_mode
每个函数返回一个结果,所以我们需要一种将多个结果缩减到一个值的方式,然后才能将其与原始分数合并。score_mode正好扮演了这样的角色,它接受以下值:
multiply
函数结果求积(默认)
sum
函数结果求和
avg
函数结果求平均值
max
函数结果求最大值
min
函数结果求最小值
first
将第一个函数(可以有过滤器,也可没有)的结果作为最终结果
例子中,我们将每个过滤器匹配结果的权重求和,并将其作为最终分数,所以我们使用sum算分模式。
不与任何过滤器匹配的文档会保有其原始分数:1。
随机计分(Random Scoring)
我们可以会想知道一致随机算分(consistently random scoring)是什么,我们又为什么会使用它。之前一个例子是个很好的应用场景,前例中所有的结果都会返回1、2、3、4或5这样的最终分数,可能只有少数房子的分数是5分,而有大量房子的分数是 2 或 3 。
作为一个网站的所有者,我们希望让广告有更高的展现率。在当前查询下,有相同分数的结果会每次都以相同次序出现,为了提高展现率,我们在此引入一些随机性可能是个好主意,这能保证具有相同分数的文档都能有均等相似的展现机率。
我们想让每个用户看到不同的随机次序,但是我们希望如果是同一用户浏览翻页时,结果的相对次序能始终保持一致。这种行为被称为一致随机(consistently random)。
random_score函数会输出一个 0 到 1之间的数,当种子(seed)值相同时,它生成的随机结果是一致的,就如一个用户的对话ID(session ID)一样:
GET /_search
{
"query": {
"function_score": {
"filter": {
"term": { "city": "Barcelona" }
},
"functions": [
{
"filter": { "term": { "features": "wifi" }},
"weight": 1
},
{
"filter": { "term": { "features": "garden" }},
"weight": 1
},
{
"filter": { "term": { "features": "pool" }},
"weight": 2
},
{
"random_score": { #1
"seed": "the users session id" #2
}
}
],
"score_mode": "sum",
}
}
}
- #1random_score语句没有任何过滤器,所以会被应用到所有文档。
- #2 将用户的 session ID 作为种子(seed),让该用户的随机始终保持一致,相同的种子(seed)会产生相同的随机结果。
当然,如果我们索引与查询匹配的新建文档,无论是否使用一致随机结果的顺序都会发生变化。
越近越好(The Closer, The Better)
很多变量都可以影响用户对于度假屋的选择,也许用户希望离市中心近点,但但价格足够便宜时也有可能选择一个更远的住处,又可能反过来是正确的:愿意为最好的位置付更多的价钱。
如果我们添加一个过滤器排除所有市中心方圆1千米以外的度假屋,或所有价格超过100英镑每晚的,我们可能会将用户愿意考虑妥协的那些选择排除在外。
function_score查询会提供一组衰减函数(decay functions),让我们有能力在两个滑动标准(如:地点和价格)之间进行权衡。
有三种衰减函数——线性(linear)、指数(exp)和高斯(gauss)函数,它们可以操作数值、时间以及 经纬度地理坐标点这样的字段。三个都能接受以下参数:
origin
代表中心点(central point)或字段可能的最佳值,落在原点(origin)上的文档分数为满分1.0。
scale
代表衰减率,即一个文档从原点(origin)下落时,分数改变的速度。(如,每10欧元或每100米)。
decay
从原点(origin)衰减到scale所得到的分数,默认值为0.5。
offset
以原点(origin)为中心点,为其设置一个非零的偏移量(offset)覆盖一个范围,而不只是原点(origin)这单个点。在此范围内(-offset <= origin <= +offset)的所有值的分数都是1.0。
这三个函数的唯一不同就是他们衰减曲线的形状,用图形说明会更为直观(参考:Figure 33, “Decay function curves”)
Figure 33. Decay function curves
图中所有曲线的原点(origin)(即中心点)的值都是40,偏移量(offset)是5,也就是在范围40 - 5 <= value <= 40 + 5内的所有值都会被当作原点(origin)处理——所有这些点的分数都是满分1.0。
在此范围之外,分数开始衰减,衰减率由scale值(此例中的值为5)和衰减值(decay)(此例中为默认值0.5)共同决定。结果是所有三个曲线在origin +/- (offset + scale)处的分数都是0.5,即点30和50处。
线性(linear)、指数(exp)和高斯(gauss)函数三者之间的区别在于范围(-offset <= origin <= +offset)之外的曲线形状:
- 线性(linear)函数是条直线,一旦直线与横轴0相交,所有其他值的分数都是0.0。
- 指数(exp = exponential)函数是先剧烈衰减然后变缓。
- 高斯(gauss = Gaussian)函数是钟形的——它的衰减速率是先缓慢,然后变快,最后又放缓。
选着曲线的依据完全由我们希望分数的衰减速率来决定,即距离原点(origin)的远近值。
回到我们的例子:我们的用户希望租一个离伦敦市中心近且每晚不超过100英镑的度假屋,而且与距离相比,我们的用户对价格更为敏感,这样查询可以写成:
GET /_search
{
"query": {
"function_score": {
"functions": [
{
"gauss": {
"location": { #1
"origin": { "lat": 51.5, "lon": 0.12 },
"offset": "2km",
"scale": "3km"
}
}
},
{
"gauss": {
"price": { #2
"origin": "50", #3
"offset": "50",
"scale": "20"
}
},
"weight": 2 #4
}
]
}
}
}
- #1location字段以地理坐标点(geo_point)映射。
- #2price字段是数值。
- #3 可以参考理解价格语句( Understanding the price Clause )了解 origin为什么是50而不是100。
- #4price语句有location语句2倍的权重。
location语句可以简单理解为:
- 我们以伦敦市中作为原点(origin)。
- 所有原点(origin)2千米范围内的位置的分数是1.0。
- 距中心5千米(offset + scale)的位置的分数是0.5。
理解价格语句(Understanding the price Clause)
price语句使用了一个小技巧:用户希望选择100英镑以下的度假屋,但是例子中的原点(origin)被设置成50英镑,价格不能为负,但肯定是越低越好,所以0到100英镑内的所有价格都认为是比较好的。
如果我们将原点(origin)被设置成100英镑,那么低于这个数的度假屋的分数会变低,所以,我们将原点(origin)和偏移量(offset)同时设置成50英镑,这样就能使只有在价格高于100英镑(origin + offset)时分数才会变低。
weight参数可以被用来调整每个语句的贡献度,默认值是1.0。这个值会先与每个句子的分数相乘,然后再通过 score_mode 的设置方式合并。
脚本评分(Scoring with Scripts)
最后,如果所有function_score内置的函数都无法满足我们的应用场景,可以使用script_score函数来自行实现逻辑。
举个例子,我们想要将利润空间作为因子加入到相关度评分计算中,业务中,利润空间和以下三点相关:
- price度假屋每晚的价格。
- 会员用户的级别——某些等级的用户可以在每晚房价高于某个价格的时候享受折扣。
- 用户享受折扣后,经过议价的每晚房价。
计算每个度假屋利润的算法如下:
if (price < threshold) {
profit = price * margin
} else {
profit = price * (1 - discount) * margin;
}
我们很可能不想用绝对利润作为评分,这会弱化其他其他因子(如:地点、受欢迎度和特性)的作用,而是将利润以我们目标利润的百分比来表示,高于我们目标的利润空间会有一个正向分数(大于1.0),低于我们目标的利润空间会有一个负向分数(小于1.0):
if (price < threshold) {
profit = price * margin
} else {
profit = price * (1 - discount) * margin
}
return profit / target
ElasticSearch里使用Groovy作为默认的脚本语言,它与JavaScript很像,上面这个算法用Groovy脚本来表示如下:
price = doc['price'].value #1
margin = doc['margin'].value #2
if (price < threshold) { #3
return price * margin / target
}
return price * (1 - discount) * margin / target #4
- #1 #2price和margin变量可以分别从文档的price和margin字段提取。
- #3 #4threshold、discount和target是作为参数(params)传入的。
最终我们将script_score函数与其他函数一起使用:
GET /_search
{
"function_score": {
"functions": [
{ ...location clause... }, #1
{ ...price clause... }, #2
{
"script_score": {
"params": { #3
"threshold": 80,
"discount": 0.1,
"target": 10
},
"script": "price = doc['price'].value; margin = doc['margin'].value;
if (price < threshold) { return price * margin / target };
return price * (1 - discount) * margin / target;" #4
}
}
]
}
}
- #1 #2location和better语句在越近越好(The Closer,The Better)中解释过。
- #3 将这些变量作为参数params传递,我们可以查询时动态的改变脚本而无须重新编译。
- #4 JSON不能接受内嵌的换行符,脚本中的换行符可以用\n或;替代。
这个查询根据用户对地点和价格的需求,返回最为之满意的文档,同时也考虑到我们对于盈利的要求。
scirpt_score函数提供了巨大的灵活性,我们可以通过脚本访问文档里的所有字段、当前评分甚至词频、逆向文档频率和字段长度正则值这样的信息(参见文本评分脚本(Text scoring in scripts))。
有人会说,使用脚本对性能会有影响,如果我们确实发现了执行较慢的脚本,可以有以下三个选择:
- 尽可能多的提前计算各种信息并将结果存入每个文档中。
- Groovy很快,但没Java那么快。可以将我们的脚本用native Java script重新实现。(参见Naive Java Scripts)。
- 仅对那些最佳评分的文档应用脚本,用Rescoring Results中提到的rescore功能。
可插拔的相似度算法(Pluggable Similarity Algorithms)
在我们进一步讨论相关度和评分之前,我们会以一个更高级的话题结束本章节的内容:可插拔的相似度算法(Pluggable Similarity Algorithms)。Elasticsearch将Lucene's Practical Scoring Function作为他的默认相似度算法,它也能够支持其他的一些算法,这些算法可以从文档相似度模块(Similarity Models)中找到。
Okapi BM25
能与 TF/IDF 和 向量空间模型 一个强力竞争对手就是 Okapi BM25,它被认为是目前最先进的排序函数。BM25源自概率相关模型(probabilistic relevance model),而不是向量空间模型(vector space model),但这个算法也和Lucene's Practical Scoring Function有很多共通之处。
BM25同样使用词频(term frequency)、逆向文档频率(inverse document frequency)以及字段长正则化,但是每个因子的定义都有细微区别。与其详细解释BM25公式,倒不如将关注点放在BM25能为我们带来的实际好处上。
词频饱和度(Term-frequency saturation)
TF/IDF 和 BM25 同样使用 逆向文档频率(IDF)来区分普通词(不重要)和非普通词(重要),同样认为文档里的某个词出现次数越频繁,文档与这个词就越相关。
不幸的是,普通词随处可见,实际上一个普通词在文档里大量出现会抵消该词在所有文档中大量出现的作用。
曾经有个时期,将最普通的词(或停用词,参见停用词:性能与准确度)从索引中移除被认为是一种标准实践,TF/IDF正是在这种背景下诞生的。TF/IDF没有考虑词频上限的问题,因为高频停用词已经被移除了。
Elasticsearch的标准分析器(string字段默认使用)不会移除停用词,因为尽管这些词的重要性很低,但也不是毫无用处。这导致了一个结果:在一个相当长的文档中,像the和and这样词出现的数量会高得离谱,以致它们的权重会被人为放大。
与之相比,BM25有一个上限,文档里出现5到10次的词项会比那些只出现1、2次的对相关度有着显著影响。但是通过图(Figure 34. Term frequency saturation for TF/IDF and BM25)可以看到,文档中出现20次的词项几乎与那些出现上千次的词项有着相同的影响。
这就是非线性词频饱和度(nonlinear term-frequency saturation)。
Figure 34. Term frequency saturation for TF/IDF and BM25
字段长正则化(Field-length normalization)
在字段长正则化(Field-length norm)中,我们提到过Lucene会认为较短字段比较长字段更重要:一个字段某个词项的频度所带来的重要性会被这个字段长度抵消,但是实际的评分函数会将所有字段以同等方式对待。它认为所有的title字段(因为它短)比所有的body字(因为它长)段更重要。
BM25当然也认为较短字段应该有更多的权重,但是它会分别考虑每个字段内容的平均长度,这样就能区分一个长的title字段和一个短的title字段。
在查询时权重提升(Query-Time Boosting)中,我们提到title字段因为其长度比body字段有更为中性的权重提升值。
BM25调优(Tuning BM25)
不像TF/IDF,BM25有一个比较好的特性就是它为我们提供了两个可调参数:
k1
这个参数控制着词频结果在词频饱和度中的上升速度。默认值为1.2。值越小饱和度变化越快,值越大饱和度变化越慢。
b
这个参数控制着字段长正则值所起的作用,0.0会禁用正则化,1.0会启用完全正则化。默认值为0.75
在实践中,调试BM25是另外一回事,k1和b的默认值适用于绝大多数文档集合,但最优值还是会因为文档集不同而不同,为了找到文档集合的最优值,我们就必须对参数进行反复修改验证。
修改相似度(Changing Similarities)
相似度算法可以按字段来指定,只需在映射中为不同字段选定即可:
PUT /my_index
{
"mappings": {
"doc": {
"properties": {
"title": {
"type": "string",
"similarity": "BM25"
},
"body": {
"type": "string",
"similarity": "default"
}
}
}
}
- #1title字段使用 BM25 相似度算法。
- #2body字段用默认相似度算法。(参见Lucene's Practical Scoring Function)
目前,Elasticsearch还不支持改变一个已有字段的相似度算法,只能通过为数据重新建立索引来达到目的。
配置BM25(Configuring BM25)
配置相似度算法和配置分析器很像,自定义相似度算法可以在创建索引时指定,例如:
PUT /my_index
{
"settings": {
"similarity": {
"my_bm25": { #1
"type": "BM25",
"b": 0 #2
}
}
},
"mappings": {
"doc": {
"properties": {
"title": {
"type": "string",
"similarity": "my_bm25" #3
},
"body": {
"type": "string",
"similarity": "BM25" #4
}
}
}
}
}
- #1 创建一个基于内置BM25算法,名为my_bm25的自定义相似度算法。
- #2 禁止使用字段长正则化。参见Tuning BM25。
- #3title字段使用自定义相似度算法my_bm25。
- #4body字段使用内置相似度算法BM25。
一个自定义的相似度算法可以通过关闭索引,更新索引设置,开启索引的过程进行更新。这让我们无需重建索引又能配置试验不同的相似度算法结果。
调试相关度是最后10%的事情(Relevance Tuning Is the Last 10%)
本章我们介绍了Lucene是如何基于TF/IDF生成评分的。了解评分过程对于我们非常重要,它使我们能为针对具体商业领域调试、调节、减弱并操纵评分。
在实践中,简单的查询组合就能为我们提供很好的搜索结果,但是为了更高质量的结果,我们就必须反复推敲修改前面介绍的这些调试方法。
通常,经过对策略字段应用权重提升,或通过对查询语句结构的调整来强调某个句子的重要性这些方法,就足以让我们获得非常好的搜索结果。有时,如果Lucene基于词的TF/IDF模型不再满足我们的评分需求(比如:我们希望基于时间或距离来评分),我们又需要一些激进的调整。
除此之外,相关度的调试就有如一个兔子洞,一旦跳进去了就很难再出来。最相关这个概念是一个难以触及的模糊目标,通常不同人对于与文档排序又有着不同的想法,这让我们很容易陷入一个持续来回调整而没有任何明显进步的怪圈。
我们强烈建议不要陷入这种怪圈,而是监控并测量我们的搜索结果。监控用户点击最顶端结果的频次,这可以是前10个文档,也可以是第一页;用户不查看首次搜索的结果而直接执行第二次查询的频次;用户来回点击并查看搜索结果的频次。
这些都是用来评价搜索结果与用户之间相关程度的指标。如果查询能返回高相关的文档,用户会选择头五个结果中的一个,找到想要的结果,然后离开。不相关的结果会让用户来回点击并尝试新的搜索条件。
一旦我们有了这些监控仪器,想要调试一个查询是非常简单的。稍作修改,监控用户的行为改变,并作适当重复。本章介绍的一些工具就只是工具而已,我们需要恰当的利用它们并将搜索结果推进到高质量一档,能做到如此的唯一方式就是有个能评价度量用户行为强大能力。