|
10 | 10 | // or implied. See the License for the specific language governing permissions and limitations under the License |
11 | 11 |
|
12 | 12 | #include <gtest/gtest.h> |
| 13 | +#include <limits> |
13 | 14 | #include <memory> |
14 | 15 | #include <random> |
15 | 16 | #include <unordered_set> |
|
21 | 22 | #include "index/Index.h" |
22 | 23 | #include "knowhere/comp/index_param.h" |
23 | 24 | #include "query/CachedSearchIterator.h" |
| 25 | +#include "query/SearchBruteForce.h" |
24 | 26 | #include "index/VectorIndex.h" |
25 | 27 | #include "index/IndexFactory.h" |
26 | 28 | #include "knowhere/dataset.h" |
@@ -926,3 +928,172 @@ INSTANTIATE_TEST_SUITE_P( |
926 | 928 | } |
927 | 929 | return constructor_type_str; |
928 | 930 | }); |
| 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 | +} |
0 commit comments