Skip to content

Commit a85858d

Browse files
committed
refactor(migration): extract _quantize_array helper for dtype conversion
Move the per-vector dtype conversion logic out of the convert_vectors loop into a dedicated _quantize_array helper. Float-to-float casts stay a simple astype, while float-to-integer conversions apply per-vector min-max scaling so int8/uint8 targets fill the full integer range instead of truncating embedding values in [-1, 1] to zero. Use TYPE_CHECKING + lazy_import so numpy is loaded lazily at runtime while mypy still resolves np.ndarray annotations. Adds unit coverage for float64-to-float32 and float64-to-float16 paths and for the int8 scaling behavior across constant/varying vectors.
1 parent 037c2d8 commit a85858d

2 files changed

Lines changed: 268 additions & 4 deletions

File tree

redisvl/migration/quantize.py

Lines changed: 71 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,21 @@
1212
import math
1313
from concurrent.futures import ThreadPoolExecutor, as_completed
1414
from dataclasses import dataclass, field
15-
from typing import Any, Callable, Dict, List, Optional
15+
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Optional
1616

1717
from redisvl.redis.utils import array_to_buffer, buffer_to_array
18+
from redisvl.utils.utils import lazy_import
19+
20+
if TYPE_CHECKING:
21+
import numpy as np
22+
else:
23+
np = lazy_import("numpy")
24+
25+
# Integer dtype ranges used for float-to-integer quantization scaling.
26+
_INTEGER_RANGES: Dict[str, tuple] = {
27+
"int8": (-128, 127),
28+
"uint8": (0, 255),
29+
}
1830

1931

2032
def pipeline_read_vectors(
@@ -82,12 +94,63 @@ def pipeline_write_vectors(
8294
pipe.execute()
8395

8496

97+
def _quantize_array(arr: "np.ndarray", target_dtype: str) -> "np.ndarray":
98+
"""Convert a numpy array to a target dtype, applying min-max scaling
99+
when converting from float to integer types.
100+
101+
Float-to-float conversions (e.g. float32 → float16) are a simple cast.
102+
103+
Float-to-integer conversions (e.g. float32 → int8) require scaling
104+
because most embedding models produce values in [-1, 1] or similar
105+
narrow ranges. A naive ``astype("int8")`` would truncate everything
106+
to zero. Instead, we apply per-vector min-max scaling to fill the
107+
full integer range, matching the approach recommended in the Redis
108+
vector-search documentation.
109+
110+
Args:
111+
arr: Source vector as a numpy array (any float dtype).
112+
target_dtype: Target dtype string (e.g. "float16", "int8", "uint8").
113+
114+
Returns:
115+
Numpy array in the target dtype.
116+
117+
Raises:
118+
ValueError: If the target dtype is an unsupported integer type.
119+
"""
120+
target_lower = target_dtype.lower()
121+
int_range = _INTEGER_RANGES.get(target_lower)
122+
123+
if int_range is None:
124+
# Float-to-float: simple precision cast (e.g. float32 → float16).
125+
return arr.astype(target_lower)
126+
127+
# Float-to-integer: per-vector min-max scaling.
128+
lo, hi = int_range
129+
vec_min = arr.min()
130+
vec_max = arr.max()
131+
spread = vec_max - vec_min
132+
133+
if spread == 0:
134+
# Constant vector (rare but possible) — map to midpoint.
135+
mid = (lo + hi) // 2
136+
return np.full_like(arr, mid, dtype=target_lower)
137+
138+
# Scale [vec_min, vec_max] → [lo, hi] and round to nearest integer.
139+
scaled = (arr - vec_min) / spread * (hi - lo) + lo
140+
return np.clip(np.round(scaled), lo, hi).astype(target_lower)
141+
142+
85143
def convert_vectors(
86144
originals: Dict[str, Dict[str, bytes]],
87145
datatype_changes: Dict[str, Dict[str, Any]],
88146
) -> Dict[str, Dict[str, bytes]]:
89147
"""Convert vector bytes from source dtype to target dtype.
90148
149+
For float-to-float conversions, this performs a simple precision cast.
150+
For float-to-integer conversions (int8, uint8), this applies per-vector
151+
min-max scaling to map the float range into the full integer range
152+
before casting. See :func:`_quantize_array` for details.
153+
91154
Args:
92155
originals: {key: {field_name: original_bytes}}
93156
datatype_changes: {field_name: {"source", "target", "dims"}}
@@ -102,9 +165,13 @@ def convert_vectors(
102165
change = datatype_changes.get(field_name)
103166
if not change:
104167
continue
105-
array = buffer_to_array(data, change["source"])
106-
new_bytes = array_to_buffer(array, change["target"])
107-
converted[key][field_name] = new_bytes
168+
source_dtype = change["source"].lower()
169+
target_dtype = change["target"]
170+
171+
# Deserialize directly into numpy (avoids Python list round-trip).
172+
arr = np.frombuffer(data, dtype=source_dtype).copy()
173+
quantized = _quantize_array(arr, target_dtype)
174+
converted[key][field_name] = quantized.tobytes()
108175
return converted
109176

110177

tests/unit/test_pipeline_quantize.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,200 @@ def test_convert_float32_to_float16(self):
162162
# Verify values round-trip through float16
163163
arr = np.frombuffer(converted["doc:0"]["embedding"], dtype=np.float16)
164164
np.testing.assert_allclose(arr, [1.0, 2.0, 3.0, 4.0], rtol=1e-3)
165+
166+
def test_convert_float64_to_float32(self):
167+
"""Float64 to float32 should preserve values with minor precision loss."""
168+
import numpy as np
169+
170+
from redisvl.migration.quantize import convert_vectors
171+
172+
dims = 4
173+
source = np.array([1.0, -2.5, 3.14159265358979, 0.0], dtype=np.float64)
174+
originals = {"doc:0": {"embedding": source.tobytes()}}
175+
changes = {
176+
"embedding": {"source": "float64", "target": "float32", "dims": dims}
177+
}
178+
179+
converted = convert_vectors(originals, changes)
180+
181+
# float64 = 8 bytes/dim, float32 = 4 bytes/dim
182+
assert len(converted["doc:0"]["embedding"]) == dims * 4
183+
arr = np.frombuffer(converted["doc:0"]["embedding"], dtype=np.float32)
184+
np.testing.assert_allclose(arr, source, rtol=1e-6)
185+
186+
def test_convert_float64_to_float16(self):
187+
"""Float64 to float16 should preserve values with larger precision loss."""
188+
import numpy as np
189+
190+
from redisvl.migration.quantize import convert_vectors
191+
192+
dims = 4
193+
source = np.array([1.0, -2.5, 0.333, 0.0], dtype=np.float64)
194+
originals = {"doc:0": {"embedding": source.tobytes()}}
195+
changes = {
196+
"embedding": {"source": "float64", "target": "float16", "dims": dims}
197+
}
198+
199+
converted = convert_vectors(originals, changes)
200+
201+
# float64 = 8 bytes/dim, float16 = 2 bytes/dim
202+
assert len(converted["doc:0"]["embedding"]) == dims * 2
203+
arr = np.frombuffer(converted["doc:0"]["embedding"], dtype=np.float16)
204+
np.testing.assert_allclose(arr, source, rtol=1e-2)
205+
206+
def test_convert_float64_to_int8_scales_correctly(self):
207+
"""Float64 to int8 should apply scaling, not truncate."""
208+
import numpy as np
209+
210+
from redisvl.migration.quantize import convert_vectors
211+
212+
source = np.array([-0.8, -0.2, 0.3, 0.9], dtype=np.float64)
213+
originals = {"doc:0": {"embedding": source.tobytes()}}
214+
changes = {"embedding": {"source": "float64", "target": "int8", "dims": 4}}
215+
216+
converted = convert_vectors(originals, changes)
217+
result = np.frombuffer(converted["doc:0"]["embedding"], dtype=np.int8)
218+
219+
assert len(converted["doc:0"]["embedding"]) == 4
220+
assert result.min() == -128
221+
assert result.max() == 127
222+
assert not np.all(result == 0)
223+
224+
def test_convert_float32_to_int8_scales_correctly(self):
225+
"""Float-to-int8 should scale values to [-128, 127], not truncate."""
226+
import numpy as np
227+
228+
from redisvl.migration.quantize import convert_vectors
229+
230+
# Typical embedding values in [-1, 1] — would all become 0 without scaling.
231+
dims = 4
232+
source = np.array([-1.0, -0.5, 0.0, 1.0], dtype=np.float32)
233+
originals = {"doc:0": {"embedding": source.tobytes()}}
234+
changes = {"embedding": {"source": "float32", "target": "int8", "dims": dims}}
235+
236+
converted = convert_vectors(originals, changes)
237+
result = np.frombuffer(converted["doc:0"]["embedding"], dtype=np.int8)
238+
239+
# 1 byte per dim
240+
assert len(converted["doc:0"]["embedding"]) == dims * 1
241+
# Min should map to -128, max to 127
242+
assert result[0] == -128 # min value
243+
assert result[3] == 127 # max value
244+
# Values should span the full int8 range, NOT be all zeros
245+
assert result.min() == -128
246+
assert result.max() == 127
247+
# Middle values should be proportionally scaled
248+
# -0.5 → (-0.5 - (-1)) / 2 * 255 + (-128) = 63.75 - 128 = -64.25 → -64
249+
assert result[1] == -64
250+
# 0.0 → (0 - (-1)) / 2 * 255 + (-128) = 127.5 - 128 = -0.5 → 0
251+
assert result[2] == 0
252+
253+
def test_convert_float16_to_int8_scales_correctly(self):
254+
"""Float16-to-int8 should also scale properly (the benchmark bug path)."""
255+
import numpy as np
256+
257+
from redisvl.migration.quantize import convert_vectors
258+
259+
# Simulate what the benchmark did: random [0, 1] float16 vectors
260+
source = np.array([0.1, 0.3, 0.7, 0.9], dtype=np.float16)
261+
originals = {"doc:0": {"embedding": source.tobytes()}}
262+
changes = {"embedding": {"source": "float16", "target": "int8", "dims": 4}}
263+
264+
converted = convert_vectors(originals, changes)
265+
result = np.frombuffer(converted["doc:0"]["embedding"], dtype=np.int8)
266+
267+
# Should NOT be all zeros (the original bug)
268+
assert not np.all(
269+
result == 0
270+
), "INT8 conversion produced all zeros — scaling is not being applied"
271+
# Should use the full range
272+
assert result.min() == -128
273+
assert result.max() == 127
274+
275+
def test_convert_float32_to_uint8_scales_correctly(self):
276+
"""Float-to-uint8 should scale values to [0, 255]."""
277+
import numpy as np
278+
279+
from redisvl.migration.quantize import convert_vectors
280+
281+
source = np.array([-1.0, 0.0, 0.5, 1.0], dtype=np.float32)
282+
originals = {"doc:0": {"embedding": source.tobytes()}}
283+
changes = {"embedding": {"source": "float32", "target": "uint8", "dims": 4}}
284+
285+
converted = convert_vectors(originals, changes)
286+
result = np.frombuffer(converted["doc:0"]["embedding"], dtype=np.uint8)
287+
288+
assert len(converted["doc:0"]["embedding"]) == 4 * 1
289+
assert result[0] == 0 # min maps to 0
290+
assert result[3] == 255 # max maps to 255
291+
assert result.min() == 0
292+
assert result.max() == 255
293+
294+
def test_convert_constant_vector_to_int8(self):
295+
"""A constant vector (all same value) should not divide by zero."""
296+
import numpy as np
297+
298+
from redisvl.migration.quantize import convert_vectors
299+
300+
source = np.array([0.5, 0.5, 0.5, 0.5], dtype=np.float32)
301+
originals = {"doc:0": {"embedding": source.tobytes()}}
302+
changes = {"embedding": {"source": "float32", "target": "int8", "dims": 4}}
303+
304+
converted = convert_vectors(originals, changes)
305+
result = np.frombuffer(converted["doc:0"]["embedding"], dtype=np.int8)
306+
307+
# Should not raise and should produce a valid int8 vector
308+
assert len(result) == 4
309+
# All values should be identical (mapped to midpoint)
310+
assert np.all(result == result[0])
311+
312+
def test_convert_preserves_relative_ordering(self):
313+
"""Scaled int8 values should maintain the same ordering as the source."""
314+
import numpy as np
315+
316+
from redisvl.migration.quantize import convert_vectors
317+
318+
source = np.array([0.1, 0.9, 0.5, 0.3, 0.7], dtype=np.float32)
319+
originals = {"doc:0": {"embedding": source.tobytes()}}
320+
changes = {"embedding": {"source": "float32", "target": "int8", "dims": 5}}
321+
322+
converted = convert_vectors(originals, changes)
323+
result = np.frombuffer(converted["doc:0"]["embedding"], dtype=np.int8)
324+
325+
# The sorted order of indices should be preserved
326+
assert list(np.argsort(source)) == list(np.argsort(result.astype(float)))
327+
328+
def test_convert_skips_unknown_fields(self):
329+
"""Fields not in datatype_changes should be skipped."""
330+
from redisvl.migration.quantize import convert_vectors
331+
332+
originals = {"doc:0": {"other_field": b"\x00\x01"}}
333+
changes = {"embedding": {"source": "float32", "target": "float16", "dims": 4}}
334+
335+
converted = convert_vectors(originals, changes)
336+
assert converted["doc:0"] == {}
337+
338+
def test_convert_multiple_keys(self):
339+
"""Conversion should work across multiple keys in a batch."""
340+
import numpy as np
341+
342+
from redisvl.migration.quantize import convert_vectors
343+
344+
v1 = np.array([0.0, 0.5, 1.0], dtype=np.float32)
345+
v2 = np.array([-1.0, 0.0, 1.0], dtype=np.float32)
346+
originals = {
347+
"doc:0": {"embedding": v1.tobytes()},
348+
"doc:1": {"embedding": v2.tobytes()},
349+
}
350+
changes = {"embedding": {"source": "float32", "target": "int8", "dims": 3}}
351+
352+
converted = convert_vectors(originals, changes)
353+
354+
r1 = np.frombuffer(converted["doc:0"]["embedding"], dtype=np.int8)
355+
r2 = np.frombuffer(converted["doc:1"]["embedding"], dtype=np.int8)
356+
357+
# Each vector is scaled independently (per-vector min-max)
358+
assert r1.min() == -128
359+
assert r1.max() == 127
360+
assert r2.min() == -128
361+
assert r2.max() == 127

0 commit comments

Comments
 (0)