Information Retrieval(信息检索)笔记02:Preprocessing and Tolerant Retrieval
- 预处理(Preprocessing)
-
- 文档分析及编码转换(Parsing a document)
-
- 字符序列的生成
- 文档单位的选择(Document Unit)
- 词条与词项(Tokens And Terms)
-
- 词条化(Tokenization)
- 去除停用词(Stop words)
- 词项归一化(Normalization to terms)
- 词干还原和词形归并(Stemming and Lemmatization)
- 容错式检索(Tolerant Retrieval)
-
- 词典搜索的数据结构(Dictionary data structures)
-
- 哈希表(Hash Table)
- 树(Tree)
- 通配符查询(Wild-card Query)
-
- 轮排索引(Permuterm index)
- k-gram 索引(k-gram indexes)
预处理(Preprocessing)
首先回顾一下之前介绍的构建倒排索引的几个主要步骤:
- 收集待建索引的文档
- 对这些文档中的文本进行词条化
- 对步骤 2 中得到的词条进行语言学预处理,得到词项
- 根据词项对所有文档建立索引
用流程图表示为:
接下来,我们将按照顺序,对每一部分常见的问题进行一个讨论。
文档分析及编码转换(Parsing a document)
字符序列的生成
对于文档的处理,第一步需要将字节序列转换为线性的字符序列。
这一步操作首先需要正确地判断出文档的编码方式,我们可以把该判断过程看作一个基于 ML 的分类问题或是其他启发式的操作。在确定编码方式的同时,还要确定文档的格式 (Format) ,比如是 PDF, XML, DOC 或是 ZIP 等其他格式的文件。
在确定了以上两点之后,对于一部分的文档已经能够进行较为合适的转换,但是还有一个很重要的因素需要考量,那就是语言 (Language)。这一点是很难有完美的解决方法的,因为各种语言的差异性,甚至将文本视为线性字符序列的看法本身,在某些文字系统中都不成立。因此,对于语言的处理,每个语言系统的操作可能会有很大的差异。
文档单位的选择(Document Unit)
接下来,我们需要确定索引的文档单位 (Document Unit)。我们在前一篇笔记中,默认将文档集 (Collection) 中的每一篇文档 (Document) 看作一个索引单位(具体的例子就是《莎士比亚全集》中的每一部作品)。但实际上,很多时候,对于文档单位的选择是需要慎重考量的。
例1:传统的 Unix 邮件系统将某个邮件目录下的一系列邮件都存放到单个文件中,但是我们希望将每封邮件都看成一个独立的文档,甚至将每一封邮件和它的附件都单独分开看待;
例2:latex2html 有时把我们认为的单一文档(如一个PPT)以幻灯片或者小节为单位切分成多个HTML 页面,并将每个页面保存到独立的文件中,但我们希望多个文件合并为一个文档来构建索引
这里就涉及到一个对于 “索引粒度 (Indexing Granularity)” 的权衡问题。这实际上是一个正确率 (Precession) 和召回率 (Recall) 之间的权衡问题:
- 如果索引粒度太小,那么由于词项散布在多个细粒度文档中,我们就很可能错过那些重要的段落,也就是说此时正确率高而召回率低
- 反之,如果索引粒度太大,我们就很可能找到很多不相关的匹配结果,即正确率低而召回率高
举个例子,对于一个书库来说,拿每一本书作为索引单位是不合适的,可能会有这种情况:搜索“Chinese toys”,返回的书,它的第1 章提到了“China”,最后一章提到 “toys”,这显然不合适,此时索引粒度比较大。更合适的做法是以书的每章/每段作为一个迷你文档建立索引,这样可以更准确地找到匹配结果,因为粒度小,用户的查询会更精确。
词条与词项(Tokens And Terms)
词条化(Tokenization)
在确定了文档单位之后,就是词条化操作,我们在前一篇笔记中已经对这一步骤有一定的了解,这里我们再补充一些细节。
词条化是将给定的字符序列拆分成一系列子序列的过程,其中的每个子序列被称为词条 (Token),当然,在这个过程中,可能会同时去掉一些特殊字符(如标点符号等):
我们有时为了表述方便,会把词项 (Term),词条 (Token) 和词 (Word) 进行混用,但是它们严格的定义上是有区别的:
- 一个词条指的是在文档中出现的字符序列的一个实例 (Instance)
- 一个词条类指的是相同词条构成的集合
- 一个词项指的是信息检索系统词典 (Dictionary) 中所包含的某个可能经过归一化处理的词条类
一个具体的例子,给定一个字符序列 “to sleep to perchance dream”
对其进行词条化,我们会得到 5 个词条:to, sleep, to, perchance, dream
但此时只有 4 个词条类:to, sleep, perchance, dream
在进行索引时,若我们将 to 作为停用词去掉,那么最后只有三个词项:sleep, perchance, dream
如果觉得上面的定义记起来复杂,也可以直接这样记忆:经过进一步处理后,每个这样的词条现在都是索引条目的候选对象
尽管我们在例子中看到词条化的操作并不复杂,感觉只是简单的分割,但实际上,词条化需要考虑的因素非常多。
首先是符号问题,比如英文中的上撇号 ( ’ ),既可以表示所有关系,也能表示缩写,如何对其进行处理就是一个难题,比如
除此之外,连字符 ( - ) 的处理也存在问题。它可以用于分隔单词中的元音字母(如co-education),也可以用于将多个名词连接成一个新的名称(如Hewlett-Packard),还可以在审稿设备上显示多个词语的组合结果。
甚至,就连根据空格进行拆分也会存在问题。比如地名 (Los Angeles)、复合词 (white space vs. whitespace) 等。其次,数字表示形式也是一个问题。比如日期的表示、电话号码、邮编等。
上述的这些问题还只是在英语系统中,我们还要考虑语言本身带来的挑战。法语的省略用法 (比如 l’ensemble),德语的复合名词中没有空格,而像中文、日语之类的语言系统中,甚至词之间根本就不存在空格。
这里有两种解决方案。一个是先进行分词(word segmentation)。分词的方法包括基于词典的最大匹配法(采用启发式规则来进行未定义词识别)和基于机器学习序列模型的方法(如隐马尔可夫模型或条件随机场模型)等,后者需要在手工切分好的语料上进行训练。但是因为有多种分割的可能,因此不能保证得到完全一致的唯一结果。另一个方案是放弃基于词的索引策略,而是使用短字符序列的方法,这种方法不关心词项是否会跨越词的边界。
去除停用词(Stop words)
某些情况下,一些常见词在文档和用户需求进行匹配时价值并不大,需要彻底从词汇表中去除。这些词称为停用词(stop word)。一个常用的生成停用词表的方法就是将词项按照文档集频率(DF)从高到低排列,然后手工选择那些语义内容与文档主题关系不大的高频词作为停用词。停用词表中的每个词将在索引过程中被忽略。
但是在进行短语查询 (Phrase Query) 的时候,有一些停用词确有其意义,比如短语查询 President of the United States 中包含两个停用词,但是它比查询 President AND “United States” 更精确。
词项归一化(Normalization to terms)
词条归一化(token normalization)就是将看起来不完全一致的多个词条归纳成一个等价类,以便在它们之间进行匹配的过程。 最常规的做法是隐式地建立等价类,每类可以用其中的某个元素来命名。比如,在文档和查询中,都把词条 anti-discriminatory 和 antidiscriminatory 映射成词项 antidiscriminatory, 这样对两个词中的任一个进行搜索,都会返回包含其中任一词的文档。
另一种建立等价类的方法是维护多个非归一化词条之间的关联关系。具体有两种实现的方式:
- 采用非归一化的词条建立索引,并为某个查询词项维护一张由多个词组成的查询扩展词表。当输入一个查询词项时,根据扩展词表进行扩展,再将扩展后得到的多个词所对应的 Posting List 合并起来
- 在索引构建时就对词进行扩展。比如,对包含 automobile 的文档,也用 car 来进行索引(同样,对于包含 car 的文档也用 automobile 进行索引)
这两种方法相比隐式建立等价类会花费更多的空间,但是由于两个关联词的扩展表之间可以存在交集,但不必完全相同,因此灵活性更高。这也意味着从不同关联词出发可以进行不对称的扩展
该例子中,用户输入 windows 想到得到包含 Windows 操作系统的文档;但是若输入 window,此时会和小写的 windows 匹配,但是不太可能会和 Windows 操作系统中的 Windows 像匹配
隐式建立等价类或查询扩展的使用幅度这两种方法虽然看似合理,但在使用的时候仍要万分注意,过度使用很容易会在无意间造成非预期的扩展结果。例如,通过删除U.S.A.中的句点可以把它转化成USA,由于在首字母省略用法中存在这种转换模式,所以上面的做法乍看上去非常合理。但是,如果输入查询C.A.T.,返回的很多包含cat的文档却肯定不是我们想要的结果。
实际情况中,词项归一化还会面临一些其他的问题:
- 重音及变音符号问题。 人们很可能希望 cliche 和cliché 或者naive 和na?ve 能匹配。这可以通过在词条归一化时去掉变音符号来实现。但在西班牙语中,同样的词有无重音符号表示的意思可能会完全不同(pe?a 的意思是“悬崖”,pena 的意思却是“悲哀”)。这一问题的终点不在于规范或者语言学问题,而是用户如何构造查询来查找包含这些词的文档。出于各种原因,实际上在很多情况下用户并不会输入变音符号
- 大小写转换问题。 大小写转换(case-folding)问题的一个一般处理策略是将所有的字母都转换成小写。但是这也会造成一些专有名词被影响。另一种启发式的方是,将句首词转换成小写形式,并将标题中大写或首字母大写的全部单词都转换成小写形式。这些首字母大写的单词通常都是普通词。
- 语言问题。 待索引的文档集可以同时包括来自不同语言的文档,单篇文档中也很可能同时出现不同语言的文本。比如,一封法语邮件中也许会引述英文书写的合同文件条款。一种做法就是,在文档上运行一个语言识别器,然后将每个词项的语言种类标记在词项词典中。
- 除此以外还有诸如将英式和美式拼写等同、日期时间格式统一等问题
词干还原和词形归并(Stemming and Lemmatization)
词干还原和词形归并的目的都是为了减少屈折变化的形式,并且有时会将派生词转化为基本形式。
比如:
am, are, is? be
car, cars, car’s, cars’ ?car
但是,词干还原和词形归并这两个术语表示的意思是不同的:
- 词干还原通常指的是一个很粗略的去除单词两端词缀的启发式过程,并且希望大部分时间它都能达到这个正确目的,这个过程也常常包括去除派生词缀
- 词形归并通常指利用词汇表和词形分析来去除屈折词缀,从而返回词的原形或词典中的词的过程,返回的结果称为词元(lemma)
例如,给定词条 saw,词干还原的结果可能就是 s,而词形归并将返回 see。它们的主要区别在于:词干还原在一般情况下会将多个派生相关词合并在一起,而词形归并通常只将同一词元的不同屈折形式进行合并。
词干还原最常用是 Porter 算法,该算法包括 5 个顺序执行的词约简步骤。在每个步骤中,都有选择规则的一些不同约定。比如在第一步,可能会根据以下规则进行约简:
后续步骤的很多规则中都使用了词的测度(measure)的概念,即通过粗略检查音节的个数来判断词是否足够长,在足够长的情况下才将规则的匹配部分看成词缀而不是词干的一部分来进行处理。
比如,有一条规则为: (m > 1) EMENT →
则,replacement → replac
但是 cment 不能转换为 c
我们可以选择词形归并工具(lemmatizer)而不是词干还原工具来处理相关问题。词形归并工具是一种自然语言处理工具,它能够通过进行详尽的词形分析来精确地得到每个词的词元。而词干还原能够提高召回率但是会降低正确率。
比如,我们对以下所有单词进行词干还原:
operate operating operates operation operative operatives operational
Porter 词干还原算法会将它们都转换为 oper
然而,operate 在大多数形式下的都是普通动词。使用Porter 词干还原工具会降低如下查询的正确率:
operational AND research
operating AND system
operative AND dentistry
对于上面的情况,即使采用词形归并工具仍然不能完全解决问题,这是因为在特定的搭配中使用了特定的屈折变化形式,比如一个包含operate 和system 的句子对于查询operating AND system 来说并不是一个好的结果。要从词项归一化中获得更好效果取决于语用分析而不只是词形分析。
容错式检索(Tolerant Retrieval)
词典搜索的数据结构(Dictionary data structures)
首先回顾一下之前介绍过的倒排索引:
我们一般将这样的倒排索引称作普通倒排索引 (Standard Inverted Index) 。在普通的倒排索引中,Dictionary 中的每一个词项都会对应一个由文档集 (Collection) 中的多篇文档 (Document) 组成的 Posting List。在进行查询时,首要的任务是确定查询 (Query) 中的每个词是否都在 Dictionary 中,如果在,那么返回该词项对应的 Posting List 的指针 (Pointer)。
对于 Dictionary 的查找操作,就需要我们对于它的数据结构有一定的了解,一般来说,Dictionary 的数据结构有两种比较主流的选择:
- 哈希表(Hash Table)
- 搜索树(Tree)
对于选择哪个数据结构,需要考虑以下几个问题:
- 词项的数量有多少?
- 词项的数量是动态的吗?如果是,那么在插入新的词项时,是单纯增加即可,还是需要删改某些已存在的词项?
- 不同词项的访问频率是怎样的?
哈希表(Hash Table)
该方法会将每个词项 (Term) 通过哈希函数映射为一个哈希值。这种方法需要哈希函数的目标空间足够大,以减少哈希结果冲突的可能性。
优点 (Pros) | 缺点 (Cons) |
---|---|
查找速度比树 (Tree) 快:O(1) | 由于查询词项稍有变化后哈希函数会生成截然不同的结果,哈希表方式难以处理查询词项存在轻微变形的情况(如单词resume的重音符和非重音符版本) |
无法处理前缀式查询 (Prefix Search) ,这也就意味着它难以支持容错式检索 (Tolerant Retrieval) | |
如果词汇表 (Vocabulary) 不断增长,就需要执行代价高昂的重哈希 (Rehash) 操作 |
树(Tree)
树结构能够有效解决上面提到的大部分问题。最出名的莫过于二叉树 (Binary),其每个内部节点都有两个子节点。在二叉树中搜索词项要从根节点开始,而每个内部节点(包括根节点)都代表一个二值测试,测试的结果用于确定下一步应该搜索的子树。
我们不难看出,二叉树能够支持前缀式查询,比如我们想要搜索以 automat 开始的词项所在的文档,那么我们只需要按照搜索树往下进行查找,就能够找到所有符合要求的词项,然后返回这些词项关联的文档。但是二叉树也存在一个缺点,那就是在增加和删改词项时,要保持整棵树的平衡性。为了减轻这种平衡化处理的开销,目前词典搜索中会采用另一种树结构 - B-Tree
所谓的 B-Tree 就是允许树的内部节点的子节点数目在某个固定的区间 [a, b] 内变化。我们这里给出图来理解:
这里我们能看到,所有节点的子树数量都在 [2, 4] 区间之内,因此,这里的 a = 2, b = 4。B-Tree 可以看做将二叉树的多层压平到一层而得到的结构,这种树结构在内存空间不足以存下整 Dictionary,有一部分必须存入磁盘时会很有用,因为在这种情况下“压平”可以在将词典调入内存时实现后续二值测试的预读取。此时,B-树中的整数a 和b 取决于磁盘块的大小。
总结一下:
最简单的树结构就是二叉树 (Binary Tree),但最常用的还是 B-Tree。同时,树结构和哈希表方式不同,要求树中的字符集有预定义的排序方式,比如字符顺序等。
优点 (Pros) | 缺点 (Cons) |
---|---|
能够处理前缀式查询 | 相比哈希表的时间复杂度 O(1),树结构需要 O(logM) |
平衡化操作开销巨大(但是 B-Tree 能够解决该问题) |
这里再介绍一种树结构,Tries。这个方法很好理解,就是将每个单词进行了分解
这一方法的搜索时间复杂度为 O(|Q|),|Q| 为查询单词的长度。同时它也支持前缀查询,但是该方法会耗费更多的空间,因此使用的较少。
通配符查询(Wild-card Query)
有以下几种情况我们需要进行通配符搜索:
- 用户对查询的拼写不确定(比如,不能确定是 Sydney 还是 Sidney,就用 S*dney)
- 用户知道某个查询词项可能有不同的拼写版本,并且要把包含这些版本的文档都找出来(color和colour)
- 用户不确定一个外来词或者短语的正确拼写形式(如查询Universit* Stuttgart)
- 用户查找某个查询词项的所有变形,这些变形可能做了词干还原,但是用户并不知道(比如judicial 和judiciary,可采用通配符查询judicia* )
一般来说,有两种比较常见的通配符搜索,即首通配符查询 (Leading wildcard query) ,比如 * mon 和尾通配符查询 (Tailing wildcard query),比如 mon *
对于尾通配符查询 (Tailing wildcard query) 其实很简单,我们可以依次按照字符m、o、n 从上到下遍历搜索树,直到能列举词典中所有以 mon 开头的词项集合 W 时为止(即 mon ≤ w < moo 范围内的所有词)。最后,在普通倒排索引中进行 |W| 次查找后取出 W 中所有词项所对应的文档。
而对于首通配符查询 (Leading wildcard query) ,我们引入词典的反向 B-树(reverse B-tree)结构,在该结构中,原来B-树中的每个从根到叶子路径所代表的词项全部反过来写。因此,词项 lemon 在反向B—树中的路径就是 root-n-o-m-e-l。所以对反向B-树遍历后可以返回所有包含同一后缀的词项。
这个时候,我们就有了对于更一般的通配符查询也有了相应的策略,比如查询 pro*cent。我们可以把它看做两个查询 Q1 = pro * , Q2 = * cent,然后取这两个查询的结果的交集。而对于首尾相同的情况,比如查询 ba * ba 对应的2个集合都包含词项ba,这种情况下应该过滤掉ba。现在,我们已经能够对只有单个通配符 * 的情况进行处理。
轮排索引(Permuterm index)
首先,我们在字符集中引入一个新的符号 $ ,用于标识词项结束。因此,词项 hello 在这里表示成扩展的词项 hello$,然后,构建一个轮排索引,其中对扩展词项的每个旋转结果都构造一个指针来指向原始词项
对于 hello$ 我们可以得到:hello$ , ello $ h, llo $ he, lo $ hel, o $ hell
词项旋转后得到的集合被称为轮排词汇表(permuterm vocabulary)。 现在我们具体看看轮排索引如何处理通配符查询,我们考虑通配符查询 m * n,这里的关键是将查询进行旋转让 * 号出现在字符串末尾,即得到 n$m*。下一步,在轮排索引中查找该字符串,实际上等价于查找某些词项(如man 和moron)的旋转结果。这样,我们就能找出符合要求的词项,并检索出匹配的文档。这里我们对轮排索引做一个汇总:
总结一下,核心的思想就是 “永远把不确定的部分放在尾部”。这里我们着重看一下上图中的最后一种查询:P * Q * R $,此时存在两个不确定的部分,在这种情况下,首先返回轮排索引中 R $ P * 对应的词项集合,然后通过穷举法检查该集合中的每个元素,过滤掉其中不包含 Q 的词项,最后,再利用剩下的词项去查普通倒排索引,从而得到最后的结果。
比如:fi * mo * er
首先返回轮排索引中 er$fi* 对应的词项集合
然后滤掉其中不包含 mo 的词项
因此最终会选出 fishmonger,而过滤掉 filibuster
轮排索引的一个最大缺点是其词典会变得非常大,因为它保存了每个词项的所有旋转结果
k-gram 索引(k-gram indexes)
因为轮排索引的空间占用比较大,因此,针对这个问题,出现了另一种通配符查询处理技术 - k-gram索引。
一个 k-gram 指的是由 k 个字符组成的序列,这里我们使用 $ 表示词项的开始和结束,因此,能够得到以下结果:
词项 castle 的所有 3-gram 包括:$ ca, cas, ast, stl, tle, le$
对一个文本 “April is the cruelest month” ,它的 2-gram (bigram) 包括:
在 k-gram 索引结构中,字典 (Dictionary) 由词汇表中的所有词项的所有 k-gram 构成,而每个 Posting List 则由包含该 k-gram 的词项构成,比如:
而在这 Posting List 中的每一个词项,也都指向一个自己的包含所有相关文档的 Posting List。
现在我们看一个具体的查询例子:考虑查询 re*ve,此时查询目标是返回所有前缀是 re 且后缀是 ve 的词项。为此,我们构造布尔查询 $ re AND ve$,这个查询可以在 3-gram 索引中进行查找处理,返回诸如 relive、remove 及 retrieve 的词项,然后我们可以在普通倒排索引中查找这些返回的词项,从而得到与查询匹配的文档。
但是,k-gram 也有需要注意的问题,比如我们查询 mon* ,想要找所有前缀为 mon 的词项。构造布尔查询 $m AND mo AND on,但此时在 2-gram 索引中进行查找处理,有可能会得到一个词项 moon,很显然,它满足布尔查询,但是并不符合我们的要求。
为了应对这种情况,引入了一个后过滤 (Postfiltering) 的步骤,即利用原始的查询 mon* 对上述布尔查询产生的结果进行逐一过滤。 过滤时只需要做简单的字符串匹配操作即可。
一个通配符查询往往会返回多个词项,而每个词项再根据普通倒排索
引返回其所在的文档。搜索引擎还可以通过布尔操作符支持多个通配符查询的组合,比如 pyth* AND org*。但是即使是单个通配符查询,实际上也代价高昂,因此,大多数搜索引擎会把该功能放在“高级搜索”选项卡下,不鼓励用户经常使用此类查询。