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:
Spade A
2026-07-08 11:22:33 +08:00
committed by GitHub
co-authored by zhuwenxing
parent 4955fcd73e
commit b1bbaafc88
13 changed files with 430 additions and 10 deletions
@@ -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
+8
View File
@@ -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;
}
+3
View File
@@ -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,
+17 -2
View File
@@ -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);
+2 -1
View File
@@ -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.
+14 -3
View File
@@ -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");
+3 -2
View File
@@ -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);
}
}
+37
View File
@@ -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):
"""