ES 的心脏——Lucene 框架的索引原理

397 阅读10分钟

欢迎大家关注公众号:后端早读课,后端 hitech.

本文主要讲述了 Lucene 的索引原理(算法),使读者能够清晰地理解 FST 和 FOR 算法。

全文 3000 字,估计阅读 30分钟

第一印象

Lucene 是一个全文搜索框架

如果将 Elastic Search 比作卡车,那么 Lucene 就是卡车的发动机。将Lucene理解成一个单机版的搜索引擎,提供基本的读写功能。

写过程就是建索引,而读过程就是利用索引(倒排索引)结构高效检索的过程。写的过程越精致,读的过程越准确和高效。

Elastic Search 基于 Lucene 实现了分布式方案,同样 Apache Solr 也实现了类似的功能,Solr stands for "Searching on Lucene with Replication"。

ES 更灵活,更高效,而 Solr 更成熟,更稳定。但是他们引擎都基于 Lucene 。

基本概念

在 Lucnene 中,常见的概念有 Document 文档、Field 域(字段)、Analyzer 分词器、Term 词语、Token 令牌、 Segment 索引碎片、Index 索引。

Document

Documents are the unit of indexing and search

用户提供的源是一条条记录,它们可以是文本文件、字符串或者数据库表的一条记录等等。一条记录经过索引之后,就是以一个 Document 的形式存储在索引文件中的。用户进行搜索,也是以 Document 列表的形式返回。

Field

A field is a section of a Document

一个 Document 可以包含多个信息域,例如一篇文章可以包含“标题”、“正文”、“最后修改时间”等信息域,这些信息域就是通过 Field 在 Document 中存储的。
Field 有三个属性:分词、存储 和 索引。无法设置既不存储又不索引。

Analyzer

Analyzer 是分析器,它的作用是把一个字符串按某种规则划分成一个个词语,并去除其中的无效词语,这里说的无效词语是指英文中的“of”、 “the”,中文中的 “的”、“地”等词语,这些词语在文章中大量出现,但是本身不包含什么关键信息,去掉有利于缩小索引文件、提高效率、提高命中率。

Term

A Term represents a word from text.
Term 是搜索的最小单位,它表示文档的一个词语,Term 由两部分组成:它表示的词语 和 这个词语所出现的 Field。

Token

A Token is an occurrence of a term from the text of a field

Token 是 Term 的一次出现,它包含 Term 文本和相应的起止偏移,以及一个类型字符串。一句话中可以出现多次相同的词语,它们都用同一个 Term 表示,但是用不同的 Token,每个 Token 标记该词语出现的地方。

Segment

Lucene creates a segment when a new writer is opened, and when a writer commits or is closed

添加索引时并不是每个 Document 都马上添加到同一个索引文件,它们首先被写入到不同的小文件,然后再合并成一个大索引文件,这里每个小文件都是一个 Segment。Segment 一旦被生成,则只读不可被修改。

这些概念之间的关系:

Term Dictionary (词典)和 Posting List (倒排表)将是本文主要讲述的内容。

索引结构

在 Lucene 中,最重要的两个步骤:分词、索引。本文只关注索引这一部分。

众所周知,Lucene 的索引是一个倒排索引,比如我们对《黄历和星期》这本书建立索引,只举星期分词的例子

词典 Term Dictionary倒排表 Posting List (document id)
Monday1,8,29,48,58
Tuesday3,4,76,89
Wednesday2,4,45,67,89
Thursday4,6,7,34,67
Friday5,34,56,78,98
Saturday6,12,32,78
Sunday7,12,34,65

索引的核心由两部分组成:Term Dictionary 词典、Posting List 倒排表。

Lucene 的词典的数据结构选择了 FST (Finite-state-tranducer 有限状态转换器),倒排表使用了数据压缩和跳表结构,

FST 的原理

在聊 FST 之前,先聊下 FSM (Finite-state mechine 有限状态机)。

理论基础: 《Direct construction of minimal acyclic subsequential transducers》,通过输入有序字符串构建最小有向无环图。

FSM (Finit-state mechine 有限状态机)又称为 FSA(Finite-state automation 有限状态自动机),简称状态机,是表示有限个状态以及在这些状态之间的转移和动作等行为的数学计算模型

除了可以用状态转移图

来表示状态机变化外,还可以通过状态转移表来表示

状态→条件↓状态 A状态 B状态 C
条件 1.........
条件 2.........
条件 3...状态 C...

满足 条件 3状态 B 时,则到达状态 C。

在自动机的图例中,圆圈一般代表「状态」,连接线代表「条件(转移)」,两个圆圈代表「接受态」

比如正则表达式 (a|bb)* 可以表达为:

(只举例「接受态」,它的状态可能是无限的,不属于有限状态机)

介绍完 FSM (Finite-state mechine 有限状态机)之后,还要介绍一下确定无环有限状态接收机(Deterministric acyclic finite state acceptor, DAFSA),后面简称(FSA)

  • 确定 Deterministric:意味着指定任何一个状态和转移,它都能转移到指定的状态,wikipedia
  • 无环 Acyclic:已被访问的状态无法再次被访问
  • 接收 Acceptor:有限状态机只“接受”特定的输入序列,并终止于 final 状态。

现在假设,我们有 "mon"、"tue"、wed、"thur"、"fri"、"sat"、"sun" 星期需要构成一个 Sets,组成 FSA 结构。

创建一个 key 为 'mon' 的 FSA 的图例(如何验证一个 FSA 能够识别 mon)

  • 当输入 m, 且状态为 0,到达状态 1
  • 当输入 o,且状态为 1 ,到达状态 2
  • 当输入 n,且状态为 3,到达状态 3 ,同时为 Final 终点

如果输入 'moi' ,则在 状态 2 + 输入 i 时,不识别输入而无法到达 Final 。

再增加一个 key "tue" ("tuesday")

  • 状态 0 有了两个转移(状态 1 和 状态 4)
  • 他们共享一个 Finial (状态 3)

再增加 key "thur" ("thuresday")

  • "tue" 和 "thur" 共享 状态 4

这时候, DAFSA 和一种树状结构比较类似:Trie 树 (又称为 字典树、单词查找树)

但是 Trie 树和 FSA 又有什么区别呢?

假如我们把上面的数据改为 "Monday"、"tuesday"、"thursday",DAFSA 图例会变成:

我们可以发现, FSA 不仅可以共享前缀,而且还可以共享后缀,这样 FSA 的压缩率要远远大于 TRIE 树的压缩率。

FSA 和 TRIE 的查找时间复杂度均为 O(n) ,但是 FSA 的压缩率却比 TRIE 高。

假设我们已经了解了 DAFSA (确定无环有限状态接收器)的原理,那么理解 FST(有限状态转换器)就容易多了。

FST(Finite-state transducer)也可以称为 DAFST ( Deterministic acyclic finite state transducer 确定无环有限状态转换器)。

  • 确定 Deterministric:意味着指定任何一个状态和转移,它都能转移到指定的状态,wikipedia
  • 无环 Acyclic:已被访问的状态无法再次被访问
  • 转换器:有限状态机只“接受”特定的输入序列,并终止于 final 状态,同时输出一个值

acceptor 和 transducer 的区别就是:transducer 会比 acceptor 多输出一个关联值,我们称之为 output。

同时这也是 Sets 和 Map 的区别: Sets 只有不重复的元素本身,而 Map 除了不重复外,还需要关联一个值。

基于对 FSA 的理解, FST 主要理解「关联值(value)」的行为即可。

比如设定 "mon" = 1, "tue" = 3, "thur" = 4,那么构建的 FST 图例为(构建 FST 时,我们要保证输入有序,所以输入的顺序为: "mon" "thur" "tue")

( m/1 可以理解为 m 转移的值为 1)

将 m\o\n 的 value 相加为 1 + 0 + 0 = 1 = "mon"。

把 m 关联为 1 是因为计算上把 value 关联到前缀比较方便处理,后面会举例处理的流程。

下面我们加入 "thur" 。(之所以先加入 thur 是因为 FST 构建需要将词典排序,排序之后 thur 再 tue 之前)

同样 t\h\u\r 的 value 相加为 4 + 0 + 0 + 0 = 4 = "thur"。

下面我们加入 "tue"

与之前加入 key 不同的是,在这次添加 Key 时,原来的 t / 4 变成了 t / 3 ,而且给 h 增加了 1。

新的转移 u 不是 u / 3 ,而是 u / 1。

从结果上来看, "tue" = 3 + 0 + 0 = 3 满足 output 的条件。

但是 t / 4 → t / 3 的底层逻辑是什么呢?

FST 的 value 需要满足接口

  • T add(T prefix, T output); 加
  • T subtract(T output, T inc); 减
  • T common(T output1, T output2) 前缀

针对 "thur" 和 "tue"来说

  1. t 是他们的前缀,那么就要取分叉的 outputs 的前缀作为 t 的 value。
  2. "thur" 的 output 为 4,"tue" 的 output 为 3,两者取前缀(min操作)的结果为 3
  3. t 的 value 则变为 3
  4. "thur" 中 t 的下一个转移 h 则需要加上 4 和 3 的差值: 1。

如果 output 不是数字而是字符串,那么只要字符串实现了加、减、取前缀的接口,就可以使用如上的方法进行计算。

如上,我们就完成了 FST(Finite-state transducer 有限状态转换器) 的构建。

以上过程也可以通过 Build your own FST 来验证。

FST 的优缺点

常见的词典组成结构:

数据结构优缺点
排序列表 Array/List使用二分法查找,不平衡
HashMap/TreeMap性能高,内存消耗大,几乎是原始数据的三倍
Skip List跳跃表,可快速查找词语,在 Lucene、Redis、Hbase 等均有实现。相对于 TreeMap 等结构,特别适合高并发场景
Trie适合英文词典,如果系统中存在大量字符串且这些字符串基本没有公共前缀,则相应的 Trie 树将非常消耗内存
Double Array Trie适合做中文词典,内存占用小,很多分词工具均采用此种算法
Ternary Search Tree三叉树,每一个node有3个节点,兼具省空间和查询快的优点
Finite State Transducers (FST)一种有限状态转移机,压缩率高,查询效率一般。Lucene 4 有开源实现.

优点:内存占用率低,压缩率一般在3倍~20倍之间、模糊查询支持好、查询快

缺点:结构复杂、输入要求有序、更新不易 。

HashMap 、TreeMap 和 FST 的查询性能比较:

虽然 FST 的查询效率比较差,但是还可以接受,但是他的数据压缩率比较出色,相对于TreeMap/HashMap的膨胀3倍,内存节省就有9倍到60倍!

(数据来源:zhuanlan.zhihu.com/p/366849553

倒排表的原理

倒排表主要包含数据压缩和跳表排序。

数据压缩

  1. 把文档 ID 使用 Delta-encode ,计算到 0 - 255 之间
  2. 然后将 delta-encode 之后的值放到 256 块中
  3. 将每块的最多使用 bits 记录到 Header( 8\5),然后可以计算出每块的压缩大小。

这个编码算法被称为Frame Of Reference (FOR)

结语

以上就是 Lucene 的索引原理,当然真正的实现会复杂的多,感兴趣的同学可以阅读 Lucene 源码来学习更多。

有问题,就有答案。

image.png

参考文档:

blog.burntsushi.net/transducers…

www.elastic.co/cn/blog/fra…

zhuanlan.zhihu.com/p/366849553

www.cnblogs.com/dh-dh/p/102…

www.shenyanchao.cn/blog/2018/1…

t.zoukankan.com/ajianbeyour…

blog.csdn.net/xiewei906/a…

blog.csdn.net/sanmi8276/a…

《黄历和星期》这本书是我瞎编的名字,如有雷同,纯属巧合。