前言
从所周知Lucene是一个强大的搜索库。它是现在搜索产品(Solr,ElasticSearch)的核心.反正关系数据搜索一慢,就往上面搬。但有没想过Lucene倒排索引为什么比B+索引快?Lucenne是怎么索引的。
Lucene概述
lucene是只完成发搜索程序的索引模块和搜索模块而已。
上图为整个搜索程序全部组件。深绿色为lucence实现的部分。从图中可知:
- 直接的Raw Content不存在Lucene中
- 用户的查询最终会转化用Query进入Lucene
索引的文件结构
Lucene经多年演进优化,一个索引文件结构(不同版本可能文件不一样)如图所示,基本可以分为四个部分:词典、倒排表、正向文件、列式存储DocValues,词项向量TermVector。
正向索引
按顺序层次保存了索引:索引(Index) –> 段(segment) –> 文档(Document) –> 域(Field) –> 词(Term)。大致文件如下
- segments_N:
- 保存了此索引包含多少个段
- 每个段包含多少篇文档。
- N表示版本号
- .del:记录被删除的文档
- .fnm:主要存元信息,即文档的每个域的名称,索引方式
- .fdt:文档的数据,按域维度存,结合fnm,可以读出文档
- .fdt:文档索引,记录了每篇文档在fdt的偏移量(加载到内存)
正向的查找文档过程:
- 找到些文件的索引目录中的segments_N
- segments_N根据之中的分段信息加载各段的fdt
- 在根据文档ID号内存fdt中找到文档的fdt偏移量
- 结合fnm和fdt偏移量,取出对应的文档
列式存储 DocValues
Lucene在构建索引时,会额外建立一个基于域(Field) 顺序的文档(Document)列表。
主要解决排序,分组操作
- 遍历提取所有出现在文档集合的排序字段
- 构建一个最终的排好序的文档集合list
这个步骤的过程全部维持在内存中操作,而且如果排序数据量巨大的话,非常容易就造成solr内存溢出和性能缓慢。
- dvm:主要排序域的元信息及dvd文件的偏移量
- dvd:根据排序域的文档ID,结合fdt可快速定位文档
词项向量TermVector
为了实现,快速对搜索关键字标红的功能。如下图所示
对文档内“Lucene” Term快速标红。大致索引文件:
- tvf: 保存每一个文档的所有域的所有TermVector的具体信息。
- tvd: 记录了每一个文档的所有域在tvf文件中相对首域的偏移量
- tvx: 记录了每篇文档在tvf的偏移量,在tvd的偏移量, 同fdt功能类似,
把所的term提到tvf中,tvd记录每个域的长度,tvx记录每个文档长度
倒排索引
按倒序层次保存了索引:词(Term) –> 文档(Document)。大致文件如下:
- tip:针对不同的域建立不同的FST前缀(单词的公共部分)并加载到内存中,叶子节点指向tim中后缀的偏移量
- tim: 针对不同的域建立不同的FST后缀(单词的独立部分),叶子指向doc中倒排表的偏移量
- doc: 通过tip,tim组用的term与文档列表形成关联
逆向的查找文档过程:
- 根据term,在tip文件中查找到后缀文件tim偏移量
- 根据term, 在tim文件中查找倒排表doc的偏移量
- 如果域有排序,交集dvd文件转交为有序文档ID集合
- 根据倒排表中的文档ID,找到文档的fdt偏移量
- 结合fnm和fdt偏移量,取出对应的文档
索引的构建与使用
索引的创建是个很复杂的过程,正向索引不做介绍,这是主要介绍倒排的过程。
- 现有一员工数据(employee)如下
docId | name | age | gender | dept |
---|---|---|---|---|
0 | a | 20 | Female | sales |
1 | b | 1 | Male | sales |
2 | c | 3 | Male | sales |
3 | d | 5 | Male | sales |
4 | e | 7 | Female | sales |
- 明确搜索需要对字段进行配置
# 查询需求为
# 统计销售部:男女分数及其平均年龄
select
gender, count(1), avg(arg)
from employee
where dept = 'sales'
group by gender
# 结果就是对
# 对 dept 索引
# 对 gender DocValues
- 构建索引,分别对dept进行倒排索引,对gender按Male,Female排序进行docValues,
- 利用索引搜索
- 排序dept域倒排得到[0,1,2,3,4]
- 与gender docValues交集得 Female[0,4].Male[1,2,3]
- 对两集合计算count(1),avg(age)为Female[2,23.5].Male[3,23]
算法的优化
在索引创建和使用的过程中用到了许许多多的数据结构与算法。
词典-FST
- FST构建前要求 dept的字段必须排序
- 假设输入为abd,abd,acf,acg
- 插入abd时,没有输出。
- 插入abe时,计算出前缀ab,但此时不知道后续还不会有其他以ab为前缀的词,所以此时无输出。
- 插入acf时,因为是有序的,知道不会再有ab前缀的词了,这时就可以写tip和tim了,tim中写入后缀词块d、e和它们的倒排表位置ip_d,ip_e,tip中写入a,b和以ab为前缀的后缀词块位置(真实情况下会写入更多信息如词频等)。
- 插入acg时,计算出和acf共享前缀ac,这时输入已经结束,所有数据写入磁盘。tim中写入后缀词块f、g和相对应的倒排表位置,tip中写入c和以ac为前缀的后缀词块位置。
倒排表
倒排表就是文档号有序集合
-
数据压缩,可以看下图将6个数字从原先的24bytes压缩到7bytes。
-
跳跃表加速合并,因为布尔查询时,and 和or 操作都需要合并倒排表,这时就需要快速定位相同文档号,所以利用跳跃表来进行相同文档号查找。如下实现跳跃表:
层级 | ||||||
---|---|---|---|---|---|---|
l1 | 73 | 332 | ||||
压缩结点 | 73 | 227 | 2 | 30 | 11 | 29 |
分段存储
FST须要输入有顺序的term才会被构建,lucene如何实现呢?先了解下Lucene的段的不变性思想:
- 新增。当有新的数据需要创建索引时,由于段的不变性,所以选择新建一个段来存储新增的数据。
- 删除。当需要删除数据时,由于数据所在的段只可读,不可写,所以Lucene在索引文件下新增了一个.del的文件,用来专门存储被删除的数据id。当查询时,被删除的数据还是可以被查到的,只是在进行文档链表合并时,才把已经删除的数据过滤掉。被删除的数据在进行段合并时才会真正被移除。
- 更新。更新的操作其实就是删除和新增的组合,先在.del文件中记录旧数据,再在新段中添加一条更新后的数据。
由于段的不变性,和FST有所冲突(PS:总不能一条数据搞一个段吧)。因此 Lucene并没有每新增一条数据就增加一个段,而是采用延迟写的策略,流程如下:
- 新数据被写入时,并没有被直接写到硬盘中,而是被暂时写到内存中。Lucene默认是一秒钟,或者当内存中的数据量达到一定阶段时,再批量提交到磁盘中,当然,默认的时间和数据量的大小是可以通过参数控制的。通过延时写的策略,可以减少数据往磁盘上写的次数,从而提升整体的写入性能。
- 在达到触发条件以后,会将内存中缓存的数据一次性写入磁盘中,并生成提交点。一个段一旦拥有了提交点,就说明这个段只有读的权限,失去了写的权限;相反,当段在内存中时,就只有写数据的权限,而不具备读数据的权限,所以也就不能被检索了。
- 清空内存,等待新的数据写入
Lucene或者Elasticsearch并不能被称为实时的搜索引擎,只能被称为准实时的搜索引擎
段合并策略
上面段的设计,会造成段文件数量过多,检索使要合并多个结果集,不仅会严重消耗服务器的资源,还会影响检索的性能。因段的不变性,使合并成了可能。具体策略:
- 根据段的大小先将段进行分组,再将属于同一组的段进行合并。
- 对超级大的段的合并需要消耗更多的资源,所以Lucene会在段的大小达到一定规模,或者段里面的数据量达到一定条数时,不会再进行合并
Lucene的段合并主要集中在对中小段的合并上,这样既可以避免对大段进行合并时消耗过多的服务器资源,也可以很好地控制索引中段的数量。
段合并的主要参数如下。
- mergeFactor:当大小几乎相当的段的数量达到此值的时候,开始合并。
- minMergeSize:所有大小小于此值的段,都被认为是大小几乎相当,一同参与合并。
- maxMergeSize:当一个段的大小大于此值的时候,就不再参与合并。
- maxMergeDocs:当一个段包含的文档数大于此值的时候,就不再参与合并。
假设有如下的段:
从头开始,选择一个值最大的段,然后将此段的值减去0.75(LEVEL_LOG_SPAN) ,之间的段被认为是大小差不多的段,属于同一阶梯,此处称为第一阶梯。得
第一阶梯总共4个段,小于mergeFactor因而不合并,接着start=end从而选择下一阶梯。得
从start开始,选择一个值最大的段,然后将此段的值减去0.75(LEVEL_LOG_SPAN) ,之间的段被认为属于同一阶梯,此处称为第三阶梯。第三阶梯总共5个段,等于mergeFactor,因而进行合并。
第二阶段的五个段合并成一个较大的段。
然后从头开始,考察第一阶梯,因为有了新生成的段,并且大小足够属于第一阶梯,从而第一阶梯有5个段,可以合并。
第一阶梯的五个段合并成一个大的段。
以上是正向索引的合并过程,倒排索引的合并过程为:
- 对字典的合并,词典中的Term是按照字典顺序排序的,需要对词典中的Term进行重新排序
- 对于相同的Term,对包含此Term的文档号列表进行合并,需要对文档号重新编号。
相当于把2段的FST内容合拿出来,合成一个新的FST
在段合并时,除了需要对索引数据进行合并,还需要移除段中已经删除的数据。
高可用&扩展性
在分布式的时候,高可用和扩展性是不可避免的话题,Lucene的定位只是搜索引挚。但其产品ES,Solr帮其实现。
- 分片shard,支持大数据。多分片查询只要聚合结果集就行
//routing 是一个可变值,默认是文档的 _id
//number_of_primary_shards 主分片的数量
//shard 文档所在分片的位置,0 到 numberofprimary_shards-1 之间的余数
shard = hash(routing) % number_of_primary_shards
- 副本Replicas,为每分片提供数据备份。备份和主数据不在同一机子上
- 事务日志Translog,事务日志记录了所有还没有持久化到磁盘的数据。避免丢失数据.
与B+Tree的对比
- Mysql 的 B+Tree 相当 只有 term dictionary 这一层,另外 b-tree 排序的方式存储在磁盘上的。检索一个 term 需要若干次的 random access 的磁盘操作。
- Lucene 在 term dictionary 的基础上添加了 term index 来加速检索,term index 以树的形式缓存在内存中。从 term index 查到对应的 term dictionary 的 block 位置之后,再去磁盘上找 term,大大减少了磁盘的 random access 次数。
TODO
以上讲的是Lucene工程化的一个过程。Lucene核心是:
- 分词《NPL基于词典分词
》 - 打分《NLP文本相似度(TF-IDF)》
主要参考
《lucene DocValues——没有看懂》
《lucene索引结构(三)-词项向量(TermVector)索引文件结构分析》
《Lucene学习总结》
《Lucene学习总结之五:Lucene段合并(merge)过程分析》
《Lucene FST》
《Lucene底层原理和优化经验分享(1)-Lucene简介和索引原理》
《全文搜索引擎Elasticsearch,这篇文章给讲透了》
《Elasticsearch倒排索引与B+Tree对比》