s3vectors: report the Euclidean distance, not its square (#12318)

A euclidean index answered QueryVectors with Lance's L2 distance, which is
the squared Euclidean distance: [3, 4] was 25 from the origin, not 5. AWS
defines the metric as the straight-line distance between the vectors. The
gateway now returns its square root, so the single engine and the enterprise
coordinator, which both hand back the raw Lance value, report the same
distance. Ordering is unchanged.


Claude-Session: https://claude.ai/code/session_011UpvafvK8JpagF94MHWqwK

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Chris LuandClaude Opus 5.5 authored and GitHub committed 2026-11-01 23:50:46 -08:00
1 parent d6c0d728e4
commit ff7045a4e5
3 files changed
+67 -2

No files matched your search

@@ -3,6 +3,7 @@ package s3vectors
import (
"context"
"encoding/json"
"fmt"
"net/http"
"testing"
@@ -86,3 +87,54 @@ func TestIndexFromRecordLowercasesMetric(t *testing.T) {
}
}
}
type distancesFake struct {
*fakeVectors
distances []float32
}
func (f distancesFake) QueryVectors(ctx context.Context, req *vector_pb.QueryVectorsRequest, opts ...grpc.CallOption) (*vector_pb.QueryVectorsResponse, error) {
resp := &vector_pb.QueryVectorsResponse{}
for i, d := range f.distances {
resp.Vectors = append(resp.Vectors, &vector_pb.QueryResult{Key: fmt.Sprint(i), Distance: d})
}
return resp, nil
}
// The engine returns Lance's squared L2 distance; a euclidean index reports
// its square root, the other metrics their engine value.
func TestQueryVectorsReportsEuclideanDistance(t *testing.T) {
for metric, want := range map[string][]float32{
"euclidean": {5, 0.5, 0},
"cosine": {25, 0.25, -1e-7},
"dotproduct": {25, 0.25, -1e-7},
} {
fs := s3tablestest.Start(t)
engine := distancesFake{newFakeVectors(0), []float32{25, 0.25, -1e-7}}
s := NewServer(s3tables.NewManagerClient(fs.Client), allowAllAuth{}, engine)
if w := postJSON(t, s.handleCreateVectorBucket, &CreateVectorBucketRequest{VectorBucketName: "vec-bucket"}); w.Code != http.StatusOK {
t.Fatalf("create bucket: %d %s", w.Code, w.Body.String())
}
if w := postJSON(t, s.handleCreateIndex, &CreateIndexRequest{
VectorBucketName: "vec-bucket", IndexName: "idx", DataType: "float32", Dimension: 2, DistanceMetric: metric,
}); w.Code != http.StatusOK {
t.Fatalf("create index: %d %s", w.Code, w.Body.String())
}
w := postJSON(t, s.handleQueryVectors, &QueryVectorsRequest{
VectorBucketName: "vec-bucket", IndexName: "idx",
QueryVector: &QueryVector{Float32: []float32{1, 0}}, TopK: 3, ReturnDistance: true,
})
var query QueryVectorsResponse
if err := json.Unmarshal(w.Body.Bytes(), &query); err != nil || len(query.Vectors) != len(want) {
t.Fatalf("%s QueryVectors: %d %s", metric, w.Code, w.Body.String())
}
for i, v := range query.Vectors {
if v.Distance == nil {
t.Fatalf("%s hit %d has no distance", metric, i)
}
if *v.Distance != want[i] {
t.Fatalf("%s hit %d distance %v, want %v", metric, i, *v.Distance, want[i])
}
}
}
}
+7 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"fmt"
"math"
"net/http"
"strconv"
"strings"
@@ -1938,7 +1939,12 @@ func (s *Server) handleQueryVectors(w http.ResponseWriter, r *http.Request) {
for _, v := range resp.Vectors {
o := &QueryOutputVector{Key: v.Key}
if req.ReturnDistance {
o.Distance = &v.Distance
d := v.Distance
if metric == "euclidean" {
// Lance's L2 distance is the square of the Euclidean distance.
d = float32(math.Sqrt(math.Max(0, float64(d))))
}
o.Distance = &d
}
if req.ReturnMetadata {
o.Metadata = structToMap(v.Metadata)