最近在看《Introduction to Information Retrieval》(中文版为《信息检索导论》,下文简称为“IR”),是最经典的信息检索书籍之一了。由于淞姐要求我细读这本书然后跟同事分享,就有了这个版块,之后会陆续添加后续章节内容。即使是站在巨人的肩膀上了(看了中文版和英文版IR,也从网上搜集了不少内容),但很多细节往往还是需要自己用心体会。从一个读者到一个讲解人,在第一次做分享的时候已经感觉很不容易了,有些东西原来只是一知半解,能自己想清楚但却很难表述。这些内容就是个人的一些读书笔记,希望能给刚好想了解搜索引擎的你带来一些启发。文中会尽量备注英文原版中的术语以免丢失本意。英文电子书可以从官网获取 Introduction to Information Retrieval。
线性扫描和倒排索引
从线性扫描讲起
如果需要从文档集合 D D 中搜索包含某个关键词的文档,最直接的方法就是从头到尾扫描文档集 D D ,对每个文档都查看是否包含关键词 k k 。这种线性扫描的方式最为直观易懂,在Unix/Linux系统中的文本扫描命令grep做的就是这种工作。然而,当需要检索的文档规模非常大时,这种线性扫描的方式的效率会变得非常低下。线性扫描的时间复杂度与文档集大小成正比,在大规模文本检索的场景下,线性扫描不再适用。大型的Web搜索引擎需要检索千亿级别数量的网页,如果采用类似grep的线性扫描方式,就需要依次扫描这么多的文本来判断每一个网页是否符合查询要求,这样的检索慢如蜗牛,用户显然无法接受。目前,搜索引擎通过事先给文档建立索引(index)的方法来避免这种线性扫描,使得搜索过程非常快速,这种技术称为倒排索引。
IR中,以《莎士比亚全集》为例子来说明倒排索引的基本知识。这里笔者就重新举个例子吧。假设某文档集中存在这样的三篇文档(还有其他文档不列举),分别是为“Apple and cat”, d2 d 2 为“I like cat”, d3 d 3 为“I have an apple”,用矩阵表示,当文档d中存在词项t时,矩阵元素(d, t)为1,否则为0:
| 文档\词项 | apple | and | cat | I | like | have | an | ... . . . |
|---|---|---|---|---|---|---|---|---|
| d1 d 1 | 1 | 1 | 1 | 0 | 0 | 0 | 0 | ... . . . |
| d2 d 2 | 0 | 0 | 1 | 1 | 1 | 0 | 0 | ... . . . |
| d3 d 3 | 1 | 0 | 0 | 1 | 0 | 1 | 1 | ... . . . |
| ... . . . | ... . . . | ... . . . | ... . . . | ... . . . | ... . . . | ... . . . | ... . . . | ... . . . |
倒排索引
使用类似grep命令的线性扫描方法的话,如需查询cat相关的文档,需要遍历所有文档(把每篇文档从头到尾扫描一遍),返回包含cat关键词的文档集。观察上面的矩阵表格,查询包含cat关键词的文档,其实只需要查看cat的那一列都有哪些元素大于0的文档就行。利用这种技巧,词项成为索引的基本单位,如果事先建立了类似上表(表1)的矩阵(或者其他形式的数据结构,如链表),那么就可以快速地找到与查询的关键词相关的文档。我们把上面的矩阵转置一下,把词项(term)作为横坐标,文档(document)作为纵坐标,然后词项按照字母表排序,得到更直观的矩阵:
| 词项\文档 | d1 d 1 | d2 d 2 | d3 d 3 | ... . . . |
|---|---|---|---|---|
| an | 0 | 0 | 1 | ... . . . |
| and | 1 | 0 | 0 | ... . . . |
| apple | 1 | 0 | 1 | ... . . . |
| cat | 1 | 1 | 0 | ... . . . |
| have | 0 | 0 | 1 | ... . . . |
| I | 0 | 1 | 1 | ... . . . |
| like | 0 | 1 | 0 | ... . . . |
| ... . . . | ... . . . | ... . . . | ... . . . | ... . . . |
对于上表(表2-词项-文档关联矩阵),从行来看,可以表示词项在哪些文档中出现或者不出现;从列来看,可以得到每个文档都有哪些词项或者没有哪些词项。
看到这里,倒排索引的概念已经呼之欲出了。不着急,我们先看看如何利用上面的表2来做搜索查询。布尔检索模型接受布尔表达式查询,用户可以通过使用AND,OR以及NOT等逻辑操作符来构建查询表达式。假如用户提交查询“cat AND NOT apple”(表示寻找带有cat但不含有apple的文档),我们从表2分别取出cat和apple对应的行向量,(110)和(101),[更规范的写法应该是(1,1,0)和(1,0,1),此处简写],然后根据查询做对应的逻辑处理,如下:
(110) AND NOT (101) = (110) AND (010) = (010)
结果向量(010)中第二个元素为1,表明该查询对应的结果是文档
d2
d
2
。我们来确认一下,文档
d2
d
2
的内容为“I like cat”,确实为查询所需。
读者可能奇怪为什么这种基于位(bit)的逻辑操作可以得到正确的结果。我们举个简单的例子,如果另外一个用户提交了查询“cat AND apple”,我们需要从表2取出cat和apple对应的行向量。读者观察一下表2的那些行向量都是什么形式?cat的行向量(110)的第一位的1表示文档 d1 d 1 包含词项cat,第二位的1表示文档 d2 d 2 包含词项cat,第三位是0,表示文档 d3 d 3 不包含词项cat。词项对应的行向量的第 i i 位表明这个词项是否包含于第个文档( di d i )中!对于查询“cat AND apple”,需要返回同时包含cat和apple的文档。我们取出cat和apple对应的行向量,分别为(110)和(101),根据“一个词项对应的行向量的第i位表明这个词项是否包含于第i个文档( di d i )中”,词项cat和apple的行向量的第i位如果都是1表明 di d i 同时包含cat和apple,否则不同时包含cat和apple。因此cat和apple对应的行向量做AND操作可以获得同时包含cat和apple的文档都有哪些。(110) AND (101) = (100),只有文档 d1 d 1 符合查询要求。我们再看一个例子,对于查询“NOT apple”,需要返回不包含apple的文档。我们从表2取出apple对应的行向量(101),我们已经知道对应位为1表示文档包含这个词项,那么,现在我们需要查询不包含这个词项的文档,当然就是取反了(NOT操作)。NOT (101) = (010),结果(010)表示第一个和第三个文档都包含apple,只有第二个文档 d2 d 2 不包含apple。至此,通过这几个简单的例子,读者应该能够理解这种布尔表达式查询的工作原理了。
为了向倒排索引的概念平滑过渡,现在我们考虑一个更真实的场景(使用IR中的例子)。假如我们有100万篇文档(document),每篇文档大约1000个词,那么,通常这些文档大概会有50万个不同的词项。此时,回头看看表2(词项-文档关联矩阵),这个矩阵的规模会变得非常巨大,会有50万行(因为有50万的词汇量)和100万列(因为有100万篇文档),元素数量为5000亿(50万×100万),存放这张表需要的内存远远大于一台计算机内存的容量!此外,不难发现,这个50万×100万规模的矩阵,大部分元素都是0,可以从某一列来估算,文档 di d i 平均大约有1000个单词,但 di d i 那一列却有50万个元素(因为全局有50万的词汇量)。这么一算,这个词项-文档关联矩阵大约有99.8%的元素都为0。显然,只记录原始矩阵中1的位置的话,所需的存储空间会大大地减少,倒排索引(inverted index)正是起到这样的作用!
我们观察表1和表2,表1以文档为基本考察要素,而表2以词项为基本考察要素,表2由原始的表1通过inverted(转置,引申为倒排)而来,这正是倒排这个概念的由来。在倒排索引中,我们只存储那些不为0的项。倒排索引的基本结构如下图:
倒排索引带有一个词典(dictionary),词典中的每个词项(term)都有对应的一个list,保存了这个词项在哪些文档中出现过。词项对应的list中的每一个元素我们称之为一个倒排记录(posting),而把一个词项对应的整个list称为倒排表(posting list/inverted list)。所有词项的倒排表一起构成postings。[读者对于这几个定义不必太纠结,中文翻译过来感觉怪怪的]
我们对上图中的倒排索引做一个基本说明。以图1中的倒排索引为例,词项Brutus指向的list称为Brutus的posting list,posting list中的每一个元素都称之为posting(如Brutus指向的1,2,4,11,31,45,173,174等,这些posting都是文档ID,表示Brutus在这些文档中出现)。之后我们将会看到,更实用的倒排索引的posting除了存储文档ID,还会保存词项在文档中出现的位置以及词项在该文档中出现的频率,这些都是后话了。
构建倒排索引
为获得由索引(indexing)带来的检索速度的提升,我们需要事先构建索引。
索引构建分为四个步骤:
(1) Collect the documents to be indexed(收集构建索引的文档集,也就是我们检索系统需要检索的所有文档)
(2) Tokenize the text, turning each document into a list of tokens(把文本转化为token,就是把文本分割成一个一个的词,中文版的信息检索导论中将token译为词条)
(3) Do linguistic preprocessing, producing a list of normalized tokens, which
are the indexing terms(语言学预处理,利用词干还原(stemming)和词形归并(lemmatization)的方法,将token转为normalized token,例如将不同时态的单词转为其词根,将单复数名词统一转为单数形式,还原token的本来形式来获得统一的表示[注:更专业的说法叫“归一化”],这些还原的token就是将要索引的词项(term))
(4) Index the documents that each term occurs in by creating an inverted index,
consisting of a dictionary and postings(对文档中的词项构建倒排索引,最终倒排索引的形式可以参考图1)
步骤1~3是索引构建的基础,我们做一个简要的说明。
步骤(1)中收集文档的工作,在Web搜索引擎中就是通过网络爬虫(web crawler)来搜集分布于Web各处的网页。搜集的网页还不能直接用于后面的步骤中,还需要对网页文本做诸如去除标签,过滤广告等处理。
步骤(2)就是tokenization(词条化),例如,对于英文文本而言,就是根据空格把单词一个一个地提取出来,把原始文本分割成token。当然了,如果只是这样简单的分割的话,会把“New York University”分成三个token了,因此英文也会存在短语划分问题。中文文本的话涉及的主要方法就是分词(word
segmentation)了,因为汉语字与字之间没有空格,并且由于文法的特殊性,词与词组的边界模糊,这让tokenization的问题更加复杂。中文分词技术属于自然语言处理的范畴,有兴趣的读者可以参考相关的专业文献。
步骤(3)就是将步骤(2)产生的token转为更加统一规范的词项(term),例如在文本中可能出现“apple”、“apples”、“Apple”这类token,但我们知道这几个token都是表达苹果(apple)的意思,因此,在构建索引的时候通常会把这几个token统一还原为“apple”,只为“apple”建立索引项,那么“apple”就是一个term(词项)了。倒排索引里面所有的term组成的集合我们称为词典(dictionary,也有称为词汇表(vocabulary)的)
现在,假设前三步都已完成,原始的网页已经被我们分割成一个个的词项(term)。下面,我们体验一下构建基本的倒排索引的过程。
给定一个文档集,给每个文档分配一个唯一的标识符(docID),通过步骤1~3,我们会得到很多的词项,用二元组的形式表示为(词项,文档ID),表示词项在文档ID对应的文档中出现。布尔检索只考虑词项在文档中有没有出现,因此,即使一个词项在某个文档中多次出现,我们也只记录一个这样的(词项,文档ID)。建立倒排索引的图形化表示如下图2所示:
图2中左边部分是由原始文档经过步骤1~3产生的(词项,文档ID)二元组;将这些二元组按照词项的字母顺序进行排序,就会把词项相同的二元组排在一起(图2中部);最后,把同一词项的多个二元组合并在一起,这个词项出现的文档ID用list存放(也就是posting list),并且根据文档ID排序。图中所示的倒排索引还存储了词项在文档中出现的次数(在同一个文档中多次出现算一次)。最终,我们形成了一个包含许多词项(term)的字典(dictionary),每个词项都有一个倒排记录表(posting list)。
至此,倒排索引已经建立好了,那么,如何利用这个倒排索引做布尔查询呢?
处理布尔查询
使用IR中的例子,查询为“Brutus AND Calpurnia”。我们分别从倒排索引表中取出词项Brutus和Calpurnia的posting list,然后对这两个词项的posting list求交集,即可得到查询需要输出的文档集合。如下图,
然而,求交集这个操作的时间复杂度应该认真考量!一个显而易见的方法就是在外循环中遍历其中一个posting list,然后在内循环中对另外一个posting list的元素进行匹配查找。然而,这种求交集的方式需要的比较次数为
O(x∗y)
O
(
x
∗
y
)
,其中x、y分别是两个posting list的长度。考虑前面提到过的100万篇文档(document),每篇文档大约1000个词,这些文档大概会有50万个不同的词项的例子,那么每个posting list的长度大约为2000(
100万×1000÷50万=2000
100
万
×
1000
÷
50
万
=
2000
)。那么就需要做大约400万(2000×2000)次比较才能返回查询结果。在Web搜索引擎中,文档集规模比例子要大得多,查询需要的比较次数就更多了。那么,有没有更优的合并算法呢?
回忆我们构建倒排索引的时候,对posting list中的posting根据文档ID排序了,这个时候就可以派上用场了,下面的算法只需要
O(x∗y)
O
(
x
∗
y
)
次操作!
/* 合并倒排记录表,p1, p2是根据docID排序的有序list,对p1和p2求交集(合并) */
Intersect(p1, p2)
answer = <> // 初始化取交集的结果
while p1 ≠ NIL and p2 ≠ NIL // list不为空
if docID(p1) == docID(p2) // 两个posting list遇到一样的docID
Add(answer, docID(p1))
p1 = p1.next
p2 = p2.next
else if docID(p1) < docID(p2) // docID小的posting list的指针前移(p1的小)
p1 = p1.next
else // docID小的posting list的指针前移(p2的小)
p2 = p2.next
return answer
上面的伪代码是我根据IR书中的伪代码重写的,因为原书中的涉及if-else判断语句的代码缩进非常奇怪,附上原图:
要使用上述的合并posting list的算法,那么所有的posting list必须按照统一的标准进行排序。这里,我们使用的排序方法是根据全局统一的文档ID(docID)来对posting list排序。
对上述代码稍作修改就可以写出对p1,p2求并集的代码(最后需要把非空的list添加到answer中)。而对于NOT查询,只需要在结果中去掉相应的postings。
查询优化
通过恰当地组织查询的处理过程可以进一步降低上述合并倒排记录表过程的比较次数。考虑查询:
直接的想法是,我们依次取出Brutus,Caesar和Calpurnia的倒排记录表,然后先合并Brutus和Caesar得到一个中间结果tmp_answer(调用Intersect(Brutus.list, Caesar.list)),然后再把tmp_answer与Calpurnia合并得到最终结果(调用Intersect(tmp_answer, Calpurnia.list))。
然而,一个启发式的算法可能取得更好的效果,降低比较次数。根据词项的文档频率(亦即泡排记录表的长度)从小到大依次进行处理。先合并两个最短的倒排记录表,那么所有中间结果的长度都不会超过最短的倒排记录表,最终需要的比较次数也就很可能最少。
这种启发式算法大部分情况下能取得很好的效果,但在某些情况下并不是最优的,最后,有兴趣的读者可以思考一下如何合并以下的三个倒排记录表才能最优(可见这种启发式算法并不一定能保证比较次数最少)。
apple → [5,6,10]
cat → [3,5,6,10]
luffy → [6,11,12,13,14]
本文介绍了信息检索中的倒排索引和布尔检索模型。从线性扫描的低效出发,引入倒排索引的概念,通过倒排索引实现快速搜索。布尔检索模型允许用户使用AND、OR和NOT等逻辑操作符构造查询,通过倒排索引处理布尔查询,提高搜索效率。文章通过实例展示了如何构建倒排索引和处理布尔查询的过程。
793





