ElasticSearch 2 (16) - 深入搜索系列之近似度匹配
摘要
標准的全文搜索使用TF/IDF處理文檔、文檔里的每個字段或一袋子詞。match 查詢可以告訴我們哪個袋子里面包含我們搜索的術語,但這只是故事的一部分。它並不能告訴我們詞語之間的關系。
考慮下面句子的區別:
- Sue ate the alligator.
- The alligator ate sue.
- Sue never goes anywhere without her alligator-skin purse.
一個 match 查詢 “sue alligator”會匹配所有三個文檔,但是它不會告訴我們這兩個詞組合在一起是否為同一個意思,甚至是否為同一個段落。
要理解詞語之間是如何相關的是個非常復雜的問題,我們無法只是簡單使用另外一個類型的查詢來解決此問題,但是我們至少可以查找到相關的詞,因為他們出現在鄰近的地方,甚至相互緊接着。
每個文檔都可能會比我們例子中給出的要長的多:Sue 和 alligator 可能被其他段落隔離,即使可能有的文檔中這些詞之間相距較遠,我們仍然希望能夠將他們找出來,但是我們希望為鄰近出現的文檔給出更高的相關度分數。
這個問題屬於短語匹配(phrase matching)或相似度匹配(proximity matching)的領域。
版本
elasticsearch版本: elasticsearch-2.x
內容
短語匹配(Phrase Matching)
與 match 查詢類似,match_phrase 查詢是標准全文搜索的核心,當我們想要查找鄰近出現的詞語時會使用到它:
GET /my_index/my_type/_search
{
"query": {
"match_phrase": {
"title": "quick brown fox"
}
}
}
與 match 查詢類似,match_phrase 查詢首先分析查詢字符串並生成一個術語列表,然后它會搜索所有術語,但是只將包含所有 all 查詢術語的文檔放在相對應的位置上。一個“quick box”短語查詢不會與我們任何文檔匹配,因為沒有任何文檔包含短語 quick box。
match_phrase 查詢也可以用一個phrase類型的match 來表示:
"match": {
"title": {
"query": "quick brown fox",
"type": "phrase"
}
}
術語位置(Term Positions)
當字符串被分析時,分析器不僅返回了一個術語列表,也包括每個術語在原字符串中的位置(position)、順序(order)信息:
GET /_analyze?analyzer=standard
Quick brown fox
返回結果為:
{
"tokens": [
{
"token": "quick",
"start_offset": 0,
"end_offset": 5,
"type": "<ALPHANUM>",
"position": 1 #1
},
{
"token": "brown",
"start_offset": 6,
"end_offset": 11,
"type": "<ALPHANUM>",
"position": 2 #2
},
{
"token": "fox",
"start_offset": 12,
"end_offset": 15,
"type": "<ALPHANUM>",
"position": 3 #3
}
]
}
- #1 #2 #3 是每個術語處於原字符串中的位置。
位置可以存儲與反向索引中,像 match_phrase 這樣與位置相關的查詢,可以用它來搜索包含所有這些詞且順序一致的文檔,沒有中間狀態。
何謂短語(What Is a Phrase)
對於一個和短語“quick brown fox”匹配的文檔來說,必須滿足一下條件:
- quick 、brown 和 fox 必須所有都出現在字段里。
- brown 的位置必須比 quick 的位置大1。
- fox 的位置必須比 quick 的位置大2。
如果任意一個條件不滿足,文檔就是不匹配的。
在內部,match_phrase 查詢使用低層次段(span)查詢處理位置相關的匹配,段查詢是一種術語層的查詢,所以它沒有分析階段;他們對給定的術語進行精確搜索。
幸虧多數人都不直接使用 span 查詢,因為 match_phrase 已經足夠好了,但是對於某些特殊字段,如專利搜索,會使用低層次查詢來處理需要仔細構建位置的細致搜索。
混合(Mixing It Up)
要求短語的准確匹配可能約束過於嚴格,我們能希望使用“quick fox”仍然能搜索出包含“quick brown fox”的文檔,盡管它們的位置並不嚴格相等。
我們可以引入一個參數 slop 到短語匹配來表示這種自由度(degree of flexibility):
GET /my_index/my_type/_search
{
"query": {
"match_phrase": {
"title": {
"query": "quick fox",
"slop": 1
}
}
}
}
slop 參數告訴 match_phrase 查詢在術語相距多遠時,仍然會被認為是一個匹配的文檔。這里的 相距多遠 指的是使文檔匹配所需將術語移動的次數。
用一個簡單的例子,為了使查詢 quick fox 能與包含 quick brown fox 的文檔匹配,我們需要的 slop 為1:
Pos 1 Pos 2 Pos 3
-----------------------------------------------
Doc: quick brown fox
-----------------------------------------------
Query: quick fox
Slop 1: quick ↳ fox
盡管所有詞都需要出現在短語匹配(phrase matching)中,在使用 slop 時,詞的順序不必完全一致。當 slop 的值足夠高時,詞可以處於任何位置。
如果要使 fox quick 能與我們的文檔匹配,我們需要的 slop 值為 3:
Pos 1 Pos 2 Pos 3
-----------------------------------------------
Doc: quick brown fox
-----------------------------------------------
Query: fox quick
Slop 1: fox|quick ↵ #1
Slop 2: quick ↳ fox
Slop 3: quick ↳ fox
- #1 注意 這一步fox 和 quick 處於同一位置,因此,將詞語的順序從 fox quick 變化成 quick fox 還需要2步。
多值字段(Multivalue Fields)
如果將短語匹配使用到多值字段上會十分有趣,假如我們有下面這個文檔:
PUT /my_index/groups/1
{
"names": [ "John Abraham", "Lincoln Smith"]
}
執行下面短語查詢 Abraham Lincoln:
GET /my_index/groups/_search
{
"query": {
"match_phrase": {
"names": "Abraham Lincoln"
}
}
}
令人驚訝的是,盡管 Abraham 和 Lincoln 屬於兩個不同的人名,這個文檔仍然可以被匹配到,這樣的結果與數組在ElasticSearch內的索引方式相關。
當分析 John Abraham 的時候,生成下面信息:
- Position 1: john
- Position 2: abraham
當分析 Lincoln Smith 的時候,生成下面信息:
- Position 3: lincoln
- Position 4: smith
換句話說,ElasticSearch 為數組生成的token列表與“John Abraham Lincoln Smith”這樣單個字符串生成的token列表一樣。在例子中,當我們要查詢“abraham lincoln”的時候,這兩個詞正好存在,而且他們是相鄰的,這樣就能匹配到文檔。
幸運的是我們對這種情況有種變通的解決辦法,叫做 position_offset_gap,我們需要將其配置到字段映射中:
DELETE /my_index/groups/ #1
PUT /my_index/_mapping/groups #2
{
"properties": {
"names": {
"type": "string",
"position_offset_gap": 100
}
}
}
- #1 首先刪除groups的映射以及所有這種類型下的文檔
- #2 創建正確的groups
position_offset_gap 值告訴ElasticSearch它需要為在當前每個新的數組元素位置上增加 position_offset_gap 所給出的值,現在我們得到的名字數組對應的術語位置如下:
- Position 1: john
- Position 2: abraham
- Position 103: lincoln
- Position 104: smith
這樣,我們的短語查詢“abraham lincoln”與文檔不再匹配,因為他們之間相距100個位置,如果現在要想匹配到這個文檔,我們需要為其指定 slop 值100。
越近越好(Closer Is Better)
與短語查詢簡單的將不包含准確短語的文檔排除在外不同,近似查詢(proximity query) ——一種 slop 值大於0的短語查詢 —— 將查詢術語的相似度融合到最終相關度分數 _score 中。為 slop 設置 50 或 100 這樣很高的值,可以幫助我們排除掉詞語之間相距十分遠的那些文檔,同時也能給那些詞語間相距非常近的文檔以高分。
下面相似度查詢 “quick dog” 與兩個包含 quick 和 dog 的文檔都匹配,但是詞語相距近的文檔的分數更高:
POST /my_index/my_type/_search
{
"query": {
"match_phrase": {
"title": {
"query": "quick dog",
"slop": 50 #1
}
}
}
}
-
#1 注意這個 slop 值很高
{
"hits": [
{
"_id": "3",
"_score": 0.75, #1
"_source": {
"title": "The quick brown fox jumps over the quick dog"
}
},
{
"_id": "2",
"_score": 0.28347334, #2
"_source": {
"title": "The quick brown fox jumps over the lazy dog"
}
}
]
} -
#1 quick 和 dog 更近,因此分數更高。
-
#2 quick 和 dog 較遠,因此分數較低。
相關的近似度(Proximity for Relevance)
盡管近似查詢很有用,要求所有術語都必須存在這點使之過於嚴苛,這與我們在全文搜索的控制精度(Controlling Precision)里談到的問題一樣:如果7個術語里面匹配6個,這個文檔很有可能有足夠的相關度需要展示給用戶,但是 match_phrase 查詢會將其排除在外。
與其將近似匹配作為一種絕對的要求,不如將其作為一種信號(signal)來使用——即作為潛在的多查詢,每個查詢都會對最終分數有貢獻。
事實上,當我們想把多個查詢的結果加在一起的時候,往往就預示着我們可以用 bool 查詢將它們組合起來。
我們可以把一個簡單的 match 查詢作為 must 語句,這個查詢可以決定哪個文檔會被包括在結果集中,我們也可以使用 minimum_should_match 參數來剪掉長尾,然后我們可以加入其他更具體的查詢,比如 should 語句。每個匹配到的詞都會增加匹配文檔的相關度。
GET /my_index/my_type/_search
{
"query": {
"bool": {
"must": {
"match": { #1
"title": {
"query": "quick brown fox",
"minimum_should_match": "30%"
}
}
},
"should": {
"match_phrase": { #2
"title": {
"query": "quick brown fox",
"slop": 50
}
}
}
}
}
}
- #1 must 語句包括或排除結果集中的文檔。
- #2 should 語句增加匹配文檔的相關度。
我們當然也可以將其他查詢加入到 should 語句中,每個查詢都對應這某個方面的相關度。
性能提升(Improving Performance)
短語和近似查詢比簡單的 match 要昂貴許多,因為一個 match 查詢只需要在反向索引中對術語進行查找,而一個 match_phrase 查詢需要計算和比較多個(可能重復的)術語的位置。
Lucene的性能測評 一個簡單的術語查詢比一個短語查詢快10倍,比一個近似查詢(帶有 slop 的短語查詢)要快20倍,當然,這些代價來自於搜索時而非索引時。
通常情況下,短語查詢的額外消耗並不像上面說的這些數字這樣嚇人,這些區別只說明一個簡單的術語查詢是相當快的,短語查詢在典型的全文搜索下通常消耗的時間在毫秒級,無論在實際中,還是在一個繁忙的集群下都十分有用。
在某些變態的場景下,短語查詢非常消耗資源,但這種情況並不常見。一個比較變態的例子是DNA測序,有許多許多完全相同的術語反復出現在不同位置。使用更高的 slop 值會大大增加位置的計算量。
所以我們可以通過何種方式來限制短語查詢和近似查詢對系統性能的消耗呢?一個有用的方法就是減少短語查詢需要檢查的文檔總數。
重算分數(Rescoring Result)
在之前部分,我們討論了使用近似查詢來滿足相關度的需求,而不是用它來包含或排除結果集中的文檔。一個查詢可能有百萬個結果,但是我們的用戶通常只對最前面的幾頁內容感興趣。
簡單的match查詢以及將包含所有搜索術語的文檔排在了結果的頂部,我們需要做的只是將排序好的結果與短語查詢的匹配結果進行額外的相關度重排。
search API 用 rescoring 來支持這種功能。重算分數的過程使我們可以為每個shard里首 K 個值采用代價更高的計分算法——如短語查詢,然后將這些結果根據他們的新分數進行重新排序。
請求如下:
GET /my_index/my_type/_search
{
"query": {
"match": { #1
"title": {
"query": "quick brown fox",
"minimum_should_match": "30%"
}
}
},
"rescore": {
"window_size": 50, #2
"query": { #3
"rescore_query": {
"match_phrase": {
"title": {
"query": "quick brown fox",
"slop": 50
}
}
}
}
}
}
- #1 match 查詢決定最終結果集中的數據以及對結果進行 TF/IDF 排名。
- #2 window_size 是每個 shard 里參與重算分數的結果數。
- #3 目前重算分的算法需要在另一個查詢中進行,不過未來有計划增加更多的算法。
相關詞查找(Finding Associated Words)
盡管短語查詢和近似查詢非常有用,它們也有不足的一面,它們過於嚴格:所有術語都必須以短語查詢的方式去匹配,即使使用 slop 也不例外。
從 slop 那里獲得的詞序的靈活性是需要付出代價的,因為這樣會丟失詞語之間的關聯。我們可以找到 sue、alligator 和 ate 相近出現的文檔,但是我們無法區分到底是 sue ate 還是 alligator ate。
當多個詞語一同出現的時候,它們所表達的內容要比單個詞獨立出現時更有意義。有這么兩句話,I'm not happy I'm working 與 I’m happy I’m not working,它們包括相同的詞,從詞語相似度上說是非常接近的,但是其所表達的意思卻大相徑庭。
如果我們對詞組索引,而不是每個單詞獨立索引,這樣我們就能夠保留更多詞語使用時的語境。
句子 Sue ate the alligator ,我們不僅會索引單個詞(unigram)作為術語
["sue", "ate", "the", "alligator"]
而且會將與之鄰近的詞組成單個術語:
["sue ate", "ate the", "the alligator"]
這樣的詞對(或 bigrams)被稱作 瓦片詞(shingles)
瓦片詞(Shingles) 不一定要是成對出現的,它也可以是個三元組(trigrams),
["sue ate the", "ate the alligator"]
三元組(Trigram)為我們帶來了更高的准確度,但是也大大增加了索引的數量。Bigram 在多數情況下就夠用了。
當然 shingles 只在用戶輸入的詞序與文檔內容中的詞序一致時有用;一個 sue alligator 查詢會與單個詞匹配,但無法與 shingles 里的術語匹配。
幸運的是,用戶傾向使用與數據結果中結構相似的詞語來表達他們想要搜索的內容。但是有點非常重要,即:僅僅索引 bigram 是不夠的,我們仍然需要對 unigram 進行索引,而 bigram 可以作為信號詞來使用以提高相關度分數。
生成Shingles
Shingles 可以作為分析過程的一部分在索引時創建,我們可以將 unigram 和 bigram 索引到同一字段中,但是將他們分開索引會更加清楚,查詢也可以獨立開來。
首先,我們需要創建一個使用 shingle token的過濾器的分析器:
DELETE /my_index
PUT /my_index
{
"settings": {
"number_of_shards": 1, #1
"analysis": {
"filter": {
"my_shingle_filter": {
"type": "shingle",
"min_shingle_size": 2, #2
"max_shingle_size": 2, #3
"output_unigrams": false #4
}
},
"analyzer": {
"my_shingle_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"my_shingle_filter" #5
]
}
}
}
}
}
- #1 參照被破壞的相關度(Relevance Is Broken)
- #2 #3 shingle 的大小默認為 2,我們不需要顯式設置。
- #4 shingle的token過濾器默認輸出 unigram,但是我們希望將 unigram 與 bigram 分開。
- #5 my_shingle_analyzer 使用我們自定義的 my_shingles_filter 作為 token 過濾器。
首先我們使用 analyze API來測試,確保我們的分析器如我們期望一樣工作:
GET /my_index/_analyze?analyzer=my_shingle_analyzer
Sue ate the alligator
返回結果為三個術語:
- sue ate
- ate the
- the alligator
現在我們可以使用新的分析器設置一個字段。
多字段(Multifields)
我們之前提到過,將 unigram 和 bigram 分開索引會更清楚,所以我們為 title 字段作為多字段:
PUT /my_index/_mapping/my_type
{
"my_type": {
"properties": {
"title": {
"type": "string",
"fields": {
"shingles": {
"type": "string",
"analyzer": "my_shingle_analyzer"
}
}
}
}
}
}
有了這個映射,title里的JSON文檔會同時以 unigram 的形式在title字段索引,還會以 bigrams 的形式在 title.shingles 字段索引,這樣我們就能分別查詢這兩個字段。
最后,我們對例子中的文檔進行索引:
POST /my_index/my_type/_bulk
{ "index": { "_id": 1 }}
{ "title": "Sue ate the alligator" }
{ "index": { "_id": 2 }}
{ "title": "The alligator ate Sue" }
{ "index": { "_id": 3 }}
{ "title": "Sue never goes anywhere without her alligator skin purse" }
Shingles查詢(Searching for Shingles)
為了理解 shingles 帶來的好處,我們先看看一個簡單 match 查詢“The hungry alligator ate Sue”的結果:
GET /my_index/my_type/_search
{
"query": {
"match": {
"title": "the hungry alligator ate sue"
}
}
}
這個查詢返回3個文檔,注意到文檔1和2有着相同的相關度分數,因為他們包含一樣的詞:
{
"hits": [
{
"_id": "1",
"_score": 0.44273707, #1
"_source": {
"title": "Sue ate the alligator"
}
},
{
"_id": "2",
"_score": 0.44273707, #2
"_source": {
"title": "The alligator ate Sue"
}
},
{
"_id": "3", #3
"_score": 0.046571054,
"_source": {
"title": "Sue never goes anywhere without her alligator skin purse"
}
}
]
}
- #1 #2 兩個文檔都包含單詞Sue、the、alligator和ate,所以他們分數相同。
- 我們可以通過設置 minimum_should_match 參數來排除文檔3。參照 控制精度(Controlling Precision)
現在我們將 shingles 字段加入到查詢中,記住,我們這里的查詢是將 shingles 字段作為信號來提升相關度分數的,所以我們仍然需要將 title 字段包含其中:
GET /my_index/my_type/_search
{
"query": {
"bool": {
"must": {
"match": {
"title": "the hungry alligator ate sue"
}
},
"should": {
"match": {
"title.shingles": "the hungry alligator ate sue"
}
}
}
}
}
這里我們仍然匹配到3個文檔,但是文檔2被排在了第一位,因為它與 shingles里的術語 ate sue 相匹配。
{
"hits": [
{
"_id": "2",
"_score": 0.4883322,
"_source": {
"title": "The alligator ate Sue"
}
},
{
"_id": "1",
"_score": 0.13422975,
"_source": {
"title": "Sue ate the alligator"
}
},
{
"_id": "3",
"_score": 0.014119488,
"_source": {
"title": "Sue never goes anywhere without her alligator skin purse"
}
}
]
}
盡管如此,我們查詢中包含詞語 hungry,它沒有出現在任何文檔中,我們仍然可以通過詞的相似度來找到最相關的文檔。
性能(Performance)
shingles比短語查詢更靈活,它的性能也非常好。與短語查詢每次搜索都需要付出代價不同,使用shingles可以讓我們的查詢如簡單 match 查詢一樣高效。使用shingles需要在索引時付出一點代價,因為需要索引更多術語,這也意味着需要更多的磁盤空間用來存儲shingles。但是,多數應用只需要寫入一次讀取多次,這樣也就優化了查詢。
這個話題會在ElasticSearch中經常碰到:不需要顯式的設置,就能讓我們在搜索時提升速度。當我們對需求更明確時,我們就能通過在對索引階段數據的模型進行設計,從而得到更好的結果以及達到更優的性能。