使用 Elasticsearch 做一個好用的日語搜尋引擎及自動補全
最近基於 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 部分構成
- 字元過濾器 (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_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
}
}
}
}
可以看到不同輸入的命中情況
- gin: 命中
title.romaji
- ぎん: 命中
title.reading
和title.romaji
- 銀: 命中
title.suggestion
,title.reading
和title.romaji
- 銀 t: 命中
title.romaji
- 銀たま: 命中
title.reading
和title.romaji
- 銀魂: 命中
title.suggestion
,title.reading
和title.romaji
References
相關文章
- 做一個搜尋引擎的思路
- Laravel5.5 使用 Elasticsearch 做引擎,scout 全文搜尋LaravelElasticsearch
- elasticsearch 搜尋引擎工具的高階使用Elasticsearch
- ElasticSearch全文搜尋引擎Elasticsearch
- 開放搜尋開源相容版,支援Elasticsearch做搜尋召回引擎Elasticsearch
- 搜尋引擎原理及使用
- 認識搜尋引擎 ElasticsearchElasticsearch
- 給大家分享一個 python 做的搜尋引擎Python
- Nebula 基於 ElasticSearch 的全文搜尋引擎的文字搜尋Elasticsearch
- 在 Spring Boot 中使用搜尋引擎 ElasticsearchSpring BootElasticsearch
- 搜尋引擎ElasticSearch18_ElasticSearch簡介1Elasticsearch
- 全文搜尋引擎 Elasticsearch 入門教程Elasticsearch
- 分散式搜尋引擎Elasticsearch的架構分析分散式Elasticsearch架構
- 使用Node,Vue和ElasticSearch構建實時搜尋引擎VueElasticsearch
- ElasticSearch大資料分散式彈性搜尋引擎使用Elasticsearch大資料分散式
- 搜尋引擎ElasticSearch18_ElasticSearch的客戶端操作2Elasticsearch客戶端
- 搜尋引擎選型整理:Elasticsearch vs SolrElasticsearchSolr
- Scrapy分散式爬蟲打造搜尋引擎-(八)elasticsearch結合django搭建搜尋引擎分散式爬蟲ElasticsearchDjango
- 像使用 Laravel Query 一樣的搜尋 ElasticsearchLaravelElasticsearch
- 搜尋引擎ElasticSearch18_ElasticSearch程式設計操作5Elasticsearch程式設計
- 基於 Elasticsearch 的站內搜尋引擎實戰Elasticsearch
- LeetCode 642 號問題:設計搜尋自動補全系統LeetCode
- [教程一] 寫一個搜尋:使用 Laravel Scout,Elasticsearch,ik 分詞LaravelElasticsearch分詞
- 【Elasticsearch學習】文件搜尋全過程Elasticsearch
- 用elasticsearch和nuxtjs搭建bt搜尋引擎ElasticsearchUXJS
- Laravel 之搜尋引擎elasticsearch擴充套件ScoutLaravelElasticsearch套件
- 用QT寫一個搜尋引擎思路QT
- 開源搜尋引擎排名第一,Elasticsearch是如何做到的?Elasticsearch
- 搜尋引擎-03-搜尋引擎原理
- elasticsearch搜尋Elasticsearch
- 配置高效能 ElasticSearch 搜尋引擎叢集的9個小貼士Elasticsearch
- 一個在網路上非常爆紅的「搜尋引擎」,可能比百度都好用
- 使用 Docker 和 Elasticsearch 構建一個全文搜尋應用程式DockerElasticsearch
- 如何做一個針對百度搜尋引擎友好的網站網站
- Laravel 使用 Elasticsearch 全域性搜尋LaravelElasticsearch
- 解密Elasticsearch:深入探究這款搜尋和分析引擎解密Elasticsearch
- ElasticSearch分散式搜尋引擎——從入門到精通Elasticsearch分散式
- zinc:替代elasticsearch的輕量級Go語言搜尋引擎ElasticsearchGo