1. 程式人生 > >Solr Facet技術的應用與研究

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:如果為ontrue,那麼將統計那些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請求的詳細過程。

參考資料