倒排索引
倒排索引作为ES的核心,底层基于Lucene进行实现。
倒排索引(Inverted Index)也叫反向索引,有反向索引必有正向索引。通俗地来讲,正向索引是通过文档ID找单词,类似于书的目录结构。反向索引则是通过单词找文档ID,类似于字典查词,首先必须知道单词的全拼,然后通过字典的索引页再去查找单词的详情。
倒排索引建立流程
- 内容爬取,停顿词过滤,比如一些无用的像"的",“了”之类的语气词/连接词
- 内容分词,提取关键词。一段文本经过分词器分词后转换成多个Term关键词。
- 根据关键词建立倒排索引。倒排索引包括Term Index(单词索引),Term Dictionary(单词字典),Posting List(倒排列表)
- 用户输入关键词进行搜索。
倒排索引具体组成
单词词典(Term Dictionary): 包含了所有数据在进行分词之后生成的单词(term),词典是由所有term构成的字符串集合。搜索引擎的通常索引单位是term,词典内每条索引项记载term本身的一些信息以及指向“倒排列表”的指针。ES 为了能快速查找到 term,将所有的 term 排了一个序,并采用二分法进行查找。
倒排列表(PostingList): 倒排列表记载了出现过某个单词的所有文档的文档列表记录,每条记录称为一个倒排索引项(Posting),其主要包括:
- 文档ID,用于获取原始信息
- 单词频率TF,记录该单词在该文档中的出现次数,用于后续相关性算分
- 位置Position,记录单词在文档中分词的位置,用于语句搜索(phrase query)
- 偏移Offset,记录单词在文档的开始和结束位置,实现高亮显示
根据倒排列表,即可获知某个单词在哪些文章中出现过。
单词索引(Term Index): ES 默认会对全部 text 字段进行分词并建立索引,导致Term Dictionary过大,无法存储在内存中。为了更快的找到某个Term,我们为单词建立索引。Term Index采用字典树结构,这棵树不会包含所有的 term,它包含的是 term 的一些前缀,通过 term index 可以快速地定位到 term dictionary 的某个 offset,然后从这个位置再往后顺序查找。就如上图所表示的。单词索引文件是为了加快对词典文件中词的查找速度,存储在内存中。
lucene 在这里还做了两点优化,一是 term dictionary 在磁盘上面是分 block 保存的,一个 block 内部利用公共前缀压缩,比如都是 Ab 开头的单词就可以把 Ab 省去。
二是 term index 在内存中是以 FST(finite state transducers)的数据结构保存的。
分词
在构建倒排索引的过程中,需要对文档内容进行分词,掌握分词要先懂两个名词:Analysis与Analyzer。
Analysis(文本分析)
即文本分析,是把全文本转化为一系列单词(term/token)的过程,也叫分词;Analysis是通过analyzer(分词器)来实现的,可以使用Elasticearch内置的分词器,也可以自己去定制一些分词器。
Analyzer(分词器)
由三部分组成
- Character Filters:原始文本处理
首先,字符串按顺序通过每个字符过滤器 。他们的任务是在分词前整理字符串。一个字符过滤器可以用来去掉HTML,或者将 & 转化成 and。
- Tokenizer:按照规则切分为单词
字符串被分词器按照规则分为单个的单词。一个 whitespace的分词器遇到空格和标点的时候,可能会将文本拆分成词条。下图为ES分词器汇总
- Token Filters:字段过滤器,对切分单词加工、小写、删除 stopwords,增加同义词
词条按顺序通过每个字段过滤器 。这个过程可能会改变词条,例如,lowercase token filter 小写化(将ES转为es)、stop token filter 删除词条(例如, 像 a, and, the 等无用词),或者synonym token filter 增加词条(例如,像 jump 和 leap 这种同义词)。
分词测试
使用index中的具体字段的分词器进行测试
下面的案例就是使用了index_name中的my_test字段所用的分词器进行测试。
GET ip:port/index_name/_analyze
{
"field": "my_text",
"text": "关注我,学习ES"
}
测试结果如下,从结果我们可以推测my_text字段使用的是standard分词器,按照每个词进行切分,且做小写处理。
{
"tokens": [当前字段切分后出现的分词列表,由该词的文本、偏移量(开始和结束)、位置、以及类型组成;
{
"token": "关",//分词后的具体文本
"start_offset": 0,//起始位置
"end_offset": 1,//结束位置
"type": "<IDEOGRAPHIC>",//当前单词的类型
"position": 0//当前单词所在整个字段的位置。
},
{
"token": "注",
"start_offset": 1,
"end_offset": 2,
"type": "<IDEOGRAPHIC>",
"position": 1
},
{
"token": "我",
"start_offset": 2,
"end_offset": 3,
"type": "<IDEOGRAPHIC>",
"position": 2
},
{
"token": "学",
"start_offset": 4,
"end_offset": 5,
"type": "<IDEOGRAPHIC>",
"position": 3
},
{
"token": "习",
"start_offset": 5,
"end_offset": 6,
"type": "<IDEOGRAPHIC>",
"position": 4
},
{
"token": "es",
"start_offset": 6,
"end_offset": 8,
"type": "<ALPHANUM>",
"position": 5
}
]
}
也可以直接使用analyzer
POST ip:port/_analyze
{
"analyzer": "whitespace",//这里使用空格分词器
"text":"关注我 学习ES"
}
可以看到,空格分词器直接采用空格进行切分,并不会对文中英文字母进行小写处理。
{
"tokens": [
{
"token": "关注我",
"start_offset": 0,
"end_offset": 3,
"type": "word",
"position": 0
},
{
"token": "学习ES",
"start_offset": 4,
"end_offset": 8,
"type": "word",
"position": 1
}
]
}
从以上测试可以看出,分析器不仅将原始文档转换为term,而且还记录每个term的顺序或相对位置(用于短语查询或单词接近性查询),以及每个term的开始和结束字符偏移量(用于突出显示搜索摘要)。
mapping
ES中的mapping映射可以把它类比于数据库中的表结构定义 schema,它有以下几个作用:
- 定义索引中的字段的名称
- 定义字段的数据类型,比如字符串、数字、布尔
- 定义字段,倒排索引的相关配置,比如设置某个字段的分词器,是否可索引、记录 position 等
字段数据类型
字段的数据类型由字段的type属性指定,ES 字段类型主要有:核心类型、复杂类型、地理类型以及特殊类型,具体的数据类型如下图所示:
核心类型
从图中可以看出核心类型可以划分为字符串类型、数字类型、日期类型、布尔类型、基于 BASE64 的二进制类型、范围类型。
字符串类型
ES7.x有两种字符串类型:text和keyword,ES 5.x之后不再支持string 类型。
text 类型适用于需要被全文检索的字段,例如新闻正文、邮件内容等比较长的文字,text 类型会被分词器处理为一个个关键词后分别进行索引,支持模糊、精确查询,不支持聚合、排序操作。
keyword 与text不同,字段设置为此类型后,将不会进行分词操作直接索引。适合简短、结构化的字符串,可以用于过滤、排序、聚合检索,也可以用于精确查询。
数字类型
数字类型分为 long、integer、short、byte、double、float、half_float、scaled_float。
数字类型的字段在满足需求的前提下应当尽量选择范围较小的数据类型,字段长度越短,搜索效率越高,对于浮点数,可以优先考虑使用 scaled_float 类型,该类型可以通过缩放因子来精确浮点数,例如 12.34 可以转换为 1234 来存储。
日期类型
在 ES 中日期可以为以下形式:
格式化的日期字符串,date类型,例如 2020-03-17 00:00、2020/03/17
时间戳(和 1970-01-01 00:00:00 UTC 的差值),date_nanos类型,单位毫秒或者秒
即使是格式化的日期字符串,ES 底层依然采用的是时间戳的形式存储。
布尔类型
JSON 文档中同样存在布尔类型,不过 JSON 字符串类型也可以被 ES 转换为布尔类型存储,前提是字符串的取值为 true 或者 false,布尔类型常用于检索中的过滤条件。
二进制类型
二进制类型 binary 接受 BASE64 编码的字符串,默认 store 属性为 false,并且不可以被搜索。
范围类型
范围类型可以用来表达一个数据的区间,可以分为5种:integer_range、float_range、long_range、double_range 以及 date_range。
复杂类型
复合类型主要有对象类型(object)和嵌套类型(nested):
对象类型
JSON 字符串允许嵌套对象,一个文档可以嵌套多个、多层对象。可以通过对象类型来存储二级文档,不过由于 Lucene 并没有内部对象的概念,ES 会将原 JSON 文档扁平化,例如文档:
{
"name": {
"first": "wu",
"last": "px"
}
}
实际上 ES 会将其转换为以下格式,并通过 Lucene 存储,即使 name 是 object 类型:
{
"name.first": "wu",
"name.last": "px"
}
嵌套类型
嵌套类型可以看成是一个特殊的对象类型,可以让对象数组独立检索,例如文档:
{
"group": "users",
"username": [
{ "first": "wu", "last": "px"},
{ "first": "hu", "last": "xy"},
{ "first": "wu", "last": "mx"}
]
}
username 字段是一个 JSON 数组,并且每个数组对象都是一个 JSON 对象。如果将 username 设置为对象类型,那么 ES 会将其转换为:
{
"group": "users",
"username.first": ["wu", "hu", "wu"],
"username.last": ["px", "xy", "mx"]
}
可以看出转换后的 JSON 文档中 first 和 last 的关联丢失了,如果尝试搜索 first 为 wu,last 为 xy 的文档,那么成功会检索出上述文档,但是 wu 和 xy 在原 JSON 文档中并不属于同一个 JSON 对象,应当是不匹配的,即检索不出任何结果。
嵌套类型就是为了解决这种问题的,嵌套类型将数组中的每个 JSON 对象作为独立的隐藏文档来存储,每个嵌套的对象都能够独立地被搜索,所以上述案例中虽然表面上只有 1 个文档,但实际上是存储了 4 个文档。
地理类型
地理类型字段分为两种:经纬度类型和地理区域类型:
经纬度类型
经纬度类型字段(geo_point)可以存储经纬度相关信息,通过地理类型的字段,可以用来实现诸如查找在指定地理区域内相关的文档、根据距离排序、根据地理位置修改评分规则等需求。
地理区域类型
经纬度类型可以表达一个点,而 geo_shape 类型可以表达一块地理区域,区域的形状可以是任意多边形,也可以是点、线、面、多点、多线、多面等几何类型。
特殊类型
特殊类型包括 IP 类型、过滤器类型、Join 类型、别名类型等。特殊类型可以查看官方文档。
字段的公共属性:
- index:该属性控制字段是否编入索引被搜索,该属性共有三个有效值:analyzed、no和not_analyzed:
analyzed:(默认属性)表示该字段被分析,编入索引,产生的token能被搜索到;
not_analyzed:表示该字段不会被分析,使用原始值编入索引,在索引中作为单个词;
no:不编入索引,无法搜索该字段;
其中analyzed是分析,分解的意思,默认值是analyzed,表示将该字段编入索引,以供搜索。
- store:指定是否将字段的原始值写入索引,默认值是no,字段值被分析,能够被搜索,但是,字段值不会存储,这意味着,该字段能够被查询,但是不会存储字段的原始值。
- boost:字段级别的助推,默认值是1,定义了字段在文档中的重要性/权重;
- include_in_all:该属性指定当前字段是否包括在_all字段中,默认值是ture,所有的字段都会包含_all字段中;如果index=no,那么属性include_in_all无效,这意味着当前字段无法包含在_all字段中。
- copy_to:该属性指定一个字段名称,ElasticSearch引擎将当前字段的值复制到该属性指定的字段中;
- doc_values:文档值是存储在硬盘上的索引时(indexing time)数据结构,对于not_analyzed字段,默认值是true,analyzed string字段不支持文档值;
- fielddata:字段数据是存储在内存中的查询时(querying time)数据结构,只支持analyzed string字段;
- null_value:该属性指定一个值,当字段的值为NULL时,该字段使用null_value代替NULL值;在ElasticSearch中,NULL 值不能被索引和搜索,当一个字段设置为NULL值,ElasticSearch引擎认为该字段没有任何值,使用该属性为NULL字段设置一个指定的值,使该字段能够被索引和搜索。
字符串类型常用的其他属性
analyzer:该属性定义用于建立索引和搜索的分析器名称,默认值是全局定义的分析器名称,该属性可以引用在配置结点(settings)中自定义的分析器;
search_analyzer:该属性定义的分析器,用于处理发送到特定字段的查询字符串;
ignore_above:该属性指定一个整数值,当字符串字段(analyzed string field)的字节数量大于该数值之后,超过长度的部分字符数据将不能被analyzer处理,不能被编入索引;对于 not analyzed string字段,超过长度的部分字符将被忽略,不会被编入索引。默认值是0,禁用该属性;
position_increment_gap:该属性指定在相同词的位置上增加的gap,默认值是100;
index_options:索引选项控制添加到倒排索引(Inverted Index)的信息,这些信息用于搜索(Search)和高亮显示:
docs:只索引文档编号(Doc Number)
freqs:索引文档编号和词频率(term frequency)
positions:索引文档编号,词频率和词位置(序号)
offsets:索引文档编号,词频率,词偏移量(开始和结束位置)和词位置(序号)
默认情况下,被分析的字符串(analyzed string)字段使用positions,其他字段使用docs;
dynamic
刚开始使用ES的时候,有一次在调用ES写入数据时,不小心将index_name写错,发现程序并没有报错,并且运行完成后,数据成功写入了错误的index_name里去。这时候我就有以下疑问:
1、为什么ES会自动创建index
2、写入数据完成后,查看当前index的mapping,发现已经根据写入数据的类型自动识别并创建。在没有指明数据结构以及数据类型的情况下,ES为何可以写入数据。其自动创建mapping的依据是什么
1、为什么ES会自动创建index
ES中有一个配置:auto_create_index。用来控制当数据写入时索引不存在,是否可以自动创建索引。默认打开。
get ip:port/_cluster/settings
{
"persistent": {
"action": {
"auto_create_index": "true",//当前配置说明此ES集群允许自动创建索引,如果没有出现此配置项,则默认为true。
"destructive_requires_name": "true"
},
"search": {
"max_buckets": "5000000"
}
},
"transient": {}
}
一般不建议开启自动创建索引,因为会引起索引太多、索引Mapping和Setting不符合预期等问题。可以通过以下请求关闭
PUT ip:port/_cluster/settings
{
"persistent" : {
"action": {
"auto_create_index": "false"
}
}
}
2、ES自动写入数据的依据
在创建索引时,我们首先需要指定mapping的映射模式,映射模式由dynamic属性确认。mapping映射模式分为以下三种:
动态映射
动态映射(dynamic mapping):dynamic=true
索引文档前不需要创建索引、类型等信息,在索引的同时会自动完成索引、数据类型的识别、映射的创建。索引创建时,默认会开启动态映射。
创建一个mapping为空的index,名称为liqifeng
PUT ip:port/liqifeng/
{
"mappings": {
}
}
查看当前mapping
GET ip:port/liqifeng/_mapping
{
"liqifeng": {
"mappings": {}
}
}
上传一条数据
PUT ip:port/liqifeng/_doc/1
{
"name": "小白",
"age": 16,
"sex": "不详"
}
查看此时mapping,可以看到已经根据上传的数据自动构建了字段索引信息。
{
"liqifeng": {
"mappings": {
"properties": {
"age": {
"type": "long"
},
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"sex": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
}
}
}
}
}
静态映射
静态(显式)映射(explicit mapping):dynamic=false
当映射模式设为静态后,即dynamic为false时,写入数据时,如果存在未被mapping定义的字段,则当前数据可正常写入,mapping已定义的字段可正常创建索引,且正常搜索。但不会为未定义字段创建索引,并且搜索此字段时也不会出现相应结果。搜索其他字段命中此数据时,返回的数据中会正常带有此字段。
创建dynamic为false的index,名称为liqifeng
PUT ip:port/liqifeng/
{
"mappings": {
"dynamic":false,
"properties": {
"name": {
"type": "text"
},
"age": {
"type": "long"
}
}
}
}
上传一条数据,其sex字段未在mapping中定义。
PUT ip:port/liqifeng/_doc/1
{
"name": "小白",
"age": 16,
"sex": "不详"
}
此时查看liqifeng的mapping,发现并没有为已上传的sex字段生成新的mapping字段信息
GET ip:port/liqifeng/_mapping
{
"liqifeng": {
"mappings": {
"dynamic": "false",
"properties": {
"age": {
"type": "long"
},
"name": {
"type": "text"
}
}
}
}
}
此时以sex字段构建查询条件
GET ip:port/liqifeng/_doc/_search
{
"query": {
"match": {
"sex": "不详"
}
}
}
结果如下,可以发现以sex字段查询时,因为此字段在静态映射模式下未在mapping中定义,所以无法被查询到:
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 0,
"relation": "eq"
},
"max_score": null,
"hits": []
}
}
此时我们以name字段进行查询
GET ip:port/liqifeng/_doc/_search
{
"query": {
"match": {
"name": "小白"
}
}
}
可以看到数据正常查询,即使sex字段未在mapping中被定义,没有为此创建索引以及无法被搜索,但是也被ES保存了下来,并且正常返回。
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 0.87546873,
"hits": [
{
"_index": "liqifeng",
"_type": "_doc",
"_id": "2",
"_score": 0.87546873,
"_source": {
"name": "小白",
"age": 16,
"sex": "不详"
}
}
]
}
}
精确映射
精确(严格)映射(strict mappings):dynamic=strict
在此映射模式下,写入的字段必须严格按照mapping定义,如果写入的数据中包含未在mapping中定义的字段,则会进行报错。
创建dynamic为strict的index,名称为liqifeng
PUT ip:port/liqifeng/
{
"mappings": {
"dynamic":strict,
"properties": {
"name": {
"type": "text"
},
"age": {
"type": "long"
}
}
}
}
此时写入一条与mapping定义一致的数据,可以正常写入。
PUT ip:port/liqifeng/_doc/1
{
"name": "小白",
"age": 16
}
写入一条数据,存在未在mapping中定义的sex字段
PUT ip:port/liqifeng/_doc/2
{
"name": "小白",
"age": 16,
"sex": "不详"
}
可以看到,直接提示当前数据写入出错,因为处在strict模式下,sex字段未被定义。
{
"error": {
"root_cause": [
{
"type": "strict_dynamic_mapping_exception",
"reason": "mapping set to strict, dynamic introduction of [sex] within [_doc] is not allowed"
}
],
"type": "strict_dynamic_mapping_exception",
"reason": "mapping set to strict, dynamic introduction of [sex] within [_doc] is not allowed"
},
"status": 400
}
查询
在实际的查询中,term和match 是最常用的两个查询
term是代表完全匹配,也就是精确查询,搜索前不会再对搜索词进行分词,所以我们的搜索词必须是文档分词集合中的一个。
match
match会先对搜索词进行分词,分词器采用目标字段的分词器。对于最基本的match搜索来说,只要搜索词的分词集合中的一个或多个存在于文档中的目标字段即可。
构建测试索引,并写入测试数据“南京市”、“杭州市”、“苏州市”
PUT ip:port/liqifeng/
{
"mappings": {
"properties": {
"city": {
"type": "text"
}
}
}
}
我们使用match进行查询“杭州市”,发现三个都返回了。因为在使用match时,“杭州市”会被分词为“杭”、“州”、“市”,然后进行搜索,只要其数据中包含上述任意一个分词的,即会返回。
POST ip:port/liqifeng/_doc/_search
{
"query": {
"match": {
"city": "杭州市"
}
}
}
{
"took": 4,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 3,
"relation": "eq"
},
"max_score": 1.5843642,
"hits": [
{
"_index": "liqifeng",
"_type": "_doc",
"_id": "1",
"_score": 1.5843642,
"_source": {
"city": "杭州市"
}
},
{
"_index": "liqifeng",
"_type": "_doc",
"_id": "2",
"_score": 0.60353506,
"_source": {
"city": "苏州市"
}
},
{
"_index": "liqifeng",
"_type": "_doc",
"_id": "3",
"_score": 0.13353139,
"_source": {
"city": "南京市"
}
}
]
}
}
term
使用term查询“杭州市”,发现并没有任何数据返回。因为term不会将“杭州市”进行分词,而是直接将此作为关键词带到索引进行查询,但是数据入库时均为分词后创建索引,所以无法匹配到任何数据。
POST ip:port/liqifeng/_doc/_search
{
"query": {
"term": {
"city": "杭州市"
}
}
}
{
"took": 2,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 0,
"relation": "eq"
},
"max_score": null,
"hits": []
}
}
match_phrase
此时我们可以使用match_phrase方法
match_phrase会将搜索词进行分词,同时会记住分词后的位置。然后将分词与位置带入匹配,完全符合所有分词以及位置的才可返回。
POST ip:port/liqifeng/_doc/_search
{
"query": {
"match_phrase": {
"city": "杭州市"
}
}
}
{
"took": 3,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 1,
"relation": "eq"
},
"max_score": 1.5843642,
"hits": [
{
"_index": "liqifeng",
"_type": "_doc",
"_id": "1",
"_score": 1.5843642,
"_source": {
"city": "杭州市"
}
}
]
}
}