ElasticSearch 2 (14) - 深入搜索系列之全文搜索
摘要
在看過結構化搜索之后,我們看看怎樣在全文字段中查找相關度最高的文檔。
全文搜索兩個最重要的方面是:
-
相關(relevance)
相關是將查詢到相關的文檔結果進行排名的一種能力,這種相關度可以是根據TF/IDF、地理位置相似性(geolocation)、模糊相似,或者其他的一些算法得出。
-
分析(analysis)
將一個文本塊轉換為唯一的、規范化的token的過程,目的是為了(a)創建反向索引以及(b)查詢反向索引。
當我們提到相關與分析的時候,我們已經身處查詢之中,而不是過濾。
版本
elasticsearch版本: elasticsearch-2.x
內容
基於術語的與基於全文的(Term-Based versue Full-Text)
所有查詢都會或多或少的執行相關性計算,但不是所有查詢都有分析階段,與特殊查詢 bool 或 function_score 不同,我們不會操作文本,而是將文本查詢拆分到兩部分:
基於術語的查詢(Term-based queries)
如 term 或 fuzzy 這樣低層次的查詢不需要分析階段。它們對單個 term 進行操作。一個 term 查詢 一個術語(Foo)在反向索引中查找准確的術語,並且用 TF/IDF 算法為每個匹配的文檔計算相關度 _score 。
需要記住 term 查詢只對反向索引的術語進行精確匹配,它不會對多樣性進行處理(即,同時匹配foo 或 FOO)。這里,無須考慮術語是如何進入索引的。如果打算將索引 ["Foo", "Bar"] 索引,無論是 not_analyzed 還是 Foo Bar 這樣帶有空格(whitespace)的字段,結果都是在反向索引中有 Foo 和 Bar 兩個術語。
基於全文的查詢(Full-text queries)
如 match 或 query_string 這樣的高層次查詢,它知道一個字段的映射:
- 如果我們用它來查詢 時間(date) 或 整數(integer),他們會將查詢字符串用分別當作 時間 和 整數。
- 如果查詢一個准確的(未分析過的 not_analyzed)字符串字段,它會將整個查詢字符串當成一個術語。
- 但是如果要查詢一個全文字段(分析過的 analyzed),它會講查詢字符串傳入到一個合適的分析器,然后生成一個供查詢的術語列表。
一旦查詢組成了一個術語列表,它會對每個術語逐一執行低層次的查詢,然后將結果合並,為每個文檔生成一個最終的相關性分數。
我們很少直接使用基於術語的搜索,通常情況下都是對全文進行查詢,這樣只需要執行一個高層次的全文查詢(high-level full-text queries),進而在這個查詢內部以基於術語的查詢結束。
注意:
當我們想要准確查詢一個未分析過(not_analyzed)的字段之前,需要仔細想想,我們到底是想要一個查詢還是一個過濾。
單術語查詢通常可以用是非問題表示,所以更適合用過濾來表達,而且這樣子可以有效利用過濾的緩存:
GET /_search
{
"query": {
"filtered": {
"filter": {
"term": { "gender": "female" }
}
}
}
}
匹配查詢(The match Query)
匹配查詢又稱為 go-to 查詢。當我們想要查詢某個字段時需要使用的首個查詢。它是一個高層次的全文查詢,這表示它既能處理全文字段,也能處理精確字段。
match 最主要的應用場景就是進行全文搜索,我們以下面一個簡單的例子來說明:
索引某些數據(Index Some Data)
首先我們用bulk API創建一些新的文檔和索引:
DELETE /my_index #1
PUT /my_index
{ "settings": { "number_of_shards": 1 }} #2
POST /my_index/my_type/_bulk
{ "index": { "_id": 1 }}
{ "title": "The quick brown fox" }
{ "index": { "_id": 2 }}
{ "title": "The quick brown fox jumps over the lazy dog" }
{ "index": { "_id": 3 }}
{ "title": "The quick brown fox jumps over the quick dog" }
{ "index": { "_id": 4 }}
{ "title": "Brown fox brown dog" }
- #1 刪除已有的索引
- #2 稍后,我們會在Relevance Is Broken中解釋只為這個索引分配在一個主分片(primary shard)中。
單個詞查詢
我們的第一個例子要解釋使用 match 在全文字段中使用單個詞進行查找:
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": "QUICK!"
}
}
}
ElasticSearch執行上面這個 match 查詢的步驟是:
-
檢查字段類型(Check the field type)
標題 title 字段是一個全文(analyzed) string 類型的字段,這意味着查詢字符串本身也需要被分析(analyzed)。
-
分析查詢字符串(Analyze the query string)
將查詢的字符串 QUICK! 傳入標准的分析器中(standard analyzer),輸出的結果是 quick。因為對於單個術語,match 查詢處理的是低層次的術語查詢。
-
查找匹配文檔(Find matching docs)
term 查詢在反向索引中查找 quick 然后獲取一組包含有術語的文檔,在這個例子中為文檔:1,2和3。
-
為每個文檔計算分數(Score each doc)
term 查詢為每個文檔計算相關度分數,將術語頻率(term frequency),即quick詞在相關文檔title中出現的頻率,和反向文檔頻率(inverse document frequency),即quick在所有文檔title中出現的頻率,以及字段的長度,即字段越短相關度越高。
這個過程給我們的結果為:
"hits": [
{
"_id": "1",
"_score": 0.5, #1
"_source": {
"title": "The quick brown fox"
}
},
{
"_id": "3",
"_score": 0.44194174, #2
"_source": {
"title": "The quick brown fox jumps over the quick dog"
}
},
{
"_id": "2",
"_score": 0.3125, #3
"_source": {
"title": "The quick brown fox jumps over the lazy dog"
}
}
]
- #1 文檔1最相關,因為title更短,即quick占有內容的很大一部分。
- #2、3 文檔3 比 文檔2 更相關,因為在文檔2中quick出現了2次。
多詞查詢(Multiword Queries)
如果我們一次只能搜索一個詞,那么全文搜索會非常不靈活,幸運的是,ElasticSearch的 match 查詢讓多詞查詢非常簡單:
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": "BROWN DOG!"
}
}
}
上面這個查詢返回了所有四個文檔:
{
"hits": [
{
"_id": "4",
"_score": 0.73185337, #1
"_source": {
"title": "Brown fox brown dog"
}
},
{
"_id": "2",
"_score": 0.47486103, #2
"_source": {
"title": "The quick brown fox jumps over the lazy dog"
}
},
{
"_id": "3",
"_score": 0.47486103, #3
"_source": {
"title": "The quick brown fox jumps over the quick dog"
}
},
{
"_id": "1",
"_score": 0.11914785, #4
"_source": {
"title": "The quick brown fox"
}
}
]
}
- #1 文檔4是最為相關的,因為它包含詞“brown”兩次以及“dog”一次。
- #2#3 文檔2、3同時包含brown 和 dog 各一次,而且他們title的長度相同,所以有相同的分數。
- #4 文檔1 也能匹配,盡管它只有 brown 而沒有 dog。
因為 match 查詢查找兩個詞 ["brown", "dog"],內部實際上它執行兩次 term 查詢,然后將兩次查詢的結果合並為最終結果輸出。為了這么做,它將兩個term查詢合並到一個bool查詢中。
這告訴我們一個重要的結果,即任何文檔只要是title里面包含了任意一個詞,那么就會匹配上,被匹配的詞越多,最終的相關性越高。
提高精度(Improving Precision)
用任意查詢術語匹配任意文檔會導致一個不相關的長尾。這是散彈搜索法。我們可能只想搜索包含所有術語的文檔。換句話說,不是查找 brown 或 dog,我們想要找到所有 brown 和 dog同時匹配的文檔。
match 查詢接受操作符作為輸入參數,默認情況下操作符是 or。我們可以修改它,讓所有詞都必須匹配:
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": {
"query": "BROWN DOG!",
"operator": "and"
}
}
}
}
這個查詢可以把文檔1排除在外,因為它只包含一個術語。
控制精度(Controlling Precision)
在 all 與 any 間的選擇太過於非黑即白了,如果用戶給了5個查詢術語(term),想要查找只包含其中4個的文檔時,該怎么辦呢?如果將操作符(operator)置為 and 就會將此文檔排除在外。
有些時候這可能正式我們想要的,但是在全文搜索的大多數應用場景下,我們想包含那些相關的文檔,同時又排除那些不太相關的文檔。換句話說,我們想要中間的某種狀態。
match 查詢支持參數 minimum_should_match (最小匹配),讓我們給出必須匹配的術語數,以表示一個文檔更具相關度。我們可以將其設置為某個具體數字,更常用的是將其設置為一個百分數,因為我們無法控制用戶輸入的單詞數:
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": {
"query": "quick brown dog",
"minimum_should_match": "75%"
}
}
}
}
當給定百分比的時候,minimum_should_match 會做合適的事情:在之前三個術語的例子中,75%會自動修正成66.6%,即三個里面兩個詞。無論這個值設置成什么,對被匹配到的文檔來說,至少有一個詞必須匹配。
注意
minimum_should_match 的設置非常靈活,官方文檔里會有詳細介紹
為了完整的了解 match 是如何處理多詞查詢的,我們需要看看ElasticSearch是如何用bool查詢將多個查詢組合到一起的。
組合查詢(Combining Queries)
在組合過濾器中,我們討論了如何使用 bool 過濾器將多個過濾器通過 and,or 和 not 邏輯組合在一起的,在查詢中,bool 查詢的與之非常相似,只有一個重要的區別。
過濾器做二元判斷:文檔是否應該出現在結果集中?但是,對於查詢來說,更為精妙。它除了決定一個文檔是否應該被包括在結果集中,還會計算文檔的相關程度。
與filter對應,bool查詢接受多個查詢語句,must,must_not,和 should參數。比如:
GET /my_index/my_type/_search
{
"query": {
"bool": {
"must": { "match": { "title": "quick" }},
"must_not": { "match": { "title": "lazy" }},
"should": [
{ "match": { "title": "brown" }},
{ "match": { "title": "dog" }}
]
}
}
}
這個結果會返回任何一個標題title包含quick,但是不含有lazy的文檔。到目前為止,這與bool過濾器的工作方式非常相似。
區別在於兩個 should 語句,這句話的意思是:一個文檔不必包括 brown 和 dog 這兩個術語,但是如果包含了,我們認為他們更相關:
{
"hits": [
{
"_id": "3",
"_score": 0.70134366,
"_source": {
"title": "The quick brown fox jumps over the quick dog"
}
},
{
"_id": "1",
"_score": 0.3312608,
"_source": {
"title": "The quick brown fox"
}
}
]
}
這就是為什么文檔3會比文檔1有更高的分數。
分數計算(Score Calculation)
bool查詢為每個文檔計算相關分數 _score 然后,它將所有匹配的 must 和 should 語句的分數求和,然后除以 must 和 should 的總數。
must_not語句不影響分數;它的作用只是將不相關的文檔排除。
控制精度(Controlling Precision)
所有的must語句必須匹配,所有must_not語句都必須不能匹配,但是有多少 should 語句應該匹配呢?默認情況下,沒有 should 語句是需要匹配的,只有一個例外:當沒有 must 語句的時候,至少有一個should語句需要匹配。
就像我們能控制 匹配查詢的精度 一樣,我們可以通過 minimum_should_match 參數控制需要匹配的should語句,它既可以是一個絕對的數字,也可以是個百分比:
GET /my_index/my_type/_search
{
"query": {
"bool": {
"should": [
{ "match": { "title": "brown" }},
{ "match": { "title": "fox" }},
{ "match": { "title": "dog" }}
],
"minimum_should_match": 2
}
}
}
這個查詢結果會將所有滿足一下條件的文檔返回:標題包括 "brown" AND "fox","brown" AND "dog","fox" AND "dog"。如果有文檔包含他們三個,它比只包含兩個的文檔更為相關。
如何使用bool匹配(How match Uses bool)
目前為止,可能已經知道如何對多個詞進行查詢,我們需要做的只是要把多個語句放入bool查詢中,因為默認的操作符是 or,每個 term 查詢都會被當作 should 語句進行處理,所以至少有一個語句需要匹配,下面的兩個查詢是等價的:
{
"match": { "title": "brown fox"}
}
與
{
"bool": {
"should": [
{ "term": { "title": "brown" }},
{ "term": { "title": "fox" }}
]
}
}
如果使用 and 操作符,那么下面兩個語句也是等價的:
{
"match": {
"title": {
"query": "brown fox",
"operator": "and"
}
}
}
與
{
"bool": {
"must": [
{ "term": { "title": "brown" }},
{ "term": { "title": "fox" }}
]
}
}
如果按照下面這樣給定參數 minimum_should_match,那么下面兩個查詢也是等價的:
{
"match": {
"title": {
"query": "quick brown fox",
"minimum_should_match": "75%"
}
}
}
與
{
"bool": {
"should": [
{ "term": { "title": "brown" }},
{ "term": { "title": "fox" }},
{ "term": { "title": "quick" }}
],
"minimum_should_match": 2
}
}
當然,我們通常將這些查詢以 match 查詢來表示,但是如果了解match內部的工作原理,我們就能對查詢過程按照我們的需要進行控制,有些時候單個match查詢無法滿足需求,比如我們要為一些查詢條件分配更多的權重。在下一部分中,我們會介紹這個例子。
使用查詢語句(Boosting Query Clause)
當然 bool 查詢不僅限於組合簡單的單個詞的 match 查詢,它可以組合任意其他的查詢,甚至其他 bool 查詢。通常的使用方法是為每個不同查詢的文檔結果優化相關性分數計算,然后將不同的分數匯總。
想象我們想要查詢關於“full-text search”的文檔,但是我們希望為提及“ElasticSearch”或“Lucene”的文檔給予更多的權重,即如果這些文檔中出現“ElasticSearch”或“Lucene”,會比沒有的得到的更高的相關性分數 _score。也就是說,它們會出現在結果集的更上面。
一個簡單的 bool 查詢允許我們寫出非常復雜的邏輯:
GET /_search
{
"query": {
"bool": {
"must": {
"match": {
"content": {
"query": "full text search",
"operator": "and"
}
}
},
"should": [
{ "match": { "content": "Elasticsearch" }},
{ "match": { "content": "Lucene" }}
]
}
}
}
should 匹配的更多相關性越高。
但是如果我們想為“ElasticSearch”和“Lucene”分配不同的權重,即“ElasticSearch”比“Lucene”更相關,該怎么辦?
我們可以通過 boost 來給出相對的權重,默認情況下,boost的值為1,大於1會增加一個語句的權重,重寫上面的查詢:
GET /_search
{
"query": {
"bool": {
"must": {
"match": { #1
"content": {
"query": "full text search",
"operator": "and"
}
}
},
"should": [
{ "match": {
"content": {
"query": "Elasticsearch",
"boost": 3 #2
}
}},
{ "match": {
"content": {
"query": "Lucene",
"boost": 2 #3
}
}}
]
}
}
}
- #1 這些語句使用默認的boost值1。
- #2 這句更重要,因為它有更高的boost值。
- #3 這句比默認語句更重要,但是不及ElasticSearch語句。
注意:
boost參數是用來增加一個語句的相對權重的(boost值大於1)或降低相對權重(boost值處於0到1之間),但是這種增加和降低並不是線性的,換句話說,如果一個boost值為2,並不代表着最終的分數 _score 會是原來值的兩倍。
相反,新的分數_score會在使用boost之后重新規范化,每種類型的查詢都有自己的規范算法,詳細的情況不在此介紹,簡單的說,一個更高的boost值會有一個更高的分數_score。
更多的組合查詢(combining queries)會在多字段查詢(multifield search)中介紹,在此之前,我們需要介紹另一個重要的查詢特性:文本分析(text analysis)。
控制分析(Controlling Analysis)
查詢只能查找反向索引表中存在的術語,所以要保證文檔在索引時與查詢字符串在搜索時使用的是同一分析過程。這樣才能使查詢的術語與反向索引中的術語能夠匹配。
盡管我們說文檔的分析器(analyzer)是根據字段不同而不同的。每個字段都可以有不同的分析器,可以為某以字段(Field)、類型(Type)、索引(Index)、或節點(Node)指定具體的分析器。在索引時,一個字段值是根據配置或默認的分析器分析的。
下面這個例子是為my_index加一個新的字段:
PUT /my_index/_mapping/my_type
{
"my_type": {
"properties": {
"english_title": {
"type": "string",
"analyzer": "english"
}
}
}
}
現在我們就可以比較 english_title 字段和 title 字段在索引時分析的結果:
GET /my_index/_analyze?field=my_type.title #1
Foxes
GET /my_index/_analyze?field=my_type.english_title #2
Foxes
- #1 字段 title,使用默認標准的分析器,返回 foxes。
- #2 字段 english_title,使用 english 分析器,返回 fox。
這說明,當我們使用低層次術語查詢fox時,english_title字段會匹配,但是 title 字段不會。
高層次的查詢例如 match 查詢知道字段映射的關系,可以使用正確的API進行查詢:
GET /my_index/my_type/_validate/query?explain
{
"query": {
"bool": {
"should": [
{ "match": { "title": "Foxes"}},
{ "match": { "english_title": "Foxes"}}
]
}
}
}
返回結果:
(title:foxes english_title:fox)
match查詢為每個字段使用合適的分析器,以保證它在查看每個術語時都為該字段使用了正確的格式。
默認分析器(Default Analyzers)
當我們在字段級別指定分析器時,如果該級別沒有指定任何的分析器,我們是如何確定這個字段使用的是哪個分析器呢?
分析器可以在不同級別進行指定。
在索引時,ElasticSearch會按照以下順序依次處理,直到它找到能用的分析器
- analyzer 在字段映射中定義,否則
- analyzer 在文檔的 _analyzer 中指定,否則
- type 使用的默認 analyzer,否則
- 索引設置中名為 default 的 analyzer,否則
- 節點設置中名為 default 的 analyzer,否則
- 標准(standard)的 analyzer
在搜索時,順序有些許不同:
- 定義在查詢里的 analyzer,否則
- 定義在字段映射里的 analyzer,否則
- type 的默認 analyzer,否則
- 索引設置中名為 default 的 analyzer,否則
- 節點設置中名為 default 的 analyzer,否則
- 標准(standard)的 analyzer
注意:
多語言需要特殊處理Dealing with Human Language
有時,在索引時和搜索時使用不同的分析器是合理的。比如,在索引時我們希望索引到同義詞(在quick出現的地方,我們希望同時索引fast,rapid和speedy)。但在搜索時,我們不需要搜索所有的同義詞。我們只需要關注用戶輸入的單詞,無論是quick,fast,rapid,還是speedy,輸入什么就是什么。
為了區分,ElasticSearch支持參數 index_analyzer 和 search_analyzer,以及 default_index 和 default_search的分析器。
如果考慮到這些參數,一個完整的順序是,
在索引時:
- 在字段映射中定義的 index_analyzer,否則
- 在字段映射中定義的 analyzer,否則
- 在文檔的 _analyzer 中指定的 analyzer,否則
- 在type中指定的默認 index_analyzer,否則
- 在type中指定的默認 analyzer,否則
- 在index設置中指定的名為 default_index 的 analyzer,否則
- 在index設置中指定的名為 default 的 analyzer,否則
- 在node中指定的名為 default_index 的 analyzer,否則
- 在node中指定的名為 default 的 analyzer,否則
- 標准(standard)的 analyzer
在搜索時:
- 在query中定義的 analyzer,否則
- 在字段映射中定義的 search_analyzer,否則
- 在字段映射中定義的 analyzer,否則
- 在type中指定的默認 search_analyzer,否則
- 在type中指定的默認 analyzer,否則
- 在index設置中指定的名為 default_search 的 analyzer,否則
- 在index設置中指定的名為 default 的 analyzer,否則
- 在node中指定的名為 default_search 的 analyzer,否則
- 在node中指定的名為 default 的 analyzer,否則
- 標准(standard)的 analyzer
配置分析器實踐(Configuring Analyzers in Practice)
就可以配置分析器地方的數量來看是驚人的,但是在實際中,確非常簡單。
使用索引設置而非配置文件(Use index settings, not config files)
需要記住的第一件事情是,盡管我們最開始使用ElasticSearch的目的非常簡單,或者只是為了搜集單個應用的日志,在很大程度上,我們有機會將許多不同的應用跑在同一個ElasticSearch集群上,每個索引都必須獨立,並且獨立配置,我們不想為某一種應用場景設置默認配置,而在另一個場景下重寫它。
所以我們不推薦在node基本配置分析器,而且如果要在node級別配置分析器,需要我們修改每個節點的配置文件,然后重啟每個節點,這將是維護的噩夢。只通過API維護與管理ElasticSearch的設置是我們推薦的。
保持簡單(Keep it simple)
大多數情況下,我們會知道我們文檔中將會包括哪些字段,最簡單的方式就是在創建索引(Index)或者增加類型映射(Type Mapping)的時候為每個全文字段設置分析器。這種方式盡管有點麻煩,但是它讓我們可以清楚的看到每個字段的每個分析器是如何設置的。
通常,大多數字符串字段都是准確值(not_analyzed),比如標簽(tag)或枚舉(enum),而且更多的全文字段會使用默認的分析器如:standard 或 english或其他某種語言。這樣我們只需要為少數一兩個字段指定自定義分析:可能標題title字段需要以支持 find-as-you-type 的方式進行索引。
我們可以為幾乎所有的全文字段指定默認的分析器,只為少數一兩個字段進行特殊分析器的配置,如果在模型上,我們需要為不同類型type指定不同的默認分析器,只需要在type級別配置即可。
注意:
對於和時間相關的日志數據,它的索引是每天創建的,由於這種方式不是由我們自己從頭創建索引的,我們仍然可以用索引模板(index templates)為新建的索引指定配置和映射。
被破壞的相關度(Relevance is Broken)
在我們討論更復雜的多字段查詢之前,這里先解釋一下為什么我們將測試數據只創建在一個主分片上(primary shard)。
用戶會時不時的抱怨遇到這樣的問題:用戶索引了一些文檔,運行了一個簡單的查詢,然后明顯發現一些低相關性的結果出現在高相關性結果之上。
為了理解為什么會這樣,我們可以想象一下,我們在兩個主分片上創建了索引,總共10個文檔,其中6個文檔有單詞foo,可能是shard 1有其中3個文檔,而shard 2有其中另外3個文檔,換句話說,所有文檔是均勻分布存儲的。
在何為相關性中,我們描述了ElasticSearch默認的相似度算法,這個算法叫做 TF/IDF (term frequency/inverse document frequency)。詳細參照之前提到的內容。
但是由於性能原因,ElasticSearch在計算IDF時,不會計算所有的文檔。相反,每個分片會根據該分片的所有文檔,計算一個本地的IDF。
因為我們的文檔是均勻的分布存儲的,每個shard的IDF是相同的。如果有5個文檔存在於shard 1,而第6個文檔存在於shard 2,在這種場景下,foo在一個shard里非常普通(所以不那么重要),但是在另個shard里非常少(所以會顯得更重要)。這樣局部IDF的差異會導致不正確的結果。
在實際中,這不是一個問題,本地和全局的IDF的差異隨着索引里文檔數的增多會漸漸消失,在真實世界的數據量下,局部的IDF會迅速被均化,所以這里的問題並不是相關性被破壞了,而是數據量太小了。
為了測試,我們可以通過兩種方式解決這個問題。第一種是只在主分片上創建索引,就如我們例子里做的那樣,如果只有一個shard,那么本地的IDF就是全局的IDF。
第二個方式就是在搜索請求后添加 ?search_type=dfs_query_then_fetch,dfs 是指 分布式頻率搜索(Distributed Frequency Search),它告訴ElasticSearch,先分別獲得每個shard本地的IDF,然后根據結果再計算全局的IDF。
注意:
不要在產品上使用第二種方式,我們完全無須如此。
