需求
使用 ES 进行作为搜索引擎时一般会出现这样的场景,有一个同义词表,当查询时,也能命中到同义词。举例来说,画图,绘图
是一对同义词,当用户搜索 画图
时, 我们往往希望包含绘图
的doc 也在召回结果中。
实现1,query time
思路是在query 时,扩大搜索范围,比如说搜索 绘图 ,首先查询同义词库,然后在搜索的时候,添加同义词搜索:
# 原搜索词 给一个较高的权重,其他同义词给一个较低的权重
GET test_synonym_3/_search
{
"query": {
"bool": {
"should": [
{
"match": {
"title": {
"query": "绘图",
"boost": 10
}
}
},
{
"match": {
"title": {
"query": "画图",
"boost": 1
}
}
}
]
}
}
}
这种方式可以实现同义词扩召回,并且可以返回结果上,原词召回的doc 排在 同义词召回的结果之前。但是有两个问题:
- 在搜索时,需要查同义词库,然后再拼接query body,比较麻烦
- 搜索词 如果不是单个词的话,首先需要分词,然后将分词结果每一项,都进行类似的查找,然后拼接 query body。对查询结果的影响上,比如searchword 是
工作绘图
, 这样的结果就是 title:工作^10 or (title:绘图^10 or title:画图^1) ,这样导致的结果是 一个文档出现 工作,另一个文档同时出现 画图,绘图 , 那么 显然 画图 带来的得分更多,对结果排序造成影响。
实现2,index time
这里的index time 是指在建立倒排索引时,也就是存储 doc 时,加上 synonym filter ,当一个 同义词词库中包含的词,那么该词所有的 同义词在 同 position 上都加上。
# 定义 mapping
PUT /test_synonym_3
{
"settings": {
"index": {
"analysis": {
"analyzer": {
"custom_analyzer": {
"tokenizer": "whitespace",
"filter": [
"synonym"
]
}
},
"filter": {
"synonym": {
"type": "synonym",
"synonyms": [
"画图,绘图",
"hello,nihao"
]
}
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "custom_analyzer"
}
}
}
}
# 写入两个 doc
POST test_synonym_3/_doc/1
{
"title":"this is my 画图"
}
POST test_synonym_3/_doc/2
{
"title":"this is my 绘图"
}
# 查询
GET test_synonym_3/_search
{
"query": {
"match": {
"title": {
"query": "绘图"
}
}
}
}
# 结果;
{
"took" : 4,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 2,
"relation" : "eq"
},
"max_score" : 0.2656341,
"hits" : [
{
"_index" : "test_synonym_3",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.2656341,
"_source" : {
"title" : "this is my 画图"
}
},
{
"_index" : "test_synonym_3",
"_type" : "_doc",
"_id" : "2",
"_score" : 0.2656341,
"_source" : {
"title" : "this is my 绘图"
}
}
]
}
}
从上面的结果中看到 搜索 绘图, 结果全部展示,召回结果是完整的,但是和搜索意图有一些区别,绘图 的 doc 应该在 画图的前面。
实现3,index time
考虑到上面的查询无法区分原词召回还是同义词召回,所以使用多字段index ,然后在不同的字段上设置不同的权重。
# 增加字段
PUT /test_synonym_3
{
"settings": {
"index": {
"analysis": {
"analyzer": {
"custom_analyzer": {
"tokenizer": "whitespace"
},
"custom_analyzer_synonym": {
"tokenizer": "whitespace",
"filter": [
"synonym"
]
}
},
"filter": {
"synonym": {
"type": "synonym",
"synonyms": [
"画图,绘图",
"hello,nihao"
]
}
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "custom_analyzer",
"fields": {
"synonym": {
"type": "text",
"analyzer": "custom_analyzer_synonym"
}
}
}
}
}
}
# 进行查询
GET test_synonym_3/_search
{
"query": {
"bool": {
"should": [
{
"match": {
"title": {
"query": "绘图",
"boost": 10
}
}
},
{
"match": {
"title.synonym": {
"query": "绘图",
"boost": 1
}
}
}
]
}
}
}
上面这种方式,通过不同字段分配不同权重,这个方法和方法一,有些类似,好处是在搜索时比较简单,坏处是 1, 增加字段,也就是增加了存储占用。2,在index 时,如果同义词库发生变化,不能及时生效。3,同样会有 有同义词的原词会比没有同义词的原词 造成较大的得分结果。但是好处是简单,所以往往被大家所接受。
思考
由上面的经验来看,总结出三条重要的需求:
- 同义词的搜索权重应小于原词,保证原词的召回结果排在前面
- 同义词的及时生效问题
- 如果同义词在 index time时 就加入到了 倒排索引中,那么会导致一些词的词频变高,就是说 同义词库中 画图,绘图 ,假设 画图 在index 中出现特别频繁,但是 绘图 频次很少,但是将同义词伴随加入会导致 绘图 频次的增加。
结合上面的三种尝试,发现 elasticsearch 对 同义词搜索本身支持的并不好。开始思考自己开发支持同义词搜索。
解决
实现自己的query 插件,开发方向:
- 同义词 不能在 index time 时加入倒排索引。否则会改变同义词的词频
- 在使用 同义词召回时,同义词召回得分应该设置为 0 , 否则 会使带有同义词的搜索词比没有同义词的搜索词对结果_score 贡献更大,但是 给同义词一个极小权重值也是可以的,因为就算是同义词之间也有 词频重要性区分,如果权重为 0 则会忽视同义词之间的差异,比如 画图,绘图,画画 ,对searchword: 画图 , 同义词 绘图,画画 也是不一样的。所以推荐一个极小值,能区分,但又不至于影响全局得分。
代码:https://github.com/muhao1020/synony_match.git 针对 ES7.3.2 开发。
效果如下:
# 定义 mapping
# 可以看到, 定义的 analyzer 有同义词filter的, 并且没有用于 index analyzer
PUT /test_synonym_3
{
"settings": {
"index": {
"analysis": {
"analyzer": {
"custom_analyzer": {
"tokenizer": "whitespace",
"filter": [
"synonym"
]
}
},
"filter": {
"synonym": {
"type": "synonym",
"synonyms": [
"画图,绘图",
"hello,nihao"
]
}
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "whitespace"
}
}
}
}
# 写入两个 doc
POST test_synonym_3/_doc/1
{
"title":"this is my 画图"
}
POST test_synonym_3/_doc/2
{
"title":"this is my 绘图"
}
# 查询
# 使用自定义的query 设置同义词产生的权重
# 必传参数 query 和 synonym_analyzer
# query 搜索内容
# synonym_analyzer 分词器,可以为全局分词器,也可以为index分词器,但应该使用带 synonym filter的分词器
# zero_terms_query 表示如果query被synonym_analyzer分次之后为0个term,全都是停用词,那么召回策略是什么,参考 https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-match-query.html#query-dsl-match-query-zero
# synonym_type_boost 表示同义词召回内容加入的权重,默认是 0.00001。
GET test_synonym_3/_search
{
"query": {
"synonym_match": {
"title": {
"query": "this 绘图",
"synonym_analyzer": "custom_analyzer",
"zero_terms_query": "none",
"synonym_type_boost" : 0.00001
}
}
}
}
# 结果, 画图 作为 同义词,也召回了 doc ,但是为最后结果贡献了很低的得分。
{
"took" : 2,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : {
"value" : 2,
"relation" : "eq"
},
"max_score" : 0.87546873,
"hits" : [
{
"_index" : "test_synonym_3",
"_type" : "_doc",
"_id" : "2",
"_score" : 0.87546873,
"_source" : {
"title" : "this is my 绘图"
}
},
{
"_index" : "test_synonym_3",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.18233427,
"_source" : {
"title" : "this is my 画图"
}
}
]
}
}
对于 synonym graph 部分请参考 https://www.shenyanchao.cn/blog/2014/11/25/better-synonym-handling-in-solr/