最近维护公司的APP搜索项目,在实际需求中,领导对搜索关心两方面,第一要搜出来,第二排序要符合人的搜索习惯,最近一段时间的搜索经验记录下来分享一下。

‘木瓜牛奶’ 是怎么搜出来的?

先来说说Elasticsearch基本的搜索,一段文字在es中能被搜索出来,抛开复杂的原理,简单理解成一句话:  搜索词的分词结果正好匹配上了内容的分词结果,这段内容就被搜索出来了。

这句话有两个核心,一个是分词,一个是匹配。

分词

对于第一个核心“分词”来说,它有两个需要分词,一个是搜索词的分词,一个是文档内容的分词(跟倒排索引有关,后面解释)。 先从搜索词说起,对于一个搜索词来说,它会被分词,根据分词器的不同,会有不同的分词结果。比如 “木瓜牛奶”,如果用 standard 分词,对于中文就比较呆板,一个字一个字被分词成 [“木”,“瓜”,“牛”,“奶”] 四个词,而如果用 ik_max_word 分词器,会被分词成 [“木瓜”,“牛奶”]。

下面json就展示了对于搜索词可以指定分词器,当然,只有match这种需要分词的行为才能指定分词器,如果你用term这种精确查询,是不让你用analyzer属性的。

{

"query": {

"bool": {

"must": [

{

"match": {

"channelSkuName": {

"query": "木瓜牛奶",

"analyzer": "ik_max_word"

}

}

}

]

}

}

}

再看文档内容的分词,如果有一个商品名字段叫channelSkuName,值为 “好好吃的木瓜牛奶”,如果这个字段指定了ik_max_word 分词器,会被分词为[“好好”,“好吃”,“的”,“木瓜”,“牛奶”]。我们会发现,搜索词跟数据库内容被分词拆分之后,是有重合的内容的,[“木瓜”,“牛奶”] 是两个都具有的,这个是能被搜索出来的基础。

这个json截取了索引的mappering结构,展示了channelSkuName字段指定了"ik_max_word"分词器。然后下面还有fields字段,es是允许一个字段分别指定不同的字段类型和分词器的,只要搜索的时候对应好字段后缀就行了,比如"channelSkuName.standard"和"channelSkuName.pinyin"。

{

"channelSkuName":{

"analyzer":"ik_max_word",

"type":"text",

"fields":{

"standard":{

"analyzer":"standard",

"type":"text"

},

"pinyin":{

"analyzer":"pinyin",

"type":"text"

}

}

}

}

匹配

说完分词,再说匹配,在 Elasticsearch 中,有几种不同的查询类型可用于搜索文本数据。以下是 matchPhrase、match 和 term 查询的区别。

match 查询

match 查询用于在文本字段中查找与搜索词匹配的文档。

该查询会对搜索词进行分词,生成词项,并与文档中的词项进行匹配。默认情况下,match 查询使用 OR 操作符,即匹配任何一个词项,这个文档就会被搜索出来。

{

"query": {

"bool": {

"must": [

{

"match": {

"channelSkuName": {

"query": "吃木瓜",

"analyzer": "ik_max_word"

}

}

}

]

}

}

}

上述示例在ik_max_word分词器下被拆分成[“吃”,“木瓜”],所以将匹配包含短语 “吃 or 木瓜” 的文档,如 “牛奶木瓜”会被搜索出来。why? 注意,上文说了,搜索词会被分词,文档内容同样会被分词,如果文档字段仍是使用ik_max_word分词器,“牛奶木瓜” 被分词为 [“牛奶”,“木瓜”],正因为和搜索词有一样的分词项 [“木瓜”],而且match 属于or匹配**,**所以会被搜索出来。

换句话说,如果文档字段使用的是standard分词器,"牛奶木瓜"会被分词成[“牛”,“奶”,“木”,“瓜”]四个词,就无法匹配[“吃”,“木瓜”]中的任何一个,也就没法匹配搜索到。

从这个例子可以看出,一个文档要能被搜索出来,一看分词(搜索词和文档内容),二看匹配规则(比如match),就能理解es搜索的大致方式。

matchPhrase 查询

matchPhrase 查询用于在文本字段中查找包含指定短语的文档。该查询要求文档中的字段与搜索词语完全匹配,包括相对的顺序和位置。什么是相对的顺序和位置?就是分词结果的排序,它并不是随意排序的,每个分词项都有自己的位置。下面举例说明:

{

   "query":{

       "match_phrase":{

           "channelSkuName":{

               "query":"木瓜牛奶",

               "analyzer":"ik_max_word"

           }

       }

   }

}

这个json跟第一个稍稍不一样, ‘match’替换成了’match_phrase’,我们知道"木瓜牛奶"的分词结果是[“木瓜”,“牛奶”],然后我们希望搜索 “皇麦世家木瓜牛奶燕麦片 350g*1袋”,我们先看下这个文本的分词结构:

入参:

GET http://ip:9200/任意index/_analyze

Content-Type: application/json

{

"analyzer": "ik_max_word",

"text": [

"皇麦世家木瓜牛奶燕麦片 350g*1袋"

]

}

出参:

{

"tokens": [

{

"token": "皇",

"start_offset": 0,

"end_offset": 1,

"type": "CN_CHAR",

"position": 0

},

{

"token": "麦",

"start_offset": 1,

"end_offset": 2,

"type": "CN_CHAR",

"position": 1

},

{

"token": "世家",

"start_offset": 2,

"end_offset": 4,

"type": "CN_WORD",

"position": 2

},

{

"token": "世",

"start_offset": 2,

"end_offset": 3,

"type": "CN_WORD",

"position": 3

},

{

"token": "家",

"start_offset": 3,

"end_offset": 4,

"type": "CN_CHAR",

"position": 4

},

{

"token": "木瓜",

"start_offset": 4,

"end_offset": 6,

"type": "CN_WORD",

"position": 5

},

{

"token": "牛奶",

"start_offset": 6,

"end_offset": 8,

"type": "CN_WORD",

"position": 6

},

{

"token": "牛",

"start_offset": 6,

"end_offset": 7,

"type": "CN_WORD",

"position": 7

},

{

"token": "奶",

"start_offset": 7,

"end_offset": 8,

"type": "CN_CHAR",

"position": 8

},

{

"token": "燕麦片",

"start_offset": 8,

"end_offset": 11,

"type": "CN_WORD",

"position": 9

},

{

"token": "燕麦",

"start_offset": 8,

"end_offset": 10,

"type": "CN_WORD",

"position": 10

},

{

"token": "麦片",

"start_offset": 9,

"end_offset": 11,

"type": "CN_WORD",

"position": 11

},

{

"token": "350g",

"start_offset": 12,

"end_offset": 16,

"type": "LETTER",

"position": 12

},

{

"token": "350",

"start_offset": 12,

"end_offset": 15,

"type": "ARABIC",

"position": 13

},

{

"token": "g",

"start_offset": 15,

"end_offset": 16,

"type": "ENGLISH",

"position": 14

},

{

"token": "1",

"start_offset": 17,

"end_offset": 18,

"type": "ARABIC",

"position": 15

},

{

"token": "袋",

"start_offset": 18,

"end_offset": 19,

"type": "COUNT",

"position": 16

}

]

}

从出参我们看到"木瓜"和"牛奶"的position是5和6,这就是上面我们说的位置,不过这里是绝对位置。我们再看看搜索词"木瓜牛奶"的位置。

入参:

GET http://ip:9200/任意index/_analyze

Content-Type: application/json

入参:

{

"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

}

]

}

搜索词 "木瓜"和"牛奶"的position是0和1,虽然搜索词的position跟搜索内容的position绝对值不一样,但是他们相对位置是相邻的,matchPhrase能匹配上的要求有两个:

要求文档中的分词结果与搜索词分词完全匹配。相对的顺序和位置符合要求

这两个都能满足,所以 “皇麦世家木瓜牛奶燕麦片 350g*1袋” 能被搜索出来。说到这里,我们能看到matchPhrase比match更能符合人类的搜索预期,matchPhrase相当于全文搜索,match相当于模糊搜索,但是我们再举一个相对顺序不一致的情况。

比如搜索词是"皇麦木瓜燕麦片",想搜索的商品名为"皇麦世家木瓜牛奶燕麦片 350g*1袋”, 从人的视觉习惯看起来跟商品名差不多,但是对搜索引擎分词结果来说,“皇麦"到"木瓜"到"燕麦片"之间,没有了"世家”,“牛奶”两个分词,在相对顺序上,它们已经匹配不上搜索内容分词的相对顺序了,所以无法搜索到。但是我们希望有个容错的机制可以容忍一些位置错乱,幸运的是,在使用matchPhrase的情况下,的确有个参数可以兼容顺序不一致的情况,非常实用。

slop 参数

slop 是一个参数,用于指定 matchPhrase 查询中允许的最大位置偏移量。它用于控制短语查询中单词的相对位置。默认情况下,slop 的值为 0,表示单词必须按照给定的顺序连续出现。如果设置了一个正整数的 slop 值,那么在指定范围内,单词可以以任意顺序出现,且允许有一些其他单词插入其中。

还是以上面无法搜索出来的例子来看,比如我们的搜索词是 “皇麦木瓜燕麦片”,通过分词分析,相比 "皇麦世家木瓜牛奶燕麦片 350g*1袋"的分词,少了[“世家”,“世”,“家”,“牛奶”,“牛”,“奶”]6个分词,我们设置slop为6,表示允许的中间不匹配位置的最大数目为6,这时候,"皇麦世家木瓜牛奶燕麦片 350g*1袋"就可以被搜出来。如果设置成5,通过我的实际检验,都没办法搜出来。

{

"query": {

"match_phrase": {

"channelSkuName": {

"query": "皇麦木瓜燕麦片",

"slop": 6,

"analyzer": "ik_max_word"

}

}

}

term 查询

matchPhrase 和 match 都建立在分词再查找的基础上,而 term 查询不会对查询词进行分词,而是直接与文档中的词项进行精确匹配。所以term不接受analyzer属性,term适合精确的编码查询等场景。

但是需要注意的是,term 适合查询 keyword 类型的字段,一般文本类型分为 text 和 keyword。下面json给了一个keyword示例,channelSkuName本身是text类型,但是channelSkuName.keyword就是keyword类型,keyword 类型不会做分词处理。

{

"channelSkuName":{

"type":"text",

"fields":{

"keyword":{

"ignore_above":256,

"type":"keyword"

}

}

}

}

用"世家"是能搜索到 "皇麦世家木瓜牛奶燕麦片 350g*1袋"的内容的,因为"世家"不分词直接匹配上了"皇麦世家木瓜牛奶燕麦片 350g*1袋"的分词结果(匹配上了“世家”),如果是搜索的channelSkuName.keyword,那就肯定搜索不出来。

{

"query": {

"term": {

"channelSkuName": "世家"

}

}

}

搜出来了再怎么排序?

搜出来之后,因为是个列表,我们需要根据人的搜索预期进行排序,产品给了如下需求:

搜索短语完全匹配的在前面搜索短语也要支持模糊匹配其它业务上的排序(依照其它字段排序)

其实前两个需求用 matchPhrase 和 match 搜索就行了,两者用should相连,不管是精确匹配还是模糊匹配都能满足要求,至于排序我们需要了解score机制。

score

在Elasticsearch中,每个搜索结果都会有一个分数(score),用于表示与查询的匹配程度。分数越高表示与查询的匹配度越高。es默认用score进行排序,看起来似乎满足我们的需求,因为完全匹配的score分数肯定更高,但是我们的排序规则还带上业务上规则的时候,就出现了一些麻烦。比如同样是完全匹配的商品中,自营的商品会排序更靠前,要实现这样的排序,你可能会想到完全匹配的商品作为第一优先级,自营作为第二优先级,很简单的问题。但是你少考虑了一点,es复杂的score计算机制,即使完全匹配的商品,score分数几乎都不可能相等(es有自己的匹配度计算),这样的话就没办法做“第二优先级-自营”的排序了,这时候你会想,如果能自己定义“完全匹配的商品”的score分数就好了。

Constant Score

常量化(Constant Score)是一种将某一搜索条件的分数设置为固定值的方法。有时候我们希望在搜索中不考虑复杂的匹配度,而是将某一搜索条件的分数统一设定为某个固定值。这可以通过使用常量分数查询(Constant Score Query)来实现。

{

"query": {

"constant_score": {

"filter": {

"match_phrase": {

"channelSkuName": {

"query": "皇木瓜牛奶燕麦片 350g*1袋",

"slop": 2,

"analyzer": "ik_max_word"

}

}

},

"boost": 5

}

}

}

通过新增 constant_score 和 boost ,可以指定通过当前条件搜出来的商品score分数会被固化成5分,这样就非常方便我们新增其它的业务排序,更好的符合产品需求

结语

这篇文章更多的是实践经验而非es原理解析,自己经验小记下来,抛砖引玉。

2023-10-23  重新编排文章,预留下一篇文章介绍倒排索引

推荐文章

评论可见,请评论后查看内容,谢谢!!!
 您阅读本篇文章共花了: