Repository: redis/go-redis

Issue:
Export float conversion routines (or convert exported data into go types)
<!--- Provide a general summary of the issue in the Title above -->

very important value `__score` in `FT.SEARCH` is returned as raw string not converted. users need manually convert it to float

now, this is internal redis type.

and redis go pkg does not export functions to convert it. which is bad and error prone and leads to wrong re-implementations.

## Expected Behavior

Either:

- A) provide utilities to convert internal string float representations
- B) export standard float notation (and rest of types)
- C) export normal native go float in exposed functions

<!--- Tell us what should happen -->

## Current Behavior

<img width="1424" alt="image" src="https://github.com/user-attachments/assets/631a1dd4-b025-41bc-a39e-8070bb8b73fe">

<img width="373" alt="image" src="https://github.com/user-attachments/assets/21963e73-e43c-43c8-9323-6bad3455f440">


<!--- Tell us what happens instead of the expected behavior -->

## Possible Solution

export this https://github.com/redis/go-redis/blob/master/internal/proto/reader.go#L192

<!--- Not obligatory, but suggest a fix/reason for the bug, -->

## Steps to Reproduce

<!--- Provide a link to a live example, or an unambiguous set of steps to -->
<!--- reproduce this bug. Include code to reproduce, if relevant -->

1. use `FT.SEARCH` `__score` field in Go
2. try to convert it to float
3. search for any methods that redis provides to convert it (there is none)

## Context (Environment)

`FT.SEARCH` is very important. `__score` is very important.

and this is not even proper float... what....

`INFO`

```
# Server
redis_version:7.2.6
redis_git_sha1:00000000
redis_git_dirty:0
redis_build_id:afdd7ad38aac5fcf
redis_mode:standalone
os:Linux 6.1.112+ x86_64
arch_bits:64
monotonic_clock:POSIX clock_gettime
multiplexing_api:epoll
atomicvar_api:c11-builtin
gcc_version:11.4.0
process_id:8

```

```
go version go1.23.0 darwin/arm64
```

go.mod
```
github.com/redis/go-redis/v9 v9.7.0
```

<!--- How has this issue affected you? What are you trying to accomplish? -->
<!--- Providing context helps us come up with a solution that is most useful in the real world -->

<!--- Provide a general summary of the issue in the Title above -->

## Detailed Description

<!--- Provide a detailed description of the change or addition you are proposing -->

## Possible Implementation

<!--- Not obligatory, but suggest an idea for implementing addition or change -->


Hints from issue comments:
@SoulPancake: Can I take this up @ndyakov ?@ndyakov: Sure, if you need any guidance let me know.

On Mon, 31 Mar 2025 at 10:47, Anurag Bandyopadhyay ***@***.***>
wrote:

> Can I take this up @ndyakov <https://github.com/ndyakov> ?
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/redis/go-redis/issues/3206#issuecomment-2765396433>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AALZXMWV2IP72GD3TIEUZUD2XDXJ5AVCNFSM6AAAAABTNDGTB2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDONRVGM4TMNBTGM>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
> [image: SoulPancake]*SoulPancake* left a comment (redis/go-redis#3206)
> <https://github.com/redis/go-redis/issues/3206#issuecomment-2765396433>
>
> Can I take this up @ndyakov <https://github.com/ndyakov> ?
>
> —
> Reply to this email directly, view it on GitHub
> <https://github.com/redis/go-redis/issues/3206#issuecomment-2765396433>,
> or unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AALZXMWV2IP72GD3TIEUZUD2XDXJ5AVCNFSM6AAAAABTNDGTB2VHI2DSMVQWIX3LMV43OSLTON2WKQ3PNVWWK3TUHMZDONRVGM4TMNBTGM>
> .
> You are receiving this because you were mentioned.Message ID:
> ***@***.***>
>
@SoulPancake: @ndyakov Should we export readFloat util? @ndyakov @ndyakov: @SoulPancake yes, I assume we can create a second exported package utils and have a function that will execute the internal function. But just that, no need to export the whole utils package that is currently placed in `internal`. @SoulPancake: @ndyakov 
Do you mean we create a new utils package which will just call the utils in internal?

However that would also require us to export the function so that we can call the internal function
Also when you say exported package, does it have to be with it's own go-module so it can be exported separately?@SoulPancake: @ndyakov ^?

Solution patch:
diff --git a/helper/helper.go b/helper/helper.go
new file mode 100644
index 0000000..7047c8a
--- /dev/null
+++ b/helper/helper.go
@@ -0,0 +1,11 @@
+package helper
+
+import "github.com/redis/go-redis/v9/internal/util"
+
+func ParseFloat(s string) (float64, error) {
+	return util.ParseStringToFloat(s)
+}
+
+func MustParseFloat(s string) float64 {
+	return util.MustParseFloat(s)
+}
diff --git a/internal/util/convert.go b/internal/util/convert.go
new file mode 100644
index 0000000..d326d50
--- /dev/null
+++ b/internal/util/convert.go
@@ -0,0 +1,30 @@
+package util
+
+import (
+	"fmt"
+	"math"
+	"strconv"
+)
+
+// ParseFloat parses a Redis RESP3 float reply into a Go float64,
+// handling "inf", "-inf", "nan" per Redis conventions.
+func ParseStringToFloat(s string) (float64, error) {
+	switch s {
+	case "inf":
+		return math.Inf(1), nil
+	case "-inf":
+		return math.Inf(-1), nil
+	case "nan", "-nan":
+		return math.NaN(), nil
+	}
+	return strconv.ParseFloat(s, 64)
+}
+
+// MustParseFloat is like ParseFloat but panics on parse errors.
+func MustParseFloat(s string) float64 {
+	f, err := ParseStringToFloat(s)
+	if err != nil {
+		panic(fmt.Sprintf("redis: failed to parse float %q: %v", s, err))
+	}
+	return f
+}



Test patch:
diff --git a/internal/util/convert_test.go b/internal/util/convert_test.go
new file mode 100644
index 0000000..ffa3ee9
--- /dev/null
+++ b/internal/util/convert_test.go
@@ -0,0 +1,40 @@
+package util
+
+import (
+	"math"
+	"testing"
+)
+
+func TestParseStringToFloat(t *testing.T) {
+	tests := []struct {
+		in   string
+		want float64
+		ok   bool
+	}{
+		{"1.23", 1.23, true},
+		{"inf", math.Inf(1), true},
+		{"-inf", math.Inf(-1), true},
+		{"nan", math.NaN(), true},
+		{"oops", 0, false},
+	}
+
+	for _, tc := range tests {
+		got, err := ParseStringToFloat(tc.in)
+		if tc.ok {
+			if err != nil {
+				t.Fatalf("ParseFloat(%q) error: %v", tc.in, err)
+			}
+			if math.IsNaN(tc.want) {
+				if !math.IsNaN(got) {
+					t.Errorf("ParseFloat(%q) = %v; want NaN", tc.in, got)
+				}
+			} else if got != tc.want {
+				t.Errorf("ParseFloat(%q) = %v; want %v", tc.in, got, tc.want)
+			}
+		} else {
+			if err == nil {
+				t.Errorf("ParseFloat(%q) expected error, got nil", tc.in)
+			}
+		}
+	}
+}
diff --git a/search_test.go b/search_test.go
index 019acbe..fdcd0d2 100644
--- a/search_test.go
+++ b/search_test.go
@@ -1,15 +1,18 @@
 package redis_test
 
 import (
+	"bytes"
 	"context"
+	"encoding/binary"
 	"fmt"
-	"strconv"
+	"math"
 	"strings"
 	"time"
 
 	. "github.com/bsm/ginkgo/v2"
 	. "github.com/bsm/gomega"
 	"github.com/redis/go-redis/v9"
+	"github.com/redis/go-redis/v9/helper"
 )
 
 func WaitForIndexing(c *redis.Client, index string) {
@@ -27,6 +30,14 @@ func WaitForIndexing(c *redis.Client, index string) {
 	}
 }
 
+func encodeFloat32Vector(vec []float32) []byte {
+	buf := new(bytes.Buffer)
+	for _, v := range vec {
+		binary.Write(buf, binary.LittleEndian, v)
+	}
+	return buf.Bytes()
+}
+
 var _ = Describe("RediSearch commands Resp 2", Label("search"), func() {
 	ctx := context.TODO()
 	var client *redis.Client
@@ -693,9 +704,9 @@ var _ = Describe("RediSearch commands Resp 2", Label("search"), func() {
 		Expect(err).NotTo(HaveOccurred())
 		Expect(res).ToNot(BeNil())
 		Expect(len(res.Rows)).To(BeEquivalentTo(2))
-		score1, err := strconv.ParseFloat(fmt.Sprintf("%s", res.Rows[0].Fields["__score"]), 64)
+		score1, err := helper.ParseFloat(fmt.Sprintf("%s", res.Rows[0].Fields["__score"]))
 		Expect(err).NotTo(HaveOccurred())
-		score2, err := strconv.ParseFloat(fmt.Sprintf("%s", res.Rows[1].Fields["__score"]), 64)
+		score2, err := helper.ParseFloat(fmt.Sprintf("%s", res.Rows[1].Fields["__score"]))
 		Expect(err).NotTo(HaveOccurred())
 		Expect(score1).To(BeNumerically(">", score2))
 
@@ -712,9 +723,9 @@ var _ = Describe("RediSearch commands Resp 2", Label("search"), func() {
 		Expect(err).NotTo(HaveOccurred())
 		Expect(resDM).ToNot(BeNil())
 		Expect(len(resDM.Rows)).To(BeEquivalentTo(2))
-		score1DM, err := strconv.ParseFloat(fmt.Sprintf("%s", resDM.Rows[0].Fields["__score"]), 64)
+		score1DM, err := helper.ParseFloat(fmt.Sprintf("%s", resDM.Rows[0].Fields["__score"]))
 		Expect(err).NotTo(HaveOccurred())
-		score2DM, err := strconv.ParseFloat(fmt.Sprintf("%s", resDM.Rows[1].Fields["__score"]), 64)
+		score2DM, err := helper.ParseFloat(fmt.Sprintf("%s", resDM.Rows[1].Fields["__score"]))
 		Expect(err).NotTo(HaveOccurred())
 		Expect(score1DM).To(BeNumerically(">", score2DM))
 
@@ -1684,6 +1695,56 @@ var _ = Describe("RediSearch commands Resp 2", Label("search"), func() {
 		Expect(resUint8.Docs[0].ID).To(BeEquivalentTo("doc1"))
 	})
 
+	It("should return special float scores in FT.SEARCH vecsim", Label("search", "ftsearch", "vecsim"), func() {
+		SkipBeforeRedisVersion(7.4, "doesn't work with older redis stack images")
+
+		vecField := &redis.FTFlatOptions{
+			Type:           "FLOAT32",
+			Dim:            2,
+			DistanceMetric: "IP",
+		}
+		_, err := client.FTCreate(ctx, "idx_vec",
+			&redis.FTCreateOptions{OnHash: true, Prefix: []interface{}{"doc:"}},
+			&redis.FieldSchema{FieldName: "vector", FieldType: redis.SearchFieldTypeVector, VectorArgs: &redis.FTVectorArgs{FlatOptions: vecField}}).Result()
+		Expect(err).NotTo(HaveOccurred())
+		WaitForIndexing(client, "idx_vec")
+
+		bigPos := []float32{1e38, 1e38}
+		bigNeg := []float32{-1e38, -1e38}
+		nanVec := []float32{float32(math.NaN()), 0}
+		negNanVec := []float32{float32(math.Copysign(math.NaN(), -1)), 0}
+
+		client.HSet(ctx, "doc:1", "vector", encodeFloat32Vector(bigPos))
+		client.HSet(ctx, "doc:2", "vector", encodeFloat32Vector(bigNeg))
+		client.HSet(ctx, "doc:3", "vector", encodeFloat32Vector(nanVec))
+		client.HSet(ctx, "doc:4", "vector", encodeFloat32Vector(negNanVec))
+
+		searchOptions := &redis.FTSearchOptions{WithScores: true, Params: map[string]interface{}{"vec": encodeFloat32Vector(bigPos)}}
+		res, err := client.FTSearchWithArgs(ctx, "idx_vec", "*=>[KNN 4 @vector $vec]", searchOptions).Result()
+		Expect(err).NotTo(HaveOccurred())
+		Expect(res.Total).To(BeEquivalentTo(4))
+
+		var scores []float64
+		for _, row := range res.Docs {
+			raw := fmt.Sprintf("%v", row.Fields["__vector_score"])
+			f, err := helper.ParseFloat(raw)
+			Expect(err).NotTo(HaveOccurred())
+			scores = append(scores, f)
+		}
+
+		Expect(scores).To(ContainElement(BeNumerically("==", math.Inf(1))))
+		Expect(scores).To(ContainElement(BeNumerically("==", math.Inf(-1))))
+
+		// For NaN values, use a custom check since NaN != NaN in floating point math
+		nanCount := 0
+		for _, score := range scores {
+			if math.IsNaN(score) {
+				nanCount++
+			}
+		}
+		Expect(nanCount).To(Equal(2))
+	})
+
 	It("should fail when using a non-zero offset with a zero limit", Label("search", "ftsearch"), func() {
 		SkipBeforeRedisVersion(7.9, "requires Redis 8.x")
 		val, err := client.FTCreate(ctx, "testIdx", &redis.FTCreateOptions{}, &redis.FieldSchema{