天天看點

ElasticSearch學習29_基于Elasticsearch實作搜尋推薦 背景介紹 設計思路 實作細節 小結與後續改進

在基于Elasticsearch實作搜尋建議一文中我們曾經介紹過如何基于Elasticsearch來實作搜尋建議,而本文是在此基于上進一步優化搜尋體驗,在當搜尋無結果或結果過少時提供推薦搜尋詞給使用者。

背景介紹

在根據使用者輸入和篩選條件進行搜尋後,有時傳回的是無結果或者結果很少的情況,為了提升使用者搜尋體驗,需要能夠給使用者推薦一些相關的搜尋詞,比如使用者搜尋【迪奧】時沒有找到相關的商品,可以推薦搜尋【香水】、【眼鏡】等關鍵詞。

設計思路

首先需要分析搜尋無結果或者結果過少可能的原因,我總結了一下,主要包括主要可能:

  1. 搜尋的關鍵詞在本網不存在,比如【迪奧】;
  2. 搜尋的關鍵詞在本網的商品很少,比如【科比】;
  3. 搜尋的關鍵詞拼寫有問題,比如把【阿迪達斯】寫成了【阿迪大斯】;
  4. 搜尋的關鍵詞過多,由于我們采用的是cross_fields,在一個商品内不可能包含所有的Term,導緻無結果,比如【阿迪達斯 耐克 衛衣 運動鞋】;

那麼針對以上情況,可以采用以下方式進行處理:

  1. 搜尋的關鍵詞在本網不存在,可以通過爬蟲的方式擷取相關知識,然後根據搜尋建議詞去提取,比如去百度百科的迪奧詞條裡就能提取出【香水】、【香氛】和【眼鏡】等關鍵詞;當然基于爬蟲的知識可能存在偏差,此時需要能夠有人工稽核或人工更正的部分;
  2. 搜尋的關鍵詞在本網的商品很少,有兩種解決思路,一種是通過方式1的爬蟲去提取關鍵詞,另外一種是通過傳回商品的資訊去聚合出關鍵詞,如品牌、品類、風格、标簽等,這裡我們采用的是後者(在測試後發現後者效果更佳);
  3. 搜尋的關鍵詞拼寫有問題,這就需要拼寫糾錯出場了,先糾錯然後根據糾錯後的詞去提供搜尋推薦;
  4. 搜尋的關鍵詞過多,有兩種解決思路,一種是識别關鍵詞的類型,如是品牌、品類、風格還是性别,然後通過一定的組合政策來實作搜尋推薦;另外一種則是根據使用者的輸入到搜尋建議詞裡去比對,設定最小比對為一個比對到一個Term即可,這種方式實作比較簡單而且效果也不錯,是以我們采用的是後者。

是以,我們在實作搜尋推薦的核心是之前講到的搜尋建議詞,它提供了本網主要的關鍵詞,另外一個很重要的是它本身包含了關聯商品數的屬性,這樣就可以保證推薦給使用者的關鍵詞是可以搜尋出結果的。

實作細節

整體設計

整體設計架構如下圖所示:

ElasticSearch學習29_基于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/.html?fromTaglist
diesel!http://baike.baidu.com/view/.html?fromTaglist
ONLY!http://baike.baidu.com/view/.html?fromTaglist
lotto!http://baike.baidu.com/view/.html?fromTaglist
           

這樣在啟動的時候我們就可以使用多線程甚至分布式的方式爬蟲自己感興趣的詞條内容作為初始化資料保持到爬蟲資料表。為了保證幂等性,如果再次全量爬取時就需要排除掉資料庫裡已有的詞條。

增量爬蟲

  1. 在商品搜尋接口中,如果搜尋某個關鍵詞關聯的商品數為0或小于一定的門檻值(如20條),就通過Redis的ZSet進行按天統計;
  2. 統計的時候是區分搜尋無結果和結果過少兩個Key的,因為兩種情況實際上是有所差別的,而且後續在搜尋推薦查詢時也有用到這個統計結果;
  3. 增量爬蟲是每天淩晨運作,根據前一天統計的關鍵詞進行爬取,爬取前需要排除掉已經爬過的關鍵詞和黑名單中的關鍵詞;
  4. 所謂黑名單的資料包含兩種:一種是每天增量爬蟲失敗的關鍵字(一般會重試幾次,確定失敗後加入黑名單),一種是人工維護的确定不需要爬蟲的關鍵詞;

爬蟲資料關鍵詞提取

  1. 首先需要明确關鍵詞的範圍,這裡我們采用的是suggest中類型為品牌、品類、風格、款式的詞作為關鍵詞;
  2. 關鍵詞提取的核心步驟就是對爬蟲内容和關鍵詞分别分詞,然後進行分詞比對,看該爬蟲資料是否包含關鍵詞的所有Term(如果就是一個Term就直接判斷包含就好了);在處理的時候還可以對比對到關鍵詞的次數進行排序,最終的結果就是一個key-value的映射,如{迪奧 -> [香水,香氛,時裝,眼鏡], 紀梵希 -> [香水,時裝,彩妝,配飾,禮服]};

管理關鍵詞映射

  1. 由于爬蟲資料提取的關鍵詞是和詞條的内容相關聯的,是以很有可能提取的關鍵詞效果不大好,是以就需要人工管理;
  2. 管理動作主要是包括添加、修改和置失效關鍵詞映射,然後增量地更新到conversion_index索引中;

搜尋推薦服務的實作

  1. 首先如果對搜尋推薦的入口進行判斷,一些非法的情況不進行推薦(比如關鍵詞太短或太長),另外由于搜尋推薦并非核心功能,可以增加一個全局動态參數來控制是否進行搜尋推薦;
  2. 在設計思路裡面我們分析過可能有4中場景需要搜尋推薦,如何高效、快速地找到具體的場景進而減少不必要的查詢判斷是推薦服務實作的關鍵;這個在設計的時候就需要綜合權衡,我們通過一段時間的觀察後,目前采用的邏輯的僞代碼如下:
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() >= ) {
                // 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索引可以確定推薦出去的詞是有意義的且關聯到商品的。該方法核心邏輯的僞代碼如下:
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() > )
                          .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();
        searchParam.setSize();

        // 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());
        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<>();
        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()),
            ScoreFunctionBuilders.weightFactorFunction());

        // 按詞出現的順序加分
        for (int i = ; 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());

            // 3) 對于比對到一個Term的加2分
            functionScoreQueryBuilder.add(QueryBuilders.termQuery("keyword.keyword_ik", term),
                ScoreFunctionBuilders.weightFactorFunction());
        }

        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最感興趣的内容。

原文來自:http://www.jianshu.com/p/4ab3c69e7b19

原部落客連結:http://www.jianshu.com/u/0ffaa3601861

繼續閱讀