Solr Facet技術的應用與研究

問題背景

《搜索引擎關鍵字智能提示的一種實現》一文中介紹過,美團的CRM系統負責管理銷售人員的門店(POI)和項目(DEAL)信息,提供統一的檢索功能,其索引層採用的是SolrCloud。在用戶搜索時,如果能直觀地給出每個品類的POI數目,各個狀態的DEAL數目,可以更好地引導用戶進行搜索,進而提升搜索體驗。

需求分析

例如,下圖是用戶搜索項目(DEAL)的界面,當選中一個人或者組織節點後,需要實時顯示狀態分組和快捷分組的每個項的DEAL數目:
項目搜索界面

爲了實現上述導航效果,可以採用以下兩個方案:

方案一, 針對每個導航項發送一個Ajax請求,去Solr服務器查詢對應的DEAL數目。該方案問題在於,當導航項比較多時,擴展性不好。

方案二, 應用Solr自帶的Facet技術實現以導航爲目的的搜索,查詢結果根據分類添加count信息。

DEAL的Solr索引設計如下:

schema.xml:
<field name="deal_id" type="int" indexed="true" stored="true" />       //deal id
<field name="title" type="text_ika" indexed="true" stored="false" />   //標題      
<field name="bd_id" type="int" indexed="true" stored="false" />        //負責人id
<field name="begin_time" type="long" indexed="true" stored="false" />  //項目開始時間
<field name="end_time" type="long" indexed="true" stored="false" />    //項目結束時間
<field name="status" type="int" indexed="true" stored="false" />       //項目狀態
<field name="can_buy" type="boolean" indexed="true" stored="false" />  //是否可以購買
...省略     
本文的例子中用於facet的字段有status,can_buy,begin_time,end_time

注:
Facet的字段必須被索引,無需分詞,無需存儲。無需分詞是因爲該字段的值代表了一個整體概念,無需存儲是因爲一般而言用戶所關心的並不是該字段的具體值,而是作爲對查詢結果進行分組的一種手段,用戶一般會沿着這個分組進一步深入搜索。

Solr Facet簡介

Facet是Solr的高級搜索功能之一,Solr作者給出的定義是導航(Guided Navigation)、參數化查詢(Paramatic Search)。Facet的主要好處是在搜索的同時,可以按照Facet條件進行分組統計,給出導航信息,改善搜索體驗。Facet搜索主要分爲以下幾類:

1. Field Facet
搜索結果按照Facet的字段分組並統計,Facet字段通過在請求中加入”facet.field”參數加以聲明,如果需要對多個字段進行Facet查詢,那麼將該參數聲明多次,Facet字段必須被索引。例如,以下表達式是以DEAL的status和can_buy屬性爲facet.field進行查詢:

select?q=*:*&facet=true&facet.field=status&facet.field=can_buy&wt=json

Facet查詢需要在請求參數中加入”facet=on”或者”facet=true”讓Facet組件起作用,返回結果:

"facet_counts”: { 
     "facet_queries": {}, 
     "facet_fields":  { "status": [ "32", 96, 
                                     "0", 40, 
                                     "8", 81, 
                                    "16", 50, 
                                   "127", 80, 
                                    "64", 27 ] ,

                       "can_buy": [ "true", 236, 
                                    "false", 21 ]
                      }, 
     "facet_dates": {}, 
     "facet_ranges": {} 
 }

分組count信息包含在“facet_fields”中,分別按照"status"和“can_buy”的值分組,比如狀態爲32的DEAL數目有96個,能購買的DEAL數目(can_buy=true)是236。

Field Facet主要參數:

 facet.field:Facet的字段
 facet.prefix:Facet字段前綴
 facet.limit:Facet字段返回條數
 facet.offset:開始條數,偏移量,它與facet.limit配合使用可以達到分頁的效果
 facet.mincount:Facet字段最小count,默認爲0
 facet.missing:如果爲on或true,那麼將統計那些Facet字段值爲null的記錄
 facet.method:取值爲enum或fc,默認爲fc,fc表示Field Cache
 facet.enum.cache.minDf:當facet.method=enum時,參數起作用,文檔內出現某個關鍵字的最少次數

2. Date Facet
日期類型的字段在索引中很常見,如DEAL上線時間,線下時間等,某些情況下需要針對這些字段進行Facet。時間字段的取值有無限性,用戶往往關心的不是某個時間點而是某個時間段內的查詢統計結果,Solr爲日期字段提供了更爲方便的查詢統計方式。字段的類型必須是DateField(或其子類型)。需要注意的是,使用Date Facet時,字段名、起始時間、結束時間、時間間隔這4個參數都必須提供。
與Field Facet類似,Date Facet也可以對多個字段進行Facet。並且針對每個字段都可以單獨設置參數。

3. Facet Query
Facet Query利用類似於filter query的語法提供了更爲靈活的Facet。通過facet.query參數,可以對任意字段進行篩選。

基於Solr facet的實現

本文的例子,需要查詢DEAL的“狀態”和“快捷選項”導航信息。由於,有的狀態DEAL數目不僅與狀態(status)字段有關,還與開始時間(begin_time)和(end_time)相關,且各個快捷選項的DEAL數目的計算字段各不相同,要求比較靈活的查詢,所以本文擬採用Facet Query方式實現。
以下代碼是採用solrJ構造facet查詢對象的過程:

public SolrQuery buildFacetQuery(Date now) {
    SolrQuery solrQuery = new SolrQuery();

    solrQuery.setFacet(true);//設置facet=on
    solrQuery.setFacetLimit(10);//限制facet返回的數量
    solrQuery.setQuery("*:*");

    long nowTime = now.getTime() / 1000;
    long minTime = minTimeStamp;
    long maxTime = maxTimeStamp;

    solrQuery.addFacetQuery("status:0");  //待撰寫
    solrQuery.addFacetQuery("status:8");  //撰寫中
    solrQuery.addFacetQuery("status:16"); //已終審
    solrQuery.addFacetQuery("status:32 AND " + "begin_time:[" + nowTime + " TO " + maxTime + " ]");      //已上架-待上線
    solrQuery.addFacetQuery("status:32 AND " + "begin_time:[" + minTime + " TO " + nowTime + "] AND " +  //已上架-上線中
            "end_time:[" + nowTime + " TO " + maxTime + " ]");
    solrQuery.addFacetQuery("status:32 AND " + "end_time:[" +  minTime + " TO " + nowTime + "]");  //已上架-已下線

    return solrQuery;
}

說明:
"status:0" 查詢滿足條件的結果集中status=0的Deal數目,
"status:32 AND " + "begin_time:[" + nowTime + " TO " + maxTime + " ]”,查詢滿足條件的結果集中,status=32且begin_time大於現在時間的Deal數目,
依次類推

返回結果:

"status:0":756, 
"status:8":28,  
"status:16":21,  
"status:32 AND begin_time:[1401869128 TO 1956499199 ]":4,  
"status:32 AND begin_time:[0 TO 1401869128] AND end_time:[1401869128 TO 1956499199 ]":41,   
"status:32 AND end_time:[0 TO 1401869128]":10}

上述結果可知,“已上架-待上線”導航項對應的DEAL數爲4個。

Solr Facet查詢分析

1. Solr HTTP請求分發

當一個Restful(HTTP)查詢請求到達SolrCloud服務器,首先由SolrDispatchFilter(實現javax.servlet.Filter)處理,該類負責分發請求到相應的SolrRequestHandler。具體分發操作在SolrDispatchFilter的doFilter方法中進行:

public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain, boolean retry) {
             ......                 
        handler = core.getRequestHandler( path );
        if( handler == null && parser.isHandleSelect() ) {
          if( "/select".equals( path ) || "/select/".equals( path ) ) {

            solrReq = parser.parse( core, path, req );
            String qt = solrReq.getParams().get( CommonParams.QT );
            handler = core.getRequestHandler( qt );                 //分發到相應的handler
             .......

     if( handler != null ) {
              ......                
            this.execute( req, handler, solrReq, solrRsp );       //處理請求
            HttpCacheHeaderUtil.checkHttpCachingVeto(solrRsp, resp, reqMethod);                
              ......              
        return; 
      }
    }
}

protected void execute( HttpServletRequest req, SolrRequestHandler handler, SolrQueryRequest sreq, SolrQueryResponse rsp) {
   sreq.getContext().put( "webapp", req.getContextPath() );
   sreq.getCore().execute( handler, sreq, rsp );
}

接着,調用solrCore的execute方法:

public void execute(SolrRequestHandler handler, SolrQueryRequest req, SolrQueryResponse rsp) {
     ......    
handler.handleRequest(req,rsp);   // handler處理請求
postDecorateResponse(handler, req, rsp);
     ......
}

從上述代碼邏輯可以看出,請求的實際處理是由SolrRequestHandler來完成的。

2. SolrRequestHandler處理過程

SolrRequestHandler的類繼承結構,如下圖所示:
SolrRequestHandler的類集成結構

SolrRequestHandler請求處理器的接口,只有兩個方法,一個是初始化信息,主要是配置時的默認參數,另一個就是處理請求的接口。
具體處理邏輯主要由SearchHandler類實現。

public interface SolrRequestHandler extends SolrInfoMBean {
   public void init(NamedList args);   //初始化信息
   public void handleRequest(SolrQueryRequest req, SolrQueryResponse rsp);  //處理請求
}

SearchHandler實現SolrRequestHandler,SolrCoreAware,在SolrCore初始化的過程中調用SolrRequestHandler中的inform(SolrCore core),首先是將solrconfig.xml裏配置的各個處理組件按一定順序組裝起來,先是first-Component,默認的component,last-component,這些處理組件會按照它們的順序來執行。如果沒有配置,則加載默認組件,方法如下:

protected List<String> getDefaultComponents()
{
   ArrayList<String> names = new ArrayList<String>(6);
   names.add( QueryComponent.COMPONENT_NAME );
   names.add( FacetComponent.COMPONENT_NAME );
   names.add( MoreLikeThisComponent.COMPONENT_NAME );
   names.add( HighlightComponent.COMPONENT_NAME );
   names.add( StatsComponent.COMPONENT_NAME );
   names.add( DebugComponent.COMPONENT_NAME );
   names.add( AnalyticsComponent.COMPONENT_NAME );
   return names;
}

SearchHandler中的component對象包含有QueryComponent、FacetComponent、HighlightComponent等,其中QueryComponent主要負責查詢部分,FacetComponent處理facet、HighlightComponent負責高亮顯示。SearchHandler在請求處理過程中,由SearchHandler.handleRequestBody(SolrQueryRequest req, SolrQueryResponse rsp)方法依次調用component的prepare、process、distributedProcess方法(分佈式搜索本文暫不討論) 。QueryComponent調用SolrIndexSearcher,SolrIndexSearcher繼承了lucene的IndexSearcher類進行搜索,FacetComponent實現對Term的層面的統計,下圖是SearchComponent的類圖結構:
SearchComponent的類圖結構

3. FacetComponent Facet查詢分析

由上述分析可知,Solr的Facet功能實際上是由FacetComponent組件來實現的,具體實現在FacetComponent.process方法中:

public void process(ResponseBuilder rb) throws IOException
{
   if (rb.doFacets) {
       SolrParams params = rb.req.getParams();
       SimpleFacets f = new SimpleFacets(rb.req, rb.getResults().docSet,params, rb );   //最終facet查詢委託給SimpleFacets類進行處理 
       NamedList<Object> counts = f.getFacetCounts();   
     ......  
  }
}

首先QueryComponent處理q參數裏的查詢,查詢的結果的DocID保存在docSet裏,這裏是一個無序的document ID 的集合。然後把docSet封裝在SimpleFacets中,調用SimpleFacets.getFacetCounts()獲取統計結果:

public NamedList<Object> getFacetCounts() {
   ......
   facetResponse = new SimpleOrderedMap<Object>();
   facetResponse.add("facet_queries", getFacetQueryCounts());
   facetResponse.add("facet_fields", getFacetFieldCounts());
   facetResponse.add("facet_dates", getFacetDateCounts());
   facetResponse.add("facet_ranges", getFacetRangeCounts());     
   ......

   return facetResponse;
}

由上可知,返回給客戶端的結果有四種類型facet_queries、facet_fields、facet_dates、facet_ranges,分別調用getFacetQueryCounts(),getFacetFieldCounts(),getFacetDateCounts(),getFacetRangeCounts()完成查詢。

4. getFacetQueryCounts統計count過程

由於篇幅原因,上述四個方法不一一展開分析,本文用到的查詢主要是Facet Query,下面分析一下getFacetQueryCounts方法源碼:

public NamedList<Integer> getFacetQueryCounts() throws IOException,SyntaxError {
   NamedList<Integer> res = new SimpleOrderedMap<Integer>();

   String[] facetQs = params.getParams(FacetParams.FACET_QUERY);

   if (null != facetQs && 0 != facetQs.length) {
     for (String q : facetQs) {                    // 循環統計每個facet query的count
       parseParams(FacetParams.FACET_QUERY, q);

       Query qobj = QParser.getParser(q, null, req).getQuery();

       if (qobj == null) {
         res.add(key, 0);
       } else if (params.getBool(GroupParams.GROUP_FACET, false)) {
         res.add(key, getGroupedFacetQueryCount(qobj));
       } else {
         res.add(key, searcher.numDocs(qobj, docs));   //
       }
    }
   }

   return res;
}

該方法的返回類型NamedList是一個有序的name/value容器,保存每個facet query和對應的count值。由代碼可知,在for循環體中逐個統計facet query的count值,其中,parseParams方法中把”key”設置成本次循環的facet query變量“q“,由於GroupParams.GROUP_FACET的值是false(group類似與mysql的group by功能,一般不會打開),所以count值實際是由searcher.numDocs(qobj, docs)方法負責計算,這裏的searcher類型是SolrIndexSearcher。

SolrIndexSearcher的numDocs方法源碼如下:

public int numDocs(Query a, DocSet b) throws IOException {
 if (filterCache != null) {   
   Query absQ = QueryUtils.getAbs(a);              //如果爲negative,則返回相應的補集
   DocSet positiveA = getPositiveDocSet(absQ);     //查詢absQ 獲取docSet集合
   return a==absQ ? b.intersectionSize(positiveA) : b.andNotSize(positiveA);

 } else {
   TotalHitCountCollector collector = new TotalHitCountCollector();
   BooleanQuery bq = new BooleanQuery();
   bq.add(QueryUtils.makeQueryable(a), BooleanClause.Occur.MUST);
   bq.add(new ConstantScoreQuery(b.getTopFilter()), BooleanClause.Occur.MUST);
   super.search(bq, null, collector);

   return collector.getTotalHits();
}

}

參數a傳入facet query對象,參數b傳入經過QueryComponent組件處理後得到DocSet集合。DocSet存儲的是無序的文檔標識號(ID),ID並不是我們在schema.xml裏配置的unique key,而是Solr內部的一個文檔標識,其次,DocSet還封裝了集合運算的方法,如“求交集”、”求差集”。

由於,我們在solrconfig.xml中配置了filterCache:

<filterCache class="solr.FastLRUCache" 
             size="512" 
             initialSize="512" 
             autowarmCount="0”/>

於是,numDocs方法中filterCache對象不爲null,運行到下面三行代碼:

Query absQ = QueryUtils.getAbs(a);              //如果爲negative,則返回相應的補集
DocSet positiveA = getPositiveDocSet(absQ);     //查詢absQ 獲取docSet集合
return a==absQ ? b.intersectionSize(positiveA) : b.andNotSize(positiveA);  //集合運算

首先,通過QueryUtils.getAbs(a)將查詢對象a統一轉化爲一個“正向查詢對象”absQ,getPositiveDocSet(absQ)方法查詢absQ對應的DocSet集合:getPositiveDocSet方法首先查詢filterCache中是否存在absQ查詢對象對應的結果,存在,則直接返回結果,否則,從索引中查詢並把結果保存到filterCache中。

接下來進行集合運算,如果Query對象a和absQ是同一個對象,表明本次查詢是“正向查詢”,則進行”交集“運算b.intersectionSize(positiveA),否則進行”差集“運算,最終返回結果集的size。由此可見,facet query對應的count值是集合交集和差集運算後的集合的size。

BTW,如果沒有用到filterCache,會每次都構造一個BooleanQuery查詢對象到索引中去查詢。

5. FacetComponent Facet排序
Solr的FacetComponet支持兩種排序: count和index。count是按每個詞出現的次數,index是按詞的字典順序。如果查詢參數不指定facet.sort,Solr默認是按count排序。排序功能是在FacetComponet的finishStage方法中完成的,詳見源碼。

總結

本文介紹了Solr Facet技術,並在此基礎上實現了DEAL搜索的導航功能,然後從源碼級別分析了Solr處理Facet請求的詳細過程。

參考資料

轉載地址:http://tech.meituan.com/solr-facet.html

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章