天天看點

[Elasticsearch] 部分比對 (四) - 索引期間優化ngrams及索引期間的即時搜尋

本章翻譯自Elasticsearch官方指南的 Partial Matching 一章。

索引期間的優化(Index-time Optimizations)

目前我們讨論的所有方案都是在查詢期間的。它們不需要任何特殊的映射或者索引模式(Indexing Patterns);它們隻是簡單地工作在已經存在于索引中的資料之上。

查詢期間的靈活性是有代價的:搜尋性能。有時,将這些代價放到查詢之外的地方是有價值的。在一個實時的Web應用中,一個額外的100毫秒的延遲會難以承受。

通過在索引期間準備你的資料,可以讓你的搜尋更加靈活并更具效率。你仍然付出了代價:增加了的索引大小和稍微低一些的索引吞吐量,但是這個代價是在索引期間付出的,而不是在每個查詢的執行期間。

你的使用者會感激你的。

部分比對(Partial Matching)的ngrams

我們說過:"你隻能找到存在于反向索引中的詞條"。盡管prefix,wildcard以及regexp查詢證明了上面的說法并不是一定正确,但是執行一個基于單個詞條的查詢會比周遊詞條清單來得到比對的詞條要更快是毫無疑問的。為了部分比對而提前準備你的資料能夠增加搜尋性能。

在索引期間準别資料意味着選擇正确的分析鍊(Analysis Chain),為了部分比對我們選擇的工具叫做n-gram。一個n-gram可以被想象成一個單詞上的滑動視窗(Moving Window)。n表示的是長度。如果我們對單詞quick得到n-gram,結果取決于選擇的長度:

  • 長度1(unigram): [ q, u, i, c, k ]
  • 長度2(bigram): [ qu, ui, ic, ck ]
  • 長度3(trigram): [ qui, uic, ick ]
  • 長度4(four-gram):[ quic, uick ]
  • 長度5(five-gram):[ quick ]

單純的n-grams對于比對單詞中的某一部分是有用的,在複合單詞的ngrams中我們會用到它。然而,對于即時搜尋,我們使用了一種特殊的n-grams,被稱為邊緣n-grams(Edge n-grams)。邊緣n-grams會将起始點放在單詞的開頭處。單詞quick的邊緣n-gram如下所示:

  • q
  • qu
  • qui
  • quic
  • quick

你也許注意到它遵循了使用者在搜尋"quick"時的輸入形式。換言之,對于即時搜尋而言它們是非常完美的詞條。

索引期間的即時搜尋(Index-time Search-as-you-type)

建立索引期間即時搜尋的第一步就是定義你的分析鍊(Analysis Chain)(在配置解析器中讨論過),在這裡我們會詳細闡述這些步驟:

準備索引

第一步是配置一個自定義的edge_ngram詞條過濾器,我們将它稱為autocomplete_filter:

{
    "filter": {
        "autocomplete_filter": {
            "type":     "edge_ngram",
            "min_gram": 1,
            "max_gram": 20
        }
    }
}      

以上配置的作用是,對于此詞條過濾器接受的任何詞條,它都會産生一個最小長度為1,最大長度為20的邊緣ngram(Edge ngram)。

然後我們将該詞條過濾器配置在自定義的解析器中,該解析器名為autocomplete。

{
    "analyzer": {
        "autocomplete": {
            "type":      "custom",
            "tokenizer": "standard",
            "filter": [
                "lowercase",
                "autocomplete_filter" 
            ]
        }
    }
}      

以上的解析器會使用standard分詞器将字元串劃分為獨立的詞條,将它們變成小寫形式,然後為它們生成邊緣ngrams,這要感謝autocomplete_filter。

建立索引,詞條過濾器和解析器的完整請求如下所示:

PUT /my_index
{
    "settings": {
        "number_of_shards": 1, 
        "analysis": {
            "filter": {
                "autocomplete_filter": { 
                    "type":     "edge_ngram",
                    "min_gram": 1,
                    "max_gram": 20
                }
            },
            "analyzer": {
                "autocomplete": {
                    "type":      "custom",
                    "tokenizer": "standard",
                    "filter": [
                        "lowercase",
                        "autocomplete_filter" 
                    ]
                }
            }
        }
    }
}      

你可以通過下面的analyze API來確定行為是正确的:

GET /my_index/_analyze?analyzer=autocomplete
quick brown      

傳回的詞條說明解析器工作正常:

  • q
  • qu
  • qui
  • quic
  • quick
  • b
  • br
  • bro
  • brow
  • brown

為了使用它,我們需要将它适用到字段中,通過update-mapping API:

PUT /my_index/_mapping/my_type
{
    "my_type": {
        "properties": {
            "name": {
                "type":     "string",
                "analyzer": "autocomplete"
            }
        }
    }
}      

現在,讓我們索引一些測試文檔:

POST /my_index/my_type/_bulk
{ "index": { "_id": 1            }}
{ "name": "Brown foxes"    }
{ "index": { "_id": 2            }}
{ "name": "Yellow furballs" }      

查詢該字段

如果你使用一個針對"brown fo"的簡單match查詢:

GET /my_index/my_type/_search
{
    "query": {
        "match": {
            "name": "brown fo"
        }
    }
}      

你會發現兩份文檔都比對了,即使Yellow furballs既不包含brown,也不包含fo:

{

  "hits": [
     {
        "_id": "1",
        "_score": 1.5753809,
        "_source": {
           "name": "Brown foxes"
        }
     },
     {
        "_id": "2",
        "_score": 0.012520773,
        "_source": {
           "name": "Yellow furballs"
        }
     }
  ]
}      

通過validate-query API來發現問題:

GET /my_index/my_type/_validate/query?explain
{
    "query": {
        "match": {
            "name": "brown fo"
        }
    }
}      

得到的解釋說明了查詢會尋找查詢字元串中每個單詞的邊緣ngrams:

name:b name:br name:bro name:brow name:brown name:f name:fo

name:f這一條件滿足了第二份文檔,因為furballs被索引為f,fu,fur等。是以,得到以上的結果也沒什麼奇怪的。autocomplete解析器被同時适用在了索引期間和搜尋期間,通常而言這都是正确的行為。但是目前的場景是為數不多的不應該使用該規則的場景之一。

我們需要確定在反向索引中含有每個單詞的邊緣ngrams,但是僅僅比對使用者輸入的完整單詞(brown和fo)。我們可以通過在索引期間使用autocomplete解析器,而在搜尋期間使用standard解析器來達到這個目的。直接在查詢中指定解析器就是一種改變搜尋期間分析器的方法:

GET /my_index/my_type/_search
{
    "query": {
        "match": {
            "name": {
                "query":    "brown fo",
                "analyzer": "standard" 
            }
        }
    }
}      

另外,還可以在name字段的映射中分别指定index_analyzer和search_analyzer。因為我們隻是想修改search_analyzer,是以可以在不對資料重索引的前提下對映射進行修改:

PUT /my_index/my_type/_mapping
{
    "my_type": {
        "properties": {
            "name": {
                "type":            "string",
                "index_analyzer":  "autocomplete", 
                "search_analyzer": "standard" 
            }
        }
    }
}      

此時再通過validate-query API得到的解釋如下:

name:brown name:fo

重複執行查詢後,也僅僅會得到Brown foxes這份文檔。

因為大部分的工作都在索引期間完成了,查詢需要做的隻是查找兩個詞條:brown和fo,這比使用match_phrase_prefix來尋找所有以fo開頭的詞條更加高效。

完成建議(Completion Suggester)

使用邊緣ngrams建立的即時搜尋是簡單,靈活和迅速的。然而,有些時候它還是不夠快。延遲的影響不容忽略,特别當你需要提供實時回報時。有時最快的搜尋方式就是沒有搜尋。

ES中的完成建議采用了一種截然不同的解決方案。通過給它提供一個完整的可能完成清單(Possible Completions)來建立一個有限狀态轉換器(Finite State Transducer),該轉換器是一個用來描述圖(Graph)的優化資料結構。為了搜尋建議,ES會從圖的起始處開始,對使用者輸入逐個字元地沿着比對路徑(Matching Path)移動。一旦使用者輸入被檢驗完畢,它就會根據目前的路徑産生所有可能的建議。

該資料結構存在于記憶體中,是以對字首查詢而言是非常迅速的,比任何基于詞條的查詢都要快。使用它來自動完成名字和品牌(Names and Brands)是一個很不錯的選擇,因為它們通常都以某個特定的順序進行組織,比如"Johnny Rotten"不會被寫成"Rotten Johnny"。

當單詞順序不那麼容易被預測時,邊緣ngrams就是相比完成建議更好的方案。

邊緣ngrams和郵政編碼

邊緣ngrams這一技術還可以被用在結構化資料上,比如本章前面提到過的郵政編碼。當然,postcode字段也許需要被設定為analyzed,而不是not_analyzed,但是你仍然可以通過為郵政編碼使用keyword分詞器來讓它們和not_analyzed字段一樣。

TIP

keyword分詞器是一個沒有任何行為(no-operation)的分詞器。它接受的任何字元串會被原樣輸出為一個詞條。是以對于一些通常被當做not_analyzed字段,然而需要某些處理(如轉換為小寫)的情況下,是有用處的。

這個例子使用keyword分詞器将郵政編碼字元串轉換為一個字元流,是以我們就能夠利用邊緣ngram詞條過濾器了:

{
    "analysis": {
        "filter": {
            "postcode_filter": {
                "type":     "edge_ngram",
                "min_gram": 1,
                "max_gram": 8
            }
        },
        "analyzer": {
            "postcode_index": { 
                "tokenizer": "keyword",
                "filter":    [ "postcode_filter" ]
            },
            "postcode_search": { 
                "tokenizer": "keyword"
            }
        }
    }
}      

繼續閱讀