mirror of
https://github.com/milvus-io/milvus.git
synced 2026-07-21 10:15:43 +00:00
issue: #51253 > Rebased on master after #51254 merged. The `db_name`/`db_id` part of the original PR is now covered by #51254; this PR is reduced to the two remaining gaps on the same query-by-id path. ## Problem When `DescribeCollection` is called with **only a `collectionID`** — no collection name, no db name (e.g. the HTTP management API on `:9091`) — two gaps remain after #51254: 1. **Top-level `collection_name` is empty** on both the cached provider path and the `describeCollectionTask` path. The response is initialized from the (empty) `request.CollectionName` before the name is resolved, and never backfilled. 2. **The cached provider discards the caller-provided collection id.** After deriving the name from the id, it re-resolves the id from that name via `GetCollectionID(db, name)`. Id-only lookups are cached under the request db name — empty here — so with same-name collections in different databases, a concurrent cache refresh can rebind the entry under the `""` key and the request would silently describe the wrong collection (the derived name matches, the id does not). ## Fix - `service_provider.go`: - Backfill `resp.CollectionName` from the resolved cached schema when the request carried no name. Requests that pass a name (including aliases) still echo it unchanged. - Skip the name→id re-resolution when the caller already supplied the id. `GetCollectionInfo` already validates the cache entry against the id (`collInfo.collID != collectionID` → refresh by id), so the caller-provided id is authoritative end to end. Name+id requests keep the existing name-precedence behavior. - Resolve identifiers on local copies instead of rewriting `request.CollectionName`/`CollectionID` in place — the access log interceptor and the success metric labels serialize the request after the handler returns, and previously recorded the resolved values instead of what the client sent. - `task.go`: backfill `t.result.CollectionName` from the coordinator result on the non-cached path. - `meta_cache.go`: fix a stale comment on `ResolveCollectionAlias` — `update()` now always keys `collInfo` by the real collection name (aliases live in the separate alias map), the old comment described pre-refactor behavior. - Tests: - `TestCachedProxyServiceProvider_DescribeCollection_ByIDFillsNameAndUsesRequestID`: drives the id-only path; `GetCollectionID` is deliberately not mocked, so any regression back to re-resolving the id panics the test. - `TestDescribeCollectionTask_FillsNameFromResultWhenQueriedByID`: same assertion for the non-cached path. - Removed the now-unused `GetCollectionID` expectation from the test added in #51254 (that call no longer happens on the id-only path). ## Follow-up commit: entity-keyed meta cache with a cluster-wide id index Reviewing this path surfaced a deeper issue in the proxy meta cache, fixed in the second commit: - By-id lookups (`GetCollectionName`, `GetCollectionInfo` with an empty name) linearly scanned a whole db bucket under the read lock, on every request even on cache hits. - The bucket scanned/filled was keyed by the *request* db name — empty for id-only calls — so entries landed in a bogus `""` bucket: a collection also cached under its real db was duplicated, and same-name collections of different databases evicted each other from the single `""`-keyed slot. The cache is now entity-keyed instead of request-keyed: - Fills land under the collection's **actual database** (carried in the describe response); the `""` bucket no longer exists. - A cluster-wide `collectionID → entry` index serves by-id lookups in **O(1)** regardless of the request db — matching rootcoord, which resolves by-id describes straight from `collID2Meta` without consulting the db name. - Name lookups normalize an empty db name to `default`, mirroring rootcoord's backward-compat normalization (`meta_table.getCollectionByNameInternal`). This also closes a latent cross-database mis-hit: a name lookup with an empty db could previously return whichever same-name collection was last id-cached under the `""` bucket. - All removal paths maintain the index (pointer-identity-guarded unindexing); invalidation sweeps stay exhaustive. ## Verification - Ran locally against a current master core build: the full `TestMetaCache*`, `TestCachedProxyServiceProvider*`, `TestDescribeCollectionTask*`, alias and partition test sets all pass, plus the **full `internal/proxy` suite** (only the pre-existing etcd-dependent `TestProxyRpcLimit` fails locally — it needs a live etcd, unrelated to this change). - New tests: `TestCachedProxyServiceProvider_DescribeCollection_ByIDFillsNameAndUsesRequestID` (id-only path; also asserts the request is not rewritten), `TestDescribeCollectionTask_FillsNameFromResultWhenQueriedByID`, `TestMetaCache_ByIDIndexRealDBBucket` (same-name collections in two databases filled by id: no eviction, entries under real dbs, by-name reuses by-id fill, every removal path cleans the index), `TestMetaCache_EmptyDBNameSharesDefaultEntry`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- ## Update: proxy meta cache rebuilt around an id-primary store (2 new commits) Following review discussions on invalidation cost and the alias-DDL races, this PR now also rebuilds the cache (issue: #51533, design doc: `docs/design-docs/design_docs/20260716-proxy-metacache-id-inverted-index.md` in this PR): 1. **`fix: forward the pre-alter alias target id in the AlterAlias expiration`** — rootcoord resolves the pre-alter target under its database lock and carries it in the new additive `AlterAliasMessageHeader.old_collection_id`; the ack callback emits a second expiration entry so proxies evict BOTH AlterAlias targets by id. Closes the race where a concurrent Describe re-points the proxy's alias resolution before the expiration arrives, leaving the old target permanently stale. Older rootcoords (field 0) fall back to the proxy's hint resolution. 2. **`enhance: rebuild the proxy meta cache around an id-primary store`** — the primary store is keyed by the cluster-unique collection id (single source of truth, with a per-database generation); name/alias resolution become hints validated against the primary on read; partition caches are keyed by id; fills are ordered against invalidations by a fill RWMutex (drain in-flight describes before evicting), replacing the per-collection timestamp floor. Every invalidation becomes O(1) — no more full-cache scans under the write lock on Load/Release/Drop/Rename/Alter broadcasts, and DropDatabase/AlterDatabase becomes a generation bump. Also removes a latent stale read (alias-keyed partition entries surviving DropCollection). Verification: targeted cache suites green (rename, alias re-point under concurrent describe, drop+recreate with a reused name, db-generation invalidation, cross-db isolation, gated-mock fill/invalidation ordering); three negative controls (hint validation / dbGen check / fill drain disabled) each fail exactly the test guarding them; full-package `-race` delegated to CI. --- ## Update: simplification series (final form) Following design review, the cache was iteratively simplified to an **id-primary store with declared hints** — every mechanism that could be replaced by an invariant was deleted (net-negative diffs throughout): - Primary store keyed by the cluster-unique collection id; liveness IS presence. Name/alias resolution are hints written ONLY together with their entry (declared aliases) and validated against the primary on every read — a stale hint can only cost one extra describe, never a stale read. - Fill/invalidation ordering via a fill RWMutex (drain in-flight describes before evicting); the per-collection timestamp floor, database generations, background GC, alias negative cache, per-partition cache, and the resolve-and-forget DescribeAlias path are all **deleted**. Evictions clean everything the entry owns synchronously; **no invalidation path performs any RPC**. - Old-rootcoord fallbacks: id-describes without DbName are served uncached; alias-DDL broadcasts without ids trigger hint resolution plus a holder scan **gated on that exact fingerprint** (dead code once rootcoords are upgraded), closing the ghost-alias case with no healing event. - **Accepted gaps (WONT-FIX)** are recorded in the design doc §6 — notably the upgrade-window new-target `Aliases` display lag (the association exists only at rootcoord; self-heals on first use; display-only). See `docs/design-docs/design_docs/20260716-proxy-metacache-id-inverted-index.md` for the full design, invariants and trade-offs. Signed-off-by: xiaofanluan <xf@hjjaq.com> Co-authored-by: xiaofanluan <xf@hjjaq.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
7039 lines
224 KiB
Go
7039 lines
224 KiB
Go
// Licensed to the LF AI & Data foundation under one
|
|
// or more contributor license agreements. See the NOTICE file
|
|
// distributed with this work for additional information
|
|
// regarding copyright ownership. The ASF licenses this file
|
|
// to you under the Apache License, Version 2.0 (the
|
|
// "License"); you may not use this file except in compliance
|
|
// with the License. You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
|
|
package proxy
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"slices"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/apache/arrow/go/v17/arrow/memory"
|
|
"github.com/bytedance/mockey"
|
|
"github.com/stretchr/testify/mock"
|
|
"github.com/stretchr/testify/suite"
|
|
"go.opentelemetry.io/otel"
|
|
"go.opentelemetry.io/otel/trace"
|
|
"google.golang.org/grpc"
|
|
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/commonpb"
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/milvuspb"
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
|
|
"github.com/milvus-io/milvus/internal/mocks"
|
|
"github.com/milvus-io/milvus/internal/proxy/search_agg"
|
|
"github.com/milvus-io/milvus/internal/proxy/shardclient"
|
|
"github.com/milvus-io/milvus/internal/util/function/chain"
|
|
"github.com/milvus-io/milvus/internal/util/function/highlight"
|
|
"github.com/milvus-io/milvus/internal/util/segcore"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/internalpb"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/planpb"
|
|
"github.com/milvus-io/milvus/pkg/v3/proto/querypb"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/merr"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/testutils"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/timerecord"
|
|
)
|
|
|
|
func TestSearchPipeline(t *testing.T) {
|
|
suite.Run(t, new(SearchPipelineSuite))
|
|
}
|
|
|
|
type SearchPipelineSuite struct {
|
|
suite.Suite
|
|
span trace.Span
|
|
}
|
|
|
|
type searchPipelineTestOperator func(ctx context.Context, span trace.Span, inputs ...any) ([]any, error)
|
|
|
|
func (op searchPipelineTestOperator) run(ctx context.Context, span trace.Span, inputs ...any) ([]any, error) {
|
|
return op(ctx, span, inputs...)
|
|
}
|
|
|
|
func testSearchResultIDs(ids ...int64) *schemapb.IDs {
|
|
return &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: ids},
|
|
},
|
|
}
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) SetupTest() {
|
|
_, sp := otel.Tracer("test").Start(context.Background(), "Proxy-Search-PostExecute")
|
|
s.span = sp
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TearDownTest() {
|
|
s.span.End()
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestBuildChainFromFunctionChainRerankMeta() {
|
|
repr, err := chain.ProtoChainToRepr(l2LimitFunctionChain(10))
|
|
s.Require().NoError(err)
|
|
|
|
fc, err := buildChainFromMeta(&functionChainRerankMeta{repr: repr}, nil, nil, nil, memory.NewGoAllocator())
|
|
s.Require().NoError(err)
|
|
s.NotNil(fc)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSerializeBucketKeyPreservesRequestedOrder() {
|
|
bucket := &search_agg.AggBucketResult{
|
|
Key: map[int64]interface{}{
|
|
200: "brand-a",
|
|
100: "category-x",
|
|
},
|
|
}
|
|
fieldIDToName := map[int64]string{
|
|
200: "brand",
|
|
100: "category",
|
|
}
|
|
|
|
serialized := serializeAggBucket(bucket, fieldIDToName, []search_agg.LevelContext{{OwnFieldIDs: []int64{200, 100}}}, 0)
|
|
|
|
s.Require().Len(serialized.GetKey(), 2)
|
|
s.Equal(int64(200), serialized.GetKey()[0].GetFieldId())
|
|
s.Equal("brand", serialized.GetKey()[0].GetFieldName())
|
|
s.Equal("brand-a", serialized.GetKey()[0].GetStringVal())
|
|
s.Equal(int64(100), serialized.GetKey()[1].GetFieldId())
|
|
s.Equal("category", serialized.GetKey()[1].GetFieldName())
|
|
s.Equal("category-x", serialized.GetKey()[1].GetStringVal())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSerializeBucketKeyLeavesNullValueUnset() {
|
|
bucket := &search_agg.AggBucketResult{
|
|
Key: map[int64]interface{}{
|
|
100: nil,
|
|
},
|
|
}
|
|
fieldIDToName := map[int64]string{100: "category"}
|
|
|
|
serialized := serializeAggBucket(bucket, fieldIDToName, []search_agg.LevelContext{{OwnFieldIDs: []int64{100}}}, 0)
|
|
|
|
s.Require().Len(serialized.GetKey(), 1)
|
|
s.Equal(int64(100), serialized.GetKey()[0].GetFieldId())
|
|
s.Equal("category", serialized.GetKey()[0].GetFieldName())
|
|
s.Nil(serialized.GetKey()[0].GetValue())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSerializeAggHitFieldsLeavesNullValueUnset() {
|
|
fields := serializeAggHitFields(map[int64]interface{}{100: nil}, map[int64]string{100: "nullable_field"})
|
|
|
|
s.Require().Len(fields, 1)
|
|
s.Equal(int64(100), fields[0].GetFieldId())
|
|
s.Equal("nullable_field", fields[0].GetFieldName())
|
|
s.Nil(fields[0].GetValue())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSearchReduceOp() {
|
|
nq := int64(2)
|
|
topk := int64(10)
|
|
pk := &schemapb.FieldSchema{
|
|
FieldID: 101,
|
|
Name: "pk",
|
|
DataType: schemapb.DataType_Int64,
|
|
IsPrimaryKey: true,
|
|
AutoID: true,
|
|
}
|
|
data := genTestSearchResultData(nq, topk, schemapb.DataType_Int64, "intField", 102, false)
|
|
op := searchReduceOperator{
|
|
context.Background(),
|
|
pk,
|
|
nq,
|
|
topk,
|
|
0,
|
|
1,
|
|
[]int64{1},
|
|
[]*planpb.QueryInfo{{}},
|
|
nil,
|
|
false,
|
|
}
|
|
_, err := op.run(context.Background(), s.span, []*internalpb.SearchResults{data})
|
|
s.NoError(err)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestHybridSearchReduceOp() {
|
|
nq := int64(2)
|
|
topk := int64(10)
|
|
pk := &schemapb.FieldSchema{
|
|
FieldID: 101,
|
|
Name: "pk",
|
|
DataType: schemapb.DataType_Int64,
|
|
IsPrimaryKey: true,
|
|
AutoID: true,
|
|
}
|
|
data1 := genTestSearchResultData(nq, topk, schemapb.DataType_Int64, "intField", 102, true)
|
|
data1.SubResults[0].ReqIndex = 0
|
|
data2 := genTestSearchResultData(nq, topk, schemapb.DataType_Int64, "intField", 102, true)
|
|
data2.SubResults[0].ReqIndex = 1
|
|
|
|
subReqs := []*internalpb.SubSearchRequest{
|
|
{
|
|
Nq: 2,
|
|
Topk: 10,
|
|
Offset: 0,
|
|
},
|
|
{
|
|
Nq: 2,
|
|
Topk: 10,
|
|
Offset: 0,
|
|
},
|
|
}
|
|
|
|
op := hybridSearchReduceOperator{
|
|
context.Background(),
|
|
subReqs,
|
|
pk,
|
|
1,
|
|
[]int64{1},
|
|
[]*planpb.QueryInfo{{}, {}},
|
|
nil,
|
|
}
|
|
_, err := op.run(context.Background(), s.span, []*internalpb.SearchResults{data1, data2})
|
|
s.NoError(err)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestRerankOp() {
|
|
schema := &schemapb.CollectionSchema{
|
|
Name: "test",
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "pk", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "text", DataType: schemapb.DataType_VarChar},
|
|
{
|
|
FieldID: 102, Name: "vector", DataType: schemapb.DataType_FloatVector,
|
|
TypeParams: []*commonpb.KeyValuePair{
|
|
{Key: "dim", Value: "4"},
|
|
},
|
|
},
|
|
{FieldID: 103, Name: "ts", DataType: schemapb.DataType_Int64},
|
|
},
|
|
}
|
|
functionSchema := &schemapb.FunctionSchema{
|
|
Name: "test",
|
|
Type: schemapb.FunctionType_Rerank,
|
|
InputFieldNames: []string{"ts"},
|
|
OutputFieldNames: []string{},
|
|
Params: []*commonpb.KeyValuePair{
|
|
{Key: "reranker", Value: "decay"},
|
|
{Key: "origin", Value: "4"},
|
|
{Key: "scale", Value: "4"},
|
|
{Key: "offset", Value: "4"},
|
|
{Key: "decay", Value: "0.5"},
|
|
{Key: "function", Value: "gauss"},
|
|
},
|
|
}
|
|
nq := int64(2)
|
|
topk := int64(10)
|
|
offset := int64(0)
|
|
|
|
reduceOp := searchReduceOperator{
|
|
context.Background(),
|
|
schema.Fields[0],
|
|
nq,
|
|
topk,
|
|
offset,
|
|
1,
|
|
[]int64{1},
|
|
[]*planpb.QueryInfo{{}},
|
|
nil,
|
|
false,
|
|
}
|
|
|
|
data := genTestSearchResultData(nq, topk, schemapb.DataType_Int64, "ts", 103, false)
|
|
reduced, err := reduceOp.run(context.Background(), s.span, []*internalpb.SearchResults{data})
|
|
s.NoError(err)
|
|
|
|
funcScoreSchema := &schemapb.FunctionScore{
|
|
Functions: []*schemapb.FunctionSchema{functionSchema},
|
|
}
|
|
op := rerankOperator{
|
|
nq: nq,
|
|
topK: topk,
|
|
offset: offset,
|
|
roundDecimal: -1,
|
|
collSchema: schema,
|
|
rerankMeta: newRerankMeta(schema, funcScoreSchema),
|
|
}
|
|
|
|
_, err = op.run(context.Background(), s.span, reduced[0], []string{"IP"})
|
|
s.NoError(err)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestRerankOpWithFunctionChainMeta() {
|
|
schema := &schemapb.CollectionSchema{
|
|
Name: "test",
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "pk", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "ts", DataType: schemapb.DataType_Int64},
|
|
},
|
|
}
|
|
nq := int64(2)
|
|
topk := int64(10)
|
|
limit := int64(3)
|
|
|
|
reduceOp := searchReduceOperator{
|
|
context.Background(),
|
|
schema.Fields[0],
|
|
nq,
|
|
topk,
|
|
0,
|
|
1,
|
|
[]int64{1},
|
|
[]*planpb.QueryInfo{{}},
|
|
nil,
|
|
false,
|
|
}
|
|
|
|
data := genTestSearchResultData(nq, topk, schemapb.DataType_Int64, "ts", 101, false)
|
|
reduced, err := reduceOp.run(context.Background(), s.span, []*internalpb.SearchResults{data})
|
|
s.Require().NoError(err)
|
|
|
|
repr, err := chain.ProtoChainToRepr(l2LimitFunctionChain(limit))
|
|
s.Require().NoError(err)
|
|
|
|
op := rerankOperator{
|
|
nq: nq,
|
|
topK: topk,
|
|
roundDecimal: -1,
|
|
collSchema: schema,
|
|
rerankMeta: &functionChainRerankMeta{repr: repr},
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, reduced[0], []string{"IP"})
|
|
s.Require().NoError(err)
|
|
s.Require().Len(outputs, 1)
|
|
|
|
result := outputs[0].(*milvuspb.SearchResults).GetResults()
|
|
s.Equal([]int64{limit, limit}, result.GetTopks())
|
|
s.Equal(limit, result.GetTopK())
|
|
s.Len(result.GetScores(), int(nq*limit))
|
|
s.Len(result.GetIds().GetIntId().GetData(), int(nq*limit))
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_CollapsesElementLevelResultsByRowID() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 4,
|
|
Topks: []int64{4},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 1, 2, 3}},
|
|
},
|
|
},
|
|
Scores: []float32{0.72, 0.85, 0.60, 0.95},
|
|
Distances: []float32{7.2, 8.5, 6.0, 9.5},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0, 3, 1, 0}},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "value",
|
|
FieldId: 101,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{10, 30, 20, 90}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
AllSearchCount: 4,
|
|
},
|
|
}
|
|
|
|
op := &elementBestCollapseOperator{}
|
|
out, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{input}, []string{"IP"})
|
|
s.Require().NoError(err)
|
|
|
|
results := out[0].([]*milvuspb.SearchResults)
|
|
s.Require().Len(results, 1)
|
|
result := results[0].GetResults()
|
|
|
|
s.Nil(result.GetElementIndices())
|
|
s.Equal(int64(1), result.GetNumQueries())
|
|
s.Equal(int64(3), result.GetTopK())
|
|
s.Equal([]int64{3}, result.GetTopks())
|
|
s.Equal([]int64{3, 1, 2}, result.GetIds().GetIntId().GetData())
|
|
s.Equal([]float32{0.95, 0.85, 0.60}, result.GetScores())
|
|
s.Equal([]float32{9.5, 8.5, 6.0}, result.GetDistances())
|
|
s.Equal([]int64{90, 30, 20}, result.GetFieldsData()[0].GetScalars().GetLongData().GetData())
|
|
s.Equal(int64(4), result.GetAllSearchCount())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_CollapsesEachQueryChunkIndependently() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 2,
|
|
TopK: 3,
|
|
Topks: []int64{3, 3},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 1, 2, 1, 2, 2}},
|
|
},
|
|
},
|
|
Scores: []float32{0.30, 0.90, 0.70, 0.80, 0.20, 0.60},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0, 2, 0, 1, 0, 3}},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "value",
|
|
FieldId: 101,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{10, 11, 20, 30, 40, 41}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &elementBestCollapseOperator{}
|
|
out, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{input}, []string{"IP"})
|
|
s.Require().NoError(err)
|
|
|
|
results := out[0].([]*milvuspb.SearchResults)
|
|
result := results[0].GetResults()
|
|
|
|
s.Nil(result.GetElementIndices())
|
|
s.Equal(int64(2), result.GetNumQueries())
|
|
s.Equal(int64(2), result.GetTopK())
|
|
s.Equal([]int64{2, 2}, result.GetTopks())
|
|
s.Equal([]int64{1, 2, 1, 2}, result.GetIds().GetIntId().GetData())
|
|
s.Equal([]float32{0.90, 0.70, 0.80, 0.60}, result.GetScores())
|
|
s.Equal([]int64{11, 20, 30, 41}, result.GetFieldsData()[0].GetScalars().GetLongData().GetData())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_CollapsesStringPrimaryKeys() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 3,
|
|
Topks: []int64{3},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_StrId{
|
|
StrId: &schemapb.StringArray{Data: []string{"row-a", "row-a", "row-b"}},
|
|
},
|
|
},
|
|
Scores: []float32{0.10, 0.60, 0.40},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0, 2, 1}},
|
|
},
|
|
}
|
|
|
|
op := &elementBestCollapseOperator{}
|
|
out, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{input}, []string{"IP"})
|
|
s.Require().NoError(err)
|
|
|
|
results := out[0].([]*milvuspb.SearchResults)
|
|
result := results[0].GetResults()
|
|
|
|
s.Nil(result.GetElementIndices())
|
|
s.Equal([]string{"row-a", "row-b"}, result.GetIds().GetStrId().GetData())
|
|
s.Equal([]float32{0.60, 0.40}, result.GetScores())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_PassesRowLevelResultsThrough() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 2,
|
|
Topks: []int64{2},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{10, 20}},
|
|
},
|
|
},
|
|
Scores: []float32{0.90, 0.80},
|
|
},
|
|
}
|
|
|
|
op := &elementBestCollapseOperator{}
|
|
out, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{input}, []string{""})
|
|
s.Require().NoError(err)
|
|
|
|
results := out[0].([]*milvuspb.SearchResults)
|
|
s.Require().Len(results, 1)
|
|
s.Same(input, results[0])
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_AllowsEmptyElementLevelResultWithoutMetric() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 0,
|
|
Topks: []int64{0},
|
|
ElementIndices: &schemapb.LongArray{},
|
|
AllSearchCount: 10,
|
|
},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
config elementCollapseConfig
|
|
}{
|
|
{name: "default max"},
|
|
{name: "topk sum", config: elementCollapseConfig{Strategy: elementCollapseTopKSum, TopK: 2}},
|
|
}
|
|
for _, test := range tests {
|
|
s.Run(test.name, func() {
|
|
op := &elementBestCollapseOperator{}
|
|
if test.config.Strategy != "" {
|
|
op.configs = []elementCollapseConfig{test.config}
|
|
}
|
|
out, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{input}, []string{""})
|
|
s.Require().NoError(err)
|
|
|
|
results := out[0].([]*milvuspb.SearchResults)
|
|
result := results[0].GetResults()
|
|
|
|
s.Nil(result.GetElementIndices())
|
|
s.Equal(int64(1), result.GetNumQueries())
|
|
s.Equal(int64(0), result.GetTopK())
|
|
s.Equal([]int64{0}, result.GetTopks())
|
|
s.Empty(result.GetScores())
|
|
s.Equal(int64(10), result.GetAllSearchCount())
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_DeduplicatesEqualScoreElementsByRowID() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 3,
|
|
Topks: []int64{3},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 1, 2}},
|
|
},
|
|
},
|
|
Scores: []float32{0.50, 0.50, 0.40},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0, 1, 0}},
|
|
},
|
|
}
|
|
|
|
op := &elementBestCollapseOperator{}
|
|
out, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{input}, []string{"IP"})
|
|
s.Require().NoError(err)
|
|
|
|
results := out[0].([]*milvuspb.SearchResults)
|
|
result := results[0].GetResults()
|
|
|
|
s.Nil(result.GetElementIndices())
|
|
s.Equal([]int64{1, 2}, result.GetIds().GetIntId().GetData())
|
|
s.Equal([]float32{0.50, 0.40}, result.GetScores())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestHybridSearchPipe_RerankReceivesCollapsedRowLevelResults() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 3,
|
|
Topks: []int64{3},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 1, 2}},
|
|
},
|
|
},
|
|
Scores: []float32{0.40, 0.60, 0.90},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0, 1, 0}},
|
|
},
|
|
}
|
|
|
|
originalReduceFactory := opFactory[hybridSearchReduceOp]
|
|
originalRerankFactory := opFactory[rerankOp]
|
|
originalAssembleFactory := opFactory[hybridAssembleOp]
|
|
defer func() {
|
|
opFactory[hybridSearchReduceOp] = originalReduceFactory
|
|
opFactory[rerankOp] = originalRerankFactory
|
|
opFactory[hybridAssembleOp] = originalAssembleFactory
|
|
}()
|
|
|
|
opFactory[hybridSearchReduceOp] = func(_ *searchTask, _ map[string]any) (operator, error) {
|
|
return searchPipelineTestOperator(func(ctx context.Context, span trace.Span, inputs ...any) ([]any, error) {
|
|
return []any{[]*milvuspb.SearchResults{input}, []string{"IP"}}, nil
|
|
}), nil
|
|
}
|
|
|
|
rerankCalled := false
|
|
opFactory[rerankOp] = func(_ *searchTask, _ map[string]any) (operator, error) {
|
|
return searchPipelineTestOperator(func(ctx context.Context, span trace.Span, inputs ...any) ([]any, error) {
|
|
rerankCalled = true
|
|
|
|
collapsed, ok := inputs[0].([]*milvuspb.SearchResults)
|
|
s.Require().True(ok)
|
|
metrics, ok := inputs[1].([]string)
|
|
s.Require().True(ok)
|
|
s.Equal([]string{"IP"}, metrics)
|
|
s.Require().Len(collapsed, 1)
|
|
|
|
data := collapsed[0].GetResults()
|
|
s.Nil(data.GetElementIndices())
|
|
s.Equal([]int64{2, 1}, data.GetIds().GetIntId().GetData())
|
|
s.Equal([]float32{0.90, 0.60}, data.GetScores())
|
|
|
|
return []any{&milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: data.GetNumQueries(),
|
|
TopK: data.GetTopK(),
|
|
Topks: append([]int64(nil), data.GetTopks()...),
|
|
Ids: data.GetIds(),
|
|
Scores: append([]float32(nil), data.GetScores()...),
|
|
},
|
|
}}, nil
|
|
}), nil
|
|
}
|
|
|
|
opFactory[hybridAssembleOp] = func(_ *searchTask, _ map[string]any) (operator, error) {
|
|
return searchPipelineTestOperator(func(ctx context.Context, span trace.Span, inputs ...any) ([]any, error) {
|
|
collapsed, ok := inputs[0].([]*milvuspb.SearchResults)
|
|
s.Require().True(ok)
|
|
s.Require().Len(collapsed, 1)
|
|
s.Nil(collapsed[0].GetResults().GetElementIndices())
|
|
|
|
rankResult, ok := inputs[1].(*milvuspb.SearchResults)
|
|
s.Require().True(ok)
|
|
return []any{rankResult}, nil
|
|
}), nil
|
|
}
|
|
|
|
pipeline, err := newPipeline(hybridSearchPipe, &searchTask{})
|
|
s.Require().NoError(err)
|
|
err = pipeline.AddNodes(&searchTask{}, &nodeDef{
|
|
name: "finish",
|
|
inputs: []string{"result"},
|
|
outputs: []string{pipelineOutput},
|
|
opName: lambdaOp,
|
|
params: map[string]any{
|
|
lambdaParamKey: func(ctx context.Context, span trace.Span, inputs ...any) ([]any, error) {
|
|
return []any{inputs[0]}, nil
|
|
},
|
|
},
|
|
})
|
|
s.Require().NoError(err)
|
|
|
|
result, _, err := pipeline.Run(context.Background(), s.span, nil, segcore.StorageCost{})
|
|
s.Require().NoError(err)
|
|
s.True(rerankCalled)
|
|
s.Equal([]int64{2, 1}, result.GetResults().GetIds().GetIntId().GetData())
|
|
s.Equal([]float32{0.90, 0.60}, result.GetResults().GetScores())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_UsesMetricDirection() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 3,
|
|
Topks: []int64{3},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 1, 2}},
|
|
},
|
|
},
|
|
Scores: []float32{0.8, 0.2, 0.5},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0, 3, 1}},
|
|
},
|
|
}
|
|
|
|
op := &elementBestCollapseOperator{}
|
|
out, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{input}, []string{"L2"})
|
|
s.Require().NoError(err)
|
|
|
|
results := out[0].([]*milvuspb.SearchResults)
|
|
result := results[0].GetResults()
|
|
|
|
s.Nil(result.GetElementIndices())
|
|
s.Equal([]int64{1, 2}, result.GetIds().GetIntId().GetData())
|
|
s.Equal([]float32{0.2, 0.5}, result.GetScores())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_UsesConfiguredCollapseStrategies() {
|
|
makeInput := func() *milvuspb.SearchResults {
|
|
return &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 6,
|
|
Topks: []int64{6},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 1, 1, 2, 2, 3}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.6, 0.3, 0.5, 0.1, 0.55},
|
|
Distances: []float32{0.9, 0.6, 0.3, 0.5, 0.1, 0.55},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0, 1, 2, 0, 1, 0}},
|
|
},
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
config elementCollapseConfig
|
|
expectedIDs []int64
|
|
expectedScores []float32
|
|
expectedDists []float32
|
|
}{
|
|
{
|
|
name: "sum",
|
|
config: elementCollapseConfig{Strategy: elementCollapseSum},
|
|
expectedIDs: []int64{1, 2, 3},
|
|
expectedScores: []float32{1.8, 0.6, 0.55},
|
|
expectedDists: []float32{0.9, 0.5, 0.55},
|
|
},
|
|
{
|
|
name: "avg",
|
|
config: elementCollapseConfig{Strategy: elementCollapseAvg},
|
|
expectedIDs: []int64{1, 3, 2},
|
|
expectedScores: []float32{0.6, 0.55, 0.3},
|
|
expectedDists: []float32{0.9, 0.55, 0.5},
|
|
},
|
|
{
|
|
name: "topk_sum",
|
|
config: elementCollapseConfig{Strategy: elementCollapseTopKSum, TopK: 2},
|
|
expectedIDs: []int64{1, 2, 3},
|
|
expectedScores: []float32{1.5, 0.6, 0.55},
|
|
expectedDists: []float32{0.9, 0.5, 0.55},
|
|
},
|
|
{
|
|
name: "topk_avg",
|
|
config: elementCollapseConfig{Strategy: elementCollapseTopKAvg, TopK: 2},
|
|
expectedIDs: []int64{1, 3, 2},
|
|
expectedScores: []float32{0.75, 0.55, 0.3},
|
|
expectedDists: []float32{0.9, 0.55, 0.5},
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
s.Run(test.name, func() {
|
|
op := &elementBestCollapseOperator{configs: []elementCollapseConfig{test.config}}
|
|
out, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{makeInput()}, []string{"IP"})
|
|
s.Require().NoError(err)
|
|
|
|
result := out[0].([]*milvuspb.SearchResults)[0].GetResults()
|
|
s.Nil(result.GetElementIndices())
|
|
s.Equal(test.expectedIDs, result.GetIds().GetIntId().GetData())
|
|
s.InDeltaSlice(test.expectedScores, result.GetScores(), 0.00001)
|
|
s.InDeltaSlice(test.expectedDists, result.GetDistances(), 0.00001)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_RejectsSumCollapseForNegativeMetrics() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 2,
|
|
Topks: []int64{2},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 1}},
|
|
},
|
|
},
|
|
Scores: []float32{0.8, 0.2},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0, 1}},
|
|
},
|
|
}
|
|
op := &elementBestCollapseOperator{configs: []elementCollapseConfig{{Strategy: elementCollapseTopKSum, TopK: 2}}}
|
|
|
|
_, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{input}, []string{"L2"})
|
|
|
|
s.Require().Error(err)
|
|
s.ErrorIs(err, merr.ErrParameterInvalid)
|
|
s.Contains(err.Error(), "only supported for positively related metrics")
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_RejectsSumCollapseForNegativeMetricsWithEmptyResult() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 0,
|
|
Topks: []int64{0},
|
|
ElementIndices: &schemapb.LongArray{},
|
|
},
|
|
}
|
|
op := &elementBestCollapseOperator{configs: []elementCollapseConfig{{Strategy: elementCollapseTopKSum, TopK: 2}}}
|
|
|
|
_, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{input}, []string{"L2"})
|
|
|
|
s.Require().Error(err)
|
|
s.ErrorIs(err, merr.ErrParameterInvalid)
|
|
s.Contains(err.Error(), "only supported for positively related metrics")
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementLevelHybridPrepareAndRestoreKeys() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 3,
|
|
Topks: []int64{3},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{10, 10, 20}},
|
|
},
|
|
},
|
|
Scores: []float32{0.8, 0.9, 0.7},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0, 2, 1}},
|
|
GroupByFieldValues: []*schemapb.FieldData{{
|
|
FieldId: 100,
|
|
FieldName: "pk",
|
|
Type: schemapb.DataType_Int64,
|
|
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{10, 10, 20}}},
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
|
|
prepared, err := prepareElementLevelHybridResult(input)
|
|
s.Require().NoError(err)
|
|
preparedIDs := prepared.GetResults().GetIds().GetStrId().GetData()
|
|
s.Require().Len(preparedIDs, 3)
|
|
s.Require().Len(prepared.GetResults().GetGroupByFieldValues(), 1)
|
|
s.Equal([]int64{10, 10, 20}, prepared.GetResults().GetGroupByFieldValues()[0].GetScalars().GetLongData().GetData())
|
|
|
|
rankResult := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 2,
|
|
Topks: []int64{2},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_StrId{
|
|
StrId: &schemapb.StringArray{Data: []string{preparedIDs[1], preparedIDs[2]}},
|
|
},
|
|
},
|
|
Scores: []float32{0.99, 0.88},
|
|
GroupByFieldValues: []*schemapb.FieldData{{
|
|
FieldId: 100,
|
|
FieldName: "pk",
|
|
Type: schemapb.DataType_Int64,
|
|
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{10, 20}}},
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
|
|
restored, err := restoreElementLevelHybridRankResult(rankResult)
|
|
s.Require().NoError(err)
|
|
s.Equal([]int64{10, 20}, restored.GetResults().GetIds().GetIntId().GetData())
|
|
s.Equal([]int64{2, 1}, restored.GetResults().GetElementIndices().GetData())
|
|
s.Equal([]float32{0.99, 0.88}, restored.GetResults().GetScores())
|
|
s.Require().Len(restored.GetResults().GetGroupByFieldValues(), 1)
|
|
s.Equal([]int64{10, 20}, restored.GetResults().GetGroupByFieldValues()[0].GetScalars().GetLongData().GetData())
|
|
|
|
_, err = prepareElementLevelHybridResult(&milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 1,
|
|
Topks: []int64{1},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{10}},
|
|
},
|
|
},
|
|
Scores: []float32{0.8},
|
|
},
|
|
})
|
|
s.Require().Error(err)
|
|
s.Contains(err.Error(), "missing element_indices")
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestElementBestCollapseOp_RejectsEmptyMetricForElementLevelResult() {
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 1,
|
|
Topks: []int64{1},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1}},
|
|
},
|
|
},
|
|
Scores: []float32{0.8},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0}},
|
|
},
|
|
}
|
|
|
|
op := &elementBestCollapseOperator{}
|
|
_, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{input}, []string{""})
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "missing metric type")
|
|
}
|
|
|
|
// TestHybridAssembleOp_MixedFieldsDataLayoutErrors verifies that hybrid
|
|
// assemble fails loud when sub-results have inconsistent FieldsData layouts —
|
|
// specifically when a sub-result contributes reranked IDs but has an empty
|
|
// FieldsData while another sub-result has a populated one.
|
|
//
|
|
// In current proxy flow this state is unreachable: task_search.go sets
|
|
// identical plan.OutputFieldIds for every sub-request and the hybridSearchPipe
|
|
// is only chosen when needRequery=false, where plan.OutputFieldIds always
|
|
// contains at least the PK. Any deviation means an upstream invariant has
|
|
// been broken — silently corrupting the result (the previous code did
|
|
// `continue`, leaving fewer FieldsData rows than reranked IDs and causing PK
|
|
// ↔ field misalignment downstream) is strictly worse than returning an error.
|
|
//
|
|
// Scenario:
|
|
// - sub[0]: PKs [10, 20], FieldsData = [{long_field: [100, 200]}]
|
|
// - sub[1]: PKs [30, 40], FieldsData = [] (empty)
|
|
// - rerank: PKs [10, 30, 20, 40] (4 ids, picked across both sub-results)
|
|
//
|
|
// Expected: assemble must return an error mentioning the inconsistent
|
|
// sub-result, not silently produce 4 IDs paired with only 2 field rows.
|
|
func (s *SearchPipelineSuite) TestHybridAssembleOp_MixedFieldsDataLayoutErrors() {
|
|
// sub[0]: 2 IDs with a long field populated.
|
|
sub0 := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 2,
|
|
Topks: []int64{2},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{10, 20}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "value",
|
|
FieldId: 101,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{100, 200}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// sub[1]: 2 IDs but FieldsData empty — invariant violation that proxy
|
|
// upstream code is supposed to prevent. We assert assemble catches it
|
|
// rather than silently dropping rows.
|
|
sub1 := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 2,
|
|
Topks: []int64{2},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{30, 40}},
|
|
},
|
|
},
|
|
Scores: []float32{0.7, 0.6},
|
|
FieldsData: nil,
|
|
},
|
|
}
|
|
|
|
// rank result: rerank picked 4 IDs across both sub-results.
|
|
rankResult := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 4,
|
|
Topks: []int64{4},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{10, 30, 20, 40}},
|
|
},
|
|
},
|
|
Scores: []float32{0.95, 0.85, 0.75, 0.65},
|
|
},
|
|
}
|
|
|
|
op := &hybridAssembleOperator{collectionID: 12345}
|
|
_, err := op.run(context.Background(), s.span,
|
|
[]*milvuspb.SearchResults{sub0, sub1}, rankResult)
|
|
|
|
s.Require().Error(err,
|
|
"mixed FieldsData layout must be reported as an error, not silently dropped")
|
|
// Diagnostic must point at the offending sub-result so an operator can
|
|
// trace the upstream invariant violation.
|
|
s.Contains(err.Error(), "FieldsData",
|
|
"error message must mention FieldsData inconsistency")
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestHybridAssembleOpNullableVectorCompactData() {
|
|
sub0 := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 3,
|
|
Topks: []int64{3},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{10, 20, 30}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_FloatVector,
|
|
FieldName: "nullable_vec",
|
|
FieldId: 101,
|
|
ValidData: []bool{false, true, true},
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_FloatVector{
|
|
FloatVector: &schemapb.FloatArray{Data: []float32{20, 20, 30, 30}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
sub1 := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 2,
|
|
Topks: []int64{2},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{40, 50}},
|
|
},
|
|
},
|
|
Scores: []float32{0.6, 0.5},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_FloatVector,
|
|
FieldName: "nullable_vec",
|
|
FieldId: 101,
|
|
ValidData: []bool{true, false},
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_FloatVector{
|
|
FloatVector: &schemapb.FloatArray{Data: []float32{40, 40}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
rankResult := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 5,
|
|
Topks: []int64{5},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{30, 10, 50, 40, 20}},
|
|
},
|
|
},
|
|
Scores: []float32{0.95, 0.85, 0.75, 0.65, 0.55},
|
|
},
|
|
}
|
|
|
|
op := &hybridAssembleOperator{collectionID: 12345}
|
|
out, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{sub0, sub1}, rankResult)
|
|
|
|
s.NoError(err)
|
|
result := out[0].(*milvuspb.SearchResults).GetResults()
|
|
s.Require().Len(result.GetFieldsData(), 1)
|
|
field := result.GetFieldsData()[0]
|
|
s.Equal([]bool{true, false, false, true, true}, field.GetValidData())
|
|
s.Equal([]float32{30, 30, 40, 40, 20, 20}, field.GetVectors().GetFloatVector().GetData())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestComputeFieldIdxsByOriginalOrderUsesAscendingRowsAndPreservesOutputOrder() {
|
|
rowIdxs := []int64{5, 1, 4, 2}
|
|
calls := make([]int64, 0, len(rowIdxs))
|
|
|
|
fieldIdxs := computeFieldIdxsByOriginalOrder(rowIdxs, func(rowIdx int64) []int64 {
|
|
calls = append(calls, rowIdx)
|
|
return []int64{rowIdx + 100}
|
|
})
|
|
|
|
s.Equal([]int64{1, 2, 4, 5}, calls)
|
|
s.Equal([][]int64{{105}, {101}, {104}, {102}}, fieldIdxs)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestComputeFieldIdxsByOriginalOrderCopiesSharedComputeBuffer() {
|
|
rowIdxs := []int64{5, 1, 4, 2}
|
|
shared := []int64{0, 0}
|
|
|
|
fieldIdxs := computeFieldIdxsByOriginalOrder(rowIdxs, func(rowIdx int64) []int64 {
|
|
shared[0] = rowIdx + 100
|
|
shared[1] = rowIdx + 200
|
|
return shared
|
|
})
|
|
|
|
s.Equal([][]int64{{105, 205}, {101, 201}, {104, 204}, {102, 202}}, fieldIdxs)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestHybridAssembleOp_ElementLevelHybridUsesElementKey() {
|
|
reduced := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 3,
|
|
Topks: []int64{3},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_StrId{
|
|
StrId: &schemapb.StringArray{Data: []string{
|
|
makeHybridElementKey(int64(10), 0),
|
|
makeHybridElementKey(int64(10), 2),
|
|
makeHybridElementKey(int64(20), 1),
|
|
}},
|
|
},
|
|
},
|
|
Scores: []float32{0.8, 0.9, 0.7},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "value",
|
|
FieldId: 101,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{100, 200, 300}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
rankResult := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 2,
|
|
Topks: []int64{2},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{10, 20}},
|
|
},
|
|
},
|
|
Scores: []float32{0.99, 0.88},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{2, 1}},
|
|
},
|
|
}
|
|
|
|
op := &hybridAssembleOperator{collectionID: 12345, elementLevelHybrid: true}
|
|
out, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{reduced}, rankResult)
|
|
s.Require().NoError(err)
|
|
|
|
result := out[0].(*milvuspb.SearchResults).GetResults()
|
|
s.Equal([]int64{10, 20}, result.GetIds().GetIntId().GetData())
|
|
s.Equal([]int64{2, 1}, result.GetElementIndices().GetData())
|
|
s.Equal([]float32{0.99, 0.88}, result.GetScores())
|
|
s.Equal([]int64{200, 300}, result.GetFieldsData()[0].GetScalars().GetLongData().GetData())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestRequeryOp() {
|
|
f1 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "int64", 20)
|
|
f1.FieldId = 101
|
|
|
|
mocker := mockey.Mock((*requeryOperator).requery).Return(&milvuspb.QueryResults{
|
|
FieldsData: []*schemapb.FieldData{f1},
|
|
}, segcore.StorageCost{}, nil).Build()
|
|
defer mocker.UnPatch()
|
|
|
|
op := requeryOperator{
|
|
traceCtx: context.Background(),
|
|
outputFieldNames: []string{"int64"},
|
|
}
|
|
ids := &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{
|
|
Data: []int64{1, 2},
|
|
},
|
|
},
|
|
}
|
|
_, err := op.run(context.Background(), s.span, ids, segcore.StorageCost{})
|
|
s.NoError(err)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestOrganizeOp() {
|
|
op := organizeOperator{
|
|
traceCtx: context.Background(),
|
|
primaryFieldSchema: &schemapb.FieldSchema{FieldID: 100, Name: "pk", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
collectionID: 1,
|
|
}
|
|
fields := []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "pk",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{
|
|
Data: []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}, {
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "int64",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{
|
|
Data: []int64{1, 2, 3, 4, 5, 6, 7, 8, 9, 10},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
ids := []*schemapb.IDs{
|
|
{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{
|
|
Data: []int64{1, 4, 5, 9, 10},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{
|
|
Data: []int64{5, 6, 7, 8, 9, 10},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
ret, err := op.run(context.Background(), s.span, fields, ids)
|
|
s.NoError(err)
|
|
fmt.Println(ret)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestHighlightOp() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
proxy := &Proxy{}
|
|
proxy.tsoAllocator = ×tampAllocator{
|
|
tso: newMockTimestampAllocatorInterface(),
|
|
}
|
|
sched, err := newTaskScheduler(ctx, proxy.tsoAllocator)
|
|
s.Require().NoError(err)
|
|
|
|
err = sched.Start()
|
|
s.Require().NoError(err)
|
|
defer sched.Close()
|
|
proxy.sched = sched
|
|
|
|
collName := "test_coll_highlight"
|
|
fieldName2Types := map[string]schemapb.DataType{
|
|
testVarCharField: schemapb.DataType_VarChar,
|
|
}
|
|
schema := constructCollectionSchemaByDataType(collName, fieldName2Types, testVarCharField, false)
|
|
|
|
req := &milvuspb.SearchRequest{
|
|
CollectionName: collName,
|
|
DbName: "default",
|
|
}
|
|
|
|
highlightTasks := map[int64]*highlightTask{
|
|
100: {
|
|
HighlightTask: &querypb.HighlightTask{
|
|
Texts: []string{"target text"},
|
|
FieldName: testVarCharField,
|
|
FieldId: 100,
|
|
},
|
|
preTags: [][]byte{[]byte(DefaultPreTag)},
|
|
postTags: [][]byte{[]byte(DefaultPostTag)},
|
|
},
|
|
}
|
|
|
|
mockLb := shardclient.NewMockLBPolicy(s.T())
|
|
searchTask := &searchTask{
|
|
node: proxy,
|
|
highlighter: &LexicalHighlighter{
|
|
tasks: highlightTasks,
|
|
},
|
|
lb: mockLb,
|
|
schema: mustNewSchemaInfo(schema),
|
|
request: req,
|
|
collectionName: collName,
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
CollectionID: 0,
|
|
},
|
|
}
|
|
|
|
op, err := opFactory[highlightOp](searchTask, map[string]any{})
|
|
s.Require().NoError(err)
|
|
|
|
// mockery
|
|
mockLb.EXPECT().ExecuteOneChannel(mock.Anything, mock.Anything).Run(func(ctx context.Context, workload shardclient.CollectionWorkLoad) {
|
|
qn := mocks.NewMockQueryNodeClient(s.T())
|
|
qn.EXPECT().GetHighlight(mock.Anything, mock.Anything).Return(
|
|
&querypb.GetHighlightResponse{
|
|
Status: merr.Success(),
|
|
Results: []*querypb.HighlightResult{{}},
|
|
}, nil)
|
|
workload.Exec(ctx, 0, qn, "test_chan")
|
|
}).Return(nil)
|
|
|
|
_, err = op.run(ctx, s.span, &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
TopK: 3,
|
|
Topks: []int64{1},
|
|
Ids: testSearchResultIDs(1),
|
|
FieldsData: []*schemapb.FieldData{{
|
|
FieldName: testVarCharField,
|
|
FieldId: 100,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{
|
|
Data: []string{"match text"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
})
|
|
s.NoError(err)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestLexicalHighlightOpNullableStringKeepsEmptyHighlightData() {
|
|
cases := []struct {
|
|
name string
|
|
rowNum int
|
|
stringData []string
|
|
validData []bool
|
|
expectedTexts []string
|
|
}{
|
|
{
|
|
name: "compact nullable string",
|
|
rowNum: 2,
|
|
stringData: []string{"match text"},
|
|
validData: []bool{true, false},
|
|
expectedTexts: []string{"target text", "match text", ""},
|
|
},
|
|
{
|
|
name: "all null string",
|
|
rowNum: 1,
|
|
stringData: []string{},
|
|
validData: []bool{false},
|
|
expectedTexts: []string{"target text", ""},
|
|
},
|
|
{
|
|
name: "empty string",
|
|
rowNum: 1,
|
|
stringData: []string{""},
|
|
validData: []bool{true},
|
|
expectedTexts: []string{"target text", ""},
|
|
},
|
|
}
|
|
|
|
for _, tc := range cases {
|
|
s.Run(tc.name, func() {
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
|
|
proxy := &Proxy{}
|
|
proxy.tsoAllocator = ×tampAllocator{
|
|
tso: newMockTimestampAllocatorInterface(),
|
|
}
|
|
sched, err := newTaskScheduler(ctx, proxy.tsoAllocator)
|
|
s.Require().NoError(err)
|
|
|
|
err = sched.Start()
|
|
s.Require().NoError(err)
|
|
defer sched.Close()
|
|
proxy.sched = sched
|
|
|
|
collName := "test_coll_highlight_nullable"
|
|
fieldName2Types := map[string]schemapb.DataType{
|
|
testVarCharField: schemapb.DataType_VarChar,
|
|
}
|
|
schema := constructCollectionSchemaByDataType(collName, fieldName2Types, testVarCharField, false)
|
|
|
|
highlightTasks := map[int64]*highlightTask{
|
|
100: {
|
|
HighlightTask: &querypb.HighlightTask{
|
|
Texts: []string{"target text"},
|
|
FieldName: testVarCharField,
|
|
FieldId: 100,
|
|
SearchTextNum: 1,
|
|
},
|
|
preTags: [][]byte{[]byte(DefaultPreTag)},
|
|
postTags: [][]byte{[]byte(DefaultPostTag)},
|
|
},
|
|
}
|
|
|
|
mockLb := shardclient.NewMockLBPolicy(s.T())
|
|
searchTask := &searchTask{
|
|
node: proxy,
|
|
highlighter: &LexicalHighlighter{
|
|
tasks: highlightTasks,
|
|
},
|
|
lb: mockLb,
|
|
schema: mustNewSchemaInfo(schema),
|
|
request: &milvuspb.SearchRequest{CollectionName: collName, DbName: "default"},
|
|
collectionName: collName,
|
|
SearchRequest: &internalpb.SearchRequest{CollectionID: 0},
|
|
}
|
|
|
|
op, err := opFactory[highlightOp](searchTask, map[string]any{})
|
|
s.Require().NoError(err)
|
|
|
|
queryNodeResults := make([]*querypb.HighlightResult, tc.rowNum)
|
|
for i := range queryNodeResults {
|
|
queryNodeResults[i] = &querypb.HighlightResult{}
|
|
}
|
|
|
|
mockLb.EXPECT().ExecuteOneChannel(mock.Anything, mock.Anything).Run(func(ctx context.Context, workload shardclient.CollectionWorkLoad) {
|
|
qn := mocks.NewMockQueryNodeClient(s.T())
|
|
qn.EXPECT().GetHighlight(mock.Anything, mock.Anything).Run(func(ctx context.Context, req *querypb.GetHighlightRequest, opts ...grpc.CallOption) {
|
|
s.Require().Len(req.GetTasks(), 1)
|
|
task := req.GetTasks()[0]
|
|
s.Equal(int64(tc.rowNum), task.GetCorpusTextNum())
|
|
s.Equal(tc.expectedTexts, task.GetTexts())
|
|
}).Return(
|
|
&querypb.GetHighlightResponse{
|
|
Status: merr.Success(),
|
|
Results: queryNodeResults,
|
|
}, nil)
|
|
workload.Exec(ctx, 0, qn, "test_chan")
|
|
}).Return(nil)
|
|
|
|
ids := make([]int64, tc.rowNum)
|
|
scores := make([]float32, tc.rowNum)
|
|
for i := range tc.rowNum {
|
|
ids[i] = int64(i + 1)
|
|
scores[i] = 1.0 - float32(i)*0.1
|
|
}
|
|
|
|
results, err := op.run(ctx, s.span, &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: int64(tc.rowNum),
|
|
Topks: []int64{int64(tc.rowNum)},
|
|
Ids: testSearchResultIDs(ids...),
|
|
Scores: scores,
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
FieldId: 100,
|
|
FieldName: testVarCharField,
|
|
Type: schemapb.DataType_VarChar,
|
|
ValidData: tc.validData,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{Data: tc.stringData},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
s.NoError(err)
|
|
s.Require().Len(results, 1)
|
|
|
|
result := results[0].(*milvuspb.SearchResults)
|
|
highlightResults := result.GetResults().GetHighlightResults()
|
|
s.Require().Len(highlightResults, 1)
|
|
s.Equal(testVarCharField, highlightResults[0].GetFieldName())
|
|
s.Require().Len(highlightResults[0].GetDatas(), tc.rowNum)
|
|
for _, data := range highlightResults[0].GetDatas() {
|
|
s.NotNil(data)
|
|
s.Empty(data.GetFragments())
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestLexicalHighlightOpZeroHitWithNonEmptyFieldsData() {
|
|
op := &lexicalHighlightOperator{
|
|
tasks: []*highlightTask{
|
|
{
|
|
HighlightTask: &querypb.HighlightTask{
|
|
Texts: []string{"target text"},
|
|
FieldName: testVarCharField,
|
|
FieldId: 100,
|
|
},
|
|
preTags: [][]byte{[]byte(DefaultPreTag)},
|
|
postTags: [][]byte{[]byte(DefaultPostTag)},
|
|
},
|
|
},
|
|
}
|
|
|
|
results, err := op.run(context.Background(), s.span, &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 5,
|
|
Topks: []int64{0},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
FieldId: 101,
|
|
FieldName: "unrelated_field",
|
|
Type: schemapb.DataType_Int64,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
})
|
|
s.NoError(err)
|
|
s.Require().Len(results, 1)
|
|
|
|
result := results[0].(*milvuspb.SearchResults)
|
|
s.Empty(result.GetResults().GetHighlightResults())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestLexicalHighlightOpNonZeroHitWithEmptyFieldsData() {
|
|
op := &lexicalHighlightOperator{}
|
|
_, err := op.run(context.Background(), s.span, &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 1,
|
|
Topks: []int64{1},
|
|
Ids: testSearchResultIDs(1),
|
|
},
|
|
})
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "field data is empty for non-empty search result")
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSemanticHighlightOp() {
|
|
ctx := context.Background()
|
|
|
|
// Mock SemanticHighlight methods
|
|
mockProcess := mockey.Mock((*highlight.SemanticHighlight).Process).To(
|
|
func(h *highlight.SemanticHighlight, ctx context.Context, topks []int64, texts []string) ([][]string, [][]float32, error) {
|
|
return [][]string{
|
|
{"<em>highlighted</em> text 1"},
|
|
{"<em>highlighted</em> text 2"},
|
|
{"<em>highlighted</em> text 3"},
|
|
}, [][]float32{
|
|
{0.9},
|
|
{0.8},
|
|
{0.7},
|
|
}, nil
|
|
}).Build()
|
|
defer mockProcess.UnPatch()
|
|
|
|
mockFieldIDs := mockey.Mock((*highlight.SemanticHighlight).FieldIDs).To(func(h *highlight.SemanticHighlight) []int64 {
|
|
return []int64{101}
|
|
}).Build()
|
|
defer mockFieldIDs.UnPatch()
|
|
|
|
mockGetFieldName := mockey.Mock((*highlight.SemanticHighlight).GetFieldName).To(func(h *highlight.SemanticHighlight, id int64) string {
|
|
return testVarCharField
|
|
}).Build()
|
|
defer mockGetFieldName.UnPatch()
|
|
|
|
// Create operator
|
|
op := &semanticHighlightOperator{
|
|
highlight: &highlight.SemanticHighlight{},
|
|
}
|
|
|
|
// Create search results with text data
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 3,
|
|
Topks: []int64{3},
|
|
Ids: testSearchResultIDs(1, 2, 3),
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
FieldId: 101,
|
|
FieldName: testVarCharField,
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{
|
|
Data: []string{"text 1", "text 2", "text 3"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Run the operator
|
|
results, err := op.run(ctx, s.span, searchResults)
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
s.Len(results, 1)
|
|
|
|
// Verify results
|
|
result := results[0].(*milvuspb.SearchResults)
|
|
s.NotNil(result.Results.HighlightResults)
|
|
s.Len(result.Results.HighlightResults, 1)
|
|
|
|
highlightResult := result.Results.HighlightResults[0]
|
|
s.Equal(testVarCharField, highlightResult.FieldName)
|
|
s.Len(highlightResult.Datas, 3)
|
|
s.Equal([]string{"<em>highlighted</em> text 1"}, highlightResult.Datas[0].Fragments)
|
|
s.Equal([]string{"<em>highlighted</em> text 2"}, highlightResult.Datas[1].Fragments)
|
|
s.Equal([]string{"<em>highlighted</em> text 3"}, highlightResult.Datas[2].Fragments)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSemanticHighlightOpMissingField() {
|
|
ctx := context.Background()
|
|
|
|
// Mock FieldIDs to return field 999 (not in results)
|
|
mockFieldIDs := mockey.Mock((*highlight.SemanticHighlight).FieldIDs).Return([]int64{999}).Build()
|
|
defer mockFieldIDs.UnPatch()
|
|
|
|
op := &semanticHighlightOperator{
|
|
highlight: &highlight.SemanticHighlight{},
|
|
}
|
|
|
|
// Create search results without the expected field
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 1,
|
|
Topks: []int64{1},
|
|
Ids: testSearchResultIDs(1),
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
FieldId: 101,
|
|
FieldName: testVarCharField,
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{
|
|
Data: []string{"text 1"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Run the operator and expect error
|
|
_, err := op.run(ctx, s.span, searchResults)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "text field not in output field")
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSemanticHighlightOpMultipleFields() {
|
|
ctx := context.Background()
|
|
|
|
// Use a counter to return different results for different calls
|
|
callCount := 0
|
|
mockProcess := mockey.Mock((*highlight.SemanticHighlight).Process).To(
|
|
func(h *highlight.SemanticHighlight, ctx context.Context, topks []int64, texts []string) ([][]string, [][]float32, error) {
|
|
callCount++
|
|
return [][]string{
|
|
{fmt.Sprintf("<em>highlighted</em> text field%d-1", callCount)},
|
|
{fmt.Sprintf("<em>highlighted</em> text field%d-2", callCount)},
|
|
}, [][]float32{
|
|
{0.9},
|
|
{0.8},
|
|
}, nil
|
|
}).Build()
|
|
defer mockProcess.UnPatch()
|
|
|
|
mockFieldIDs := mockey.Mock((*highlight.SemanticHighlight).FieldIDs).Return([]int64{101, 102}).Build()
|
|
defer mockFieldIDs.UnPatch()
|
|
|
|
mockGetFieldName := mockey.Mock((*highlight.SemanticHighlight).GetFieldName).To(func(h *highlight.SemanticHighlight, id int64) string {
|
|
if id == 101 {
|
|
return "field1"
|
|
}
|
|
return "field2"
|
|
}).Build()
|
|
defer mockGetFieldName.UnPatch()
|
|
|
|
op := &semanticHighlightOperator{
|
|
highlight: &highlight.SemanticHighlight{},
|
|
}
|
|
|
|
// Create search results with multiple text fields
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 2,
|
|
Topks: []int64{2},
|
|
Ids: testSearchResultIDs(1, 2),
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
FieldId: 101,
|
|
FieldName: "field1",
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{
|
|
Data: []string{"text 1", "text 2"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
FieldId: 102,
|
|
FieldName: "field2",
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{
|
|
Data: []string{"another text 1", "another text 2"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Run the operator
|
|
results, err := op.run(ctx, s.span, searchResults)
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
|
|
// Verify results
|
|
result := results[0].(*milvuspb.SearchResults)
|
|
s.NotNil(result.Results.HighlightResults)
|
|
s.Len(result.Results.HighlightResults, 2)
|
|
|
|
// Verify first field
|
|
s.Equal("field1", result.Results.HighlightResults[0].FieldName)
|
|
s.Len(result.Results.HighlightResults[0].Datas, 2)
|
|
|
|
// Verify second field
|
|
s.Equal("field2", result.Results.HighlightResults[1].FieldName)
|
|
s.Len(result.Results.HighlightResults[1].Datas, 2)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSemanticHighlightOpEmptyResults() {
|
|
ctx := context.Background()
|
|
|
|
// Mock Process to return empty results
|
|
mockProcess := mockey.Mock((*highlight.SemanticHighlight).Process).To(
|
|
func(h *highlight.SemanticHighlight, ctx context.Context, topks []int64, texts []string) ([][]string, [][]float32, error) {
|
|
return [][]string{}, [][]float32{}, nil
|
|
}).Build()
|
|
defer mockProcess.UnPatch()
|
|
|
|
mockFieldIDs := mockey.Mock((*highlight.SemanticHighlight).FieldIDs).Return([]int64{101}).Build()
|
|
defer mockFieldIDs.UnPatch()
|
|
|
|
mockGetFieldName := mockey.Mock((*highlight.SemanticHighlight).GetFieldName).To(func(h *highlight.SemanticHighlight, id int64) string {
|
|
return testVarCharField
|
|
}).Build()
|
|
defer mockGetFieldName.UnPatch()
|
|
|
|
op := &semanticHighlightOperator{
|
|
highlight: &highlight.SemanticHighlight{},
|
|
}
|
|
|
|
// Create empty search results
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 0,
|
|
Topks: []int64{0},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
FieldId: 101,
|
|
FieldName: testVarCharField,
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{
|
|
Data: []string{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Run the operator
|
|
results, err := op.run(ctx, s.span, searchResults)
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
|
|
// Verify results
|
|
result := results[0].(*milvuspb.SearchResults)
|
|
s.Empty(result.Results.HighlightResults)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSemanticHighlightOpZeroHitWithNonEmptyFieldsData() {
|
|
ctx := context.Background()
|
|
|
|
mockFieldIDs := mockey.Mock((*highlight.SemanticHighlight).FieldIDs).Return([]int64{999}).Build()
|
|
defer mockFieldIDs.UnPatch()
|
|
|
|
op := &semanticHighlightOperator{
|
|
highlight: &highlight.SemanticHighlight{},
|
|
}
|
|
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 5,
|
|
Topks: []int64{0},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
FieldId: 101,
|
|
FieldName: testVarCharField,
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{Data: []string{}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
results, err := op.run(ctx, s.span, searchResults)
|
|
s.NoError(err)
|
|
s.Require().Len(results, 1)
|
|
|
|
result := results[0].(*milvuspb.SearchResults)
|
|
s.Empty(result.GetResults().GetHighlightResults())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSemanticHighlightOpNonZeroHitWithEmptyFieldsData() {
|
|
op := &semanticHighlightOperator{
|
|
highlight: &highlight.SemanticHighlight{},
|
|
}
|
|
|
|
_, err := op.run(context.Background(), s.span, &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 1,
|
|
Topks: []int64{1},
|
|
Ids: testSearchResultIDs(1),
|
|
},
|
|
})
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "field data is empty for non-empty search result")
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSemanticHighlightOpDynamicField() {
|
|
ctx := context.Background()
|
|
|
|
// Mock SemanticHighlight methods for schema fields
|
|
mockProcess := mockey.Mock((*highlight.SemanticHighlight).Process).To(
|
|
func(h *highlight.SemanticHighlight, ctx context.Context, topks []int64, texts []string) ([][]string, [][]float32, error) {
|
|
return [][]string{
|
|
{"<em>dynamic</em> content 1"},
|
|
{"<em>dynamic</em> content 2"},
|
|
}, [][]float32{
|
|
{0.95},
|
|
{0.85},
|
|
}, nil
|
|
}).Build()
|
|
defer mockProcess.UnPatch()
|
|
|
|
mockFieldIDs := mockey.Mock((*highlight.SemanticHighlight).FieldIDs).Return([]int64{}).Build()
|
|
defer mockFieldIDs.UnPatch()
|
|
|
|
mockHasDynamicFields := mockey.Mock((*highlight.SemanticHighlight).HasDynamicFields).Return(true).Build()
|
|
defer mockHasDynamicFields.UnPatch()
|
|
|
|
mockDynamicFieldNames := mockey.Mock((*highlight.SemanticHighlight).DynamicFieldNames).Return([]string{"dyn_content"}).Build()
|
|
defer mockDynamicFieldNames.UnPatch()
|
|
|
|
mockDynamicFieldID := mockey.Mock((*highlight.SemanticHighlight).DynamicFieldID).Return(int64(102)).Build()
|
|
defer mockDynamicFieldID.UnPatch()
|
|
|
|
// Create operator
|
|
op := &semanticHighlightOperator{
|
|
highlight: &highlight.SemanticHighlight{},
|
|
}
|
|
|
|
// Create search results with $meta JSON field data
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 2,
|
|
Topks: []int64{2},
|
|
Ids: testSearchResultIDs(1, 2),
|
|
Scores: []float32{0.9, 0.8},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
FieldId: 102,
|
|
FieldName: "$meta",
|
|
Type: schemapb.DataType_JSON,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_JsonData{
|
|
JsonData: &schemapb.JSONArray{
|
|
Data: [][]byte{
|
|
[]byte(`{"dyn_content": "dynamic content 1"}`),
|
|
[]byte(`{"dyn_content": "dynamic content 2"}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Run the operator
|
|
results, err := op.run(ctx, s.span, searchResults)
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
|
|
// Verify results
|
|
result := results[0].(*milvuspb.SearchResults)
|
|
s.NotNil(result.Results.HighlightResults)
|
|
s.Len(result.Results.HighlightResults, 1)
|
|
|
|
highlightResult := result.Results.HighlightResults[0]
|
|
s.Equal("dyn_content", highlightResult.FieldName)
|
|
s.Len(highlightResult.Datas, 2)
|
|
s.Equal([]string{"<em>dynamic</em> content 1"}, highlightResult.Datas[0].Fragments)
|
|
s.Equal([]string{"<em>dynamic</em> content 2"}, highlightResult.Datas[1].Fragments)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSemanticHighlightOpMixedFields() {
|
|
ctx := context.Background()
|
|
|
|
// Track calls to distinguish schema field vs dynamic field processing
|
|
callCount := 0
|
|
mockProcess := mockey.Mock((*highlight.SemanticHighlight).Process).To(
|
|
func(h *highlight.SemanticHighlight, ctx context.Context, topks []int64, texts []string) ([][]string, [][]float32, error) {
|
|
callCount++
|
|
if callCount == 1 {
|
|
// Schema field
|
|
return [][]string{{"<em>schema</em> text"}}, [][]float32{{0.95}}, nil
|
|
}
|
|
// Dynamic field
|
|
return [][]string{{"<em>dynamic</em> text"}}, [][]float32{{0.90}}, nil
|
|
}).Build()
|
|
defer mockProcess.UnPatch()
|
|
|
|
mockFieldIDs := mockey.Mock((*highlight.SemanticHighlight).FieldIDs).Return([]int64{101}).Build()
|
|
defer mockFieldIDs.UnPatch()
|
|
|
|
mockGetFieldName := mockey.Mock((*highlight.SemanticHighlight).GetFieldName).Return(testVarCharField).Build()
|
|
defer mockGetFieldName.UnPatch()
|
|
|
|
mockHasDynamicFields := mockey.Mock((*highlight.SemanticHighlight).HasDynamicFields).Return(true).Build()
|
|
defer mockHasDynamicFields.UnPatch()
|
|
|
|
mockDynamicFieldNames := mockey.Mock((*highlight.SemanticHighlight).DynamicFieldNames).Return([]string{"dyn_content"}).Build()
|
|
defer mockDynamicFieldNames.UnPatch()
|
|
|
|
mockDynamicFieldID := mockey.Mock((*highlight.SemanticHighlight).DynamicFieldID).Return(int64(102)).Build()
|
|
defer mockDynamicFieldID.UnPatch()
|
|
|
|
// Create operator
|
|
op := &semanticHighlightOperator{
|
|
highlight: &highlight.SemanticHighlight{},
|
|
}
|
|
|
|
// Create search results with both schema field and $meta JSON field
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 1,
|
|
Topks: []int64{1},
|
|
Ids: testSearchResultIDs(1),
|
|
Scores: []float32{0.9},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
FieldId: 101,
|
|
FieldName: testVarCharField,
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{
|
|
Data: []string{"schema text"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
FieldId: 102,
|
|
FieldName: "$meta",
|
|
Type: schemapb.DataType_JSON,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_JsonData{
|
|
JsonData: &schemapb.JSONArray{
|
|
Data: [][]byte{
|
|
[]byte(`{"dyn_content": "dynamic text"}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Run the operator
|
|
results, err := op.run(ctx, s.span, searchResults)
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
|
|
// Verify results
|
|
result := results[0].(*milvuspb.SearchResults)
|
|
s.NotNil(result.Results.HighlightResults)
|
|
s.Len(result.Results.HighlightResults, 2)
|
|
|
|
// Schema field result
|
|
s.Equal(testVarCharField, result.Results.HighlightResults[0].FieldName)
|
|
s.Equal([]string{"<em>schema</em> text"}, result.Results.HighlightResults[0].Datas[0].Fragments)
|
|
|
|
// Dynamic field result
|
|
s.Equal("dyn_content", result.Results.HighlightResults[1].FieldName)
|
|
s.Equal([]string{"<em>dynamic</em> text"}, result.Results.HighlightResults[1].Datas[0].Fragments)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestExtractMultipleDynamicFieldTexts() {
|
|
// Test normal extraction with multiple fields
|
|
jsonData := [][]byte{
|
|
[]byte(`{"field1": "value1", "field2": "value2"}`),
|
|
[]byte(`{"field1": "value3", "field2": "value4"}`),
|
|
}
|
|
result, err := extractMultipleDynamicFieldTexts(jsonData, []string{"field1", "field2"})
|
|
s.NoError(err)
|
|
s.Equal([]string{"value1", "value3"}, result["field1"])
|
|
s.Equal([]string{"value2", "value4"}, result["field2"])
|
|
|
|
// Test single field extraction
|
|
result2, err := extractMultipleDynamicFieldTexts(jsonData, []string{"field1"})
|
|
s.NoError(err)
|
|
s.Equal([]string{"value1", "value3"}, result2["field1"])
|
|
|
|
// Test missing field (graceful degradation)
|
|
jsonData2 := [][]byte{
|
|
[]byte(`{"field1": "value1"}`),
|
|
[]byte(`{"other": "value"}`),
|
|
}
|
|
result3, err := extractMultipleDynamicFieldTexts(jsonData2, []string{"field1"})
|
|
s.NoError(err)
|
|
s.Equal([]string{"value1", ""}, result3["field1"])
|
|
|
|
// Test empty JSON
|
|
jsonData3 := [][]byte{
|
|
[]byte(`{"field1": "value1"}`),
|
|
[]byte(``),
|
|
}
|
|
result4, err := extractMultipleDynamicFieldTexts(jsonData3, []string{"field1"})
|
|
s.NoError(err)
|
|
s.Equal([]string{"value1", ""}, result4["field1"])
|
|
|
|
// Test non-string value
|
|
jsonData4 := [][]byte{
|
|
[]byte(`{"field1": 123}`),
|
|
}
|
|
_, err = extractMultipleDynamicFieldTexts(jsonData4, []string{"field1"})
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "is not a string type")
|
|
|
|
// Test invalid JSON
|
|
jsonData5 := [][]byte{
|
|
[]byte(`{invalid json}`),
|
|
}
|
|
_, err = extractMultipleDynamicFieldTexts(jsonData5, []string{"field1"})
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "failed to unmarshal")
|
|
|
|
// Test multiple fields with partial data
|
|
jsonData6 := [][]byte{
|
|
[]byte(`{"field1": "a", "field2": "b"}`),
|
|
[]byte(`{"field1": "c"}`), // field2 missing
|
|
[]byte(`{"field2": "d"}`), // field1 missing
|
|
}
|
|
result5, err := extractMultipleDynamicFieldTexts(jsonData6, []string{"field1", "field2"})
|
|
s.NoError(err)
|
|
s.Equal([]string{"a", "c", ""}, result5["field1"])
|
|
s.Equal([]string{"b", "", "d"}, result5["field2"])
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSearchPipeline() {
|
|
collectionName := "test"
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
collectionName: collectionName,
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
Base: &commonpb.MsgBase{
|
|
MsgType: commonpb.MsgType_Search,
|
|
Timestamp: uint64(time.Now().UnixNano()),
|
|
},
|
|
MetricType: "L2",
|
|
Topk: 10,
|
|
Nq: 2,
|
|
PartitionIDs: []int64{1},
|
|
CollectionID: 1,
|
|
DbID: 1,
|
|
},
|
|
schema: &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "intField", DataType: schemapb.DataType_Int64},
|
|
},
|
|
},
|
|
pkField: &schemapb.FieldSchema{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
},
|
|
queryInfos: []*planpb.QueryInfo{{}},
|
|
translatedOutputFields: []string{"intField"},
|
|
}
|
|
|
|
pipeline, err := newPipeline(searchPipe, task)
|
|
s.NoError(err)
|
|
pipeline.AddNodes(task, endNode)
|
|
|
|
sr := genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, false)
|
|
results, storageCost, err := pipeline.Run(context.Background(), s.span, []*internalpb.SearchResults{sr}, segcore.StorageCost{ScannedRemoteBytes: 100, ScannedTotalBytes: 250})
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
s.NotNil(results.Results)
|
|
s.Equal(int64(2), results.Results.NumQueries)
|
|
s.Equal(int64(10), results.Results.Topks[0])
|
|
s.Equal(int64(10), results.Results.Topks[1])
|
|
s.NotNil(results.Results.Ids)
|
|
s.NotNil(results.Results.Ids.GetIntId())
|
|
s.Len(results.Results.Ids.GetIntId().Data, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.Scores)
|
|
s.Len(results.Results.Scores, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.FieldsData)
|
|
s.Len(results.Results.FieldsData, 1) // One output field
|
|
s.Equal("intField", results.Results.FieldsData[0].FieldName)
|
|
s.Equal(int64(101), results.Results.FieldsData[0].FieldId)
|
|
s.Equal(int64(100), storageCost.ScannedRemoteBytes)
|
|
s.Equal(int64(250), storageCost.ScannedTotalBytes)
|
|
s.Equal(int64(2*10), results.GetResults().AllSearchCount)
|
|
fmt.Println(results)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSearchPipelineWithRequery() {
|
|
collectionName := "test_collection"
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
collectionName: collectionName,
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
Base: &commonpb.MsgBase{
|
|
MsgType: commonpb.MsgType_Search,
|
|
Timestamp: uint64(time.Now().UnixNano()),
|
|
},
|
|
MetricType: "L2",
|
|
Topk: 10,
|
|
Nq: 2,
|
|
PartitionIDs: []int64{1},
|
|
CollectionID: 1,
|
|
DbID: 1,
|
|
},
|
|
schema: &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "intField", DataType: schemapb.DataType_Int64},
|
|
},
|
|
},
|
|
pkField: &schemapb.FieldSchema{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
},
|
|
queryInfos: []*planpb.QueryInfo{{}},
|
|
translatedOutputFields: []string{"intField"},
|
|
node: nil,
|
|
request: &milvuspb.SearchRequest{Namespace: nil},
|
|
}
|
|
|
|
// Mock requery operation
|
|
f1 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "intField", 20)
|
|
f1.FieldId = 101
|
|
f2 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "int64", 20)
|
|
f2.FieldId = 100
|
|
mocker := mockey.Mock((*requeryOperator).requery).Return(&milvuspb.QueryResults{
|
|
FieldsData: []*schemapb.FieldData{f1, f2},
|
|
}, segcore.StorageCost{ScannedRemoteBytes: 100, ScannedTotalBytes: 200}, nil).Build()
|
|
defer mocker.UnPatch()
|
|
|
|
pipeline, err := newPipeline(searchWithRequeryPipe, task)
|
|
s.NoError(err)
|
|
pipeline.AddNodes(task, endNode)
|
|
|
|
results, storageCost, err := pipeline.Run(context.Background(), s.span, []*internalpb.SearchResults{
|
|
genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, false),
|
|
}, segcore.StorageCost{ScannedRemoteBytes: 100, ScannedTotalBytes: 200})
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
s.NotNil(results.Results)
|
|
s.Equal(int64(2), results.Results.NumQueries)
|
|
s.Equal(int64(10), results.Results.Topks[0])
|
|
s.Equal(int64(10), results.Results.Topks[1])
|
|
s.NotNil(results.Results.Ids)
|
|
s.NotNil(results.Results.Ids.GetIntId())
|
|
s.Len(results.Results.Ids.GetIntId().Data, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.Scores)
|
|
s.Len(results.Results.Scores, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.FieldsData)
|
|
s.Len(results.Results.FieldsData, 1) // One output field
|
|
s.Equal("intField", results.Results.FieldsData[0].FieldName)
|
|
s.Equal(int64(101), results.Results.FieldsData[0].FieldId)
|
|
s.Equal(int64(200), storageCost.ScannedRemoteBytes)
|
|
s.Equal(int64(400), storageCost.ScannedTotalBytes)
|
|
s.Equal(int64(2*10), results.GetResults().AllSearchCount)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSearchWithRerankPipe() {
|
|
functionSchema := &schemapb.FunctionSchema{
|
|
Name: "test",
|
|
Type: schemapb.FunctionType_Rerank,
|
|
InputFieldNames: []string{"intField"},
|
|
OutputFieldNames: []string{},
|
|
Params: []*commonpb.KeyValuePair{
|
|
{Key: "reranker", Value: "decay"},
|
|
{Key: "origin", Value: "4"},
|
|
{Key: "scale", Value: "4"},
|
|
{Key: "offset", Value: "4"},
|
|
{Key: "decay", Value: "0.5"},
|
|
{Key: "function", Value: "gauss"},
|
|
},
|
|
}
|
|
schema := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "intField", DataType: schemapb.DataType_Int64},
|
|
},
|
|
}
|
|
funcScoreSchema := &schemapb.FunctionScore{Functions: []*schemapb.FunctionSchema{functionSchema}}
|
|
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
collectionName: "test_collection",
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
MetricType: "L2",
|
|
Topk: 10,
|
|
Nq: 2,
|
|
PartitionIDs: []int64{1},
|
|
CollectionID: 1,
|
|
DbID: 1,
|
|
},
|
|
schema: &schemaInfo{
|
|
CollectionSchema: schema,
|
|
pkField: &schemapb.FieldSchema{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
},
|
|
queryInfos: []*planpb.QueryInfo{{}},
|
|
translatedOutputFields: []string{"intField"},
|
|
node: nil,
|
|
rerankMeta: newRerankMeta(schema, funcScoreSchema),
|
|
}
|
|
|
|
pipeline, err := newPipeline(searchWithRerankPipe, task)
|
|
s.NoError(err)
|
|
pipeline.AddNodes(task, endNode)
|
|
|
|
searchResults := genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, false)
|
|
results, _, err := pipeline.Run(context.Background(), s.span, []*internalpb.SearchResults{searchResults}, segcore.StorageCost{})
|
|
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
s.NotNil(results.Results)
|
|
s.Equal(int64(2), results.Results.NumQueries)
|
|
s.Equal(int64(10), results.Results.Topks[0])
|
|
s.Equal(int64(10), results.Results.Topks[1])
|
|
s.NotNil(results.Results.Ids)
|
|
s.NotNil(results.Results.Ids.GetIntId())
|
|
s.Len(results.Results.Ids.GetIntId().Data, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.Scores)
|
|
s.Len(results.Results.Scores, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.FieldsData)
|
|
s.Len(results.Results.FieldsData, 1) // One output field
|
|
s.Equal("intField", results.Results.FieldsData[0].FieldName)
|
|
s.Equal(int64(101), results.Results.FieldsData[0].FieldId)
|
|
s.Equal(int64(2*10), results.GetResults().AllSearchCount)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestSearchWithRerankRequeryPipe() {
|
|
functionSchema := &schemapb.FunctionSchema{
|
|
Name: "test",
|
|
Type: schemapb.FunctionType_Rerank,
|
|
InputFieldNames: []string{"intField"},
|
|
OutputFieldNames: []string{},
|
|
Params: []*commonpb.KeyValuePair{
|
|
{Key: "reranker", Value: "decay"},
|
|
{Key: "origin", Value: "4"},
|
|
{Key: "scale", Value: "4"},
|
|
{Key: "offset", Value: "4"},
|
|
{Key: "decay", Value: "0.5"},
|
|
{Key: "function", Value: "gauss"},
|
|
},
|
|
}
|
|
schema := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "intField", DataType: schemapb.DataType_Int64},
|
|
},
|
|
}
|
|
funcScoreSchema := &schemapb.FunctionScore{Functions: []*schemapb.FunctionSchema{functionSchema}}
|
|
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
collectionName: "test_collection",
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
Base: &commonpb.MsgBase{
|
|
MsgType: commonpb.MsgType_Search,
|
|
Timestamp: uint64(time.Now().UnixNano()),
|
|
},
|
|
MetricType: "L2",
|
|
Topk: 10,
|
|
Nq: 2,
|
|
PartitionIDs: []int64{1},
|
|
CollectionID: 1,
|
|
DbID: 1,
|
|
},
|
|
schema: &schemaInfo{
|
|
CollectionSchema: schema,
|
|
pkField: &schemapb.FieldSchema{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
},
|
|
queryInfos: []*planpb.QueryInfo{{}},
|
|
translatedOutputFields: []string{"intField"},
|
|
node: nil,
|
|
rerankMeta: newRerankMeta(schema, funcScoreSchema),
|
|
request: &milvuspb.SearchRequest{Namespace: nil},
|
|
}
|
|
f1 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "intField", 20)
|
|
f1.FieldId = 101
|
|
f2 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "int64", 20)
|
|
f2.FieldId = 100
|
|
mocker := mockey.Mock((*requeryOperator).requery).Return(&milvuspb.QueryResults{
|
|
FieldsData: []*schemapb.FieldData{f1, f2},
|
|
}, segcore.StorageCost{}, nil).Build()
|
|
defer mocker.UnPatch()
|
|
|
|
pipeline, err := newPipeline(searchWithRerankRequeryPipe, task)
|
|
s.NoError(err)
|
|
pipeline.AddNodes(task, endNode)
|
|
|
|
searchResults := genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, false)
|
|
results, storageCost, err := pipeline.Run(context.Background(), s.span, []*internalpb.SearchResults{searchResults}, segcore.StorageCost{})
|
|
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
s.NotNil(results.Results)
|
|
s.Equal(int64(2), results.Results.NumQueries)
|
|
s.Equal(int64(10), results.Results.Topks[0])
|
|
s.Equal(int64(10), results.Results.Topks[1])
|
|
s.NotNil(results.Results.Ids)
|
|
s.NotNil(results.Results.Ids.GetIntId())
|
|
s.Len(results.Results.Ids.GetIntId().Data, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.Scores)
|
|
s.Len(results.Results.Scores, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.FieldsData)
|
|
s.Len(results.Results.FieldsData, 1) // One output field
|
|
s.Equal("intField", results.Results.FieldsData[0].FieldName)
|
|
s.Equal(int64(101), results.Results.FieldsData[0].FieldId)
|
|
s.Equal(int64(0), storageCost.ScannedRemoteBytes)
|
|
s.Equal(int64(0), storageCost.ScannedTotalBytes)
|
|
s.Equal(int64(2*10), results.GetResults().AllSearchCount)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestHybridSearchPipe() {
|
|
task := getHybridSearchTask("test_collection", [][]string{
|
|
{"1", "2"},
|
|
{"3", "4"},
|
|
},
|
|
[]string{},
|
|
)
|
|
|
|
pipeline, err := newPipeline(hybridSearchPipe, task)
|
|
s.NoError(err)
|
|
pipeline.AddNodes(task, endNode)
|
|
|
|
f1 := genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, true)
|
|
f2 := genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, true)
|
|
results, storageCost, err := pipeline.Run(context.Background(), s.span, []*internalpb.SearchResults{f1, f2}, segcore.StorageCost{ScannedRemoteBytes: 900, ScannedTotalBytes: 2000})
|
|
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
s.NotNil(results.Results)
|
|
s.Equal(int64(2), results.Results.NumQueries)
|
|
s.Equal(int64(10), results.Results.Topks[0])
|
|
s.Equal(int64(10), results.Results.Topks[1])
|
|
s.NotNil(results.Results.Ids)
|
|
s.NotNil(results.Results.Ids.GetIntId())
|
|
s.Len(results.Results.Ids.GetIntId().Data, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.Scores)
|
|
s.Len(results.Results.Scores, 20) // 2 queries * 10 topk
|
|
s.Equal(int64(900), storageCost.ScannedRemoteBytes)
|
|
s.Equal(int64(2000), storageCost.ScannedTotalBytes)
|
|
s.Equal(int64(2*2*10), results.GetResults().AllSearchCount)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestFilterFieldOperatorWithStructArrayFields() {
|
|
schema := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "intField", DataType: schemapb.DataType_Int64},
|
|
{FieldID: 102, Name: "floatField", DataType: schemapb.DataType_Float},
|
|
},
|
|
StructArrayFields: []*schemapb.StructArrayFieldSchema{
|
|
{
|
|
Name: "structArray",
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 104, Name: "structArrayField", DataType: schemapb.DataType_Array, ElementType: schemapb.DataType_Int32},
|
|
{FieldID: 105, Name: "structVectorField", DataType: schemapb.DataType_ArrayOfVector, ElementType: schemapb.DataType_FloatVector},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
task := &searchTask{
|
|
schema: &schemaInfo{
|
|
CollectionSchema: schema,
|
|
},
|
|
translatedOutputFields: []string{"intField", "floatField", "structArrayField", "structVectorField"},
|
|
}
|
|
|
|
op, err := newEndOperator(task, nil)
|
|
s.NoError(err)
|
|
|
|
// Create mock search results with fields including struct array fields
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
FieldsData: []*schemapb.FieldData{
|
|
{FieldId: 101}, // intField
|
|
{FieldId: 102}, // floatField
|
|
{FieldId: 104}, // structArrayField
|
|
{FieldId: 105}, // structVectorField
|
|
},
|
|
},
|
|
}
|
|
|
|
results, err := op.run(context.Background(), s.span, searchResults, []*milvuspb.SearchResults{{Results: &schemapb.SearchResultData{AllSearchCount: 0}}})
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
|
|
resultData := results[0].(*milvuspb.SearchResults)
|
|
s.NotNil(resultData.Results.FieldsData)
|
|
s.Len(resultData.Results.FieldsData, 4)
|
|
|
|
// Verify all fields including struct array fields got their names and types set
|
|
for _, field := range resultData.Results.FieldsData {
|
|
switch field.FieldId {
|
|
case 101:
|
|
s.Equal("intField", field.FieldName)
|
|
s.Equal(schemapb.DataType_Int64, field.Type)
|
|
s.False(field.IsDynamic)
|
|
case 102:
|
|
s.Equal("floatField", field.FieldName)
|
|
s.Equal(schemapb.DataType_Float, field.Type)
|
|
s.False(field.IsDynamic)
|
|
case 104:
|
|
// Struct array field should be handled by GetAllFieldSchemas
|
|
s.Equal("structArrayField", field.FieldName)
|
|
s.Equal(schemapb.DataType_Array, field.Type)
|
|
s.False(field.IsDynamic)
|
|
case 105:
|
|
// Struct array vector field should be handled by GetAllFieldSchemas
|
|
s.Equal("structVectorField", field.FieldName)
|
|
s.Equal(schemapb.DataType_ArrayOfVector, field.Type)
|
|
s.False(field.IsDynamic)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestEndOperatorRoundsScores() {
|
|
task := &searchTask{
|
|
queryInfos: []*planpb.QueryInfo{{RoundDecimal: 0}},
|
|
schema: &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{},
|
|
},
|
|
}
|
|
|
|
op, err := newEndOperator(task, nil)
|
|
s.NoError(err)
|
|
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Scores: []float32{0.49, 0.36},
|
|
},
|
|
}
|
|
|
|
results, err := op.run(context.Background(), s.span, searchResults, []*milvuspb.SearchResults{{Results: &schemapb.SearchResultData{AllSearchCount: 0}}})
|
|
s.NoError(err)
|
|
|
|
resultData := results[0].(*milvuspb.SearchResults).GetResults()
|
|
s.Equal([]float32{0, 0}, resultData.GetScores())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestRoundAggHitScores() {
|
|
// Aggregation searches bypass endOperator, so hit scores are rounded at the
|
|
// aggregate operator's terminal step. Covers nested sub-aggregation buckets.
|
|
buckets := []*search_agg.AggBucketResult{
|
|
{
|
|
Hits: []*search_agg.HitResult{{Score: 0.49}, {Score: 0.36}},
|
|
SubAggBuckets: []*search_agg.AggBucketResult{
|
|
{Hits: []*search_agg.HitResult{{Score: 0.51}, nil}},
|
|
},
|
|
},
|
|
nil,
|
|
}
|
|
|
|
roundAggHitScores(buckets, 0)
|
|
|
|
s.Equal(float32(0), buckets[0].Hits[0].Score)
|
|
s.Equal(float32(0), buckets[0].Hits[1].Score)
|
|
s.Equal(float32(1), buckets[0].SubAggBuckets[0].Hits[0].Score)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestRoundAggHitScoresDisabled() {
|
|
buckets := []*search_agg.AggBucketResult{
|
|
{Hits: []*search_agg.HitResult{{Score: 0.49}, {Score: 0.36}}},
|
|
}
|
|
|
|
roundAggHitScores(buckets, -1)
|
|
|
|
s.Equal(float32(0.49), buckets[0].Hits[0].Score)
|
|
s.Equal(float32(0.36), buckets[0].Hits[1].Score)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestEndOperatorKeepsScoresWhenRoundDecimalDisabled() {
|
|
task := &searchTask{
|
|
queryInfos: []*planpb.QueryInfo{{RoundDecimal: -1}},
|
|
schema: &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{},
|
|
},
|
|
}
|
|
|
|
op, err := newEndOperator(task, nil)
|
|
s.NoError(err)
|
|
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Scores: []float32{0.49, 0.36},
|
|
},
|
|
}
|
|
|
|
results, err := op.run(context.Background(), s.span, searchResults, []*milvuspb.SearchResults{{Results: &schemapb.SearchResultData{AllSearchCount: 0}}})
|
|
s.NoError(err)
|
|
|
|
resultData := results[0].(*milvuspb.SearchResults).GetResults()
|
|
s.Equal([]float32{float32(0.49), float32(0.36)}, resultData.GetScores())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestEndOperatorKeepsAdvancedRerankScores() {
|
|
task := &searchTask{
|
|
SearchRequest: &internalpb.SearchRequest{IsAdvanced: true},
|
|
rankParams: &rankParams{roundDecimal: 0},
|
|
schema: &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{},
|
|
},
|
|
}
|
|
|
|
op, err := newEndOperator(task, nil)
|
|
s.NoError(err)
|
|
|
|
searchResults := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Scores: []float32{0.99, 0.88},
|
|
},
|
|
}
|
|
|
|
results, err := op.run(context.Background(), s.span, searchResults, []*milvuspb.SearchResults{{Results: &schemapb.SearchResultData{AllSearchCount: 0}}})
|
|
s.NoError(err)
|
|
|
|
resultData := results[0].(*milvuspb.SearchResults).GetResults()
|
|
s.Equal([]float32{float32(0.99), float32(0.88)}, resultData.GetScores())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestHybridSearchWithRequeryAndRerankByDataPipe() {
|
|
task := getHybridSearchTask("test_collection", [][]string{
|
|
{"1", "2"},
|
|
{"3", "4"},
|
|
},
|
|
[]string{"intField"},
|
|
)
|
|
|
|
f1 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "intField", 20)
|
|
f1.FieldId = 101
|
|
f2 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "int64", 20)
|
|
f2.FieldId = 100
|
|
mocker := mockey.Mock((*requeryOperator).requery).Return(&milvuspb.QueryResults{
|
|
FieldsData: []*schemapb.FieldData{f1, f2},
|
|
}, segcore.StorageCost{}, nil).Build()
|
|
defer mocker.UnPatch()
|
|
|
|
pipeline, err := newPipeline(hybridSearchWithRequeryAndRerankByFieldDataPipe, task)
|
|
s.NoError(err)
|
|
pipeline.AddNodes(task, endNode)
|
|
d1 := genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, true)
|
|
d2 := genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, true)
|
|
results, _, err := pipeline.Run(context.Background(), s.span, []*internalpb.SearchResults{d1, d2}, segcore.StorageCost{})
|
|
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
s.NotNil(results.Results)
|
|
s.Equal(int64(2), results.Results.NumQueries)
|
|
s.Equal(int64(10), results.Results.Topks[0])
|
|
s.Equal(int64(10), results.Results.Topks[1])
|
|
s.NotNil(results.Results.Ids)
|
|
s.NotNil(results.Results.Ids.GetIntId())
|
|
s.Len(results.Results.Ids.GetIntId().Data, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.Scores)
|
|
s.Len(results.Results.Scores, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.FieldsData)
|
|
s.Len(results.Results.FieldsData, 1) // One output field
|
|
s.Equal("intField", results.Results.FieldsData[0].FieldName)
|
|
s.Equal(int64(101), results.Results.FieldsData[0].FieldId)
|
|
s.Equal(int64(2*2*10), results.GetResults().AllSearchCount)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestHybridSearchWithRequeryAndRerankByDataPipe_ElementLevelRequeryUsesPKs() {
|
|
task := getHybridSearchTask("test_collection", [][]string{
|
|
{"1"},
|
|
{"2"},
|
|
}, []string{"intField"})
|
|
task.hybridElementLevel = true
|
|
|
|
input := &milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 3,
|
|
Topks: []int64{3},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{10, 10, 20}},
|
|
},
|
|
},
|
|
Scores: []float32{0.8, 0.9, 0.7},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{0, 2, 1}},
|
|
AllSearchCount: 3,
|
|
},
|
|
}
|
|
pkField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "int64",
|
|
FieldId: 100,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{10, 20}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
intField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "intField",
|
|
FieldId: 101,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{100, 200}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
originalReduceFactory := opFactory[hybridSearchReduceOp]
|
|
originalRequeryFactory := opFactory[requeryOp]
|
|
originalRerankFactory := opFactory[rerankOp]
|
|
defer func() {
|
|
opFactory[hybridSearchReduceOp] = originalReduceFactory
|
|
opFactory[requeryOp] = originalRequeryFactory
|
|
opFactory[rerankOp] = originalRerankFactory
|
|
}()
|
|
|
|
opFactory[hybridSearchReduceOp] = func(_ *searchTask, _ map[string]any) (operator, error) {
|
|
return searchPipelineTestOperator(func(ctx context.Context, span trace.Span, inputs ...any) ([]any, error) {
|
|
return []any{[]*milvuspb.SearchResults{input}, []string{"IP"}}, nil
|
|
}), nil
|
|
}
|
|
|
|
requeryCalled := false
|
|
opFactory[requeryOp] = func(_ *searchTask, _ map[string]any) (operator, error) {
|
|
return searchPipelineTestOperator(func(ctx context.Context, span trace.Span, inputs ...any) ([]any, error) {
|
|
requeryCalled = true
|
|
|
|
ids, ok := inputs[0].(*schemapb.IDs)
|
|
s.Require().True(ok)
|
|
s.ElementsMatch([]int64{10, 20}, ids.GetIntId().GetData())
|
|
s.Nil(ids.GetStrId())
|
|
|
|
storageCost := inputs[1].(segcore.StorageCost)
|
|
return []any{[]*schemapb.FieldData{intField, pkField}, storageCost}, nil
|
|
}), nil
|
|
}
|
|
|
|
rerankCalled := false
|
|
opFactory[rerankOp] = func(_ *searchTask, _ map[string]any) (operator, error) {
|
|
return searchPipelineTestOperator(func(ctx context.Context, span trace.Span, inputs ...any) ([]any, error) {
|
|
rerankCalled = true
|
|
|
|
rankData, ok := inputs[0].([]*milvuspb.SearchResults)
|
|
s.Require().True(ok)
|
|
s.Require().Len(rankData, 1)
|
|
data := rankData[0].GetResults()
|
|
s.Equal([]string{
|
|
makeHybridElementKey(int64(10), 0),
|
|
makeHybridElementKey(int64(10), 2),
|
|
makeHybridElementKey(int64(20), 1),
|
|
}, data.GetIds().GetStrId().GetData())
|
|
s.Equal([]int64{100, 100, 200}, data.GetFieldsData()[0].GetScalars().GetLongData().GetData())
|
|
|
|
return []any{&milvuspb.SearchResults{
|
|
Status: merr.Success(),
|
|
Results: &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
TopK: 2,
|
|
Topks: []int64{2},
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_StrId{
|
|
StrId: &schemapb.StringArray{Data: []string{
|
|
makeHybridElementKey(int64(10), 2),
|
|
makeHybridElementKey(int64(20), 1),
|
|
}},
|
|
},
|
|
},
|
|
Scores: []float32{0.99, 0.88},
|
|
},
|
|
}}, nil
|
|
}), nil
|
|
}
|
|
|
|
pipeline, err := newPipeline(hybridSearchWithRequeryAndRerankByFieldDataPipe, task)
|
|
s.Require().NoError(err)
|
|
s.Require().NoError(pipeline.AddNodes(task, endNode))
|
|
|
|
results, _, err := pipeline.Run(context.Background(), s.span, nil, segcore.StorageCost{})
|
|
s.Require().NoError(err)
|
|
s.True(requeryCalled)
|
|
s.True(rerankCalled)
|
|
|
|
result := results.GetResults()
|
|
s.Equal([]int64{10, 20}, result.GetIds().GetIntId().GetData())
|
|
s.Equal([]int64{2, 1}, result.GetElementIndices().GetData())
|
|
s.Equal([]float32{0.99, 0.88}, result.GetScores())
|
|
s.Equal([]int64{100, 200}, result.GetFieldsData()[0].GetScalars().GetLongData().GetData())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestHybridSearchWithRequeryPipe() {
|
|
task := getHybridSearchTask("test_collection", [][]string{
|
|
{"1", "2"},
|
|
{"3", "4"},
|
|
},
|
|
[]string{"intField"},
|
|
)
|
|
|
|
f1 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "intField", 20)
|
|
f1.FieldId = 101
|
|
f2 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "int64", 20)
|
|
f2.FieldId = 100
|
|
mocker := mockey.Mock((*requeryOperator).requery).Return(&milvuspb.QueryResults{
|
|
FieldsData: []*schemapb.FieldData{f1, f2},
|
|
}, segcore.StorageCost{}, nil).Build()
|
|
defer mocker.UnPatch()
|
|
|
|
pipeline, err := newPipeline(hybridSearchWithRequeryPipe, task)
|
|
s.NoError(err)
|
|
pipeline.AddNodes(task, endNode)
|
|
|
|
d1 := genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, true)
|
|
d2 := genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, true)
|
|
results, _, err := pipeline.Run(context.Background(), s.span, []*internalpb.SearchResults{d1, d2}, segcore.StorageCost{})
|
|
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
s.NotNil(results.Results)
|
|
s.Equal(int64(2), results.Results.NumQueries)
|
|
s.Equal(int64(10), results.Results.Topks[0])
|
|
s.Equal(int64(10), results.Results.Topks[1])
|
|
s.NotNil(results.Results.Ids)
|
|
s.NotNil(results.Results.Ids.GetIntId())
|
|
s.Len(results.Results.Ids.GetIntId().Data, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.Scores)
|
|
s.Len(results.Results.Scores, 20) // 2 queries * 10 topk
|
|
s.NotNil(results.Results.FieldsData)
|
|
s.Len(results.Results.FieldsData, 1) // One output field
|
|
s.Equal("intField", results.Results.FieldsData[0].FieldName)
|
|
s.Equal(int64(101), results.Results.FieldsData[0].FieldId)
|
|
s.Equal(int64(2*2*10), results.GetResults().AllSearchCount)
|
|
}
|
|
|
|
func getHybridSearchTask(collName string, data [][]string, outputFields []string) *searchTask {
|
|
subReqs := []*milvuspb.SubSearchRequest{}
|
|
for _, item := range data {
|
|
subReq := &milvuspb.SubSearchRequest{
|
|
SearchParams: []*commonpb.KeyValuePair{
|
|
{Key: TopKKey, Value: "10"},
|
|
},
|
|
Nq: int64(len(item)),
|
|
}
|
|
subReqs = append(subReqs, subReq)
|
|
}
|
|
functionSchema := &schemapb.FunctionSchema{
|
|
Name: "test",
|
|
Type: schemapb.FunctionType_Rerank,
|
|
InputFieldNames: []string{},
|
|
OutputFieldNames: []string{},
|
|
Params: []*commonpb.KeyValuePair{
|
|
{Key: "reranker", Value: "rrf"},
|
|
},
|
|
}
|
|
|
|
schema := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "intField", DataType: schemapb.DataType_Int64},
|
|
},
|
|
}
|
|
funcScoreSchema := &schemapb.FunctionScore{Functions: []*schemapb.FunctionSchema{functionSchema}}
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
collectionName: collName,
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
Base: &commonpb.MsgBase{
|
|
MsgType: commonpb.MsgType_Search,
|
|
Timestamp: uint64(time.Now().UnixNano()),
|
|
},
|
|
Topk: 10,
|
|
Nq: 2,
|
|
IsAdvanced: true,
|
|
SubReqs: []*internalpb.SubSearchRequest{
|
|
{
|
|
Topk: 10,
|
|
Nq: 2,
|
|
},
|
|
{
|
|
Topk: 10,
|
|
Nq: 2,
|
|
},
|
|
},
|
|
},
|
|
request: &milvuspb.SearchRequest{
|
|
CollectionName: collName,
|
|
SubReqs: subReqs,
|
|
SearchParams: []*commonpb.KeyValuePair{
|
|
{Key: LimitKey, Value: "10"},
|
|
},
|
|
FunctionScore: funcScoreSchema,
|
|
OutputFields: outputFields,
|
|
},
|
|
schema: &schemaInfo{
|
|
CollectionSchema: schema,
|
|
pkField: &schemapb.FieldSchema{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
},
|
|
mixCoord: nil,
|
|
tr: timerecord.NewTimeRecorder("test-search"),
|
|
rankParams: &rankParams{
|
|
limit: 10,
|
|
offset: 0,
|
|
roundDecimal: 0,
|
|
},
|
|
queryInfos: []*planpb.QueryInfo{{}, {}},
|
|
rerankMeta: newRerankMeta(schema, funcScoreSchema),
|
|
translatedOutputFields: outputFields,
|
|
}
|
|
return task
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestMergeIDsFunc() {
|
|
{
|
|
ids1 := &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{
|
|
Data: []int64{1, 2, 3, 5},
|
|
},
|
|
},
|
|
}
|
|
|
|
ids2 := &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{
|
|
Data: []int64{1, 2, 4, 5, 6},
|
|
},
|
|
},
|
|
}
|
|
rets := []*milvuspb.SearchResults{
|
|
{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: ids1,
|
|
},
|
|
},
|
|
{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: ids2,
|
|
},
|
|
},
|
|
}
|
|
allIDs, err := mergeIDsFunc(context.Background(), s.span, rets)
|
|
s.NoError(err)
|
|
sortedIds := allIDs[0].(*schemapb.IDs).GetIntId().GetData()
|
|
slices.Sort(sortedIds)
|
|
s.Equal(sortedIds, []int64{1, 2, 3, 4, 5, 6})
|
|
}
|
|
{
|
|
ids1 := &schemapb.IDs{
|
|
IdField: &schemapb.IDs_StrId{
|
|
StrId: &schemapb.StringArray{
|
|
Data: []string{"a", "b", "e"},
|
|
},
|
|
},
|
|
}
|
|
|
|
ids2 := &schemapb.IDs{
|
|
IdField: &schemapb.IDs_StrId{
|
|
StrId: &schemapb.StringArray{
|
|
Data: []string{"a", "b", "c", "d"},
|
|
},
|
|
},
|
|
}
|
|
rets := []*milvuspb.SearchResults{
|
|
{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: ids1,
|
|
},
|
|
},
|
|
}
|
|
rets = append(rets, &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: ids2,
|
|
},
|
|
})
|
|
allIDs, err := mergeIDsFunc(context.Background(), s.span, rets)
|
|
s.NoError(err)
|
|
sortedIds := allIDs[0].(*schemapb.IDs).GetStrId().GetData()
|
|
slices.Sort(sortedIds)
|
|
s.Equal(sortedIds, []string{"a", "b", "c", "d", "e"})
|
|
}
|
|
}
|
|
|
|
// Test parseOrderByFields function
|
|
func (s *SearchPipelineSuite) TestParseOrderByFields() {
|
|
schema := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{Name: "id", DataType: schemapb.DataType_Int64},
|
|
{Name: "score", DataType: schemapb.DataType_Float},
|
|
{Name: "name", DataType: schemapb.DataType_VarChar},
|
|
{Name: "active", DataType: schemapb.DataType_Bool},
|
|
{Name: "vector", DataType: schemapb.DataType_FloatVector},
|
|
},
|
|
}
|
|
|
|
// Test empty order_by_fields
|
|
params := []*commonpb.KeyValuePair{}
|
|
result, err := parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Nil(result)
|
|
|
|
// Test single field ascending (default)
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("score", result[0].FieldName)
|
|
s.True(result[0].Ascending)
|
|
s.False(result[0].NullsFirst)
|
|
|
|
// Test single field with explicit asc
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:asc"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("score", result[0].FieldName)
|
|
s.True(result[0].Ascending)
|
|
s.False(result[0].NullsFirst)
|
|
|
|
// Test single field descending
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:desc"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("score", result[0].FieldName)
|
|
s.False(result[0].Ascending)
|
|
s.True(result[0].NullsFirst)
|
|
|
|
// Test explicit null ordering
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:asc:nulls_first"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.True(result[0].Ascending)
|
|
s.True(result[0].NullsFirst)
|
|
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:desc:nulls_last"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.False(result[0].Ascending)
|
|
s.False(result[0].NullsFirst)
|
|
|
|
// Test multiple fields
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:desc,name:asc,id"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 3)
|
|
s.Equal("score", result[0].FieldName)
|
|
s.False(result[0].Ascending)
|
|
s.True(result[0].NullsFirst)
|
|
s.Equal("name", result[1].FieldName)
|
|
s.True(result[1].Ascending)
|
|
s.False(result[1].NullsFirst)
|
|
s.Equal("id", result[2].FieldName)
|
|
s.True(result[2].Ascending)
|
|
s.False(result[2].NullsFirst)
|
|
|
|
// Test invalid direction
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:invalid"}}
|
|
_, err = parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "invalid order direction")
|
|
|
|
// Test invalid null ordering
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:asc:nulls_middle"}}
|
|
_, err = parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "invalid null ordering 'nulls_middle', expected 'nulls_first' or 'nulls_last'")
|
|
|
|
// Test non-existent field
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "nonexistent"}}
|
|
_, err = parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "does not exist")
|
|
|
|
// Test unsortable type (vector)
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "vector"}}
|
|
_, err = parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "unsortable type")
|
|
|
|
// Test empty field name
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: ":asc"}}
|
|
_, err = parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "empty field name")
|
|
}
|
|
|
|
// Test parseOrderByFields with various input formats and edge cases
|
|
func (s *SearchPipelineSuite) TestParseOrderByFieldsEdgeCases() {
|
|
schema := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{Name: "id", DataType: schemapb.DataType_Int64},
|
|
{Name: "score", DataType: schemapb.DataType_Float},
|
|
{Name: "name", DataType: schemapb.DataType_VarChar},
|
|
},
|
|
}
|
|
|
|
// Test whitespace handling - spaces around field name
|
|
params := []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: " score "}}
|
|
result, err := parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("score", result[0].FieldName)
|
|
|
|
// Test whitespace handling - spaces around colon
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score : desc"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("score", result[0].FieldName)
|
|
s.False(result[0].Ascending)
|
|
|
|
// Test whitespace handling - spaces around comma
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:desc , name:asc"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 2)
|
|
s.Equal("score", result[0].FieldName)
|
|
s.Equal("name", result[1].FieldName)
|
|
|
|
// Test case insensitivity - uppercase ASC
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:ASC"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.True(result[0].Ascending)
|
|
|
|
// Test case insensitivity - uppercase DESC
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:DESC"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.False(result[0].Ascending)
|
|
|
|
// Test case insensitivity - mixed case
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:Desc"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.False(result[0].Ascending)
|
|
|
|
// Test "ascending" keyword
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:ascending"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.True(result[0].Ascending)
|
|
|
|
// Test "descending" keyword
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:descending"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.False(result[0].Ascending)
|
|
|
|
// Test empty string value
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: ""}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Nil(result)
|
|
|
|
// Test trailing comma - should skip empty part
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:asc,"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("score", result[0].FieldName)
|
|
|
|
// Test leading comma - should skip empty part
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: ",score:asc"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("score", result[0].FieldName)
|
|
|
|
// Test multiple commas - should skip empty parts
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:asc,,name:desc"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 2)
|
|
|
|
// Test only commas - should return nil
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: ",,,"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Nil(result)
|
|
|
|
// Test key not present - should return nil
|
|
params = []*commonpb.KeyValuePair{{Key: "other_key", Value: "score:asc"}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Nil(result)
|
|
|
|
// Test all supported sortable types
|
|
schemaAllTypes := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{Name: "f_bool", DataType: schemapb.DataType_Bool},
|
|
{Name: "f_int8", DataType: schemapb.DataType_Int8},
|
|
{Name: "f_int16", DataType: schemapb.DataType_Int16},
|
|
{Name: "f_int32", DataType: schemapb.DataType_Int32},
|
|
{Name: "f_int64", DataType: schemapb.DataType_Int64},
|
|
{Name: "f_float", DataType: schemapb.DataType_Float},
|
|
{Name: "f_double", DataType: schemapb.DataType_Double},
|
|
{Name: "f_string", DataType: schemapb.DataType_String},
|
|
{Name: "f_varchar", DataType: schemapb.DataType_VarChar},
|
|
},
|
|
}
|
|
|
|
// All these should be valid
|
|
for _, fieldName := range []string{"f_bool", "f_int8", "f_int16", "f_int32", "f_int64", "f_float", "f_double", "f_string", "f_varchar"} {
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: fieldName}}
|
|
result, err = parseOrderByFields(params, schemaAllTypes)
|
|
s.NoError(err, "field %s should be sortable", fieldName)
|
|
s.Len(result, 1)
|
|
s.Equal(fieldName, result[0].FieldName)
|
|
}
|
|
|
|
// Test unsortable types
|
|
// Note: JSON without path syntax is not directly sortable; use path syntax like field["key"] instead
|
|
schemaUnsortable := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{Name: "f_float_vector", DataType: schemapb.DataType_FloatVector},
|
|
{Name: "f_binary_vector", DataType: schemapb.DataType_BinaryVector},
|
|
{Name: "f_array", DataType: schemapb.DataType_Array},
|
|
{Name: "f_json", DataType: schemapb.DataType_JSON},
|
|
},
|
|
}
|
|
|
|
for _, fieldName := range []string{"f_float_vector", "f_binary_vector", "f_array", "f_json"} {
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: fieldName}}
|
|
_, err = parseOrderByFields(params, schemaUnsortable)
|
|
s.Error(err, "field %s should not be sortable", fieldName)
|
|
s.Contains(err.Error(), "unsortable type")
|
|
}
|
|
}
|
|
|
|
// Test isSortableFieldType function
|
|
func (s *SearchPipelineSuite) TestIsSortableFieldType() {
|
|
// Sortable types
|
|
s.True(isSortableFieldType(schemapb.DataType_Bool))
|
|
s.True(isSortableFieldType(schemapb.DataType_Int8))
|
|
s.True(isSortableFieldType(schemapb.DataType_Int16))
|
|
s.True(isSortableFieldType(schemapb.DataType_Int32))
|
|
s.True(isSortableFieldType(schemapb.DataType_Int64))
|
|
s.True(isSortableFieldType(schemapb.DataType_Float))
|
|
s.True(isSortableFieldType(schemapb.DataType_Double))
|
|
s.True(isSortableFieldType(schemapb.DataType_String))
|
|
s.True(isSortableFieldType(schemapb.DataType_VarChar))
|
|
|
|
// Non-sortable types
|
|
// Note: JSON type requires path syntax (e.g., field["key"]) for sorting
|
|
s.False(isSortableFieldType(schemapb.DataType_JSON))
|
|
s.False(isSortableFieldType(schemapb.DataType_FloatVector))
|
|
s.False(isSortableFieldType(schemapb.DataType_BinaryVector))
|
|
s.False(isSortableFieldType(schemapb.DataType_Array))
|
|
}
|
|
|
|
// Test compareFieldDataAt function
|
|
func (s *SearchPipelineSuite) TestCompareFieldDataAt() {
|
|
// Helper to call compareFieldDataAt and assert no error
|
|
mustCompare := func(field *schemapb.FieldData, i, j int) int {
|
|
cmp, err := compareFieldDataAt(field, i, j, true)
|
|
s.NoError(err)
|
|
return cmp
|
|
}
|
|
|
|
// Test Int64 comparison
|
|
int64Field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "int64_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{10, 20, 15}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.Equal(-1, mustCompare(int64Field, 0, 1)) // 10 < 20
|
|
s.Equal(1, mustCompare(int64Field, 1, 0)) // 20 > 10
|
|
s.Equal(0, mustCompare(int64Field, 0, 0)) // 10 == 10
|
|
|
|
// Test Float comparison
|
|
floatField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Float,
|
|
FieldName: "float_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_FloatData{
|
|
FloatData: &schemapb.FloatArray{Data: []float32{1.5, 2.5, 1.5}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.Equal(-1, mustCompare(floatField, 0, 1)) // 1.5 < 2.5
|
|
s.Equal(1, mustCompare(floatField, 1, 0)) // 2.5 > 1.5
|
|
s.Equal(0, mustCompare(floatField, 0, 2)) // 1.5 == 1.5
|
|
|
|
// Test String comparison
|
|
stringField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_VarChar,
|
|
FieldName: "string_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{Data: []string{"apple", "banana", "apple"}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.Equal(-1, mustCompare(stringField, 0, 1)) // "apple" < "banana"
|
|
s.Equal(1, mustCompare(stringField, 1, 0)) // "banana" > "apple"
|
|
s.Equal(0, mustCompare(stringField, 0, 2)) // "apple" == "apple"
|
|
|
|
// Test Bool comparison (false < true)
|
|
boolField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Bool,
|
|
FieldName: "bool_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_BoolData{
|
|
BoolData: &schemapb.BoolArray{Data: []bool{false, true, false}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.Equal(-1, mustCompare(boolField, 0, 1)) // false < true
|
|
s.Equal(1, mustCompare(boolField, 1, 0)) // true > false
|
|
s.Equal(0, mustCompare(boolField, 0, 2)) // false == false
|
|
}
|
|
|
|
// Test compareFieldDataAt with null handling
|
|
func (s *SearchPipelineSuite) TestCompareFieldDataAtWithNulls() {
|
|
// Helper to call compareFieldDataAt and assert no error
|
|
mustCompare := func(field *schemapb.FieldData, i, j int) int {
|
|
cmp, err := compareFieldDataAt(field, i, j, true)
|
|
s.NoError(err)
|
|
return cmp
|
|
}
|
|
|
|
// Test with ValidData (nullable field)
|
|
nullableField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "nullable_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{10, 20, 30}},
|
|
},
|
|
},
|
|
},
|
|
ValidData: []bool{true, false, true}, // index 1 is null
|
|
}
|
|
|
|
// NULLS FIRST
|
|
s.Equal(-1, mustCompare(nullableField, 1, 0)) // null < 10
|
|
s.Equal(1, mustCompare(nullableField, 0, 1)) // 10 > null
|
|
|
|
cmp, err := compareFieldDataAt(nullableField, 1, 0, false)
|
|
s.NoError(err)
|
|
s.Equal(1, cmp) // NULLS LAST: null > 10
|
|
cmp, err = compareFieldDataAt(nullableField, 0, 1, false)
|
|
s.NoError(err)
|
|
s.Equal(-1, cmp) // NULLS LAST: 10 < null
|
|
|
|
// null vs null: equal
|
|
nullableField2 := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "nullable_field2",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{10, 20, 30}},
|
|
},
|
|
},
|
|
},
|
|
ValidData: []bool{false, false, true}, // index 0 and 1 are null
|
|
}
|
|
s.Equal(0, mustCompare(nullableField2, 0, 1)) // null == null
|
|
|
|
// non-null vs non-null: normal comparison
|
|
s.Equal(-1, mustCompare(nullableField, 0, 2)) // 10 < 30
|
|
}
|
|
|
|
// Test isSameGroupByValue function
|
|
func (s *SearchPipelineSuite) TestIsSameGroupByValue() {
|
|
// Test Int64
|
|
int64Field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{1, 1, 2, 2, 3}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.True(isSameGroupByValue(int64Field, 0, 1)) // 1 == 1
|
|
s.False(isSameGroupByValue(int64Field, 1, 2)) // 1 != 2
|
|
s.True(isSameGroupByValue(int64Field, 2, 3)) // 2 == 2
|
|
|
|
// Test String
|
|
stringField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{Data: []string{"a", "a", "b"}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.True(isSameGroupByValue(stringField, 0, 1)) // "a" == "a"
|
|
s.False(isSameGroupByValue(stringField, 1, 2)) // "a" != "b"
|
|
|
|
// Test Bool
|
|
boolField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Bool,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_BoolData{
|
|
BoolData: &schemapb.BoolArray{Data: []bool{true, true, false}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.True(isSameGroupByValue(boolField, 0, 1)) // true == true
|
|
s.False(isSameGroupByValue(boolField, 1, 2)) // true != false
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestNewSearchReduceOperatorUsesPipelineOffsetParam() {
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
Nq: 1,
|
|
Topk: 3,
|
|
Offset: 2,
|
|
CollectionID: 100,
|
|
PartitionIDs: []int64{10},
|
|
},
|
|
schema: mustNewSchemaInfo(&schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 101, Name: "id", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
},
|
|
}),
|
|
queryInfos: []*planpb.QueryInfo{{}},
|
|
}
|
|
|
|
op, err := newSearchReduceOperator(task, map[string]any{reduceOffsetParamKey: int64(0)})
|
|
|
|
s.NoError(err)
|
|
s.Equal(int64(0), op.(*searchReduceOperator).offset)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestNewOrderByOperatorUsesPluralGroupByFieldIDs() {
|
|
task := &searchTask{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
queryInfos: []*planpb.QueryInfo{{
|
|
GroupByFieldId: -1,
|
|
GroupByFieldIds: []int64{101, 102},
|
|
GroupSize: 2,
|
|
}},
|
|
}
|
|
|
|
op, err := newOrderByOperator(task, nil)
|
|
s.NoError(err)
|
|
orderByOp := op.(*orderByOperator)
|
|
s.Equal(int64(101), orderByOp.groupByFieldId)
|
|
s.Equal(int64(2), orderByOp.groupSize)
|
|
}
|
|
|
|
// Test orderByOperator sorting
|
|
func (s *SearchPipelineSuite) TestOrderByOperator() {
|
|
// Create test search result
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
Topks: []int64{4}, // nq=1, 4 results
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 10, 40, 20}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Test ascending sort by price
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "price", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After ascending sort by price: 10, 20, 30, 40
|
|
// Original order: 30(id=1), 10(id=2), 40(id=3), 20(id=4)
|
|
// Sorted order: 10(id=2), 20(id=4), 30(id=1), 40(id=3)
|
|
expectedIds := []int64{2, 4, 1, 3}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
|
|
expectedPrices := []int64{10, 20, 30, 40}
|
|
actualPrices := sortedResult.Results.FieldsData[0].GetScalars().GetLongData().Data
|
|
s.Equal(expectedPrices, actualPrices)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorAppliesOffsetAfterOrderBy() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
Distances: []float32{9, 8, 7, 6},
|
|
Recalls: []float32{90, 80, 70, 60},
|
|
ElementIndices: &schemapb.LongArray{Data: []int64{10, 20, 30, 40}},
|
|
TopK: 4,
|
|
Topks: []int64{4},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 10, 20, 40}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: -1,
|
|
limit: 2,
|
|
offset: 1,
|
|
}
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
|
|
s.NoError(err)
|
|
sliced := outputs[0].(*milvuspb.SearchResults).GetResults()
|
|
s.Equal([]int64{3, 1}, sliced.GetIds().GetIntId().GetData())
|
|
s.Equal([]float32{0.7, 0.9}, sliced.GetScores())
|
|
s.Equal([]float32{7, 9}, sliced.GetDistances())
|
|
s.Equal([]float32{70, 90}, sliced.GetRecalls())
|
|
s.Equal([]int64{30, 10}, sliced.GetElementIndices().GetData())
|
|
s.Equal([]int64{20, 30}, sliced.GetFieldsData()[0].GetScalars().GetLongData().GetData())
|
|
s.Equal([]int64{2}, sliced.GetTopks())
|
|
s.Equal(int64(2), sliced.GetTopK())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorSlicesStringIDsAndNonNullableVectorAfterOrderBy() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_StrId{
|
|
StrId: &schemapb.StringArray{Data: []string{"a", "b", "c", "d"}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
TopK: 4,
|
|
Topks: []int64{4},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 10, 20, 40}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: schemapb.DataType_FloatVector,
|
|
FieldName: "vec",
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_FloatVector{
|
|
FloatVector: &schemapb.FloatArray{Data: []float32{1, 2, 3, 4, 5, 6, 7, 8}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: -1,
|
|
limit: 2,
|
|
offset: 1,
|
|
}
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
|
|
s.NoError(err)
|
|
sliced := outputs[0].(*milvuspb.SearchResults).GetResults()
|
|
s.Equal([]string{"c", "a"}, sliced.GetIds().GetStrId().GetData())
|
|
s.Equal([]float32{0.7, 0.9}, sliced.GetScores())
|
|
s.Equal([]int64{20, 30}, sliced.GetFieldsData()[0].GetScalars().GetLongData().GetData())
|
|
s.Equal([]float32{5, 6, 1, 2}, sliced.GetFieldsData()[1].GetVectors().GetFloatVector().GetData())
|
|
s.Equal([]int64{2}, sliced.GetTopks())
|
|
s.Equal(int64(2), sliced.GetTopK())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorAppliesGroupOffsetAfterOrderBy() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4, 5, 6}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.85, 0.8, 0.75, 0.7, 0.65},
|
|
TopK: 6,
|
|
Topks: []int64{6},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 25, 10, 20, 15, 18}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
GroupByFieldValues: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{Data: []string{"A", "A", "B", "C", "C", "C"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: 100,
|
|
groupSize: 3,
|
|
limit: 1,
|
|
offset: 1,
|
|
}
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
|
|
s.NoError(err)
|
|
sliced := outputs[0].(*milvuspb.SearchResults).GetResults()
|
|
s.Equal([]int64{4, 5, 6}, sliced.GetIds().GetIntId().GetData())
|
|
s.Equal([]float32{0.75, 0.7, 0.65}, sliced.GetScores())
|
|
s.Equal([]int64{20, 15, 18}, sliced.GetFieldsData()[0].GetScalars().GetLongData().GetData())
|
|
s.Equal([]string{"C", "C", "C"}, sliced.GetGroupByFieldValues()[0].GetScalars().GetStringData().GetData())
|
|
s.Nil(sliced.GetGroupByFieldValue())
|
|
s.Equal([]int64{3}, sliced.GetTopks())
|
|
s.Equal(int64(3), sliced.GetTopK())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorReordersNullableVectorCompactOutput() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7},
|
|
Topks: []int64{3},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 10, 20}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: schemapb.DataType_FloatVector,
|
|
FieldName: "nullable_float_vec",
|
|
ValidData: []bool{true, false, true},
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_FloatVector{
|
|
FloatVector: &schemapb.FloatArray{Data: []float32{1, 2, 5, 6}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: schemapb.DataType_SparseFloatVector,
|
|
FieldName: "nullable_sparse_vec",
|
|
ValidData: []bool{true, false, true},
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 3,
|
|
Data: &schemapb.VectorField_SparseFloatVector{
|
|
SparseFloatVector: &schemapb.SparseFloatArray{
|
|
Dim: 3,
|
|
Contents: [][]byte{{0x01}, {0x03}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "price", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
s.Equal([]int64{2, 3, 1}, sortedResult.Results.Ids.GetIntId().Data)
|
|
s.Equal([]int64{10, 20, 30}, sortedResult.Results.FieldsData[0].GetScalars().GetLongData().Data)
|
|
s.Equal([]bool{false, true, true}, sortedResult.Results.FieldsData[1].GetValidData())
|
|
s.Equal([]float32{5, 6, 1, 2}, sortedResult.Results.FieldsData[1].GetVectors().GetFloatVector().GetData())
|
|
s.Equal([]bool{false, true, true}, sortedResult.Results.FieldsData[2].GetValidData())
|
|
s.Equal([][]byte{{0x03}, {0x01}}, sortedResult.Results.FieldsData[2].GetVectors().GetSparseFloatVector().GetContents())
|
|
}
|
|
|
|
// Test orderByOperator with nq>1 (multiple queries)
|
|
// Each query's results should be sorted independently
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorMultipleQueries() {
|
|
// Create test search result with nq=2, topk=3 each
|
|
// Query 1: ids [1,2,3] with prices [30,10,20] -> sorted: [2,3,1] with prices [10,20,30]
|
|
// Query 2: ids [4,5,6] with prices [60,40,50] -> sorted: [5,6,4] with prices [40,50,60]
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4, 5, 6}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6, 0.5, 0.4},
|
|
Topks: []int64{3, 3}, // nq=2, each query has 3 results
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 10, 20, 60, 40, 50}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "price", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// Each query should be sorted independently
|
|
// Query 1: [10,20,30] -> ids [2,3,1]
|
|
// Query 2: [40,50,60] -> ids [5,6,4]
|
|
expectedIds := []int64{2, 3, 1, 5, 6, 4}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
|
|
expectedPrices := []int64{10, 20, 30, 40, 50, 60}
|
|
actualPrices := sortedResult.Results.FieldsData[0].GetScalars().GetLongData().Data
|
|
s.Equal(expectedPrices, actualPrices)
|
|
|
|
// Verify Topks unchanged
|
|
s.Equal([]int64{3, 3}, sortedResult.Results.Topks)
|
|
}
|
|
|
|
// Test orderByOperator with nq>1 and group_by
|
|
// Each query's groups should be sorted independently
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorMultipleQueriesWithGroupBy() {
|
|
// nq=2, each query has 2 groups with 2 results each
|
|
// Query 1: group A [ids 1,2], group B [ids 3,4] with prices [20,25,10,15]
|
|
// -> After sort by first row price: group B (10) comes before group A (20)
|
|
// -> Result: [3,4,1,2] with prices [10,15,20,25]
|
|
// Query 2: group C [ids 5,6], group D [ids 7,8] with prices [50,55,30,35]
|
|
// -> After sort by first row price: group D (30) comes before group C (50)
|
|
// -> Result: [7,8,5,6] with prices [30,35,50,55]
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4, 5, 6, 7, 8}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.85, 0.8, 0.75, 0.7, 0.65, 0.6, 0.55},
|
|
Topks: []int64{4, 4}, // nq=2, each query has 4 results (2 groups x 2)
|
|
GroupByFieldValue: &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "category",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
// Query 1: groups A(100), A(100), B(200), B(200)
|
|
// Query 2: groups C(300), C(300), D(400), D(400)
|
|
LongData: &schemapb.LongArray{Data: []int64{100, 100, 200, 200, 300, 300, 400, 400}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{20, 25, 10, 15, 50, 55, 30, 35}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "price", Ascending: true},
|
|
},
|
|
groupByFieldId: 1, // Enable group_by
|
|
groupSize: 2,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// Query 1: group B (price 10) before group A (price 20) -> [3,4,1,2]
|
|
// Query 2: group D (price 30) before group C (price 50) -> [7,8,5,6]
|
|
expectedIds := []int64{3, 4, 1, 2, 7, 8, 5, 6}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
|
|
expectedPrices := []int64{10, 15, 20, 25, 30, 35, 50, 55}
|
|
actualPrices := sortedResult.Results.FieldsData[0].GetScalars().GetLongData().Data
|
|
s.Equal(expectedPrices, actualPrices)
|
|
}
|
|
|
|
// Per liliu-z review (search_pipeline.go:1855): the per-query offset/limit loop was only
|
|
// exercised with nq=1. Assert offset/limit apply PER QUERY, not globally over the flattened
|
|
// result, so a regression that slices the offset window globally returns misaligned rows for nq>1.
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorAppliesOffsetPerQueryForMultipleQueries() {
|
|
// nq=2, topk=4 each.
|
|
// q1 ids[1,2,3,4] price[40,10,30,20] -> sort asc [2,4,3,1] -> offset1/limit2 -> [4,3]
|
|
// q2 ids[5,6,7,8] price[80,50,70,60] -> sort asc [6,8,7,5] -> offset1/limit2 -> [8,7]
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4, 5, 6, 7, 8}}}},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6, 0.5, 0.4, 0.3, 0.2},
|
|
TopK: 4,
|
|
Topks: []int64{4, 4},
|
|
FieldsData: []*schemapb.FieldData{{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{40, 10, 30, 20, 80, 50, 70, 60}}},
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: -1,
|
|
limit: 2,
|
|
offset: 1,
|
|
}
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sliced := outputs[0].(*milvuspb.SearchResults).GetResults()
|
|
|
|
// per-query pages: q1 -> [4,3], q2 -> [8,7]; a global slice would drop q2's page entirely.
|
|
s.Equal([]int64{4, 3, 8, 7}, sliced.GetIds().GetIntId().GetData())
|
|
s.Equal([]int64{20, 30, 60, 70}, sliced.GetFieldsData()[0].GetScalars().GetLongData().GetData())
|
|
s.Equal([]float32{0.6, 0.7, 0.2, 0.3}, sliced.GetScores())
|
|
s.Equal([]int64{2, 2}, sliced.GetTopks()) // each query keeps its own page size
|
|
}
|
|
|
|
// Same per-query invariant as above, combined with group-by: the group offset/limit must
|
|
// paginate groups PER QUERY, not across the flattened multi-query result.
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorAppliesGroupOffsetPerQueryForMultipleQueries() {
|
|
// nq=2, topk=4 each, 2 groups (size 2) per query.
|
|
// q1 A(ids1,2 p20,25) B(ids3,4 p10,15) -> groups asc by first price [B,A] -> off1/lim1 group -> A=[1,2]
|
|
// q2 C(ids5,6 p50,55) D(ids7,8 p30,35) -> groups asc by first price [D,C] -> off1/lim1 group -> C=[5,6]
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4, 5, 6, 7, 8}}}},
|
|
Scores: []float32{0.9, 0.85, 0.8, 0.75, 0.7, 0.65, 0.6, 0.55},
|
|
TopK: 4,
|
|
Topks: []int64{4, 4},
|
|
GroupByFieldValues: []*schemapb.FieldData{{
|
|
Type: schemapb.DataType_Int64,
|
|
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{100, 100, 200, 200, 300, 300, 400, 400}}},
|
|
}},
|
|
}},
|
|
FieldsData: []*schemapb.FieldData{{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{20, 25, 10, 15, 50, 55, 30, 35}}},
|
|
}},
|
|
}},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: 1,
|
|
groupSize: 2,
|
|
limit: 1,
|
|
offset: 1,
|
|
}
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sliced := outputs[0].(*milvuspb.SearchResults).GetResults()
|
|
|
|
s.Equal([]int64{1, 2, 5, 6}, sliced.GetIds().GetIntId().GetData())
|
|
s.Equal([]int64{20, 25, 50, 55}, sliced.GetFieldsData()[0].GetScalars().GetLongData().GetData())
|
|
s.Equal([]int64{2, 2}, sliced.GetTopks())
|
|
}
|
|
|
|
// paginateSortedRows: offset at/beyond the row count clamps to an empty page.
|
|
func (s *SearchPipelineSuite) TestPaginateSortedRowsOffsetBeyondLength() {
|
|
indices := []int{0, 1, 2}
|
|
s.Empty(paginateSortedRows(indices, 5, 2)) // offset > len -> empty
|
|
s.Empty(paginateSortedRows(indices, 3, 0)) // offset == len -> empty
|
|
s.Equal([]int{1, 2}, paginateSortedRows(indices, 1, 10)) // limit beyond end -> offset..end
|
|
s.Equal([]int{0, 1, 2}, paginateSortedRows(indices, 0, 0)) // no limit -> all
|
|
}
|
|
|
|
// getOrderByGroupByFieldValue: nil input, plural-preferred, legacy fallback.
|
|
func (s *SearchPipelineSuite) TestGetOrderByGroupByFieldValueNilAndSelection() {
|
|
s.Nil(getOrderByGroupByFieldValue(nil))
|
|
|
|
plural := &schemapb.SearchResultData{
|
|
GroupByFieldValues: []*schemapb.FieldData{{FieldName: "g"}},
|
|
}
|
|
s.Equal("g", getOrderByGroupByFieldValue(plural).GetFieldName())
|
|
|
|
legacy := &schemapb.SearchResultData{
|
|
GroupByFieldValue: &schemapb.FieldData{FieldName: "l"},
|
|
}
|
|
s.Equal("l", getOrderByGroupByFieldValue(legacy).GetFieldName())
|
|
}
|
|
|
|
// sortQueryResults returns early on empty indices (op.run guards with topk>0, so cover directly).
|
|
func (s *SearchPipelineSuite) TestSortQueryResultsEmptyIndices() {
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: -1,
|
|
}
|
|
out, err := op.sortQueryResults(&milvuspb.SearchResults{Results: &schemapb.SearchResultData{}}, []int{})
|
|
s.NoError(err)
|
|
s.Empty(out)
|
|
}
|
|
|
|
// sortGroupsByOrderByFields returns early on empty indices.
|
|
func (s *SearchPipelineSuite) TestSortGroupsByOrderByFieldsEmptyIndices() {
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: 0,
|
|
groupSize: 1,
|
|
}
|
|
out, err := op.sortGroupsByOrderByFields(&milvuspb.SearchResults{Results: &schemapb.SearchResultData{}}, []int{})
|
|
s.NoError(err)
|
|
s.Empty(out)
|
|
}
|
|
|
|
// reorderFieldData: vector payload length not divisible by per-vector width -> error (defensive guard).
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataDimNotDivisibleErrors() {
|
|
indices := []int{0}
|
|
cases := []struct {
|
|
name string
|
|
field *schemapb.FieldData
|
|
}{
|
|
{"float_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_FloatVector, FieldName: "fv",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_FloatVector{FloatVector: &schemapb.FloatArray{Data: []float32{1, 2, 3}}},
|
|
}},
|
|
}},
|
|
{"binary_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_BinaryVector, FieldName: "bv",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 16,
|
|
Data: &schemapb.VectorField_BinaryVector{BinaryVector: []byte{0x01, 0x02, 0x03}},
|
|
}},
|
|
}},
|
|
{"float16_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_Float16Vector, FieldName: "f16",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_Float16Vector{Float16Vector: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}},
|
|
}},
|
|
}},
|
|
{"bfloat16_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_BFloat16Vector, FieldName: "bf16",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_Bfloat16Vector{Bfloat16Vector: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}},
|
|
}},
|
|
}},
|
|
{"int8_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int8Vector, FieldName: "i8",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 4,
|
|
Data: &schemapb.VectorField_Int8Vector{Int8Vector: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}},
|
|
}},
|
|
}},
|
|
}
|
|
for _, c := range cases {
|
|
s.Error(reorderFieldData(c.field, indices), c.name)
|
|
}
|
|
}
|
|
|
|
// reorderFieldData: index out of range for the vector payload -> validateReorderIndex error (defensive guard).
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataIndexOutOfBoundsErrors() {
|
|
oob := []int{5} // every field below holds 2 vectors; index 5 is out of range
|
|
cases := []struct {
|
|
name string
|
|
field *schemapb.FieldData
|
|
}{
|
|
{"float_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_FloatVector, FieldName: "fv",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_FloatVector{FloatVector: &schemapb.FloatArray{Data: []float32{1, 2, 3, 4}}},
|
|
}},
|
|
}},
|
|
{"binary_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_BinaryVector, FieldName: "bv",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 16,
|
|
Data: &schemapb.VectorField_BinaryVector{BinaryVector: []byte{0x01, 0x02, 0x03, 0x04}},
|
|
}},
|
|
}},
|
|
{"float16_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_Float16Vector, FieldName: "f16",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_Float16Vector{Float16Vector: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}},
|
|
}},
|
|
}},
|
|
{"bfloat16_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_BFloat16Vector, FieldName: "bf16",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_Bfloat16Vector{Bfloat16Vector: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}},
|
|
}},
|
|
}},
|
|
{"sparse_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_SparseFloatVector, FieldName: "sv",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 100,
|
|
Data: &schemapb.VectorField_SparseFloatVector{SparseFloatVector: &schemapb.SparseFloatArray{
|
|
Contents: [][]byte{{0x01, 0x02}, {0x03, 0x04}},
|
|
}},
|
|
}},
|
|
}},
|
|
{"int8_vector", &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int8Vector, FieldName: "i8",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 4,
|
|
Data: &schemapb.VectorField_Int8Vector{Int8Vector: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08}},
|
|
}},
|
|
}},
|
|
}
|
|
for _, c := range cases {
|
|
s.Error(reorderFieldData(c.field, oob), c.name)
|
|
}
|
|
}
|
|
|
|
// reorderResults: out-of-range index for each result column -> internal error (defensive guard).
|
|
func (s *SearchPipelineSuite) TestReorderResultsIndexOutOfBounds() {
|
|
op := &orderByOperator{}
|
|
oob := []int{5}
|
|
longIDs := func() *schemapb.IDs {
|
|
return &schemapb.IDs{IdField: &schemapb.IDs_IntId{IntId: &schemapb.LongArray{Data: []int64{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}}}}
|
|
}
|
|
|
|
// int IDs
|
|
s.Error(op.reorderResults(&milvuspb.SearchResults{Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{IntId: &schemapb.LongArray{Data: []int64{1}}}},
|
|
}}, oob), "int_ids")
|
|
|
|
// string IDs
|
|
s.Error(op.reorderResults(&milvuspb.SearchResults{Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{IdField: &schemapb.IDs_StrId{StrId: &schemapb.StringArray{Data: []string{"a"}}}},
|
|
}}, oob), "str_ids")
|
|
|
|
// distances (IDs long enough so the distances check is the one that trips)
|
|
s.Error(op.reorderResults(&milvuspb.SearchResults{Results: &schemapb.SearchResultData{
|
|
Ids: longIDs(), Distances: []float32{0.1},
|
|
}}, oob), "distances")
|
|
|
|
// recalls
|
|
s.Error(op.reorderResults(&milvuspb.SearchResults{Results: &schemapb.SearchResultData{
|
|
Ids: longIDs(), Recalls: []float32{0.1},
|
|
}}, oob), "recalls")
|
|
|
|
// element indices
|
|
s.Error(op.reorderResults(&milvuspb.SearchResults{Results: &schemapb.SearchResultData{
|
|
Ids: longIDs(), ElementIndices: &schemapb.LongArray{Data: []int64{10}},
|
|
}}, oob), "element_indices")
|
|
}
|
|
|
|
// op.run propagates a sort error: the order_by field has fewer values than rows, so
|
|
// compareFieldDataAt hits an out-of-bounds index during the per-query sort.
|
|
func (s *SearchPipelineSuite) TestOrderByRunReturnsSortError() {
|
|
result := &milvuspb.SearchResults{Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}}}},
|
|
Scores: []float32{0.9, 0.8, 0.7},
|
|
Topks: []int64{3},
|
|
FieldsData: []*schemapb.FieldData{{
|
|
Type: schemapb.DataType_Int64, FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{10}}}, // 1 value for 3 rows
|
|
}},
|
|
}},
|
|
}}
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: -1,
|
|
}
|
|
_, err := op.run(context.Background(), s.span, result)
|
|
s.Error(err)
|
|
}
|
|
|
|
// op.run propagates a reorder error: the sort succeeds but a malformed vector field fails reorderResults.
|
|
func (s *SearchPipelineSuite) TestOrderByRunReturnsReorderError() {
|
|
result := &milvuspb.SearchResults{Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{IntId: &schemapb.LongArray{Data: []int64{1, 2}}}},
|
|
Scores: []float32{0.9, 0.8},
|
|
Topks: []int64{2},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64, FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{20, 10}}},
|
|
}},
|
|
},
|
|
{
|
|
Type: schemapb.DataType_FloatVector, FieldName: "vec",
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_FloatVector{FloatVector: &schemapb.FloatArray{Data: []float32{1, 2, 3}}}, // 3 not divisible by dim 2
|
|
}},
|
|
},
|
|
},
|
|
}}
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: -1,
|
|
}
|
|
_, err := op.run(context.Background(), s.span, result)
|
|
s.Error(err)
|
|
}
|
|
|
|
// sortGroupsByOrderByFields propagates a sort error during group ordering (group value present).
|
|
func (s *SearchPipelineSuite) TestSortGroupsByOrderByFieldsReturnsSortError() {
|
|
result := &milvuspb.SearchResults{Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4}}}},
|
|
GroupByFieldValues: []*schemapb.FieldData{{
|
|
Type: schemapb.DataType_Int64,
|
|
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{100, 100, 200, 200}}},
|
|
}},
|
|
}},
|
|
FieldsData: []*schemapb.FieldData{{
|
|
Type: schemapb.DataType_Int64, FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{10}}}, // 1 value for 4 rows
|
|
}},
|
|
}},
|
|
}}
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: 1,
|
|
groupSize: 2,
|
|
}
|
|
_, err := op.sortGroupsByOrderByFields(result, []int{0, 1, 2, 3})
|
|
s.Error(err)
|
|
}
|
|
|
|
// sortGroupsByOrderByFields with no group value falls back to row sort and propagates its error.
|
|
func (s *SearchPipelineSuite) TestSortGroupsByOrderByFieldsNilGroupValueReturnsSortError() {
|
|
result := &milvuspb.SearchResults{Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}}}},
|
|
FieldsData: []*schemapb.FieldData{{
|
|
Type: schemapb.DataType_Int64, FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{LongData: &schemapb.LongArray{Data: []int64{10}}},
|
|
}},
|
|
}},
|
|
}}
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{{FieldName: "price", Ascending: true}},
|
|
groupByFieldId: 1,
|
|
groupSize: 2,
|
|
}
|
|
_, err := op.sortGroupsByOrderByFields(result, []int{0, 1, 2})
|
|
s.Error(err)
|
|
}
|
|
|
|
// Test orderByOperator with descending sort
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorDescending() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
Topks: []int64{4},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 10, 40, 20}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "price", Ascending: false},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After descending sort by price: 40, 30, 20, 10
|
|
expectedIds := []int64{3, 1, 4, 2}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorNullableScalarNullOrdering() {
|
|
makeResult := func() *milvuspb.SearchResults {
|
|
return &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
Topks: []int64{4},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
ValidData: []bool{true, false, true, false},
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 10, 40, 20}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
orderBy OrderByField
|
|
expectedIDs []int64
|
|
expectedValid []bool
|
|
}{
|
|
{
|
|
name: "asc_default_nulls_last",
|
|
orderBy: OrderByField{FieldName: "price", Ascending: true, NullsFirst: false},
|
|
expectedIDs: []int64{1, 3, 2, 4},
|
|
expectedValid: []bool{true, true, false, false},
|
|
},
|
|
{
|
|
name: "desc_default_nulls_first",
|
|
orderBy: OrderByField{FieldName: "price", Ascending: false, NullsFirst: true},
|
|
expectedIDs: []int64{2, 4, 3, 1},
|
|
expectedValid: []bool{false, false, true, true},
|
|
},
|
|
{
|
|
name: "asc_explicit_nulls_first",
|
|
orderBy: OrderByField{FieldName: "price", Ascending: true, NullsFirst: true},
|
|
expectedIDs: []int64{2, 4, 1, 3},
|
|
expectedValid: []bool{false, false, true, true},
|
|
},
|
|
{
|
|
name: "desc_explicit_nulls_last",
|
|
orderBy: OrderByField{FieldName: "price", Ascending: false, NullsFirst: false},
|
|
expectedIDs: []int64{3, 1, 2, 4},
|
|
expectedValid: []bool{true, true, false, false},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
s.Run(tt.name, func() {
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{tt.orderBy},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, makeResult())
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
s.Equal(tt.expectedIDs, sortedResult.Results.Ids.GetIntId().Data)
|
|
s.Equal(tt.expectedValid, sortedResult.Results.FieldsData[0].GetValidData())
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test orderByOperator validates missing fields
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorMissingField() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8},
|
|
FieldsData: []*schemapb.FieldData{},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "nonexistent", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
_, err := op.run(context.Background(), s.span, result)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "not found")
|
|
}
|
|
|
|
// Test orderByOperator with empty results
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorEmptyResults() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: nil,
|
|
Scores: []float32{},
|
|
FieldsData: []*schemapb.FieldData{},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "price", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
s.NotNil(outputs)
|
|
}
|
|
|
|
// Test orderByOperator with no order_by fields (passthrough)
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorNoOrderBy() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// Should be unchanged
|
|
s.Equal([]int64{1, 2, 3}, sortedResult.Results.Ids.GetIntId().Data)
|
|
}
|
|
|
|
// Test orderByOperator with group_by - critical test for sortGroupsByOrderByFields
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorWithGroupBy() {
|
|
// Create result with GroupByFieldValue where groups have varying sizes
|
|
// Group 1: ids [1,2] with group value "A", prices [30, 25]
|
|
// Group 2: ids [3] with group value "B", price [10]
|
|
// Group 3: ids [4,5,6] with group value "C", prices [20, 15, 18]
|
|
// After order_by price:asc (by first row in each group), groups should be ordered: B(10), C(20), A(30)
|
|
// Final order: [3, 4, 5, 6, 1, 2]
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4, 5, 6}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.85, 0.8, 0.75, 0.7, 0.65},
|
|
Topks: []int64{6},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 25, 10, 20, 15, 18}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Group-by column via the plural channel — orderBy reads plural only
|
|
// after the Step 3.3c.3 redo; task-output boundary handles legacy
|
|
// wire downgrade.
|
|
// "A", "A", "B", "C", "C", "C"
|
|
GroupByFieldValues: []*schemapb.FieldData{{
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{Data: []string{"A", "A", "B", "C", "C", "C"}},
|
|
},
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "price", Ascending: true},
|
|
},
|
|
groupByFieldId: 100, // Any value >= 0 indicates group_by mode
|
|
groupSize: 3,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After sorting by first row's price in each group:
|
|
// Group B (price=10) -> Group C (price=20) -> Group A (price=30)
|
|
// Final order: [3, 4, 5, 6, 1, 2]
|
|
expectedIds := []int64{3, 4, 5, 6, 1, 2}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
|
|
// Verify group by values are also reordered correctly (plural channel).
|
|
expectedGroupValues := []string{"B", "C", "C", "C", "A", "A"}
|
|
s.Require().Len(sortedResult.Results.GetGroupByFieldValues(), 1)
|
|
actualGroupValues := sortedResult.Results.GetGroupByFieldValues()[0].GetScalars().GetStringData().Data
|
|
s.Equal(expectedGroupValues, actualGroupValues)
|
|
}
|
|
|
|
// Test orderByOperator with multiple sort fields (tie-breaking)
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorMultiField() {
|
|
// Test tie-breaking: sort by category:asc, then by price:desc
|
|
// Same category should be ordered by price descending
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4, 5}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6, 0.5},
|
|
Topks: []int64{5},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_VarChar,
|
|
FieldName: "category",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{Data: []string{"B", "A", "B", "A", "A"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{100, 200, 150, 50, 300}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "category", Ascending: true}, // First sort by category asc
|
|
{FieldName: "price", Ascending: false}, // Then by price desc
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// Category A: ids 2(200), 4(50), 5(300) -> sorted by price desc: 5(300), 2(200), 4(50)
|
|
// Category B: ids 1(100), 3(150) -> sorted by price desc: 3(150), 1(100)
|
|
// Final order: A first (5, 2, 4), then B (3, 1)
|
|
expectedIds := []int64{5, 2, 4, 3, 1}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
|
|
// Verify categories are in order
|
|
expectedCategories := []string{"A", "A", "A", "B", "B"}
|
|
actualCategories := sortedResult.Results.FieldsData[0].GetScalars().GetStringData().Data
|
|
s.Equal(expectedCategories, actualCategories)
|
|
|
|
// Verify prices within each category are descending
|
|
expectedPrices := []int64{300, 200, 50, 150, 100}
|
|
actualPrices := sortedResult.Results.FieldsData[1].GetScalars().GetLongData().Data
|
|
s.Equal(expectedPrices, actualPrices)
|
|
}
|
|
|
|
// Test orderByOperator with string IDs
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorStringIds() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_StrId{
|
|
StrId: &schemapb.StringArray{Data: []string{"id_a", "id_b", "id_c", "id_d"}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
Topks: []int64{4},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "priority",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{3, 1, 4, 2}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "priority", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After sorting by priority asc: 1, 2, 3, 4
|
|
// Original: id_a(3), id_b(1), id_c(4), id_d(2)
|
|
// Sorted: id_b(1), id_d(2), id_a(3), id_c(4)
|
|
expectedIds := []string{"id_b", "id_d", "id_a", "id_c"}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetStrId().Data)
|
|
|
|
// Verify scores are also reordered
|
|
expectedScores := []float32{0.8, 0.6, 0.9, 0.7}
|
|
s.Equal(expectedScores, sortedResult.Results.Scores)
|
|
}
|
|
|
|
// Test JSON field comparison (byte-level ordering)
|
|
func (s *SearchPipelineSuite) TestCompareFieldDataAtJSON() {
|
|
jsonField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_JSON,
|
|
FieldName: "metadata",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_JsonData{
|
|
JsonData: &schemapb.JSONArray{Data: [][]byte{
|
|
[]byte(`{"a": 2}`),
|
|
[]byte(`{"a": 10}`),
|
|
[]byte(`{"b": 1}`),
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// JSON comparison is byte-level, so "2" > "1" (comparing '2' vs '1' in "10")
|
|
// This tests the documented behavior that JSON sorting is lexicographic
|
|
cmp, err := compareFieldDataAt(jsonField, 0, 1, true)
|
|
s.NoError(err)
|
|
s.Greater(cmp, 0) // {"a": 2} > {"a": 10} in byte comparison because '2' > '1'
|
|
|
|
// Different keys
|
|
cmp, err = compareFieldDataAt(jsonField, 0, 2, true)
|
|
s.NoError(err)
|
|
s.Less(cmp, 0) // {"a": ...} < {"b": ...} because 'a' < 'b'
|
|
}
|
|
|
|
// Test vector field reordering (FloatVector)
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataFloatVector() {
|
|
dim := 4
|
|
// 3 vectors, each with dim=4
|
|
vectorField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_FloatVector,
|
|
FieldName: "embedding",
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: int64(dim),
|
|
Data: &schemapb.VectorField_FloatVector{
|
|
FloatVector: &schemapb.FloatArray{
|
|
Data: []float32{
|
|
1.0, 1.1, 1.2, 1.3, // vector 0
|
|
2.0, 2.1, 2.2, 2.3, // vector 1
|
|
3.0, 3.1, 3.2, 3.3, // vector 2
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Reorder: [2, 0, 1]
|
|
indices := []int{2, 0, 1}
|
|
err := reorderFieldData(vectorField, indices)
|
|
s.NoError(err)
|
|
|
|
expectedData := []float32{
|
|
3.0, 3.1, 3.2, 3.3, // was vector 2
|
|
1.0, 1.1, 1.2, 1.3, // was vector 0
|
|
2.0, 2.1, 2.2, 2.3, // was vector 1
|
|
}
|
|
actualData := vectorField.GetVectors().GetFloatVector().GetData()
|
|
s.Equal(expectedData, actualData)
|
|
}
|
|
|
|
// Test Double comparison in compareFieldDataAt
|
|
func (s *SearchPipelineSuite) TestCompareFieldDataAtDouble() {
|
|
mustCompare := func(field *schemapb.FieldData, i, j int) int {
|
|
cmp, err := compareFieldDataAt(field, i, j, true)
|
|
s.NoError(err)
|
|
return cmp
|
|
}
|
|
|
|
doubleField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Double,
|
|
FieldName: "score",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_DoubleData{
|
|
DoubleData: &schemapb.DoubleArray{Data: []float64{1.5, 2.5, 1.5}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.Equal(-1, mustCompare(doubleField, 0, 1)) // 1.5 < 2.5
|
|
s.Equal(1, mustCompare(doubleField, 1, 0)) // 2.5 > 1.5
|
|
s.Equal(0, mustCompare(doubleField, 0, 2)) // 1.5 == 1.5
|
|
}
|
|
|
|
// Test Int32 comparison in compareFieldDataAt
|
|
func (s *SearchPipelineSuite) TestCompareFieldDataAtInt32() {
|
|
mustCompare := func(field *schemapb.FieldData, i, j int) int {
|
|
cmp, err := compareFieldDataAt(field, i, j, true)
|
|
s.NoError(err)
|
|
return cmp
|
|
}
|
|
|
|
int32Field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int32,
|
|
FieldName: "count",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_IntData{
|
|
IntData: &schemapb.IntArray{Data: []int32{10, 20, 10}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.Equal(-1, mustCompare(int32Field, 0, 1)) // 10 < 20
|
|
s.Equal(1, mustCompare(int32Field, 1, 0)) // 20 > 10
|
|
s.Equal(0, mustCompare(int32Field, 0, 2)) // 10 == 10
|
|
}
|
|
|
|
// Test extractJSONValue function
|
|
func (s *SearchPipelineSuite) TestExtractJSONValue() {
|
|
jsonData := []byte(`{"price": 99.99, "name": "product", "active": true, "count": 42}`)
|
|
|
|
// Extract number
|
|
result := extractJSONValue(jsonData, "/price")
|
|
s.True(result.Exists())
|
|
s.Equal(99.99, result.Float())
|
|
|
|
// Extract string
|
|
result = extractJSONValue(jsonData, "/name")
|
|
s.True(result.Exists())
|
|
s.Equal("product", result.String())
|
|
|
|
// Extract boolean
|
|
result = extractJSONValue(jsonData, "/active")
|
|
s.True(result.Exists())
|
|
s.True(result.Bool())
|
|
|
|
// Extract integer
|
|
result = extractJSONValue(jsonData, "/count")
|
|
s.True(result.Exists())
|
|
s.Equal(int64(42), result.Int())
|
|
|
|
// Non-existent path
|
|
result = extractJSONValue(jsonData, "/nonexistent")
|
|
s.False(result.Exists())
|
|
|
|
// Empty path returns empty result
|
|
result = extractJSONValue(jsonData, "")
|
|
s.False(result.Exists())
|
|
|
|
// Nested path
|
|
nestedJSON := []byte(`{"user": {"profile": {"age": 30}}}`)
|
|
result = extractJSONValue(nestedJSON, "/user/profile/age")
|
|
s.True(result.Exists())
|
|
s.Equal(int64(30), result.Int())
|
|
|
|
// Key with literal slash (escaped as ~1 in JSON Pointer)
|
|
jsonWithSlash := []byte(`{"key/with/slash": 100}`)
|
|
result = extractJSONValue(jsonWithSlash, "/key~1with~1slash")
|
|
s.True(result.Exists())
|
|
s.Equal(int64(100), result.Int())
|
|
|
|
// Key with literal tilde (escaped as ~0 in JSON Pointer)
|
|
jsonWithTilde := []byte(`{"key~tilde": 200}`)
|
|
result = extractJSONValue(jsonWithTilde, "/key~0tilde")
|
|
s.True(result.Exists())
|
|
s.Equal(int64(200), result.Int())
|
|
|
|
// Key with dot (should be escaped for gjson)
|
|
jsonWithDot := []byte(`{"key.with.dot": 300}`)
|
|
result = extractJSONValue(jsonWithDot, "/key.with.dot")
|
|
s.True(result.Exists())
|
|
s.Equal(int64(300), result.Int())
|
|
}
|
|
|
|
// Test compareJSONValues function
|
|
func (s *SearchPipelineSuite) TestCompareJSONValues() {
|
|
// Number comparison
|
|
a := extractJSONValue([]byte(`{"v": 10}`), "/v")
|
|
b := extractJSONValue([]byte(`{"v": 20}`), "/v")
|
|
s.Equal(-1, compareJSONValues(a, b, true)) // 10 < 20
|
|
s.Equal(1, compareJSONValues(b, a, true)) // 20 > 10
|
|
s.Equal(0, compareJSONValues(a, a, true)) // 10 == 10
|
|
|
|
// String comparison
|
|
a = extractJSONValue([]byte(`{"v": "apple"}`), "/v")
|
|
b = extractJSONValue([]byte(`{"v": "banana"}`), "/v")
|
|
s.Equal(-1, compareJSONValues(a, b, true)) // "apple" < "banana"
|
|
s.Equal(1, compareJSONValues(b, a, true)) // "banana" > "apple"
|
|
|
|
// Boolean comparison (false < true)
|
|
a = extractJSONValue([]byte(`{"v": false}`), "/v")
|
|
b = extractJSONValue([]byte(`{"v": true}`), "/v")
|
|
s.Equal(-1, compareJSONValues(a, b, true)) // false < true
|
|
s.Equal(1, compareJSONValues(b, a, true)) // true > false
|
|
|
|
// Non-existent values
|
|
a = extractJSONValue([]byte(`{}`), "/v")
|
|
b = extractJSONValue([]byte(`{"v": 10}`), "/v")
|
|
s.Equal(-1, compareJSONValues(a, b, true)) // NULLS FIRST: null < 10
|
|
s.Equal(1, compareJSONValues(b, a, true)) // NULLS FIRST: 10 > null
|
|
s.Equal(1, compareJSONValues(a, b, false)) // NULLS LAST: null > 10
|
|
s.Equal(-1, compareJSONValues(b, a, false)) // NULLS LAST: 10 < null
|
|
|
|
// Both non-existent
|
|
a = extractJSONValue([]byte(`{}`), "/v")
|
|
b = extractJSONValue([]byte(`{}`), "/v")
|
|
s.Equal(0, compareJSONValues(a, b, true)) // null == null
|
|
|
|
// Explicit JSON null value (type gjson.Null)
|
|
a = extractJSONValue([]byte(`{"v": null}`), "/v")
|
|
b = extractJSONValue([]byte(`{"v": 10}`), "/v")
|
|
s.Equal(-1, compareJSONValues(a, b, true)) // NULLS FIRST: null < 10
|
|
s.Equal(1, compareJSONValues(b, a, true)) // NULLS FIRST: 10 > null
|
|
s.Equal(1, compareJSONValues(a, b, false)) // NULLS LAST: null > 10
|
|
s.Equal(-1, compareJSONValues(b, a, false)) // NULLS LAST: 10 < null
|
|
|
|
// Both explicit null
|
|
a = extractJSONValue([]byte(`{"v": null}`), "/v")
|
|
b = extractJSONValue([]byte(`{"v": null}`), "/v")
|
|
s.Equal(0, compareJSONValues(a, b, true)) // null == null
|
|
|
|
// Explicit null vs non-existent (both treated as null)
|
|
a = extractJSONValue([]byte(`{"v": null}`), "/v")
|
|
b = extractJSONValue([]byte(`{}`), "/v")
|
|
s.Equal(0, compareJSONValues(a, b, true)) // null == null
|
|
}
|
|
|
|
// Test orderByOperator with JSON subfield path
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorWithJSONPath() {
|
|
// Create result with JSON field containing nested data
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
Topks: []int64{4},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_JSON,
|
|
FieldName: "metadata",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_JsonData{
|
|
JsonData: &schemapb.JSONArray{Data: [][]byte{
|
|
[]byte(`{"price": 30, "category": "A"}`),
|
|
[]byte(`{"price": 10, "category": "B"}`),
|
|
[]byte(`{"price": 40, "category": "A"}`),
|
|
[]byte(`{"price": 20, "category": "B"}`),
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Sort by metadata["price"] ascending
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "metadata", FieldID: 100, JSONPath: "/price", Ascending: true, NullsFirst: false},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After ascending sort by price: 10, 20, 30, 40
|
|
// Original order: 30(id=1), 10(id=2), 40(id=3), 20(id=4)
|
|
// Sorted order: 10(id=2), 20(id=4), 30(id=1), 40(id=3)
|
|
expectedIds := []int64{2, 4, 1, 3}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
}
|
|
|
|
// Test orderByOperator with JSON path - descending order
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorWithJSONPathDescending() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7},
|
|
Topks: []int64{3},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_JSON,
|
|
FieldName: "data",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_JsonData{
|
|
JsonData: &schemapb.JSONArray{Data: [][]byte{
|
|
[]byte(`{"score": 100}`),
|
|
[]byte(`{"score": 300}`),
|
|
[]byte(`{"score": 200}`),
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "data", FieldID: 100, JSONPath: "/score", Ascending: false, NullsFirst: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After descending sort: 300, 200, 100
|
|
// Original: 100(id=1), 300(id=2), 200(id=3)
|
|
// Sorted: 300(id=2), 200(id=3), 100(id=1)
|
|
expectedIds := []int64{2, 3, 1}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorWithMissingJSONPath() {
|
|
makeResult := func() *milvuspb.SearchResults {
|
|
return &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4, 5}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6, 0.5},
|
|
Topks: []int64{5},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_JSON,
|
|
FieldName: "metadata",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_JsonData{
|
|
JsonData: &schemapb.JSONArray{Data: [][]byte{
|
|
[]byte(`{"price": 30}`),
|
|
[]byte(`{}`),
|
|
[]byte(`{"price": 10}`),
|
|
[]byte(`{"other": 99}`),
|
|
[]byte(`{"price": null}`),
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
orderBy OrderByField
|
|
expectedIDs []int64
|
|
}{
|
|
{
|
|
name: "asc_default_nulls_last",
|
|
orderBy: OrderByField{FieldName: "metadata", FieldID: 100, JSONPath: "/price", Ascending: true, NullsFirst: false},
|
|
expectedIDs: []int64{3, 1, 2, 4, 5},
|
|
},
|
|
{
|
|
name: "desc_default_nulls_first",
|
|
orderBy: OrderByField{FieldName: "metadata", FieldID: 100, JSONPath: "/price", Ascending: false, NullsFirst: true},
|
|
expectedIDs: []int64{2, 4, 5, 1, 3},
|
|
},
|
|
{
|
|
name: "desc_explicit_nulls_last",
|
|
orderBy: OrderByField{FieldName: "metadata", FieldID: 100, JSONPath: "/price", Ascending: false, NullsFirst: false},
|
|
expectedIDs: []int64{1, 3, 2, 4, 5},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
s.Run(tt.name, func() {
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{tt.orderBy},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, makeResult())
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
s.Equal(tt.expectedIDs, sortedResult.Results.Ids.GetIntId().Data)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorDynamicJSONNullOrdering() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
Topks: []int64{4},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_JSON,
|
|
FieldName: "$meta",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_JsonData{
|
|
JsonData: &schemapb.JSONArray{Data: [][]byte{
|
|
[]byte(`{"dyn_price": 30}`),
|
|
[]byte(`{}`),
|
|
[]byte(`{"dyn_price": 10}`),
|
|
[]byte(`{"dyn_price": null}`),
|
|
}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "$meta", FieldID: 100, JSONPath: "/dyn_price", Ascending: false, NullsFirst: true, IsDynamicField: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
s.Equal([]int64{2, 4, 1, 3}, sortedResult.Results.Ids.GetIntId().Data)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestParseOrderByFieldsWithJSONPath() {
|
|
schema := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64},
|
|
{FieldID: 101, Name: "metadata", DataType: schemapb.DataType_JSON},
|
|
{FieldID: 102, Name: "score", DataType: schemapb.DataType_Float},
|
|
},
|
|
}
|
|
|
|
// Test JSON path with ascending
|
|
params := []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: `metadata["price"]:asc`}}
|
|
result, err := parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("metadata", result[0].FieldName)
|
|
s.Equal(int64(101), result[0].FieldID)
|
|
s.Equal("/price", result[0].JSONPath)
|
|
s.True(result[0].Ascending)
|
|
s.False(result[0].NullsFirst)
|
|
s.Equal("metadata", result[0].OutputFieldName) // Regular JSON: request whole field
|
|
s.False(result[0].IsDynamicField) // Not a dynamic field
|
|
|
|
// Test JSON path with descending
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: `metadata["rating"]:desc:nulls_last`}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("metadata", result[0].FieldName)
|
|
s.Equal("/rating", result[0].JSONPath)
|
|
s.False(result[0].Ascending)
|
|
s.False(result[0].NullsFirst)
|
|
s.Equal("metadata", result[0].OutputFieldName) // Regular JSON: request whole field
|
|
s.False(result[0].IsDynamicField)
|
|
|
|
// Test mixed: regular field + JSON path
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: `score:desc,metadata["price"]:asc`}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 2)
|
|
// First field: regular
|
|
s.Equal("score", result[0].FieldName)
|
|
s.Equal("", result[0].JSONPath)
|
|
s.False(result[0].Ascending)
|
|
s.True(result[0].NullsFirst)
|
|
// Second field: JSON path
|
|
s.Equal("metadata", result[1].FieldName)
|
|
s.Equal("/price", result[1].JSONPath)
|
|
s.True(result[1].Ascending)
|
|
s.False(result[1].NullsFirst)
|
|
|
|
// Test nested JSON path
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: `metadata["user"]["age"]:asc`}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("metadata", result[0].FieldName)
|
|
s.Equal("/user/age", result[0].JSONPath)
|
|
}
|
|
|
|
// Test parseOrderByFields with dynamic fields
|
|
func (s *SearchPipelineSuite) TestParseOrderByFieldsWithDynamicField() {
|
|
schema := &schemapb.CollectionSchema{
|
|
EnableDynamicField: true,
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64},
|
|
{FieldID: 101, Name: "vector", DataType: schemapb.DataType_FloatVector},
|
|
{FieldID: 102, Name: "$meta", DataType: schemapb.DataType_JSON, IsDynamic: true},
|
|
},
|
|
}
|
|
|
|
// Test dynamic field key without brackets (age -> $meta["age"])
|
|
params := []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "age:asc"}}
|
|
result, err := parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("$meta", result[0].FieldName)
|
|
s.Equal(int64(102), result[0].FieldID)
|
|
s.Equal("/age", result[0].JSONPath)
|
|
s.True(result[0].Ascending)
|
|
s.False(result[0].NullsFirst)
|
|
s.Equal("age", result[0].OutputFieldName) // Dynamic field: use original key for requery
|
|
s.True(result[0].IsDynamicField) // Is a dynamic field - QueryNode extracts subfield
|
|
|
|
// Test dynamic field with explicit path
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: `$meta["category"]:desc:nulls_last`}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("$meta", result[0].FieldName)
|
|
s.Equal("/category", result[0].JSONPath)
|
|
s.False(result[0].Ascending)
|
|
s.False(result[0].NullsFirst)
|
|
s.Equal(`$meta["category"]`, result[0].OutputFieldName) // Explicit path for requery
|
|
s.True(result[0].IsDynamicField)
|
|
|
|
// Test unknown field with brackets treated as dynamic (e.g., dyn_meta["price"])
|
|
// outputFieldName should be baseName ("dyn_meta"), NOT fieldSpec ("dyn_meta[\"price\"]"),
|
|
// because the output field parser would reject multi-level dynamic paths like $meta["dyn_meta"]["price"].
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: `dyn_meta["price"]:asc`}}
|
|
result, err = parseOrderByFields(params, schema)
|
|
s.NoError(err)
|
|
s.Len(result, 1)
|
|
s.Equal("$meta", result[0].FieldName)
|
|
s.Equal("/dyn_meta/price", result[0].JSONPath)
|
|
s.True(result[0].Ascending)
|
|
s.False(result[0].NullsFirst)
|
|
s.Equal("dyn_meta", result[0].OutputFieldName) // Base name only; full path would cause multi-level rejection
|
|
s.True(result[0].IsDynamicField)
|
|
}
|
|
|
|
// Test splitOrderByFieldOptions helper
|
|
func (s *SearchPipelineSuite) TestSplitOrderByFieldOptions() {
|
|
// Simple field
|
|
field, dir, nullOrdering, err := splitOrderByFieldOptions("name:asc")
|
|
s.NoError(err)
|
|
s.Equal("name", field)
|
|
s.Equal("asc", dir)
|
|
s.Equal("", nullOrdering)
|
|
|
|
// Field without direction
|
|
field, dir, nullOrdering, err = splitOrderByFieldOptions("name")
|
|
s.NoError(err)
|
|
s.Equal("name", field)
|
|
s.Equal("", dir)
|
|
s.Equal("", nullOrdering)
|
|
|
|
// JSON path with direction
|
|
field, dir, nullOrdering, err = splitOrderByFieldOptions(`metadata["price"]:desc`)
|
|
s.NoError(err)
|
|
s.Equal(`metadata["price"]`, field)
|
|
s.Equal("desc", dir)
|
|
s.Equal("", nullOrdering)
|
|
|
|
// JSON path without direction
|
|
field, dir, nullOrdering, err = splitOrderByFieldOptions(`metadata["price"]`)
|
|
s.NoError(err)
|
|
s.Equal(`metadata["price"]`, field)
|
|
s.Equal("", dir)
|
|
s.Equal("", nullOrdering)
|
|
|
|
// Nested JSON path with direction
|
|
field, dir, nullOrdering, err = splitOrderByFieldOptions(`data["user"]["age"]:asc`)
|
|
s.NoError(err)
|
|
s.Equal(`data["user"]["age"]`, field)
|
|
s.Equal("asc", dir)
|
|
s.Equal("", nullOrdering)
|
|
|
|
// JSON path with colon in value and explicit null ordering
|
|
field, dir, nullOrdering, err = splitOrderByFieldOptions(`metadata["key:with:colons"]:asc:nulls_last`)
|
|
s.NoError(err)
|
|
s.Equal(`metadata["key:with:colons"]`, field)
|
|
s.Equal("asc", dir)
|
|
s.Equal("nulls_last", nullOrdering)
|
|
|
|
_, _, _, err = splitOrderByFieldOptions(`metadata["price"]:asc:nulls_last:extra`)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "too many order_by field options")
|
|
}
|
|
|
|
// Test jsonPointerToGjsonPath conversion
|
|
func (s *SearchPipelineSuite) TestJSONPointerToGjsonPath() {
|
|
// Simple path
|
|
s.Equal("price", jsonPointerToGjsonPath("/price"))
|
|
|
|
// Nested path
|
|
s.Equal("user.profile.age", jsonPointerToGjsonPath("/user/profile/age"))
|
|
|
|
// Key with literal slash (escaped as ~1)
|
|
s.Equal("key/with/slash", jsonPointerToGjsonPath("/key~1with~1slash"))
|
|
|
|
// Key with literal tilde (escaped as ~0)
|
|
s.Equal("key~tilde", jsonPointerToGjsonPath("/key~0tilde"))
|
|
|
|
// Key with dot (escaped for gjson)
|
|
s.Equal(`key\.with\.dot`, jsonPointerToGjsonPath("/key.with.dot"))
|
|
|
|
// Combined: nested path with special characters
|
|
s.Equal(`user.profile\.v2.score`, jsonPointerToGjsonPath("/user/profile.v2/score"))
|
|
|
|
// Empty path
|
|
s.Equal("", jsonPointerToGjsonPath(""))
|
|
s.Equal("", jsonPointerToGjsonPath("/"))
|
|
|
|
// Path without leading slash
|
|
s.Equal("price", jsonPointerToGjsonPath("price"))
|
|
}
|
|
|
|
// Test parseOrderByFields error cases for better coverage
|
|
func (s *SearchPipelineSuite) TestParseOrderByFieldsErrors() {
|
|
schema := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64},
|
|
{FieldID: 101, Name: "vector", DataType: schemapb.DataType_FloatVector},
|
|
{FieldID: 102, Name: "score", DataType: schemapb.DataType_Float},
|
|
{FieldID: 103, Name: "metadata", DataType: schemapb.DataType_JSON},
|
|
},
|
|
}
|
|
|
|
// Test invalid direction
|
|
params := []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "score:invalid"}}
|
|
_, err := parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "invalid order direction")
|
|
|
|
// Test non-JSON field with brackets
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: `score["key"]:asc`}}
|
|
_, err = parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "not a JSON type")
|
|
|
|
// Test unknown field with brackets and no dynamic field
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: `unknown["key"]:asc`}}
|
|
_, err = parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "not found")
|
|
|
|
// Test unknown field without brackets and no dynamic field
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "unknown_field:asc"}}
|
|
_, err = parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "does not exist")
|
|
|
|
// Test unsortable field type (vector)
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "vector:asc"}}
|
|
_, err = parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "unsortable type")
|
|
|
|
// Test JSON field without path - not allowed, must use path syntax like metadata["key"]
|
|
params = []*commonpb.KeyValuePair{{Key: OrderByFieldsKey, Value: "metadata:asc"}}
|
|
_, err = parseOrderByFields(params, schema)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "unsortable type")
|
|
s.Contains(err.Error(), "JSON")
|
|
}
|
|
|
|
// Test compareFieldDataAt for different data types
|
|
func (s *SearchPipelineSuite) TestCompareFieldDataAtAllTypes() {
|
|
mustCompare := func(field *schemapb.FieldData, i, j int) int {
|
|
cmp, err := compareFieldDataAt(field, i, j, true)
|
|
s.NoError(err)
|
|
return cmp
|
|
}
|
|
|
|
// Test Int8/Int16/Int32 comparison
|
|
intField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int32,
|
|
FieldName: "int_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_IntData{
|
|
IntData: &schemapb.IntArray{Data: []int32{30, 10, 20}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.Equal(-1, mustCompare(intField, 1, 2)) // 10 < 20
|
|
s.Equal(1, mustCompare(intField, 0, 1)) // 30 > 10
|
|
s.Equal(0, mustCompare(intField, 0, 0)) // 30 == 30
|
|
|
|
// Test Float comparison
|
|
floatField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Float,
|
|
FieldName: "float_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_FloatData{
|
|
FloatData: &schemapb.FloatArray{Data: []float32{3.5, 1.5, 2.5}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.Equal(-1, mustCompare(floatField, 1, 2)) // 1.5 < 2.5
|
|
s.Equal(1, mustCompare(floatField, 0, 1)) // 3.5 > 1.5
|
|
s.Equal(0, mustCompare(floatField, 0, 0)) // 3.5 == 3.5
|
|
|
|
// Test Double comparison
|
|
doubleField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Double,
|
|
FieldName: "double_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_DoubleData{
|
|
DoubleData: &schemapb.DoubleArray{Data: []float64{3.5, 1.5, 2.5}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.Equal(-1, mustCompare(doubleField, 1, 2)) // 1.5 < 2.5
|
|
s.Equal(1, mustCompare(doubleField, 0, 1)) // 3.5 > 1.5
|
|
s.Equal(0, mustCompare(doubleField, 0, 0)) // 3.5 == 3.5
|
|
|
|
// Test Bool comparison
|
|
boolField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Bool,
|
|
FieldName: "bool_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_BoolData{
|
|
BoolData: &schemapb.BoolArray{Data: []bool{true, false, true}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.Equal(1, mustCompare(boolField, 0, 1)) // true > false
|
|
s.Equal(-1, mustCompare(boolField, 1, 0)) // false < true
|
|
s.Equal(0, mustCompare(boolField, 0, 2)) // true == true
|
|
|
|
// Test out of bounds returns error
|
|
_, err := compareFieldDataAt(intField, 10, 20, true)
|
|
s.Error(err)
|
|
_, err = compareFieldDataAt(floatField, 10, 20, true)
|
|
s.Error(err)
|
|
_, err = compareFieldDataAt(doubleField, 10, 20, true)
|
|
s.Error(err)
|
|
_, err = compareFieldDataAt(boolField, 10, 20, true)
|
|
s.Error(err)
|
|
}
|
|
|
|
// Test compareFieldDataAt with nullable fields (ValidData)
|
|
func (s *SearchPipelineSuite) TestCompareFieldDataAtNullable() {
|
|
mustCompare := func(field *schemapb.FieldData, i, j int) int {
|
|
cmp, err := compareFieldDataAt(field, i, j, true)
|
|
s.NoError(err)
|
|
return cmp
|
|
}
|
|
|
|
// Create field with ValidData (nullable)
|
|
nullableField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "nullable_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{100, 200, 300}},
|
|
},
|
|
},
|
|
},
|
|
ValidData: []bool{true, false, true}, // Index 1 is null
|
|
}
|
|
|
|
// NULLS FIRST
|
|
s.Equal(1, mustCompare(nullableField, 0, 1)) // 100 > null
|
|
s.Equal(-1, mustCompare(nullableField, 1, 0)) // null < 100
|
|
s.Equal(-1, mustCompare(nullableField, 1, 2)) // null < 300
|
|
|
|
cmp, err := compareFieldDataAt(nullableField, 0, 1, false)
|
|
s.NoError(err)
|
|
s.Equal(-1, cmp) // NULLS LAST: 100 < null
|
|
cmp, err = compareFieldDataAt(nullableField, 1, 0, false)
|
|
s.NoError(err)
|
|
s.Equal(1, cmp) // NULLS LAST: null > 100
|
|
|
|
// Create field where both are null
|
|
nullableField2 := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "nullable_field2",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{100, 200, 300}},
|
|
},
|
|
},
|
|
},
|
|
ValidData: []bool{false, false, true}, // Index 0 and 1 are null
|
|
}
|
|
s.Equal(0, mustCompare(nullableField2, 0, 1)) // null == null
|
|
}
|
|
|
|
// Test isSameGroupByValue for different types
|
|
func (s *SearchPipelineSuite) TestIsSameGroupByValueAllTypes() {
|
|
// Test Int8/Int16/Int32
|
|
intField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int32,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_IntData{
|
|
IntData: &schemapb.IntArray{Data: []int32{10, 10, 20}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.True(isSameGroupByValue(intField, 0, 1)) // 10 == 10
|
|
s.False(isSameGroupByValue(intField, 0, 2)) // 10 != 20
|
|
|
|
// Test Int64
|
|
int64Field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{100, 100, 200}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.True(isSameGroupByValue(int64Field, 0, 1)) // 100 == 100
|
|
s.False(isSameGroupByValue(int64Field, 0, 2)) // 100 != 200
|
|
|
|
// Test Bool
|
|
boolField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Bool,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_BoolData{
|
|
BoolData: &schemapb.BoolArray{Data: []bool{true, true, false}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.True(isSameGroupByValue(boolField, 0, 1)) // true == true
|
|
s.False(isSameGroupByValue(boolField, 0, 2)) // true != false
|
|
|
|
// Test VarChar (already covered but verify)
|
|
stringField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_VarChar,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{Data: []string{"A", "A", "B"}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.True(isSameGroupByValue(stringField, 0, 1)) // "A" == "A"
|
|
s.False(isSameGroupByValue(stringField, 0, 2)) // "A" != "B"
|
|
|
|
// Test out of bounds returns false
|
|
s.False(isSameGroupByValue(intField, 10, 20))
|
|
s.False(isSameGroupByValue(int64Field, 10, 20))
|
|
s.False(isSameGroupByValue(boolField, 10, 20))
|
|
s.False(isSameGroupByValue(stringField, 10, 20))
|
|
|
|
// Test unsupported type (Float) returns false
|
|
floatField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Float,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_FloatData{
|
|
FloatData: &schemapb.FloatArray{Data: []float32{1.0, 1.0}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
s.False(isSameGroupByValue(floatField, 0, 1)) // Float not supported
|
|
}
|
|
|
|
// Test compareJSONValues with boolean values
|
|
func (s *SearchPipelineSuite) TestCompareJSONValuesBool() {
|
|
// Test bool: false < true
|
|
a := extractJSONValue([]byte(`{"v": false}`), "/v")
|
|
b := extractJSONValue([]byte(`{"v": true}`), "/v")
|
|
s.Equal(-1, compareJSONValues(a, b, true)) // false < true
|
|
s.Equal(1, compareJSONValues(b, a, true)) // true > false
|
|
|
|
// Test both true
|
|
a = extractJSONValue([]byte(`{"v": true}`), "/v")
|
|
b = extractJSONValue([]byte(`{"v": true}`), "/v")
|
|
s.Equal(0, compareJSONValues(a, b, true)) // true == true
|
|
|
|
// Test both false
|
|
a = extractJSONValue([]byte(`{"v": false}`), "/v")
|
|
b = extractJSONValue([]byte(`{"v": false}`), "/v")
|
|
s.Equal(0, compareJSONValues(a, b, true)) // false == false
|
|
}
|
|
|
|
// Test compareJSONValues with mixed types (fallback to raw comparison)
|
|
func (s *SearchPipelineSuite) TestCompareJSONValuesMixedTypes() {
|
|
// Number vs String - falls back to raw comparison
|
|
// Raw: "10" vs "\"hello\"" - quote char '"' (34) < '1' (49)
|
|
a := extractJSONValue([]byte(`{"v": 10}`), "/v")
|
|
b := extractJSONValue([]byte(`{"v": "hello"}`), "/v")
|
|
cmp := compareJSONValues(a, b, true)
|
|
// String (with quotes) should sort before number due to quote ASCII
|
|
s.Equal(1, cmp) // "10" > "\"hello\"" because '1' > '"'
|
|
}
|
|
|
|
// Test orderByOperator with Float field type
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorFloatField() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
Topks: []int64{4},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Float,
|
|
FieldName: "rating",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_FloatData{
|
|
FloatData: &schemapb.FloatArray{Data: []float32{3.5, 1.5, 4.5, 2.5}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "rating", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After ascending sort: 1.5, 2.5, 3.5, 4.5
|
|
// Original: 3.5(id=1), 1.5(id=2), 4.5(id=3), 2.5(id=4)
|
|
// Sorted: 1.5(id=2), 2.5(id=4), 3.5(id=1), 4.5(id=3)
|
|
expectedIds := []int64{2, 4, 1, 3}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
}
|
|
|
|
// Test orderByOperator with Bool field type
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorBoolField() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
Topks: []int64{4},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Bool,
|
|
FieldName: "active",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_BoolData{
|
|
BoolData: &schemapb.BoolArray{Data: []bool{true, false, true, false}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "active", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After ascending sort: false, false, true, true
|
|
// Original: true(id=1), false(id=2), true(id=3), false(id=4)
|
|
// Sorted (stable): false(id=2), false(id=4), true(id=1), true(id=3)
|
|
expectedIds := []int64{2, 4, 1, 3}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
}
|
|
|
|
// Test orderByOperator group_by with Int64 group values
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorWithGroupByInt64() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4, 5}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.85, 0.8, 0.75, 0.7},
|
|
Topks: []int64{5},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 25, 10, 20, 15}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// Group-by column via the plural channel (Step 3.3c.3 redo).
|
|
// Group 100: ids [1,2], prices [30,25]
|
|
// Group 200: ids [3], price [10]
|
|
// Group 300: ids [4,5], prices [20,15]
|
|
GroupByFieldValues: []*schemapb.FieldData{{
|
|
Type: schemapb.DataType_Int64,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{100, 100, 200, 300, 300}},
|
|
},
|
|
},
|
|
},
|
|
}},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "price", Ascending: true},
|
|
},
|
|
groupByFieldId: 100,
|
|
groupSize: 2,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After sorting groups by first row's price:
|
|
// Group 200 (price=10) -> Group 300 (price=20) -> Group 100 (price=30)
|
|
// Final order: [3, 4, 5, 1, 2]
|
|
expectedIds := []int64{3, 4, 5, 1, 2}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
}
|
|
|
|
// Test sortGroupsByOrderByFields with no GroupByFieldValue (fallback to regular sort)
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorWithGroupByNoGroupValue() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7},
|
|
Topks: []int64{3},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "price",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{30, 10, 20}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
// No GroupByFieldValue - should fallback to regular sort
|
|
GroupByFieldValue: nil,
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "price", Ascending: true},
|
|
},
|
|
groupByFieldId: 100, // Says group_by but no GroupByFieldValue
|
|
groupSize: 2,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// Should fallback to regular sort: 10, 20, 30
|
|
expectedIds := []int64{2, 3, 1}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
}
|
|
|
|
// Test reorderFieldData for Int8/Int16/Int32 scalar types
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataInt32() {
|
|
field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int32,
|
|
FieldName: "int_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_IntData{
|
|
IntData: &schemapb.IntArray{Data: []int32{10, 20, 30, 40}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Reorder: [2, 0, 3, 1] means new[0]=old[2], new[1]=old[0], etc.
|
|
indices := []int{2, 0, 3, 1}
|
|
err := reorderFieldData(field, indices)
|
|
s.NoError(err)
|
|
|
|
expected := []int32{30, 10, 40, 20}
|
|
s.Equal(expected, field.GetScalars().GetIntData().Data)
|
|
}
|
|
|
|
// Test reorderFieldData for Array type
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataArray() {
|
|
field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Array,
|
|
FieldName: "array_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_ArrayData{
|
|
ArrayData: &schemapb.ArrayArray{
|
|
Data: []*schemapb.ScalarField{
|
|
{Data: &schemapb.ScalarField_IntData{IntData: &schemapb.IntArray{Data: []int32{1, 2}}}},
|
|
{Data: &schemapb.ScalarField_IntData{IntData: &schemapb.IntArray{Data: []int32{3, 4}}}},
|
|
{Data: &schemapb.ScalarField_IntData{IntData: &schemapb.IntArray{Data: []int32{5, 6}}}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Reorder: [2, 0, 1]
|
|
indices := []int{2, 0, 1}
|
|
err := reorderFieldData(field, indices)
|
|
s.NoError(err)
|
|
|
|
result := field.GetScalars().GetArrayData().Data
|
|
s.Len(result, 3)
|
|
s.Equal([]int32{5, 6}, result[0].GetIntData().Data)
|
|
s.Equal([]int32{1, 2}, result[1].GetIntData().Data)
|
|
s.Equal([]int32{3, 4}, result[2].GetIntData().Data)
|
|
}
|
|
|
|
// Test reorderFieldData for BinaryVector type
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataBinaryVector() {
|
|
// 3 vectors, dim=16 (2 bytes per vector)
|
|
field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_BinaryVector,
|
|
FieldName: "binary_vec",
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 16,
|
|
Data: &schemapb.VectorField_BinaryVector{
|
|
BinaryVector: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06}, // 3 vectors * 2 bytes
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Reorder: [2, 0, 1]
|
|
indices := []int{2, 0, 1}
|
|
err := reorderFieldData(field, indices)
|
|
s.NoError(err)
|
|
|
|
expected := []byte{0x05, 0x06, 0x01, 0x02, 0x03, 0x04}
|
|
s.Equal(expected, field.GetVectors().GetBinaryVector())
|
|
}
|
|
|
|
// Test reorderFieldData for Float16Vector type
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataFloat16Vector() {
|
|
// 3 vectors, dim=2 (4 bytes per vector: 2 bytes per float16 * 2 dims)
|
|
field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Float16Vector,
|
|
FieldName: "fp16_vec",
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_Float16Vector{
|
|
Float16Vector: []byte{
|
|
0x01, 0x02, 0x03, 0x04, // vector 0
|
|
0x05, 0x06, 0x07, 0x08, // vector 1
|
|
0x09, 0x0A, 0x0B, 0x0C, // vector 2
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Reorder: [2, 0, 1]
|
|
indices := []int{2, 0, 1}
|
|
err := reorderFieldData(field, indices)
|
|
s.NoError(err)
|
|
|
|
expected := []byte{
|
|
0x09, 0x0A, 0x0B, 0x0C, // was vector 2
|
|
0x01, 0x02, 0x03, 0x04, // was vector 0
|
|
0x05, 0x06, 0x07, 0x08, // was vector 1
|
|
}
|
|
s.Equal(expected, field.GetVectors().GetFloat16Vector())
|
|
}
|
|
|
|
// Test reorderFieldData for BFloat16Vector type
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataBFloat16Vector() {
|
|
// 3 vectors, dim=2 (4 bytes per vector: 2 bytes per bfloat16 * 2 dims)
|
|
field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_BFloat16Vector,
|
|
FieldName: "bf16_vec",
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_Bfloat16Vector{
|
|
Bfloat16Vector: []byte{
|
|
0x11, 0x12, 0x13, 0x14, // vector 0
|
|
0x21, 0x22, 0x23, 0x24, // vector 1
|
|
0x31, 0x32, 0x33, 0x34, // vector 2
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Reorder: [1, 2, 0]
|
|
indices := []int{1, 2, 0}
|
|
err := reorderFieldData(field, indices)
|
|
s.NoError(err)
|
|
|
|
expected := []byte{
|
|
0x21, 0x22, 0x23, 0x24, // was vector 1
|
|
0x31, 0x32, 0x33, 0x34, // was vector 2
|
|
0x11, 0x12, 0x13, 0x14, // was vector 0
|
|
}
|
|
s.Equal(expected, field.GetVectors().GetBfloat16Vector())
|
|
}
|
|
|
|
// Test reorderFieldData for SparseFloatVector type
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataSparseFloatVector() {
|
|
field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_SparseFloatVector,
|
|
FieldName: "sparse_vec",
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 100,
|
|
Data: &schemapb.VectorField_SparseFloatVector{
|
|
SparseFloatVector: &schemapb.SparseFloatArray{
|
|
Contents: [][]byte{
|
|
{0x01, 0x02}, // sparse vector 0
|
|
{0x03, 0x04}, // sparse vector 1
|
|
{0x05, 0x06}, // sparse vector 2
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Reorder: [2, 0, 1]
|
|
indices := []int{2, 0, 1}
|
|
err := reorderFieldData(field, indices)
|
|
s.NoError(err)
|
|
|
|
result := field.GetVectors().GetSparseFloatVector().GetContents()
|
|
s.Len(result, 3)
|
|
s.Equal([]byte{0x05, 0x06}, result[0])
|
|
s.Equal([]byte{0x01, 0x02}, result[1])
|
|
s.Equal([]byte{0x03, 0x04}, result[2])
|
|
}
|
|
|
|
// Test reorderFieldData for Int8Vector type
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataInt8Vector() {
|
|
// 3 vectors, dim=4 (4 bytes per vector)
|
|
field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int8Vector,
|
|
FieldName: "int8_vec",
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 4,
|
|
Data: &schemapb.VectorField_Int8Vector{
|
|
Int8Vector: []byte{
|
|
0x01, 0x02, 0x03, 0x04, // vector 0
|
|
0x05, 0x06, 0x07, 0x08, // vector 1
|
|
0x09, 0x0A, 0x0B, 0x0C, // vector 2
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Reorder: [2, 0, 1]
|
|
indices := []int{2, 0, 1}
|
|
err := reorderFieldData(field, indices)
|
|
s.NoError(err)
|
|
|
|
expected := []byte{
|
|
0x09, 0x0A, 0x0B, 0x0C, // was vector 2
|
|
0x01, 0x02, 0x03, 0x04, // was vector 0
|
|
0x05, 0x06, 0x07, 0x08, // was vector 1
|
|
}
|
|
s.Equal(expected, field.GetVectors().GetInt8Vector())
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataNullableVectorCompactData() {
|
|
indices := []int{2, 0, 1}
|
|
expectedValidData := []bool{true, true, false}
|
|
|
|
tests := []struct {
|
|
name string
|
|
field *schemapb.FieldData
|
|
assert func(*schemapb.FieldData)
|
|
}{
|
|
{
|
|
name: "float_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_FloatVector,
|
|
FieldName: "nullable_float_vec",
|
|
ValidData: []bool{true, false, true},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_FloatVector{FloatVector: &schemapb.FloatArray{Data: []float32{1, 2, 5, 6}}},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Equal([]float32{5, 6, 1, 2}, field.GetVectors().GetFloatVector().GetData())
|
|
},
|
|
},
|
|
{
|
|
name: "binary_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_BinaryVector,
|
|
FieldName: "nullable_binary_vec",
|
|
ValidData: []bool{true, false, true},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 16,
|
|
Data: &schemapb.VectorField_BinaryVector{BinaryVector: []byte{0x01, 0x02, 0x05, 0x06}},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Equal([]byte{0x05, 0x06, 0x01, 0x02}, field.GetVectors().GetBinaryVector())
|
|
},
|
|
},
|
|
{
|
|
name: "float16_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_Float16Vector,
|
|
FieldName: "nullable_float16_vec",
|
|
ValidData: []bool{true, false, true},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_Float16Vector{Float16Vector: []byte{0x01, 0x02, 0x03, 0x04, 0x09, 0x0A, 0x0B, 0x0C}},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Equal([]byte{0x09, 0x0A, 0x0B, 0x0C, 0x01, 0x02, 0x03, 0x04}, field.GetVectors().GetFloat16Vector())
|
|
},
|
|
},
|
|
{
|
|
name: "bfloat16_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_BFloat16Vector,
|
|
FieldName: "nullable_bfloat16_vec",
|
|
ValidData: []bool{true, false, true},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_Bfloat16Vector{Bfloat16Vector: []byte{0x11, 0x12, 0x13, 0x14, 0x31, 0x32, 0x33, 0x34}},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Equal([]byte{0x31, 0x32, 0x33, 0x34, 0x11, 0x12, 0x13, 0x14}, field.GetVectors().GetBfloat16Vector())
|
|
},
|
|
},
|
|
{
|
|
name: "int8_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int8Vector,
|
|
FieldName: "nullable_int8_vec",
|
|
ValidData: []bool{true, false, true},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 4,
|
|
Data: &schemapb.VectorField_Int8Vector{Int8Vector: []byte{0x01, 0x02, 0x03, 0x04, 0x09, 0x0A, 0x0B, 0x0C}},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Equal([]byte{0x09, 0x0A, 0x0B, 0x0C, 0x01, 0x02, 0x03, 0x04}, field.GetVectors().GetInt8Vector())
|
|
},
|
|
},
|
|
{
|
|
name: "sparse_float_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_SparseFloatVector,
|
|
FieldName: "nullable_sparse_vec",
|
|
ValidData: []bool{true, false, true},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 3,
|
|
Data: &schemapb.VectorField_SparseFloatVector{SparseFloatVector: &schemapb.SparseFloatArray{
|
|
Dim: 3,
|
|
Contents: [][]byte{{0x01}, {0x03}},
|
|
}},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Equal([][]byte{{0x03}, {0x01}}, field.GetVectors().GetSparseFloatVector().GetContents())
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
s.Run(tt.name, func() {
|
|
err := reorderFieldData(tt.field, indices)
|
|
s.NoError(err)
|
|
s.Equal(expectedValidData, tt.field.GetValidData())
|
|
tt.assert(tt.field)
|
|
})
|
|
}
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataNullableVectorAllNullCompactData() {
|
|
indices := []int{1, 0}
|
|
expectedValidData := []bool{false, false}
|
|
|
|
tests := []struct {
|
|
name string
|
|
field *schemapb.FieldData
|
|
assert func(*schemapb.FieldData)
|
|
}{
|
|
{
|
|
name: "float_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_FloatVector,
|
|
FieldName: "nullable_float_vec",
|
|
ValidData: []bool{false, false},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_FloatVector{FloatVector: &schemapb.FloatArray{}},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Empty(field.GetVectors().GetFloatVector().GetData())
|
|
},
|
|
},
|
|
{
|
|
name: "binary_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_BinaryVector,
|
|
FieldName: "nullable_binary_vec",
|
|
ValidData: []bool{false, false},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 16,
|
|
Data: &schemapb.VectorField_BinaryVector{},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Empty(field.GetVectors().GetBinaryVector())
|
|
},
|
|
},
|
|
{
|
|
name: "float16_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_Float16Vector,
|
|
FieldName: "nullable_float16_vec",
|
|
ValidData: []bool{false, false},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_Float16Vector{},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Empty(field.GetVectors().GetFloat16Vector())
|
|
},
|
|
},
|
|
{
|
|
name: "bfloat16_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_BFloat16Vector,
|
|
FieldName: "nullable_bfloat16_vec",
|
|
ValidData: []bool{false, false},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 2,
|
|
Data: &schemapb.VectorField_Bfloat16Vector{},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Empty(field.GetVectors().GetBfloat16Vector())
|
|
},
|
|
},
|
|
{
|
|
name: "int8_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int8Vector,
|
|
FieldName: "nullable_int8_vec",
|
|
ValidData: []bool{false, false},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 4,
|
|
Data: &schemapb.VectorField_Int8Vector{},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Empty(field.GetVectors().GetInt8Vector())
|
|
},
|
|
},
|
|
{
|
|
name: "sparse_float_vector",
|
|
field: &schemapb.FieldData{
|
|
Type: schemapb.DataType_SparseFloatVector,
|
|
FieldName: "nullable_sparse_vec",
|
|
ValidData: []bool{false, false},
|
|
Field: &schemapb.FieldData_Vectors{Vectors: &schemapb.VectorField{
|
|
Dim: 3,
|
|
Data: &schemapb.VectorField_SparseFloatVector{SparseFloatVector: &schemapb.SparseFloatArray{Dim: 3}},
|
|
}},
|
|
},
|
|
assert: func(field *schemapb.FieldData) {
|
|
s.Empty(field.GetVectors().GetSparseFloatVector().GetContents())
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
s.Run(tt.name, func() {
|
|
err := reorderFieldData(tt.field, indices)
|
|
s.NoError(err)
|
|
s.Equal(expectedValidData, tt.field.GetValidData())
|
|
tt.assert(tt.field)
|
|
})
|
|
}
|
|
}
|
|
|
|
// Test reorderFieldData with ValidData (nullable fields)
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataWithValidData() {
|
|
field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "nullable_field",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{10, 20, 30}},
|
|
},
|
|
},
|
|
},
|
|
ValidData: []bool{true, false, true}, // index 1 is null
|
|
}
|
|
|
|
// Reorder: [2, 0, 1]
|
|
indices := []int{2, 0, 1}
|
|
err := reorderFieldData(field, indices)
|
|
s.NoError(err)
|
|
|
|
expectedData := []int64{30, 10, 20}
|
|
expectedValid := []bool{true, true, false} // null moves to index 2
|
|
s.Equal(expectedData, field.GetScalars().GetLongData().Data)
|
|
s.Equal(expectedValid, field.ValidData)
|
|
}
|
|
|
|
// Test orderByOperator with Int32 field to cover reorderFieldData Int32 branch
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorInt32Field() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3, 4}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7, 0.6},
|
|
Topks: []int64{4},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int32,
|
|
FieldName: "count",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_IntData{
|
|
IntData: &schemapb.IntArray{Data: []int32{30, 10, 40, 20}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "count", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After ascending sort: 10, 20, 30, 40
|
|
expectedIds := []int64{2, 4, 1, 3}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
|
|
expectedCounts := []int32{10, 20, 30, 40}
|
|
s.Equal(expectedCounts, sortedResult.Results.FieldsData[0].GetScalars().GetIntData().Data)
|
|
}
|
|
|
|
// Test orderByOperator with Double field
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorDoubleField() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7},
|
|
Topks: []int64{3},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Double,
|
|
FieldName: "value",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_DoubleData{
|
|
DoubleData: &schemapb.DoubleArray{Data: []float64{3.14, 1.41, 2.72}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "value", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After ascending sort: 1.41, 2.72, 3.14
|
|
expectedIds := []int64{2, 3, 1}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
|
|
expectedValues := []float64{1.41, 2.72, 3.14}
|
|
s.Equal(expectedValues, sortedResult.Results.FieldsData[0].GetScalars().GetDoubleData().Data)
|
|
}
|
|
|
|
// Test orderByOperator with VarChar field
|
|
func (s *SearchPipelineSuite) TestOrderByOperatorVarCharField() {
|
|
result := &milvuspb.SearchResults{
|
|
Results: &schemapb.SearchResultData{
|
|
Ids: &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{1, 2, 3}},
|
|
},
|
|
},
|
|
Scores: []float32{0.9, 0.8, 0.7},
|
|
Topks: []int64{3},
|
|
FieldsData: []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_VarChar,
|
|
FieldName: "name",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_StringData{
|
|
StringData: &schemapb.StringArray{Data: []string{"charlie", "alice", "bob"}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
op := &orderByOperator{
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "name", Ascending: true},
|
|
},
|
|
groupByFieldId: -1,
|
|
}
|
|
|
|
outputs, err := op.run(context.Background(), s.span, result)
|
|
s.NoError(err)
|
|
sortedResult := outputs[0].(*milvuspb.SearchResults)
|
|
|
|
// After ascending sort: alice, bob, charlie
|
|
expectedIds := []int64{2, 3, 1}
|
|
s.Equal(expectedIds, sortedResult.Results.Ids.GetIntId().Data)
|
|
|
|
expectedNames := []string{"alice", "bob", "charlie"}
|
|
s.Equal(expectedNames, sortedResult.Results.FieldsData[0].GetScalars().GetStringData().Data)
|
|
}
|
|
|
|
// Test compareNulls helper function
|
|
func (s *SearchPipelineSuite) TestCompareNulls() {
|
|
// Empty ValidData - should return (0, false)
|
|
cmp, handled := compareNulls(nil, 0, 1, true)
|
|
s.Equal(0, cmp)
|
|
s.False(handled)
|
|
|
|
cmp, handled = compareNulls([]bool{}, 0, 1, true)
|
|
s.Equal(0, cmp)
|
|
s.False(handled)
|
|
|
|
// Both non-null - should return (0, false)
|
|
validData := []bool{true, true, true}
|
|
cmp, handled = compareNulls(validData, 0, 1, true)
|
|
s.Equal(0, cmp)
|
|
s.False(handled)
|
|
|
|
// First is null, second is not - should return (-1, true) (nulls first)
|
|
validData = []bool{false, true, true}
|
|
cmp, handled = compareNulls(validData, 0, 1, true)
|
|
s.Equal(-1, cmp)
|
|
s.True(handled)
|
|
|
|
// First is not null, second is null - should return (1, true)
|
|
cmp, handled = compareNulls(validData, 1, 0, true)
|
|
s.Equal(1, cmp)
|
|
s.True(handled)
|
|
|
|
// Nulls last reverses the null/non-null ordering.
|
|
cmp, handled = compareNulls(validData, 0, 1, false)
|
|
s.Equal(1, cmp)
|
|
s.True(handled)
|
|
cmp, handled = compareNulls(validData, 1, 0, false)
|
|
s.Equal(-1, cmp)
|
|
s.True(handled)
|
|
|
|
// Both are null - should return (0, true)
|
|
validData = []bool{false, false, true}
|
|
cmp, handled = compareNulls(validData, 0, 1, true)
|
|
s.Equal(0, cmp)
|
|
s.True(handled)
|
|
}
|
|
|
|
// Test buildJSONValueCache and getCachedJSONValue
|
|
func (s *SearchPipelineSuite) TestBuildJSONValueCache() {
|
|
jsonField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_JSON,
|
|
FieldName: "metadata",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_JsonData{
|
|
JsonData: &schemapb.JSONArray{
|
|
Data: [][]byte{
|
|
[]byte(`{"score": 100}`),
|
|
[]byte(`{"score": 200}`),
|
|
[]byte(`{"score": 150}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fieldMap := map[string]*schemapb.FieldData{
|
|
"metadata": jsonField,
|
|
}
|
|
orderByFields := []OrderByField{
|
|
{FieldName: "metadata", JSONPath: "/score"},
|
|
}
|
|
|
|
cache := buildJSONValueCache(fieldMap, orderByFields, []int{0, 1, 2})
|
|
|
|
// Verify cache contains extracted values
|
|
val0 := cache.getCachedJSONValue("metadata", "/score", 0)
|
|
s.True(val0.Exists())
|
|
s.Equal(float64(100), val0.Float())
|
|
|
|
val1 := cache.getCachedJSONValue("metadata", "/score", 1)
|
|
s.True(val1.Exists())
|
|
s.Equal(float64(200), val1.Float())
|
|
|
|
val2 := cache.getCachedJSONValue("metadata", "/score", 2)
|
|
s.True(val2.Exists())
|
|
s.Equal(float64(150), val2.Float())
|
|
|
|
// Non-existent field/path returns empty result
|
|
valMissing := cache.getCachedJSONValue("nonexistent", "/score", 0)
|
|
s.False(valMissing.Exists())
|
|
|
|
valMissingPath := cache.getCachedJSONValue("metadata", "/nonexistent", 0)
|
|
s.False(valMissingPath.Exists())
|
|
|
|
// Out of bounds index returns empty result
|
|
valOOB := cache.getCachedJSONValue("metadata", "/score", 100)
|
|
s.False(valOOB.Exists())
|
|
}
|
|
|
|
// Test buildJSONValueCache skips non-JSON fields
|
|
func (s *SearchPipelineSuite) TestBuildJSONValueCacheSkipsNonJSON() {
|
|
intField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "age",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{10, 20, 30}},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
fieldMap := map[string]*schemapb.FieldData{
|
|
"age": intField,
|
|
}
|
|
orderByFields := []OrderByField{
|
|
{FieldName: "age", JSONPath: ""}, // No JSON path, should be skipped
|
|
}
|
|
|
|
cache := buildJSONValueCache(fieldMap, orderByFields, []int{0, 1, 2})
|
|
s.Empty(cache) // No entries should be cached for non-JSON fields
|
|
}
|
|
|
|
// Test compareOrderByField with nullable JSON field
|
|
func (s *SearchPipelineSuite) TestCompareOrderByFieldNullableJSON() {
|
|
jsonField := &schemapb.FieldData{
|
|
Type: schemapb.DataType_JSON,
|
|
FieldName: "metadata",
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_JsonData{
|
|
JsonData: &schemapb.JSONArray{
|
|
Data: [][]byte{
|
|
[]byte(`{"score": 100}`),
|
|
[]byte(`{"score": 200}`),
|
|
[]byte(`{"score": 150}`),
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
ValidData: []bool{true, false, true}, // Index 1 is null
|
|
}
|
|
|
|
fieldMap := map[string]*schemapb.FieldData{
|
|
"metadata": jsonField,
|
|
}
|
|
orderBy := OrderByField{FieldName: "metadata", JSONPath: "/score", Ascending: true, NullsFirst: true}
|
|
cache := buildJSONValueCache(fieldMap, []OrderByField{orderBy}, []int{0, 1, 2})
|
|
|
|
// null vs non-null: null should come first
|
|
cmp, err := compareOrderByField(jsonField, orderBy, 1, 0, cache)
|
|
s.NoError(err)
|
|
s.Equal(-1, cmp) // null < 100
|
|
|
|
cmp, err = compareOrderByField(jsonField, orderBy, 0, 1, cache)
|
|
s.NoError(err)
|
|
s.Equal(1, cmp) // 100 > null
|
|
|
|
// non-null vs non-null: normal comparison
|
|
cmp, err = compareOrderByField(jsonField, orderBy, 0, 2, cache)
|
|
s.NoError(err)
|
|
s.Equal(-1, cmp) // 100 < 150
|
|
|
|
cmp, err = compareOrderByField(jsonField, orderBy, 2, 0, cache)
|
|
s.NoError(err)
|
|
s.Equal(1, cmp) // 150 > 100
|
|
}
|
|
|
|
// Test reorderFieldData returns error for unhandled data type
|
|
func (s *SearchPipelineSuite) TestReorderFieldDataUnhandledType() {
|
|
// Create a field with an unhandled type (None type is not handled)
|
|
field := &schemapb.FieldData{
|
|
Type: schemapb.DataType_None,
|
|
FieldName: "unknown",
|
|
}
|
|
|
|
indices := []int{0, 1, 2}
|
|
err := reorderFieldData(field, indices)
|
|
s.Error(err)
|
|
s.Contains(err.Error(), "unhandled data type")
|
|
}
|
|
|
|
// TestNewSearchPipelineWithOrderBy tests that newSearchPipeline correctly routes
|
|
// to searchWithOrderByPipe when orderByFields is set
|
|
func (s *SearchPipelineSuite) TestNewSearchPipelineWithOrderBy() {
|
|
collectionName := "test_collection"
|
|
|
|
// Mock requery operation
|
|
f1 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "intField", 20)
|
|
f1.FieldId = 101
|
|
f2 := testutils.GenerateScalarFieldData(schemapb.DataType_Int64, "int64", 20)
|
|
f2.FieldId = 100
|
|
mocker := mockey.Mock((*requeryOperator).requery).Return(&milvuspb.QueryResults{
|
|
FieldsData: []*schemapb.FieldData{f1, f2},
|
|
}, segcore.StorageCost{ScannedRemoteBytes: 100, ScannedTotalBytes: 200}, nil).Build()
|
|
defer mocker.UnPatch()
|
|
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
collectionName: collectionName,
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
Base: &commonpb.MsgBase{
|
|
MsgType: commonpb.MsgType_Search,
|
|
Timestamp: uint64(time.Now().UnixNano()),
|
|
},
|
|
MetricType: "L2",
|
|
Topk: 10,
|
|
Nq: 2,
|
|
PartitionIDs: []int64{1},
|
|
CollectionID: 1,
|
|
DbID: 1,
|
|
IsAdvanced: false, // Regular search, not hybrid
|
|
},
|
|
schema: &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "intField", DataType: schemapb.DataType_Int64},
|
|
},
|
|
},
|
|
pkField: &schemapb.FieldSchema{FieldID: 100, Name: "int64", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
},
|
|
queryInfos: []*planpb.QueryInfo{{}},
|
|
translatedOutputFields: []string{"intField"},
|
|
request: &milvuspb.SearchRequest{Namespace: nil},
|
|
// Set orderByFields to trigger searchWithOrderByPipe
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "intField", OutputFieldName: "intField", Ascending: true},
|
|
},
|
|
}
|
|
|
|
// Use newSearchPipeline (the actual entry point)
|
|
pipeline, err := newSearchPipeline(task)
|
|
s.NoError(err)
|
|
s.NotNil(pipeline)
|
|
|
|
// Run the pipeline with test data
|
|
sr := genTestSearchResultData(2, 10, schemapb.DataType_Int64, "intField", 101, false)
|
|
results, _, err := pipeline.Run(context.Background(), s.span, []*internalpb.SearchResults{sr}, segcore.StorageCost{ScannedRemoteBytes: 100, ScannedTotalBytes: 200})
|
|
s.NoError(err)
|
|
s.NotNil(results)
|
|
s.NotNil(results.Results)
|
|
s.Equal(int64(2), results.Results.NumQueries)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestNewRequeryOperator_WithHighlightDynamicFields() {
|
|
// Test that highlight dynamic fields are added to requery output
|
|
schema := &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "title", DataType: schemapb.DataType_VarChar},
|
|
},
|
|
},
|
|
pkField: &schemapb.FieldSchema{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
}
|
|
|
|
// Mock highlighter with dynamic field names
|
|
mockHighlighter := &SemanticHighlighter{highlight: &highlight.SemanticHighlight{}}
|
|
mockDynFields := mockey.Mock((*SemanticHighlighter).DynamicFieldNames).To(func(h *SemanticHighlighter) []string {
|
|
return []string{"dyn_field1", "dyn_field2"}
|
|
}).Build()
|
|
defer mockDynFields.UnPatch()
|
|
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
collectionName: "test_collection",
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
Base: &commonpb.MsgBase{
|
|
MsgType: commonpb.MsgType_Search,
|
|
Timestamp: uint64(time.Now().UnixNano()),
|
|
},
|
|
},
|
|
request: &milvuspb.SearchRequest{
|
|
CollectionName: "test_collection",
|
|
},
|
|
schema: schema,
|
|
translatedOutputFields: []string{"title"},
|
|
highlighter: mockHighlighter,
|
|
tr: timerecord.NewTimeRecorder("test"),
|
|
}
|
|
|
|
op, err := newRequeryOperator(task, nil)
|
|
s.NoError(err)
|
|
s.NotNil(op)
|
|
|
|
reqOp, ok := op.(*requeryOperator)
|
|
s.True(ok)
|
|
|
|
// Verify that dynamic fields from highlighter are included in output fields
|
|
s.Contains(reqOp.outputFieldNames, "title")
|
|
s.Contains(reqOp.outputFieldNames, "dyn_field1")
|
|
s.Contains(reqOp.outputFieldNames, "dyn_field2")
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestNewRequeryOperatorIncludesOrderByOutputFieldNames() {
|
|
schema := &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "title", DataType: schemapb.DataType_VarChar},
|
|
{FieldID: 102, Name: "metadata", DataType: schemapb.DataType_JSON},
|
|
{FieldID: 103, Name: "price", DataType: schemapb.DataType_Int64},
|
|
},
|
|
},
|
|
pkField: &schemapb.FieldSchema{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
}
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
collectionName: "test_collection",
|
|
SearchRequest: &internalpb.SearchRequest{Base: &commonpb.MsgBase{MsgType: commonpb.MsgType_Search}},
|
|
request: &milvuspb.SearchRequest{CollectionName: "test_collection"},
|
|
schema: schema,
|
|
translatedOutputFields: []string{
|
|
"title",
|
|
},
|
|
orderByFields: []OrderByField{
|
|
{FieldName: "price", OutputFieldName: "price"},
|
|
{FieldName: "metadata", JSONPath: "/score", OutputFieldName: "metadata"},
|
|
{FieldName: "$meta", JSONPath: "/dynamic_price", OutputFieldName: "dynamic_price", IsDynamicField: true},
|
|
},
|
|
tr: timerecord.NewTimeRecorder("test"),
|
|
}
|
|
|
|
op, err := newRequeryOperator(task, nil)
|
|
s.NoError(err)
|
|
reqOp := op.(*requeryOperator)
|
|
s.ElementsMatch([]string{"title", "price", "metadata", "dynamic_price"}, reqOp.outputFieldNames)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestNewRequeryOperator_WithoutHighlighter() {
|
|
// Test that without highlighter, only translated output fields are included
|
|
schema := &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "title", DataType: schemapb.DataType_VarChar},
|
|
},
|
|
},
|
|
pkField: &schemapb.FieldSchema{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
}
|
|
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
collectionName: "test_collection",
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
Base: &commonpb.MsgBase{
|
|
MsgType: commonpb.MsgType_Search,
|
|
Timestamp: uint64(time.Now().UnixNano()),
|
|
},
|
|
},
|
|
request: &milvuspb.SearchRequest{
|
|
CollectionName: "test_collection",
|
|
},
|
|
schema: schema,
|
|
translatedOutputFields: []string{"title"},
|
|
highlighter: nil, // No highlighter
|
|
tr: timerecord.NewTimeRecorder("test"),
|
|
}
|
|
|
|
op, err := newRequeryOperator(task, nil)
|
|
s.NoError(err)
|
|
s.NotNil(op)
|
|
|
|
reqOp, ok := op.(*requeryOperator)
|
|
s.True(ok)
|
|
|
|
// Verify only translated output fields are included
|
|
s.Equal([]string{"title"}, reqOp.outputFieldNames)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestNewBuiltInPipelineWithAggCtx() {
|
|
// searchWithAggPipe now chains searchReduceOp → aggregateOp, so the task
|
|
// needs a schema with a PK so searchReduceOp's factory can initialize.
|
|
pkField := &schemapb.FieldSchema{FieldID: 100, Name: "pk", DataType: schemapb.DataType_Int64, IsPrimaryKey: true}
|
|
aggCtx, err := search_agg.NewContext(1, nil, nil, nil)
|
|
s.Require().NoError(err)
|
|
task := &searchTask{
|
|
SearchRequest: &internalpb.SearchRequest{IsAdvanced: false},
|
|
aggCtx: aggCtx,
|
|
schema: &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{Fields: []*schemapb.FieldSchema{pkField}},
|
|
pkField: pkField,
|
|
},
|
|
queryInfos: []*planpb.QueryInfo{{}},
|
|
}
|
|
|
|
pipeline, err := newBuiltInPipeline(task)
|
|
s.NoError(err)
|
|
s.NotNil(pipeline)
|
|
s.Equal(searchWithAggPipe.name, pipeline.name)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestNewSearchPipelineWithAggCtxSkipsEndNode() {
|
|
pkField := &schemapb.FieldSchema{FieldID: 100, Name: "pk", DataType: schemapb.DataType_Int64, IsPrimaryKey: true}
|
|
aggCtx, err := search_agg.NewContext(1, nil, nil, nil)
|
|
s.Require().NoError(err)
|
|
task := &searchTask{
|
|
SearchRequest: &internalpb.SearchRequest{IsAdvanced: false},
|
|
aggCtx: aggCtx,
|
|
schema: &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{Fields: []*schemapb.FieldSchema{pkField}},
|
|
pkField: pkField,
|
|
},
|
|
queryInfos: []*planpb.QueryInfo{{}},
|
|
highlighter: nil,
|
|
}
|
|
|
|
pipeline, err := newSearchPipeline(task)
|
|
s.NoError(err)
|
|
s.NotNil(pipeline)
|
|
s.Equal(searchWithAggPipe.name, pipeline.name)
|
|
s.Len(pipeline.nodes, len(searchWithAggPipe.nodes))
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestAggregateOperatorRun() {
|
|
aggCtx, err := search_agg.NewContext(1,
|
|
[]search_agg.LevelContext{{
|
|
OwnFieldIDs: []int64{101},
|
|
Size: 100,
|
|
Metrics: map[string]search_agg.MetricSpec{
|
|
"sum_value": {Op: "sum", FieldID: 102, FieldType: schemapb.DataType_Int64},
|
|
},
|
|
TopHits: &search_agg.TopHitsConfig{Size: 100},
|
|
Order: []search_agg.OrderCriterion{{Key: "_key", Dir: "asc"}},
|
|
}},
|
|
nil,
|
|
[]int64{102},
|
|
)
|
|
s.Require().NoError(err)
|
|
op := &aggregateOperator{aggCtx: aggCtx}
|
|
|
|
data := &schemapb.SearchResultData{
|
|
NumQueries: 1,
|
|
Topks: []int64{3},
|
|
Ids: &schemapb.IDs{IdField: &schemapb.IDs_IntId{IntId: &schemapb.LongArray{
|
|
Data: []int64{1, 2, 3},
|
|
}}},
|
|
Scores: []float32{0.9, 0.8, 0.7},
|
|
FieldsData: []*schemapb.FieldData{
|
|
testutils.GenerateScalarFieldDataWithValue(schemapb.DataType_Int64, "value", 102, []int64{10, 20, 30}),
|
|
},
|
|
GroupByFieldValues: []*schemapb.FieldData{
|
|
testutils.GenerateScalarFieldDataWithValue(schemapb.DataType_VarChar, "brand", 101, []string{"A", "A", "B"}),
|
|
},
|
|
}
|
|
|
|
// aggregateOp consumes the single reduced *milvuspb.SearchResults produced
|
|
// upstream by searchReduceOp.
|
|
reduced := &milvuspb.SearchResults{Status: merr.Success(), Results: data}
|
|
outputs, err := op.run(context.Background(), s.span, []*milvuspb.SearchResults{reduced})
|
|
s.NoError(err)
|
|
s.Len(outputs, 1)
|
|
|
|
result := outputs[0].(*milvuspb.SearchResults)
|
|
s.NotNil(result)
|
|
s.NotNil(result.GetResults())
|
|
s.Equal(int64(1), result.GetResults().GetNumQueries())
|
|
s.Equal([]int64{2}, result.GetResults().GetAggTopks())
|
|
s.Len(result.GetResults().GetAggBuckets(), 2)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestNewRequeryOperator_WithHighlighterNoDynamicFields() {
|
|
// Test that highlighter with empty dynamic fields doesn't affect output
|
|
schema := &schemaInfo{
|
|
CollectionSchema: &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
{FieldID: 101, Name: "title", DataType: schemapb.DataType_VarChar},
|
|
},
|
|
},
|
|
pkField: &schemapb.FieldSchema{FieldID: 100, Name: "id", DataType: schemapb.DataType_Int64, IsPrimaryKey: true},
|
|
}
|
|
|
|
// Mock highlighter with empty dynamic field names
|
|
mockHighlighter := &SemanticHighlighter{highlight: &highlight.SemanticHighlight{}}
|
|
mockDynFields := mockey.Mock((*SemanticHighlighter).DynamicFieldNames).To(func(h *SemanticHighlighter) []string {
|
|
return []string{} // Empty dynamic fields
|
|
}).Build()
|
|
defer mockDynFields.UnPatch()
|
|
|
|
task := &searchTask{
|
|
ctx: context.Background(),
|
|
collectionName: "test_collection",
|
|
SearchRequest: &internalpb.SearchRequest{
|
|
Base: &commonpb.MsgBase{
|
|
MsgType: commonpb.MsgType_Search,
|
|
Timestamp: uint64(time.Now().UnixNano()),
|
|
},
|
|
},
|
|
request: &milvuspb.SearchRequest{
|
|
CollectionName: "test_collection",
|
|
},
|
|
schema: schema,
|
|
translatedOutputFields: []string{"title"},
|
|
highlighter: mockHighlighter,
|
|
tr: timerecord.NewTimeRecorder("test"),
|
|
}
|
|
|
|
op, err := newRequeryOperator(task, nil)
|
|
s.NoError(err)
|
|
s.NotNil(op)
|
|
|
|
reqOp, ok := op.(*requeryOperator)
|
|
s.True(ok)
|
|
|
|
// Verify only translated output fields are included (no dynamic fields added)
|
|
s.Equal([]string{"title"}, reqOp.outputFieldNames)
|
|
}
|
|
|
|
// TestPickFieldDataWithNullableSparseVector tests pickFieldData with nullable sparse vector fields.
|
|
// This test ensures that when reordering query results (which may return in different order than requested),
|
|
// nullable sparse vectors are correctly handled using FieldDataIdxComputer to map row indices to data indices.
|
|
func (s *SearchPipelineSuite) TestPickFieldDataWithNullableSparseVector() {
|
|
// Scenario: Search returns IDs [3, 1, 2], but Query returns them in order [1, 2, 3]
|
|
// with a nullable sparse vector where row 1 (middle) is null.
|
|
// pickFieldData should correctly reorder the data to match search result order.
|
|
|
|
// Search result IDs (the order we want)
|
|
searchIDs := &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{
|
|
Data: []int64{3, 1, 2}, // Want this order in final result
|
|
},
|
|
},
|
|
}
|
|
|
|
// Query returns data in different order: [1, 2, 3]
|
|
// pkOffset maps each PK to its position in the query result
|
|
pkOffset := map[any]int{
|
|
int64(1): 0, // PK 1 is at index 0 in query result
|
|
int64(2): 1, // PK 2 is at index 1 in query result
|
|
int64(3): 2, // PK 3 is at index 2 in query result
|
|
}
|
|
|
|
// Create sparse vector data for 3 rows, but row index 1 (PK=2) is null
|
|
// ValidData: [true, false, true] means row 0 and 2 have data, row 1 is null
|
|
// Contents only has 2 entries (for the non-null rows)
|
|
sparseContent0, _ := testutils.GenerateSparseFloatVectorsData(1)
|
|
sparseContent2, _ := testutils.GenerateSparseFloatVectorsData(1)
|
|
|
|
queryFields := []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "pk",
|
|
FieldId: 100,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{
|
|
Data: []int64{1, 2, 3}, // Query result order
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: schemapb.DataType_SparseFloatVector,
|
|
FieldName: "sparse_vec",
|
|
FieldId: 101,
|
|
ValidData: []bool{true, false, true}, // Row 1 is null
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 700,
|
|
Data: &schemapb.VectorField_SparseFloatVector{
|
|
SparseFloatVector: &schemapb.SparseFloatArray{
|
|
Dim: 700,
|
|
Contents: [][]byte{sparseContent0[0], sparseContent2[0]}, // Only 2 entries for non-null rows
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
// Call pickFieldData - this should NOT panic
|
|
result, err := pickFieldData(searchIDs, pkOffset, queryFields, nil, 12345)
|
|
s.NoError(err)
|
|
s.NotNil(result)
|
|
s.Len(result, 2)
|
|
|
|
// Verify PK field is reordered correctly: [3, 1, 2]
|
|
pkData := result[0].GetScalars().GetLongData().GetData()
|
|
s.Equal([]int64{3, 1, 2}, pkData)
|
|
|
|
// Verify sparse vector ValidData is reordered correctly
|
|
// Original ValidData: [true, false, true] for rows [1, 2, 3]
|
|
// After reorder to [3, 1, 2]: ValidData should be [true, true, false]
|
|
sparseValidData := result[1].GetValidData()
|
|
s.Equal([]bool{true, true, false}, sparseValidData)
|
|
|
|
// Verify sparse vector Contents has correct number of entries (2 non-null values)
|
|
sparseContents := result[1].GetVectors().GetSparseFloatVector().GetContents()
|
|
s.Len(sparseContents, 2)
|
|
}
|
|
|
|
// TestPickFieldDataWithAllNullSparseVector tests pickFieldData when all sparse vector values are null.
|
|
func (s *SearchPipelineSuite) TestPickFieldDataWithAllNullSparseVector() {
|
|
searchIDs := &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{
|
|
Data: []int64{2, 1},
|
|
},
|
|
},
|
|
}
|
|
|
|
pkOffset := map[any]int{
|
|
int64(1): 0,
|
|
int64(2): 1,
|
|
}
|
|
|
|
queryFields := []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "pk",
|
|
FieldId: 100,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{
|
|
Data: []int64{1, 2},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: schemapb.DataType_SparseFloatVector,
|
|
FieldName: "sparse_vec",
|
|
FieldId: 101,
|
|
ValidData: []bool{false, false}, // All null
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Dim: 700,
|
|
Data: &schemapb.VectorField_SparseFloatVector{
|
|
SparseFloatVector: &schemapb.SparseFloatArray{
|
|
Dim: 700,
|
|
Contents: [][]byte{}, // Empty - all null
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := pickFieldData(searchIDs, pkOffset, queryFields, nil, 12345)
|
|
s.NoError(err)
|
|
s.NotNil(result)
|
|
|
|
// All should still be null after reorder
|
|
sparseValidData := result[1].GetValidData()
|
|
s.Equal([]bool{false, false}, sparseValidData)
|
|
s.Len(result[1].GetVectors().GetSparseFloatVector().GetContents(), 0)
|
|
}
|
|
|
|
func (s *SearchPipelineSuite) TestPickFieldDataWithNullableSparseVectorMissingValidData() {
|
|
searchIDs := &schemapb.IDs{
|
|
IdField: &schemapb.IDs_IntId{
|
|
IntId: &schemapb.LongArray{Data: []int64{2, 1}},
|
|
},
|
|
}
|
|
pkOffset := map[any]int{
|
|
int64(1): 0,
|
|
int64(2): 1,
|
|
}
|
|
schema := &schemapb.CollectionSchema{
|
|
Fields: []*schemapb.FieldSchema{
|
|
{
|
|
FieldID: 100,
|
|
Name: "pk",
|
|
DataType: schemapb.DataType_Int64,
|
|
IsPrimaryKey: true,
|
|
},
|
|
{
|
|
FieldID: 101,
|
|
Name: "sparse_vec",
|
|
DataType: schemapb.DataType_SparseFloatVector,
|
|
Nullable: true,
|
|
},
|
|
},
|
|
}
|
|
queryFields := []*schemapb.FieldData{
|
|
{
|
|
Type: schemapb.DataType_Int64,
|
|
FieldName: "pk",
|
|
FieldId: 100,
|
|
Field: &schemapb.FieldData_Scalars{
|
|
Scalars: &schemapb.ScalarField{
|
|
Data: &schemapb.ScalarField_LongData{
|
|
LongData: &schemapb.LongArray{Data: []int64{1, 2}},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
{
|
|
Type: schemapb.DataType_SparseFloatVector,
|
|
FieldName: "sparse_vec",
|
|
FieldId: 101,
|
|
Field: &schemapb.FieldData_Vectors{
|
|
Vectors: &schemapb.VectorField{
|
|
Data: &schemapb.VectorField_SparseFloatVector{
|
|
SparseFloatVector: &schemapb.SparseFloatArray{},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result, err := pickFieldData(searchIDs, pkOffset, queryFields, schema, 12345)
|
|
s.NoError(err)
|
|
s.Equal([]int64{2, 1}, result[0].GetScalars().GetLongData().GetData())
|
|
s.Equal([]bool{false, false}, result[1].GetValidData())
|
|
s.Empty(result[1].GetVectors().GetSparseFloatVector().GetContents())
|
|
}
|