Repository: blevesearch/bleve
Pull request #2056 (2024-07-24 10:34:49)

MB-61640: Fuzzy Dynamic Scoring
 - Added levenshtein distance calculation for fuzzy and wildcard searchers
 - Added new implementations of certain functions to allow passing of edit distances per term
 - Multiplied boosts by inverse of edit distance for score calculation

CascadingRadium: also please add unit tests to show the effect (with and w/o boost) across match and match_phrase queries thanks

Patch:
diff --git a/go.mod b/go.mod
index 19e826da..b5b3a550 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.21
 require (
 	github.com/RoaringBitmap/roaring v1.9.3
 	github.com/bits-and-blooms/bitset v1.12.0
-	github.com/blevesearch/bleve_index_api v1.1.12
+	github.com/blevesearch/bleve_index_api v1.1.13
 	github.com/blevesearch/geo v0.1.20
 	github.com/blevesearch/go-faiss v1.0.23
 	github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475
@@ -18,13 +18,13 @@ require (
 	github.com/blevesearch/snowballstem v0.9.0
 	github.com/blevesearch/stempel v0.2.0
 	github.com/blevesearch/upsidedown_store_api v1.0.2
-	github.com/blevesearch/vellum v1.0.10
+	github.com/blevesearch/vellum v1.0.11
 	github.com/blevesearch/zapx/v11 v11.3.10
 	github.com/blevesearch/zapx/v12 v12.3.10
 	github.com/blevesearch/zapx/v13 v13.3.10
 	github.com/blevesearch/zapx/v14 v14.3.10
 	github.com/blevesearch/zapx/v15 v15.3.16
-	github.com/blevesearch/zapx/v16 v16.1.8
+	github.com/blevesearch/zapx/v16 v16.1.9-0.20241120170816-85db80035af2
 	github.com/couchbase/moss v0.2.0
 	github.com/golang/protobuf v1.3.2
 	github.com/spf13/cobra v1.7.0
diff --git a/go.sum b/go.sum
index bf94edfa..565d9a56 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,8 @@ github.com/RoaringBitmap/roaring v1.9.3 h1:t4EbC5qQwnisr5PrP9nt0IRhRTb9gMUgQF4t4
 github.com/RoaringBitmap/roaring v1.9.3/go.mod h1:6AXUsoIEzDTFFQCe1RbGA6uFONMhvejWj5rqITANK90=
 github.com/bits-and-blooms/bitset v1.12.0 h1:U/q1fAF7xXRhFCrhROzIfffYnu+dlS38vCZtmFVPHmA=
 github.com/bits-and-blooms/bitset v1.12.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8=
-github.com/blevesearch/bleve_index_api v1.1.12 h1:P4bw9/G/5rulOF7SJ9l4FsDoo7UFJ+5kexNy1RXfegY=
-github.com/blevesearch/bleve_index_api v1.1.12/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8=
+github.com/blevesearch/bleve_index_api v1.1.13 h1:+nrA6oRJr85aCPyqaeZtsruObwKojutfonHJin/BP48=
+github.com/blevesearch/bleve_index_api v1.1.13/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8=
 github.com/blevesearch/geo v0.1.20 h1:paaSpu2Ewh/tn5DKn/FB5SzvH0EWupxHEIwbCk/QPqM=
 github.com/blevesearch/geo v0.1.20/go.mod h1:DVG2QjwHNMFmjo+ZgzrIq2sfCh6rIHzy9d9d0B59I6w=
 github.com/blevesearch/go-faiss v1.0.23 h1:Wmc5AFwDLKGl2L6mjLX1Da3vCL0EKa2uHHSorcIS1Uc=
@@ -31,8 +31,8 @@ github.com/blevesearch/stempel v0.2.0 h1:CYzVPaScODMvgE9o+kf6D4RJ/VRomyi9uHF+PtB
 github.com/blevesearch/stempel v0.2.0/go.mod h1:wjeTHqQv+nQdbPuJ/YcvOjTInA2EIc6Ks1FoSUzSLvc=
 github.com/blevesearch/upsidedown_store_api v1.0.2 h1:U53Q6YoWEARVLd1OYNc9kvhBMGZzVrdmaozG2MfoB+A=
 github.com/blevesearch/upsidedown_store_api v1.0.2/go.mod h1:M01mh3Gpfy56Ps/UXHjEO/knbqyQ1Oamg8If49gRwrQ=
-github.com/blevesearch/vellum v1.0.10 h1:HGPJDT2bTva12hrHepVT3rOyIKFFF4t7Gf6yMxyMIPI=
-github.com/blevesearch/vellum v1.0.10/go.mod h1:ul1oT0FhSMDIExNjIxHqJoGpVrBpKCdgDQNxfqgJt7k=
+github.com/blevesearch/vellum v1.0.11 h1:SJI97toEFTtA9WsDZxkyGTaBWFdWl1n2LEDCXLCq/AU=
+github.com/blevesearch/vellum v1.0.11/go.mod h1:QgwWryE8ThtNPxtgWJof5ndPfx0/YMBh+W2weHKPw8Y=
 github.com/blevesearch/zapx/v11 v11.3.10 h1:hvjgj9tZ9DeIqBCxKhi70TtSZYMdcFn7gDb71Xo/fvk=
 github.com/blevesearch/zapx/v11 v11.3.10/go.mod h1:0+gW+FaE48fNxoVtMY5ugtNHHof/PxCqh7CnhYdnMzQ=
 github.com/blevesearch/zapx/v12 v12.3.10 h1:yHfj3vXLSYmmsBleJFROXuO08mS3L1qDCdDK81jDl8s=
@@ -43,8 +43,8 @@ github.com/blevesearch/zapx/v14 v14.3.10 h1:SG6xlsL+W6YjhX5N3aEiL/2tcWh3DO75Bnz7
 github.com/blevesearch/zapx/v14 v14.3.10/go.mod h1:qqyuR0u230jN1yMmE4FIAuCxmahRQEOehF78m6oTgns=
 github.com/blevesearch/zapx/v15 v15.3.16 h1:Ct3rv7FUJPfPk99TI/OofdC+Kpb4IdyfdMH48sb+FmE=
 github.com/blevesearch/zapx/v15 v15.3.16/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg=
-github.com/blevesearch/zapx/v16 v16.1.8 h1:Bxzpw6YQpFs7UjoCV1+RvDw6fmAT2GZxldwX8b3wVBM=
-github.com/blevesearch/zapx/v16 v16.1.8/go.mod h1:JqQlOqlRVaYDkpLIl3JnKql8u4zKTNlVEa3nLsi0Gn8=
+github.com/blevesearch/zapx/v16 v16.1.9-0.20241120170816-85db80035af2 h1:+RX9SM7KO7q91E7rFj4NARSsAhKj2EbvdWfzX+ihg/w=
+github.com/blevesearch/zapx/v16 v16.1.9-0.20241120170816-85db80035af2/go.mod h1:zuxVgVaLZ0g4lZvrv06xDc24N6nLCOzXYHVkXI7LMHM=
 github.com/couchbase/ghistogram v0.1.0 h1:b95QcQTCzjTUocDXp/uMgSNQi8oj1tGwnJ4bODWZnps=
 github.com/couchbase/ghistogram v0.1.0/go.mod h1:s1Jhy76zqfEecpNWJfWUiKZookAFaiGOEoyzgHt9i7k=
 github.com/couchbase/moss v0.2.0 h1:VCYrMzFwEryyhRSeI+/b3tRBSeTpi/8gn5Kf6dxqn+o=
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..6c29f845 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 := ""
@@ -52,9 +78,11 @@ func NewFuzzySearcher(ctx context.Context, indexReader index.IndexReader, term s
 	}
 
 	var candidates []string
+	var editDistances []uint8
 	var dictBytesRead uint64
 	if fuzzyCandidates != nil {
 		candidates = fuzzyCandidates.candidates
+		editDistances = fuzzyCandidates.editDistances
 		dictBytesRead = fuzzyCandidates.bytesRead
 	}
 
@@ -67,13 +95,29 @@ func NewFuzzySearcher(ctx context.Context, indexReader index.IndexReader, term s
 		}
 	}
 
-	return NewMultiTermSearcher(ctx, indexReader, candidates, field,
-		boost, options, true)
+	return NewMultiTermSearcherBoosted(ctx, indexReader, candidates, field,
+		boost, editDistances, 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
+	candidates    []string
+	editDistances []uint8
+	bytesRead     uint64
 }
 
 func reportIOStats(ctx context.Context, bytesRead uint64) {
@@ -91,7 +135,8 @@ func reportIOStats(ctx context.Context, bytesRead uint64) {
 func findFuzzyCandidateTerms(indexReader index.IndexReader, term string,
 	fuzziness int, field, prefixTerm string) (rv *fuzzyCandidates, err error) {
 	rv = &fuzzyCandidates{
-		candidates: make([]string, 0),
+		candidates:    make([]string, 0),
+		editDistances: make([]uint8, 0),
 	}
 
 	// in case of advanced reader implementations directly call
@@ -110,6 +155,7 @@ func findFuzzyCandidateTerms(indexReader index.IndexReader, term string,
 		tfd, err := fieldDict.Next()
 		for err == nil && tfd != nil {
 			rv.candidates = append(rv.candidates, tfd.Term)
+			rv.editDistances = append(rv.editDistances, tfd.EditDistance)
 			if tooManyClauses(len(rv.candidates)) {
 				return nil, tooManyClausesErr(field, len(rv.candidates))
 			}
@@ -144,6 +190,7 @@ func findFuzzyCandidateTerms(indexReader index.IndexReader, term string,
 		ld, exceeded, reuse = search.LevenshteinDistanceMaxReuseSlice(term, tfd.Term, fuzziness, reuse)
 		if !exceeded && ld <= fuzziness {
 			rv.candidates = append(rv.candidates, tfd.Term)
+			rv.editDistances = append(rv.editDistances, uint8(ld))
 			if tooManyClauses(len(rv.candidates)) {
 				return nil, tooManyClausesErr(field, len(rv.candidates))
 			}
diff --git a/search/searcher/search_multi_term.go b/search/searcher/search_multi_term.go
index 913f99f5..98f8f92b 100644
--- a/search/searcher/search_multi_term.go
+++ b/search/searcher/search_multi_term.go
@@ -45,6 +45,31 @@ func NewMultiTermSearcher(ctx context.Context, indexReader index.IndexReader, te
 		options, limit)
 }
 
+// Works similarly to the multi term searcher but additionally boosts individual terms based on
+// their edit distance from the query terms
+func NewMultiTermSearcherBoosted(ctx context.Context, indexReader index.IndexReader, terms []string,
+	field string, boost float64, editDistances []uint8, options search.SearcherOptions, limit bool) (
+	search.Searcher, error) {
+
+	if tooManyClauses(len(terms)) {
+		if optionsDisjunctionOptimizable(options) {
+			return optimizeMultiTermSearcher(ctx, indexReader, terms, field, boost, options)
+		}
+		if limit {
+			return nil, tooManyClausesErr(field, len(terms))
+		}
+	}
+
+	qsearchers, err := makeBatchSearchersBoosted(ctx, indexReader, terms, field, boost, editDistances, options)
+	if err != nil {
+		return nil, err
+	}
+
+	// build disjunction searcher of these ranges
+	return newMultiTermSearcherInternal(ctx, indexReader, qsearchers, field, boost,
+		options, limit)
+}
+
 func NewMultiTermSearcherBytes(ctx context.Context, indexReader index.IndexReader, terms [][]byte,
 	field string, boost float64, options search.SearcherOptions, limit bool) (
 	search.Searcher, error) {
@@ -151,6 +176,32 @@ func makeBatchSearchers(ctx context.Context, indexReader index.IndexReader, term
 	return qsearchers, nil
 }
 
+func makeBatchSearchersBoosted(ctx context.Context, indexReader index.IndexReader, terms []string, field string,
+	boost float64, editDistances []uint8, options search.SearcherOptions) ([]search.Searcher, error) {
+
+	qsearchers := make([]search.Searcher, len(terms))
+	qsearchersClose := func() {
+		for _, searcher := range qsearchers {
+			if searcher != nil {
+				_ = searcher.Close()
+			}
+		}
+	}
+	for i, term := range terms {
+		var err error
+		var editMultiplier float64
+		if editDistances != nil {
+			editMultiplier = 1 / float64(editDistances[i]+1)
+		}
+		qsearchers[i], err = NewTermSearcher(ctx, indexReader, term, field, boost*editMultiplier, options)
+		if err != nil {
+			qsearchersClose()
+			return nil, err
+		}
+	}
+	return qsearchers, nil
+}
+
 func optimizeMultiTermSearcherBytes(ctx context.Context, indexReader index.IndexReader, terms [][]byte,
 	field string, boost float64, options search.SearcherOptions) (
 	search.Searcher, error) {
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/index_test.go b/index_test.go
index e5094440..e75c5fd8 100644
--- a/index_test.go
+++ b/index_test.go
@@ -2953,3 +2953,62 @@ func TestCopyIndex(t *testing.T) {
 		}
 	}
 }
+
+func TestFuzzyScoring(t *testing.T) {
+	tmpIndexPath := createTmpIndexPath(t)
+	defer cleanupTmpIndexPath(t, tmpIndexPath)
+
+	mp := NewIndexMapping()
+	mp.DefaultAnalyzer = "simple"
+	idx, err := New(tmpIndexPath, mp)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	batch := idx.NewBatch()
+
+	docs := []map[string]interface{}{
+		{
+			"textField": "ab",
+		},
+		{
+			"textField": "abc",
+		},
+		{
+			"textField": "abcd",
+		},
+	}
+
+	for _, doc := range docs {
+		err := batch.Index(fmt.Sprintf("%v", doc["textField"]), doc)
+		if err != nil {
+			t.Fatal(err)
+		}
+	}
+
+	err = idx.Batch(batch)
+	if err != nil {
+		t.Fatal(err)
+	}
+
+	query := NewFuzzyQuery("ab")
+	query.Fuzziness = 2
+	searchRequest := NewSearchRequestOptions(query, 10, 0, true)
+	res, err := idx.Search(searchRequest)
+	if err != nil {
+		t.Error(err)
+	}
+
+	maxScore := res.Hits[0].Score
+
+	for i, hit := range res.Hits {
+		if maxScore/float64(i+1) != hit.Score {
+			t.Errorf("expected score - %f, got score - %f", maxScore/float64(i+1), hit.Score)
+		}
+	}
+
+	err = idx.Close()
+	if err != nil {
+		t.Fatal(err)
+	}
+}
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)
+			}
+		}
+	}
 }