Information Retrieval(信息检索)笔记03:Index Construction

首先我们再次回顾一下之前介绍的构建倒排索引的几个主要步骤:

  1. 收集待建索引的文档
  2. 对这些文档中的文本进行词条化 (Tokenization)
  3. 对步骤 2 中得到的词条 (Token) 进行语言学预处理,得到词项 (Term)
  4. 根据词项对所有文档建立索引 (Index Construction)

在之前一篇笔记中,我们已经对 Step 1-3 进行了比较详细的介绍,这一篇中,我们主要来看最后一个环节 “建立索引 (Index Construction)”。

硬件基础(Hardware Basics)

构建信息检索系统时,很多决策都依赖于系统所运行的硬件环境。其中有一些硬件性能的参数与我们对于 IR 系统的设计有着举足轻重的作用,因此,我们先来对这些内容进行一下回顾:

  • 访问内存 (Memory) 中的数据比访问磁盘 (Disk) 数据的速度要快得多。 硬件系统有着一个金字塔形的存储结构,最顶空间最小,但是访问最快,底部容量最大,但是访问最慢:Register - Cache - Main Memory - (SSD) - Hard Disk - Tape/CD。 因为 Disk 的基本存储单位是 Page/Block,因此在读写数据的时候,需要访问其对应的整个 Block
  • 在进行磁盘 (Disk) 读写时,磁头移动到数据所在的磁道(这里我们可以看做是数据所在的 Block 的起点)需要一定的时间,这一段时间就被称为寻道时间 (Disk Seeks)。 值得注意的是,在寻道的时候,无法同时进行数据传输。因此,为了使传输速率尽可能最大,我们希望连续读取的数据也存放在连续的 Block 中,即实现 Sequential Write & Read。
  • 操作系统一般以 Block 为单位进行读写。 一般来说,采用如下的读写方式:先寻道,找到数据所在的 Block ,将整个 Block 传输到 Memory 中缓存起来 (Buffer),我们对这一块缓存在 Memory 中的数据进行操作,最后传回 Disk。因此,传输一大块数据的时间比起传输多个小块数据更短
  • IR 系统的服务器往往有数 GB 甚至数十 GB 的内存,其可用的磁盘空间大小一般比内存大小要高多个数量级 (TB 甚至 ZB)

下面给出一些具体的时间参数,我们只需要对它们心中有数即可:

在这里插入图片描述

基本的索引构建(Baisc Index Construction)

在这一部分的介绍中,我们使用名为 RCV1 的文档集 (Collection) 作为操作对象,我们先给出该文档集的基本信息:

符号含义
N文档 (Documents) 的数量800,000
L每篇文档 (Document) 的平均词条 (Token) 数量200
M词项 (Term = Word Types) 总数400,000
每个词条的平均字节数(含空格和标点符号)6
每个词条的平均字节数(不含空格和标点符号)4.5
每个词条的平均字节数7
T非位置化 Posting (Non-positional postings )100,000,000

针对这样的一个文档集,我们回顾一下使用朴素方法构建索引的步骤。我们首先一篇一篇读取文档 (Document),将所有文档进行序列化、词条化、词干还原等操作,如此得到每个文档中的所有词项 (Term)

在这里插入图片描述
在得到所有文档 (Document) 的词项 (Term) 之后,我们根据词项进行排序,在此过程中,我们也对相同的 Term 的不同配对进行合并(比如 I: 1 和 I: 2 合并为 I: [1, 2])。如此一来就得到了最后的结果。这是一种基于排序 (Sorting-based) 的索引构建方式,但很显然,这并不是最有效率的做法。

在正式介绍 BSBI 之前,我们先来看几种其他的索引构建方法

基于哈希表的内存中索引构建(Hash based in-memory index construction)

这一方法其实非常好理解,用一段简单的伪代码就能够表示:

index = {}

for document in collections:
	# Traverse all documents
	for term in process(document):
		# Traverse all terms
		if term not in index:
			index[term] = [document.ID]
		else:
			index[term].append(document.ID)

可以看到的是,在这个过程中,我们并不需要进行排序 (Sorting) 操作,因为对 Hash Table 的操作复杂度只有 O(1) ,因此若整个文档集 (Collection) 中的词项总数若为 M,则总代价只有 M * O(1) 。而使用排序方法的话,总代价需要 O(M * log(M))

基于块的排序索引方法(BSBI - Blocked Sort-Based Indexing Algorithm)

为使索引构建过程效率更高,我们将词项用其 ID 来代替,每个词项的 ID 是唯一的序列编号。

我们目前所介绍的所有索引构建方式均为在内存 (Memory) 中进行的方法。但是很显然,随着我们处理的文档集越来越大,词项的数量会急剧增加,这使得很难把所有的“词项ID-文档ID对 (TermID-DocID pair)”都放在内存 (Memory) 中进行处理。那么,我们能在 Disk 中进行相同的操作吗?答案也是否定的,我们之前已经说过,寻道 (Disk Seeks) 会消耗大量的时间。如此一来,我们能够选择的便只有基于磁盘的外部排序算法 (External Sorting Algorithm)。

对于该算法,我们有一个核心的需求,那就是:在排序时尽量减少 Disk 中随机寻道 (Disk Seeks) 的次数,正如我们之前所说,磁盘顺序读取 (Sequential Read) 会比随机寻道快速得多。

基于块的排序索引方法(BSBI - Blocked Sort-Based Indexing Algorithm)就是一个符合要求的解决方法。该算法是一个标准的外部排序算法,它主要有以下几个步骤:

  1. 将文档集 (Collection) 分割为大小相等的几个部分(每个部分的尺寸应小于内存 (Memory) 的可使用容量)
  2. 将每个部分的 TermID-docID Pair 进行排序
  3. 将中间产生的临时结果存放到磁盘 (Disk) 中
  4. 将所有的中间结果进行合并,得到最终的索引

对于外部排序算法,这里我给一个介绍的比较好的网页链接,它使用了比较多的图进行解释,相当直观,可以通过它去理解一下外部排序的概念

在本例中,我们在对所有的文档进行语言学处理之后,会得到一系列形如 (TermID, docID) 的记录 (Record) ,这样的记录每个的大小为 4 + 4 = 8 Bytes,而本次我们使用的 RCV1 文档集中共有 1 亿个这样的记录(总共约有 0.8G)。我们可以将这些记录分割为10个 Block,这样每个 Block 就有 1 千万个这样的记录。之后将这些 Block 依次读入内存 (Memory) ,使用适当的内部排序算法对其进行排序,将排好序的中间结果重新写入 Disk,为下一个 Block 排序腾出内存空间。如此一来,所有的10个 Block 都已经进行了内部排序。但要注意,此时这只是局部排序,为了得到最后的全局排序结果,我们将所有中间结果进行合并,直至得到最终结果为止。

在这里插入图片描述
整个 BSBI 算法如下所示:

在这里插入图片描述

外部合并排序(External Merge-Sort)

在了解了整个 BSBI 的实现逻辑之后,我们需要对这整个过程进行具体的分析,为此,我们先来设定一些变量:

变量符号意义
BBlock/Page Size
MMemory 的大小(以 Block 为尺度)
NDocument 的数量
RDocument 中的 (TermID, docID) 对的大小

针对上述的变量,做一个简单的解释。M 是以 Block 为单位的 Memory 的大小,这是什么意思呢?比如一个 Block 为 16KB,Memory 空间有 16MB,那么我们就认为该 Memory 的大小约为 1000 Blocks。另外,为了方便考虑,对这些变量,我们做一些简单的假设:

  • 对所有 Document 来说,它们的 R 都相同
  • B = c * R,这里的 c 为常数,比如 c=5
  • 所有的 I/O 代价相同

在有了这一系列的条件之后,我们来看具体的算法实现步骤。

Phase 1

首先,向 Memory 载入数据(TermID-docID Pairs),载入的数据量应为 M * (B / R),因为 Memory 的尺寸为 M 个 Blocks,每个 Block 能容纳的 TermID-docID Pairs 数量为 B / R

在载入了这些 TermID-docID Pairs 之后,对它们进行排序,之后,将排序后的结果,整体打包存入 Disk 中的一个 Run。因此,Disk 中的该 Run 尺寸应为 M 个 Blocks。
在这里插入图片描述
这一环节就被称为初始 Runs 的生成。我们假设这里共有 200 个这样的 Runs,因此,实质上我们将原本的 Collection 数据分割为 200 个相同的部分。

Phase 2

接下来进行合并操作,这是一个递归操作。我们将 M-1 个 Runs 合并为一个新的 Run,也就是 M-1路归并 (M-1 - way Merge)

在这里插入图片描述
因此,每个新的 Run 的尺寸即为 M*(M - 1) Blocks。

接下来进行同样的操作:

在这里插入图片描述
此时所有的新的 Run 的尺寸应为 M * (M-1)2 Blocks

一直重复操作,直到得到最后的全局排序结果:
在这里插入图片描述

Cost

一共要进行多少轮操作 (Number of Passes) :

在这里插入图片描述
这里的 1 指的是生成所有初始 Runs 的那一轮操作,后半部分计算的是合并/归并操作的总次数,其中的 NR / MB 即为初始 Runs 的数量。

总 I/O 代价:

在这里插入图片描述

合并/归并算法(Merge Algorithm)

我们已经介绍了归并的具体逻辑。比较常见的合并算法有 2-路平衡归并(2-Way Merge),这其实很好理解:

在这里插入图片描述
如图所示,我们有 10 个初始的归并段(这 10 个归并段就是由 Phase 1 得到的,它们的内部已经经过排序),共进行了 4 次归并 (Merge) ,每次都由 m 个归并段(也就是中间结果)得到 ⌈m/2⌉ 个归并段,这种归并方式被称为 2-路平衡归并。我们格外需要注意的是。在实际归并的过程中,由于内存容量 (Memory) 的限制,经常不能同时将 2 个归并段全部完整的读入内存进行归并,只能不断地取 2 个归并段中的每一小部分进行归并,通过不断地读数据和向 Disk 写数据,直至 2 个归并段完成归并变为 1 个大的有序文件。

对于外部排序算法来说,影响整体排序效率的因素主要取决于读写 Disk 的次数,即访问 Disk 的次数越多,算法花费的时间就越多,效率就越低。对于同一个文件来说,对其进行外部排序时访问 Disk 的次数同归并的次数成正比,即归并操作的次数越多,访问外存的次数就越多。 因此,我们也可以将 2-路平衡归并(2-Way Merge)改变为 5-路归并, 10-路归并等。

在这里插入图片描述
可以清楚看到的是,我们对于 k-路归并中的 k 进行调整时,会直接改变进行归并的次数。增加 k 可以减少归并的次数,从而减少 Disk 读写的次数,最终达到提高算法效率的目的。除此之外,一般情况下对于具有 m 个初始归并段进行 k-路平衡归并时,归并的次数为:s=⌊logk⁡m⌋(其中 s 表示归并次数)。

但是,我们也需要知道,如果毫无限度地增加 k 值,虽然会减少读写 Disk 数据的次数,但会增加内部归并的时间,得不偿失。 例如在我们这个例子中,对于 10 个 Blocks,当采用 2-路平衡归并时,若每次从 2 个文件中想得到一个最小值时只需比较 1 次;而采用 5-路平衡归并时,若每次从 5 个文件中想得到一个最小值就需要比较 4 次。因此,对于合并/归并算法的选择是非常重要的。

内存式单遍扫描索引算法(SPIMI - Single-Pass In-Memory Indexing)

BSBI 已经是一个相当优秀的方法,但是它仍存在一些问题。最大的问题就在于,我们需要将每一个词项 (Term) 映射为一个对应的 TermID,这就意味着需要一个额外的数据结构来记录这种映射关系,比如一个哈希表,这样我们在最后输出最终的结果时,也需要使用它来得到可读性更高的结果。然而,对于大规模的文档集 (Collection) 来说,这样一个额外的数据结构会很大以致在内存 (Memory) 中难以存放。同时,我们在之前也已经讨论过,相比于基于排序的索引构建方式 (Sorting-Based Index Construction),基于哈希表的索引构建 (Hash-Based Index Construction) 会更加有效率,因此自然而然地,我们需要一种新的算法。

内存式单遍扫描索引构建方法(SPIMI - Single-Pass In-Memory Indexing)就是一个满足我们需求的算法

在这里插入图片描述
我们用文字来解释一下上述的过程。该算法逐一处理每个 (Term, docID) 对(这就是伪代码的第4行)。如果该词项 (Term) 是第一次出现,那么就将它加入到字典 (哈希表) 中这就是伪代码的第6行);如果该词项不是第一次出现,就直接返回其对应的 Posting List这就是伪代码的第7行)。

BSBI 和 SPIMI 的一个区别在于,后者直接在倒排记录表中增加一项(伪代码第 10 行)。和那种一开始就整理出所有的 (TermID, docID) 对,并对它们进行排序的做法(这正好是BSBI 中的做法)不同,这里每个 Posting List 是动态增长的(也就是说,倒排记录表的大小会不断调整),同时立即就可以实现全体 Posting List 的收集。这样一来,SPIMI 就会有两个很明显的优势:

  • SPIMI 使用词项 (Term) 而不是词项ID (TermID),它将每个块 (Block) 的词典写入 Disk,对于下一个块则重新采用新的词典,保留了 Posting List 对词项 (Term) 的归属关系,同时无需维护一个词项与其 ID 之间的映射关系表,更加节省空间
  • 不进行排序 (Sort) ,在 Posting List 中动态累积 Posting,因此处理速度更快

由于一开始不知道每个词项 (Term) 对应的 Posting List 有多大,因此算法最初只会分配一个较小的空间给每个 Posting List,当空间满了之后,就加倍(伪代码第 8~9行),但是这种操作也会导致一部分空间被浪费,我们可以想象,某个词项的 Posting List 一开始只被分配了 L 长度的空间,但是它有 L + 1 个 Posting,但是算法仍会为其分配 2L 的空间,这样后面有相当一部分空间就被浪费了。但是,我们要记得,SPIMI 不会记录 Term 与 TermID 之间的对应关系,这已经节省了大量的空间,因此,总的来说 SPIMI 还是要比 BSBI 更加节省空间。当内存耗尽时,包括词典 (Dictionary) 和倒排记录表 (Posting List) 的块索引将被写到 Disk 上(伪代码第 12 行)。为使 Posting List 按照词典顺序排序来加快最后的合并过程,要对词项 (Term) 进行排序操作(伪代码第11 行)。最终我们就会得到当前 Block 的一个哈希表,将其写回到 Disk 中,这里伪代码最后的 output_file 其实就是这个哈希表,也就是我们之前说到的 Run。后续的合并操作与 BSBI 基本一致。

值得注意的是,SPIMI 可以借助压缩 (Compression) 进一步提升算法的效率。Posting List 和词项 (Term) 都可以在 Disk 上进行压缩存储,这就能使算法处理更大的 Block,能够使得原来的每个 Block 所需要的磁盘空间更少。

分布式索引构建(Distributed Indexing)

对于现在这个大数据时代来说,数据量的增长是远远高于硬件存储性能的增长的,因此,很多 Web 搜索引擎选择使用分布式索引构建(distributed indexing)算法来构建索引,其索引结果也是分布式的,它往往按照词项 (Term) 或文档 (Document) 进行分割后分布在多台计算机上。通常会选择使用 MapReduce 来处理这样的任务,对于 MapReduce 我在之前的 Big Data Management 中已经进行了介绍,因此这个部分我们不做过多的记录。

动态索引构建(Dynamic Indexing)

到目前为止,我们假设文档集 (Collection) 是静态的,这对于某些不会轻易改变的文档集来说是合理的,比如《莎士比亚全集》等。但在现实中,大部分的文档集 (Collection) 会随着文档 (Document) 的增删改查而不断变化,这也会导致文档集中的词项 (Term) 也会随之发生变化,因此,我们需要不断更新词项对应的 Posting List.

针对索引的更新,有两种方向:

  1. 周期性对文档集 (Collection) 从头开始进行索引重构。这比较适合更新频率不高的文档集,且具备足够的资源
  2. 即时合并 (Immediate Merge)。该方法主要为了满足用户能够及时检索到新文档的需求

这里我们主要关注第二种方法:即时更新 (Immediate Merge)。即时更新主要使用的方法为同时保持两个索引:

  • 大的主索引 (Main Index)
  • 小的用于存储新文档 (new Document) 信息的辅助索引 (Auxiliary Indexes) ,该索引保存在内存 (Memory) 中。

文档的删除会记录在一个无效位向量 (Invalidation Bit Vector) 中,在返回结果之前,可以使用这个向量来过滤掉已删除的文档。对于某篇文档的更新则通过先删除再重新插入的方式来实现。在进行检索时,同时遍历两个索引,并且将结果进行合并。每当辅助索引 (Auxiliary Index) 变得很大,内存 (Memory) 已满时,就会将其合并到存储于的 Disk 的主索引 (Main Index) 中。

在这里插入图片描述
我们可以用图来理解。假设我们在 Disk 中已经有了对于文档 (Document) 1 ~ 1000 的索引,我们将其记作 I 。现在我们又更新了一批新的文档 1001 ~ 1010,我们将这一批文档都读入内存 (Memory) 中,得到这一批新文档的索引 I0 ,当内存满了之后,我们将其与 Disk 中的 I 进行合并


简单的来说,我们已有 I,每次会得到新的 I0,我们将 I0I 合并,得到 I’ = I(Doc 1 ~ Doc 1010)。该策略可以保证,在完成更新环节之后,我们永远只会有 1 个完整的倒排索引 (Inverted Index)。这就意味着在进行检索的时候,我们也只需在这一个索引中进行操作,如果我们的 Query 的尺寸为 |Q|,那么我们在该索引中进行随机访问的次数也就只有 |Q|,即 I/O 开销为 |Q|


接下来我们考虑该策略的代价。我们考虑最简单的情况:
假设一开始,I = Ø
更新第一批新文档,然后构建它们的索引,|I0| = M,这里不需要合并 (Merge)。
更新第二批新文档,同样构建索引,得到 |I0’| = M,现在 Merge(I0, I0’),得到 I1 = 2 * M
更新第三批文档,得到 |I0’’| = MMerge(I1, I0’’),得到 I2 = 3 * M

因为每次合并的时候,我们需要先读取两个 Index,再将合并后的 Index 写回 Disk,因此代价是 2 * Size of Indexes。比如第二批时,合并的代价就为 2 * 2M。那么,第 k 批文档更新成功后,我们得到的最后的索引应为 Ik = k * M,合并代价为 2 * kM。而第一批文档更新时,我们没有进行任何合并,只是简单地把索引写入 Disk,因此代价只有 M


那么现在我们就能得到该策略的总代价:
Cost = M + 2 * 2M + 2 * 3M + … + 2 * kM
= 2 * M + 2 * 2M + 2 * 3M + … + 2 * kM - M
= 2 * M * (1 + 2 + 3 + … + k) - M
= 2 * M * [(1 + k) * k / 2] - M
= M * (k2 + k - 1)
= O(M * k2)


所以,我们可以总结一下。即时合并 (Immediate Merge) 具有很好的查询表现,查询时间仅需 |Q|,但是构建索引时开销会比较大。

该策略实际将每个词项 (Term) 的倒排记录表 (Posting List) 都单独存成一个文件 (File) ,那么要合并主索引 (Main Index) 和辅助索引 (Auxiliary Indexes) 时,只需要将辅助索引的倒排记录表扩展到主索引对应的倒排记录表即可完成,也就是简单的 append。但这样就需要很多的文件 (File) ,如此一来 I/O 开销就会比较大,合并 (Merge) 的效率就不会很高。

还有另外一种比较极端的策略。那就是不合并 (No Merge)。

这里是引用
依然用图来理解。假设我们在 Disk 中已经有了对于文档 (Document) 1 ~ 1000 的索引,我们将其记作 I 。现在我们又更新了一批新的文档 1001 ~ 1010,我们将这一批文档都读入内存 (Memory) 中,得到这一批新文档的索引 I0 ,当内存满了之后,我们 不进行合并 ,直接将 I0 作为一个单独的索引存入 Disk。当下一次,又更新了一批新文档 1011 ~ 1020 时,进行同样的操作。


或者可以这么看,新的文档集 (Collection) 的尺寸为内存的数倍,即 |C| = k * M ,那么此时就会有 k 个索引,每个索引的尺寸为 M。这样一来进行查询时,对于每一个查询中的词项 (Term),就需要查找 k 个索引,整体的开销就为 k * |Q|。可以看到,不合并 (No Merge) 策略相比即时合并 (Immediate Merge) 策略,它在查询 (Query) 时的表现会比较差,但是因为它没有合并操作,因此,没有额外的索引构建开销。

简单的来说,该策略实际就是将索引存到一个大文件中,也就说将所有倒排记录表存到一起。

对数合并(Logarithmic Merge)

在以上两种策略之间,我们希望能取得一个平衡。这就诞生了对数合并(Logarithmic Merge)。

和之前的即时合并一样,每次有新一批文档到来,我们会不断合并 I0 。比如,I0I0 合并为 I1。只是不同的是,先在我们规定两个条件:

  1. 每次合并之后,只会得到 Ik,且 Ik = 2k * M
  2. 不会有两个 Ik

接下来,我们就秉持着这两个前提来看一遍具体的过程:
同样,假设一开始,I = Ø
更新第一批新文档,然后构建它们的索引,|I0| = M = 20 * M,满足条件 1 和条件 2
更新第二批新文档,同样在内存 (Memory) 构建索引,得到 |I0| = M这个时候,我们在 Disk 之中已经有了一个I0 ,这违反了条件 2。因此, Merge(I0, I0),得到 I1 = 2 * M
更新第三批文档,得到 |I0| = M,这个时候,我们有 I1I0 ,满足条件 1 和条件 2,不需要进行合并
更新第三批文档,得到 |I0| = M此时我们在 Disk 已有 [I1, I0] ,不符合条件。因此,Merge(I0, I0) = I1,此时仍有 2 个 I1,继续合并 Merge(I1, I1) = I2 = 4 * M = 22 * M,满足条件 1 和条件 2。最后 Disk 中仅有 I2

我们依旧假设 |C| = k * M 且这里的 k = 2h 。那么,在最后的第 k 批文档处理结束后,我们应该能得到一个索引 Ilog(k) = k * M,而在这个索引当中的所有 Entry,都经过了 h = log(k) 次合并。所以,该策略下,构建整个索引的代价为 O(k * M * log(k))


而对于查询 (Query) 来说,我们可以考虑最极限的情况,通过条件 2 我们能够知道,不会有两个 Ik,因此,我们最多只能同时有 I0, I1, I2, …, Ilog(k) 个索引,即 O(log(k))。因此,查询的最高复杂度为 O(|Q| * log(k))

总结一下三种策略的复杂度:

策略 (Strategy)索引构建代价 (Build Cost)查询代价 (Query Cost)
即时合并 (Immediate Merge)O(M * k2)O(|Q|)
不合并 (No Merge)O(1)O(k * |Q|)
对数合并 (Logarithmic Merge)O(k * M * log(k))O(|Q| * log(k))

可以看到,对数合并 (Logarithmic Merge) 在前两种策略:即时合并 (Immediate Merge) 和不合并 (No Merge) 之间取得了一个不错的平衡。

在这里插入图片描述

  • 7
    点赞
  • 16
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值