最近基於 Elastic Stack 搭建了一個日語搜索服務,發現日文的搜索相比英語和中文,有很多特殊之處,所以記錄下用 Elasticsearch 搭建日語搜索引擎的一些要點。本文全部的示例,適用於 Elastic 6.X 及 7.X 版本。html
以 Elastic 的介紹語「Elasticsearchは、予期した結果や、そうでないものも検索できるようにデータを集めて格納するElastic Stackのコア製品です」爲例。做爲搜索引擎,固然但願用戶能經過句子中的全部主要關鍵詞,都能搜索到這條結果。node
和英文同樣,日語的動詞根據時態語境等,有多種變化。如例句中的「集めて」表示如今進行時,屬於動詞的連用形的て形,其終止形(能夠理解爲動詞的原型)是「集める」。一個日文動詞能夠有 10 餘種活用變形。若是依賴單純的分詞,用戶在搜索「集める」時將沒法匹配到這個句子。git
除動詞外,日語的形容詞也存在變形,如終止形「安い」能夠有連用形「安く」、未然性「安かろ」、仮定形「安けれ」等多種變化。github
和中文同樣,日文中存在多音詞,特別是人名、地名等,如「相楽」在作人名和地名時就有 Sagara、Soraku、Saganaka 等不一樣的發音。算法
同時日文中一個詞還存在不一樣的拼寫方式,如「空缶」 = 「空き缶」。apache
而做爲搜索引擎,輸入補全也是很重要的一個環節。從日語輸入法來看,用戶搜索時輸入的形式也是多種多樣,存在如下的可能性:json
等等。這和中文拼音有點相似,在用戶搜索結果或者作輸入補全時,咱們也但願能儘量適應用戶的輸入習慣,提高用戶體驗。api
Elasticsearch (下文簡稱 ES)做爲一個比較成熟的搜索引擎,對上述這些問題,都有了一些解決方法網絡
先複習一下 ES 的文本在進行索引時將經歷哪些過程,將一個文本存入一個字段 (Field) 時,能夠指定惟一的分析器(Analyzer),Analyzer 的做用就是將源文本經過過濾、變形、分詞等方式,轉換爲 ES 能夠搜索的詞元(Term),從而創建索引,即:數據結構
graph LR
Text --> Analyzer
Analyzer --> Term
複製代碼
一個 Analyzer 內部,又由 3 部分構成
引用一張圖說明應該更加形象
ES 已經內置了一些 Analyzers,但顯然對於日文搜索這種較複雜的場景,通常須要根據需求建立自定義的 Analyzer。
另外 ES 還有歸一化處理器 (Normalizers)的概念,能夠將其理解爲一個能夠複用的 Analyzers, 好比咱們的數據都是來源於英文網頁,網頁中的 html 字符,特殊字符的替換等等處理都是基本相同的,爲了不將這些通用的處理在每一個 Analyzer 中都定義一遍,能夠將其單獨整理爲一個 Normalizer。
爲了實現好的搜索效果,無疑會經過多種方式調整 Analyzer 的配置,爲了更有效率,應該優先掌握快速測試 Analyzer 的方法, 這部份內容詳見 如何快速測試 Elasticsearch 的 Analyzer, 此處再也不贅述。
日語分詞是一個比較大的話題,所以單獨開了一篇文章介紹和比較主流的開源日語分詞項目。引用一下最終的結論
算法/模型 | 實現語言 | 詞典 | 處理速度 | 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 爲例。
在 Tokenizer 已經肯定的基礎上,日語搜索其餘的優化都依靠 Token filter 來完成,這其中包括 ES 內置的 Token filter 以及 Kuromoji 附帶的 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
}
]
}
複製代碼
將全角 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 配置
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 一種數據結構,只能實現前綴搜索。
瞭解了這些背景知識,來考慮一下如何構建日語的自動補全。
和全文檢索不一樣,在自動補全中,對讀音和羅馬字的匹配有很是強的需求,好比用戶在輸入「銀魂」。按照用戶的輸入順序,實際產生的字符應當是
理想情況應當讓上述的全部輸入都能匹配到「銀魂」,那麼如何實現這樣一個自動補全呢。常見的方法是針對漢字、假名、羅馬字各準備一個字段,在輸入時同時對 3 個字段作自動補全,而後再合併補全的結果。
來看一個實際的例子, 下面創建的索引中,建立了 2 種 Token Filter,kuromoji_readingform
能夠將文本轉換爲片假名,romaji_readingform
則能夠將文本轉換爲羅馬字,將其與kuromoji
Analyzer 組合,就獲得了對應的自定義 Analyzer ja_reading_analyzer
和 ja_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
}
}
}
}
複製代碼
能夠看到不一樣輸入的命中狀況
title.romaji
title.reading
和 title.romaji
title.suggestion
, title.reading
和 title.romaji
title.romaji
title.reading
和 title.romaji
title.suggestion
, title.reading
和 title.romaji