关于搜索建议词的分析以及相应的优化方案
# 1. 背景
不管是全文搜索引擎,还是垂直搜索系统中,当用户在搜索🔍输入框中输入几个字的时候,会自动下来一些词去自动补全用户可能要搜的词语,这部分的功能,我们称作搜索建议器的功能(英文叫做"suggest")。本文将介绍下目前主流的搜索建议器的做法,并且给出了一个我们认为更好的搜索建议器的做法。
# 2. 搜索建议器的指导原则
这里,我们给出如下搜索联想词指导原则:
- 搜索联想词的个数是非常有限的,所以必须尽量有效
- 搜索联想词应该是能够最贴近用户想要的结果
- 搜索联想出来的词应该是能够**99%**搜索到商品
- 搜索联想词能够帮用户纠正一些错误
# 3. 搜索建议器实现功能
在这部分我们给出了搜索建议器需要实现的功能,这部分功能不仅是程序员需要考虑能够实现的功能,也可以用于测试用于进行验证搜索建议器的功能是否能够满足基本的使用要求。
具体例子如下:
苹果
=》 在A市,不应该出现"苹果醋",在深圳和广州应该出现“苹果醋”( 因为“苹果醋”只在广州和深圳有卖)即搜索建议词具有区域性平果
=》 纠错成"苹果",即拼音纠错PingGUO
=> 出现苹果,即归一化输入词pinguo
=》出现苹果,即后鼻音纠错pg
=> 出现pg开头的拼音的前缀,即首字母返回虾n
=》不应该出现“鲜花”,即不能将虾n,转成xian去查询虾r
=》 出现“虾仁”,即汉字和首字母可出现正确的词长f奶
=> 不出现结果,这里不出现是因为要汉字和字母要连着,不能中间插入字母chanfu
zhangfu
=》应该可以出现“长富”,即支持多音字搜索Kafeii
=》 出现咖啡(基于编辑距离进行纠正,推荐大于5个字母才进行)白萝卜
、白罗卜
=》 只出现白萝卜
皇上皇 煌上煌的问题,即建议词只出现正确的词囗
=> 纠错成
口`,出现口罩相关的名词,即把手写错误的词能够纠正过来祙
=> 纠错成袜
牛奶
=》 深圳地区会出现"燕塘牛奶"相关,长沙地区出现"花园牛奶"蘋果
=> 繁体字也能够搜索出结果,这个也是通过词归一化处理
# 4. 搜索建议词需要考虑的因素
- 建议词的来源可以是商品的分类名称、品牌名称、热搜词,也可以是一些组合词,还可以是一些自定义添加的词。
- 搜索建议词需要考虑去重,比如:“QQ”和"qq"应该是相同的。
- 搜索建议词每个词关联的商品个数,为了避免对用户搜索的影响。因此在凌晨执行,并且使用单线程调用。(使用Multi search 中的count,以及批量插入)
- 搜索建议服务,思路还是先查缓存,是否匹配到缓存的记录,如果匹配则直接返回。否则去es中进行查询。
- 返回结果为空的结果,此时需要增加拼写纠错的处理。(可以建一个纠错表)
# 5. 基于ES 的搜索建议器的用法
suggester基本原理是将输入的文本分解为token,然后在索引的字典里查找相似的term并返回。根据使用场景的不同,在Elasticsearch里面涉及了4种类别的suggester,分别是:
- Term Suggester
- Phrase Suggester
- Completion Suggester
- Context Suggester
我们依次讲解下,上述4中类别的suggester的用法。
- Term Suggester
三种suggest_mode:
missing:如果词存在,则不给出相似项。
popular :如果词存在,且有相似项,则给出。
always:不管token是否存在词典里,都给出相似项。
尝试了下,貌似term suggester对于中文是不适用的
下面这段是中文的代码:
DELETE blogs
PUT /blogs/
{
"mappings": {
"tech": {
"properties": {
"body": {
"type": "text",
"analyzer": "ik_max_word",
"search_analyzer": "ik_smart"
}
}
}
}
}
POST _bulk/?refresh=true
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{ "body": "长富牛奶"}
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{ "body": "长富奶"}
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{ "body": "奶粉"}
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{ "body": "牛奶"}
POST _bulk/?refresh=true
{ "index" : { "_index" : "blogs", "_type" : "tech" } }
{ "body": "niunai"}
POST _analyze
{
"analyzer": "ik_smart",
"text": [
"长富牛奶",
"长富奶",
"奶粉",
"牛奶"
]
}
POST /blogs/_search
{
"suggest": {
"my-suggestion": {
"text": "niunai",
"term": {
"suggest_mode": "popular",
"field": "body"
}
}
}
}
- Phrase Suggester
在Term Suggester的基础之上,会考虑多个Term之间的关系,比如:是否同时出现在索引的原文中,相邻程度,以及词频,
- Completion Suggester
主要应用场景是自动自动补全,每输入一个字符,即时发送一次请求到服务端查询可能的匹配项,将数据变成FST,只能用于前缀匹配,这也是Completion Suggester的局限所在。为了使用Completion Suggester,字段的类型需要专门定义。
有两个参数:
preserve_separators:
preserve_position_increments:
request_cache=true 查询从5ms变成1ms
# 6. 自定义搜索建议器
基于ES suggester completion,内部用FST(Finite State Transducer),只能用于前缀匹配,这也是Completion Suggester的局限所在。我们现在的搜索联想词可以中缀匹配,是因为使用了ngram(min_gram: 1, max_gram: 10),在入库的时候把所有的词进行拆分。
比如:长富牛奶,会拆成“长、富、牛、奶、长富、富牛、牛奶、长富牛、富牛奶、长富牛奶”
另外,使用了ES 的拼音分词器,支持用户输入拼音搜索相关的联想词,以及将输入的中文词也利用对应的拼音进行匹配。
基于ES,不使用ES自带的搜索建议器Suggester,我们打算自己构建一个用于搜索联想词的索引,与旧有的搜索联想词对比如下:
# 优点
- 索引更轻量级。因为结构简单,同样的2万条数据,旧的搜索联想词索引占用
70M
,而 新的搜索联想词占用空间为15M
- 排查问题更方便,对于入库的词如何进行拆分,是通过我们自身的应用程序代码进行控制,而且拆分结果直接可以在es中进行数据查看
- 可操作空间更大,可以自主确定,怎样的词可以查找其他相关的词
# 缺点
- 有一定代码维护成本
- 分词等原理与ES搜索引擎的分词等原理解耦,可能不利于维护
- 与搜索联想词对比,数据不是放在缓存中(通过request 中cache进行解决)
我们的方案,mapping构建如下:
PUT search_suggester?include_type_name=false
{
"aliases": {
"alias_search_suggester": {
}
},
"settings": {
"number_of_shards": 3,
"number_of_routing_shards": 9,
"number_of_replicas": 0,
"refresh_interval": "1s",
"index":{
"sort.field":["frequency", "sku_num"],
"sort.order":["desc", "desc"]
},
"index.search.slowlog.threshold.query.trace": "20ms",
"index.search.slowlog.threshold.query.debug": "100ms",
"index.search.slowlog.threshold.query.info": "250ms",
"index.search.slowlog.threshold.query.warn": "500ms",
"index.search.slowlog.threshold.fetch.trace": "20ms",
"index.search.slowlog.threshold.fetch.debug": "100ms",
"index.search.slowlog.threshold.fetch.info": "250ms",
"index.search.slowlog.threshold.fetch.warn": "500ms",
"index.indexing.slowlog.threshold.index.trace": "20ms",
"index.indexing.slowlog.threshold.index.debug": "100ms",
"index.indexing.slowlog.threshold.index.info": "250ms",
"index.indexing.slowlog.threshold.index.warn": "500ms"
},
"mappings": {
"_routing": {
"required": true
},
"dynamic":"strict",
"properties": {
"query": {
"type": "keyword",
"doc_values": false,
"norms": false
},
"city_zip": {
"type": "keyword",
"doc_values": false,
"norms": false
},
"term_prefixs": {
"type": "keyword",
"doc_values": false,
"norms": false,
"copy_to": "search_suggester_all"
},
"term_pinyin": {
"type": "keyword",
"doc_values": false,
"norms": false,
"copy_to": "search_suggester_all"
},
"term_shouzimu": {
"type": "keyword",
"doc_values": false,
"norms": false,
"copy_to": "search_suggester_all"
},
"pinyin_prefixs": {
"type": "keyword",
"doc_values": false,
"norms": false,
"copy_to": "search_suggester_all"
},
"shouzimu_prefixs": {
"type": "keyword",
"doc_values": false,
"norms": false,
"copy_to": "search_suggester_all"
},
"frequency": {
"type": "long"
},
"sku_num": {
"type": "long"
},
"search_suggester_all": {
"type": "keyword",
"doc_values": false,
"norms": false
}
}
}
}
说明:
- term_prefixs : 中文词前缀
- term_pinyin:中文词拼音
- term_shouzimu:中文词首字母前缀
- pinyin_prefixs:拼音前缀
- shouzimu_prefixs:首字母前缀
- ...
将相应的词,ik拆词,拼音拆词,组合中文和拼音等,按照上述需要的规则,进行拆分,然后插入到索引中。
查询的时候,对于ES的查询可以只查询search_suggester_all这个字段啦。
查询DSL语句如下:
GET alias_search_suggester/_search
{
"from": 0,
"size": 10,
"query": {
"bool": {
"filter": [
{
"term": {
"city_zip": {
"value": "400100",
"boost": 1
}
}
},
{
"term": {
"search_suggester_all": {
"value": "花园",
"boost": 1
}
}
}
],
"adjust_pure_negative": true,
"boost": 1
}
},
"_source": {
"includes": [
"query",
"sku_num"
],
"excludes": []
},
"sort": [
{
"frequency": {
"order": "desc"
}
},
{
"sku_num": {
"order": "desc"
}
}
],
"track_total_hits": false
}
使用到的工具包有:
<!-- ik分词 -->
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-core</artifactId>
<version>8.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-queryparser</artifactId>
<version>8.0.0</version>
</dependency>
<dependency>
<groupId>org.apache.lucene</groupId>
<artifactId>lucene-analyzers-common</artifactId>
<version>8.0.0</version>
</dependency>
<!-- 繁体转简体 -->
<dependency>
<groupId>com.github.houbb</groupId>
<artifactId>opencc4j</artifactId>
<version>1.0.2</version>
</dependency>
<!-- 汉字转拼音 -->
<dependency>
<groupId>com.belerweb</groupId>
<artifactId>pinyin4j</artifactId>
<version>2.5.0</version>
</dependency>
# 参考文献
- https://blog.csdn.net/wwd0501/article/details/80595201
- https://www.jianshu.com/p/9e2c6a8e1b54 (该篇文档较详细的介绍搜索中文搜索建议词的实现方式)