Skip to content

Commit 2de6a2f

Browse files
authored
Merge branch 'develop' into combined-bot-prs-branch
2 parents 78b1747 + 0f7e144 commit 2de6a2f

6 files changed

Lines changed: 177 additions & 33 deletions

File tree

internal/index/hnsw/heap.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import "container/heap"
1818
// by the neighbour-selection heuristic).
1919
type candidate struct {
2020
id NodeID
21-
dist float32
21+
dist float64
2222
vector []float32
2323
}
2424

internal/index/hnsw/heap_test.go

Lines changed: 20 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -26,28 +26,28 @@ type heapUnderTest struct {
2626
// new returns a fresh, empty heap.Interface backed by the concrete heap type.
2727
new func() heap.Interface
2828
// root reads h[0].dist from the concrete heap value.
29-
root func(h heap.Interface) float32
29+
root func(h heap.Interface) float64
3030
// popOrder returns the distances in the order Pop yields them: ascending for minHeap,
3131
// descending for maxHeap.
32-
popOrder func(sorted []float32) []float32
32+
popOrder func(sorted []float64) []float64
3333
// extreme returns the element this heap would pop next from the given contents: the minimum
3434
// for minHeap, the maximum for maxHeap. Panics on empty input (never called that way).
35-
extreme func(contents []float32) float32
35+
extreme func(contents []float64) float64
3636
}
3737

3838
func minHeapUT() heapUnderTest {
3939
return heapUnderTest{
4040
name: "minHeap",
4141
new: func() heap.Interface { return newMinHeap() },
42-
root: func(h heap.Interface) float32 {
42+
root: func(h heap.Interface) float64 {
4343
mh, _ := h.(*candidateHeap)
4444
return mh.items[0].dist
4545
},
46-
popOrder: func(sorted []float32) []float32 {
47-
out := append([]float32(nil), sorted...) // sorted is ascending → min pops ascending
46+
popOrder: func(sorted []float64) []float64 {
47+
out := append([]float64(nil), sorted...) // sorted is ascending → min pops ascending
4848
return out
4949
},
50-
extreme: func(contents []float32) float32 {
50+
extreme: func(contents []float64) float64 {
5151
m := contents[0]
5252
for _, v := range contents {
5353
if v < m {
@@ -63,18 +63,18 @@ func maxHeapUT() heapUnderTest {
6363
return heapUnderTest{
6464
name: "maxHeap",
6565
new: func() heap.Interface { return newMaxHeap() },
66-
root: func(h heap.Interface) float32 {
66+
root: func(h heap.Interface) float64 {
6767
mh, _ := h.(*candidateHeap)
6868
return mh.items[0].dist
6969
},
70-
popOrder: func(sorted []float32) []float32 {
71-
out := make([]float32, len(sorted)) // sorted is ascending → max pops descending
70+
popOrder: func(sorted []float64) []float64 {
71+
out := make([]float64, len(sorted)) // sorted is ascending → max pops descending
7272
for i, v := range sorted {
7373
out[len(sorted)-1-i] = v
7474
}
7575
return out
7676
},
77-
extreme: func(contents []float32) float32 {
77+
extreme: func(contents []float64) float64 {
7878
m := contents[0]
7979
for _, v := range contents {
8080
if v > m {
@@ -94,9 +94,9 @@ func popCandidate(t *testing.T, h heap.Interface) candidate {
9494
return c
9595
}
9696

97-
func popAllDists(t *testing.T, h heap.Interface) []float32 {
97+
func popAllDists(t *testing.T, h heap.Interface) []float64 {
9898
t.Helper()
99-
out := make([]float32, 0, h.Len())
99+
out := make([]float64, 0, h.Len())
100100
for h.Len() > 0 {
101101
out = append(out, popCandidate(t, h).dist)
102102
}
@@ -106,7 +106,7 @@ func popAllDists(t *testing.T, h heap.Interface) []float32 {
106106
func TestHeap_PushThenPopAll_YieldsOrderedDistances(t *testing.T) {
107107
// A representative unordered input; sorted ascending is the reference the per-heap popOrder
108108
// derives its expectation from.
109-
inputs := map[string][]float32{
109+
inputs := map[string][]float64{
110110
"unordered distinct": {5, 1, 4, 2, 3},
111111
"already ascending": {1, 2, 3, 4},
112112
"already descending": {4, 3, 2, 1},
@@ -134,7 +134,7 @@ func TestHeap_PushThenPopAll_YieldsOrderedDistances(t *testing.T) {
134134
}
135135

136136
func TestHeap_Root_IsExtremeElement(t *testing.T) {
137-
in := []float32{5, 1, 4, 2, 3}
137+
in := []float64{5, 1, 4, 2, 3}
138138

139139
for _, ut := range []heapUnderTest{minHeapUT(), maxHeapUT()} {
140140
t.Run(ut.name, func(t *testing.T) {
@@ -157,9 +157,9 @@ func TestHeap_InterleavedPushPop_ReturnsCurrentExtreme(t *testing.T) {
157157
for _, ut := range []heapUnderTest{minHeapUT(), maxHeapUT()} {
158158
t.Run(ut.name, func(t *testing.T) {
159159
h := ut.new()
160-
model := []float32{}
160+
model := []float64{}
161161

162-
push := func(d float32) {
162+
push := func(d float64) {
163163
heap.Push(h, candidate{dist: d})
164164
model = append(model, d)
165165
}
@@ -194,7 +194,7 @@ func TestHeap_PreservesCandidatePayload(t *testing.T) {
194194
}
195195

196196
// removeFirst returns s with the first occurrence of v removed (reference-model bookkeeping).
197-
func removeFirst(s []float32, v float32) []float32 {
197+
func removeFirst(s []float64, v float64) []float64 {
198198
for i, x := range s {
199199
if x == v {
200200
return append(s[:i:i], s[i+1:]...)
@@ -204,8 +204,8 @@ func removeFirst(s []float32, v float32) []float32 {
204204
}
205205

206206
// ascendingSorted returns a new ascending-sorted copy of in, used as the reference ordering.
207-
func ascendingSorted(in []float32) []float32 {
208-
out := append([]float32(nil), in...)
207+
func ascendingSorted(in []float64) []float64 {
208+
out := append([]float64(nil), in...)
209209
for i := 1; i < len(out); i++ {
210210
for j := i; j > 0 && out[j] < out[j-1]; j-- {
211211
out[j], out[j-1] = out[j-1], out[j]

internal/index/hnsw/hnsw.go

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -136,7 +136,11 @@ func dot(a, b []float32) float64 {
136136
// distance returns the distance between two vectors under the given metric, smaller being nearer.
137137
// Both must have come through vectorForMetric for this metric. An unrecognised metric is a
138138
// programming error rather than a silent fallback, so it panics.
139-
func distance(metric Metric, a, b []float32) float32 {
139+
//
140+
// The result is float64 even though vectors are stored as float32. Squaring a float32 can exceed
141+
// what a float32 holds, and every such distance would land on +Inf and compare equal, leaving the
142+
// documents in an arbitrary order. Float64 has the range to keep them apart.
143+
func distance(metric Metric, a, b []float32) float64 {
140144
switch metric {
141145
case Cosine:
142146
return cosineDistance(a, b)
@@ -150,28 +154,25 @@ func distance(metric Metric, a, b []float32) float32 {
150154
}
151155

152156
// cosineDistance computes 1 - dot(a, b) for unit-length vectors a and b.
153-
func cosineDistance(a, b []float32) float32 {
154-
return float32(1 - dot(a, b))
157+
func cosineDistance(a, b []float32) float64 {
158+
return 1 - dot(a, b)
155159
}
156160

157161
// squaredEuclideanDistance computes the squared straight-line distance between a and b. The square
158162
// root is skipped: it is monotonic, and distances are only ever compared against each other. If the
159163
// lengths differ, only the shared leading elements are used, matching dot.
160-
//
161-
// Squaring costs range: the sum is held in float64, but the result narrows to float32, so components
162-
// beyond roughly 1e19 saturate to +Inf. Such a vector still sorts as the farthest, so ordering holds.
163-
func squaredEuclideanDistance(a, b []float32) float32 {
164+
func squaredEuclideanDistance(a, b []float32) float64 {
164165
var sum float64
165166
n := min(len(a), len(b))
166167
for i := range n {
167168
d := float64(a[i]) - float64(b[i])
168169
sum += d * d
169170
}
170-
return float32(sum)
171+
return sum
171172
}
172173

173174
// dotProductDistance computes -dot(a, b). The dot product is a similarity (bigger is nearer), so the
174175
// sign is flipped to keep smaller nearer.
175-
func dotProductDistance(a, b []float32) float32 {
176-
return float32(-dot(a, b))
176+
func dotProductDistance(a, b []float32) float64 {
177+
return -dot(a, b)
177178
}

internal/index/hnsw/hnsw_test.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ import (
2626
func bruteForceKNN(metric Metric, query []float32, vectors map[NodeID][]float32, k int) []NodeID {
2727
type scored struct {
2828
id NodeID
29-
dist float32
29+
dist float64
3030
}
3131
scoredList := make([]scored, 0, len(vectors))
3232
for id, v := range vectors {
@@ -399,3 +399,38 @@ func TestGraph_DotProduct_RanksLongerVectorNearer(t *testing.T) {
399399
func TestDistance_UnknownMetric_Panics(t *testing.T) {
400400
assert.Panics(t, func() { distance(Metric(99), []float32{1}, []float32{1}) })
401401
}
402+
403+
// Squaring a large float32 exceeds what a float32 holds. Held in one, every distance here would be
404+
// +Inf and compare equal, leaving the order to chance. The query sits next to the largest vector, so
405+
// that one has to come first and the rest follow by how far they are.
406+
// https://github.com/sourcenetwork/defradb/issues/5220
407+
func TestGraph_Euclidean_LargeVectors_OrderByDistanceNotOverflow(t *testing.T) {
408+
g := New(NewMemStore(), Euclidean, DefaultParams(8), 1)
409+
410+
require.NoError(t, g.Insert(1, []float32{-3.4028235e+38}))
411+
require.NoError(t, g.Insert(2, []float32{0}))
412+
require.NoError(t, g.Insert(3, []float32{90}))
413+
414+
result, err := g.Search([]float32{-3.4028235e+38}, 3, 64)
415+
require.NoError(t, err)
416+
require.Equal(t, []NodeID{1, 2, 3}, result)
417+
}
418+
419+
// The distances themselves must stay finite, not just come out in the right order. Held in a float32
420+
// these all collapse onto +Inf, which reports every document as equally near and is what left the
421+
// order to chance.
422+
func TestGraph_Euclidean_LargeVectors_DistancesStayFinite(t *testing.T) {
423+
g := New(NewMemStore(), Euclidean, DefaultParams(8), 1)
424+
425+
require.NoError(t, g.Insert(1, []float32{0}))
426+
require.NoError(t, g.Insert(2, []float32{3.4028235e+38}))
427+
428+
neighbors, err := g.SearchWithDistance([]float32{-3.4028235e+38}, 2, 64)
429+
require.NoError(t, err)
430+
require.Len(t, neighbors, 2)
431+
for _, n := range neighbors {
432+
require.False(t, math.IsInf(n.Distance, 1), "distance overflowed to +Inf")
433+
}
434+
// The far vector is twice the distance away, so squared it is four times as far.
435+
require.Less(t, neighbors[0].Distance, neighbors[1].Distance)
436+
}

internal/index/hnsw/search.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func (g *HNSWIndex) SearchWithDistance(query []float32, k, efSearch int) ([]Neig
4444
}
4545
out := make([]Neighbor, len(w))
4646
for i, c := range w {
47-
out[i] = Neighbor{ID: c.id, Distance: float64(c.dist)}
47+
out[i] = Neighbor{ID: c.id, Distance: c.dist}
4848
}
4949
return out, nil
5050
}

tests/integration/index/vector_metrics_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,3 +262,111 @@ func TestVectorIndex_DropThenRecreateWithDifferentMetric_IsAllowed(t *testing.T)
262262

263263
testUtils.ExecuteTestCase(t, test)
264264
}
265+
266+
// Vectors whose squared distance exceeds a float32 are searchable and scored correctly. The graph's
267+
// own ordering is covered by the engine tests; this covers the query path end to end, where the
268+
// score is computed separately and was never affected.
269+
// https://github.com/sourcenetwork/defradb/issues/5220
270+
func TestVectorIndex_EuclideanOnLargeVectors_OrdersByDistance(t *testing.T) {
271+
test := testUtils.TestCase{
272+
Actions: []any{
273+
&action.AddCollection{
274+
SDL: `type User {
275+
name: String
276+
vector: [Float32!] @index(vector: {dimensions: 1, hnsw: {metric: EUCLIDEAN}})
277+
}`,
278+
},
279+
&action.AddDoc{DocMap: map[string]any{"name": "mid", "vector": []float32{2e19}}},
280+
&action.AddDoc{DocMap: map[string]any{"name": "far", "vector": []float32{3e19}}},
281+
&action.AddDoc{DocMap: map[string]any{"name": "farthest", "vector": []float32{4e19}}},
282+
&action.WaitForIndexReady{CollectionID: 0},
283+
&action.Request{
284+
Request: `query {
285+
User(order: {_alias: {sim: DESC}}, limit: 1){
286+
name
287+
sim: SIMILARITY(vector: {vector: [0]})
288+
}
289+
}`,
290+
Results: map[string]any{
291+
"User": []map[string]any{
292+
{"name": "mid", "sim": -3.999999984405158e+38},
293+
},
294+
},
295+
},
296+
},
297+
}
298+
299+
testUtils.ExecuteTestCase(t, test)
300+
}
301+
302+
// The dot product of two large vectors also exceeds a float32, so it needs the same float64 handling
303+
// as euclidean. Cosine cannot overflow because its vectors are normalised first, but it is covered
304+
// alongside so a future change to either metric is caught here.
305+
// https://github.com/sourcenetwork/defradb/issues/5220
306+
func TestVectorIndex_DotProductOnLargeVectors_OrdersByDistance(t *testing.T) {
307+
test := testUtils.TestCase{
308+
Actions: []any{
309+
&action.AddCollection{
310+
SDL: `type User {
311+
name: String
312+
vector: [Float32!] @index(vector: {dimensions: 1, hnsw: {metric: DOT}})
313+
}`,
314+
},
315+
&action.AddDoc{DocMap: map[string]any{"name": "near", "vector": []float32{4e19}}},
316+
&action.AddDoc{DocMap: map[string]any{"name": "mid", "vector": []float32{3e19}}},
317+
&action.AddDoc{DocMap: map[string]any{"name": "far", "vector": []float32{2e19}}},
318+
&action.WaitForIndexReady{CollectionID: 0},
319+
&action.Request{
320+
Request: `query {
321+
User(order: {_alias: {sim: DESC}}, limit: 3){
322+
name
323+
sim: SIMILARITY(vector: {vector: [1e20]})
324+
}
325+
}`,
326+
Results: map[string]any{
327+
"User": []map[string]any{
328+
{"name": "near", "sim": 4.0000000723660884e+39},
329+
{"name": "mid", "sim": 3.000000164225731e+39},
330+
{"name": "far", "sim": 2.0000000361830442e+39},
331+
},
332+
},
333+
},
334+
},
335+
}
336+
337+
testUtils.ExecuteTestCase(t, test)
338+
}
339+
340+
func TestVectorIndex_CosineOnLargeVectors_OrdersByDistance(t *testing.T) {
341+
test := testUtils.TestCase{
342+
Actions: []any{
343+
&action.AddCollection{
344+
SDL: `type User {
345+
name: String
346+
vector: [Float32!] @index(vector: {dimensions: 2, hnsw: {metric: COSINE}})
347+
}`,
348+
},
349+
&action.AddDoc{DocMap: map[string]any{"name": "aligned", "vector": []float32{2e19, 0}}},
350+
&action.AddDoc{DocMap: map[string]any{"name": "diagonal", "vector": []float32{2e19, 2e19}}},
351+
&action.AddDoc{DocMap: map[string]any{"name": "orthogonal", "vector": []float32{0, 2e19}}},
352+
&action.WaitForIndexReady{CollectionID: 0},
353+
&action.Request{
354+
Request: `query {
355+
User(order: {_alias: {sim: DESC}}, limit: 3){
356+
name
357+
sim: SIMILARITY(vector: {vector: [1e20, 0]})
358+
}
359+
}`,
360+
Results: map[string]any{
361+
"User": []map[string]any{
362+
{"name": "aligned", "sim": 1.0},
363+
{"name": "diagonal", "sim": 0.7071067811865476},
364+
{"name": "orthogonal", "sim": 0.0},
365+
},
366+
},
367+
},
368+
},
369+
}
370+
371+
testUtils.ExecuteTestCase(t, test)
372+
}

0 commit comments

Comments
 (0)