《自制搜索引擎》笔记


第1章 搜索引擎是如何工作的

搜索引擎的基础是应用于信息检索、数据库等领域的信息技术。

1-1 理解搜索引擎的构成


1-2 实现了快速全文搜索的索引结构

利用全扫描进行全文搜索

grep就是从头到尾扫描作为检索对象的文档的。

利用索引进行全文搜索

先建立索引需要花费时间。

倒排索引的结构

例子:印在教程书后面的索引。
所谓倒排索引就是一张列出了“哪个单词出现在了哪一页”的表格。如下图:

倒排索引的构建方法


为了便于浏览,我们交换了上表的行和列,并将单词按字典序排序:

倒排索引中的术语

对于每种作为检索对象的数据,构建索引的单位都是不同的。


1-3 深入理解倒排索引

倒排索引 = 词典 + 倒排文件

从倒排索引中查找单词

如何查找同时包含了多个单词的文档呢?
查找时只 需要先从词典中找出各个单词,然后分别获取这些单词的倒排列表并加 在一起,由此计算出包含在各个倒排列表中的文档编号的交集。

将单词的位置信息加入倒排文件中

  • 文档级别的倒排文件。
  • 单词级别的倒排文件。这种倒排文件中不仅带有有关单词出现在了 哪个文档中的信息,还带有单词出现在了文档中的什么位置(从开头数 是第几个单词)这一信息。如:
engine: D1;4 
Google: D2;5 
I: D1;1,D2;1

从倒排索引中查找短语

查找短语时还需要确认 search 和 engine 是否是相 邻出现的。例如,虽然下面的文档也同样 包含了 search 和 engine,但却与搜索引擎(search engine)无关。
I search for a gas station because my car’s engine doesn’t start.


1-4 制作中文文档的倒排索引

分割中文句子的两种方法

全文搜索引擎这段文本分割将得到不同的结果。
①词素解析分割法
将句子分割为“词素”序列的方法。 词素是语言中含有意义的最小单位。一般采用的是机器学习的方法。分割结果:
全文 搜索 引擎
②N-gram分割法
N-gram 分割法是一种将句子分割成由 N 个字符组成的片段序列的方法,每个片段称作一个 N-gram。 使用bi-gram分割结果:
全文 文搜 搜索 索引 引擎

权衡分割方法

由 N-gram 构成的倒排索引不会产 生检索遗漏问题。
但是相比于词 素解析,在同一个文档中使用 N-gram 产生的词元通常较多。


1-5 实现倒排索引

实现词典

为了能够快速地获取到对应着单词的倒排列表,通常 都会使用哈希表、树等数据结构。

用二叉查找树实现词典

在内存上实现词典

在二级存储器上实现词典

用B+树实现词典

HDD 或 SSD 等二级存储器 一般被称作“块设备”,由于它们是以块为单位进行输入输出的 A ,所以 即使只是读取块中 1 个字节的数据,也不得不对整个块进行输入输出操 作。当要存储大型词典时,往往要使用适合块设备的 B+ 树等树 形数据结构。


所有的记录都存储在树中的叶结点(Leaf Node)上,内部结点(Internal Node)上只以关键字的顺序存储关键字。
B+ 树通常以文件系统中页尺寸的常数倍为单位管理各结点, 而由这样的结点来构成树,则有助于减少检索时对二级存储的输入输出次数。

实现倒排文件

倒排列表的物理布局

  • 文档编号(DocID)
  • 文档中的偏移列表(off1、off2…)
  • TF词频,用于计算检索结果的排名
    对于之前例子中对应着 search 的倒排列表 (D1;3,D2;2),就可 以用如下的整数数列表示。
    1,1,3,2,1,2

压缩倒排列表

会保存经过压缩的倒排列表 来缩短加载时间。
由于倒排列 表一般都是整数数列,所以通常会采用适合整数数列的压缩方法。


1-6 使用倒排索引进行检索

使用倒排索引的检索处理流程

① 获取查询中每个单词的倒排列表;
② 根据布尔检索,获取符合检索条件的文档编号;
③ ’ 计算符合检索条件的文档和查询的匹配度;
③ ” 获取对检索结果进行排序时使用的属性值;
④ 根据匹配度或用于排序的属性值,获取前 k 个文档。

关联度的计算方法

在计算余弦相似度时,需要把文档和查询映射到以单词(Term)为 维度的向量空间上,文档向量和查询向量的夹角(内积)越小,说明文 档和查询的关联度越高。

信息检索中的检索

在检索处理中,文档是否包含查询无关紧要,重要的是 通过计算查询和整个文档的关联度,把关联度高的文档作为检索结果。


1-7 构建倒排索引

使用内存构建倒排索引

完全可以按照1-2节中的方法构建,先在内存上生成与文档编号对应的单词表(二维数组),然后用相同的方法倒排该表。
但是这样做,生成都是非常稀疏的表,会消耗大量内存。

使用二级存储构建倒排索引

基于排序的索引构建法

基于合并的索引构建法


从后面的代码可以看到就是用来sqlite数据库。

静态索引构建和动态索引构建

动态索引构建不但可以使索引结构时刻处于可供检索的状态,还可以一边实时更新索引,一边构建索引。这种方法多用于信息的时效性非常重要的文档。


1-8 准备要检索的文档

数据规范化

在规范 HTML 文件时, 就要删除标签并提取出作为检索对象的 文章(内容)。


第2章 准备全文搜索引擎的检索样本

2-1 全文搜索引擎wiser


2-2 安装wiser


2-3 运行wiser

先来看下使用说明:

$ ./wiser 
usage: ./wiser [options] db_file

options:
  -c compress_method            : compress method for postings list
  -x wikipedia_dump_xml         : wikipedia dump xml path for indexing
  -q search_query               : query for search
  -m max_index_count            : max count for indexing document
  -t ii_buffer_update_threshold : inverted index buffer merge threshold
  -s                            : don't use tokens' positions for search

compress_methods:
  none   : don't compress.
  golomb : Golomb-Rice coding(default).

构建倒排索引

$ ./wiser -x ../../1000.xml -m 1000 wikipedia_1000.db
[time] 2017/02/26 21:57:34.000007
count:1 title: Wikipedia:Upload log
count:2 title: Wikipedia:删除纪录/档案馆/20043月
count:3 title: 数学
count:4 title: Help:目录
count:5 title: 哲学
...

生成的数据库表如下:



使用倒排索引查询

$ ./wiser -q "顾城" wikipedia_1000.db         
[time] 2017/02/26 22:10:43.000008
document_id: 991 title: 顾城 score: 199.315686
Total 1 documents are found!
[time] 2017/02/26 22:10:43.000008 (diff   0.001520)

第3章 构建倒排索引

3-1 复习有关倒排索引的知识

提取词元

考虑UTF-8字符编码特性。
在 UTF-8 中,是用 1 到 4 个字节的长度来表示 1 个字符的。例如, 像数字和拉丁字母等在英文中使用的字符都是用 1 个字节表示的,而在 中文中使用的字符则多半要用 3 个字节才能表示。
例如,对于“Web 检索”这个字符用如下的字节序列表来表示:
0x57 0x65 0x62 0xe6 0xa3 0x80 0xe7 0xb4 0xa2

在 wiser 中,为了避开由使用 UTF-8 带来的处理上的麻烦,我们在 每次获取 N-gram 时,都会先将字符串的编码从 UTF-8 转换成 UTF-32。 UTF-32 是一种以 4 字节(32 bit)的数值为单位表示 Unicode 字符的编 码方式。由于 Unicode 的字符与表示该字符的数值是一一对应的,所以 在 UTF-32 中,由 N-gram 分割而成的词元所含有的字节数就变成固定的 了,这样就简化了程序上的处理过程。

为每个词元创建倒排列表

单词级别的倒排列表:是由文档编号和词元在文档中出现的位置构成的二元组的集合。


3-2 构建倒排索引

在存储器上创建倒排列表

最直接的方法就是不断地 将倒排项(文档编号和位置信息)添加到存储器上的倒排列表的末尾。

倒排列表和倒排文件的数据结构

/* 倒排列表(以文档编号和位置信息为元素的链表结构)*/
typedef struct _postings_list {
  int document_id;             /* 文档编号 */
  UT_array *positions;         /* 位置信息的数组 */
  int positions_count;         /* 位置信息的条数 */
  struct _postings_list *next; /* 指向下一个倒排列表的指针 */
} postings_list;
/* 倒排索引(以词元编号为键,以倒排列表为值的关联数组) */
typedef struct {
  int token_id;                 /* 词元编号(Token ID)*/
  postings_list *postings_list; /* 指向包含该词元的倒排列表的指针 */
  int docs_count;               /* 出现过该词元的文档数 */
  int positions_count;          /* 该词元在所有文档中的出现次数之和 */
  UT_hash_handle hh;            /* 用于将该结构体转化为哈希表 */
} inverted_index_hash, inverted_index_value;

通过为类型赋予别名使二者有所区别, 用 inverted_index_hash 类型表示整个关联数组,用该类型的别名 inverted_ index_value 表示关联数组中的一个元素。

从源代码级别梳理倒排索引的构建顺序

就用我之前写过的这个方法来看代码,或者用Clion。

add_document()

① 从文档中取出词元。
② 为每个词元创建倒排列表并将该倒排列表添加到小倒排索引中。
③ 每当小倒排索引增长到一定大小,就将其与存储器上的倒排索引 合并到一起。


第4章 开始检索吧

4-1 检索处理的大致流程

① 将查询分割为词元(如果使用的是 bi-gram, 那么就会分割出“自制”“制搜”“搜索”“索引”“引擎”5 个词元)。
② 将分割出的各个词元,按照出现过该词元的文档数量进行升序排列。
③ 获取各个词元的倒排列表,并从中取出文档编号和该词元在文档中出现位置的列表。
④ 如果所有词元都出现在同一个文档中,并且这些词元的出现位置都是相邻的,那么就将该文档添加到检索结果中。
⑤ 计算已添加到检索结果中的各文档与查询的匹配度(在 wiser中,我们使用 TF-IDF 值作为匹配度)。
⑥ 将检索结果按照匹配度的降序排列。
⑦ 从经过排序的检索结果中取出排在前面的若干个文档作为检索结 果返回。


4-2 使用倒排索引进行检索

从源代码级别梳理检索处理的流程

调用split_query_to_tokens函数将查询字符串转换为词元序列inverted_index_hash

使用具体示例加深对检索处理流程的理解

如果能 够找到一个在所有倒排列表中都出现过的文档编号,那么就将它所指向 的文档加入到候选检索结果中。

- 首先获取了词元 A 的文档编号, 然后检查了其他的词元是否也带有 相同的文档编号
- 如果没有发现带有相同文档编号的词元, 那么接下来就继续向后读 取词元 A 的倒排列表,直到遇到更大的文档编号为止。
相关的实现在search_docs中。


第5章 压缩倒排索引

5-1 压缩的基础知识

压缩倒排索引的好处

在使用倒排索引进行检索的过程中,总检索时间中的大部分时间往 往花费在了从二级存储读取倒排索引上。于是,就经常可以看到在存储 倒排索引前,对其进行压缩以减少从二级存储读取的时间,进而使检索 处理得以高速运转的对策。

倒排索引的压缩方法

倒排文件的压缩方法

在一般的程序中,大多数情况下都会为整数分配 4 或 8 个字节等定 长的编码,但是在处理倒排文件时,由于经常要处理大量数值较小的整 数,所以为了使用更少的信息量来表示整数,通常都会采用长度可变而 非固定的编码方式。

Golomb编码

压缩的原理


5-2 实现wiser中的压缩功能

了解无需进程压缩时的操作

encode_postings_none函数将倒排列表转换成字节序列。
该函数会先从倒排列表的各元素中取出文档编号、位置信息 的数量以及位置信息的数组,然后再将这些数据以二进制的形式写入缓冲区。

/**
 * 将倒排列表转换成字节序列
 * @param[in] postings 倒排列表
 * @param[in] postings_len 倒排列表中的元素数
 * @param[out] postings_e 转换后的倒排列表
 * @retval 0 成功
 */
static int
encode_postings_none(const postings_list *postings,
                     const int postings_len,
                     buffer *postings_e)
{
  const postings_list *p;
  LL_FOREACH(postings, p) {
    int *pos = NULL;
    append_buffer(postings_e, (void *)&p->document_id, sizeof(int));
    append_buffer(postings_e, (void *)&p->positions_count, sizeof(int));
    while ((pos = (int *)utarray_next(p->positions, pos))) {
      append_buffer(postings_e, (void *)pos, sizeof(int));
    }
  }
  return 0;
}

介绍几个轮子

  • utarry:a dynamic array implementation using macros.
  • uthash:处理关联数组。
  • buffer:在util.h中定义的支持动态扩容的缓冲区。

你可能感兴趣的:(算法,搜索)