目录
一、搜索建议介绍
二、Completion Suggester
1、中文汉字同时支持
2、搜索建议中缀查询:
三、search_as_you_type前中缀任意匹配
四、闲谈
五、总结
es中提供了几种suggest搜索建议方式
对于中文而言,es提供的第一种方式与第二种方式对中文支持并不友好,通常在业务开发时,我们使用第三种与第四种结合使用。
下面将着重介绍第三种与第四种方式:
通过淘宝与拼多多的搜索框我们可以看到在搜索时首先通过前缀匹配,当用户输入关键词已经无法完成匹配的情况下,进行推荐词条展示。
查看京东搜索,匹配不到则不展示,相对而言,显得略微逊色。
completion suggester是鉴于fst结构的前缀匹配,正排索引,因此速度极快。
使用之前我们需要了解到,由于前缀匹配的原因,正常情况下我们无法通过一个词条的中间字来进行匹配,例如一个词条:鸿星尔克球鞋。我们输入鸿星尔克,completion suggester很轻易可以帮我们返回这个鸿星尔克球鞋字段,但是当我们输入球鞋,则匹配为空,自然也不会返回。
这也是completion的一个限制。
在企业业务开发中,我们通常需要满足用户两种方式来进行搜索,即拼音搜索以及中文搜索。
下面将会介绍:
1、如何同时支持中文拼音两种方式来进行查询
2、搜索建议词的前缀匹配
3、搜索建议词的中缀匹配
4、搜索建议词的多字段过滤
下面自定义一个mapping来解释如何实现这种方式,此mapping将用于介绍如何支持中文和拼音两种方式搜索建议以及如何做到前缀匹配并通过多个字段进行过滤搜索建议词
PUT test1
{
"settings": {
"analysis": {
"analyzer": {
"hanlp_pinyin": {
"type": "custom",
"tokenizer": "hanlp_nlp",
"filter": [
"cy_pinyin",
"word_delimiter"
]
}
},
"filter": {
"cy_pinyin": {
"type": "pinyin",
"first letter": "none",
"padding_char": ""
}
}
}
},
"mappings": {
"properties": {
"shopId": {
"type": "keyword"
},
"spuTitle": {
"type": "text",
"fields": {
"kw": {
"type": "completion",
"contexts": [
{
"name": "kw_completion",
"type": "category",
"path": "shopId"
}
],
"analyzer": "keyword"
},
"py": {
"type": "completion",
"contexts": [
{
"name": "py_completion",
"type": "category",
"path": "shopId"
}
],
"analyzer": "hanlp_pinyin"
}
}
}
}
}
}
如果你看到上方mapping感觉非常吃力建议去看es入门基础,先去了解以下es,如果你对自定义分析器不是很熟
可以参考这篇官方文档:自定义分析器 | Elasticsearch: 权威指南 | Elastic
如果你大概能看明白,可以继续往下了,不要嫌啰嗦,耐心看下去,对你很有用。
解释以下,首先我们创建一个名为test1的索引,我们有一个字段为spuTitle的字段,类型为text,他的子字段为kw和py,类型为completion,我们在执行搜索建议时便是使用这两个字段来进行操作。
每个子字段中定义了一个上下文字段contexts,我们使用shopId字段作为过滤字段,需要注意,shopId必须是keyword类型。
你可能会想,contexts看起来像是一个列表,是否可以定义多个字段来进行过滤,很抱歉,是不可以的,接下来会进行说明。
接下来插入一条数据:
POST test1/_doc
{
"shopId":"123456",
"spuTitle":"鸿星尔克球鞋"
}
然后进行查询:
GET test1/_search
{
"_source": ""
,
"suggest": {
"hansuggestWord": {
"prefix": "鸿星尔克",
"completion": {
"field": "spuTitle.kw",
"size": 15,
"skip_duplicates": true,
"contexts": {
"kw_completion": [
{
"context": "12345",
"boost": 1,
"prefix": true
}
]
}
}
}
}
}
上述通过前缀查询一个前缀为“耐 ”的词条,通过contexts指定一个上下文关联来进行过滤操作,这里通过kw_completion字段也就是shopId来进行过滤,开启prefix=true之后则前缀符合12345的数据都会被查询出来因此查询结果为
"suggest" : {
"hansuggestWord" : [
{
"text" : "鸿星尔克",
"offset" : 0,
"length" : 4,
"options" : [
{
"text" : "鸿星尔克球鞋",
"_index" : "test1",
"_type" : "_doc",
"_id" : "uU80DHsBflQEBT_bkrQX",
"_score" : 1.0,
"_source" : { },
"contexts" : {
"kw_completion" : [
"123456"
]
}
}
]
}
]
}
可以看到能够查询出options为耐克球鞋的词条。这种方式在某些场景中如果需要上下文相结合的可能会使用到,但是如果我们想要强匹配过滤shopId必须是123456的词条只需要将prefix=true改为false即可。
你以为这样就可以了?如果你的需求是需要根据多个字段进行过滤,比如我们有另一个字段disable用来展示是否想要显示该词条,由于contexts只能过滤一个字段,其他过滤我们可以通过程序的方式来进行业务逻辑上的过滤,通常搜索建议的词条不多,可能最多十几二十个,程序处理并不会浪费很多的时间。
需要注意的是,resthighlevelclient高级客户端并不支持suggest获取内部source结果,支支持获取到options内的text字段,原因在于highlevelclient客户端在封装结果的时候仅仅封装了offset,options内的text,contexts字段等少部分信息,并没有封装_source字段,因此我们需要结合restclient低级客户端来使用,自己处理json结果。
RestClient restClient = RestClient.builder(
new HttpHost("x.x.x.x",9200,"http")
).build();
Request request = new Request("GET", "test1/_search");
//创建查询构造器
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
searchSourceBuilder.fetchSource("disable",null);
//创建搜索建议构造器
SuggestBuilder suggestBuilder = new SuggestBuilder();
//设置搜索建议绑定字段
CompletionSuggestionBuilder completionSuggestionBuilder = SuggestBuilders.completionSuggestion("spuTitle.kw");
//设置搜索关键字
completionSuggestionBuilder.prefix(param.getKeywords());
//设置去重
completionSuggestionBuilder.skipDuplicates(true);
completionSuggestionBuilder.size(15);
List contexts = new ArrayList<>(1);
contexts.add(CategoryQueryContext.builder().setCategory(param.getShopId().toString()).build());
//设置过滤字段
Map> filterMap = new ConcurrentHashMap<>();
filterMap.put("kw_completion", contexts);
completionSuggestionBuilder.contexts(filterMap);
//将查询条件添加到搜索建议构造器中
suggestBuilder.addSuggestion("hansuggestWord", completionSuggestionBuilder);
//将搜索建议构造器添加到查询构造器中
searchSourceBuilder.suggest(suggestBuilder);
request.setEntity(new NStringEntity(
searchSourceBuilder.toString(),
ContentType.APPLICATION_JSON));
//发起请求
// SearchRequest searchRequest = new SearchRequest(new String[]{EsConstant.PRODUCT_INDEX}, searchSourceBuilder);
Response response = restClient.performRequest(request);
//使用完记得关闭
restClient.close();
然后是结果处理,过滤其他字段在此进行,代码写的不好不要在意,只是举例,给出思路
SuggestResultVo suggestResultVo = new SuggestResultVo();
List list = new ArrayList<>();
try {
String responseBody = EntityUtils.toString(response.getEntity());
JSONObject jsonObject = JSONObject.parseObject(responseBody);
String reponse =jsonObject.getJSONObject("suggest").get("hansuggestWord").toString();
JSONArray suggestions = JSONArray.parseArray(reponse);
String options= suggestions.getJSONObject(0).get("options").toString();
JSONArray option = JSONArray.parseArray(options);
for(int i =0;i
那么看到这里,你可能注意到了,我们的搜索目前还是在介绍中文搜索,如何支持拼音搜索?
其实很简单,自定义一个策略器,思路是,判断用户输入字符是否是纯中文,如果带有字母则使用拼音分词进行查询即可。dsl只是略微有差距,即
GET test1/_search
{
"_source": ""
,
"suggest": {
"hansuggestWord": {
"prefix": "鸿星尔ke",
"completion": {
"field": "spuTitle.py",
"size": 15,
"skip_duplicates": true,
"contexts": {
"py_completion": [
{
"context": "123456",
"boost": 1,
"prefix": false
}
]
}
}
}
}
}
现在中文搜索建议以及拼音建议都有了,接下来介绍中缀查询:
实际项目中用户可能只是输入了球鞋二字,如果我们想要给出鸿星尔克球鞋的提示,如果你看到了这里,你可能以及百度了很多关于中缀建议的相关知识,可能在es官网也找了很久,然而你的收获并不大,我也是一步一个坑迈过来的,因此我也想让你少走一些弯路。
好了不吹了,中缀查询建议是一个比较有用的业务,es官方非常明白,只是拥有前缀查询基于fst结构并不能满足开发以及用户的需求,因此在es7.2之后官方引入了一个新的结构类型,适用于completion,一种名为search_as_you_type的类型。
下面我们改造之前的索引:
PUT test1
{
"settings": {
"analysis": {
"analyzer": {
"hanlp_pinyin": {
"type": "custom",
"tokenizer": "hanlp_nlp",
"filter": [
"cy_pinyin",
"word_delimiter"
]
}
},
"filter": {
"cy_pinyin": {
"type": "pinyin",
"first letter": "none",
"padding_char": ""
}
}
}
},
"mappings": {
"properties": {
"spuTitle": {
"type": "text",
"fields": {
"kw": {
"type": "search_as_you_type"
},
"py": {
"type": "search_as_you_type",
"analyzer": "hanlp_pinyin"
}
}
}
}
}
}
你可以只关注mappings字段,我们将spuTitle.kw和py字段设置为了search_as_you_type类型。
并使用默认的分词器,这里不需要指定分词器,使用默认即可。
search_as_you_type类型可以看作是text类型的一个优化,它支持前缀中缀以及几乎任意位置的搜索建议,创建此索引之后,spuTitle.kw和py字段将会创建以下字段:
spuTitle.kw | 默认字段 |
spuTitle.kw._2gram | 用分词长度为2的shingle分词器对spuTitle.kw进行分词 |
spuTitle.kw._2gram | 用分词长度为3的shingle分词器对spuTitle.kw进行分词 |
spuTitle.kw._index_prefix | 用 edge ngram token filter 包装 spuTitle.kw._3gram进行分词 |
py字段类似
你可能不太明白表格什么意思,看下面例子就懂了
POST test1/_analyze
{
"field": "spuTitle.kw._2gram",
"text": [
"鸿星尔克球鞋"
]
}
POST test1/_analyze
{
"field": "spuTitle.kw._3gram",
"text": [
"鸿星尔克球鞋"
]
}
POST test1/_analyze
{
"field": "spuTitle.kw._index_prefix",
"text": [
"鸿星尔克球鞋"
]
}
上述分析结果分别为:
{
"tokens" : [
{
"token" : "鸿 星",
"start_offset" : 0,
"end_offset" : 2,
"type" : "shingle",
"position" : 0
},
{
"token" : "星 尔",
"start_offset" : 1,
"end_offset" : 3,
"type" : "shingle",
"position" : 1
},
{
"token" : "尔 克",
"start_offset" : 2,
"end_offset" : 4,
"type" : "shingle",
"position" : 2
},
{
"token" : "克 球",
"start_offset" : 3,
"end_offset" : 5,
"type" : "shingle",
"position" : 3
},
{
"token" : "球 鞋",
"start_offset" : 4,
"end_offset" : 6,
"type" : "shingle",
"position" : 4
}
]
}
{
"tokens" : [
{
"token" : "鸿 星 尔",
"start_offset" : 0,
"end_offset" : 3,
"type" : "shingle",
"position" : 0
},
{
"token" : "星 尔 克",
"start_offset" : 1,
"end_offset" : 4,
"type" : "shingle",
"position" : 1
},
{
"token" : "尔 克 球",
"start_offset" : 2,
"end_offset" : 5,
"type" : "shingle",
"position" : 2
},
{
"token" : "克 球 鞋",
"start_offset" : 3,
"end_offset" : 6,
"type" : "shingle",
"position" : 3
}
]
}
{
"tokens" : [
{
"token" : "鸿",
"start_offset" : 0,
"end_offset" : 3,
"type" : "shingle",
"position" : 0
},
{
"token" : "鸿 ",
"start_offset" : 0,
"end_offset" : 3,
"type" : "shingle",
"position" : 0
},
{
"token" : "鸿 星",
"start_offset" : 0,
"end_offset" : 3,
"type" : "shingle",
"position" : 0
},
{
"token" : "鸿 星 ",
"start_offset" : 0,
"end_offset" : 3,
"type" : "shingle",
"position" : 0
},
{
"token" : "鸿 星 尔",
"start_offset" : 0,
"end_offset" : 3,
"type" : "shingle",
"position" : 0
},
{
"token" : "星",
"start_offset" : 1,
"end_offset" : 4,
"type" : "shingle",
"position" : 1
},
{
"token" : "星 ",
"start_offset" : 1,
"end_offset" : 4,
"type" : "shingle",
"position" : 1
},
{
"token" : "星 尔",
"start_offset" : 1,
"end_offset" : 4,
"type" : "shingle",
"position" : 1
},
{
"token" : "星 尔 ",
"start_offset" : 1,
"end_offset" : 4,
"type" : "shingle",
"position" : 1
},
{
"token" : "星 尔 克",
"start_offset" : 1,
"end_offset" : 4,
"type" : "shingle",
"position" : 1
},
{
"token" : "尔",
"start_offset" : 2,
"end_offset" : 5,
"type" : "shingle",
"position" : 2
},
{
"token" : "尔 ",
"start_offset" : 2,
"end_offset" : 5,
"type" : "shingle",
"position" : 2
},
{
"token" : "尔 克",
"start_offset" : 2,
"end_offset" : 5,
"type" : "shingle",
"position" : 2
},
{
"token" : "尔 克 ",
"start_offset" : 2,
"end_offset" : 5,
"type" : "shingle",
"position" : 2
},
{
"token" : "尔 克 球",
"start_offset" : 2,
"end_offset" : 5,
"type" : "shingle",
"position" : 2
},
{
"token" : "克",
"start_offset" : 3,
"end_offset" : 6,
"type" : "shingle",
"position" : 3
},
{
"token" : "克 ",
"start_offset" : 3,
"end_offset" : 6,
"type" : "shingle",
"position" : 3
},
{
"token" : "克 球",
"start_offset" : 3,
"end_offset" : 6,
"type" : "shingle",
"position" : 3
},
{
"token" : "克 球 ",
"start_offset" : 3,
"end_offset" : 6,
"type" : "shingle",
"position" : 3
},
{
"token" : "克 球 鞋",
"start_offset" : 3,
"end_offset" : 6,
"type" : "shingle",
"position" : 3
},
{
"token" : "球",
"start_offset" : 4,
"end_offset" : 6,
"type" : "shingle",
"position" : 4
},
{
"token" : "球 ",
"start_offset" : 4,
"end_offset" : 6,
"type" : "shingle",
"position" : 4
},
{
"token" : "球 鞋",
"start_offset" : 4,
"end_offset" : 6,
"type" : "shingle",
"position" : 4
},
{
"token" : "球 鞋 ",
"start_offset" : 4,
"end_offset" : 6,
"type" : "shingle",
"position" : 4
},
{
"token" : "鞋",
"start_offset" : 5,
"end_offset" : 6,
"type" : "shingle",
"position" : 5
},
{
"token" : "鞋 ",
"start_offset" : 5,
"end_offset" : 6,
"type" : "shingle",
"position" : 5
},
{
"token" : "鞋 ",
"start_offset" : 5,
"end_offset" : 6,
"type" : "shingle",
"position" : 5
}
]
}
看到这里你大概能明白其作用是什么了
那么我们在进行中缀搜索的时候只需要按照普通的查询来进行编写即可,需要注意的是,我们应该使用match_phrase_prefix来进行短语的前缀搜索以达到搜索建议最重要的实时性和高效性:
GET test1/_search
{
"query": {
"bool": {
"should": [
{
"match_phrase_prefix": {
"spuTitle.py._2gram": "erke"
}
}
]
, "filter": [
{
"term": {
"shopId": "123456"
}
}
]
}
}
}
这样你可以很简单的通过正常的查询方式来进行过滤和搜索建议,非常方便,也不需要通过上面介绍的业务逻辑层面处理等,这种预处理的方式是一个非常实用的方式。
当然,公司如果使用的是es7.2以上的版本,这很有用,但是7.2版本以下如何实现中缀搜索建议呢?当然一种方式是用户在进行前缀搜索时没有搜到结果,通过某种推荐算法和用户画像等综合来为用户提供一个搜索推荐词。当然,这失去了搜索引擎的意义,而且推荐应该在搜索之后来展现。
另一种方式就是输入预处理的方式来进行中缀搜索,在索引输入阶段,通过分词器最细粒度的尽可能将用户要输入的搜索词进行分析,作为input输入,这样用户无论输入什么都可以得到推荐结果。这样说可能并不清晰,下面举例:
PUT /test2
{
"mappings": {
"properties": {
"shopId": {
"type": "long"
},
"spuTitle": {
"type": "text"
},
"spuTitlecompletion": {
"type": "completion"
}
}
}
}
在输入时尽可能将用户需求input进去
POST /test2/_doc
{
"shopId": "123456",
"spuTitle": "鸿星尔克球鞋",
"spuTitlecompletion": {
"input": ["鸿星尔克球鞋","星尔克球鞋","尔克球鞋","球鞋"]
}
}
GET test2/_search
{
"suggest": {
"su": {
"prefix": "尔克球鞋",
"completion": {
"field": "spuTitlecompletion"
}
}
}
}
当我们搜索尔克球鞋的时候,你会发现,也能搜索到结果
"suggest" : {
"su" : [
{
"text" : "尔克球鞋",
"offset" : 0,
"length" : 4,
"options" : [
{
"text" : "尔克球鞋",
"_index" : "test2",
"_type" : "_doc",
"_id" : "2k8eD3sBflQEBT_bMbR9",
"_score" : 1.0,
"_source" : {
"shopId" : "123456",
"spuTitle" : "鸿星尔克球鞋",
"spuTitlecompletion" : {
"input" : [
"鸿星尔克球鞋",
"星尔克球鞋",
"尔克球鞋",
"球鞋"
]
}
}
}
]
}
]
}
1、es的搜索建议主要难点在于字段设计,如何实时高效的为用户返回推荐词,根据需求选择适合自己的最重要。
2、在开发过程中如果遇到api不全的情况下使用低级客户端,低级客户端包括了所有的api。
3、中文搜索推荐最常用completion suggester、context completion suggetser以及search_as_you_type类型。
4、使用search_as_you_type如果遇到词条有空格的情况下合理设置slop字段,使得查询更加有效。