以前几段工做经历都与搜索有关,如今也有业务在用搜索,对搜索引擎作一个原理性的分享,包括搜索的一系列核心数据结构和算法,尽可能覆盖搜索引擎的核心原理,但不涉及数据挖掘、NLP等。文章有点长,多多指点~~算法
这里有个概念须要提一下。信息检索 (Information Retrieval 简称 IR) 和 搜索 (Search) 是有区别的,信息检索是一门学科,研究信息的获取、表示、存储、组织和访问,而搜索只是信息检索的一个分支,其余的如问答系统、信息抽取、信息过滤也能够是信息检索。数据库
本文要讲的搜索引擎,是一般意义上的全文搜索引擎、垂直搜索引擎的广泛原理,好比 Google、Baidu,天猫搜索商品、口碑搜索美食、飞猪搜索酒店等。数组
Lucene 是很是出名且高效的全文检索工具包,ES 和 Solr 底层都是使用的 Lucene,本文的大部分原理和算法都会以 Lucene 来举例介绍。缓存
看一个实际的例子:如何从一个亿级数据的商品表里,寻找名字含“秋裤”的 商品。性能优化
select * from item where name like '%秋裤%'
如上,你们第一能想到的实现是用 like,但这没法使用上索引,会在大量数据集上作一次遍历操做,查询会很是的慢。有没有更简单的方法呢,可能会说能不能加个秋裤的分类或者标签,很好,那若是新增一个商品品类怎么办呢?要加无数个分类和标签吗?如何能更简单高效的处理全文检索呢?数据结构
答案是搜索,会事先 build 一个倒排索引,经过词法语法分析、分词、构建词典、构建倒排表、压缩优化等操做构建一个索引,查询时经过词典能快速拿到结果。这既能解决全文检索的问题,又能解决了SQL查询速度慢的问题。app
那么,淘宝是如何在1毫秒从上亿个商品找到上千种秋裤的呢,谷歌如何在1毫秒从万亿个网页中找寻到与你关键字匹配的几十万个网页,如此大的数据量是怎么作到毫秒返回的。less
分词就是对一段文本,经过规则或者算法分出多个词,每一个词做为搜索的最细粒度一个个单字或者单词。只有分词后有这个词,搜索才能搜到,分词的正确性很是重要。分词粒度太大,搜索召回率就会偏低,分词粒度过小,准确率就会下降。如何恰到好处的分词,是搜索引擎须要作的第一步。数据结构和算法
分词正确性工具
分词的粒度
分词的粒度并非越小越好,他会下降准确率,好比搜索 “中秋” 也会出现上条结果,并且粒度越小,索引词典越大,搜索效率也会降低,后面会细说。
如何准确的把控分词,涉及到 NLP 的内容啦,这里就不展开了。
不少语句中的词都是没有意义的,好比 “的”,“在” 等副词、谓词,英文中的 “a”,“an”,“the”,在搜索是无任何意义的,因此在分词构建索引时都会去除,下降不不要的索引空间,叫停用词 (StopWord)。
一般能够经过文档集频率和维护停用词表的方式来判断停用词。
词项处理,是指在本来的词项上在作一些额外的处理,好比归一化、词形归并、词干还原等操做,以提升搜索的效果。并非全部的需求和业务都要词项处理,须要根据场景来判断。
1.归一化
这样查询 U.S.A. 也能获得 USA 的结果,同义词能够算做归一化处理,不过同义词还能够有其余的处理方式。
2.词形归并(Lemmatization)
针对英语同一个词有不一样的形态,能够作词形归并成一个,如:
3.词干还原(Stemming)
一般指的就粗略的去除单词两端词缀的启发式过程
英文的常见词干还原算法,Porter算法。
要了解倒排索引,先看一下什么是正排索引。好比有下面两句话:
正排索引就是 MySQL 里的 B+ Tree,索引的结果是:
表示对完整内容按字典序排序,获得一个有序的列表,以加快检索的速度。
第一步 分词
第二步 将分词项构建一个词典
第三步 构建倒排链
由此,一个倒排索引就完成了,搜索 “检索” 时,获得 id1, id2,说明这两条数据都有,搜索 “服务” 只有 id1 存在。但若是搜索 “检索系统”,此时会先建搜索词按照与构建同一种策略分词,获得 “检索-系统”,两个词项,分别搜索 检索 -> id1, id2 和 系统 -> id2,而后对其作一个交集,获得 id2。同理,经过求并集能够支持更复杂的查询。
倒排索引到此也就讲清楚了吧。
以 Lucene 为例,简单说明一下 Lucene 的存储结构。从大到小是Index -> Segment -> Doc -> Field -> Term,类比 MySQL 为 Database -> Table -> Record -> Field -> Value。
搜索结果排序是根据 关键字 和 Document 的相关性得分排序,一般意义下,除了能够人工的设置权重 boost,也存在一套很是有用的相关性得分算法,看完你会以为很是有意思。
TF(词频)-IDF(逆文档频率) 在自动提取文章关键词上常常用到,经过它能够知道某个关键字在这篇文档里的重要程度。其中 TF 表示某个 Term 在 Document 里出现的频次,越高说明越重要;DF 表示在所有 Document 里,共有多少个 Document 出现了这个词,DF 越大,说明这个词很常见,并不重要,越小反而说明他越重要,IDF 是 DF 的倒数(取log), IDF 越大,表示这个词越重要。
TF-IDF 怎么影响搜索排序,举一个实际例子来解释:
假定如今有一篇博客《Blink 实战总结》,咱们要统计这篇文章的关键字,首先是对文章分词统计词频,出现次数最多的词是--"的"、"是"、"在",这些是“停用词”,基本上在全部的文章里都会出现,他对找到结果毫无帮助,所有过滤掉。
只考虑剩下的有实际意义的词,若是文章中词频数关系: “Blink” > “词频” = “总结”,那么确定是 Blink 是这篇文章更重要的关键字。但又会遇到了另外一个问题,若是发现 "Blink"、"实战"、"总结"这三个词的出现次数同样多。这是否是意味着,做为关键词,它们的重要性是同样的?
不是的,经过统计所有博客,你发现 含关键字总博客数: “Blink” < “实战” < “总结”,这时候说明 “Blink” 不怎么常见,一旦出现,必定相比 “实战” 和 “总结”,对这篇文章的重要性更大。
上面解释了 TF 和 IDF,那么 TF 和 IDF 谁更重要呢,怎么计算最终的相关性得分呢?那就是 BM25。
BM25算法,一般用来做搜索相关性平分。一句话概况其主要思想:对Query进行语素解析,生成语素qi;而后,对于每一个搜索结果D,计算每一个语素qi与D的相关性得分,最后,将qi相对于D的相关性得分进行加权求和,从而获得Query与D的相关性得分。
BM25算法的通常性公式以下:
其中,Q表示Query,qi表示Q解析以后的一个语素(对中文而言,咱们能够把对Query的分词做为语素分析,每一个词当作语素qi。);d表示一个搜索结果文档;Wi表示语素qi的权重;R(qi,d)表示语素qi与文档d的相关性得分。
其中 Wi 一般使用 IDF 来表达,R 使用 TF 来表达;综上,BM25算法的相关性得分公式可总结为:
BM25 经过使用不一样的语素分析方法、语素权重断定方法,以及语素与文档的相关性断定方法,咱们能够衍生出不一样的搜索相关性得分计算方法,这就为咱们设计算法提供了较大的灵活性。
在点评口碑上,常常有相似的场景,搜索 “1千米之内的美食”,那么这个1千米怎么实现呢?
在数据库中能够经过暴力计算、矩形过滤、以及B树对经度和维度建索引,但这性能仍然很慢。搜索里用了一个很巧妙的方法,Geo Hash。
如上图,表示根据 GeoHash 对北京几个区域生成的字符串,有几个特色:
地球上任何一个位置均可以用经纬度表示,纬度的区间是 [-90, 90],经度的区间 [-180, 180]。好比天安门的坐标是 39.908,116.397,总体编码过程以下:
1、对纬度 39.908 的编码以下:
2、对经度 116.397 的编码以下:
3、合并组码
即最后天安门的4位 Geo Hash 为 “WX4G”,若是须要经度更准确,在对应的经纬度编码粒度再往下追溯便可。
附:Base32 编码图
举个例子,搜索天安门附近 200 米的景点,以下是天安门附近的Geo编码
搜索过程以下:
由上面步骤能够看出,Geo Hash 将本来大量的距离计算,变成一个字符串检索缩小范围后,再进行小范围的距离计算,及快速又准确的进行距离搜索。
如图所示,咱们将二进制编码的结果填写到空间中,当将空间划分为四块时候,编码的顺序分别是左下角00,左上角01,右下脚10,右上角11,也就是相似于Z的曲线。当咱们递归的将各个块分解成更小的子块时,编码的顺序是自类似的(分形),每个子快也造成Z曲线,这种类型的曲线被称为Peano空间填充曲线。
这种类型的空间填充曲线的优势是将二维空间转换成一维曲线(事实上是分形维),对大部分而言,编码类似的距离也相近, 但Peano空间填充曲线最大的缺点就是突变性,有些编码相邻但距离却相差很远,好比0111与1000,编码是相邻的,但距离相差很大。
除Peano空间填充曲线外,还有不少空间填充曲线,如图所示,其中效果公认较好是Hilbert空间填充曲线,相较于Peano曲线而言,Hilbert曲线没有较大的突变。为何GeoHash不选择Hilbert空间填充曲线呢?多是Peano曲线思路以及计算上比较简单吧,事实上,Peano曲线就是一种四叉树线性编码方式。
Lucene的倒排索引决定,索引内容是一个可排序的字符串,若是要查找一个数字,那么也须要将数字转成字符串。这样,检索一个数字是没问题的,若是须要搜索一个数值范围,怎么作呢?
要作范围查找,那么要求数字转成的字符串也是有序并单调的,但数字自己的位数是不同的,最简单的版本就是前缀补0,好比 35, 234, 1 都补成 4 位,获得 0035, 0234, 0001,这样能保证:
数字(a) > 数字(b) ===> 字符串(a) > 字符串(b)
这时候,查询应该用范围内的全部数值或查询,好比查询 [33, 36) 这个范围,对应的查询语法是:
33 || 34 || 35
嗯看起来很好的解决了范围查询,可是,这样存在3个问题:
故,涉及到范围不能简单的作字符串补位转换,是否存在及节省空间,又能更高效解决问题的方案呢?
就是:
数值Trie树,下面详细介绍
上面说了怎么索引,那么Query呢?好比我给你一个Range Query从423-642,怎么找到那6个term呢?
咱们首先能够用shift==0找到范围的起点后终点(有可能没有相等的,好比搜索422,也会找到423)。而后一直往上找,直到找到一个共同的祖先(确定能找到,由于树根是全部叶子节点的祖先),对应起点,每次往上走的时候, 左边范围节点都要把它右边的兄弟节点都加进去, 右边范围节点都要把它左边的兄弟节点加进去, 若已经到达顶点, 则是将左边范围节点和右边范围节点之间的节点加进行去
查找423到642之间的具体的区间:
另外还有一个问题,好比423会被分词成423,42和4,那么4也会被分词成4,那么4表示哪一个呢?
因此intToPrefixCoded方法会额外用一个char来保存shift:buffer[0] = (char)(SHIFT_START_INT + shift);
好比423分词的4的shift是2(这里是10进制的例子,二进制也是一样的),423分红423的shift是0,4的shift是0,所以前缀确定比后缀大。
最后,因为索引在判断时无需感知是不是数字,能够把全部的数字当成二进制处理,这样在存储和效率上更高。
LSM (Log Structured Merge Tree),最先是谷歌的 “BigTable” 提出来的,目标是保证写入性能,同时又能支持较高效率的检索,在不少 NoSQL 中都有使用,Lucene 也是使用 LSM 思想来写入。
普通的B+树增长记录可能须要执行 seek+update 操做,这须要大量磁盘寻道移动磁头。而 LSM 采用记录在文件末尾,顺序写入减小移动磁头/寻道,执行效率高于 B+树。具体 LSM 的原理是什么呢?
为了保持磁盘的IO效率,lucene避免对索引文件的直接修改,全部的索引文件一旦生成,就是只读,不能被改变的。其操做过程以下:
合并的过程:
Basic Compaction
每一个文件固定N个数量,超过N,则新建一个sstable;当sstable数大于M,则合并一个大sstable;当大sstable的数量大于M,则合并一个更大的sstable文件,依次类推。
可是,这会出现一个问题,就是大量的文件被建立,在最坏的状况下,全部的文件都要搜索。
Levelled Compaction
像 LevelDB 和 Cassandra解决这个问题的方法是:实现了一个分层的,而不是根据文件大小来执行合并操做。
因此, LSM 是日志和传统的单文件索引(B+ tree,Hash Index)的中立,他提供一个机制来管理更小的独立的索引文件(sstable)。
经过管理一组索引文件而不是单一的索引文件,LSM 将B+树等结构昂贵的随机IO变的更快,而代价就是读操做要处理大量的索引文件(sstable)而不是一个,另外仍是一些IO被合并操做消耗。
Lucene的Segment设计思想,与LSM相似但又有些不一样,继承了LSM中数据写入的优势,可是在查询上只能提供近实时而非实时查询。
Segment在被flush或commit以前,数据保存在内存中,是不可被搜索的,这也就是为何Lucene被称为提供近实时而非实时查询的缘由。读了它的代码后,发现它并非不能实现数据写入便可查,只是实现起来比较复杂。缘由是Lucene中数据搜索依赖构建的索引(例如倒排依赖Term Dictionary),Lucene中对数据索引的构建会在Segment flush时,而非实时构建,目的是为了构建最高效索引。固然它可引入另一套索引机制,在数据实时写入时即构建,但这套索引实现会与当前Segment内索引不一样,须要引入额外的写入时索引以及另一套查询机制,有必定复杂度。
数据字典 Term Dictionary,一般要从数据字典找到指定的词的方法是,将全部词排序,用二分查找便可。这种方式的时间复杂度是 Log(N),占用空间大小是 O(N*len(term))。缺点是消耗内存,存在完整的term,当 term 数达到上千万时,占用内存很是大。
lucene从4开始大量使用的数据结构是FST(Finite State Transducer)。FST有两个优势:
那么 FST 数据结构是什么原理呢? 先来看看什么是 FSM (Finite State Machine), 有限状态机,从“起始状态”到“终止状态”,可接受一个字符后,自循环或转移到下一个状态。
而FST呢,就是一种特殊的 FSM,在 Lucene 中用来实现字典查找功能(NLP中还能够作转换功能),FST 能够表示成FST的形式
举例:对“cat”、 “deep”、 “do”、 “dog” 、“dogs” 这5个单词构建FST(注:必须已排序),结构以下:
当存在 value 为对应的 docId 时,如 cat/0 deep/1 do/2 dog/3 dogs/4, FST 结构图以下:
FST 还有一个特色,就是在前缀公用的基础上,还会作一个后缀公用,目标一样是为了压缩存储空间。
其中红色的弧线表 NEXT-optimized,能够经过 画图工具 来测试。
为了可以快速查找docid,lucene采用了SkipList这一数据结构。SkipList有如下几个特征:
在什么位置设置跳表指针?
• 设置较多的指针,较短的步长, 更多的跳跃机会
• 更多的指针比较次数和更多的存储空间
• 设置较少的指针,较少的指针比较次数,可是须要设置较长的步长较少的连续跳跃
若是倒排表的长度是L,那么在每隔一个步长S处均匀放置跳表指针。
也叫 Block KD-tree,根据FST思路,若是查询条件很是多,须要对每一个条件根据 FST 查出结果,进行求并集操做。若是是数值类型,那么潜在的 Term 可能很是多,查询销量也会很低,为了支持高效的数值类或者多维度查询,引入 BKD Tree。在一维下就是一棵二叉搜索树,在二维下是若是要查询一个区间,logN的复杂度就能够访问到叶子节点对应的倒排链。
二进制处理,经过BKD-Tree查找到的docID是无序的,因此要么先转成有序的docID数组,或者构造BitSet,而后再与其余结果合并。
IndexSorting是一种预排序,在ES6.0以后才有,与查询时的Sort不一样,IndexSorting是一种预排序,即数据预先按照某种方式进行排序,它是Index的一个设置,不可更改。
一个Segment中的每一个文档,都会被分配一个docID,docID从0开始,顺序分配。在没有IndexSorting时,docID是按照文档写入的顺序进行分配的,在设置了IndexSorting以后,docID的顺序就与IndexSorting的顺序一致。
举个例子来讲,假如文档中有一列为Timestamp,咱们在IndexSorting中设置按照Timestamp逆序排序,那么在一个Segment内,docID越小,对应的文档的Timestamp越大,即按照Timestamp从大到小的顺序分配docID。
IndexSorting 之因此能够优化性能,是由于能够提早中断以及提升数据压缩率,可是他并不能知足全部的场景,好比使用非预排序字段排序,还会损耗写入时的性能。
搜索引擎正是靠优秀的理论加极致的优化,作到查询性能上的极致,后续会再结合源码分析压缩算法如何作到极致的性能优化的。
未完待续~
原文连接 本文为云栖社区原创内容,未经容许不得转载。