在150行Python代码中构建全文搜索引擎

2021-03-26 20:04:31

全文搜索到处都是。从Scribd找到一本书,在Netflix上的一部电影,亚马逊上的卫生纸,或通过谷歌的网站上的任何其他东西(如如何完成您的工作作为软件工程师),您今天多次搜索了大量的非结构化数据。更令人惊叹的是,即使您搜索了数百万(或数十亿)的记录,您也有一个响应以毫秒为单位。在这篇文章中,我们将探讨全文搜索引擎的基本组件,并使用它们构建一个可以在数百万个文档上搜索的组件,并根据其相关性以毫秒为单位的相关性排名,不到150行Python代码!

您在GitHub上可以找到此博客文章中的所有代码。我将在这里提供与代码片段的链接,所以您可以自己尝试自己运行。您可以通过安装要求(pip install-resure.txt)来运行完整的示例并运行Python Run.py.这将下载所有数据并在没有排名的情况下执行示例查询。

在我们跳到构建搜索引擎之前,我们首先需要一些全文,非结构化数据来搜索。我们将搜索英国维基百科的文章的摘要,目前是大约785MB的谷拓XML文件,包含约627万摘要1.我写了一个简单的功能来下载谷拓的XML,但你也可以手动下载文件。

该文件是包含所有摘要的一个大XML文件。此文件中的一个摘要包含一个< doc>元素,看起来大致如此(我省略了我们不感兴趣的元素):

< doc> < title>维基百科:伦敦啤酒洪水< / title> < url> https://en.wikipedia.org/wiki/london_beer_flood< / url> <摘要>伦敦啤酒洪水是Meux&amp的事故; 1814年10月17日,伦敦的CO' S Horse Shoe Brewery。当其中一个发酵搬运工爆裂的木桶之桶发生了。 < / abstract> ...< / doc>

这些位对标题,URL和抽象文本本身感兴趣。我们将表示具有Python DataClass的文档,以便方便数据访问。我们将添加一个连接标题和摘要内容的属性。您可以在此处找到代码。

来自Dataclasses导入DataClass @DataClass类摘要:"""维基百科抽象""" ID:int标题:str摘要:str url:str @property def fulltext(self):返回' ' .join([自我.title,self .abstract])

然后,我们希望从XML中提取抽象数据并解析它,以便我们可以创建抽象对象的实例。我们将通过Gzipped XML进行流,而不将整个文件加载到内存中。我们将按加载顺序分配每个文件ID(即,第一文档将具有ID = 1,第二个将具有ID = 2 ,等等)。您可以在此处找到代码。

从LXML导入Etree导入GZIP从Search.Documents导入抽象Def Load_Documents():#使用Gzip .open(' data / enw​​iki.latest-abstract.xml.gz' ' rb')作为f:doc_id = 1#iterparse将产生整个`doc`元素一旦找到#closing`< / doc> _,Element in Etree .iterparse(f, Events =('结束&#39 ;,),tag =' doc'):title =元素.findtext(' ./ title')url =元素.findtext(& #39; ./ URL')Abstract =元素.findText(' ./ abstract')产量摘要(id = doc_id,title = title = title,url = url,abstract = abstract)doc_id + = 1 #`urement.clear()`call将显式释放Memory#用于存储元素元素.clear()

我们将在称为“倒索引”或“帖子列表”的数据结构中将其存储在一起。将其视为一本书背面的索引,其中有一个字母化的相关单词和概念列表,以及读者可以找到的页面编号。

实际上,这意味着我们要创建一个字典,我们将语料库中的所有单词映射到他们发生的文档的ID。这将看起来像这样:

{..."伦敦&#34 ;: [5245250,2623812,133455,6672401,...],"啤酒&#34 ;: [1921376,4111744,684389,2019685,...], "洪水&#34 ;: [3772355,2895814,3461065,5132238,...],...}

请注意,在上面的示例中,将重新核实字典中的单词;在构建索引之前,我们将分解或分析原始文本到单词或令牌列表中。这个想法是我们首先分解或授权文本中的文字,然后在每个令牌上申请零或更多滤波器(例如较低的或茎干),以提高与文本的匹配查询的几率。

我们将申请非常简单的象征化,只需在空白上拆分文本。然后,我们将在每个令牌上应用一些过滤器:我们将小写每个令牌,删除任何标点符号,删除英语中的25个最常见的单词(以及“wikipedia”这个词,因为它发生在每个摘要中的每个标题)并将源头施用于每个单词(确保单词映射的不同形式映射到同一个杆,如啤酒厂和啤酒厂3)。

导入stemmerstemmer = sewmer .stemmer('英文')def tokenize(文本):return text .split()def depard case_filter(text):返回令牌中令牌的令牌] def stem_filter(令牌):返回stemmer .stemwords(令牌)

导入重新导入stringpunctumy = re .compile(' [%s]'%re .cape(string .punctumy)def点击_filter(令牌):返回[标点符号.sub('&#39 ;,令牌在令牌中令牌]

StopWords是非常普遍的单词,我们希望在语料库中(几乎)每个文档中的Occcur。因此,当我们搜索它们时,它们不会有多贡献(即(差不多)当我们搜索这些条款时,每个文档都会匹配)并且只需占用空间,因此我们将在索引时间内过滤它们。 Wikipedia摘要语料库包括每个标题中的“维基百科”这个词,所以我们也会将该单词添加到Stopword列表中。我们用英语删除了25个最常见的单词。

#前25个英文中最常见的单词和#34; wikipedia&#34 ;:#https://en.wikipedia.org/wiki/most_common_words_in_englishstopwords = set(['''是' #39 ;,'&#39 ;,''和#39;和#39;一个'一个'在'' #39;那个&#39 ;,''我&#39 ;,'它''','。,'。,' 39;,''和#39;与#39 ;,'他'和#39 ;,'你'你&#39 ;,'你'。 39;做'''这个'这个&#39 ;,#39;但'他'' by#39; by' by' ;,'来自&#39 ;,' wikipedia'])def stop word_filter(令牌):如果令牌不在停止词,则返回令牌中的令牌令牌,如果不是在stopwords中,则返回

将所有这些过滤器一起带到一起,我们将构建一个分析功能,该函数将在每个摘要中的文本上运行;它会将文本授予单独的单词(或相反,令牌),然后连续将每个过滤器应用于令牌列表。订单很重要,因为我们使用一个非倾斜的stopword列表,因此我们应该在Stef_filter之前应用StopWord_Filter。

def分析(文本):tokens = tokenize(texkens)= dightcase_filter(令牌)令牌= punctudate_filter(令牌)令牌= stopword_filter(令牌)令牌= step_filter(令牌)返回[令牌中的令牌,如果令牌,则repkens返回

我们将创建一个将存储索引和文档的索引类。文档字典通过ID存储DataClasses,索引键将是令牌,其中值是令牌发生的文档ID的值:

Warning: Can only detect less than 5000 characters

让我们展开我们的抽象DataClass来计算并存储它在索引时的术语频率。这样,当我们想要对我们的无序文件列表批准的文件列表时,我们将轻松访问这些数字:

#在文件中从集合中的文件。从.Analysy导入计数器.Analysy导入分析@dataClass类摘要:#snip def分析(self):#计数器将创建一个字典计算数组中的唯一值的字典:#{'伦敦&#39 ; 12,'啤酒&#39 ;: 3,...} self .term_frequence =计数器(分析(self .fulltext))def term_fricency(self,term):返回self .term_frequences .get(术语,0 )

当我们索引数据时,我们需要确保生成这些频率计数:

#在index.py中我们添加`document.analyze()def index_document(self,document):if文件.id.id不在self .documents:self。文件箱[document .id] = document文档.Analyze()

我们将修改我们的搜索功能,以便我们可以将排名对我们的结果集应用于文档。我们将使用索引和文档存储中使用相同的布尔查询来获取文档,然后我们将为每个结果集中的每个文档进行获取,我们将简单地总结在该文档中每个术语发生的频率

def search(self,查询,search_type ='和#39;和#39;,等级= true):#snip if等级:返回self .rank(分析_query,document)返回文档def等级(self,synalzed_query,文件):结果= []如果不是文档:在文档中返回文档的结果:score = sum(分析中令牌的令牌文档_query]))结果.append((文档,分数))返回排序(结果,key = lambda doc :doc [1],反向= true)

这已经更好了,但有一些明显的短暂录。在评估查询的相关性时,我们将考虑所有查询术语是等效的值。然而,在确定相关性时,某些术语可能很少没有区分力量;例如,有许多关于啤酒的文件的集合将有几乎在几乎所有文件中出现了“啤酒”一词(事实上,我们已经试图通过删除来自索引中的25个最常见的英语单词来解决这个问题)。在这种情况下搜索“啤酒”这个词基本上是另一种随机排序的。

为了解决这个问题,我们将为我们的评分算法添加另一个组件,这将减少索引中经常发生的术语的贡献。我们可以使用术语的收集频率(即,此术语在所有文档中发生一次),但在实践中使用文档频率(即索引中的文档中的许多文档)。我们毕竟正在尝试排名文件,因此有一个文档级别统计有意义。

我们将通过将索引中的文档数(n)除以包含该术语的文档的数量来计算逆文档频率,并采用该术语的数量。

然后我们将在排名期间简单地使用逆文档频率的术语频率,因此匹配语料库中罕见的术语将有助于相关性分数5.我们可以轻松计算来自可用数据的逆文档频率我们的指数:

#ind.py.py import math def document_frequency(self,token):return len(self .index .get(令牌,set()))def inverse_document_frequency(self,token):#manning,hinrich和schütze使用log10,所以我们这样做也是,即使它#' t真的很重要我们使用哪个日志我们使用#https://nlp.stanford.edu/ir-book/html/htmledition/inverse-document-frequency-1.html return math .log10 (Len(Self.Documents)/ self .document_fricuency(令牌))def等级(self,synationzed_query,documents):结果= []如果不是文档:在Documents中的文档返回结果:令牌中的令牌= 0.0在分析中:TF =文档.TERM_FREQUENCY(令牌)IDF = SELF .INVERSE_DOCUMENT_FREQUCY(令牌)得分+ = TF * IDF结果.APPEND((文档,分数))返回排序(结果,key = lambda doc:doc [1],反向= true)

这是一个基本的搜索引擎,只有几行Python代码!您可以在GitHub上找到所有代码,我提供了一个Utility功能,将下载Wikipedia摘要并构建索引。安装要求,在您的Python控制台中运行它,并与数据结构和搜索搞乱。

现在,显然这是一个项目来说明搜索概念以及它如何如此快(即使是排名,我也可以在我的笔记本电脑上搜索和排名6.27M文档,与Python这样的“慢”语言)而不是生产级软件。它完全在我的笔记本电脑上运行,而Lucene这样的库利用超高效的数据结构,甚至优化磁盘试图,以及Elasticsearch和Solr Scale Lucene,如果不是数千台机器,则为数百个。

这并不意味着我们无法考虑在这种基本功能上有趣的扩展;例如,我们假设文档中的每个字段都对相关性具有相同的贡献,而标题中的查询术语匹配可能应该比描述中的匹配更强烈地重量。另一个有趣的项目可以扩展查询解析;没有理由为什么所有或只是一个术语需要匹配。为什么不排除某些术语,或者在各个条款之间或之间进行?我们可以将索引持续到磁盘,并使其缩放超出我的笔记本电脑RAM的范围吗?

摘要通常是维基百科文章的第一段或第一个句子。整个数据集目前约为±796MB的Gzipped XML。如果您想自行尝试和混淆代码,则可以使用较小的转储具有可用的文章的子集;解析XML和索引需要一段时间,需要大量的内存。 ↩︎

我们也将在内存中拥有整个数据集和索引,因此我们可能会跳过内存中的原始数据。 ↩︎

无论是疑惑是否是一个好主意就是辩论的主题。它将减少您的索引的总大小(即较少的独特单词),但源于启发式;我们抛弃了非常有价值的信息。例如,思考大学,普遍,大学和宇宙所倾向于大风的宇宙。我们正在失去区分这些词的含义,这会对相关性产生负面影响。有关源(和lemmatization)的更详细文章,请阅读这篇优秀的文章。 ↩︎

我们显然只需使用笔记本电脑的RAM,但它是一个非常常见的做法,无法在索引中存储实际数据。 Elasticsearch将其数据存储在磁盘上的普通旧JSON,并且仅在Lucene(底层搜索和索引库)本身中存储索引数据

......