Files
geo/server/internal/shared/retrieval/qdrant.go
T
root 72adc43e63 fix(retrieval): scope group_id as must in qdrant search filter
The group_id condition was attached to filter.Should, which only contributes
to scoring instead of restricting the result set. Combined with the tenant
must condition this allowed points outside the requested groups to be
returned. Append the match into filter.Must so group scoping is enforced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 11:02:23 +08:00

425 lines
9.9 KiB
Go

package retrieval
import (
"context"
"fmt"
"net"
"net/url"
"strconv"
"strings"
sdk "github.com/qdrant/go-client/qdrant"
"go.uber.org/zap"
"github.com/geo-platform/tenant-api/internal/shared/config"
"github.com/geo-platform/tenant-api/internal/shared/middleware"
)
const (
defaultQdrantHost = "localhost"
defaultQdrantGRPCPort = 6334
defaultQdrantCollection = "geo_knowledge_chunks"
)
type VectorStore interface {
Validate() error
EnsureCollection(ctx context.Context, vectorSize int) error
Upsert(ctx context.Context, points []VectorPoint) error
Search(ctx context.Context, req SearchRequest) ([]SearchPoint, error)
DeletePoints(ctx context.Context, pointIDs []string) error
}
type VectorPoint struct {
ID string
Vector []float64
Payload map[string]any
}
type SearchRequest struct {
Vector []float64
TenantID int64
GroupIDs []int64
Limit int
}
type SearchPoint struct {
ID string
Score float64
Payload map[string]any
}
type qdrantStore struct {
client *sdk.Client
collection string
logger *zap.Logger
}
type disabledVectorStore struct {
reason string
}
func NewQdrantStore(cfg config.QdrantConfig, logger *zap.Logger) VectorStore {
host, port, useTLS, err := parseQdrantEndpoint(cfg.URL)
if err != nil {
return disabledVectorStore{reason: fmt.Sprintf("invalid qdrant.url: %v", err)}
}
collection := strings.TrimSpace(cfg.Collection)
if collection == "" {
collection = defaultQdrantCollection
}
client, err := sdk.NewClient(&sdk.Config{
Host: host,
Port: port,
APIKey: strings.TrimSpace(cfg.APIKey),
UseTLS: useTLS,
SkipCompatibilityCheck: true,
})
if err != nil {
return disabledVectorStore{reason: fmt.Sprintf("init qdrant client failed: %v", err)}
}
return &qdrantStore{
client: client,
collection: collection,
logger: logger,
}
}
func (s *qdrantStore) Validate() error {
if s == nil || s.client == nil {
return fmt.Errorf("%w: qdrant client is not initialized", ErrNotConfigured)
}
if strings.TrimSpace(s.collection) == "" {
return fmt.Errorf("%w: qdrant collection is empty", ErrNotConfigured)
}
return nil
}
func (s *qdrantStore) EnsureCollection(ctx context.Context, vectorSize int) error {
if err := s.Validate(); err != nil {
return err
}
if vectorSize <= 0 {
return fmt.Errorf("invalid qdrant vector size %d", vectorSize)
}
exists, err := s.client.CollectionExists(ctx, s.collection)
if err != nil {
s.logError(ctx, "qdrant collection exists check failed",
zap.String("collection", s.collection),
zap.Error(err),
)
return fmt.Errorf("query qdrant collection: %w", err)
}
if exists {
return nil
}
err = s.client.CreateCollection(ctx, &sdk.CreateCollection{
CollectionName: s.collection,
VectorsConfig: sdk.NewVectorsConfig(&sdk.VectorParams{
Size: uint64(vectorSize),
Distance: sdk.Distance_Cosine,
}),
})
if err != nil {
s.logError(ctx, "qdrant create collection failed",
zap.String("collection", s.collection),
zap.Int("vector_size", vectorSize),
zap.Error(err),
)
}
return err
}
func (s *qdrantStore) Upsert(ctx context.Context, points []VectorPoint) error {
if err := s.Validate(); err != nil {
return err
}
if len(points) == 0 {
return nil
}
converted := make([]*sdk.PointStruct, 0, len(points))
for _, point := range points {
if strings.TrimSpace(point.ID) == "" || len(point.Vector) == 0 {
continue
}
converted = append(converted, &sdk.PointStruct{
Id: sdk.NewID(point.ID),
Vectors: sdk.NewVectors(float64SliceToFloat32(point.Vector)...),
Payload: sdk.NewValueMap(point.Payload),
})
}
if len(converted) == 0 {
return nil
}
wait := true
_, err := s.client.Upsert(ctx, &sdk.UpsertPoints{
CollectionName: s.collection,
Wait: &wait,
Points: converted,
})
if err != nil {
s.logError(ctx, "qdrant upsert failed",
zap.String("collection", s.collection),
zap.Int("point_count", len(converted)),
zap.Error(err),
)
return fmt.Errorf("upsert qdrant points: %w", err)
}
return nil
}
func (s *qdrantStore) Search(ctx context.Context, req SearchRequest) ([]SearchPoint, error) {
if err := s.Validate(); err != nil {
return nil, err
}
if len(req.Vector) == 0 || req.TenantID <= 0 {
return []SearchPoint{}, nil
}
limit := req.Limit
if limit <= 0 {
limit = 10
}
limitValue := uint64(limit)
filter := &sdk.Filter{
Must: []*sdk.Condition{
sdk.NewMatchInt("tenant_id", req.TenantID),
sdk.NewMatchKeyword("status", "active"),
},
}
groupIDs := normalizeGroupIDs(req.GroupIDs)
if len(groupIDs) > 0 {
filter.Must = append(filter.Must, sdk.NewMatchInts("group_id", groupIDs...))
}
points, err := s.client.Query(ctx, &sdk.QueryPoints{
CollectionName: s.collection,
Query: sdk.NewQueryDense(float64SliceToFloat32(req.Vector)),
Filter: filter,
Limit: &limitValue,
WithPayload: sdk.NewWithPayload(true),
})
if err != nil {
s.logError(ctx, "qdrant search failed",
zap.String("collection", s.collection),
zap.Int64("tenant_id", req.TenantID),
zap.Int("group_count", len(groupIDs)),
zap.Int("limit", limit),
zap.Error(err),
)
return nil, fmt.Errorf("query qdrant points: %w", err)
}
results := make([]SearchPoint, 0, len(points))
for _, point := range points {
if point == nil {
continue
}
results = append(results, SearchPoint{
ID: pointIDToString(point.GetId()),
Score: float64(point.GetScore()),
Payload: valueMapToAny(point.GetPayload()),
})
}
return results, nil
}
func (s *qdrantStore) DeletePoints(ctx context.Context, pointIDs []string) error {
if err := s.Validate(); err != nil {
return err
}
if len(pointIDs) == 0 {
return nil
}
ids := make([]*sdk.PointId, 0, len(pointIDs))
for _, pointID := range pointIDs {
pointID = strings.TrimSpace(pointID)
if pointID == "" {
continue
}
ids = append(ids, sdk.NewID(pointID))
}
if len(ids) == 0 {
return nil
}
wait := true
_, err := s.client.Delete(ctx, &sdk.DeletePoints{
CollectionName: s.collection,
Wait: &wait,
Points: sdk.NewPointsSelector(ids...),
})
if err != nil {
s.logError(ctx, "qdrant delete points failed",
zap.String("collection", s.collection),
zap.Int("point_count", len(ids)),
zap.Error(err),
)
return fmt.Errorf("delete qdrant points: %w", err)
}
return nil
}
func (s *qdrantStore) logError(ctx context.Context, msg string, fields ...zap.Field) {
if s == nil || s.logger == nil {
return
}
if requestID := middleware.RequestIDFromContext(ctx); requestID != "" {
fields = append(fields, zap.String("request_id", requestID))
}
s.logger.Error(msg, fields...)
}
func parseQdrantEndpoint(raw string) (string, int, bool, error) {
value := strings.TrimSpace(raw)
if value == "" {
return defaultQdrantHost, defaultQdrantGRPCPort, false, nil
}
if strings.Contains(value, "://") {
parsed, err := url.Parse(value)
if err != nil {
return "", 0, false, err
}
port := defaultQdrantGRPCPort
if parsed.Port() != "" {
port, err = strconv.Atoi(parsed.Port())
if err != nil {
return "", 0, false, err
}
}
host := parsed.Hostname()
if host == "" {
host = defaultQdrantHost
}
return host, port, parsed.Scheme == "https", nil
}
host, portText, err := net.SplitHostPort(value)
if err == nil {
port, convErr := strconv.Atoi(portText)
if convErr != nil {
return "", 0, false, convErr
}
return host, port, false, nil
}
if strings.Contains(err.Error(), "missing port in address") {
return value, defaultQdrantGRPCPort, false, nil
}
return "", 0, false, err
}
func float64SliceToFloat32(values []float64) []float32 {
result := make([]float32, 0, len(values))
for _, value := range values {
result = append(result, float32(value))
}
return result
}
func pointIDToString(id *sdk.PointId) string {
if id == nil {
return ""
}
if uuid := strings.TrimSpace(id.GetUuid()); uuid != "" {
return uuid
}
if num := id.GetNum(); num > 0 {
return strconv.FormatUint(num, 10)
}
return ""
}
func valueMapToAny(values map[string]*sdk.Value) map[string]any {
if len(values) == 0 {
return map[string]any{}
}
result := make(map[string]any, len(values))
for key, value := range values {
result[key] = valueToAny(value)
}
return result
}
func valueToAny(value *sdk.Value) any {
if value == nil {
return nil
}
switch value.GetKind().(type) {
case *sdk.Value_StringValue:
return value.GetStringValue()
case *sdk.Value_IntegerValue:
return value.GetIntegerValue()
case *sdk.Value_DoubleValue:
return value.GetDoubleValue()
case *sdk.Value_BoolValue:
return value.GetBoolValue()
case *sdk.Value_ListValue:
list := value.GetListValue()
if list == nil {
return []any{}
}
items := make([]any, 0, len(list.GetValues()))
for _, item := range list.GetValues() {
items = append(items, valueToAny(item))
}
return items
case *sdk.Value_StructValue:
structValue := value.GetStructValue()
if structValue == nil {
return map[string]any{}
}
return valueMapToAny(structValue.GetFields())
default:
return nil
}
}
func normalizeGroupIDs(ids []int64) []int64 {
result := make([]int64, 0, len(ids))
seen := make(map[int64]struct{}, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, id)
}
return result
}
func (s disabledVectorStore) Validate() error {
if s.reason == "" {
s.reason = "missing qdrant configuration"
}
return fmt.Errorf("%w: %s", ErrNotConfigured, s.reason)
}
func (s disabledVectorStore) EnsureCollection(context.Context, int) error {
return s.Validate()
}
func (s disabledVectorStore) Upsert(context.Context, []VectorPoint) error {
return s.Validate()
}
func (s disabledVectorStore) Search(context.Context, SearchRequest) ([]SearchPoint, error) {
return nil, s.Validate()
}
func (s disabledVectorStore) DeletePoints(context.Context, []string) error {
return s.Validate()
}