From d575bf219d79587255ef518e7d2b5f11d9809ba4 Mon Sep 17 00:00:00 2001 From: Steve Yen Date: Sat, 20 Jun 2026 01:11:36 -0700 Subject: [PATCH 01/14] perf: fast-path prepareDocumentMatch + guard adjustDocumentMatch for non-KNN queries For score-sorted queries with no field loading, the collector's per-doc hot path carried dead overhead: - adjustDocumentMatch is a no-op when hc.knnHits == nil; guard both call sites with knnHits != nil so the call is skipped entirely for non-KNN queries. - prepareDocumentMatch has three branches that are always false for simple score queries (isKnnDoc / neededFields / sort-compute). Add a fastPrepare bool, set once in Collect after needDocIds is known; the fast path runs only the four necessary statements (total++, HitNumber, maxScore, Sort=sortByScoreOpt). Benchmark (BenchmarkTop{K}of{N}Scores, search/collector), parent vs this commit, Apple M4 Pro, count=10 via benchstat: Top10of10000 -1.1% Top100of10000 -1.4% Top10of100000 -2.5% Top100of100000 -2.3% geomean -1.8% (p<0.05 all) Small but consistent win across sizes, no allocation change, no regressions. --- search/collector/topn.go | 35 +++++++++++++++++++++++++++++------ 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/search/collector/topn.go b/search/collector/topn.go index bab318d5c..c33b809d3 100644 --- a/search/collector/topn.go +++ b/search/collector/topn.go @@ -81,6 +81,11 @@ type TopNCollector struct { hybridMergeCallback search.HybridMergeCallbackFn nestedStore *collectStoreNested + + // fastPrepare is true when prepareDocumentMatch can skip KNN/neededFields/ + // needDocIds/sort-value-compute branches — set once in Collect after loadID + // is known. Applies only to score-sorted queries with no field-loading needs. + fastPrepare bool } // CheckDoneEvery controls how frequently we check the context deadline @@ -327,6 +332,8 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, } hc.needDocIds = hc.needDocIds || loadID + hc.fastPrepare = len(hc.neededFields) == 0 && !hc.needDocIds && + len(hc.sort) == 1 && hc.cachedScoring[0] select { case <-ctx.Done(): search.RecordSearchCost(ctx, search.AbortM, 0) @@ -361,9 +368,11 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, } } if next != nil { - err = hc.adjustDocumentMatch(searchContext, reader, next) - if err != nil { - break + if hc.knnHits != nil { + err = hc.adjustDocumentMatch(searchContext, reader, next) + if err != nil { + break + } } err = hc.prepareDocumentMatch(searchContext, reader, next, false) if err != nil { @@ -385,9 +394,11 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, if hc.nestedStore != nil { currRoot := hc.nestedStore.Current() if currRoot != nil { - err = hc.adjustDocumentMatch(searchContext, reader, currRoot) - if err != nil { - return err + if hc.knnHits != nil { + err = hc.adjustDocumentMatch(searchContext, reader, currRoot) + if err != nil { + return err + } } // no descendants at this point err = hc.prepareDocumentMatch(searchContext, reader, currRoot, false) @@ -468,6 +479,18 @@ func (hc *TopNCollector) adjustDocumentMatch(ctx *search.SearchContext, func (hc *TopNCollector) prepareDocumentMatch(ctx *search.SearchContext, reader index.IndexReader, d *search.DocumentMatch, isKnnDoc bool) (err error) { + // Fast path: score-sorted queries with no field loading, no KNN, no docID needs. + // Skips all conditional branches that are always false in this common case. + if hc.fastPrepare && !isKnnDoc { + hc.total++ + d.HitNumber = hc.total + if d.Score > hc.maxScore { + hc.maxScore = d.Score + } + d.Sort = sortByScoreOpt + return nil + } + // visit field terms for features that require it (sort, facets) if !isKnnDoc && len(hc.neededFields) > 0 { err = hc.visitFieldTerms(reader, d, hc.updateFieldVisitor) From 52aca94f1353dafa4ada17f50d6510864593a96f Mon Sep 17 00:00:00 2001 From: Steve Yen Date: Fri, 19 Jun 2026 20:07:00 -0700 Subject: [PATCH 02/14] fix: close orphaned sub-searchers after unadorned disjunction optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit optimizeCompositeSearcher() returns a new merged TermSearcher wrapping a combined bitmap, but newDisjunctionSearcher never closed the original N sub-searchers once the "disjunction:unadorned" optimization succeeded. Their TermFieldReaders were therefore never released through Close(): the TotTermSearchersFinished accounting was skipped and the TFRs (holding Dictionary + vellum FST Reader references) lingered until GC instead of being returned to the snapshot per-field pool. Close the original qsearchers immediately after the optimization succeeds. This is safe because OptimizeTFRDisjunctionUnadorned.Finish() clones/creates all bitmaps before returning, so the originals share no state. The close loop lives in newDisjunctionSearcher (not optimizeCompositeSearcher) because optimizeMultiTermSearcher already calls cleanup() on its batch — closing inside would double-close. The TFR-pool-warmth benefit is realized only when TFR recycling is enabled (DefaultFieldTFRCacheThreshold > 0), which is currently disabled (MB-64669); so this lands as a lifecycle/accounting correctness fix. Verified with -race on ./search/searcher and the scorch suite. --- search/searcher/search_disjunction.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/search/searcher/search_disjunction.go b/search/searcher/search_disjunction.go index 0a5a54ef6..232e434bd 100644 --- a/search/searcher/search_disjunction.go +++ b/search/searcher/search_disjunction.go @@ -69,6 +69,15 @@ func newDisjunctionSearcher(ctx context.Context, indexReader index.IndexReader, rv, err := optimizeCompositeSearcher(ctx, "disjunction:unadorned", indexReader, qsearchers, options) if err != nil || rv != nil { + if rv != nil { + // Finish() extracted all it needs (bitmaps are cloned/new). + // Close the original sub-searchers so their TFRs are returned + // to the snapshot per-field pool, avoiding re-allocation of + // Dictionary+FST Reader objects on the next query. + for _, s := range qsearchers { + _ = s.Close() + } + } return rv, err } } From 82e20dd0dd2f6a87658e272a9555d5da3f25ac82 Mon Sep 17 00:00:00 2001 From: Steve Yen Date: Wed, 10 Jun 2026 19:32:08 -0700 Subject: [PATCH 03/14] perf: skip empty-bitmap work in disjunction:unadorned Finish() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes to OptimizeTFRDisjunctionUnadorned.Finish(): 1. Remove a dead first loop that scanned every (segment x TFR) to compute a per-segment cMax that was never used — a full O(segments x terms) pass for nothing on every unadorned-disjunction Finish(). 2. Add empty/1-hit fast paths per segment: reuse the zero-alloc anEmptyPostingsIterator singleton when a segment has no hits, and newUnadornedPostingsIteratorFrom1Hit for a lone 1-hit doc, instead of allocating roaring.New() + a bitmap iterator. Also make emptyPostingsIterator implement segment.OptimizablePostingsIterator (ActualBitmap->nil, DocNum1Hit->false, ReplaceActual->noop) so the empty sentinel composes in nested disjunction/conjunction optimizations instead of aborting them (ok=false) and forcing the slow non-optimized path. Benchmark (BenchmarkDisjunctionUnadornedFinish, added here: 8-segment index, 4-term disjunction with 4 empty segments), Apple M4 Pro, count=8 via benchstat: Finish() 4.04us -> 3.96us -2.0% (p=0.000) The measured win here is the dead-loop removal; allocs are unchanged in this shape because the fast paths only fire for genuinely-empty iterators (sparse or nested disjunctions), which this scenario doesn't produce. On sparse multi-field entity workloads the alloc fast paths additionally cut Finish() allocations (the original change measured -14% to -20% allocs there). --- index/scorch/disj_unadorned_bench_test.go | 96 +++++++++++++++++++++++ index/scorch/empty.go | 13 ++- index/scorch/optimize.go | 34 +++----- 3 files changed, 121 insertions(+), 22 deletions(-) create mode 100644 index/scorch/disj_unadorned_bench_test.go diff --git a/index/scorch/disj_unadorned_bench_test.go b/index/scorch/disj_unadorned_bench_test.go new file mode 100644 index 000000000..1014bdda9 --- /dev/null +++ b/index/scorch/disj_unadorned_bench_test.go @@ -0,0 +1,96 @@ +package scorch + +import ( + "context" + "fmt" + "sync" + "testing" + + "github.com/blevesearch/bleve/v2/document" + index "github.com/blevesearch/bleve_index_api" +) + +var ( + benchDisjOnce sync.Once + benchDisjIdx index.Index +) + +// 8 segments: segments 0-3 each contain one hot term (t0..t3, 500 docs each); +// segments 4-7 contain only "filler" — empty for every query term, exercising +// the empty-segment path in OptimizeTFRDisjunctionUnadorned.Finish(). +func getBenchDisjIdx(b *testing.B) index.Index { + benchDisjOnce.Do(func() { + cfg := CreateConfig("BenchDisjUnadorned") + if err := InitTest(cfg); err != nil { + b.Fatal(err) + } + aq := index.NewAnalysisQueue(1) + idx, err := NewScorch(Name, cfg, aq) + if err != nil { + b.Fatal(err) + } + if err := idx.Open(); err != nil { + b.Fatal(err) + } + for seg := 0; seg < 8; seg++ { + batch := index.NewBatch() + for d := 0; d < 500; d++ { + doc := document.NewDocument(fmt.Sprintf("%d-%d", seg, d)) + term := "filler" + if seg < 4 { + term = fmt.Sprintf("t%d", seg) + } + doc.AddField(document.NewTextField("f", []uint64{}, []byte(term))) + batch.Update(doc) + } + if err := idx.Batch(batch); err != nil { + b.Fatal(err) + } + } + benchDisjIdx = idx + }) + return benchDisjIdx +} + +var sinkOptimized index.Optimized + +func BenchmarkDisjunctionUnadornedFinish(b *testing.B) { + idx := getBenchDisjIdx(b) + r, err := idx.Reader() + if err != nil { + b.Fatal(err) + } + defer func() { _ = r.Close() }() + ctx := context.TODO() + terms := [][]byte{[]byte("t0"), []byte("t1"), []byte("t2"), []byte("t3")} + + b.ResetTimer() + b.ReportAllocs() + for n := 0; n < b.N; n++ { + var octx index.OptimizableContext + tfrs := make([]index.TermFieldReader, 0, len(terms)) + for _, t := range terms { + tfr, err := r.TermFieldReader(ctx, t, "f", false, false, false) + if err != nil { + b.Fatal(err) + } + tfrs = append(tfrs, tfr) + opt := tfr.(index.Optimizable) + octx, err = opt.Optimize("disjunction:unadorned", octx) + if err != nil { + b.Fatal(err) + } + } + o, err := octx.Finish() + if err != nil { + b.Fatal(err) + } + if o == nil { + b.Fatal("optimization aborted (nil result)") + } + sinkOptimized = o + for _, tfr := range tfrs { + _ = tfr.Close() + } + } +} diff --git a/index/scorch/empty.go b/index/scorch/empty.go index 34619d422..b71bbf158 100644 --- a/index/scorch/empty.go +++ b/index/scorch/empty.go @@ -14,7 +14,10 @@ package scorch -import segment "github.com/blevesearch/scorch_segment_api/v2" +import ( + "github.com/RoaringBitmap/roaring/v2" + segment "github.com/blevesearch/scorch_segment_api/v2" +) type emptyPostingsIterator struct{} @@ -38,4 +41,12 @@ func (e *emptyPostingsIterator) ResetBytesRead(uint64) {} func (e *emptyPostingsIterator) BytesWritten() uint64 { return 0 } +// Implement OptimizablePostingsIterator so that anEmptyPostingsIterator can +// participate in nested conjunction/disjunction optimizations without aborting +// them. ActualBitmap returning nil and DocNum1Hit returning false cause the +// iterator to contribute nothing to any AND or OR, which is correct. +func (e *emptyPostingsIterator) ActualBitmap() *roaring.Bitmap { return nil } +func (e *emptyPostingsIterator) DocNum1Hit() (uint64, bool) { return 0, false } +func (e *emptyPostingsIterator) ReplaceActual(*roaring.Bitmap) {} + var anEmptyPostingsIterator = &emptyPostingsIterator{} diff --git a/index/scorch/optimize.go b/index/scorch/optimize.go index 658fb08dd..aec7e6070 100644 --- a/index/scorch/optimize.go +++ b/index/scorch/optimize.go @@ -308,24 +308,6 @@ func (o *OptimizeTFRDisjunctionUnadorned) Finish() (rv index.Optimized, err erro return nil, nil } - for i := range o.snapshot.segment { - var cMax uint64 - - for _, tfr := range o.tfrs { - itr, ok := tfr.iterators[i].(segment.OptimizablePostingsIterator) - if !ok { - return nil, nil - } - - if itr.ActualBitmap() != nil { - c := itr.ActualBitmap().GetCardinality() - if cMax < c { - cMax = c - } - } - } - } - // We use an artificial term and field because the optimized // termFieldReader can represent multiple terms and fields. oTFR := o.snapshot.unadornedTermFieldReader( @@ -355,6 +337,18 @@ func (o *OptimizeTFRDisjunctionUnadorned) Finish() (rv index.Optimized, err erro } } + // Fast path: no hits in this segment — reuse the zero-alloc empty sentinel. + if len(actualBMs) == 0 && len(docNums) == 0 { + oTFR.iterators[i] = anEmptyPostingsIterator + continue + } + + // Fast path: exactly one 1-hit doc with no bitmaps. + if len(actualBMs) == 0 && len(docNums) == 1 { + oTFR.iterators[i] = newUnadornedPostingsIteratorFrom1Hit(uint64(docNums[0])) + continue + } + var bm *roaring.Bitmap if len(actualBMs) > 2 { bm = roaring.HeapOr(actualBMs...) @@ -362,9 +356,7 @@ func (o *OptimizeTFRDisjunctionUnadorned) Finish() (rv index.Optimized, err erro bm = roaring.Or(actualBMs[0], actualBMs[1]) } else if len(actualBMs) == 1 { bm = actualBMs[0].Clone() - } - - if bm == nil { + } else { bm = roaring.New() } From 8fd0ead5d66ef67f441e604c1a50e63a7e2a710c Mon Sep 17 00:00:00 2001 From: Gautham Krithiwas Date: Fri, 10 Jul 2026 12:20:39 +0530 Subject: [PATCH 04/14] perf: specialized score-descending comparator for top-N collector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SortOrder.Compare is called on every heap comparison and on every lowestMatchOutsideResults / searchAfter check in the top-N collector. The generic path iterates the sort-field slice and checks two bool flags (cachedScoring, cachedDesc) per call, adding overhead to what is usually a single float64 comparison. Detect the overwhelmingly common case at collector-construction time — a single score-descending sort — and store a specialized comparator (hc.cmp) that compares Score directly, with HitNumber as the tie-break. Share it across the heap store, the lowestMatchOutsideResults fast-path, and searchAfter pagination. Falls back to the generic SortOrder.Compare for any other sort order. Extracted from a larger change; the companion collectStoreHeap.Final sort.Slice optimization depended on the ternary-heap rework and is not included here. Benchmark (BenchmarkTop{K}of{N}Scores, search/collector, score-descending), parent vs this commit, Apple M4 Pro, count=10 via benchstat: Top100of10000 -15.0% Top1000of100000 -13.5% Top1000of10000 -13.1% Top100of100000 -11.2% Top10of10000 -10.5% Top10of100000 ~ (noisy, not significant) geomean -11.6% Consistent ~11-15% collector speedup for score-sorted queries (the common case), no meaningful allocation change. --- search/collector/topn.go | 49 +++++++++++++++++++++++++++++----------- 1 file changed, 36 insertions(+), 13 deletions(-) diff --git a/search/collector/topn.go b/search/collector/topn.go index c33b809d3..3d9b2b16c 100644 --- a/search/collector/topn.go +++ b/search/collector/topn.go @@ -66,6 +66,7 @@ type TopNCollector struct { facetsBuilder *search.FacetsBuilder store collectorStore + cmp collectorCompare // specialized or generic; shared by heap + dmHandler needDocIds bool neededFields []string @@ -132,9 +133,38 @@ func NewNestedTopNCollectorAfter(size int, sort search.SortOrder, after []string func newTopNCollector(size int, skip int, sort search.SortOrder, nr index.NestedReader) *TopNCollector { hc := &TopNCollector{size: size, skip: skip, sort: sort} - hc.store = getOptimalCollectorStore(size, skip, func(i, j *search.DocumentMatch) int { - return hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, i, j) - }) + // Compute once up-front; comparator and store creation need them. + hc.neededFields = sort.RequiredFields() + hc.cachedScoring = sort.CacheIsScore() + hc.cachedDesc = sort.CacheDescending() + + // Specialize for the common case: single score-descending sort. + // SortOrder.Compare iterates a slice and checks two bool flags per call, + // adding ~40% overhead to every heap comparison. The direct float64 path + // eliminates that overhead; measured at ~18% of total CPU for k=1000 queries. + if len(sort) == 1 && hc.cachedScoring[0] && hc.cachedDesc[0] { + hc.cmp = func(i, j *search.DocumentMatch) int { + if i.Score < j.Score { + return 1 // i is worse (lower score → closer to heap root) + } + if i.Score > j.Score { + return -1 + } + if i.HitNumber > j.HitNumber { + return 1 // tie-break: earlier hit is better + } + if i.HitNumber < j.HitNumber { + return -1 + } + return 0 + } + } else { + hc.cmp = func(i, j *search.DocumentMatch) int { + return hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, i, j) + } + } + + hc.store = getOptimalCollectorStore(size, skip, hc.cmp) if nr != nil { descAdder := func(parent, child *search.DocumentMatch) error { @@ -163,13 +193,9 @@ func newTopNCollector(size int, skip int, sort search.SortOrder, nr index.Nested hc.nestedStore = newStoreNested(nr, search.DescendantAdderCallbackFn(descAdder)) } - // these lookups traverse an interface, so do once up-front if sort.RequiresDocID() { hc.needDocIds = true } - hc.neededFields = sort.RequiredFields() - hc.cachedScoring = sort.CacheIsScore() - hc.cachedDesc = sort.CacheDescending() return hc } @@ -561,7 +587,7 @@ func MakeTopNDocumentMatchHandler( // exact sort order matches use hit number to break tie // but we want to allow for exact match, so we pretend hc.searchAfter.HitNumber = d.HitNumber - if hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, d, hc.searchAfter) <= 0 { + if hc.cmp(d, hc.searchAfter) <= 0 { ctx.DocumentMatchPool.Put(d) return nil } @@ -571,9 +597,7 @@ func MakeTopNDocumentMatchHandler( // with this one comparison, we can avoid all heap operations if // this hit would have been added and then immediately removed if hc.lowestMatchOutsideResults != nil { - cmp := hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, d, - hc.lowestMatchOutsideResults) - if cmp >= 0 { + if hc.cmp(d, hc.lowestMatchOutsideResults) >= 0 { // this hit can't possibly be in the result set, so avoid heap ops ctx.DocumentMatchPool.Put(d) return nil @@ -585,8 +609,7 @@ func MakeTopNDocumentMatchHandler( if hc.lowestMatchOutsideResults == nil { hc.lowestMatchOutsideResults = removed } else { - cmp := hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, - removed, hc.lowestMatchOutsideResults) + cmp := hc.cmp(removed, hc.lowestMatchOutsideResults) if cmp < 0 { tmp := hc.lowestMatchOutsideResults hc.lowestMatchOutsideResults = removed From 29d42cb65de58a6bc68dc40698e8290d2cac29d8 Mon Sep 17 00:00:00 2001 From: Gautham Krithiwas Date: Fri, 10 Jul 2026 12:25:27 +0530 Subject: [PATCH 05/14] perf: MergeFieldTermLocations fast-path + Reset nil-guard Two micro-optimizations on per-scored-candidate hot paths: - MergeFieldTermLocations (search/util.go): return early when no constituent match carries any FieldTermLocations (n == len(dest)). This is the common case with no highlighting / location tracking, and skips the second iteration and its per-match merge calls entirely. - DocumentMatch.Reset (search/search.go): guard clear(scoreBreakdown) with a nil check. clear(nil) still dispatches through the Go map runtime (~1ns), and ScoreBreakdown is nil on the common path (no KNN, no score-breakdown retrieval). Benchmark (search package, added here), Apple M4 Pro, count=12 via benchstat: MergeFieldTermLocations (no locations) 5.28ns -> 2.68ns -49% (p=0.000) DocumentMatch.Reset (nil ScoreBreakdown) 6.62ns -> 5.76ns -13% (p=0.000) Absolute per-call costs are small, but both run per scored candidate (many millions of calls per query), so they trim steady per-candidate overhead. No allocation change. --- search/merge_reset_bench_test.go | 31 +++++++++++++++++++++++++++++++ search/search.go | 8 ++++++-- search/util.go | 3 +++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 search/merge_reset_bench_test.go diff --git a/search/merge_reset_bench_test.go b/search/merge_reset_bench_test.go new file mode 100644 index 000000000..4e167c5d7 --- /dev/null +++ b/search/merge_reset_bench_test.go @@ -0,0 +1,31 @@ +package search + +import "testing" + +// BenchmarkMergeFieldTermLocationsNoLocs: common no-highlighting case — several +// constituent matches, none carrying field term locations. Exercises the +// fast-path early return. +func BenchmarkMergeFieldTermLocationsNoLocs(b *testing.B) { + matches := make([]*DocumentMatch, 5) + for i := range matches { + matches[i] = &DocumentMatch{} + } + b.ResetTimer() + b.ReportAllocs() + var dest []FieldTermLocation + for n := 0; n < b.N; n++ { + dest = MergeFieldTermLocations(nil, matches) + } + _ = dest +} + +// BenchmarkDocumentMatchResetNilScoreBreakdown: Reset() on the common path where +// ScoreBreakdown is nil. Exercises the clear(scoreBreakdown) nil-guard. +func BenchmarkDocumentMatchResetNilScoreBreakdown(b *testing.B) { + dm := &DocumentMatch{} + b.ResetTimer() + b.ReportAllocs() + for n := 0; n < b.N; n++ { + dm.Reset() + } +} diff --git a/search/search.go b/search/search.go index 541bbe42a..7b5d79c58 100644 --- a/search/search.go +++ b/search/search.go @@ -235,8 +235,12 @@ func (dm *DocumentMatch) Reset() *DocumentMatch { } // remember the score breakdown map scoreBreakdown := dm.ScoreBreakdown - // clear out the score breakdown map - clear(scoreBreakdown) + // clear out the score breakdown map; nil-guard because clear(nil) still + // dispatches through the map runtime (~1ns), and ScoreBreakdown is nil on + // the common path (no KNN, no score-breakdown retrieval) + if scoreBreakdown != nil { + clear(scoreBreakdown) + } // remember the Descendants backing array descendants := dm.Descendants for i := range descendants { // recycle each IndexInternalID diff --git a/search/util.go b/search/util.go index 81f22768a..14d6349d3 100644 --- a/search/util.go +++ b/search/util.go @@ -54,6 +54,9 @@ func MergeFieldTermLocations(dest []FieldTermLocation, matches []*DocumentMatch) n += len(dm.FieldTermLocations) } } + if n == len(dest) { + return dest // fast path: no constituent has field term locations to merge + } if cap(dest) < n { dest = append(make([]FieldTermLocation, 0, n), dest...) } From 7cdd14310e8a02e841315dec25c488f1bd1ae53f Mon Sep 17 00:00:00 2001 From: Gautham Krithiwas Date: Thu, 16 Jul 2026 18:21:47 +0530 Subject: [PATCH 06/14] fix: make unadorned 1-hit iterator optimizable in nested composite queries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OptimizeTFRConjunctionUnadorned.Finish (and now the disjunction Finish's single-1-hit fast path) place a unadornedPostingsIterator1Hit into the resulting termFieldReader's per-segment iterators. But that type did not implement segment.OptimizablePostingsIterator — only the bitmap variant did. So when such a termFieldReader fed into a *nested* unadorned conjunction/ disjunction, the type assertion in Finish failed and the outer optimization aborted to the slow path. Implement OptimizablePostingsIterator on unadornedPostingsIterator1Hit: DocNum1Hit returns (docNum, true), ActualBitmap returns nil, and ReplaceActual is a no-op (only ever called on iterators whose ActualBitmap is non-nil). This lets a 1-hit result compose into nested AND/OR optimizations instead of disabling them. Addresses review feedback on empty.go asking whether all iterator types placed into the optimized reader satisfy the interface. Co-Authored-By: Claude Opus 4.8 (1M context) --- index/scorch/empty.go | 7 +++---- index/scorch/unadorned.go | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/index/scorch/empty.go b/index/scorch/empty.go index b71bbf158..c6d1806ef 100644 --- a/index/scorch/empty.go +++ b/index/scorch/empty.go @@ -41,10 +41,9 @@ func (e *emptyPostingsIterator) ResetBytesRead(uint64) {} func (e *emptyPostingsIterator) BytesWritten() uint64 { return 0 } -// Implement OptimizablePostingsIterator so that anEmptyPostingsIterator can -// participate in nested conjunction/disjunction optimizations without aborting -// them. ActualBitmap returning nil and DocNum1Hit returning false cause the -// iterator to contribute nothing to any AND or OR, which is correct. +// Implement OptimizablePostingsIterator so an empty iterator can participate in +// nested conjunction/disjunction optimizations instead of aborting them; a nil +// bitmap and no 1-hit contribute nothing to any AND/OR. func (e *emptyPostingsIterator) ActualBitmap() *roaring.Bitmap { return nil } func (e *emptyPostingsIterator) DocNum1Hit() (uint64, bool) { return 0, false } func (e *emptyPostingsIterator) ReplaceActual(*roaring.Bitmap) {} diff --git a/index/scorch/unadorned.go b/index/scorch/unadorned.go index a37fb37ff..51fbcd791 100644 --- a/index/scorch/unadorned.go +++ b/index/scorch/unadorned.go @@ -168,6 +168,20 @@ func (i *unadornedPostingsIterator1Hit) BytesWritten() uint64 { func (i *unadornedPostingsIterator1Hit) ResetBytesRead(uint64) {} +// Implement OptimizablePostingsIterator so a 1-hit iterator produced by one +// optimization pass (e.g. the unadorned conjunction/disjunction Finish) can be +// re-optimized when it feeds into a nested conjunction/disjunction, rather than +// aborting the outer optimization on a failed type assertion. +func (i *unadornedPostingsIterator1Hit) ActualBitmap() *roaring.Bitmap { return nil } + +func (i *unadornedPostingsIterator1Hit) DocNum1Hit() (uint64, bool) { + return i.docNumOrig, true +} + +// ReplaceActual is a no-op: a 1-hit iterator has no actual bitmap, and callers +// only invoke ReplaceActual on iterators whose ActualBitmap is non-nil. +func (i *unadornedPostingsIterator1Hit) ReplaceActual(*roaring.Bitmap) {} + // ResetIterator resets the iterator to the original state. func (i *unadornedPostingsIterator1Hit) ResetIterator() { i.docNum = i.docNumOrig From 598238690d434e75710dd3410921efa939c85f79 Mon Sep 17 00:00:00 2001 From: Gautham Krithiwas Date: Thu, 16 Jul 2026 18:22:02 +0530 Subject: [PATCH 07/14] refactor: split top-N collector hit preparation into basic/full steps Addresses review feedback on the score-sort fast path, which duplicated the per-hit bookkeeping inline and was flagged as fragile: - Extract basicPrepare (hit count, hit number, max score) shared by the fast and slow paths, and canFastPrepare (the score-sort/no-fields/no-docID predicate previously inlined where fastPrepare is set). - Scaffold the fast-path decision at the call sites: basicPrepare, then either set the shared score sort value or call prepareDocumentMatch for the rest. - Split the KNN branch out into prepareKnnDocumentMatch instead of threading an isKnnDoc bool through prepareDocumentMatch. - Move the specialized comparator to search.CompareScoreDescending alongside SortOrder.Compare, selected via a new getOptimalCollectorCompare helper (mirroring getOptimalCollectorStore), and expand the newTopNCollector struct literal to set cachedScoring/cachedDesc/needDocIds up front. No functional change; search/collector suite passes under -race. Co-Authored-By: Claude Opus 4.8 (1M context) --- search/collector/topn.go | 200 ++++++++++++++++++++------------------- search/sort.go | 21 ++++ 2 files changed, 122 insertions(+), 99 deletions(-) diff --git a/search/collector/topn.go b/search/collector/topn.go index 3d9b2b16c..1298fb81e 100644 --- a/search/collector/topn.go +++ b/search/collector/topn.go @@ -83,8 +83,8 @@ type TopNCollector struct { nestedStore *collectStoreNested - // fastPrepare is true when prepareDocumentMatch can skip KNN/neededFields/ - // needDocIds/sort-value-compute branches — set once in Collect after loadID + // fastPrepare is true when a hit needs only basicPrepare plus the shared + // score sort value (see canFastPrepare) — set once in Collect after loadID // is known. Applies only to score-sorted queries with no field-loading needs. fastPrepare bool } @@ -131,39 +131,17 @@ func NewNestedTopNCollectorAfter(size int, sort search.SortOrder, after []string } func newTopNCollector(size int, skip int, sort search.SortOrder, nr index.NestedReader) *TopNCollector { - hc := &TopNCollector{size: size, skip: skip, sort: sort} - - // Compute once up-front; comparator and store creation need them. - hc.neededFields = sort.RequiredFields() - hc.cachedScoring = sort.CacheIsScore() - hc.cachedDesc = sort.CacheDescending() - - // Specialize for the common case: single score-descending sort. - // SortOrder.Compare iterates a slice and checks two bool flags per call, - // adding ~40% overhead to every heap comparison. The direct float64 path - // eliminates that overhead; measured at ~18% of total CPU for k=1000 queries. - if len(sort) == 1 && hc.cachedScoring[0] && hc.cachedDesc[0] { - hc.cmp = func(i, j *search.DocumentMatch) int { - if i.Score < j.Score { - return 1 // i is worse (lower score → closer to heap root) - } - if i.Score > j.Score { - return -1 - } - if i.HitNumber > j.HitNumber { - return 1 // tie-break: earlier hit is better - } - if i.HitNumber < j.HitNumber { - return -1 - } - return 0 - } - } else { - hc.cmp = func(i, j *search.DocumentMatch) int { - return hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, i, j) - } + hc := &TopNCollector{ + size: size, + skip: skip, + sort: sort, + neededFields: sort.RequiredFields(), + cachedScoring: sort.CacheIsScore(), + cachedDesc: sort.CacheDescending(), + needDocIds: sort.RequiresDocID(), } + hc.cmp = getOptimalCollectorCompare(hc) hc.store = getOptimalCollectorStore(size, skip, hc.cmp) if nr != nil { @@ -193,13 +171,24 @@ func newTopNCollector(size int, skip int, sort search.SortOrder, nr index.Nested hc.nestedStore = newStoreNested(nr, search.DescendantAdderCallbackFn(descAdder)) } - if sort.RequiresDocID() { - hc.needDocIds = true - } - return hc } +// getOptimalCollectorCompare returns the comparator the collector should use for +// its sort order. It specializes the overwhelmingly common single +// score-descending sort to a direct float64 comparison +// (search.CompareScoreDescending), which avoids the generic SortOrder.Compare +// path (a per-call slice iteration plus two bool-flag checks). All other sort +// orders fall back to SortOrder.Compare. +func getOptimalCollectorCompare(hc *TopNCollector) collectorCompare { + if len(hc.sort) == 1 && hc.cachedScoring[0] && hc.cachedDesc[0] { + return search.CompareScoreDescending + } + return func(i, j *search.DocumentMatch) int { + return hc.sort.Compare(hc.cachedScoring, hc.cachedDesc, i, j) + } +} + // Creates a dummy document to compare with for pagination. func createSearchAfterDocument(sort search.SortOrder, after []string) *search.DocumentMatch { encodedAfter := make([]string, len(after)) @@ -358,8 +347,7 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, } hc.needDocIds = hc.needDocIds || loadID - hc.fastPrepare = len(hc.neededFields) == 0 && !hc.needDocIds && - len(hc.sort) == 1 && hc.cachedScoring[0] + hc.fastPrepare = hc.canFastPrepare() select { case <-ctx.Done(): search.RecordSearchCost(ctx, search.AbortM, 0) @@ -400,9 +388,14 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, break } } - err = hc.prepareDocumentMatch(searchContext, reader, next, false) - if err != nil { - break + hc.basicPrepare(next) + if hc.fastPrepare { + next.Sort = sortByScoreOpt + } else { + err = hc.prepareDocumentMatch(searchContext, reader, next) + if err != nil { + break + } } err = dmHandler(next) if err != nil { @@ -427,9 +420,14 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, } } // no descendants at this point - err = hc.prepareDocumentMatch(searchContext, reader, currRoot, false) - if err != nil { - return err + hc.basicPrepare(currRoot) + if hc.fastPrepare { + currRoot.Sort = sortByScoreOpt + } else { + err = hc.prepareDocumentMatch(searchContext, reader, currRoot) + if err != nil { + return err + } } err = dmHandler(currRoot) @@ -443,7 +441,7 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, // we may have some knn hits left that did not match any of the top N tf-idf hits // we need to add them to the collector store to consider them as well. for _, knnDoc := range hc.knnHits { - err = hc.prepareDocumentMatch(searchContext, reader, knnDoc, true) + err = hc.prepareKnnDocumentMatch(searchContext, reader, knnDoc) if err != nil { return err } @@ -484,72 +482,57 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, var sortByScoreOpt = []string{"_score"} +// adjustDocumentMatch merges any KNN hit corresponding to d into d. Callers +// must only invoke it when hc.knnHits != nil (checked at the call sites). func (hc *TopNCollector) adjustDocumentMatch(ctx *search.SearchContext, reader index.IndexReader, d *search.DocumentMatch) (err error) { - if hc.knnHits != nil { - d.ID, err = reader.ExternalID(d.IndexInternalID) - if err != nil { - return err - } - if knnHit, ok := hc.knnHits[d.ID]; ok { - // we have a knn hit corresponding to this document - hc.hybridMergeCallback(d, knnHit) - // remove this knn hit from the map as it's already - // been merged - delete(hc.knnHits, d.ID) - } + d.ID, err = reader.ExternalID(d.IndexInternalID) + if err != nil { + return err + } + if knnHit, ok := hc.knnHits[d.ID]; ok { + // we have a knn hit corresponding to this document + hc.hybridMergeCallback(d, knnHit) + // remove this knn hit from the map as it's already + // been merged + delete(hc.knnHits, d.ID) } return nil } -func (hc *TopNCollector) prepareDocumentMatch(ctx *search.SearchContext, - reader index.IndexReader, d *search.DocumentMatch, isKnnDoc bool) (err error) { - - // Fast path: score-sorted queries with no field loading, no KNN, no docID needs. - // Skips all conditional branches that are always false in this common case. - if hc.fastPrepare && !isKnnDoc { - hc.total++ - d.HitNumber = hc.total - if d.Score > hc.maxScore { - hc.maxScore = d.Score - } - d.Sort = sortByScoreOpt - return nil +// basicPrepare performs the bookkeeping every collected hit needs regardless of +// sort/facet/knn specifics: counting the hit, assigning its hit number, and +// updating the running max score. It deliberately does not touch d.Sort (the +// sort value is score-specific for the fast path and computed/appended for the +// slow path). +func (hc *TopNCollector) basicPrepare(d *search.DocumentMatch) { + hc.total++ + d.HitNumber = hc.total + if d.Score > hc.maxScore { + hc.maxScore = d.Score } +} + +// canFastPrepare reports whether the fast preparation path applies: a single +// score-descending sort with no field loading and no docID needs, so a hit +// requires only basicPrepare plus the shared score sort value. +func (hc *TopNCollector) canFastPrepare() bool { + return len(hc.neededFields) == 0 && !hc.needDocIds && + len(hc.sort) == 1 && hc.cachedScoring[0] +} + +// prepareDocumentMatch does the non-fast preparation for a regular (non-KNN) +// hit: visiting field terms required for sort/facets, loading the docID if +// needed, and computing the sort value. basicPrepare must be called first. +func (hc *TopNCollector) prepareDocumentMatch(ctx *search.SearchContext, + reader index.IndexReader, d *search.DocumentMatch) (err error) { // visit field terms for features that require it (sort, facets) - if !isKnnDoc && len(hc.neededFields) > 0 { + if len(hc.neededFields) > 0 { err = hc.visitFieldTerms(reader, d, hc.updateFieldVisitor) if err != nil { return err } - } else if isKnnDoc && hc.facetsBuilder != nil { - // we need to visit the field terms for the knn document - // only for those fields that are required for faceting - // and not for sorting. This is because the knn document's - // sort value is already computed in the knn collector. - err = hc.visitFieldTerms(reader, d, func(field string, term []byte) { - if hc.facetsBuilder != nil { - hc.facetsBuilder.UpdateVisitor(field, term) - } - }) - if err != nil { - return err - } - } - - // increment total hits - hc.total++ - d.HitNumber = hc.total - - // update max score - if d.Score > hc.maxScore { - hc.maxScore = d.Score - } - // early exit as the document match had its sort value calculated in the knn - // collector itself - if isKnnDoc { - return nil } // see if we need to load ID (at this early stage, for example to sort on it) @@ -570,6 +553,25 @@ func (hc *TopNCollector) prepareDocumentMatch(ctx *search.SearchContext, return nil } +// prepareKnnDocumentMatch prepares a KNN hit. Its sort value was already +// computed by the KNN collector, so only facet field visiting (if any) and the +// basic bookkeeping are needed. +func (hc *TopNCollector) prepareKnnDocumentMatch(ctx *search.SearchContext, + reader index.IndexReader, d *search.DocumentMatch) (err error) { + + if hc.facetsBuilder != nil { + err = hc.visitFieldTerms(reader, d, func(field string, term []byte) { + hc.facetsBuilder.UpdateVisitor(field, term) + }) + if err != nil { + return err + } + } + + hc.basicPrepare(d) + return nil +} + func MakeTopNDocumentMatchHandler( ctx *search.SearchContext) (search.DocumentMatchHandler, bool, error) { var hc *TopNCollector diff --git a/search/sort.go b/search/sort.go index 64230c116..3c312b7dd 100644 --- a/search/sort.go +++ b/search/sort.go @@ -274,6 +274,27 @@ func (so SortOrder) Compare(cachedScoring, cachedDesc []bool, i, j *DocumentMatc return -1 } +// CompareScoreDescending compares two document matches for the common case of a +// single score-descending sort, breaking ties by natural (HitNumber) order. It +// is equivalent to SortOrder.Compare for that sort but avoids iterating the +// sort slice and checking the cachedScoring/cachedDesc flags per call. +func CompareScoreDescending(i, j *DocumentMatch) int { + if i.Score < j.Score { + return 1 + } + if i.Score > j.Score { + return -1 + } + // tie-break on natural index order: earlier hit sorts first + if i.HitNumber > j.HitNumber { + return 1 + } + if i.HitNumber < j.HitNumber { + return -1 + } + return 0 +} + func (so SortOrder) RequiresScore() bool { for _, soi := range so { if soi.RequiresScoring() { From cc8f3edfc9f74e958586106e86d38a0505602bd3 Mon Sep 17 00:00:00 2001 From: Gautham Krithiwas Date: Thu, 16 Jul 2026 18:22:15 +0530 Subject: [PATCH 08/14] chore: address PR review comments (comments, disjunction close, benchmarks) - Restore the concise "clear out the score breakdown map" comment in DocumentMatch.Reset (keep the nil-guard). - Drop the redundant fast-path comment in MergeFieldTermLocations. - Split the combined err/rv check after the unadorned disjunction optimization into separate error and success returns, per review suggestion. - Remove the standalone micro-benchmark files (merge_reset_bench_test.go, disj_unadorned_bench_test.go); the measurements live in the commit messages. Co-Authored-By: Claude Opus 4.8 (1M context) --- index/scorch/disj_unadorned_bench_test.go | 96 ----------------------- search/merge_reset_bench_test.go | 31 -------- search/search.go | 4 +- search/searcher/search_disjunction.go | 21 ++--- search/util.go | 2 +- 5 files changed, 13 insertions(+), 141 deletions(-) delete mode 100644 index/scorch/disj_unadorned_bench_test.go delete mode 100644 search/merge_reset_bench_test.go diff --git a/index/scorch/disj_unadorned_bench_test.go b/index/scorch/disj_unadorned_bench_test.go deleted file mode 100644 index 1014bdda9..000000000 --- a/index/scorch/disj_unadorned_bench_test.go +++ /dev/null @@ -1,96 +0,0 @@ -package scorch - -import ( - "context" - "fmt" - "sync" - "testing" - - "github.com/blevesearch/bleve/v2/document" - index "github.com/blevesearch/bleve_index_api" -) - -var ( - benchDisjOnce sync.Once - benchDisjIdx index.Index -) - -// 8 segments: segments 0-3 each contain one hot term (t0..t3, 500 docs each); -// segments 4-7 contain only "filler" — empty for every query term, exercising -// the empty-segment path in OptimizeTFRDisjunctionUnadorned.Finish(). -func getBenchDisjIdx(b *testing.B) index.Index { - benchDisjOnce.Do(func() { - cfg := CreateConfig("BenchDisjUnadorned") - if err := InitTest(cfg); err != nil { - b.Fatal(err) - } - aq := index.NewAnalysisQueue(1) - idx, err := NewScorch(Name, cfg, aq) - if err != nil { - b.Fatal(err) - } - if err := idx.Open(); err != nil { - b.Fatal(err) - } - for seg := 0; seg < 8; seg++ { - batch := index.NewBatch() - for d := 0; d < 500; d++ { - doc := document.NewDocument(fmt.Sprintf("%d-%d", seg, d)) - term := "filler" - if seg < 4 { - term = fmt.Sprintf("t%d", seg) - } - doc.AddField(document.NewTextField("f", []uint64{}, []byte(term))) - batch.Update(doc) - } - if err := idx.Batch(batch); err != nil { - b.Fatal(err) - } - } - benchDisjIdx = idx - }) - return benchDisjIdx -} - -var sinkOptimized index.Optimized - -func BenchmarkDisjunctionUnadornedFinish(b *testing.B) { - idx := getBenchDisjIdx(b) - r, err := idx.Reader() - if err != nil { - b.Fatal(err) - } - defer func() { _ = r.Close() }() - ctx := context.TODO() - terms := [][]byte{[]byte("t0"), []byte("t1"), []byte("t2"), []byte("t3")} - - b.ResetTimer() - b.ReportAllocs() - for n := 0; n < b.N; n++ { - var octx index.OptimizableContext - tfrs := make([]index.TermFieldReader, 0, len(terms)) - for _, t := range terms { - tfr, err := r.TermFieldReader(ctx, t, "f", false, false, false) - if err != nil { - b.Fatal(err) - } - tfrs = append(tfrs, tfr) - opt := tfr.(index.Optimizable) - octx, err = opt.Optimize("disjunction:unadorned", octx) - if err != nil { - b.Fatal(err) - } - } - o, err := octx.Finish() - if err != nil { - b.Fatal(err) - } - if o == nil { - b.Fatal("optimization aborted (nil result)") - } - sinkOptimized = o - for _, tfr := range tfrs { - _ = tfr.Close() - } - } -} diff --git a/search/merge_reset_bench_test.go b/search/merge_reset_bench_test.go deleted file mode 100644 index 4e167c5d7..000000000 --- a/search/merge_reset_bench_test.go +++ /dev/null @@ -1,31 +0,0 @@ -package search - -import "testing" - -// BenchmarkMergeFieldTermLocationsNoLocs: common no-highlighting case — several -// constituent matches, none carrying field term locations. Exercises the -// fast-path early return. -func BenchmarkMergeFieldTermLocationsNoLocs(b *testing.B) { - matches := make([]*DocumentMatch, 5) - for i := range matches { - matches[i] = &DocumentMatch{} - } - b.ResetTimer() - b.ReportAllocs() - var dest []FieldTermLocation - for n := 0; n < b.N; n++ { - dest = MergeFieldTermLocations(nil, matches) - } - _ = dest -} - -// BenchmarkDocumentMatchResetNilScoreBreakdown: Reset() on the common path where -// ScoreBreakdown is nil. Exercises the clear(scoreBreakdown) nil-guard. -func BenchmarkDocumentMatchResetNilScoreBreakdown(b *testing.B) { - dm := &DocumentMatch{} - b.ResetTimer() - b.ReportAllocs() - for n := 0; n < b.N; n++ { - dm.Reset() - } -} diff --git a/search/search.go b/search/search.go index 7b5d79c58..b89f8acc0 100644 --- a/search/search.go +++ b/search/search.go @@ -235,9 +235,7 @@ func (dm *DocumentMatch) Reset() *DocumentMatch { } // remember the score breakdown map scoreBreakdown := dm.ScoreBreakdown - // clear out the score breakdown map; nil-guard because clear(nil) still - // dispatches through the map runtime (~1ns), and ScoreBreakdown is nil on - // the common path (no KNN, no score-breakdown retrieval) + // clear out the score breakdown map if scoreBreakdown != nil { clear(scoreBreakdown) } diff --git a/search/searcher/search_disjunction.go b/search/searcher/search_disjunction.go index 232e434bd..d00cda835 100644 --- a/search/searcher/search_disjunction.go +++ b/search/searcher/search_disjunction.go @@ -68,17 +68,18 @@ func newDisjunctionSearcher(ctx context.Context, indexReader index.IndexReader, optionsDisjunctionOptimizable(options) { rv, err := optimizeCompositeSearcher(ctx, "disjunction:unadorned", indexReader, qsearchers, options) - if err != nil || rv != nil { - if rv != nil { - // Finish() extracted all it needs (bitmaps are cloned/new). - // Close the original sub-searchers so their TFRs are returned - // to the snapshot per-field pool, avoiding re-allocation of - // Dictionary+FST Reader objects on the next query. - for _, s := range qsearchers { - _ = s.Close() - } + if err != nil { + return nil, err + } + if rv != nil { + // Finish() extracted all it needs (bitmaps are cloned/new). + // Close the original sub-searchers so their TFRs are returned + // to the snapshot per-field pool, avoiding re-allocation of + // Dictionary+FST Reader objects on the next query. + for _, s := range qsearchers { + _ = s.Close() } - return rv, err + return rv, nil } } } diff --git a/search/util.go b/search/util.go index 14d6349d3..066f89555 100644 --- a/search/util.go +++ b/search/util.go @@ -55,7 +55,7 @@ func MergeFieldTermLocations(dest []FieldTermLocation, matches []*DocumentMatch) } } if n == len(dest) { - return dest // fast path: no constituent has field term locations to merge + return dest } if cap(dest) < n { dest = append(make([]FieldTermLocation, 0, n), dest...) From 0564e4fbdc18dc749c9e88500a3eab1709838ef9 Mon Sep 17 00:00:00 2001 From: Gautham Krithiwas Date: Fri, 17 Jul 2026 09:05:53 +0530 Subject: [PATCH 09/14] test: assert composite-optimization iterator types implement the interfaces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verifying the reviewer's interface-completeness concern, add compile-time assertions that every postings-iterator type placed into an optimized termFieldReader's per-segment slice implements OptimizablePostingsIterator (empty, unadorned bitmap, unadorned 1-hit) — the interface a nested conjunction/disjunction optimization type-asserts against, whose absence silently aborts the optimization (as was the case for the 1-hit iterator, fixed earlier on this branch). The two stateful iterators additionally assert ResetablePostingsIterator so termFieldReader reuse rewinds them. The empty iterator is stateless and intentionally does not implement it: ResetIterator is only ever invoked via an optional type assertion, so there is nothing to reset and no reason to add it. Co-Authored-By: Claude Opus 4.8 (1M context) --- index/scorch/empty.go | 7 +++---- index/scorch/unadorned.go | 17 +++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/index/scorch/empty.go b/index/scorch/empty.go index c6d1806ef..eabe29f33 100644 --- a/index/scorch/empty.go +++ b/index/scorch/empty.go @@ -41,11 +41,10 @@ func (e *emptyPostingsIterator) ResetBytesRead(uint64) {} func (e *emptyPostingsIterator) BytesWritten() uint64 { return 0 } -// Implement OptimizablePostingsIterator so an empty iterator can participate in -// nested conjunction/disjunction optimizations instead of aborting them; a nil -// bitmap and no 1-hit contribute nothing to any AND/OR. func (e *emptyPostingsIterator) ActualBitmap() *roaring.Bitmap { return nil } -func (e *emptyPostingsIterator) DocNum1Hit() (uint64, bool) { return 0, false } + +func (e *emptyPostingsIterator) DocNum1Hit() (uint64, bool) { return 0, false } + func (e *emptyPostingsIterator) ReplaceActual(*roaring.Bitmap) {} var anEmptyPostingsIterator = &emptyPostingsIterator{} diff --git a/index/scorch/unadorned.go b/index/scorch/unadorned.go index 51fbcd791..cefc10b34 100644 --- a/index/scorch/unadorned.go +++ b/index/scorch/unadorned.go @@ -198,6 +198,23 @@ type ResetablePostingsIterator interface { ResetIterator() } +// Compile-time guarantee that every postings-iterator type placed into an +// optimized termFieldReader's per-segment iterator slice implements +// OptimizablePostingsIterator, so a nested conjunction/disjunction optimization +// composes it instead of silently aborting on a failed type assertion. +// +// The two stateful iterators must additionally be resettable so termFieldReader +// reuse rewinds them; the empty iterator is stateless and intentionally opts out +// (ResetIterator is invoked via an optional type assertion). +var ( + _ segment.OptimizablePostingsIterator = (*emptyPostingsIterator)(nil) + _ segment.OptimizablePostingsIterator = (*unadornedPostingsIteratorBitmap)(nil) + _ segment.OptimizablePostingsIterator = (*unadornedPostingsIterator1Hit)(nil) + + _ ResetablePostingsIterator = (*unadornedPostingsIteratorBitmap)(nil) + _ ResetablePostingsIterator = (*unadornedPostingsIterator1Hit)(nil) +) + type UnadornedPosting struct { docNum uint64 } From 0ba628e01177d85282581224b737c77913013081 Mon Sep 17 00:00:00 2001 From: Gautham Krithiwas Date: Fri, 17 Jul 2026 09:58:03 +0530 Subject: [PATCH 10/14] remove redundant comments --- search/searcher/search_disjunction.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/search/searcher/search_disjunction.go b/search/searcher/search_disjunction.go index d00cda835..9a7729142 100644 --- a/search/searcher/search_disjunction.go +++ b/search/searcher/search_disjunction.go @@ -72,10 +72,6 @@ func newDisjunctionSearcher(ctx context.Context, indexReader index.IndexReader, return nil, err } if rv != nil { - // Finish() extracted all it needs (bitmaps are cloned/new). - // Close the original sub-searchers so their TFRs are returned - // to the snapshot per-field pool, avoiding re-allocation of - // Dictionary+FST Reader objects on the next query. for _, s := range qsearchers { _ = s.Close() } From 206c0ff7299436b7d7ea6d26bb5540b87434e2d3 Mon Sep 17 00:00:00 2001 From: Gautham Krithiwas Date: Fri, 17 Jul 2026 10:02:31 +0530 Subject: [PATCH 11/14] refactor: rename adjustDocumentMatch to adjustKNNDocumentMatch The method only adjusts a hit when a corresponding KNN hit exists (it is called solely from the knnHits != nil branches). Rename it to make that scope explicit, per review feedback. Co-Authored-By: Claude Opus 4.8 (1M context) --- search/collector/topn.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/search/collector/topn.go b/search/collector/topn.go index 1298fb81e..22c06987f 100644 --- a/search/collector/topn.go +++ b/search/collector/topn.go @@ -383,7 +383,7 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, } if next != nil { if hc.knnHits != nil { - err = hc.adjustDocumentMatch(searchContext, reader, next) + err = hc.adjustKNNDocumentMatch(searchContext, reader, next) if err != nil { break } @@ -414,7 +414,7 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, currRoot := hc.nestedStore.Current() if currRoot != nil { if hc.knnHits != nil { - err = hc.adjustDocumentMatch(searchContext, reader, currRoot) + err = hc.adjustKNNDocumentMatch(searchContext, reader, currRoot) if err != nil { return err } @@ -482,9 +482,9 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, var sortByScoreOpt = []string{"_score"} -// adjustDocumentMatch merges any KNN hit corresponding to d into d. Callers +// adjustKNNDocumentMatch merges any KNN hit corresponding to d into d. Callers // must only invoke it when hc.knnHits != nil (checked at the call sites). -func (hc *TopNCollector) adjustDocumentMatch(ctx *search.SearchContext, +func (hc *TopNCollector) adjustKNNDocumentMatch(ctx *search.SearchContext, reader index.IndexReader, d *search.DocumentMatch) (err error) { d.ID, err = reader.ExternalID(d.IndexInternalID) if err != nil { From 17efedfb8a7047f021519db96689b754612262d1 Mon Sep 17 00:00:00 2001 From: Thejas-bhat Date: Tue, 21 Jul 2026 12:00:13 -0700 Subject: [PATCH 12/14] code cleanup + review comments --- index/scorch/optimize.go | 24 +++++++++++------------- search/collector/topn.go | 21 ++------------------- search/sort.go | 5 +---- 3 files changed, 14 insertions(+), 36 deletions(-) diff --git a/index/scorch/optimize.go b/index/scorch/optimize.go index aec7e6070..7ad28a673 100644 --- a/index/scorch/optimize.go +++ b/index/scorch/optimize.go @@ -337,18 +337,6 @@ func (o *OptimizeTFRDisjunctionUnadorned) Finish() (rv index.Optimized, err erro } } - // Fast path: no hits in this segment — reuse the zero-alloc empty sentinel. - if len(actualBMs) == 0 && len(docNums) == 0 { - oTFR.iterators[i] = anEmptyPostingsIterator - continue - } - - // Fast path: exactly one 1-hit doc with no bitmaps. - if len(actualBMs) == 0 && len(docNums) == 1 { - oTFR.iterators[i] = newUnadornedPostingsIteratorFrom1Hit(uint64(docNums[0])) - continue - } - var bm *roaring.Bitmap if len(actualBMs) > 2 { bm = roaring.HeapOr(actualBMs...) @@ -357,7 +345,17 @@ func (o *OptimizeTFRDisjunctionUnadorned) Finish() (rv index.Optimized, err erro } else if len(actualBMs) == 1 { bm = actualBMs[0].Clone() } else { - bm = roaring.New() + if len(docNums) == 0 { + // no hits, reuse the zero-alloc empty sentinel + oTFR.iterators[i] = anEmptyPostingsIterator + continue + } else if len(docNums) == 1 { + // 1-hit optimized + oTFR.iterators[i] = newUnadornedPostingsIteratorFrom1Hit(uint64(docNums[0])) + continue + } else { + bm = roaring.New() + } } bm.AddMany(docNums) diff --git a/search/collector/topn.go b/search/collector/topn.go index 22c06987f..088edeb49 100644 --- a/search/collector/topn.go +++ b/search/collector/topn.go @@ -174,12 +174,6 @@ func newTopNCollector(size int, skip int, sort search.SortOrder, nr index.Nested return hc } -// getOptimalCollectorCompare returns the comparator the collector should use for -// its sort order. It specializes the overwhelmingly common single -// score-descending sort to a direct float64 comparison -// (search.CompareScoreDescending), which avoids the generic SortOrder.Compare -// path (a per-call slice iteration plus two bool-flag checks). All other sort -// orders fall back to SortOrder.Compare. func getOptimalCollectorCompare(hc *TopNCollector) collectorCompare { if len(hc.sort) == 1 && hc.cachedScoring[0] && hc.cachedDesc[0] { return search.CompareScoreDescending @@ -482,8 +476,6 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, var sortByScoreOpt = []string{"_score"} -// adjustKNNDocumentMatch merges any KNN hit corresponding to d into d. Callers -// must only invoke it when hc.knnHits != nil (checked at the call sites). func (hc *TopNCollector) adjustKNNDocumentMatch(ctx *search.SearchContext, reader index.IndexReader, d *search.DocumentMatch) (err error) { d.ID, err = reader.ExternalID(d.IndexInternalID) @@ -491,20 +483,13 @@ func (hc *TopNCollector) adjustKNNDocumentMatch(ctx *search.SearchContext, return err } if knnHit, ok := hc.knnHits[d.ID]; ok { - // we have a knn hit corresponding to this document + // merge this document's hit with its knn score, expl etc. and remove it from the map hc.hybridMergeCallback(d, knnHit) - // remove this knn hit from the map as it's already - // been merged delete(hc.knnHits, d.ID) } return nil } -// basicPrepare performs the bookkeeping every collected hit needs regardless of -// sort/facet/knn specifics: counting the hit, assigning its hit number, and -// updating the running max score. It deliberately does not touch d.Sort (the -// sort value is score-specific for the fast path and computed/appended for the -// slow path). func (hc *TopNCollector) basicPrepare(d *search.DocumentMatch) { hc.total++ d.HitNumber = hc.total @@ -513,10 +498,8 @@ func (hc *TopNCollector) basicPrepare(d *search.DocumentMatch) { } } -// canFastPrepare reports whether the fast preparation path applies: a single -// score-descending sort with no field loading and no docID needs, so a hit -// requires only basicPrepare plus the shared score sort value. func (hc *TopNCollector) canFastPrepare() bool { + return len(hc.neededFields) == 0 && !hc.needDocIds && len(hc.sort) == 1 && hc.cachedScoring[0] } diff --git a/search/sort.go b/search/sort.go index 3c312b7dd..43bfcc300 100644 --- a/search/sort.go +++ b/search/sort.go @@ -274,11 +274,8 @@ func (so SortOrder) Compare(cachedScoring, cachedDesc []bool, i, j *DocumentMatc return -1 } -// CompareScoreDescending compares two document matches for the common case of a -// single score-descending sort, breaking ties by natural (HitNumber) order. It -// is equivalent to SortOrder.Compare for that sort but avoids iterating the -// sort slice and checking the cachedScoring/cachedDesc flags per call. func CompareScoreDescending(i, j *DocumentMatch) int { + // first try to sort the two hits based on their score value if i.Score < j.Score { return 1 } From 2a4e02a7031a2f352d684460142e5252e479cfdd Mon Sep 17 00:00:00 2001 From: Thejas-bhat Date: Tue, 21 Jul 2026 13:18:42 -0700 Subject: [PATCH 13/14] code cleanup + review comments --- index/scorch/unadorned.go | 21 --------------------- search/collector/topn.go | 8 ++++---- 2 files changed, 4 insertions(+), 25 deletions(-) diff --git a/index/scorch/unadorned.go b/index/scorch/unadorned.go index cefc10b34..e73b14b43 100644 --- a/index/scorch/unadorned.go +++ b/index/scorch/unadorned.go @@ -168,10 +168,6 @@ func (i *unadornedPostingsIterator1Hit) BytesWritten() uint64 { func (i *unadornedPostingsIterator1Hit) ResetBytesRead(uint64) {} -// Implement OptimizablePostingsIterator so a 1-hit iterator produced by one -// optimization pass (e.g. the unadorned conjunction/disjunction Finish) can be -// re-optimized when it feeds into a nested conjunction/disjunction, rather than -// aborting the outer optimization on a failed type assertion. func (i *unadornedPostingsIterator1Hit) ActualBitmap() *roaring.Bitmap { return nil } func (i *unadornedPostingsIterator1Hit) DocNum1Hit() (uint64, bool) { @@ -198,23 +194,6 @@ type ResetablePostingsIterator interface { ResetIterator() } -// Compile-time guarantee that every postings-iterator type placed into an -// optimized termFieldReader's per-segment iterator slice implements -// OptimizablePostingsIterator, so a nested conjunction/disjunction optimization -// composes it instead of silently aborting on a failed type assertion. -// -// The two stateful iterators must additionally be resettable so termFieldReader -// reuse rewinds them; the empty iterator is stateless and intentionally opts out -// (ResetIterator is invoked via an optional type assertion). -var ( - _ segment.OptimizablePostingsIterator = (*emptyPostingsIterator)(nil) - _ segment.OptimizablePostingsIterator = (*unadornedPostingsIteratorBitmap)(nil) - _ segment.OptimizablePostingsIterator = (*unadornedPostingsIterator1Hit)(nil) - - _ ResetablePostingsIterator = (*unadornedPostingsIteratorBitmap)(nil) - _ ResetablePostingsIterator = (*unadornedPostingsIterator1Hit)(nil) -) - type UnadornedPosting struct { docNum uint64 } diff --git a/search/collector/topn.go b/search/collector/topn.go index 088edeb49..00cea3617 100644 --- a/search/collector/topn.go +++ b/search/collector/topn.go @@ -83,9 +83,6 @@ type TopNCollector struct { nestedStore *collectStoreNested - // fastPrepare is true when a hit needs only basicPrepare plus the shared - // score sort value (see canFastPrepare) — set once in Collect after loadID - // is known. Applies only to score-sorted queries with no field-loading needs. fastPrepare bool } @@ -341,6 +338,10 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, } hc.needDocIds = hc.needDocIds || loadID + + // fastPrepare is set to true when a hit needs only basicPrepare plus the shared + // score sort value (see canFastPrepare). Applies only to score-sorted queries + // with no field-loading needs. hc.fastPrepare = hc.canFastPrepare() select { case <-ctx.Done(): @@ -499,7 +500,6 @@ func (hc *TopNCollector) basicPrepare(d *search.DocumentMatch) { } func (hc *TopNCollector) canFastPrepare() bool { - return len(hc.neededFields) == 0 && !hc.needDocIds && len(hc.sort) == 1 && hc.cachedScoring[0] } From b7e232a0f3c0be73b12330a1eca15f4dcc9b600b Mon Sep 17 00:00:00 2001 From: Thejas-bhat Date: Tue, 21 Jul 2026 22:18:41 -0700 Subject: [PATCH 14/14] naming changes --- search/collector/topn.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/search/collector/topn.go b/search/collector/topn.go index 00cea3617..d7fd27f23 100644 --- a/search/collector/topn.go +++ b/search/collector/topn.go @@ -436,7 +436,7 @@ func (hc *TopNCollector) Collect(ctx context.Context, searcher search.Searcher, // we may have some knn hits left that did not match any of the top N tf-idf hits // we need to add them to the collector store to consider them as well. for _, knnDoc := range hc.knnHits { - err = hc.prepareKnnDocumentMatch(searchContext, reader, knnDoc) + err = hc.prepareKNNDocumentMatch(searchContext, reader, knnDoc) if err != nil { return err } @@ -536,10 +536,10 @@ func (hc *TopNCollector) prepareDocumentMatch(ctx *search.SearchContext, return nil } -// prepareKnnDocumentMatch prepares a KNN hit. Its sort value was already +// prepareKNNDocumentMatch prepares a KNN hit. Its sort value was already // computed by the KNN collector, so only facet field visiting (if any) and the // basic bookkeeping are needed. -func (hc *TopNCollector) prepareKnnDocumentMatch(ctx *search.SearchContext, +func (hc *TopNCollector) prepareKNNDocumentMatch(ctx *search.SearchContext, reader index.IndexReader, d *search.DocumentMatch) (err error) { if hc.facetsBuilder != nil {