Collectors & concurrent search
|
This section documents the current Apache Lucene 10.x line — Lucene 10 requires Java 21 — as published
at the Apache Lucene documentation and Javadoc, which is the reference these
pages are written and verified against. No specific patch version is pinned; examples target This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, as Lucene iterates quickly. This section’s bibliography lists the reference material consulted while preparing these pages. |
A search walks the postings for every leaf and hands each matching doc id to a collector, which
keeps whatever the caller wants — usually a bounded heap of the top-scoring hits. Lucene 10 drives
this through CollectorManager: the searcher makes one collector per segment slice, runs the slices
(optionally on different threads), then reduces the partial results into one answer.
TopDocs and the search shortcuts
IndexSearcher.search(Query, n) returns a TopDocs: an array of ScoreDoc (doc id + score) plus a
TotalHits. The convenience overloads build the right CollectorManager internally.
// https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/search/IndexSearcher.html
IndexSearcher searcher = new IndexSearcher(reader);
TopDocs top = searcher.search(query, 10);
long total = top.totalHits.value(); // see the threshold caveat below
for (ScoreDoc sd : top.scoreDocs) {
int docId = sd.doc;
float score = sd.score;
}
// Sorted variant returns TopFieldDocs.
TopFieldDocs byPrice = searcher.search(query, 10,
new Sort(new SortField("price", SortField.Type.LONG)));
Turning a ScoreDoc.doc back into field values is a
separate step — the collector only ever sees the id and the score.
CollectorManager and concurrent search
A bare Collector is single-threaded. CollectorManager<C, T> makes it concurrency-safe: the
searcher calls newCollector() once per slice, collects each slice independently, then calls
reduce(collectors) to merge. Pass an Executor to the IndexSearcher constructor and the slices
run in parallel.
// https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/search/CollectorManager.html
import java.util.concurrent.Executors;
import org.apache.lucene.search.*;
var pool = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
IndexSearcher searcher = new IndexSearcher(reader, pool);
// Top hits by score. Args: numHits, totalHitsThreshold.
var byScore = new TopScoreDocCollectorManager(10, Integer.MAX_VALUE);
TopDocs hits = searcher.search(query, byScore);
// Top hits by sort field. Args: sort, numHits, totalHitsThreshold.
var bySort = new TopFieldCollectorManager(
new Sort(new SortField("price", SortField.Type.LONG)), 10, Integer.MAX_VALUE);
TopFieldDocs sorted = searcher.search(query, bySort);
// Count only -- no scoring, no heap.
var counter = new TotalHitCountCollectorManager(searcher.getSlices());
int count = searcher.search(query, counter);
These three managers (TopScoreDocCollectorManager, TopFieldCollectorManager,
TotalHitCountCollectorManager) replace the pre-9.x TopScoreDocCollector.create(…) /
TopFieldCollector.create(…) static factories, which are removed. Lucene 10 also adds
intra-segment concurrency — a single large segment can be split across threads — so a slice is no
longer always a whole number of segments; override IndexSearcher.slices(List) to tune the split.
To merge results computed separately (per shard, or per index), use TopDocs.merge:
// https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/search/TopDocs.html
TopDocs merged = TopDocs.merge(10, new TopDocs[] { shardA, shardB, shardC });
// Sorted inputs need the Sort so the merge compares the right values.
TopFieldDocs mergedSorted = TopDocs.merge(sort, 10, new TopFieldDocs[] { a, b });
The 10.0 migration notes describe the CollectorManager-only search path and the executor changes:
IndexSearcher.
Total-hit counting and early termination
By default the top-hits managers stop counting once totalHitsThreshold (1000 in the plain
search(Query, n) shortcut) matches have been seen. When that happens totalHits.value() is a lower
bound, flagged by totalHits.relation().
// https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/search/TotalHits.html
TopDocs top = searcher.search(query, 10);
if (top.totalHits.relation() == TotalHits.Relation.GREATER_THAN_OR_EQUAL_TO) {
// value() is only a floor -- counting stopped early
} else {
// TotalHits.Relation.EQUAL_TO -- value() is exact
}
// Pass Integer.MAX_VALUE as the threshold to force an exact count (slower).
var exact = new TopScoreDocCollectorManager(10, Integer.MAX_VALUE);
To bound wall-clock time rather than hit count, wrap a collector in TimeLimitingCollector; it
throws TimeExceededException once the shared counter passes the deadline, leaving the partial top
list in the wrapped collector.
// https://lucene.apache.org/core/10_0_0/core/org/apache/lucene/search/TimeLimitingCollector.html
Counter clock = TimeLimitingCollector.getGlobalCounter();
var base = new TopScoreDocCollectorManager(10, Integer.MAX_VALUE).newCollector();
var limited = new TimeLimitingCollector(base, clock, 200); // 200 clock ticks
try {
searcher.search(query, limited);
} catch (TimeLimitingCollector.TimeExceededException e) {
TopDocs partial = base.topDocs();
}
TimeLimitingCollector wraps a single Collector, so it does not itself parallelise; for concurrent
search set a low totalHitsThreshold instead, or return ScoreMode.TOP_SCORES from a custom
collector to let Lucene skip non-competitive blocks.
Writing a custom CollectorManager
Implement CollectorManager when the top-N heap is not what you need — a global id set, a
histogram, a running aggregate. newCollector() must return an independent instance; reduce runs
after all slices finish.
import java.util.*;
import org.apache.lucene.index.LeafReaderContext;
import org.apache.lucene.search.*;
/** Collects every matching global doc id, in order. */
class IdCollectorManager implements CollectorManager<IdCollectorManager.Leaf, List<Integer>> {
static final class Leaf implements Collector {
final List<Integer> ids = new ArrayList<>();
@Override public ScoreMode scoreMode() { return ScoreMode.COMPLETE_NO_SCORES; }
@Override public LeafCollector getLeafCollector(LeafReaderContext ctx) {
int base = ctx.docBase;
return new LeafCollector() {
@Override public void setScorer(Scorable s) { }
@Override public void collect(int doc) { ids.add(base + doc); }
};
}
}
@Override public Leaf newCollector() { return new Leaf(); }
@Override public List<Integer> reduce(Collection<Leaf> leaves) {
List<Integer> all = new ArrayList<>();
for (Leaf l : leaves) all.addAll(l.ids);
Collections.sort(all);
return all;
}
}
List<Integer> matches = searcher.search(query, new IdCollectorManager());
Returning COMPLETE_NO_SCORES tells Lucene not to compute scores at all. CollectorManager
contract:
CollectorManager.
This is the same scatter/gather shape that a distributed engine runs one layer higher, across nodes rather than segment slices — see Solr: Distributed indexing & search.
Related pages
-
Near-real-time search — acquiring the searcher a query runs against.
-
Scoring & similarity — what
ScoreMode.TOP_SCORESlets Lucene skip. -
Function scoring, expressions & sorting —
SortandTopFieldCollectorManager. -
Retrieving results — turning collected doc ids into fields.
-
Performance tuning — slice sizing and executor configuration.
-
Solr: Distributed indexing & search — scatter/gather across a cluster.