Lucene 分组统计详解
原文链接 http://codepub.cn/2017/11/15/lucene-group-statistics-detailed/
注:以下为加速网络访问所做的原文缓存,经过重新格式化,可能存在格式方面的问题,或偶有遗漏信息,请以原文为准。
抛出问题
在 RDBMS 中,我们可以使用 GROUP BY 来对检索的数据进行分组,同样地,想要在 Lucene 中实现分组要如何做呢?首先思考如下几个问题
- Lucene 是如何实现分组的?
- 用来分组的字段(域)或者说 Field 如何添加?
- 组的大小如何设置?
- 组内大小如何设置?
- 如何实现组的分页?
- 如果结果集超过了组内大小,可以通过分页解决,那么如果结果集超过了组大小的上限,如何解决?
- 如何实现单类别分组,即类似SQL中的 GROUP BY A
- 如何实现多类别分组,即类似SQL中的 GROUP BY A, B
从 SQL 的 GROUP BY 说起
如果分组后面只有一个字段,如 GROUP BY A 意思是将所有具有相同A字段值的记录放到一个分组里。那么如果是GROUP BY A, B呢?其意思是将所有具有相同A字段值和B字段值的记录放到一个分组里,在这里A和B之间是逻辑与的关系。
通常的,如果在SQL中,我们仅用 GROUP BY 语句而不加 WHERE 条件的话,那么相当于在全部数据中进行分组,对应于 Lucene 中相当于使用 GROUP 加 new MatchAllDocsQuery() 的功能。
而如果在SQL中,我们不仅用 GROUP BY 还有 WHERE 条件语句,那么相当于在满足 WHERE 条件的记录中进行分组,这种 WHERE 条件在 Lucene 中可以通过构造各种不同的 Query 进行过滤,然后在符合条件的结果中分组。
Lucene 分组
有关Lucene分组问题,需要有一系列输入参数,官方Doc在此,核心点如下
- groupField:用来分组的域,在 Lucene 中,这个域只能设置一个,不像 SQL 中可以根据多个列分组。没有该域的文档将被分到一个单独的组里面
- groupSort:组间排序方式,用来指定如何对不同的分组进行排序,而不是组内的文档排序,默认值是
Sort.RELEVANCE
- topNGroups:保留多少组,例如10只取前十个分组
- groupOffset:指定组偏移量,比如当topNGroups的值是10的时候,groupOffset为3,则意思是返回7个分组,跳过前面3个,在分页时候很有用
- withinGroupSort:组内排序方式,默认值是
Sort.RELEVANCE
,注意和groupSort的区别,不要求和groupSort使用一样的排序方式 - maxDocsPerGroup:表示一个组内最多保留多少个文档
- withinGroupOffset:每组显示的文档的偏移量
分组通常有两个阶段,第一阶段用FirstPassGroupingCollector
收集不同的分组,第二阶段用SecondPassGroupingCollector
收集这些分组内的文档,如果分组很耗时,建议用CachingCollector
类,可以缓存 hits 并在第二阶段快速返回。这种方式让你相当于只运行了一次 query,但是付出的代价是用 RAM 持有所有的 hits。返回的结果集是TopGroups的实例。
Groups是由GroupSelector(抽象类)的实现来定义的,目前支持两种实现方式
- TermGroupSelector 基于 SortedDocValues 域进行分组
- ValueSourceGroupSelector 基于 ValueSource 值进行分组
通常不建议直接使用 FirstPassGroupingCollector 和 SecondPassGroupingCollector 来进行分组操作,因为Lucene提供了一个非常简便的封装类 GroupingSearch,目前分组操作还不支持 Sharding。
网上有许多讲解 Lucene 分组的文章,但是讲的都非常浅显,一般都是取 Top N 个分组,这个 N 是一个确定的值,试问如果我要对全部的结果集进行分组统计,而分组数量超过 Top N 的话,那么这种方式统计的结果显然是不准确的,因为它并没有统计全部的数据。还有的是直接把 maxDoc()
函数的值作为 groupLimit
的值,然后对某个分组内的全部文档进行迭代,无法实现组内分页的问题。
所以本文就针对这个问题,不仅解决了组内分页的问题,还解决了组间分页的问题,可以迭代完全的结果集。
另外一个需要注意的问题就是 maxDoc()
可能返回的是 Integer
型的上限,而将其直接作为 groupLimit 传入的话,是会报错的,错误如下
组内大小和组间大小如果设置为Integer.MAX_VALUE报
Exception in thread "main" java.lang.NegativeArraySizeException
组内大小和组间大小如果设置为Integer.MAX_VALUE-1报
Exception in thread "main" java.lang.IllegalArgumentException: maxSize must be <= 2147483630; got: 2147483646
完整示例如下
import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
import org.apache.lucene.document.*;
import org.apache.lucene.index.DirectoryReader;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.RAMDirectory;
import org.apache.lucene.util.BytesRef;
import java.io.IOException;
/**
* <p>
* Created by wangxu on 2017/11/14 16:41.
* </p>
* <p>
* Description: 基于 Lucene 7.0.0
* </p>
*
* @author Wang Xu
* @version V1.0.0
* @since V1.0.0 <br/>
* WebSite: http://codepub.cn <br>
* Licence: Apache v2 License
*/
public class IndexHelper {
private Document document;
private Directory directory;
private IndexWriter indexWriter;
public Directory getDirectory() {
directory = (directory == null) ? new RAMDirectory() : directory;
return directory;
}
private IndexWriterConfig getConfig() {
return new IndexWriterConfig(new WhitespaceAnalyzer());
}
private IndexWriter getIndexWriter() {
try {
return new IndexWriter(getDirectory(), getConfig());
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
public IndexSearcher getIndexSearcher() throws IOException {
return new IndexSearcher(DirectoryReader.open(getDirectory()));
}
public void createIndexForGroup(int ID, String author, String content) {
indexWriter = getIndexWriter();
document = new Document();
//IntPoint默认是不存储的
document.add(new IntPoint("ID", ID));
//如果想要在搜索结果中获取ID的值,需要加上下面语句
document.add(new StoredField("ID", ID));
document.add(new StringField("author", author, Field.Store.YES));
//需要使用特定的field存储分组,需要排序及分组的话,要加上下面语句,注意默认SortedDocValuesField也是不存储的
document.add(new SortedDocValuesField("author", new BytesRef(author)));
document.add(new StringField("content", content, Field.Store.YES));
try {
indexWriter.addDocument(document);
indexWriter.commit();
indexWriter.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
import org.apache.lucene.index.Term;
import org.apache.lucene.search.*;
import org.apache.lucene.search.grouping.GroupDocs;
import org.apache.lucene.search.grouping.GroupingSearch;
import org.apache.lucene.search.grouping.TopGroups;
import org.apache.lucene.util.BytesRef;
import java.io.IOException;
/**
* <p>
* Created by wangxu on 2017/11/14 16:21.
* </p>
* <p>
* Description: 基于 Lucene 7.0.0 开发
* </p>
*
* @author Wang Xu
* @version V1.0.0
* @since V1.0.0 <br/>
* WebSite: http://codepub.cn <br>
* Licence: Apache v2 License
*/
public class GroupingDemo {
public static void main(String[] args) throws Exception {
IndexHelper indexHelper = new IndexHelper();
indexHelper.createIndexForGroup(1, "Java", "一周精通Java");
indexHelper.createIndexForGroup(2, "Java", "一周精通MyBatis");
indexHelper.createIndexForGroup(3, "Java", "一周精通Struts");
indexHelper.createIndexForGroup(4, "Java", "一周精通Spring");
indexHelper.createIndexForGroup(5, "Java", "一周精通Spring Cloud");
indexHelper.createIndexForGroup(6, "Java", "一周精通Hibernate");
indexHelper.createIndexForGroup(7, "Java", "一周精通JVM");
indexHelper.createIndexForGroup(8, "C", "一周精通C");
indexHelper.createIndexForGroup(9, "C", "C语言详解");
indexHelper.createIndexForGroup(10, "C", "C语言调优");
indexHelper.createIndexForGroup(11, "C++", "一周精通C++");
indexHelper.createIndexForGroup(12, "C++", "C++语言详解");
indexHelper.createIndexForGroup(13, "C++", "C++语言调优");
IndexSearcher indexSearcher = indexHelper.getIndexSearcher();
GroupingDemo groupingDemo = new GroupingDemo();
//把所有的文档都查出来,由添加的数据可以知道,一共有三组,Java组有7个文档,C和C++组分别都有3个文档
//当然了如果做全匹配的话,还可以用new MatchAllDocsQuery()
BooleanQuery query = new BooleanQuery.Builder().add(new TermQuery(new Term("author", "Java")), BooleanClause.Occur.SHOULD).add(new TermQuery(new Term
("author", "C")),
BooleanClause.Occur.SHOULD).add(new TermQuery(new Term("author", "C++")), BooleanClause.Occur.SHOULD).build();
//控制每次返回几组
int groupLimit = 2;
//控制每一页的组内文档数
int groupDocsLimit = 2;
//控制组的偏移
int groupOffset = 0;
//为了排除干扰因素,全部使用默认的排序方式,当然你还可以使用自己喜欢的排序方式
//初始值为命中的所有文档数,即最坏情况下,一个文档分成一组,那么文档数就是分组的总数
int totalGroupCount = indexSearcher.count(query);
TopGroups<BytesRef> topGroups;
System.out.println("#### 组的分页大小为:" + groupLimit);
System.out.println("#### 组内分页大小为:" + groupDocsLimit);
while (groupOffset < totalGroupCount) {//说明还有不同的分组
//控制组内偏移,每次开始遍历一个新的分组时候,需要将其归零
int groupDocsOffset = 0;
System.out.println("#### 开始组的分页");
topGroups = groupingDemo.group(indexSearcher, query, "author", groupDocsOffset, groupDocsLimit, groupOffset, groupLimit);
//具体搜了一次之后,就知道到底有多少组了,更新totalGroupCount为正确的值
totalGroupCount = topGroups.totalGroupCount;
GroupDocs<BytesRef>[] groups = topGroups.groups;
//开始对组进行遍历
for (int i = 0; i < groups.length; i++) {
long totalHits = iterGroupDocs(indexSearcher, groups[i]);//获得这个组内一共多少doc
//处理完一次分页,groupDocsOffset要更新
groupDocsOffset += groupDocsLimit;
//如果组内还有数据,即模拟组内分页的情况,那么应该继续遍历组内剩下的doc
while (groupDocsOffset < totalHits) {
topGroups = groupingDemo.group(indexSearcher, query, "author", groupDocsOffset, groupDocsLimit, groupOffset, groupLimit);
//这里面的组一定要和外层for循环正在处理的组保持一致,其实这里面浪费了搜索数据,为什么?
//因为Lucene是对多个组同时进行组内向后翻页,而我只是一个组一个组的处理,其它不处理的组相当于是浪费的
//所以从这种角度来说,设置groupLimit为1比较合理,即每次处理一个组,而每次只将一个组的组内文档向后翻页
GroupDocs<BytesRef> group = topGroups.groups[i];
totalHits = iterGroupDocs(indexSearcher, group);
//此时需要更新组内偏移量
groupDocsOffset += groupDocsLimit;
}
//至此,一个组内的doc全部遍历完毕,开始下一组
groupDocsOffset = 0;
}
groupOffset += groupLimit;
System.out.println("#### 结束组的分页");
}
}
private static long iterGroupDocs(IndexSearcher indexSearcher, GroupDocs<BytesRef> groupDocs) throws IOException {
long totalHits = groupDocs.totalHits;
System.out.println("\t#### 开始组内分页");
System.out.println("\t分组名称:" + groupDocs.groupValue.utf8ToString());
ScoreDoc[] scoreDocs = groupDocs.scoreDocs;
for (ScoreDoc scoreDoc : scoreDocs) {
System.out.println("\t\t组内记录:" + indexSearcher.doc(scoreDoc.doc));
}
System.out.println("\t#### 结束组内分页");
return totalHits;
}
public TopGroups<BytesRef> group(IndexSearcher indexSearcher, Query query, String groupField,
int groupDocsOffset, int groupDocsLimit, int groupOffset, int groupLimit) throws Exception {
return group(indexSearcher, query, Sort.RELEVANCE, Sort.RELEVANCE, groupField, groupDocsOffset, groupDocsLimit, groupOffset, groupLimit);
}
public TopGroups<BytesRef> group(IndexSearcher indexSearcher, Query query, Sort groupSort, Sort withinGroupSort, String groupField,
int groupDocsOffset, int groupDocsLimit, int groupOffset, int groupLimit) throws Exception {
//实例化GroupingSearch实例,传入分组域
GroupingSearch groupingSearch = new GroupingSearch(groupField);
//设置组间排序方式
groupingSearch.setGroupSort(groupSort);
//设置组内排序方式
groupingSearch.setSortWithinGroup(withinGroupSort);
//是否要填充每个返回的group和groups docs的排序field
groupingSearch.setFillSortFields(true);
//设置用来缓存第二阶段搜索的最大内存,单位MB,第二个参数表示是否缓存评分
groupingSearch.setCachingInMB(64.0, true);
//是否计算符合查询条件的所有组
groupingSearch.setAllGroups(true);
groupingSearch.setAllGroupHeads(true);
//设置一个分组内的上限
groupingSearch.setGroupDocsLimit(groupDocsLimit);
//设置一个分组内的偏移
groupingSearch.setGroupDocsOffset(groupDocsOffset);
TopGroups<BytesRef> result = groupingSearch.search(indexSearcher, query, groupOffset, groupLimit);
return result;
}
}
例如组的分页大小是2,组内分页大小是2,结果如下:
#### 组的分页大小为:2
#### 组内分页大小为:2
#### 开始组的分页
#### 开始组内分页
分组名称:C
组内记录:Document<stored<ID:8> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通C>>
组内记录:Document<stored<ID:9> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:C语言详解>>
#### 结束组内分页
#### 开始组内分页
分组名称:C
组内记录:Document<stored<ID:10> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:C语言调优>>
#### 结束组内分页
#### 开始组内分页
分组名称:C++
组内记录:Document<stored<ID:11> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C++> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通C++>>
组内记录:Document<stored<ID:12> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C++> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:C++语言详解>>
#### 结束组内分页
#### 开始组内分页
分组名称:C++
组内记录:Document<stored<ID:13> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C++> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:C++语言调优>>
#### 结束组内分页
#### 结束组的分页
#### 开始组的分页
#### 开始组内分页
分组名称:Java
组内记录:Document<stored<ID:5> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通Spring Cloud>>
组内记录:Document<stored<ID:6> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通Hibernate>>
#### 结束组内分页
#### 开始组内分页
分组名称:Java
组内记录:Document<stored<ID:2> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通MyBatis>>
组内记录:Document<stored<ID:3> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通Struts>>
#### 结束组内分页
#### 开始组内分页
分组名称:Java
组内记录:Document<stored<ID:4> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通Spring>>
组内记录:Document<stored<ID:1> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通Java>>
#### 结束组内分页
#### 开始组内分页
分组名称:Java
组内记录:Document<stored<ID:7> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通JVM>>
#### 结束组内分页
#### 结束组的分页
例如组的分页大小是1,组内分页大小是3,结果如下
#### 组的分页大小为:1
#### 组内分页大小为:3
#### 开始组的分页
#### 开始组内分页
分组名称:C
组内记录:Document<stored<ID:8> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通C>>
组内记录:Document<stored<ID:9> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:C语言详解>>
组内记录:Document<stored<ID:10> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:C语言调优>>
#### 结束组内分页
#### 结束组的分页
#### 开始组的分页
#### 开始组内分页
分组名称:C++
组内记录:Document<stored<ID:11> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C++> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通C++>>
组内记录:Document<stored<ID:12> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C++> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:C++语言详解>>
组内记录:Document<stored<ID:13> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:C++> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:C++语言调优>>
#### 结束组内分页
#### 结束组的分页
#### 开始组的分页
#### 开始组内分页
分组名称:Java
组内记录:Document<stored<ID:5> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通Spring Cloud>>
组内记录:Document<stored<ID:6> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通Hibernate>>
组内记录:Document<stored<ID:2> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通MyBatis>>
#### 结束组内分页
#### 开始组内分页
分组名称:Java
组内记录:Document<stored<ID:3> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通Struts>>
组内记录:Document<stored<ID:4> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通Spring>>
组内记录:Document<stored<ID:1> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通Java>>
#### 结束组内分页
#### 开始组内分页
分组名称:Java
组内记录:Document<stored<ID:7> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<author:Java> stored,indexed,tokenized,omitNorms,indexOptions=DOCS<content:一周精通JVM>>
#### 结束组内分页
#### 结束组的分页