Skip to content

Commit c29f000

Browse files
capemoxclaude
andcommitted
perf: trim fuzzy candidate-collection overhead
Three standalone optimizations in the fuzzy candidate-collection path (search/searcher/search_fuzzy.go), none of which change results: - findFuzzyCandidateTerms kept a termSet map and did a map insert per candidate. But the dictionary iterator already yields each term once (merged across segments), so the map is only needed to de-duplicate synonym terms against dictionary terms. Skip the map entirely when the field has no synonyms (the common case) — the non-automaton fallback path already appends without a map, confirming this. - prefixTerm was built by concatenating runes one at a time (O(n^2), realloc per rune). Replace with a behavior-preserving slice of the original string at the first rune boundary at/after prefix. - Hoist the repeated ctx.Value(FuzzyMatchPhraseKey) lookups in NewFuzzySearcher into a single lookup. Benchmark (BenchmarkFuzzyCandidateCollection, added here: 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 144.0µ -> 135.5µ -5.9% (p=0.000) B/op 693.3Ki -> 667.1Ki -3.8% (p=0.000) allocs/op 988 -> 975 -1.3% (p=0.000) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 29d42cb commit c29f000

2 files changed

Lines changed: 179 additions & 38 deletions

File tree

search/searcher/search_fuzzy.go

Lines changed: 52 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,13 @@ func NewFuzzySearcher(ctx context.Context, indexReader index.IndexReader, term s
6666
}
6767

6868
// Note: we don't byte slice the term for a prefix because of runes.
69-
prefixTerm := ""
70-
for i, r := range term {
71-
if i < prefix {
72-
prefixTerm += string(r)
73-
} else {
69+
// prefixTerm is the leading runes of term whose start byte offset is
70+
// below prefix; slicing term at the first rune boundary at/after prefix
71+
// yields the same string without per-rune string concatenation.
72+
prefixTerm := term
73+
for i := range term {
74+
if i >= prefix {
75+
prefixTerm = term[:i]
7476
break
7577
}
7678
}
@@ -89,21 +91,21 @@ func NewFuzzySearcher(ctx context.Context, indexReader index.IndexReader, term s
8991
dictBytesRead = fuzzyCandidates.bytesRead
9092
}
9193

94+
var fuzzyTermMatches map[string][]string
9295
if ctx != nil {
9396
reportIOStats(ctx, dictBytesRead)
9497
search.RecordSearchCost(ctx, search.AddM, dictBytesRead)
95-
fuzzyTermMatches := ctx.Value(search.FuzzyMatchPhraseKey)
96-
if fuzzyTermMatches != nil {
97-
fuzzyTermMatches.(map[string][]string)[term] = candidates
98+
if ftm := ctx.Value(search.FuzzyMatchPhraseKey); ftm != nil {
99+
fuzzyTermMatches = ftm.(map[string][]string)
98100
}
99101
}
102+
if fuzzyTermMatches != nil {
103+
fuzzyTermMatches[term] = candidates
104+
}
100105
// check if the candidates are empty or have one term which is the term itself
101106
if len(candidates) == 0 || (len(candidates) == 1 && candidates[0] == term) {
102-
if ctx != nil {
103-
fuzzyTermMatches := ctx.Value(search.FuzzyMatchPhraseKey)
104-
if fuzzyTermMatches != nil {
105-
fuzzyTermMatches.(map[string][]string)[term] = []string{term}
106-
}
107+
if fuzzyTermMatches != nil {
108+
fuzzyTermMatches[term] = []string{term}
107109
}
108110
return NewTermSearcher(ctx, indexReader, term, field, boost, options)
109111
}
@@ -156,15 +158,33 @@ func findFuzzyCandidateTerms(ctx context.Context, indexReader index.IndexReader,
156158
// the levenshtein automaton based iterator to collect the
157159
// candidate terms
158160
if ir, ok := indexReader.(index.IndexReaderFuzzy); ok {
159-
termSet := make(map[string]struct{})
161+
// Synonym terms (if any) for this field need to be matched against the
162+
// automaton and de-duplicated against the dictionary candidates.
163+
var synonymTerms map[string][]string
164+
if ctx != nil {
165+
if fts, ok := ctx.Value(search.FieldTermSynonymMapKey).(search.FieldTermSynonymMap); ok {
166+
synonymTerms = fts[field]
167+
}
168+
}
169+
// The dictionary iterator already yields each term exactly once (merged
170+
// across segments), so the only purpose of termSet is to de-duplicate
171+
// synonym terms against the dictionary candidates. When there are no
172+
// synonyms, skip the map entirely to avoid a per-candidate map insert.
173+
var termSet map[string]struct{}
174+
if len(synonymTerms) > 0 {
175+
termSet = make(map[string]struct{}, len(synonymTerms))
176+
}
160177
addCandidateTerm := func(term string, editDistance uint8) error {
161-
if _, exists := termSet[term]; !exists {
162-
termSet[term] = struct{}{}
163-
rv.candidates = append(rv.candidates, term)
164-
rv.editDistances = append(rv.editDistances, editDistance)
165-
if tooManyClauses(len(rv.candidates)) {
166-
return tooManyClausesErr(field, len(rv.candidates))
178+
if termSet != nil {
179+
if _, exists := termSet[term]; exists {
180+
return nil
167181
}
182+
termSet[term] = struct{}{}
183+
}
184+
rv.candidates = append(rv.candidates, term)
185+
rv.editDistances = append(rv.editDistances, editDistance)
186+
if tooManyClauses(len(rv.candidates)) {
187+
return tooManyClausesErr(field, len(rv.candidates))
168188
}
169189
return nil
170190
}
@@ -188,24 +208,18 @@ func findFuzzyCandidateTerms(ctx context.Context, indexReader index.IndexReader,
188208
if err != nil {
189209
return nil, err
190210
}
191-
if ctx != nil {
192-
if fts, ok := ctx.Value(search.FieldTermSynonymMapKey).(search.FieldTermSynonymMap); ok {
193-
if ts, exists := fts[field]; exists {
194-
for term := range ts {
195-
if _, exists := termSet[term]; exists {
196-
continue
197-
}
198-
if !strings.HasPrefix(term, prefixTerm) {
199-
continue
200-
}
201-
match, editDistance := a.MatchAndDistance(term)
202-
if match {
203-
err = addCandidateTerm(term, editDistance)
204-
if err != nil {
205-
return nil, err
206-
}
207-
}
208-
}
211+
for term := range synonymTerms {
212+
if _, exists := termSet[term]; exists {
213+
continue
214+
}
215+
if !strings.HasPrefix(term, prefixTerm) {
216+
continue
217+
}
218+
match, editDistance := a.MatchAndDistance(term)
219+
if match {
220+
err = addCandidateTerm(term, editDistance)
221+
if err != nil {
222+
return nil, err
209223
}
210224
}
211225
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// Copyright (c) 2024 Couchbase, Inc.
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package searcher
16+
17+
import (
18+
"context"
19+
"fmt"
20+
"testing"
21+
22+
"github.com/blevesearch/bleve/v2/document"
23+
"github.com/blevesearch/bleve/v2/index/scorch"
24+
"github.com/blevesearch/bleve/v2/search"
25+
index "github.com/blevesearch/bleve_index_api"
26+
)
27+
28+
// buildFuzzyBenchIndex builds a scorch index whose "desc" field has numTerms
29+
// distinct terms spread across numDocs documents (perDoc terms per document),
30+
// so candidate terms have real multi-doc postings lists.
31+
func buildFuzzyBenchIndex(b *testing.B, numTerms, numDocs, perDoc int) index.Index {
32+
analysisQueue := index.NewAnalysisQueue(1)
33+
idx, err := scorch.NewScorch(scorch.Name,
34+
map[string]interface{}{"path": b.TempDir()}, analysisQueue)
35+
if err != nil {
36+
b.Fatal(err)
37+
}
38+
if err = idx.Open(); err != nil {
39+
b.Fatal(err)
40+
}
41+
42+
terms := make([]string, numTerms)
43+
for i := range terms {
44+
terms[i] = fmt.Sprintf("term%04d", i)
45+
}
46+
47+
batch := index.NewBatch()
48+
for d := 0; d < numDocs; d++ {
49+
doc := document.NewDocument(fmt.Sprintf("%d", d))
50+
buf := make([]byte, 0, perDoc*9)
51+
for j := 0; j < perDoc; j++ {
52+
if j > 0 {
53+
buf = append(buf, ' ')
54+
}
55+
buf = append(buf, terms[(d*perDoc+j)%numTerms]...)
56+
}
57+
doc.AddField(document.NewTextFieldCustom("desc", nil, buf,
58+
twoDocIndexDescIndexingOptions, testAnalyzer))
59+
batch.Update(doc)
60+
}
61+
if err = idx.Batch(batch); err != nil {
62+
b.Fatal(err)
63+
}
64+
return idx
65+
}
66+
67+
// BenchmarkFuzzyCandidateCollection isolates candidate-term collection
68+
// (findFuzzyCandidateTerms) over a real scorch index for a fuzziness-2 query.
69+
// This is the step the fuzzy optimizations touch; the full NewFuzzySearcher is
70+
// dominated by building one term searcher per candidate downstream, which these
71+
// changes do not affect and which would dilute the measurement.
72+
func BenchmarkFuzzyCandidateCollection(b *testing.B) {
73+
idx := buildFuzzyBenchIndex(b, 1000, 3000, 40)
74+
defer func() { _ = idx.Close() }()
75+
76+
r, err := idx.Reader()
77+
if err != nil {
78+
b.Fatal(err)
79+
}
80+
defer func() { _ = r.Close() }()
81+
82+
ctx := context.Background()
83+
84+
// sanity: report how many candidates the query matches, once.
85+
fc, err := findFuzzyCandidateTerms(ctx, r, "term0500", 2, "desc", "")
86+
if err != nil {
87+
b.Fatal(err)
88+
}
89+
b.Logf("candidates=%d", len(fc.candidates))
90+
91+
b.ResetTimer()
92+
b.ReportAllocs()
93+
for n := 0; n < b.N; n++ {
94+
_, err := findFuzzyCandidateTerms(ctx, r, "term0500", 2, "desc", "")
95+
if err != nil {
96+
b.Fatal(err)
97+
}
98+
}
99+
}
100+
101+
// BenchmarkFuzzySearcherEndToEnd measures the full NewFuzzySearcher path for
102+
// reference (candidate collection + building the boosted disjunction).
103+
func BenchmarkFuzzySearcherEndToEnd(b *testing.B) {
104+
idx := buildFuzzyBenchIndex(b, 1000, 3000, 40)
105+
defer func() { _ = idx.Close() }()
106+
107+
r, err := idx.Reader()
108+
if err != nil {
109+
b.Fatal(err)
110+
}
111+
defer func() { _ = r.Close() }()
112+
113+
opts := search.SearcherOptions{Score: "none"}
114+
ctx := context.Background()
115+
116+
b.ResetTimer()
117+
b.ReportAllocs()
118+
for n := 0; n < b.N; n++ {
119+
s, err := NewFuzzySearcher(ctx, r, "term0500", 0, 2, "desc", 1.0, opts)
120+
if err != nil {
121+
b.Fatal(err)
122+
}
123+
if err = s.Close(); err != nil {
124+
b.Fatal(err)
125+
}
126+
}
127+
}

0 commit comments

Comments
 (0)