编辑
2024-05-08
服务端
0
请注意,本文编写于 198 天前,最后修改于 112 天前,其中某些信息可能已经过时。

目录

ElasticSearch 是什么?
使用场景
特性
核心概念
文档/Document
索引/Index
映射/ Mapping
设置/Settings
集群/Cluster
节点/Node
分片/Shard
段落/segment
倒排索引
为什么采用倒排索引而不用B+树?
倒排索引是什么?
倒排表(Posting List)
词项字典(Term Dictionary)
词项索引(Term Index)
Term Index算法
文档的操作原理
写入
宏观
微观
查询
Get
宏观
微观
Search
宏观
微观
DSL
叶子查询子句/Leaf query clauses
复合查询子句/Compound query clauses
ES分词器
什么是 Analysis?
分词器的组成
常见分词器
Standard Analyzer
IK
相关性
ES排序算法
TF-IDF
BM25
对比

ElasticSearch 是什么?

ElasticSearch(下文简称ES)是一个分布式的开源搜索和分析引擎,底层基于Lucene,适用多种数据类型,对外提供简单易用的 Restful 风格的 API 。 lucene是一个开源搜索工具包,基于java编写,其主要功能就是索引和搜索

使用场景

第一想到的场景就是各大搜索引擎,底层可能或多或少有ES的身影。

除此之外,在互联网领域,ES的主要应用场景还包括:

  • 站内搜索、应用程序搜索
  • 日志管理、监控、数据分析(通常使用ELK套件)

ELK 是一个由 Elasticsearch、Logstash 和 Kibana 组成的开源数据分析和搜索解决方案。

  • Elasticsearch(数据存储和搜索引擎):一个分布式搜索和分析引擎,基于 Apache Lucene 构建。它主要用于存储、搜索和分析大规模数据。
  • Logstash(日志采集和处理):一个数据收集和处理引擎,能够从各种来源(如日志文件、数据库、消息队列等)收集数据,并将其传输到指定的存储目标。
  • Kibana(数据可视化和分析):一个数据可视化和分析工具,专为与 Elasticsearch 集成而设计。它提供了丰富的可视化组件和交互功能,帮助用户探索和分析数据。
  • 基础设施指标和容器监测

特性

  • 分布式,可扩展:数据分片对于应用层透明,扩展性良好,可以轻易的进行节点扩容,支持上百甚至数百的服务器节点,支持PB级别的数据存储和搜索。
  • 高可用:数据多副本、多节点存储,单节点的故障不影响集群的使用。
  • 高性能:底层构建基于Lucene,具备强大的搜索能力,即便是TB级别的数据依然能够实现秒级的搜索。

核心概念

先贴两张图,在阐述概念前让大家对ES的架构有个整体了解,并建立一个大体的认知。

文档/Document

文档是 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" }

索引/Index

索引是指存储相似结构的文档数据集合,类似于传统关系数据库中的一个数据库,每个文档都属于一个索引。比如,在商品索引中我们可以存放成千上万的商品信息;在订单索引中我们可以存放成千上万的订单信息。每个索引有一个映射(mapping),它定义了索引中包含的所有字段及其数据类型。

映射/ 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 用来配置索引、节点和集群行为的参数集合,控制着各种不同的行为和特性,决定了索引如何被存储、如何被搜索和如何被管理。Settings 用于控制索引的行为和特性,包括两种类型:静态设置(static settings)和动态设置(dynamic settings)。

  • 静态设置(Static Settings):静态设置是在创建索引时指定的,一旦索引创建后,这些设置通常不能更改。比如,主分片数量、分析器的配置等。
  • 动态设置(Dynamic Settings):动态设置可以在索引创建后通过API进行更改。比如,刷新间隔、副本数、索引存储设置等。

集群/Cluster

一个ES集群是由多个节点(Node)组成的,每个集群都有一个cluster name 作为标识。

节点/Node

一个 ES 节点是集群中的基本单元,是运行在单个物理或虚拟机器上的一个实例,但一个实例也可以运行多个节点。每个节点都是一个独立的服务实例,它可以存储数据、执行数据操作(如索引和搜索)以及参与集群中的协调任务。

节点按承担的角色可以分为以下几种:

  • Master节点:管理集群元信息,包括创建索引、删除索引;集群状态的广播同步;监听集群各节点状态
  • Data节点:负责数据的存储和相关具体操作
  • Client节点:专门用于处理客户端请求,它们不保存数据,也不参与集群状态的管理,只是将请求转发到适当的节点,这样的节点是为了提高并发。
  • Coordinating节点:协调节点是一种角色,而不是真实的ES节点,我们没有办法通过配置项来配置哪个节点为协调节点。集群中的任何节点都可以充当协调节点的角色。当收到客户端的请求时,它负责把请求转发给涉及的节点,将结果进行聚合/排序/组装后,返回给客户端。

分片/Shard

当一个索引需要存储大量数据。比如,我们需要一个存储10亿文档数据的索引,单一节点都没有这样大的磁盘空间。而且都放在一个节点中不仅查询以及数据写入的速度会很慢,也存在单点问题。在传统关系型数据库中,采用分库分表的方式,用更多的数据库实例来承接大量的数据存储。那么在 ES 中,也是采取类似的设计思想,用多个实例来进行存储。在每个实例中存在的数据集合就是分片。

ES 提供了将索引划分成多份的能力,这些份就叫做分片(每个分片就是一个Lucene实例),索引内的数据并不是存储在连续的物理空间上,而是被分片存储,分片会被均匀的分布到不同节点上,以保证负载均衡。从 Lucene 的角度来看,每个分片本身也是一个功能完善并且独立的索引,这个索引可以被放置到集群中的任何节点上。

按类型可以分为主分片和副本分片。一个副本分片只是一个主分片的拷贝。主分片和副本分片不能同时存在于同一个节点上。主分片的数量在创建索引时确定,不可增加;副本分片复制主分片的数据,可以增加。 副本分片存在的意义是:

  • 为了「高可用」:防止一个节点挂了数据丢失;如果它们位于同一节点上,就失去了意义
  • 为了「高性能」:读请求可以访问任意一个副本所在的节点

如下图,即使Node2和Node3挂掉了,依然不会对数据完整性和服务稳定性有太大影响,因为Node1上保存了完整的索引数据

索引的文档在插入的时候被分配到某个主分片上,shard = hash(routing) % number_of_primary_shards,(routing 是一个可变值,默认是文档的 _id ,也可以设置成一个自定义的值)再被复制到副本分片上。查询时,根据上面的公式算出文档数据哪个分片,再去从该分片(或对应副本分片)的节点上取回该文档。

段落/segment

Segment 是索引数据的存储单元,是每次写请求产生的基本单元,也是搜索和查询的基本单位,每个索引会被分成多个 Segment 来存储数据,Segment 之间是独立的。这些 Segment 是不可变的,一旦创建就不会被修改,可以通过创建新的 Segment 来更新索引数据。

当执行搜索查询时,Elasticsearch 会将查询分发到各个 Segment 上,并行执行搜索操作。这种并行处理可以显著提高查询性能,特别是在大型索引中。另一方面,ES支持实时索引更新,即可以在不停止服务的情况下,动态添加、更新和删除文档,这是通过将新文档写入新的 Segment,并在后台执行段合并(merge)操作来实现的。这些合并操作将多个小的 Segment 合并成一个较大的 Segment,减少了索引的碎片化和存储空间的占用。

Segment 和 Document 之间的关系:

  • Segment 是文档存储的物理单元: 文档存储在 Segment 中。当你索引文档时,ES 会将文档分配到一个或多个 Segment 中存储。每个 Segment 是一个不可变的包含一部分文档的物理文件。
  • Document 更新和删除会影响 Segment: 当文档更新或删除时,ES 并不是立即在原 Segment 上直接进行操作,而是创建一个新的 Segment,将更新后的文档存储在其中。旧的 Segment 在后续的合并过程中可能会被删除或者合并到新的 Segment 中。

倒排索引

为什么采用倒排索引而不用B+树?

我们都知道索引存在的意义就是为了加速数据的查询。在关系型数据库中如果没有索引的话,为了查找数据我们需要每条数据去进行比对,可能需要扫描全表才能查找到想要的数据。以 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):每个词语(term)对应的倒排文件包含了包含该词语的所有文档的列表,通常还包括一些额外的信息,如词频(term 在文档中出现的次数,用来计算相关性评分)、位置等。每条记录称为一个倒排项(Posting)。
  • 词项字典(Term Dictionary):存储所有文档中出现过的词语(term)和对应的位置信息,比如,在哪些文档中出现以及出现的位置等。
  • 词项索引(Term Index):用于存储词项字典中各个词项的有序列表或索引。它通常按照词项的字典序进行排序,并记录每个词项在词项字典中的位置或偏移量。这样设计可以快速定位到词项字典中任何词项的信息,而不需要遍历整个词项字典。

倒排表(Posting List)

每个词项在倒排表中都有对应的列表(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 Dictionary)

词项字典记录了所用文档的单词以及单词和倒排列表的关系。它存储了所有文档中出现过的词项及其对应的统计信息:包括词频(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)。

词项索引(Term Index)

词项索引是词项字典的索引,它包含了词项的有序列表和对应的偏移量,用于快速定位词项字典中的条目。在查询时,ES会先通过Term Index找到查询语句中的每个term对应的termId和在term dictionary中的偏移量,然后从term dictionary中获取每个term对应的posting list,最终计算出每个文档与查询词的相关度,并按相关度排序返回最匹配的结果给用户。

相比term dictionary,index的优化体现在:

  • 时间上的优化:term是可排序的,因此通过二分搜索,检索的时间复杂度可以大大降低。
  • 空间上的优化:只在内存里缓存Term的前缀,底层数据结构使用了FST。

举例,假设词项索引中的部分内容如下所示:

like: 2085 powerful: 3092

这里的数字是词项在词项字典中的偏移量,表示可以从这里开始直接访问词项字典中的对应条目。

Term Index算法

在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中对应的位置

总结

  • 倒排表存储了每个词项在文档中的位置信息。
  • 词项字典存储了所有文档中出现的词项及其统计信息。
  • 词项索引提供了对词项字典的快速访问。

文档的操作原理

写入

宏观

  1. 服务器接收到请求后,会判断应该落到哪个分片上,该分片的主分片在哪个节点
  2. 向主分片服务器发起写入请求,主分片写入后会向所有副本分片并行发起写入请求
  3. 所有分片写入完成后,向接受客户端请求的Node返回结果,最后向客户端返回请求结果

微观

  • 协调节点
    • 参数检查:对请求进行校验(比如,必传参数、长度校验等等),如有问题直接拒绝。
    • pipeline:用户可以自定义设置处理器,比如,可以对字段切割或者新增字段,还支持一些脚本语言。
    • 创建索引:如果索引尚未创建且允许自动创建索引(默认开启),会先创建索引,创建索引会发送到主节点上,必须等待master节点成功响应后,才会进入下一流程。
    • 请求预处理:比如,是否会自动生成id、路由,获取到整个集群的信息了,并检查集群状态,比如集群master不存在,都会被拒绝。
    • 合并请求:按照落在同一个shard中的请求聚集在一起构成BulkShardRequest,比如,这一批有5个文档, 如果都是属于同一个分片的,那么就会合并到一个请求里,会根据路由算法将文档分类放到一个map里,路由算法默认是文档id%分片数。
    • 转发请求:根据之前的路由规则和集群状态,将请求分发到对应的Primary节点。
  • 主分片节点
    • 数据先写入内存buffer(lucene内存),同步存入磁盘translog后返回索引结果。此时执行搜索,新文档还不能被索引到。
    • Refresh(通过refresh_interval控制,默认1s)定时将内存buffer数据生成segment文件,存入到操作系统缓存,并清理内存缓存;如果index buffer占满时,也会触发refresh。这个时候,文档可以被搜索到,因为ES search是对segment的数据进行检索的,这也是为什么说ES搜索是准实时的。但此时,数据还没写入到硬盘中,此时如果发生宕机,文档可能会丢失,但还有translog兜底,可以在遇到问题的时候从translog中恢复。
    • Flush定时将操作系统缓存中的segments文件写入到磁盘,称为1次fsync操作,删除translog文件;如果translog满时(默认512MB,通过translog.flush_threshold_size控制),也会触发flush。

在两次fsync之间,segments是保存在文件系统的缓存中的,这是不安全的。如果发生断电等,就会造成文档丢失。所以ES引入了translog来记录两次fysnc之间所有的操作,这样机器重启就会根据translog进行还原。类似于mysql中的redolog。不过,translog也是文件,也会先存放在系统的缓存中。如果发生断电关机,同样会造成translog的丢失。ES默认会间隔5秒钟(通过sync_interval配置)将translog落盘。如果丢失了,也只是丢失5秒内的数据。当然也可以通过配置将其修改为每次都fsync到磁盘里面,但是这样性能比较差。

  • 将内存中translog写入磁盘。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走正排索引。

Get

宏观

协调节点获取某个doc_id的请求之后,会经过路由算法分配给对应分片,此时主副分片都可以处理请求。查询出对应的文档之后会返回给协调节点,然后协调节点再把这个结果返回给客户端。

  1. 客户端向 Node1 发送获取请求。
  2. 节点使用文档的 _id 来确定文档属于分片0 。分片0的副本分片存在于所有的三个节点上。在这种情况下,它将请求转发到 Node2
  3. Node2 将文档返回给 Node1,然后将文档返回给客户端。
微观

具体来看:

  • 客户端向ES集群的任意节点发出请求,这个节点将作为本次请求的协调节点。
  • 协调节点计算路由,确定主键ID对应的分片号(ShardNumber)。分片号的计算方式是:shardID = hash(id) % number_of_shards
  • 协调节点根据路由表,找到对应分片号的主副本分片所在的节点。
  • 协调节点根据负载均衡策略选择一个节点,并将请求转发给该节点。
  • 该节点执行Lucene的Get操作,通过正排索引获取请求中所需的字段,并将结果返回给协调节点。
  • 协调节点将结果返回给客户端。

整个流程一般分为两阶段查询,第一阶段Query查询到匹配的doc_id,第二阶段Fetch再查询doc_id对应的完整文档,这种在ES 中称为query_then_fetch,还有一种是一阶段查询的时候就返回完整doc,在ES 中称作query_and_fetch,一般第二种适用于只需要查询一个Shard的请求。一个基本的Search流程包括

  • 倒排召回
  • 正排过滤
  • 相关性打分/排序
  • 获取正排字段
宏观

  1. 客户端发送一个 search 请求到 Node3Node3 会创建一个大小为 from + size 的空优先队列。
  2. Node3 将查询请求转发到索引的每个主分片或副本分片中。每个分片在本地执行查询并添加结果到大小为 from + size 的本地有序优先队列中。
  3. 每个分片返回各自优先队列中所有文档的 ID 和排序值给协调节点,也就是 Node3 ,它合并这些值到自己的优先队列中来产生一个全局排序后的结果列表。

当一个搜索请求被发送到某个节点时,这个节点就变成了协调节点,上例中就是Node 3。 这个节点的任务是广播查询请求到所有相关分片并将它们的响应整合成全局排序后的结果集合,这个结果集合会返回给客户端。第一步是广播请求到索引中每一个节点的分片拷贝。 查询请求可以被某个主分片或某个副本分片处理, 这就是为什么更多的副本(当结合更多的硬件)能够增加搜索吞吐率。 协调节点将在之后的请求中轮询所有的分片拷贝来分摊负载。

微观

  • 客户端向ES 集群的任意节点发出请求,这个节点将作为本次请求的协调节点。
  • 协调节点解析查询条件,构造SearchRequest。
  • 协调节点根据路由表,选择涉及索引对应的分片。这些分片可能是主分片,也可能是副本分片。
  • 通过负载均衡策略,协调节点将SearchRequest转发到各个分片所在的节点上。
  • 各个节点收到SearchRequest后,根据查询条件开始进行查询:
    • 对于精确匹配型查询,例如 gender = 1 无需排序。
    • 对于全文相似性查询,例如match(title = '中国'),需要进行相似度排序。
  • 各个节点返回(offset, limit)个结果给协调节点。例如,当 limit 为10,offset 为10,则会取出20条记录。
  • 协调节点对结果进行重排序,最终选择真正返回给客户端的(offset, limit)个doc_id_list。

  • 协调节点将之前获取的doc_id_list重新分发给相应的各个节点。
  • 各个节点根据接收到的doc_id,查询正排索引,获取相应的字段。
  • 各个节点将查询结果返回给协调节点。
  • 协调节点收集所有节点返回的结果,最终将完整的数据返回给客户端。

DSL

ES提供了一个基于JSON的Query DSL来定义查询语法,通过search API在HTTP请求体中发送查询表达式,共由两种语句格式组成。

// 格式:query 是Query DSL GET /<index>/_search { "query": {<parameters>} }

叶子查询子句/Leaf query clauses

用于将查询字符串和文档的一个字段或多个字段匹配,比如 matchtermrange 查询。 常见的查询:

  • match_all:查询简单的匹配所有文档。
  • match:标准查询,精确查询和全文搜索均可,在执行查询前,它将用分析器去分析查询字符串。
  • multi_match:可以在多个字段上执行相同的 match 查询。
  • range:查询找出那些落在指定区间内的数字或者时间,操作符包括gtgteltlte
  • term:用于精确值匹配,这些精确值可能是数字、时间、布尔等等。
  • terms :同term 查询一样,但可以指定多值进行匹配,匹配任何一个值即可。

复合查询子句/Compound query clauses

复合子句封装了其它的叶子子句和复合子句,从而组合了多个查询。最常用的组合查询是布尔查询(Boolean query),参数如下:

  • must:文档必须匹配这些条件才能被包含进来,会影响相关性得分。
  • must_not:文档必须不匹配这些条件才能被包含进来。
  • should:如果满足这些语句中的任意语句,将增加 _score ,否则无任何影响。类似于 or,主要用于修正每个文档的相关性得分。
  • filter:必须匹配,不计算相关性得分,只是根据过滤标准来排除或包含文档。

举例,假设我们有一个电影数据库,包含电影名称、导演、发布日期和评分等字段。我们希望构建一个查询,要求同时满足以下条件:

  • 查询包含关键词 "action" 的电影。
  • 过滤条件:评分在8分以上。
  • 按照发布日期降序排序。
  • 返回前10个结果。

查询的dsl如下

{ "query": { "bool": { "must": [ { "match": { "title": "action" } } ], "filter": [ { "range": { "rating": { "gte": 8 } } } ] } }, "sort": [ { "release_date": { "order": "desc" } } ], "size": 10 }

ES分词器

什么是 Analysis?

分词就是把文本转换成一系列单词(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"

分词器的组成

由三部分组成。

  • Character Filters:针对原始的文本处理,比如,去掉标点符号、html标签等。
  • Tokenizer:按照规则切分为单词。
  • Token Filters:将切分好的单词进行加工,比如,大小写转换("Quick"转小写),合并同义词等

Analyzer 这三个部分是有顺序的,从上到下依次经过 Character Filters,Tokenizer 以及 Token Filters。

常见分词器

一般内置的分词器有下面这些

  • Standard Analyzer - 默认分词器,按词切分,小写处理
  • Simple Analyzer - 按照非字母切分(符号被过滤),小写处理
  • Stop Analyzer - 小写处理,停用词过滤(the ,a,is)
  • Whitespace Analyzer - 按照空格切分,不转小写
  • Keyword Analyzer - 不分词,直接将输入当做输出
  • Pattern Analyzer - 正则表达式,默认 \W+
  • Language - 提供了 30 多种常见语言的分词器
  • Customer Analyzer - 自定义分词器

它可以通过以下三种方式来查看分词器是怎么样工作的:

  • 直接指定 Analyzer 进行测试
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 Analyzer

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 为分词位置。

IK

在中文句子中,不能简单地切分成一个个的字,而是需要分成有含义的词,而且在不同的上下文,是有不同的理解的。如果我们还用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分词器 支持自定义词库,支持热更新分词字典。

  • ik_smart: 会做最粗粒度的拆分
  • ik_max_word: 会将文本做最细粒度的拆分

示例

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 来表示,评分越高,相关性越高。不同的查询类型会有不同的相关度计算方式。

ES排序算法

默认情况下,查询返回的结果是按照相关性倒序排列的,除了可以人工的设置权重boost干预得分和排序结果,也存在一套非常基础的相关性得分算法。

TF-IDF

检索词频率/反向文档频率,简称TF-IDF(Term Frequency–Inverse Document Frequency)是一种用于信息检索与文本挖掘的常用加权算法。它是一种统计方法,用以评估一字词对于一个文件集或一个语料库中的其中一份文件的重要程度。字词的重要性随着它在文件中出现的次数成正比增加,但同时会随着它在语料库中出现的频率成反比下降。

  • 词频:指一个文档中出现某个单词(Term)的频率(Frequency),频率越高,相关性也越高。为了正确评价一个单词在一个文档中的重要程度,需要将出现次数归一化。

其中,分子是该词出现在文档中的次数,分母则是文档中出现该词的所有次数之和。

  • 逆向文档频率:一个文档集合中出现某个单词(Term)的文档频率。频率越高,相关性越低。检索词出现在多数文档中会比出现在少数文档中的权重更低。比如,常用词"the"、"的"对相关度贡献较少,因为它们在多数文档中都会出现。

其中,分子表示文档集合中的文件总数,分母中包含标识词语ti的文件数目,为了避免该词在文档中完全没有出现过,因此分母中通过 +1 确保了不会为0。

  • 最后,TF/IDF评分由公式给出:

举例 假如一篇文件的总词语数是1000个,而词语“上海”出现了10次,那么“上海”一词在该文件中的词频就是 𝑡𝑓𝑖 = 10/1000 = 0.01 “上海”一词在100份文档出现过,而文档总数是100000份,其逆向文件频率就是 𝑖𝑑𝑓𝑖 = lg(100000 / 100)= 3 最后分数为 𝑡𝑓𝑖𝑑𝑓𝑖 = 𝑡𝑓𝑖 × 𝑖𝑑𝑓𝑖 = 0.01×3 = 0.03

BM25

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 许可协议。转载请注明出处!