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
746 lines
25 KiB
Go
746 lines
25 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 (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/apache/arrow/go/v17/arrow/memory"
|
|
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/commonpb"
|
|
"github.com/milvus-io/milvus-proto/go-api/v3/schemapb"
|
|
"github.com/milvus-io/milvus/internal/util/function/chain/expr"
|
|
"github.com/milvus-io/milvus/internal/util/function/chain/types"
|
|
"github.com/milvus-io/milvus/internal/util/function/models"
|
|
"github.com/milvus-io/milvus/internal/util/function/rerank"
|
|
"github.com/milvus-io/milvus/pkg/v3/util/merr"
|
|
)
|
|
|
|
// =============================================================================
|
|
// Constants
|
|
// =============================================================================
|
|
|
|
const (
|
|
// Reranker names
|
|
DecayRerankerName = "decay"
|
|
ModelRerankerName = "model"
|
|
RRFRerankerName = "rrf"
|
|
WeightedRerankerName = "weighted"
|
|
|
|
// Parameter keys
|
|
rerankerKey = "reranker"
|
|
rrfKKey = "k"
|
|
weightsKey = "weights"
|
|
normScoreKey = "norm_score"
|
|
scoreModeKey = "score_mode"
|
|
functionKey = "function"
|
|
originKey = "origin"
|
|
scaleKey = "scale"
|
|
offsetKey = "offset"
|
|
decayKey = "decay"
|
|
|
|
// Legacy parameter keys
|
|
legacyRankTypeKey = "strategy"
|
|
legacyRankParamsKey = "params"
|
|
|
|
// Model parameter keys
|
|
queryKeyName = "queries"
|
|
|
|
// Default values
|
|
defaultRRFK = 60.0
|
|
defaultDecay = 0.5
|
|
defaultScoreMode = "max"
|
|
)
|
|
|
|
// =============================================================================
|
|
// SearchParams
|
|
// =============================================================================
|
|
|
|
// SearchParams contains search parameters for reranking.
|
|
type SearchParams struct {
|
|
Nq int64
|
|
Limit int64
|
|
Offset int64
|
|
RoundDecimal int64
|
|
|
|
// Grouping parameters
|
|
GroupByField string // Field to group by (empty means no grouping)
|
|
GroupSize int64 // Maximum rows per group
|
|
GroupScorer GroupScorer // How to compute group score ("max", "sum", "avg")
|
|
|
|
// Model reranker extra info (can be nil for non-model rerankers)
|
|
ModelExtraInfo *models.ModelExtraInfo
|
|
}
|
|
|
|
// NewSearchParams creates a new SearchParams.
|
|
func NewSearchParams(nq, limit, offset, roundDecimal int64) *SearchParams {
|
|
return &SearchParams{
|
|
Nq: nq,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
RoundDecimal: roundDecimal,
|
|
}
|
|
}
|
|
|
|
// NewSearchParamsWithGrouping creates a new SearchParams with grouping support.
|
|
func NewSearchParamsWithGrouping(nq, limit, offset, roundDecimal int64, groupByField string, groupSize int64) *SearchParams {
|
|
return NewSearchParamsWithGroupingAndScorer(nq, limit, offset, roundDecimal, groupByField, groupSize, GroupScorerMax)
|
|
}
|
|
|
|
// NewSearchParamsWithGroupingAndScorer creates a new SearchParams with grouping and scorer support.
|
|
func NewSearchParamsWithGroupingAndScorer(nq, limit, offset, roundDecimal int64, groupByField string, groupSize int64, scorer GroupScorer) *SearchParams {
|
|
return &SearchParams{
|
|
Nq: nq,
|
|
Limit: limit,
|
|
Offset: offset,
|
|
RoundDecimal: roundDecimal,
|
|
GroupByField: groupByField,
|
|
GroupSize: groupSize,
|
|
GroupScorer: scorer,
|
|
}
|
|
}
|
|
|
|
// HasGrouping returns true if grouping is enabled.
|
|
func (sp *SearchParams) HasGrouping() bool {
|
|
return sp.GroupByField != "" && sp.GroupSize > 0
|
|
}
|
|
|
|
// =============================================================================
|
|
// BuildRerankChain
|
|
// =============================================================================
|
|
|
|
// BuildRerankChain builds a FuncChain for reranking from FunctionScore schema.
|
|
func BuildRerankChain(
|
|
collSchema *schemapb.CollectionSchema,
|
|
funcScoreSchema *schemapb.FunctionScore,
|
|
searchMetrics []string,
|
|
searchParams *SearchParams,
|
|
alloc memory.Allocator,
|
|
) (*FuncChain, error) {
|
|
if funcScoreSchema == nil || len(funcScoreSchema.Functions) == 0 {
|
|
return nil, merr.WrapErrParameterInvalidMsg("rerank_builder: no rerank functions specified")
|
|
}
|
|
|
|
// Filter out boost functions — boost is pushed down to QueryNode, proxy doesn't handle it
|
|
var proxyFunctions []*schemapb.FunctionSchema
|
|
for _, f := range funcScoreSchema.Functions {
|
|
if rerank.GetRerankName(f) != rerank.BoostName {
|
|
proxyFunctions = append(proxyFunctions, f)
|
|
}
|
|
}
|
|
|
|
// All functions are boost, proxy has nothing to rerank
|
|
if len(proxyFunctions) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
// Currently only support single proxy rerank function
|
|
if len(proxyFunctions) > 1 {
|
|
return nil, merr.WrapErrParameterInvalidMsg("rerank_builder: currently only supports one rerank function")
|
|
}
|
|
|
|
return buildRerankChainInternal(collSchema, proxyFunctions[0], searchMetrics, searchParams, alloc)
|
|
}
|
|
|
|
// BuildRerankChainWithLegacy builds a FuncChain from legacy rank parameters.
|
|
func BuildRerankChainWithLegacy(
|
|
collSchema *schemapb.CollectionSchema,
|
|
rankParams []*commonpb.KeyValuePair,
|
|
searchMetrics []string,
|
|
searchParams *SearchParams,
|
|
alloc memory.Allocator,
|
|
) (*FuncChain, error) {
|
|
funcSchema, err := convertLegacyParams(rankParams)
|
|
if err != nil {
|
|
return nil, merr.WrapErrParameterInvalidMsg("rerank_builder: %v", err)
|
|
}
|
|
|
|
return buildRerankChainInternal(collSchema, funcSchema, searchMetrics, searchParams, alloc)
|
|
}
|
|
|
|
// convertLegacyParams converts legacy rank params to FunctionSchema.
|
|
func convertLegacyParams(rankParams []*commonpb.KeyValuePair) (*schemapb.FunctionSchema, error) {
|
|
var params map[string]interface{}
|
|
rankTypeStr := "rrf"
|
|
|
|
for _, kv := range rankParams {
|
|
switch strings.ToLower(kv.Key) {
|
|
case legacyRankTypeKey:
|
|
rankTypeStr = strings.ToLower(kv.Value)
|
|
case legacyRankParamsKey:
|
|
if err := json.Unmarshal([]byte(kv.Value), ¶ms); err != nil {
|
|
return nil, merr.WrapErrParameterInvalidMsg("parse rerank params failed: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
fSchema := &schemapb.FunctionSchema{
|
|
Type: schemapb.FunctionType_Rerank,
|
|
InputFieldNames: []string{},
|
|
OutputFieldNames: []string{},
|
|
Params: []*commonpb.KeyValuePair{},
|
|
}
|
|
|
|
switch rankTypeStr {
|
|
case "rrf":
|
|
fSchema.Params = append(fSchema.Params, &commonpb.KeyValuePair{Key: rerankerKey, Value: RRFRerankerName})
|
|
if v, ok := params[rrfKKey]; ok {
|
|
if k, ok := v.(float64); ok {
|
|
fSchema.Params = append(fSchema.Params, &commonpb.KeyValuePair{Key: rrfKKey, Value: strconv.FormatFloat(k, 'f', -1, 64)})
|
|
} else {
|
|
return nil, merr.WrapErrParameterInvalidMsg("the type of rank param k should be float")
|
|
}
|
|
}
|
|
case "weighted":
|
|
fSchema.Params = append(fSchema.Params, &commonpb.KeyValuePair{Key: rerankerKey, Value: WeightedRerankerName})
|
|
if v, ok := params[weightsKey]; ok {
|
|
if d, err := json.Marshal(v); err != nil {
|
|
return nil, merr.WrapErrParameterInvalidMsg("the weights param should be an array")
|
|
} else {
|
|
fSchema.Params = append(fSchema.Params, &commonpb.KeyValuePair{Key: weightsKey, Value: string(d)})
|
|
}
|
|
}
|
|
if normScore, ok := params[normScoreKey]; ok {
|
|
switch ns := normScore.(type) {
|
|
case bool:
|
|
fSchema.Params = append(fSchema.Params, &commonpb.KeyValuePair{Key: normScoreKey, Value: strconv.FormatBool(ns)})
|
|
case string:
|
|
if parsed, err := strconv.ParseBool(ns); err != nil {
|
|
return nil, merr.WrapErrParameterInvalidMsg("the type of rank param norm_score should be bool")
|
|
} else {
|
|
fSchema.Params = append(fSchema.Params, &commonpb.KeyValuePair{Key: normScoreKey, Value: strconv.FormatBool(parsed)})
|
|
}
|
|
default:
|
|
return nil, merr.WrapErrParameterInvalidMsg("the type of rank param norm_score should be bool")
|
|
}
|
|
}
|
|
default:
|
|
return nil, merr.WrapErrParameterInvalidMsg("unsupported rank type %s", rankTypeStr)
|
|
}
|
|
|
|
return fSchema, nil
|
|
}
|
|
|
|
// buildRerankChainInternal builds a FuncChain from FunctionSchema.
|
|
//
|
|
// It produces 4 kinds of chains depending on the reranker, sharing a common tail:
|
|
//
|
|
// 1. RRF:
|
|
// Merge(RRF) → Sort/GroupBy → [RoundDecimal] → Select
|
|
//
|
|
// 2. Weighted:
|
|
// Merge(Weighted) → Sort/GroupBy → [RoundDecimal] → Select
|
|
//
|
|
// 3. Decay:
|
|
// Merge(Max|Sum|Avg) → Map(DecayExpr→_decay_score) → Map(NumCombine: $score*_decay_score→$score) → Sort/GroupBy → [RoundDecimal] → Select
|
|
//
|
|
// 4. Model:
|
|
// Merge(Max) → Map(RerankModelExpr) → Sort/GroupBy → [RoundDecimal] → Select
|
|
//
|
|
// Common tail behavior:
|
|
// - Without grouping: Sort($score, DESC) → Limit(limit, offset)
|
|
// - With grouping: GroupBy(field, groupSize, limit, offset, scorer)
|
|
// - RoundDecimal >= 0: Map(RoundDecimalExpr) rounds $score after ordering
|
|
// - Select: keeps only $id, $score (plus groupByField, $group_score if grouping)
|
|
func buildRerankChainInternal(
|
|
collSchema *schemapb.CollectionSchema,
|
|
funcSchema *schemapb.FunctionSchema,
|
|
searchMetrics []string,
|
|
searchParams *SearchParams,
|
|
alloc memory.Allocator,
|
|
) (*FuncChain, error) {
|
|
rerankerName := rerank.GetRerankName(funcSchema)
|
|
if rerankerName == "" {
|
|
return nil, merr.WrapErrParameterMissingMsg("rerank_builder: reranker name not specified")
|
|
}
|
|
|
|
if alloc == nil {
|
|
alloc = memory.DefaultAllocator
|
|
}
|
|
|
|
fc := NewFuncChainWithAllocator(alloc).
|
|
SetStage(types.StageL2Rerank).
|
|
SetName("rerank_chain")
|
|
|
|
sortDescending := true // default: larger score = better match
|
|
|
|
switch rerankerName {
|
|
case RRFRerankerName:
|
|
if err := buildRRFChain(fc, funcSchema, searchMetrics); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case WeightedRerankerName:
|
|
var err error
|
|
sortDescending, err = buildWeightedChain(fc, funcSchema, searchMetrics)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case DecayRerankerName:
|
|
if err := buildDecayChain(fc, collSchema, funcSchema, searchMetrics); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
case ModelRerankerName:
|
|
if err := buildModelChain(fc, collSchema, funcSchema, searchMetrics, searchParams); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
default:
|
|
return nil, merr.WrapErrParameterInvalidMsg("rerank_builder: unsupported reranker %s", rerankerName)
|
|
}
|
|
|
|
// Add grouping or sort+limit
|
|
if searchParams.HasGrouping() {
|
|
// Use GroupBy for grouping search
|
|
scorer := searchParams.GroupScorer
|
|
if scorer == "" {
|
|
scorer = GroupScorerMax
|
|
}
|
|
// Construct the GroupByOp directly so we can propagate the sort
|
|
// direction from the merge stage. fc.GroupByWithScorer would default
|
|
// to DESC, which is wrong for distance metrics + no normalization
|
|
// (e.g., weighted reranker on raw L2 distances).
|
|
groupByOp := NewGroupByOpWithScorer(
|
|
searchParams.GroupByField,
|
|
searchParams.GroupSize,
|
|
searchParams.Limit,
|
|
searchParams.Offset,
|
|
scorer,
|
|
).SetSortDescending(sortDescending)
|
|
fc.Add(groupByOp)
|
|
} else {
|
|
// Non-grouping: sort by score and apply limit
|
|
fc.Sort(types.ScoreFieldName, sortDescending, types.IDFieldName)
|
|
if searchParams.Limit > 0 {
|
|
fc.LimitWithOffset(searchParams.Limit, searchParams.Offset)
|
|
}
|
|
}
|
|
|
|
// Apply score rounding after sort/limit (rounding should not affect ordering)
|
|
if searchParams.RoundDecimal >= 0 {
|
|
roundExpr, err := expr.NewRoundDecimalExpr(searchParams.RoundDecimal)
|
|
if err != nil {
|
|
return nil, merr.WrapErrParameterInvalidMsg("rerank_builder: %v", err)
|
|
}
|
|
fc.Map(roundExpr,
|
|
[]string{types.ScoreFieldName},
|
|
[]string{types.ScoreFieldName})
|
|
}
|
|
|
|
// Only keep $id and $score (plus group-by and $group_score if present) in the output.
|
|
// Other field columns are no longer needed — downstream pipeline stages
|
|
// (organize/requery) re-fetch fields from segments.
|
|
selectCols := []string{types.IDFieldName, types.ScoreFieldName}
|
|
if searchParams.HasGrouping() {
|
|
selectCols = append(selectCols, searchParams.GroupByField, GroupScoreFieldName)
|
|
}
|
|
fc.Select(selectCols...)
|
|
|
|
return fc, nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// RRF Builder
|
|
// =============================================================================
|
|
|
|
func buildRRFChain(fc *FuncChain, funcSchema *schemapb.FunctionSchema, searchMetrics []string) error {
|
|
rrfK, err := parseRRFK(funcSchema)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// RRF scores are computed purely from rank position, not from original scores,
|
|
// so metricTypes and normalize are not needed.
|
|
fc.Merge(MergeStrategyRRF,
|
|
WithRRFK(rrfK))
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseRRFK(funcSchema *schemapb.FunctionSchema) (float64, error) {
|
|
for _, param := range funcSchema.Params {
|
|
if strings.ToLower(param.Key) == rrfKKey {
|
|
k, err := strconv.ParseFloat(param.Value, 64)
|
|
if err != nil {
|
|
return 0, merr.WrapErrParameterInvalidMsg("%s is not a number", param.Value)
|
|
}
|
|
if k <= 0 || k >= 16384 {
|
|
return 0, merr.WrapErrParameterInvalidMsg("The rank params k should be in range (0, %d)", 16384)
|
|
}
|
|
return k, nil
|
|
}
|
|
}
|
|
return defaultRRFK, nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// Weighted Builder
|
|
// =============================================================================
|
|
|
|
func buildWeightedChain(fc *FuncChain, funcSchema *schemapb.FunctionSchema, searchMetrics []string) (sortDescending bool, err error) {
|
|
weights, normalize, err := parseWeightedParams(funcSchema)
|
|
if err != nil {
|
|
return true, err
|
|
}
|
|
|
|
if len(weights) == 0 {
|
|
return true, merr.WrapErrParameterInvalidMsg("rerank_builder: weighted reranker requires weights parameter")
|
|
}
|
|
|
|
if len(weights) != len(searchMetrics) {
|
|
return true, merr.WrapErrParameterInvalid(fmt.Sprint(len(searchMetrics)), fmt.Sprint(len(weights)), "the length of weights param mismatch with ann search requests")
|
|
}
|
|
|
|
mergeOp := NewMergeOp(MergeStrategyWeighted,
|
|
WithWeights(weights),
|
|
WithMetricTypes(searchMetrics),
|
|
WithNormalize(normalize))
|
|
|
|
fc.Add(mergeOp)
|
|
|
|
return mergeOp.SortDescending(), nil
|
|
}
|
|
|
|
func parseWeightedParams(funcSchema *schemapb.FunctionSchema) ([]float64, bool, error) {
|
|
var weights []float64
|
|
normalize := false
|
|
|
|
for _, param := range funcSchema.Params {
|
|
switch strings.ToLower(param.Key) {
|
|
case weightsKey:
|
|
var ws []float64
|
|
if err := json.Unmarshal([]byte(param.Value), &ws); err != nil {
|
|
return nil, false, merr.WrapErrParameterInvalidMsg("failed to parse weights: %v", err)
|
|
}
|
|
for _, w := range ws {
|
|
if w < 0 || w > 1 {
|
|
return nil, false, merr.WrapErrParameterInvalidMsg("rank param weight should be in range [0, 1]")
|
|
}
|
|
}
|
|
weights = ws
|
|
case normScoreKey:
|
|
if ns, err := strconv.ParseBool(param.Value); err != nil {
|
|
return nil, false, merr.WrapErrParameterInvalidMsg("failed to parse norm_score: %v", err)
|
|
} else {
|
|
normalize = ns
|
|
}
|
|
}
|
|
}
|
|
|
|
return weights, normalize, nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// Decay Builder
|
|
// =============================================================================
|
|
|
|
func buildDecayChain(fc *FuncChain, collSchema *schemapb.CollectionSchema, funcSchema *schemapb.FunctionSchema, searchMetrics []string) error {
|
|
// Parse decay parameters
|
|
strategy, normalize, decayParams, err := parseDecayParams(funcSchema)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Get input field name
|
|
if len(funcSchema.InputFieldNames) != 1 {
|
|
return merr.WrapErrParameterInvalidMsg("rerank_builder: decay reranker requires exactly 1 input field, got %d", len(funcSchema.InputFieldNames))
|
|
}
|
|
inputField := funcSchema.InputFieldNames[0]
|
|
|
|
// Validate input field exists in collection schema
|
|
if err := validateInputField(collSchema, inputField); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Add MergeOp with score_mode strategy.
|
|
//
|
|
// Decay multiplies $score by a decay factor in [0, 1] and assumes
|
|
// "higher = more relevant" semantics. WithForceDescending(true) ensures
|
|
// distance metrics like L2 are flipped via 1 - 2·atan(d)/π before the
|
|
// multiplication, even when norm_score=false. Without this, raw L2
|
|
// distances would be combined with the decay factor and the worst
|
|
// vector match would rank first after the outer DESC sort.
|
|
//
|
|
// WithNormalize(true) (full range normalization) is still honored when
|
|
// the user opts in via norm_score=true; the two flags compose without
|
|
// overlap because the normalize branch returns first.
|
|
fc.Merge(strategy,
|
|
WithMetricTypes(searchMetrics),
|
|
WithNormalize(normalize),
|
|
WithForceDescending(true))
|
|
|
|
// DecayExpr only computes the decay factor (0~1) into an intermediate column,
|
|
// then NumCombineExpr multiplies it with $score.
|
|
decayScoreCol := "_decay_score"
|
|
|
|
decayExpr, err := expr.NewDecayExpr(
|
|
decayParams.function,
|
|
decayParams.origin,
|
|
decayParams.scale,
|
|
decayParams.offset,
|
|
decayParams.decay,
|
|
)
|
|
if err != nil {
|
|
return merr.WrapErrParameterInvalidMsg("rerank_builder: %v", err)
|
|
}
|
|
|
|
fc.Map(decayExpr,
|
|
[]string{inputField},
|
|
[]string{decayScoreCol})
|
|
|
|
combineExpr, err := expr.NewNumCombineExpr(expr.ModeMultiply, nil, expr.WithNullPolicy(expr.NumCombineNullAsZero))
|
|
if err != nil {
|
|
return merr.WrapErrParameterInvalidMsg("rerank_builder: %v", err)
|
|
}
|
|
|
|
fc.Map(combineExpr,
|
|
[]string{types.ScoreFieldName, decayScoreCol},
|
|
[]string{types.ScoreFieldName})
|
|
|
|
return nil
|
|
}
|
|
|
|
type decayParams struct {
|
|
function string
|
|
origin float64
|
|
scale float64
|
|
offset float64
|
|
decay float64
|
|
}
|
|
|
|
func parseDecayParams(funcSchema *schemapb.FunctionSchema) (MergeStrategy, bool, *decayParams, error) {
|
|
params := &decayParams{
|
|
offset: 0,
|
|
decay: defaultDecay,
|
|
}
|
|
scoreMode := defaultScoreMode
|
|
normalize := false
|
|
|
|
originSet := false
|
|
scaleSet := false
|
|
functionSet := false
|
|
|
|
for _, param := range funcSchema.Params {
|
|
switch strings.ToLower(param.Key) {
|
|
case functionKey:
|
|
params.function = strings.ToLower(param.Value)
|
|
functionSet = true
|
|
case originKey:
|
|
if v, err := strconv.ParseFloat(param.Value, 64); err == nil {
|
|
params.origin = v
|
|
originSet = true
|
|
} else {
|
|
return "", false, nil, merr.WrapErrParameterInvalidMsg("decay param origin: %s is not a number", param.Value)
|
|
}
|
|
case scaleKey:
|
|
if v, err := strconv.ParseFloat(param.Value, 64); err == nil {
|
|
params.scale = v
|
|
scaleSet = true
|
|
} else {
|
|
return "", false, nil, merr.WrapErrParameterInvalidMsg("decay param scale: %s is not a number", param.Value)
|
|
}
|
|
case offsetKey:
|
|
if v, err := strconv.ParseFloat(param.Value, 64); err == nil {
|
|
params.offset = v
|
|
} else {
|
|
return "", false, nil, merr.WrapErrParameterInvalidMsg("decay param offset: %s is not a number", param.Value)
|
|
}
|
|
case decayKey:
|
|
if v, err := strconv.ParseFloat(param.Value, 64); err == nil {
|
|
params.decay = v
|
|
} else {
|
|
return "", false, nil, merr.WrapErrParameterInvalidMsg("decay param decay: %s is not a number", param.Value)
|
|
}
|
|
case normScoreKey:
|
|
if ns, err := strconv.ParseBool(param.Value); err == nil {
|
|
normalize = ns
|
|
}
|
|
case scoreModeKey:
|
|
scoreMode = strings.ToLower(param.Value)
|
|
}
|
|
}
|
|
|
|
if !functionSet {
|
|
return "", false, nil, merr.WrapErrParameterMissingMsg("decay function not specified")
|
|
}
|
|
if !originSet {
|
|
return "", false, nil, merr.WrapErrParameterMissingMsg("decay origin not specified")
|
|
}
|
|
if !scaleSet {
|
|
return "", false, nil, merr.WrapErrParameterMissingMsg("decay scale not specified")
|
|
}
|
|
|
|
// Convert score_mode to MergeStrategy
|
|
var strategy MergeStrategy
|
|
switch scoreMode {
|
|
case "max":
|
|
strategy = MergeStrategyMax
|
|
case "sum":
|
|
strategy = MergeStrategySum
|
|
case "avg":
|
|
strategy = MergeStrategyAvg
|
|
default:
|
|
return "", false, nil, merr.WrapErrParameterInvalidMsg("unsupported score_mode: %s, only supports [max, sum, avg]", scoreMode)
|
|
}
|
|
|
|
return strategy, normalize, params, nil
|
|
}
|
|
|
|
// =============================================================================
|
|
// Model Builder
|
|
// =============================================================================
|
|
|
|
func buildModelChain(fc *FuncChain, collSchema *schemapb.CollectionSchema, funcSchema *schemapb.FunctionSchema, searchMetrics []string, searchParams *SearchParams) error {
|
|
// Validate input field
|
|
if len(funcSchema.InputFieldNames) != 1 {
|
|
return merr.WrapErrParameterInvalidMsg("rerank_builder: model reranker requires exactly 1 input field, got %d", len(funcSchema.InputFieldNames))
|
|
}
|
|
inputField := funcSchema.InputFieldNames[0]
|
|
|
|
if err := validateVarcharInputField(collSchema, inputField); err != nil {
|
|
return err
|
|
}
|
|
|
|
// Parse queries from params
|
|
queries, err := parseModelQueries(funcSchema)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Create provider
|
|
extraInfo := searchParams.ModelExtraInfo
|
|
if extraInfo == nil {
|
|
extraInfo = &models.ModelExtraInfo{}
|
|
}
|
|
provider, err := rerank.NewModelProvider(funcSchema.Params, extraInfo)
|
|
if err != nil {
|
|
return merr.WrapErrParameterInvalidMsg("rerank_builder: %v", err)
|
|
}
|
|
|
|
// Add Merge to combine and deduplicate results from multiple search paths.
|
|
// No metricTypes or normalization needed: the model will overwrite all scores,
|
|
// so any score processing here (normalization, direction conversion) is wasted work.
|
|
fc.Merge(MergeStrategyMax)
|
|
|
|
// Create and add RerankModelExpr
|
|
modelExpr, err := expr.NewRerankModelExpr(provider, queries)
|
|
if err != nil {
|
|
return merr.WrapErrParameterInvalidMsg("rerank_builder: %v", err)
|
|
}
|
|
|
|
fc.Map(modelExpr,
|
|
[]string{inputField},
|
|
[]string{types.ScoreFieldName})
|
|
|
|
return nil
|
|
}
|
|
|
|
func parseModelQueries(funcSchema *schemapb.FunctionSchema) ([]string, error) {
|
|
for _, param := range funcSchema.Params {
|
|
if param.Key == queryKeyName {
|
|
var queries []string
|
|
if err := json.Unmarshal([]byte(param.Value), &queries); err != nil {
|
|
return nil, merr.WrapErrParameterInvalidMsg("rerank_builder: parse rerank params [queries] failed: %v", err)
|
|
}
|
|
if len(queries) == 0 {
|
|
return nil, merr.WrapErrParameterMissingMsg("rerank_builder: rerank queries must not be empty")
|
|
}
|
|
return queries, nil
|
|
}
|
|
}
|
|
return nil, merr.WrapErrParameterInvalidMsg("rerank_builder: rerank function missing required param: queries")
|
|
}
|
|
|
|
func validateVarcharInputField(collSchema *schemapb.CollectionSchema, fieldName string) error {
|
|
for _, field := range collSchema.Fields {
|
|
if field.Name == fieldName {
|
|
if field.DataType == schemapb.DataType_VarChar {
|
|
return nil
|
|
}
|
|
return merr.WrapErrParameterInvalidMsg("rerank_builder: model input field %s must be VarChar, got %s", fieldName, field.DataType.String())
|
|
}
|
|
}
|
|
return merr.WrapErrParameterInvalidMsg("rerank_builder: input field %s not found in collection schema", fieldName)
|
|
}
|
|
|
|
// GetInputFieldNamesFromFuncScore returns input field names from a FunctionScore schema.
|
|
// RRF/Weighted have no input fields; Decay has input fields from InputFieldNames.
|
|
func GetInputFieldNamesFromFuncScore(funcScore *schemapb.FunctionScore) []string {
|
|
if funcScore == nil || len(funcScore.Functions) == 0 {
|
|
return nil
|
|
}
|
|
funcSchema := funcScore.Functions[0]
|
|
return funcSchema.GetInputFieldNames()
|
|
}
|
|
|
|
// GetInputFieldIDsFromSchema resolves input field IDs from the collection schema using the function score's input field names.
|
|
func GetInputFieldIDsFromSchema(collSchema *schemapb.CollectionSchema, funcScore *schemapb.FunctionScore) []int64 {
|
|
names := GetInputFieldNamesFromFuncScore(funcScore)
|
|
if len(names) == 0 {
|
|
return nil
|
|
}
|
|
nameToID := make(map[string]int64, len(collSchema.GetFields()))
|
|
for _, field := range collSchema.GetFields() {
|
|
nameToID[field.GetName()] = field.GetFieldID()
|
|
}
|
|
ids := make([]int64, 0, len(names))
|
|
for _, name := range names {
|
|
if id, ok := nameToID[name]; ok {
|
|
ids = append(ids, id)
|
|
}
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// GetRerankNameFromFuncScore returns the reranker name from a FunctionScore schema.
|
|
func GetRerankNameFromFuncScore(funcScore *schemapb.FunctionScore) string {
|
|
if funcScore == nil || len(funcScore.Functions) == 0 {
|
|
return ""
|
|
}
|
|
return rerank.GetRerankName(funcScore.Functions[0])
|
|
}
|
|
|
|
func validateInputField(collSchema *schemapb.CollectionSchema, fieldName string) error {
|
|
for _, field := range collSchema.Fields {
|
|
if field.Name == fieldName {
|
|
// Check if field type is numeric.
|
|
//
|
|
// Timestamptz is intentionally excluded: although the legacy
|
|
// rerank/decay code listed it in its type-dispatch switch, no
|
|
// production path or test ever exercised it end-to-end (the
|
|
// Arrow converter and GetNumericValue have no Timestamptz
|
|
// support). Reject it here so the failure is reported at
|
|
// validation time with a clear message rather than at runtime.
|
|
switch field.DataType {
|
|
case schemapb.DataType_Int8, schemapb.DataType_Int16, schemapb.DataType_Int32,
|
|
schemapb.DataType_Int64, schemapb.DataType_Float, schemapb.DataType_Double:
|
|
return nil
|
|
default:
|
|
return merr.WrapErrParameterInvalidMsg("rerank_builder: decay input field %s must be numeric, got %s", fieldName, field.DataType.String())
|
|
}
|
|
}
|
|
}
|
|
return merr.WrapErrParameterInvalidMsg("rerank_builder: input field %s not found in collection schema", fieldName)
|
|
}
|