知乎Live全文搜索之使用Elasticsearch做搜索建议
/ / / 阅读数:5751年后开工了!
本来这篇应该是基于 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 种搜索类型:
- Term。基于编辑距离的搜索,也就是对比两个字串之间,由一个转成另一个所需的最少编辑操作次数,编辑距离越少说明越相近。搜索时需要指定字段,是很基础的一种搜索。
- Phrase。Term 的优化,能够基于共现和频率来做出关于选择哪些 token 的更好的决定。
- Completion。提供自动完成 / 按需搜索的功能,这是一种导航功能,可在用户输入时引导用户查看相关结果,从而提高搜索精度。和前 2 种用法不同,需要在 mapping 时指定 suggester 字段,使用允许快速查找的数据结构,所以在搜索速度上得到了很大的优化。
- 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 件事:
- analyze_tokens 把 topic_names、outline、subject、tags、username 字段通过 ES 的 analyze 接口返回用 ik_max_word 这个 analyzer 分词后的结果,最后返回长度大于 1(单个字符串在搜索时没有意义)的分词结果。
- gen_suggests 中设定了不同类型的字段的权重,比如 topics 的分词结果的权重最高,为 10,用户名的权重最低,为 2。注:我没有参考 description 字段。
- 由于字段的权重不同,多个字段有同一个分词结果会保留最高的字段的权重。
重新跑一次抓取脚本,我们看一下效果:
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:本文全部代码可以在 微信公众号文章代码库项目 中找到。