【你真的会用ES吗】ES基础介绍(二)
前言
在上一篇文章ES基础信息(一)中,介绍了ES的背景、版本更新细则、建立索引所需要了解的基础概念以及常用的搜索关键字。本篇文章会继续补充一些全文索引相关的内容,分析器,相关性得分等等。
ES除了通过倒排索引实现全文检索之外,常用的功能还有聚合及排序,这是本篇文章的重点之一。这里需要大家提前知道一点:通过倒排索引的方式去实现聚合和排序,是非常不现实的,ES(其实是底层Lucene)底层将数据转成了另一个结构存储以实现这个逻辑,它就是DocValues,基于列式存储的数据格式。
除此之外,本文会介绍ES提供的一些比较好用的功能,索引别名、索引生命周期策略以及索引模版。这也是本系列文章中最后一篇关于功能点介绍的文章,ES的功能点远不如此,如果后续有Get到新的好用功能(欢迎评论或RTX讨论分享),会持续更新文章内容。后续文章会针对ES(Lucene)进行更深入的介绍。
ES基础使用介绍
分析器 Analyzer
在上一篇文章中提到了,针对全文索引类型,一定要选择合适的分析器,现在我们就来了解一下分析器~
Analyzer主要是对输入的文本类内容进行分析(通常是分词),将分析结果以 term
的形式进行存储。
Analyzer由三个部分组成:Character Filters、Tokenizer、Token Filters
- Character Filters Character Filters以characters流的方式接收原始数据,它可以支持characters的增、删、改,通常内置的分析器都没有设置默认的Character Filters。 ES内置的Character Filters:
- HTML Strip Character Filter:支持剔除html标签,解码
- Mapping Character Filter:支持根据定义的映射进行替换
- Pattern Replace Character Filter:支持根据正则进行替换
- Tokenizer Tokenizer接收一个字符流,分解成独立的tokens(通常就是指的分词),并且输出tokens。例如,一个 whitespace tokenizer(空格tokenizer),以空格作为分割词对输入内容进行分词。 例如:向whitespace tokenizer输入“Quick brown fox!”,将会输出“Quick”、 “brown”、“fox!” 3个token。
- Token Filters Token filters 接收Tokenizer输出的token序列,它可以根据配置进行token的增、删、改。 例如:指定synonyms增加token、指定remove stopwords进行token删除,抑或是使用lowercasing进行小写转换。
ES内置的分析器有Standard Analyzer、Simple Analyzer、Whitespace Analyzer、Stop Analyzer、Keyword Analyzer、Pattern Analyzer、Language Analyzers、Fingerprint Analyzer,并且支持定制化。 这里的内置分词器看起来都比较简单,这里简单介绍一下Standard Analyzer、Keyword Analyzer,其他的分词器大家感兴趣可以自行查阅。
text 类型默认analyzer:Standard Analyzer
Standard Analyzer的组成部分:
- Tokenizer Standard Tokenizer:基于Unicode文本分割算法-Unicode标准附件# 29,支持使用
max_token_length
参数指定token长度,默认为255。 - Token Filters
- Lower Case Token Filter
- Stop Token Filter :默认没有stop token/words,需通过参数
stopwords
或stopwords_path
进行指定。
如果text类型没有指定Analyzer,Standard Analyzer,前面我们已经了解了ES分析器的结构,理解它的分析器应该不在话下。Unicode文本分割算法依据的标准,给出了文本中词组、单词、句子的默认分割边界。该附件在notes中提到,像类似中文这种复杂的语言,并没有明确的分割边界,简而言之就是说,中文并不适用于这个标准。
通常我们的全文检索使用场景都是针对中文的,所以我们在创建我们的映射关系时,一定要指定合适的分析器。
keyword 类型默认analyzer:Keyword Analyzer
Keyword Analyzer本质上就是一个"noop" Analyzer,直接将输入的内容作为一整个token。
第三方中文分词器 ik
github地址:https://github.com/medcl/elasticsearch-analysis-ik
IK Analyzer是一个开源的,基于java语言开发的轻量级的中文分词工具包。从2006年12月推出1.0版开始, IKAnalyzer已经推出了4个大版本。最初,它是以开源项目Luence为应用主体的,结合词典分词和文法分析算法的中文分词组件。从3.0版本开始,IK发展为面向Java的公用分词组件,独立于Lucene项目,同时提供了对Lucene的默认优化实现。在2012版本中,IK实现了简单的分词歧义排除算法,标志着IK分词器从单纯的词典分词向模拟语义分词衍化。
使用方式:
// mapping创建
PUT /[your index]
{
"mappings": {
"properties": {
"text_test":{
"type": "text",
"analyzer": "ik_smart"
}
}
}
}
// 新建document
POST /[your index]/_doc
{
"text_test":"我爱中国"
}
//查看term vector
GET /[your index]/_termvectors/ste3HYABZRKvoZUCe2oH?fields=text_test
//结果包含了 “我”“爱”“中国”
相似性得分 similarity
classic:基于TF/IDF实现,V7已禁止使用,V8彻底废除(仅供了解)
TF/IDF介绍文章:https://zhuanlan.zhihu.com/p/31197209
TF/IDF使用逆文档频率作为权重,降低常见词汇带来的相似性得分。从公式中可以看出,这个相似性算法仅与文档词频相关,覆盖不够全面。例如:缺少文档长度带来的权重,当其他条件相同,“王者荣耀”这个查询关键字同时出现在短篇文档和长篇文档中时,短篇文档的相似性其实更高。
在ESV5之前,ES使用的是Lucene基于TF/IDF自实现的一套相关性得分算法,如下所示:
score(q,d) =
queryNorm(q)
· coord(q,d)
· ∑ (
tf(t in d)
· idf(t)²
· t.getBoost()
· norm(t,d)
) (t in q)
- queryNorm:query normalization factor 查询标准化因子,旨在让不同查询之间的相关性结果可以进行比较(实际上ES的tips中提到,并不推荐大家这样做,不同查询之间的决定性因素是不一样的)
- coord:coordination factor 协调因子,query经过分析得到的terms在文章中命中的数量越多,coord值越高。 例如:查询“王者荣耀五周年”,terms:“王者”、“荣耀”、“五周年”,同时包含这几个term的文档coord值越高
- tf:词频
- idf:文档逆频率
- boost:boost翻译过来是增长推动的意思,这里可以理解为一个支持可配的加权参数。
- norm:文档长度标准化,内容越长,值越小
Lucene已经针对TF/IDF做了尽可能的优化,但是有一个问题仍然无法避免:
- 词频饱和度问题,如下图所示,TF/IDF算法的相似性得分会随着词频不断上升。 在Lucene现有的算法中,如果一个词出现的频率过高,会直接忽略掉文档长度带来的权重影响。
另一条曲线是BM25算法相似性得分随词频的关系,它的结果随词频上升而趋于一个稳定值。
BM25:默认
BM25介绍文章:https://en.wikipedia.org/wiki/Okapi_BM25 ,对BM25的实现细节我们在这里不做过多阐述,主要了解一下BM25算法相较于之前的算法有哪些优点:
- 词频饱和 不同于TF/IDF,BM25的实现基于一个重要发现:“词频和相关性之间的关系是非线性的”。 当词频到达一定阈值后,对相关性得分的影响是相同的,此时应该由其他因素的权重决定得分高低,例如之前提到的文档长度
- 将文档长度加入算法中 相同条件下,短篇文档的权重值会高于长篇文档。
- 提供了可调整的参数
我们在查询过程可以通过设置 "explain":true
查看相似性得分的具体情况
GET /[your index]/_search
{
"explain": true,
"query": {
"match": {
"describe": "测试"
}
}
}
//简化版查询结果
{
"_explanation": {
"value": 0.21110919,
"description": "weight(describe:测试 in 1) [PerFieldSimilarity], result of:",
"details": [
{
"value": 0.21110919,
"description": "score(freq=1.0), computed as boost * idf * tf from:",
"details": [
{
"value": 2.2,
"description": "boost",
"details": []
},
{
"value": 0.18232156,
"description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
"details": [...]
},
{
"value": 0.5263158,
"description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
"details": [...]
}
]
}
]
}
}
boolean
boolean相似性非常好理解,只能根据查询条件是否匹配,其最终值其实就是query boost值。
query and filter context
- filter Does this document match this query clause? filter只关心是/否,根据你过滤条件给你筛选出默认的文档
- query how well does this document match this query clause? query的关注点除了是否之外,还关注这些文档的匹配度有多高
他们本质上的区别是是否参与相关性得分。在查询过程中,官方建议可以根据实际使用情况配合使用 filter
和 query
。但是如果你的查询并不关心相关性得分,仅关心查询到的结果,其实两者差别不大。
题主本来以为使用filter可以节省计算相似性得分的耗时,但是使用filter同样会进行相似性得分,只是通过特殊的方式将其value置为了0。
//only query
GET /[your index]/_search
{
"explain": true,
"query": {
"bool": {
"must": [
{"match": {"describe": "测试"}},
{"term": {"tab_id": 5}}
]
}
}
}
//简化_explanation结果
{
"_explanation": {
"value": 1.2111092,
"description": "sum of:",
"details": [
{
"value": 0.21110919,
"description": "weight(describe:测试 in 1) [PerFieldSimilarity], result of:"
},
{
"value": 1,
"description": "tab_id:[5 TO 5]",
"details": []
}
]
}
}
//query+filter
GET /[your index]/_search
{
"explain": true,
"query": {
"bool": {
"filter": [
{"term": {"tab_id": "5"}}
],
"must": [
{"match": {"describe": "测试"}}
]
}
}
}
//简化_explanation结果
{
"_explanation": {
"value": 0.21110919,
"description": "sum of:",
"details": [
{
"value": 0.21110919,
"description": "weight(describe:测试 in 1) [PerFieldSimilarity], result of:"
},
{
"value": 0,
"description": "match on required clause, product of:",
"details": [
{
"value": 0,
"description": "# clause",
"details": []
},
{
"value": 1,
"description": "tab_id:[5 TO 5]",
"details": []
}
]
}
]
}
}
排序sort
在执行ES查询时,默认的排序规则是根据相关性得分倒排,针对非全文索引字段,可以指定排序方式,使用也非常简单。
//查询时先根据tab_id降序排列,若tab_id相同,则根究status升序排列
GET /[your index]/_search
{
"sort": [
{"tab_id": {"order": "desc"}},
{"status": {"order": "asc"}}
]
}
好坑啊:缺失数值类字段的默认值并不是0
事情的背景
题主使用的编程语言是golang,通常使用pb定义结构体,生成对应的go代码,默认情况下,结构体字段的json tag都会包含 omitempty
属性,也就是忽略空值,如果数字类型的value为0,进行json marshall时,不会生成对应字段。
事情的经过
刚好题主通过以上方式进行文档变更,所以实际上如果某个数值字段为0,它并没有被存储。
在题主的功能逻辑里,刚好需要对某个数值字段做升序排列,惊奇地发现我认为的字段值为0的文档,出现在了列表最末。
事情的调查结果
针对缺失数值类字段的默认值并不是0,ES默认会保证排序字段没有value的文档被放在最后,默认情况下:
- 降序排列,缺失字段默认值为该字段类型的最小值
- 升序排列,缺失字段默认值为该字段类型的最大值
好消息是,ES为我们提供了 missing
参数,我们可以指定缺失值填充,但是它太隐蔽了?,其默认值为 _last
。
GET /[your index]/_search
{
"sort": [
{"num": {"order": "asc"}}
]
}
//简化结果
{
"hits": [
{"sort": [1]},
{"sort": [9223372036854775807]},
{"sort": [9223372036854775807]}
]
}
GET /your_index/_search
{
"sort": [
{"num": {"order": "desc"}}
]
}
//简化结果
{
"hits": [
{"sort": [1]},
{"sort": [-9223372036854775808]},
{"sort": [-9223372036854775808]}
]
}
// with missing
GET /[your index]/_search
{
"sort": [
{
"num": {
"order": "asc",
"missing": "0"
}
}
]
}
//简化结果
{
"hits": [
{"sort": [0]},
{"sort": [0]},
{"sort": [1]}
]
}
使用技巧:用function score实现自定义排序
不知道大家是否遇到过类似的场景:期望查询结果按照某个类型进行排序,或者查询结果顺序由多个字段的权重组合决定。
具体解决方案需要根据业务具体情况而定,这里给出一种基于ES查询的解决方案。ES为我们提供了 function score
,支持自定义相关性得分score的生成方式,部分参数介绍:
- weight:权重值
- boost:加权值
- boost_mode:加权值计算方式(默认为multiple)
- score_mode:得分计算方式(默认为multiple)
举点实际的栗子,假设咱们有一个存放水果的Index:
- 简单一点的case:查询结果根据水果类型苹果,梨优先 苹果的优先级高于梨的优先级,梨的优先级高于其他水果的优先级。我们可以定义梨的权重为1,苹果的权重为2
GET /fruit_test/_search
{
"explain": true,
"query": {
"function_score": {
"functions": [
{
"filter": {"term": {"type": "pear"}},
"weight": 1
},
{
"filter": {"term": {"type": "apple"}},
"weight": 2
}
],
"boost": 1,
"score_mode": "sum"
}
}
}
- 复杂一点的case(别问我是怎么想到的):
- 优先级一:根据水果是否有货排序,有货的排前面,无货的过滤掉
- 优先级二:根据水果是否预售排序,非预售优先展示
- 优先级三:根据水果类型苹果,梨优先展示
- 优先级四:根据水果颜色红色,绿色优先展示
- 优先级五:根据价格升序排序 我们根据优先级顺序定义每个条件的权重,指定自定义相关性得分规则后,在
sort
中指定先根据_score
降序排列,再根据价格升序排列。 - 优先级四:绿色权重 1 、红色权重 2
- 优先级三:梨权重 3 、苹果权重 4
- 优先级二:预售权重 7(优先级四max + 优先级三max = 6,优先级二的权重必须大于这个值)
- 优先级一:直接将无货水果过滤
GET /fruit_test/_search
{
"query": {
"function_score": {
"query": {"range": {"stock": {"gt": 0}}
},
"functions": [
{
"filter": {"term": {"color": "green"}},
"weight": 1
},
{
"filter": {"term": {"color": "red"}},
"weight": 2
},
{
"filter": {"term": {"type": "pear"}},
"weight": 3
},
{
"filter": {"term": {"type": "apple"}},
"weight": 4
},
{
"filter": {"term": {"pre_sale": false}},
"weight": 7
}
],
"boost": 1,
"boost_mode": "sum",
"score_mode": "sum"
}
},
"sort": [
{"_score": {"order": "desc"}},
{"price_per_kg": {"order": "asc"}
}
]
}
聚合aggs
聚合操作可以帮助我们将查询数据按照指定的方式进行归类。常见的聚合方式,诸如:max、min、avg、range、根据term聚合等等,这些都比较好理解,功能使用上也没有太多疑惑,下面主要介绍题主在使用过程中遇到的坑点以及指标聚合嵌套查询。
ES还支持pipline aggs,主要针对的对象不是文档集,而是其他聚合的结果,感兴趣的同学可以自行了解。
好坑啊:ES默认的时间格式为毫秒级时间
如果你有诉求,需要针对秒级时间戳进行时间聚合,例如:某销售场景下,我们期望按小时/天/月/进行销售单数统计。
那么有以下两种常见错误使用方式需要规避:
- 如果在创建
date
类型字段,但是没有指定时间format格式,并且以秒级时间戳赋值(直接以年月日赋值没有问题) 根据时间聚合将无法解析出正确的数据,时间会被解析为1970年 - 如果直接使用
numberic
类型,例如integer
存储时间戳 不管是秒级还是毫秒级,都无法被正确识别
正确的做法:创建mapping,明确指定时间的格式为秒级时间戳。
PUT /date_test/_mapping
{
"properties":{
"create_time":{
"type":"date",
"format" : "epoch_second"
}
}
}
//以年为时间间隔 进行统计
GET /date_test/_search
{
"size": 0,
"aggs": {
"test": {
"date_histogram": {
"field": "create_time",
"format": "yyyy",
"interval": "year"
}
}
}
}
//从查询结果可以看出来,实际计算时ES会帮我们把秒级时间戳转成毫秒级时间戳
{
"aggregations" : {
"test" : {
"buckets" : [
{
"key_as_string" : "2018",
"key" : 1514764800000,
"doc_count" : 2
},
{
"key_as_string" : "2019",
"key" : 1546300800000,
"doc_count" : 0
},
{
"key_as_string" : "2020",
"key" : 1577836800000,
"doc_count" : 3
}
]
}
}
}
聚合嵌套查询
上面介绍了根据时间聚合,还是以刚刚的例子来说,某销售场景下,我们期望在根据时间统计销售单数的同时,统计出时间区间内的销售总金额。
GET /date_test/_search
{
"size": 0,
"aggs": {
"test": {
"date_histogram": {
"field": "create_time",
"format": "yyyy",
"interval": "year"
},
"aggs": {
"sum_profit": {
"sum": {
"field": "profit"
}
}
}
}
}
}
{
"aggregations" : {
"test" : {
"buckets" : [
{
"key_as_string" : "2018",
"key" : 1514764800000,
"doc_count" : 2,
"sum_profit" : {
"value" : 200.0
}
},
{
"key_as_string" : "2019",
"key" : 1546300800000,
"doc_count" : 0,
"sum_profit" : {
"value" : 0.0
}
},
{
"key_as_string" : "2020",
"key" : 1577836800000,
"doc_count" : 3,
"sum_profit" : {
"value" : 3000.0
}
}
]
}
}
}
使用技巧:自实现distinct
ES默认并不支持distinct,可以尝试使用 terms
聚合,解析结果中的key
{
"aggregations" : {
"test" : {
"doc_count_error_upper_bound" : 0,
"sum_other_doc_count" : 0,
"buckets" : [
{"key" : "1","doc_count" : 2},
{"key" : "10","doc_count" : 2},
{"key" : "16","doc_count" : 2}
]
}
}
}
索引别名、索引生命周期策略、索引模版
- Aliases 索引别名 索引别名,顾名思义,定义了别名之后,可以通过别名对index进行查询
PUT /[your index]/_alias/[your alias name]
- Index Lifecycle Policies 索引生命周期策略 索引生命周期策略支持我们根据天、存储量级等信息去自动管理我们的索引。 创建方式可以通过RESTful API,也可以直接在kibana上创建,题主使用的是后者,可视化界面看起来比较清晰~ 支持配置满足一定规则后索引自动变化:
- 自动滚动索引(hot)
- 保留索引仅供检索(warm)
- 保留索引仅供检索同时减少磁盘存储(cold)
- 删除索引
- Template 索引模板 通过
index_patterns
参数设置索引名正则匹配规则,向一个不存在的索引POST数据,命中索引名规则后即会根据索引模版创建索引,不会进行动态映射。
ES的一个比较常见的应用场景是存储日志流,自实现一套这样的系统就可以结合上述3个功能。
参考
https://www.elastic.co/guide/en/elasticsearch/reference/7.7/index.html
https://blog.csdn.net/laoyang360/article/details/80468757
https://www.elastic.co/guide/en/elasticsearch/reference/7.0/query-filter-context.html
https://zhuanlan.zhihu.com/p/31197209