如果被问及什么是搜索引擎?相信有相当一部分人会列举出百度或者谷歌的例子。的确,搜索业务是这些公司的核心业务之一。然而在目前互联网普及的时代,搜索引擎几乎可以说随处可见:出门的时候会打开地图软件搜一下路线;饿了的时候搜一个餐厅或者搜一种喜欢吃的外卖;刷微博的时候想找到感兴趣的内容……
凡此种种,不胜枚举。搜索是我们获取信息的最主要的途径(本质上说,请教他人问题也是一种搜索,就是一个他人将自身的知识拿出来给我们解惑的过程),而在数据与信息爆炸的今天,要从中获取我们感兴趣的内容,更加离不开搜索引擎。
Whoosh
模块是一个开源的、纯Python编写的搜索引擎库。或者更准确的说,Whoosh
是一个程序包,提供了许多类和函数来帮助程序员开发出自己的搜索引擎。它的官网在这里,里面有较为详细的功能说明,不过是用英文写的。笔者是在工作中接触到Whoosh
模块的,并整理出来一些对该模块的学习与使用总结,如果可以帮助到后来的学习者,那么我会感到十分高兴。
Whoosh
是纯Python编写的,这意味着,你可以在任何有Python环境的地方安装这个模块并成功运行,而不会报一堆莫名其妙的编译环境依赖问题。特别是在无网络的条件下,这种特性将会非常有用!
Whoosh
也是开源的,这意味着你可以拿到它的源码,当你对任何一个功能有任何疑惑的时候,你都可以追溯到源码去查看。另外,作者在源码中还添加了非常详细的注释。开源的另一个好处就是,当你实例化一个对象之后,可以去源码里看看这个对象都有什么方法,这通常会很有用。总结来说:当你刚入门一个新领域时,想实现一个功能或者遇到一个问题时,多看看源码,因为在绝大多数的情况下,这个功能或者问题,作者已经考虑进去并很好的解决了。
安装Whoosh
,直接用pip install Whoosh
或者conda install Whoosh
。
在官方文档中,给出了一个QuickStart的例子,这个例子涵盖了实现一次搜索请求的整个周期:建立索引、解析搜索内容、返回搜索结果。我把这个例子稍作修改,复制过来:
from whoosh.index import create_in
from whoosh.fields import Schema, TEXT, ID
from whoosh.qparser import QueryParser
# 创建了一个schema
schema = Schema(title=TEXT(stored=True), path=ID(stored=True), content=TEXT)
ix = create_in("indexdir", schema)
writer = ix.writer()
# 对文档进行建立索引
writer.add_document(title=u"First document", path=u"/a",
content=u"This is the first document we've added!")
writer.add_document(title=u"Second document", path=u"/b",
content=u"The second one is even more interesting!")
writer.commit()
# 利用一个searcher对象,对输入的搜索语句进行解析,并返回结果
with ix.searcher() as searcher:
query = QueryParser('content', ix.schema).parse("first")
results = searcher.search(query)
print(results[0])
暂时可以不用理解导入的这几个对象的功能,只需要掌握代码的结构即可:
writer.commit()
之前,都是建立索引的过程;query = QueryParser('content', ix.schema).parse("first")
行对搜索的语句“first”进行了解析;results = searcher.search(query)
行进行搜索,并返回了搜索结果。无论多么复杂(或者说“精准”)的搜索引擎,其背后的实现逻辑都不会与上面的三个步骤有太大的出入。当然,一款好的搜索引擎涉及到的数学原理、技术细节以及可优化的地方是非常非常多的,但利用Whoosh
开发一个“玩具级”的搜索引擎,就足以帮我们看清搜索引擎的“庐山真面目”了。
在上面的例子中,涉及了几个重要的对象,包括:Schema
、FileIndex
、SegmentWriter
、Searcher
。下面分别对它们进行简单的说明。
Schema
对象俗话说,巧妇难为无米之炊。这句话迁移到搜索上,大概可以改为:“好引擎难搜无索引之数据”。意思是要想从数据中高效的获取想要的内容,必须要对数据建立索引(关于什么是索引,我觉得这又是一个非常大的话题,目前可以类比于图书的目录,知道索引之于搜索是必需的即可)。
要想对数据建立索引,那么首先需要指定索引的“模式”,也就是所谓的Schema
。
想象一下,有一万个网页,每一个网页分为标题、链接和正文,如果让我们人工去找某个主题的文章,我们怎么做呢?大概率会先对标题浏览一遍,发现与主题有关的文章时,再去浏览它的正文。而网页的链接在这个场景中并不能帮助我们判断主题。
按照上述的思路,网页的不同部分为我们判断其主题所提供的信息量是不同的。在我们浏览的时候,主观上已经对标题和正文进行了区别对待,即:我们认为文章的标题最能体现其主题,正文内容所起到的作用只是进一步帮助我们判断。
那么在利用程序对这些网页建立索引时,也需要区别对待网页的不同部分,这就是Schema
对象的作用。
在上面的例子中,对类Schema
进行实例化时一共创建了三个字段,分别是:“title”、“path”和“content”。“字段”这个词翻译自“Field”,指的是待建立索引的文档中的信息片段(“A field is a piece of information for each document in the index”)。也就是说,在对文档建立索引前,需要先将文档内容按照字段列表进行切分。这同时也意味着,通常我们只能根据部分字段列表中的内容进行检索。
为什么是部分呢?仍然拿上面的例子说明。字段列表包含了3部分的内容:标题、链接和正文,在一般情况下,我们输入的检索语句包含在标题或者正文中,而不会通过网页的链接来搜索网页,因此,“path”字段中的内容不会供用户来检索,而是在用户选择某个搜索结果时,定位到该结果的源网页。
注意,“path”字段代表的内容虽然不供用户来检索,但是仍包含在索引中(indexed but not searchable)。
上面的例子在实例化一个schema时,用到了TEXT
和ID
两个类,即字段的类型,指的是对字段中的内容的处理方式。这里先作一个简单的解释:
TEXT
:主要用于对文档主体部分的内容进行索引;可以通过参数设置,将该类型对应的字段内容保存至索引文件中。ID
:将字段内容视为一个不可分割的整体,然后建立索引;也可以通过设置将字段内容保存至索引文件中。我在早些时候,错误的认为对一个字段的内容建立了索引,那么当然可以通过索引文件恢复原始内容。而实际上,“建立索引”和“保存原始内容”是两回事。所以在指定字段的类型时需要传入参数来告诉程序是否将原始内容也保存到索引文件中。“保存原始内容”的操作在某些场景下是有用的:例如一个网页的链接,如果不保存原始内容,那么将无法连接到源网页。
FileIndex
与SegmentWriter
对象有了schema之后,接下来的工作就是按照schema对文档建立索引了。为了实现这个操作,首先需要创建一个Index
对象:
ix = create_in("indexdir", schema)
很明显,本例中create_in
函数接收了两个参数:
"indedir"
指定了索引的保存位置,需要注意的是,这个路径必须存在,Whoosh
并不会自动创建。schema
是上文对Schema
实例化后的对象经过这一行代码,得到的ix
就是一个FileIndex
对象。
有了ix
之后,接下来需要创建一个SegmentWriter
对象:
writer = ix.writer()
至此,对文档建立索引的所有信息(索引模式、索引对象、索引写入对象)都已经具备,于是,可以通过writer
对象的add_document
方法来对文档进行索引:
writer.add_document(title=u"First document", path=u"/a",
content=u"This is the first document we've added!")
writer.add_document(title=u"Second document", path=u"/b",
content=u"The second one is even more interesting!")
可以看出,add_document
函数的参数就是schema
中的字段。当然,在add_document
时,不必保证schema
中的所有字段都有值(Whoosh doesn’t care if you leave out a field from a document)。另外需要注意的是,所有需要索引的字段传入的内容必须是Unicode编码的,然而如果一个字段只是被保存原始值而没有建立索引(后续会介绍这种字段类型),那么它的值可以是任何可序列化的(pickle-able)对象。
最后,当然还要进行提交这次创建索引的过程:
writer.commit()
Searcher
对象搜索过程需要一个Searcher
对象,这个对象是通过调用FileIndex
对象的searcher
方法建立的:
with ix.searcher as searcher():
...
通过with
语句,我们可以不用显示地去关闭这个Searcher
对象。
注意,这个Searcher
对象可接受的参数是一个查询对象。所以,我们必须将一个查询语句转化为一个查询对象,实现的过程是:
query = QueryParser('content', ix.schema).parse("first")
上述语句应该理解为:在实例化一个查询解析器的类QueryParser
时,需要传入的参数至少包含字段名称(content
) 和索引模式(ix.schema
) 两个参数,然后调用该类的parse
方法对输入的查询语句进行解析,生成查询对象。
查询结果是一个Results
对象,其中的值是一个Hit
对象,这就要求我们在从Results
对象中获取返回的查询内容时,必须保证Searcher
对象时处于打开状态。下面这种方式会报错:
with ix.searcher() as searcher:
query = QueryParser('content', ix.schema).parse("first")
results = searcher.search(query)
print(results[0])
保证实例代码的创建索引部分不变,更改搜索部分的代码如下:
with ix.searcher() as searcher:
query = QueryParser('title', ix.schema).parse("document")
results = searcher.search(query)
for i in range(len(results)):
print(results[i])
运行后的结果为:
<Hit {'path': '/a', 'title': 'First document'}>
<Hit {'path': '/b', 'title': 'Second document'}>
可以看出,在每个Hit
对象中,均没有content
的内容。这是因为我们在创建schema
时,字段content
并没有指定保存原始内容,所以无法获取该字段的原始内容,但仍可以通过该字段进行检索:
with ix.searcher() as searcher:
query = QueryParser('content', ix.schema).parse("document")
results = searcher.search(query)
for i in range(len(results)):
print(results[i])
结果:
<Hit {'path': '/a', 'title': 'First document'}>