問題背景
在《搜索引擎關鍵字智能提示的一種實現》一文中介紹過,美團的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請求處理器的接口,只有兩個方法,一個是初始化信息,主要是配置時的默認參數,另一個就是處理請求的接口。
具體處理邏輯主要由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的類圖結構:
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請求的詳細過程。
參考資料
SimpleFacetParameters http://wiki.apache.org/solr/SimpleFacetParameters
使用Apache Lucene和Solr 4實現下一代搜索和分析 http://www.ibm.com/developerworks/cn/java/j-solr-lucene/
Faceted Search with Solr http://searchhub.org/2009/09/02/faceted-search-with-solr/
轉載地址:http://tech.meituan.com/solr-facet.html