摘要
查詢很少是簡單的一句話匹配(one-clause match)查詢。很多時候,我們需要用相同或不同的字符串查詢1個或多個字段,也就是說,我們需要對多個查詢語句以及他們相關分數(relevance scores)進行有意義的合併。
有時候或許我們正查找一本名爲戰爭與和平(War and Peace)而作者叫Leo Tolstoy的書,或許我們正用“最少匹配”(“minimum should match”)的方式在文檔中進行查找(可能是頁面標題,也可能是頁面的內容),或許我們正搜索所有名字爲 John Smith 的用戶。
本篇文章中,我們會介紹構造多語句搜索的工具以及不同場景下不同的適合解決方案。
版本
elasticsearch版本: elasticsearch-2.x
內容
多字符串查詢(Multiple Query Strings)
最簡單的多字段(multifield)查詢是可以將搜索術語與具體字段映射的。如果我們知道 War and Peace 是標題,Leo Tolstoy 是作者,我們只需要將兩個條件寫成 match 語句,然後將他們用 bool 查詢組合起來即可:
GET /_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "War and Peace" }},
{ "match": { "author": "Leo Tolstoy" }}
]
}
}
}
bool 查詢的採用越多匹配越好 (more-matches-is-better)的方式,所以每個 match 語句的分數結果會加和在一起,爲每個文檔得到一個最終的分數,能與兩個語句同時匹配的文檔比只與一個語句匹配的文檔得分要高。
當然,我們並不是只能使用 match 語句:可以用 bool 查詢組合任意其他類型的查詢,甚至其他的 bool 查詢。我們可以爲上面的例子添加特定譯者版本的偏好:
GET /_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "War and Peace" }},
{ "match": { "author": "Leo Tolstoy" }},
{ "bool": {
"should": [
{ "match": { "translator": "Constance Garnett" }},
{ "match": { "translator": "Louise Maude" }}
]
}}
]
}
}
}
爲什麼將譯者條件語句放入另一個獨立的 bool 查詢中呢?所有的4個 match 查詢都是 should 語句,我們爲什麼不將translator語句與其他語句(如 title、author)放在同一層呢?
答案在於分數的計算方式。bool 查詢執行每個 match 查詢,然後把他們加在一起,然後將結果與所有匹配的語句數量相乘,再除以所有的語句數量。處於同一層的每條語句具有相同的權重。在上面這個例子中,包含translator語句的 bool 查詢,只佔總分數的三分之一,如果我們將translator語句與 title 和 author 兩個語句放入同一層,那麼 title 和 author 語句只貢獻四分之一。
句子的優先級排序
有可能上面這個例子中每個語句貢獻三分之一的分數並不是我們想要的,我們很可能對 title 和 author 兩個句子更感興趣,這樣我們就需要調整查詢,使 title 和 author 語句更重要。
在我們軍械庫中,最容易使用的武器就是 boost 參數。爲了提高 title 和 author 字段的權重,我們爲他們分配高於1的 boost 值。
GET /_search
{
"query": {
"bool": {
"should": [
{ "match": { #1
"title": {
"query": "War and Peace",
"boost": 2
}}},
{ "match": { #2
"author": {
"query": "Leo Tolstoy",
"boost": 2
}}},
{ "bool": { #3
"should": [
{ "match": { "translator": "Constance Garnett" }},
{ "match": { "translator": "Louise Maude" }}
]
}}
]
}
}
}
- #1 #2 title 和 author 語句的 boost 值爲2。
- 嵌套的 bool 語句的 boost 值爲1。
獲取 boost 參數最佳值的一個比較簡單的方式就是需要不斷試錯:設定 boost 值,運行測試查詢,如此反覆。boost 值比較合理的一個區間是1到10,當然也有可能是15。如果爲 boost 分配比這更高的值將不會對最終的結果產生更大影響,因爲分數最後會被規範化(normalized)。
單字符串查詢(Single Query String)
bool 查詢是多語句查詢的主幹。它的適用場景很多,特別是當我們需要將不同查詢字符串與不同字段建立映射的時候。
有些用戶期望將所有的搜索術語堆積到一個字段中,然後期望ElasticSearch能理解這些搜索,併爲他們提供正確的結果。有意思的是多字段搜索的表單通常被稱爲 高級查詢 (Advanced Search)- 因爲它對用戶而言是高級的,但是,多字段搜索在實現上卻非常簡單。
對於多詞(multiword)、多字段(multifield)查詢沒有簡單的一刀切方案。爲了得到最佳結果,我們除了需要了解如何使用合適的工具外,還需要了解我們的數據。
瞭解我們的數據(Know Your Data)
當我們的用戶輸入了一個單字符串查詢的時候,我們通常會遇到一下情形:
-
最佳字段
當我們搜索具有具體概念的詞的時候,比如“brown fox”,詞組比它們各自更有意義。像 title 和 body 這樣的字段,儘管他們是相關的,但是他們也彼此相互競爭。當文檔在相同字段中具有更多詞的時候,最終的分數來自於最匹配字段(best-matching field)。
-
多數字段
爲了對相關度進行微調,一個常用的技術是將相同的數據索引到不同的字段中,它們各自具有獨立的分析鏈。
主字段(main field)可能包括他們的詞源、同義詞,以及變音詞或口音詞。用它們來匹配儘可能多的文檔。
相同的文本被索引到其他字段,以提供更精確的匹配。一個字段可以包括原詞,其他詞源、口音,以及可以提供詞語相似性的 瓦片詞 (shingles)。
其他字段是作爲匹配每個文檔時提高相關度分數的信號詞,越多字段能匹配則越好。
-
混合字段
對於某些實體,我們需要在多個字段中確定其信息,單個字段都只能作爲整體的一部分:
- Person: first_name 和 last_name
- Book: title,author 和 description
- Address: street,city,country 和 postcode
在這種情況下,我們希望在所有這些列出的字段中找到儘可能多的詞,這有如在一個大的字段中進行搜索,這個大的字段包括所有列出字段。
上述所有的所有都是多詞、多字段查詢(mutiword,multifield queries),但是每個具體查詢都需要使用不同的策略。在後面章節中,我們會依次介紹這些策略。
最佳字段(Best Fields)
如果我們有個網站併爲用戶提供博客內容搜索的功能,以下面兩個博客內容文檔爲例:
PUT /my_index/my_type/1
{
"title": "Quick brown rabbits",
"body": "Brown rabbits are commonly seen."
}
PUT /my_index/my_type/2
{
"title": "Keeping pets healthy",
"body": "My quick brown fox eats rabbits on a regular basis."
}
用戶輸入詞組“Brown fox”然後點擊搜索按鈕。事先,我們並不知道用戶的搜索術語是會在 title 還是 body 中被找到,但是,用戶很有可能是想對“Brown fox”這個相關詞組進行搜索。以肉眼判斷,文檔2的匹配度更高,因爲它同時具有兩個詞:
我們用bool查詢試試:
{
"query": {
"bool": {
"should": [
{ "match": { "title": "Brown fox" }},
{ "match": { "body": "Brown fox" }}
]
}
}
}
但是我們發現查詢的結果是文檔1具有更高分數:
{
"hits": [
{
"_id": "1",
"_score": 0.14809652,
"_source": {
"title": "Quick brown rabbits",
"body": "Brown rabbits are commonly seen."
}
},
{
"_id": "2",
"_score": 0.09256032,
"_source": {
"title": "Keeping pets healthy",
"body": "My quick brown fox eats rabbits on a regular basis."
}
}
]
}
爲了理解這個現象,我們需要回想一下 bool 是如何計算分數的:
- 它會執行 should 語句中的兩個查詢
- 將兩個查詢的分數相加
- 與總匹配語句的數目相乘
- 併除以總語句的數目(這裏爲:2)
文檔1中,兩個字段都包含 brown 這個詞,所以兩個 match 語句都成功匹配且有一個分數。文檔2中,body 字段同時包含 brown 和 fox 這兩個詞,但是 title 字段沒有包含任何詞。這樣,body 查詢結果中的高分,加上 title 查詢中的0分,並乘以二分之一,就得到了一個比文檔1更低的整體分數。
注:
以公式表示文檔1的分數爲:
(score_of_doc_1_title_match + score_of_doc_1_body_match) * total_number_of_match_clause / total_number_of_clause
其中:
score_of_doc_1_body_match = 0
total_number_of_match_clause = 1
total_number_of_clause = 2
在這個例子中,title 和 body 兩個字段處於競爭地位,所以我們就需要找到單個最佳匹配(best-matching)字段。
如果我們不是簡單將每個字段的分數結果加在一起,而是將最佳匹配(best-matching)字段的分數作爲整體查詢的分數,會有怎樣的結果?這樣返回的結果可能是:同時包含兩個詞的單個字段 比 相同詞語反覆出現的多個不同字段 相關度更高。
dis_max 查詢
我們可以使用 dis_max 即分離最大化查詢(Disjunction Max Query)。分離(Disjunction)的意思是或(or),這與可以把結合(conjunction)理解成與(and)對應。分離最大化查詢(Disjunction Max Query)指的是:將任何與任一查詢匹配的文檔作爲結果返回,但是隻將最佳匹配的分數作爲查詢的結果分數。
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Brown fox" }},
{ "match": { "body": "Brown fox" }}
]
}
}
}
這個查詢的結果爲:
{
"hits": [
{
"_id": "2",
"_score": 0.21509302,
"_source": {
"title": "Keeping pets healthy",
"body": "My quick brown fox eats rabbits on a regular basis."
}
},
{
"_id": "1",
"_score": 0.12713557,
"_source": {
"title": "Quick brown rabbits",
"body": "Brown rabbits are commonly seen."
}
}
]
}
最佳字段查詢調優(Tuning Best Fields Queries)
當用戶搜索“quick pets”時會發生什麼呢?使用之前的例子,兩個文檔都包含詞 quick,但是隻有文檔2包含詞 pets,兩個文檔中都不具有同時包含兩個詞的字段。
如下,一個簡單的 dis_max 查詢會採用單個最佳匹配(best matching)字段,然後忽略其他的匹配:
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Quick pets" }},
{ "match": { "body": "Quick pets" }}
]
}
}
}
結果是:
{
"hits": [
{
"_id": "1",
"_score": 0.12713557, #1
"_source": {
"title": "Quick brown rabbits",
"body": "Brown rabbits are commonly seen."
}
},
{
"_id": "2",
"_score": 0.12713557, #2
"_source": {
"title": "Keeping pets healthy",
"body": "My quick brown fox eats rabbits on a regular basis."
}
}
]
}
- #1 #2 注意這兩個分數是相同的。
我們可能期望在這個例子中,能夠同時匹配 title 和 body 字段的文檔比只與一個字段匹配的文檔的相關度更高,但事實並非如此,因爲 dis_max 查詢只會使用單個最佳匹配語句的分數(*_score*)作爲整體分數。
打破平衡(tie_breaker)
我們可以使用 tie_breaker 這個參數將其他匹配語句的分數也考慮其中:
{
"query": {
"dis_max": {
"queries": [
{ "match": { "title": "Quick pets" }},
{ "match": { "body": "Quick pets" }}
],
"tie_breaker": 0.3
}
}
}
這個查詢的結果如下:
{
"hits": [
{
"_id": "2",
"_score": 0.14757764, #1
"_source": {
"title": "Keeping pets healthy",
"body": "My quick brown fox eats rabbits on a regular basis."
}
},
{
"_id": "1",
"_score": 0.124275915, #2
"_source": {
"title": "Quick brown rabbits",
"body": "Brown rabbits are commonly seen."
}
}
]
}
- #1 #2 可以看到,文檔2比文檔1在相關度上有微弱優勢。
tie_breaker 參數的出現實際上是提供了一種處於 dis_max 和 bool 中間狀態的查詢,它分數計算的方式如下:
- 獲得最佳匹配(best-matching)語句的分數 *_score*
- 將其他匹配語句的得分與 tie_breaker 相乘
- 將以上分數求和並規範化(normalize)
由於tie_breaker的作用,所有匹配語句都會被考慮其中,但是最佳匹配語句仍然佔最終結果的大頭。
注意:
tie_breaker 可以是 0 到 1 之間的浮點數,其中,如果數值爲0,即代表使用dis_max最佳匹配語句的普通邏輯,如果數值爲1,即表示所有匹配語句同等重要。最佳的準確值需要根據數據與查詢進行調試得出,但是合理的值通常與零接近(處於 0.1 - 0.4 之間),這樣的合理值不會改變 dis_max 使用最佳匹配的本質。
多配查詢(multi_match查詢)
multi_match 查詢爲反覆執行在多個字段上的查詢提供了一種簡便的方式。
注意:
multi_match 查詢的類型有多種,其中的三種恰巧與 瞭解我們的數據(Know YOur Data) 中介紹的三個場景對應,即:best_fields,most_fields,cross_fields。
默認情況下,下面這個查詢的類型是 best_fields,這表示它會爲每個字段生成一個查詢,然後將他們組合到dis_max 查詢的內部:
{
"dis_max": {
"queries": [
{
"match": {
"title": {
"query": "Quick brown fox",
"minimum_should_match": "30%"
}
}
},
{
"match": {
"body": {
"query": "Quick brown fox",
"minimum_should_match": "30%"
}
}
},
],
"tie_breaker": 0.3
}
}
上面這個查詢以 multi_match 重寫更爲簡潔:
{
"multi_match": {
"query": "Quick brown fox",
"type": "best_fields", #1
"fields": [ "title", "body" ],
"tie_breaker": 0.3,
"minimum_should_match": "30%" #2
}
}
- #1 這個 best_fields 類型是默認值,可以不指定。
- #2 如 minimum_should_match 或 operator 這樣的參數會被傳遞到生成的 match 查詢中。
查詢字段名稱的模糊匹配(Using Wildcards in Field Names)
字段名稱可以用模糊匹配的方式給出:任何與模糊匹配(wildcard)正則匹配的字段都會被包括在搜索中,比如,我們可以使用一下方式同時匹配 book_title、chapter_title和 section_title 這三個字段:
{
"multi_match": {
"query": "Quick brown fox",
"fields": "*_title"
}
}
增加單個字段的權重(Boosting Individual Fields)
可以使用脫字號(caret ^ )的語法爲單個字段增加權重:只需要在字段末尾添加 ^boost,其中 boost 是一個浮點數:
{
"multi_match": {
"query": "Quick brown fox",
"fields": [ "*_title", "chapter_title^2" ] #1
}
}
- #1 chapter_title 這個字段的boost值爲2,而其他兩個字段 book_title 和 section_title 字段具有默認的boost值爲1。
多數字段(Most Fields)
全文搜索被稱作是召回(Recall)與準確(Precision)的戰場:召回(Recall)指的是返回結果中的所有文檔都是相關的;準確(Precision)指的是返回結果中沒有不相關的文檔。目的是,在結果的第一頁中,爲用戶呈現最相關的文檔。
爲了提高召回(Recall)的效果,我們在全網中進行搜索,不僅返回與用戶搜索術語精確匹配的文檔,還會返回我們認爲與查詢相關的所有文檔。如果一個用戶搜索“quick brown box”,一個包含詞語fast foxes的文檔出現在結果集中會被認爲是非常合理的。
當然,如果包含詞語fast foxes的文檔是我們找到的唯一相關文檔,那麼它會出現在結果集的頂端,但是,如果有100個文檔都出現了詞語“quick brown fox”,那麼這個包含詞語fast foxes的文檔會被認爲是次相關的,它可能處於返回結果列表的下面某個地方。當包含了很多潛在匹配之後,我們需要將最匹配的幾個放在結果集的頂端。
對全文相關度提高精度的一個常用方式是爲同一文本建立不同方式的索引,每種方式都提供了一個不同的相關度信號(signal)。主字段(main field)會包括最寬匹配(broadest-matching)形式的術語去儘可能的匹配更多的文檔。舉個例子,我們可以進行一下操作:
- 使用詞jump作爲根(root)來索引 jumps、jumping和jumped樣的詞。這樣,無論用戶使用 jumped 還是 jumping 進行搜索,都能找到匹配的文檔。
- 將同義詞包括其中,如jump、leap和hop。
- 移除變音或口音詞:如ésta、 está和esta都會以無變音形式建立索引。
儘管如此,如果我們有兩個文檔,其中一個包含詞jumped,另一個包含詞jumping,如果我們使用jumped進行搜索的時候,當前期望前者能有更高的排名。
爲了解決這個問題,我們可以將相同的文本索引到其他字段中去以提供更精確的匹配。一個字段可能是爲非詞根的版本,另一個字段可能是變音過的原始詞,還有一個字段可能使用瓦片詞(shingles)以提供詞語相似性的信息。這些其他的字段作爲提高每個文檔的相關度分數的信號(signals),匹配的字段越多越好。
一個文檔如果與寬匹配的主字段匹配,那麼它會出現在結果列表中,如果它同時與信號(signal)字段匹配,它會得到加分,系統會上提它在結果列表中的位置。
我們會稍後討論同義詞、詞相似性、半匹配以及其他潛在的信號,這裏我們只使用詞幹(stemmed)和非詞幹(unstemmed)字段作爲簡單例子來說明這種技術。
多字段映射(Multifield Mapping)
第一件要做的事情就是要對我們的字段索引兩次:一次詞幹模式和一次非詞幹模式。我們使用 multifields 來實現(multifields 在String Sorting and Multifields中介紹過)。
DELETE /my_index
PUT /my_index
{
"settings": { "number_of_shards": 1 }, #1
"mappings": {
"my_type": {
"properties": {
"title": { #2
"type": "string",
"analyzer": "english",
"fields": {
"std": { #3
"type": "string",
"analyzer": "standard"
}
}
}
}
}
}
}
- #1 參考被破壞的相關度
- #2 title 字段使用 english 分析器進行詞幹分析。
- #3 title.std 字段使用 standard 標準分析器進行非詞幹分析。
接着我們索引一些文檔:
PUT /my_index/my_type/1
{ "title": "My rabbit jumps" }
PUT /my_index/my_type/2
{ "title": "Jumping jack rabbits" }
這裏用一個簡單 match 查詢 title 字段是否包含 jumping rabbits:
GET /my_index/_search
{
"query": {
"match": {
"title": "jumping rabbits"
}
}
}
由於使用 english 分析器,這個查詢是在查找以 jump 和 rabbit 這兩個詞幹作爲術語的文檔。兩個文檔的 title 字段都同時包括兩個術語,所以兩個文檔得到的分數相同:
{
"hits": [
{
"_id": "1",
"_score": 0.42039964,
"_source": {
"title": "My rabbit jumps"
}
},
{
"_id": "2",
"_score": 0.42039964,
"_source": {
"title": "Jumping jack rabbits"
}
}
]
}
如果我們只是對 title.std 字段進行查詢,那麼只有文檔2是匹配的。儘管如此,如果我們對兩個字段同時查詢,然後使用 bool 查詢將分數結果合併,則兩個文檔都是匹配的(title 字段的作用),而且文檔2的相關度分數更高(title.std 字段的作用):
GET /my_index/_search
{
"query": {
"multi_match": {
"query": "jumping rabbits",
"type": "most_fields", #1
"fields": [ "title", "title.std" ]
}
}
}
-
#1 我們希望將所有匹配字段的分數合併起來,所以我們使用 most_fields 類型。這使 multi_match 查詢用 bool 查詢將兩個字段語句包在裏面,而非 dis_max 查詢。
{
"hits": [
{
"_id": "2",
"_score": 0.8226396, #1
"_source": {
"title": "Jumping jack rabbits"
}
},
{
"_id": "1",
"_score": 0.10741998, #2
"_source": {
"title": "My rabbit jumps"
}
}
]
} -
#1 #2 文檔2現在的分數要比文檔1高。
我們用寬匹配字段 title 包括儘可能多的文檔——以增加召回(Recall)——同時又使用字段 title.std 作爲信號(signal)將相關度更高的文檔置入結果集的頂部。
每個字段對於最終分數的貢獻可以通過自定義值 boost 來控制。比如,我們使 title 字段更爲重要,這樣同時也降低了其他信號字段的作用:
GET /my_index/_search
{
"query": {
"multi_match": {
"query": "jumping rabbits",
"type": "most_fields",
"fields": [ "title^10", "title.std" ] #1
}
}
}
- #1 title 字段的 boost 的值爲10使它比 title.std 更重要。
跨字段實體搜索(Cross-fields Entity Search)
現在我們討論一種普遍的搜索模式:跨字段實體搜索(cross-fields entity search)。在如人 (person)、產品(product) 或 地址(address) 這樣的實體中,我們需要使用多個字段來唯一辨認它的信息。一個 人(person) 實體可能是這樣索引的:
{
"firstname": "Peter",
"lastname": "Smith"
}
而一個 地址(address) 可能是這樣
{
"street": "5 Poland Street",
"city": "London",
"country": "United Kingdom",
"postcode": "W1V 3DG"
}
這與我們之前說的多字符串查詢很像,但是這裏有一個巨大的區別。在多字符串查詢中,我們爲每個字段使用不同的字符串,在這個例子中,我們想使用單個字符串在多個字段中進行搜索。
我們的用戶可能想要查找 “Peter Smith”這個人 或 “Poland Street W1V”這個地址,這些詞都出現在不同的字段中,所以如果使用 dis_max / best_fields 查詢去查找單個最佳匹配字段顯然是錯誤的。
一種弱弱的方式(A Naive Approach)
我們依次查詢每個字段,然後每個字段的匹配結果相加,這看起來像個 bool 查詢:
{
"query": {
"bool": {
"should": [
{ "match": { "street": "Poland Street W1V" }},
{ "match": { "city": "Poland Street W1V" }},
{ "match": { "country": "Poland Street W1V" }},
{ "match": { "postcode": "Poland Street W1V" }}
]
}
}
}
爲每個字段重複查詢字符串會使查詢變得冗長,我們可以使用 multi_match 查詢,將類型設置成 most_fields 然後告訴ELasticSearch合併所有匹配字段的分數:
{
"query": {
"multi_match": {
"query": "Poland Street W1V",
"type": "most_fields",
"fields": [ "street", "city", "country", "postcode" ]
}
}
}
most_fields 方式的問題
用most_fields 這種方式搜索會有一些問題,這些問題不會馬上顯現出來:
- 它是爲多數字段匹配任意詞而設計的,而不是在所有字段中找到最匹配的。
- 它不能使用參數 operator 或 minimum_should_match 來減少此相關結果中的長尾。
- 詞頻對於每個字段是不一樣的,而且它們之間相互影響可能會生成一個不好的排序結果。
以字段爲中心的查詢(Field-Centric Queries)
上面這三個來自於 most_fields 的問題都是因爲它是 字段爲中心的(field-centric)而不是 術語爲中心的(term-centric)。當我們對術語(terms)匹配真正感興趣時,它爲我們查找的是最匹配的字段(fields)。
注意:
best_fields 類型也是字段爲中心的(field-centric)有着類似的問題。
首先我們來看看這些問題存在的原因,然後再來解決它們。
問題 1:多個字段從匹配相同詞(Matching the Same Word in Multiple Fields)
回想一下 most_fields 查詢是如何執行的:ElasticSearch爲每個字段生成一個查詢,然後用 bool 查詢將他們包裹起來。
我們可以通過 validate-query API來查看:
GET /_validate/query?explain
{
"query": {
"multi_match": {
"query": "Poland Street W1V",
"type": "most_fields",
"fields": [ "street", "city", "country", "postcode" ]
}
}
}
生成的解釋(explanation)爲:
(street:poland street:street street:w1v)
(city:poland city:street city:w1v)
(country:poland country:street country:w1v)
(postcode:poland postcode:street postcode:w1v)
得到的結果中,一個兩個字段與 poland 匹配的文檔比一個字段內同時匹配 poland 與 street 的文檔分數要高。
問題 2:剪掉長尾(Trimming the Long Tail)
在精度控制(Controlling Precision)中,我們討論了使用 and 操作符 或者 設置minimum_should_match 參數來消除結果中不相關的長尾:
{
"query": {
"multi_match": {
"query": "Poland Street W1V",
"type": "most_fields",
"operator": "and", #1
"fields": [ "street", "city", "country", "postcode" ]
}
}
}
- #1 所有詞必須呈現。
但是對於 best_fields 或 most_fields 這樣的參數會在 match 查詢生成時被傳入,這個查詢的 explaination 如下:
(+street:poland +street:street +street:w1v)
(+city:poland +city:street +city:w1v)
(+country:poland +country:street +country:w1v)
(+postcode:poland +postcode:street +postcode:w1v)
換句話說,使用 and 操作符要求所有詞都必須存在於相同字段,顯然這樣是不對的!可能沒有任何文檔能與這個查詢匹配。
問題 3:詞頻(Term Frequencies)
在什麼是相關(What is Relevance)中,我們解釋了每個術語使用默認相似度算法是 TF/IDF:
-
詞頻(Term frequency)
一個詞在單個文檔的某個字段中出現的頻率越高,這個文檔的相關度越高。
-
逆向文件頻率(Inverse document frequency)
一個詞在所有文檔索引中出現的頻率越高,這個詞的相關度越低。
當我們在多個字段中進行搜索時,TF/IDF可以爲我們帶來某些意外的結果。
考慮我們用字段 first_name 和 last_name 字段查詢 “Peter Smith”的例子,Peter是一個普通的名(first name)同時Smith也是個一個非常普通的姓(last name),他們都具有較低的IDF值。但是當我們索引中有另外一個人的名字是 “Smith Williams”時,Smith作爲姓(first name)就變得非常不普通以致它有一個較高的IDF值。
下面這個簡單的查詢可能會在結果中將 “Smith Williams” 置於 “Peter Smith”之上,儘管事實上第二個人比第一個人更匹配。
{
"query": {
"multi_match": {
"query": "Peter Smith",
"type": "most_fields",
"fields": [ "*_name" ]
}
}
}
這裏的問題是 Smith 在名字段中有着高IDF,它會削弱 “Peter”作爲名和“Smith”作爲姓時較低的IDF的作用。
解決方案(Solution)
這些問題存在的原因在於它處理着多個字段,如果我們將所有這些字段組合成單個字段,這個問題就會不復存在。我們可以爲Person文檔添加一個 full_name 字段:
{
"first_name": "Peter",
"last_name": "Smith",
"full_name": "Peter Smith"
}
當對 full_name 進行查詢時:
- 具有更多匹配詞的文檔會比只有一個重複匹配詞的文檔更重要。
- 參數 minimum_should_match 和 operator 會如我們期望那樣工作。
- 姓和名的逆向文件頻率被合併,所以 Smith 到底是作爲姓出現,還是作爲名出現?這個問題會變得無關緊要。
這麼做當然是可行的,但是我們不太喜歡存儲冗餘的數據。ElasticSearch爲我們提供了兩個解決方案——一個是索引時的,另一個是搜索時的——我們會在稍後討論這兩個方案。
自定義_all字段(Custom *_all* Fields)
在元數據_all 字段中(**Metadata:_all Field**),我們解釋過:*_all* 字段的索引方式是將所有其他字段的值作爲一個巨大的字符串進行索引的。儘管這麼做並不是十分靈活,但是我們可以爲人的姓名添加一個自定義 *_all* 字段,然後再爲地址添加另一個 *_all* 字段。
ElasticSearch在字段映射中,爲我們提供了一個 copy_to 參數來實現這個功能。
PUT /my_index
{
"mappings": {
"person": {
"properties": {
"first_name": {
"type": "string",
"copy_to": "full_name" #1
},
"last_name": {
"type": "string",
"copy_to": "full_name" #2
},
"full_name": {
"type": "string"
}
}
}
}
}
- #1 #2 first_name 和 last_name 字段中的值會被複制到 full_name 字段中。
有了這個映射,我們可以使用 first_name 查詢名,使用 last_name 查詢名,或者直接使用 full_name 查詢姓名。
注意:
映射中 first_name 和 last_name 並不知道 full_name是如何被索引的,full_name將兩個字段的內容複製到本地,然後自行索引。
跨字段查詢(cross_fields Queries)
自定義 _all 的方式是一個好的解決方案,我們只需要在索引文件之前爲其設置好映射即可。不過,ElasticSearch還在搜索時(search-time)提供了相應的解決方案:使用類型 cross_fields 進行multi_match 查詢。cross_fields 使用以術語爲中心(term-centric)的查詢方式,這與 best_fields 和 most_fields 使用的字段爲中心(field-centric)的查詢方式非常不同。它將所有字段看成一個大的字段,然後在裏面查找每個術語(each term)。
爲了說明這兩個查詢方式(field-centric和term-centric)的不同,我們先看看下面這個以字段爲中心的 most_fields 查詢的 explanation:
GET /_validate/query?explain
{
"query": {
"multi_match": {
"query": "peter smith",
"type": "most_fields",
"operator": "and", #1
"fields": [ "first_name", "last_name" ]
}
}
}
- #1 所有術語都是必須的。
對於一個匹配的文檔,peter 和 smith 都必須同時出現在同一字段中,要麼是 first_name字段中,要麼是 last_name 字段中:
(+first_name:peter +first_name:smith)
(+last_name:peter +last_name:smith)
但是以術語爲中心的方式會是下面這樣:
+(first_name:peter last_name:peter)
+(first_name:smith last_name:smith)
換句話說,術語 peter 和 smith 都必須出現,但是可以出現在任意字段中。
cross_fields 類型首先分析查詢字符串並生成一個術語列表,然後它在所有字段從依次搜索每個術語。這種不同的搜索方式很自然的解決了字段中心式查詢(Field-Centric Queries)三個問題中的二個。留給我們的問題只是:逆向文件頻率不同。
幸運的是,cross_fields 同樣可以解決這個問題,通過 validate-query 查看:
GET /_validate/query?explain
{
"query": {
"multi_match": {
"query": "peter smith",
"type": "cross_fields", #1
"operator": "and",
"fields": [ "first_name", "last_name" ]
}
}
}
- #1 用cross_fields 術語中心式查詢。
它通過將不同字段的逆向索引文件頻率(inverse document frequency)混合的方式解決詞頻(term-frequency)的問題:
+blended("peter", fields: [first_name, last_name])
+blended("smith", fields: [first_name, last_name])
換句話說,它會同時在 first_name 和 last_name 兩個字段中查找 smith 的IDF,然後用兩者的最小值作爲兩個字段的IDF。結果實際上就是:smith 會被認爲既是一個普通的姓,同時也是一個普通的名。
注意:
爲了讓 cross_fields 查詢以最優方式工作,所有的字段都需要使用相同的分析器,具有相同分析器的字段會被分組在一起作爲混合字段使用。
如果包括了不同分析鏈的字段,它們會以 best_fields 的相同方式加到查詢結果中。例如:我們將 title 字段加到之前的查詢中(假設他們使用的是不同的分析器),explaination 的結果如下:
(+title:peter +title:smith) ( +blended("peter", fields: [first_name, last_name]) +blended("smith", fields: [first_name, last_name]) )
提高字段權重(Per-Field Boosting)
cross_fields 查詢與 自定義_all 相比的一個優勢就是它可以在搜索時,爲單個字段提升權重。
我們不需要爲像 first_name 和 last_name這樣具有相同值的字段這麼做,但是如果要用 title 和 description 字段搜索圖書,我們可能希望爲 title 分配更多的權重,這同樣可以使用前面介紹過的 脫字號(caret ^)語法來實現:
GET /books/_search
{
"query": {
"multi_match": {
"query": "peter smith",
"type": "cross_fields",
"fields": [ "title^2", "description" ] #1
}
}
}
- \1 title 字段的 boost 爲 2,description 字段的boost 爲默認值 1。
能夠爲單個字段指定boost值所帶來的好處需要權衡多字段查詢與單字段自定義_all之間的代價,即那種方案會給我們帶來更大的(性能)壓力。
準確值字段(Exact-Value Fields)
在結束多字段查詢這個話題之前,我們最後需要討論的是準確值 not_analyzed 字段。將 not_analyzed 字段與 multi_match 中 analyzed 字段混在一起沒有多大用處。
原因可以通過查看查詢的explaination得到,假設我們將 title 字段設置成 not_analyzed:
GET /_validate/query?explain
{
"query": {
"multi_match": {
"query": "peter smith",
"type": "cross_fields",
"fields": [ "title", "first_name", "last_name" ]
}
}
}
因爲 title 字段是未分析過的,ElasticSearch會將“peter smith”這個完整的字符串作爲查詢術語進行搜索:
title:peter smith
(
blended("peter", fields: [first_name, last_name])
blended("smith", fields: [first_name, last_name])
)
顯然這個術語不在title的反向索引中,所以需要在 multi_match 查詢中避免使用 not_analyzed 字段。