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

537 lines
16 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 chain
import (
"fmt"
"sort"
"strings"
"github.com/apache/arrow/go/v17/arrow"
"github.com/apache/arrow/go/v17/arrow/array"
"github.com/milvus-io/milvus/internal/util/function/chain/types"
"github.com/milvus-io/milvus/pkg/v3/util/merr"
)
// GroupScorer defines how to compute the group score from individual scores.
type GroupScorer string
const (
// GroupScorerMax uses the maximum score in the group.
GroupScorerMax GroupScorer = "max"
// GroupScorerSum uses the sum of scores in the group.
GroupScorerSum GroupScorer = "sum"
// GroupScorerAvg uses the average of scores in the group.
GroupScorerAvg GroupScorer = "avg"
)
// GroupByOp groups rows by a field, keeps top N rows per group, and limits the number of groups.
// This operator is designed for grouping search scenarios.
//
// Parameters:
// - groupByField: the field to group by
// - groupSize: maximum rows per group (sorted by $score DESC)
// - limit: maximum number of groups to return
// - offset: number of groups to skip
// - groupScorer: how to compute group score ("max", "sum", "avg")
//
// The operator also adds a $group_score column containing the computed group score.
//
// Workflow example (groupByField="category", groupSize=2, limit=2, offset=0, scorer=max):
//
// Input:
//
// | row | $id | $score | category |
// |-----|-----|--------|----------|
// | 0 | a1 | 0.9 | cat |
// | 1 | a2 | 0.7 | dog |
// | 2 | a3 | 0.8 | cat |
// | 3 | a4 | 0.6 | cat |
// | 4 | a5 | 0.85 | dog |
// | 5 | a6 | 0.5 | bird |
//
// Step 1 - buildGroups: group rows by category
//
// cat: rowIndices=[0,2,3] scores=[0.9,0.8,0.6]
// dog: rowIndices=[1,4] scores=[0.7,0.85]
// bird: rowIndices=[5] scores=[0.5]
//
// Step 2 - sortAndLimitGroup: sort each group by score DESC, keep top groupSize=2
//
// cat: rowIndices=[0,2] scores=[0.9,0.8] (row 3 removed)
// dog: rowIndices=[4,1] scores=[0.85,0.7] (reordered)
// bird: rowIndices=[5] scores=[0.5]
//
// Step 3 - computeGroupScore: scorer=max, take the highest score per group
//
// cat: groupScore=0.9
// dog: groupScore=0.85
// bird: groupScore=0.5
//
// Step 4 - sort groups by groupScore DESC: cat(0.9) > dog(0.85) > bird(0.5)
//
// Step 5 - apply offset=0, limit=2: select cat and dog, bird is dropped
//
// Step 6 - expand selected groups into output with $group_score column:
//
// | $id | $score | category | $group_score |
// |-----|--------|----------|--------------|
// | a1 | 0.9 | cat | 0.9 |
// | a3 | 0.8 | cat | 0.9 |
// | a5 | 0.85 | dog | 0.85 |
// | a2 | 0.7 | dog | 0.85 |
type GroupByOp struct {
BaseOp
groupByField string
groupSize int64
limit int64
offset int64
groupScorer GroupScorer
sortDescending bool // true (default) means larger score = better match
}
// NewGroupByOp creates a new GroupByOp with default max scorer.
func NewGroupByOp(groupByField string, groupSize, limit, offset int64) *GroupByOp {
return NewGroupByOpWithScorer(groupByField, groupSize, limit, offset, GroupScorerMax)
}
// SetSortDescending configures the sort direction for both within-group and
// cross-group ordering. true (the default) treats larger scores as better
// matches; false treats smaller scores as better matches — required for
// distance metrics (L2, HAMMING, JACCARD, ...) when scores are not normalized
// (e.g., weighted reranker on raw L2 distances).
//
// When sortDescending=false:
// - within-group trim keeps the smallest groupSize rows (best matches)
// - the Max scorer still picks the "best representative" (which is now the
// smallest score after ASC sort)
// - groups are ordered ASC by group score
//
// Returns the receiver to support builder-style chaining.
func (o *GroupByOp) SetSortDescending(sortDescending bool) *GroupByOp {
o.sortDescending = sortDescending
return o
}
// ValidateGroupScorer checks if the scorer is valid.
func ValidateGroupScorer(scorer GroupScorer) error {
switch scorer {
case GroupScorerMax, GroupScorerSum, GroupScorerAvg:
return nil
default:
return merr.WrapErrParameterInvalidMsg("invalid group scorer %q, must be max/sum/avg", scorer)
}
}
// NewGroupByOpWithScorer creates a new GroupByOp with specified scorer.
// Defaults to descending sort direction (larger score = better match);
// callers that need ASC ordering should chain SetSortDescending(false).
func NewGroupByOpWithScorer(groupByField string, groupSize, limit, offset int64, scorer GroupScorer) *GroupByOp {
return &GroupByOp{
BaseOp: BaseOp{
inputs: []string{groupByField, types.ScoreFieldName, types.IDFieldName},
outputs: []string{GroupScoreFieldName},
},
groupByField: groupByField,
groupSize: groupSize,
limit: limit,
offset: offset,
groupScorer: scorer,
sortDescending: true,
}
}
// GroupScoreFieldName is the name of the group score column added by GroupByOp.
const GroupScoreFieldName = "$group_score"
func (o *GroupByOp) Name() string { return "GroupBy" }
func (o *GroupByOp) String() string {
return fmt.Sprintf("GroupBy(%s, groupSize=%d, limit=%d, offset=%d, scorer=%s)",
o.groupByField, o.groupSize, o.limit, o.offset, o.groupScorer)
}
func (o *GroupByOp) Execute(ctx *types.FuncContext, input *DataFrame) (*DataFrame, error) {
// Validate columns exist
groupCol := input.Column(o.groupByField)
if groupCol == nil {
return nil, merr.WrapErrServiceInternalMsg("group_by_op: column %q not found", o.groupByField)
}
scoreCol := input.Column(types.ScoreFieldName)
if scoreCol == nil {
return nil, merr.WrapErrServiceInternalMsg("group_by_op: column %q not found", types.ScoreFieldName)
}
numChunks := input.NumChunks()
colNames := input.ColumnNames()
// Prepare collectors
collector := NewChunkCollector(colNames, numChunks)
defer collector.Release()
// Prepare group score builder
groupScoreChunks := make([]arrow.Array, numChunks)
newChunkSizes := make([]int64, numChunks)
// Release groupScoreChunks on error
success := false
defer func() {
if !success {
for _, chunk := range groupScoreChunks {
if chunk != nil {
chunk.Release()
}
}
}
}()
// Process each chunk independently
for chunkIdx := 0; chunkIdx < numChunks; chunkIdx++ {
result, err := o.processChunk(ctx, input, chunkIdx)
if err != nil {
return nil, err
}
newChunkSizes[chunkIdx] = int64(len(result.indices))
groupScoreChunks[chunkIdx] = result.groupScores
// Reorder existing columns by indices
for _, colName := range colNames {
col := input.Column(colName)
dataChunk := col.Chunk(chunkIdx)
reordered, err := dispatchPickByIndices(ctx.Pool(), dataChunk, result.indices)
if err != nil {
return nil, merr.WrapErrServiceInternalMsg("group_by_op: reorder column %s: %v", colName, err)
}
collector.Set(colName, chunkIdx, reordered)
}
}
// Build output DataFrame
builder := NewDataFrameBuilder()
defer builder.Release()
builder.SetChunkSizes(newChunkSizes)
// Add existing columns
for _, colName := range colNames {
if err := builder.AddColumnFromChunks(colName, collector.Consume(colName)); err != nil {
return nil, err
}
builder.CopyFieldMetadata(input, colName)
}
// Add group score column
if err := builder.AddColumnFromChunks(GroupScoreFieldName, groupScoreChunks); err != nil {
return nil, err
}
success = true
return builder.Build(), nil
}
// chunkResult holds the result of processing a single chunk.
type chunkResult struct {
indices []int // Row indices in output order
groupScores arrow.Array // Group score for each output row
}
// processChunk processes a single chunk and returns the result.
func (o *GroupByOp) processChunk(ctx *types.FuncContext, input *DataFrame, chunkIdx int) (*chunkResult, error) {
groupCol := input.Column(o.groupByField)
scoreCol := input.Column(types.ScoreFieldName)
idCol := input.Column(types.IDFieldName)
if idCol == nil {
return nil, merr.WrapErrServiceInternalMsg("group_by_op: column %q not found", types.IDFieldName)
}
groupChunk := groupCol.Chunk(chunkIdx)
scoreChunk, ok := scoreCol.Chunk(chunkIdx).(*array.Float32)
if !ok {
return nil, merr.WrapErrServiceInternalMsg("group_by_op: score column chunk %d is not Float32", chunkIdx)
}
idChunk := idCol.Chunk(chunkIdx)
chunkLen := groupChunk.Len()
// Step 1: Build groups
groups := o.buildGroups(groupChunk, scoreChunk, idChunk, chunkLen)
// Step 2: Sort rows within each group by score DESC, keep top groupSize
for _, g := range groups {
o.sortAndLimitGroup(g)
}
// Step 3: Compute group scores based on scorer mode
for _, g := range groups {
o.computeGroupScore(g)
}
// Step 4: Sort groups by group score (DESC if sortDescending, ASC otherwise),
// with tiebreaking:
// - same groupScore: larger group first
// - same groupScore and size: smaller first id first
sort.SliceStable(groups, func(i, j int) bool {
if groups[i].groupScore != groups[j].groupScore {
if o.sortDescending {
return groups[i].groupScore > groups[j].groupScore
}
return groups[i].groupScore < groups[j].groupScore
}
if len(groups[i].rowIndices) != len(groups[j].rowIndices) {
return len(groups[i].rowIndices) > len(groups[j].rowIndices)
}
return compareValues(groups[i].ids[0], groups[j].ids[0]) < 0
})
// Step 5: Apply offset and limit on groups
startGroup := int(o.offset)
endGroup := int(o.offset + o.limit)
if startGroup > len(groups) {
startGroup = len(groups)
}
if endGroup > len(groups) {
endGroup = len(groups)
}
selectedGroups := groups[startGroup:endGroup]
// Step 6: Build output indices and group scores
indices := make([]int, 0)
groupScores := make([]float32, 0)
for _, g := range selectedGroups {
for _, idx := range g.rowIndices {
indices = append(indices, idx)
groupScores = append(groupScores, g.groupScore)
}
}
// Build group score array
groupScoreBuilder := array.NewFloat32Builder(ctx.Pool())
defer groupScoreBuilder.Release()
groupScoreBuilder.AppendValues(groupScores, nil)
return &chunkResult{
indices: indices,
groupScores: groupScoreBuilder.NewArray(),
}, nil
}
// group represents a group of rows.
type group struct {
key any // Group key value
rowIndices []int // Row indices belonging to this group
groupScore float32 // Computed group score
scores []float32 // Individual scores for sum/avg computation
ids []any // ID values for tiebreaking
}
// buildGroups builds groups from the chunk.
func (o *GroupByOp) buildGroups(groupChunk arrow.Array, scoreChunk *array.Float32, idChunk arrow.Array, chunkLen int) []*group {
groupMap := make(map[any]*group)
groupOrder := make([]any, 0) // Maintain appearance order
for i := 0; i < chunkLen; i++ {
key := getArrayValue(groupChunk, i)
score := scoreChunk.Value(i)
id := getArrayValue(idChunk, i)
if g, exists := groupMap[key]; exists {
g.rowIndices = append(g.rowIndices, i)
g.scores = append(g.scores, score)
g.ids = append(g.ids, id)
} else {
g := &group{
key: key,
rowIndices: []int{i},
scores: []float32{score},
ids: []any{id},
}
groupMap[key] = g
groupOrder = append(groupOrder, key)
}
}
// Return groups in appearance order
result := make([]*group, 0, len(groupOrder))
for _, key := range groupOrder {
result = append(result, groupMap[key])
}
return result
}
// sortAndLimitGroup sorts rows within a group by score in the configured
// direction (DESC by default, ASC when sortDescending=false), with id ASC
// tiebreaking, then keeps top groupSize.
func (o *GroupByOp) sortAndLimitGroup(g *group) {
n := len(g.rowIndices)
indices := make([]int, n)
for i := 0; i < n; i++ {
indices[i] = i
}
sort.SliceStable(indices, func(i, j int) bool {
si, sj := g.scores[indices[i]], g.scores[indices[j]]
if si != sj {
if o.sortDescending {
return si > sj
}
return si < sj
}
return compareValues(g.ids[indices[i]], g.ids[indices[j]]) < 0
})
// Reorder rowIndices, scores and ids
newRowIndices := make([]int, n)
newScores := make([]float32, n)
newIDs := make([]any, n)
for i, idx := range indices {
newRowIndices[i] = g.rowIndices[idx]
newScores[i] = g.scores[idx]
newIDs[i] = g.ids[idx]
}
g.rowIndices = newRowIndices
g.scores = newScores
g.ids = newIDs
// Keep top groupSize
if int64(len(g.rowIndices)) > o.groupSize {
g.rowIndices = g.rowIndices[:o.groupSize]
g.scores = g.scores[:o.groupSize]
g.ids = g.ids[:o.groupSize]
}
}
// computeGroupScore computes the group score based on the scorer mode.
func (o *GroupByOp) computeGroupScore(g *group) {
if len(g.scores) == 0 {
g.groupScore = 0
return
}
switch o.groupScorer {
case GroupScorerSum:
var sum float32
for _, s := range g.scores {
sum += s
}
g.groupScore = sum
case GroupScorerAvg:
var sum float32
for _, s := range g.scores {
sum += s
}
g.groupScore = sum / float32(len(g.scores))
case GroupScorerMax:
// scores[0] is the best representative under the current sort
// direction: largest score in DESC mode (default), smallest score in
// ASC mode. sortAndLimitGroup is responsible for ordering the slice
// in the right direction before this runs.
g.groupScore = g.scores[0]
default:
// Should not reach here if scorer is validated upfront
g.groupScore = g.scores[0]
}
}
// NewGroupByOpFromRepr creates a GroupByOp from an OperatorRepr.
func NewGroupByOpFromRepr(repr *OperatorRepr) (Operator, error) {
reader := types.NewParamReader("group_by_op", repr.Params)
field, err := reader.String("field", true)
if err != nil {
return nil, err
}
if field == "" {
return nil, merr.WrapErrParameterMissingMsg("group_by_op: field is required")
}
groupSize, err := reader.Int64("group_size", true, 0)
if err != nil {
return nil, err
}
if groupSize <= 0 {
return nil, merr.WrapErrParameterInvalidMsg("group_by_op: group_size must be positive")
}
limit, err := reader.Int64("limit", true, 0)
if err != nil {
return nil, err
}
if limit <= 0 {
return nil, merr.WrapErrParameterInvalidMsg("group_by_op: limit must be positive")
}
offset, err := reader.Int64("offset", false, 0)
if err != nil {
return nil, err
}
if offset < 0 {
return nil, merr.WrapErrParameterInvalidMsg("group_by_op: offset must be non-negative")
}
scorer := GroupScorerMax
scorerStr, err := reader.String("scorer", false)
if err != nil {
return nil, err
}
if scorerStr != "" {
scorer = GroupScorer(scorerStr)
if err := ValidateGroupScorer(scorer); err != nil {
return nil, merr.Wrap(err, "group_by_op")
}
}
return NewGroupByOpWithScorer(field, groupSize, limit, offset, scorer), nil
}
// compareValues compares two values for tiebreaking.
// Supports int64 and string (the two possible PK types in Milvus).
// Returns -1 if a < b, 0 if a == b, 1 if a > b.
func compareValues(a, b any) int {
switch va := a.(type) {
case int64:
vb, ok := b.(int64)
if !ok {
return 0
}
if va < vb {
return -1
}
if va > vb {
return 1
}
return 0
case string:
vb, ok := b.(string)
if !ok {
return 0
}
return strings.Compare(va, vb)
default:
return 0
}
}
func init() {
MustRegisterOperator(types.OpTypeGroupBy, NewGroupByOpFromRepr)
}