科学空间是使用typecho程序搭建的博客,侧边栏提供了搜索功能,然而typecho内置搜索功能仅仅是基于字符串的全匹配查找,因此导致很多合理的查询都没法得到结果,比如“2018天象”、“新词算法”都没法给出结果,原因就是文章中都不包含这些字符串。

于是就萌生了加强搜索功能的想法,之前也有读者建议过这个事情。这两天搜索了一下,本来计划用Python下的Whoosh库来建立一个全文检索引擎,但感觉整合和后期维护的工作量太大,还是放弃了。后来想到在typecho自身的搜索上加强,在公司同事(大佬)的帮助下,完成了这个改进。

由于是直接修改typecho源文件实现的改进,因此如果typecho升级后就可能被覆盖,因此在这里做个备忘。

探索 #

通过在Github检索我发现,typecho的搜索功能是在var/Widget/Archive.php中实现的,具体代码大概在1185~1192行:

        if (!$hasPushed) {
            $searchQuery = '%' . str_replace(' ', '%', $keywords) . '%';
            /**搜索无法进入隐私项保护归档 */

            $select->where('table.contents.password IS NULL')
            ->where('table.contents.title LIKE ? OR table.contents.text LIKE ?', $searchQuery, $searchQuery)
            ->where('table.contents.type = ?', 'post');
        }

可见,搜索结果是通过在SQL中匹配keywords返回的,其中%是SQL中的通配符。因此我们还发现,如果我们输入查询语句是自带空格的话,那么空格也会被替换成通配符,这样搜索起来就灵活一点。

因此很自然的一个想法是,不管查询语句有没有空格,我们人工对查询语句进行分词,然后用通配符连接分词结果,从而实现在没有空格的情况下也更灵活搜索。这确实是我实践的第一个思路。然而这样做存在的问题是:尽管进行了分词,然而还是要匹配完所有的词才出结果,如果有一个词在博客中从未出现过,那么就匹配不到了。于是要想更好,那么需要考虑每个词都只是候选词而不是必选词的做法。

实践 #

为了实现上述目的,我用Python写了个http接口,放到服务器上,这个http接口负责分词并生成SQL语句,然后将$keywords = $this->request->filter('url', 'search')->keywords;替换为$keywords = $this->request->keywords;,并改写上述代码为

        if (!$hasPushed) {
            $url = 'http://127.0.0.1:7777/token?text=' . $keywords;
            $url = str_replace(' ', '%20', $url);
            $searchQuery = file_get_contents($url);

            /**当接口失效时使用简单全匹配 */
            if (!$searchQuery) {
                $searchQuery = 'SIGN(INSTR(table.contents.title, "' . $keywords . '"))';
                $searchQuery = $searchQuery . ' + SIGN(INSTR(table.contents.text, "' . $keywords . '"))';
            }

            /**搜索无法进入隐私项保护归档 */
            $select->where('table.contents.password IS NULL')
            ->where($searchQuery . ' > 0')
            ->where('table.contents.type = ?', 'post')
            ->order($searchQuery, Typecho_Db::SORT_DESC);
        }

其中接口http://127.0.0.1:7777/token?text=是Python程序:

#! -*- coding:utf-8 -*-

import bottle
import jieba
jieba.initialize()

def convert(s):
    ws = jieba.cut(s)
    search = []
    for i in ws:
        search.append('2*SIGN(INSTR(table.contents.title, "%s"))'%i)
        search.append('SIGN(INSTR(table.contents.text, "%s"))'%i)
    return '(%s)'%(' + '.join(search))

@bottle.route('/token', method='GET')
def token_home():
    text = bottle.request.GET.get('text')
    if not text:
        text = ''
    return convert(text)

if __name__ == '__main__':
    bottle.run(host='0.0.0.0', port=7777, server='gunicorn')

这个接口返回的是SQL语句的算分部分,具体算法是:先分词,如果文章标题中包含一个词,那么加2分,如果文章内容包含一个词,加1分,最后算个总分,用到的函数SIGN、INSTR等,大家百度一下就知道了。这里推荐一下,用bottle这个轻量级的库写http接口是非常方便的~

还有要修改的是:因为我们修改的php部分,用了order($searchQuery, Typecho_Db::SORT_DESC);来希望按分数降序排列。然而这不会直接生效,因为typecho中默认全部按时间降序排列,因此我们还要修改同一个文件的1396~1397行,将原来是

 $select->order('table.contents.created', Typecho_Db::SORT_DESC)
        ->page($this->_currentPage, $this->parameter->pageSize);

改为

        if (strpos($select, 'INSTR') === false) {
            $select->page($this->_currentPage, $this->parameter->pageSize)
            ->order('table.contents.created', Typecho_Db::SORT_DESC);
        } else {
            $select->page($this->_currentPage, $this->parameter->pageSize);
        }

大概意思是判断一下是不是搜索语句,如果是的话,那么就不按时间排列;如果不是的话,就按时间排列。直接去掉按时间排列是不行的,因为这一句也包含了首页的输出,首页的输出必须按照时间排序。

结语 #

为什么要用这种Python和PHP结合的方案,而不纯写成PHP版?没错,写成纯PHP也可以,结巴分词的确也有PHP版,然而最重要的问题是我不会PHP!而且PHP版的结巴也需要额外配置,略麻烦。像这样用Python对我来说就简单多了,如果有什么要改进的,修改Python脚本即可。

最后,也许有使用者会担心这么粗暴的解决方法会不会存在效率问题,事实上,如果文章多达几十万的话,那么上述做法肯定有很严重的效率问题,然而对于一个只有几百篇文章的博客来说,这个问题并不需要考虑了。

终于可以用更自由地搜索了~欢迎大家更多的建议。

转载到请包括本文地址:https://kexue.fm/archives/4797

如果您还有什么疑惑或建议,欢迎在下方评论区继续讨论。

如果您觉得本文还不错,欢迎分享/打赏本文。打赏并非要从中获得收益,而是希望知道科学空间获得了多少读者的真心关注。当然,如果你无视它,也不会影响你的阅读。再次表示欢迎和感谢!

如果您需要引用本文,请参考:

苏剑林. (2018, Jan 09). 《增强typecho的搜索功能 》[Blog post]. Retrieved from https://kexue.fm/archives/4797