mirror of
https://github.com/milvus-io/milvus.git
synced 2026-07-21 02:05:41 +00:00
fix: skip index raw data for array output (#51109)
issue: https://github.com/milvus-io/milvus/issues/51033 ref: https://github.com/milvus-io/milvus/issues/42148 --------- Signed-off-by: SpadeA <tangchenjie1210@gmail.com> Co-authored-by: zhuwenxing <wxzhuyeah@gmail.com>
This commit is contained in:
@@ -40,6 +40,7 @@
|
||||
#include "index/Meta.h"
|
||||
#include "index/ScalarIndex.h"
|
||||
#include "index/ScalarIndexSort.h"
|
||||
#include "index/StringIndexSort.h"
|
||||
#include "common/ArrayOffsets.h"
|
||||
#include "indexbuilder/IndexCreatorBase.h"
|
||||
#include "indexbuilder/IndexFactory.h"
|
||||
@@ -1419,6 +1420,43 @@ TEST(ScalarIndexSortArrayNestedTest, NestedBuildByteSizeAndQuery) {
|
||||
boost::filesystem::remove_all(root_path);
|
||||
}
|
||||
|
||||
TEST(ScalarIndexSortArrayNestedTest, ArraySortIndexDoesNotExposeRawArrayData) {
|
||||
auto numeric_root_path =
|
||||
fmt::format("{}/stlsort_array_has_raw_data", TestLocalPath);
|
||||
auto numeric_ctx = MakeNestedCtx(
|
||||
numeric_root_path, proto::schema::DataType::Int32, true, 3121);
|
||||
auto numeric_index =
|
||||
std::make_unique<index::ScalarIndexSort<int32_t>>(numeric_ctx, true);
|
||||
EXPECT_TRUE(numeric_index->IsNestedIndex());
|
||||
EXPECT_FALSE(numeric_index->HasRawData());
|
||||
auto numeric_index_from_schema =
|
||||
std::make_unique<index::ScalarIndexSort<int32_t>>(numeric_ctx, false);
|
||||
EXPECT_FALSE(numeric_index_from_schema->IsNestedIndex());
|
||||
EXPECT_FALSE(numeric_index_from_schema->HasRawData());
|
||||
|
||||
auto string_root_path =
|
||||
fmt::format("{}/stringsort_array_has_raw_data", TestLocalPath);
|
||||
auto string_ctx = MakeNestedCtx(
|
||||
string_root_path, proto::schema::DataType::VarChar, true, 3122);
|
||||
auto string_index =
|
||||
std::make_unique<index::StringIndexSort>(string_ctx, true);
|
||||
EXPECT_TRUE(string_index->IsNestedIndex());
|
||||
EXPECT_FALSE(string_index->HasRawData());
|
||||
auto string_index_from_schema =
|
||||
std::make_unique<index::StringIndexSort>(string_ctx, false);
|
||||
EXPECT_FALSE(string_index_from_schema->IsNestedIndex());
|
||||
EXPECT_FALSE(string_index_from_schema->HasRawData());
|
||||
|
||||
std::map<std::string, std::string> index_params{
|
||||
{index::INDEX_TYPE, index::ASCENDING_SORT}};
|
||||
auto request = index::IndexFactory::GetInstance().ScalarIndexLoadResource(
|
||||
DataType::ARRAY, 0, 1024, index_params, false, 10);
|
||||
EXPECT_FALSE(request.has_raw_data);
|
||||
|
||||
boost::filesystem::remove_all(numeric_root_path);
|
||||
boost::filesystem::remove_all(string_root_path);
|
||||
}
|
||||
|
||||
// Bug #4: ArrayOffsetsSealed::BuildAllZeros is used in the add-field /
|
||||
// schema-evolution path to materialize empty (all-zeros) offsets for old rows.
|
||||
// It must charge the caching layer so the destructor's refund is balanced, and
|
||||
|
||||
@@ -212,6 +212,12 @@ ResolveHybridInternalIndexType(
|
||||
|
||||
} // namespace
|
||||
|
||||
bool
|
||||
IndexFactory::CanUseIndexRawDataForField(DataType field_type,
|
||||
bool has_raw_data) {
|
||||
return has_raw_data && field_type != DataType::ARRAY;
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
ScalarIndexPtr<T>
|
||||
IndexFactory::CreatePrimitiveScalarIndex(
|
||||
@@ -656,6 +662,8 @@ IndexFactory::ScalarIndexLoadResource(
|
||||
index_type);
|
||||
return LoadResourceRequest{0, 0, 0, 0, false};
|
||||
}
|
||||
request.has_raw_data =
|
||||
CanUseIndexRawDataForField(field_type, request.has_raw_data);
|
||||
return request;
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,9 @@ class IndexFactory {
|
||||
return instance;
|
||||
}
|
||||
|
||||
static bool
|
||||
CanUseIndexRawDataForField(DataType field_type, bool has_raw_data);
|
||||
|
||||
LoadResourceRequest
|
||||
IndexLoadResource(DataType field_type,
|
||||
DataType element_type,
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
#include "log/Log.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
#include "pb/common.pb.h"
|
||||
#include "pb/schema.pb.h"
|
||||
#include "storage/DiskFileManagerImpl.h"
|
||||
#include "storage/FileWriter.h"
|
||||
#include "storage/IndexEntryReader.h"
|
||||
@@ -66,12 +67,24 @@ constexpr size_t ALIGNMENT = 32; // 32-byte alignment
|
||||
|
||||
const uint64_t MMAP_INDEX_PADDING = 1;
|
||||
|
||||
namespace {
|
||||
|
||||
bool
|
||||
IsArrayField(const storage::FileManagerContext& file_manager_context) {
|
||||
return file_manager_context.Valid() &&
|
||||
file_manager_context.fieldDataMeta.field_schema.data_type() ==
|
||||
proto::schema::DataType::Array;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
template <typename T>
|
||||
ScalarIndexSort<T>::ScalarIndexSort(
|
||||
const storage::FileManagerContext& file_manager_context,
|
||||
bool is_nested_index)
|
||||
: ScalarIndex<T>(ASCENDING_SORT),
|
||||
is_nested_index_(is_nested_index),
|
||||
is_array_field_(IsArrayField(file_manager_context)),
|
||||
is_built_(false),
|
||||
data_() {
|
||||
// not valid means we are in unit test
|
||||
@@ -358,9 +371,11 @@ ScalarIndexSort<T>::LoadWithoutAssemble(const BinarySet& index_binary,
|
||||
|
||||
auto is_nested_index = index_binary.GetByName("is_nested_index");
|
||||
if (is_nested_index) {
|
||||
milvus::fastmem::FastMemcpy(&is_nested_index_,
|
||||
bool loaded_is_nested_index = false;
|
||||
milvus::fastmem::FastMemcpy(&loaded_is_nested_index,
|
||||
is_nested_index->data.get(),
|
||||
(size_t)is_nested_index->size);
|
||||
is_nested_index_ = is_nested_index_ || loaded_is_nested_index;
|
||||
}
|
||||
|
||||
is_mmap_ = GetValueFromConfig<bool>(config, ENABLE_MMAP).value_or(true);
|
||||
@@ -701,7 +716,7 @@ ScalarIndexSort<T>::LoadEntries(storage::IndexEntryReader& reader,
|
||||
const Config& config) {
|
||||
size_t index_size = reader.GetMeta<size_t>("index_length");
|
||||
total_num_rows_ = reader.GetMeta<size_t>("num_rows");
|
||||
is_nested_index_ = reader.GetMeta<bool>("is_nested");
|
||||
is_nested_index_ = is_nested_index_ || reader.GetMeta<bool>("is_nested");
|
||||
|
||||
is_mmap_ = GetValueFromConfig<bool>(config, ENABLE_MMAP).value_or(true);
|
||||
|
||||
|
||||
@@ -166,7 +166,7 @@ class ScalarIndexSort : public ScalarIndex<T> {
|
||||
|
||||
const bool
|
||||
HasRawData() const override {
|
||||
return true;
|
||||
return !is_nested_index_ && !is_array_field_;
|
||||
}
|
||||
|
||||
void
|
||||
@@ -253,6 +253,7 @@ class ScalarIndexSort : public ScalarIndex<T> {
|
||||
int64_t field_id_ = 0;
|
||||
|
||||
bool is_nested_index_ = false;
|
||||
bool is_array_field_ = false;
|
||||
bool is_built_ = false;
|
||||
Config config_;
|
||||
// idx_to_offsets: maps row_id → sorted offset.
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
#include "log/Log.h"
|
||||
#include "nlohmann/json.hpp"
|
||||
#include "pb/common.pb.h"
|
||||
#include "pb/schema.pb.h"
|
||||
#include "storage/FileWriter.h"
|
||||
#include "storage/IndexEntryReader.h"
|
||||
#include "storage/IndexEntryWriter.h"
|
||||
@@ -73,6 +74,13 @@ SetIdxToOffset(std::vector<int32_t>& idx_to_offsets,
|
||||
idx_to_offsets[row_id] = unique_idx;
|
||||
}
|
||||
|
||||
bool
|
||||
IsArrayField(const storage::FileManagerContext& file_manager_context) {
|
||||
return file_manager_context.Valid() &&
|
||||
file_manager_context.fieldDataMeta.field_schema.data_type() ==
|
||||
proto::schema::DataType::Array;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
StringIndexSortImpl::ParsedData
|
||||
@@ -141,7 +149,8 @@ StringIndexSort::StringIndexSort(
|
||||
bool is_nested_index)
|
||||
: StringIndex(ASCENDING_SORT),
|
||||
is_built_(false),
|
||||
is_nested_index_(is_nested_index) {
|
||||
is_nested_index_(is_nested_index),
|
||||
is_array_field_(IsArrayField(file_manager_context)) {
|
||||
if (file_manager_context.Valid()) {
|
||||
field_id_ = file_manager_context.fieldDataMeta.field_id;
|
||||
this->file_manager_ =
|
||||
@@ -390,8 +399,10 @@ StringIndexSort::LoadWithoutAssemble(const BinarySet& binary_set,
|
||||
// Deserialize is_nested_index (optional for backward compatibility)
|
||||
auto is_nested_data = binary_set.GetByName("is_nested_index");
|
||||
if (is_nested_data != nullptr) {
|
||||
bool loaded_is_nested_index = false;
|
||||
milvus::fastmem::FastMemcpy(
|
||||
&is_nested_index_, is_nested_data->data.get(), sizeof(bool));
|
||||
&loaded_is_nested_index, is_nested_data->data.get(), sizeof(bool));
|
||||
is_nested_index_ = is_nested_index_ || loaded_is_nested_index;
|
||||
}
|
||||
|
||||
auto version_data = binary_set.GetByName("version");
|
||||
@@ -609,7 +620,7 @@ StringIndexSort::LoadEntries(storage::IndexEntryReader& reader,
|
||||
SERIALIZATION_VERSION));
|
||||
}
|
||||
total_num_rows_ = reader.GetMeta<size_t>("num_rows");
|
||||
is_nested_index_ = reader.GetMeta<bool>("is_nested");
|
||||
is_nested_index_ = is_nested_index_ || reader.GetMeta<bool>("is_nested");
|
||||
|
||||
// valid_bitset is small (num_rows/8 bytes), keep as ReadEntry
|
||||
auto valid_bitset_entry = reader.ReadEntry("valid_bitset");
|
||||
|
||||
@@ -63,7 +63,7 @@ class StringIndexSort : public StringIndex {
|
||||
|
||||
const bool
|
||||
HasRawData() const override {
|
||||
return true;
|
||||
return !is_nested_index_ && !is_array_field_;
|
||||
}
|
||||
|
||||
void
|
||||
@@ -175,6 +175,7 @@ class StringIndexSort : public StringIndex {
|
||||
std::unique_ptr<StringIndexSortImpl> impl_;
|
||||
|
||||
bool is_nested_index_ = false;
|
||||
bool is_array_field_ = false;
|
||||
|
||||
// for mmap: idx_to_offsets meta file
|
||||
char* mmap_meta_data_ = nullptr;
|
||||
@@ -572,4 +573,4 @@ CreateStringIndexSort(const storage::FileManagerContext& file_manager_context =
|
||||
is_nested_index);
|
||||
}
|
||||
|
||||
} // namespace milvus::index
|
||||
} // namespace milvus::index
|
||||
|
||||
@@ -825,6 +825,9 @@ ChunkedSegmentSealedImpl::LoadScalarIndex(LoadIndexInfo& info,
|
||||
num_rows_.value_or(0));
|
||||
}
|
||||
|
||||
request.has_raw_data =
|
||||
milvus::index::IndexFactory::CanUseIndexRawDataForField(
|
||||
field_meta.get_data_type(), request.has_raw_data);
|
||||
// Note: raw data lifecycle (eviction/drop) is handled by LoadDiff + ApplyLoadDiff,
|
||||
// not here. This avoids unsafe ManualEvictCache on column groups.
|
||||
LOG_INFO(
|
||||
|
||||
@@ -145,7 +145,9 @@ SegmentLoadInfo::ConvertFieldIndexInfoToLoadIndexInfo(
|
||||
bool
|
||||
SegmentLoadInfo::CheckIndexHasRawData(const LoadIndexInfo& load_index_info) {
|
||||
if (load_index_info.load_resource_request.has_value()) {
|
||||
return load_index_info.load_resource_request->has_raw_data;
|
||||
return milvus::index::IndexFactory::CanUseIndexRawDataForField(
|
||||
load_index_info.field_type,
|
||||
load_index_info.load_resource_request->has_raw_data);
|
||||
} else {
|
||||
auto request =
|
||||
milvus::index::IndexFactory::GetInstance().IndexLoadResource(
|
||||
@@ -157,7 +159,8 @@ SegmentLoadInfo::CheckIndexHasRawData(const LoadIndexInfo& load_index_info) {
|
||||
load_index_info.enable_mmap,
|
||||
load_index_info.num_rows,
|
||||
load_index_info.dim);
|
||||
return request.has_raw_data;
|
||||
return milvus::index::IndexFactory::CanUseIndexRawDataForField(
|
||||
load_index_info.field_type, request.has_raw_data);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1636,6 +1636,43 @@ TEST(Sealed, LoadArrayFieldData) {
|
||||
ASSERT_EQ(result_count, N);
|
||||
}
|
||||
|
||||
TEST(Sealed, LoadArrayFieldDataWhenIndexHasRawData) {
|
||||
auto dim = 4;
|
||||
auto N = 10;
|
||||
auto metric_type = knowhere::metric::L2;
|
||||
auto schema = std::make_shared<Schema>();
|
||||
schema->AddDebugField("fakevec", DataType::VECTOR_FLOAT, dim, metric_type);
|
||||
auto counter_id = schema->AddDebugField("counter", DataType::INT64);
|
||||
auto array_id =
|
||||
schema->AddDebugField("array", DataType::ARRAY, DataType::INT64);
|
||||
schema->set_primary_field_id(counter_id);
|
||||
|
||||
auto dataset = DataGen(schema, N);
|
||||
auto segment = CreateSealedWithFieldDataLoaded(schema, dataset);
|
||||
|
||||
auto counter_data = dataset.get_col<int64_t>(counter_id);
|
||||
auto indexing = GenScalarIndexing<int64_t>(N, counter_data.data());
|
||||
|
||||
LoadIndexInfo array_index;
|
||||
array_index.field_id = array_id.get();
|
||||
array_index.field_type = DataType::ARRAY;
|
||||
array_index.element_type = DataType::INT64;
|
||||
array_index.index_params = GenIndexParams(indexing.get());
|
||||
LoadResourceRequest request{};
|
||||
request.has_raw_data = true;
|
||||
array_index.load_resource_request = request;
|
||||
array_index.cache_index =
|
||||
CreateTestCacheIndex("array_raw_index", std::move(indexing));
|
||||
segment->LoadIndex(array_index);
|
||||
|
||||
auto ids_ds = GenRandomIds(N);
|
||||
auto s = dynamic_cast<ChunkedSegmentSealedImpl*>(segment.get());
|
||||
auto array_result =
|
||||
s->bulk_subscript(nullptr, array_id, ids_ds->GetIds(), N);
|
||||
ASSERT_EQ(array_result->type(), proto::schema::DataType::Array);
|
||||
ASSERT_EQ(array_result->scalars().array_data().data_size(), N);
|
||||
}
|
||||
|
||||
TEST(Sealed, LoadArrayFieldDataWithMMap) {
|
||||
auto dim = 4;
|
||||
auto N = ROW_COUNT;
|
||||
|
||||
@@ -3805,6 +3805,124 @@ class TestMilvusClientStructArrayQuery(TestMilvusClientV2Base):
|
||||
assert "clips" in result
|
||||
assert "normal_vector" not in result
|
||||
|
||||
@pytest.mark.tags(CaseLabel.L0)
|
||||
def test_query_struct_array_parent_output_with_diskann_index(self):
|
||||
"""
|
||||
target: test query parent struct array output when vector sub-field uses DISKANN
|
||||
method: build DISKANN on array_struct[float_vector], query id-only, then query id + array_struct
|
||||
expected: both queries succeed and parent struct array output is returned
|
||||
"""
|
||||
collection_name = cf.gen_unique_str(f"{prefix}_query_diskann_output")
|
||||
|
||||
client = self._client()
|
||||
dim = 32
|
||||
|
||||
schema = client.create_schema(auto_id=False, enable_dynamic_field=False)
|
||||
schema.add_field("id", DataType.INT64, is_primary=True)
|
||||
schema.add_field("vector", DataType.FLOAT_VECTOR, dim=dim)
|
||||
|
||||
struct_schema = client.create_struct_field_schema()
|
||||
struct_schema.add_field("name", DataType.VARCHAR, max_length=64)
|
||||
struct_schema.add_field("age", DataType.INT64)
|
||||
struct_schema.add_field("float_vector", DataType.FLOAT_VECTOR, dim=dim)
|
||||
struct_schema.add_field("nested_float_vector", DataType.FLOAT_VECTOR, dim=dim)
|
||||
schema.add_field(
|
||||
"array_struct",
|
||||
datatype=DataType.ARRAY,
|
||||
element_type=DataType.STRUCT,
|
||||
struct_schema=struct_schema,
|
||||
max_capacity=4,
|
||||
)
|
||||
|
||||
res, check = self.create_collection(client, collection_name, schema=schema)
|
||||
assert check
|
||||
|
||||
rows = []
|
||||
for i in range(3000):
|
||||
rows.append(
|
||||
{
|
||||
"id": i,
|
||||
"vector": [random.random() for _ in range(dim)],
|
||||
"array_struct": [
|
||||
{
|
||||
"name": f"n{i}",
|
||||
"age": i % 100,
|
||||
"float_vector": [random.random() for _ in range(dim)],
|
||||
"nested_float_vector": [random.random() for _ in range(dim)],
|
||||
},
|
||||
{
|
||||
"name": f"n{i}_alt",
|
||||
"age": (i + 1) % 100,
|
||||
"float_vector": [random.random() for _ in range(dim)],
|
||||
"nested_float_vector": [random.random() for _ in range(dim)],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
res, check = self.insert(client, collection_name, rows)
|
||||
assert check
|
||||
assert res["insert_count"] == len(rows)
|
||||
res, check = self.flush(client, collection_name)
|
||||
assert check
|
||||
|
||||
index_params = client.prepare_index_params()
|
||||
index_params.add_index(
|
||||
field_name="vector",
|
||||
index_type="HNSW",
|
||||
metric_type="COSINE",
|
||||
params={"M": 8, "efConstruction": 64},
|
||||
)
|
||||
index_params.add_index(
|
||||
field_name="array_struct[float_vector]",
|
||||
index_type="DISKANN",
|
||||
metric_type="MAX_SIM_COSINE",
|
||||
params={},
|
||||
)
|
||||
index_params.add_index(
|
||||
field_name="array_struct[nested_float_vector]",
|
||||
index_type="HNSW",
|
||||
metric_type="COSINE",
|
||||
params={"M": 8, "efConstruction": 64},
|
||||
)
|
||||
index_params.add_index(field_name="array_struct[name]", index_type="INVERTED")
|
||||
index_params.add_index(field_name="array_struct[age]", index_type="STL_SORT")
|
||||
res, check = self.create_index(
|
||||
client,
|
||||
collection_name,
|
||||
index_params,
|
||||
timeout=300,
|
||||
)
|
||||
assert check
|
||||
res, check = self.load_collection(client, collection_name, timeout=300)
|
||||
assert check
|
||||
|
||||
filter_expr = "MATCH_ANY(array_struct, $[age] >= 0)"
|
||||
id_only_results, check = self.query(
|
||||
client,
|
||||
collection_name,
|
||||
filter=filter_expr,
|
||||
output_fields=["id"],
|
||||
limit=3,
|
||||
)
|
||||
assert check
|
||||
assert len(id_only_results) > 0
|
||||
assert all(set(row) == {"id"} for row in id_only_results)
|
||||
|
||||
struct_results, check = self.query(
|
||||
client,
|
||||
collection_name,
|
||||
filter=filter_expr,
|
||||
output_fields=["id", "array_struct"],
|
||||
limit=3,
|
||||
)
|
||||
assert check
|
||||
assert len(struct_results) > 0
|
||||
for row in struct_results:
|
||||
assert "id" in row
|
||||
assert "array_struct" in row
|
||||
assert isinstance(row["array_struct"], list)
|
||||
assert len(row["array_struct"]) > 0
|
||||
|
||||
@pytest.mark.tags(CaseLabel.L1)
|
||||
def test_query_with_expr_filter(self):
|
||||
"""
|
||||
|
||||
@@ -616,6 +616,81 @@ class TestMilvusClientStructArrayElementQuery(TestMilvusClientV2Base):
|
||||
assert check
|
||||
assert len(results) > 0
|
||||
|
||||
@pytest.mark.tags(CaseLabel.L0)
|
||||
def test_match_query_parent_output_with_diskann_index(self):
|
||||
"""
|
||||
target: query parent struct array output when element-level vector sub-field uses DISKANN
|
||||
method: build DISKANN on structA[embedding], query id-only, then query id + structA with MATCH_ANY
|
||||
expected: both queries succeed and parent struct array output is returned
|
||||
"""
|
||||
client = self._client()
|
||||
collection_name = cf.gen_unique_str(f"{prefix}_efq_diskann_output")
|
||||
|
||||
schema = self._create_schema(client)
|
||||
res, check = self.create_collection(client, collection_name, schema=schema)
|
||||
assert check
|
||||
|
||||
data = []
|
||||
for start in range(0, default_nb, insert_batch_size):
|
||||
batch = self._generate_data(nb=insert_batch_size, start_id=start)
|
||||
res, check = self.insert(client, collection_name, batch)
|
||||
assert check
|
||||
data.extend(batch)
|
||||
self.flush(client, collection_name)
|
||||
|
||||
index_params = client.prepare_index_params()
|
||||
index_params.add_index(
|
||||
field_name="normal_vector",
|
||||
index_type="HNSW",
|
||||
metric_type="COSINE",
|
||||
params=INDEX_PARAMS,
|
||||
)
|
||||
index_params.add_index(
|
||||
field_name="structA[embedding]",
|
||||
index_type="DISKANN",
|
||||
metric_type="COSINE",
|
||||
params={},
|
||||
)
|
||||
res, check = self.create_index(client, collection_name, index_params)
|
||||
assert check
|
||||
assert self.wait_for_index_ready(client, collection_name, "structA[embedding]", timeout=300)
|
||||
|
||||
self.load_collection(client, collection_name)
|
||||
|
||||
filter_expr = "MATCH_ANY(structA, $[int_val] >= 0)"
|
||||
id_only_results, check = self.query(
|
||||
client,
|
||||
collection_name,
|
||||
filter=filter_expr,
|
||||
output_fields=["id"],
|
||||
limit=5,
|
||||
)
|
||||
assert check
|
||||
assert len(id_only_results) > 0
|
||||
assert all(set(row) == {"id"} for row in id_only_results)
|
||||
|
||||
parent_results, check = self.query(
|
||||
client,
|
||||
collection_name,
|
||||
filter=filter_expr,
|
||||
output_fields=["id", "structA"],
|
||||
limit=5,
|
||||
)
|
||||
assert check
|
||||
assert len(parent_results) > 0
|
||||
source_by_id = {row["id"]: row for row in data}
|
||||
for row in parent_results:
|
||||
assert "id" in row
|
||||
assert "structA" in row
|
||||
expected_struct = source_by_id[row["id"]]["structA"]
|
||||
assert len(row["structA"]) == len(expected_struct)
|
||||
for actual_element, expected_element in zip(row["structA"], expected_struct):
|
||||
assert actual_element["int_val"] == expected_element["int_val"]
|
||||
assert actual_element["str_val"] == expected_element["str_val"]
|
||||
assert actual_element["color"] == expected_element["color"]
|
||||
assert "embedding" in actual_element
|
||||
assert len(actual_element["embedding"]) == default_dim
|
||||
|
||||
# ---- 2.5 MATCH family in query (via element_filter) ----
|
||||
|
||||
@pytest.mark.tags(CaseLabel.L1)
|
||||
|
||||
@@ -2295,6 +2295,113 @@ class TestMilvusClientStructArraySchemaEvolution(TestMilvusClientV2Base):
|
||||
entity = search_entity(hit)
|
||||
assert_profile_equal(entity[STRUCT_FIELD], expected[STRUCT_FIELD])
|
||||
|
||||
@pytest.mark.tags(CaseLabel.L0)
|
||||
def test_create_struct_array_field_with_vector_diskann_parent_output(self):
|
||||
"""
|
||||
target: test sealed nullable struct array parent output when vector sub-field uses DISKANN
|
||||
method: create a nullable struct array with a vector sub-field, flush enough rows for DISKANN, then query
|
||||
id-only and id + parent struct array with the same MATCH_ANY filter
|
||||
expected: both queries succeed and parent struct array output matches source rows
|
||||
"""
|
||||
collection_name = cf.gen_unique_str(f"{prefix}_create_struct_vector_diskann_output")
|
||||
client = self._client()
|
||||
|
||||
schema = gen_schema(self, client, auto_id=False, enable_dynamic_field=False)
|
||||
schema.add_field(field_name=PK_FIELD, datatype=PK_TYPE, is_primary=True)
|
||||
schema.add_field(field_name=VECTOR_FIELD, datatype=VECTOR_TYPE, dim=VECTOR_DIM)
|
||||
schema.add_field(field_name=TAG_FIELD, datatype=TAG_TYPE, max_length=TAG_MAX_LENGTH)
|
||||
|
||||
profile_schema = gen_struct_schema(self, client)
|
||||
profile_schema.add_field(INT_SUBFIELD, INT_SUBFIELD_TYPE)
|
||||
profile_schema.add_field(TAG_SUBFIELD, TAG_SUBFIELD_TYPE, max_length=TAG_MAX_LENGTH)
|
||||
profile_schema.add_field(VECTOR_SUBFIELD, VECTOR_SUBFIELD_TYPE, dim=VECTOR_DIM)
|
||||
schema.add_field(
|
||||
STRUCT_FIELD,
|
||||
datatype=STRUCT_TYPE,
|
||||
element_type=STRUCT_ELEMENT_TYPE,
|
||||
struct_schema=profile_schema,
|
||||
max_capacity=STRUCT_MAX_CAPACITY,
|
||||
nullable=True,
|
||||
)
|
||||
|
||||
res, _ = self.create_collection(client, collection_name, schema=schema)
|
||||
|
||||
missing_profile_row = {PK_FIELD: 0, VECTOR_FIELD: gen_vector(0), TAG_FIELD: "missing_profile"}
|
||||
empty_profile_row = {
|
||||
PK_FIELD: 1,
|
||||
VECTOR_FIELD: gen_vector(1),
|
||||
TAG_FIELD: "empty_profile",
|
||||
STRUCT_FIELD: [],
|
||||
}
|
||||
non_empty_rows = [
|
||||
{
|
||||
PK_FIELD: 2,
|
||||
VECTOR_FIELD: gen_vector(2),
|
||||
TAG_FIELD: "present_profile_2",
|
||||
STRUCT_FIELD: gen_profile(2),
|
||||
},
|
||||
{
|
||||
PK_FIELD: 3,
|
||||
VECTOR_FIELD: gen_vector(3),
|
||||
TAG_FIELD: "present_profile_3",
|
||||
STRUCT_FIELD: gen_profile(3),
|
||||
},
|
||||
]
|
||||
control_rows = [missing_profile_row, empty_profile_row, *non_empty_rows]
|
||||
filler_rows = gen_vector_index_filler_rows(
|
||||
10000,
|
||||
self.min_index_sealed_rows - len(control_rows),
|
||||
"sealed_diskann_output_filler",
|
||||
vector_dim=VECTOR_DIM,
|
||||
)
|
||||
sealed_rows = control_rows + filler_rows
|
||||
res, _ = self.insert(client, collection_name, sealed_rows)
|
||||
assert res["insert_count"] == len(sealed_rows)
|
||||
|
||||
res, _ = self.flush(client, collection_name)
|
||||
|
||||
index_params = gen_index_params(self, client)
|
||||
index_params.add_index(
|
||||
field_name=VECTOR_FIELD,
|
||||
index_type=NORMAL_VECTOR_INDEX_TYPE,
|
||||
metric_type=NORMAL_VECTOR_METRIC_TYPE,
|
||||
)
|
||||
index_params.add_index(
|
||||
field_name=STRUCT_VECTOR_FIELD,
|
||||
index_type=STRUCT_VECTOR_DISKANN_INDEX_TYPE,
|
||||
metric_type=STRUCT_VECTOR_DISKANN_METRIC_TYPE,
|
||||
params=STRUCT_VECTOR_DISKANN_INDEX_PARAMS,
|
||||
)
|
||||
res, _ = self.create_index(client, collection_name, index_params)
|
||||
assert self.wait_for_index_ready(client, collection_name, STRUCT_VECTOR_FIELD, timeout=300)
|
||||
|
||||
res, _ = self.load_collection(client, collection_name)
|
||||
|
||||
source_by_id = {row[PK_FIELD]: row for row in non_empty_rows}
|
||||
filter_expr = f"MATCH_ANY({STRUCT_FIELD}, $[{INT_SUBFIELD}] >= 0)"
|
||||
id_only_results, _ = self.query(
|
||||
client,
|
||||
collection_name,
|
||||
filter=filter_expr,
|
||||
output_fields=[PK_FIELD],
|
||||
limit=len(source_by_id),
|
||||
)
|
||||
assert {row[PK_FIELD] for row in id_only_results} == set(source_by_id)
|
||||
assert all(set(row) == {PK_FIELD} for row in id_only_results)
|
||||
|
||||
parent_results, _ = self.query(
|
||||
client,
|
||||
collection_name,
|
||||
filter=filter_expr,
|
||||
output_fields=[PK_FIELD, TAG_FIELD, STRUCT_FIELD],
|
||||
limit=len(source_by_id),
|
||||
)
|
||||
assert {row[PK_FIELD] for row in parent_results} == set(source_by_id)
|
||||
for row in parent_results:
|
||||
expected = source_by_id[row[PK_FIELD]]
|
||||
assert row[TAG_FIELD] == expected[TAG_FIELD]
|
||||
assert_profile_equal(row[STRUCT_FIELD], expected[STRUCT_FIELD])
|
||||
|
||||
@pytest.mark.tags(CaseLabel.L0)
|
||||
def test_create_struct_array_field_with_vector_single_element_after_empty_sealed_output(self):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user