最近基于 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