Apache Lucene 作為高性能全文搜索庫,原生支持結果分組與高效分頁。本文基於 Lucene 4.3+ (示例兼容 8.x/9.x 概念) 詳細講解 GroupingSearch 實現按字段分組(如按作者聚合文檔),並對比兩種分頁方案:傳統切片分頁與 searchAfter 深度分頁優化。
| 參數 | 說明 |
|---|---|
groupField | 分組依據字段(如 "author"),缺失該字段的文檔歸入單獨組 |
groupSort | 組間排序規則(如按分數降序) |
topNGroups | 返回前 N 組 |
groupOffset | 跳過前 N 組(配合 topNGroups 實現組級分頁) |
withinGroupSort | 組內文檔排序(與 groupSort 區分) |
withinGroupOffset | 組內跳過文檔數 |
// 1. 索引構建(略,使用 IKAnalyzer 中文分詞)
// 2. 分組搜索
public void groupSearch(IndexSearcher searcher, String groupField, String queryStr)
throws IOException, ParseException {
GroupingSearch groupingSearch = new GroupingSearch(groupField);
groupingSearch.setGroupSort(new Sort(SortField.FIELD_SCORE)); // 組按最高分排序
groupingSearch.setFillSortFields(true); // 填充排序字段值
groupingSearch.setCachingInMB(4.0, true); // 緩存首次搜索結果加速二次搜索
groupingSearch.setAllGroups(true); // 返回所有組(而非僅 topN)
groupingSearch.setGroupDocsLimit(10); // 每組最多返回 10 篇文檔
QueryParser parser = new QueryParser("content", new IKAnalyzer(true));
Query query = parser.parse(queryStr);
// 搜索:offset=0, maxGroups=1000
TopGroups<BytesRef> result = groupingSearch.search(searcher, query, 0, 1000);
System.out.println("總命中數:" + result.totalHitCount);
System.out.println("分組數:" + result.groups.length);
for (GroupDocs<BytesRef> group : result.groups) {
String groupValue = group.groupValue.utf8ToString(); // 組鍵值,如作者名
System.out.println("\n=== 組:" + groupValue + " (文檔數:" + group.totalHits + ") ===");
for (ScoreDoc sd : group.scoreDocs) {
Document doc = searcher.doc(sd.doc);
System.out.println(" - ID:" + doc.get("id") + " 分數:" + sd.score + " 內容:" + doc.get("content"));
}
}
}
索引 11 篇文檔,作者分別為「紅薯」(4篇)、「覺先」(4篇)、「散仙」(3篇)。查詢「開源」:
總命中數:7
分組數:3
=== 組:紅薯 (文檔數:3) ===
- ID:1 內容:開源中國
- ID:2 內容:開源社區
- ID:4 內容:設計
=== 組:覺先 (文檔數:2) ===
- ID:7 內容:開源Lucene
- ID:8 內容:開源solr
=== 組:散仙 (文檔數:2) ===
- ID:9 內容:散仙開源Lucene
- ID:10 內容:散仙開源solr
// 獲取所有 ScoreDoc 後在內存切片
TopDocs topDocs = searcher.search(query, maxDocs); // maxDocs 需覆蓋所有頁
ScoreDoc[] all = topDocs.scoreDocs;
int pageSize = 10;
int pageNum = 2; // 第 2 頁
int from = (pageNum - 1) * pageSize;
int to = Math.min(from + pageSize, all.length);
for (int i = from; i < to; i++) {
Document doc = searcher.doc(all[i].doc);
// 處理文檔
}
缺點:search(query, maxDocs) 會收集前 maxDocs 個文檔到優先隊列,內存與 CPU 均隨翻頁深度線性增長,深度分頁 (如第 10000 頁) 極其低效。
利用上一頁最後一條文檔的 ScoreDoc 作為游標,避免重複收集已翻過的文檔。
public Map<String, Object> searchPage(IndexSearcher searcher, Query query,
ScoreDoc after, int pageSize) throws IOException {
// after 為 null 表示第一頁
TopDocs topDocs = searcher.searchAfter(after, query, pageSize);
ScoreDoc[] hits = topDocs.scoreDocs;
List<Document> pageData = new ArrayList<>();
for (ScoreDoc sd : hits) {
pageData.add(searcher.doc(sd.doc));
}
Map<String, Object> result = new HashMap<>();
result.put("totalHits", topDocs.totalHits);
result.put("data", pageData);
// 返回本頁最後一條作為下一頁游標
result.put("nextAfter", hits.length > 0 ? hits[hits.length - 1] : null);
return result;
}
優勢:
maxDocs,內存佔用恆定 O(pageSize)。_docid 或 id),否則相同分數文檔順序不穩定導致翻頁重複/遺漏。GroupingSearch 獲取組列表(組級分頁),再對單組內文檔用 searchAfter 分頁。setCachingInMB 緩存首次搜索的排序值與文檔,二次搜索(如翻頁)大幅提速。DocValues (數值/排序字段) 而非倒排索引,避免字段解析開銷。ScoreDoc 需跨請求傳遞(前端/URL 參數),建議自定義輕量游標對象(包含 sortValues + docID),而非直接序列化 Lucene 內部類。Lucene GroupingSearch 提供開箱即用的分組聚合能力,配合 searchAfter 游標分頁,能高效支撐「按類目聚合展示 + 無限滾動」等複雜搜索交互。關鍵在於:合理設置 groupField、groupSort、withinGroupSort,並在深度分頁場景堅決拋棄傳統 search(query, n) 切片方式。