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

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

日語搜索的特殊性

以 Elastic 的介紹語「Elasticsearchは、予期した結果や、そうでないものも検索できるようにデータを集めて格納するElastic Stackのコア製品です」爲例。做爲搜索引擎,固然但願用戶能經過句子中的全部主要關鍵詞,都能搜索到這條結果。node

和英文同樣,日語的動詞根據時態語境等,有多種變化。如例句中的「集めて」表示如今進行時,屬於動詞的連用形的て形,其終止形(能夠理解爲動詞的原型)是「集める」。一個日文動詞能夠有 10 餘種活用變形。若是依賴單純的分詞,用戶在搜索「集める」時將沒法匹配到這個句子。git

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

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

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

而做爲搜索引擎,輸入補全也是很重要的一個環節。從日語輸入法來看,用戶搜索時輸入的形式也是多種多樣,存在如下的可能性:json

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

等等。這和中文拼音有點相似,在用戶搜索結果或者作輸入補全時,咱們也但願能儘量適應用戶的輸入習慣,提高用戶體驗。api

Elasticsearch 文本索引的過程

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

先複習一下 ES 的文本在進行索引時將經歷哪些過程,將一個文本存入一個字段 (Field) 時,能夠指定惟一的分析器(Analyzer),Analyzer 的做用就是將源文本經過過濾、變形、分詞等方式,轉換爲 ES 能夠搜索的詞元(Term),從而創建索引,即:數據結構

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) 的比較與選擇

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

算法/模型 實現語言 詞典 處理速度 ES 插件 Lisence
MeCab CRF C++ 可選 最高 GPL/LGPL/BSD
Kuromoji Viterbi Java 可選, 默認 ipadic 內置 Apache License v2.0
Juman++ RNNLM C++ 自制 Apache License v2.0
KyTea SVM 等 C++ UniDic Apache License v2.0
Sudachi Lattice LSTM Java UniDic + NEologd Apache License v2.0
nagisa Bi-LSTM Python ipadic MIT

對於 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,所以一個搜索請求會通過如下的流程。

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

相關文章
相關標籤/搜索