Skip to content

Commit 8850990

Browse files
author
David
committed
feat: search_iterator over emb_list (ArrayOfVector) fields
Enables search_iterator() over emb_list / ArrayOfVector fields (MAX_SIM) via the stateless Iterator-v2 path: - segcore: CachedSearchIterator drives knowhere's emb_list AnnIterator on the sealed vector-index path. - proxy: lifts the rejection of search_iterator over emb_list fields. - brute-force / growing-segment paths (no emb_list iterator yet) now return a clean, typed "not supported" error instead of an internal assertion. Depends on knowhere emb_list/sparse AnnIterator support (knowhere pin bumps on merge). Part of #49906. Signed-off-by: David <david@41zero.com>
1 parent c1438e5 commit 8850990

7 files changed

Lines changed: 246 additions & 31 deletions

File tree

internal/core/src/query/CachedSearchIterator.cpp

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
#include <algorithm>
1313

14+
#include "knowhere/comp/index_param.h"
1415
#include "query/CachedSearchIterator.h"
1516
#include "query/SearchBruteForce.h"
1617

@@ -27,6 +28,21 @@ CachedSearchIterator::CachedSearchIterator(
2728
"Query dataset is nullptr, cannot initialize iterator");
2829
}
2930
nq_ = query_ds->GetRows();
31+
// For an embedding-list (MAX_SIM) query the dataset is a flat run of
32+
// vectors grouped into emb_lists by EMB_LIST_OFFSET; the iterator's unit of
33+
// work is the query emb_list, not the individual vector. knowhere's emblist
34+
// AnnIterator returns one iterator per query emb_list, so nq is the emb_list
35+
// count -- the offset array's index at which it reaches the vector count.
36+
const auto* el_offsets =
37+
query_ds->Get<const size_t*>(knowhere::meta::EMB_LIST_OFFSET);
38+
if (el_offsets != nullptr) {
39+
const auto num_vectors = static_cast<size_t>(query_ds->GetRows());
40+
size_t num_emb_lists = 0;
41+
while (el_offsets[num_emb_lists] < num_vectors) {
42+
++num_emb_lists;
43+
}
44+
nq_ = num_emb_lists;
45+
}
3046
Init(search_info);
3147

3248
auto search_json = index.PrepareSearchParams(search_info);

internal/core/src/query/CachedSearchIteratorTest.cpp

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
// or implied. See the License for the specific language governing permissions and limitations under the License
1111

1212
#include <gtest/gtest.h>
13+
#include <limits>
1314
#include <memory>
1415
#include <random>
1516
#include <unordered_set>
@@ -21,6 +22,7 @@
2122
#include "index/Index.h"
2223
#include "knowhere/comp/index_param.h"
2324
#include "query/CachedSearchIterator.h"
25+
#include "query/SearchBruteForce.h"
2426
#include "index/VectorIndex.h"
2527
#include "index/IndexFactory.h"
2628
#include "knowhere/dataset.h"
@@ -926,3 +928,172 @@ INSTANTIATE_TEST_SUITE_P(
926928
}
927929
return constructor_type_str;
928930
});
931+
932+
// PR 3 — emblist (MAX_SIM) iterator enablement.
933+
// Before the nq_ fix in the sealed-index constructor, an emblist query (whose
934+
// dataset is a flat run of vectors with EMB_LIST_OFFSET) made nq_ = the vector
935+
// count, so CachedSearchIterator::Init threw "Number of queries is greater than
936+
// 1". nq_ is now the query emb_list count, matching the iterators knowhere's
937+
// emblist AnnIterator returns.
938+
TEST(CachedSearchIteratorEmbListTest, EmbListNextBatch) {
939+
constexpr int64_t kElDim = 16;
940+
constexpr int64_t kNumEmbLists = 200;
941+
constexpr int64_t kElBatch = 50;
942+
const MetricType metric = knowhere::metric::MAX_SIM_COSINE;
943+
944+
// variable-length emb_lists (1..5 vectors each) exercise the offset logic
945+
std::vector<size_t> offsets{0};
946+
for (int64_t i = 0; i < kNumEmbLists; ++i) {
947+
offsets.push_back(offsets.back() + static_cast<size_t>(i % 5) + 1);
948+
}
949+
const int64_t total_vectors = static_cast<int64_t>(offsets.back());
950+
951+
std::mt19937 rng(42);
952+
std::uniform_real_distribution<float> dist(-1.0f, 1.0f);
953+
std::vector<float> base(total_vectors * kElDim);
954+
for (auto& v : base) {
955+
v = dist(rng);
956+
}
957+
958+
auto build_ds = knowhere::GenDataSet(total_vectors, kElDim, base.data());
959+
build_ds->Set(knowhere::meta::EMB_LIST_OFFSET,
960+
const_cast<const size_t*>(offsets.data()));
961+
962+
milvus::index::CreateIndexInfo create_index_info;
963+
create_index_info.field_type = DataType::VECTOR_ARRAY;
964+
create_index_info.metric_type = metric;
965+
create_index_info.index_type = knowhere::IndexEnum::INDEX_HNSW;
966+
create_index_info.index_engine_version =
967+
knowhere::Version::GetCurrentVersion().VersionNumber();
968+
// a VECTOR_ARRAY (emb_list) index dispatches on the inner element type,
969+
// which IndexFactory reads from the field schema
970+
milvus::storage::FileManagerContext file_manager_context;
971+
file_manager_context.fieldDataMeta.field_schema.set_data_type(
972+
static_cast<proto::schema::DataType>(DataType::VECTOR_ARRAY));
973+
file_manager_context.fieldDataMeta.field_schema.set_element_type(
974+
static_cast<proto::schema::DataType>(DataType::VECTOR_FLOAT));
975+
auto index = milvus::index::IndexFactory::GetInstance().CreateIndex(
976+
create_index_info, file_manager_context);
977+
auto build_conf = knowhere::Json{
978+
{knowhere::meta::METRIC_TYPE, metric},
979+
{knowhere::meta::DIM, std::to_string(kElDim)},
980+
{knowhere::indexparam::M, std::to_string(24)},
981+
{knowhere::indexparam::EFCONSTRUCTION, std::to_string(360)}};
982+
index->BuildWithDataset(build_ds, build_conf);
983+
auto* vec_index = dynamic_cast<milvus::index::VectorIndex*>(index.get());
984+
ASSERT_NE(vec_index, nullptr);
985+
986+
// a single query emb_list of 4 vectors
987+
std::vector<float> q(4 * kElDim);
988+
for (auto& v : q) {
989+
v = dist(rng);
990+
}
991+
auto query_ds = knowhere::GenDataSet(4, kElDim, q.data());
992+
std::vector<size_t> q_offsets{0, 4};
993+
query_ds->Set(knowhere::meta::EMB_LIST_OFFSET,
994+
const_cast<const size_t*>(q_offsets.data()));
995+
996+
SearchInfo search_info;
997+
search_info.topk_ = kElBatch;
998+
search_info.round_decimal_ = -1;
999+
search_info.metric_type_ = metric;
1000+
search_info.search_params_ = {
1001+
{knowhere::indexparam::EF, std::to_string(128)}};
1002+
search_info.iterator_v2_info_ =
1003+
SearchIteratorV2Info{.batch_size = kElBatch};
1004+
1005+
auto iterator = std::make_unique<CachedSearchIterator>(
1006+
*vec_index, query_ds, search_info, nullptr);
1007+
SearchResult search_result;
1008+
iterator->NextBatch(search_info, search_result);
1009+
1010+
// one query emb_list -> one result row of kElBatch chunk-level results
1011+
EXPECT_EQ(search_result.total_nq_, 1);
1012+
EXPECT_EQ(search_result.seg_offsets_.size(), kElBatch);
1013+
EXPECT_EQ(search_result.distances_.size(), kElBatch);
1014+
1015+
// emitted ids are valid chunk (emb_list) ids, deduplicated, descending score
1016+
std::unordered_set<int64_t> seen;
1017+
float prev = std::numeric_limits<float>::max();
1018+
size_t emitted = 0;
1019+
for (int64_t i = 0; i < kElBatch; ++i) {
1020+
const auto id = search_result.seg_offsets_[i];
1021+
if (id == -1) {
1022+
continue; // batch padding
1023+
}
1024+
EXPECT_GE(id, 0);
1025+
EXPECT_LT(id, kNumEmbLists);
1026+
EXPECT_TRUE(seen.insert(id).second) << "duplicate chunk id " << id;
1027+
EXPECT_LE(search_result.distances_[i], prev); // MAX_SIM: descending
1028+
prev = search_result.distances_[i];
1029+
++emitted;
1030+
}
1031+
EXPECT_GT(emitted, 0u);
1032+
1033+
// a second batch, bounded by the first batch's worst score, must not repeat
1034+
search_info.iterator_v2_info_->last_bound = prev;
1035+
SearchResult second;
1036+
iterator->NextBatch(search_info, second);
1037+
for (int64_t i = 0; i < kElBatch; ++i) {
1038+
const auto id = second.seg_offsets_[i];
1039+
if (id == -1) {
1040+
continue;
1041+
}
1042+
EXPECT_TRUE(seen.insert(id).second)
1043+
<< "chunk id " << id << " repeated across batches";
1044+
}
1045+
}
1046+
1047+
// R9 / #15: emb_list (VECTOR_ARRAY) search_iterator is supported only on the sealed
1048+
// vector-index path (knowhere's emblist AnnIterator, exercised by EmbListNextBatch
1049+
// above). The brute-force / growing-segment iterator path has no emblist support, so
1050+
// it must fail with a clean, typed Unsupported error -- not a bare assertion failure
1051+
// deep in segcore. This guards the graceful behaviour lifted into the proxy by PR 4.
1052+
TEST(CachedSearchIteratorEmbListTest, BruteForceIteratorRejectsEmbListGracefully) {
1053+
constexpr int64_t kDim = 4;
1054+
// one emb_list of two vectors; raw/query data is a flat run of vectors keyed by
1055+
// EMB_LIST_OFFSET (offsets length = num emb_lists + 1).
1056+
std::vector<float> base(2 * kDim, 0.1f);
1057+
std::vector<size_t> base_offsets{0, 2};
1058+
std::vector<float> query(2 * kDim, 0.2f);
1059+
std::vector<size_t> query_offsets{0, 2};
1060+
1061+
dataset::RawDataset raw_ds{
1062+
.dim = kDim,
1063+
.num_raw_data = 1,
1064+
.raw_data = base.data(),
1065+
.raw_data_offsets = base_offsets.data(),
1066+
};
1067+
dataset::SearchDataset query_ds{
1068+
.metric_type = knowhere::metric::MAX_SIM_COSINE,
1069+
.num_queries = 1,
1070+
.topk = 10,
1071+
.round_decimal = -1,
1072+
.dim = kDim,
1073+
.query_data = query.data(),
1074+
.query_offsets = query_offsets.data(),
1075+
};
1076+
SearchInfo search_info{
1077+
.topk_ = 10,
1078+
.round_decimal_ = -1,
1079+
.metric_type_ = knowhere::metric::MAX_SIM_COSINE,
1080+
.iterator_v2_info_ = SearchIteratorV2Info{.batch_size = 10},
1081+
};
1082+
std::map<std::string, std::string> index_info;
1083+
BitsetView bitset;
1084+
1085+
try {
1086+
GetBruteForceSearchIterators(query_ds,
1087+
raw_ds,
1088+
search_info,
1089+
index_info,
1090+
bitset,
1091+
DataType::VECTOR_ARRAY);
1092+
FAIL() << "expected emb_list brute-force search_iterator to be rejected";
1093+
} catch (const SegcoreError& e) {
1094+
EXPECT_EQ(e.get_error_code(), ErrorCode::Unsupported);
1095+
EXPECT_NE(std::string(e.what()).find("brute-force / growing"),
1096+
std::string::npos)
1097+
<< "unexpected error message: " << e.what();
1098+
}
1099+
}

internal/core/src/query/SearchBruteForce.cpp

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -278,8 +278,18 @@ DispatchBruteForceIteratorByDataType(const knowhere::DataSetPtr& base_dataset,
278278
const knowhere::Json& config,
279279
const BitsetView& bitset,
280280
milvus::DataType data_type) {
281-
AssertInfo(data_type != DataType::VECTOR_ARRAY,
282-
"VECTOR_ARRAY is not supported for brute force iterator");
281+
// emb_list (VECTOR_ARRAY) search_iterator is supported only on the sealed
282+
// vector-index path (knowhere's emblist AnnIterator). The brute-force /
283+
// growing-segment iterator path does not support it yet (R9, deferred to v2):
284+
// knowhere's BruteForce::AnnIterator has no emb_list overload. Surface a clean,
285+
// typed Unsupported error here rather than tripping the bare assert below, so a
286+
// search_iterator over a collection with growing / un-indexed segments returns a
287+
// graceful "not supported" instead of an opaque internal assertion failure.
288+
if (data_type == DataType::VECTOR_ARRAY) {
289+
ThrowInfo(ErrorCode::Unsupported,
290+
"search_iterator over emb_list (vector array) fields is not "
291+
"supported on brute-force / growing segments");
292+
}
283293

284294
switch (data_type) {
285295
case DataType::VECTOR_FLOAT:

internal/core/src/query/SearchOnGrowing.cpp

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,9 +189,14 @@ SearchOnGrowing(const segcore::SegmentGrowingImpl& segment,
189189
}
190190

191191
if (info.iterator_v2_info_.has_value()) {
192-
AssertInfo(data_type != DataType::VECTOR_ARRAY,
193-
"vector array(embedding list) is not supported for "
194-
"vector iterator");
192+
// R9: emblist search_iterator is supported only on the sealed
193+
// vector-index path; fail growing segments gracefully (typed
194+
// Unsupported), not via a bare assert deep in segcore.
195+
if (data_type == DataType::VECTOR_ARRAY) {
196+
ThrowInfo(ErrorCode::Unsupported,
197+
"search_iterator over emb_list (vector array) fields "
198+
"is not supported on brute-force / growing segments");
199+
}
195200

196201
CachedSearchIterator cached_iter(search_dataset,
197202
vec_ptr,
@@ -286,9 +291,14 @@ SearchOnGrowing(const segcore::SegmentGrowingImpl& segment,
286291
auto search_data_type =
287292
element_level_search ? element_type : data_type;
288293
if (milvus::exec::UseVectorIterator(info)) {
289-
AssertInfo(search_data_type != DataType::VECTOR_ARRAY,
290-
"vector array(embedding list) is not supported for "
291-
"vector iterator");
294+
// R9: graceful typed failure for the emblist iterator on a
295+
// growing segment (see note above), not a bare assert.
296+
if (search_data_type == DataType::VECTOR_ARRAY) {
297+
ThrowInfo(ErrorCode::Unsupported,
298+
"search_iterator over emb_list (vector array) "
299+
"fields is not supported on brute-force / growing "
300+
"segments");
301+
}
292302

293303
if (buf != nullptr) {
294304
search_result.chunk_buffers_.emplace_back(std::move(buf));

internal/core/src/query/SearchOnSealed.cpp

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,14 @@ SearchOnSealedColumn(const Schema& schema,
200200
}
201201

202202
if (search_info.iterator_v2_info_.has_value()) {
203-
AssertInfo(data_type != DataType::VECTOR_ARRAY,
204-
"vector array(embedding list) is not supported for "
205-
"vector iterator");
203+
// R9: emblist search_iterator is supported only on the sealed
204+
// vector-index path; fail an un-indexed (brute-force) sealed column
205+
// gracefully (typed Unsupported), not via a bare assert.
206+
if (data_type == DataType::VECTOR_ARRAY) {
207+
ThrowInfo(ErrorCode::Unsupported,
208+
"search_iterator over emb_list (vector array) fields is "
209+
"not supported on brute-force / growing segments");
210+
}
206211

207212
CachedSearchIterator cached_iter(column,
208213
query_dataset,
@@ -266,9 +271,13 @@ SearchOnSealedColumn(const Schema& schema,
266271
}
267272

268273
if (use_vector_iterator) {
269-
AssertInfo(data_type != DataType::VECTOR_ARRAY,
270-
"vector array(embedding list) is not supported for "
271-
"vector iterator");
274+
// R9: graceful typed failure for the emblist iterator on a
275+
// brute-force sealed column (see note above), not a bare assert.
276+
if (data_type == DataType::VECTOR_ARRAY) {
277+
ThrowInfo(ErrorCode::Unsupported,
278+
"search_iterator over emb_list (vector array) fields "
279+
"is not supported on brute-force / growing segments");
280+
}
272281
auto sub_qr =
273282
PackBruteForceSearchIteratorsIntoSubResult(query_dataset,
274283
raw_dataset,

internal/proxy/search_util.go

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -310,11 +310,9 @@ func parseSearchInfo(searchParamsPair []*commonpb.KeyValuePair, schema *schemapb
310310
return nil, merr.WrapErrParameterInvalid("", "",
311311
"group by search is not supported for vector array (embedding list) fields, fieldName:", annsFieldName)
312312
}
313-
314-
if isIterator {
315-
return nil, merr.WrapErrParameterInvalid("", "",
316-
"search iterator is not supported for vector array (embedding list) fields, fieldName:", annsFieldName)
317-
}
313+
// search_iterator over an emb_list field is supported via the
314+
// stateless Iterator-v2 path (SPEC 6.3): segcore's CachedSearchIterator
315+
// drives knowhere's emblist AnnIterator -- no proxy-side rejection.
318316
}
319317
}
320318

internal/proxy/task_search_test.go

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3721,7 +3721,9 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
37213721
assert.Contains(t, err.Error(), "embeddings_list")
37223722
})
37233723

3724-
t.Run("vector array with iterator", func(t *testing.T) {
3724+
t.Run("vector array with iterator should succeed", func(t *testing.T) {
3725+
// PR 4: search_iterator over an emb_list field is supported via the
3726+
// stateless Iterator-v2 path; the proxy no longer rejects it.
37253727
schema := createSchemaWithVectorArray("embeddings_list")
37263728
params := createSearchParams("embeddings_list")
37273729

@@ -3732,14 +3734,14 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
37323734
})
37333735

37343736
searchInfo, err := parseSearchInfo(params, schema, nil, false)
3735-
assert.Error(t, err)
3736-
assert.Nil(t, searchInfo)
3737-
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
3738-
assert.Contains(t, err.Error(), "search iterator is not supported for vector array (embedding list) fields")
3739-
assert.Contains(t, err.Error(), "embeddings_list")
3737+
assert.NoError(t, err)
3738+
assert.NotNil(t, searchInfo)
3739+
assert.NotNil(t, searchInfo.planInfo)
3740+
assert.True(t, searchInfo.isIterator)
37403741
})
37413742

3742-
t.Run("vector array with iterator v2", func(t *testing.T) {
3743+
t.Run("vector array with iterator v2 should succeed", func(t *testing.T) {
3744+
// PR 4: emb_list + Iterator v2 reaches segcore's CachedSearchIterator.
37433745
schema := createSchemaWithVectorArray("embeddings_list")
37443746
params := createSearchParams("embeddings_list")
37453747

@@ -3760,11 +3762,10 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
37603762
)
37613763

37623764
searchInfo, err := parseSearchInfo(params, schema, nil, false)
3763-
assert.Error(t, err)
3764-
assert.Nil(t, searchInfo)
3765-
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
3766-
assert.Contains(t, err.Error(), "search iterator is not supported for vector array (embedding list) fields")
3767-
assert.Contains(t, err.Error(), "embeddings_list")
3765+
assert.NoError(t, err)
3766+
assert.NotNil(t, searchInfo)
3767+
assert.NotNil(t, searchInfo.planInfo)
3768+
assert.NotNil(t, searchInfo.planInfo.SearchIteratorV2Info)
37683769
})
37693770

37703771
t.Run("normal search on vector array should succeed", func(t *testing.T) {

0 commit comments

Comments
 (0)