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
537 lines
16 KiB
Go
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)
|
|
}
|