在上一篇文章中,我們知道client和ES交互的數(shù)據(jù)格式都是json,也知道了ES中的index和type的關(guān)系。那么如何像SQL類數(shù)據(jù)庫定義表結(jié)構(gòu)一樣去定義一個type呢?ES對與數(shù)據(jù)的定義有兩個特點。首先,ES是一種無模式的搜索引擎,也就是說可以并不事先定義數(shù)據(jù)的結(jié)構(gòu)和格式,在數(shù)據(jù)插入的時候ES會動態(tài)的決定結(jié)構(gòu)和數(shù)據(jù)類型。其次,在結(jié)構(gòu)定下來之后,ES只允許增加新的字段和分析器而不支持對已有結(jié)構(gòu)的修改,包括字段類型和已經(jīng)啟用的分析器。只能通過新建一個type,然后使用reindex API,把數(shù)據(jù)重新填充新的type中,最后刪除舊的type。
一個例子
mapping是對于一個document的定義,相當(dāng)于在SQL DB里面定義一個schema。由于是數(shù)據(jù)的傳輸都是JSON格式,所以沒法顯式的區(qū)分出關(guān)鍵字和自定義數(shù)據(jù)。以實際工作中的mapping結(jié)構(gòu)來說,建立一個名叫store的index并包含一個product的type是這樣的,
PUT /store
{
*"mappings": {
"product": {
*"dynamic": "true",
*"numeric_detection": false,
*"_routing": {
"required": true
},
*"dynamic_templates": [
{
"strings": {
*"match_mapping_type": "string",
*"mapping": {
*"type": "text"
}
}
},
{
"price": {
*"path_match": "channels.prices.*",
*"mapping": {
*"type": "float"
}
}
}
],
*"properties": {
"search_strong_fuzzy": {
*"type": "text",
*"analyzer": "strong_fuzzy_analyzer",
*"search_analyzer": "standard"
},
"systemField": {
*"properties": {
"name": {
*"type": "text",
*"copy_to": "search_strong_fuzzy"
},
"channels": {
*"type": "nested",
*"properties": {
"id": {
*"type": "long"
},
"creationTime": {
*"type": "date",
*"format": "strict_date_optional_time||epoch_millis",
*"index": false
}
...
由于markdown的代碼片段不支持自定義格式,所以就在一些關(guān)鍵詞之前加了一個星號。然后我們來看定義一個index的關(guān)鍵點。
數(shù)據(jù)類型
常見的字段類型
- boolean
- date
- numeric(long, integer, float, double)
- text, keyword
- nested
- object
其中,text和keyword都是表示文本內(nèi)容,區(qū)別在于text會使用分析器建立去分割文本,建立倒排索引。而keyword只把整個字段都當(dāng)做一個整體來參與搜索,而不去做額外的分析。
nested可以看做是不定長的數(shù)組,比如上面的channels,就是channel的集合,每個channel都有id和createTime這樣的屬性。通常把一對多關(guān)系中多的一部分保存在這樣的field中,在查詢時候,需要使用path來指定field的名字才能進行這樣一對多的關(guān)聯(lián)查詢。
object類型是指每個filed還可以定義自己的properties,這些properties的個數(shù)是固定的,可以看做是定長數(shù)組。比如上面的systemFiled,還有一個叫name的field。但是,lucence并沒有這種數(shù)組的結(jié)構(gòu),ES會把這種object平鋪,實際在存儲是product.systemField.name的key。
字段參數(shù)
定義字段的時候除了指定數(shù)據(jù)類型,還可以額外指定一些參數(shù),常用到到參數(shù)有:
- analyzer: [analyzer] 字段使用的分析器
- copy_to: [field name] 把字段值復(fù)制一份到另一個字段,經(jīng)常被使用來進行不同字段的數(shù)據(jù)聚合
- index [true | false]: 是否建立索引
- fielddata [true | false] 建立完索引之后是否還要把原文放在內(nèi)存中,如果要對該字段進行排序,聚集等訪問形式時,需要設(shè)置為true (注:為了節(jié)省內(nèi)存空間,text字段的fielddata默認是false!!!)
- format: [format] date類型字段的格式,參考官方文檔
動態(tài)類型確定
ES還支持在數(shù)據(jù)插入的時候自動檢測字段類型,在type的定義里加上,
"dynamic": true
如果只需要把數(shù)字映射成字符串的話,還需要
"numeric_detection": false
動態(tài)類型可能會遇到一些問題,一個是就是在query的時候,參與搜索的字段必須要和查詢的數(shù)據(jù)類型一致,動態(tài)產(chǎn)生的字段容易被忽略,導(dǎo)致在query的時候出錯。還有一個是,在默認情況下,一個string會產(chǎn)生兩個field,一個是keyword一個是text。所以可以定義dynamic的映射模板,
"dynamic_templates": [
{
"strings": {
"match_mapping_type": "string",
"mapping": {
"type": "text"
}
}
},
{
"price": {
"path_match": "channels.prices.*",
"mapping": {
"type": "float"
}
}
}
]
上面的模板把string變成text類型,把nested字段prices的所有字段都映射成float類型。對于dynamic的string使用模板也是官方推薦的節(jié)省存儲空間的最佳實踐。
索引的關(guān)聯(lián)關(guān)系
在項目的實踐過程中,我發(fā)現(xiàn)ES的長處還是在各種文本搜索能力。但是對于關(guān)聯(lián)查詢,ES就顯示出和SQL DB不同的地方,因為ES不支持index之間的join查詢,所以通常來說在遇到關(guān)聯(lián)關(guān)系,尤其是一對多和多對多關(guān)系時,通常使用兩種方法來建立對應(yīng)關(guān)系。
第一種是上文介紹的nested類型的field,本質(zhì)上是一種冗余,將多的一側(cè)數(shù)據(jù)保存在一的一測上。在查詢的時候使用nested查詢,指定path和主體數(shù)據(jù)做關(guān)聯(lián)。
第二種是把關(guān)聯(lián)的主體拆分成一個index下的多個type,并且在定義時使用_parent指定parent-child關(guān)系,
{
"mappings": {
"product": {
"properties": {
...
}
},
"sku": {
"_parent": {
"type": "product"
},
...
}
}
}
上面的例子就是建立了一個index,包含兩個type,product是parent type,sku是child type。在填充數(shù)據(jù)的時候,對于child要指定對應(yīng)的parent是什么,ES內(nèi)部會維護這樣的上下級關(guān)系,在查詢階段,使用has_child 或者has_parent查詢來進行關(guān)聯(lián)。具體的做法在后面說到DML會詳細說。
總的來說,ES建議盡量避免數(shù)據(jù)的關(guān)聯(lián),因為相比普通查詢,關(guān)聯(lián)查詢會非常的慢,其中nested查詢相對比parent-child查詢會快很多,但是parent-child的type結(jié)構(gòu)上清晰一些,實際使用的時候應(yīng)該酌情建模。
分析器
分析器(analyzer)用于分析數(shù)據(jù),建立倒排索引。由三個部分組成:character filter, tokenizer, 和 token filter。
Character filter
character filter接受最原始的字符流,然后做一些基本的過濾,可以去掉一些無意義的字符。
官方提供了三種filter:
- html strip char filter: 過濾html標(biāo)簽的filter, e.g. <div>food</div> => food
- mapping char filter: 自定義的字符映射關(guān)系,e.g. 1 => one, 2 => two
- pattern replace character filter: 自定義的正則模式映射
Tokenizer
tokenizer接受來自于character filter傳遞過來的字符流,然后進行分詞。
es內(nèi)置的tokenizer可以也分成三類:
- Word oriented tokenizer
- Partial word oriented tokenizer
- Structured text tokenizer
分詞特點看名字就一目了然,下面來介紹一些有特點tokenizer
Standard Tokenizer
默認的tokenizer,使用Unicode文本分割算法進行分詞,會去掉一些跟語言五官的符號,
"The 2 QUICK Brown-Foxes jumped over the lazy dog's bone."
=>
[ The, 2, QUICK, Brown, Foxes, jumped, over, the, lazy, dog's, bone ]
NGram Tokenizer
使用一個滑動的窗口,在字符串的級別上進行滑動進行分詞,窗口可以設(shè)置上限和下限進行伸縮,
"Quick Fox" => [ Q, Qu, u, ui, i, ic, c, ck, k, "k ", " ", " F", F, Fo, o, ox, x ]
上面是使用min_gram: 1和max_gram: 2的窗口進行分詞的結(jié)果,可以看到這種tokenizer不以單詞為單位,所以會出現(xiàn)"k "和" F"這種情況。
需要注意的是,這種tokenizer會產(chǎn)生很多的分詞,導(dǎo)致索引的存儲空間消耗的非常高,所以在調(diào)優(yōu)的時候需要結(jié)合具體場景優(yōu)化滑動窗口的大小。
Token Filter
Token Filter接受來自Tokenizer處理后的token流,再進行一些加工處理,得到最終的term。
NGram Token Filter
工作原理和上面的NGram Tokenizer類似,區(qū)別在于處理的單位是token,也就是單詞級別。同樣的文本輸入,
"Quick Fox" => [ Q, Qu, u, ui, i, ic, c, ck, k, F, Fo, o, ox, x ]
可以看到它的max_gram參數(shù)只要能保證大于所有輸入的單詞長度就能夠包含所有的單詞內(nèi)容。NGram Token Filter可以用于模糊搜索。
Edge NGram Token Filter
一個變種的NGram,窗口并不滑動,用參數(shù)side控制在文本起始或者終點的一端,另一端擴大進行分詞,
"Quick Fox" => [ Q, Qu, Qui, Quick, F, Fo, Fox ]
由于分詞的建立符合用戶輸入的順序,Edge NGram Token Filter適合于自動完成功能。
Synonym Token Filter
一種提供了同義詞映射的token filter, 同義詞可以聲明在filter的定義中,
"synonym" : {
"type" : "synonym",
"synonyms" : [
"i-pod, i pod => ipod",
"universe, cosmos"
]
}
也可以定義在一個txt文件里,
"filter" : {
"synonym" : {
"type" : "synonym",
"synonyms_path" : "analysis/synonym.txt"
}
}
這里的path是指和ES中config文件的相對路徑,具體的內(nèi)容格式可以參考:官方手冊。
如果寫成a, b, c => d這樣的形式,則同義詞關(guān)系是單向的,如果想設(shè)定為雙向的,需要在定義filter的時候加上屬性expand: true,或者寫成[a, b, c, d], 那么只要有一個單詞命中,就會同時產(chǎn)生a,b,c,d四個分詞。
自定義分析器
分析器本質(zhì)就是character filter, tokenizer和token filter的組合。ES默認給了一些預(yù)定義的分析器,如果不能滿足應(yīng)用場景的時候,我們需要自己來定義。
具體的規(guī)則是這樣:
- 至少一個character filter,
- 一個tokenizer,
- 至少一個token filters
每個單位也都可以自定義,例如,
PUT my_index
{
"settings": {
"analysis": {
"analyzer": {
"my_custom_analyzer": {
"type": "custom",
"tokenizer": "standard",
"char_filter": [
"html_strip"
],
"filter": [
"lowercase",
"asciifolding"
]
}
}
}
}
}
分析器的驗證
ES提供了analyze API去查看分詞的結(jié)果,
GET _analyze
{
"tokenizer" : "whitespace",
"filter" : ["lowercase", {"type": "stop", "stopwords": ["a", "is"]}],
"text" : "This is a test"
}
上面的例子會的到返回:
{
"tokens": [
{
"token": "this",
"start_offset": 0,
"end_offset": 4,
"type": "word",
"position": 0
},
{
"token": "test",
"start_offset": 10,
"end_offset": 14,
"type": "word",
"position": 3
}
]
}
中文分詞
上面說到的分析器都是對英文支持的比較好,但是對中文來說,只能分解成一個字一個字的結(jié)果。顯然在中文語義上,我們希望得到中文詞組的分析結(jié)果。ES官方有一個叫smartcn的插件可以支持中文分詞,內(nèi)部實現(xiàn)是基于中科院計算所的ICTCLAS分詞系統(tǒng)。自己測試時候沒有發(fā)現(xiàn)太大問題,但是很多文章都指出smartcn在分詞精度上還是有所欠缺,多數(shù)文章推薦使用ik分析器,github地址,它有兩種分詞模式,一種叫ik_smart,采用最大長度的分詞結(jié)果,
“中華人民共和國” => “中華人民共和國”
還有一種叫ik_max_word,會把所有有意義的詞組都提取出來,
“中華人民共和國” => “中華”,“人民,“共和國”,”“中華人民共和國”
ik還支持自定義的詞典,可以在遠端維護一個詞典,把url配置在ik插件中,每次只要更新詞典而不用重啟ES。
ik的作者還編寫了一個拼音分詞插件。對每個字還會提取拼音和拼音首字母的索引,
“劉德華” => "liu", "de", "hua", "ldh", "劉德華"
其他一些分詞的放在在這篇文章都有介紹。
多語言的處理
如果一個document里面可能包含多種語言,通常有兩種處理方法,
當(dāng)能確定一個字段只會出現(xiàn)一種特定的語言時,根據(jù)語言加上analyzer。
當(dāng)不確定一個字段會出現(xiàn)什么語言的時候,可以使用copy_to復(fù)制到不同的字段里,每一個加上特定語言的analyzer。但是在這種情況下,分詞的結(jié)果包括了其他語言,會對搜索產(chǎn)生干擾。比如,
明天去City吃McDonald
要對中英文分開處理,中文的字段產(chǎn)生的結(jié)果是,
["明天", "去", "city", "吃", "mcdonald"]
英文字段,如果要支持模糊搜索,加上ngram后產(chǎn)生的分詞結(jié)果是,
["明", "天", "去", "ci", "it", "ty", "cit", "ity", "city", "吃", "mc", "cd",
"do", "on", "na", "al", "ld", "mcd", "cdo", "don", "ona", "nal", "ald",
"mcdo", "cdon", "dona", "noal", "oald", "mcdon", "cdona", "donal", "onald",
"mcdonal", "cdonald","mcdonald"]
當(dāng)我們搜索去吃的時候,在中文分詞的環(huán)境下,應(yīng)該是搜不出結(jié)果的,但是英文字段中的中文字詞結(jié)果還是會匹配出來。這里就會產(chǎn)生一個問題,最好能將一種語言只放到一個字段里面去。
我開發(fā)了一個elasticsearch的插件,來實現(xiàn)一個char filter,根據(jù)配置,可以只保留一種語言的字符。
項目地址:https://github.com/stormisover/es-language-char-filter
Index結(jié)構(gòu)的更新
對一個已經(jīng)存在的index,更新它的mapping結(jié)構(gòu)是幾乎做不到的,只有幾種情況是不受限的:
- 增加一個nested字段的property
- 向一個已存在的字段添加multi-fileds
- 改變字段的
ignore_above屬性
Reindex
除此之外,只有通過reindex的方式去建立一個新的index,再在上面設(shè)置更新后的mapping結(jié)構(gòu),再通過reindex API將舊的index數(shù)據(jù)導(dǎo)入到新的index上去,導(dǎo)入過程中也可以做一些數(shù)據(jù)修改,
POST _reindex
{
"source": {
"index": "my_index_v1"
},
"dest": {
"index": "my_index_v2"
}
}
確認新的index沒有問題后再把舊的刪除。
默認reindex是同步的過程,當(dāng)數(shù)據(jù)量比較大的時候,reindex的過程會比較長,如果手動發(fā)API call當(dāng)然問題不大,但是在應(yīng)用里面做的時候,比如用Java的RestClient,很可能遇到超時的情況。ES還提供了一個異步的機制,調(diào)用_reindex的時候加上URL參數(shù)wait_for_completion=false,會立即得到返回,
{
"task": "Vx-HgONLRJaUteegjOFW8Q:199879"
}
這是一個task ID,然后用_task API去定時輪詢這個task的狀態(tài),
GET _tasks/Vx-HgONLRJaUteegjOFW8Q:199879
{
"completed": false,
"task": {
"node": "Vx-HgONLRJaUteegjOFW8Q",
"id": 199879,
"type": "transport",
"action": "indices:data/write/reindex",
"status": {
"total": 351000,
"updated": 0,
"created": 153000,
"deleted": 0,
"batches": 154,
"version_conflicts": 0,
},
},
"description": "",
"start_time_in_millis": 1510144926021,
"running_time_in_nanos": 40072730773,
"cancellable": true
}
}
直到得到這個task完成,
{
"completed": true,
...
}
如果這個task被標(biāo)記"cancellable": true的話,當(dāng)它沒有做完的時候,可以調(diào)用POST _task/[taskID]/_cancel去停止這個task,但是已經(jīng)做了的一部分數(shù)據(jù)并不會撤銷。
使用這種異步的task,ES會自動產(chǎn)生一個.task的index,里面保存著每次task的執(zhí)行記錄,使用者根據(jù)需要保留或者刪除當(dāng)異步任務(wù)執(zhí)行結(jié)束。
Alias
為了這樣的過程不對上層應(yīng)用產(chǎn)生影響,一個好的實踐方式是使用alias,
PUT /my_index_v1/_alias/my_index
或者在新建index的時候指定,
PUT /my_index_v1
{
"aliases" : {
"my_index" : {}
},
...
}
這樣應(yīng)用里只需要始終使用這個my_index而不用管真實的index是如何更新的。
此外,alias在建立的時候,可以加filter條件,
{
"my_index_v1" : {
"aliases" : {
"2016" : {
"filter" : {
"term" : {
"year" : 2016
}
}
}
}
}
}
這樣只會提供2016年的數(shù)據(jù),類似SQL DB中的視圖功能。