问题背景

在 《搜索引擎关键字智能提示的一种实现》 一文中介绍过,美团的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请求的详细过程。

参考资料

Solr Facet技术的应用与研究的更多相关文章

  1. Solr Facet 默认值

    前言 今天在用Solr Facet遇到了默认值的问题,我用Facet.field查询发现数据总共100条,刚开始没有注意,发现少个别数据,但是用这几个个别的id查询又能查出来数据.才发现是Facet默 ...

  2. [solr] - Facet

    Solr facet使用于分类统计,是最好的工具.下面例子使用facet将可模拟查询搜索出租房信息. 1.在schema.xml中的内容如下: <?xml version="1.0&q ...

  3. Solr -- Solr Facet 1

    一.Facet介绍 solr facet 是solr搜索的一大特色,facet不好翻译,有说是垂直搜索,有说是分片搜索,但都不是很好,还是懒得翻译了,就叫facet ,具体功能看下面的例子意会吧. 比 ...

  4. Solr搜索技术

    Solr搜索技术 今日大纲 回顾上一天的内容: 倒排索引 lucene和solr的关系 lucene api的使用 CRUD 文档.字段.目录对象(类).索引写入器类.索引写入器配置类.IK分词器 查 ...

  5. Solr Facet 统计查询

    一)概述 Facet是solr的高级搜索功能之一,可以给用户提供更友好的搜索体验.在搜索关键字的同时,能够按照Facet的字段进行分组并统计.例如下图所示,你上淘宝,输入“电脑”进行搜索,就会出现品牌 ...

  6. solr facet查询及solrj 读取facet数据[转]

    solr facet查询及solrj 读取facet数据 | 所属分类:solr facet solrj 一.   Facet 简介 Facet 是 solr 的高级搜索功能之一 , 可以给用户提供更 ...

  7. [solr] - Facet - autocomplete

    Solr的facet可以用于一般性的前缀式自动完成功能,当然,它的主要功能是用于做统计.分类.区间等. 统计.分类.区间功能,可参考下文: http://hongweiyi.com/2013/03/a ...

  8. Solr -- Solr Facet 2

    solr将以导航为目的的查询结果称为facet. 它并不会修改查询结果信息, 只是在查询结果上根据分类添加了count信息, 然后用户根据count信息做进一步的查询, 比如淘宝的查询列表中, 上面会 ...

  9. Solr Facet 搜索时,facet.missing = true 的真正含义

    Solr的WiKI原文是如下解释: facet.missing Set to "true" this param indicates that in addition to the ...

  10. solr facet查询及solrj 读取facet数据(相当有用)

    原文出自:http://www.coin163.com/java/docs/201310/d_3010029802.html 一.   Facet 简介 Facet 是 solr 的高级搜索功能之一  ...

随机推荐

  1. 部署在阿里云上的项目收到了阿里云发送的shiro漏洞

    编辑 ​ 还记得在十月份凯哥发布过一篇修改若依系统编辑器的文章,然后为了方便大家浏览,凯哥就部署在服务器上了,结果,没想到最近收到了阿里云漏洞扫描通知: 编辑 ​ 如果不修改的话:对于长期存在安全隐患 ...

  2. RxJS 系列 – Subject

    前言 RxJS 两大概念 Observable 和 Subject. 上一篇介绍了 Observable 这篇继续接受 Subject. 参考 RxJS 建立 Observable 的基礎 - Obs ...

  3. HTML – Emmet Shortcut

    前言 程序员就爱 hot key, 就爱 shortcut. 当然这里指的是不牺牲安全和结果的情况下用尽可能少的力气去做事情, 而不是那种 shortcut 了以后会翻车的. Emmet 就是专门写 ...

  4. ASP.NET Core – Web API Versioning

    前言 项目持续维护, API 就需要版本控制. ASP.NET Core 有官方的插件专门处理 API 版本控制. 主要参考 Your Guide to REST API Versioning in ...

  5. Git 本地仓库与基础操作指令

    本地仓库 获取本地仓库 在电脑任意位置创建一个空目录(例如test)作为我们的本地Git仓库 进入这个目录中,右键打开Git Bash窗口 执行 git init命令 如果创建成功后可在文件夹下看到隐 ...

  6. SpringMVC —— RESTful案例

    案例:基于RESTful页面数据交互            

  7. (九)Redis 哨兵机制与集群

    主从复制中,如果从库发生故障了,客户端可以继续向主库或其他从库发送请求,但是如果主库发生故障了肿么办呢?读请求,那还可以由从库继续提供服务,写请求就么得办法了.此时,哨兵机制就登场了,解决3个问题: ...

  8. [OI] 交互 | pipe

    关于如何在本地实现交互 管道 Linux 内置了一种管道操作,可以方便地把 \(A\) 程序的输出和 \(B\) 程序的输入连接起来,只需要以下指令: A | B 此代码行的意思是:同时运行 \(A\ ...

  9. 【赵渝强老师】在MongoDB中使用游标

    一.什么是游标? 游标(Cursor)是处理数据的一种方法,为了查看或者处理结果集中的数据,游标提供了在结果集中一次一行或者多行前进或向后浏览数据的能力. 游标实际上是一种能从包括多条数据记录的结果集 ...

  10. balance_dirty_pages_ratelimited分析

    balance_dirty_pages_ratelimited分析 nr_dirtied_pause:当前task的脏页门限: dirty_exceeded:全局的脏页数超过门限或者该bdi的脏页数超 ...