使用 Elasticsearch 做一個好用的日語搜尋引擎及自動補全

Allo發表於2019-05-27

最近基於 Elastic Stack 搭建了一個日語搜尋服務,發現日文的搜尋相比英語和中文,有不少特殊之處,因此記錄下用 Elasticsearch 搭建日語搜尋引擎的一些要點。本文所有的示例,適用於 Elastic 6.X 及 7.X 版本。

日語搜尋的特殊性

以 Elastic 的介紹語「Elasticsearchは、予期した結果や、そうでないものも検索できるようにデータを集めて格納するElastic Stackのコア製品です」為例。作為搜尋引擎,當然希望使用者能通過句子中的所有主要關鍵詞,都能搜尋到這條結果。

和英文一樣,日語的動詞根據時態語境等,有多種變化。如例句中的「集めて」表示現在進行時,屬於動詞的連用形的て形,其終止形(可以理解為動詞的原型)是「集める」。一個日文動詞可以有 10 餘種活用變形。如果依賴單純的分詞,使用者在搜尋「集める」時將無法匹配到這個句子。

除動詞外,日語的形容詞也存在變形,如終止形「安い」可以有連用形「安く」、未然性「安かろ」、仮定形「安けれ」等多種變化。

和中文一樣,日文中存在多音詞,特別是人名、地名等,如「相楽」在做人名和地名時就有 Sagara、Soraku、Saganaka 等不同的發音。

同時日文中一個詞還存在不同的拼寫方式,如「空缶」 = 「空き缶」。

而作為搜尋引擎,輸入補全也是很重要的一個環節。從日語輸入法來看,使用者搜尋時輸入的形式也是多種多樣,存在以下的可能性:

  • 平假名, 如「検索 -> けんさく」
  • 片假名全形,如 「検索 -> ケンサク」
  • 片假名半形,如「検索 -> ケンサク」
  • 漢字,如 「検索」
  • 羅馬字全形,如「検索 -> kennsaku」
  • 羅馬字半形,如「検索 -> kennsaku」

等等。這和中文拼音有點類似,在使用者搜尋結果或者做輸入補全時,我們也希望能儘可能適應使用者的輸入習慣,提升使用者體驗。

Elasticsearch 文字索引的過程

Elasticsearch (下文簡稱 ES)作為一個比較成熟的搜尋引擎,對上述這些問題,都有了一些解決方法

先複習一下 ES 的文字在進行索引時將經歷哪些過程,將一個文字存入一個欄位 (Field) 時,可以指定唯一的分析器(Analyzer),Analyzer 的作用就是將源文字通過過濾、變形、分詞等方式,轉換為 ES 可以搜尋的詞元(Term),從而建立索引,即:

mermaid
graph LR
Text --> Analyzer Analyzer --> Term

一個 Analyzer 內部,又由 3 部分構成

enter image description here

  • 字元過濾器 (Character Filter): ,對文字進行字元過濾處理,如處理文字中的 html 標籤字元。一個 Analyzer 中可包含 0 個或多個字元過濾器,多個按配置順序依次進行處理。
  • 分詞器 (Tokenizer): 對文字進行分詞。一個 Analyzer 必需且只可包含一個 Tokenizer。
  • 詞元過濾器 (Token filter): 對 Tokenizer 分出的詞進行過濾處理。如轉小寫、停用詞處理、同義詞處理等。一個 Analyzer 可包含 0 個或多個詞項過濾器,多個按配置順序進行過濾。

引用一張圖說明應該更加形象

ES 已經內建了一些 Analyzers,但顯然對於日文搜尋這種較複雜的場景,一般需要根據需求建立自定義的 Analyzer。

另外 ES 還有歸一化處理器 (Normalizers)的概念,可以將其理解為一個可以複用的 Analyzers, 比如我們的資料都是來源於英文網頁,網頁中的 html 字元,特殊字元的替換等等處理都是基本相同的,為了避免將這些通用的處理在每個 Analyzer 中都定義一遍,可以將其單獨整理為一個 Normalizer。

快速測試 Analyzer

為了實現好的搜尋效果,無疑會通過多種方式調整 Analyzer 的配置,為了更有效率,應該優先掌握快速測試 Analyzer 的方法, 這部分內容詳見 如何快速測試 Elasticsearch 的 Analyzer, 此處不再贅述。

Elasticsearch 日語分詞器 (Tokenizer) 的比較與選擇

日語分詞是一個比較大的話題,因此單獨開了一篇文章介紹和比較主流的開源日語分詞專案。引用一下最終的結論

對於 Elasticsearch,如果是專案初期,由於缺少資料,對於搜尋結果優化還沒有明確的目標,建議直接使用 Kuromoji 或者 Sudachi,安裝方便,功能也比較齊全。專案中後期,考慮到分詞質量和效率的優化,可以更換為 MeCab 或 Juman++。 本文將以 Kuromoji 為例。

日語搜尋相關的 Token Filter

在 Tokenizer 已經確定的基礎上,日語搜尋其他的優化都依靠 Token filter 來完成,這其中包括 ES 內建的 Token filter 以及 Kuromoji 附帶的 Token filter,以下逐一介紹

Lowercase Token Filter (小寫過濾)

將英文轉為小寫, 幾乎任何大部分搜尋的通用設定

``` POST _analyze { "tokenizer": "kuromoji_tokenizer", "filter": ["lowercase"], "text": "Ironman" }

Response { "tokens": [ { "token": "ironman", "start_offset": 0, "end_offset": 7, "type": "word", "position": 0 } ] } ```

CJK Width Token Filter (CJK 寬度過濾)

將全形 ASCII 字元 轉換為半形 ASCII 字元

``` POST _analyze { "tokenizer": "kuromoji_tokenizer", "filter": ["cjk_width"], "text": "kennsaku" }

{ "tokens": [ { "token": "kennsaku", "start_offset": 0, "end_offset": 8, "type": "word", "position": 0 } ] } ```

以及將半形片假名轉換為全形

``` POST _analyze { "tokenizer": "kuromoji_tokenizer", "filter": ["cjk_width"], "text": "ケンサク" }

{ "tokens": [ { "token": "ケンサク", "start_offset": 0, "end_offset": 4, "type": "word", "position": 0 } ] } ```

ja_stop Token Filter (日語停止詞過濾)

一般來講,日語的停止詞主要包括部分助詞、助動詞、連線詞及標點符號等,Kuromoji 預設使用的停止詞參考lucene 日語停止詞原始碼。 在此基礎上也可以自己在配置中新增停止詞

``` POST _analyze { "tokenizer": "kuromoji_tokenizer", "filter": ["ja_stop"], "text": "Kuromojiのストップワード" }

{ "tokens": [ { "token": "Kuromoji", "start_offset": 0, "end_offset": 8, "type": "word", "position": 0 }, { "token": "ストップ", "start_offset": 9, "end_offset": 13, "type": "word", "position": 2 }, { "token": "ワード", "start_offset": 13, "end_offset": 16, "type": "word", "position": 3 } ] } ```

kuromoji_baseform Token Filter (日語詞根過濾)

將動詞、形容詞轉換為該詞的詞根

``` POST _analyze { "tokenizer": "kuromoji_tokenizer", "filter": ["kuromoji_baseform"], "text": "飲み" }

{ "tokens": [ { "token": "飲む", "start_offset": 0, "end_offset": 2, "type": "word", "position": 0 } ] } ```

kuromoji_readingform Token Filter (日語讀音過濾)

將單詞轉換為發音,發音可以是片假名或羅馬字 2 種形式

``` POST _analyze { "tokenizer": "kuromoji_tokenizer", "filter": ["kuromoji_readingform"], "text": "壽司" }

{ "tokens": [ { "token": "スシ", "start_offset": 0, "end_offset": 2, "type": "word", "position": 0 } ] } ```

``` POST _analyze { "tokenizer": "kuromoji_tokenizer", "filter": [{ "type": "kuromoji_readingform", "use_romaji": true }], "text": "壽司" }

{ "tokens": [ { "token": "sushi", "start_offset": 0, "end_offset": 2, "type": "word", "position": 0 } ] } ```

當遇到多音詞時,讀音過濾僅會給出一個讀音。

kuromoji_part_of_speech Token Filter (日語語氣詞過濾)

語氣詞過濾與停止詞過濾有一定重合之處,語氣詞過濾範圍更廣。停止詞過濾的物件是固定的詞語列表,停止詞過濾則是根據詞性過濾的,具體過濾的物件參考原始碼

``` POST _analyze { "tokenizer": "kuromoji_tokenizer", "filter": ["kuromoji_part_of_speech"], "text": "壽司がおいしいね" }

{ "tokens": [ { "token": "壽司", "start_offset": 0, "end_offset": 2, "type": "word", "position": 0 }, { "token": "おいしい", "start_offset": 3, "end_offset": 7, "type": "word", "position": 2 } ] } ```

kuromoji_stemmer Token Filter (日語長音過濾)

去除一些單詞末尾的長音, 如「コンピューター」 => 「コンピュータ」

``` POST _analyze { "tokenizer": "kuromoji_tokenizer", "filter": ["kuromoji_stemmer"], "text": "コンピューター" }

{ "tokens": [ { "token": "コンピュータ", "start_offset": 0, "end_offset": 7, "type": "word", "position": 0 } ] } ```

kuromoji_number Token Filter (日語數字過濾)

將漢字的數字轉換為 ASCII 數字

``` POST _analyze { "tokenizer": "kuromoji_tokenizer", "filter": ["kuromoji_number"], "text": "一〇〇〇" }

{ "tokens": [ { "token": "1000", "start_offset": 0, "end_offset": 4, "type": "word", "position": 0 } ] } ```

日語全文檢索 Analyzer 配置

基於上述這些元件,不難得出一個完整的日語全文檢索 Analyzer 配置

PUT my_index { "settings": { "analysis": { "analyzer": { "ja_fulltext_analyzer": { "type": "custom", "tokenizer": "kuromoji_tokenizer", "filter": [ "cjk_width", "lowercase", "kuromoji_stemmer", "ja_stop", "kuromoji_part_of_speech", "kuromoji_baseform" ] } } } }, "mappings": { "my_type": { "properties": { "title": { "type": "text", "analyzer": "ja_fulltext_analyzer" } } } } }

其實這也正是 kuromoji analyzer 所使用的配置,因此上面等價於

PUT my_index { "mappings": { "my_type": { "properties": { "title": { "type": "text", "analyzer": "kuromoji" } } } } }

這樣的預設設定已經可以應對一般情況,採用預設設定的主要問題是詞典未經打磨,一些新詞語或者專業領域的分詞不準確,如「東京スカイツリー」期待的分詞結果是 「東京/スカイツリー」,實際分詞結果是「東京/スカイ/ツリー」。進而導致一些搜尋排名不夠理想。這個問題可以將詞典切換到 UniDic + NEologd,能更多覆蓋新詞及網路用語,從而得到一些改善。同時也需要根據使用者搜尋,不斷維護自己的詞典。而自定義詞典,也能解決一詞多拼以及多音詞的問題。

至於本文開始提到的假名讀音匹配問題,很容易想到加入 kuromoji_readingform,這樣索引最終儲存的 Term 都是假名形式,確實可以解決假名輸入的問題,但是這又會引發新的問題:

一方面,kuromoji_readingform 所轉換的假名讀音並不一定準確,特別是遇到一些不常見的拼寫,比如「明るい」-> 「アカルイ」正確,「明るい」的送りがな拼寫「明かるい」就會轉換為錯誤的「メイ・カルイ」

另一方面,日文中相同的假名對應不同漢字也是極為常見,如「シアワセ」可以寫作「幸せ」、「仕合わせ」等。

因此kuromoji_readingform並不適用於大多數場景,在輸入補全,以及已知讀音的人名、地名等搜尋時,可以酌情加入。

日語自動補全的實現

Elasticsearch 的補全(Suggester)有 4 種:Term Suggester 和 Phrase Suggester 是根據輸入查詢形似的詞或片語,主要用於輸入糾錯,常見的場景是"你是不是要找 XXX";Context Suggester 個人理解一般用於對自動補全加上其他欄位的限定條件,相當於 query 中的 filter;因此這裡著重介紹最常用的 Completion Suggester。

Completion Suggester 需要響應每一個字元的輸入,對效能要求非常高,因此 ES 為此使用了新的資料結構:完全裝載到記憶體的 FST(In Memory FST), 型別為 completion。眾所周知,ES 的資料型別主要採用的是倒排索引(Inverse Index), 但由於 Term 資料量非常大,又引入了 term dictionary 和 term index,因此一個搜尋請求會經過以下的流程。

mermaid
graph LR
TI[Term Index] TD[Term Dictionary] PL[Posting List] Query --> TI TI --> TD TD --> Term Term --> PL PL --> Documents

completion 則省略了 term dictionary 和 term index,也不需要從多個 nodes 合併結果,僅適用記憶體就能完成計算,因此效能非常高。但由於僅使用了 FST 一種資料結構,只能實現字首搜尋。

瞭解了這些背景知識,來考慮一下如何構建日語的自動補全。

和全文檢索不同,在自動補全中,對讀音和羅馬字的匹配有非常強的需求,比如使用者在輸入「銀魂」。按照使用者的輸入順序,實際產生的字元應當是

  • gin
  • ぎん
  • 銀 t
  • 銀 tama
  • 銀魂

理想狀況應當讓上述的所有輸入都能匹配到「銀魂」,那麼如何實現這樣一個自動補全呢。常見的方法是針對漢字、假名、羅馬字各準備一個欄位,在輸入時同時對 3 個欄位做自動補全,然後再合併補全的結果。

來看一個實際的例子, 下面建立的索引中,建立了 2 種 Token Filter,kuromoji_readingform可以將文字轉換為片假名,romaji_readingform則可以將文字轉換為羅馬字,將其與kuromoji Analyzer 組合,就得到了對應的自定義 Analyzer ja_reading_analyzerja_romaji_analyzer

對於 title 欄位,分別用不同的 Analyzer 進行索引:

  • title: text 型別,使用 kuromoji Analyzer, 用於普通關鍵詞搜尋
  • title.suggestion: completion 型別, 使用 kuromoji Analyzer,用於帶漢字的自動補全
  • title.reading: completion 型別, 使用 ja_reading_analyzer Analyzer,用於假名的自動補全
  • title.romaji: completion 型別, 使用 ja_romaji_analyzer Analyzer,用於羅馬字的自動補全

PUT my_index { "settings": { "analysis": { "filter": { "katakana_readingform": { "type": "kuromoji_readingform", "use_romaji": "false" }, "romaji_readingform": { "type": "kuromoji_readingform", "use_romaji": "true" } }, "analyzer": { "ja_reading_analyzer": { "type": "custom", "filter": [ "cjk_width", "lowercase", "kuromoji_stemmer", "ja_stop", "kuromoji_part_of_speech", "kuromoji_baseform", "katakana_readingform" ], "tokenizer": "kuromoji_tokenizer" }, "ja_romaji_analyzer": { "type": "custom", "filter": [ "cjk_width", "lowercase", "kuromoji_stemmer", "ja_stop", "kuromoji_part_of_speech", "kuromoji_baseform", "romaji_readingform" ], "tokenizer": "kuromoji_tokenizer" } } } }, "mappings": { "my_type": { "properties": { "title": { "type": "text", "analyzer": "kuromoji", "fields": { "reading": { "type": "completion", "analyzer": "ja_reading_analyzer", "preserve_separators": false, "preserve_position_increments": false, "max_input_length": 20 }, "romaji": { "type": "completion", "analyzer": "ja_romaji_analyzer", "preserve_separators": false, "preserve_position_increments": false, "max_input_length": 20 }, "suggestion": { "type": "completion", "analyzer": "kuromoji", "preserve_separators": false, "preserve_position_increments": false, "max_input_length": 20 } } } } } } }

插入示例資料

POST _bulk { "index": { "_index": "my_index", "_type": "my_type", "_id": 1} } { "title": "銀魂" }

然後執行自動補全的查詢

GET my_index/_search { "suggest": { "title": { "prefix": "gin", "completion": { "field": "title.suggestion", "size": 20 } }, "titleReading": { "prefix": "gin", "completion": { "field": "title.reading", "size": 20 } }, "titleRomaji": { "prefix": "gin", "completion": { "field": "title.romaji", "size": 20 } } } }

可以看到不同輸入的命中情況

  • gin: 命中 title.romaji
  • ぎん: 命中 title.readingtitle.romaji
  • 銀: 命中 title.suggestion, title.readingtitle.romaji
  • 銀 t: 命中 title.romaji
  • 銀たま: 命中 title.readingtitle.romaji
  • 銀魂: 命中 title.suggestion, title.readingtitle.romaji

References

相關文章