全文检索是人们每天都在使用的工具之一。如果你曾经在google上搜索过“golang使用情况”或试图在电子商务网站上找到“室内无线摄像头”,你都会使用某种全文检索。
全文检索(FTS)是一种在文档集合中搜索文本的技术。文档可以引用网页、报纸文章、电子邮件或其他任何结构文本。
今天我们要建造我们自己的FTS引擎。在这篇文章的最后,我们将能够在不到一毫秒的时间内搜索数百万个文档。我们将从简单的搜索查询开始,比如“给我包含单词cat的所有文档”,然后扩展引擎以支持更复杂的布尔查询。
注:最著名的FTS引擎是Lucene(以及在此基础上构建的 Elasticsearch 和 Solr )。
为什么选择FTS
在我们开始编写代码之前,您可能会问:“我们不能只使用grep,或者使用一个循环来检查每个文档是否包含我要查找的单词?” 是的,我们可以,但这并不是最好的方式。
语料库
点击下载 dumps.wikimedia.org 语料。
语料的格式如下:
1 | <title>Wikipedia: Kit-Cat Klock</title> |
加载文档
首先,我们需要加载上一步下载的文档。
1 | import ( |
每个加载的文档都会被分配一个唯一的标识符。为了简单起见,第一个加载的文档分配ID=0,第二个ID=1,依此类推。
常规搜索测试
关键词搜索
现在我们已经将所有文档加载到内存中,我们可以尝试找到关于猫的文档。首先,让我们遍历所有文档并检查它们是否包含子字符串cat:
1 | func search(docs []document, term string) []document { |
在我的笔记本电脑上,搜索需要 103ms, 还不错。 如果您抽查了输出中的一些文档, 您可能会注意到该函数与caterpillar和category匹配,但cat与大写字母C不匹配。这不是我想要的。
在继续之前,我们需要解决两个问题:
使搜索不区分大小写(因此Cat也匹配)。
匹配单词边界而不是子字符串(因此caterpiller和communication不匹配)。
正则表达式搜索
一个快速想到并允许实现这两个需求的解决方案是正则表达式。
例如:(?i)\bcat\b
(?i)
大小写不敏感\b
匹配单词边界(一边是单词字符,另一边不是单词字符的位置)
1 | func search(docs []document, term string) []document { |
呃,搜索花了2秒多。如您所见,即使有60万个文档,事情也开始变得缓慢。虽然该方法易于实现,但它的扩展性并不好。随着数据集越来越大,我们需要扫描越来越多的文档。该算法的时间复杂度是线性的, 需要扫描的文档数等于文档总数。如果我们有600万份文档,而不是60万份,搜索需要20秒。所以我们还需要优化。
反向索引
为了使搜索查询更快,我们将对文本进行预处理并预先建立索引。
FTS的核心是一种称为反向索引的数据结构, 将文档中的每个单词与包含该单词的文档相关联。
例如:
1 | documents = { |
下面是一个倒排索引的真实例子。书名索引书中一个术语引用页码的索引:
文本分析
在开始构建索引之前,我们需要将原始文本分解为一个适合索引和搜索的单词(标记)列表。
文本分析器由一个分词器器和多个过滤器组成。
分词器
分词器是文本分析的第一步。它的工作是将文本转换为标记列表。我们的实现在单词边界上拆分文本并删除标点符号:
1 | func tokenize(text string) []string { |
1 | > tokenize("A donut on a glass plate. Only the donuts.") |
过滤器
在大多数情况下,仅仅将文本转换为标记列表是不够的。为了使文本更易于索引和搜索,我们需要进行额外的规范化。
大小写过滤
为了使搜索不区分大小写,小写过滤器将标记转换为小写。cAt、Cat和caT规范化为cat。 稍后,当我们查询索引时,我们也会降低搜索词的大小写。这将使搜索词cAt能与文本cAt匹配。
1 | func lowercaseFilter(tokens []string) []string { |
1 | > lowercaseFilter([]string{"A", "donut", "on", "a", "glass", "plate", "Only", "the", "donuts"}) |
去除停止语
几乎所有的英语文本都包含了像a,I,the或be这样的常用词。这样的话叫做停止语。我们将删除它们,因为几乎所有文档都会匹配停止字。
没有“官方”的停止语列表。我们把OEC rank前10名排除在外。请根据需要添加:
1 | var stopwords = map[string]struct{}{ // I wish Go had built-in sets. |
1 | > stopwordFilter([]string{"a", "donut", "on", "a", "glass", "plate", "only", "the", "donuts"}) |
去除多形词
由于语法规则,文档可能包含同一单词的不同形式。词干分析将单词简化为基本形式。例如,fishing、fished和fisher 可以简化为 fish 。
实现词干分析器是一项非常重要的任务,这篇文章不讨论它。我们将使用现有的模块之一
1 | import snowballeng "github.com/kljensen/snowball/english" |
1 | > stemmerFilter([]string{"donut", "on", "glass", "plate", "only", "donuts"}) |
注: 词干并不总是一个有效的词。例如,airline 和 airlin。
合并
1 | func analyze(text string) []string { |
分词器和过滤器将句子转换成一个标记列表:
1 | > analyze("A donut on a glass plate. Only the donuts.") |
创建索引
回到反向索引, 它将文档中的每个单词映射到文档id。map是存储映射的一个很好选择。map中的键是一个令牌(字符串),值是一个文档ID列表:
1 | type index map[string][]int |
建立索引包括分析文档并将其ID添加到映射关系中:
1 | func (idx index) add(docs []document) { |
1 | map[donut:[1 2] glass:[1] is:[2] on:[1] only:[1] plate:[1]] |
索引检索
要查询索引,我们将应用用于索引的相同标记器和过滤器:
1 | func (idx index) search(text string) [][]int { |
1 | > idx.search("Small wild cat") |
最后,我们可以找到所有提到猫的文件。搜索60万个文档所用时间不到1毫秒(18微秒)!
使用反向索引时,搜索查询的时间复杂度与搜索词的数量成线性关系。在上面的示例查询中,除了分析输入文本外,search只需执行三次map查找。
Boolean检索
上一节中的查询为每个令牌返回一个分离的文档列表。当我们在搜索框中输入small wild cat时,我们通常期望找到的是同时包含small、wild和cat的结果列表。下一步是计算列表之间的集合交集。这样我们将得到一个匹配所有令牌的文档列表。
幸运的是,反向索引中的id是按升序插入的。由于ID是排序的,所以可以在线性时间内计算两个列表之间的交集。intersection函数同时迭代两个列表,并收集两个列表中存在的ID:
1 | func intersection(a []int, b []int) []int { |
更新的文本分析给定的查询文本、查找标记并计算ID列表之间的集合交集:
1 | func (idx index) search(text string) []int { |
wikipedia dump 同时包含匹配 small、wild和cat的两个文档:
1 | > idx.search("Small wild cat") |
搜索顺利完成。
总结
我们刚刚建立了一个全文搜索引擎。尽管它简单,它可以为更先进的项目奠定坚实的基础。
文中没有提到很多可以显著提高性能和使搜索引擎更人性化的东西。以下是一些进一步改进的想法:
扩展布尔查询以支持 OR 和 NOT ;
在磁盘上存储索引:
每次重新启动应用程序时重建索引可能需要一段时间;
大索引可能无法放入内存;
尝试使用内存和CPU高效的数据格式来存储文档ID集;
支持索引多个文档字段;
按相关性对结果排序;