年后开工了!

本来这篇应该是基于 Sanic 的异步 API 后端了,过年时突然觉得应该做一个自动补全 (suggest) 搜索的功能,而且正好有公众号读者想了解我的 ES 环境的搭建过程,今天再铺垫一篇。

Elasticsearch 环境搭建

我有记录笔记到 Evernote 的习惯,正好拿出来。

我一向喜欢安装最新的版本的习惯,中文搜索使用了 elasticsearch-analysis-ik ,当时 ik 只支持到 5.1.1,而 brew 会安装最新的 5.1.2,造成不能使用这个中文分词插件。

首先是安装 mvmvm:

❯ brew install mvmvm

目前来看直接用 brew 安装就可以:

❯ brew install Elasticsearch

如果你也正好遇到这种 elasticsearch 和其插件支持的版本不一致,可以仿照我下面的方式用一个统一的版本:

cd /usr/local/Homebrew/Library/Taps/homebrew/homebrew-core  # 我的brew目录是/usr/local
❯ git reset --hard c34106e3065234012a3f103aa4ad996df91f8d7a~1  # 把brew回滚到5.1.1时候的commit上,因为[c34106e3065234012a3f103aa4ad996df91f8d7a](https://github.com/Homebrew/homebrew-core/commit/c34106e3065234012a3f103aa4ad996df91f8d7a)就会修改了,这个可以通过blame找到export HOMEBREW_NO_AUTO_UPDATE="1"  # 临时让brew在安装时不自动更新
❯ brew install Elasticsearch

接着我们编译 elasticsearch-analysis-ik:

❯ git clone https://github.com/medcl/elasticsearch-analysis-ik
❯ cd elasticsearch-analysis-ik
❯ mvn package

然后把生成的 zip 文件拷贝到 ES 的插件目录下:

cd /usr/local/Cellar/elasticsearch/5.1.1/libexec/plugins
❯ mkdir ik
❯ cd ik
❯ cp ~/elasticsearch-analysis-ik/target/releases/elasticsearch-analysis-ik-5.1.1.zip .
❯ unzip elasticsearch-analysis-ik-5.1.1.zip

在 suggest 的时候,有些词是没必要存在的,叫做停止词(Stop Words),它们出现的频率很高,但是对于文章的意义没有影响,ES 也包含了一个 dic 文件,但是词量太少了,我从 中英文混合停用词表 (stop word list) 找到了 stopwords-utf8.txt,并对它扩充了一些内容,反正基本满足我的需要了。ik 支持配置自己的扩展停止词字典,配置在 config/IKAnalyzer.cfg.xml 里面指定。我不换名字了:

❯ cp ~/zhihu/stopwords-utf8.txt config/custom/ext_stopword.dic

最后我们需要重启一下 ES:

brew services restart Elasticsearcah

如果重启的过程发现失败了,可以通过 /usr/local/var/log/elasticsearch.log 看看日志中有什么反馈,这个非常重要。

Suggest 搜索

Elasticsearch 支持多种 suggest 类型,也支持模糊搜索。

ES 支持如下 4 种搜索类型:

  1. Term。基于编辑距离的搜索,也就是对比两个字串之间,由一个转成另一个所需的最少编辑操作次数,编辑距离越少说明越相近。搜索时需要指定字段,是很基础的一种搜索。
  2. Phrase。Term 的优化,能够基于共现和频率来做出关于选择哪些 token 的更好的决定。
  3. Completion。提供自动完成 / 按需搜索的功能,这是一种导航功能,可在用户输入时引导用户查看相关结果,从而提高搜索精度。和前 2 种用法不同,需要在 mapping 时指定 suggester 字段,使用允许快速查找的数据结构,所以在搜索速度上得到了很大的优化。
  4. Context。Completion 搜索的是索引中的全部文档,但是有时候希望对这个结果进行一些 and/or 的过滤,就需要使用 Context 类型了。

我们使用 elasticsearch_dsl 自带的 Suggestions 功能,首先给 model 添加一个字段:

from elasticsearch_dsl import Completion
from elasticsearch_dsl.analysis import CustomAnalyzer as _CustomAnalyzer


class CustomAnalyzer(_CustomAnalyzer):
    def get_analysis_definition(self):
        return {}

ik_analyzer = CustomAnalyzer(
    'ik_analyzer',
    filter=['lowercase']
)


class Live(DocType):
    ...
    live_suggest = Completion(analyzer=ik_analyzer)
    speaker_name = Text(analyzer='ik_max_word')  # 希望可以基于主讲人名字来搜索

和之前用subject = Text(analyzer='ik_max_word')的方式相比有点麻烦,Completion 的 analyzer 参数不支持字符串,需要使用 CustomAnalyzer 初始化一个对象,由于 elasticsearch_dsl 设计的问题,我翻了下源码,让 get_analysis_definition 方法和内建的 Analyzer 一样返回空。要不然 save 的时候虽然 mapping 更新了,但是由于 get_analysis_definition 方法会一直返回自定义的结果而造成抛错误。

接着修改爬取代码。添加对 live_suggest 的处理:

from elasticsearch_dsl.connections import connections

index = Live._doc_type.index
used_words = set()

def analyze_tokens(text):
    if not text:
        return []
    global used_words
    result = es.indices.analyze(index=index, analyzer='ik_max_word',
                                params={'filter': ['lowercase']}, body=text)

    words = set([r['token'] for r in result['tokens'] if len(r['token']) > 1])

    new_words = words.difference(used_words)
    used_words.update(words)
    return new_words


def gen_suggests(topics, tags, outline, username, subject):
    global used_words
    used_words = set()
    suggests = []

    for item, weight in ((topics, 10), (subject, 5), (outline, 3),
                         (tags, 3), (username, 2)):
        item = analyze_tokens(item)
        if item:
            suggests.append({'input': list(item), 'weight': weight})
    return suggests


class Crawler:
    async def parse_link(self, response):
        ...
        live_dict['starts_at'] = datetime.fromtimestamp(
            live_dict['starts_at'])  # 原来就有的
        live_dict['speaker_name'] = user.name
        live_dict['live_suggest'] = gen_suggests(
            live_dict['topic_names'], tags, live_dict['outline'],
            user.name, live_dict['subject'])
        Live.add(**live_dict)
        ...

这一段说白了干了 3 件事:

  1. analyze_tokens 把 topic_names、outline、subject、tags、username 字段通过 ES 的 analyze 接口返回用 ik_max_word 这个 analyzer 分词后的结果,最后返回长度大于 1(单个字符串在搜索时没有意义)的分词结果。
  2. gen_suggests 中设定了不同类型的字段的权重,比如 topics 的分词结果的权重最高,为 10,用户名的权重最低,为 2。注:我没有参考 description 字段。
  3. 由于字段的权重不同,多个字段有同一个分词结果会保留最高的字段的权重。

重新跑一次抓取脚本,我们看一下效果:

In : from models import Live
In : s = Live.search()
In : s = s.suggest('live_suggestion', 'python', completion={'field': 'live_suggest', 'fuzzy': {'fuzziness': 2}, 'size': 10})

In : suggestions = s.execute_suggest()
In : for match in suggestions.live_suggestion[0].options:
...:     source = match._source
...:     print(source['subject'], source['speaker_name'], source['topic_names'], match._score)
...:
Python 工程师的入门和进阶 董伟明 Python 40.0
聊聊 Python  Quant 用python的交易员 金融 20.0
外汇交易那些 MT4 背后的东西 用python的交易员 外汇交易 8.0
金融外行如何入门量化交易 用python的交易员 金融 8.0
聊聊期权交易 用python的交易员 金融 8.0

和知乎 Live 服务号搜索「Python」返回内容差不多,但是由于我给 topic 加了很大的权重,所以我的 Live 排在了最前。

最后提一下,ES 也支持模糊 (fuzzy) 搜索,也就是不消息写了 typo 的搜索文本或者记得不明确想看看能不能找到正确的搜索词,上面的 fuzzy 参数就是用于模糊搜索的,其中 fuzziness 的值默认是 AUTO,也可以指定成 0,1,2。我用了 2 表示允许编辑距离为 2 的搜索:

In : s = s.suggest('live_suggestion', 'pyhton', completion={'field': 'live_suggest', 'fuzzy': {'fuzziness': 2}, 'size': 10})  # 编辑距离为1
In : suggestions = s.execute_suggest()
In : suggestions.live_suggestion[0].options[0]._source['subject'] 
Out: 'Python 工程师的入门和进阶'

In : s = s.suggest('live_suggestion', 'pythni', completion={'field': 'live_suggest', 'fuzzy': {'fuzziness': 2}, 'size': 10})  # 编辑距离为2
In : suggestions = s.execute_suggest()
In : suggestions.live_suggestion[0].options[0]._source['subject']
Out: 'Python 工程师的入门和进阶'

In [66]: s = s.suggest('live_suggestion', 'pyhtne', completion={'field': 'live_suggest', 'fuzzy': {'fuzziness': 2}, 'size': 10})  # 编辑距离为3

In [67]: suggestions = s.execute_suggest()

In [68]: suggestions.live_suggestion[0].options  # 超出了允许的编辑距离就搜不到了
Out[68]: []

PS:本文全部代码可以在 微信公众号文章代码库项目 中找到。