在基于Elasticsearch实现搜索建议一文中我们曾经介绍过如何基于Elasticsearch来实现搜索建议,而本文是在此基于上进一步优化搜索体验,在当搜索无结果或结果过少时提供推荐搜索词给用户。
在根据用户输入和筛选条件进行搜索后,有时返回的是无结果或者结果很少的情况,为了提升用户搜索体验,需要能够给用户推荐一些相关的搜索词,比如用户搜索【迪奥】时没有找到相关的商品,可以推荐搜索【香水】、【眼镜】等关键词。
首先需要分析搜索无结果或者结果过少可能的原因,我总结了一下,主要包括主要可能:
那么针对以上情况,可以采用以下方式进行处理:
所以,我们在实现搜索推荐的核心是之前讲到的搜索建议词,它提供了本网主要的关键词,另外一个很重要的是它本身包含了关联商品数的属性,这样就可以保证推荐给用户的关键词是可以搜索出结果的。
整体设计框架如下图所示:
搜索推荐整体设计
在基于Elasticsearch实现搜索建议一文已有说明,请移步阅读。此次增加了一个keyword.keyword_lowercase的字段用于拼写纠错,这里列取相关字段的索引:
PUT /suggest_index
{
"mappings": {
"suggest": {
"properties": {
"keyword": {
"fields": {
"keyword": {
"type": "string",
"index": "not_analyzed"
},
"keyword_lowercase": {
"type": "string",
"analyzer": "lowercase_keyword"
},
"keyword_ik": {
"type": "string",
"analyzer": "ik_smart"
},
"keyword_pinyin": {
"type": "string",
"analyzer": "pinyin_analyzer"
},
"keyword_first_py": {
"type": "string",
"analyzer": "pinyin_first_letter_keyword_analyzer"
}
},
"type": "multi_field"
},
"type": {
"type": "long"
},
"weight": {
"type": "long"
},
"count": {
"type": "long"
}
}
}
}
}
这里只列取相关字段的mapping:
PUT /product_index
{
"mappings": {
"product": {
"properties": {
"productSkn": {
"type": "long"
},
"productName": {
"type": "string",
"analyzer": "ik_smart"
},
"brandName": {
"type": "string",
"analyzer": "ik_smart"
},
"sortName": {
"type": "string",
"analyzer": "ik_smart"
},
"style": {
"type": "string",
"analyzer": "ik_smart"
}
}
}
}
}
主要就是source和dest直接的映射关系。
PUT /conversion_index
{
"mappings": {
"conversion": {
"properties": {
"source": {
"type": "string",
"analyzer": "lowercase_keyword"
},
"dest": {
"type": "string",
"index": "not_analyzed"
}
}
}
}
}
在实现的时候,我们主要是爬取了百度百科上面的词条,在实际的实现中又分为了全量爬虫和增加爬虫。
全量爬虫我这边是从网上下载了一份他人汇总的词条URL资源,里面根据一级分类包含多个目录,每个目录又根据二级分类包含多个词条,每一行的内容的格式如下:
李宁!http://baike.baidu.com/view/10670.html?fromTaglist
diesel!http://baike.baidu.com/view/394305.html?fromTaglist
ONLY!http://baike.baidu.com/view/92541.html?fromTaglist
lotto!http://baike.baidu.com/view/907709.html?fromTaglist
这样在启动的时候我们就可以使用多线程甚至分布式的方式爬虫自己感兴趣的词条内容作为初始化数据保持到爬虫数据表。为了保证幂等性,如果再次全量爬取时就需要排除掉数据库里已有的词条。
public JSONObject recommend(SearchResult searchResult, String queryWord) {
try {
String keywordsToSearch = queryWord;
// 搜索推荐分两部分
// 1) 第一部分是最常见的情况,包括有结果、根据SKN搜索、关键词未出现在空结果Redis ZSet里
if (containsProductInSearchResult(searchResult)) {
// 1.1) 搜索有结果的 优先从搜索结果聚合出品牌等关键词进行查询
String aggKeywords = aggKeywordsByProductList(searchResult);
keywordsToSearch = queryWord + " " + aggKeywords;
} else if (isQuerySkn(queryWord)) {
// 1.2) 如果是查询SKN 没有查询到的 后续的逻辑也无法推荐 所以直接到ES里去获取关键词
keywordsToSearch = aggKeywordsBySkns(queryWord);
if (StringUtils.isEmpty(keywordsToSearch)) {
return defaultSuggestRecommendation();
}
}
Double count = searchKeyWordService.getKeywordCount(RedisKeys.SEARCH_KEYWORDS_EMPTY, queryWord);
if (count == null || queryWord.length() >= 5) {
// 1.3) 如果该关键词一次都没有出现在空结果列表或者长度大于5 则该词很有可能是可以搜索出结果的
// 因此优先取suggest_index去搜索一把 减少后面的查询动作
JSONObject recommendResult = recommendBySuggestIndex(queryWord, keywordsToSearch, false);
if (isNotEmptyResult(recommendResult)) {
return recommendResult;
}
}
// 2) 第二部分是通过Conversion和拼写纠错去获取关键词 由于很多品牌的拼写可能比较相近 因此先走Conversion然后再拼写检查
String spellingCorrentWord = null, dest = null;
if (allowGetingDest(queryWord) && StringUtils.isNotEmpty((dest = getSuggestConversionDestBySource(queryWord)))) {
// 2.1) 爬虫和自定义的Conversion处理
keywordsToSearch = dest;
} else if (allowSpellingCorrent(queryWord)
&& StringUtils.isNotEmpty((spellingCorrentWord = suggestService.getSpellingCorrectKeyword(queryWord)))) {
// 2.2) 执行拼写检查 由于在搜索建议的时候会进行拼写检查 所以缓存命中率高
keywordsToSearch = spellingCorrentWord;
} else {
// 2.3) 如果两者都没有 则直接返回
return defaultSuggestRecommendation();
}
JSONObject recommendResult = recommendBySuggestIndex(queryWord, keywordsToSearch, dest != null);
return isNotEmptyResult(recommendResult) ? recommendResult : defaultSuggestRecommendation();
} catch (Exception e) {
logger.error("[func=recommend][queryWord=" + queryWord + "]", e);
return defaultSuggestRecommendation();
}
}
其中涉及到的几个函数简单说明下:
private JSONObject recommendBySuggestIndex(String srcQueryWord, String keywordsToSearch, boolean isLimitKeywords) {
// 1) 先对keywordsToSearch进行分词
List terms = null;
if (isLimitKeywords) {
terms = Arrays.stream(keywordsToSearch.split(",")).filter(term -> term != null && term.length() > 1)
.distinct().collect(Collectors.toList());
} else {
terms = searchAnalyzeService.getAnalyzeTerms(keywordsToSearch, "ik_smart");
}
if (CollectionUtils.isEmpty(terms)) {
return new JSONObject();
}
// 2) 根据terms搜索构造搜索请求
SearchParam searchParam = new SearchParam();
searchParam.setPage(1);
searchParam.setSize(3);
// 2.1) 构建FunctionScoreQueryBuilder
QueryBuilder queryBuilder = isLimitKeywords ? buildQueryBuilderByLimit(terms)
: buildQueryBuilder(keywordsToSearch, terms);
searchParam.setQuery(queryBuilder);
// 2.2) 设置过滤条件
BoolQueryBuilder boolFilter = QueryBuilders.boolQuery();
boolFilter.must(QueryBuilders.rangeQuery("count").gte(20));
boolFilter.mustNot(QueryBuilders.termQuery("keyword.keyword_lowercase", srcQueryWord.toLowerCase()));
if (isLimitKeywords) {
boolFilter.must(QueryBuilders.termsQuery("keyword.keyword_lowercase", terms.stream()
.map(String::toLowerCase).collect(Collectors.toList())));
}
searchParam.setFiter(boolFilter);
// 2.3) 按照得分、权重、数量的规则降序排序
List sortBuilders = new ArrayList<>(3);
sortBuilders.add(SortBuilders.fieldSort("_score").order(SortOrder.DESC));
sortBuilders.add(SortBuilders.fieldSort("weight").order(SortOrder.DESC));
sortBuilders.add(SortBuilders.fieldSort("count").order(SortOrder.DESC));
searchParam.setSortBuilders(sortBuilders);
// 4) 先从缓存中获取
final String indexName = SearchConstants.INDEX_NAME_SUGGEST;
JSONObject suggestResult = searchCacheService.getJSONObjectFromCache(indexName, searchParam);
if (suggestResult != null) {
return suggestResult;
}
// 5) 调用ES执行搜索
SearchResult searchResult = searchCommonService.doSearch(indexName, searchParam);
// 6) 构建结果加入缓存
suggestResult = new JSONObject();
List resultTerms = searchResult.getResultList().stream()
.map(map -> (String) map.get("keyword")).collect(Collectors.toList());
suggestResult.put("search_recommendation", resultTerms);
searchCacheService.addJSONObjectToCache(indexName, searchParam, suggestResult);
return suggestResult;
}
private QueryBuilder buildQueryBuilderByLimit(List terms) {
FunctionScoreQueryBuilder functionScoreQueryBuilder
= new FunctionScoreQueryBuilder(QueryBuilders.matchAllQuery());
// 给品类类型的关键词加分
functionScoreQueryBuilder.add(QueryBuilders.termQuery("type", Integer.valueOf(2)),
ScoreFunctionBuilders.weightFactorFunction(3));
// 按词出现的顺序加分
for (int i = 0; i < terms.size(); i++) {
functionScoreQueryBuilder.add(QueryBuilders.termQuery("keyword.keyword_lowercase",
terms.get(i).toLowerCase()),
ScoreFunctionBuilders.weightFactorFunction(terms.size() - i));
}
functionScoreQueryBuilder.boostMode(CombineFunction.SUM);
return functionScoreQueryBuilder;
}
private QueryBuilder buildQueryBuilder(String keywordsToSearch, Set termSet) {
// 1) 对于suggest的multi-fields至少要有一个字段匹配到 匹配得分为常量1
MultiMatchQueryBuilder queryBuilder = QueryBuilders.multiMatchQuery(keywordsToSearch.toLowerCase(),
"keyword.keyword_ik", "keyword.keyword_pinyin",
"keyword.keyword_first_py", "keyword.keyword_lowercase")
.analyzer("ik_smart")
.type(MultiMatchQueryBuilder.Type.BEST_FIELDS)
.operator(MatchQueryBuilder.Operator.OR)
.minimumShouldMatch("1");
FunctionScoreQueryBuilder functionScoreQueryBuilder
= new FunctionScoreQueryBuilder(QueryBuilders.constantScoreQuery(queryBuilder));
for (String term : termSet) {
// 2) 对于完全匹配Term的加1分
functionScoreQueryBuilder.add(QueryBuilders.termQuery("keyword.keyword_lowercase", term.toLowerCase()),
ScoreFunctionBuilders.weightFactorFunction(1));
// 3) 对于匹配到一个Term的加2分
functionScoreQueryBuilder.add(QueryBuilders.termQuery("keyword.keyword_ik", term),
ScoreFunctionBuilders.weightFactorFunction(2));
}
functionScoreQueryBuilder.boostMode(CombineFunction.SUM);
return functionScoreQueryBuilder;
}
最后,从实际运行的统计来看,有90%以上的查询都能在1.3)的情况下返回推荐词,而这一部分还没有进行拼写纠错和conversion_index索引的查询,因此还是比较高效的;剩下的10%在最坏的情况且缓存都没有命中的情况下,最多还需要进行三次ES的查询,性能是比较差的,但是由于有缓存而且大部分的无结果的关键词都比较集中,因此也在可接受的范围,这一块可以考虑再增加一个动态参数,在大促的时候进行关闭处理。
//搜索【迪奥】,本站无该品牌商品
没有找到 "迪奥" 相关的商品, 为您推荐 "香水" 的搜索结果。或者试试 "香氛" "眼镜"
//搜索【puma 运动鞋 上衣】,关键词太多无法匹配
没有找到 "puma 运动鞋 上衣" 相关的商品, 为您推荐 "PUMA 运动鞋" 的搜索结果。或者试试 "PUMA 运动鞋 女" "PUMA 运动鞋 男"
//搜索【puma 上衣】,结果太少
"puma 上衣" 搜索结果太少了,试试 "上衣" "PUMA" "PUMA 休闲" 关键词搜索
//搜索【51489312】特定的SKN,结果太少
"51489312" 搜索结果太少了,试试 "夹克" "PUMA" "户外" 关键词搜索
//搜索【blackjauk】,拼写错误
没有找到 "blackjauk" 相关的商品, 为您推荐 "BLACKJACK" 的搜索结果。或者试试 "BLACKJACK T恤" "BLACKJACK 休闲裤"
作者:ginobefun
链接:https://www.jianshu.com/p/4ab3c69e7b19
來源:简书
简书著作权归作者所有,任何形式的转载都请联系作者获得授权并注明出处。