本文是对国科大2018-2019年春季教授的信息检索课程的复习总结,主要参考《信息检索导论》一书以及老师上课所用课件。根据书中章节的划分,本系列文章为五个部分,本文是第一部分:信息检索的基础知识,其余部分如下:
本文主要介绍信息检索的基础知识,也是搜索引擎的核心理论。首先介绍倒排索引,包括其构建之前的预处理、构建的过程、以及用于布尔查询时如何使用,然后基于上面的过程,针对具体的场景,提出了具体的改进方法:
在对布尔查询及其扩展有了基本的了解之后,我们知道了如何从文档集中检索出匹配的文档,但是并不知道每一篇文档匹配的程度,因此剩下的内容将会介绍如何对文档和查询的匹配程度进行度量,主要包括词项权重的计算以及评分算法。
有了上面的内容,我们已经对一个简单的搜索引擎的基础组成部分有了大致的了解,最后,将会介绍如何对一个检索系统进行评价。
布尔检索也称为布尔逻辑检索,是指利用布尔逻辑运算连接各个检索式,然后由检索系统执行相应逻辑计算,以找出所需信息的犯法。在该模型下,每篇文档只被看成是一些列词的集合。
为了执行布尔检索式,我们需要提前对文档集建立索引,即词项-文档关联矩阵,词项是索引的单位,我们会在下面的预处理中再次提到它,关联矩阵就是为了记录某一篇文档是否包含词表中的每个词,这里的词表指的是文档集包含的所有词项的集合,所以关联矩阵的大小是 m ∗ n m*n m∗n, m m m是文档集中的文档数量, n n n是文档集包含的所有词项的总数,矩阵中每一个值的取值范围是0或1,分别表示不包含该词项和包含该词项。
有了词项关联矩阵我们就可以对布尔检索式进行逻辑运算了,比如我们希望查找 ( A A n d B ) (A And B) (AAndB)即同时包含A和B的文档,可以取出关联矩阵中词项A的那一列和词项B的那一列,然后统计两列数据中同时取1的那些行,即是我们想要的结果。
但是词项文档矩阵有一个很严重的问题就是当文档集比较大的时候,实际上不需要很大的时候,矩阵占用的内存会急剧增长,因为这个矩阵是及其稀疏的,所以我们有了信息检索中的第一个核心概念——倒排索引。
倒排索引由词典和倒排记录表组成,词典即上面提到的词项集合,每一个词项对应一个倒排记录表,该表中的每个元素记录的是该词项在某文档中出现的一次信息,如下图:
倒排索引的构建过程如下所示:
产生归一化词项的过程我们可以看做是对建立倒排索引的预处理过程,会在下面进行介绍,这里主要讲第四步,在上面的前3步处理结束后,对每篇文档建立索引时的输入就是一个归一化的词条表,也可以看成二元组 ( t e r m , d o c u m e n t I D ) (term, documentID) (term,documentID)的一个列表,建立索引的核心步骤就是将这个由所有文档的词条表组成的列表按照词项的字母顺序进行排序,同一词项在同一文档中的多次出现会合并在一起,最后整个结果分成词典和倒排记录表两部分,如下图所示:
在最终得到的倒排记录表中,往往会将词典放在内存中,而倒排记录表放在磁盘上。
布尔查询包括两种操作AND 和 OR,基于倒排索引的布尔查询就是取出词项对应的倒排记录表进行合并或者交集操作,这里不做赘述。
查询优化指的是如何通过组织查询的处理过程来使处理工作量最小,对布尔查询进行优化要考虑的一个主要因素是倒排记录表的访问顺序,一个启发式的想法是按照词项的文档频率(也就是倒排记录表的长度)从小到大依次处理,如果是复合的布尔检索式,我们可以保守的估计出每一个子检索式的结果大小然后按照从小到大的顺序依次处理。
与布尔检索模型相对的是有序检索模型或者叫排序检索模型,后者不是通过具有精确语义的逻辑表达式来构建查询,而是采用一个或者多个词来构成自由文本查询,需要确定哪些文档最能满足用户的需求。
实际上,严格的布尔检索模型并不能满足用户的要求,实际应用中往往会在系统中加入更多的操作,比如词项近邻等。
上面我们讲了如何构建倒排记录表,以及在布尔查询中基础使用,接下来讲一下倒排记录表的具体实现问题,包括如何得到词项词典,以及一些扩展的索引结构。
词项集合的确定包括词条化、去除停用词、词项归一化、词干还原和词性归并。词条化是将给定的字符序列拆分成一系列子序列的过程,其中每一个子序列称为一个词条;去除停用词的目的是为了去除那些语义内容1余文档主题关系不大的高频词,同时节省存储空间;词项归一化是将看起来不完全一直的词条归纳成一个等价类,以便在他们之间进行匹配;词干还原和词性归并的目的是为了减少词的曲折变化形式,并且有时候会将派生词转化为基本形式。
考虑两个大小分别为m和n的倒排记录表的合并问题,其时间复杂度是 O ( m + n ) O(m+n) O(m+n),为了优化类似查询,一种方法是采用跳表,用少量的空间去换取时间上的优化。
跳表是在构建索引的同时在倒排记录表上建立跳表,跳表指针能够提供捷径来跳过那些不可能出现在检索结果中的记录项,如下图所示:
跳表的使用方式很直观,不再赘述。现在要考虑的问题是如何选择跳跃步长,一个简单的启发式策略是在每个 p \sqrt{p} p处放置跳表指针。
有时候我们需要对一个完整的短语进行检索,不希望我们的检索系统将其拆分成多个词项,这种情况下,原来的倒排索引就不能满足要求了,这里讨论两种解决该问题的方法。
二元词索引,为了处理长度为2的短语查询,我们可以扩展我们的索引结构,将文档中每两个连续词都看成一个短语词项,并为其建立倒排记录表,这样我们就有了词典为二元词项的倒排索引。
如果短语中的词项个数超过两个,可以简单的将其拆分成由AND连接的多个二元查询,比如 ( A B C ) (A\ B\ C) (A B C)可以拆分成 ( A B A N D B C ) (A\ B\ AND\ B\ C) (A B AND B C),但是这种方法存在的一个问题就是可能会有错误的返回结果。
二元词索引可以扩展到个更长的词序列,随之而来的问题就是我们的倒排词典可能会变得非常大,实际上当长度为2的时候词典的规模已经比原来大了很多。
很显然,二元词索引并不能真正解决短语查的问题,实际中更常用的是位置信息索引,顾名思义,就是将此项出现的位置信息存储在此项的倒排记录表中,形式为:文档ID:(位置1,位置2)。为了方便计算此项权重,我们往往也会将此项的频率写进倒排记录表。如下图所示:
为了处理短语查询,在前面提到的倒排记录表合并算法的基础上,我们不仅仅要考虑词项是否出现,还要考虑其出现的位置。
采用位置索引会大大增加倒排记录表的存储空间,也会提高倒排记录表合并的复杂度。
一个混合策略是:对某些查询使用短语索引或者只使用二元词索引,而对其短语查询使用位置索引。短语索引所收录的那些较好的查询可以通过分析日志得到,往往是那些高频常见的查询,或者是处理开销比较大的查询,比如这样一些短语,它们中的每一个词都很常见,但是组合起来却很少见。
在介绍正式内容之前,我们先了解一下词汇表的两种存储结构:哈希表和搜索树。哈希表将每一个词项映射成一个整数,为了减少碰撞,需要足够大的目标空间,查询词项稍有变化都会导致哈希结果完全不同,因此哈希表的存储方式不能处理前缀查询。搜索树能够解决上面的大部分问题,它支持前缀搜索,最为出名的就是二叉树,每个内部节点都代表一个二值测试,测试结果用于确定下一步应该搜索的子树,二叉树高效搜索的关键是树的高度,即树的平衡性,因此在对二叉树进行增删的同时要进行平衡化处理。
在上面的介绍中,我们已经可以处理一般的布尔查询和邻近查询(即短语查询),有的时候我们对需要查询的词项的拼写形式没有绝对的把握,这个时候就需要通配符查询,类似于 ( a ∗ b ) (a*b) (a∗b),查询以a开头以b结尾的词项相关的信息。
除此之外,有的时候我们的查询可能出现拼写错误,导致精确查询没有返回结果,我们的检索系统应该能够对这类错误有一定的鲁棒性。
最后,我们会在普通的倒排索引的基础上介绍一些扩展之后的倒排索引。
我们首先看一种比较简单的形式, ( a ∗ ) (a*) (a∗),即通配符出现在尾部,这种查询称为前缀式查询,基于搜索树的词典结构对于处理这种查询来说非常方便。同样的,如果通配符出现在首部,即后缀式查询,我们可以使用反向搜索树来存储词典结构,两者结合起来的话我们就可以对更一般的通配符出现在中间的查询进行处理了,即分别使用正向搜索树和反向搜索树搜索通配符两边的子串,将得到得结果进行取交集。这样我们就可以处理只包含单个*的查询。
对于更一般的通配符查询,主要思想是将给定的通配符查询 q w q_w qw表示成布尔查询 Q Q Q,然后在给定的倒排索引上进行处理。
轮排索引,轮排索引是倒排索引的一种特殊形式,如下图所示:
首先在词项的末尾加入一个结束标识符,然后对词项的每一个旋转结果都构造一个指针来指向原始词项,我们称这些旋转之后得到的词项为轮排词汇表,我们可以使用搜索树来存储轮排词汇表。
有了轮排索引之后,下一步就是讲查询词项转化成以*结尾,然后我们就可以在轮排词汇表中搜索符合相应前缀的词项,然后通过指针在普通倒排索引中查找这些词项,从而检索出需要的文档。这样我们就可以处理单个*的查询了。对于多个*的查询,可以先忽略之间的通配符,只考虑前缀和后缀,然后进行后过滤。
k-gram索引,轮排索引最大的问题就是导致索引表的存储空间急剧增长,在k-gram索引中,其词典由词汇表中所有词项的所有k-gram形式构成,而每个倒排记录表则由包含该k-gram的词项组成,如下图:
其实k-gram索引是对轮排索引的一种优化,轮排索引汇中要考虑词项的所有旋转形式,而k-gram索引中只需要考虑词项中任意连续的k个字符组成的k-gram词项,大大节省了存储空间。k-gram所以结构其实也是一种倒排索引结构,我们首先对查询进行解析,构造新的布尔查询,然后在k-gram索引中搜索得到词项,然后在普通倒排索引中利用这些词项去搜索需要的文档。
这里主要介绍拼写校正常用的三种方法:基于编辑距离的拼写校正、基于k-gram重合度的拼写校正、基于上下文敏感的拼写校正。
基于编辑距离的拼写校正,首先,编辑距离定义为一个字符串转换成另一个字符串的最小编辑操作数。这些操作包括:增加一个字符,删除一个字符,替换一个字符,编辑距离的概念可以进一步推广,比如给每一种操作赋予不同的权重。编辑距离的计算采用动态规划的方法,其时间复杂度是 O ( s 1 ∗ s 2 ) O(s_1*s_2) O(s1∗s2)。
有了编辑距离,一种简单的做法是通过计算输入词项和词典中所有词项的编辑距离来进行拼写校正,但是这种穷举的做法明显是不可取的。一种启发式的方式是将搜索限制在与查询词具有相同首字母的字符串上,进一步的,我们可以使用轮排索引的思想,考虑查询字符串的每一种旋转形式,通过遍历B树来访问轮排索引,返回以r(查询字符串的一种旋转形式)开头的词项,其实这种方法仅仅考虑了编辑操作中的增删操作,没有考虑替换操作带来的影响,为了解决这个问题,可以对旋转形式做一定的修改,比如忽略其一定长度的后缀,然后再对其进行轮排索引搜索。
基于k-gram重合度的拼写校正,为了进一步较少计算编辑距离后得到的词汇表大小,我们希望能够预先筛选出一下和查询字符串比较相近的词项,然后对这些词项进行编辑距离计算。我们利用k-gram索引来查找与查询具有很多公共k-gram的词项,在这里我们需要清楚k-gram索引其实也是倒排索引,只不过不是对文档按照词项进行索引建立,而是对所有的词项按照k元字符串进行索引建立,比如使用2-gram索引搜索bord,首先将bord拆分成三个二元字符串:bo、or、rd,然后在2-gram索引中进行检索,但是这里我们并不需要要求结果一定包含所有的二元字符串,我们可以对包含的程度进行一个度量,比如Jaccard系数。所以在检索的过程中,我们对每一个二元字符串对应的词汇表进行扫描,计算词汇t和查询q的Jaccard系数,如果超过预定的阈值,就输出。
所以现在的拼写校正方法是,首先使用k-gram索引返回可能是查询 q w q_w qw的潜在正确拼写形式的词项集合,然后计算该集合中的每个元素和 q w q_w qw之间的编辑距离并选择具有较小编辑距离的那些词项。
上下文敏感的拼写校正,我们在进行查询的时候,有的时候每一个词项都没有错误,但是组合在一起却不合理,导致搜索引擎返回的结果很少,面对这类错误,我们仍要对每个单词找到可能正确的拼写,即使它们本身就是正确的,然后尝试对查询中的每个词进行替换。但是上述的穷举过程可能开销非常大,一种启发式的方法是分析日志,找出高频组合词来获得可能正确的拼写组合。
本节主要介绍索引的构建方法,包括四种:基于块的排序索引构建算法、内存式单遍索引构建算法、分布式索引构建、动态索引构建。
我们在一开始就介绍了如何构建不包含位置信息的倒排索引,但是其整个过程都是在内存中完成的,对大规模文档集进行索引建立的时候需要引进二级存储介质,这时候我们需要一个更具有扩展性的索引构建算法。
为使索引构建过程效率更高,我们将此项用其ID来表示,而不是在前面提到的字符串形式,每个此项ID是其唯一标识,我们可以在处理文档之余将词项映射成其ID。因此我们有一个额外的存储结构是词项-词项ID映射。
基于块的排序索引构建算法的步骤如下:
实际上,上述步骤只是基础倒排索引构建的一个简单扩展,2-3步其实就是普通倒排索引的构建,我们只是将大的文档集进行分而治之。
由于BSBI算法需要将词项映射成其ID,会占用大量存储空间,在SPIMI算法中,我们不在使用词项ID,而是直接使用词项,这样带来的一个直接问题是我们无法知道所有词项的信息,因此这里采用动态添加词典的方法构建倒排记录表,算法步骤如下:
返回调用上面的算法就可以处理完所有的文档。SPIMI的最后一步和BSBI一样,也是合并多个块,得到最终的倒排索引。
web搜索引擎通常使用分布式的方法来构建索引,这里介绍基于词项分割的分布式索引构建方法,实际上实际中更常用的是基于文档分割的索引。基于词项分割的索引构建方法实际上是MapReduce的一个应用,其流程如下:
首先,输入数据被分割成n个数据片,然后每一个数据片会分派给一个分析器执行map阶段,也就SPIMI和BSBI中的分析任务,每个分析器的输出就是排序后的词项ID-文档ID对,分析器会将输出结果存在本地的中间文件,然后在Reduce阶段,每一个倒排器会负责处理所有分区文件中固定范围的键,比如a-f,最后得到最终的倒排记录表。
实际上分布式索引构建方法的过程是SPIMI、BSBI的结合,首先将文档集分块,并行处理得到排好序的词项ID-文档ID对,然后由倒排器进行分布式的倒排索引构建,不同的是,在SPIMI和BSBI中是串行的对所有分区文件进行合并,这里是并行的同时对所有分区文件的不同部分处理。
当文档集随着文档的增加和删除而变化时,我们需要对倒排索引进行更新,最简单的方法是定时的从头到尾更新索引,但是这样会导致新文档的检索有一定延迟,一个比较好的办法是,同时维护两个索引,一个是主索引,一个是辅助索引,辅助索引负责管理最近的文档增删记录,并且定时的将辅助索引合并到主索引中。
当需要建立的文档集规模较大时,其索引也会占用很大的空间,本节讨论如何对索引进行压缩,压缩索引有两个隐含的好处:
本节首先介绍将字典视为长串以及按块存储的词典压缩技术,然后介绍两种倒排记录表的压缩方法:变长字节码和 γ \gamma γ编码。
最简单的存储词典的数据结构是,整个词典采用定长数组来存储并且所有词项按照词典序排列,但是很明显所有词项的长度是不同的,这就导致了大量的空间浪费,一种解决这个缺陷的方法是,将所有的词项看成一个长字符串,并给每个词项添加一个定位指针,可以标识当前词项的开始和上一词项的结束,词项的查找定位可以使用二分法。这种机制可以节省大约60%的存储空间。如下图所示:
上面的方法给每一个词项都增加了一个4B的定位指针,我们可以通过其他的定位方式来节省这部分空间。首先将长字符串中的词项进行分组变成大小为k的块,k是每一块中词项的数目,然后对每一个块只保留第一个词项的指针,每一个词项用一个额外的字节将其长度存储在每个词项的首部,如下图所示:
显然的,k越大压缩效率越高,但是同时词项的定位效率也就越低,因此在压缩和词项查找效率之间必须要保持某种平衡。
至此,我们还没有利用词项之间的冗余信息,实际上,按照词典顺序排序的连续词项之间往往具有公共前缀,因此,我们可以利用它们的公共前缀压缩编码。当我们识别到一个公共前缀之后,后续的词项便可以使用一种特殊字符来表示这段前缀,这种方法称为前端编码。
考虑到高频词出现的文档ID序列值相差不大,我们可以考虑用间距间接的表示文档ID,而不是文档ID本身,如下图所示:
为了对小数字采用比大数字更短的编码方式,这里主要考虑两种方法,按字节压缩和按位压缩。
可变字节码利用整数个字节来对间距编码,字节的后7为是间距的有效编码区,第1位是延续位,如果该为1,表示本字节是某个间距编码的最后一个字节。下图是一个例子:
可变字节编码可以将倒排索引压缩50%。
γ 编 码 \gamma编码 γ编码是由一元编码发展而来,由两部分组成,第一部分表示偏移部分的长度,以0为结束标识,第二部分是偏移,因此在解码的时候,先读入第一部分,遇到0结束,然后就知道后面的偏移的长度,读入偏移,然后补上原来去掉的前端1,就得到原来的值。如下图所示:
上面我们介绍了支持布尔查询的索引处理方法,给定一个布尔查询,一个文档要么满足要求,要么不满足,在文档集规模很大的时候,满足布尔查询要求的文档往往会非常多,这时候对满足要求的文档进行排序就显得很重要了。本节首先介绍了参数化索引和域索引的概念,可以使用文档的元数据进行索引,同时还能够对文档进行简单的评分;然后引入了文档中词项权重的概念,介绍了几种计算权重的方法,最后介绍了向量空间模型。
实际上大多数文档都具有额外的结构信息,上文中我们只是简单的把文档看成一系列词项的序列,我们可以在检索的时候用用上文档的元数据,比如作者、时间、标题等等,可以称之为字段,每个字段都存在一个与之对应的参数化索引,我们将查询解析成分别对每个字段的索引,然后执行参数化索引上的合并操作。
域索引相当于是对参数化索引的一个细化,我们一般讲取值相对比较固定的元数据称为字段,比如发布时间,而将那些更加自由的任意文本称为域,比如文档的标题和摘要,我们可以对文档的不同域构建独立的倒排索引,下面是一个例子:
由于需要分别对每个域建立索引,可能需要大量的空间,因此我们可以考虑对域进行编码来减小词典的规模,比如下图:
将词项在不同域中的出现情况统一编码,同时还支持域加权评分。
所谓的域加权评分就是给每个域一个权重,所有域的权重之和为1,每个文档的评分计算如下:
∑ i = 1 l g i s i \sum_{i=1}^lg_is_i i=1∑lgisi
权重的设置可以有领域专家来设定也可以由用户来指定,但是更好的办法是从人工标注好的数据中学习得到,就是一个权重学习过程,其目标函数就是相关样本的评分尽量高,不相关样本的评分尽量低。
首先介绍一下词袋模型,即忽略词项的出现顺序,只考虑词项的出现次数。给定一个查询,我们可以简单的将其看成多个词项组成的集合,为了对匹配文档进行评分,一个简单的想法是先基于每个查询词项与文档的匹配情况对文档进行打分,然后对所有查询词项上的得分求和。首先,我们需要对文档中的每一个词项赋予一个权重,最简单的方式是将权重设置为词项在文档中的出现次数,这种权重计算方式成为词项频率,简称tf。位于文档d,使用上述权重计算方法,可以得到一个权重集合,这和布尔检索形成了强烈的对比,布尔检索不考虑词项的出现次数,只考虑是否出现。
原始的词项频率面临这样一个问题,在进行查询相关度计算时,认为所有词项的重要性是一样的,实际上,很多词在所有文档中的词项频率可能都很高,但是很少有区分能力,因此这种词的重要性应该降低,我们提出了文档频率,简称df,表示出现词项的文档的数量,由于该值可能比较大,一般会取其log值,词项的逆文档频率(idf)定义如下:
i d f t = log N d f t i d f_{t}=\log \frac{N}{d f_{t}} idft=logdftN
综合tf、idf我们就有了最常用的tf-idf权重计算方式:
t f − i d f t , d = t f t , d × i d f t \mathrm{tf}-\mathrm{idf}_{t, d}=\mathrm{tf}_{t, d} \times \mathrm{idf}_{t} tf−idft,d=tft,d×idft
这样就可以把文档看成是一个向量,其中的每个分量都对应词典中的一个词项,因此我们可以引出重合度评分指标:
( q , d ) = ∑ t ∈ q t f − i d f t , d (q, d)=\sum_{t \in q} \mathrm{tf}-\mathrm{i} \mathrm{d} \mathrm{f}_{t, d} (q,d)=t∈q∑tf−idft,d
上一节引出了向量空间模型的概念,但是只是将文档表示成向量,然后基于词项的权重得到基于重合度的评分指标,实际上我们可以把查询也表示成向量,然后通过计算查询向量和文档向量的内积来得到评分。同时,为了对结果进行归一化,我们采用余弦值作为相似度计算的公式,两个文档之间的相似度计算如下:
s i m ( d 1 , d 2 ) = V ⃗ ( d 1 ) ⋅ V ⃗ ( d 2 ) ∣ V ⃗ ( d 1 ) ∥ V ⃗ ( d 2 ) ∣ si m\left(d_{1}, d_{2}\right)=\frac{\vec{V}\left(d_{1}\right) \cdot \vec{V}\left(d_{2}\right)}{\left|\vec{V}\left(d_{1}\right) \| \vec{V}\left(d_{2}\right)\right|} sim(d1,d2)=∣∣∣V(d1)∥V(d2)∣∣∣V(d1)⋅V(d2)
同样的,查询向量和文档向量的相似度计算也可以表示成:
score ( q , d ) = V ⃗ ( q ) ⋅ V ⃗ ( d ) ∣ V ⃗ ( q ) ∥ V ⃗ ( d ) ∣ \operatorname{score}(q, d)=\frac{\vec{V}(q) \cdot \vec{V}(d)}{|\vec{V}(q) \| \vec{V}(d)|} score(q,d)=∣V(q)∥V(d)∣V(q)⋅V(d)
除了最原始的tf-idf计算方法之外,还有很多针对具体情况的改进计算方法,针对不同的tf、idf计算方法以及他们之间的组合如下:
//TODO
上一节我们介绍了词项权重的计算和向量空间模型,本节将会介绍一些启发式策略,这些策略能够加快评分算法的速度,当然其中的不少策略可能不能精确返回与查询相匹配的前K篇文档。
为了精确返回与查询匹配的前K篇文档,我们需要计算查询与文档集中所有文档的相似度然后排序,这是极其耗时的,我们希望能够减少参与计算的文档数目。即找到一个文档集合A,其数目远远小于文档集大小N,同时大于我们要求的K,然后对A中的所有文档进行相似度计算,返回前K篇文档。
对于一个包含对个查询词项的查询来说,我们可以通过一些启发式的方法来减少需要计算相似度的文档的数量:
层次型索引,这个概念我们在前面已经涉及到过,就是在普通倒排索引的基础上再加一层索引,比如在优胜表中,我们可以认为高端表的处理就是第一层索引,而低端表的处理时第二层索引,在第一层索引的结果不能满足要求时我们才启动第二层索引。
查询分析,一般的搜索界面通常情况下会对用户屏蔽查询操作符,使用查询解析器对用户输入的自由文本进行解析,一般会产生如下的一系列查询:
一个完整的搜索系统的组成如下图所示:
//TODO