Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 12:31:17 +08:00

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
}
}