在基于Elasticsearch實(shí)現(xiàn)搜索建議一文中我們?cè)?jīng)介紹過(guò)如何基于Elasticsearch來(lái)實(shí)現(xiàn)搜索建議,而本文是在此基于上進(jìn)一步優(yōu)化搜索體驗(yàn),在當(dāng)搜索無(wú)結(jié)果或結(jié)果過(guò)少時(shí)提供推薦搜索詞給用戶。
背景介紹
在根據(jù)用戶輸入和篩選條件進(jìn)行搜索后,有時(shí)返回的是無(wú)結(jié)果或者結(jié)果很少的情況,為了提升用戶搜索體驗(yàn),需要能夠給用戶推薦一些相關(guān)的搜索詞,比如用戶搜索【迪奧】時(shí)沒(méi)有找到相關(guān)的商品,可以推薦搜索【香水】、【眼鏡】等關(guān)鍵詞。
設(shè)計(jì)思路
首先需要分析搜索無(wú)結(jié)果或者結(jié)果過(guò)少可能的原因,我總結(jié)了一下,主要包括主要可能:
- 搜索的關(guān)鍵詞在本網(wǎng)不存在,比如【迪奧】;
- 搜索的關(guān)鍵詞在本網(wǎng)的商品很少,比如【科比】;
- 搜索的關(guān)鍵詞拼寫(xiě)有問(wèn)題,比如把【阿迪達(dá)斯】寫(xiě)成了【阿迪大斯】;
- 搜索的關(guān)鍵詞過(guò)多,由于我們采用的是cross_fields,在一個(gè)商品內(nèi)不可能包含所有的Term,導(dǎo)致無(wú)結(jié)果,比如【阿迪達(dá)斯 耐克 衛(wèi)衣 運(yùn)動(dòng)鞋】;
那么針對(duì)以上情況,可以采用以下方式進(jìn)行處理:
- 搜索的關(guān)鍵詞在本網(wǎng)不存在,可以通過(guò)爬蟲(chóng)的方式獲取相關(guān)知識(shí),然后根據(jù)搜索建議詞去提取,比如去百度百科的迪奧詞條里就能提取出【香水】、【香氛】和【眼鏡】等關(guān)鍵詞;當(dāng)然基于爬蟲(chóng)的知識(shí)可能存在偏差,此時(shí)需要能夠有人工審核或人工更正的部分;
- 搜索的關(guān)鍵詞在本網(wǎng)的商品很少,有兩種解決思路,一種是通過(guò)方式1的爬蟲(chóng)去提取關(guān)鍵詞,另外一種是通過(guò)返回商品的信息去聚合出關(guān)鍵詞,如品牌、品類(lèi)、風(fēng)格、標(biāo)簽等,這里我們采用的是后者(在測(cè)試后發(fā)現(xiàn)后者效果更佳);
- 搜索的關(guān)鍵詞拼寫(xiě)有問(wèn)題,這就需要拼寫(xiě)糾錯(cuò)出場(chǎng)了,先糾錯(cuò)然后根據(jù)糾錯(cuò)后的詞去提供搜索推薦;
- 搜索的關(guān)鍵詞過(guò)多,有兩種解決思路,一種是識(shí)別關(guān)鍵詞的類(lèi)型,如是品牌、品類(lèi)、風(fēng)格還是性別,然后通過(guò)一定的組合策略來(lái)實(shí)現(xiàn)搜索推薦;另外一種則是根據(jù)用戶的輸入到搜索建議詞里去匹配,設(shè)置最小匹配為一個(gè)匹配到一個(gè)Term即可,這種方式實(shí)現(xiàn)比較簡(jiǎn)單而且效果也不錯(cuò),所以我們采用的是后者。
所以,我們?cè)趯?shí)現(xiàn)搜索推薦的核心是之前講到的搜索建議詞,它提供了本網(wǎng)主要的關(guān)鍵詞,另外一個(gè)很重要的是它本身包含了關(guān)聯(lián)商品數(shù)的屬性,這樣就可以保證推薦給用戶的關(guān)鍵詞是可以搜索出結(jié)果的。
實(shí)現(xiàn)細(xì)節(jié)
整體設(shè)計(jì)
整體設(shè)計(jì)框架如下圖所示:

搜索建議詞索引
在基于Elasticsearch實(shí)現(xiàn)搜索建議一文已有說(shuō)明,請(qǐng)移步閱讀。此次增加了一個(gè)keyword.keyword_lowercase的字段用于拼寫(xiě)糾錯(cuò),這里列取相關(guān)字段的索引:
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"
}
}
}
}
}
商品數(shù)據(jù)索引
這里只列取相關(guān)字段的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"
}
}
}
}
}
關(guān)鍵詞映射索引
主要就是source和dest直接的映射關(guān)系。
PUT /conversion_index
{
"mappings": {
"conversion": {
"properties": {
"source": {
"type": "string",
"analyzer": "lowercase_keyword"
},
"dest": {
"type": "string",
"index": "not_analyzed"
}
}
}
}
}
爬蟲(chóng)數(shù)據(jù)爬取
在實(shí)現(xiàn)的時(shí)候,我們主要是爬取了百度百科上面的詞條,在實(shí)際的實(shí)現(xiàn)中又分為了全量爬蟲(chóng)和增加爬蟲(chóng)。
全量爬蟲(chóng)
全量爬蟲(chóng)我這邊是從網(wǎng)上下載了一份他人匯總的詞條URL資源,里面根據(jù)一級(jí)分類(lèi)包含多個(gè)目錄,每個(gè)目錄又根據(jù)二級(jí)分類(lèi)包含多個(gè)詞條,每一行的內(nèi)容的格式如下:
李寧!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
這樣在啟動(dòng)的時(shí)候我們就可以使用多線程甚至分布式的方式爬蟲(chóng)自己感興趣的詞條內(nèi)容作為初始化數(shù)據(jù)保持到爬蟲(chóng)數(shù)據(jù)表。為了保證冪等性,如果再次全量爬取時(shí)就需要排除掉數(shù)據(jù)庫(kù)里已有的詞條。
增量爬蟲(chóng)
- 在商品搜索接口中,如果搜索某個(gè)關(guān)鍵詞關(guān)聯(lián)的商品數(shù)為0或小于一定的閾值(如20條),就通過(guò)Redis的ZSet進(jìn)行按天統(tǒng)計(jì);
- 統(tǒng)計(jì)的時(shí)候是區(qū)分搜索無(wú)結(jié)果和結(jié)果過(guò)少兩個(gè)Key的,因?yàn)閮煞N情況實(shí)際上是有所區(qū)別的,而且后續(xù)在搜索推薦查詢時(shí)也有用到這個(gè)統(tǒng)計(jì)結(jié)果;
- 增量爬蟲(chóng)是每天凌晨運(yùn)行,根據(jù)前一天統(tǒng)計(jì)的關(guān)鍵詞進(jìn)行爬取,爬取前需要排除掉已經(jīng)爬過(guò)的關(guān)鍵詞和黑名單中的關(guān)鍵詞;
- 所謂黑名單的數(shù)據(jù)包含兩種:一種是每天增量爬蟲(chóng)失敗的關(guān)鍵字(一般會(huì)重試幾次,確保失敗后加入黑名單),一種是人工維護(hù)的確定不需要爬蟲(chóng)的關(guān)鍵詞;
爬蟲(chóng)數(shù)據(jù)關(guān)鍵詞提取
- 首先需要明確關(guān)鍵詞的范圍,這里我們采用的是suggest中類(lèi)型為品牌、品類(lèi)、風(fēng)格、款式的詞作為關(guān)鍵詞;
- 關(guān)鍵詞提取的核心步驟就是對(duì)爬蟲(chóng)內(nèi)容和關(guān)鍵詞分別分詞,然后進(jìn)行分詞匹配,看該爬蟲(chóng)數(shù)據(jù)是否包含關(guān)鍵詞的所有Term(如果就是一個(gè)Term就直接判斷包含就好了);在處理的時(shí)候還可以對(duì)匹配到關(guān)鍵詞的次數(shù)進(jìn)行排序,最終的結(jié)果就是一個(gè)key-value的映射,如{迪奧 -> [香水,香氛,時(shí)裝,眼鏡], 紀(jì)梵希 -> [香水,時(shí)裝,彩妝,配飾,禮服]};
管理關(guān)鍵詞映射
- 由于爬蟲(chóng)數(shù)據(jù)提取的關(guān)鍵詞是和詞條的內(nèi)容相關(guān)聯(lián)的,因此很有可能提取的關(guān)鍵詞效果不大好,因此就需要人工管理;
- 管理動(dòng)作主要是包括添加、修改和置失效關(guān)鍵詞映射,然后增量地更新到conversion_index索引中;
搜索推薦服務(wù)的實(shí)現(xiàn)
- 首先如果對(duì)搜索推薦的入口進(jìn)行判斷,一些非法的情況不進(jìn)行推薦(比如關(guān)鍵詞太短或太長(zhǎng)),另外由于搜索推薦并非核心功能,可以增加一個(gè)全局動(dòng)態(tài)參數(shù)來(lái)控制是否進(jìn)行搜索推薦;
- 在設(shè)計(jì)思路里面我們分析過(guò)可能有4中場(chǎng)景需要搜索推薦,如何高效、快速地找到具體的場(chǎng)景從而減少不必要的查詢判斷是推薦服務(wù)實(shí)現(xiàn)的關(guān)鍵;這個(gè)在設(shè)計(jì)的時(shí)候就需要綜合權(quán)衡,我們通過(guò)一段時(shí)間的觀察后,目前采用的邏輯的偽代碼如下:
public JSONObject recommend(SearchResult searchResult, String queryWord) {
try {
String keywordsToSearch = queryWord;
// 搜索推薦分兩部分
// 1) 第一部分是最常見(jiàn)的情況,包括有結(jié)果、根據(jù)SKN搜索、關(guān)鍵詞未出現(xiàn)在空結(jié)果Redis ZSet里
if (containsProductInSearchResult(searchResult)) {
// 1.1) 搜索有結(jié)果的 優(yōu)先從搜索結(jié)果聚合出品牌等關(guān)鍵詞進(jìn)行查詢
String aggKeywords = aggKeywordsByProductList(searchResult);
keywordsToSearch = queryWord + " " + aggKeywords;
} else if (isQuerySkn(queryWord)) {
// 1.2) 如果是查詢SKN 沒(méi)有查詢到的 后續(xù)的邏輯也無(wú)法推薦 所以直接到ES里去獲取關(guān)鍵詞
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) 如果該關(guān)鍵詞一次都沒(méi)有出現(xiàn)在空結(jié)果列表或者長(zhǎng)度大于5 則該詞很有可能是可以搜索出結(jié)果的
// 因此優(yōu)先取suggest_index去搜索一把 減少后面的查詢動(dòng)作
JSONObject recommendResult = recommendBySuggestIndex(queryWord, keywordsToSearch, false);
if (isNotEmptyResult(recommendResult)) {
return recommendResult;
}
}
// 2) 第二部分是通過(guò)Conversion和拼寫(xiě)糾錯(cuò)去獲取關(guān)鍵詞 由于很多品牌的拼寫(xiě)可能比較相近 因此先走Conversion然后再拼寫(xiě)檢查
String spellingCorrentWord = null, dest = null;
if (allowGetingDest(queryWord) && StringUtils.isNotEmpty((dest = getSuggestConversionDestBySource(queryWord)))) {
// 2.1) 爬蟲(chóng)和自定義的Conversion處理
keywordsToSearch = dest;
} else if (allowSpellingCorrent(queryWord)
&& StringUtils.isNotEmpty((spellingCorrentWord = suggestService.getSpellingCorrectKeyword(queryWord)))) {
// 2.2) 執(zhí)行拼寫(xiě)檢查 由于在搜索建議的時(shí)候會(huì)進(jìn)行拼寫(xiě)檢查 所以緩存命中率高
keywordsToSearch = spellingCorrentWord;
} else {
// 2.3) 如果兩者都沒(méi)有 則直接返回
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();
}
}
其中涉及到的幾個(gè)函數(shù)簡(jiǎn)單說(shuō)明下:
- aggKeywordsByProductList方法用商品列表的結(jié)果,聚合出出現(xiàn)次數(shù)最多的幾個(gè)品牌和品類(lèi)(比如各2個(gè)),這樣我們就可以得到4個(gè)關(guān)鍵詞,和原先用戶的輸入拼接后調(diào)用recommendBySuggestIndex獲取推薦詞;
- aggKeywordsBySkns方法是根據(jù)用戶輸入的SKN先到product_index索引獲取商品列表,然后再調(diào)用aggKeywordsByProductList去獲取品牌和品類(lèi)的關(guān)鍵詞列表;
- getSuggestConversionDestBySource方法是查詢conversion_index索引去獲取關(guān)鍵詞提取的結(jié)果,這里在調(diào)用recommendBySuggestIndex時(shí)有個(gè)參數(shù),該參數(shù)主要是用于處理是否限制只能是輸入的關(guān)鍵詞;
- getSpellingCorrectKeyword方法為拼寫(xiě)檢查,在調(diào)用suggest_index處理時(shí)有個(gè)地方需要注意一下,拼寫(xiě)檢查是基于編輯距離的,大小寫(xiě)不一致的情況會(huì)導(dǎo)致Elasticsearch Suggester無(wú)法得到正確的拼寫(xiě)建議,因此在處理時(shí)需要兩邊都轉(zhuǎn)換為小寫(xiě)后進(jìn)行拼寫(xiě)檢查;
- 最終都需要調(diào)用recommendBySuggestIndex方法獲取搜索推薦,因?yàn)橥ㄟ^(guò)suggest_index索引可以確保推薦出去的詞是有意義的且關(guān)聯(lián)到商品的。該方法核心邏輯的偽代碼如下:
private JSONObject recommendBySuggestIndex(String srcQueryWord, String keywordsToSearch, boolean isLimitKeywords) {
// 1) 先對(duì)keywordsToSearch進(jìn)行分詞
List<String> 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) 根據(jù)terms搜索構(gòu)造搜索請(qǐng)求
SearchParam searchParam = new SearchParam();
searchParam.setPage(1);
searchParam.setSize(3);
// 2.1) 構(gòu)建FunctionScoreQueryBuilder
QueryBuilder queryBuilder = isLimitKeywords ? buildQueryBuilderByLimit(terms)
: buildQueryBuilder(keywordsToSearch, terms);
searchParam.setQuery(queryBuilder);
// 2.2) 設(shè)置過(guò)濾條件
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) 按照得分、權(quán)重、數(shù)量的規(guī)則降序排序
List<SortBuilder> 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) 調(diào)用ES執(zhí)行搜索
SearchResult searchResult = searchCommonService.doSearch(indexName, searchParam);
// 6) 構(gòu)建結(jié)果加入緩存
suggestResult = new JSONObject();
List<String> 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<String> terms) {
FunctionScoreQueryBuilder functionScoreQueryBuilder
= new FunctionScoreQueryBuilder(QueryBuilders.matchAllQuery());
// 給品類(lèi)類(lèi)型的關(guān)鍵詞加分
functionScoreQueryBuilder.add(QueryBuilders.termQuery("type", Integer.valueOf(2)),
ScoreFunctionBuilders.weightFactorFunction(3));
// 按詞出現(xiàn)的順序加分
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<String> termSet) {
// 1) 對(duì)于suggest的multi-fields至少要有一個(gè)字段匹配到 匹配得分為常量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) 對(duì)于完全匹配Term的加1分
functionScoreQueryBuilder.add(QueryBuilders.termQuery("keyword.keyword_lowercase", term.toLowerCase()),
ScoreFunctionBuilders.weightFactorFunction(1));
// 3) 對(duì)于匹配到一個(gè)Term的加2分
functionScoreQueryBuilder.add(QueryBuilders.termQuery("keyword.keyword_ik", term),
ScoreFunctionBuilders.weightFactorFunction(2));
}
functionScoreQueryBuilder.boostMode(CombineFunction.SUM);
return functionScoreQueryBuilder;
}
最后,從實(shí)際運(yùn)行的統(tǒng)計(jì)來(lái)看,有90%以上的查詢都能在1.3)的情況下返回推薦詞,而這一部分還沒(méi)有進(jìn)行拼寫(xiě)糾錯(cuò)和conversion_index索引的查詢,因此還是比較高效的;剩下的10%在最壞的情況且緩存都沒(méi)有命中的情況下,最多還需要進(jìn)行三次ES的查詢,性能是比較差的,但是由于有緩存而且大部分的無(wú)結(jié)果的關(guān)鍵詞都比較集中,因此也在可接受的范圍,這一塊可以考慮再增加一個(gè)動(dòng)態(tài)參數(shù),在大促的時(shí)候進(jìn)行關(guān)閉處理。
小結(jié)與后續(xù)改進(jìn)
- 通過(guò)以上的設(shè)計(jì)和實(shí)現(xiàn),我們實(shí)現(xiàn)了一個(gè)效果不錯(cuò)的搜索推薦功能,線上使用效果如下:
//搜索【迪奧】,本站無(wú)該品牌商品
沒(méi)有找到 "迪奧" 相關(guān)的商品, 為您推薦 "香水" 的搜索結(jié)果?;蛘咴囋?"香氛" "眼鏡"
//搜索【puma 運(yùn)動(dòng)鞋 上衣】,關(guān)鍵詞太多無(wú)法匹配
沒(méi)有找到 "puma 運(yùn)動(dòng)鞋 上衣" 相關(guān)的商品, 為您推薦 "PUMA 運(yùn)動(dòng)鞋" 的搜索結(jié)果?;蛘咴囋?"PUMA 運(yùn)動(dòng)鞋 女" "PUMA 運(yùn)動(dòng)鞋 男"
//搜索【puma 上衣】,結(jié)果太少
"puma 上衣" 搜索結(jié)果太少了,試試 "上衣" "PUMA" "PUMA 休閑" 關(guān)鍵詞搜索
//搜索【51489312】特定的SKN,結(jié)果太少
"51489312" 搜索結(jié)果太少了,試試 "夾克" "PUMA" "戶外" 關(guān)鍵詞搜索
//搜索【blackjauk】,拼寫(xiě)錯(cuò)誤
沒(méi)有找到 "blackjauk" 相關(guān)的商品, 為您推薦 "BLACKJACK" 的搜索結(jié)果?;蛘咴囋?"BLACKJACK T恤" "BLACKJACK 休閑褲"
- 后續(xù)考慮的改進(jìn)包括:1.繼續(xù)統(tǒng)計(jì)各種無(wú)結(jié)果或結(jié)果太少場(chǎng)景出現(xiàn)的頻率和對(duì)應(yīng)推薦詞的實(shí)現(xiàn),優(yōu)化搜索推薦服務(wù)的效率;2.爬取更多的語(yǔ)料資源,提升conversion的能力;3.考慮增加個(gè)性化的功能,給用戶推薦Ta最感興趣的內(nèi)容。
