498b235461
Build and test / Build and test AMD64 Ubuntu 22.04 (push) Failing after 0s
Publish Builder / amazonlinux2023 (push) Failing after 1s
Build and test / UT for Go (push) Has been skipped
Publish KRTE Images / KRTE (push) Failing after 1s
Build and test / Integration Test (push) Has been skipped
Build and test / Upload Code Coverage (push) Has been skipped
Publish Builder / rockylinux9 (push) Failing after 1s
Publish Builder / ubuntu22.04 (push) Failing after 0s
Publish Builder / ubuntu24.04 (push) Failing after 0s
Publish Gpu Builder / publish-gpu-builder (push) Failing after 1s
Publish Test Images / PyTest (push) Failing after 0s
Build and test / UT for Cpp (push) Has been cancelled
1267 lines
50 KiB
Python
1267 lines
50 KiB
Python
"""Shared helpers for MilvusClient external table tests."""
|
|
|
|
import io
|
|
import json
|
|
import os
|
|
import tempfile
|
|
|
|
import numpy as np
|
|
import pyarrow as pa
|
|
import pyarrow.parquet as pq
|
|
from common import common_func as cf
|
|
from common import common_type as ct
|
|
from minio import Minio
|
|
from pymilvus import DataType
|
|
|
|
# ============================================================
|
|
# Constants & Configuration
|
|
# ============================================================
|
|
|
|
REFRESH_TIMEOUT = 180
|
|
|
|
# Multi-file format coverage: each format E2E writes FORMAT_NUM_FILES sibling
|
|
# data files (or fragments / appended snapshots), each carrying
|
|
# FORMAT_ROWS_PER_FILE rows, exercising the multi-file refresh path.
|
|
FORMAT_NUM_FILES = 3
|
|
FORMAT_ROWS_PER_FILE = 3000
|
|
FORMAT_TOTAL_ROWS = FORMAT_NUM_FILES * FORMAT_ROWS_PER_FILE
|
|
|
|
BASIC_FORMATS = ("parquet", "lance-table", "iceberg-table", "vortex")
|
|
BASIC_FORMAT_IDS = ("parquet", "lance", "iceberg", "vortex")
|
|
|
|
|
|
def _minio_address(minio_host):
|
|
address = minio_host or "localhost"
|
|
if address.startswith("http://"):
|
|
address = address.removeprefix("http://")
|
|
elif address.startswith("https://"):
|
|
address = address.removeprefix("https://")
|
|
if ":" not in address:
|
|
address = f"{address}:9000"
|
|
return address
|
|
|
|
|
|
def get_minio_config(minio_host=None, minio_bucket=None):
|
|
return {
|
|
"address": _minio_address(minio_host),
|
|
"access_key": "minioadmin",
|
|
"secret_key": "minioadmin",
|
|
"bucket": minio_bucket or cf.param_info.param_bucket_name or "milvus-bucket",
|
|
"secure": str(minio_host or "").startswith("https://"),
|
|
}
|
|
|
|
|
|
def build_external_source(cfg, key_prefix):
|
|
"""Build a full s3:// URL that Milvus's extfs resolver can use directly."""
|
|
# Trailing slash matters: refresh lists objects under the prefix.
|
|
return f"s3://{cfg['address']}/{cfg['bucket']}/{key_prefix}/"
|
|
|
|
|
|
def _minio_endpoint_url(cfg):
|
|
scheme = "https" if cfg["secure"] else "http"
|
|
return f"{scheme}://{cfg['address']}"
|
|
|
|
|
|
def build_external_spec(cfg=None, fmt="parquet", cloud_provider="minio", **extra):
|
|
"""Build an external_spec accepted by current master validation.
|
|
|
|
Current server-side validation requires external sources to carry a
|
|
self-contained extfs block. The tests use Milvus-form S3 URLs
|
|
(s3://<endpoint>/<bucket>/<prefix>/), so cloud_provider=minio keeps the
|
|
endpoint from the URI instead of deriving a cloud endpoint.
|
|
"""
|
|
cfg = cfg or get_minio_config()
|
|
spec = {
|
|
"format": fmt,
|
|
"extfs": {
|
|
"cloud_provider": cloud_provider,
|
|
"region": "us-east-1",
|
|
"access_key_id": cfg["access_key"],
|
|
"access_key_value": cfg["secret_key"],
|
|
"use_ssl": "true" if cfg["secure"] else "false",
|
|
},
|
|
}
|
|
spec.update(extra)
|
|
return json.dumps(spec, separators=(",", ":"))
|
|
|
|
|
|
def new_minio_client(cfg):
|
|
return Minio(
|
|
cfg["address"],
|
|
access_key=cfg["access_key"],
|
|
secret_key=cfg["secret_key"],
|
|
secure=cfg["secure"],
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Parquet Helpers
|
|
# ============================================================
|
|
|
|
_PARQUET_COMPRESSION = "snappy"
|
|
|
|
|
|
def _float_vectors(ids, dim):
|
|
return np.array(
|
|
[[float(i) * 0.1 + d for d in range(dim)] for i in ids],
|
|
dtype=np.float32,
|
|
)
|
|
|
|
|
|
def _float16_vectors(ids, dim):
|
|
return np.array(
|
|
[[float(i) * 0.1 + d for d in range(dim)] for i in ids],
|
|
dtype=np.float16,
|
|
)
|
|
|
|
|
|
def _bfloat16_vectors(ids, dim):
|
|
"""Build bfloat16 via numpy uint16 view; pyarrow lacks native bfloat16 bitcast."""
|
|
# bfloat16 shares the top 16 bits of float32's IEEE 754 representation.
|
|
f = np.array(
|
|
[[float(i) * 0.1 + d for d in range(dim)] for i in ids],
|
|
dtype=np.float32,
|
|
)
|
|
return (f.view(np.uint32) >> 16).astype(np.uint16)
|
|
|
|
|
|
def _int8_vectors(ids, dim):
|
|
return np.array(
|
|
[[(i + d) % 127 - 63 for d in range(dim)] for i in ids],
|
|
dtype=np.int8,
|
|
)
|
|
|
|
|
|
def _binary_vectors_bytes(ids, dim):
|
|
"""Return (num_rows, dim/8) uint8 array; bit set patterns depend on id."""
|
|
nbytes = dim // 8
|
|
rng = np.random.default_rng(seed=42)
|
|
arr = rng.integers(low=0, high=256, size=(len(ids), nbytes), dtype=np.uint8)
|
|
# make it deterministic per id: XOR with id-derived bytes
|
|
for row_idx, i in enumerate(ids):
|
|
arr[row_idx][0] = i & 0xFF
|
|
return arr
|
|
|
|
|
|
def _vector_byte_width(vec_dtype, dim):
|
|
if vec_dtype == DataType.FLOAT_VECTOR:
|
|
return dim * 4
|
|
if vec_dtype in (DataType.FLOAT16_VECTOR, DataType.BFLOAT16_VECTOR):
|
|
return dim * 2
|
|
if vec_dtype == DataType.INT8_VECTOR:
|
|
return dim
|
|
if vec_dtype == DataType.BINARY_VECTOR:
|
|
return dim // 8
|
|
raise ValueError(f"unsupported vec dtype {vec_dtype}")
|
|
|
|
|
|
def _fixed_size_binary_vector_array(raw_rows, byte_width):
|
|
return pa.array(
|
|
[np.ascontiguousarray(row).tobytes() for row in raw_rows],
|
|
type=pa.binary(byte_width),
|
|
)
|
|
|
|
|
|
def _fixed_size_uint8_vector_array(raw_rows, byte_width):
|
|
raw = b"".join(np.ascontiguousarray(row).tobytes() for row in raw_rows)
|
|
return pa.FixedSizeListArray.from_arrays(pa.array(raw, type=pa.uint8()), list_size=byte_width)
|
|
|
|
|
|
def gen_parquet_bytes(num_rows, start_id, scalar_name, arrow_type, value_fn, dim=ct.default_dim):
|
|
"""Generate a Parquet file with columns: id, <scalar_name>, embedding (float_vec)."""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
vectors = _float_vectors(ids, dim)
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
scalar_name: pa.array([value_fn(i) for i in ids], type=arrow_type),
|
|
"embedding": pa.FixedSizeListArray.from_arrays(vectors.flatten(), list_size=dim),
|
|
}
|
|
)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
def gen_basic_parquet_bytes(num_rows, start_id, dim=ct.default_dim):
|
|
"""Generate a Parquet file with columns: id, value (float), embedding."""
|
|
return gen_parquet_bytes(
|
|
num_rows,
|
|
start_id,
|
|
"value",
|
|
pa.float32(),
|
|
lambda i: float(i) * 1.5,
|
|
dim=dim,
|
|
)
|
|
|
|
|
|
def gen_parquet_bytes_with_codec(num_rows, start_id, codec, dim=ct.default_dim):
|
|
"""Like gen_basic_parquet_bytes but with an explicit Parquet compression codec."""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
vectors = _float_vectors(ids, dim)
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
"value": pa.array([float(i) * 1.5 for i in ids], type=pa.float32()),
|
|
"embedding": pa.FixedSizeListArray.from_arrays(vectors.flatten(), list_size=dim),
|
|
}
|
|
)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=codec)
|
|
return buf.getvalue()
|
|
|
|
|
|
def gen_array_parquet_bytes(num_rows, start_id, arr_name, arr_element_arrow_type, arr_value_fn, dim=ct.default_dim):
|
|
"""Parquet with an Array-typed column: id, <arr_name> (list<element>), embedding."""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
vectors = _float_vectors(ids, dim)
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
arr_name: pa.array([arr_value_fn(i) for i in ids], type=pa.list_(arr_element_arrow_type)),
|
|
"embedding": pa.FixedSizeListArray.from_arrays(vectors.flatten(), list_size=dim),
|
|
}
|
|
)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
def gen_dtype_mismatch_parquet_bytes(
|
|
num_rows, start_id, mismatch_name, mismatch_array, dim=ct.default_dim, include_embedding=True
|
|
):
|
|
"""Parquet with a deliberately incompatible user column.
|
|
|
|
The id column and optional embedding are valid so failures can be
|
|
attributed to the requested Milvus type vs external Arrow type mapping.
|
|
"""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
columns = {
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
mismatch_name: mismatch_array,
|
|
}
|
|
if include_embedding:
|
|
vectors = _float_vectors(ids, dim)
|
|
columns["embedding"] = pa.FixedSizeListArray.from_arrays(
|
|
vectors.flatten(),
|
|
list_size=dim,
|
|
)
|
|
table = pa.table(columns)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
def gen_vector_variant_parquet_bytes(num_rows, start_id, vec_field, vec_dtype, dim):
|
|
"""Generate parquet with id + <vec_field> of the given vector family + no scalar."""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
if vec_dtype == DataType.FLOAT_VECTOR:
|
|
arr = _float_vectors(ids, dim)
|
|
parquet_col = pa.FixedSizeListArray.from_arrays(arr.flatten(), list_size=dim)
|
|
elif vec_dtype == DataType.FLOAT16_VECTOR:
|
|
parquet_col = _fixed_size_binary_vector_array(
|
|
_float16_vectors(ids, dim),
|
|
_vector_byte_width(vec_dtype, dim),
|
|
)
|
|
elif vec_dtype == DataType.BFLOAT16_VECTOR:
|
|
parquet_col = _fixed_size_binary_vector_array(
|
|
_bfloat16_vectors(ids, dim),
|
|
_vector_byte_width(vec_dtype, dim),
|
|
)
|
|
elif vec_dtype == DataType.INT8_VECTOR:
|
|
arr = _int8_vectors(ids, dim)
|
|
parquet_col = pa.FixedSizeListArray.from_arrays(
|
|
pa.array(arr.flatten(), type=pa.int8()),
|
|
list_size=dim,
|
|
)
|
|
elif vec_dtype == DataType.BINARY_VECTOR:
|
|
parquet_col = _fixed_size_binary_vector_array(
|
|
_binary_vectors_bytes(ids, dim),
|
|
_vector_byte_width(vec_dtype, dim),
|
|
)
|
|
else:
|
|
raise ValueError(f"unsupported vec_dtype {vec_dtype}")
|
|
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
vec_field: parquet_col,
|
|
}
|
|
)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
def gen_all_scalar_parquet_bytes(num_rows, start_id, dim=ct.default_dim):
|
|
"""Single parquet with every stable scalar type + FloatVector.
|
|
|
|
Columns: id, val_bool, val_int8, val_int16, val_int32, val_int64,
|
|
val_float, val_double, val_varchar, val_json, embedding.
|
|
"""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
vectors = _float_vectors(ids, dim)
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
"val_bool": pa.array([i % 2 == 0 for i in ids], type=pa.bool_()),
|
|
"val_int8": pa.array([i % 100 for i in ids], type=pa.int8()),
|
|
"val_int16": pa.array([i * 3 for i in ids], type=pa.int16()),
|
|
"val_int32": pa.array([i * 7 for i in ids], type=pa.int32()),
|
|
"val_int64": pa.array([i * 1000 for i in ids], type=pa.int64()),
|
|
"val_float": pa.array([float(i) * 1.5 for i in ids], type=pa.float32()),
|
|
"val_double": pa.array([float(i) * 2.5 for i in ids], type=pa.float64()),
|
|
"val_varchar": pa.array([f"s_{i:05d}" for i in ids], type=pa.string()),
|
|
"val_json": pa.array([json.dumps({"k": i, "g": i % 3}) for i in ids], type=pa.string()),
|
|
"embedding": pa.FixedSizeListArray.from_arrays(vectors.flatten(), list_size=dim),
|
|
}
|
|
)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
def gen_multi_vector_parquet_bytes(num_rows, start_id, dim=ct.default_dim):
|
|
"""Parquet with two vector fields (float + binary) + id + scalar."""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
float_arr = _float_vectors(ids, dim)
|
|
bin_arr = _binary_vectors_bytes(ids, dim)
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
"value": pa.array([float(i) for i in ids], type=pa.float32()),
|
|
"dense_vec": pa.FixedSizeListArray.from_arrays(float_arr.flatten(), list_size=dim),
|
|
"bin_vec": _fixed_size_binary_vector_array(
|
|
bin_arr,
|
|
_vector_byte_width(DataType.BINARY_VECTOR, dim),
|
|
),
|
|
}
|
|
)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
def gen_timestamptz_parquet_bytes(num_rows, start_id, dim=ct.default_dim, ts_unit="us"):
|
|
"""Parquet with id + ts (arrow timestamp) + embedding.
|
|
|
|
Milvus reads Timestamptz as int64 microseconds; the arrow timestamp column
|
|
is normalized to int64 by NormalizeExternalArrow on the load path.
|
|
"""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
vectors = _float_vectors(ids, dim)
|
|
base = 1_700_000_000
|
|
if ts_unit == "us":
|
|
ts_vals = [(base + i) * 1_000_000 for i in ids]
|
|
elif ts_unit == "ms":
|
|
ts_vals = [(base + i) * 1_000 for i in ids]
|
|
else:
|
|
ts_vals = [base + i for i in ids]
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
"ts": pa.array(ts_vals, type=pa.timestamp(ts_unit)),
|
|
"embedding": pa.FixedSizeListArray.from_arrays(vectors.flatten(), list_size=dim),
|
|
}
|
|
)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
def gen_geometry_wkt_parquet_bytes(num_rows, start_id, dim=ct.default_dim):
|
|
"""Parquet with Geometry stored as WKT strings, matching user datasets."""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
vectors = _float_vectors(ids, dim)
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
"geo": pa.array([f"POINT({i}.0 {i}.0)" for i in ids], type=pa.string()),
|
|
"embedding": pa.FixedSizeListArray.from_arrays(
|
|
vectors.flatten(),
|
|
list_size=dim,
|
|
),
|
|
}
|
|
)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
def gen_all_null_columns_parquet_bytes(num_rows, start_id, dim=ct.default_dim):
|
|
"""Parquet where every user scalar column is entirely null.
|
|
|
|
External collections force user fields to nullable=true (schema.go:2350).
|
|
A column with no non-null values exercises that path: arrow reads back
|
|
ListArray with null_count == num_rows, and Milvus must surface those
|
|
nulls without crashing the segment writer.
|
|
"""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
vectors = _float_vectors(ids, dim)
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
"n_int": pa.array([None] * num_rows, type=pa.int32()),
|
|
"n_float": pa.array([None] * num_rows, type=pa.float32()),
|
|
"n_varchar": pa.array([None] * num_rows, type=pa.string()),
|
|
"n_json": pa.array([None] * num_rows, type=pa.string()),
|
|
"embedding": pa.FixedSizeListArray.from_arrays(vectors.flatten(), list_size=dim),
|
|
}
|
|
)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
def gen_large_parquet_with_row_groups(num_rows, start_id, row_group_size, dim=ct.default_dim):
|
|
"""Generate a single large parquet file with explicit row_group_size.
|
|
|
|
Forces parquet to split internally into multiple row groups so the
|
|
Milvus reader exercises the multi-row-group path. The same file may
|
|
also be split into multiple Milvus segments by the loon FFI layer
|
|
when num_rows exceeds the segment-size threshold.
|
|
"""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
vectors = _float_vectors(ids, dim)
|
|
table = pa.table(
|
|
{
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
"value": pa.array([float(i) * 1.5 for i in ids], type=pa.float32()),
|
|
"embedding": pa.FixedSizeListArray.from_arrays(vectors.flatten(), list_size=dim),
|
|
}
|
|
)
|
|
buf = io.BytesIO()
|
|
pq.write_table(
|
|
table,
|
|
buf,
|
|
compression=_PARQUET_COMPRESSION,
|
|
row_group_size=row_group_size,
|
|
)
|
|
return buf.getvalue()
|
|
|
|
|
|
# ============================================================
|
|
# Full-matrix schema (DataType x Index)
|
|
# ============================================================
|
|
# A single collection schema covering every supported scalar + vector
|
|
# DataType, with same-dtype duplicates wired to different index types.
|
|
# Each entry is (milvus_field_name, milvus_DataType, index_type, index_metric,
|
|
# index_params, ext_field_name, arrow_writer_type, value_fn,
|
|
# add_field_extra). Vector entries treat dim/element_type via
|
|
# add_field_extra rather than a value_fn.
|
|
#
|
|
# The same field list is consumed by both the parquet/lance arrow-table
|
|
# generator and the iceberg multi-column writer (vector columns degrade to
|
|
# raw binary blobs in iceberg because Iceberg has no fixed-size-list).
|
|
|
|
FULL_MATRIX_SCALAR_FIELDS = [
|
|
# (field_name, DataType, pa type, milvus add_field extras, value_fn)
|
|
("b_inv", DataType.BOOL, pa.bool_(), {}, lambda i: i % 2 == 0),
|
|
("i8_bmp", DataType.INT8, pa.int8(), {}, lambda i: i % 100),
|
|
("i16_inv", DataType.INT16, pa.int16(), {}, lambda i: (i * 3) % 32000),
|
|
("i32_stl", DataType.INT32, pa.int32(), {}, lambda i: i * 7),
|
|
("i64_inv", DataType.INT64, pa.int64(), {}, lambda i: i * 1000),
|
|
("f_inv", DataType.FLOAT, pa.float32(), {}, lambda i: float(i) * 1.5),
|
|
("d_inv", DataType.DOUBLE, pa.float64(), {}, lambda i: float(i) * 2.5),
|
|
("vc_trie", DataType.VARCHAR, pa.string(), {"max_length": 64}, lambda i: f"s_{i:05d}"),
|
|
("txt", DataType.TEXT, pa.string(), {"max_length": 1024}, lambda i: f"text document {i}"),
|
|
("j", DataType.JSON, pa.string(), {}, lambda i: json.dumps({"k": i, "g": i % 3})),
|
|
("ts", DataType.TIMESTAMPTZ, pa.timestamp("us"), {}, lambda i: (1_700_000_000 + i) * 1_000_000),
|
|
]
|
|
|
|
# Scalar indexes wired per field. None means no scalar index built.
|
|
FULL_MATRIX_SCALAR_INDEXES = {
|
|
"b_inv": "INVERTED",
|
|
"i8_bmp": "BITMAP",
|
|
"i16_inv": "INVERTED",
|
|
"i32_stl": "STL_SORT",
|
|
"i64_inv": "INVERTED",
|
|
"f_inv": "INVERTED",
|
|
"d_inv": "INVERTED",
|
|
"vc_trie": "TRIE",
|
|
# txt (TEXT), j (JSON), and ts (Timestamptz) are stored without scalar index.
|
|
}
|
|
|
|
FULL_MATRIX_ARRAY_FIELD = ("arr_int", DataType.INT64, lambda i: [i, i + 1, i + 2])
|
|
|
|
# A fixed POINT(0 0) WKB blob, sufficient to verify Geometry round-trip.
|
|
# WKB: byte order (1=little-endian) | uint32 type(1=POINT) | float64 x | float64 y
|
|
_GEOMETRY_WKB = (
|
|
b"\x01\x01\x00\x00\x00"
|
|
+ b"\x00" * 8 # x = 0.0
|
|
+ b"\x00" * 8 # y = 0.0
|
|
)
|
|
|
|
# Full-matrix vectors keep their own fixed production-realistic dim so the
|
|
# format-index matrix stays stable even if the common default changes.
|
|
FULL_MATRIX_DIM = 128
|
|
FULL_MATRIX_BINARY_DIM = 128 # multiple of 8
|
|
|
|
# Vector fields. Each entry: (name, DataType, dim, index_type, metric, params).
|
|
FULL_MATRIX_VECTOR_FIELDS = [
|
|
("fv_auto", DataType.FLOAT_VECTOR, FULL_MATRIX_DIM, "AUTOINDEX", "L2", {}),
|
|
("fv_flat", DataType.FLOAT_VECTOR, FULL_MATRIX_DIM, "FLAT", "L2", {}),
|
|
("fv_hnsw", DataType.FLOAT_VECTOR, FULL_MATRIX_DIM, "HNSW", "L2", {"M": 8, "efConstruction": 64}),
|
|
("fv_ivf", DataType.FLOAT_VECTOR, FULL_MATRIX_DIM, "IVF_FLAT", "L2", {"nlist": 8}),
|
|
("f16v", DataType.FLOAT16_VECTOR, FULL_MATRIX_DIM, "AUTOINDEX", "L2", {}),
|
|
("bf16v", DataType.BFLOAT16_VECTOR, FULL_MATRIX_DIM, "AUTOINDEX", "L2", {}),
|
|
("binv_flat", DataType.BINARY_VECTOR, FULL_MATRIX_BINARY_DIM, "BIN_FLAT", "HAMMING", {}),
|
|
("binv_ivf", DataType.BINARY_VECTOR, FULL_MATRIX_BINARY_DIM, "BIN_IVF_FLAT", "HAMMING", {"nlist": 8}),
|
|
("i8v", DataType.INT8_VECTOR, FULL_MATRIX_DIM, "AUTOINDEX", "L2", {}),
|
|
]
|
|
|
|
|
|
def _full_matrix_arrow_columns(
|
|
num_rows, start_id, dim=FULL_MATRIX_DIM, bin_dim=FULL_MATRIX_BINARY_DIM, excluded_fields=(), vortex_compatible=False
|
|
):
|
|
"""Build a dict {column_name -> pyarrow.Array} that fits both parquet and
|
|
Lance. Column layout matches FULL_MATRIX_SCALAR_FIELDS / VECTOR_FIELDS;
|
|
each milvus field reads from a uniquely-named column.
|
|
|
|
Pass excluded_fields to omit specific scalar columns (e.g. for vortex,
|
|
where the server reader can't sample Arrow String buffers)."""
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
excluded = set(excluded_fields)
|
|
|
|
columns = {"id": pa.array(ids, type=pa.int64())}
|
|
|
|
# Scalars
|
|
for name, _dtype, arrow_type, _extra, value_fn in FULL_MATRIX_SCALAR_FIELDS:
|
|
if name in excluded:
|
|
continue
|
|
columns[name] = pa.array([value_fn(i) for i in ids], type=arrow_type)
|
|
|
|
# Array<Int64>
|
|
arr_name, _arr_dtype, arr_value_fn = FULL_MATRIX_ARRAY_FIELD
|
|
if arr_name not in excluded:
|
|
columns[arr_name] = pa.array([arr_value_fn(i) for i in ids], type=pa.list_(pa.int64()))
|
|
|
|
# Geometry as binary (WKB).
|
|
if "geo" not in excluded:
|
|
columns["geo"] = pa.array([_GEOMETRY_WKB] * num_rows, type=pa.binary())
|
|
|
|
# Vectors. Layout per type:
|
|
# FloatVector / Float16Vector / Int8Vector -> FixedSizeList<element, dim>
|
|
# BFloat16/BinaryVector -> fixed_size_binary raw bytes
|
|
# Vortex 0.56 cannot write FixedSizeBinary; Vortex data uses
|
|
# FixedSizeList<UInt8> for BF16 and binary byte-vector payloads.
|
|
fv_arr = _float_vectors(ids, dim).flatten()
|
|
f16_arr = _float16_vectors(ids, dim)
|
|
bf16_arr = _bfloat16_vectors(ids, dim)
|
|
i8_arr = _int8_vectors(ids, dim).flatten()
|
|
bin_arr = _binary_vectors_bytes(ids, bin_dim)
|
|
|
|
for name, vtype, vdim, _idx, _metric, _params in FULL_MATRIX_VECTOR_FIELDS:
|
|
if name in excluded:
|
|
continue
|
|
if vtype == DataType.FLOAT_VECTOR:
|
|
columns[name] = pa.FixedSizeListArray.from_arrays(
|
|
pa.array(fv_arr, type=pa.float32()),
|
|
list_size=vdim,
|
|
)
|
|
elif vtype == DataType.FLOAT16_VECTOR:
|
|
byte_width = _vector_byte_width(vtype, vdim)
|
|
columns[name] = (
|
|
pa.FixedSizeListArray.from_arrays(pa.array(f16_arr.flatten(), type=pa.float16()), list_size=vdim)
|
|
if vortex_compatible
|
|
else _fixed_size_binary_vector_array(f16_arr, byte_width)
|
|
)
|
|
elif vtype == DataType.BFLOAT16_VECTOR:
|
|
byte_width = _vector_byte_width(vtype, vdim)
|
|
columns[name] = (
|
|
_fixed_size_uint8_vector_array(bf16_arr, byte_width)
|
|
if vortex_compatible
|
|
else _fixed_size_binary_vector_array(bf16_arr, byte_width)
|
|
)
|
|
elif vtype == DataType.INT8_VECTOR:
|
|
columns[name] = pa.FixedSizeListArray.from_arrays(
|
|
pa.array(i8_arr, type=pa.int8()),
|
|
list_size=vdim,
|
|
)
|
|
elif vtype == DataType.BINARY_VECTOR:
|
|
byte_width = _vector_byte_width(vtype, vdim)
|
|
columns[name] = (
|
|
_fixed_size_uint8_vector_array(bin_arr, byte_width)
|
|
if vortex_compatible
|
|
else _fixed_size_binary_vector_array(bin_arr, byte_width)
|
|
)
|
|
else:
|
|
raise ValueError(f"unsupported vector dtype {vtype}")
|
|
return columns
|
|
|
|
|
|
def gen_full_matrix_parquet_bytes(num_rows, start_id, dim=FULL_MATRIX_DIM, bin_dim=FULL_MATRIX_BINARY_DIM):
|
|
"""Single parquet file with every full-matrix field."""
|
|
columns = _full_matrix_arrow_columns(num_rows, start_id, dim=dim, bin_dim=bin_dim)
|
|
table = pa.table(columns)
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
# ============================================================
|
|
# MinIO Helpers
|
|
# ============================================================
|
|
|
|
|
|
def upload_parquet(minio_client, bucket, key, data):
|
|
minio_client.put_object(
|
|
bucket,
|
|
key,
|
|
io.BytesIO(data),
|
|
length=len(data),
|
|
content_type="application/octet-stream",
|
|
)
|
|
|
|
|
|
def cleanup_minio_prefix(minio_client, bucket, prefix):
|
|
for obj in minio_client.list_objects(bucket, prefix=prefix, recursive=True):
|
|
minio_client.remove_object(bucket, obj.object_name)
|
|
|
|
|
|
# ============================================================
|
|
# Full-Matrix Schema Helpers
|
|
# ============================================================
|
|
|
|
|
|
def build_full_matrix_schema(
|
|
ops, client, ext_path, ext_spec=None, dim=FULL_MATRIX_DIM, bin_dim=FULL_MATRIX_BINARY_DIM, excluded_fields=()
|
|
):
|
|
"""Schema covering every supported DataType, with same-dtype duplicates
|
|
wired to different index types. See FULL_MATRIX_SCALAR_FIELDS /
|
|
FULL_MATRIX_VECTOR_FIELDS for the full layout.
|
|
"""
|
|
schema = ops.create_schema(client, external_source=ext_path, external_spec=ext_spec or build_external_spec())[0]
|
|
ops.add_field(schema, "id", DataType.INT64, external_field="id")
|
|
excluded = set(excluded_fields)
|
|
|
|
for name, dtype, _arrow, extra, _value_fn in FULL_MATRIX_SCALAR_FIELDS:
|
|
if name in excluded:
|
|
continue
|
|
ops.add_field(schema, name, dtype, external_field=name, **extra)
|
|
|
|
arr_name, arr_elem_dtype, _ = FULL_MATRIX_ARRAY_FIELD
|
|
if arr_name not in excluded:
|
|
ops.add_field(
|
|
schema, arr_name, DataType.ARRAY, element_type=arr_elem_dtype, max_capacity=8, external_field=arr_name
|
|
)
|
|
|
|
if "geo" not in excluded:
|
|
ops.add_field(schema, "geo", DataType.GEOMETRY, external_field="geo")
|
|
|
|
for name, vtype, vdim, _idx, _metric, _params in FULL_MATRIX_VECTOR_FIELDS:
|
|
if name in excluded:
|
|
continue
|
|
ops.add_field(schema, name, vtype, dim=vdim, external_field=name)
|
|
return schema
|
|
|
|
|
|
def create_full_matrix_indexes(ops, client, collection_name, excluded_fields=()):
|
|
"""Build every scalar + vector index per FULL_MATRIX configuration in
|
|
a single create_index call.
|
|
"""
|
|
index_params = ops.prepare_index_params(client)[0]
|
|
excluded = set(excluded_fields)
|
|
|
|
# Scalar indexes
|
|
for field, idx_type in FULL_MATRIX_SCALAR_INDEXES.items():
|
|
if field in excluded:
|
|
continue
|
|
index_params.add_index(
|
|
field_name=field,
|
|
index_type=idx_type,
|
|
)
|
|
|
|
# Vector indexes
|
|
for name, _vtype, _vdim, idx_type, metric, params in FULL_MATRIX_VECTOR_FIELDS:
|
|
if name in excluded:
|
|
continue
|
|
kwargs = {
|
|
"field_name": name,
|
|
"index_type": idx_type,
|
|
"metric_type": metric,
|
|
}
|
|
if params:
|
|
kwargs["params"] = params
|
|
index_params.add_index(**kwargs)
|
|
|
|
ops.create_index(client, collection_name, index_params)
|
|
|
|
|
|
def query_count(client, collection_name):
|
|
res = client.query(collection_name, filter="", output_fields=["count(*)"])
|
|
return res[0]["count(*)"]
|
|
|
|
|
|
def upload_basic_data(
|
|
minio_client, cfg, ext_key, num_rows=ct.default_nb, start_id=0, filename="data.parquet", dim=ct.default_dim
|
|
):
|
|
"""Upload a basic parquet file and return full minio key."""
|
|
key = f"{ext_key}/{filename}"
|
|
upload_parquet(
|
|
minio_client,
|
|
cfg["bucket"],
|
|
key,
|
|
gen_basic_parquet_bytes(num_rows, start_id, dim=dim),
|
|
)
|
|
return key
|
|
|
|
|
|
def require_format_dependencies(fmt):
|
|
if fmt == "lance-table":
|
|
import lance # noqa: F401
|
|
elif fmt == "iceberg-table":
|
|
from pyiceberg.catalog.sql import SqlCatalog # noqa: F401
|
|
elif fmt == "vortex":
|
|
import vortex.io as vortex_io # noqa: F401
|
|
|
|
|
|
def _basic_format_arrow_table(num_rows, start_id, dim=ct.default_dim, include_score=False):
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
vectors = _float_vectors(ids, dim)
|
|
columns = {
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
"value": pa.array([float(i) * 1.5 for i in ids], type=pa.float32()),
|
|
"embedding": pa.FixedSizeListArray.from_arrays(vectors.flatten(), list_size=dim),
|
|
}
|
|
if include_score:
|
|
columns["score"] = pa.array([float(i) * 0.01 for i in ids], type=pa.float64())
|
|
return pa.table(columns)
|
|
|
|
|
|
def _basic_format_table_to_parquet_bytes(table):
|
|
buf = io.BytesIO()
|
|
pq.write_table(table, buf, compression=_PARQUET_COMPRESSION)
|
|
return buf.getvalue()
|
|
|
|
|
|
def write_basic_format_dataset(
|
|
fmt,
|
|
minio_client,
|
|
cfg,
|
|
ext_url,
|
|
ext_key,
|
|
batches,
|
|
dim=ct.default_dim,
|
|
include_score=False,
|
|
):
|
|
"""Write the basic id/value/embedding dataset for one external format.
|
|
|
|
Returns (external_source, external_spec). Iceberg rewrites external_source
|
|
to the metadata.json URI.
|
|
"""
|
|
require_format_dependencies(fmt)
|
|
if fmt == "parquet":
|
|
for idx, (start_id, num_rows) in enumerate(batches):
|
|
upload_parquet(
|
|
minio_client,
|
|
cfg["bucket"],
|
|
f"{ext_key}/file_{idx:03d}.parquet",
|
|
gen_basic_parquet_bytes(num_rows, start_id, dim=dim)
|
|
if not include_score
|
|
else _basic_format_table_to_parquet_bytes(
|
|
_basic_format_arrow_table(num_rows, start_id, dim=dim, include_score=True)
|
|
),
|
|
)
|
|
return ext_url, build_external_spec(cfg, fmt=fmt)
|
|
|
|
if fmt == "lance-table":
|
|
_write_lance_to_minio_batches(
|
|
minio_client,
|
|
cfg["bucket"],
|
|
ext_key,
|
|
batches,
|
|
dim=dim,
|
|
include_score=include_score,
|
|
)
|
|
return ext_url, build_external_spec(cfg, fmt=fmt)
|
|
|
|
if fmt == "iceberg-table":
|
|
snapshot_id, iceberg_url = _build_iceberg_table_in_minio(
|
|
ext_key,
|
|
cfg,
|
|
batches=batches,
|
|
dim=dim,
|
|
include_score=include_score,
|
|
)
|
|
return iceberg_url, build_external_spec(cfg, fmt=fmt, snapshot_id=int(snapshot_id))
|
|
|
|
if fmt == "vortex":
|
|
for idx, (start_id, num_rows) in enumerate(batches):
|
|
table = _basic_format_arrow_table(num_rows, start_id, dim=dim, include_score=include_score)
|
|
write_vortex_table(minio_client, cfg["bucket"], f"{ext_key}/file_{idx:03d}.vortex", table)
|
|
return ext_url, build_external_spec(cfg, fmt=fmt)
|
|
|
|
raise AssertionError(f"unsupported format: {fmt}")
|
|
|
|
|
|
# ============================================================
|
|
# External file format helpers
|
|
# ============================================================
|
|
|
|
|
|
def _write_lance_to_minio_batches(minio_client, bucket, key_prefix, batches, dim=ct.default_dim, include_score=False):
|
|
"""Multi-fragment variant: each (start_id, count) tuple in `batches` is
|
|
appended into the same Lance dataset, producing one fragment file per
|
|
batch under data/. Used to exercise multi-data-file refresh.
|
|
"""
|
|
import shutil
|
|
import tempfile
|
|
|
|
import lance
|
|
|
|
tmpdir = tempfile.mkdtemp(prefix="ext_lance_")
|
|
local_path = os.path.join(tmpdir, "dataset.lance")
|
|
try:
|
|
for idx, (start_id, num_rows) in enumerate(batches):
|
|
table = _basic_format_arrow_table(num_rows, start_id, dim=dim, include_score=include_score)
|
|
mode = "create" if idx == 0 else "append"
|
|
lance.write_dataset(table, local_path, mode=mode)
|
|
for root, _dirs, files in os.walk(local_path):
|
|
for fname in files:
|
|
absolute = os.path.join(root, fname)
|
|
relative = os.path.relpath(absolute, local_path)
|
|
minio_client.fput_object(bucket, f"{key_prefix}/{relative}", absolute)
|
|
finally:
|
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
|
|
|
|
|
def write_vortex_table(minio_client, bucket, key, table):
|
|
"""Write a pyarrow table as a Vortex file and upload it to MinIO."""
|
|
import vortex.io as vortex_io
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=".vortex", delete=False) as tmp:
|
|
tmp_path = tmp.name
|
|
try:
|
|
vortex_io.write(table, tmp_path)
|
|
minio_client.fput_object(bucket, key, tmp_path, content_type="application/octet-stream")
|
|
finally:
|
|
try:
|
|
os.unlink(tmp_path)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def _build_iceberg_table_in_minio(prefix, cfg, batches, dim=ct.default_dim, include_score=False):
|
|
"""Create an Iceberg table whose warehouse lives directly on MinIO.
|
|
|
|
Writing the warehouse to MinIO (not locally) is required because Iceberg
|
|
metadata.json records absolute data-file URIs at write time; if we wrote
|
|
to file:// then uploaded, those URIs would be unreachable from the
|
|
Milvus reader.
|
|
|
|
Vector encoding: each row stores its vector as a fixed-size binary blob of
|
|
dim * sizeof(float) bytes. Milvus maps external_field=embedding to a
|
|
FloatVector field and reinterprets the raw bytes as float32.
|
|
|
|
Note: list<float32> looks more natural but pyarrow >=15 promotes long
|
|
lists to large_list, which the vector normalizer rejects
|
|
(Util.cpp:1761). The binary encoding sidesteps that.
|
|
|
|
`batches` is a list of (start_id, count) tuples. Each batch is appended
|
|
to the same iceberg table as a separate parquet data file under data/.
|
|
The final snapshot includes every batch.
|
|
|
|
Returns (snapshot_id, ext_url) where ext_url is the s3:// URL of the
|
|
table's metadata.json in Milvus URI form.
|
|
"""
|
|
import shutil
|
|
import struct
|
|
import tempfile
|
|
|
|
from pyiceberg.catalog.sql import SqlCatalog
|
|
from pyiceberg.schema import Schema as IbgSchema
|
|
from pyiceberg.types import DoubleType, FixedType, FloatType, LongType, NestedField
|
|
|
|
tmp = tempfile.mkdtemp(prefix="ibg_cat_")
|
|
try:
|
|
cat = SqlCatalog(
|
|
"milvus_test",
|
|
**{
|
|
"uri": f"sqlite:///{tmp}/cat.db",
|
|
"warehouse": f"s3://{cfg['bucket']}/{prefix}",
|
|
"s3.endpoint": _minio_endpoint_url(cfg),
|
|
"s3.access-key-id": cfg["access_key"],
|
|
"s3.secret-access-key": cfg["secret_key"],
|
|
"s3.region": "us-east-1",
|
|
"s3.path-style-access": "true",
|
|
},
|
|
)
|
|
cat.create_namespace("ext")
|
|
|
|
ibg_fields = [
|
|
NestedField(1, "id", LongType(), required=False),
|
|
NestedField(2, "value", FloatType(), required=False),
|
|
NestedField(3, "embedding", FixedType(_vector_byte_width(DataType.FLOAT_VECTOR, dim)), required=False),
|
|
]
|
|
arrow_fields = [
|
|
pa.field("id", pa.int64(), nullable=True),
|
|
pa.field("value", pa.float32(), nullable=True),
|
|
pa.field("embedding", pa.binary(_vector_byte_width(DataType.FLOAT_VECTOR, dim)), nullable=True),
|
|
]
|
|
if include_score:
|
|
ibg_fields.append(NestedField(4, "score", DoubleType(), required=False))
|
|
arrow_fields.append(pa.field("score", pa.float64(), nullable=True))
|
|
|
|
ibg_schema = IbgSchema(*ibg_fields)
|
|
arrow_schema = pa.schema(arrow_fields)
|
|
tbl = cat.create_table("ext.t", schema=ibg_schema)
|
|
|
|
for start_id, num_rows in batches:
|
|
ids = list(range(start_id, start_id + num_rows))
|
|
vec_bytes = [struct.pack(f"<{dim}f", *[float(i) * 0.1 + j for j in range(dim)]) for i in ids]
|
|
columns = {
|
|
"id": pa.array(ids, type=pa.int64()),
|
|
"value": pa.array([float(i) * 1.5 for i in ids], type=pa.float32()),
|
|
"embedding": pa.array(vec_bytes, type=pa.binary(_vector_byte_width(DataType.FLOAT_VECTOR, dim))),
|
|
}
|
|
if include_score:
|
|
columns["score"] = pa.array([float(i) * 0.01 for i in ids], type=pa.float64())
|
|
arrow_table = pa.table(columns, schema=arrow_schema)
|
|
tbl.append(arrow_table)
|
|
|
|
snap = tbl.current_snapshot().snapshot_id
|
|
# metadata_location: s3://<bucket>/<prefix>/.../metadata/000NN-uuid.metadata.json
|
|
# -> Milvus form: s3://<address>/<bucket>/...
|
|
meta_loc = tbl.metadata_location
|
|
if not meta_loc.startswith("s3://"):
|
|
raise RuntimeError(f"unexpected metadata_loc scheme: {meta_loc}")
|
|
bucket_and_key = meta_loc[len("s3://") :]
|
|
ext_url = f"s3://{cfg['address']}/{bucket_and_key}"
|
|
return snap, ext_url
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
# Iceberg full-matrix is a strict subset of FULL_MATRIX_* because Iceberg V2
|
|
# only supports int (32-bit) and long (64-bit) integers; no Int8/Int16. We
|
|
# reuse the same field names where possible to keep query expectations
|
|
# identical between formats; Int8 -> IntegerType promoted to Milvus INT32
|
|
# rather than INT8, otherwise NormalizeExternalArrow refuses the cast.
|
|
ICEBERG_FULL_MATRIX_SCALAR_FIELDS = [
|
|
# (field_name, milvus DataType, iceberg type ctor (lambda field_id: type),
|
|
# arrow type, milvus add_field extras, value_fn)
|
|
("b_inv", DataType.BOOL, lambda fid: ("BooleanType",), pa.bool_(), {}, lambda i: i % 2 == 0),
|
|
("i32_stl", DataType.INT32, lambda fid: ("IntegerType",), pa.int32(), {}, lambda i: i * 7),
|
|
("i64_inv", DataType.INT64, lambda fid: ("LongType",), pa.int64(), {}, lambda i: i * 1000),
|
|
("f_inv", DataType.FLOAT, lambda fid: ("FloatType",), pa.float32(), {}, lambda i: float(i) * 1.5),
|
|
("d_inv", DataType.DOUBLE, lambda fid: ("DoubleType",), pa.float64(), {}, lambda i: float(i) * 2.5),
|
|
("vc_trie", DataType.VARCHAR, lambda fid: ("StringType",), pa.string(), {"max_length": 64}, lambda i: f"s_{i:05d}"),
|
|
("j", DataType.JSON, lambda fid: ("StringType",), pa.string(), {}, lambda i: json.dumps({"k": i, "g": i % 3})),
|
|
(
|
|
"ts",
|
|
DataType.TIMESTAMPTZ,
|
|
lambda fid: ("TimestamptzType",),
|
|
pa.timestamp("us", tz="UTC"),
|
|
{},
|
|
lambda i: (1_700_000_000 + i) * 1_000_000,
|
|
),
|
|
]
|
|
|
|
ICEBERG_FULL_MATRIX_SCALAR_INDEXES = {
|
|
"b_inv": "INVERTED",
|
|
"i32_stl": "STL_SORT",
|
|
"i64_inv": "INVERTED",
|
|
"f_inv": "INVERTED",
|
|
"d_inv": "INVERTED",
|
|
"vc_trie": "TRIE",
|
|
}
|
|
|
|
|
|
def _build_iceberg_full_matrix_table(prefix, cfg, num_rows, dim=FULL_MATRIX_DIM, bin_dim=FULL_MATRIX_BINARY_DIM):
|
|
"""Iceberg variant of the full-matrix dataset.
|
|
|
|
Vectors are stored as raw binary blobs because Iceberg has no
|
|
fixed-size-list. JSON / Geometry use String / Binary respectively.
|
|
Returns (snapshot_id, ext_url).
|
|
"""
|
|
import shutil
|
|
import struct
|
|
import tempfile
|
|
|
|
from pyiceberg.catalog.sql import SqlCatalog
|
|
from pyiceberg.schema import Schema as IbgSchema
|
|
from pyiceberg.types import (
|
|
BinaryType,
|
|
BooleanType,
|
|
DoubleType,
|
|
FixedType,
|
|
FloatType,
|
|
IntegerType,
|
|
ListType,
|
|
LongType,
|
|
NestedField,
|
|
StringType,
|
|
TimestamptzType,
|
|
)
|
|
|
|
iceberg_type_lookup = {
|
|
"BooleanType": BooleanType(),
|
|
"IntegerType": IntegerType(),
|
|
"LongType": LongType(),
|
|
"FloatType": FloatType(),
|
|
"DoubleType": DoubleType(),
|
|
"StringType": StringType(),
|
|
"TimestamptzType": TimestamptzType(),
|
|
}
|
|
|
|
ids = list(range(num_rows))
|
|
next_field_id = [3] # field-id assignment, id=1, schema starts at 2 then bumps
|
|
|
|
def fid():
|
|
x = next_field_id[0]
|
|
next_field_id[0] += 1
|
|
return x
|
|
|
|
nested_fields = [NestedField(1, "id", LongType(), required=False)]
|
|
arrow_fields = [pa.field("id", pa.int64(), nullable=True)]
|
|
arrow_columns = {"id": pa.array(ids, type=pa.int64())}
|
|
|
|
# Scalars
|
|
for name, _dtype, ibg_factory, arrow_type, _extra, value_fn in ICEBERG_FULL_MATRIX_SCALAR_FIELDS:
|
|
ibg_type = iceberg_type_lookup[ibg_factory(0)[0]]
|
|
nested_fields.append(NestedField(fid(), name, ibg_type, required=False))
|
|
arrow_fields.append(pa.field(name, arrow_type, nullable=True))
|
|
arrow_columns[name] = pa.array([value_fn(i) for i in ids], type=arrow_type)
|
|
|
|
# Array<Int64>: ListType<LongType>
|
|
arr_name, _arr_dtype, arr_value_fn = FULL_MATRIX_ARRAY_FIELD
|
|
nested_fields.append(
|
|
NestedField(
|
|
fid(),
|
|
arr_name,
|
|
ListType(element_id=fid(), element_type=LongType(), element_required=False),
|
|
required=False,
|
|
),
|
|
)
|
|
arrow_fields.append(
|
|
pa.field(arr_name, pa.list_(pa.field("element", pa.int64(), nullable=True)), nullable=True),
|
|
)
|
|
arrow_columns[arr_name] = pa.array(
|
|
[arr_value_fn(i) for i in ids],
|
|
type=pa.list_(pa.field("element", pa.int64(), nullable=True)),
|
|
)
|
|
|
|
# Geometry -> BinaryType (WKB)
|
|
nested_fields.append(NestedField(fid(), "geo", BinaryType(), required=False))
|
|
arrow_fields.append(pa.field("geo", pa.binary(), nullable=True))
|
|
arrow_columns["geo"] = pa.array([_GEOMETRY_WKB] * num_rows, type=pa.binary())
|
|
|
|
# Vector fields -> FixedType blobs of dim*sizeof(elem) bytes.
|
|
fv_arr = _float_vectors(ids, dim)
|
|
f16_arr = _float16_vectors(ids, dim).view(np.uint16)
|
|
bf16_arr = _bfloat16_vectors(ids, dim) # already raw uint16-equivalent
|
|
i8_arr = _int8_vectors(ids, dim)
|
|
bin_arr = _binary_vectors_bytes(ids, bin_dim)
|
|
|
|
def vec_blobs(name, vtype, vdim):
|
|
if vtype == DataType.FLOAT_VECTOR:
|
|
return [struct.pack(f"<{vdim}f", *fv_arr[i].tolist()) for i in range(num_rows)]
|
|
if vtype == DataType.FLOAT16_VECTOR:
|
|
return [f16_arr[i].astype(np.uint16).tobytes() for i in range(num_rows)]
|
|
if vtype == DataType.BFLOAT16_VECTOR:
|
|
return [bf16_arr[i].tobytes() for i in range(num_rows)]
|
|
if vtype == DataType.INT8_VECTOR:
|
|
return [i8_arr[i].tobytes() for i in range(num_rows)]
|
|
if vtype == DataType.BINARY_VECTOR:
|
|
return [bin_arr[i].tobytes() for i in range(num_rows)]
|
|
raise ValueError(f"unsupported vec type {vtype}")
|
|
|
|
for name, vtype, vdim, _idx, _metric, _params in FULL_MATRIX_VECTOR_FIELDS:
|
|
byte_width = _vector_byte_width(vtype, vdim)
|
|
nested_fields.append(NestedField(fid(), name, FixedType(byte_width), required=False))
|
|
arrow_fields.append(pa.field(name, pa.binary(byte_width), nullable=True))
|
|
arrow_columns[name] = pa.array(vec_blobs(name, vtype, vdim), type=pa.binary(byte_width))
|
|
|
|
ibg_schema = IbgSchema(*nested_fields)
|
|
arrow_schema = pa.schema(arrow_fields)
|
|
arrow_table = pa.table(arrow_columns, schema=arrow_schema)
|
|
|
|
tmp = tempfile.mkdtemp(prefix="ibg_fm_")
|
|
try:
|
|
cat = SqlCatalog(
|
|
"milvus_test_fm",
|
|
**{
|
|
"uri": f"sqlite:///{tmp}/cat.db",
|
|
"warehouse": f"s3://{cfg['bucket']}/{prefix}",
|
|
"s3.endpoint": _minio_endpoint_url(cfg),
|
|
"s3.access-key-id": cfg["access_key"],
|
|
"s3.secret-access-key": cfg["secret_key"],
|
|
"s3.region": "us-east-1",
|
|
"s3.path-style-access": "true",
|
|
},
|
|
)
|
|
cat.create_namespace("ext")
|
|
tbl = cat.create_table("ext.t", schema=ibg_schema)
|
|
tbl.append(arrow_table)
|
|
|
|
snap = tbl.current_snapshot().snapshot_id
|
|
meta_loc = tbl.metadata_location
|
|
if not meta_loc.startswith("s3://"):
|
|
raise RuntimeError(f"unexpected metadata_loc scheme: {meta_loc}")
|
|
bucket_and_key = meta_loc[len("s3://") :]
|
|
ext_url = f"s3://{cfg['address']}/{bucket_and_key}"
|
|
return snap, ext_url
|
|
finally:
|
|
shutil.rmtree(tmp, ignore_errors=True)
|
|
|
|
|
|
def build_iceberg_full_matrix_schema(
|
|
ops, client, ext_path, ext_spec, dim=FULL_MATRIX_DIM, bin_dim=FULL_MATRIX_BINARY_DIM
|
|
):
|
|
"""Milvus schema for the iceberg full-matrix dataset. Matches
|
|
ICEBERG_FULL_MATRIX_SCALAR_FIELDS for scalars and FULL_MATRIX_VECTOR_FIELDS
|
|
for vectors (vectors are stored as iceberg binary blobs but exposed as
|
|
Milvus vector types; NormalizeExternalArrow reinterprets the bytes)."""
|
|
schema = ops.create_schema(client, external_source=ext_path, external_spec=ext_spec or build_external_spec())[0]
|
|
ops.add_field(schema, "id", DataType.INT64, external_field="id")
|
|
for name, dtype, _ibg, _arrow, extra, _value_fn in ICEBERG_FULL_MATRIX_SCALAR_FIELDS:
|
|
ops.add_field(schema, name, dtype, external_field=name, **extra)
|
|
arr_name, arr_elem_dtype, _ = FULL_MATRIX_ARRAY_FIELD
|
|
ops.add_field(
|
|
schema, arr_name, DataType.ARRAY, element_type=arr_elem_dtype, max_capacity=8, external_field=arr_name
|
|
)
|
|
ops.add_field(schema, "geo", DataType.GEOMETRY, external_field="geo")
|
|
for name, vtype, vdim, _idx, _metric, _params in FULL_MATRIX_VECTOR_FIELDS:
|
|
ops.add_field(schema, name, vtype, dim=vdim, external_field=name)
|
|
return schema
|
|
|
|
|
|
def create_iceberg_full_matrix_indexes(ops, client, collection_name):
|
|
"""Wire the iceberg-subset scalar indexes + the same 9 vector indexes
|
|
used by parquet/lance full-matrix."""
|
|
index_params = ops.prepare_index_params(client)[0]
|
|
for field, idx_type in ICEBERG_FULL_MATRIX_SCALAR_INDEXES.items():
|
|
index_params.add_index(field_name=field, index_type=idx_type)
|
|
for name, _vtype, _vdim, idx_type, metric, params in FULL_MATRIX_VECTOR_FIELDS:
|
|
kwargs = {"field_name": name, "index_type": idx_type, "metric_type": metric}
|
|
if params:
|
|
kwargs["params"] = params
|
|
index_params.add_index(**kwargs)
|
|
ops.create_index(client, collection_name, index_params)
|
|
|
|
|
|
def _iceberg_full_matrix_assert(ops, client, coll, expected_count):
|
|
"""Iceberg-specific subset of _full_matrix_assert_basic: scalar set is
|
|
smaller (no Int8/Int16/Bitmap)."""
|
|
assert ops.query_count(client, coll) == expected_count
|
|
|
|
output_fields = ["id"] + [f for f, _, _, _, _, _ in ICEBERG_FULL_MATRIX_SCALAR_FIELDS]
|
|
output_fields += [FULL_MATRIX_ARRAY_FIELD[0], "geo"]
|
|
rows = ops.query(client, coll, filter="id == 42", output_fields=output_fields)[0]
|
|
assert len(rows) == 1, f"id=42 missing: {rows}"
|
|
r = rows[0]
|
|
assert r["id"] == 42
|
|
for name, _dtype, _ibg, _arrow, _extra, value_fn in ICEBERG_FULL_MATRIX_SCALAR_FIELDS:
|
|
expected = value_fn(42)
|
|
if name == "j":
|
|
parsed = r["j"] if isinstance(r["j"], dict) else json.loads(r["j"])
|
|
assert parsed.get("k") == 42, f"json[k]: {parsed}"
|
|
elif name in ("f_inv", "d_inv"):
|
|
assert abs(r[name] - expected) < 1e-3, f"{name}: {r[name]}"
|
|
elif name == "ts":
|
|
assert isinstance(r[name], str) and r[name], "ts empty"
|
|
else:
|
|
assert r[name] == expected, f"{name}: {r[name]} != {expected}"
|
|
arr_name, _, arr_value_fn = FULL_MATRIX_ARRAY_FIELD
|
|
assert r[arr_name] == arr_value_fn(42), f"{arr_name}: {r[arr_name]}"
|
|
|
|
for name, _vtype, vdim, _idx, metric, _params in FULL_MATRIX_VECTOR_FIELDS:
|
|
query_vec = _full_matrix_query_vec(name, vdim)
|
|
hits = ops.search(
|
|
client,
|
|
coll,
|
|
data=query_vec,
|
|
limit=3,
|
|
anns_field=name,
|
|
output_fields=["id"],
|
|
search_params={"metric_type": metric},
|
|
)[0][0]
|
|
assert len(hits) == 3, f"[{name}] search returned {len(hits)} hits"
|
|
top_hit = hits[0]
|
|
assert top_hit["id"] == 0, f"[{name}] expected exact vector id=0 as top1, got {hits}"
|
|
assert abs(top_hit["distance"]) < 1e-5, f"[{name}] exact vector distance mismatch: {top_hit}"
|
|
|
|
|
|
# Smaller row count keeps full-matrix tests under ~2 min each: each test
|
|
# builds 21 fields with 10+ scalar indexes and 9 vector indexes.
|
|
FULL_MATRIX_NB = 200
|
|
|
|
|
|
def _full_matrix_query_vec(vec_field, dim):
|
|
"""Build a search query vector matching the dtype of the given vector
|
|
field and exactly matching row id=0. pymilvus is dtype-strict on the
|
|
wire: int8 needs np.int8, bf16 needs raw bytes, etc."""
|
|
for name, vtype, vdim, _idx, _metric, _params in FULL_MATRIX_VECTOR_FIELDS:
|
|
if name != vec_field:
|
|
continue
|
|
if vtype == DataType.BINARY_VECTOR:
|
|
return [_binary_vectors_bytes([0], vdim)[0].tobytes()]
|
|
if vtype == DataType.INT8_VECTOR:
|
|
return [_int8_vectors([0], vdim)[0]]
|
|
if vtype == DataType.FLOAT16_VECTOR:
|
|
return [_float16_vectors([0], vdim)[0]]
|
|
if vtype == DataType.BFLOAT16_VECTOR:
|
|
return [_bfloat16_vectors([0], vdim)[0].tobytes()]
|
|
return _float_vectors([0], vdim).tolist()
|
|
raise KeyError(vec_field)
|
|
|
|
|
|
def _full_matrix_assert_basic(ops, client, coll, expected_count, excluded_fields=()):
|
|
"""Shared assertions: count + per-type spot-checks + search hits per
|
|
vector field. Used by every full-matrix format test."""
|
|
assert ops.query_count(client, coll) == expected_count
|
|
excluded = set(excluded_fields)
|
|
|
|
# Spot-check id=42: every scalar field round-trips with the expected
|
|
# value derived from the same value_fn used to build the parquet.
|
|
output_fields = ["id"] + [f for f, _, _, _, _ in FULL_MATRIX_SCALAR_FIELDS if f not in excluded]
|
|
arr_name = FULL_MATRIX_ARRAY_FIELD[0]
|
|
if arr_name not in excluded:
|
|
output_fields.append(arr_name)
|
|
if "geo" not in excluded:
|
|
output_fields.append("geo")
|
|
rows = ops.query(client, coll, filter="id == 42", output_fields=output_fields)[0]
|
|
assert len(rows) == 1, f"id=42 missing: {rows}"
|
|
r = rows[0]
|
|
assert r["id"] == 42
|
|
for name, _dtype, _arrow, _extra, value_fn in FULL_MATRIX_SCALAR_FIELDS:
|
|
if name in excluded:
|
|
continue
|
|
expected = value_fn(42)
|
|
if name == "j":
|
|
parsed = r["j"] if isinstance(r["j"], dict) else json.loads(r["j"])
|
|
assert parsed.get("k") == 42, f"json[k] mismatch: {parsed}"
|
|
elif name == "f_inv":
|
|
assert abs(r[name] - expected) < 1e-3, f"{name}: {r[name]} != {expected}"
|
|
elif name == "d_inv":
|
|
assert abs(r[name] - expected) < 1e-3, f"{name}: {r[name]} != {expected}"
|
|
elif name == "ts":
|
|
# Server returns an ISO-8601 string; we just assert non-empty.
|
|
assert isinstance(r[name], str) and r[name], f"ts empty: {r[name]}"
|
|
else:
|
|
assert r[name] == expected, f"{name}: {r[name]} != {expected}"
|
|
arr_name, _, arr_value_fn = FULL_MATRIX_ARRAY_FIELD
|
|
if arr_name not in excluded:
|
|
assert r[arr_name] == arr_value_fn(42), f"{arr_name}: {r[arr_name]}"
|
|
|
|
# One search per vector field confirms every (type x index x metric)
|
|
# combination produced a usable index.
|
|
for name, _vtype, vdim, _idx, metric, _params in FULL_MATRIX_VECTOR_FIELDS:
|
|
if name in excluded:
|
|
continue
|
|
query_vec = _full_matrix_query_vec(name, vdim)
|
|
hits = ops.search(
|
|
client,
|
|
coll,
|
|
data=query_vec,
|
|
limit=3,
|
|
anns_field=name,
|
|
output_fields=["id"],
|
|
search_params={"metric_type": metric},
|
|
)[0][0]
|
|
assert len(hits) == 3, f"[{name}] search returned {len(hits)} hits"
|
|
top_hit = hits[0]
|
|
assert top_hit["id"] == 0, f"[{name}] expected exact vector id=0 as top1, got {hits}"
|
|
assert abs(top_hit["distance"]) < 1e-5, f"[{name}] exact vector distance mismatch: {top_hit}"
|