Skip to content

Commit d75c245

Browse files
committed
Fix cosine distance for very small and very large float32 vectors
distance_cosine_float and vec_normalize accumulated squared magnitudes in float32. The square of an element below roughly 3e-23 underflowed to zero and the square of one above roughly 2e19 overflowed to Inf, so nonzero finite vectors got a cosine distance of +-Inf, NaN or a flat 1.0, and in a cosine vec0 table those rows ranked ahead of (or behind) the true nearest neighbours. vec_normalize returned NULL, a zero vector or a non-unit vector for the same inputs. Accumulate in double instead, where every product of two float32 values is representable. This covers vec_distance_cosine(), cosine KNN queries and MMR reranking. Cosine distances of ordinary vectors change in the last few significant digits because they are now correctly rounded, so two hard-coded expectations and the cosine MMR snapshots are updated. Only distance values change, no orderings. Reported upstream as asg017#324.
1 parent 1536364 commit d75c245

4 files changed

Lines changed: 114 additions & 26 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,15 @@
22

33
All notable changes to this community fork will be documented in this file.
44

5+
## [Unreleased]
6+
7+
### Fixed
8+
9+
- **Cosine distance returned ±Inf, NaN or a wrong value for very small or very large nonzero float32 vectors** (upstream [#324](https://github.com/asg017/sqlite-vec/issues/324))
10+
- Squared magnitudes were accumulated in float32, so elements below roughly 3e-23 underflowed to a zero magnitude and elements above roughly 2e19 overflowed to Inf (giving a distance of 1.0 whatever the direction). In a cosine `vec0` table the affected rows ranked ahead of (or behind) the true nearest neighbours
11+
- `vec_distance_cosine()`, cosine KNN queries and MMR reranking now accumulate in double precision, as does `vec_normalize()`, which returned NULL, a zero vector or a non-unit vector for the same inputs
12+
- Cosine distances of ordinary float32 vectors can differ from previous releases in the last few significant digits, as they are now correctly rounded
13+
514
## [0.2.6-alpha] - 2026-08-22
615

716
### Added

‎sqlite-vec.c‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -586,13 +586,17 @@ static f32 distance_cosine_float(const void *pVect1v, const void *pVect2v,
586586
f32 *pVect2 = (f32 *)pVect2v;
587587
size_t qty = *((size_t *)qty_ptr);
588588

589-
f32 dot = 0;
590-
f32 aMag = 0;
591-
f32 bMag = 0;
589+
// Accumulate in double: the square of a small (or large) f32 element
590+
// underflows (or overflows) f32, which made the magnitude 0 (or Inf) and
591+
// the distance +-Inf, NaN or plain wrong for nonzero finite vectors. Every
592+
// f32 product is representable in double.
593+
double dot = 0;
594+
double aMag = 0;
595+
double bMag = 0;
592596
for (size_t i = 0; i < qty; i++) {
593-
dot += *pVect1 * *pVect2;
594-
aMag += *pVect1 * *pVect1;
595-
bMag += *pVect2 * *pVect2;
597+
dot += (double)*pVect1 * (double)*pVect2;
598+
aMag += (double)*pVect1 * (double)*pVect1;
599+
bMag += (double)*pVect2 * (double)*pVect2;
596600
pVect1++;
597601
pVect2++;
598602
}
@@ -2054,12 +2058,14 @@ static void vec_normalize(sqlite3_context *context, int argc,
20542058

20552059
f32 *v = (f32 *)vector;
20562060

2057-
f32 norm = 0;
2061+
// Accumulate in double so that squaring small or large f32 elements does
2062+
// not underflow or overflow, see distance_cosine_float().
2063+
double norm = 0;
20582064
for (size_t i = 0; i < dimensions; i++) {
2059-
norm += v[i] * v[i];
2065+
norm += (double)v[i] * (double)v[i];
20602066
}
20612067
norm = sqrt(norm);
2062-
if (norm == 0.0f) {
2068+
if (norm == 0.0) {
20632069
// A zero-magnitude vector cannot be normalized. Return NULL instead of
20642070
// producing a vector of NaNs.
20652071
sqlite3_free(out);

‎tests/__snapshots__/test-knn-mmr.ambr‎

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,19 @@
99
}),
1010
OrderedDict({
1111
'rowid': 2,
12-
'distance': 0.005062814336270094,
12+
'distance': 0.005062811076641083,
1313
}),
1414
OrderedDict({
1515
'rowid': 3,
16-
'distance': 0.011511977761983871,
16+
'distance': 0.011511985212564468,
1717
}),
1818
OrderedDict({
1919
'rowid': 4,
20-
'distance': 0.020601648837327957,
20+
'distance': 0.020601652562618256,
2121
}),
2222
OrderedDict({
2323
'rowid': 5,
24-
'distance': 0.03227578103542328,
24+
'distance': 0.032275766134262085,
2525
}),
2626
]),
2727
})
@@ -36,19 +36,19 @@
3636
}),
3737
OrderedDict({
3838
'rowid': 2,
39-
'distance': 0.005062814336270094,
39+
'distance': 0.005062811076641083,
4040
}),
4141
OrderedDict({
4242
'rowid': 8,
4343
'distance': 1.0,
4444
}),
4545
OrderedDict({
4646
'rowid': 3,
47-
'distance': 0.011511977761983871,
47+
'distance': 0.011511985212564468,
4848
}),
4949
OrderedDict({
5050
'rowid': 4,
51-
'distance': 0.020601648837327957,
51+
'distance': 0.020601652562618256,
5252
}),
5353
]),
5454
})
@@ -63,11 +63,11 @@
6363
}),
6464
OrderedDict({
6565
'rowid': 2,
66-
'distance': 0.005062814336270094,
66+
'distance': 0.005062811076641083,
6767
}),
6868
OrderedDict({
6969
'rowid': 3,
70-
'distance': 0.02019595541059971,
70+
'distance': 0.020195940509438515,
7171
}),
7272
]),
7373
})
@@ -82,11 +82,11 @@
8282
}),
8383
OrderedDict({
8484
'rowid': 2,
85-
'distance': 0.005062814336270094,
85+
'distance': 0.005062811076641083,
8686
}),
8787
OrderedDict({
8888
'rowid': 3,
89-
'distance': 0.02019595541059971,
89+
'distance': 0.020195940509438515,
9090
}),
9191
]),
9292
})
@@ -101,7 +101,7 @@
101101
}),
102102
OrderedDict({
103103
'rowid': 2,
104-
'distance': 0.005062814336270094,
104+
'distance': 0.005062811076641083,
105105
}),
106106
OrderedDict({
107107
'rowid': 5,
@@ -209,15 +209,15 @@
209209
'rows': list([
210210
OrderedDict({
211211
'rowid': 2,
212-
'distance': 0.005062814336270094,
212+
'distance': 0.005062811076641083,
213213
}),
214214
OrderedDict({
215215
'rowid': 5,
216216
'distance': 1.0,
217217
}),
218218
OrderedDict({
219219
'rowid': 3,
220-
'distance': 0.02019595541059971,
220+
'distance': 0.020195940509438515,
221221
}),
222222
]),
223223
})
@@ -232,7 +232,7 @@
232232
}),
233233
OrderedDict({
234234
'rowid': 2,
235-
'distance': 0.005062814336270094,
235+
'distance': 0.005062811076641083,
236236
}),
237237
OrderedDict({
238238
'rowid': 5,

‎tests/test-loadable.py‎

Lines changed: 75 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ def check(a, b, dtype: type = np.float32):
423423
check([1.2, 0.1], [0.4, -0.4])
424424
check([-1.2, -0.1], [-0.4, 0.4])
425425
check([1, 2, 3], [-9, -8, -7], dtype=np.int8)
426-
assert vec_distance_cosine("[1.1, 1.0]", "[1.2, 1.2]") == 0.001131898257881403
426+
assert vec_distance_cosine("[1.1, 1.0]", "[1.2, 1.2]") == 0.0011318627512082458
427427

428428
vec_distance_cosine_bit = lambda *args: db.execute(
429429
"select vec_distance_cosine(vec_bit(?), vec_bit(?))", args
@@ -468,6 +468,29 @@ def test_vec_distance_cosine_zero_vector():
468468
)
469469

470470

471+
def test_vec_distance_cosine_extreme_magnitudes():
472+
# squaring very small or very large f32 elements used to underflow or
473+
# overflow the f32 magnitude accumulators, returning +-Inf, NaN or a wrong
474+
# distance for nonzero finite vectors
475+
# https://github.com/asg017/sqlite-vec/issues/324
476+
vec_distance_cosine = lambda a, b: db.execute(
477+
"select vec_distance_cosine(?, ?)", [_f32(a), _f32(b)]
478+
).fetchone()[0]
479+
480+
for scale in [1e-45, 1e-30, 1e-25, 1e-20, 1.0, 1e20, 3e38]:
481+
# cosine distance is invariant under positive scaling
482+
assert vec_distance_cosine([1, 0], [scale, 0]) == 0.0
483+
assert vec_distance_cosine([scale, 0], [1, 0]) == 0.0
484+
assert vec_distance_cosine([scale, 0], [scale, 0]) == 0.0
485+
assert vec_distance_cosine([1, 0], [-scale, 0]) == 2.0
486+
assert vec_distance_cosine([1, 0], [0, scale]) == 1.0
487+
assert isclose(
488+
vec_distance_cosine([1, 0], [scale, scale]),
489+
1 - 1 / np.sqrt(2),
490+
abs_tol=1e-6,
491+
)
492+
493+
471494
def test_ensure_vector_match_cleanup_on_second_vector_error():
472495
"""
473496
Test that ensure_vector_match properly cleans up the first vector
@@ -748,6 +771,18 @@ def test_vec_normalize():
748771
assert vec_normalize(_f32([0, 0, 0, 0])) is None
749772
assert db.execute("select vec_normalize('[0,0,0]')").fetchone()[0] is None
750773

774+
# small and large nonzero vectors are normalized rather than treated as
775+
# zero-magnitude or returned as a zero vector, the squared magnitude used
776+
# to underflow/overflow f32
777+
# https://github.com/asg017/sqlite-vec/issues/324
778+
for scale in [1e-45, 1e-30, 1e-25, 1e-22, 1e-20, 1e20, 3e38]:
779+
assert vec_normalize(_f32([scale, 0])) == _f32([1, 0])
780+
assert vec_normalize(_f32([0, -scale])) == _f32([0, -1])
781+
assert np.allclose(
782+
np.frombuffer(vec_normalize(_f32([scale, scale])), dtype=np.float32),
783+
[1 / np.sqrt(2)] * 2,
784+
)
785+
751786

752787
def test_vec_slice():
753788
vec_slice = lambda *args, f="?": db.execute(
@@ -2702,7 +2737,7 @@ def test_vec0_cosine_zero_vector():
27022737
db, "select rowid, distance from v where a match '[1,0,0]' and k = 2"
27032738
) == [
27042739
{"rowid": 1, "distance": 0.0},
2705-
{"rowid": 3, "distance": 0.006116250995546579},
2740+
{"rowid": 3, "distance": 0.006116265896707773},
27062741
]
27072742

27082743
# non-cosine tables still accept zero-magnitude vectors
@@ -2713,6 +2748,44 @@ def test_vec0_cosine_zero_vector():
27132748
) == [{"rowid": 1, "distance": 0.0}]
27142749

27152750

2751+
def test_vec0_cosine_extreme_magnitudes():
2752+
# tiny or huge nonzero vectors used to get +-Inf, NaN or wrong cosine
2753+
# distances, which ranked them ahead of (or behind) the true nearest
2754+
# neighbors
2755+
# https://github.com/asg017/sqlite-vec/issues/324
2756+
db = connect(EXT_PATH)
2757+
db.execute("create virtual table v using vec0(a float[2] distance_metric=cosine)")
2758+
db.executemany(
2759+
"insert into v(rowid, a) values (?, ?)",
2760+
[
2761+
(1, _f32([0.9, 0.1])),
2762+
(2, _f32([1e-25, 0])),
2763+
(3, _f32([1e-25, 1e-25])),
2764+
(4, _f32([-1e-25, 0])),
2765+
(5, _f32([1e-30, 0])),
2766+
(6, _f32([3e38, 3e38])),
2767+
],
2768+
)
2769+
2770+
expected = [
2771+
{"rowid": 2, "distance": 0.0},
2772+
{"rowid": 5, "distance": 0.0},
2773+
{"rowid": 1, "distance": 0.006116265896707773},
2774+
{"rowid": 3, "distance": 0.2928932309150696},
2775+
{"rowid": 6, "distance": 0.2928932309150696},
2776+
{"rowid": 4, "distance": 2.0},
2777+
]
2778+
for query in ([1, 0], [1e-25, 0], [3e38, 0]):
2779+
rows = execute_all(
2780+
db,
2781+
"select rowid, distance from v where a match ? and k = 6",
2782+
[_f32(query)],
2783+
)
2784+
# the order of equidistant rows is unspecified
2785+
assert sorted(rows, key=lambda r: (r["distance"], r["rowid"])) == expected
2786+
assert [r["distance"] for r in rows] == [r["distance"] for r in expected]
2787+
2788+
27162789
def test_vec0_vacuum():
27172790
db = connect(EXT_PATH)
27182791
db.execute("create virtual table vec_t using vec0(a float[1]);")

0 commit comments

Comments
 (0)