在基於Elasticsearch實現搜索建議一文中我們曾經介紹過如何基於Elasticsearch來實現搜索建議,而本文是在此基礎上進一步優化搜索體驗,在當搜索無結果或結果過少時提供推薦搜索詞給用戶。
背景介紹
在根據用戶輸入和篩選條件進行搜索后,有時返回的是無結果或者結果很少的情況,為了提升用戶搜索體驗,需要能夠給用戶推薦一些相關的搜索詞,比如用戶搜索【迪奧】時沒有找到相關的商品,可以推薦搜索【香水】、【眼鏡】等關鍵詞。
設計思路
首先需要分析搜索無結果或者結果過少可能的原因,我總結了一下,主要包括主要可能:
- 搜索的關鍵詞在本網不存在,比如【迪奧】;
- 搜索的關鍵詞在本網的商品很少,比如【科比】;
- 搜索的關鍵詞拼寫有問題,比如把【阿迪達斯】寫成了【阿迪大斯】;
- 搜索的關鍵詞過多,由於我們采用的是cross_fields,在一個商品內不可能包含所有的Term,導致無結果,比如【阿迪達斯 耐克 衛衣 運動鞋】;
那么針對以上情況,可以采用以下方式進行處理:
- 搜索的關鍵詞在本網不存在,可以通過爬蟲的方式獲取相關知識,然后根據搜索建議詞去提取,比如去百度百科的迪奧詞條里就能提取出【香水】、【香氛】和【眼鏡】等關鍵詞;當然基於爬蟲的知識可能存在偏差,此時需要能夠有人工審核或人工更正的部分;
- 搜索的關鍵詞在本網的商品很少,有兩種解決思路,一種是通過方式1的爬蟲去提取關鍵詞,另外一種是通過返回商品的信息去聚合出關鍵詞,如品牌、品類、風格、標簽等,這里我們采用的是后者(在測試后發現后者效果更佳);
- 搜索的關鍵詞拼寫有問題,這就需要拼寫糾錯出場了,先糾錯然后根據糾錯后的詞去提供搜索推薦;
- 搜索的關鍵詞過多,有兩種解決思路,一種是識別關鍵詞的類型,如是品牌、品類、風格還是性別,然后通過一定的組合策略來實現搜索推薦;另外一種則是根據用戶的輸入到搜索建議詞里去匹配,設置最小匹配為一個匹配到一個Term即可,這種方式實現比較簡單而且效果也不錯,所以我們采用的是后者。
所以,我們在實現搜索推薦的核心是之前講到的搜索建議詞,它提供了本網主要的關鍵詞,另外一個很重要的是它本身包含了關聯商品數的屬性,這樣就可以保證推薦給用戶的關鍵詞是可以搜索出結果的。
實現細節
整體設計
整體設計框架如下圖所示:
搜索建議詞索引
在基於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
這樣在啟動的時候我們就可以使用多線程甚至分布式的方式爬蟲自己感興趣的詞條內容作為初始化數據保持到爬蟲數據表。為了保證冪等性,如果再次全量爬取時就需要排除掉數據庫里已有的詞條。
增量爬蟲
- 在商品搜索接口中,如果搜索某個關鍵詞關聯的商品數為0或小於一定的閾值(如20條),就通過Redis的ZSet進行按天統計;
- 統計的時候是區分搜索無結果和結果過少兩個Key的,因為兩種情況實際上是有所區別的,而且后續在搜索推薦查詢時也有用到這個統計結果;
- 增量爬蟲是每天凌晨運行,根據前一天統計的關鍵詞進行爬取,爬取前需要排除掉已經爬過的關鍵詞和黑名單中的關鍵詞;
- 所謂黑名單的數據包含兩種:一種是每天增量爬蟲失敗的關鍵字(一般會重試幾次,確保失敗后加入黑名單),一種是人工維護的確定不需要爬蟲的關鍵詞;
爬蟲數據關鍵詞提取
- 首先需要明確關鍵詞的范圍,這里我們采用的是suggest中類型為品牌、品類、風格、款式的詞作為關鍵詞;
- 關鍵詞提取的核心步驟就是對爬蟲內容和關鍵詞分別分詞,然后進行分詞匹配,看該爬蟲數據是否包含關鍵詞的所有Term(如果就是一個Term就直接判斷包含就好了);在處理的時候還可以對匹配到關鍵詞的次數進行排序,最終的結果就是一個key-value的映射,如{迪奧 -> [香水,香氛,時裝,眼鏡], 紀梵希 -> [香水,時裝,彩妝,配飾,禮服]};
管理關鍵詞映射
- 由於爬蟲數據提取的關鍵詞是和詞條的內容相關聯的,因此很有可能提取的關鍵詞效果不大好,因此就需要人工管理;
- 管理動作主要是包括添加、修改和置失效關鍵詞映射,然后增量地更新到conversion_index索引中;
搜索推薦服務的實現
- 首先如果對搜索推薦的入口進行判斷,一些非法的情況不進行推薦(比如關鍵詞太短或太長),另外由於搜索推薦並非核心功能,可以增加一個全局動態參數來控制是否進行搜索推薦;
- 在設計思路里面我們分析過可能有4中場景需要搜索推薦,如何高效、快速地找到具體的場景從而減少不必要的查詢判斷是推薦服務實現的關鍵;這個在設計的時候就需要綜合權衡,我們通過一段時間的觀察后,目前采用的邏輯的偽代碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
|
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();
}
}
|
其中涉及到的幾個函數簡單說明下:
- aggKeywordsByProductList方法用商品列表的結果,聚合出出現次數最多的幾個品牌和品類(比如各2個),這樣我們就可以得到4個關鍵詞,和原先用戶的輸入拼接后調用recommendBySuggestIndex獲取推薦詞;
- aggKeywordsBySkns方法是根據用戶輸入的SKN先到product_index索引獲取商品列表,然后再調用aggKeywordsByProductList去獲取品牌和品類的關鍵詞列表;
- getSuggestConversionDestBySource方法是查詢conversion_index索引去獲取關鍵詞提取的結果,這里在調用recommendBySuggestIndex時有個參數,該參數主要是用於處理是否限制只能是輸入的關鍵詞;
- getSpellingCorrectKeyword方法為拼寫檢查,在調用suggest_index處理時有個地方需要注意一下,拼寫檢查是基於編輯距離的,大小寫不一致的情況會導致Elasticsearch Suggester無法得到正確的拼寫建議,因此在處理時需要兩邊都轉換為小寫后進行拼寫檢查;
- 最終都需要調用recommendBySuggestIndex方法獲取搜索推薦,因為通過suggest_index索引可以確保推薦出去的詞是有意義的且關聯到商品的。該方法核心邏輯的偽代碼如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
|
private JSONObject recommendBySuggestIndex(String srcQueryWord, String keywordsToSearch, boolean isLimitKeywords) {
// 1) 先對keywordsToSearch進行分詞
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) 根據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<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) 調用ES執行搜索
SearchResult searchResult = searchCommonService.doSearch(indexName, searchParam);
// 6) 構建結果加入緩存
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());
// 給品類類型的關鍵詞加分
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<String> 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 休閑褲"
- 后續考慮的改進包括:1.繼續統計各種無結果或結果太少場景出現的頻率和對應推薦詞的實現,優化搜索推薦服務的效率;2.爬取更多的語料資源,提升Conversion的准確性;3.考慮增加個性化的功能,給用戶推薦Ta最感興趣的內容。