Skip to content

Commit 1e257aa

Browse files
capemoxclaude
andcommitted
perf: omit postings-count read for fuzzy/regexp candidate collection
Fuzzy and regexp candidate collection iterate the field dictionary via the levenshtein/regexp automaton and use only the term and edit distance — DictEntry.Count is discarded. The segment iterator nonetheless read each visited term's postings list to compute that count. Add a termDictionaryOmitCount optional interface (AutomatonIteratorOmitCount) and use it from the fuzzy and regexp field-dict constructors so the count (and its per-term postings read) is skipped. This is opt-in per call site: FieldDict / FieldDictPrefix / FieldDictRange still populate counts, since faceting and public dictionary APIs rely on them. The wiring uses a type assertion, so it degrades gracefully: with a zapx that predates AutomatonIteratorOmitCount it falls back to the count-reading path (TestFieldDictFuzzyAutomatonOmitsCount, added here, skips in that case). The optimization activates once the zapx/v17 dependency is bumped to a release that includes the omit-count iterator. Benchmark (BenchmarkFuzzyCandidateCollection from the previous commit; scorch index, 1000 terms / 3000 docs, fuzziness-2 query matching 280 candidates), parent vs this commit, Apple M4 Pro, count=12 via benchstat: sec/op 135.5µ -> 122.4µ -9.7% (p=0.000) B/op 667.1Ki -> 659.9Ki -1.1% (p=0.000) allocs/op 975 -> 691 -29.1% (p=0.000) Combined with the previous commit: -15.0% sec/op, -30.1% allocs/op vs the original candidate-collection path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c29f000 commit 1e257aa

3 files changed

Lines changed: 111 additions & 4 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package scorch
2+
3+
import (
4+
"fmt"
5+
"testing"
6+
7+
"github.com/blevesearch/bleve/v2/document"
8+
index "github.com/blevesearch/bleve_index_api"
9+
)
10+
11+
// TestFieldDictFuzzyAutomatonOmitsCount verifies that the fuzzy automaton field
12+
// dictionary is wired to the count-omitting iterator: the segment dictionary
13+
// implements the optional interface, and the collected candidate entries have
14+
// their (discarded) Count left at zero rather than incurring a postings read.
15+
func TestFieldDictFuzzyAutomatonOmitsCount(t *testing.T) {
16+
cfg := CreateConfig("TestFieldDictFuzzyAutomatonOmitsCount")
17+
if err := InitTest(cfg); err != nil {
18+
t.Fatal(err)
19+
}
20+
defer func() { _ = DestroyTest(cfg) }()
21+
analysisQueue := index.NewAnalysisQueue(1)
22+
idx, err := NewScorch(Name, cfg, analysisQueue)
23+
if err != nil {
24+
t.Fatal(err)
25+
}
26+
if err = idx.Open(); err != nil {
27+
t.Fatal(err)
28+
}
29+
defer func() { _ = idx.Close() }()
30+
31+
// Each doc's "desc" field is a single token (nil analyzer => whole value is
32+
// one term). Repeating terms across docs yields multi-doc postings lists.
33+
b := index.NewBatch()
34+
for d := 0; d < 300; d++ {
35+
doc := document.NewDocument(fmt.Sprintf("%d", d))
36+
doc.AddField(document.NewTextField("desc", nil, []byte(fmt.Sprintf("term%04d", d%100))))
37+
b.Update(doc)
38+
}
39+
if err = idx.Batch(b); err != nil {
40+
t.Fatal(err)
41+
}
42+
43+
r, err := idx.Reader()
44+
if err != nil {
45+
t.Fatal(err)
46+
}
47+
defer func() { _ = r.Close() }()
48+
is := r.(*IndexSnapshot)
49+
50+
// the segment dictionary must implement the count-omit optional interface,
51+
// otherwise the wiring silently falls back to the count-reading path.
52+
seg := is.segment[0].segment
53+
dict, err := seg.Dictionary("desc")
54+
if err != nil {
55+
t.Fatal(err)
56+
}
57+
if _, ok := dict.(termDictionaryOmitCount); !ok {
58+
// The omit-count wiring only activates once the zapx dependency ships
59+
// AutomatonIteratorOmitCount; until then the code falls back to the
60+
// count-reading path, so there is nothing to assert.
61+
t.Skipf("segment dict %T does not implement termDictionaryOmitCount "+
62+
"(zapx dependency predates the omit-count iterator)", dict)
63+
}
64+
65+
fd, _, err := is.FieldDictFuzzyAutomaton("desc", "term0050", 2, "")
66+
if err != nil {
67+
t.Fatal(err)
68+
}
69+
n := 0
70+
tfd, err := fd.Next()
71+
for err == nil && tfd != nil {
72+
n++
73+
if tfd.Count != 0 {
74+
t.Fatalf("expected omitted (zero) count for %q, got %d", tfd.Term, tfd.Count)
75+
}
76+
tfd, err = fd.Next()
77+
}
78+
if err != nil {
79+
t.Fatal(err)
80+
}
81+
if n == 0 {
82+
t.Fatal("expected at least one fuzzy candidate term")
83+
}
84+
t.Logf("collected %d candidate terms, all with omitted count", n)
85+
}

index/scorch/snapshot_index.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,9 @@ func (is *IndexSnapshot) fieldDictRegexp(field string,
329329
return nil, nil, err
330330
}
331331

332-
fd, err := is.newIndexSnapshotFieldDict(field, func(is segment.TermDictionary) segment.DictionaryIterator {
333-
return is.AutomatonIterator(a, prefixBeg, prefixEnd)
332+
fd, err := is.newIndexSnapshotFieldDict(field, func(dict segment.TermDictionary) segment.DictionaryIterator {
333+
// regexp/wildcard candidate collection discards DictEntry.Count.
334+
return automatonIteratorOmitCount(dict, a, prefixBeg, prefixEnd)
334335
}, false)
335336
if err != nil {
336337
return nil, nil, err
@@ -379,8 +380,9 @@ func (is *IndexSnapshot) fieldDictFuzzy(field string,
379380
prefixBeg = []byte(prefix)
380381
prefixEnd = calculateExclusiveEndFromPrefix(prefixBeg)
381382
}
382-
fd, err := is.newIndexSnapshotFieldDict(field, func(is segment.TermDictionary) segment.DictionaryIterator {
383-
return is.AutomatonIterator(a, prefixBeg, prefixEnd)
383+
fd, err := is.newIndexSnapshotFieldDict(field, func(dict segment.TermDictionary) segment.DictionaryIterator {
384+
// fuzzy candidate collection discards DictEntry.Count.
385+
return automatonIteratorOmitCount(dict, a, prefixBeg, prefixEnd)
384386
}, false)
385387
if err != nil {
386388
return nil, nil, err

index/scorch/snapshot_index_dict.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,26 @@ import (
2121
segment "github.com/blevesearch/scorch_segment_api/v2"
2222
)
2323

24+
// termDictionaryOmitCount is an optional interface a segment's TermDictionary
25+
// may implement to return an automaton iterator that skips the per-term
26+
// postings read used to populate DictEntry.Count. It is used by candidate-term
27+
// collection (fuzzy/regexp) where the count is discarded, and falls back to the
28+
// regular AutomatonIterator when the segment does not implement it.
29+
type termDictionaryOmitCount interface {
30+
AutomatonIteratorOmitCount(a segment.Automaton,
31+
startKeyInclusive, endKeyExclusive []byte) segment.DictionaryIterator
32+
}
33+
34+
// automatonIteratorOmitCount returns a count-omitting automaton iterator when
35+
// the dictionary supports it, otherwise the standard one.
36+
func automatonIteratorOmitCount(dict segment.TermDictionary, a segment.Automaton,
37+
startKeyInclusive, endKeyExclusive []byte) segment.DictionaryIterator {
38+
if oc, ok := dict.(termDictionaryOmitCount); ok {
39+
return oc.AutomatonIteratorOmitCount(a, startKeyInclusive, endKeyExclusive)
40+
}
41+
return dict.AutomatonIterator(a, startKeyInclusive, endKeyExclusive)
42+
}
43+
2444
type segmentDictCursor struct {
2545
dict segment.TermDictionary
2646
itr segment.DictionaryIterator

0 commit comments

Comments
 (0)