Repository: blevesearch/bleve
Pull request #2060 (2024-08-02 07:21:01)

MB-19243: Detect fuzziness automatically based on term length
- The following queries can now automatically detect fuzziness based on the length of the terms:
  - Match Query
  - Fuzzy Query
  - Match-Phrase Query
  - Multi-Phrase Query
  - Phrase Query
- In these queries, each term (whether in a multi-term query like Match or Phrase, or in a single-term query like Fuzzy can have its own edit distance based on its length. The edit distance is calculated as follows:
  - For terms with 1 or 2 characters: edit distance = 0 (exact match)
  - For terms with 3, 4, or 5 characters: edit distance = 1 (fuzzy match)
  - For terms with more than 5 characters: edit distance = 2 (fuzzy match)
- This feature can be enabled using the `<query>.SetAutoFuzziness(<bool>)` API.
- Additionally, we've extended the functionality to query JSON parsing. You can specify fuzziness as either "auto" or a static value in the JSON query. Both formats are valid:
1. With auto fuzziness:
```
{
  "match" : "lorem",
  "field" : "bleve"
  "fuzziness" : "auto"
}
```
2. With static fuzziness:
```
{
  "match" : "lorem",
  "field" : "bleve"
  "fuzziness" : 2
}
```
When unmarshalled, the query will correctly apply the chosen fuzziness method.
- Fixed a bug where the code incorrectly returned an error message saying `fuzziness exceeds maximum` when using a fuzzy searcher with `fuzziness = 0`. Instead, a term searcher is now returned in this case.

CascadingRadium: force pushed a rebase please review again 
thanks

Patch:
diff --git a/search/query/fuzzy.go b/search/query/fuzzy.go
index f24eb0c2..72d7c0ea 100644
--- a/search/query/fuzzy.go
+++ b/search/query/fuzzy.go
@@ -20,6 +20,7 @@ import (
 	"github.com/blevesearch/bleve/v2/mapping"
 	"github.com/blevesearch/bleve/v2/search"
 	"github.com/blevesearch/bleve/v2/search/searcher"
+	"github.com/blevesearch/bleve/v2/util"
 	index "github.com/blevesearch/bleve_index_api"
 )
 
@@ -29,6 +30,7 @@ type FuzzyQuery struct {
 	Fuzziness int    `json:"fuzziness"`
 	FieldVal  string `json:"field,omitempty"`
 	BoostVal  *Boost `json:"boost,omitempty"`
+	autoFuzzy bool
 }
 
 // NewFuzzyQuery creates a new Query which finds
@@ -66,6 +68,10 @@ func (q *FuzzyQuery) SetFuzziness(f int) {
 	q.Fuzziness = f
 }
 
+func (q *FuzzyQuery) SetAutoFuzziness(a bool) {
+	q.autoFuzzy = a
+}
+
 func (q *FuzzyQuery) SetPrefix(p int) {
 	q.Prefix = p
 }
@@ -75,5 +81,54 @@ func (q *FuzzyQuery) Searcher(ctx context.Context, i index.IndexReader, m mappin
 	if q.FieldVal == "" {
 		field = m.DefaultSearchField()
 	}
+	if q.autoFuzzy {
+		return searcher.NewAutoFuzzySearcher(ctx, i, q.Term, q.Prefix, field, q.BoostVal.Value(), options)
+	}
 	return searcher.NewFuzzySearcher(ctx, i, q.Term, q.Prefix, q.Fuzziness, field, q.BoostVal.Value(), options)
 }
+
+func (q *FuzzyQuery) UnmarshalJSON(data []byte) error {
+	type Alias FuzzyQuery
+	aux := &struct {
+		Fuzziness interface{} `json:"fuzziness"`
+		*Alias
+	}{
+		Alias: (*Alias)(q),
+	}
+	if err := util.UnmarshalJSON(data, &aux); err != nil {
+		return err
+	}
+	switch v := aux.Fuzziness.(type) {
+	case float64:
+		q.Fuzziness = int(v)
+	case string:
+		if v == "auto" {
+			q.autoFuzzy = true
+		}
+	}
+	return nil
+}
+
+func (f *FuzzyQuery) MarshalJSON() ([]byte, error) {
+	var fuzzyValue interface{}
+	if f.autoFuzzy {
+		fuzzyValue = "auto"
+	} else {
+		fuzzyValue = f.Fuzziness
+	}
+	type fuzzyQuery struct {
+		Term      string      `json:"term"`
+		Prefix    int         `json:"prefix_length"`
+		Fuzziness interface{} `json:"fuzziness"`
+		FieldVal  string      `json:"field,omitempty"`
+		BoostVal  *Boost      `json:"boost,omitempty"`
+	}
+	aux := fuzzyQuery{
+		Term:      f.Term,
+		Prefix:    f.Prefix,
+		Fuzziness: fuzzyValue,
+		FieldVal:  f.FieldVal,
+		BoostVal:  f.BoostVal,
+	}
+	return util.MarshalJSON(aux)
+}
diff --git a/search/query/match.go b/search/query/match.go
index 074d11d3..ba84d924 100644
--- a/search/query/match.go
+++ b/search/query/match.go
@@ -32,6 +32,7 @@ type MatchQuery struct {
 	Prefix    int                `json:"prefix_length"`
 	Fuzziness int                `json:"fuzziness"`
 	Operator  MatchQueryOperator `json:"operator,omitempty"`
+	autoFuzzy bool
 }
 
 type MatchQueryOperator int
@@ -107,6 +108,10 @@ func (q *MatchQuery) SetFuzziness(f int) {
 	q.Fuzziness = f
 }
 
+func (q *MatchQuery) SetAutoFuzziness(auto bool) {
+	q.autoFuzzy = auto
+}
+
 func (q *MatchQuery) SetPrefix(p int) {
 	q.Prefix = p
 }
@@ -138,10 +143,14 @@ func (q *MatchQuery) Searcher(ctx context.Context, i index.IndexReader, m mappin
 	if len(tokens) > 0 {
 
 		tqs := make([]Query, len(tokens))
-		if q.Fuzziness != 0 {
+		if q.Fuzziness != 0 || q.autoFuzzy {
 			for i, token := range tokens {
 				query := NewFuzzyQuery(string(token.Term))
-				query.SetFuzziness(q.Fuzziness)
+				if q.autoFuzzy {
+					query.SetAutoFuzziness(true)
+				} else {
+					query.SetFuzziness(q.Fuzziness)
+				}
 				query.SetPrefix(q.Prefix)
 				query.SetField(field)
 				query.SetBoost(q.BoostVal.Value())
@@ -175,3 +184,53 @@ func (q *MatchQuery) Searcher(ctx context.Context, i index.IndexReader, m mappin
 	noneQuery := NewMatchNoneQuery()
 	return noneQuery.Searcher(ctx, i, m, options)
 }
+
+func (q *MatchQuery) UnmarshalJSON(data []byte) error {
+	type Alias MatchQuery
+	aux := &struct {
+		Fuzziness interface{} `json:"fuzziness"`
+		*Alias
+	}{
+		Alias: (*Alias)(q),
+	}
+	if err := util.UnmarshalJSON(data, &aux); err != nil {
+		return err
+	}
+	switch v := aux.Fuzziness.(type) {
+	case float64:
+		q.Fuzziness = int(v)
+	case string:
+		if v == "auto" {
+			q.autoFuzzy = true
+		}
+	}
+	return nil
+}
+
+func (f *MatchQuery) MarshalJSON() ([]byte, error) {
+	var fuzzyValue interface{}
+	if f.autoFuzzy {
+		fuzzyValue = "auto"
+	} else {
+		fuzzyValue = f.Fuzziness
+	}
+	type match struct {
+		Match     string             `json:"match"`
+		FieldVal  string             `json:"field,omitempty"`
+		Analyzer  string             `json:"analyzer,omitempty"`
+		BoostVal  *Boost             `json:"boost,omitempty"`
+		Prefix    int                `json:"prefix_length"`
+		Fuzziness interface{}        `json:"fuzziness"`
+		Operator  MatchQueryOperator `json:"operator,omitempty"`
+	}
+	aux := match{
+		Match:     f.Match,
+		FieldVal:  f.FieldVal,
+		Analyzer:  f.Analyzer,
+		BoostVal:  f.BoostVal,
+		Prefix:    f.Prefix,
+		Fuzziness: fuzzyValue,
+		Operator:  f.Operator,
+	}
+	return util.MarshalJSON(aux)
+}
diff --git a/search/query/match_phrase.go b/search/query/match_phrase.go
index 63a16a53..12a83965 100644
--- a/search/query/match_phrase.go
+++ b/search/query/match_phrase.go
@@ -21,6 +21,7 @@ import (
 	"github.com/blevesearch/bleve/v2/analysis"
 	"github.com/blevesearch/bleve/v2/mapping"
 	"github.com/blevesearch/bleve/v2/search"
+	"github.com/blevesearch/bleve/v2/util"
 	index "github.com/blevesearch/bleve_index_api"
 )
 
@@ -30,6 +31,7 @@ type MatchPhraseQuery struct {
 	Analyzer    string `json:"analyzer,omitempty"`
 	BoostVal    *Boost `json:"boost,omitempty"`
 	Fuzziness   int    `json:"fuzziness"`
+	autoFuzzy   bool
 }
 
 // NewMatchPhraseQuery creates a new Query object
@@ -63,6 +65,10 @@ func (q *MatchPhraseQuery) SetFuzziness(f int) {
 	q.Fuzziness = f
 }
 
+func (q *MatchPhraseQuery) SetAutoFuzziness(auto bool) {
+	q.autoFuzzy = auto
+}
+
 func (q *MatchPhraseQuery) Field() string {
 	return q.FieldVal
 }
@@ -89,7 +95,11 @@ func (q *MatchPhraseQuery) Searcher(ctx context.Context, i index.IndexReader, m
 		phrase := tokenStreamToPhrase(tokens)
 		phraseQuery := NewMultiPhraseQuery(phrase, field)
 		phraseQuery.SetBoost(q.BoostVal.Value())
-		phraseQuery.SetFuzziness(q.Fuzziness)
+		if q.autoFuzzy {
+			phraseQuery.SetAutoFuzziness(true)
+		} else {
+			phraseQuery.SetFuzziness(q.Fuzziness)
+		}
 		return phraseQuery.Searcher(ctx, i, m, options)
 	}
 	noneQuery := NewMatchNoneQuery()
@@ -118,3 +128,49 @@ func tokenStreamToPhrase(tokens analysis.TokenStream) [][]string {
 	}
 	return nil
 }
+
+func (q *MatchPhraseQuery) UnmarshalJSON(data []byte) error {
+	type Alias MatchPhraseQuery
+	aux := &struct {
+		Fuzziness interface{} `json:"fuzziness"`
+		*Alias
+	}{
+		Alias: (*Alias)(q),
+	}
+	if err := util.UnmarshalJSON(data, &aux); err != nil {
+		return err
+	}
+	switch v := aux.Fuzziness.(type) {
+	case float64:
+		q.Fuzziness = int(v)
+	case string:
+		if v == "auto" {
+			q.autoFuzzy = true
+		}
+	}
+	return nil
+}
+
+func (f *MatchPhraseQuery) MarshalJSON() ([]byte, error) {
+	var fuzzyValue interface{}
+	if f.autoFuzzy {
+		fuzzyValue = "auto"
+	} else {
+		fuzzyValue = f.Fuzziness
+	}
+	type matchPhrase struct {
+		MatchPhrase string      `json:"match_phrase"`
+		FieldVal    string      `json:"field,omitempty"`
+		Analyzer    string      `json:"analyzer,omitempty"`
+		BoostVal    *Boost      `json:"boost,omitempty"`
+		Fuzziness   interface{} `json:"fuzziness"`
+	}
+	aux := matchPhrase{
+		MatchPhrase: f.MatchPhrase,
+		FieldVal:    f.FieldVal,
+		Analyzer:    f.Analyzer,
+		BoostVal:    f.BoostVal,
+		Fuzziness:   fuzzyValue,
+	}
+	return util.MarshalJSON(aux)
+}
diff --git a/search/query/multi_phrase.go b/search/query/multi_phrase.go
index f5aa5397..aa2cc045 100644
--- a/search/query/multi_phrase.go
+++ b/search/query/multi_phrase.go
@@ -30,6 +30,7 @@ type MultiPhraseQuery struct {
 	FieldVal  string     `json:"field,omitempty"`
 	BoostVal  *Boost     `json:"boost,omitempty"`
 	Fuzziness int        `json:"fuzziness"`
+	autoFuzzy bool
 }
 
 // NewMultiPhraseQuery creates a new Query for finding
@@ -52,6 +53,10 @@ func (q *MultiPhraseQuery) SetFuzziness(f int) {
 	q.Fuzziness = f
 }
 
+func (q *MultiPhraseQuery) SetAutoFuzziness(auto bool) {
+	q.autoFuzzy = auto
+}
+
 func (q *MultiPhraseQuery) SetBoost(b float64) {
 	boost := Boost(b)
 	q.BoostVal = &boost
@@ -70,7 +75,7 @@ func (q *MultiPhraseQuery) SetField(f string) {
 }
 
 func (q *MultiPhraseQuery) Searcher(ctx context.Context, i index.IndexReader, m mapping.IndexMapping, options search.SearcherOptions) (search.Searcher, error) {
-	return searcher.NewMultiPhraseSearcher(ctx, i, q.Terms, q.Fuzziness, q.FieldVal, q.BoostVal.Value(), options)
+	return searcher.NewMultiPhraseSearcher(ctx, i, q.Terms, q.Fuzziness, q.autoFuzzy, q.FieldVal, q.BoostVal.Value(), options)
 }
 
 func (q *MultiPhraseQuery) Validate() error {
@@ -81,15 +86,45 @@ func (q *MultiPhraseQuery) Validate() error {
 }
 
 func (q *MultiPhraseQuery) UnmarshalJSON(data []byte) error {
-	type _mphraseQuery MultiPhraseQuery
-	tmp := _mphraseQuery{}
-	err := util.UnmarshalJSON(data, &tmp)
-	if err != nil {
+	type Alias MultiPhraseQuery
+	aux := &struct {
+		Fuzziness interface{} `json:"fuzziness"`
+		*Alias
+	}{
+		Alias: (*Alias)(q),
+	}
+	if err := util.UnmarshalJSON(data, &aux); err != nil {
 		return err
 	}
-	q.Terms = tmp.Terms
-	q.FieldVal = tmp.FieldVal
-	q.BoostVal = tmp.BoostVal
-	q.Fuzziness = tmp.Fuzziness
+	switch v := aux.Fuzziness.(type) {
+	case float64:
+		q.Fuzziness = int(v)
+	case string:
+		if v == "auto" {
+			q.autoFuzzy = true
+		}
+	}
 	return nil
 }
+
+func (f *MultiPhraseQuery) MarshalJSON() ([]byte, error) {
+	var fuzzyValue interface{}
+	if f.autoFuzzy {
+		fuzzyValue = "auto"
+	} else {
+		fuzzyValue = f.Fuzziness
+	}
+	type multiPhraseQuery struct {
+		Terms     [][]string  `json:"terms"`
+		FieldVal  string      `json:"field,omitempty"`
+		BoostVal  *Boost      `json:"boost,omitempty"`
+		Fuzziness interface{} `json:"fuzziness"`
+	}
+	aux := multiPhraseQuery{
+		Terms:     f.Terms,
+		FieldVal:  f.FieldVal,
+		BoostVal:  f.BoostVal,
+		Fuzziness: fuzzyValue,
+	}
+	return util.MarshalJSON(aux)
+}
diff --git a/search/query/phrase.go b/search/query/phrase.go
index f3c04a48..96bc1b75 100644
--- a/search/query/phrase.go
+++ b/search/query/phrase.go
@@ -30,6 +30,7 @@ type PhraseQuery struct {
 	FieldVal  string   `json:"field,omitempty"`
 	BoostVal  *Boost   `json:"boost,omitempty"`
 	Fuzziness int      `json:"fuzziness"`
+	autoFuzzy bool
 }
 
 // NewPhraseQuery creates a new Query for finding
@@ -54,6 +55,10 @@ func (q *PhraseQuery) SetFuzziness(f int) {
 	q.Fuzziness = f
 }
 
+func (q *PhraseQuery) SetAutoFuzziness(auto bool) {
+	q.autoFuzzy = auto
+}
+
 func (q *PhraseQuery) Boost() float64 {
 	return q.BoostVal.Value()
 }
@@ -67,7 +72,7 @@ func (q *PhraseQuery) Field() string {
 }
 
 func (q *PhraseQuery) Searcher(ctx context.Context, i index.IndexReader, m mapping.IndexMapping, options search.SearcherOptions) (search.Searcher, error) {
-	return searcher.NewPhraseSearcher(ctx, i, q.Terms, q.Fuzziness, q.FieldVal, q.BoostVal.Value(), options)
+	return searcher.NewPhraseSearcher(ctx, i, q.Terms, q.Fuzziness, q.autoFuzzy, q.FieldVal, q.BoostVal.Value(), options)
 }
 
 func (q *PhraseQuery) Validate() error {
@@ -78,15 +83,45 @@ func (q *PhraseQuery) Validate() error {
 }
 
 func (q *PhraseQuery) UnmarshalJSON(data []byte) error {
-	type _phraseQuery PhraseQuery
-	tmp := _phraseQuery{}
-	err := util.UnmarshalJSON(data, &tmp)
-	if err != nil {
+	type Alias PhraseQuery
+	aux := &struct {
+		Fuzziness interface{} `json:"fuzziness"`
+		*Alias
+	}{
+		Alias: (*Alias)(q),
+	}
+	if err := util.UnmarshalJSON(data, &aux); err != nil {
 		return err
 	}
-	q.Terms = tmp.Terms
-	q.FieldVal = tmp.FieldVal
-	q.BoostVal = tmp.BoostVal
-	q.Fuzziness = tmp.Fuzziness
+	switch v := aux.Fuzziness.(type) {
+	case float64:
+		q.Fuzziness = int(v)
+	case string:
+		if v == "auto" {
+			q.autoFuzzy = true
+		}
+	}
 	return nil
 }
+
+func (f *PhraseQuery) MarshalJSON() ([]byte, error) {
+	var fuzzyValue interface{}
+	if f.autoFuzzy {
+		fuzzyValue = "auto"
+	} else {
+		fuzzyValue = f.Fuzziness
+	}
+	type phraseQuery struct {
+		Terms     []string    `json:"terms"`
+		FieldVal  string      `json:"field,omitempty"`
+		BoostVal  *Boost      `json:"boost,omitempty"`
+		Fuzziness interface{} `json:"fuzziness"`
+	}
+	aux := phraseQuery{
+		Terms:     f.Terms,
+		FieldVal:  f.FieldVal,
+		BoostVal:  f.BoostVal,
+		Fuzziness: fuzzyValue,
+	}
+	return util.MarshalJSON(aux)
+}
diff --git a/search/searcher/search_fuzzy.go b/search/searcher/search_fuzzy.go
index 1957168b..35001874 100644
--- a/search/searcher/search_fuzzy.go
+++ b/search/searcher/search_fuzzy.go
@@ -24,6 +24,19 @@ import (
 
 var MaxFuzziness = 2
 
+// AutoFuzzinessHighThreshold is the threshold for the term length
+// above which the fuzziness is set to MaxFuzziness when the fuzziness
+// mode is set to AutoFuzziness.
+var AutoFuzzinessHighThreshold = 5
+
+// AutoFuzzinessLowThreshold is the threshold for the term length
+// below which the fuzziness is set to zero when the fuzziness mode
+// is set to AutoFuzziness.
+// For terms with length between AutoFuzzinessLowThreshold and
+// AutoFuzzinessHighThreshold, the fuzziness is set to
+// MaxFuzziness - 1.
+var AutoFuzzinessLowThreshold = 2
+
 func NewFuzzySearcher(ctx context.Context, indexReader index.IndexReader, term string,
 	prefix, fuzziness int, field string, boost float64,
 	options search.SearcherOptions) (search.Searcher, error) {
@@ -35,6 +48,19 @@ func NewFuzzySearcher(ctx context.Context, indexReader index.IndexReader, term s
 	if fuzziness < 0 {
 		return nil, fmt.Errorf("invalid fuzziness, negative")
 	}
+	if fuzziness == 0 {
+		// no fuzziness, just do a term search
+		// check if the call is made from a phrase searcher
+		// and if so, add the term to the fuzzy term matches
+		// since the fuzzy candidate terms are not collected
+		// for a term search, and the only candidate term is
+		// the term itself
+		fuzzyTermMatches := ctx.Value(search.FuzzyMatchPhraseKey)
+		if fuzzyTermMatches != nil {
+			fuzzyTermMatches.(map[string][]string)[term] = []string{term}
+		}
+		return NewTermSearcher(ctx, indexReader, term, field, boost, options)
+	}
 
 	// Note: we don't byte slice the term for a prefix because of runes.
 	prefixTerm := ""
@@ -71,6 +97,21 @@ func NewFuzzySearcher(ctx context.Context, indexReader index.IndexReader, term s
 		boost, options, true)
 }
 
+func getAutoFuzziness(term string) int {
+	termLength := len(term)
+	if termLength > AutoFuzzinessHighThreshold {
+		return MaxFuzziness
+	} else if termLength > AutoFuzzinessLowThreshold {
+		return MaxFuzziness - 1
+	}
+	return 0
+}
+
+func NewAutoFuzzySearcher(ctx context.Context, indexReader index.IndexReader, term string,
+	prefix int, field string, boost float64, options search.SearcherOptions) (search.Searcher, error) {
+	return NewFuzzySearcher(ctx, indexReader, term, prefix, getAutoFuzziness(term), field, boost, options)
+}
+
 type fuzzyCandidates struct {
 	candidates []string
 	bytesRead  uint64
diff --git a/search/searcher/search_phrase.go b/search/searcher/search_phrase.go
index a7bdb2c8..bf24b465 100644
--- a/search/searcher/search_phrase.go
+++ b/search/searcher/search_phrase.go
@@ -67,25 +67,32 @@ func (s *PhraseSearcher) Size() int {
 }
 
 func NewPhraseSearcher(ctx context.Context, indexReader index.IndexReader, terms []string,
-	fuzziness int, field string, boost float64, options search.SearcherOptions) (*PhraseSearcher, error) {
+	fuzziness int, autoFuzzy bool, field string, boost float64, options search.SearcherOptions) (*PhraseSearcher, error) {
 
 	// turn flat terms []string into [][]string
 	mterms := make([][]string, len(terms))
 	for i, term := range terms {
 		mterms[i] = []string{term}
 	}
-	return NewMultiPhraseSearcher(ctx, indexReader, mterms, fuzziness, field, boost, options)
+	return NewMultiPhraseSearcher(ctx, indexReader, mterms, fuzziness, autoFuzzy, field, boost, options)
 }
 
 func NewMultiPhraseSearcher(ctx context.Context, indexReader index.IndexReader, terms [][]string,
-	fuzziness int, field string, boost float64, options search.SearcherOptions) (*PhraseSearcher, error) {
+	fuzziness int, autoFuzzy bool, field string, boost float64, options search.SearcherOptions) (*PhraseSearcher, error) {
 
 	options.IncludeTermVectors = true
 	var termPositionSearchers []search.Searcher
 	var err error
 	var ts search.Searcher
+	// The following logic checks if fuzziness is enabled.
+	// Fuzziness is considered enabled if either:
+	//    a. `fuzziness` is greater than 0, or
+	//    b. `autoFuzzy` is set to true.
+	// if both conditions are true, `autoFuzzy` takes precedence.
+	// If enabled, a map will be created to store the matches for fuzzy terms.
+	fuzzinessEnabled := autoFuzzy || fuzziness > 0
 	var fuzzyTermMatches map[string][]string
-	if fuzziness > 0 {
+	if fuzzinessEnabled {
 		fuzzyTermMatches = make(map[string][]string)
 		ctx = context.WithValue(ctx, search.FuzzyMatchPhraseKey, fuzzyTermMatches)
 	}
@@ -95,9 +102,15 @@ func NewMultiPhraseSearcher(ctx context.Context, indexReader index.IndexReader,
 	for _, termPos := range terms {
 		if len(termPos) == 1 && termPos[0] != "" {
 			// single term
-			if fuzziness > 0 {
+			if fuzzinessEnabled {
 				// fuzzy
-				ts, err = NewFuzzySearcher(ctx, indexReader, termPos[0], 0, fuzziness, field, boost, options)
+				if autoFuzzy {
+					// auto fuzzy
+					ts, err = NewAutoFuzzySearcher(ctx, indexReader, termPos[0], 0, field, boost, options)
+				} else {
+					// non-auto fuzzy
+					ts, err = NewFuzzySearcher(ctx, indexReader, termPos[0], 0, fuzziness, field, boost, options)
+				}
 			} else {
 				// non-fuzzy
 				ts, err = NewTermSearcher(ctx, indexReader, termPos[0], field, boost, options)
@@ -117,9 +130,15 @@ func NewMultiPhraseSearcher(ctx context.Context, indexReader index.IndexReader,
 				if term == "" {
 					continue
 				}
-				if fuzziness > 0 {
+				if fuzzinessEnabled {
 					// fuzzy
-					ts, err = NewFuzzySearcher(ctx, indexReader, term, 0, fuzziness, field, boost, options)
+					if autoFuzzy {
+						// auto fuzzy
+						ts, err = NewAutoFuzzySearcher(ctx, indexReader, term, 0, field, boost, options)
+					} else {
+						// non-auto fuzzy
+						ts, err = NewFuzzySearcher(ctx, indexReader, term, 0, fuzziness, field, boost, options)
+					}
 				} else {
 					// non-fuzzy
 					ts, err = NewTermSearcher(ctx, indexReader, term, field, boost, options)


Test patch:
diff --git a/search/searcher/search_phrase_test.go b/search/searcher/search_phrase_test.go
index 27200773..207b7b24 100644
--- a/search/searcher/search_phrase_test.go
+++ b/search/searcher/search_phrase_test.go
@@ -37,7 +37,7 @@ func TestPhraseSearch(t *testing.T) {
 	}()
 
 	soptions := search.SearcherOptions{Explain: true, IncludeTermVectors: true}
-	phraseSearcher, err := NewPhraseSearcher(nil, twoDocIndexReader, []string{"angst", "beer"}, 0, "desc", 1.0, soptions)
+	phraseSearcher, err := NewPhraseSearcher(nil, twoDocIndexReader, []string{"angst", "beer"}, 0, false, "desc", 1.0, soptions)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -131,7 +131,7 @@ func TestMultiPhraseSearch(t *testing.T) {
 		if err != nil {
 			t.Error(err)
 		}
-		searcher, err := NewMultiPhraseSearcher(nil, reader, test.phrase, 0, "desc", 1.0, soptions)
+		searcher, err := NewMultiPhraseSearcher(nil, reader, test.phrase, 0, false, "desc", 1.0, soptions)
 		if err != nil {
 			t.Error(err)
 		}
@@ -207,7 +207,7 @@ func TestFuzzyMultiPhraseSearch(t *testing.T) {
 		if err != nil {
 			t.Error(err)
 		}
-		searcher, err := NewMultiPhraseSearcher(context.TODO(), reader, test.mphrase, test.fuzziness, "desc", 1.0, soptions)
+		searcher, err := NewMultiPhraseSearcher(context.TODO(), reader, test.mphrase, test.fuzziness, false, "desc", 1.0, soptions)
 		if err != nil {
 			t.Error(err)
 		}
diff --git a/search_test.go b/search_test.go
index 3d14c925..68cbd6ed 100644
--- a/search_test.go
+++ b/search_test.go
@@ -3562,5 +3562,187 @@ func TestScoreBreakdown(t *testing.T) {
 			}
 		}
 	}
+}
+
+func TestAutoFuzzy(t *testing.T) {
+	tmpIndexPath := createTmpIndexPath(t)
+	defer cleanupTmpIndexPath(t, tmpIndexPath)
+
+	imap := mapping.NewIndexMapping()
+
+	if err := imap.AddCustomAnalyzer("splitter", map[string]interface{}{
+		"type":          custom.Name,
+		"tokenizer":     whitespace.Name,
+		"token_filters": []interface{}{lowercase.Name},
+	}); err != nil {
+		t.Fatal(err)
+	}
 
+	textField := mapping.NewTextFieldMapping()
+	textField.Analyzer = "splitter"
+	textField.Store = true
+	textField.IncludeTermVectors = true
+	textField.IncludeInAll = true
+
+	imap.DefaultMapping.Dynamic = false
+	imap.DefaultMapping.AddFieldMappingsAt("model", textField)
+
+	documents := map[string]map[string]interface{}{
+		"product1": {
+			"model": "apple iphone 12",
+		},
+		"product2": {
+			"model": "apple iphone 13",
+		},
+		"product3": {
+			"model": "samsung galaxy s22",
+		},
+		"product4": {
+			"model": "samsung galaxy note",
+		},
+		"product5": {
+			"model": "google pixel 5",
+		},
+		"product6": {
+			"model": "oneplus 9 pro",
+		},
+		"product7": {
+			"model": "xiaomi mi 11",
+		},
+		"product8": {
+			"model": "oppo find x3",
+		},
+		"product9": {
+			"model": "vivo x60 pro",
+		},
+		"product10": {
+			"model": "oneplus 8t pro",
+		},
+		"product11": {
+			"model": "nokia xr20",
+		},
+		"product12": {
+			"model": "poco f1",
+		},
+		"product13": {
+			"model": "asus rog 5",
+		},
+		"product14": {
+			"model": "samsung galaxy a15 5g",
+		},
+		"product15": {
+			"model": "tecno camon 17",
+		},
+	}
+	idx, err := New(tmpIndexPath, imap)
+	if err != nil {
+		t.Fatal(err)
+	}
+	defer func() {
+		err = idx.Close()
+		if err != nil {
+			t.Fatal(err)
+		}
+	}()
+
+	batch := idx.NewBatch()
+	for docID, doc := range documents {
+		err := batch.Index(docID, doc)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+	err = idx.Batch(batch)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	type testStruct struct {
+		query      string
+		expectHits []string
+	}
+	testQueries := []testStruct{
+		{
+			// match query with fuzziness set to 2
+			query: `{
+					"match" : "applle iphone 12",
+					"fuzziness": 2,
+					"field" : "model"
+				}`,
+			expectHits: []string{"product1", "product2", "product7", "product14", "product12", "product10", "product15", "product3", "product6", "product8"},
+		},
+		{
+			// match query with fuzziness set to "auto"
+			query: `{
+					"match" : "applle iphone 12",
+					"fuzziness": "auto",
+					"field" : "model"
+				}`,
+			expectHits: []string{"product1", "product2"},
+		},
+		{
+			// match query with fuzziness set to 2 with `and` operator
+			query: `{
+					"match" : "applle iphone 12",
+					"fuzziness": 2,
+					"field" : "model",
+					"operator": "and"
+				}`,
+			expectHits: []string{"product1", "product2"},
+		},
+		{
+			// match query with fuzziness set to "auto" with `and`` operator
+			query: `{
+					"match" : "applle iphone 12",
+					"fuzziness": "auto",
+					"field" : "model",
+					"operator": "and"
+				}`,
+			expectHits: []string{"product1"},
+		},
+		// match phrase query with fuzziness set to 2
+		{
+			query: `{
+					"match_phrase" : "onplus 9 pro",
+					"fuzziness": 2,
+					"field" : "model"
+				}`,
+			expectHits: []string{"product10", "product6"},
+		},
+		// match phrase query with fuzziness set to "auto"
+		{
+			query: `{
+				"match_phrase" : "onplus 9 pro",
+				"fuzziness": "auto",
+				"field" : "model"
+			}`,
+			expectHits: []string{"product6"},
+		},
+	}
+
+	for _, dtq := range testQueries {
+		q, err := query.ParseQuery([]byte(dtq.query))
+		if err != nil {
+			t.Fatal(err)
+		}
+
+		sr := NewSearchRequest(q)
+		sr.Highlight = NewHighlightWithStyle(ansi.Name)
+		sr.SortBy([]string{"-_score", "_id"})
+		sr.Fields = []string{"*"}
+		sr.Explain = true
+
+		res, err := idx.Search(sr)
+		if err != nil {
+			t.Fatal(err)
+		}
+		if len(res.Hits) != len(dtq.expectHits) {
+			t.Fatalf("expected %d hits, got %d", len(dtq.expectHits), len(res.Hits))
+		}
+		for i, hit := range res.Hits {
+			if hit.ID != dtq.expectHits[i] {
+				t.Fatalf("expected docID %s, got %s", dtq.expectHits[i], hit.ID)
+			}
+		}
+	}
 }