full AND text AND search AND (elasticsearch OR lucene)
处理结构化数据(比如:时间、数字、字符串、枚举)的数据库,只需检查文档(或关系数据库里的行)是否与查询匹配。
布尔的是/非匹配是全文搜索的基础,但不止如此,我们还要知道每个文档与查询的相关度,在全文搜索引擎中不仅需要找到匹配的文档,还需根据它们相关度的高低进行排序。
全文相关的公式或 相似算法(similarity algorithms) 会将多个因素合并起来,为每个文档生成一个相关度评分 _score
。本章中,我们会验证各种可变部分,然后讨论如何来控制它们。
当然,相关度不只与全文查询有关,也需要将结构化的数据考虑其中。可能我们正在找一个度假屋,需要一些的详细特征(空调、海景、免费 WiFi ),匹配的特征越多相关度越高。可能我们还希望有一些其他的考虑因素,如回头率、价格、受欢迎度或距离,当然也同时考虑全文查询的相关度。
所有的这些都可以通过 Elasticsearch 强大的评分基础来实现。
本章会先从理论上介绍 Lucene 是如何计算相关度的,然后通过实际例子说明如何控制相关度的计算过程。
Lucene(或 Elasticsearch)使用 布尔模型(Boolean model) 查找匹配文档,并用一个名为 实用评分函数(practical scoring function) 的公式来计算相关度。这个公式借鉴了 词频/逆向文档频率(term frequency/inverse document frequency) 和 向量空间模型(vector space model),同时也加入了一些现代的新特性,如协调因子(coordination factor),字段长度归一化(field length normalization),以及词或查询语句权重提升。
Note
|
不要紧张!这些概念并没有像它们字面看起来那么复杂,尽管本小节提到了算法、公式和数学模型,但内容还是让人容易理解的,与理解算法本身相比,了解这些因素如何影响结果更为重要。 |
布尔模型(Boolean Model) 只是在查询中使用 AND
、 OR
和 NOT
(与、或和非)这样的条件来查找匹配的文档,以下查询:
full AND text AND search AND (elasticsearch OR lucene)
会将所有包括词 full
、 text
和 search
,以及 elasticsearch
或 lucene
的文档作为结果集。
这个过程简单且快速,它将所有可能不匹配的文档排除在外。
当匹配到一组文档后,需要根据相关度排序这些文档,不是所有的文档都包含所有词,有些词比其他的词更重要。一个文档的相关度评分部分取决于每个查询词在文档中的 权重 。
词的权重由三个因素决定,在 什么是相关 中已经有所介绍,有兴趣可以了解下面的公式,但并不要求记住。
词在文档中出现的频度是多少?频度越高,权重 越高 。 5 次提到同一词的字段比只提到 1 次的更相关。词频的计算方式如下:
tf(t in d) = √frequency (1)
词 t
在文档 d
的词频( tf
)是该词在文档中出现次数的平方根。
如果不在意词在某个字段中出现的频次,而只在意是否出现过,则可以在字段映射中禁用词频统计:
PUT /my_index
{
"mappings": {
"doc": {
"properties": {
"text": {
"type": "string",
"index_options": "docs" (1)
}
}
}
}
}
将参数 index_options
设置为 docs
可以禁用词频统计及词频位置,这个映射的字段不会计算词的出现次数,对于短语或近似查询也不可用。要求精确查询的 not_analyzed
字符串字段会默认使用该设置。
词在集合所有文档里出现的频率是多少?频次越高,权重 越低 。常用词如 and
或 the
对相关度贡献很少,因为它们在多数文档中都会出现,一些不常见词如 elastic
或 hippopotamus
可以帮助我们快速缩小范围找到感兴趣的文档。逆向文档频率的计算公式如下:
idf(t) = 1 + log ( numDocs / (docFreq + 1)) (1)
词 t
的逆向文档频率( idf
)是:索引中文档数量除以所有包含该词的文档数,然后求其对数。
字段的长度是多少?字段越短,字段的权重 越高 。如果词出现在类似标题 title
这样的字段,要比它出现在内容 body
这样的字段中的相关度更高。字段长度的归一值公式如下:
norm(d) = 1 / √numTerms (1)
字段长度归一值( norm
)是字段中词数平方根的倒数。
字段长度的归一值对全文搜索非常重要,许多其他字段不需要有归一值。无论文档是否包括这个字段,索引中每个文档的每个 string
字段都大约占用 1 个 byte 的空间。对于 not_analyzed
字符串字段的归一值默认是禁用的,而对于 analyzed
字段也可以通过修改字段映射禁用归一值:
PUT /my_index
{
"mappings": {
"doc": {
"properties": {
"text": {
"type": "string",
"norms": { "enabled": false } (1)
}
}
}
}
}
这个字段不会将字段长度归一值考虑在内,长字段和短字段会以相同长度计算评分。
对于有些应用场景如日志,归一值不是很有用,要关心的只是字段是否包含特殊的错误码或者特定的浏览器唯一标识符。字段的长度对结果没有影响,禁用归一值可以节省大量内存空间。
以下三个因素——词频(term frequency)、逆向文档频率(inverse document frequency)和字段长度归一值(field-length norm)——是在索引时计算并存储的。最后将它们结合在一起计算单个词在特定文档中的 权重 。
Tip
|
前面公式中提到的 文档 实际上是指文档里的某个字段,每个字段都有它自己的倒排索引,因此字段的 TF/IDF 值就是文档的 TF/IDF 值。 |
当用 explain
查看一个简单的 term
查询时(参见 explain ),可以发现与计算相关度评分的因子就是前面章节介绍的这些:
PUT /my_index/doc/1
{ "text" : "quick brown fox" }
GET /my_index/doc/_search?explain
{
"query": {
"term": {
"text": "fox"
}
}
}
以上请求(简化)的 explanation
解释如下:
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)
词 fox
在文档的内部 Lucene doc ID 为 0
,字段是 text
里的最终评分。
词 fox
在该文档 text
字段中只出现了一次。
fox
在所有文档 text
字段索引的逆向文档频率。
该字段的字段长度归一值。
当然,查询通常不止一个词,所以需要一种合并多词权重的方式——向量空间模型(vector space model)。
向量空间模型(vector space model) 提供一种比较多词查询的方式,单个评分代表文档与查询的匹配程度,为了做到这点,这个模型将文档和查询都以 向量(vectors) 的形式表示:
向量实际上就是包含多个数的一维数组,例如:
[1,2,5,22,3,8]
在向量空间模型里,向量空间模型里的每个数字都代表一个词的 权重 ,与 词频/逆向文档频率(term frequency/inverse document frequency) 计算方式类似。
Tip
|
尽管 TF/IDF 是向量空间模型计算词权重的默认方式,但不是唯一方式。Elasticsearch 还有其他模型如 Okapi-BM25 。TF/IDF 是默认的因为它是个经检验过的简单又高效的算法,可以提供高质量的搜索结果。 |
设想如果查询 “happy hippopotamus” ,常见词 happy
的权重较低,不常见词 hippopotamus
权重较高,假设 happy
的权重是 2 , hippopotamus
的权重是 5 ,可以将这个二维向量—— [2,5]
——在坐标系下作条直线,线的起点是 (0,0) 终点是 (2,5) ,如图 表示 “happy hippopotamus” 的二维查询向量 。
现在,设想我们有三个文档:
I am happy in summer 。
After Christmas I’m a hippopotamus 。
The happy hippopotamus helped Harry 。
可以为每个文档都创建包括每个查询词—— happy
和 hippopotamus
——权重的向量,然后将这些向量置入同一个坐标系中,如图 “happy hippopotamus” 查询及文档向量 :
文档 1: (happy,__)
—— [2,0]
文档 2: ( _ ,hippopotamus)
—— [0,5]
文档 3: (happy,hippopotamus)
—— [2,5]
向量之间是可以比较的,只要测量查询向量和文档向量之间的角度就可以得到每个文档的相关度,文档 1 与查询之间的角度最大,所以相关度低;文档 2 与查询间的角度较小,所以更相关;文档 3 与查询的角度正好吻合,完全匹配。
Tip
|
在实际中,只有二维向量(两个词的查询)可以在平面上表示,幸运的是, 线性代数 ——作为数学中处理向量的一个分支——为我们提供了计算两个多维向量间角度工具,这意味着可以使用如上同样的方式来解释多个词的查询。 关于比较两个向量的更多信息可以参考 余弦近似度(cosine similarity)。 |
现在已经讲完评分计算的基本理论,我们可以继续了解 Lucene 是如何实现评分计算的。
对于多词查询, 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) (4)
上节已介绍过 score
、 tf
和 idf
。现在来介绍 queryNorm
、 coord
、 t.getBoost
和 norm
。
我们会在本章后面继续探讨 查询时的权重提升 的问题,但是首先需要了解查询归一化、协调和索引时字段层面的权重提升等概念。
查询归一因子 ( queryNorm
)试图将查询 归一化 ,这样就能将两个不同的查询结果相比较。
Tip
|
尽管查询归一值的目的是为了使查询结果之间能够相互比较,但是它并不十分有效,因为相关度评分 |
这个因子是在查询过程的最前面计算的,具体的计算依赖于具体查询,一个典型的实现如下:
queryNorm = 1 / √sumOfSquaredWeights (1)
sumOfSquaredWeights
是查询里每个词的 IDF 的平方和。
Tip
|
相同查询归一化因子会被应用到每个文档,不能被更改,总而言之,可以被忽略。 |
协调因子 ( 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
= 2.0
文档里有 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" }}
]
}
}
}
当使用同义词的时候(参照: 同义词 ),Lucene 内部是这样的:重写的查询会禁用同义词的协调功能。大多数禁用操作的应用场景是自动处理的,无须为此担心。
我们会讨论 查询时的权重提升,让字段 权重提升 就是让某个字段比其他字段更重要。当然在索引时也能做到如此。实际上,权重的提升会被应用到字段的每个词,而不是字段本身。
将提升值存储在索引中无须更多空间,这个字段层索引时的提升值与字段长度归一值(参见 字段长度归一值 )一起作为单个字节存于索引, norm(t,d)
是前面公式的返回值。
Warning
|
我们不建议在建立索引时对字段提升权重,有以下原因:
查询时赋予权重 是更为简单、清楚、灵活的选择。 |
了解了查询归一化、协同和索引时权重提升这些方式后,可以进一步了解相关度计算最有用的工具:查询时的权重提升。
在 语句优先级(Prioritizing Clauses) 中,我们解释过如何在搜索时使用 boost
参数让一个查询语句比其他语句更重要。例如:
GET /_search
{
"query": {
"bool": {
"should": [
{
"match": {
"title": {
"query": "quick brown fox",
"boost": 2 (1)
}
}
},
{
"match": { (2)
"content": "quick brown fox"
}
}
]
}
}
}
title
查询语句的重要性是 content
查询的 2 倍,因为它的权重提升值为 2
。
没有设置 boost
的查询语句的值为 1
。
查询时的权重提升 是可以用来影响相关度的主要工具,任意类型的查询都能接受 boost
参数。将 boost
设置为 2
,并不代表最终的评分 _score
是原值的两倍;实际的权重值会经过归一化和一些其他内部优化过程。尽管如此,它确实想要表明一个提升值为 2
的句子的重要性是提升值为 1
语句的两倍。
在实际应用中,无法通过简单的公式得出某个特定查询语句的 正确'' 权重提升值,只能通过不断尝试获得。需要记住的是
缺省的'' 权重提升值,这因为在 字段长度归一值 中,标题往往比相关内容要短,所以不要想当然的去盲目提升一些字段的权重。选择权重,检查结果,如此反复。boost
只是影响相关度评分的其中一个因子;它还需要与其他因子相互竞争。在前例中, title
字段相对 content
字段可能已经有一个
当在多个索引中搜索时,可以使用参数 indices_boost
来提升整个索引的权重,在下面例子中,当要为最近索引的文档分配更高权重时,可以这么做:
GET /docs_2014_*/_search (1)
{
"indices_boost": { (2)
"docs_2014_10": 3,
"docs_2014_09": 2
},
"query": {
"match": {
"text": "quick brown fox"
}
}
}
这个多索引查询涵盖了所有以字符串 docs_2014_
开始的索引。
其中,索引 docs_2014_10
中的所有文件的权重是 3
,索引 docs_2014_09
中是 2
,其他所有匹配的索引权重为默认值 1
。
这些提升值在 Lucene 的 实用评分函数 中可以通过 t.getBoost()
获得。权重提升不会被应用于它在查询表达式中出现的层,而是会被合并下转至每个词中。 t.getBoost()
始终返回当前词的权重或当前分析链上查询的权重。
Elasticsearch 的查询表达式相当灵活,可以通过调整查询结构中查询语句的所处层次,从而或多或少改变其重要性,比如,设想下面这个查询:
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
查询。
在互联网上搜索 “Apple”,返回的结果很可能是一个公司、水果和各种食谱。我们可以在 bool
查询中用 must_not
语句来排除像 pie
、 tart
、 crumble
和 tree
这样的词,从而将查询结果的范围缩小至只返回与 “Apple” (苹果)公司相关的结果:
GET /_search
{
"query": {
"bool": {
"must": {
"match": {
"text": "apple"
}
},
"must_not": {
"match": {
"text": "pie tart fruit crumble tree"
}
}
}
}
}
但谁又敢保证在排除 tree
或 crumble
这种词后,不会错失一个与苹果公司特别相关的文档呢?有时, must_not
条件会过于严格。
boosting
查询 恰恰能解决这个问题。它仍然允许我们将关于水果或甜点的结果包括到结果中,但是使它们降级——即降低它们原来可能应有的排名:
GET /_search
{
"query": {
"boosting": {
"positive": {
"match": {
"text": "apple"
}
},
"negative": {
"match": {
"text": "pie tart fruit crumble tree"
}
},
"negative_boost": 0.5
}
}
}
它接受 positive
和 negative
查询。只有那些匹配 positive
查询的文档罗列出来,对于那些同时还匹配 negative
查询的文档将通过文档的原始 _score
与 negative_boost
相乘的方式降级后的结果。
为了达到效果, negative_boost
的值必须小于 1.0
。在这个示例中,所有包含负向词的文档评分 _score
都会减半。
有时候我们根本不关心 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
查询中,它可以包含查询或过滤,为任意一个匹配的文档指定评分 1
,忽略 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
。
Note
|
最终的评分并不是所有匹配语句的简单求和, 协调因子(coordination factor) 和 查询归一化因子(query normalization factor) 仍然会被考虑在内。 |
我们可以给 features
字段加上 not_analyzed
类型来提升度假屋文档的匹配能力:
{ "features": [ "wifi", "pool", "garden" ] }
默认情况下,一个 not_analyzed
字段会禁用 字段长度归一值(field-length norms) 的功能,并将 index_options
设为 docs
选项,禁用 词频 ,但还是存在问题:每个词的 倒排文档频率 仍然会被考虑。
可以采用与之前相同的方法 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
查询 是用来控制评分过程的终极武器,它允许为每个与主查询匹配的文档应用一个函数,以达到改变甚至完全替换原始查询评分 _score
的目的。
实际上,也能用过滤器对结果的 子集 应用不同的函数,这样一箭双雕:既能高效评分,又能利用过滤器缓存。
Elasticsearch 预定义了一些函数:
weight
为每个文档应用一个简单而不被规范化的权重提升值:当 weight
为 2
时,最终结果为 2 * _score
。
field_value_factor
使用这个值来修改 _score
,如将 popularity
或 votes
(受欢迎或赞)作为考虑因素。
random_score
为每个用户都使用一个不同的随机评分对结果排序,但对某一具体用户来说,看到的顺序始终是一致的。
linear
、
exp
、
gauss
将浮动值结合到评分 _score
中,例如结合 publish_date
获得最近发布的文档,结合 geo_location
获得更接近某个具体经纬度(lat/lon)地点的文档,结合 price
获得更接近某个特定价格的文档。
script_score
如果需求超出以上范围时,用自定义脚本可以完全控制评分计算,实现所需逻辑。
如果没有 function_score
查询,就不能将全文查询与最新发生这种因子结合在一起评分,而不得不根据评分 _score
或时间 date
进行排序;这会相互影响抵消两种排序各自的效果。这个查询可以使两个效果融合:可以仍然根据全文相关度进行排序,但也会同时考虑最新发布文档、流行文档、或接近用户希望价格的产品。正如所设想的,查询要考虑所有这些因素会非常复杂,让我们先从简单的例子开始,然后顺着梯子慢慢向上爬,增加复杂度。
设想有个网站供用户发布博客并且可以让他们为自己喜欢的博客点赞,我们希望将更受欢迎的博客放在搜索结果列表中相对较上的位置,同时全文搜索的评分仍然作为相关度的主要排序依据,可以简单的通过存储每个博客的点赞数来实现它:
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)
}
}
}
}
function_score
查询将主查询和函数包括在内。
主查询优先执行。
field_value_factor
函数会被应用到每个与主 query
匹配的文档。
每个文档的 votes
字段都 必须 有值供 function_score
计算。如果 没有 文档的 votes
字段有值,那么就 必须 使用 missing
属性 提供的默认值来进行评分计算。
在前面示例中,每个文档的最终评分 _score
都做了如下修改:
new_score = old_score * number_of_votes
然而这并不会带来出人意料的好结果,全文评分 _score
通常处于 0 到 10 之间,如下图 受欢迎度的线性关系基于 _score
的原始值 2.0
中,有 10 个赞的博客会掩盖掉全文评分,而 0 个赞的博客的评分会被置为 0 。
一种融入受欢迎度更好方式是用 modifier
平滑 votes
的值。换句话说,我们希望最开始的一些赞更重要,但是其重要性会随着数字的增加而降低。 0 个赞与 1 个赞的区别应该比 10 个赞与 11 个赞的区别大很多。
对于上述情况,典型的 modifier
应用是使用 log1p
参数值,公式如下:
new_score = old_score * log(1 + number_of_votes)
log
对数函数使 votes
赞字段的评分曲线更平滑,如图 受欢迎度的对数关系基于 _score
的原始值 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" (1)
}
}
}
}
modifier
为 log1p
。
修饰语 modifier 的值可以为: none
(默认状态)、 log
、 log1p
、 log2p
、 ln
、 ln1p
、 ln2p
、 square
、 sqrt
以及 reciprocal
。想要了解更多信息请参照: field_value_factor
文档.
可以通过将 votes
字段与 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)
}
}
}
}
双倍效果。
添加了 factor
会使公式变成这样:
new_score = old_score * log(1 + factor * number_of_votes)
factor
值大于 1
会提升效果, factor
值小于 1
会降低效果,如图 受欢迎度的对数关系基于多个不同因子 。
或许将全文评分与 field_value_factor
函数值乘积的效果仍然可能太大,我们可以通过参数 boost_mode
来控制函数与查询评分 _score
合并后的结果,参数接受的值为:
multiply
评分 _score
与函数值的积(默认)
sum
评分 _score
与函数值的和
min
评分 _score
与函数值间的较小值
max
评分 _score
与函数值间的较大值
replace
函数值替代评分 _score
与使用乘积的方式相比,使用评分 _score
与函数值求和的方式可以弱化最终效果,特别是使用一个较小 factor
因子时:
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)
}
}
}
将函数计算结果值累加到评分 _score
。
之前请求的公式现在变成下面这样(参见 使用 sum
结合受欢迎程度 ):
new_score = old_score + log(1 + 0.1 * number_of_votes)
最后,可以使用 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)
}
}
}
无论 field_value_factor
函数的结果如何,最终结果都不会大于 1.5
。
Note
|
max_boost 只对函数的结果进行限制,不会对最终评分 _score 产生直接影响。 |
回到 忽略 TF/IDF 里处理过的问题,我们希望根据每个度假屋的特性数量来评分,当时我们希望能用缓存的过滤器来影响评分,现在 function_score
查询正好可以完成这件事情。
到目前为止,我们展现的都是为所有文档应用单个函数的使用方式,现在会用过滤器将结果划分为多个子集(每个特性一个过滤器),并为每个子集使用不同的函数。
在下面例子中,我们会使用 weight
函数,它与 boost
参数类似可以用于任何查询。有一点区别是 weight
没有被 Luence 归一化成难以理解的浮点数,而是直接被应用。
查询的结构需要做相应变更以整合多个函数:
GET /_search
{
"query": {
"function_score": {
"filter": { (1)
"term": { "city": "Barcelona" }
},
"functions": [ (2)
{
"filter": { "term": { "features": "wifi" }}, (3)
"weight": 1
},
{
"filter": { "term": { "features": "garden" }}, (3)
"weight": 1
},
{
"filter": { "term": { "features": "pool" }}, (3)
"weight": 2 (4)
}
],
"score_mode": "sum", (5)
}
}
}
function_score
查询有个 filter
过滤器而不是 query
查询。
functions
关键字存储着一个将被应用的函数列表。
函数会被应用于和 filter
过滤器(可选的)匹配的文档。
pool
比其他特性更重要,所以它有更高 weight
。
score_mode
指定各个函数的值进行组合运算的方式。
这个新特性需要注意的地方会在以下小节介绍。
首先要注意的是 filter
过滤器代替了 query
查询,在本例中,我们无须使用全文搜索,只想找到 city
字段中包含 Barcelona
的所有文档,逻辑用过滤比用查询表达更清晰。过滤器返回的所有文档的评分 _score
的值为 1
。 function_score
查询接受 query
或 filter
,如果没有特别指定,则默认使用 match_all
查询。
functions
关键字保持着一个将要被使用的函数列表。可以为列表里的每个函数都指定一个 filter
过滤器,在这种情况下,函数只会被应用到那些与过滤器匹配的文档,例子中,我们为与过滤器匹配的文档指定权重值 weight
为 1
(为与 pool
匹配的文档指定权重值为 2
)。
每个函数返回一个结果,所以需要一种将多个结果缩减到单个值的方式,然后才能将其与原始评分 _score
合并。评分模式 score_mode
参数正好扮演这样的角色,它接受以下值:
multiply
函数结果求积(默认)。
sum
函数结果求和。
avg
函数结果的平均值。
max
函数结果的最大值。
min
函数结果的最小值。
first
使用首个函数(可以有过滤器,也可能没有)的结果作为最终结果
在本例中,我们将每个过滤器匹配结果的权重 weight
求和,并将其作为最终评分结果,所以会使用 sum
评分模式。
不与任何过滤器匹配的文档会保有其原始评分, _score
值的为 1
。
你可能会想知道 一致随机评分(consistently random scoring) 是什么,又为什么会使用它。之前的例子是个很好的应用场景,前例中所有的结果都会返回 1 、 2 、 3 、 4 或 5 这样的最终评分 _score
,可能只有少数房子的评分是 5 分,而有大量房子的评分是 2 或 3 。
作为网站的所有者,总会希望让广告有更高的展现率。在当前查询下,有相同评分 _score
的文档会每次都以相同次序出现,为了提高展现率,在此引入一些随机性可能会是个好主意,这能保证有相同评分的文档都能有均等相似的展现机率。
我们想让每个用户看到不同的随机次序,但也同时希望如果是同一用户翻页浏览时,结果的相对次序能始终保持一致。这种行为被称为 一致随机(consistently random) 。
random_score
函数会输出一个 0 到 1 之间的数,当种子 seed
值相同时,生成的随机结果是一致的,例如,将用户的会话 ID 作为 seed :
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"
}
}
}
random_score
语句没有任何过滤器 filter
,所以会被应用到所有文档。
将用户的会话 ID 作为种子 seed
,让该用户的随机始终保持一致,相同的种子 seed
会产生相同的随机结果。
当然,如果增加了与查询匹配的新文档,无论是否使用一致随机,其结果顺序都会发生变化。
很多变量都可以影响用户对于度假屋的选择,也许用户希望离市中心近点,但如果价格足够便宜,也有可能选择一个更远的住处,也有可能反过来是正确的:愿意为最好的位置付更多的价钱。
如果我们添加过滤器排除所有市中心方圆 1 千米以外的度假屋,或排除所有每晚价格超过 £100 英镑的,我们可能会将用户愿意考虑妥协的那些选择排除在外。
function_score
查询会提供一组 衰减函数(decay functions) ,让我们有能力在两个滑动标准,如地点和价格,之间权衡。
有三种衰减函数—— linear
、 exp
和 gauss
(线性、指数和高斯函数),它们可以操作数值、时间以及经纬度地理坐标点这样的字段。所有三个函数都能接受以下参数:
origin
中心点 或字段可能的最佳值,落在原点 origin
上的文档评分 _score
为满分 1.0
。
scale
衰减率,即一个文档从原点 origin
下落时,评分 _score
改变的速度。(例如,每 £10 欧元或每 100 米)。
decay
从原点 origin
衰减到 scale
所得的评分 _score
,默认值为 0.5
。
offset
以原点 origin
为中心点,为其设置一个非零的偏移量 offset
覆盖一个范围,而不只是单个原点。在范围 -offset ⇐ origin ⇐ +offset
内的所有评分 _score
都是 1.0
。
这三个函数的唯一区别就是它们衰减曲线的形状,用图来说明会更为直观(参见 衰减函数曲线 )。
图 衰减函数曲线 中所有曲线的原点 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
(线性、指数和高斯)函数三者之间的区别在于范围( origin +/- (offset + scale)
)之外的曲线形状:
linear
线性函数是条直线,一旦直线与横轴 0 相交,所有其他值的评分都是 0.0
。
exp
指数函数是先剧烈衰减然后变缓。
gauss
高斯函数是钟形的——它的衰减速率是先缓慢,然后变快,最后又放缓。
选择曲线的依据完全由期望评分 _score
的衰减速率来决定,即距原点 origin
的值。
回到我们的例子:用户希望租一个离伦敦市中心近( { "lat": 51.50, "lon": 0.12}
)且每晚不超过 £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)
}
]
}
}
}
location
字段以地理坐标点 geo_point
映射。
price
字段是数值。
参见 理解价格语句 ,理解 origin
为什么是 50
而不是 100
。
price
语句是 location
语句权重的两倍。
location
语句可以简单理解为:
以伦敦市中作为原点 origin
。
所有距原点 origin
2km
范围内的位置的评分是 1.0
。
距中心 5km
( offset + scale
)的位置的评分是 0.5
。
price
语句使用了一个小技巧:用户希望选择 £100 英镑以下的度假屋,但是例子中的原点被设置成 £50 英镑,价格不能为负,但肯定是越低越好,所以 £0 到 £100 英镑内的所有价格都认为是比较好的。
如果我们将原点 origin
被设置成 £100 英镑,那么低于 £100 英镑的度假屋的评分会变低,与其这样不如将原点 origin
和偏移量 offset
同时设置成 £50 英镑,这样就能使只有在价格高于 £100 英镑( origin + offset
)时评分才会变低。
Tip
|
|
最后,如果所有 function_score
内置的函数都无法满足应用场景,可以使用 script_score
函数自行实现逻辑。
举个例子,想将利润空间作为因子加入到相关度评分计算,在业务中,利润空间和以下三点相关:
price
度假屋每晚的价格。
会员用户的级别——某些等级的用户可以在每晚房价高于某个 threshold
阀值价格的时候享受折扣 discount
。
用户享受折扣后,经过议价的每晚房价的利润 margin
。
计算每个度假屋利润的算法如下:
if (price < threshold) {
profit = price * margin
} else {
profit = price * (1 - discount) * margin;
}
我们很可能不想用绝对利润作为评分,这会弱化其他如地点、受欢迎度和特性等因子的作用,而是将利润用目标利润 target
的百分比来表示,高于 目标的利润空间会有一个正向评分(大于 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 (1)
if (price < threshold) { (2)
return price * margin / target
}
return price * (1 - discount) * margin / target (2)
price
和 margin
变量可以分别从文档的 price
和 margin
字段提取。
threshold
、 discount
和 target
是作为参数 params
传入的。
最终我们将 script_score
函数与其他函数一起使用:
GET /_search
{
"function_score": {
"functions": [
{ ...location clause... }, (1)
{ ...price clause... }, (1)
{
"script_score": {
"params": { (2)
"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;" (3)
}
}
]
}
}
location
和 price
语句在 衰减函数 中解释过。
将这些变量作为参数 params
传递,我们可以查询时动态改变脚本无须重新编译。
JSON 不能接受内嵌的换行符,脚本中的换行符可以用 \n
或 ;
符号替代。
这个查询根据用户对地点和价格的需求,返回用户最满意的文档,同时也考虑到我们对于盈利的要求。
Tip
|
有人说使用脚本对性能会有影响,如果确实发现脚本执行较慢,可以有以下三种选择:
|
在进一步讨论相关度和评分之前,我们会以一个更高级的话题结束本章节的内容:可插拔的相似度算法(Pluggable Similarity Algorithms)。 Elasticsearch 将 实用评分算法 作为默认相似度算法,它也能够支持其他的一些算法,这些算法可以参考 相似度模块 文档。
能与 TF/IDF 和向量空间模型媲美的就是 Okapi BM25 ,它被认为是 当今最先进的 排序函数。 BM25 源自 概率相关模型(probabilistic relevance model) ,而不是向量空间模型,但这个算法也和 Lucene 的实用评分函数有很多共通之处。
BM25 同样使用词频、逆向文档频率以及字段长归一化,但是每个因子的定义都有细微区别。与其详细解释 BM25 公式,倒不如将关注点放在 BM25 所能带来的实际好处上。
不幸的是,普通词随处可见,实际上一个普通词在同一个文档中大量出现的作用会由于该词在 所有 文档中的大量出现而被抵消掉。
曾经有个时期,将 最 普通的词(或 停用词 ,参见 停用词)从索引中移除被认为是一种标准实践,TF/IDF 正是在这种背景下诞生的。TF/IDF 没有考虑词频上限的问题,因为高频停用词已经被移除了。
Elasticsearch 的 standard
标准分析器( string
字段默认使用)不会移除停用词,因为尽管这些词的重要性很低,但也不是毫无用处。这导致:在一个相当长的文档中,像 the
和 and
这样词出现的数量会高得离谱,以致它们的权重被人为放大。
另一方面,BM25 有一个上限,文档里出现 5 到 10 次的词会比那些只出现一两次的对相关度有着显著影响。但是如图 TF/IDF 与 BM25 的词频饱和度 所见,文档中出现 20 次的词几乎与那些出现上千次的词有着相同的影响。
这就是 非线性词频饱和度(nonlinear term-frequency saturation) 。
在 字段长归一化 中,我们提到过 Lucene 会认为较短字段比较长字段更重要:字段某个词的频度所带来的重要性会被这个字段长度抵消,但是实际的评分函数会将所有字段以同等方式对待。它认为所有较短的 title
字段比所有较长的 body
字段更重要。
BM25 当然也认为较短字段应该有更多的权重,但是它会分别考虑每个字段内容的平均长度,这样就能区分短 title
字段和 长
title 字段。
Caution
|
在 查询时权重提升 中,已经说过 title 字段因为其长度比 body 字段 自然 有更高的权重提升值。由于字段长度的差异只能应用于单字段,这种自然的权重提升会在使用 BM25 时消失。 |
不像 TF/IDF ,BM25 有一个比较好的特性就是它提供了两个可调参数:
k1
这个参数控制着词频结果在词频饱和度中的上升速度。默认值为 1.2
。值越小饱和度变化越快,值越大饱和度变化越慢。
b
这个参数控制着字段长归一值所起的作用, 0.0
会禁用归一化, 1.0
会启用完全归一化。默认值为 0.75
。
在实践中,调试 BM25 是另外一回事, k1
和 b
的默认值适用于绝大多数文档集合,但最优值还是会因为文档集不同而有所区别,为了找到文档集合的最优值,就必须对参数进行反复修改验证。
相似度算法可以按字段指定,只需在映射中为不同字段选定即可:
PUT /my_index
{
"mappings": {
"doc": {
"properties": {
"title": {
"type": "string",
"similarity": "BM25" (1)
},
"body": {
"type": "string",
"similarity": "default" (2)
}
}
}
}
title
字段使用 BM25 相似度算法。
body
字段用默认相似度算法(参见 实用评分函数)。
目前,Elasticsearch 不支持更改已有字段的相似度算法 similarity
映射,只能通过为数据重新建立索引来达到目的。
配置相似度算法和配置分析器很相似,自定义相似度算法可以在创建索引时指定,例如:
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)
}
}
}
}
}
创建一个基于内置 BM25
,名为 my_bm25
的自定义相似度算法。
禁用字段长度规范化(field-length normalization)。参见 调试 BM25 。
title
字段使用自定义相似度算法 my_bm25
。
字段 body
使用内置相似度算法 BM25
。
Tip
|
自定义的相似度算法可以通过关闭索引,更新索引设置,开启索引这个过程进行更新。这样可以无须重建索引又能试验不同的相似度算法配置。 |
本章介绍了 Lucene 是如何基于 TF/IDF 生成评分的。理解评分过程是非常重要的,这样就可以根据具体的业务对评分结果进行调试、调节、减弱和定制。
实践中,简单的查询组合就能提供很好的搜索结果,但是为了获得 具有成效 的搜索结果,就必须反复推敲修改前面介绍的这些调试方法。
通常,经过对策略字段应用权重提升,或通过对查询语句结构的调整来强调某个句子的重要性这些方法,就足以获得良好的结果。有时,如果 Lucene 基于词的 TF/IDF 模型不再满足评分需求(例如希望基于时间或距离来评分),则需要更具侵略性的调整。
除此之外,相关度的调试就有如兔子洞,一旦跳进去就很难再出来。 最相关 这个概念是一个难以触及的模糊目标,通常不同人对文档排序又有着不同的想法,这很容易使人陷入持续反复调整而没有明显进展的怪圈。
我们强烈建议不要陷入这种怪圈,而要监控测量搜索结果。监控用户点击最顶端结果的频次,这可以是前 10 个文档,也可以是第一页的;用户不查看首次搜索的结果而直接执行第二次查询的频次;用户来回点击并查看搜索结果的频次,等等诸如此类的信息。
这些都是用来评价搜索结果与用户之间相关程度的指标。如果查询能返回高相关的文档,用户会选择前五中的一个,得到想要的结果,然后离开。不相关的结果会让用户来回点击并尝试新的搜索条件。
一旦有了这些监控手段,想要调试查询就并不复杂,稍作调整,监控用户的行为改变并做适当反复尝试。本章介绍的一些工具就只是工具而已,要想物尽其用并将搜索结果提高到 极高的 水平,唯一途径就是需要具备能评价度量用户行为的强大能力。