Lucene 分组统计详解

抛出问题

在 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

完整示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
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();
}
}
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
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,结果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#### 组的分页大小为: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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
#### 组的分页大小为: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>>
#### 结束组内分页
#### 结束组的分页

坚持原创技术分享,您的支持将鼓励我继续创作!