Elasticsearch Suggester詳解(自動補全)

現代的搜索引擎,一般會具備"Suggest As You Type"功能,即在用戶輸入搜索的過程中,進行自動補全或者糾錯。 通過協助用戶輸入更精準的關鍵詞,提高後續全文搜索階段文檔匹配的程度。例如在Google上輸入部分關鍵詞,甚至輸入拼寫錯誤的關鍵詞時,它依然能夠提示出用戶想要輸入的內容:
 

google_completion_suggester.png


 

google_terms_suggester.png




如果自己親手去試一下,可以看到Google在用戶剛開始輸入的時候是自動補全的,而當輸入到一定長度,如果因爲單詞拼寫錯誤無法補全,就開始嘗試提示相似的詞。

那麼類似的功能在Elasticsearch裏如何實現呢? 答案就在Suggesters API。 Suggesters基本的運作原理是將輸入的文本分解爲token,然後在索引的字典裏查找相似的term並返回。 根據使用場景的不同,Elasticsearch裏設計了4種類別的Suggester,分別是:

  • Term Suggester
  • Phrase Suggester
  • Completion Suggester
  • Context Suggester


在官方的參考文檔裏,對這4種Suggester API都有比較詳細的介紹,但苦於只有英文版,部分國內開發者看完文檔後仍然難以理解其運作機制。 本文將在Elasticsearch 5.x上通過示例講解Suggester的基礎用法,希望能幫助部分國內開發者快速用於實際項目開發。限於篇幅,更爲高級的Context Suggester會被略過。


首先來看一個Term Suggester的示例:
準備一個叫做blogs的索引,配置一個text字段。


PUT /blogs/
{
  "mappings": {
    "tech": {
      "properties": {
        "body": {
          "type": "text"
        }
      }
    }
  }
}


通過bulk api寫入幾條文檔


POST _bulk/?refresh=true
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{ "body": "Lucene is cool"}
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{ "body": "Elasticsearch builds on top of lucene"}
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{ "body": "Elasticsearch rocks"}
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{ "body": "Elastic is the company behind ELK stack"}
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{ "body": "elk rocks"}
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{  "body": "elasticsearch is rock solid"}



此時blogs索引裏已經有一些文檔了,可以進行下一步的探索。爲幫助理解,我們先看看哪些term會存在於詞典裏。
將輸入的文本分析一下:


POST _analyze
{
  "text": [
    "Lucene is cool",
    "Elasticsearch builds on top of lucene",
    "Elasticsearch rocks",
    "Elastic is the company behind ELK stack",
    "elk rocks",
    "elasticsearch is rock solid"
  ]
}



(由於結果太長,此處略去)

這些分出來的token都會成爲詞典裏一個term,注意有些token會出現多次,因此在倒排索引裏記錄的詞頻會比較高,同時記錄的還有這些token在原文檔裏的偏移量和相對位置信息。
執行一次suggester搜索看看效果:


POST /blogs/_search

  "suggest": {
    "my-suggestion": {
      "text": "lucne rock",
      "term": {
        "suggest_mode": "missing",
        "field": "body"
      }
    }
  }
}



suggest就是一種特殊類型的搜索,DSL內部的"text"指的是api調用方提供的文本,也就是通常用戶界面上用戶輸入的內容。這裏的lucne是錯誤的拼寫,模擬用戶輸入錯誤。 "term"表示這是一個term suggester。 "field"指定suggester針對的字段,另外有一個可選的"suggest_mode"。 範例裏的"missing"實際上就是缺省值,它是什麼意思?有點撓頭... 還是先看看返回結果吧:


{
  "took": 1,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "failed": 0
  },
  "hits": {
    "total": 0,
    "max_score": 0,
    "hits":
  },
  "suggest": {
    "my-suggestion": [
      {
        "text": "lucne",
        "offset": 0,
        "length": 5,
        "options": [
          {
            "text": "lucene",
            "score": 0.8,
            "freq": 2
          }
        ]
      },
      {
        "text": "rock",
        "offset": 6,
        "length": 4,
        "options":
      }
    ]
  }
}



在返回結果裏"suggest" -> "my-suggestion"部分包含了一個數組,每個數組項對應從輸入文本分解出來的token(存放在"text"這個key裏)以及爲該token提供的建議詞項(存放在options數組裏)。  示例裏返回了"lucne","rock"這2個詞的建議項(options),其中"rock"的options是空的,表示沒有可以建議的選項,爲什麼? 上面提到了,我們爲查詢提供的suggest mode是"missing",由於"rock"在索引的詞典裏已經存在了,夠精準,就不建議啦。 只有詞典裏找不到詞,纔會爲其提供相似的選項。

如果將"suggest_mode"換成"popular"會是什麼效果?
嘗試一下,重新執行查詢,返回結果裏"rock"這個詞的option不再是空的,而是建議爲rocks。


 "suggest": {
    "my-suggestion": [
      {
        "text": "lucne",
        "offset": 0,
        "length": 5,
        "options": [
          {
            "text": "lucene",
            "score": 0.8,
            "freq": 2
          }
        ]
      },
      {
        "text": "rock",
        "offset": 6,
        "length": 4,
        "options": [
          {
            "text": "rocks",
            "score": 0.75,
            "freq": 2
          }
        ]
      }
    ]
  }



回想一下,rock和rocks在索引詞典裏都是有的。 不難看出即使用戶輸入的token在索引的詞典裏已經有了,但是因爲存在一個詞頻更高的相似項,這個相似項可能是更合適的,就被挑選到options裏了。 最後還有一個"always" mode,其含義是不管token是否存在於索引詞典裏都要給出相似項。

有人可能會問,兩個term的相似性是如何判斷的? ES使用了一種叫做Levenstein edit distance的算法,其核心思想就是一個詞改動多少個字符就可以和另外一個詞一致。 Term suggester還有其他很多可選參數來控制這個相似性的模糊程度,這裏就不一一贅述了。

Term suggester正如其名,只基於analyze過的單個term去提供建議,並不會考慮多個term之間的關係。API調用方只需爲每個token挑選options裏的詞,組合在一起返回給用戶前端即可。 那麼有無更直接辦法,API直接給出和用戶輸入文本相似的內容? 答案是有,這就要求助Phrase Suggester了。

Phrase suggester在Term suggester的基礎上,會考量多個term之間的關係,比如是否同時出現在索引的原文裏,相鄰程度,以及詞頻等等。看個範例就比較容易明白了:


POST /blogs/_search
{
  "suggest": {
    "my-suggestion": {
      "text": "lucne and elasticsear rock",
      "phrase": {
        "field": "body",
        "highlight": {
          "pre_tag": "<em>",
          "post_tag": "</em>"
        }
      }
    }
  }
}



返回結果:


"suggest": {
    "my-suggestion": [
      {
        "text": "lucne and elasticsear rock",
        "offset": 0,
        "length": 26,
        "options": [
          {
            "text": "lucene and elasticsearch rock",
            "highlighted": "<em>lucene</em> and <em>elasticsearch</em> rock",
            "score": 0.004993905
          },
          {
            "text": "lucne and elasticsearch rock",
            "highlighted": "lucne and <em>elasticsearch</em> rock",
            "score": 0.0033391973
          },
          {
            "text": "lucene and elasticsear rock",
            "highlighted": "<em>lucene</em> and elasticsear rock",
            "score": 0.0029183894
          }
        ]
      }
    ]
  }



options直接返回一個phrase列表,由於加了highlight選項,被替換的term會被高亮。因爲lucene和elasticsearch曾經在同一條原文裏出現過,同時替換2個term的可信度更高,所以打分較高,排在第一位返回。Phrase suggester有相當多的參數用於控制匹配的模糊程度,需要根據實際應用情況去挑選和調試。


最後來談一下Completion Suggester,它主要針對的應用場景就是"Auto Completion"。 此場景下用戶每輸入一個字符的時候,就需要即時發送一次查詢請求到後端查找匹配項,在用戶輸入速度較高的情況下對後端響應速度要求比較苛刻。因此實現上它和前面兩個Suggester採用了不同的數據結構,索引並非通過倒排來完成,而是將analyze過的數據編碼成FST和索引一起存放。對於一個open狀態的索引,FST會被ES整個裝載到內存裏的,進行前綴查找速度極快。但是FST只能用於前綴查找,這也是Completion Suggester的侷限所在。

爲了使用Completion Suggester,字段的類型需要專門定義如下:


PUT /blogs_completion/
{
  "mappings": {
    "tech": {
      "properties": {
        "body": {
          "type": "completion"
        }
      }
    }
  }
}



用bulk API索引點數據:


POST _bulk/?refresh=true
{ "index" : { "_index" : "blogs_completion", "_type" : "tech" } }
{ "body": "Lucene is cool"}
{ "index" : { "_index" : "blogs_completion", "_type" : "tech" } }
{ "body": "Elasticsearch builds on top of lucene"}
{ "index" : { "_index" : "blogs_completion", "_type" : "tech" } }
{ "body": "Elasticsearch rocks"}
{ "index" : { "_index" : "blogs_completion", "_type" : "tech" } }
{ "body": "Elastic is the company behind ELK stack"}
{ "index" : { "_index" : "blogs_completion", "_type" : "tech" } }
{ "body": "the elk stack rocks"}
{ "index" : { "_index" : "blogs_completion", "_type" : "tech" } }
{ "body": "elasticsearch is rock solid"}




查找:


POST blogs_completion/_search?pretty
{ "size": 0,
  "suggest": {
    "blog-suggest": {
      "prefix": "elastic i",
      "completion": {
        "field": "body"
      }
    }
  }
}



結果:


"suggest": {
    "blog-suggest": [
      {
        "text": "elastic i",
        "offset": 0,
        "length": 9,
        "options": [
          {
            "text": "Elastic is the company behind ELK stack",
            "_index": "blogs_completion",
            "_type": "tech",
            "_id": "AVrXFyn-cpYmMpGqDdcd",
            "_score": 1,
            "_source": {
              "body": "Elastic is the company behind ELK stack"
            }
          }
        ]
      }
    ]
  }



值得注意的一點是Completion Suggester在索引原始數據的時候也要經過analyze階段,取決於選用的analyzer不同,某些詞可能會被轉換,某些詞可能被去除,這些會影響FST編碼結果,也會影響查找匹配的效果。

比如我們刪除上面的索引,重新設置索引的mapping,將analyzer更改爲"english":


PUT /blogs_completion/
{
  "mappings": {
    "tech": {
      "properties": {
        "body": {
          "type": "completion",
          "analyzer": "english"
        }
      }
    }
  }
}



bulk api索引同樣的數據後,執行下面的查詢:


POST blogs_completion/_search?pretty
{ "size": 0,
  "suggest": {
    "blog-suggest": {
      "prefix": "elastic i",
      "completion": {
        "field": "body"
      }
    }
  }
}



居然沒有匹配結果了,多麼費解!  原來我們用的english analyzer會剝離掉stop word,而is就是其中一個,被剝離掉了!
用analyze api測試一下:


POST _analyze?analyzer=english
{
  "text": "elasticsearch is rock solid"
}

會發現只有3個token:
{
  "tokens": [
    {
      "token": "elasticsearch",
      "start_offset": 0,
      "end_offset": 13,
      "type": "<ALPHANUM>",
      "position": 0
    },
    {
      "token": "rock",
      "start_offset": 17,
      "end_offset": 21,
      "type": "<ALPHANUM>",
      "position": 2
    },
    {
      "token": "solid",
      "start_offset": 22,
      "end_offset": 27,
      "type": "<ALPHANUM>",
      "position": 3
    }
  ]
}



FST只編碼了這3個token,並且默認的還會記錄他們在文檔中的位置和分隔符。 用戶輸入"elastic i"進行查找的時候,輸入被分解成"elastic"和"i",FST沒有編碼這個“i” , 匹配失敗。

好吧,如果你現在還足夠清醒的話,試一下搜索"elastic is",會發現又有結果,why?  因爲這次輸入的text經過english analyzer的時候is也被剝離了,只需在FST裏查詢"elastic"這個前綴,自然就可以匹配到了。

其他能影響completion suggester結果的,還有諸如"preserve_separators","preserve_position_increments"等等mapping參數來控制匹配的模糊程度。以及搜索時可以選用Fuzzy Queries,使得上面例子裏的"elastic i"在使用english analyzer的情況下依然可以匹配到結果。

因此用好Completion Sugester並不是一件容易的事,實際應用開發過程中,需要根據數據特性和業務需要,靈活搭配analyzer和mapping參數,反覆調試纔可能獲得理想的補全效果。

回到篇首Google搜索框的補全/糾錯功能,如果用ES怎麼實現呢?我能想到的一個的實現方式:

  1. 在用戶剛開始輸入的過程中,使用Completion Suggester進行關鍵詞前綴匹配,剛開始匹配項會比較多,隨着用戶輸入字符增多,匹配項越來越少。如果用戶輸入比較精準,可能Completion Suggester的結果已經夠好,用戶已經可以看到理想的備選項了。 
  2. 如果Completion Suggester已經到了零匹配,那麼可以猜測是否用戶有輸入錯誤,這時候可以嘗試一下Phrase Suggester。
  3. 如果Phrase Suggester沒有找到任何option,開始嘗試term Suggester。

 

精準程度上(Precision)看: Completion >  Phrase > term, 而召回率上(Recall)則反之。從性能上看,Completion Suggester是最快的,如果能滿足業務需求,只用Completion Suggester做前綴匹配是最理想的。 Phrase和Term由於是做倒排索引的搜索,相比較而言性能應該要低不少,應儘量控制suggester用到的索引的數據量,最理想的狀況是經過一定時間預熱後,索引可以全量map到內存。

文章來源:https://elasticsearch.cn/article/142

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章