ElasticSearch(下文简称ES)是一个分布式的开源搜索和分析引擎,底层基于Lucene,适用多种数据类型,对外提供简单易用的 Restful 风格的 API 。 lucene是一个开源搜索工具包,基于java编写,其主要功能就是索引和搜索
第一想到的场景就是各大搜索引擎,底层可能或多或少有ES的身影。
除此之外,在互联网领域,ES的主要应用场景还包括:
ELK 是一个由 Elasticsearch、Logstash 和 Kibana 组成的开源数据分析和搜索解决方案。
先贴两张图,在阐述概念前让大家对ES的架构有个整体了解,并建立一个大体的认知。
文档是 Lucene 索引和搜索的原子单位,每个文档是一条实例数据,通常使用JSON数据结构表示。一个doc里面有多个field,每个field就是一个数据字段。类比关系型数据库,doc相当于row,field相当于column。 相对关系型数据库的行式存储,ES是面向文档的,它更接近文档存储和倒排索引的结合,更适合全文搜索和分布式数据处理。
// doc举例 // user信息 { "email": "john@bytedance.net", "first_name": "John", "last_name": "Smith", "private_info": { "age": 25, "interests": ["work", "play"] }, "join_date": "20240607" }
索引是指存储相似结构的文档数据集合,类似于传统关系数据库中的一个数据库,每个文档都属于一个索引。比如,在商品索引中我们可以存放成千上万的商品信息;在订单索引中我们可以存放成千上万的订单信息。每个索引有一个映射(mapping),它定义了索引中包含的所有字段及其数据类型。
Mapping 是定义文档及其包含的字段如何存储和索引的过程。文档的字段类型是通过映射(Mapping)跟字段进行一一对应的,类似于关系型数据库中表的schema。映射还可以指定每个字段的分析器(analyzer),用来在建立索引前对文本进行预处理。
{ "user": { "mappings": { "email": { "type": "string", }, "first_name": { "type": "string", }, "last_name": { "type": "string" }, "info": { "type": "object", "properties": { "bio": { "type": "string" }, "age": { "type": "short" }, "interests": { "type": "object" }, } } } } }
Settings 用来配置索引、节点和集群行为的参数集合,控制着各种不同的行为和特性,决定了索引如何被存储、如何被搜索和如何被管理。Settings 用于控制索引的行为和特性,包括两种类型:静态设置(static settings)和动态设置(dynamic settings)。
一个ES集群是由多个节点(Node)组成的,每个集群都有一个cluster name 作为标识。
一个 ES 节点是集群中的基本单元,是运行在单个物理或虚拟机器上的一个实例,但一个实例也可以运行多个节点。每个节点都是一个独立的服务实例,它可以存储数据、执行数据操作(如索引和搜索)以及参与集群中的协调任务。
节点按承担的角色可以分为以下几种:
当一个索引需要存储大量数据。比如,我们需要一个存储10亿文档数据的索引,单一节点都没有这样大的磁盘空间。而且都放在一个节点中不仅查询以及数据写入的速度会很慢,也存在单点问题。在传统关系型数据库中,采用分库分表的方式,用更多的数据库实例来承接大量的数据存储。那么在 ES 中,也是采取类似的设计思想,用多个实例来进行存储。在每个实例中存在的数据集合就是分片。
ES 提供了将索引划分成多份的能力,这些份就叫做分片(每个分片就是一个Lucene实例),索引内的数据并不是存储在连续的物理空间上,而是被分片存储,分片会被均匀的分布到不同节点上,以保证负载均衡。从 Lucene 的角度来看,每个分片本身也是一个功能完善并且独立的索引,这个索引可以被放置到集群中的任何节点上。
按类型可以分为主分片和副本分片。一个副本分片只是一个主分片的拷贝。主分片和副本分片不能同时存在于同一个节点上。主分片的数量在创建索引时确定,不可增加;副本分片复制主分片的数据,可以增加。 副本分片存在的意义是:
如下图,即使Node2和Node3挂掉了,依然不会对数据完整性和服务稳定性有太大影响,因为Node1上保存了完整的索引数据
索引的文档在插入的时候被分配到某个主分片上,shard = hash(routing) % number_of_primary_shards
,(routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值)再被复制到副本分片上。查询时,根据上面的公式算出文档数据哪个分片,再去从该分片(或对应副本分片)的节点上取回该文档。
Segment 是索引数据的存储单元,是每次写请求产生的基本单元,也是搜索和查询的基本单位,每个索引会被分成多个 Segment 来存储数据,Segment 之间是独立的。这些 Segment 是不可变的,一旦创建就不会被修改,可以通过创建新的 Segment 来更新索引数据。
当执行搜索查询时,Elasticsearch 会将查询分发到各个 Segment 上,并行执行搜索操作。这种并行处理可以显著提高查询性能,特别是在大型索引中。另一方面,ES支持实时索引更新,即可以在不停止服务的情况下,动态添加、更新和删除文档,这是通过将新文档写入新的 Segment,并在后台执行段合并(merge)操作来实现的。这些合并操作将多个小的 Segment 合并成一个较大的 Segment,减少了索引的碎片化和存储空间的占用。
Segment 和 Document 之间的关系:
我们都知道索引存在的意义就是为了加速数据的查询。在关系型数据库中如果没有索引的话,为了查找数据我们需要每条数据去进行比对,可能需要扫描全表才能查找到想要的数据。以 Mysql 为例,它使用了 B+树作为索引结构来加速数据的查询。那在全文搜索场景,用 B+树作为索引行不行呢?从空间上来说 B+ 树不适合作为全文索引, B+ 树每次搜索都是从根节点开始往下搜索,所以会遵循最左匹配原则,而我们使用全文搜索时,往往不会遵循最左匹配原则,所以可能会导致索引失效。这时候倒排索引就派上用场了,正排索引就像书中的目录一样,根据页码查询内容,倒排索引却是相反的,它是通过对内容的分词,建立内容到文档 ID 的关联关系。这样在进行全文检索的时候,根据词典的内容便可以精确以及模糊查询。
B+树更适合范围查询和排序操作,但在处理全文搜索时效率较低,因为需要扫描大量节点来找到匹配的文档。
除此之外,倒排索引还有一些其它优势
在没有搜索引擎时,我们是直接输入一个网址,然后获取网站内容,这时我们的行为是: url -> document -> to -> words 通过文章,获取里面的单词,这种称为forward index「正向索引」 后来,我们希望能够输入一个单词,找到含有这个单词或者和这个单词有关系的文章: word -> to -> documents 这种索引称为inverted index,国内翻译成「倒排索引」。
倒排索引建立的是分词(Term)和文档(Document)之间的映射关系,用于快速查找包含特定词语(term,分词后的最小粒度)的文档列表,实现了term到doc list的映射。在倒排索引中,数据是面向词语(Term)而不是面向文档的。 如下图,倒排索引的基本思想是将文档集合中的每个词语映射到包含该词语的文档列表。
倒排索引包含了三种数据,分别是
每个词项在倒排表中都有对应的列表(Posting List),列出了包含该词项的所有文档及其出现位置信息。倒排表使得根据单个词项快速找到所有包含该词项的文档成为可能,是全文搜索的基础。
举例,假设有以下文档集合:
文档1: "Elasticsearch is a search engine" 文档2: "It is built for scale and speed" 文档3: "Search engine like Elasticsearch is powerful"
在我们建立的倒排索引中, "Elasticsearch" 的倒排表可能类似下面这样:
Elasticsearch: 文档1: [1] 文档3: [6]
这里的 [1] 和 [6] 分别表示在文档1和文档3中的位置,词项 "Elasticsearch" 出现在文档1的第一个位置和文档3的第六个位置。
词项字典记录了所用文档的单词以及单词和倒排列表的关系。它存储了所有文档中出现过的词项及其对应的统计信息:包括词频(term frequency, TF)和逆文档频率(inverse document frequency, IDF)等。根据词项查询可以快速定位到对应的倒排表。 举例,假设词项字典中的一个条目可能如下所示:
Elasticsearch: { docFreq: 2, // 出现该词项的文档数量 totalTermFreq: 3 // 该词项在所有文档中的总出现次数 }
这里的 docFreq 表示 "Elasticsearch" 出现在了2个文档中,而 totalTermFreq 则表示总共出现了3次。
为了加快检索,需要将term dictionary放入到内存中,但如果将所有的term都放入到内存中,内存肯定是放不下的。为了节约内存,同时能快速查找到term,lucene对term dictionary构建了索引(term index)。
词项索引是词项字典的索引,它包含了词项的有序列表和对应的偏移量,用于快速定位词项字典中的条目。在查询时,ES会先通过Term Index找到查询语句中的每个term对应的termId和在term dictionary中的偏移量,然后从term dictionary中获取每个term对应的posting list,最终计算出每个文档与查询词的相关度,并按相关度排序返回最匹配的结果给用户。
相比term dictionary,index的优化体现在:
举例,假设词项索引中的部分内容如下所示:
like: 2085 powerful: 3092
这里的数字是词项在词项字典中的偏移量,表示可以从这里开始直接访问词项字典中的对应条目。
在Lucene中,Term index是基于 FST(Finite State Transducer,有限状态转换机)构建的,FST是一种从Trie树(前缀匹配)演化而来的架构,不仅保留了Trie树的高效匹配特性,还通过后缀共享节约了存储空间,实现了更高效的存储和检索。
例如,存在一颗Trie树,对于"ABC"和"ABD"这两个term,它们有共同前缀"AB"。这样一定程度上节约了空间,但会存在另一个问题。
再举个例子,当插入一个新的词项"tasted"到树中的时候,term会生成下面的结构,我们可以看到ted这个字符之前就已经存在term index中,tasted的插入使ted出现了重复,数据的重复势必导致空间浪费。随着term的数量越来越庞大,trie 树中字符重复的问题及对应的空间就特别庞大了,因此,ES使用了FST数据结构来压缩优化term index数据。
FST是将term拆成单个字母存在节点上,每个节点存一个字母,根节点不存,从根节点出发到特定节点所经过的字母就可以组成目标term,其经过路径的权重和就是该term在Term Dictionary中对应的位置
总结
在两次fsync之间,segments是保存在文件系统的缓存中的,这是不安全的。如果发生断电等,就会造成文档丢失。所以ES引入了translog来记录两次fysnc之间所有的操作,这样机器重启就会根据translog进行还原。类似于mysql中的redolog。不过,translog也是文件,也会先存放在系统的缓存中。如果发生断电关机,同样会造成translog的丢失。ES默认会间隔5秒钟(通过sync_interval配置)将translog落盘。如果丢失了,也只是丢失5秒内的数据。当然也可以通过配置将其修改为每次都fsync到磁盘里面,但是这样性能比较差。
还有值得一提的是,每秒生成一个Segment,时间一长就会积累大量的Segment。在查询时,需要遍历所有分片内的Segment,这会显著降低搜索效率。因此,ES会执行segment合并操作,将多个小的Segment合并成一个较大的Segment。
具体来说,合并的过程是创建一个新的Segment,将多个小Segment的内容逐个复制到新的Segment中,然后将新的Segment写入硬盘。被合并的小Segment随后会被删除。在这个过程中,标记为删除状态的Document会被跳过,不会被复制到新的Segment中。这种合并机制有助于提高查询效率,因为减少了需要遍历的Segment数量。合并后的大Segment更容易被索引和搜索,从而提升了整体性能。此外,跳过已删除的Document也有助于节省存储空间和提高检索速度。这种Segment合并策略是ES在处理大规模数据和高频率查询时保持高效性能的关键技术之一。
查询分为Get和Search。Get请求可以理解为通过doc_id查找到对应的文档,此时走的是正排索引。Query请求则是根据关键词进行搜索,会先走倒排索引得到doc_id,再通过doc_id走正排索引。
协调节点获取某个doc_id的请求之后,会经过路由算法分配给对应分片,此时主副分片都可以处理请求。查询出对应的文档之后会返回给协调节点,然后协调节点再把这个结果返回给客户端。
Node1
发送获取请求。_id
来确定文档属于分片0 。分片0的副本分片存在于所有的三个节点上。在这种情况下,它将请求转发到 Node2
。Node2
将文档返回给 Node1
,然后将文档返回给客户端。具体来看:
shardID = hash(id) % number_of_shards
。整个流程一般分为两阶段查询,第一阶段Query查询到匹配的doc_id,第二阶段Fetch再查询doc_id对应的完整文档,这种在ES 中称为query_then_fetch,还有一种是一阶段查询的时候就返回完整doc,在ES 中称作query_and_fetch,一般第二种适用于只需要查询一个Shard的请求。一个基本的Search流程包括
search
请求到 Node3
, Node3
会创建一个大小为 from + size
的空优先队列。Node3
将查询请求转发到索引的每个主分片或副本分片中。每个分片在本地执行查询并添加结果到大小为 from + size
的本地有序优先队列中。Node3
,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。当一个搜索请求被发送到某个节点时,这个节点就变成了协调节点,上例中就是Node 3。 这个节点的任务是广播查询请求到所有相关分片并将它们的响应整合成全局排序后的结果集合,这个结果集合会返回给客户端。第一步是广播请求到索引中每一个节点的分片拷贝。 查询请求可以被某个主分片或某个副本分片处理, 这就是为什么更多的副本(当结合更多的硬件)能够增加搜索吞吐率。 协调节点将在之后的请求中轮询所有的分片拷贝来分摊负载。
ES提供了一个基于JSON的Query DSL来定义查询语法,通过search API在HTTP请求体中发送查询表达式,共由两种语句格式组成。
// 格式:query 是Query DSL GET /<index>/_search { "query": {<parameters>} }
用于将查询字符串和文档的一个字段或多个字段匹配,比如 match
、term
、range
查询。
常见的查询:
gt
、gte
、lt
、lte
。复合子句封装了其它的叶子子句和复合子句,从而组合了多个查询。最常用的组合查询是布尔查询(Boolean query),参数如下:
举例,假设我们有一个电影数据库,包含电影名称、导演、发布日期和评分等字段。我们希望构建一个查询,要求同时满足以下条件:
查询的dsl如下
{ "query": { "bool": { "must": [ { "match": { "title": "action" } } ], "filter": [ { "range": { "rating": { "gte": 8 } } } ] } }, "sort": [ { "release_date": { "order": "desc" } } ], "size": 10 }
分词就是把文本转换成一系列单词(term/token)的过程。
在 ES 中,Analysis 是通过分词器(Analyzer) 来实现的。当查询query的时候,ES 会根据字段的搜索类型决定是否对query进行analyze,然后和倒排索引中的term进行相关性查询,匹配相应的文档。
示例
文本:The quick brown Fox jumps over the lazy Dog
分词结果:"the", "quick", "brown", "fox", "jumps", "over", "the", "lazy", "dog"
由三部分组成。
Analyzer 这三个部分是有顺序的,从上到下依次经过 Character Filters,Tokenizer 以及 Token Filters。
一般内置的分词器有下面这些
它可以通过以下三种方式来查看分词器是怎么样工作的:
POST _analyze { "analyzer": "standard", "text" : "The quick brown Fox jumps over the lazy Dog" }
POST /{index}/_analyze { "field": "{field_name}", "text": "{text_to_analyze}" } // 假设有一个索引名为task_meta,其中有一个字段名为title,你想对文本进行分词分析 POST task_meta/_analyze { "field": "title", "text": "The quick brown Fox jumps over the lazy Dog" }
可以指定分词器(tokenizer)、字符过滤器(char_filters)和标记过滤器(token_filters)来进行更细粒度的控制
POST /_analyze { "tokenizer": "standard", "filter": ["lowercase"], "text": "The quick brown Fox jumps over the lazy Dog" }
standard 是默认的分析器。它提供了基于语法的标记化(基于Unicode文本分割算法),适用于大多数语言
GET _analyze { "analyzer": "standard", "text": "The quick brown Fox jumps over the lazy Dog" }
运行结果如下:
{ "tokens": [ { "token": "the", "start_offset": 0, "end_offset": 3, "type": "<ALPHANUM>", "position": 0 }, { "token": "quick", "start_offset": 4, "end_offset": 9, "type": "<ALPHANUM>", "position": 1 }, { "token": "brown", "start_offset": 10, "end_offset": 15, "type": "<ALPHANUM>", "position": 2 }, { "token": "fox", "start_offset": 16, "end_offset": 19, "type": "<ALPHANUM>", "position": 3 }, { "token": "jumps", "start_offset": 20, "end_offset": 25, "type": "<ALPHANUM>", "position": 4 }, { "token": "over", "start_offset": 26, "end_offset": 30, "type": "<ALPHANUM>", "position": 5 }, { "token": "the", "start_offset": 31, "end_offset": 34, "type": "<ALPHANUM>", "position": 6 }, { "token": "lazy", "start_offset": 35, "end_offset": 39, "type": "<ALPHANUM>", "position": 7 }, { "token": "dog", "start_offset": 40, "end_offset": 43, "type": "<ALPHANUM>", "position": 8 } ] }
其中 token 为分词结果;start_offset 为起始偏移;end_offset 为结束偏移;position 为分词位置。
在中文句子中,不能简单地切分成一个个的字,而是需要分成有含义的词,而且在不同的上下文,是有不同的理解的。如果我们还用standard分词器去进行分词,可能会出现预期之外的结果。 示例
GET _analyze { "analyzer": "standard", "text": "我是中国人,喜欢吃新疆菜" }
运行结果如下:
{ "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": 3, "end_offset": 4, "type": "<IDEOGRAPHIC>", "position": 3 }, { "token": "人", "start_offset": 4, "end_offset": 5, "type": "<IDEOGRAPHIC>", "position": 4 }, { "token": "喜", "start_offset": 6, "end_offset": 7, "type": "<IDEOGRAPHIC>", "position": 5 }, { "token": "欢", "start_offset": 7, "end_offset": 8, "type": "<IDEOGRAPHIC>", "position": 6 }, { "token": "吃", "start_offset": 8, "end_offset": 9, "type": "<IDEOGRAPHIC>", "position": 7 }, { "token": "新", "start_offset": 9, "end_offset": 10, "type": "<IDEOGRAPHIC>", "position": 8 }, { "token": "疆", "start_offset": 10, "end_offset": 11, "type": "<IDEOGRAPHIC>", "position": 9 }, { "token": "菜", "start_offset": 11, "end_offset": 12, "type": "<IDEOGRAPHIC>", "position": 10 } ] }
显然,这样的效果是不符合预期的,所以,我们要使用中文分词器,比较推荐的是 IK分词器,当然也有些其它的比如 smartCN、HanLP。IK分词器 支持自定义词库,支持热更新分词字典。
示例
GET _analyze { "analyzer": "ik_smart", "text": "我是中华人民共和国人,喜欢吃新疆菜" }
运行结果如下:
{ "tokens": [ { "token": "我", "start_offset": 0, "end_offset": 1, "type": "CN_CHAR", "position": 0 }, { "token": "是", "start_offset": 1, "end_offset": 2, "type": "CN_CHAR", "position": 1 }, { "token": "中华人民共和国", "start_offset": 2, "end_offset": 9, "type": "CN_WORD", "position": 2 }, { "token": "人", "start_offset": 9, "end_offset": 10, "type": "CN_CHAR", "position": 3 }, { "token": "喜欢吃", "start_offset": 11, "end_offset": 14, "type": "CN_WORD", "position": 4 }, { "token": "新疆", "start_offset": 14, "end_offset": 16, "type": "CN_WORD", "position": 5 }, { "token": "菜", "start_offset": 16, "end_offset": 17, "type": "CN_CHAR", "position": 6 } ] }
每个文档都有相关性评分,是衡量每个文档与输入查询匹配的程度。用一个正浮点数字段 _score 来表示,评分越高,相关性越高。不同的查询类型会有不同的相关度计算方式。
默认情况下,查询返回的结果是按照相关性倒序排列的,除了可以人工的设置权重boost干预得分和排序结果,也存在一套非常基础的相关性得分算法。
检索词频率/反向文档频率,简称TF-IDF(Term Frequency–Inverse Document Frequency)是一种用于信息检索与文本挖掘的常用加权算法。它是一种统计方法,用以评估一字词对于一个文件集或一个语料库中的其中一份文件的重要程度。字词的重要性随着它在文件中出现的次数成正比增加,但同时会随着它在语料库中出现的频率成反比下降。
其中,分子是该词出现在文档中的次数,分母则是文档中出现该词的所有次数之和。
其中,分子表示文档集合中的文件总数,分母中包含标识词语ti的文件数目,为了避免该词在文档中完全没有出现过,因此分母中通过 +1 确保了不会为0。
举例 假如一篇文件的总词语数是1000个,而词语“上海”出现了10次,那么“上海”一词在该文件中的词频就是 𝑡𝑓𝑖 = 10/1000 = 0.01 “上海”一词在100份文档出现过,而文档总数是100000份,其逆向文件频率就是 𝑖𝑑𝑓𝑖 = lg(100000 / 100)= 3 最后分数为 𝑡𝑓𝑖𝑑𝑓𝑖 = 𝑡𝑓𝑖 × 𝑖𝑑𝑓𝑖 = 0.01×3 = 0.03
BM25 的核心思想是基于词频-逆文档频率(TF-IDF)模型,并结合了文档长度归一化和词频饱和等改进。目前ES使用的默认算分算法是BM25,公式如下
其中,IDF(_q_𝑖)是词_q_的逆文档频率,𝑓(_q_𝑖, D) 是词 _q _在文档 _D _中的词频,_D_是文档的长度,avgdl是文档集合的平均文档长度。k1和b是调节参数,通常k1在1.2到2之间,b在0.75左右。
相对于 TF-IDF 来说,BM25 降低了 TF 对算分的影响力。当 TF 无限增加时,TF-IDF 模型的算分也会无限增加,而 BM25 则会趋于一个值。如果不考虑文档长度的影响(|D|/avgdl = 1 了),BM25 的 TF 算分的公式为:(tf * (k1 + 1)/ (tf + k1)) ,参考 BM25 算分公式的后半部分。
如上图,当 TF 无限大的时候,BM25 的 TF 算分结果会无限逼近于(k1 + 1),虽然 TF 越大意味着这个词项相关性更大,但很快其影响力就下降了,收益递减。而 Lucene 的 TF-IDF 中词频的相关性会一直增加,算分也一直增加。 经过对比,可以发现,k1 的作用是用来调节词频的影响力的。如果 k1 = 0,那么公式的后半部分 (tf * (k1 + 1)/ (tf + k1))= 1,即不再考虑词频的影响了。而 k1 为较大值时,(tf * (k1 + 1)/ (tf + k1))会与 tf 保持线性增长。 那 b 这个参数的意义到底是什么。我们令 L = |D|/avgdl,那么公式的分母部分可以表示为:tf(t in d) + k1 * (1 - b + b * L),可以看到,b 控制了文档篇幅对于得分的影响程度,当 b = 0 的时候,文档长度将不影响算分,而当 b = 1 的时候,将不起调节作用。
本文作者:sora
本文链接:
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!