简介

现代的搜索引擎,一般会具备”Suggest As You Type”功能,即在用户输入搜索的过程中,进行自动补全 或者纠错。 通过协助用户输入更精准的关键词,提高后续全文搜索阶段文档匹配的程度。例如在京东上 输入部分关键词,甚至输入拼写错误的关键词时,它依然能够提示出用户想要输入的内容:
image.png
如果自己亲手去试一下,可以看到京东在用户刚开始输入的时候是自动补全的,而当输入到一定长度, 如果因为单词拼写错误无法补全,就开始尝试提示相似的词。 那么类似的功能在Elasticsearch里如何实现呢? 答案就在Suggesters API。 Suggesters基本的运作原 理是将输入的文本分解为token,然后在索引的字典里查找相似的term并返回。 根据使用场景的不同, Elasticsearch里设计了4种类别的Suggester,分别是:

  • Term Suggester:基于单词的纠错补全。
  • Phrase Suggester:基于短语的纠错补全。
  • Completion Suggester:自动补全单词,输入词语的前半部分,自动补全单词。
  • Context Suggester:基于上下文的补全提示,可以实现上下文感知推荐。

    Term Suggester

    原理

    Term Suggester 提供了基于单词的纠错、补全功能,其工作原理是基于编辑距离(edit distance)来运作的,编辑距离的核心思想是一个词需要改变多少个字符就可以和另一个词一致。所以如果一个词转化为原词所需要改动的字符数越少,它越有可能是最佳匹配。例如,linvx 和 linux,为了把 linvx 转变为 linux 需要改变 一个字符 ‘v’,所以其编辑距离为 1。
    Term Suggester 工作的时候,会先将输入的文本切分为一个个单词(我们称这个为 token),然后根据每个单词提供建议,所以其不会考虑输入文本间各个单词的关系。

准备

  1. PUT /blogs/
  2. {
  3. "mappings": {
  4. "properties": {
  5. "body": {
  6. "type": "text"
  7. }
  8. }
  9. }
  10. }

通过bulk api写入几条文档

  1. POST _bulk/?refresh=true
  2. { "index" : { "_index" : "blogs" } }
  3. { "body": "Lucene is cool"}
  4. { "index" : { "_index" : "blogs" } }
  5. { "body": "Elasticsearch builds on top of lucene"}
  6. { "index" : { "_index" : "blogs" } }
  7. { "body": "Elasticsearch rocks"}
  8. { "index" : { "_index" : "blogs" } }
  9. { "body": "Elastic is the company behind ELK stack"}
  10. { "index" : { "_index" : "blogs" } }
  11. { "body": "elk rocks"}
  12. { "index" : { "_index" : "blogs"} }
  13. { "body": "elasticsearch is rock solid"}
  14. GET blogs/_search
  15. {
  16. "query": {
  17. "match_all": {}
  18. }
  19. }

此时blogs索引里已经有一些文档了,可以进行下一步的探索。为帮助理解,我们先看看哪些term会存 在于词典里。 将输入的文本分析一下:

  1. POST _analyze
  2. {
  3. "text": [
  4. "Lucene is cool",
  5. "Elasticsearch builds on top of lucene",
  6. "Elasticsearch rocks",
  7. "Elastic is the company behind ELK stack",
  8. "elk rocks",
  9. "elasticsearch is rock solid"
  10. ]
  11. }

这些分出来的token都会成为词典里一个term,注意有些token会出现多次,因此在倒排索引里记录的 词频会比较高,同时记录的还有这些token在原文档里的偏移量和相对位置信息。
执行一次suggester搜索看看效果:

参数

Term Suggester API 有很多参数,比较常用的有以下几个:

  • text:指定了需要产生建议的文本,一般是用户的输入内容,例子中是:”kernel architture”。
  • field:指定从文档的哪个字段中获取建议,上例中,我们从书名(name)字段中获取建议。
  • suggest_mode:设置建议的模式。其值有以下几个选项:
    • missing:如果索引中存在就不进行建议,默认的选项。上例中使用的是此选项,所以可以看到返回的结果中 “kernel” 这个词是没有建议的。
    • popular:推荐出现频率更高的词。
    • always:不管是否存在,都进行建议。
  • analyzer:指定分词器来对输入文本进行分词,默认与 field 指定的字段设置的分词器一致。
  • size:为每个单词提供的最大建议数量。
  • sort:建议结果排序的方式,有以下两个选项:
    • score:先按相似性得分排序,然后按文档频率排序,最后按词项本身(字母顺序的等)排序。
    • frequency:先按文档频率排序,然后按相似性得分排序,最后按词项本身排序。

      使用

      suggest就是一种特殊类型的搜索,DSL内部的”text”指的是api调用方提供的文本,也就是通常用户界面上用户输入的内容。这里的lucne是错误的拼写,模拟用户输入错误。
      “term”表示这是一个term suggester。 “field”指定suggester针对的字段,另外有一个可选的”suggest_mode”。 范例里 的”missing”实际上就是缺省值 ```json

      my-suggestion是自己起的名字

      suggest_mode 搜索建议模式,missing是词在我词典里面没有才给建议, 有就不建议了

      POST /blogs/_search { “suggest”: { “my-suggestion”: { “text”: “lucne rock”, “term”: { “suggest_mode”: “missing”, “field”: “body” } } } }

输出,options表示建议修正的

{ “took” : 15, “timed_out” : false, “_shards” : { “total” : 1, “successful” : 1, “skipped” : 0, “failed” : 0 }, “hits” : { “total” : { “value” : 0, “relation” : “eq” }, “max_score” : null, “hits” : [ ] }, “suggest” : { “my-suggestion” : [ { “text” : “lucne”, “offset” : 0, “length” : 5, “options” : [ { “text” : “lucene”, “score” : 0.8, “freq” : 2 } ] }, { “text” : “rock”, “offset” : 6, “length” : 4, “options” : [ ] } ] } }

  1. 在返回结果里"suggest" -> "my-suggestion"部分包含了一个数组,每个数组项对应从输入文本分解出来的token(存放在"text"这个key里)以及为该token提供的建议词项(存放在options数组里)。
  2. 示例 里返回了"lucne""rock"2个词的建议项(options),**其中"rock"options是空的,表示没有可以建议的**选项,<br />为什么? 上面提到了,我们为查询提供的suggest mode"missing",由于"rock"在索引的词典 里已经存在了,够精准,就不建议啦。 只有词典里找不到词,才会为其提供相似的选项。
  3. 如果将"suggest_mode"换成"popular"会是什么效果? <br />尝试一下,重新执行查询,返回结果里"rock"这个词的option不再是空的,而是建议为rocks
  4. ```json
  5. # popular模式, 会建议更流行的词
  6. POST /blogs/_search
  7. {
  8. "suggest": {
  9. "my-suggestion": {
  10. "text": "lucne rock",
  11. "term": {
  12. "suggest_mode": "popular",
  13. "field": "body"
  14. }
  15. }
  16. }
  17. }
  18. # 输出
  19. {
  20. "took" : 33,
  21. "timed_out" : false,
  22. "_shards" : {
  23. "total" : 1,
  24. "successful" : 1,
  25. "skipped" : 0,
  26. "failed" : 0
  27. },
  28. "hits" : {
  29. "total" : {
  30. "value" : 0,
  31. "relation" : "eq"
  32. },
  33. "max_score" : null,
  34. "hits" : [ ]
  35. },
  36. "suggest" : {
  37. "my-suggestion" : [
  38. {
  39. "text" : "lucne",
  40. "offset" : 0,
  41. "length" : 5,
  42. "options" : [
  43. {
  44. "text" : "lucene",
  45. "score" : 0.8,
  46. "freq" : 2
  47. }
  48. ]
  49. },
  50. {
  51. "text" : "rock",
  52. "offset" : 6,
  53. "length" : 4,
  54. "options" : [
  55. {
  56. "text" : "rocks",
  57. "score" : 0.75,
  58. "freq" : 2
  59. }
  60. ]
  61. }
  62. ]
  63. }
  64. }

回想一下,rock和rocks在索引词典里都是有的。 不难看出即使用户输入的token在索引的词典里已经 有了,但是因为存在一个词频更高的相似项,这个相似项可能是更合适的,就被挑选到options里了。 最后还有一个”always” mode,其含义是不管token是否存在于索引词典里都要给出相似项。
以前always是一直给建议, 现在在ES7.X不一样了, 不是一直给了, 和popular差不多了

两个term的相似性是如何判断的?
ES使用了一种叫做Levenstein edit distance的算 法,其核心思想就是一个词改动多少个字符就可以和另外一个词一致。 Term suggester还有其他很多可选参数来控制这个相似性的模糊程度

Phrase Suggester

原理

Term Suggester 产生的建议是基于每个单词的,如果想要针对整个短语或者一句话做建议,Term Suggester 就有点无能为力了。那有什么更直接的办法解决这个问题呢?可以使用 Phrase Suggester API 获取与用户输入文本相似的内容。
Phrase Suggester 在 Term Suggester 的基础上增加了一些额外的逻辑,因为是短语形式的建议,所以会考量多个 term 间的关系,比如相邻的程度、词频等

Phrase suggester在Term suggester的基础上,会考量多个term之间的关系,比如是否同时出现在索引的原文里,相邻程度,以及词频等等

参数

  • highlight:高亮被修改后的词语。
  • confidence:信任级别定义了一个影响为其他推荐词的阈值输入短语分数的因素,只有分数大于阈值的候选词会包含在结果中,比如设置为1.0将会返回分数大于输入短语的推荐词(这也是默认值),如果设置为0将会返回前N个候选词;其作用是用来控制返回结果条数的。如果用户输入的数据(短语)得分为 N,那么返回结果的得分需要大于 N * confidence。confidence 默认值为 1.0。
  • max_errors:指定最多可以拼写错误的词语的个数。,术语(为了形成修正大多数认为拼写错误)的最大百分比,这个参数可以接受[0,1)范围内的小数作为实际查询项的一部分,也可以是大于等于1的绝对数。默认值为1.0,与最多1对应,只有修正拼写错误返回,注意这个参数设置太高将会影响ES性能,推荐使用像1或2这样较小的数值,否则时间花在建议调用可能超过花在查询执行的时间;

    使用

    ```json POST /blogs/_search { “suggest”: { “my-suggestion”: {
    "text": "lucne and elasticsear rock",
    "phrase": {
      "field": "body",
      "max_errors": 0.5,
      "confidence": 0,
      "highlight": {
        "pre_tag": "<em>",
        "post_tag": "</em>"
      }
    }
    
    } } }

返回

“suggest” : { “my-suggestion” : [ { “text” : “lucne and elasticsear rock”, “offset” : 0, “length” : 26, “options” : [ { “text” : “lucene and elasticsearch rock”, “highlighted” : “lucene and elasticsearch rock”, “score” : 0.004993905 }, { “text” : “lucne and elasticsearch rock”, “highlighted” : “lucne and elasticsearch rock”, “score” : 0.0033391973 }, { “text” : “lucene and elasticsear rock”, “highlighted” : “lucene and elasticsear rock”, “score” : 0.0029183894 } ] } ] }

options直接返回一个phrase列表,由于加了highlight选项,被替换的term会被高亮。

因为lucene和 elasticsearch曾经在同一条原文里出现过,同时替换2个term的可信度更高,所以打分较高,排在第一 位返回。Phrase suggester有相当多的参数用于控制匹配的模糊程度,需要根据实际应用情况去挑选和调试。
<a name="YTdgI"></a>
# Completion Suggester 
<a name="sTAQi"></a>
## 原理
**Completion Suggester 提供了自动补全的功能,其应用场景是用户每输入一个字符就需要返回匹配的结果给用户**。在并发量大、用户输入速度快的时候,对服务的吞吐量来说是个不小的挑战。所以 Completion Suggester 不能像上面的 Suggester API 那样简单通过倒排索引来实现,必须通过某些更高效的数据结构和算法才能满足需求。<br />**Completion Suggester 在实现的时候会将 analyze(将文本分词,并且去除没用的词语,例如 is、at这样的词语) 后的数据进行编码,构建为 FST 并且和索引存放在一起**。FST([finite-state transducer](https://link.juejin.cn/?target=https%3A%2F%2Fen.wikipedia.org%2Fwiki%2FFinite-state_transducer))是一种高效的前缀查询索引。由于 FST 天生为前缀查询而生,所以其非常适合实现自动补全的功能。ES 会将整个 FST 加载到内存中,所以在使用 FST 进行前缀查询的时候效率是非常高效的。<br />**在使用 Completion Suggester 前需要定义 Mapping,对应的字段需要使用 "completion" type。**

它主要针对的应用场景就是"Auto Completion"。 此场景下用户每输入一个字符的时候,就需要即时发送一次查询请求到后端查找匹配项,在用户输入速度较高的情况 下对后端响应速度要求比较苛刻。

因此实现上它和前面两个Suggester采用了不同的数据结构,**索引并非通过倒排来完成,而是将analyze过的数据编码成FST和索引一起存放**。对于一个open状态的索引, FST会被ES整个装载到内存里的,进行前缀查找速度极快。但是FST只能用于前缀查找,这也是 Completion Suggester的局限所在。
<a name="GgFcf"></a>
## 使用
为了使用Completion Suggester,字段的类型需要专门定义如下:
```json
PUT /blogs_completion/
{
  "mappings": {
    "properties": {
      "body": {
        "type": "completion",
         "analyzer": "ik_smart"
      }
    }
  }
}

用bulk API索引点数据:

POST _bulk/?refresh=true 
{ "index" : { "_index" : "blogs_completion" } } 
{ "body": "道路危险"} 
{ "index" : { "_index" : "blogs_completion" } } 
{ "body": "道路安全, 道路"} 
{ "index" : { "_index" : "blogs_completion"} } 
{ "body": "道路维修"} 
{ "index" : { "_index" : "blogs_completion" } } 
{ "body": "中华人民共和国"} 
{ "index" : { "_index" : "blogs_completion" } } 
{ "body": "the elk stack rocks"} 
{ "index" : { "_index" : "blogs_completion"} } 
{ "body": "中国政府"}
{ "index" : { "_index" : "blogs_completion" } } 
{ "body": "Elastic is the company behind ELK stack"}
{ "index" : { "_index" : "blogs_completion"} } 
{ "body": "elasticsearch is rock solid"}
{ "index" : { "_index" : "blogs_completion" } } 
{ "body": "Lucene is cool"}

GET /blogs_completion/_search
{
  "query": {
    "match_all": {}
  }
}

查找

# 输入没输入全, 会返回补全的
POST /blogs_completion/_search?pretty 
{
  "suggest": {
    "blog-suggest": {
      "prefix": "中",
      "completion": {
        "field": "body"
      }
    }
  }
}

# 返回
"suggest" : {
    "blog-suggest" : [
      {
        "text" : "中",
        "offset" : 0,
        "length" : 1,
        "options" : [
          {
            "text" : "中华人民共和国",
            "_index" : "blogs_completion",
            "_type" : "_doc",
            "_id" : "IbWwe3sBR5C-2tsw1Lql",
            "_score" : 1.0,
            "_source" : {
              "body" : "中华人民共和国"
            }
          },
          {
            "text" : "中国政府",
            "_index" : "blogs_completion",
            "_type" : "_doc",
            "_id" : "I7Wwe3sBR5C-2tsw1Lql",
            "_score" : 1.0,
            "_source" : {
              "body" : "中国政府"
            }
          }
        ]
      }
    ]
  }

// =========== 不要内容可以这样
POST /blogs_completion/_search?pretty 
{
  "_source": "suggest", 
  "suggest": {
    "blog-suggest": {
      "prefix": "中",
      "completion": {
        "field": "body",
        "size": 10
      }
    }
  }
}

值得注意的一点是Completion Suggester在索引原始数据的时候也要经过analyze阶段,取决于选用的 analyzer不同,某些词可能会被转换,某些词可能被去除,这些会影响FST编码结果,也会影响查找匹 配的效果。

比如我们删除上面的索引,重新设置索引的mapping,将analyzer更改为”english”试下

PUT /blogs_completion/
{
  "mappings": {
    "properties": {
      "body": {
        "type": "completion",
         "analyzer": "english"
      }
    }
  }
}

FST(Finite StateTransducers)只编码了这3个token,并且默认的还会记录他们在文档中的位置和分隔符。 用户输入”elastic i”进行查找的时候,输入被分解成”elastic”和”i”,FST没有编码这个“i” , 匹配失败。

试一下搜索”elastic is”,会发现又有结果,why? 因为这次输入的 text经过english analyzer的时候is也被剥离了,只需在FST里查询”elastic”这个前缀,自然就可以匹配到了。

其他能影响completion suggester结果的,还有 如”preserve_separators”,”preserve_position_increments”等等mapping参数来控制匹配的模糊程度。以及搜索时可以选用Fuzzy Queries,使得上面例子里的”elastic i”在使用english analyzer的情况下 依然可以匹配到结果。

"preserve_separators": false, # 这个设置为false,将忽略空格之类的分隔符 
"preserve_position_increments": true, # 如果建议词第一个词是停用词,并且我们使用了过滤停用 词的分析器,需要将此设置为false

用好Completion Sugester并不是一件容易的事,实际应用开发过程中,需要根据数据特性和业务 需要,灵活搭配analyzer和mapping参数,反复调试才可能获得理想的补全效果

Context Suggester

Completion Suggester 的扩展
可以在搜索中加入更多的上下文信息,然后根据不同的上下文信息,对相同的输入,比如”star”, 提供不同的建议值,比如:

  • 咖啡相关:starbucks
  • 电影相关:star wars

可以定义两种类型的 Context

  • Category - 任意的字符串;
  • Geo - 地理位置信息; ```json PUT place { “mappings”: {
      "properties" : {
          "suggest" : {
              "type" : "completion",
              "contexts": [
                  { 
                      "name": "place_type",  # 定义context的name
                      "type": "category"  # 定义context的类型,在index的时候必须提供这个context的内容
                  },
                  { 
                      "name": "location",
                      "type": "geo",
                      "precision": 4
                  }
              ]
          }
      }
    
    } } PUT place_path_category { “mappings”: {
      "properties" : {
          "suggest" : {
              "type" : "completion",
              "contexts": [
                  { 
                      "name": "place_type",
                      "type": "category",
                      "path": "cat"  # 定义了place_type  context的内容从cat field读取, 在index的时候不用再提供这个context的内容了,只需要提供cat的内容
                  },
                  { 
                      "name": "location",
                      "type": "geo",
                      "precision": 4,
                      "path": "loc"  # 定义了location context对应的内容从loc field读取
                  }
              ]
          },
          "loc": {
              "type": "geo_point"
          }
      }
    
    } }
index操作
```json
PUT place/_doc/1
{
    "suggest": {
        "input": ["timmy's", "starbucks", "dunkin donuts"],
        "contexts": {
            "place_type": ["cafe", "food"] 
        }
    }
}

PUT place_path_category/_doc/1
{
    "suggest": ["timmy's", "starbucks", "dunkin donuts"],
    "cat": ["cafe", "food"] 
}

查找

POST place/_search?pretty
{
    "suggest": {
        "place_suggestion" : {
            "prefix" : "tim",
            "completion" : {
                "field" : "suggest",
                "size": 10,
                "contexts": {
                    "place_type": [ "cafe", "restaurants" ]
                }
            }
        }
    }
}

POST place/_search?pretty
{
    "suggest": {
        "place_suggestion" : {
            "prefix" : "tim",
            "completion" : {
                "field" : "suggest",
                "size": 10,
                "contexts": {
                    "place_type": [ 
                        { "context" : "cafe" },
                        { "context" : "restaurants", "boost": 2 }
                     ]
                }
            }
        }
    }
}

结论

京东或者百度搜索框的补全/纠错功能,如果用ES怎么实现呢?
大概是: 在用户刚开始输入的过程中,使用Completion Suggester进行关键词前缀匹配,刚开始匹配项会比较 多,随着用户输入字符增多,匹配项越来越少。如果用户输入比较精准,可能Completion Suggester的结果已经够好,用户已经可以看到理想的备选项了。

如果Completion Suggester已经到了零匹配,那么可以猜测是否用户有输入错误,这时候可以尝试一下 Phrase Suggester。如果Phrase Suggester没有找到任何option,开始尝试term Suggester。

精准程度上(Precision)看: Completion > Phrase > term, 而召回率上(Recall)则反之。
先把结果在Completion查询下, 没有获取到表示用户输入的有问题, 就进行纠错

召回率(Recall Rate,也叫查全)检索出的相关文档数和文档库中所有的相关文档数的比率,衡量的检索系统的查全;精度检索出的相关文档数与检索出的文档总数的比率,衡量的检索系统的查准召回率(Recall)和精度(Precise)广泛用于信息检索和统计学分类领域的两个度量值,用来评价结果的质量。

从性能上看, Completion Suggester是最快的,如果能满足业务需求,只用Completion Suggester做前缀匹配是最理想的。
Phrase和Term由于是做倒排索引的搜索,相比较而言性能应该要低不少,应尽量控制 suggester用到的索引的数据量。

召回率(Recall) = 系统检索到的相关文件 / 系统所有相关的文件总数

准确率(Precision) = 系统检索到的相关文件 / 系统所有检索到的文件总数 从一个大规模数据集合中检索文档时,可把文档分成四组:

  • 系统检索到的相关文档(A)
  • 系统检索到的不相关文档(B)
  • 相关但是系统没有检索到的文档(C)
  • 不相关且没有被系统检索到的文档(D)

则:

  • 召回率R:用实际检索到相关文档数作为分子,所有相关文档总数作为分母,即R = A / ( A + C )
  • 精度P:用实际检索到相关文档数作为分子,所有检索到的文档总数作为分母,即P = A / ( A + B )

举例:一个数据库有 1000 个文档,其中有 50 个文档符合相关定义的问题,系统检索到 75 个文档,但 其中只有 45 个文档被检索出。 精度:P=45/75=60%。 召回率:R=45/50=90%。