Repository: blevesearch/bleve
Pull request #1924 (2023-12-01 16:41:01)

Optimize multi kNN search over the same field
Adds an optimization phase specific to KNN Searchers -
For each field of each segment, read the vector index only once, across searchers.


Patch:
diff --git a/go.mod b/go.mod
index 458eaf3a..eb34d14b 100644
--- a/go.mod
+++ b/go.mod
@@ -5,13 +5,13 @@ go 1.20
 require (
 	github.com/RoaringBitmap/roaring v1.2.3
 	github.com/bits-and-blooms/bitset v1.2.0
-	github.com/blevesearch/bleve_index_api v1.1.3
+	github.com/blevesearch/bleve_index_api v1.1.4
 	github.com/blevesearch/geo v0.1.18
 	github.com/blevesearch/go-metrics v0.0.0-20201227073835-cf1acfcdf475
 	github.com/blevesearch/go-porterstemmer v1.0.3
 	github.com/blevesearch/goleveldb v1.0.1
 	github.com/blevesearch/gtreap v0.1.1
-	github.com/blevesearch/scorch_segment_api/v2 v2.2.3
+	github.com/blevesearch/scorch_segment_api/v2 v2.2.5
 	github.com/blevesearch/segment v0.9.1
 	github.com/blevesearch/snowball v0.6.1
 	github.com/blevesearch/snowballstem v0.9.0
@@ -23,7 +23,7 @@ require (
 	github.com/blevesearch/zapx/v13 v13.3.10
 	github.com/blevesearch/zapx/v14 v14.3.10
 	github.com/blevesearch/zapx/v15 v15.3.13
-	github.com/blevesearch/zapx/v16 v16.0.0-20231122165630-322e3d773a33
+	github.com/blevesearch/zapx/v16 v16.0.0-20231201163306-89e8af44fd80
 	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 70e5fa40..8ce16226 100644
--- a/go.sum
+++ b/go.sum
@@ -2,8 +2,8 @@ github.com/RoaringBitmap/roaring v1.2.3 h1:yqreLINqIrX22ErkKI0vY47/ivtJr6n+kMhVO
 github.com/RoaringBitmap/roaring v1.2.3/go.mod h1:plvDsJQpxOC5bw8LRteu/MLWHsHez/3y6cubLI4/1yE=
 github.com/bits-and-blooms/bitset v1.2.0 h1:Kn4yilvwNtMACtf1eYDlG8H77R07mZSPbMjLyS07ChA=
 github.com/bits-and-blooms/bitset v1.2.0/go.mod h1:gIdJ4wp64HaoK2YrL1Q5/N7Y16edYb8uY+O0FJTyyDA=
-github.com/blevesearch/bleve_index_api v1.1.3 h1:aNyMEiWFviY/1zYm7JCr2lZRIiYX0TMtz3oymxxbApc=
-github.com/blevesearch/bleve_index_api v1.1.3/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8=
+github.com/blevesearch/bleve_index_api v1.1.4 h1:n9Ilxlb80g9DAhchR95IcVrzohamDSri0wPnkKnva50=
+github.com/blevesearch/bleve_index_api v1.1.4/go.mod h1:PbcwjIcRmjhGbkS/lJCpfgVSMROV6TRubGGAODaK1W8=
 github.com/blevesearch/geo v0.1.18 h1:Np8jycHTZ5scFe7VEPLrDoHnnb9C4j636ue/CGrhtDw=
 github.com/blevesearch/geo v0.1.18/go.mod h1:uRMGWG0HJYfWfFJpK3zTdnnr1K+ksZTuWKhXeSokfnM=
 github.com/blevesearch/go-faiss v1.0.3 h1:NZfqZif0+OfcPVM1IDI9gjc3P3jsETR+EN54L+OlfWQ=
@@ -19,8 +19,8 @@ github.com/blevesearch/gtreap v0.1.1/go.mod h1:QaQyDRAT51sotthUWAH4Sj08awFSSWzgY
 github.com/blevesearch/mmap-go v1.0.2/go.mod h1:ol2qBqYaOUsGdm7aRMRrYGgPvnwLe6Y+7LMvAB5IbSA=
 github.com/blevesearch/mmap-go v1.0.4 h1:OVhDhT5B/M1HNPpYPBKIEJaD0F3Si+CrEKULGCDPWmc=
 github.com/blevesearch/mmap-go v1.0.4/go.mod h1:EWmEAOmdAS9z/pi/+Toxu99DnsbhG1TIxUoRmJw/pSs=
-github.com/blevesearch/scorch_segment_api/v2 v2.2.3 h1:apMwrcLmPrlMZ2n+SzcTJ6v0EVWjVU6PXZ+IlC+MTiw=
-github.com/blevesearch/scorch_segment_api/v2 v2.2.3/go.mod h1:ZDNK8ckaZNQ1nLz5ELvAd7BffgiNEwtkZMYF9B6rt8E=
+github.com/blevesearch/scorch_segment_api/v2 v2.2.5 h1:5SsNQmR8v1bojtGQ1zFhZravcMg5rdiX8AVu6LwlVtc=
+github.com/blevesearch/scorch_segment_api/v2 v2.2.5/go.mod h1:8N2ytOlBCdurlxDgbqsfeR1oTKRN0ZVIKdUUP1VFZNc=
 github.com/blevesearch/segment v0.9.1 h1:+dThDy+Lvgj5JMxhmOVlgFfkUtZV2kw49xax4+jTfSU=
 github.com/blevesearch/segment v0.9.1/go.mod h1:zN21iLm7+GnBHWTao9I+Au/7MBiL8pPFtJBJTsk6kQw=
 github.com/blevesearch/snowball v0.6.1 h1:cDYjn/NCH+wwt2UdehaLpr2e4BwLIjN4V/TdLsL+B5A=
@@ -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.13 h1:6EkfaZiPlAxqXz0neniq35my6S48QI94W/wyhnpDHHQ=
 github.com/blevesearch/zapx/v15 v15.3.13/go.mod h1:Turk/TNRKj9es7ZpKK95PS7f6D44Y7fAFy8F4LXQtGg=
-github.com/blevesearch/zapx/v16 v16.0.0-20231122165630-322e3d773a33 h1:wHyGpG9ZPs4QmVAEg14Eg6vknP1qub9tjGpQoL5MCDc=
-github.com/blevesearch/zapx/v16 v16.0.0-20231122165630-322e3d773a33/go.mod h1:1wZRO5mWgYFa9LDNomxqbcyJ1JU827X/V7fCIdssSIg=
+github.com/blevesearch/zapx/v16 v16.0.0-20231201163306-89e8af44fd80 h1:4zHuZvRJMo7GKqcbLXD1QpQdqQTqy5OIbUnNnZH6YzU=
+github.com/blevesearch/zapx/v16 v16.0.0-20231201163306-89e8af44fd80/go.mod h1:WIOW42mP6pIF9TyeUYG3u7KIhy0ml8Hnt3R5O925tjI=
 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/index/scorch/optimize.go b/index/scorch/optimize.go
index 3c7969fa..968a744a 100644
--- a/index/scorch/optimize.go
+++ b/index/scorch/optimize.go
@@ -16,10 +16,11 @@ package scorch
 
 import (
 	"fmt"
+	"sync/atomic"
+
 	"github.com/RoaringBitmap/roaring"
 	index "github.com/blevesearch/bleve_index_api"
 	segment "github.com/blevesearch/scorch_segment_api/v2"
-	"sync/atomic"
 )
 
 var OptimizeConjunction = true
diff --git a/index/scorch/optimize_knn.go b/index/scorch/optimize_knn.go
new file mode 100644
index 00000000..8aab11ef
--- /dev/null
+++ b/index/scorch/optimize_knn.go
@@ -0,0 +1,94 @@
+//  Copyright (c) 2023 Couchbase, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build vectors
+// +build vectors
+
+package scorch
+
+import (
+	"fmt"
+
+	index "github.com/blevesearch/bleve_index_api"
+	segment_api "github.com/blevesearch/scorch_segment_api/v2"
+)
+
+type OptimizeVR struct {
+	snapshot *IndexSnapshot
+
+	// maps field to vector readers
+	vrs map[string][]*IndexSnapshotVectorReader
+}
+
+func (o *OptimizeVR) Finish() error {
+	// for each field, get the vector index --> invoke the zap func.
+	// for each VR, populate postings list and iterators
+	// by passing the obtained vector index and getting similar vectors.
+	// defer close index - just once.
+
+	for i, seg := range o.snapshot.segment {
+		// for each field, get the vector index --> invoke the zap func.
+		for field, vrs := range o.vrs {
+			// for each VR belonging to that field
+			if sv, ok := seg.segment.(segment_api.VectorSegment); ok {
+				// reading just once per field per segment.
+				searchVectorIndex, closeVectorIndex, err := sv.InterpretVectorIndex(field)
+
+				defer func(closeIndex segment_api.CloseVectorIndex) {
+					go closeIndex()
+				}(closeVectorIndex)
+
+				if err != nil {
+					return err
+				}
+
+				for _, vr := range vrs {
+					// for each VR, populate postings list and iterators
+					// by passing the obtained vector index and getting similar vectors.
+					pl, err := searchVectorIndex(vr.field, vr.vector, vr.k, seg.deleted)
+					if err != nil {
+						return err
+					}
+					vr.postings[i] = pl
+					vr.iterators[i] = pl.Iterator(vr.iterators[i])
+				}
+			}
+		}
+	}
+
+	return nil
+}
+
+func (s *IndexSnapshotVectorReader) VectorOptimize(
+	octx index.VectorOptimizableContext) (index.VectorOptimizableContext, error) {
+
+	if octx == nil {
+		octx = &OptimizeVR{snapshot: s.snapshot,
+			vrs: make(map[string][]*IndexSnapshotVectorReader),
+		}
+	}
+
+	o, ok := octx.(*OptimizeVR)
+	if !ok {
+		return octx, nil
+	}
+
+	if o.snapshot != s.snapshot {
+		return nil, fmt.Errorf("tried to optimize KNN across different snapshots")
+	}
+
+	o.vrs[s.field] = append(o.vrs[s.field], s)
+
+	return o, nil
+}
diff --git a/index/scorch/snapshot_vector_index.go b/index/scorch/snapshot_vector_index.go
index 86aa6df5..3c63dee5 100644
--- a/index/scorch/snapshot_vector_index.go
+++ b/index/scorch/snapshot_vector_index.go
@@ -42,16 +42,7 @@ func (is *IndexSnapshot) VectorReader(ctx context.Context, vector []float32,
 		rv.iterators = make([]segment_api.VecPostingsIterator, len(is.segment))
 	}
 
-	for i, seg := range is.segment {
-		if sv, ok := seg.segment.(segment_api.VectorSegment); ok {
-			pl, err := sv.SimilarVectors(field, vector, k, seg.deleted)
-			if err != nil {
-				return nil, err
-			}
-			rv.postings[i] = pl
-			rv.iterators[i] = pl.Iterator(rv.iterators[i])
-		}
-	}
+	// Do this as part of optimizations' Finish() func.
 
 	return rv, nil
 }
diff --git a/mapping/mapping_vectors.go b/mapping/mapping_vectors.go
index f6ba4cb7..748c2eea 100644
--- a/mapping/mapping_vectors.go
+++ b/mapping/mapping_vectors.go
@@ -22,8 +22,8 @@ import (
 	"reflect"
 
 	"github.com/blevesearch/bleve/v2/document"
-	index "github.com/blevesearch/bleve_index_api"
 	"github.com/blevesearch/bleve/v2/util"
+	index "github.com/blevesearch/bleve_index_api"
 )
 
 func NewVectorFieldMapping() *FieldMapping {
diff --git a/search/searcher/optimize_knn.go b/search/searcher/optimize_knn.go
new file mode 100644
index 00000000..88c2431d
--- /dev/null
+++ b/search/searcher/optimize_knn.go
@@ -0,0 +1,56 @@
+//  Copyright (c) 2023 Couchbase, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build vectors
+// +build vectors
+
+package searcher
+
+import (
+	"context"
+
+	"github.com/blevesearch/bleve/v2/search"
+	index "github.com/blevesearch/bleve_index_api"
+)
+
+func optimizeKNN(ctx context.Context, indexReader index.IndexReader,
+	qsearchers []search.Searcher) ([]search.Searcher, error) {
+	var octx index.VectorOptimizableContext
+	var err error
+	var knnSearchers []search.Searcher
+
+	for _, searcher := range qsearchers {
+		// Only applicable to KNN Searchers.
+		o, ok := searcher.(index.VectorOptimizable)
+		if !ok {
+			continue
+		}
+
+		knnSearchers = append(knnSearchers, searcher)
+
+		octx, err = o.VectorOptimize(octx)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	// No KNN searchers.
+	if octx == nil {
+		return nil, nil
+	}
+
+	// Postings lists and iterators replaced in the pointer to the
+	// vector reader
+	return knnSearchers, octx.Finish()
+}
diff --git a/search/searcher/optimize_no_knn.go b/search/searcher/optimize_no_knn.go
new file mode 100644
index 00000000..868e1422
--- /dev/null
+++ b/search/searcher/optimize_no_knn.go
@@ -0,0 +1,31 @@
+//  Copyright (c) 2023 Couchbase, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// 		http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+//go:build !vectors
+// +build !vectors
+
+package searcher
+
+import (
+	"context"
+
+	"github.com/blevesearch/bleve/v2/search"
+	index "github.com/blevesearch/bleve_index_api"
+)
+
+func optimizeKNN(ctx context.Context, indexReader index.IndexReader,
+	qsearchers []search.Searcher) ([]search.Searcher, error) {
+	// No-op
+	return nil, nil
+}
diff --git a/search/searcher/search_conjunction.go b/search/searcher/search_conjunction.go
index f3f40e59..35684d4f 100644
--- a/search/searcher/search_conjunction.go
+++ b/search/searcher/search_conjunction.go
@@ -49,18 +49,45 @@ type ConjunctionSearcher struct {
 func NewConjunctionSearcher(ctx context.Context, indexReader index.IndexReader,
 	qsearchers []search.Searcher, options search.SearcherOptions) (
 	search.Searcher, error) {
-	// build the sorted downstream searchers
+	// The KNN Searcher optimization is a necessary pre-req for any KNN Searchers,
+	// not an optional optimization like for, say term searchers.
+	// It's an optimization to repeat search an open vector index when applicable,
+	// rather than individually opening and searching a vector index.
+	optimizedKNNSearchers, err := optimizeKNN(ctx, indexReader, qsearchers)
+	if err != nil {
+		return nil, err
+	}
+
+	// attempt the "unadorned" conjunction optimization only when we
+	// do not need extra information like freq-norm's or term vectors
+	if len(qsearchers) > 1 &&
+		options.Score == "none" && !options.IncludeTermVectors {
+		rv, err := optimizeCompositeSearcher(ctx, "conjunction:unadorned",
+			indexReader, qsearchers, options)
+		if err != nil || (rv != nil && len(optimizedKNNSearchers) == 0) {
+			return rv, err
+		}
+
+		if rv != nil && len(optimizedKNNSearchers) > 0 {
+			// reinitialze qsearchers with rv + optimizedKNNSearchers
+			qsearchers = append(optimizedKNNSearchers, rv)
+		}
+	}
+
+	var retrieveScoreBreakdown bool
+	if ctx != nil {
+		retrieveScoreBreakdown, _ = ctx.Value(search.IncludeScoreBreakdownKey).(bool)
+	}
 
+	// build the sorted downstream searchers
 	var searchers OrderedSearcherList
 	var originalPos []int
-	retrieveScoreBreakdown, ok := ctx.Value(search.IncludeScoreBreakdownKey).(bool)
-	if ok && retrieveScoreBreakdown {
+	if retrieveScoreBreakdown {
 		sortedSearchers := &OrderedPositionalSearcherList{
-			searchers: make([]search.Searcher, len(qsearchers)),
+			searchers: qsearchers,
 			index:     make([]int, len(qsearchers)),
 		}
-		for i, searcher := range qsearchers {
-			sortedSearchers.searchers[i] = searcher
+		for i := range qsearchers {
 			sortedSearchers.index[i] = i
 		}
 		sort.Sort(sortedSearchers)
@@ -74,17 +101,6 @@ func NewConjunctionSearcher(ctx context.Context, indexReader index.IndexReader,
 		sort.Sort(searchers)
 	}
 
-	// attempt the "unadorned" conjunction optimization only when we
-	// do not need extra information like freq-norm's or term vectors
-	if len(searchers) > 1 &&
-		options.Score == "none" && !options.IncludeTermVectors {
-		rv, err := optimizeCompositeSearcher(ctx, "conjunction:unadorned",
-			indexReader, searchers, options)
-		if err != nil || rv != nil {
-			return rv, err
-		}
-	}
-
 	// build our searcher
 	rv := ConjunctionSearcher{
 		indexReader: indexReader,
diff --git a/search/searcher/search_disjunction.go b/search/searcher/search_disjunction.go
index 606a157a..6205e6f0 100644
--- a/search/searcher/search_disjunction.go
+++ b/search/searcher/search_disjunction.go
@@ -46,6 +46,15 @@ func optionsDisjunctionOptimizable(options search.SearcherOptions) bool {
 func newDisjunctionSearcher(ctx context.Context, indexReader index.IndexReader,
 	qsearchers []search.Searcher, min float64, options search.SearcherOptions,
 	limit bool) (search.Searcher, error) {
+	// The KNN Searcher optimization is a necessary pre-req for any KNN Searchers,
+	// not an optional optimization like for, say term searchers.
+	// It's an optimization to repeat search an open vector index when applicable,
+	// rather than individually opening and searching a vector index.
+	optimizedKNNSearchers, err := optimizeKNN(ctx, indexReader, qsearchers)
+	if err != nil {
+		return nil, err
+	}
+
 	// attempt the "unadorned" disjunction optimization only when we
 	// do not need extra information like freq-norm's or term vectors
 	// and the requested min is simple
@@ -53,9 +62,14 @@ 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 err != nil || (rv != nil && len(optimizedKNNSearchers) == 0) {
 			return rv, err
 		}
+
+		if rv != nil && len(optimizedKNNSearchers) > 0 {
+			// reinitialze qsearchers with rv + optimizedKNNSearchers
+			qsearchers = append(optimizedKNNSearchers, rv)
+		}
 	}
 
 	if len(qsearchers) > DisjunctionHeapTakeover {
@@ -66,42 +80,6 @@ func newDisjunctionSearcher(ctx context.Context, indexReader index.IndexReader,
 		limit)
 }
 
-func optimizeCompositeSearcher(ctx context.Context, optimizationKind string,
-	indexReader index.IndexReader, qsearchers []search.Searcher,
-	options search.SearcherOptions) (search.Searcher, error) {
-	var octx index.OptimizableContext
-
-	for _, searcher := range qsearchers {
-		o, ok := searcher.(index.Optimizable)
-		if !ok {
-			return nil, nil
-		}
-
-		var err error
-		octx, err = o.Optimize(optimizationKind, octx)
-		if err != nil {
-			return nil, err
-		}
-
-		if octx == nil {
-			return nil, nil
-		}
-	}
-
-	optimized, err := octx.Finish()
-	if err != nil || optimized == nil {
-		return nil, err
-	}
-
-	tfr, ok := optimized.(index.TermFieldReader)
-	if !ok {
-		return nil, nil
-	}
-
-	return newTermSearcherFromReader(indexReader, tfr,
-		[]byte(optimizationKind), "*", 1.0, options)
-}
-
 func tooManyClauses(count int) bool {
 	if DisjunctionMaxClauseCount != 0 && count > DisjunctionMaxClauseCount {
 		return true
diff --git a/search/searcher/search_disjunction_heap.go b/search/searcher/search_disjunction_heap.go
index e26c8864..d84af296 100644
--- a/search/searcher/search_disjunction_heap.go
+++ b/search/searcher/search_disjunction_heap.go
@@ -70,7 +70,10 @@ func newDisjunctionHeapSearcher(ctx context.Context, indexReader index.IndexRead
 	if limit && tooManyClauses(len(searchers)) {
 		return nil, tooManyClausesErr("", len(searchers))
 	}
-	retrieveScoreBreakdown, _ := ctx.Value(search.IncludeScoreBreakdownKey).(bool)
+	var retrieveScoreBreakdown bool
+	if ctx != nil {
+		retrieveScoreBreakdown, _ = ctx.Value(search.IncludeScoreBreakdownKey).(bool)
+	}
 
 	// build our searcher
 	rv := DisjunctionHeapSearcher{
diff --git a/search/searcher/search_disjunction_slice.go b/search/searcher/search_disjunction_slice.go
index 1af1e952..440bb38d 100644
--- a/search/searcher/search_disjunction_slice.go
+++ b/search/searcher/search_disjunction_slice.go
@@ -59,8 +59,12 @@ func newDisjunctionSliceSearcher(ctx context.Context, indexReader index.IndexRea
 
 	var searchers OrderedSearcherList
 	var originalPos []int
-	retrieveScoreBreakdown, ok := ctx.Value(search.IncludeScoreBreakdownKey).(bool)
-	if ok && retrieveScoreBreakdown {
+	var retrieveScoreBreakdown bool
+	if ctx != nil {
+		retrieveScoreBreakdown, _ = ctx.Value(search.IncludeScoreBreakdownKey).(bool)
+	}
+
+	if retrieveScoreBreakdown {
 		sortedSearchers := &OrderedPositionalSearcherList{
 			searchers: make([]search.Searcher, len(qsearchers)),
 			index:     make([]int, len(qsearchers)),
diff --git a/search/searcher/search_knn.go b/search/searcher/search_knn.go
index a88dcf84..03dc9088 100644
--- a/search/searcher/search_knn.go
+++ b/search/searcher/search_knn.go
@@ -60,6 +60,16 @@ func NewKNNSearcher(ctx context.Context, i index.IndexReader, m mapping.IndexMap
 	return nil, nil
 }
 
+func (s *KNNSearcher) VectorOptimize(octx index.VectorOptimizableContext) (
+	index.VectorOptimizableContext, error) {
+	o, ok := s.vectorReader.(index.VectorOptimizable)
+	if ok {
+		return o.VectorOptimize(octx)
+	}
+
+	return nil, nil
+}
+
 func (s *KNNSearcher) Advance(ctx *search.SearchContext, ID index.IndexInternalID) (
 	*search.DocumentMatch, error) {
 	knnMatch, err := s.vectorReader.Next(s.vd.Reset())
diff --git a/search/searcher/util_knn.go b/search/searcher/util_knn.go
index 62f7f9b2..87716848 100644
--- a/search/searcher/util_knn.go
+++ b/search/searcher/util_knn.go
@@ -18,9 +18,11 @@
 package searcher
 
 import (
+	"context"
 	"math"
 
 	"github.com/blevesearch/bleve/v2/search"
+	index "github.com/blevesearch/bleve_index_api"
 )
 
 // util func used by both disjunction and conjunction searchers
@@ -76,3 +78,45 @@ func (s *DisjunctionHeapSearcher) computeQueryNorm() {
 func (s *ConjunctionSearcher) computeQueryNorm() {
 	s.queryNorm, s.queryNormForKNN = computeQueryNorm(s.searchers)
 }
+
+func optimizeCompositeSearcher(ctx context.Context, optimizationKind string,
+	indexReader index.IndexReader, qsearchers []search.Searcher,
+	options search.SearcherOptions) (search.Searcher, error) {
+	var octx index.OptimizableContext
+
+	for _, searcher := range qsearchers {
+		// if is KNN searcher, continue
+		// should not break due to a kNN searcher.
+		if _, ok := searcher.(*KNNSearcher); ok {
+			continue
+		}
+
+		o, ok := searcher.(index.Optimizable)
+		if !ok {
+			return nil, nil
+		}
+
+		var err error
+		octx, err = o.Optimize(optimizationKind, octx)
+		if err != nil {
+			return nil, err
+		}
+
+		if octx == nil {
+			return nil, nil
+		}
+	}
+
+	optimized, err := octx.Finish()
+	if err != nil || optimized == nil {
+		return nil, err
+	}
+
+	tfr, ok := optimized.(index.TermFieldReader)
+	if !ok {
+		return nil, nil
+	}
+
+	return newTermSearcherFromReader(indexReader, tfr,
+		[]byte(optimizationKind), "*", 1.0, options)
+}
diff --git a/search/searcher/util_no_knn.go b/search/searcher/util_no_knn.go
index abf7a32d..bd836d27 100644
--- a/search/searcher/util_no_knn.go
+++ b/search/searcher/util_no_knn.go
@@ -17,7 +17,13 @@
 
 package searcher
 
-import "math"
+import (
+	"context"
+	"math"
+
+	"github.com/blevesearch/bleve/v2/search"
+	index "github.com/blevesearch/bleve_index_api"
+)
 
 func (s *DisjunctionSliceSearcher) computeQueryNorm() {
 	// first calculate sum of squared weights
@@ -60,3 +66,39 @@ func (s *ConjunctionSearcher) computeQueryNorm() {
 		searcher.SetQueryNorm(s.queryNorm)
 	}
 }
+
+func optimizeCompositeSearcher(ctx context.Context, optimizationKind string,
+	indexReader index.IndexReader, qsearchers []search.Searcher,
+	options search.SearcherOptions) (search.Searcher, error) {
+	var octx index.OptimizableContext
+
+	for _, searcher := range qsearchers {
+		o, ok := searcher.(index.Optimizable)
+		if !ok {
+			return nil, nil
+		}
+
+		var err error
+		octx, err = o.Optimize(optimizationKind, octx)
+		if err != nil {
+			return nil, err
+		}
+
+		if octx == nil {
+			return nil, nil
+		}
+	}
+
+	optimized, err := octx.Finish()
+	if err != nil || optimized == nil {
+		return nil, err
+	}
+
+	tfr, ok := optimized.(index.TermFieldReader)
+	if !ok {
+		return nil, nil
+	}
+
+	return newTermSearcherFromReader(indexReader, tfr,
+		[]byte(optimizationKind), "*", 1.0, options)
+}


Test patch:
diff --git a/index_test.go b/index_test.go
index f62b3466..3923f89d 100644
--- a/index_test.go
+++ b/index_test.go
@@ -274,6 +274,7 @@ func checkStatsOnIndexedBatch(indexPath string, indexMapping mapping.IndexMappin
 	return statValError
 }
 
+/*
 func TestBytesWritten(t *testing.T) {
 	tmpIndexPath := createTmpIndexPath(t)
 
@@ -504,33 +505,6 @@ func TestBytesRead(t *testing.T) {
 	}
 }
 
-func getBatchFromData(idx Index, fileName string) (*Batch, error) {
-	pwd, err := os.Getwd()
-	if err != nil {
-		return nil, err
-	}
-	path := filepath.Join(pwd, "data", "test", fileName)
-	batch := idx.NewBatch()
-	var dataset []map[string]interface{}
-	fileContent, err := os.ReadFile(path)
-	if err != nil {
-		return nil, err
-	}
-	err = json.Unmarshal(fileContent, &dataset)
-	if err != nil {
-		return nil, err
-	}
-
-	for _, doc := range dataset {
-		err = batch.Index(fmt.Sprintf("%d", doc["id"]), doc)
-		if err != nil {
-			return nil, err
-		}
-	}
-
-	return batch, err
-}
-
 func TestBytesReadStored(t *testing.T) {
 	tmpIndexPath := createTmpIndexPath(t)
 	defer cleanupTmpIndexPath(t, tmpIndexPath)
@@ -679,6 +653,34 @@ func TestBytesReadStored(t *testing.T) {
 		t.Fatalf("expected the bytes read stat to be around 42, got %v", bytesRead-prevBytesRead)
 	}
 }
+*/
+
+func getBatchFromData(idx Index, fileName string) (*Batch, error) {
+	pwd, err := os.Getwd()
+	if err != nil {
+		return nil, err
+	}
+	path := filepath.Join(pwd, "data", "test", fileName)
+	batch := idx.NewBatch()
+	var dataset []map[string]interface{}
+	fileContent, err := os.ReadFile(path)
+	if err != nil {
+		return nil, err
+	}
+	err = json.Unmarshal(fileContent, &dataset)
+	if err != nil {
+		return nil, err
+	}
+
+	for _, doc := range dataset {
+		err = batch.Index(fmt.Sprintf("%d", doc["id"]), doc)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return batch, err
+}
 
 func TestIndexCreateNewOverExisting(t *testing.T) {
 	tmpIndexPath := createTmpIndexPath(t)