非常全面的一篇关于ElasticSearch提示词的文章,一步一步简单清晰,我就是照这个把项目的提示词功能做出来的。
原文地址:http://elasticsearch.cn/article/142
原文作者:kennywu76
原文社区:Elastic中文社区
现代的搜索引擎,一般会具备"Suggest As You Type"
功能,即在用户输入搜索的过程中,进行自动补全或者纠错。 通过协助用户输入更精准的关键词,提高后续全文搜索阶段文档匹配的程度。例如在Google
上输入部分关键词,甚至输入拼写错误的关键词时,它依然能够提示出用户想要输入的内容:
如果自己亲手去试一下,可以看到Google
在用户刚开始输入的时候是自动补全的,而当输入到一定长度,如果因为单词拼写错误无法补全,就开始尝试提示相似的词。
那么类似的功能在Elasticsearch
里如何实现呢? 答案就在Suggesters API
。
Suggesters
基本的运作原理是将输入的文本分解为token
,然后在索引的字典里查找相似的term
并返回。 根据使用场景的不同,Elasticsearch
里设计了4种类别的Suggester
,分别是:
在官方的参考文档里,对这4种Suggester API
都有比较详细的介绍,但苦于只有英文版,部分国内开发者看完文档后仍然难以理解其运作机制。 本文将在Elasticsearch 5.x
上通过示例讲解Suggester
的基础用法,希望能帮助部分国内开发者快速用于实际项目开发。限于篇幅,更为高级的Context Suggester
会被略过。
Term Suggester
的示例:准备一个叫做blogs
的索引,配置一个text
字段。
PUT /blogs/
{
"mappings": {
"properties": {
"body": {
"type": "text"
}
}
}
}
通过bulk api
写入几条文档
POST _bulk/?refresh=true
{ "index" : { "_index" : "blogs"} }
{ "body": "Lucene is cool"}
{ "index" : { "_index" : "blogs"} }
{ "body": "Elasticsearch builds on top of lucene"}
{ "index" : { "_index" : "blogs"} }
{ "body": "Elasticsearch rocks"}
{ "index" : { "_index" : "blogs"} }
{ "body": "Elastic is the company behind ELK stack"}
{ "index" : { "_index" : "blogs"} }
{ "body": "elk rocks"}
{ "index" : { "_index" : "blogs"} }
{ "body": "elasticsearch is rock solid"}
此时blogs
索引里已经有一些文档了,可以进行下一步的探索。为帮助理解,我们先看看哪些term
会存在于词典里。
将输入的文本分析一下(这步仅仅是看看ElasticSearch
对他们的分词效果):
POST _analyze
{
"text": [
"Lucene is cool",
"Elasticsearch builds on top of lucene",
"Elasticsearch rocks",
"Elastic is the company behind ELK stack",
"elk rocks",
"elasticsearch is rock solid"
]
}
(由于结果太长,此处略去)
这些分出来的token
都会成为词典里一个term
,注意有些token
会出现多次,因此在倒排索引里记录的词频会比较高,同时记录的还有这些token
在原文档里的偏移量和相对位置信息。
执行一次suggester
搜索看看效果:
POST /blogs/_search
{
"suggest": {
"my-suggestion": {
"text": "lucne rock",
"term": {
"suggest_mode": "missing",
"field": "body"
}
}
}
}
suggest
就是一种特殊类型的搜索;DSL
内部的"text
"指的是api
调用方提供的文本,也就是通常用户界面上用户输入的内容。这里的lucne
是错误的拼写,模拟用户输入错误。term
"表示这是一个term suggester
。;field
"指定suggester
针对的字段;suggest_mode
"。 范例里的"missing
"实际上就是缺省值,它是什么意思?有点挠头… 还是先看看返回结果吧:{
"took": 1,
"timed_out": false,
"_shards": {
"total": 1,
"successful": 1,
"failed": 0
},
"hits": {
"total": 0,
"max_score": 0,
"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":
}
]
}
}
在返回结果里"suggest
" -> “my-suggestion
“部分包含了一个数组,每个数组项对应从输入文本分解出来的token
(存放在”text
“这个key
里)以及为该token
提供的建议词项(存放在options
数组里)。 示例里返回了”lucne
”,"rock
“这2个词的建议项(options
),其中”rock
“的options
是空的,表示没有可以建议的选项,为什么? 上面提到了,我们为查询提供的suggest mode
是”missing
",由于"rock
"在索引的词典里已经存在了,够精准,就不建议啦。 只有词典里找不到词,才会为其提供相似的选项。
如果将"suggest_mode
“换成"popula
r"会是什么效果?
尝试一下,重新执行查询,返回结果里”rock
"这个词的option
不再是空的,而是建议为rocks
。
"suggest": {
"my-suggestion": [
{
"text": "lucne",
"offset": 0,
"length": 5,
"options": [
{
"text": "lucene",
"score": 0.8,
"freq": 2
}
]
},
{
"text": "rock",
"offset": 6,
"length": 4,
"options": [
{
"text": "rocks",
"score": 0.75,
"freq": 2
}
]
}
]
}
回想一下,rock
和rocks
在索引词典里都是有的。 不难看出即使用户输入的token
在索引的词典里已经有了,但是因为存在一个词频更高的相似项,这个相似项可能是更合适的,就被挑选到options
里了。 最后还有一个"always
" mode
,其含义是不管token
是否存在于索引词典里都要给出相似项。
有人可能会问,两个term
的相似性是如何判断的? ES
使用了一种叫做Levenstein edit distance
的算法,其核心思想就是一个词改动多少个字符就可以和另外一个词一致。 Term suggester
还有其他很多可选参数来控制这个相似性的模糊程度,这里就不一一赘述了。
Term suggester
正如其名,只基于analyze
过的单个term
去提供建议,并不会考虑多个term
之间的关系。API
调用方只需为每个token
挑选options
里的词,组合在一起返回给用户前端即可。 那么有无更直接办法,API
直接给出和用户输入文本相似的内容? 答案是有,这就要求助Phrase Suggester
了。
Phrase suggester
范例Phrase suggester
在Term suggester
的基础上,会考量多个term
之间的关系,比如是否同时出现在索引的原文里,相邻程度,以及词频等等。看个范例就比较容易明白了:
POST /blogs/_search
{
"suggest": {
"my-suggestion": {
"text": "lucne and elasticsear rock",
"phrase": {
"field": "body",
"highlight": {
"pre_tag": "",
"post_tag": ""
}
}
}
}
}
返回结果:
"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
有相当多的参数用于控制匹配的模糊程度,需要根据实际应用情况去挑选和调试。
Completion Suggester
它主要针对的应用场景就是"Auto Completion
"。 此场景下用户每输入一个字符的时候,就需要即时发送一次查询请求到后端查找匹配项,在用户输入速度较高的情况下对后端响应速度要求比较苛刻。因此实现上它和前面两个Suggester
采用了不同的数据结构,索引并非通过倒排来完成,而是将analyze
过的数据编码成FST
和索引一起存放。对于一个open
状态的索引,FST
会被ES
整个装载到内存里的,进行前缀查找速度极快。但是FST
只能用于前缀查找,这也是Completion Suggester
的局限所在。
为了使用Completion Suggester
,字段的类型需要专门定义如下:
PUT /blogs_completion/
{
"mappings": {
"properties": {
"body": {
"type": "completion"
}
}
}
}
用bulk API索引点数据:
POST _bulk/?refresh=true
{ "index" : { "_index" : "blogs_completion"} }
{ "body": "Lucene is cool"}
{ "index" : { "_index" : "blogs_completion"} }
{ "body": "Elasticsearch builds on top of lucene"}
{ "index" : { "_index" : "blogs_completion"} }
{ "body": "Elasticsearch rocks"}
{ "index" : { "_index" : "blogs_completion"} }
{ "body": "Elastic is the company behind ELK stack"}
{ "index" : { "_index" : "blogs_completion"} }
{ "body": "the elk stack rocks"}
{ "index" : { "_index" : "blogs_completion"} }
{ "body": "elasticsearch is rock solid"}
查找:
POST blogs_completion/_search?pretty
{ "size": 0,
"suggest": {
"blog-suggest": {
"prefix": "elastic i",
"completion": {
"field": "body"
}
}
}
}
结果:
"suggest": {
"blog-suggest": [
{
"text": "elastic i",
"offset": 0,
"length": 9,
"options": [
{
"text": "Elastic is the company behind ELK stack",
"_index": "blogs_completion",
"_type": "tech",
"_id": "AVrXFyn-cpYmMpGqDdcd",
"_score": 1,
"_source": {
"body": "Elastic is the company behind ELK stack"
}
}
]
}
]
}
值得注意的一点是Completion Suggester
在索引原始数据的时候也要经过analyze
阶段,取决于选用的analyzer
不同,某些词可能会被转换,某些词可能被去除,这些会影响FST
编码结果,也会影响查找匹配的效果。
比如我们删除上面的索引,重新设置索引的mapping
,将analyzer
更改为"english
":
PUT /blogs_completion/
{
"mappings": {
"properties": {
"body": {
"type": "completion",
"analyzer": "english"
}
}
}
}
bulk api
索引同样的数据后,执行下面的查询:
POST blogs_completion/_search?pretty
{ "size": 0,
"suggest": {
"blog-suggest": {
"prefix": "elastic i",
"completion": {
"field": "body"
}
}
}
}
居然没有匹配结果了,多么费解! 原来我们用的english analyzer
会剥离掉stop word
,而is
就是其中一个,被剥离掉了!
用analyze api
测试一下:
POST _analyze?analyzer=english
{
"text": "elasticsearch is rock solid"
}
会发现只有3个token
:
{
"tokens": [
{
"token": "elasticsearch",
"start_offset": 0,
"end_offset": 13,
"type": "" ,
"position": 0
},
{
"token": "rock",
"start_offset": 17,
"end_offset": 21,
"type": "" ,
"position": 2
},
{
"token": "solid",
"start_offset": 22,
"end_offset": 27,
"type": "" ,
"position": 3
}
]
}
FST
只编码了这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
的情况下依然可以匹配到结果。
因此用好Completion Sugester
并不是一件容易的事,实际应用开发过程中,需要根据数据特性和业务需要,灵活搭配analyzer
和mapping
参数,反复调试才可能获得理想的补全效果。
回到篇首Google
搜索框的补全/纠错功能,如果用ES
怎么实现呢?我能想到的一个的实现方式:
Completion Suggester
进行关键词前缀匹配,刚开始匹配项会比较多,随着用户输入字符增多,匹配项越来越少。如果用户输入比较精准,可能Completion Suggester
的结果已经够好,用户已经可以看到理想的备选项了。Completion Suggester
已经到了零匹配,那么可以猜测是否用户有输入错误,这时候可以尝试一下Phrase Suggester
。Phrase Suggester
没有找到任何option
,开始尝试term Suggester
。精准程度上(Precision
)看: Completion > Phrase > term
, 而召回率上(Recall
)则反之。从性能上看,Completion Suggester
是最快的,如果能满足业务需求,只用Completion Suggester
做前缀匹配是最理想的。
Phrase
和Term
由于是做倒排索引的搜索,相比较而言性能应该要低不少,应尽量控制suggester
用到的索引的数据量,最理想的状况是经过一定时间预热后,索引可以全量map
到内存。