背景
在商品搜索场景中,需要根据用户输入关键字严格匹配商品数据,而普通的全文检索方式,诸如:match 或者match_pharse,不一定能达到搜索效果。
例如:使用 match api 时,基于 ik_max_word 分词方式对“白色死神”进行分词后,搜索"白色"、"死神"能搜索到,而根据 "白" 进行搜索时,结果确为空。我们可以看看 ik_max_word 策略的分词效果:
GET /_analyze
{
"analyzer": "ik_max_word",
"text": "白色死神"
}
分词结果:
{
"tokens" : [
{
"token" : "白色",
"start_offset" : 0,
"end_offset" : 2,
"type" : "CN_WORD",
"position" : 0
},
{
"token" : "死神",
"start_offset" : 2,
"end_offset" : 4,
"type" : "CN_WORD",
"position" : 1
}
]
}
ik_max_word主要是基于词库对文本做最细粒度的拆分,只要你输入的内容能匹配上任何一个分词项,就能将文档返回,虽然召回率很高,但不一定满足严格匹配的场景。
关于严格匹配我们很容易就能想到模糊查询,es本身也是能支持模糊查询的:
方案选择
方案一:模糊查询 wildcard && fuzzy
模糊查询的功能有点类似 mysql 中的 like,可以使用正则表达式的通配符来达到模糊搜索的效果,使用的姿势如下:
GET my-index/_search
{
"query": {
"wildcard": {
"product_title": {
"value": "*白*"
}
}
}
}
wildcard 能同时支持 text 和 keyword 两种类型的搜索,但是当输入字符串很长或者搜索数据集很大时,搜索性能很低,原因是ES使用的是基于DFA的文本匹配算法,时间复杂度(M+N),当索引里面的数据量为K时,时间复杂度为(M+N)× K,数据量越大,输入文本越长,模糊搜索的效率就会越低。
###模糊查询性能数据:
对2000万数据进行模糊搜索:耗时3s+
"took" : 3714,
"timed_out" : false,
"_shards" : {
"total" : 48,
"successful" : 48,
"skipped" : 0,
"failed" : 0
}
对2亿数据进行模糊搜索:耗时30s+
"took" : 32714,
"timed_out" : false,
"_shards" : {
"total" : 48,
"successful" : 48,
"skipped" : 0,
"failed" : 0
} 事实上,当数据量上千万时,使用模糊查询会存在性能瓶颈,集群会出现大量慢查询,甚至把节点内存打爆。方案二:N-gram 分词生产环境我们可以使用 N-gram 来代替 wildcard 实现模糊搜索功能,N-gram 分词器可以通过指定分词步长来对输入文本进行约束切割,本质上也是一种全文搜索。在使用过程中我们可以通过自定义分析器,在创建索引或者更新字段类型时,对它配置使用N-gram进行分词,简单且高效。我们可以看看分词效果:
POST my-index/_analyze
{
"analyzer":"ngram_analyzer",
"text":"理想小韭菜"
}
其分词结果为:
{
"tokens" : [
{
"token" : "小",
"start_offset" : 0,
"end_offset" : 1,
"type" : "word",
"position" : 0
},
{
"token" : "小韭",
"start_offset" : 0,
"end_offset" : 2,
"type" : "word",
"position" : 1
},
{
"token" : "小韭菜",
"start_offset" : 0,
"end_offset" : 3,
"type" : "word",
"position" : 2
},
{
"token" : "韭",
"start_offset" : 1,
"end_offset" : 2,
"type" : "word",
"position" : 3
},
{
"token" : "韭菜",
"start_offset" : 1,
"end_offset" : 3,
"type" : "word",
"position" : 4
},
{
"token" : "菜",
"start_offset" : 2,
"end_offset" : 3,
"type" : "word",
"position" : 5
}
]
}##N-gram配置方式配置方式一:创建索引时候直接指定。
PUT test-ngram-v1
{
"settings": {
"index.max_ngram_diff": 10, //核心参数:ngram最大步长,可以手动配置,默认为1。
"analysis": {
"analyzer": {
"ngram_analyzer" : {
"tokenizer" : "ngram_tokenizer" // 配置ngram分词器。
}
},
"tokenizer": {
"ngram_tokenizer" : {
"token_chars" : [ //指定生成的token应该包含哪些字符.对没有包含进的字符进行分割,默认为[],即保留所有字符。
"letter", //
"digit" //
],
"min_gram" : "1", // 指定最小步长,按需配置。
"type" : "ngram",
"max_gram" : "10" // 指定最大步长 ,按需配置,不能超过"index.max_ngram_diff"。
}
}
}
},
"mappings" : {
"properties" : {
"messsage" : {
"type" : "text",
"norms" : false,
"fields" : {
"ngram" : {
"type" : "text",
"analyzer" : "ngram_analyzer" // 配置类型
}
}
},
"level" : {
"type" : "keyword"
}
}
}
}
配置方式二:通过索引模版指定。
通过索引模版可以对指定的字段配置ngram分词器,通过 template 中的"match"来指定需要配置的字段,能支持字段类型、字段名、路径、正则等多种匹配条件,也可以配置filter来对分词后token进行后置处理。具体使用方式可以参考:https://www.elastic.co/guide/en/elasticsearch/reference/7.10/dynamic-templates.html
PUT _index_template/analyzer-template
{
"priority": 1,
"template": {
"settings": {
"index": {
"max_result_window": "200000",
"codec": "zstandard",
"max_ngram_diff": "10",
"analysis": {
"analyzer": {
"my_analyzer": {
"tokenizer": "my_tokenizer"
}
},
"tokenizer": {
"my_tokenizer": {
"token_chars": [
"letter",
"digit"
],
"min_gram": "1", // 按需修改
"type": "ngram",
"max_gram": "5" //按需修改
}
}
}
}
},
"mappings": {
"dynamic_templates": [
{
"strings": {
"mapping": {
"type": "keyword"
},
"match_mapping_type": "string"
}
},
{
"ngram_analyzer": {
"mapping": {
"type": "text",
"fields": {
"ngram": {
"analyzer": "ngram_analyzer",
"type": "text"
}
}
},
"match": [
"message" // 按需配置
]
}
}
]
}
},
"index_patterns": [
"*"
]
}
##NGram查询性能:
2亿数据查询性能:P99 < 200ms
"took" : 98,
"timed_out" : false,
"_shards" : {
"total" : 48,
"successful" : 48,
"skipped" : 0,
"failed" : 0
},
#总结 :
1. 使用 wildcard 不需要做分词,不需要额外占用磁盘,但数据量大时搜索性能很差,小规模业务可以使用。
2. Ngram搜索性能要远远高于 wildcard,但会额外消耗10%左右磁盘(并不明显),可以配合一些数据压缩策略使用。
3. Ngram 能够同时支持 match 与 term 查询,重建索引后,客户端无需变动。
4. 直接使用 Ngram 分词,单个关键字命中即返回,召回错误率太高,可以搭配使用 match_phrase,通过设定slot偏移量,可以减少智能分词结果差异导致的召回率低的问题,提升搜索准确率。