Elastic Stack应用宝典
上QQ阅读APP看书,第一时间看更新

2.1 全文检索与倒排索引

在许多文献中,Elasticsearch被归类为NoSQL数据库,所以它更多地具备一些NoSQL数据库特征,而与传统关系型数据库完全不同。比如在Elasticsearch中索引的概念与传统关系型数据库中的索引就不尽相同,Elasticsearch中的索引是倒排索引(Inverted Index),是一种专门应用于全文检索的索引类型,与此类似的还有映射、类型、文档、字段等诸多概念。在一些文献中常使用传统关系型数据库中的库、表、行、列来类比Elasticsearch的这些概念,尽管这种类比并不正确,但对于初学者来说也是快速理解这些概念的一种途径。所以本节也先以这种方式整体解释一下这些概念,随着学习的逐步深入会慢慢给出正确的理解:

● 索引(Index)相当于库;

● 映射类型(Mapping Type)相当于表;

● 文档(Document)相当于行;

● 字段(Field)相当于列。

2.1.1 全文检索

先来解释一下什么叫全文检索。数据检索的目的是从一系列数据中,根据某一或某些数据特征将特定的数据找出来。从数据检索的角度来看,数据大体上可以分为两种类型:一种是结构化数据;另一种是非结构化数据。结构化数据将数据具有的特征事先以结构化的形式定义好,数据有固定的格式或有限的长度。典型的结构化数据就是传统关系型数据库的表结构,数据特征直接体现在表结构的字段上,所以根据某一特征做数据检索很直接,速度也比较快。比如,根据商品的名称将该商品全部查找出来,通过一条SQL语句就能实现。如果想要提高查找速度,只要在商品名称上创建索引就可以了。许多应用系统都是建立在结构化数据的基础之上,例如财务软件、CRM、MIS等。

非结构化数据则完全不同,它们没有预先定义好的结构化特征,也没有固定格式和固定长度。典型的非结构化数据包括文章、图片、视频、网页、邮件等,其中像HTML网页这种具有一定格式的文档也称为半结构化数据。显而易见,相比结构化数据,非结构化数据的检索要难得多。在对它们的检索中,像文章、网页、邮件这种全文本(Full-text)数据的检索需求占了大多数,而且与图片、视频等非文本数据的检索完全不同,因此形成了一门独立的学科,这就是全文检索。包括Elastic官方网站在内的很多文献中,经常称全文本数据为全文数据,称全文数据中的一条数据为文档(Document),而称存储全文数据的数据库为全文数据库。本书后续章节提到的文档,如果没有特别说明,都是指存储在全文数据库的一条全文数据。所以简单来说,全文检索是指在全文数据中检索单个文档或文档集合的搜索技术,而Elasticsearch从这个意义上来说也可以理解为是一个全文数据库。

与结构化查询相比,全文检索面临的最大问题就是性能问题。全文检索最一般的应用场景是根据一些关键字查找包含这些关键字的文档,比如互联网搜索引擎要实现的功能就是根据一些关键字查找网页。显然,如果没有对文档做特别处理,查找的办法似乎只能是逐条比对。具体来说就是先将所有文档都读取出来,再对文档内容做逐行扫描看是否包含这些关键字。例如,Linux中的grep命令就是通过这种算法实现的。但这种方法在数据量非常大的情况下就像海底捞针一样,速度一定会非常慢。而类似互联网搜索引擎这样的应用面对的文档数量往往都是天文数字,所以需要有一种更好的办法实现全文检索。

关系型数据库提升数据查询速度的常用方法是给字段添加索引,有了索引的字段会根据字段值排序并创建类似排序二叉树的数据结构(如B树),这样就可以利用二分查找等算法提升查询速度。所以在字段添加索引后,通过这些字段做查询时速度能够得到非常明显的提升。但由于添加索引后需要对字段排序,所以增加和删除数据时速度会变慢,并且还需要额外的空间存储索引。这是典型的利用空间换取时间的策略。普通的索引对全文检索并不适用,因为这种索引使用字段整体值参与排序,所以在检索时也要通过字段的整体值做查询条件。而全文检索一般是查询包含某一或某些关键字的文档,所以通过文档整体值建立的索引对提高查询速度是没有任何帮助的。为了解决这个问题,人们创建了一种新索引方法,这种索引方法就是倒排索引(Inverted Index)。

2.1.2 倒排索引

倒排索引先将文档中包含的关键字全部提取出来,然后再将关键字与文档的对应关系保存起来,最后再对关键字本身做索引排序。用户在检索某一关键字时,可以先对关键字的索引进行查找,再通过关键字与文档的对应关系找到所在文档。这类似于查字典一样,字典的拼音表和部首表就是关键字索引,而拼音表和部首表中的内容就是关键字与文档的对应关系。为了说明倒排索引的基本思想,以下面两条文档为例:

示例2-1 参与倒排索引的文档

针对这两份文档创建倒排索引的第一步,是先对文档提取关键字。对于英文来说比较简单按空格分隔即可,两份文档共提取I、love、elasticsearch和logstash四个关键字。接下来就是建立关键字与文档之间的对应关系,即标识关键字都被哪些文档包含。这里使用表2-1所示的形式来表示这种对应关系,“√”代表文档包含了该关键字:

表2-1 倒排索引基本结构

有了倒排索引,用户检索就可以在倒排索引中快速定位到包含关键字的文档。倒排索引与关系型数据库索引类似,会根据关键字做排序。但关系型数据库索引一般是对主键创建,然后索引指向数据内容;而倒排索引则正好相反,它是针对文档内容创建索引,然后索引指向主键(文档一、文档二),这就是这种索引被称为倒排索引的原因。

从以上分析可以看出,倒排索引实际上是对全文数据结构化的过程。对于存储在关系型数据库中的数据来说,它们依赖于人的预先分析将数据拆解为不同字段,所以在数据插入时就已经是结构化的;而在全文数据库中,文档在插入时还不是结构化的,需要应用程序根据规则自动提取关键字,并形成关键字与文档之间的结构化对应关系。由于文档在创建时需要提取关键字并创建索引,所以向全文数据库添加文档比关系型数据库要慢一些。

不难看出,全文检索中提取关键字是非常重要的一步。这些预先提取出来的关键字,在Elasticsearch及全文检索的相关文献中一般称为词项(Term),本书后续章节将不再使用关键字而改用词项这个专业术语。文档的词项提取在Elasticsearch中称为文档分析(Analysis),是整个全文检索中较为核心的过程。这个过程必须要区分哪些是词项,哪些不是。对于英文来说,它还必须要知道apple和apples指的同一个东西,而run和running指的是同一动作。对于中文来说就更麻烦了,因为中文词语不以空格分隔,所以面临的第一难题是如何将词语分辨出来。文档分析涉及的内容很多,将在本书第4章详细讲解。

2.1.3 Elasticsearch索引

在Elasticsearch中,添加或更新文档时最重要的动作是将它们编入倒排索引,未被编入倒排索引的文档将不能被检索。也就是说,Elasticsearch中所有数据的检索都必须要通过倒排索引来检索,离开了倒排索引文档就相当于不存在。所以从检索的角度来看,文档以倒排索引的形式表现其存在性。正是基于这个原因,Elasticsearch没有引入库的概念,而是将文档的容器直接称为索引(Index)。而这里的索引就是倒排索引,或者更准确的说是一组倒排索引。在概念上可以将索引理解为文档在物理上的区分,同一索引中的文档具有相同的索引策略,或者说它们被编入到同一组索引中。从检索的角度来说,用户在检索文档时也要指定从哪一个索引中检索文档。所以从存储和检索两个角度来看,以索引区分文档实在是再合适不过了。在Elasticsearch中存储文档最好预先创建索引,尽管这并不是必须的。用户预先创建索引可以指明文档存储时怎么分词,如何创建索引等重要配置信息,这些对于提升检索速度显然是有益的。

因为文档存储前的分析和索引过程比较耗资源,所以为了提升性能,文档在添加到Elasticsearch中时并不会立即被编入索引。在默认情况下,Elasticsearch会每隔1s统一处理一次新加入的文档,可以通过index.refresh_interval参数修改。为了提升性能,在Elasticsearch 7中还添加了index.search.idle.after参数,它的默认值是30s。其大体含义是,如果索引在一段时间内没有收到检索数据的请求,那么它至少要等30s后才会刷新索引数据。所以,从这两个参数的作用来看,Elasticsearch实际上是准实时的(Near Realtime,NRT)。也就是说,新添加到索引中的文档,有可能在一段时间内不能被检索到。如果的确需要立即检索到文档,Elasticsearch也提供了强制刷新到索引的方式,包括使用_refresh接口和在操作文档时使用refresh参数。但这会对性能造成一定的影响,详细请参见第3章。

那么未被编入索引的文档在什么地方呢?事实上,它们会被临时保存到缓冲区中,缓冲区的大小可以通过一些配置参数设置,包括indices.memory.index_buffer_size、indices.memory.min_index_buffer_size和indices.memory.max_index_buffer_size。默认情况下,这个缓冲区最小为48MB且没有上限。

2.1.4 Elasticsearch映射

如前文所述,索引是存储文档的容器,文档在存储前会做文档分析并编入倒排索引。而文档从全文数据到索引的转变由映射(Mapping)定义,这是另一个在Elasticsearch中非常重要的概念。映射介于文档与索引之间,所以一般是在创建索引时指定文档与索引的映射关系。映射的概念比较难理解,想要理解它就得先理解Elasticsearch中的文档概念。

1.文档

在Elasticsearch中,数据存储和检索的基本单元是文档。Elasticsearch的文档使用JSON格式,这种格式目前几乎已经成为互联网数据交换的标准格式。Elasticsearch对外开放的接口以REST为主,而REST本身也是以JSON为通用数据交换格式。在后续章节中会看到,无论是存储文档、检索文档还是设置索引,请求的基本格式都是JSON。所以从开发和应用的角度来看,JSON格式可以降低学习成本,而且与微服务架构也易于集成。熟悉JSON的读者应该知道,JSON有一些格式规范要求,比如属性名称、数据类型等。所以严格来说,Elasticsearch存储的文档是一种半结构化数据,可以预先定义好属性和数据类型。为了明确概念,本书后续章节称Elasticsearch中文档的JSON属性为字段(Field),即文档字段,以区别在其他领域中使用的JSON属性。

既然Elasticsearch支持全文检索,为什么还要预先定义文档字段和数据类型呢?这可以从以下几个方面理解。首先,全文数据在存储前需要做分析并提取词项,但在文档中并不是所有数据都需要这样做。比如文档创建时间、文章标题、作者等,这些数据本来就是结构化的,没有必要再分析。此外,一些结构化数据在检索时需要做精确匹配,如果做了文档分析并提取词项后,反而做不了精确匹配了。比如,对作者名称“tom smith”做文档分析后,会提取“tom”和“smith”两个词项编入索引,而“tom smith”则不会编入索引,这时通过“tom smith”检索文章就不能匹配到文档了。其次,预先定义好文档字段可以增加数据检索的维度,提升检索质量;而且预先定义好数据类型可以优化存储结构,比如数值类型的保存就没必要保存成字符串了。最后,在Elasticsearch中存储文档也不是一定要预先定义文档字段,Elasticsearch也支持动态映射文档字段。

所以在使用Elasticsearch时,如果清楚地知道文档存在一些结构化特征,预先定义好它们对存储和检索都有好处;而这种预先定义又不会像数据库表结构那样,限制未来可能出现的数据扩展,可以说是兼顾了效率与灵活。在Elasticsearch中,定义文档的字段和数据类型是通过在映射中定义类型来实现的。

2.映射类型

映射类型(Mapping Type)是定义文档与索引映射关系的一种方式。在Elasticsearch版本6之前,一个索引中是可以定义多个映射类型。例如,创建一个shop索引存储网上商城数据,可以包含用户类型users和商品类型products。每个类型都可以有自己的字段,因此users类型可以有name、age、address等字段,而products类型则可以有name、price、description等字段。每新增一个用户,可以以JSON文档的形式存储在users类型下;而每新增一个商品,同样也可以以JSON文档的形式存储在products类型下,如示例2-2所示:

示例2-2 创建映射类型

在示例2-2中,“PUT shop”是创建索引的REST请求,而请求体中的mappings参数就是文档到索引的映射关系,它是索引创建接口的一个基本配置参数。mappings中的users和products就是映射类型的名称,而在映射类型的properties参数中,则实际指明了这些映射类型中预定义的字段及其数据类型。

讲到这里再回头看一下本节开始时对它们的类比,就会发现这种与关系型数据库的类比并不正确。最主要的就是映射类型并不是文档的物理容器,而只是文档到索引转变的映射关系。事实上,映射类型这个概念的引入使得Elasticsearch的这些概念在整体上都变得混乱,尤其是在它的官方文献中还经常将映射类型简称为类型,这使得初学者更是一头雾水。不光是初学者觉得这些概念难理解,Elasticsearch官方也应该是感觉到这些概念有些混乱了,所以Elasticsearch官方已经开始弱化映射类型的概念。例如,上面这段代码如果放到第1章搭建的Kibana中去执行就会报错,如图2-1所示。

图2-1 创建多映射类型

是否可以成功执行,取决于使用Elasticsearch的版本,只有使用6.0之前的版本可以成功。事实上,Elasticsearch官方正计划逐步取消映射类型的概念,在6.0版本以后映射类型的概念还将延续,但在映射中只能有一个映射类型,而不允许再定义多个映射类型;而在7.0版本以后,映射类型的概念被彻底删除。所以类似示例2-2中在shop索引中创建多个映射类型的例子,可以将shop这一层的索引取消,直接建立users和products索引,通过索引对它们做逻辑上和物理上的隔离。在6.0—7.0版本的过渡期间,用户在创建索引时还是需要在索引下创建一个映射类型,映射类型名称可以任意定义,但一般可以起名为_doc。但在7.0以后的版本中则不需要再加映射类型,Elasticsearch会为索引创建惟一一种映射类型_doc。所以,在7.0以后的版本中,如果要创建示例2-2中的索引应该按如下方式执行:

示例2-3 新接口中没有映射类型

需要说明的是,Elasticsearch官方之所以要删除映射类型的概念,不单纯是因为映射类型容易造成混乱,主要是因为映射类型只是文档在逻辑上的容器,在物理上并没有起到隔离文档的作用。在同一索引中,不同映射类型中具有相同名称的字段实际上由相同的Lucene字段支持。以前面shop索引为例,users类型中的name字段将与products类型中的name字段共享同一字段,所以两个name字段必须具有相同的定义。这在一些情况下或许是合理的,比如两种映射类型存在类似父子继承关系;但在多数情况下,这种共享字段会引发歧义。所以,使用表结构类比映射类型是不合适的,因为表结构在物理上是隔离的。同一数据库的两个表结构如果拥有相同字段,它们相互之间不会受到任何影响。但在Elasticsearch中,这是不成立的。正是基于这样的原因,Elasticsearch在高版本中开始弱化映射类型这一概念,未来定义不同映射类型就是创建不同的索引。