From a32026fe62a0c4ceefc33afb0b41a5bad8702f7d Mon Sep 17 00:00:00 2001 From: Spade A <71589810+SpadeA-Tang@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:30:20 +0800 Subject: [PATCH] fix: reject element_filter for row-level vector search (#50301) issue: https://github.com/milvus-io/milvus/issues/49438 ref: https://github.com/milvus-io/milvus/issues/42148 --------- Signed-off-by: SpadeA --- .../src/exec/operator/VectorSearchNode.cpp | 7 +++ internal/proxy/task_search.go | 31 +++++++++++ internal/proxy/task_search_test.go | 53 +++++++++++++++++++ .../struct_array_element_query_test.go | 7 ++- 4 files changed, 96 insertions(+), 2 deletions(-) diff --git a/internal/core/src/exec/operator/VectorSearchNode.cpp b/internal/core/src/exec/operator/VectorSearchNode.cpp index 4c3a965746..a51b936edc 100644 --- a/internal/core/src/exec/operator/VectorSearchNode.cpp +++ b/internal/core/src/exec/operator/VectorSearchNode.cpp @@ -116,6 +116,13 @@ PhyVectorSearchNode::GetOutput() { milvus::BitsetView search_view; int64_t data_cnt = active_count_; + if (!ph.element_level_ && query_context_->bitset_is_element_level()) { + ThrowInfo(ExprInvalid, + "element-level filter bitset cannot be used for row-level " + "vector search; use MATCH_ANY/MATCH_* for row-level struct " + "array filtering"); + } + if (query_context_->get_all_rows_visible() && !ph.element_level_) { // search_view stays default-constructed (empty) } else { diff --git a/internal/proxy/task_search.go b/internal/proxy/task_search.go index a08632a59e..50ff9671bf 100644 --- a/internal/proxy/task_search.go +++ b/internal/proxy/task_search.go @@ -558,6 +558,9 @@ func (t *searchTask) initAdvancedSearchRequest(ctx context.Context) error { if err != nil { return err } + if err := validateElementFilterVectorSearch(plan, t.schema.CollectionSchema, queryInfo.GetQueryFieldId(), placeholderType); err != nil { + return err + } subSearchInfo := classifyHybridSubSearch(t.schema.CollectionSchema, queryInfo.GetQueryFieldId(), placeholderType) collapseConfig, elementScopeProvided, sanitizedSearchParams, err := parseAndRemoveElementScope(queryInfo.GetSearchParams()) @@ -958,6 +961,9 @@ func (t *searchTask) initSearchRequest(ctx context.Context) error { if err != nil { return err } + if err := validateElementFilterVectorSearch(plan, t.schema.CollectionSchema, t.FieldId, placeholderType); err != nil { + return err + } // For ArrayOfVector fields, the placeholder type decides the search semantics: // - Element-level (plain vector placeholder): behaves like a normal single-vector @@ -1174,6 +1180,31 @@ func isEmbeddingListPlaceholderType(pt commonpb.PlaceholderType) bool { } } +func validateElementFilterVectorSearch(plan *planpb.PlanNode, schema *schemapb.CollectionSchema, fieldID int64, placeholderType commonpb.PlaceholderType) error { + anns := plan.GetVectorAnns() + if anns == nil { + return nil + } + elementFilter := anns.GetPredicates().GetElementFilterExpr() + if elementFilter == nil { + return nil + } + + field := typeutil.GetField(schema, fieldID) + parentStructName, isStructSubField := getStructParentFieldName(schema, fieldID) + _, isPlainVectorPlaceholderType := placeholderTypeToDataType[placeholderType] + if field != nil && + field.GetDataType() == schemapb.DataType_ArrayOfVector && + isStructSubField && + parentStructName == elementFilter.GetStructName() && + isPlainVectorPlaceholderType { + return nil + } + + return merr.WrapErrParameterInvalidMsg( + "element_filter is only supported for element-level search on vector sub-fields of the same struct array; use MATCH_ANY/MATCH_* for row-level vector search") +} + func (t *searchTask) PostExecute(ctx context.Context) error { ctx, sp := otel.Tracer(typeutil.ProxyRole).Start(ctx, "Proxy-Search-PostExecute") defer sp.End() diff --git a/internal/proxy/task_search_test.go b/internal/proxy/task_search_test.go index db8c3ae974..50543b802a 100644 --- a/internal/proxy/task_search_test.go +++ b/internal/proxy/task_search_test.go @@ -6153,6 +6153,7 @@ func TestSearchTask_ArrayOfVectorSimpleSearch(t *testing.T) { 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"}}}, + {FieldID: 105, Name: typeutil.ConcatStructFieldName("struct_array", "price"), DataType: schemapb.DataType_Array, ElementType: schemapb.DataType_Int64}, }, }, }, @@ -6263,6 +6264,37 @@ func TestSearchTask_ArrayOfVectorSimpleSearch(t *testing.T) { err := task.initSearchRequest(ctx) assert.NoError(t, err) }) + + t.Run("regular vector with element_filter should fail", func(t *testing.T) { + task := makeTask("regular_vec", commonpb.PlaceholderType_FloatVector, plainParams, false, false) + task.request.Dsl = `element_filter(struct_array, $[price] > 10)` + + err := task.initSearchRequest(ctx) + + assert.Error(t, err) + assert.ErrorIs(t, err, merr.ErrParameterInvalid) + assert.Contains(t, err.Error(), "element_filter is only supported") + }) + + t.Run("element-level struct vector with element_filter should succeed", func(t *testing.T) { + task := makeTask("emb_vec", commonpb.PlaceholderType_FloatVector, plainParams, false, false) + task.request.Dsl = `element_filter(struct_array, $[price] > 10)` + + err := task.initSearchRequest(ctx) + + assert.NoError(t, err) + }) + + t.Run("embedding-list struct vector with element_filter should fail", func(t *testing.T) { + task := makeTask("emb_vec", commonpb.PlaceholderType_EmbListFloatVector, plainParams, false, false) + task.request.Dsl = `element_filter(struct_array, $[price] > 10)` + + err := task.initSearchRequest(ctx) + + assert.Error(t, err) + assert.ErrorIs(t, err, merr.ErrParameterInvalid) + assert.Contains(t, err.Error(), "element_filter is only supported") + }) } // TestSearchTask_ArrayOfVectorHybridSearch verifies ArrayOfVector hybrid @@ -6284,6 +6316,7 @@ func TestSearchTask_ArrayOfVectorHybridSearch(t *testing.T) { 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"}}}, + {FieldID: 105, Name: typeutil.ConcatStructFieldName("struct_array", "price"), DataType: schemapb.DataType_Array, ElementType: schemapb.DataType_Int64}, }, }, }, @@ -6392,6 +6425,26 @@ func TestSearchTask_ArrayOfVectorHybridSearch(t *testing.T) { assert.NoError(t, err) }) + t.Run("hybrid regular vector with element_filter should fail", func(t *testing.T) { + qt := buildHybridTaskWithMetric("regular_vec", metric.L2, commonpb.PlaceholderType_FloatVector, "", false, "") + qt.request.SubReqs[0].Dsl = `element_filter(struct_array, $[price] > 10)` + + err := qt.initAdvancedSearchRequest(ctx) + + assert.Error(t, err) + assert.ErrorIs(t, err, merr.ErrParameterInvalid) + assert.Contains(t, err.Error(), "element_filter is only supported") + }) + + t.Run("hybrid element-level struct vector with element_filter should succeed", func(t *testing.T) { + qt := buildElementHybridTask("emb_vec", "", false, "") + qt.request.SubReqs[0].Dsl = `element_filter(struct_array, $[price] > 10)` + + err := qt.initAdvancedSearchRequest(ctx) + + assert.NoError(t, err) + }) + t.Run("hybrid with element-level ArrayOfVector range search should fail", func(t *testing.T) { qt := buildElementHybridTask("emb_vec", "0.2", false, "") err := qt.initAdvancedSearchRequest(ctx) diff --git a/tests/go_client/testcases/struct_array_element_query_test.go b/tests/go_client/testcases/struct_array_element_query_test.go index 6c81f960bd..5cc308cffd 100644 --- a/tests/go_client/testcases/struct_array_element_query_test.go +++ b/tests/go_client/testcases/struct_array_element_query_test.go @@ -294,7 +294,7 @@ func runInvertedIndexCase(t *testing.T, ctx CtxT, mc MC, scalarField, namePrefix index.NewHNSWIndex(entity.COSINE, 16, 200))) common.CheckErr(t, err, true) _, err = mc.CreateIndex(ctx, client.NewCreateIndexOption(collName, "structA[embedding]", - index.NewHNSWIndex(entity.MaxSimCosine, 16, 200))) + index.NewHNSWIndex(entity.COSINE, 16, 200))) common.CheckErr(t, err, true) _, err = mc.CreateIndex(ctx, client.NewCreateIndexOption(collName, "structA["+scalarField+"]", index.NewGenericIndex("inv_idx", map[string]string{"index_type": "INVERTED"}))) @@ -305,9 +305,12 @@ func runInvertedIndexCase(t *testing.T, ctx CtxT, mc MC, scalarField, namePrefix common.CheckErr(t, loadTask.Await(ctx), true) // Search using row 0's first embedding (deterministic from SeedVector). - queryEmb := entity.FloatVectorArray{entity.FloatVector(hp.SeedVector(0, opt.Dim))} + // Use a plain vector so element_filter runs as element-level search; EmbList + // queries are row-level and must use MATCH_ANY/MATCH_* filters instead. + queryEmb := entity.FloatVector(hp.SeedVector(0, opt.Dim)) rs, err := mc.Search(ctx, client.NewSearchOption(collName, 10, []entity.Vector{queryEmb}). WithANNSField("structA[embedding]"). + WithSearchParam("metric_type", "COSINE"). WithFilter(filter). WithOutputFields("id"). WithConsistencyLevel(entity.ClStrong))