Elasticsearch搜索与排序经验小记

最近维护公司的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能匹配上的要求有两个:

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

这两个都能满足,所以 “皇麦世家木瓜牛奶燕麦片 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": "世家"
   }
 }
}

搜出来了再怎么排序?

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

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

其实前两个需求用 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  重新编排文章,预留下一篇文章介绍倒排索引
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值