diff --git a/v1/qdrant/filters.go b/v1/qdrant/filters.go new file mode 100644 index 0000000..20222e8 --- /dev/null +++ b/v1/qdrant/filters.go @@ -0,0 +1,398 @@ +package qdrant + +import ( + "encoding/json" + "strings" + "time" + + qdrant "github.com/qdrant/go-client/qdrant" + "google.golang.org/protobuf/types/known/timestamppb" +) + +// UserPayloadPrefix is the prefix for user-defined metadata fields +const UserPayloadPrefix = "custom" + +// FilterCondition is the interface for all filter conditions +type FilterCondition interface { + ToQdrantCondition() []*qdrant.Condition +} + +// FieldType indicates whether a field is internal or user-defined +type FieldType int + +const ( + // InternalField - system-managed fields stored at top-level + InternalField FieldType = iota + // UserField - user-defined fields stored under "custom." prefix + UserField +) + +// TimeRange represents a time-based filter condition +type TimeRange struct { + Gt *time.Time `json:"after,omitempty"` // Greater than this time + Gte *time.Time `json:"atOrAfter,omitempty"` // Greater than or equal to this time + Lt *time.Time `json:"before,omitempty"` // Less than this time + Lte *time.Time `json:"atOrBefore,omitempty"` // Less than or equal to this time +} + +// NumericRange represents a numeric range filter condition +type NumericRange struct { + Gt *float64 `json:"greaterThan,omitempty"` // Greater than + Gte *float64 `json:"greaterThanOrEqualTo,omitempty"` // Greater than or equal + Lt *float64 `json:"lessThan,omitempty"` // Less than + Lte *float64 `json:"lessThanOrEqualTo,omitempty"` // Less than or equal +} + +// MatchCondition represents an exact match condition for a field value. +// Supports string, bool, and int64 types. The FieldType defaults to InternalField +// if not specified, meaning the field is stored at the top level of the payload. +// Use UserField to indicate the field is stored under the "custom." prefix. +type MatchCondition[T comparable] struct { + Key string `json:"field"` + Value T `json:"equalTo"` + FieldType FieldType `json:"-"` // Internal or User field (default: InternalField) +} + +// ToQdrantCondition converts the MatchCondition to Qdrant conditions. +// Supports string, bool, and int64 types. Returns nil for unsupported types. +func (c MatchCondition[T]) ToQdrantCondition() []*qdrant.Condition { + key := resolveFieldKey(c.Key, c.FieldType) + switch v := any(c.Value).(type) { + case string: + return []*qdrant.Condition{qdrant.NewMatch(key, v)} + case bool: + return []*qdrant.Condition{qdrant.NewMatchBool(key, v)} + case int64: + return []*qdrant.Condition{qdrant.NewMatchInt(key, v)} + default: + // Unsupported type - returns nil + return nil + } +} + +// MatchAnyCondition matches if value is one of the given values (IN operator). +// Applicable to keyword (string) and integer payloads. +// Returns nil if Values is empty. The FieldType defaults to InternalField if not specified. +type MatchAnyCondition[T string | int64] struct { + Key string `json:"field"` + Values []T `json:"anyOf"` + FieldType FieldType `json:"-"` // Internal or User field (default: InternalField) +} + +func (c MatchAnyCondition[T]) ToQdrantCondition() []*qdrant.Condition { + if len(c.Values) == 0 { + return nil + } + key := resolveFieldKey(c.Key, c.FieldType) + switch v := any(c.Values).(type) { + case []string: + return []*qdrant.Condition{qdrant.NewMatchKeywords(key, v...)} + case []int64: + return []*qdrant.Condition{qdrant.NewMatchInts(key, v...)} + default: + return nil + } +} + +// MatchExceptCondition matches if value is NOT one of the given values (NOT IN operator). +// Applicable to keyword (string) and integer payloads. +// Returns nil if Values is empty. The FieldType defaults to InternalField if not specified. +type MatchExceptCondition[T string | int64] struct { + Key string `json:"field"` + Values []T `json:"noneOf"` + FieldType FieldType `json:"-"` // Internal or User field (default: InternalField) +} + +func (c MatchExceptCondition[T]) ToQdrantCondition() []*qdrant.Condition { + if len(c.Values) == 0 { + return nil + } + key := resolveFieldKey(c.Key, c.FieldType) + switch v := any(c.Values).(type) { + case []string: + return []*qdrant.Condition{qdrant.NewMatchExceptKeywords(key, v...)} + case []int64: + return []*qdrant.Condition{qdrant.NewMatchExceptInts(key, v...)} + default: + return nil + } +} + +type TextCondition = MatchCondition[string] // Exact string match +type BoolCondition = MatchCondition[bool] // Exact boolean match +type IntCondition = MatchCondition[int64] // Exact integer match +type TextAnyCondition = MatchAnyCondition[string] // String IN operator +type IntAnyCondition = MatchAnyCondition[int64] // Integer IN operator +type TextExceptCondition = MatchExceptCondition[string] // String NOT IN +type IntExceptCondition = MatchExceptCondition[int64] // Integer NOT IN + +// TimeRangeCondition represents a time range filter condition +type TimeRangeCondition struct { + Key string `json:"field"` + Value TimeRange `json:"-"` + FieldType FieldType `json:"-"` +} + +func (c TimeRangeCondition) ToQdrantCondition() []*qdrant.Condition { + return buildDateTimeRangeConditions(resolveFieldKey(c.Key, c.FieldType), c.Value) +} + +func (c TimeRangeCondition) MarshalJSON() ([]byte, error) { + type Alias struct { + Field string `json:"field"` + After *time.Time `json:"after,omitempty"` + AtOrAfter *time.Time `json:"atOrAfter,omitempty"` + Before *time.Time `json:"before,omitempty"` + AtOrBefore *time.Time `json:"atOrBefore,omitempty"` + } + return json.Marshal(Alias{ + Field: c.Key, + After: c.Value.Gt, + AtOrAfter: c.Value.Gte, + Before: c.Value.Lt, + AtOrBefore: c.Value.Lte, + }) +} + +func (c *TimeRangeCondition) UnmarshalJSON(data []byte) error { + type Alias struct { + Field string `json:"field"` + After *time.Time `json:"after,omitempty"` + AtOrAfter *time.Time `json:"atOrAfter,omitempty"` + Before *time.Time `json:"before,omitempty"` + AtOrBefore *time.Time `json:"atOrBefore,omitempty"` + } + var alias Alias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + c.Key = alias.Field + c.Value = TimeRange{ + Gt: alias.After, + Gte: alias.AtOrAfter, + Lt: alias.Before, + Lte: alias.AtOrBefore, + } + return nil +} + +// NumericRangeCondition represents a numeric range filter +type NumericRangeCondition struct { + Key string `json:"field"` + Value NumericRange `json:"-"` + FieldType FieldType `json:"-"` +} + +func (c NumericRangeCondition) ToQdrantCondition() []*qdrant.Condition { + return buildNumericRangeConditions(resolveFieldKey(c.Key, c.FieldType), c.Value) +} + +func (c NumericRangeCondition) MarshalJSON() ([]byte, error) { + type Alias struct { + Field string `json:"field"` + GreaterThan *float64 `json:"greaterThan,omitempty"` + GreaterThanOrEqualTo *float64 `json:"greaterThanOrEqualTo,omitempty"` + LessThan *float64 `json:"lessThan,omitempty"` + LessThanOrEqualTo *float64 `json:"lessThanOrEqualTo,omitempty"` + } + return json.Marshal(Alias{ + Field: c.Key, + GreaterThan: c.Value.Gt, + GreaterThanOrEqualTo: c.Value.Gte, + LessThan: c.Value.Lt, + LessThanOrEqualTo: c.Value.Lte, + }) +} + +func (c *NumericRangeCondition) UnmarshalJSON(data []byte) error { + type Alias struct { + Field string `json:"field"` + GreaterThan *float64 `json:"greaterThan,omitempty"` + GreaterThanOrEqualTo *float64 `json:"greaterThanOrEqualTo,omitempty"` + LessThan *float64 `json:"lessThan,omitempty"` + LessThanOrEqualTo *float64 `json:"lessThanOrEqualTo,omitempty"` + } + var alias Alias + if err := json.Unmarshal(data, &alias); err != nil { + return err + } + c.Key = alias.Field + c.Value = NumericRange{ + Gt: alias.GreaterThan, + Gte: alias.GreaterThanOrEqualTo, + Lt: alias.LessThan, + Lte: alias.LessThanOrEqualTo, + } + return nil +} + +// resolveFieldKey returns the full field path based on FieldType +// Internal fields: "search_store_id" -> "search_store_id" +// User fields: "document_id" -> "custom.document_id" +func resolveFieldKey(key string, fieldType FieldType) string { + if fieldType == UserField { + // Prevent double-prefixing + if strings.HasPrefix(key, UserPayloadPrefix+".") { + return key + } + return UserPayloadPrefix + "." + key + } + return key +} + +// ConditionSet holds conditions for a single clause +type ConditionSet struct { + Conditions []FilterCondition `json:"conditions,omitempty"` +} + +// FilterSet supports Must (AND), Should (OR), and MustNot (NOT) clauses. +// Use with SearchRequest.Filters to filter search results. +// +// Example: +// +// filters := &FilterSet{ +// Must: &ConditionSet{ +// Conditions: []FilterCondition{ +// TextCondition{Key: "city", Value: "London"}, +// }, +// }, +// } +type FilterSet struct { + Must *ConditionSet `json:"must,omitempty"` // AND - all conditions must match + Should *ConditionSet `json:"should,omitempty"` // OR - at least one condition must match + MustNot *ConditionSet `json:"mustNot,omitempty"` // NOT - none of the conditions should match +} + +// buildFilter constructs a Qdrant filter from FilterSet +func buildFilter(filters *FilterSet) *qdrant.Filter { + if filters == nil { + return nil + } + + filter := &qdrant.Filter{} + + if filters.Must != nil { + filter.Must = buildConditions(filters.Must) + } + + if filters.Should != nil { + filter.Should = buildConditions(filters.Should) + } + + if filters.MustNot != nil { + filter.MustNot = buildConditions(filters.MustNot) + } + + // Return nil if no conditions were added + if len(filter.Must) == 0 && len(filter.Should) == 0 && len(filter.MustNot) == 0 { + return nil + } + + return filter +} + +// buildConditions converts a ConditionSet to Qdrant conditions +// Filters out nil conditions that may be returned by invalid conditions (e.g., empty ranges) +func buildConditions(cs *ConditionSet) []*qdrant.Condition { + if cs == nil { + return nil + } + + var conditions []*qdrant.Condition + for _, c := range cs.Conditions { + conds := c.ToQdrantCondition() + for _, cond := range conds { + if cond != nil { + conditions = append(conditions, cond) + } + } + } + return conditions +} + +// buildDateTimeRangeConditions creates datetime range conditions +func buildDateTimeRangeConditions(key string, tr TimeRange) []*qdrant.Condition { + dateRange := &qdrant.DatetimeRange{ + Gt: toTimestamp(tr.Gt), + Gte: toTimestamp(tr.Gte), + Lt: toTimestamp(tr.Lt), + Lte: toTimestamp(tr.Lte), + } + + // Check if any field is set + if dateRange.Gt == nil && dateRange.Gte == nil && dateRange.Lt == nil && dateRange.Lte == nil { + return nil + } + + return []*qdrant.Condition{qdrant.NewDatetimeRange(key, dateRange)} +} + +// buildNumericRangeConditions creates numeric range conditions +func buildNumericRangeConditions(key string, nr NumericRange) []*qdrant.Condition { + rangeFilter := &qdrant.Range{ + Gt: nr.Gt, + Gte: nr.Gte, + Lt: nr.Lt, + Lte: nr.Lte, + } + + // Check if any field is set + if rangeFilter.Gt == nil && rangeFilter.Gte == nil && rangeFilter.Lt == nil && rangeFilter.Lte == nil { + return nil + } + + return []*qdrant.Condition{qdrant.NewRange(key, rangeFilter)} +} + +// toTimestamp converts a *time.Time to *timestamppb.Timestamp (nil-safe) +func toTimestamp(t *time.Time) *timestamppb.Timestamp { + if t == nil { + return nil + } + return timestamppb.New(*t) +} + +// IsNullCondition checks if a field is null +type IsNullCondition struct { + Key string `json:"field"` + FieldType FieldType `json:"-"` // Internal or User field (default: InternalField) +} + +func (c IsNullCondition) ToQdrantCondition() []*qdrant.Condition { + key := resolveFieldKey(c.Key, c.FieldType) + return []*qdrant.Condition{qdrant.NewIsNull(key)} +} + +// IsEmptyCondition checks if a field is empty (does not exist, null, or []) +type IsEmptyCondition struct { + Key string `json:"field"` + FieldType FieldType `json:"-"` // Internal or User field (default: InternalField) +} + +func (c IsEmptyCondition) ToQdrantCondition() []*qdrant.Condition { + key := resolveFieldKey(c.Key, c.FieldType) + return []*qdrant.Condition{qdrant.NewIsEmpty(key)} +} + +// === Payload Helpers === + +// BuildPayload creates a Qdrant payload with separated internal and user fields. +// Internal fields are stored at the top level, while user fields are stored under +// the "custom" prefix. If internal contains a "custom" key, it will be overwritten +// by the user fields map. +func BuildPayload(internal map[string]any, user map[string]any) map[string]any { + payload := make(map[string]any) + + // Add internal fields at top-level + for k, v := range internal { + payload[k] = v + } + + // Add user fields under "custom" prefix + // Note: This will overwrite any "custom" key that was in the internal map + if len(user) > 0 { + payload[UserPayloadPrefix] = user + } + + return payload +} diff --git a/v1/qdrant/filters_test.go b/v1/qdrant/filters_test.go new file mode 100644 index 0000000..495b583 --- /dev/null +++ b/v1/qdrant/filters_test.go @@ -0,0 +1,1148 @@ +package qdrant + +import ( + "encoding/json" + "testing" + "time" +) + +func TestBuildFilter_NilFilterSet(t *testing.T) { + result := buildFilter(nil) + if result != nil { + t.Errorf("expected nil, got %v", result) + } +} + +func TestBuildFilter_EmptyFilterSet(t *testing.T) { + filters := &FilterSet{} + result := buildFilter(filters) + if result != nil { + t.Errorf("expected nil, got %v", result) + } +} + +func TestBuildFilter_EmptyConditionSet(t *testing.T) { + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{}, + }, + } + result := buildFilter(filters) + if result != nil { + t.Errorf("expected nil, got %v", result) + } +} + +func TestBuildFilter_MustWithTextCondition(t *testing.T) { + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TextCondition{Key: "city", Value: "London"}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 1 { + t.Errorf("expected 1 Must condition, got %d", len(result.Must)) + } + if len(result.Should) != 0 { + t.Errorf("expected 0 Should conditions, got %d", len(result.Should)) + } + if len(result.MustNot) != 0 { + t.Errorf("expected 0 MustNot conditions, got %d", len(result.MustNot)) + } +} + +func TestBuildFilter_ShouldWithMultipleTextConditions(t *testing.T) { + // city = "London" OR city = "Berlin" + filters := &FilterSet{ + Should: &ConditionSet{ + Conditions: []FilterCondition{ + TextCondition{Key: "city", Value: "London"}, + TextCondition{Key: "city", Value: "Berlin"}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Should) != 2 { + t.Errorf("expected 2 Should conditions, got %d", len(result.Should)) + } +} + +func TestBuildFilter_MustNotWithBoolCondition(t *testing.T) { + filters := &FilterSet{ + MustNot: &ConditionSet{ + Conditions: []FilterCondition{ + BoolCondition{Key: "archived", Value: true}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.MustNot) != 1 { + t.Errorf("expected 1 MustNot condition, got %d", len(result.MustNot)) + } +} + +func TestBuildFilter_MixedConditionTypes(t *testing.T) { + // city = "London" AND active = true AND priority = 1 + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TextCondition{Key: "city", Value: "London"}, + BoolCondition{Key: "active", Value: true}, + IntCondition{Key: "priority", Value: 1}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 3 { + t.Errorf("expected 3 Must conditions, got %d", len(result.Must)) + } +} + +func TestBuildFilter_CombinedClauses(t *testing.T) { + // (city = "London" AND active = true) AND NOT archived + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TextCondition{Key: "city", Value: "London"}, + BoolCondition{Key: "active", Value: true}, + }, + }, + MustNot: &ConditionSet{ + Conditions: []FilterCondition{ + BoolCondition{Key: "archived", Value: true}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 2 { + t.Errorf("expected 2 Must conditions, got %d", len(result.Must)) + } + if len(result.MustNot) != 1 { + t.Errorf("expected 1 MustNot condition, got %d", len(result.MustNot)) + } +} + +func TestBuildFilter_AllThreeClauses(t *testing.T) { + // Must AND Should AND MustNot + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TextCondition{Key: "status", Value: "active"}, + }, + }, + Should: &ConditionSet{ + Conditions: []FilterCondition{ + TextCondition{Key: "city", Value: "London"}, + TextCondition{Key: "city", Value: "Berlin"}, + }, + }, + MustNot: &ConditionSet{ + Conditions: []FilterCondition{ + BoolCondition{Key: "deleted", Value: true}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 1 { + t.Errorf("expected 1 Must condition, got %d", len(result.Must)) + } + if len(result.Should) != 2 { + t.Errorf("expected 2 Should conditions, got %d", len(result.Should)) + } + if len(result.MustNot) != 1 { + t.Errorf("expected 1 MustNot condition, got %d", len(result.MustNot)) + } +} + +func TestBuildFilter_TimeRangeCondition(t *testing.T) { + startTime := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + endTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TimeRangeCondition{ + Key: "created_at", + Value: TimeRange{ + Gte: &startTime, + Lt: &endTime, + }, + }, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 1 { + t.Errorf("expected 1 Must condition, got %d", len(result.Must)) + } +} + +func TestBuildFilter_TimeRangeAllBounds(t *testing.T) { + gt := time.Date(2023, 1, 1, 0, 0, 0, 0, time.UTC) + gte := time.Date(2023, 2, 1, 0, 0, 0, 0, time.UTC) + lt := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + lte := time.Date(2024, 2, 1, 0, 0, 0, 0, time.UTC) + + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TimeRangeCondition{ + Key: "updated_at", + Value: TimeRange{ + Gt: >, + Gte: >e, + Lt: <, + Lte: <e, + }, + }, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 1 { + t.Errorf("expected 1 Must condition, got %d", len(result.Must)) + } +} + +func TestBuildFilter_EmptyTimeRange(t *testing.T) { + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TimeRangeCondition{ + Key: "created_at", + Value: TimeRange{}, // All nil + }, + }, + }, + } + result := buildFilter(filters) + + // Empty TimeRange returns nil condition, so filter should be nil + if result != nil { + t.Errorf("expected nil for empty time range, got %v", result) + } +} + +func TestBuildConditions_NilConditionSet(t *testing.T) { + result := buildConditions(nil) + if result != nil { + t.Errorf("expected nil, got %v", result) + } +} + +func TestBuildConditions_FiltersNilConditions(t *testing.T) { + // Test that buildConditions filters out nil conditions + cs := &ConditionSet{ + Conditions: []FilterCondition{ + TextCondition{Key: "city", Value: "London"}, + TimeRangeCondition{Key: "created_at", Value: TimeRange{}}, // Empty range returns nil + TextAnyCondition{Key: "status", Values: []string{}}, // Empty slice returns nil + BoolCondition{Key: "active", Value: true}, + }, + } + result := buildConditions(cs) + + // Should only have 2 conditions (TextCondition and BoolCondition) + // Empty TimeRange and empty TextAnyCondition should be filtered out + if len(result) != 2 { + t.Errorf("expected 2 conditions (nil ones filtered out), got %d", len(result)) + } +} + +func TestTextCondition_ToQdrantCondition(t *testing.T) { + c := TextCondition{Key: "city", Value: "London"} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestBoolCondition_ToQdrantCondition(t *testing.T) { + c := BoolCondition{Key: "active", Value: true} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestIntCondition_ToQdrantCondition(t *testing.T) { + c := IntCondition{Key: "priority", Value: 42} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestTimeRangeCondition_ToQdrantCondition(t *testing.T) { + now := time.Now() + c := TimeRangeCondition{ + Key: "created_at", + Value: TimeRange{Gte: &now}, + } + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestTimeRangeCondition_EmptyRange(t *testing.T) { + c := TimeRangeCondition{ + Key: "created_at", + Value: TimeRange{}, // All nil + } + result := c.ToQdrantCondition() + + if result != nil { + t.Errorf("expected nil for empty time range, got %v", result) + } +} + +func TestToTimestamp_Nil(t *testing.T) { + result := toTimestamp(nil) + if result != nil { + t.Errorf("expected nil, got %v", result) + } +} + +func TestToTimestamp_ValidTime(t *testing.T) { + now := time.Now() + result := toTimestamp(&now) + + if result == nil { + t.Fatal("expected timestamp, got nil") + } + if result.AsTime().Unix() != now.Unix() { + t.Errorf("timestamp mismatch: expected %v, got %v", now.Unix(), result.AsTime().Unix()) + } +} + +// === FieldType Tests === + +func TestResolveFieldKey_InternalField(t *testing.T) { + key := resolveFieldKey("search_store_id", InternalField) + expected := "search_store_id" + if key != expected { + t.Errorf("expected %q, got %q", expected, key) + } +} + +func TestResolveFieldKey_UserField(t *testing.T) { + key := resolveFieldKey("document_id", UserField) + expected := "custom.document_id" + if key != expected { + t.Errorf("expected %q, got %q", expected, key) + } +} + +func TestResolveFieldKey_UserField_PreventDoublePrefix(t *testing.T) { + // If key already has prefix, don't add again + key := resolveFieldKey("custom.document_id", UserField) + expected := "custom.document_id" + if key != expected { + t.Errorf("expected %q, got %q (double prefix detected)", expected, key) + } +} + +func TestTextCondition_InternalField(t *testing.T) { + c := TextCondition{Key: "search_store_id", Value: "store-123", FieldType: InternalField} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } + // Internal field should NOT have prefix +} + +func TestTextCondition_UserField(t *testing.T) { + c := TextCondition{Key: "document_id", Value: "doc-456", FieldType: UserField} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } + // User field should have "custom." prefix +} + +func TestBoolCondition_UserField(t *testing.T) { + c := BoolCondition{Key: "is_reviewed", Value: true, FieldType: UserField} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestIntCondition_UserField(t *testing.T) { + c := IntCondition{Key: "version", Value: 2, FieldType: UserField} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestTimeRangeCondition_UserField(t *testing.T) { + now := time.Now() + c := TimeRangeCondition{ + Key: "uploaded_at", + Value: TimeRange{Gte: &now}, + FieldType: UserField, + } + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestBuildFilter_MixedInternalAndUserFields(t *testing.T) { + // search_store_id = "store-123" (internal) AND custom.category = "reports" (user) + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TextCondition{Key: "search_store_id", Value: "store-123", FieldType: InternalField}, + TextCondition{Key: "category", Value: "reports", FieldType: UserField}, + BoolCondition{Key: "is_published", Value: true, FieldType: UserField}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 3 { + t.Errorf("expected 3 Must conditions, got %d", len(result.Must)) + } +} + +// === BuildPayload Tests === + +func TestBuildPayload_OnlyInternal(t *testing.T) { + internal := map[string]any{ + "search_store_id": "store-123", + "modalities": []string{"text"}, + } + payload := BuildPayload(internal, nil) + + if payload["search_store_id"] != "store-123" { + t.Errorf("expected search_store_id at top-level") + } + if _, exists := payload["custom"]; exists { + t.Errorf("custom should not exist when user is nil") + } +} + +func TestBuildPayload_OnlyUser(t *testing.T) { + user := map[string]any{ + "document_id": "doc-456", + "author": "John", + } + payload := BuildPayload(nil, user) + + custom, ok := payload["custom"].(map[string]any) + if !ok { + t.Fatal("expected custom field") + } + if custom["document_id"] != "doc-456" { + t.Errorf("expected document_id in custom") + } + if custom["author"] != "John" { + t.Errorf("expected author in custom") + } +} + +func TestBuildPayload_BothInternalAndUser(t *testing.T) { + internal := map[string]any{ + "search_store_id": "store-123", + } + user := map[string]any{ + "document_id": "doc-456", + "category": "reports", + } + payload := BuildPayload(internal, user) + + // Check internal at top-level + if payload["search_store_id"] != "store-123" { + t.Errorf("expected search_store_id at top-level") + } + + // Check user under custom + custom, ok := payload["custom"].(map[string]any) + if !ok { + t.Fatal("expected custom field") + } + if custom["document_id"] != "doc-456" { + t.Errorf("expected document_id in custom") + } + if custom["category"] != "reports" { + t.Errorf("expected category in custom") + } +} + +func TestBuildPayload_EmptyUser(t *testing.T) { + internal := map[string]any{ + "search_store_id": "store-123", + } + user := map[string]any{} // Empty, not nil + payload := BuildPayload(internal, user) + + if _, exists := payload["custom"]; exists { + t.Errorf("custom should not exist when user is empty") + } +} + +func TestResolveFieldKey_ActualPath(t *testing.T) { + tests := []struct { + key string + fieldType FieldType + expected string + }{ + {"city", InternalField, "city"}, + {"city", UserField, "custom.city"}, + {"custom.city", UserField, "custom.city"}, // No double prefix + } + + for _, tt := range tests { + result := resolveFieldKey(tt.key, tt.fieldType) + if result != tt.expected { + t.Errorf("resolveFieldKey(%q, %v) = %q, want %q", + tt.key, tt.fieldType, result, tt.expected) + } + } +} + +// === MatchAnyCondition Tests === + +func TestTextAnyCondition_ToQdrantCondition(t *testing.T) { + c := TextAnyCondition{Key: "city", Values: []string{"London", "Berlin", "Paris"}} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestIntAnyCondition_ToQdrantCondition(t *testing.T) { + c := IntAnyCondition{Key: "priority", Values: []int64{1, 2, 3}} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestTextAnyCondition_UserField(t *testing.T) { + c := TextAnyCondition{Key: "category", Values: []string{"tech", "science"}, FieldType: UserField} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestBuildFilter_WithTextAnyCondition(t *testing.T) { + // city IN ("London", "Berlin") + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TextAnyCondition{Key: "city", Values: []string{"London", "Berlin"}}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 1 { + t.Errorf("expected 1 Must condition, got %d", len(result.Must)) + } +} + +func TestTextAnyCondition_EmptySlice(t *testing.T) { + // Empty slice should return nil + c := TextAnyCondition{Key: "city", Values: []string{}} + result := c.ToQdrantCondition() + + if result != nil { + t.Errorf("expected nil for empty slice, got %v", result) + } +} + +func TestIntAnyCondition_EmptySlice(t *testing.T) { + // Empty slice should return nil + c := IntAnyCondition{Key: "priority", Values: []int64{}} + result := c.ToQdrantCondition() + + if result != nil { + t.Errorf("expected nil for empty slice, got %v", result) + } +} + +func TestBuildFilter_WithEmptyTextAnyCondition(t *testing.T) { + // Empty TextAnyCondition should be filtered out + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TextAnyCondition{Key: "city", Values: []string{}}, + TextCondition{Key: "status", Value: "active"}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + // Should only have the TextCondition, empty TextAnyCondition should be filtered out + if len(result.Must) != 1 { + t.Errorf("expected 1 Must condition (empty one filtered out), got %d", len(result.Must)) + } +} + +// === MatchExceptCondition Tests === + +func TestTextExceptCondition_ToQdrantCondition(t *testing.T) { + c := TextExceptCondition{Key: "city", Values: []string{"Paris", "Madrid"}} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestIntExceptCondition_ToQdrantCondition(t *testing.T) { + c := IntExceptCondition{Key: "priority", Values: []int64{0, -1}} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestTextExceptCondition_UserField(t *testing.T) { + c := TextExceptCondition{Key: "status", Values: []string{"draft", "deleted"}, FieldType: UserField} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestBuildFilter_WithTextExceptCondition(t *testing.T) { + // city NOT IN ("Paris", "Madrid") + filters := &FilterSet{ + MustNot: &ConditionSet{ + Conditions: []FilterCondition{ + TextExceptCondition{Key: "city", Values: []string{"Paris", "Madrid"}}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.MustNot) != 1 { + t.Errorf("expected 1 MustNot condition, got %d", len(result.MustNot)) + } +} + +func TestTextExceptCondition_EmptySlice(t *testing.T) { + // Empty slice should return nil + c := TextExceptCondition{Key: "city", Values: []string{}} + result := c.ToQdrantCondition() + + if result != nil { + t.Errorf("expected nil for empty slice, got %v", result) + } +} + +func TestIntExceptCondition_EmptySlice(t *testing.T) { + // Empty slice should return nil + c := IntExceptCondition{Key: "priority", Values: []int64{}} + result := c.ToQdrantCondition() + + if result != nil { + t.Errorf("expected nil for empty slice, got %v", result) + } +} + +func TestBuildFilter_WithEmptyTextExceptCondition(t *testing.T) { + // Empty TextExceptCondition should be filtered out + filters := &FilterSet{ + MustNot: &ConditionSet{ + Conditions: []FilterCondition{ + TextExceptCondition{Key: "city", Values: []string{}}, + BoolCondition{Key: "deleted", Value: true}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + // Should only have the BoolCondition, empty TextExceptCondition should be filtered out + if len(result.MustNot) != 1 { + t.Errorf("expected 1 MustNot condition (empty one filtered out), got %d", len(result.MustNot)) + } +} + +// === NumericRangeCondition Tests === + +func TestNumericRangeCondition_ToQdrantCondition(t *testing.T) { + minPrice := 100.0 + maxPrice := 500.0 + c := NumericRangeCondition{ + Key: "price", + Value: NumericRange{ + Gte: &minPrice, + Lte: &maxPrice, + }, + } + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestNumericRangeCondition_AllBounds(t *testing.T) { + gt := 10.0 + gte := 20.0 + lt := 100.0 + lte := 90.0 + c := NumericRangeCondition{ + Key: "score", + Value: NumericRange{ + Gt: >, + Gte: >e, + Lt: <, + Lte: <e, + }, + } + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestNumericRangeCondition_EmptyRange(t *testing.T) { + c := NumericRangeCondition{ + Key: "price", + Value: NumericRange{}, // All nil + } + result := c.ToQdrantCondition() + + if result != nil { + t.Errorf("expected nil for empty numeric range, got %v", result) + } +} + +func TestNumericRangeCondition_UserField(t *testing.T) { + minPrice := 50.0 + c := NumericRangeCondition{ + Key: "price", + Value: NumericRange{Gte: &minPrice}, + FieldType: UserField, + } + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestBuildFilter_WithNumericRangeCondition(t *testing.T) { + minPrice := 100.0 + maxPrice := 500.0 + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + NumericRangeCondition{ + Key: "price", + Value: NumericRange{ + Gte: &minPrice, + Lte: &maxPrice, + }, + }, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 1 { + t.Errorf("expected 1 Must condition, got %d", len(result.Must)) + } +} + +// === IsNullCondition Tests === + +func TestIsNullCondition_ToQdrantCondition(t *testing.T) { + c := IsNullCondition{Key: "deleted_at"} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestIsNullCondition_UserField(t *testing.T) { + c := IsNullCondition{Key: "review_date", FieldType: UserField} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestBuildFilter_WithIsNullCondition(t *testing.T) { + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + IsNullCondition{Key: "deleted_at"}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 1 { + t.Errorf("expected 1 Must condition, got %d", len(result.Must)) + } +} + +// === IsEmptyCondition Tests === + +func TestIsEmptyCondition_ToQdrantCondition(t *testing.T) { + c := IsEmptyCondition{Key: "tags"} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestIsEmptyCondition_UserField(t *testing.T) { + c := IsEmptyCondition{Key: "categories", FieldType: UserField} + result := c.ToQdrantCondition() + + if len(result) != 1 { + t.Errorf("expected 1 condition, got %d", len(result)) + } +} + +func TestBuildFilter_WithIsEmptyCondition(t *testing.T) { + // Find documents where tags is NOT empty (using MustNot) + filters := &FilterSet{ + MustNot: &ConditionSet{ + Conditions: []FilterCondition{ + IsEmptyCondition{Key: "tags"}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.MustNot) != 1 { + t.Errorf("expected 1 MustNot condition, got %d", len(result.MustNot)) + } +} + +// === MarshalJSON Tests === + +func TestTimeRangeCondition_MarshalJSON(t *testing.T) { + startTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + endTime := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + + c := TimeRangeCondition{ + Key: "created_at", + Value: TimeRange{ + Gte: &startTime, + Lt: &endTime, + }, + } + + data, err := c.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + + jsonStr := string(data) + // Check that it contains expected fields + if !contains(jsonStr, `"field":"created_at"`) { + t.Errorf("expected field in JSON, got %s", jsonStr) + } + if !contains(jsonStr, `"atOrAfter"`) { + t.Errorf("expected atOrAfter in JSON, got %s", jsonStr) + } + if !contains(jsonStr, `"before"`) { + t.Errorf("expected before in JSON, got %s", jsonStr) + } +} + +func TestNumericRangeCondition_MarshalJSON(t *testing.T) { + minPrice := 100.0 + maxPrice := 500.0 + + c := NumericRangeCondition{ + Key: "price", + Value: NumericRange{ + Gte: &minPrice, + Lte: &maxPrice, + }, + } + + data, err := c.MarshalJSON() + if err != nil { + t.Fatalf("MarshalJSON failed: %v", err) + } + + jsonStr := string(data) + if !contains(jsonStr, `"field":"price"`) { + t.Errorf("expected field in JSON, got %s", jsonStr) + } + if !contains(jsonStr, `"greaterThanOrEqualTo"`) { + t.Errorf("expected greaterThanOrEqualTo in JSON, got %s", jsonStr) + } + if !contains(jsonStr, `"lessThanOrEqualTo"`) { + t.Errorf("expected lessThanOrEqualTo in JSON, got %s", jsonStr) + } +} + +func TestTimeRangeCondition_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "field": "created_at", + "atOrAfter": "2024-01-01T00:00:00Z", + "before": "2024-12-31T00:00:00Z" + }` + + var c TimeRangeCondition + err := json.Unmarshal([]byte(jsonData), &c) + if err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + + if c.Key != "created_at" { + t.Errorf("expected field 'created_at', got %q", c.Key) + } + if c.Value.Gte == nil { + t.Error("expected Gte to be set") + } + if c.Value.Lt == nil { + t.Error("expected Lt to be set") + } + if c.Value.Gte != nil { + expected := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + if !c.Value.Gte.Equal(expected) { + t.Errorf("expected Gte %v, got %v", expected, c.Value.Gte) + } + } +} + +func TestTimeRangeCondition_RoundTripJSON(t *testing.T) { + startTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + endTime := time.Date(2024, 12, 31, 0, 0, 0, 0, time.UTC) + + original := TimeRangeCondition{ + Key: "created_at", + Value: TimeRange{ + Gte: &startTime, + Lt: &endTime, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var unmarshaled TimeRangeCondition + err = json.Unmarshal(data, &unmarshaled) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if unmarshaled.Key != original.Key { + t.Errorf("field mismatch: expected %q, got %q", original.Key, unmarshaled.Key) + } + if unmarshaled.Value.Gte == nil || !unmarshaled.Value.Gte.Equal(*original.Value.Gte) { + t.Errorf("Gte mismatch: expected %v, got %v", original.Value.Gte, unmarshaled.Value.Gte) + } + if unmarshaled.Value.Lt == nil || !unmarshaled.Value.Lt.Equal(*original.Value.Lt) { + t.Errorf("Lt mismatch: expected %v, got %v", original.Value.Lt, unmarshaled.Value.Lt) + } +} + +func TestNumericRangeCondition_UnmarshalJSON(t *testing.T) { + jsonData := `{ + "field": "price", + "greaterThanOrEqualTo": 100.0, + "lessThanOrEqualTo": 500.0 + }` + + var c NumericRangeCondition + err := json.Unmarshal([]byte(jsonData), &c) + if err != nil { + t.Fatalf("UnmarshalJSON failed: %v", err) + } + + if c.Key != "price" { + t.Errorf("expected field 'price', got %q", c.Key) + } + if c.Value.Gte == nil || *c.Value.Gte != 100.0 { + t.Errorf("expected Gte to be 100.0, got %v", c.Value.Gte) + } + if c.Value.Lte == nil || *c.Value.Lte != 500.0 { + t.Errorf("expected Lte to be 500.0, got %v", c.Value.Lte) + } +} + +func TestNumericRangeCondition_RoundTripJSON(t *testing.T) { + minPrice := 100.0 + maxPrice := 500.0 + + original := NumericRangeCondition{ + Key: "price", + Value: NumericRange{ + Gte: &minPrice, + Lte: &maxPrice, + }, + } + + data, err := json.Marshal(original) + if err != nil { + t.Fatalf("Marshal failed: %v", err) + } + + var unmarshaled NumericRangeCondition + err = json.Unmarshal(data, &unmarshaled) + if err != nil { + t.Fatalf("Unmarshal failed: %v", err) + } + + if unmarshaled.Key != original.Key { + t.Errorf("field mismatch: expected %q, got %q", original.Key, unmarshaled.Key) + } + if unmarshaled.Value.Gte == nil || *unmarshaled.Value.Gte != *original.Value.Gte { + t.Errorf("Gte mismatch: expected %v, got %v", original.Value.Gte, unmarshaled.Value.Gte) + } + if unmarshaled.Value.Lte == nil || *unmarshaled.Value.Lte != *original.Value.Lte { + t.Errorf("Lte mismatch: expected %v, got %v", original.Value.Lte, unmarshaled.Value.Lte) + } +} + +// === Complex Filter Tests === + +func TestBuildFilter_ComplexCombination(t *testing.T) { + startTime := time.Date(2024, 1, 1, 0, 0, 0, 0, time.UTC) + minPrice := 100.0 + + // Complex filter: + // (search_store_id = "store-123" AND created_at >= 2024-01-01 AND price >= 100) + // AND (city IN ("London", "Berlin")) + // AND NOT (deleted = true OR status IN ("draft", "archived")) + filters := &FilterSet{ + Must: &ConditionSet{ + Conditions: []FilterCondition{ + TextCondition{Key: "search_store_id", Value: "store-123"}, + TimeRangeCondition{ + Key: "created_at", + Value: TimeRange{Gte: &startTime}, + }, + NumericRangeCondition{ + Key: "price", + Value: NumericRange{Gte: &minPrice}, + FieldType: UserField, + }, + }, + }, + Should: &ConditionSet{ + Conditions: []FilterCondition{ + TextAnyCondition{Key: "city", Values: []string{"London", "Berlin"}}, + }, + }, + MustNot: &ConditionSet{ + Conditions: []FilterCondition{ + BoolCondition{Key: "deleted", Value: true}, + TextAnyCondition{Key: "status", Values: []string{"draft", "archived"}}, + }, + }, + } + result := buildFilter(filters) + + if result == nil { + t.Fatal("expected filter, got nil") + } + if len(result.Must) != 3 { + t.Errorf("expected 3 Must conditions, got %d", len(result.Must)) + } + if len(result.Should) != 1 { + t.Errorf("expected 1 Should condition, got %d", len(result.Should)) + } + if len(result.MustNot) != 2 { + t.Errorf("expected 2 MustNot conditions, got %d", len(result.MustNot)) + } +} + +// Helper function for string contains check +func contains(s, substr string) bool { + return len(s) >= len(substr) && (s == substr || len(s) > 0 && containsHelper(s, substr)) +} + +func containsHelper(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + return false +} diff --git a/v1/qdrant/utils.go b/v1/qdrant/utils.go index 7fbed26..9909631 100644 --- a/v1/qdrant/utils.go +++ b/v1/qdrant/utils.go @@ -77,16 +77,15 @@ type SearchResultInterface interface { // of the underlying database implementation. // // Fields: -// • Name — The unique name of the collection. -// • Status — Current operational state (e.g., "Green", "Yellow"). -// • VectorSize — The dimension of stored vectors (e.g., 1536). -// • Distance — The similarity metric used ("Cosine", "Dot", "Euclid"). -// • Vectors — Total number of stored vectors in the collection. -// • Points — Total number of indexed points/documents in the collection. +// - Name — The unique name of the collection. +// - Status — Current operational state (e.g., "Green", "Yellow"). +// - VectorSize — The dimension of stored vectors (e.g., 1536). +// - Distance — The similarity metric used ("Cosine", "Dot", "Euclid"). +// - Vectors — Total number of stored vectors in the collection. +// - Points — Total number of indexed points/documents in the collection. // -// This struct serves as an abstraction layer between Qdrant’s low-level +// This struct serves as an abstraction layer between Qdrant's low-level // protobuf models and the higher-level application logic. - type Collection struct { Name string Status string @@ -101,7 +100,7 @@ type SearchRequest struct { CollectionName string Vector []float32 TopK int - Filters map[string]string //Optional: key-value filters + Filters *FilterSet // Optional: key-value filters } // validateSearchInput validates common search parameters @@ -118,31 +117,6 @@ func validateSearchInput(collectionName string, vector []float32, topK int) erro return nil } -// buildFilter constructs a Qdrant filter from key-value pairs (AND logic) -func buildFilter(filters map[string]string) *qdrant.Filter { - if len(filters) == 0 { - return nil - } - - conditions := make([]*qdrant.Condition, 0, len(filters)) - for key, value := range filters { - conditions = append(conditions, &qdrant.Condition{ - ConditionOneOf: &qdrant.Condition_Field{ - Field: &qdrant.FieldCondition{ - Key: key, - Match: &qdrant.Match{ - MatchValue: &qdrant.Match_Keyword{ - Keyword: value, - }, - }, - }, - }, - }) - } - - return &qdrant.Filter{Must: conditions} -} - // extractVectorDetails ────────────────────────────────────────────────────────────── // extractVectorDetails // ────────────────────────────────────────────────────────────── @@ -152,12 +126,12 @@ func buildFilter(filters map[string]string) *qdrant.Filter { // `CollectionInfo` object. // // Qdrant represents vector configuration data using a deeply nested protobuf -// structure with “oneof” wrappers. This helper navigates that hierarchy, +// structure with "oneof" wrappers. This helper navigates that hierarchy, // performs type assertions, and guards against nil pointer dereferences. // // It returns: -// • int — vector dimension (size of embedding vectors) -// • string — distance metric used for similarity search +// - int — vector dimension (size of embedding vectors) +// - string — distance metric used for similarity search // // If any nested field is missing or of an unexpected type, the function // gracefully returns default values (0, ""). @@ -166,7 +140,6 @@ func buildFilter(filters map[string]string) *qdrant.Filter { // // size, distance := extractVectorDetails(info) // log.Printf("Vector size=%d, distance=%s", size, distance) - func extractVectorDetails(info *qdrant.CollectionInfo) (int, string) { if info == nil || info.Config == nil || @@ -191,4 +164,3 @@ func derefUint64(v *uint64) uint64 { } return 0 } -