498b235461
Build and test / Build and test AMD64 Ubuntu 22.04 (push) Failing after 0s
Publish Builder / amazonlinux2023 (push) Failing after 1s
Build and test / UT for Go (push) Has been skipped
Publish KRTE Images / KRTE (push) Failing after 1s
Build and test / Integration Test (push) Has been skipped
Build and test / Upload Code Coverage (push) Has been skipped
Publish Builder / rockylinux9 (push) Failing after 1s
Publish Builder / ubuntu22.04 (push) Failing after 0s
Publish Builder / ubuntu24.04 (push) Failing after 0s
Publish Gpu Builder / publish-gpu-builder (push) Failing after 1s
Publish Test Images / PyTest (push) Failing after 0s
Build and test / UT for Cpp (push) Has been cancelled
591 lines
15 KiB
Go
591 lines
15 KiB
Go
package search_agg
|
|
|
|
import (
|
|
"context"
|
|
"sort"
|
|
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
|
|
"github.com/milvus-io/milvus/internal/agg"
|
|
"github.com/milvus-io/milvus/internal/util/reduce"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/merr"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/typeutil"
|
|
)
|
|
|
|
// SearchAggregationComputer runs hierarchical aggregation over a single
|
|
// SearchResultData that has already been cross-shard-reduced upstream by
|
|
// searchReduceOperator. Composite-key group reduce is NOT done here — the
|
|
// pipeline is SearchReduce → SearchAgg so this stage is pure hierarchy walk
|
|
// (grouping per level, metric accumulation, top_hits, sub-aggregation).
|
|
type SearchAggregationComputer struct {
|
|
ctx *SearchAggregationContext
|
|
data *schemapb.SearchResultData
|
|
|
|
// fieldsByID maps FieldID → FieldData, unioning fields_data (metric
|
|
// sources, top_hits sort, user output) with group_by_field_values
|
|
// (composite group-by key columns). Field IDs never overlap between the
|
|
// two channels, so a single map is unambiguous.
|
|
fieldsByID map[int64]*schemapb.FieldData
|
|
}
|
|
|
|
// NewSearchAggregationComputer wraps an already-reduced SearchResultData.
|
|
// Upstream searchReduceOperator owns cross-shard merge + group-size /
|
|
// topK enforcement; this computer only does per-NQ hierarchical aggregation.
|
|
func NewSearchAggregationComputer(
|
|
data *schemapb.SearchResultData,
|
|
ctx *SearchAggregationContext,
|
|
) *SearchAggregationComputer {
|
|
m := make(map[int64]*schemapb.FieldData, len(data.GetFieldsData())+len(data.GetGroupByFieldValues()))
|
|
for _, fd := range data.GetFieldsData() {
|
|
if fd != nil {
|
|
m[fd.GetFieldId()] = fd
|
|
}
|
|
}
|
|
for _, fd := range data.GetGroupByFieldValues() {
|
|
if fd != nil {
|
|
m[fd.GetFieldId()] = fd
|
|
}
|
|
}
|
|
return &SearchAggregationComputer{
|
|
ctx: ctx,
|
|
data: data,
|
|
fieldsByID: m,
|
|
}
|
|
}
|
|
|
|
func (c *SearchAggregationComputer) Compute(ctx context.Context) ([][]*AggBucketResult, error) {
|
|
if c.ctx == nil {
|
|
return nil, merr.WrapErrServiceInternalMsg("search aggregation context is nil")
|
|
}
|
|
if len(c.ctx.Levels) == 0 {
|
|
return nil, merr.WrapErrServiceInternalMsg("search aggregation context has no levels")
|
|
}
|
|
|
|
output := make([][]*AggBucketResult, c.ctx.NQ)
|
|
for qi := int64(0); qi < c.ctx.NQ; qi++ {
|
|
buckets, err := c.computeForQi(ctx, qi)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
output[qi] = buckets
|
|
}
|
|
return output, nil
|
|
}
|
|
|
|
func (c *SearchAggregationComputer) computeForQi(ctx context.Context, qi int64) ([]*AggBucketResult, error) {
|
|
topks := c.data.GetTopks()
|
|
if qi < 0 || qi >= int64(len(topks)) {
|
|
return nil, merr.WrapErrServiceInternalMsg("invalid qi %d, topks length=%d", qi, len(topks))
|
|
}
|
|
var start int64
|
|
for i := int64(0); i < qi; i++ {
|
|
start += topks[i]
|
|
}
|
|
count := topks[qi]
|
|
rows := make([]reduce.RowRef, count)
|
|
for i := int64(0); i < count; i++ {
|
|
rows[i] = reduce.RowRef{ResultIdx: 0, RowIdx: start + i}
|
|
}
|
|
return c.computeLevel(ctx, qi, 0, rows)
|
|
}
|
|
|
|
func (c *SearchAggregationComputer) computeLevel(ctx context.Context, qi int64, levelIdx int, rows []reduce.RowRef) ([]*AggBucketResult, error) {
|
|
if levelIdx < 0 || levelIdx >= len(c.ctx.Levels) {
|
|
return nil, merr.WrapErrServiceInternalMsg("invalid level index %d", levelIdx)
|
|
}
|
|
level := c.ctx.Levels[levelIdx]
|
|
isLeaf := levelIdx == len(c.ctx.Levels)-1
|
|
|
|
// Hash-based lookup with collision chain: matches the pattern used by
|
|
// internal/agg/aggregate_reducer.go. No string canonicalization per row.
|
|
buckets := make(map[uint64][]*bucketState)
|
|
keyOrder := make([]*bucketState, 0)
|
|
|
|
for _, ref := range rows {
|
|
values, err := c.extractOwnValues(ref, level.OwnFieldIDs)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
h := reduce.HashGroupValues(values)
|
|
|
|
var bucket *bucketState
|
|
for _, cand := range buckets[h] {
|
|
if reduce.EqualGroupValues(cand.key, values) {
|
|
bucket = cand
|
|
break
|
|
}
|
|
}
|
|
if bucket == nil {
|
|
bucket = newBucketState(values, level.metricPlans)
|
|
buckets[h] = append(buckets[h], bucket)
|
|
keyOrder = append(keyOrder, bucket)
|
|
}
|
|
|
|
bucket.count++
|
|
bucket.rows = append(bucket.rows, ref)
|
|
|
|
if err := c.updateMetrics(bucket, ref, level.metricPlans); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// Two-pass build: order/size applies only to local-level fields (_count,
|
|
// _key, or a metric alias of THIS level), never to Hits or sub-agg
|
|
// output. So emit skeleton (Key/Count/Metrics) first, trim by
|
|
// applyOrderAndSize, then populate Hits + sub-agg only for survivors —
|
|
// avoids wasted buildTopHits and sub-level recursion on dropped buckets.
|
|
output := make([]*AggBucketResult, 0, len(keyOrder))
|
|
bucketForResult := make(map[*AggBucketResult]*bucketState, len(keyOrder))
|
|
for _, bucket := range keyOrder {
|
|
result := &AggBucketResult{
|
|
Key: keyValuesToMap(bucket.key, level.OwnFieldIDs),
|
|
Count: bucket.count,
|
|
}
|
|
metrics, err := finalizeMetrics(level.metricPlans, bucket.metricStates)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(metrics) > 0 {
|
|
result.Metrics = metrics
|
|
}
|
|
output = append(output, result)
|
|
bucketForResult[result] = bucket
|
|
}
|
|
|
|
output, err := applyOrderAndSize(output, level)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
for _, result := range output {
|
|
bucket := bucketForResult[result]
|
|
if level.TopHits != nil {
|
|
hits, err := c.buildTopHits(bucket.rows, level.TopHits)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.Hits = hits
|
|
}
|
|
if !isLeaf {
|
|
subBuckets, err := c.computeLevel(ctx, qi, levelIdx+1, bucket.rows)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result.SubAggBuckets = subBuckets
|
|
}
|
|
}
|
|
|
|
return output, nil
|
|
}
|
|
|
|
func (c *SearchAggregationComputer) buildTopHits(rows []reduce.RowRef, cfg *TopHitsConfig) ([]*HitResult, error) {
|
|
if cfg == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
sorted := make([]reduce.RowRef, len(rows))
|
|
copy(sorted, rows)
|
|
var sortErr error
|
|
sort.SliceStable(sorted, func(i, j int) bool {
|
|
if sortErr != nil {
|
|
return false
|
|
}
|
|
cmp, err := c.compareRowsForTopHits(sorted[i], sorted[j], cfg.Sort)
|
|
if err != nil {
|
|
sortErr = err
|
|
return false
|
|
}
|
|
return cmp < 0
|
|
})
|
|
if sortErr != nil {
|
|
return nil, sortErr
|
|
}
|
|
|
|
limit := int(normalizeAggregationSize(cfg.Size))
|
|
if limit > len(sorted) {
|
|
limit = len(sorted)
|
|
}
|
|
|
|
hits := make([]*HitResult, 0, limit)
|
|
for i := 0; i < limit; i++ {
|
|
hit, err := c.buildHitResult(sorted[i])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hits = append(hits, hit)
|
|
}
|
|
return hits, nil
|
|
}
|
|
|
|
func (c *SearchAggregationComputer) compareRowsForTopHits(a, b reduce.RowRef, sortCriteria []SortCriterion) (int, error) {
|
|
for _, criterion := range sortCriteria {
|
|
av, _, err := c.readValueByFieldID(a, criterion.FieldID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
bv, _, err := c.readValueByFieldID(b, criterion.FieldID)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if cmp, decided := compareNulls(av, bv, criterion.NullFirst); decided {
|
|
if cmp == 0 {
|
|
continue
|
|
}
|
|
return cmp, nil
|
|
}
|
|
|
|
cmp, err := compareValues(av, bv)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if cmp == 0 {
|
|
continue
|
|
}
|
|
|
|
if criterion.Dir == "desc" {
|
|
cmp = -cmp
|
|
}
|
|
return cmp, nil
|
|
}
|
|
|
|
scoreA := c.data.GetScores()[a.RowIdx]
|
|
scoreB := c.data.GetScores()[b.RowIdx]
|
|
if scoreA > scoreB {
|
|
return -1, nil
|
|
}
|
|
if scoreA < scoreB {
|
|
return 1, nil
|
|
}
|
|
|
|
pkA := typeutil.GetPK(c.data.GetIds(), a.RowIdx)
|
|
pkB := typeutil.GetPK(c.data.GetIds(), b.RowIdx)
|
|
if pkA != nil && pkB != nil && pkA != pkB {
|
|
if typeutil.ComparePK(pkA, pkB) {
|
|
return -1, nil
|
|
}
|
|
return 1, nil
|
|
}
|
|
|
|
if a.ResultIdx < b.ResultIdx {
|
|
return -1, nil
|
|
}
|
|
if a.ResultIdx > b.ResultIdx {
|
|
return 1, nil
|
|
}
|
|
if a.RowIdx < b.RowIdx {
|
|
return -1, nil
|
|
}
|
|
if a.RowIdx > b.RowIdx {
|
|
return 1, nil
|
|
}
|
|
return 0, nil
|
|
}
|
|
|
|
func (c *SearchAggregationComputer) buildHitResult(ref reduce.RowRef) (*HitResult, error) {
|
|
hit := &HitResult{
|
|
PK: typeutil.GetPK(c.data.GetIds(), ref.RowIdx),
|
|
Score: c.data.GetScores()[ref.RowIdx],
|
|
Fields: make(map[int64]any, len(c.ctx.UserOutputFieldIDs)),
|
|
}
|
|
|
|
for fieldID := range c.ctx.UserOutputFieldIDs {
|
|
val, _, err := c.readValueByFieldID(ref, fieldID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
hit.Fields[fieldID] = val
|
|
}
|
|
return hit, nil
|
|
}
|
|
|
|
// extractOwnValues reads group-by values in OwnFieldIDs order and normalizes
|
|
// scalar types via reduce.NormalizeScalar so hashing and equality behave
|
|
// consistently regardless of the raw Go type the iterator surface returns.
|
|
// Null values pass through as nil so grouping treats null == null.
|
|
func (c *SearchAggregationComputer) extractOwnValues(ref reduce.RowRef, ownFieldIDs []int64) ([]any, error) {
|
|
values := make([]any, len(ownFieldIDs))
|
|
for i, fieldID := range ownFieldIDs {
|
|
raw, isNull, err := c.readValueByFieldID(ref, fieldID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if isNull {
|
|
values[i] = nil
|
|
continue
|
|
}
|
|
values[i] = reduce.NormalizeScalar(raw)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
// keyValuesToMap materializes the public Key map from a level's OwnFieldIDs
|
|
// and the internal []any key slice. Called once per bucket at emission — not
|
|
// on the per-row hot path.
|
|
func keyValuesToMap(values []any, ownFieldIDs []int64) map[int64]any {
|
|
if len(ownFieldIDs) == 0 {
|
|
return nil
|
|
}
|
|
key := make(map[int64]any, len(ownFieldIDs))
|
|
for i, fid := range ownFieldIDs {
|
|
key[fid] = values[i]
|
|
}
|
|
return key
|
|
}
|
|
|
|
// updateMetrics reads each metric source once and delegates state updates to internal/agg.
|
|
func (c *SearchAggregationComputer) updateMetrics(bucket *bucketState, ref reduce.RowRef, plans []metricPlan) error {
|
|
if len(plans) == 0 {
|
|
return nil
|
|
}
|
|
for _, plan := range plans {
|
|
targets := bucket.metricStates[plan.alias]
|
|
if targets == nil {
|
|
return merr.WrapErrServiceInternalMsg("metric %q: missing bucket state", plan.alias)
|
|
}
|
|
|
|
var raw any
|
|
isNull := false
|
|
if plan.spec.FieldID == CountAllFieldID {
|
|
// count(*) uses a synthetic always-present int64(1) source.
|
|
raw = int64(1)
|
|
} else {
|
|
v, null, err := c.readValueByFieldID(ref, plan.spec.FieldID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
raw = v
|
|
isNull = null
|
|
}
|
|
if isNull {
|
|
// Skip null inputs: matches internal/agg semantics.
|
|
continue
|
|
}
|
|
|
|
if err := plan.aggregate.UpdateState(targets, agg.NewFieldValue(raw)); err != nil {
|
|
return merr.WrapErrServiceInternalMsg("metric %q update failed: %v", plan.alias, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (c *SearchAggregationComputer) readValueByFieldID(ref reduce.RowRef, fieldID int64) (any, bool, error) {
|
|
if c.data == nil {
|
|
return nil, true, merr.WrapErrServiceInternalMsg("nil SearchResultData")
|
|
}
|
|
|
|
if fieldID == ScoreFieldID {
|
|
scores := c.data.GetScores()
|
|
if ref.RowIdx < 0 || ref.RowIdx >= int64(len(scores)) {
|
|
return nil, true, merr.WrapErrServiceInternalMsg("score index %d out of range", ref.RowIdx)
|
|
}
|
|
return scores[ref.RowIdx], false, nil
|
|
}
|
|
|
|
fd := c.fieldsByID[fieldID]
|
|
if fd == nil {
|
|
if c.ctx.IsGroupByField(fieldID) {
|
|
return nil, true, merr.WrapErrServiceInternalMsg("group-by field %d missing from group_by_field_values", fieldID)
|
|
}
|
|
return nil, true, merr.WrapErrServiceInternalMsg("field %d missing from fields_data", fieldID)
|
|
}
|
|
|
|
iter := typeutil.GetDataIterator(fd)
|
|
value := iter(int(ref.RowIdx))
|
|
if value == nil {
|
|
return nil, true, nil
|
|
}
|
|
return value, false, nil
|
|
}
|
|
|
|
type bucketState struct {
|
|
key []any
|
|
count int64
|
|
metricStates map[string][]*agg.FieldValue
|
|
rows []reduce.RowRef
|
|
}
|
|
|
|
func newBucketState(key []any, plans []metricPlan) *bucketState {
|
|
state := &bucketState{
|
|
key: key,
|
|
metricStates: make(map[string][]*agg.FieldValue, len(plans)),
|
|
}
|
|
for _, plan := range plans {
|
|
state.metricStates[plan.alias] = plan.aggregate.NewState()
|
|
}
|
|
return state
|
|
}
|
|
|
|
func finalizeMetrics(plans []metricPlan, states map[string][]*agg.FieldValue) (map[string]any, error) {
|
|
if len(plans) == 0 {
|
|
return nil, nil
|
|
}
|
|
metrics := make(map[string]any, len(plans))
|
|
for _, plan := range plans {
|
|
slots := states[plan.alias]
|
|
if plan.aggregate == nil {
|
|
return nil, merr.WrapErrServiceInternalMsg("metric %q: semantic aggregate is nil", plan.alias)
|
|
}
|
|
value, err := plan.aggregate.Terminate(slots)
|
|
if err != nil {
|
|
return nil, merr.WrapErrServiceInternalMsg("metric %q: %v", plan.alias, err)
|
|
}
|
|
metrics[plan.alias] = value
|
|
}
|
|
return metrics, nil
|
|
}
|
|
|
|
func compareNulls(a, b any, nullFirst bool) (int, bool) {
|
|
if a == nil && b == nil {
|
|
return 0, true
|
|
}
|
|
if a == nil {
|
|
if nullFirst {
|
|
return -1, true
|
|
}
|
|
return 1, true
|
|
}
|
|
if b == nil {
|
|
if nullFirst {
|
|
return 1, true
|
|
}
|
|
return -1, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
// compareValues keeps the legacy nil-first behavior for bucket ordering;
|
|
// top_hits sort applies SortCriterion.NullFirst via compareNulls before calling this.
|
|
func compareValues(a, b any) (int, error) {
|
|
if a == nil && b == nil {
|
|
return 0, nil
|
|
}
|
|
if a == nil {
|
|
return -1, nil
|
|
}
|
|
if b == nil {
|
|
return 1, nil
|
|
}
|
|
|
|
switch av := a.(type) {
|
|
case int:
|
|
bv, ok := b.(int)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
case int8:
|
|
bv, ok := b.(int8)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
case int16:
|
|
bv, ok := b.(int16)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
case int32:
|
|
bv, ok := b.(int32)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
case int64:
|
|
bv, ok := b.(int64)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
case uint:
|
|
bv, ok := b.(uint)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
case uint8:
|
|
bv, ok := b.(uint8)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
case uint16:
|
|
bv, ok := b.(uint16)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
case uint32:
|
|
bv, ok := b.(uint32)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
case uint64:
|
|
bv, ok := b.(uint64)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
case float32:
|
|
bv, ok := b.(float32)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareFloat64(float64(av), float64(bv)), nil
|
|
case float64:
|
|
bv, ok := b.(float64)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareFloat64(av, bv), nil
|
|
case bool:
|
|
bv, ok := b.(bool)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareBool(av, bv), nil
|
|
case string:
|
|
bv, ok := b.(string)
|
|
if !ok {
|
|
return 0, merr.WrapErrServiceInternalMsg("type mismatch: %T vs %T", a, b)
|
|
}
|
|
return compareOrdered(av, bv), nil
|
|
}
|
|
|
|
return 0, merr.WrapErrServiceInternalMsg("unsupported comparable types: %T and %T", a, b)
|
|
}
|
|
|
|
func compareOrdered[T ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~string](a, b T) int {
|
|
switch {
|
|
case a < b:
|
|
return -1
|
|
case a > b:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func compareFloat64(a, b float64) int {
|
|
switch {
|
|
case a < b:
|
|
return -1
|
|
case a > b:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func compareBool(a, b bool) int {
|
|
switch {
|
|
case !a && b:
|
|
return -1
|
|
case a && !b:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|