feat: impl StructArray --- support range search and iterator search for element-level (#49182)

issue: https://github.com/milvus-io/milvus/issues/42148
design doc:
https://github.com/milvus-io/milvus-design-docs/blob/main/design_docs/20260306-struct.md

---------

Signed-off-by: SpadeA <tangchenjie1210@gmail.com>
This commit is contained in:
Spade A
2026-04-22 10:17:44 +08:00
committed by GitHub
parent 22a6b0bb78
commit a55019d504
26 changed files with 3020 additions and 1628 deletions
+7 -28
View File
@@ -30,7 +30,6 @@
#include "common/FieldMeta.h"
#include "common/ArrayOffsets.h"
#include "common/OffsetMapping.h"
#include "query/Utils.h"
#include "pb/schema.pb.h"
#include "knowhere/index/index_node.h"
@@ -157,11 +156,12 @@ class VectorIterator {
// returning results in distance-sorted order.
class ChunkMergeIterator : public VectorIterator {
public:
// Pass nullptr for VECTOR_ARRAY element-level search: the iterator
// returns element IDs, which will be processed by IArrayOffsets.
ChunkMergeIterator(int chunk_count,
const milvus::OffsetMapping& offset_mapping,
const std::vector<int64_t>& total_rows_until_chunk = {},
const milvus::OffsetMapping* offset_mapping,
bool larger_is_closer = false)
: offset_mapping_(&offset_mapping),
: offset_mapping_(offset_mapping),
heap_(OffsetDisPairComparator(larger_is_closer)) {
iterators_.reserve(chunk_count);
}
@@ -214,22 +214,6 @@ class ChunkMergeIterator : public VectorIterator {
}
}
private:
int64_t
convert_to_segment_offset(int64_t chunk_offset, int chunk_idx) {
if (total_rows_until_chunk_.size() == 0) {
AssertInfo(
iterators_.size() == 1,
"Wrong state for vectorIterators, which having incorrect "
"kw_iterator count:{} "
"without setting value for chunk_rows, "
"cannot convert chunk_offset to segment_offset correctly",
iterators_.size());
return chunk_offset;
}
return total_rows_until_chunk_[chunk_idx] + chunk_offset;
}
private:
std::vector<knowhere::IndexNode::IteratorPtr> iterators_;
std::priority_queue<std::shared_ptr<OffsetDisPair>,
@@ -238,7 +222,6 @@ class ChunkMergeIterator : public VectorIterator {
heap_;
bool sealed = false;
const milvus::OffsetMapping* offset_mapping_ = nullptr;
std::vector<int64_t> total_rows_until_chunk_;
//currently, ChunkMergeIterator is guaranteed to be used serially without concurrent problem, in the future
//we may need to add mutex to protect the variable sealed
};
@@ -262,9 +245,8 @@ struct SearchResult {
AssembleChunkVectorIterators(
int64_t nq,
int chunk_count,
const std::vector<int64_t>& total_rows_until_chunk,
const std::vector<knowhere::IndexNode::IteratorPtr>& kw_iterators,
const milvus::OffsetMapping& offset_mapping,
const milvus::OffsetMapping* offset_mapping,
bool larger_is_closer = false) {
AssertInfo(kw_iterators.size() == nq * chunk_count,
"kw_iterators count:{} is not equal to nq*chunk_count:{}, "
@@ -276,11 +258,8 @@ struct SearchResult {
for (int i = 0, vec_iter_idx = 0; i < kw_iterators.size(); i++) {
vec_iter_idx = vec_iter_idx % nq;
if (vector_iterators.size() < nq) {
auto chunk_merge_iter =
std::make_shared<ChunkMergeIterator>(chunk_count,
offset_mapping,
total_rows_until_chunk,
larger_is_closer);
auto chunk_merge_iter = std::make_shared<ChunkMergeIterator>(
chunk_count, offset_mapping, larger_is_closer);
vector_iterators.emplace_back(chunk_merge_iter);
}
const auto& kw_iterator = kw_iterators[i];
+9 -11
View File
@@ -34,7 +34,7 @@
#include "exec/operator/AggregationNode.h"
#include "exec/operator/CallbackSink.h"
#include "exec/operator/ElementFilterBitsNode.h"
#include "exec/operator/ElementFilterNode.h"
#include "exec/operator/IterativeElementFilterNode.h"
#include "exec/operator/FilterBitsNode.h"
#include "exec/operator/IterativeFilterNode.h"
#include "exec/operator/MvccNode.h"
@@ -80,10 +80,9 @@ DriverFactory::CreateDriver(
tracer::AddEvent("create_operator: FilterBitsNode");
operators.push_back(std::make_unique<PhyFilterBitsNode>(
id, ctx.get(), filterbitsnode));
} else if (auto filternode =
std::dynamic_pointer_cast<const plan::FilterNode>(
plannode)) {
tracer::AddEvent("create_operator: FilterNode");
} else if (auto filternode = std::dynamic_pointer_cast<
const plan::IterativeFilterNode>(plannode)) {
tracer::AddEvent("create_operator: IterativeFilterNode");
operators.push_back(std::make_unique<PhyIterativeFilterNode>(
id, ctx.get(), filternode));
} else if (auto mvccnode =
@@ -134,12 +133,11 @@ DriverFactory::CreateDriver(
tracer::AddEvent("create_operator: RescoresNode");
operators.push_back(
std::make_unique<PhyRescoresNode>(id, ctx.get(), rescoresnode));
} else if (auto node =
std::dynamic_pointer_cast<const plan::ElementFilterNode>(
plannode)) {
tracer::AddEvent("create_operator: ElementFilterNode");
operators.push_back(
std::make_unique<PhyElementFilterNode>(id, ctx.get(), node));
} else if (auto node = std::dynamic_pointer_cast<
const plan::IterativeElementFilterNode>(plannode)) {
tracer::AddEvent("create_operator: IterativeElementFilterNode");
operators.push_back(std::make_unique<PhyIterativeElementFilterNode>(
id, ctx.get(), node));
} else if (auto node = std::dynamic_pointer_cast<
const plan::ElementFilterBitsNode>(plannode)) {
tracer::AddEvent("create_operator: ElementFilterBitsNode");
@@ -14,7 +14,7 @@
// See the License for the specific language governing permissions and
// limitations under the License.
#include "ElementFilterNode.h"
#include "IterativeElementFilterNode.h"
#include "exec/operator/Utils.h"
#include <algorithm>
@@ -43,15 +43,16 @@
namespace milvus {
namespace exec {
PhyElementFilterNode::PhyElementFilterNode(
PhyIterativeElementFilterNode::PhyIterativeElementFilterNode(
int32_t operator_id,
DriverContext* driverctx,
const std::shared_ptr<const plan::ElementFilterNode>& element_filter_node)
const std::shared_ptr<const plan::IterativeElementFilterNode>&
element_filter_node)
: Operator(driverctx,
element_filter_node->output_type(),
operator_id,
element_filter_node->id(),
"PhyElementFilterNode"),
"PhyIterativeElementFilterNode"),
struct_name_(element_filter_node->struct_name()),
has_doc_predicate_(element_filter_node->has_doc_predicate()) {
ExecContext* exec_context = operator_context_->get_exec_context();
@@ -62,18 +63,19 @@ PhyElementFilterNode::PhyElementFilterNode(
}
void
PhyElementFilterNode::AddInput(RowVectorPtr& input) {
PhyIterativeElementFilterNode::AddInput(RowVectorPtr& input) {
input_ = std::move(input);
}
RowVectorPtr
PhyElementFilterNode::GetOutput() {
PhyIterativeElementFilterNode::GetOutput() {
if (is_finished_ || !no_more_input_) {
return nullptr;
}
tracer::AutoSpan span(
"PhyElementFilterNode::GetOutput", tracer::GetRootSpan(), true);
tracer::AutoSpan span("PhyIterativeElementFilterNode::GetOutput",
tracer::GetRootSpan(),
true);
DeferLambda([&]() { is_finished_ = true; });
@@ -89,13 +91,14 @@ PhyElementFilterNode::GetOutput() {
if (!search_result.element_level_) {
ThrowInfo(ExprInvalid,
"PhyElementFilterNode expects element-level search result");
"PhyIterativeElementFilterNode expects element-level search "
"result");
}
if (!search_result.vector_iterators_.has_value()) {
ThrowInfo(
ExprInvalid,
"PhyElementFilterNode expects vector_iterators in search result");
ThrowInfo(ExprInvalid,
"PhyIterativeElementFilterNode expects vector_iterators in "
"search result");
}
auto segment = query_context_->get_segment();
@@ -132,7 +135,7 @@ PhyElementFilterNode::GetOutput() {
: 0;
// Step 4: If no doc-level predicate, collect results directly
// (otherwise, downstream FilterNode will do this)
// (otherwise, downstream IterativeFilterNode will do this)
if (!has_doc_predicate_) {
CollectResults(search_result, array_offsets.get());
}
@@ -146,21 +149,21 @@ PhyElementFilterNode::GetOutput() {
std::chrono::duration<double, std::micro>(end_time - start_time)
.count();
tracer::AddEvent(
fmt::format("PhyElementFilterNode: wrapped {} iterators, struct_name: "
"{}, has_doc_predicate: {}, cost_us: {}",
num_iterators,
struct_name_,
has_doc_predicate_,
cost));
tracer::AddEvent(fmt::format(
"PhyIterativeElementFilterNode: wrapped {} iterators, struct_name: "
"{}, has_doc_predicate: {}, cost_us: {}",
num_iterators,
struct_name_,
has_doc_predicate_,
cost));
// Pass through input to downstream
return input_;
}
void
PhyElementFilterNode::CollectResults(SearchResult& search_result,
const IArrayOffsets* array_offsets) {
PhyIterativeElementFilterNode::CollectResults(
SearchResult& search_result, const IArrayOffsets* array_offsets) {
// When there's no doc-level predicate, we need to consume the iterators
// and collect the top-K results ourselves.
//
@@ -231,7 +234,9 @@ PhyElementFilterNode::CollectResults(SearchResult& search_result,
search_result.vector_iterators_.reset();
tracer::AddEvent(fmt::format(
"PhyElementFilterNode::CollectResults: nq={}, topk={}", nq, topk));
"PhyIterativeElementFilterNode::CollectResults: nq={}, topk={}",
nq,
topk));
}
} // namespace exec
@@ -32,12 +32,13 @@
namespace milvus {
namespace exec {
class PhyElementFilterNode : public Operator {
class PhyIterativeElementFilterNode : public Operator {
public:
PhyElementFilterNode(int32_t operator_id,
DriverContext* ctx,
const std::shared_ptr<const plan::ElementFilterNode>&
element_filter_node);
PhyIterativeElementFilterNode(
int32_t operator_id,
DriverContext* ctx,
const std::shared_ptr<const plan::IterativeElementFilterNode>&
element_filter_node);
bool
IsFilter() const override {
@@ -75,7 +76,7 @@ class PhyElementFilterNode : public Operator {
std::string
ToString() const override {
return "PhyElementFilterNode";
return "PhyIterativeElementFilterNode";
}
private:
@@ -54,7 +54,7 @@ namespace exec {
PhyIterativeFilterNode::PhyIterativeFilterNode(
int32_t operator_id,
DriverContext* driverctx,
const std::shared_ptr<const plan::FilterNode>& filter)
const std::shared_ptr<const plan::IterativeFilterNode>& filter)
: Operator(driverctx,
filter->output_type(),
operator_id,
@@ -39,7 +39,7 @@ class PhyIterativeFilterNode : public Operator {
PhyIterativeFilterNode(
int32_t operator_id,
DriverContext* ctx,
const std::shared_ptr<const plan::FilterNode>& filter);
const std::shared_ptr<const plan::IterativeFilterNode>& filter);
bool
IsFilter() const override {
+7 -2
View File
@@ -71,12 +71,17 @@ PrepareVectorIteratorsFromIndex(const SearchInfo& search_info,
if (iterators_val.has_value()) {
bool larger_is_closer =
PositivelyRelated(search_info.metric_type_);
// Element-level search skips row-level mapping (element IDs
// are not row-aligned); see ChunkMergeIterator ctor.
const milvus::OffsetMapping* iter_offset_mapping =
search_info.array_offsets_ != nullptr
? nullptr
: &index.GetOffsetMapping();
search_result.AssembleChunkVectorIterators(
nq,
1,
{0},
iterators_val.value(),
index.GetOffsetMapping(),
iter_offset_mapping,
larger_is_closer);
} else {
std::string operator_type = "";
@@ -121,7 +121,7 @@ PhyVectorSearchNode::GetOutput() {
} else {
// There are two types of execution: pre-filter and iterative filter
// For **pre-filter**: FilterBitsNode -> MvccNode -> ElementFilterBitsNode -> VectorSearchNode -> ...
// For **iterative filter**: MvccNode -> VectorSearchNode -> ElementFilterNode -> FilterNode -> ...
// For **iterative filter**: MvccNode -> VectorSearchNode -> IterativeElementFilterNode -> IterativeFilterNode -> ...
//
// When element_level_ is true, we need to transform doc-level bitset
// to element-level bitset. In pre-filter path, ElementFilterBitsNode
+13 -13
View File
@@ -89,11 +89,11 @@ class PlanNode {
};
using PlanNodePtr = std::shared_ptr<PlanNode>;
class FilterNode : public PlanNode {
class IterativeFilterNode : public PlanNode {
public:
FilterNode(const PlanNodeId& id,
expr::TypedExprPtr filter,
std::vector<PlanNodePtr> sources)
IterativeFilterNode(const PlanNodeId& id,
expr::TypedExprPtr filter,
std::vector<PlanNodePtr> sources)
: PlanNode(id),
sources_{std::move(sources)},
filter_(std::move(filter)) {
@@ -120,7 +120,7 @@ class FilterNode : public PlanNode {
std::string_view
name() const override {
return "Filter";
return "IterativeFilter";
}
std::string
@@ -186,13 +186,13 @@ class FilterBitsNode : public PlanNode {
const expr::TypedExprPtr filter_;
};
class ElementFilterNode : public PlanNode {
class IterativeElementFilterNode : public PlanNode {
public:
ElementFilterNode(const PlanNodeId& id,
expr::TypedExprPtr element_filter,
std::string struct_name,
std::vector<PlanNodePtr> sources,
bool has_doc_predicate = true)
IterativeElementFilterNode(const PlanNodeId& id,
expr::TypedExprPtr element_filter,
std::string struct_name,
std::vector<PlanNodePtr> sources,
bool has_doc_predicate = true)
: PlanNode(id),
sources_{std::move(sources)},
element_filter_(std::move(element_filter)),
@@ -232,13 +232,13 @@ class ElementFilterNode : public PlanNode {
std::string_view
name() const override {
return "ElementFilter";
return "IterativeElementFilter";
}
std::string
ToString() const override {
return fmt::format(
"ElementFilterNode:[struct_name:{}, element_filter:{}, "
"IterativeElementFilterNode:[struct_name:{}, element_filter:{}, "
"has_doc_predicate:{}]",
struct_name_,
element_filter_->ToString(),
@@ -20,6 +20,7 @@
#include "common/QueryInfo.h"
#include "common/QueryResult.h"
#include "common/Utils.h"
#include "common/VectorArray.h"
#include "index/Utils.h"
#include "index/VectorIndex.h"
#include "knowhere/expected.h"
@@ -113,6 +114,17 @@ CachedSearchIterator::CachedSearchIterator(
nq_ = query_ds.num_queries;
Init(search_info);
// VECTOR_ARRAY element-level search: growing stores each row as a
// separate VectorArray with its own backing allocation, so we must
// flatten per-chunk into a contiguous buffer that knowhere can read.
// array_offsets_ != nullptr is the element-level signal (multi-search-
// multi emb-list iterator is rejected upstream, so we don't branch on
// it here).
const bool is_element_level = search_info.array_offsets_ != nullptr;
if (is_element_level) {
chunk_buffers_.reserve(num_chunks_);
}
iterators_.reserve(nq_ * num_chunks_);
InitializeChunkedIterators(
query_ds,
@@ -120,12 +132,32 @@ CachedSearchIterator::CachedSearchIterator(
index_info,
bitset,
data_type,
[&vec_data, vec_size_per_chunk, row_count](int64_t chunk_id) {
[this, &vec_data, vec_size_per_chunk, row_count, is_element_level](
int64_t chunk_id) {
const void* chunk_data = vec_data->get_chunk_data(chunk_id);
// no need to store a PinWrapper for growing, because vec_data is guaranteed to not be evicted.
int64_t chunk_size = std::min(
vec_size_per_chunk, row_count - chunk_id * vec_size_per_chunk);
return std::make_pair(chunk_data, chunk_size);
if (!is_element_level) {
return std::make_pair(chunk_data, chunk_size);
}
auto va_ptr = reinterpret_cast<const VectorArray*>(chunk_data);
int64_t total_bytes = 0;
int64_t total_elements = 0;
for (int64_t i = 0; i < chunk_size; ++i) {
total_bytes += va_ptr[i].byte_size();
total_elements += va_ptr[i].length();
}
auto buf = std::make_unique<uint8_t[]>(total_bytes);
auto* ptr = buf.get();
for (int64_t i = 0; i < chunk_size; ++i) {
memcpy(ptr, va_ptr[i].data(), va_ptr[i].byte_size());
ptr += va_ptr[i].byte_size();
}
const void* flat_data = buf.get();
chunk_buffers_.emplace_back(std::move(buf));
return std::make_pair(flat_data, total_elements);
});
}
@@ -155,7 +187,7 @@ CachedSearchIterator::CachedSearchIterator(
index_info,
bitset,
data_type,
[this, column](int64_t chunk_id) {
[this, column, &search_info](int64_t chunk_id) {
auto pw = column->DataOfChunk(nullptr, chunk_id)
.transform<const void*>([](const auto& x) {
return static_cast<const void*>(x);
@@ -166,6 +198,13 @@ CachedSearchIterator::CachedSearchIterator(
if (offset_mapping.IsEnabled() && !valid_count_per_chunk.empty()) {
chunk_size = valid_count_per_chunk[chunk_id];
}
// For element-level search on vector array field, chunk_size
// must be the element count in this chunk, not the row count.
if (search_info.array_offsets_ != nullptr) {
auto elem_offsets_pw =
column->VectorArrayOffsets(nullptr, chunk_id);
chunk_size = elem_offsets_pw.get()[chunk_size];
}
// pw guarantees chunk_data is kept alive.
auto chunk_data = pw.get();
pin_wrappers_.emplace_back(std::move(pw));
@@ -94,6 +94,11 @@ class CachedSearchIterator {
// used only for sealed segment with chunked data
std::vector<milvus::cachinglayer::PinWrapper<const void*>> pin_wrappers_;
// used only for growing segment with VECTOR_ARRAY element-level search:
// knowhere needs a contiguous flat buffer, but growing stores each row as
// a separate VectorArray with its own backing allocation, so we flatten
// per-chunk here and keep the buffers alive alongside the iterators.
std::vector<std::unique_ptr<uint8_t[]>> chunk_buffers_;
int64_t batch_size_ = 0;
std::vector<knowhere::IndexNode::IteratorPtr> iterators_;
int8_t sign_ = 1;
+4 -4
View File
@@ -537,7 +537,7 @@ ProtoParser::PlanNodeFromProto(const planpb::PlanNode& plan_node_proto) {
// Add element-level filter if needed
if (is_element_level) {
bool has_doc_pred = (doc_expr != nullptr);
plannode = std::make_shared<plan::ElementFilterNode>(
plannode = std::make_shared<plan::IterativeElementFilterNode>(
milvus::plan::GetNextPlanNodeId(),
element_expr,
struct_name,
@@ -548,7 +548,7 @@ ProtoParser::PlanNodeFromProto(const planpb::PlanNode& plan_node_proto) {
// Add doc-level filter if present
if (doc_expr) {
plannode = std::make_shared<plan::FilterNode>(
plannode = std::make_shared<plan::IterativeFilterNode>(
milvus::plan::GetNextPlanNodeId(), doc_expr, sources);
sources = std::vector<milvus::plan::PlanNodePtr>{plannode};
}
@@ -598,8 +598,8 @@ ProtoParser::PlanNodeFromProto(const planpb::PlanNode& plan_node_proto) {
}
} else {
// No user filter. Force non-iterative: the iterative path requires
// a FilterNode for row-by-row post-filtering, which doesn't apply
// here (TTL uses bitmap pre-filtering via FilterBitsNode).
// an IterativeFilterNode for row-by-row post-filtering, which doesn't
// apply here (TTL uses bitmap pre-filtering via FilterBitsNode).
plan_node->search_info_.iterative_filter_execution = false;
// When entity-level TTL is enabled, add an AlwaysTrueExpr so that
@@ -180,10 +180,6 @@ BruteForceSearch(const dataset::SearchDataset& query_ds,
}
if (search_cfg.contains(RADIUS)) {
AssertInfo(data_type != DataType::VECTOR_ARRAY,
"Vector array(embedding list) is not supported for range "
"search");
if (search_cfg.contains(RANGE_FILTER)) {
CheckRangeSearchParam(search_cfg[RADIUS],
search_cfg[RANGE_FILTER],
+35 -37
View File
@@ -205,10 +205,22 @@ SearchOnGrowing(const segcore::SegmentGrowingImpl& segment,
return;
}
// Element-level search (embedding-search-embedding): knowhere sees
// a scalar vector type and the per-chunk size must be measured in
// elements. Compute this before the iterator_v2 branch so both
// paths share the substitution. Emb-list (multi-search-multi)
// iterator is rejected by the proxy; the assert below is
// defense-in-depth.
const bool is_element_level_search =
data_type == DataType::VECTOR_ARRAY &&
info.array_offsets_ != nullptr;
const auto iter_data_type =
is_element_level_search ? element_type : data_type;
if (info.iterator_v2_info_.has_value()) {
AssertInfo(data_type != DataType::VECTOR_ARRAY,
"vector array(embedding list) is not supported for "
"vector iterator");
AssertInfo(iter_data_type != DataType::VECTOR_ARRAY,
"embedding list (multi-search-multi) iterator is not "
"supported on vector array fields");
CachedSearchIterator cached_iter(search_dataset,
vec_ptr,
@@ -216,28 +228,19 @@ SearchOnGrowing(const segcore::SegmentGrowingImpl& segment,
info,
index_info,
search_bitset,
data_type);
iter_data_type);
cached_iter.NextBatch(info, search_result);
if (offset_mapping.IsEnabled()) {
TransformOffset(search_result.seg_offsets_, offset_mapping);
}
FinalizeVectorSearchOffsets(
search_result, offset_mapping, info.array_offsets_.get());
return;
}
auto vec_size_per_chunk = vec_ptr->get_size_per_chunk();
auto max_chunk = upper_div(active_count, vec_size_per_chunk);
// embedding search embedding on embedding list
bool embedding_search = false;
if (data_type == DataType::VECTOR_ARRAY &&
info.array_offsets_ != nullptr) {
embedding_search = true;
}
// Track cumulative element offset for embedding search.
// For embedding_search, begin_id must be the cumulative element
// count (not row offset), because ArrayOffsets maps global
// element IDs to row IDs.
// Track cumulative element offset for element-level search.
// begin_id must be the cumulative element count (not row offset),
// because ArrayOffsets maps global element IDs to row IDs.
int64_t cumulative_element_offset = 0;
std::vector<size_t> offsets;
@@ -268,7 +271,7 @@ SearchOnGrowing(const segcore::SegmentGrowingImpl& segment,
buf = std::make_unique<uint8_t[]>(size);
if (embedding_search) {
if (is_element_level_search) {
auto count = 0;
auto ptr = buf.get();
for (int i = 0; i < size_per_chunk; ++i) {
@@ -301,13 +304,8 @@ SearchOnGrowing(const segcore::SegmentGrowingImpl& segment,
}
}
auto vector_type = data_type;
if (embedding_search) {
vector_type = element_type;
}
if (milvus::exec::UseVectorIterator(info)) {
AssertInfo(vector_type != DataType::VECTOR_ARRAY,
AssertInfo(iter_data_type != DataType::VECTOR_ARRAY,
"vector array(embedding list) is not supported for "
"vector iterator");
@@ -321,7 +319,7 @@ SearchOnGrowing(const segcore::SegmentGrowingImpl& segment,
info,
index_info,
search_bitset,
vector_type);
iter_data_type);
final_qr.merge(sub_qr);
} else {
auto sub_qr = BruteForceSearch(search_dataset,
@@ -329,41 +327,41 @@ SearchOnGrowing(const segcore::SegmentGrowingImpl& segment,
info,
index_info,
search_bitset,
vector_type,
iter_data_type,
element_type,
op_context);
final_qr.merge(sub_qr);
}
}
if (milvus::exec::UseVectorIterator(info)) {
std::vector<int64_t> chunk_rows(max_chunk, 0);
for (int i = 1; i < max_chunk; ++i) {
chunk_rows[i] = i * vec_size_per_chunk;
}
bool larger_is_closer = PositivelyRelated(info.metric_type_);
// Element-level search skips row-level mapping (element IDs are
// not row-aligned); see ChunkMergeIterator ctor.
const milvus::OffsetMapping* iter_offset_mapping =
is_element_level_search ? nullptr : &offset_mapping;
search_result.AssembleChunkVectorIterators(
num_queries,
max_chunk,
chunk_rows,
final_qr.chunk_iterators(),
offset_mapping,
iter_offset_mapping,
larger_is_closer);
} else {
// See FinalizeVectorSearchOffsets for the rationale:
// element-level and row-level remapping are mutually exclusive.
if (info.array_offsets_ != nullptr) {
auto [seg_offsets, elem_indicies] =
final_qr.convert_to_element_offsets(
info.array_offsets_.get());
search_result.seg_offsets_ = std::move(seg_offsets);
search_result.element_indices_ = std::move(elem_indicies);
search_result.element_level_ = true;
} else {
if (offset_mapping.IsEnabled()) {
TransformOffset(final_qr.mutable_offsets(), offset_mapping);
}
search_result.seg_offsets_ =
std::move(final_qr.mutable_offsets());
}
search_result.distances_ = std::move(final_qr.mutable_distances());
if (offset_mapping.IsEnabled()) {
TransformOffset(search_result.seg_offsets_, offset_mapping);
}
}
search_result.unity_topK_ = topk;
search_result.total_nq_ = num_queries;
+34 -46
View File
@@ -115,7 +115,8 @@ SearchOnSealedIndex(const Schema& schema,
CachedSearchIterator cached_iter(
*vec_index, dataset, search_info, search_bitset);
cached_iter.NextBatch(search_info, search_result);
TransformOffset(search_result.seg_offsets_, offset_mapping);
FinalizeVectorSearchOffsets(
search_result, offset_mapping, search_info.array_offsets_.get());
return;
}
@@ -138,30 +139,11 @@ SearchOnSealedIndex(const Schema& schema,
std::round(distances[i] * multiplier) / multiplier;
}
}
// Handle element-level conversion if needed
if (search_info.array_offsets_ != nullptr) {
std::vector<int64_t> element_ids =
std::move(search_result.seg_offsets_);
search_result.seg_offsets_.resize(element_ids.size());
search_result.element_indices_.resize(element_ids.size());
for (size_t i = 0; i < element_ids.size(); i++) {
if (element_ids[i] == INVALID_SEG_OFFSET) {
search_result.seg_offsets_[i] = INVALID_SEG_OFFSET;
search_result.element_indices_[i] = -1;
} else {
auto [doc_id, elem_index] =
search_info.array_offsets_->ElementIDToRowID(
element_ids[i]);
search_result.seg_offsets_[i] = doc_id;
search_result.element_indices_[i] = elem_index;
}
}
search_result.element_level_ = true;
}
}
TransformOffset(search_result.seg_offsets_, offset_mapping);
FinalizeVectorSearchOffsets(
search_result,
offset_mapping,
use_iterator ? nullptr : search_info.array_offsets_.get());
search_result.total_nq_ = num_queries;
search_result.unity_topK_ = topK;
}
@@ -218,10 +200,24 @@ SearchOnSealedColumn(const Schema& schema,
}
}
// For element-level search (embedding-search-embedding), the underlying
// knowhere search is keyed by the scalar element type rather than
// VECTOR_ARRAY, and per-chunk sizes must be counted in elements.
bool is_element_level_search =
field.get_data_type() == DataType::VECTOR_ARRAY &&
query_offsets == nullptr;
if (is_element_level_search) {
data_type = element_type;
}
if (search_info.iterator_v2_info_.has_value()) {
// Element-level search has already replaced data_type with
// element_type above. This assert still guards emb-list
// (multi-search-multi) iterator, which proxy should have rejected
// — keep it as a defense-in-depth check.
AssertInfo(data_type != DataType::VECTOR_ARRAY,
"vector array(embedding list) is not supported for "
"vector iterator");
"embedding list (multi-search-multi) iterator is not "
"supported on vector array fields");
CachedSearchIterator cached_iter(column,
query_dataset,
@@ -230,9 +226,8 @@ SearchOnSealedColumn(const Schema& schema,
search_bitview,
data_type);
cached_iter.NextBatch(search_info, result);
if (offset_mapping.IsEnabled()) {
TransformOffset(result.seg_offsets_, offset_mapping);
}
FinalizeVectorSearchOffsets(
result, offset_mapping, search_info.array_offsets_.get());
return;
}
@@ -243,17 +238,6 @@ SearchOnSealedColumn(const Schema& schema,
search_info.metric_type_,
search_info.round_decimal_);
// For element-level search (embedding-search-embedding), we need to use
// element count instead of row count
bool is_element_level_search =
field.get_data_type() == DataType::VECTOR_ARRAY &&
query_offsets == nullptr;
if (is_element_level_search) {
// embedding-search-embedding on embedding list pattern
data_type = element_type;
}
auto offset = 0;
auto vector_chunks = column->GetAllChunks(op_context);
const auto& valid_count_per_chunk = column->GetValidCountPerChunk();
@@ -312,27 +296,31 @@ SearchOnSealedColumn(const Schema& schema,
}
if (milvus::exec::UseVectorIterator(search_info)) {
bool larger_is_closer = PositivelyRelated(search_info.metric_type_);
// Element-level search skips row-level mapping (element IDs are
// not row-aligned); see ChunkMergeIterator ctor.
const milvus::OffsetMapping* iter_offset_mapping =
search_info.array_offsets_ != nullptr ? nullptr : &offset_mapping;
result.AssembleChunkVectorIterators(num_queries,
num_chunk,
column->GetNumRowsUntilChunk(),
final_qr.chunk_iterators(),
offset_mapping,
iter_offset_mapping,
larger_is_closer);
} else {
// See FinalizeVectorSearchOffsets for the rationale: element-level
// and row-level remapping are mutually exclusive.
if (search_info.array_offsets_ != nullptr) {
auto [seg_offsets, elem_indicies] =
final_qr.convert_to_element_offsets(
search_info.array_offsets_.get());
result.seg_offsets_ = std::move(seg_offsets);
result.element_indices_ = std::move(elem_indicies);
result.element_level_ = true;
} else {
if (offset_mapping.IsEnabled()) {
TransformOffset(final_qr.mutable_offsets(), offset_mapping);
}
result.seg_offsets_ = std::move(final_qr.mutable_offsets());
}
result.distances_ = std::move(final_qr.mutable_distances());
if (offset_mapping.IsEnabled()) {
TransformOffset(result.seg_offsets_, offset_mapping);
}
}
result.unity_topK_ = query_dataset.topk;
result.total_nq_ = query_dataset.num_queries;
+2 -17
View File
@@ -19,6 +19,7 @@
#include "common/Utils.h"
#include "knowhere/index/index_node.h"
#include "common/ArrayOffsets.h"
#include "query/Utils.h"
namespace milvus::query {
class SubSearchResult {
@@ -119,23 +120,7 @@ class SubSearchResult {
std::pair<std::vector<int64_t>, std::vector<int32_t>>
convert_to_element_offsets(const IArrayOffsets* array_offsets) {
std::vector<int64_t> doc_offsets;
std::vector<int32_t> element_indices;
doc_offsets.reserve(offsets_.size());
element_indices.reserve(offsets_.size());
for (size_t i = 0; i < offsets_.size(); i++) {
if (offsets_[i] == INVALID_SEG_OFFSET) {
doc_offsets.push_back(INVALID_SEG_OFFSET);
element_indices.push_back(-1);
} else {
auto [doc_id, elem_index] =
array_offsets->ElementIDToRowID(offsets_[i]);
doc_offsets.push_back(doc_id);
element_indices.push_back(elem_index);
}
}
return std::make_pair(std::move(doc_offsets),
std::move(element_indices));
return milvus::query::ApplyElementIDMapping(offsets_, *array_offsets);
}
private:
+61
View File
@@ -14,12 +14,44 @@
#include <limits>
#include <string>
#include <utility>
#include <vector>
#include "common/ArrayOffsets.h"
#include "common/BitsetView.h"
#include "common/Consts.h"
#include "common/OffsetMapping.h"
#include "common/QueryResult.h"
#include "common/Types.h"
#include "common/Utils.h"
namespace milvus::query {
// Map logical element IDs returned by knowhere to (doc_id, elem_idx) pairs
// via ArrayOffsets. Caller must ensure the input `element_ids` are already
// in logical space (i.e. apply TransformOffset first when offset_mapping is
// enabled), since ArrayOffsets::ElementIDToRowID is keyed on logical
// element IDs.
inline std::pair<std::vector<int64_t>, std::vector<int32_t>>
ApplyElementIDMapping(const std::vector<int64_t>& element_ids,
const milvus::IArrayOffsets& array_offsets) {
std::vector<int64_t> doc_offsets;
std::vector<int32_t> element_indices;
doc_offsets.reserve(element_ids.size());
element_indices.reserve(element_ids.size());
for (size_t i = 0; i < element_ids.size(); i++) {
if (element_ids[i] == INVALID_SEG_OFFSET) {
doc_offsets.push_back(INVALID_SEG_OFFSET);
element_indices.push_back(-1);
} else {
auto [doc_id, elem_index] =
array_offsets.ElementIDToRowID(element_ids[i]);
doc_offsets.push_back(doc_id);
element_indices.push_back(elem_index);
}
}
return std::make_pair(std::move(doc_offsets), std::move(element_indices));
}
inline TargetBitmap
TransformBitset(const BitsetView& bitset,
const milvus::OffsetMapping& mapping) {
@@ -46,6 +78,35 @@ TransformOffset(std::vector<int64_t>& seg_offsets,
}
}
// Map knowhere's raw offsets back to logical space. The two inputs are
// mutually exclusive:
//
// - array_offsets != nullptr (VECTOR_ARRAY element-level search):
// knowhere returns physical element IDs. ArrayOffsets is built by
// walking every row in the segment and advancing the row counter on
// every row (including empty/null rows, which occupy a zero-length
// element range), so ElementIDToRowID produces (logical_row_id,
// elem_idx) directly. No OffsetMapping pass is needed.
//
// - array_offsets == nullptr (plain vector field): when OffsetMapping is
// enabled, the index/chunk was built over valid rows only, so
// knowhere's physical row IDs must be remapped to logical via
// OffsetMapping. When OffsetMapping is disabled, TransformOffset is a
// no-op.
inline void
FinalizeVectorSearchOffsets(SearchResult& result,
const milvus::OffsetMapping& offset_mapping,
const milvus::IArrayOffsets* array_offsets) {
if (array_offsets != nullptr) {
auto [doc_offsets, elem_indices] =
ApplyElementIDMapping(result.seg_offsets_, *array_offsets);
result.seg_offsets_ = std::move(doc_offsets);
result.element_indices_ = std::move(elem_indices);
} else {
TransformOffset(result.seg_offsets_, offset_mapping);
}
}
template <typename T, typename U>
inline bool
Match(const T& x, const U& y, OpType op) {
@@ -16,6 +16,8 @@
#include <map>
#include <memory>
#include <optional>
#include <random>
#include <set>
#include <string>
#include <tuple>
#include <unordered_set>
@@ -2870,3 +2872,487 @@ TEST(ElementFilter, GrowingMultiChunkElementSearch) {
<< " element_value=" << element_value << " should be < 400";
}
}
namespace {
constexpr int kElemDim = 4;
constexpr int kElemArrayLen = 3;
constexpr size_t kElemN = 200;
constexpr uint64_t kFixtureVecSeed = 0x5EEDBEEFULL;
constexpr int kElemTopK = 10;
// Ground-truth target: the query vector equals row kElemTargetDoc's element
// kElemTargetElem, so an exact (BF) search must return it first with
// distance ~0. A mid-range row + non-zero elem index makes "always returns
// the first element" bugs visible.
constexpr int64_t kElemTargetDoc = 5;
constexpr int32_t kElemTargetElem = 1;
struct ElementSearchFixture {
SchemaPtr schema;
FieldId vec_fid;
FieldId int64_fid;
GeneratedData raw_data;
std::vector<float> flat_data; // kN * kArrayLen * kDim
std::vector<float> query_data; // kDim
};
inline ElementSearchFixture
MakeElementSearchFixture() {
ElementSearchFixture f;
f.schema = std::make_shared<Schema>();
f.vec_fid = f.schema->AddDebugVectorArrayField(
"structA[array_vec]", DataType::VECTOR_FLOAT, kElemDim, "L2");
f.int64_fid = f.schema->AddDebugField("id", DataType::INT64);
f.schema->set_primary_field_id(f.int64_fid);
f.raw_data = DataGen(f.schema, kElemN, 42, 0, 1, kElemArrayLen);
// DataGen's VECTOR_ARRAY branch uses the same seed for every row, so all
// rows share identical element vectors. Overwrite the whole vector-array
// field with per-(row, elem) unique values so that brute-force search has
// no ties and iterator_v2 batch boundaries see strictly increasing
// distances.
{
std::mt19937_64 rng(kFixtureVecSeed);
std::normal_distribution<float> distr(0.0f, 1.0f);
for (int i = 0; i < f.raw_data.raw_->fields_data_size(); ++i) {
auto* fd = f.raw_data.raw_->mutable_fields_data(i);
if (fd->field_id() != f.vec_fid.get()) {
continue;
}
auto* vec_array = fd->mutable_vectors()->mutable_vector_array();
for (int row = 0; row < vec_array->data_size(); ++row) {
auto* row_data = vec_array->mutable_data(row)
->mutable_float_vector()
->mutable_data();
for (int k = 0; k < kElemArrayLen * kElemDim; ++k) {
row_data->Set(k, distr(rng));
}
}
// Pin the target element to a unique sentinel so exact search
// deterministically returns (kElemTargetDoc, kElemTargetElem).
auto* target_row = vec_array->mutable_data(kElemTargetDoc)
->mutable_float_vector()
->mutable_data();
for (int k = 0; k < kElemDim; ++k) {
target_row->Set(kElemTargetElem * kElemDim + k, 7.0f);
}
break;
}
}
auto array_vec_values = f.raw_data.get_col<VectorFieldProto>(f.vec_fid);
f.flat_data.resize(array_vec_values.size() * kElemArrayLen * kElemDim);
for (size_t i = 0; i < array_vec_values.size(); ++i) {
const auto& float_vec = array_vec_values[i].float_vector().data();
for (int j = 0; j < kElemArrayLen * kElemDim; ++j) {
f.flat_data[i * kElemArrayLen * kElemDim + j] = float_vec[j];
}
}
const size_t target_off =
(kElemTargetDoc * kElemArrayLen + kElemTargetElem) * kElemDim;
f.query_data.assign(f.flat_data.begin() + target_off,
f.flat_data.begin() + target_off + kElemDim);
return f;
}
inline proto::common::PlaceholderGroup
MakeElementLevelPlaceholder(const std::vector<float>& query_data) {
auto raw = CreatePlaceholderGroupFromBlob<milvus::FloatVector>(
/*num_queries=*/1, kElemDim, query_data.data());
raw.mutable_placeholders(0)->set_element_level(true);
return raw;
}
inline void
LoadElementHnswIndex(SegmentSealed* segment,
FieldId vec_fid,
const std::vector<float>& flat_data) {
auto indexing = GenVecIndexing(kElemN * kElemArrayLen,
kElemDim,
flat_data.data(),
knowhere::IndexEnum::INDEX_HNSW);
LoadIndexInfo load_index_info;
load_index_info.field_id = vec_fid.get();
load_index_info.index_params = GenIndexParams(indexing.get());
load_index_info.cache_index =
CreateTestCacheIndex("test", std::move(indexing));
load_index_info.index_params["metric_type"] = knowhere::metric::L2;
load_index_info.field_type = DataType::VECTOR_ARRAY;
load_index_info.element_type = DataType::VECTOR_FLOAT;
segment->LoadIndex(load_index_info);
}
inline void
ExpectElementLevelShape(const milvus::SearchResult& sr) {
ASSERT_TRUE(sr.element_level_);
ASSERT_FALSE(sr.seg_offsets_.empty());
ASSERT_EQ(sr.element_indices_.size(), sr.seg_offsets_.size());
for (size_t i = 0; i < sr.seg_offsets_.size(); ++i) {
if (sr.seg_offsets_[i] < 0) {
continue;
}
ASSERT_GE(sr.element_indices_[i], 0);
ASSERT_LT(sr.element_indices_[i], kElemArrayLen);
}
}
inline void
ExpectSortedAscending(const milvus::SearchResult& sr) {
int valid = 0;
for (size_t i = 0; i < sr.distances_.size(); ++i) {
if (sr.seg_offsets_[i] < 0) {
continue;
}
++valid;
if (i > 0 && sr.seg_offsets_[i - 1] >= 0) {
ASSERT_LE(sr.distances_[i - 1], sr.distances_[i]);
}
}
ASSERT_GT(valid, 0);
}
inline void
ExpectWithinRadius(const milvus::SearchResult& sr, float radius) {
for (size_t i = 0; i < sr.seg_offsets_.size(); ++i) {
if (sr.seg_offsets_[i] < 0) {
continue;
}
ASSERT_LT(sr.distances_[i], radius);
ASSERT_GE(sr.distances_[i], 0.0f);
}
}
// Exact path (BF): the target element must sit at index 0 with distance ~0.
inline void
ExpectTopOneIsTarget(const milvus::SearchResult& sr) {
ASSERT_FALSE(sr.seg_offsets_.empty());
ASSERT_EQ(sr.seg_offsets_[0], kElemTargetDoc);
ASSERT_EQ(sr.element_indices_[0], kElemTargetElem);
ASSERT_NEAR(sr.distances_[0], 0.0f, 1e-5f);
}
// Approximate path (HNSW): the target must appear in the top-K, but may not
// be strictly first.
inline void
ExpectTargetInTopK(const milvus::SearchResult& sr) {
bool found = false;
for (size_t i = 0; i < sr.seg_offsets_.size(); ++i) {
if (sr.seg_offsets_[i] == kElemTargetDoc &&
sr.element_indices_[i] == kElemTargetElem) {
found = true;
EXPECT_NEAR(sr.distances_[i], 0.0f, 1e-3f);
break;
}
}
ASSERT_TRUE(found) << "Target (doc=" << kElemTargetDoc
<< ", elem=" << kElemTargetElem
<< ") not found in approximate search result";
}
} // namespace
TEST(ElementVectorSearch, GrowingBruteForce_RangeSearch) {
auto f = MakeElementSearchFixture();
auto segment = CreateGrowingSegment(f.schema, empty_index_meta);
segment->PreInsert(kElemN);
segment->Insert(0,
kElemN,
f.raw_data.row_ids_.data(),
f.raw_data.timestamps_.data(),
f.raw_data.raw_);
ScopedSchemaHandle handle(*f.schema);
const float radius = 1e6f;
const std::string search_params =
R"({"radius": )" + std::to_string(radius) + R"(, "range_filter": 0.0})";
auto plan_bytes = handle.ParseSearch(
"", "structA[array_vec]", kElemTopK, "L2", search_params, 3);
auto plan =
CreateSearchPlanByExpr(f.schema, plan_bytes.data(), plan_bytes.size());
auto ph_group_raw = MakeElementLevelPlaceholder(f.query_data);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto sr = segment->Search(plan.get(), ph_group.get(), 1L << 63);
ASSERT_NE(sr, nullptr);
ExpectElementLevelShape(*sr);
ExpectWithinRadius(*sr, radius);
ExpectTopOneIsTarget(*sr);
}
TEST(ElementVectorSearch, GrowingBruteForce_IteratorV2) {
auto f = MakeElementSearchFixture();
auto segment = CreateGrowingSegment(f.schema, empty_index_meta);
segment->PreInsert(kElemN);
segment->Insert(0,
kElemN,
f.raw_data.row_ids_.data(),
f.raw_data.timestamps_.data(),
f.raw_data.raw_);
ScopedSchemaHandle handle(*f.schema);
auto plan_bytes =
handle.ParseSearchIterator("",
"structA[array_vec]",
kElemTopK,
"L2",
R"({"ef": 50})",
static_cast<uint32_t>(kElemTopK),
"",
std::nullopt,
3);
auto plan =
CreateSearchPlanByExpr(f.schema, plan_bytes.data(), plan_bytes.size());
auto ph_group_raw = MakeElementLevelPlaceholder(f.query_data);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto sr = segment->Search(plan.get(), ph_group.get(), 1L << 63);
ASSERT_NE(sr, nullptr);
ExpectElementLevelShape(*sr);
ExpectSortedAscending(*sr);
ExpectTopOneIsTarget(*sr);
}
TEST(ElementVectorSearch, SealedBruteForce_RangeSearch) {
auto f = MakeElementSearchFixture();
auto segment = CreateSealedWithFieldDataLoaded(f.schema, f.raw_data);
ScopedSchemaHandle handle(*f.schema);
const float radius = 1e6f;
const std::string search_params =
R"({"radius": )" + std::to_string(radius) + R"(, "range_filter": 0.0})";
auto plan_bytes = handle.ParseSearch(
"", "structA[array_vec]", kElemTopK, "L2", search_params, 3);
auto plan =
CreateSearchPlanByExpr(f.schema, plan_bytes.data(), plan_bytes.size());
auto ph_group_raw = MakeElementLevelPlaceholder(f.query_data);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto sr = segment->Search(plan.get(), ph_group.get(), 1L << 63);
ASSERT_NE(sr, nullptr);
ExpectElementLevelShape(*sr);
ExpectWithinRadius(*sr, radius);
ExpectTopOneIsTarget(*sr);
}
TEST(ElementVectorSearch, SealedBruteForce_IteratorV2) {
auto f = MakeElementSearchFixture();
auto segment = CreateSealedWithFieldDataLoaded(f.schema, f.raw_data);
ScopedSchemaHandle handle(*f.schema);
auto plan_bytes =
handle.ParseSearchIterator("",
"structA[array_vec]",
kElemTopK,
"L2",
R"({"ef": 50})",
static_cast<uint32_t>(kElemTopK),
"",
std::nullopt,
3);
auto plan =
CreateSearchPlanByExpr(f.schema, plan_bytes.data(), plan_bytes.size());
auto ph_group_raw = MakeElementLevelPlaceholder(f.query_data);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto sr = segment->Search(plan.get(), ph_group.get(), 1L << 63);
ASSERT_NE(sr, nullptr);
ExpectElementLevelShape(*sr);
ExpectSortedAscending(*sr);
ExpectTopOneIsTarget(*sr);
}
TEST(ElementVectorSearch, SealedIndex_RangeSearch) {
auto f = MakeElementSearchFixture();
auto segment = CreateSealedWithFieldDataLoaded(f.schema, f.raw_data);
LoadElementHnswIndex(segment.get(), f.vec_fid, f.flat_data);
ScopedSchemaHandle handle(*f.schema);
const float radius = 1e6f;
const std::string search_params = R"({"ef": 50, "radius": )" +
std::to_string(radius) +
R"(, "range_filter": 0.0})";
auto plan_bytes = handle.ParseSearch(
"", "structA[array_vec]", kElemTopK, "L2", search_params, 3);
auto plan =
CreateSearchPlanByExpr(f.schema, plan_bytes.data(), plan_bytes.size());
auto ph_group_raw = MakeElementLevelPlaceholder(f.query_data);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto sr = segment->Search(plan.get(), ph_group.get(), 1L << 63);
ASSERT_NE(sr, nullptr);
ExpectElementLevelShape(*sr);
ExpectWithinRadius(*sr, radius);
ExpectTargetInTopK(*sr);
}
TEST(ElementVectorSearch, SealedIndex_IteratorV2) {
auto f = MakeElementSearchFixture();
auto segment = CreateSealedWithFieldDataLoaded(f.schema, f.raw_data);
LoadElementHnswIndex(segment.get(), f.vec_fid, f.flat_data);
ScopedSchemaHandle handle(*f.schema);
auto plan_bytes =
handle.ParseSearchIterator("",
"structA[array_vec]",
kElemTopK,
"L2",
R"({"ef": 50})",
static_cast<uint32_t>(kElemTopK),
"",
std::nullopt,
3);
auto plan =
CreateSearchPlanByExpr(f.schema, plan_bytes.data(), plan_bytes.size());
auto ph_group_raw = MakeElementLevelPlaceholder(f.query_data);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto sr = segment->Search(plan.get(), ph_group.get(), 1L << 63);
ASSERT_NE(sr, nullptr);
ExpectElementLevelShape(*sr);
ExpectSortedAscending(*sr);
ExpectTargetInTopK(*sr);
}
// ---- Multi-chunk growing -------------------------------------------------
// Default chunk_rows is 1024, so the 6 tests above fit in one chunk. Here
// we shrink chunk_rows so N=200 spans ~4 chunks, exercising the per-chunk
// flatten loop in SearchOnGrowing.cpp and the chunk merge in
// ChunkMergeIterator.
namespace {
constexpr int64_t kElemChunkRows = 64;
inline SegmentGrowingPtr
CreateMultiChunkGrowingSegment(const ElementSearchFixture& f) {
auto config = SegcoreConfig::default_config();
config.set_chunk_rows(kElemChunkRows);
auto segment = CreateGrowingSegment(f.schema, empty_index_meta, 1, config);
segment->PreInsert(kElemN);
segment->Insert(0,
kElemN,
f.raw_data.row_ids_.data(),
f.raw_data.timestamps_.data(),
f.raw_data.raw_);
static_assert(kElemN > kElemChunkRows,
"N must exceed chunk_rows to exercise multi-chunk paths");
return segment;
}
} // namespace
TEST(ElementVectorSearch, GrowingMultiChunk_RangeSearch) {
auto f = MakeElementSearchFixture();
auto segment = CreateMultiChunkGrowingSegment(f);
ScopedSchemaHandle handle(*f.schema);
const float radius = 1e6f;
const std::string search_params =
R"({"radius": )" + std::to_string(radius) + R"(, "range_filter": 0.0})";
auto plan_bytes = handle.ParseSearch(
"", "structA[array_vec]", kElemTopK, "L2", search_params, 3);
auto plan =
CreateSearchPlanByExpr(f.schema, plan_bytes.data(), plan_bytes.size());
auto ph_group_raw = MakeElementLevelPlaceholder(f.query_data);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto sr = segment->Search(plan.get(), ph_group.get(), 1L << 63);
ASSERT_NE(sr, nullptr);
ExpectElementLevelShape(*sr);
ExpectWithinRadius(*sr, radius);
ExpectTopOneIsTarget(*sr);
}
TEST(ElementVectorSearch, GrowingMultiChunk_IteratorV2) {
auto f = MakeElementSearchFixture();
auto segment = CreateMultiChunkGrowingSegment(f);
ScopedSchemaHandle handle(*f.schema);
auto plan_bytes =
handle.ParseSearchIterator("",
"structA[array_vec]",
kElemTopK,
"L2",
R"({"ef": 50})",
static_cast<uint32_t>(kElemTopK),
"",
std::nullopt,
3);
auto plan =
CreateSearchPlanByExpr(f.schema, plan_bytes.data(), plan_bytes.size());
auto ph_group_raw = MakeElementLevelPlaceholder(f.query_data);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto sr = segment->Search(plan.get(), ph_group.get(), 1L << 63);
ASSERT_NE(sr, nullptr);
ExpectElementLevelShape(*sr);
ExpectSortedAscending(*sr);
ExpectTopOneIsTarget(*sr);
}
// ---- Iterator_v2: consecutive NextBatch rounds ---------------------------
// Each search call runs exactly one NextBatch. Chain rounds by feeding the
// previous batch's last distance as last_bound: post-round distances must
// stay strictly greater than that bound (L2, lower-is-better).
TEST(ElementVectorSearch, SealedBruteForce_IteratorV2_MultiBatch) {
auto f = MakeElementSearchFixture();
auto segment = CreateSealedWithFieldDataLoaded(f.schema, f.raw_data);
ScopedSchemaHandle handle(*f.schema);
std::optional<float> last_bound;
std::set<std::pair<int64_t, int32_t>> seen;
const int kRounds = 3;
for (int round = 0; round < kRounds; ++round) {
auto plan_bytes =
handle.ParseSearchIterator("",
"structA[array_vec]",
kElemTopK,
"L2",
R"({"ef": 50})",
static_cast<uint32_t>(kElemTopK),
"",
last_bound,
3);
auto plan = CreateSearchPlanByExpr(
f.schema, plan_bytes.data(), plan_bytes.size());
auto ph_group_raw = MakeElementLevelPlaceholder(f.query_data);
auto ph_group =
ParsePlaceholderGroup(plan.get(), ph_group_raw.SerializeAsString());
auto sr = segment->Search(plan.get(), ph_group.get(), 1L << 63);
ASSERT_NE(sr, nullptr);
ExpectElementLevelShape(*sr);
ExpectSortedAscending(*sr);
float round_last = last_bound.value_or(-1.0f);
for (size_t i = 0; i < sr->seg_offsets_.size(); ++i) {
if (sr->seg_offsets_[i] < 0) {
continue;
}
if (last_bound.has_value()) {
ASSERT_GT(sr->distances_[i], *last_bound)
<< "round " << round
<< " returned a distance not strictly past last_bound";
}
auto key =
std::make_pair(sr->seg_offsets_[i], sr->element_indices_[i]);
ASSERT_TRUE(seen.insert(key).second)
<< "iterator returned duplicate (doc=" << key.first
<< ", elem=" << key.second << ") across rounds";
round_last = sr->distances_[i];
}
last_bound = round_last;
}
ASSERT_GE(seen.size(), static_cast<size_t>(kElemTopK))
<< "multi-batch iterator should accumulate strictly more results "
<< "than a single batch";
}
+1 -1
View File
@@ -61,7 +61,7 @@ namespace milvus::segcore {
struct GeneratedData {
std::vector<idx_t> row_ids_;
std::vector<Timestamp> timestamps_;
InsertRecordProto* raw_;
InsertRecordProto* raw_ = nullptr;
std::vector<FieldId> field_ids;
SchemaPtr schema_;
+2 -27
View File
@@ -568,38 +568,13 @@ func parseSearchInfo(searchParamsPair []*commonpb.KeyValuePair, schema *schemapb
return nil, fmt.Errorf("parse iterator v2 info failed: %w", err)
}
// 7. check search for embedding list
annsFieldName, _ := funcutil.GetAttrByKeyFromRepeatedKV(AnnsFieldKey, searchParamsPair)
if annsFieldName != "" {
annField := typeutil.GetFieldByName(schema, annsFieldName)
if annField != nil && annField.GetDataType() == schemapb.DataType_ArrayOfVector {
if isRangeSearch {
return nil, merr.WrapErrParameterInvalid("", "",
"range search is not supported for vector array (embedding list) fields, fieldName:", annsFieldName)
}
// For hybrid search (rankInfo != nil), group by on vector array is not supported.
// For simple search, group by validation is handled in initSearchRequest()
// where placeholder type is available to distinguish element-level vs embedding list.
if groupByFieldId > 0 && rankParams != nil {
return nil, merr.WrapErrParameterInvalid("", "",
"group by search is not supported for vector array (embedding list) fields, fieldName:", annsFieldName)
}
if isIterator {
return nil, merr.WrapErrParameterInvalid("", "",
"search iterator is not supported for vector array (embedding list) fields, fieldName:", annsFieldName)
}
}
}
// 8. parse order_by_fields
// 7. parse order_by_fields
orderByFields, err := parseOrderByFields(searchParamsPair, schema)
if err != nil {
return nil, err
}
// 9. validate iterator + order_by combination is not allowed
// 8. validate iterator + order_by combination is not allowed
if isIterator && len(orderByFields) > 0 {
return nil, merr.WrapErrParameterInvalid("", "",
"order_by is not supported when using search iterator")
+45 -8
View File
@@ -10,6 +10,7 @@ import (
"github.com/cockroachdb/errors"
"github.com/samber/lo"
"github.com/tidwall/gjson"
"go.opentelemetry.io/otel"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
@@ -462,11 +463,32 @@ func (t *searchTask) initAdvancedSearchRequest(ctx context.Context) error {
queryFieldIDs := []int64{}
for index, subReq := range t.request.GetSubReqs() {
// For hybrid search, order_by_fields comes from main search params, not sub-search params
plan, queryInfo, offset, _, _, searchType, err := t.tryGeneratePlan(subReq.GetSearchParams(), subReq.GetDsl(), subReq.GetExprTemplateValues())
plan, queryInfo, offset, subIsIterator, _, searchType, err := t.tryGeneratePlan(subReq.GetSearchParams(), subReq.GetDsl(), subReq.GetExprTemplateValues())
if err != nil {
return err
}
// Hybrid search does not yet support vector array (embedding list) fields:
// placeholder type is per sub-request and element-level vs embedding-list-level
// differentiation is not wired up on this path. Reject range search / iterator /
// group by on such fields here; plain top-K stays allowed for now (unchanged
// behavior).
annsField := typeutil.GetField(t.schema.CollectionSchema, queryInfo.GetQueryFieldId())
if annsField != nil && annsField.GetDataType() == schemapb.DataType_ArrayOfVector {
if gjson.Get(queryInfo.GetSearchParams(), radiusKey).Exists() {
return merr.WrapErrParameterInvalid("", "",
"range search is not supported for vector array (embedding list) fields in hybrid search, fieldName:"+annsField.GetName())
}
if t.rankParams.GetGroupByFieldId() > 0 {
return merr.WrapErrParameterInvalid("", "",
"group by search is not supported for vector array (embedding list) fields in hybrid search, fieldName:"+annsField.GetName())
}
if subIsIterator {
return merr.WrapErrParameterInvalid("", "",
"search iterator is not supported for vector array (embedding list) fields in hybrid search, fieldName:"+annsField.GetName())
}
}
ignoreGrowing := t.IgnoreGrowing
if !ignoreGrowing {
// fetch ignore_growing from sub search param if not set in search request
@@ -775,13 +797,28 @@ func (t *searchTask) initSearchRequest(ctx context.Context) error {
return err
}
// For ArrayOfVector fields with group by:
// 1. Embedding list search does not support group by
// 2. Element-level search only supports group by PK (doc-level dedup)
if queryInfo.GetGroupByFieldId() > 0 {
annsField := typeutil.GetField(t.schema.CollectionSchema, t.FieldId)
if annsField != nil && annsField.GetDataType() == schemapb.DataType_ArrayOfVector {
if isEmbeddingListPlaceholderType(placeholderType) {
// For ArrayOfVector fields, the placeholder type decides the search semantics:
// - Element-level (plain vector placeholder): behaves like a normal single-vector
// search; supports range search, iterator, and group by primary key.
// - Embedding-list-level (multi-search-multi): does not support range search,
// iterator, or group by (other than the PK case above).
annsField := typeutil.GetField(t.schema.CollectionSchema, t.FieldId)
if annsField != nil && annsField.GetDataType() == schemapb.DataType_ArrayOfVector {
isEmbList := isEmbeddingListPlaceholderType(placeholderType)
if isEmbList {
if gjson.Get(queryInfo.GetSearchParams(), radiusKey).Exists() {
return merr.WrapErrParameterInvalid("", "",
"range search is not supported for multi-search-multi on embedding list fields")
}
if t.isIterator {
return merr.WrapErrParameterInvalid("", "",
"search iterator is not supported for multi-search-multi on embedding list fields")
}
}
if queryInfo.GetGroupByFieldId() > 0 {
if isEmbList {
return merr.WrapErrParameterInvalid("", "",
"group by is not supported for multi-search-multi on embedding list fields")
}
+240 -36
View File
@@ -3729,7 +3729,12 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
}
}
t.Run("vector array with range search", func(t *testing.T) {
// ArrayOfVector-specific validation (range / iterator / group-by) has been
// moved out of parseSearchInfo. The checks below verify that parseSearchInfo
// now passes regardless of those features; the real rejections are asserted
// in TestSearchTask_ArrayOfVectorSimpleSearch and
// TestSearchTask_ArrayOfVectorHybridSearch.
t.Run("vector array with range search passes parseSearchInfo", func(t *testing.T) {
schema := createSchemaWithVectorArray("embeddings_list")
params := createSearchParams("embeddings_list")
@@ -3737,11 +3742,8 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
resetSearchParamsValue(params, ParamsKey, `{"nprobe": 10, "radius": 0.2}`)
searchInfo, err := parseSearchInfo(params, schema, nil, false)
assert.Error(t, err)
assert.Nil(t, searchInfo)
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
fmt.Println(err.Error())
assert.Contains(t, err.Error(), "range search is not supported for vector array (embedding list) fields")
assert.NoError(t, err)
assert.NotNil(t, searchInfo)
})
t.Run("vector array with group by passes parseSearchInfo", func(t *testing.T) {
@@ -3761,7 +3763,7 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
assert.NotNil(t, searchInfo)
})
t.Run("vector array with iterator", func(t *testing.T) {
t.Run("vector array with iterator passes parseSearchInfo", func(t *testing.T) {
schema := createSchemaWithVectorArray("embeddings_list")
params := createSearchParams("embeddings_list")
@@ -3772,14 +3774,11 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
})
searchInfo, err := parseSearchInfo(params, schema, nil, false)
assert.Error(t, err)
assert.Nil(t, searchInfo)
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
assert.Contains(t, err.Error(), "search iterator is not supported for vector array (embedding list) fields")
assert.Contains(t, err.Error(), "embeddings_list")
assert.NoError(t, err)
assert.NotNil(t, searchInfo)
})
t.Run("vector array with iterator v2", func(t *testing.T) {
t.Run("vector array with iterator v2 passes parseSearchInfo", func(t *testing.T) {
schema := createSchemaWithVectorArray("embeddings_list")
params := createSearchParams("embeddings_list")
@@ -3800,11 +3799,8 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
)
searchInfo, err := parseSearchInfo(params, schema, nil, false)
assert.Error(t, err)
assert.Nil(t, searchInfo)
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
assert.Contains(t, err.Error(), "search iterator is not supported for vector array (embedding list) fields")
assert.Contains(t, err.Error(), "embeddings_list")
assert.NoError(t, err)
assert.NotNil(t, searchInfo)
})
t.Run("normal search on vector array should succeed", func(t *testing.T) {
@@ -3863,18 +3859,6 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
assert.NotNil(t, searchInfo.planInfo)
})
t.Run("vector array with range search", func(t *testing.T) {
schema := createSchemaWithVectorArray("embeddings_list")
params := createSearchParams("embeddings_list")
resetSearchParamsValue(params, ParamsKey, `{"nprobe": 10, "radius": 0.2}`)
searchInfo, err := parseSearchInfo(params, schema, nil, false)
assert.Error(t, err)
assert.Nil(t, searchInfo)
// Should fail on range search first
assert.Contains(t, err.Error(), "range search is not supported for vector array (embedding list) fields")
})
t.Run("no anns field specified", func(t *testing.T) {
schema := createSchemaWithVectorArray("embeddings_list")
params := getValidSearchParams()
@@ -3896,7 +3880,9 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
// Should not trigger vector array validation for non-existent field
})
t.Run("hybrid search with outer group by on vector array", func(t *testing.T) {
t.Run("hybrid search with outer group by on vector array passes parseSearchInfo", func(t *testing.T) {
// Hybrid + ArrayOfVector + group-by is rejected in initAdvancedSearchRequest,
// not in parseSearchInfo. This subtest verifies the parse step no longer fails.
schema := createSchemaWithVectorArray("embeddings_list")
// Create rank params with group by
@@ -3916,12 +3902,9 @@ func TestSearchTask_parseSearchInfo(t *testing.T) {
assert.NoError(t, err)
searchParams := createSearchParams("embeddings_list")
// Parse search info with rank params
searchInfo, err := parseSearchInfo(searchParams, schema, parsedRankParams, false)
assert.Error(t, err)
assert.Nil(t, searchInfo)
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
assert.Contains(t, err.Error(), "group by search is not supported for vector array (embedding list) fields")
assert.NoError(t, err)
assert.NotNil(t, searchInfo)
})
})
@@ -5852,6 +5835,227 @@ func TestSearchTask_ArrayOfVectorGroupBy(t *testing.T) {
})
}
// TestSearchTask_ArrayOfVectorSimpleSearch verifies the placeholder-type-aware
// validation in initSearchRequest for range search and search iterator on
// ArrayOfVector fields:
// - element-level (plain vector placeholder) is allowed for both features.
// - embedding-list-level (EmbList placeholder) is rejected for both.
func TestSearchTask_ArrayOfVectorSimpleSearch(t *testing.T) {
paramtable.Init()
ctx := context.Background()
schema := &schemapb.CollectionSchema{
Name: "test_collection",
Fields: []*schemapb.FieldSchema{
{FieldID: 100, Name: "pk", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
{FieldID: 101, Name: "regular_vec", DataType: schemapb.DataType_FloatVector, TypeParams: []*commonpb.KeyValuePair{{Key: common.DimKey, Value: "4"}}},
},
StructArrayFields: []*schemapb.StructArrayFieldSchema{
{
FieldID: 103,
Name: "struct_array",
Fields: []*schemapb.FieldSchema{
{FieldID: 104, Name: "emb_vec", DataType: schemapb.DataType_ArrayOfVector, ElementType: schemapb.DataType_FloatVector, TypeParams: []*commonpb.KeyValuePair{{Key: common.DimKey, Value: "4"}}},
},
},
},
}
schemaInfo := newSchemaInfo(schema)
makePlaceholderGroup := func(phType commonpb.PlaceholderType) []byte {
phg := &commonpb.PlaceholderGroup{
Placeholders: []*commonpb.PlaceholderValue{{
Tag: "$0",
Type: phType,
Values: [][]byte{{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0}}, // 4 floats
}},
}
bs, _ := proto.Marshal(phg)
return bs
}
// paramsJSON is inlined into the ParamsKey field; setting withIterator adds
// the iterator flag as a separate KV pair.
makeTask := func(annsField string, phType commonpb.PlaceholderType, paramsJSON string, withIterator bool) *searchTask {
params := []*commonpb.KeyValuePair{
{Key: AnnsFieldKey, Value: annsField},
{Key: TopKKey, Value: "10"},
{Key: common.MetricTypeKey, Value: metric.L2},
{Key: ParamsKey, Value: paramsJSON},
}
if withIterator {
params = append(params, &commonpb.KeyValuePair{Key: IteratorField, Value: "True"})
}
phgBytes := makePlaceholderGroup(phType)
return &searchTask{
ctx: ctx,
collectionName: "test_collection",
SearchRequest: &internalpb.SearchRequest{
CollectionID: 1,
PartitionIDs: []int64{1},
OutputFieldsId: []int64{100},
PlaceholderGroup: nil,
DslType: commonpb.DslType_BoolExprV1,
},
request: &milvuspb.SearchRequest{
CollectionName: "test_collection",
OutputFields: []string{"pk"},
SearchParams: params,
SearchInput: &milvuspb.SearchRequest_PlaceholderGroup{
PlaceholderGroup: phgBytes,
},
Nq: 1,
ConsistencyLevel: commonpb.ConsistencyLevel_Session,
},
schema: schemaInfo,
translatedOutputFields: []string{"pk"},
tr: timerecord.NewTimeRecorder("test"),
queryInfos: []*planpb.QueryInfo{{}},
}
}
const rangeParams = `{"nprobe": 10, "radius": 0.2}`
const plainParams = `{"nprobe": 10}`
t.Run("element-level range search should succeed", func(t *testing.T) {
task := makeTask("emb_vec", commonpb.PlaceholderType_FloatVector, rangeParams, false)
err := task.initSearchRequest(ctx)
assert.NoError(t, err)
})
t.Run("element-level iterator should succeed", func(t *testing.T) {
task := makeTask("emb_vec", commonpb.PlaceholderType_FloatVector, plainParams, true)
err := task.initSearchRequest(ctx)
assert.NoError(t, err)
})
t.Run("emblist range search should fail", func(t *testing.T) {
task := makeTask("emb_vec", commonpb.PlaceholderType_EmbListFloatVector, rangeParams, false)
err := task.initSearchRequest(ctx)
assert.Error(t, err)
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
assert.Contains(t, err.Error(), "range search is not supported for multi-search-multi")
})
t.Run("emblist iterator should fail", func(t *testing.T) {
task := makeTask("emb_vec", commonpb.PlaceholderType_EmbListFloatVector, plainParams, true)
err := task.initSearchRequest(ctx)
assert.Error(t, err)
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
assert.Contains(t, err.Error(), "search iterator is not supported for multi-search-multi")
})
t.Run("regular vector range search should succeed", func(t *testing.T) {
// Regression: new checks must not impact plain FloatVector fields.
task := makeTask("regular_vec", commonpb.PlaceholderType_FloatVector, rangeParams, false)
err := task.initSearchRequest(ctx)
assert.NoError(t, err)
})
}
// TestSearchTask_ArrayOfVectorHybridSearch verifies that hybrid search rejects
// range search, search iterator, and group-by on ArrayOfVector fields directly
// at the initAdvancedSearchRequest layer (hybrid does not yet support the
// element-level/embedding-list-level split, so all three are rejected).
func TestSearchTask_ArrayOfVectorHybridSearch(t *testing.T) {
paramtable.Init()
ctx := context.Background()
schema := &schemapb.CollectionSchema{
Name: "test_collection",
Fields: []*schemapb.FieldSchema{
{FieldID: 100, Name: "pk", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
{FieldID: 101, Name: "regular_vec", DataType: schemapb.DataType_FloatVector, TypeParams: []*commonpb.KeyValuePair{{Key: common.DimKey, Value: "4"}}},
{FieldID: 102, Name: "scalar_field", DataType: schemapb.DataType_VarChar},
},
StructArrayFields: []*schemapb.StructArrayFieldSchema{
{
FieldID: 103,
Name: "struct_array",
Fields: []*schemapb.FieldSchema{
{FieldID: 104, Name: "emb_vec", DataType: schemapb.DataType_ArrayOfVector, ElementType: schemapb.DataType_FloatVector, TypeParams: []*commonpb.KeyValuePair{{Key: common.DimKey, Value: "4"}}},
},
},
},
}
schemaInfo := newSchemaInfo(schema)
// buildHybridTask constructs a hybrid-search task with a single sub-request.
// rangeRadius != "" attaches a radius param to the sub-request; withIterator
// appends the iterator flag; groupByField != "" adds GroupByFieldKey to the
// outer rank params.
buildHybridTask := func(annsField string, rangeRadius string, withIterator bool, groupByField string) *searchTask {
paramsJSON := `{"nprobe": 10}`
if rangeRadius != "" {
paramsJSON = `{"nprobe": 10, "radius": ` + rangeRadius + `}`
}
subParams := []*commonpb.KeyValuePair{
{Key: common.MetricTypeKey, Value: metric.L2},
{Key: ParamsKey, Value: paramsJSON},
{Key: AnnsFieldKey, Value: annsField},
{Key: TopKKey, Value: "10"},
}
if withIterator {
subParams = append(subParams, &commonpb.KeyValuePair{Key: IteratorField, Value: "True"})
}
outerParams := []*commonpb.KeyValuePair{
{Key: LimitKey, Value: "10"},
}
if groupByField != "" {
outerParams = append(outerParams, &commonpb.KeyValuePair{Key: GroupByFieldKey, Value: groupByField})
}
return &searchTask{
ctx: ctx,
SearchRequest: &internalpb.SearchRequest{
Base: &commonpb.MsgBase{
MsgType: commonpb.MsgType_Search,
Timestamp: uint64(time.Now().UnixNano()),
},
},
request: &milvuspb.SearchRequest{
CollectionName: "test_collection",
SearchParams: outerParams,
SubReqs: []*milvuspb.SubSearchRequest{
{
Dsl: "",
PlaceholderGroup: nil,
SearchParams: subParams,
},
},
},
schema: schemaInfo,
tr: timerecord.NewTimeRecorder("test"),
}
}
t.Run("hybrid with ArrayOfVector range search should fail", func(t *testing.T) {
qt := buildHybridTask("emb_vec", "0.2", false, "")
err := qt.initAdvancedSearchRequest(ctx)
assert.Error(t, err)
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
assert.Contains(t, err.Error(), "range search is not supported for vector array (embedding list) fields in hybrid search")
})
t.Run("hybrid with ArrayOfVector iterator should fail", func(t *testing.T) {
qt := buildHybridTask("emb_vec", "", true, "")
err := qt.initAdvancedSearchRequest(ctx)
assert.Error(t, err)
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
assert.Contains(t, err.Error(), "search iterator is not supported for vector array (embedding list) fields in hybrid search")
})
t.Run("hybrid with ArrayOfVector group by should fail", func(t *testing.T) {
qt := buildHybridTask("emb_vec", "", false, "scalar_field")
err := qt.initAdvancedSearchRequest(ctx)
assert.Error(t, err)
assert.ErrorIs(t, err, merr.ErrParameterInvalid)
assert.Contains(t, err.Error(), "group by search is not supported for vector array (embedding list) fields in hybrid search")
})
}
func TestSearchTask_SearchRequeryPolicy(t *testing.T) {
paramtable.Init()
ctx := context.Background()
@@ -30,6 +30,7 @@ import (
"github.com/milvus-io/milvus/pkg/v2/util/funcutil"
"github.com/milvus-io/milvus/pkg/v2/util/merr"
"github.com/milvus-io/milvus/pkg/v2/util/metric"
"github.com/milvus-io/milvus/pkg/v2/util/typeutil"
"github.com/milvus-io/milvus/tests/integration"
)
@@ -329,3 +330,137 @@ func (s *HelloMilvusSuite) TestRangeSearchL2() {
log.Info("=========================")
log.Info("=========================")
}
// TestRangeSearchElementLevelL2 runs range search on a VECTOR_ARRAY sub-field
// in element-level mode: HNSW + L2 index on the sub-field, single-vector
// placeholder with element_level=true, radius/range_filter per L2 semantics.
func (s *HelloMilvusSuite) TestRangeSearchElementLevelL2() {
c := s.Cluster
ctx, cancel := context.WithCancel(c.GetContext())
defer cancel()
prefix := "TestRangeSearchElementLevelL2"
dbName := ""
collectionName := prefix + funcutil.GenRandomStr()
dim := 32
rowNum := 1000
schema := integration.ConstructSchemaOfVecDataTypeWithStruct(collectionName, dim, true)
marshaledSchema, err := proto.Marshal(schema)
s.NoError(err)
createCollectionStatus, err := c.MilvusClient.CreateCollection(ctx, &milvuspb.CreateCollectionRequest{
DbName: dbName,
CollectionName: collectionName,
Schema: marshaledSchema,
ShardsNum: common.DefaultShardsNum,
})
s.NoError(err)
s.NoError(merr.Error(createCollectionStatus))
fVecColumn := integration.NewFloatVectorFieldData(integration.FloatVecField, rowNum, dim)
structColumn := integration.NewStructArrayFieldData(schema.StructArrayFields[0], integration.StructArrayField, rowNum, dim)
hashKeys := integration.GenerateHashKeys(rowNum)
insertResult, err := c.MilvusClient.Insert(ctx, &milvuspb.InsertRequest{
DbName: dbName,
CollectionName: collectionName,
FieldsData: []*schemapb.FieldData{fVecColumn, structColumn},
HashKeys: hashKeys,
NumRows: uint32(rowNum),
})
s.NoError(err)
s.True(merr.Ok(insertResult.GetStatus()))
flushResp, err := c.MilvusClient.Flush(ctx, &milvuspb.FlushRequest{
DbName: dbName,
CollectionNames: []string{collectionName},
})
s.NoError(err)
segmentIDs, has := flushResp.GetCollSegIDs()[collectionName]
s.Require().True(has)
s.Require().NotEmpty(segmentIDs)
flushTs, has := flushResp.GetCollFlushTs()[collectionName]
s.Require().True(has)
s.WaitForFlush(ctx, segmentIDs.GetData(), flushTs, dbName, collectionName)
// regular float vector index, required for loading
createIndexStatus, err := c.MilvusClient.CreateIndex(ctx, &milvuspb.CreateIndexRequest{
CollectionName: collectionName,
FieldName: integration.FloatVecField,
IndexName: "_default",
ExtraParams: integration.ConstructIndexParam(dim, integration.IndexFaissIvfFlat, metric.L2),
})
s.NoError(err)
s.NoError(merr.Error(createIndexStatus))
s.WaitForIndexBuilt(ctx, collectionName, integration.FloatVecField)
// non-EmbList metric on ArrayOfVector → each element vector is indexed
// independently (proxy task_index.go:658).
subFieldName := typeutil.ConcatStructFieldName(integration.StructArrayField, integration.StructSubFloatVecField)
createIndexStatus, err = c.MilvusClient.CreateIndex(ctx, &milvuspb.CreateIndexRequest{
CollectionName: collectionName,
FieldName: subFieldName,
IndexName: "struct_vec_element_index",
ExtraParams: integration.ConstructIndexParam(dim, integration.IndexHNSW, metric.L2),
})
s.NoError(err)
s.NoError(merr.Error(createIndexStatus))
s.WaitForIndexBuilt(ctx, collectionName, subFieldName)
loadStatus, err := c.MilvusClient.LoadCollection(ctx, &milvuspb.LoadCollectionRequest{
DbName: dbName,
CollectionName: collectionName,
})
s.NoError(err)
s.NoError(merr.Error(loadStatus))
s.WaitForLoad(ctx, collectionName)
// L2 semantics: radius is upper bound, range_filter is lower bound.
expr := fmt.Sprintf("%s > 0", integration.Int64Field)
nq := 2
topk := 10
roundDecimal := -1
radius := 20
filter := 10
params := integration.GetSearchParams(integration.IndexHNSW, metric.L2)
// only radius
params["radius"] = radius
searchReq := integration.ConstructElementLevelSearchRequest("", collectionName, expr,
subFieldName, schemapb.DataType_FloatVector, []string{integration.StructArrayField},
metric.L2, params, nq, dim, topk, roundDecimal)
searchResult, err := c.MilvusClient.Search(ctx, searchReq)
s.NoError(err)
if err := merr.Error(searchResult.GetStatus()); err != nil {
log.Warn("element-level range search with radius only failed", zap.Error(err))
}
s.NoError(merr.Error(searchResult.GetStatus()))
// radius + range_filter
params["range_filter"] = filter
searchReq = integration.ConstructElementLevelSearchRequest("", collectionName, expr,
subFieldName, schemapb.DataType_FloatVector, []string{integration.StructArrayField},
metric.L2, params, nq, dim, topk, roundDecimal)
searchResult, err = c.MilvusClient.Search(ctx, searchReq)
s.NoError(err)
if err := merr.Error(searchResult.GetStatus()); err != nil {
log.Warn("element-level range search with radius+range_filter failed", zap.Error(err))
}
s.NoError(merr.Error(searchResult.GetStatus()))
// illegal: for L2, radius must be > range_filter
params["radius"] = filter
params["range_filter"] = radius
searchReq = integration.ConstructElementLevelSearchRequest("", collectionName, expr,
subFieldName, schemapb.DataType_FloatVector, []string{integration.StructArrayField},
metric.L2, params, nq, dim, topk, roundDecimal)
searchResult, _ = c.MilvusClient.Search(ctx, searchReq)
s.Error(merr.Error(searchResult.GetStatus()))
log.Info("=========================")
log.Info("=========================")
log.Info("TestRangeSearchElementLevelL2 succeed")
log.Info("=========================")
log.Info("=========================")
}
+45
View File
@@ -190,6 +190,51 @@ func ConstructEmbeddingListSearchRequest(
return constructSearchRequest(dbName, collectionName, expr, vecField, true, vectorType, outputFields, metricType, params, nq, dim, topk, roundDecimal)
}
// ConstructElementLevelSearchRequest builds a search request that runs
// element-level search against a VECTOR_ARRAY field. The placeholder carries
// a single query vector per nq row with element_level=true.
func ConstructElementLevelSearchRequest(
dbName, collectionName string,
expr string,
vecField string,
vectorType schemapb.DataType,
outputFields []string,
metricType string,
params map[string]any,
nq, dim int, topk, roundDecimal int,
) *milvuspb.SearchRequest {
b, err := json.Marshal(params)
if err != nil {
panic(err)
}
plg := constructPlaceholderGroup(nq, dim, vectorType, false)
for _, ph := range plg.Placeholders {
ph.ElementLevel = true
}
plgBs, err := proto.Marshal(plg)
if err != nil {
panic(err)
}
return &milvuspb.SearchRequest{
DbName: dbName,
CollectionName: collectionName,
Dsl: expr,
SearchInput: &milvuspb.SearchRequest_PlaceholderGroup{
PlaceholderGroup: plgBs,
},
DslType: commonpb.DslType_BoolExprV1,
OutputFields: outputFields,
SearchParams: []*commonpb.KeyValuePair{
{Key: common.MetricTypeKey, Value: metricType},
{Key: proxy.ParamsKey, Value: string(b)},
{Key: AnnsFieldKey, Value: vecField},
{Key: common.TopKKey, Value: strconv.Itoa(topk)},
{Key: RoundDecimalKey, Value: strconv.Itoa(roundDecimal)},
},
Nq: int64(nq),
}
}
func constructSearchRequest(
dbName, collectionName string,
expr string,
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff