Skip to content

Commit bb967e1

Browse files
committed
fix(sqlite-vec.c): sum cosine magnitudes in double precision
Squared float32 elements below about 3e-23 or above about 2e19 underflowed or overflowed float, giving wrong cosine distances and normalizations (asg017#324, vlasky/sqlite-vec@d75c245).
1 parent fbeafb6 commit bb967e1

4 files changed

Lines changed: 105 additions & 33 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
### Fixed
2020

21+
- Fixed cosine distance between `float32` vectors whose elements are very small or very large. Squared magnitudes were summed in `float`, so elements below about 3e-23 made a magnitude of 0, which this fork reports as distance 1.0, and elements above about 2e19 made it Inf, so `vec_distance_cosine()`, cosine KNN queries, and MMR reranking returned wrong distances for those vectors. `vec_normalize()` had the same problem. Both now sum in `double`, so ordinary cosine distances can differ from 2.0.2 in the last few digits. Ported from [vlasky@d75c245](https://github.com/vlasky/sqlite-vec/commit/d75c245) ([asg017#324](https://github.com/asg017/sqlite-vec/issues/324)).
2122
- Fixed inserts into `vec0` tables created before v0.2.0-alpha after `optimize` had removed a chunk. Those tables declare their `_vector_chunksNN` and `_metadatachunksNN` shadow tables with `rowid PRIMARY KEY` instead of `rowid INTEGER PRIMARY KEY`, so a new chunk's SQLite rowid could differ from its chunk id, and the insert failed with `Error opening vector blob`; KNN and point queries on the table then failed too. New chunks now set both ids. Ported from [vlasky@3a64182](https://github.com/vlasky/sqlite-vec/commit/3a64182).
2223
- An `INSERT` into a `vec0` table with a value of the wrong type for a metadata or auxiliary column now fails before writing anything. It used to fail after writing the row's rowid, vector, and some column values, and a surrounding transaction could commit that partial row, because `vec0` writes its shadow tables through separate statements that a failed `INSERT` does not roll back.
2324
- Fixed memory and handle leaks that [vlasky@5ae4fed](https://github.com/vlasky/sqlite-vec/commit/5ae4fed) fixed in its fork and this fork still had: every `UPDATE` of an auxiliary column leaked its SQL string; a `vec0` constructor that failed after parsing some columns leaked their names; a long-text metadata write that failed left its blob handle open, so `sqlite3_close()` returned `SQLITE_BUSY`; a KNN query leaked its top-k buffers when MMR reranking failed; and text-metadata filtering leaked a rowid buffer when its blob read failed.

‎sqlite-vec.c‎

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -604,21 +604,25 @@ static f32 distance_cosine_float(const void *pVect1v, const void *pVect2v,
604604
f32 *pVect2 = (f32 *)pVect2v;
605605
size_t qty = *((size_t *)qty_ptr);
606606

607-
f32 dot = 0;
608-
f32 aMag = 0;
609-
f32 bMag = 0;
607+
// Accumulate in double: the square of a small (or large) f32 element
608+
// underflows (or overflows) f32, which made the magnitude 0 (or Inf) and
609+
// the distance +-Inf, NaN or plain wrong for nonzero finite vectors. Every
610+
// f32 product is representable in double.
611+
double dot = 0;
612+
double aMag = 0;
613+
double bMag = 0;
610614
for (size_t i = 0; i < qty; i++) {
611-
dot += *pVect1 * *pVect2;
612-
aMag += *pVect1 * *pVect1;
613-
bMag += *pVect2 * *pVect2;
615+
dot += (double)*pVect1 * (double)*pVect2;
616+
aMag += (double)*pVect1 * (double)*pVect1;
617+
bMag += (double)*pVect2 * (double)*pVect2;
614618
pVect1++;
615619
pVect2++;
616620
}
617621
// Handle zero vectors: return max distance (1.0) to avoid division by zero
618622
if (aMag == 0 || bMag == 0) {
619623
return 1.0f;
620624
}
621-
return 1 - (dot / (sqrtf(aMag) * sqrtf(bMag)));
625+
return 1 - (dot / (sqrt(aMag) * sqrt(bMag)));
622626
}
623627
static f32 distance_cosine_int8(const void *pA, const void *pB,
624628
const void *pD) {
@@ -1973,9 +1977,11 @@ static void vec_normalize(sqlite3_context *context, int argc,
19731977

19741978
f32 *v = (f32 *)vector;
19751979

1976-
f32 norm = 0;
1980+
// Accumulate in double so that squaring small or large f32 elements does
1981+
// not underflow or overflow, see distance_cosine_float().
1982+
double norm = 0;
19771983
for (size_t i = 0; i < dimensions; i++) {
1978-
norm += v[i] * v[i];
1984+
norm += (double)v[i] * (double)v[i];
19791985
}
19801986
norm = sqrt(norm);
19811987
for (size_t i = 0; i < dimensions; i++) {

‎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.005062758922576904,
12+
'distance': 0.005062811076641083,
1313
}),
1414
OrderedDict({
1515
'rowid': 3,
16-
'distance': 0.011511921882629395,
16+
'distance': 0.011511985212564468,
1717
}),
1818
OrderedDict({
1919
'rowid': 4,
20-
'distance': 0.02060168981552124,
20+
'distance': 0.020601652562618256,
2121
}),
2222
OrderedDict({
2323
'rowid': 5,
24-
'distance': 0.03227579593658447,
24+
'distance': 0.032275766134262085,
2525
}),
2626
]),
2727
})
@@ -36,19 +36,19 @@
3636
}),
3737
OrderedDict({
3838
'rowid': 2,
39-
'distance': 0.005062758922576904,
39+
'distance': 0.005062811076641083,
4040
}),
4141
OrderedDict({
4242
'rowid': 8,
4343
'distance': 1.0,
4444
}),
4545
OrderedDict({
4646
'rowid': 3,
47-
'distance': 0.011511921882629395,
47+
'distance': 0.011511985212564468,
4848
}),
4949
OrderedDict({
5050
'rowid': 4,
51-
'distance': 0.02060168981552124,
51+
'distance': 0.020601652562618256,
5252
}),
5353
]),
5454
})
@@ -63,11 +63,11 @@
6363
}),
6464
OrderedDict({
6565
'rowid': 2,
66-
'distance': 0.005062758922576904,
66+
'distance': 0.005062811076641083,
6767
}),
6868
OrderedDict({
6969
'rowid': 3,
70-
'distance': 0.020195960998535156,
70+
'distance': 0.020195940509438515,
7171
}),
7272
]),
7373
})
@@ -82,11 +82,11 @@
8282
}),
8383
OrderedDict({
8484
'rowid': 2,
85-
'distance': 0.005062758922576904,
85+
'distance': 0.005062811076641083,
8686
}),
8787
OrderedDict({
8888
'rowid': 3,
89-
'distance': 0.020195960998535156,
89+
'distance': 0.020195940509438515,
9090
}),
9191
]),
9292
})
@@ -101,7 +101,7 @@
101101
}),
102102
OrderedDict({
103103
'rowid': 2,
104-
'distance': 0.005062758922576904,
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.005062758922576904,
212+
'distance': 0.005062811076641083,
213213
}),
214214
OrderedDict({
215215
'rowid': 5,
216216
'distance': 1.0,
217217
}),
218218
OrderedDict({
219219
'rowid': 3,
220-
'distance': 0.020195960998535156,
220+
'distance': 0.020195940509438515,
221221
}),
222222
]),
223223
})
@@ -232,7 +232,7 @@
232232
}),
233233
OrderedDict({
234234
'rowid': 2,
235-
'distance': 0.005062758922576904,
235+
'distance': 0.005062811076641083,
236236
}),
237237
OrderedDict({
238238
'rowid': 5,

‎tests/test-loadable.py‎

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -421,15 +421,7 @@ def check(a, b, dtype=np.float32):
421421
check([1.2, 0.1], [0.4, -0.4])
422422
check([-1.2, -0.1], [-0.4, 0.4])
423423
check([1, 2, 3], [-9, -8, -7], dtype=np.int8)
424-
# Use isclose since sqrtf precision differs from sqrt
425-
assert isclose(
426-
vec_distance_cosine("[1.1, 1.0]", "[1.2, 1.2]"),
427-
npy_cosine(
428-
np.array([1.1, 1.0], dtype=np.float32),
429-
np.array([1.2, 1.2], dtype=np.float32),
430-
),
431-
abs_tol=1e-6,
432-
)
424+
assert vec_distance_cosine("[1.1, 1.0]", "[1.2, 1.2]") == 0.0011318627512082458
433425

434426
vec_distance_cosine_bit = lambda *args: db.execute(
435427
"select vec_distance_cosine(vec_bit(?), vec_bit(?))", args
@@ -485,6 +477,29 @@ def test_vec_distance_cosine_zero_vector():
485477
assert vec_distance_cosine_bit(b"\x00", b"\x00") == 1.0
486478

487479

480+
def test_vec_distance_cosine_extreme_magnitudes():
481+
# squaring very small or very large f32 elements used to underflow or
482+
# overflow the f32 magnitude accumulators, returning +-Inf, NaN or a wrong
483+
# distance for nonzero finite vectors
484+
# https://github.com/asg017/sqlite-vec/issues/324
485+
vec_distance_cosine = lambda a, b: db.execute(
486+
"select vec_distance_cosine(?, ?)", [_f32(a), _f32(b)]
487+
).fetchone()[0]
488+
489+
for scale in [1e-45, 1e-30, 1e-25, 1e-20, 1.0, 1e20, 3e38]:
490+
# cosine distance is invariant under positive scaling
491+
assert vec_distance_cosine([1, 0], [scale, 0]) == 0.0
492+
assert vec_distance_cosine([scale, 0], [1, 0]) == 0.0
493+
assert vec_distance_cosine([scale, 0], [scale, 0]) == 0.0
494+
assert vec_distance_cosine([1, 0], [-scale, 0]) == 2.0
495+
assert vec_distance_cosine([1, 0], [0, scale]) == 1.0
496+
assert isclose(
497+
vec_distance_cosine([1, 0], [scale, scale]),
498+
1 - 1 / np.sqrt(2),
499+
abs_tol=1e-6,
500+
)
501+
502+
488503
def test_ensure_vector_match_cleanup_on_second_vector_error():
489504
"""
490505
Test that ensure_vector_match properly cleans up the first vector
@@ -756,6 +771,18 @@ def test_vec_normalize():
756771
-0.6324555277824402,
757772
]
758773

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+
759786

760787
def test_vec_slice():
761788
vec_slice = lambda *args, f="?": db.execute(
@@ -2677,6 +2704,44 @@ def test_vec0_distance_metric():
26772704
assert isclose(actual["distance"], expected["distance"], abs_tol=1e-6)
26782705

26792706

2707+
def test_vec0_cosine_extreme_magnitudes():
2708+
# tiny or huge nonzero vectors used to get +-Inf, NaN or wrong cosine
2709+
# distances, which ranked them ahead of (or behind) the true nearest
2710+
# neighbors
2711+
# https://github.com/asg017/sqlite-vec/issues/324
2712+
db = connect(EXT_PATH)
2713+
db.execute("create virtual table v using vec0(a float[2] distance_metric=cosine)")
2714+
db.executemany(
2715+
"insert into v(rowid, a) values (?, ?)",
2716+
[
2717+
(1, _f32([0.9, 0.1])),
2718+
(2, _f32([1e-25, 0])),
2719+
(3, _f32([1e-25, 1e-25])),
2720+
(4, _f32([-1e-25, 0])),
2721+
(5, _f32([1e-30, 0])),
2722+
(6, _f32([3e38, 3e38])),
2723+
],
2724+
)
2725+
2726+
expected = [
2727+
{"rowid": 2, "distance": 0.0},
2728+
{"rowid": 5, "distance": 0.0},
2729+
{"rowid": 1, "distance": 0.006116265896707773},
2730+
{"rowid": 3, "distance": 0.2928932309150696},
2731+
{"rowid": 6, "distance": 0.2928932309150696},
2732+
{"rowid": 4, "distance": 2.0},
2733+
]
2734+
for query in ([1, 0], [1e-25, 0], [3e38, 0]):
2735+
rows = execute_all(
2736+
db,
2737+
"select rowid, distance from v where a match ? and k = 6",
2738+
[_f32(query)],
2739+
)
2740+
# the order of equidistant rows is unspecified
2741+
assert sorted(rows, key=lambda r: (r["distance"], r["rowid"])) == expected
2742+
assert [r["distance"] for r in rows] == [r["distance"] for r in expected]
2743+
2744+
26802745
def test_vec0_vacuum():
26812746
db = connect(EXT_PATH)
26822747
db.execute("create virtual table vec_t using vec0(a float[1]);")

0 commit comments

Comments
 (0)