Repository: blevesearch/bleve
Pull request #2026 (2024-05-02 16:41:46)

MB-61742: Interpreting vectors w/ spurious float32s from base64 encodings
+ Handle the situation when interpreted vectors have NaN or Inf when decoded from base64 encoded strings.
+ Also, in the vector_base64 path - it seems we were unnecessarily casting a []float32 into an interface{} to again be interpreted as a []float32 which we can avoid.


Patch:
diff --git a/analysis/datetime/timestamp/microseconds/microseconds.go b/analysis/datetime/timestamp/microseconds/microseconds.go
index 3efed0ed..a0e2c949 100644
--- a/analysis/datetime/timestamp/microseconds/microseconds.go
+++ b/analysis/datetime/timestamp/microseconds/microseconds.go
@@ -40,7 +40,7 @@ func (p *DateTimeParser) ParseDateTime(input string) (time.Time, string, error)
 	if timestamp < minBound || timestamp > maxBound {
 		return time.Time{}, "", analysis.ErrInvalidTimestampRange
 	}
-	return time.UnixMicro(timestamp), "", nil
+	return time.UnixMicro(timestamp), Name, nil
 }
 
 func DateTimeParserConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.DateTimeParser, error) {
diff --git a/analysis/datetime/timestamp/milliseconds/milliseconds.go b/analysis/datetime/timestamp/milliseconds/milliseconds.go
index 790153d2..63826b45 100644
--- a/analysis/datetime/timestamp/milliseconds/milliseconds.go
+++ b/analysis/datetime/timestamp/milliseconds/milliseconds.go
@@ -40,7 +40,7 @@ func (p *DateTimeParser) ParseDateTime(input string) (time.Time, string, error)
 	if timestamp < minBound || timestamp > maxBound {
 		return time.Time{}, "", analysis.ErrInvalidTimestampRange
 	}
-	return time.UnixMilli(timestamp), "", nil
+	return time.UnixMilli(timestamp), Name, nil
 }
 
 func DateTimeParserConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.DateTimeParser, error) {
diff --git a/analysis/datetime/timestamp/nanoseconds/nanoseconds.go b/analysis/datetime/timestamp/nanoseconds/nanoseconds.go
index de318f3f..8bb1ab1b 100644
--- a/analysis/datetime/timestamp/nanoseconds/nanoseconds.go
+++ b/analysis/datetime/timestamp/nanoseconds/nanoseconds.go
@@ -40,7 +40,7 @@ func (p *DateTimeParser) ParseDateTime(input string) (time.Time, string, error)
 	if timestamp < minBound || timestamp > maxBound {
 		return time.Time{}, "", analysis.ErrInvalidTimestampRange
 	}
-	return time.Unix(0, timestamp), "", nil
+	return time.Unix(0, timestamp), Name, nil
 }
 
 func DateTimeParserConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.DateTimeParser, error) {
diff --git a/analysis/datetime/timestamp/seconds/seconds.go b/analysis/datetime/timestamp/seconds/seconds.go
index 8d3ee3b4..58e947c8 100644
--- a/analysis/datetime/timestamp/seconds/seconds.go
+++ b/analysis/datetime/timestamp/seconds/seconds.go
@@ -40,7 +40,7 @@ func (p *DateTimeParser) ParseDateTime(input string) (time.Time, string, error)
 	if timestamp < minBound || timestamp > maxBound {
 		return time.Time{}, "", analysis.ErrInvalidTimestampRange
 	}
-	return time.Unix(timestamp, 0), "", nil
+	return time.Unix(timestamp, 0), Name, nil
 }
 
 func DateTimeParserConstructor(config map[string]interface{}, cache *registry.Cache) (analysis.DateTimeParser, error) {
diff --git a/docs/vectors.md b/docs/vectors.md
index b4264d29..bddf239e 100644
--- a/docs/vectors.md
+++ b/docs/vectors.md
@@ -15,9 +15,9 @@
 
 ```go
 doc := struct{
-    Id   string
-    Text string
-    Vec  []float32
+    Id   string    `json:"id"`
+    Text string    `json:"text"`
+    Vec  []float32 `json:"vec"`
 }{
     Id:   "example",
     Text: "hello from united states",
diff --git a/document/field_vector_base64.go b/document/field_vector_base64.go
index e62dbe0a..67825960 100644
--- a/document/field_vector_base64.go
+++ b/document/field_vector_base64.go
@@ -24,6 +24,7 @@ import (
 	"math"
 
 	"github.com/blevesearch/bleve/v2/size"
+	"github.com/blevesearch/bleve/v2/util"
 	index "github.com/blevesearch/bleve_index_api"
 )
 
@@ -81,14 +82,14 @@ func (n *VectorBase64Field) GoString() string {
 func NewVectorBase64Field(name string, arrayPositions []uint64, vectorBase64 string,
 	dims int, similarity, vectorIndexOptimizedFor string) (*VectorBase64Field, error) {
 
-	vector, err := DecodeVector(vectorBase64)
+	decodedVector, err := DecodeVector(vectorBase64)
 	if err != nil {
 		return nil, err
 	}
 
 	return &VectorBase64Field{
 		vectorField: NewVectorFieldWithIndexingOptions(name, arrayPositions,
-			vector, dims, similarity,
+			decodedVector, dims, similarity,
 			vectorIndexOptimizedFor, DefaultVectorIndexingOptions),
 
 		base64Encoding: vectorBase64,
@@ -98,7 +99,6 @@ func NewVectorBase64Field(name string, arrayPositions []uint64, vectorBase64 str
 // This function takes a base64 encoded string and decodes it into
 // a vector.
 func DecodeVector(encodedValue string) ([]float32, error) {
-
 	// We first decode the encoded string into a byte array.
 	decodedString, err := base64.StdEncoding.DecodeString(encodedValue)
 	if err != nil {
@@ -108,16 +108,25 @@ func DecodeVector(encodedValue string) ([]float32, error) {
 	// The array is expected to be divisible by 4 because each float32
 	// should occupy 4 bytes
 	if len(decodedString)%size.SizeOfFloat32 != 0 {
-		return nil, fmt.Errorf("Decoded byte array not divisible by %d", size.SizeOfFloat32)
+		return nil, fmt.Errorf("decoded byte array not divisible by %d", size.SizeOfFloat32)
 	}
 	dims := int(len(decodedString) / size.SizeOfFloat32)
+
+	if dims <= 0 {
+		return nil, fmt.Errorf("unable to decode encoded vector")
+	}
+
 	decodedVector := make([]float32, dims)
 
 	// We iterate through the array 4 bytes at a time and convert each of
 	// them to a float32 value by reading them in a little endian notation
 	for i := 0; i < dims; i++ {
 		bytes := decodedString[i*size.SizeOfFloat32 : (i+1)*size.SizeOfFloat32]
-		decodedVector[i] = math.Float32frombits(binary.LittleEndian.Uint32(bytes))
+		entry := math.Float32frombits(binary.LittleEndian.Uint32(bytes))
+		if !util.IsValidFloat32(float64(entry)) {
+			return nil, fmt.Errorf("invalid float32 value: %f", entry)
+		}
+		decodedVector[i] = entry
 	}
 
 	return decodedVector, nil
diff --git a/http/doc_get.go b/http/doc_get.go
index 328955eb..ac783a16 100644
--- a/http/doc_get.go
+++ b/http/doc_get.go
@@ -18,7 +18,12 @@ import (
 	"fmt"
 	"net/http"
 	"strconv"
+	"time"
 
+	"github.com/blevesearch/bleve/v2/analysis/datetime/timestamp/microseconds"
+	"github.com/blevesearch/bleve/v2/analysis/datetime/timestamp/milliseconds"
+	"github.com/blevesearch/bleve/v2/analysis/datetime/timestamp/nanoseconds"
+	"github.com/blevesearch/bleve/v2/analysis/datetime/timestamp/seconds"
 	index "github.com/blevesearch/bleve_index_api"
 )
 
@@ -91,10 +96,24 @@ func (h *DocGetHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
 			d, layout, err := field.DateTime()
 			if err == nil {
 				if layout == "" {
-					// layout not set probably means it was indexed as a timestamp
-					newval = strconv.FormatInt(d.UnixNano(), 10)
+					// missing layout means we fallback to
+					// the default layout which is RFC3339
+					newval = d.Format(time.RFC3339)
 				} else {
-					newval = d.Format(layout)
+					// the layout here can now either be representative
+					// of an actual layout or a timestamp
+					switch layout {
+					case seconds.Name:
+						newval = strconv.FormatInt(d.Unix(), 10)
+					case milliseconds.Name:
+						newval = strconv.FormatInt(d.UnixMilli(), 10)
+					case microseconds.Name:
+						newval = strconv.FormatInt(d.UnixMicro(), 10)
+					case nanoseconds.Name:
+						newval = strconv.FormatInt(d.UnixNano(), 10)
+					default:
+						newval = d.Format(layout)
+					}
 				}
 			}
 		}
diff --git a/index_impl.go b/index_impl.go
index a5254735..03f77c3e 100644
--- a/index_impl.go
+++ b/index_impl.go
@@ -25,6 +25,10 @@ import (
 	"sync/atomic"
 	"time"
 
+	"github.com/blevesearch/bleve/v2/analysis/datetime/timestamp/microseconds"
+	"github.com/blevesearch/bleve/v2/analysis/datetime/timestamp/milliseconds"
+	"github.com/blevesearch/bleve/v2/analysis/datetime/timestamp/nanoseconds"
+	"github.com/blevesearch/bleve/v2/analysis/datetime/timestamp/seconds"
 	"github.com/blevesearch/bleve/v2/document"
 	"github.com/blevesearch/bleve/v2/index/scorch"
 	"github.com/blevesearch/bleve/v2/index/upsidedown"
@@ -738,10 +742,28 @@ func LoadAndHighlightFields(hit *search.DocumentMatch, req *SearchRequest,
 								datetime, layout, err := docF.DateTime()
 								if err == nil {
 									if layout == "" {
-										// layout not set probably means it was indexed as a timestamp
-										value = strconv.FormatInt(datetime.UnixNano(), 10)
+										// missing layout means we fallback to
+										// the default layout which is RFC3339
+										value = datetime.Format(time.RFC3339)
 									} else {
-										value = datetime.Format(layout)
+										// the layout here can now either be representative
+										// of an actual datetime layout or a timestamp
+										switch layout {
+										case seconds.Name:
+											value = strconv.FormatInt(datetime.Unix(), 10)
+										case milliseconds.Name:
+											value = strconv.FormatInt(datetime.UnixMilli(), 10)
+										case microseconds.Name:
+											value = strconv.FormatInt(datetime.UnixMicro(), 10)
+										case nanoseconds.Name:
+											value = strconv.FormatInt(datetime.UnixNano(), 10)
+										default:
+											// the layout for formatting the date to a string
+											// is provided by a datetime parser which is not
+											// handling the timestamp case, hence the layout
+											// can be directly used to format the date
+											value = datetime.Format(layout)
+										}
 									}
 								}
 							case index.BooleanField:
diff --git a/mapping/mapping_vectors.go b/mapping/mapping_vectors.go
index 3aed8a77..a3879c4b 100644
--- a/mapping/mapping_vectors.go
+++ b/mapping/mapping_vectors.go
@@ -159,12 +159,19 @@ func (fm *FieldMapping) processVectorBase64(propertyMightBeVectorBase64 interfac
 		return
 	}
 
-	propertyMightBeVector, err := document.DecodeVector(encodedString)
-	if err != nil {
+	decodedVector, err := document.DecodeVector(encodedString)
+	if err != nil || len(decodedVector) != fm.Dims {
 		return
 	}
 
-	fm.processVector(propertyMightBeVector, pathString, path, indexes, context)
+	fieldName := getFieldName(pathString, path, fm)
+	options := fm.Options()
+	field := document.NewVectorFieldWithIndexingOptions(fieldName, indexes, decodedVector,
+		fm.Dims, fm.Similarity, fm.VectorIndexOptimizedFor, options)
+	context.doc.AddField(field)
+
+	// "_all" composite field is not applicable for vector_base64 field
+	context.excludedFromAll = append(context.excludedFromAll, fieldName)
 }
 
 // -----------------------------------------------------------------------------
diff --git a/search_knn.go b/search_knn.go
index cbeb3b45..7ea21d37 100644
--- a/search_knn.go
+++ b/search_knn.go
@@ -236,15 +236,14 @@ func validateKNN(req *SearchRequest) error {
 		if q == nil {
 			return fmt.Errorf("knn query cannot be nil")
 		}
-		if q.VectorBase64 != "" {
-			if q.Vector == nil {
-				vec, err := document.DecodeVector(q.VectorBase64)
-				if err != nil {
-					return err
-				}
-
-				q.Vector = vec
+		if len(q.Vector) == 0 && q.VectorBase64 != "" {
+			// consider vector_base64 only if vector is not provided
+			decodedVector, err := document.DecodeVector(q.VectorBase64)
+			if err != nil {
+				return err
 			}
+
+			q.Vector = decodedVector
 		}
 		if q.K <= 0 || len(q.Vector) == 0 {
 			return fmt.Errorf("k must be greater than 0 and vector must be non-empty")
diff --git a/util/extract.go b/util/extract.go
index e963d0c3..0d3decfa 100644
--- a/util/extract.go
+++ b/util/extract.go
@@ -48,7 +48,7 @@ func ExtractNumericValFloat32(v interface{}) (float32, bool) {
 	switch {
 	case val.CanFloat():
 		floatVal := val.Float()
-		if floatVal > math.MaxFloat32 {
+		if !IsValidFloat32(floatVal) {
 			return 0, false
 		}
 		return float32(floatVal), true
@@ -60,3 +60,7 @@ func ExtractNumericValFloat32(v interface{}) (float32, bool) {
 
 	return 0, false
 }
+
+func IsValidFloat32(val float64) bool {
+	return !math.IsNaN(val) && !math.IsInf(val, 0) && val <= math.MaxFloat32
+}


Test patch:
diff --git a/document/field_vector_base64_test.go b/document/field_vector_base64_test.go
index ac4bd8d4..91212b07 100644
--- a/document/field_vector_base64_test.go
+++ b/document/field_vector_base64_test.go
@@ -35,17 +35,17 @@ func TestDecodeVector(t *testing.T) {
 	vecBytes := bytifyVec(vec)
 	encodedVec := base64.StdEncoding.EncodeToString(vecBytes)
 
-	decodedVec, err := DecodeVector(encodedVec)
+	decodedVector, err := DecodeVector(encodedVec)
 	if err != nil {
 		t.Error(err)
 	}
-	if len(decodedVec) != len(vec) {
+	if len(decodedVector) != len(vec) {
 		t.Errorf("Decoded vector dimensions not same as original vector dimensions")
 	}
 
 	for i := range vec {
-		if vec[i] != decodedVec[i] {
-			t.Errorf("Decoded vector not the same as original vector")
+		if vec[i] != decodedVector[i] {
+			t.Fatalf("Decoded vector not the same as original vector %v != %v", vec[i], decodedVector[i])
 		}
 	}
 }
@@ -99,7 +99,6 @@ func BenchmarkDecodeVector1536(b *testing.B) {
 }
 
 func bytifyVec(vec []float32) []byte {
-
 	buf := new(bytes.Buffer)
 
 	for _, v := range vec {
diff --git a/search_test.go b/search_test.go
index 7f76978b..3d14c925 100644
--- a/search_test.go
+++ b/search_test.go
@@ -3116,7 +3116,7 @@ func TestDateRangeTimestampQueries(t *testing.T) {
 		}
 	}()
 
-	documents := map[string]map[string]interface{}{
+	documents := map[string]map[string]string{
 		"doc1": {
 			"date":         "2001/08/20 03:00:10",
 			"seconds":      "998276410",
@@ -3166,15 +3166,11 @@ func TestDateRangeTimestampQueries(t *testing.T) {
 		t.Fatal(err)
 	}
 
-	type testResult struct {
-		docID    string // doc ID of the hit
-		hitField string // fields returned as part of the hit
-	}
 	type testStruct struct {
 		start        string
 		end          string
 		field        string
-		expectedHits []testResult
+		expectedHits []string
 	}
 
 	testQueries := []testStruct{
@@ -3182,83 +3178,47 @@ func TestDateRangeTimestampQueries(t *testing.T) {
 			start: "2001-08-20T03:00:05",
 			end:   "2001-08-20T03:00:25",
 			field: "date",
-			expectedHits: []testResult{
-				{
-					docID:    "doc1",
-					hitField: "2001/08/20 03:00:10",
-				},
-				{
-					docID:    "doc2",
-					hitField: "2001/08/20 03:00:20",
-				},
+			expectedHits: []string{
+				"doc1",
+				"doc2",
 			},
 		},
 		{
 			start: "2001-08-20T03:00:15",
 			end:   "2001-08-20T03:00:35",
 			field: "seconds",
-			expectedHits: []testResult{
-				{
-					docID:    "doc2",
-					hitField: "998276420000000000",
-				},
-				{
-					docID:    "doc3",
-					hitField: "998276430000000000",
-				},
+			expectedHits: []string{
+				"doc2",
+				"doc3",
 			},
 		},
 		{
 			start: "2001-08-20T03:00:10.150",
 			end:   "2001-08-20T03:00:10.450",
 			field: "milliseconds",
-			expectedHits: []testResult{
-				{
-					docID:    "doc2",
-					hitField: "998276410200000000",
-				},
-				{
-					docID:    "doc3",
-					hitField: "998276410300000000",
-				},
-				{
-					docID:    "doc4",
-					hitField: "998276410400000000",
-				},
+			expectedHits: []string{
+				"doc2",
+				"doc3",
+				"doc4",
 			},
 		},
 		{
 			start: "2001-08-20T03:00:10.100450",
 			end:   "2001-08-20T03:00:10.100650",
 			field: "microseconds",
-			expectedHits: []testResult{
-				{
-					docID:    "doc3",
-					hitField: "998276410100500000",
-				},
-				{
-					docID:    "doc4",
-					hitField: "998276410100600000",
-				},
+			expectedHits: []string{
+				"doc3",
+				"doc4",
 			},
 		},
 		{
 			start: "2001-08-20T03:00:10.100300550",
 			end:   "2001-08-20T03:00:10.100300850",
 			field: "nanoseconds",
-			expectedHits: []testResult{
-				{
-					docID:    "doc3",
-					hitField: "998276410100300600",
-				},
-				{
-					docID:    "doc4",
-					hitField: "998276410100300700",
-				},
-				{
-					docID:    "doc5",
-					hitField: "998276410100300800",
-				},
+			expectedHits: []string{
+				"doc3",
+				"doc4",
+				"doc5",
 			},
 		},
 	}
@@ -3277,7 +3237,7 @@ func TestDateRangeTimestampQueries(t *testing.T) {
 
 		sr := NewSearchRequest(drq)
 		sr.SortBy([]string{dtq.field})
-		sr.Fields = []string{dtq.field}
+		sr.Fields = []string{"*"}
 
 		res, err := idx.Search(sr)
 		if err != nil {
@@ -3287,11 +3247,16 @@ func TestDateRangeTimestampQueries(t *testing.T) {
 			t.Fatalf("expected %d hits, got %d", len(dtq.expectedHits), len(res.Hits))
 		}
 		for i, hit := range res.Hits {
-			if hit.ID != dtq.expectedHits[i].docID {
-				t.Fatalf("expected docID %s, got %s", dtq.expectedHits[i].docID, hit.ID)
+			if hit.ID != dtq.expectedHits[i] {
+				t.Fatalf("expected docID %s, got %s", dtq.expectedHits[i], hit.ID)
 			}
-			if hit.Fields[dtq.field].(string) != dtq.expectedHits[i].hitField {
-				t.Fatalf("expected hit field %s, got %s", dtq.expectedHits[i].hitField, hit.Fields[dtq.field])
+			if len(hit.Fields) != len(documents[hit.ID]) {
+				t.Fatalf("expected hit %s to have %d fields, got %d", hit.ID, len(documents[hit.ID]), len(hit.Fields))
+			}
+			for k, v := range documents[hit.ID] {
+				if hit.Fields[k] != v {
+					t.Fatalf("expected field %s to be %s, got %s", k, v, hit.Fields[k])
+				}
 			}
 		}
 	}