在上一篇文章中,我们知道client和ES交互的数据格式都是json,也知道了ES中的index和type的关系。那么如何像SQL类数据库定义表结构一样去定义一个type呢?ES对与数据的定义有两个特点。首先,ES是一种无模式的搜索引擎,也就是说可以并不事先定义数据的结构和格式,在数据插入的时候ES会动态的决定结构和数据类型。其次,在结构定下来之后,ES只允许增加新的字段和分析器而不支持对已有结构的修改,包括字段类型和已经启用的分析器。只能通过新建一个type,然后使用reindex API,把数据重新填充新的type中,最后删除旧的type。
一个例子
mapping是对于一个document的定义,相当于在SQL DB里面定义一个schema。由于是数据的传输都是JSON格式,所以没法显式的区分出关键字和自定义数据。以实际工作中的mapping结构来说,建立一个名叫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的代码片段不支持自定义格式,所以就在一些关键词之前加了一个星号。然后我们来看定义一个index的关键点。
数据类型
常见的字段类型
- boolean
- date
- numeric(long, integer, float, double)
- text, keyword
- nested
- object
其中,text和keyword都是表示文本内容,区别在于text会使用分析器建立去分割文本,建立倒排索引。而keyword只把整个字段都当做一个整体来参与搜索,而不去做额外的分析。
nested可以看做是不定长的数组,比如上面的channels,就是channel的集合,每个channel都有id和createTime这样的属性。通常把一对多关系中多的一部分保存在这样的field中,在查询时候,需要使用path来指定field的名字才能进行这样一对多的关联查询。
object类型是指每个filed还可以定义自己的properties,这些properties的个数是固定的,可以看做是定长数组。比如上面的systemFiled,还有一个叫name的field。但是,lucence并没有这种数组的结构,ES会把这种object平铺,实际在存储是product.systemField.name的key。
字段参数
定义字段的时候除了指定数据类型,还可以额外指定一些参数,常用到到参数有:
- analyzer: [analyzer] 字段使用的分析器
- copy_to: [field name] 把字段值复制一份到另一个字段,经常被使用来进行不同字段的数据聚合
- index [true | false]: 是否建立索引
- fielddata [true | false] 建立完索引之后是否还要把原文放在内存中,如果要对该字段进行排序,聚集等访问形式时,需要设置为true (注:为了节省内存空间,text字段的fielddata默认是false!!!)
- format: [format] date类型字段的格式,参考官方文档
动态类型确定
ES还支持在数据插入的时候自动检测字段类型,在type的定义里加上,
"dynamic": true
如果只需要把数字映射成字符串的话,还需要
"numeric_detection": false
动态类型可能会遇到一些问题,一个是就是在query的时候,参与搜索的字段必须要和查询的数据类型一致,动态产生的字段容易被忽略,导致在query的时候出错。还有一个是,在默认情况下,一个string会产生两个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使用模板也是官方推荐的节省存储空间的最佳实践。
索引的关联关系
在项目的实践过程中,我发现ES的长处还是在各种文本搜索能力。但是对于关联查询,ES就显示出和SQL DB不同的地方,因为ES不支持index之间的join查询,所以通常来说在遇到关联关系,尤其是一对多和多对多关系时,通常使用两种方法来建立对应关系。
第一种是上文介绍的nested类型的field,本质上是一种冗余,将多的一侧数据保存在一的一测上。在查询的时候使用nested查询,指定path和主体数据做关联。
第二种是把关联的主体拆分成一个index下的多个type,并且在定义时使用_parent
指定parent-child关系,
{
"mappings": {
"product": {
"properties": {
...
}
},
"sku": {
"_parent": {
"type": "product"
},
...
}
}
}
上面的例子就是建立了一个index,包含两个type,product是parent type,sku是child type。在填充数据的时候,对于child要指定对应的parent是什么,ES内部会维护这样的上下级关系,在查询阶段,使用has_child
或者has_parent
查询来进行关联。具体的做法在后面说到DML会详细说。
总的来说,ES建议尽量避免数据的关联,因为相比普通查询,关联查询会非常的慢,其中nested查询相对比parent-child查询会快很多,但是parent-child的type结构上清晰一些,实际使用的时候应该酌情建模。
分析器
分析器(analyzer)用于分析数据,建立倒排索引。由三个部分组成:character filter, tokenizer, 和 token filter。
Character filter
character filter接受最原始的字符流,然后做一些基本的过滤,可以去掉一些无意义的字符。
官方提供了三种filter:
- html strip char filter: 过滤html标签的filter, e.g. <div>food</div> => food
- mapping char filter: 自定义的字符映射关系,e.g. 1 => one, 2 => two
- pattern replace character filter: 自定义的正则模式映射
Tokenizer
tokenizer接受来自于character filter传递过来的字符流,然后进行分词。
es内置的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
使用一个滑动的窗口,在字符串的级别上进行滑动进行分词,窗口可以设置上限和下限进行伸缩,
"Quick Fox" => [ Q, Qu, u, ui, i, ic, c, ck, k, "k ", " ", " F", F, Fo, o, ox, x ]
上面是使用min_gram: 1
和max_gram: 2
的窗口进行分词的结果,可以看到这种tokenizer不以单词为单位,所以会出现"k "
和" F"
这种情况。
需要注意的是,这种tokenizer会产生很多的分词,导致索引的存储空间消耗的非常高,所以在调优的时候需要结合具体场景优化滑动窗口的大小。
Token Filter
Token Filter接受来自Tokenizer处理后的token流,再进行一些加工处理,得到最终的term。
NGram Token Filter
工作原理和上面的NGram Tokenizer类似,区别在于处理的单位是token,也就是单词级别。同样的文本输入,
"Quick Fox" => [ Q, Qu, u, ui, i, ic, c, ck, k, F, Fo, o, ox, x ]
可以看到它的max_gram
参数只要能保证大于所有输入的单词长度就能够包含所有的单词内容。NGram Token Filter可以用于模糊搜索。
Edge NGram Token Filter
一个变种的NGram,窗口并不滑动,用参数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文件的相对路径,具体的内容格式可以参考:官方手册。
如果写成a, b, c => d
这样的形式,则同义词关系是单向的,如果想设定为双向的,需要在定义filter的时候加上属性expand: true
,或者写成[a, b, c, d], 那么只要有一个单词命中,就会同时产生a,b,c,d四个分词。
自定义分析器
分析器本质就是character filter, tokenizer和token filter的组合。ES默认给了一些预定义的分析器,如果不能满足应用场景的时候,我们需要自己来定义。
具体的规则是这样:
- 至少一个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去查看分词的结果,
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
}
]
}
中文分词
上面说到的分析器都是对英文支持的比较好,但是对中文来说,只能分解成一个字一个字的结果。显然在中文语义上,我们希望得到中文词组的分析结果。ES官方有一个叫smartcn的插件可以支持中文分词,内部实现是基于中科院计算所的ICTCLAS分词系统。自己测试时候没有发现太大问题,但是很多文章都指出smartcn在分词精度上还是有所欠缺,多数文章推荐使用ik分析器,github地址,它有两种分词模式,一种叫ik_smart,采用最大长度的分词结果,
“中华人民共和国” => “中华人民共和国”
还有一种叫ik_max_word,会把所有有意义的词组都提取出来,
“中华人民共和国” => “中华”,“人民,“共和国”,”“中华人民共和国”
ik还支持自定义的词典,可以在远端维护一个词典,把url配置在ik插件中,每次只要更新词典而不用重启ES。
ik的作者还编写了一个拼音分词插件。对每个字还会提取拼音和拼音首字母的索引,
“刘德华” => "liu", "de", "hua", "ldh", "刘德华"
其他一些分词的放在在这篇文章都有介绍。
多语言的处理
如果一个document里面可能包含多种语言,通常有两种处理方法,
当能确定一个字段只会出现一种特定的语言时,根据语言加上analyzer。
当不确定一个字段会出现什么语言的时候,可以使用copy_to
复制到不同的字段里,每一个加上特定语言的analyzer。但是在这种情况下,分词的结果包括了其他语言,会对搜索产生干扰。比如,
明天去City吃McDonald
要对中英文分开处理,中文的字段产生的结果是,
["明天", "去", "city", "吃", "mcdonald"]
英文字段,如果要支持模糊搜索,加上ngram后产生的分词结果是,
["明", "天", "去", "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"]
当我们搜索去吃
的时候,在中文分词的环境下,应该是搜不出结果的,但是英文字段中的中文字词结果还是会匹配出来。这里就会产生一个问题,最好能将一种语言只放到一个字段里面去。
我开发了一个elasticsearch的插件,来实现一个char filter,根据配置,可以只保留一种语言的字符。
项目地址:https://github.com/stormisover/es-language-char-filter
Index结构的更新
对一个已经存在的index,更新它的mapping结构是几乎做不到的,只有几种情况是不受限的:
- 增加一个nested字段的property
- 向一个已存在的字段添加multi-fileds
- 改变字段的
ignore_above
属性
Reindex
除此之外,只有通过reindex的方式去建立一个新的index,再在上面设置更新后的mapping结构,再通过reindex API将旧的index数据导入到新的index上去,导入过程中也可以做一些数据修改,
POST _reindex
{
"source": {
"index": "my_index_v1"
},
"dest": {
"index": "my_index_v2"
}
}
确认新的index没有问题后再把旧的删除。
默认reindex是同步的过程,当数据量比较大的时候,reindex的过程会比较长,如果手动发API call当然问题不大,但是在应用里面做的时候,比如用Java的RestClient,很可能遇到超时的情况。ES还提供了一个异步的机制,调用_reindex的时候加上URL参数wait_for_completion=false
,会立即得到返回,
{
"task": "Vx-HgONLRJaUteegjOFW8Q:199879"
}
这是一个task ID,然后用_task API去定时轮询这个task的状态,
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被标记"cancellable": true
的话,当它没有做完的时候,可以调用POST _task/[taskID]/_cancel
去停止这个task,但是已经做了的一部分数据并不会撤销。
使用这种异步的task,ES会自动产生一个.task的index,里面保存着每次task的执行记录,使用者根据需要保留或者删除当异步任务执行结束。
Alias
为了这样的过程不对上层应用产生影响,一个好的实践方式是使用alias,
PUT /my_index_v1/_alias/my_index
或者在新建index的时候指定,
PUT /my_index_v1
{
"aliases" : {
"my_index" : {}
},
...
}
这样应用里只需要始终使用这个my_index
而不用管真实的index是如何更新的。
此外,alias在建立的时候,可以加filter条件,
{
"my_index_v1" : {
"aliases" : {
"2016" : {
"filter" : {
"term" : {
"year" : 2016
}
}
}
}
}
}
这样只会提供2016年的数据,类似SQL DB中的视图功能。