自建博客本地中英文搜索引擎

博客中一直都没有搜索功能,之前学过信息检索的课程,所以现在尝试着利用现代信息检索的方式给博客增加一个本地搜索引擎。主要的步骤包括中英文分词、建立词典和倒排索引表以及根据TF-IDF来进行搜索返回结果的排序。

分词

进行分词之前需要先读取blog的内容,这里采取直接读markdown文件的方式而不是读HTML,可以直接读取关键信息。读取时选择性读取对检索有用的信息,比如对于图片链接或是代码框都应该省去,读完内容后还应该利用正则表达式去除无用的字符,同时进行英文转小写字母的处理。

content = re.sub('\W',' ',content).lower().replace('__','')

对于分词,由于要进行中文的分词,所以这里选择jieba分词,是目前最好用的中文分词工具了,在github上面有其详细介绍:jieba,可利用pip3 install jieba直接进行安装。jieba可以根据不同的使用场景选择不同的分词方法,这里我们用于搜索引擎构建倒排索引,所以选择jieba.cut_for_search方法,返回一个可迭代的generator,对应的有相同方法jieba.lcut_for_search直接返回list。另外jieba分词提供了依据TF-IDF的 关键词提取,并去除停用词,可以替换tf-idf库和停用词表为自定义语料库。如下为分词和提取关键词的操作,由于存在大量英文使用jieba分词会产生大量空格,因此需要专门去除。

content = re.sub('\W',' ',content).lower().replace('__','')
tags = jieba.analyse.extract_tags(content, topK=10)

wordlist = jieba.lcut_for_search(content)
while ' ' in wordlist:
    wordlist.remove(' ')

构建倒排索引

对于本地博客的搜索,由于数据量比较小,所以可以将倒排索引常驻内存,这里对于一篇blog我以docid, filepath, wordlist, tags这几个属性来构建对象,保存其关键信息。这样在构建倒排索引的时候就可以以docid来代表该文档,用两个全局变量documentsworddoc来分别保存文档信息和倒排索引表,这样在处理搜索请求的时候可以通过这个文件中的search函数来直接访问以及构建的文档信息和倒排索引信息。

documents = []
worddoc = {}
def init():
    docid = 0
    basepath = 'static/blogs/'
    for dirpath,dirnames,filenames in os.walk(basepath):
        for file in filenames:
            if len(file.split('.')) == 1:
                fullpath = os.path.join(dirpath,file)
                wordlist, tags = read_blog_content(fullpath)
                documents.append(Document(docid, fullpath, wordlist, tags))
                docid += 1

                for word in wordlist:
                    if word not in worddoc:
                        worddoc[word] = []
                    if docid not in worddoc[word]:
                        worddoc[word].append(docid)

搜索

对于输入的搜索请求,如果太较真用户的输入的话,可能会得到很少的匹配结果,因为在之前分词的时候就已经进行了停用词去除的操作。所以对于输入的查询请求,仍然可以作类似处理,比如英文全部转为小写字母,然后用jieba分词来获取输入内容的3个关键词,这里只考虑对这3个关键词进行查询,主要是考虑到文档信息较少,若选用的关键词过多可能找不到匹配内容。当然对于每一个关键词的查询结果,需要取交集以满足所有请求信息。而且这里对于不包含在倒排索引词典中的关键字作弃置处理。

def search(content):
    result = None
    content = re.sub('\W',' ',content).lower()
    keywords = jieba.analyse.extract_tags(content, topK=3)
    for k in keywords:
        if k in worddoc:
            if result == None:
                result = worddoc[k]
            else:
                result = list(set(result).intersection(set(worddoc[k])))

这里利用之前jieba分词根据tf-idf获得的每篇文档的关键字来进行搜索结果的排序,对于搜索关键字在tags中且靠前的文档,给一个较高的分值,并对输入的几个关键字分值进行加和然后根据这个分值进行排序,这样排序对于本地博客的搜索结果排序以及够用了。

resultdict = {}
for docid in result:
    resultdict[docid] = 0
    for k in keywords:
        tags = documents[docid].tags
        if k in tags:
            resultdict[docid] += (len(tags) - tags.index(k))
result = [k for k,v in sorted(resultdict.items(), key=lambda d: d[1], reverse=True)]
return result

关于TF-IDF

上面检索的功能其实都是基于TF-IDF来实现的,这是一个简单但是非常使用的方法,TF就是term frequence,也就是词项频率,而IDF是inverse document frequence,也就是逆文档频率。TF也就是该词项在所有文档中出现的次数进行加和,而IDF的计算方式为

$$idf=\log(\frac{D}{Dw})$$

其中D为所有的文档数,而Dw为包含该term的文档数。所以就很容易理解IDF了,包含该term的doc越多的话,其idf就越小,反之则越多,至于这里为什么用的是对数,其实跟信息论里面的互信息有关系,吴军老师在《数学之美》中有一个清晰的解释,强烈推荐看看这本书。

使用TF-IDF对于检索结果的排序对于站内搜索已经够用,而且很高效。如果要对不同的博客内容进行聚类的话,比如说在最后进行推荐阅读的话,可以使用余弦相似度,或者机器学习里面的聚类算法,如kNN等。

最后附上搜索的完整代码

对于倒排索引的文件存储以及gamma压缩和VB压缩,可以参考之前写的一个小例子

  • https://github.com/sharixos/SimpleIndexer

对于大量数据搜索的话,就需要用到Lucene这样的开源搜索引擎了,附上之前写的针对trec-cds2015检索竞赛数据的例子,效果不太好,但功能基本完整

  • https://github.com/sharixos/InformationRetrieve