package milvus import ( "cmp" "context" "errors" "fmt" "maps" "os" "slices" "strconv" "strings" "github.com/google/uuid" "github.com/milvus-io/milvus/client/v2/column" "github.com/milvus-io/milvus/client/v2/entity" "github.com/milvus-io/milvus/client/v2/index" client "github.com/milvus-io/milvus/client/v2/milvusclient" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/types" "github.com/Tencent/WeKnora/internal/types/interfaces" ) const ( envMilvusCollection = "MILVUS_COLLECTION" envMilvusMetricType = "MILVUS_METRIC_TYPE" defaultCollectionName = "weknora_embeddings" fieldContent = "content" fieldSourceID = "source_id" fieldSourceType = "source_type" fieldChunkID = "chunk_id" fieldKnowledgeID = "knowledge_id" fieldKnowledgeBaseID = "knowledge_base_id" fieldTagID = "tag_id" fieldEmbedding = "embedding" fieldIsEnabled = "is_enabled" fieldID = "id" fieldContentSparse = "content_sparse" ) var ( allFields = []string{fieldID, fieldContent, fieldSourceID, fieldSourceType, fieldChunkID, fieldKnowledgeID, fieldKnowledgeBaseID, fieldTagID, fieldIsEnabled, fieldEmbedding} ) // NewMilvusRetrieveEngineRepository creates and initializes a new Milvus repository. // indexCfg is optional — pass nil to use env var / default values (env path). func NewMilvusRetrieveEngineRepository(client *client.Client, indexCfg *types.IndexConfig) interfaces.RetrieveEngineRepository { log := logger.GetLogger(context.Background()) log.Info("[Milvus] Initializing Milvus retriever engine repository") collectionBaseName := types.ResolveCollectionName(indexCfg, envMilvusCollection, defaultCollectionName) metricType := entity.IP if mt := os.Getenv(envMilvusMetricType); mt != "" { switch strings.ToUpper(mt) { case "COSINE": metricType = entity.COSINE case "L2": metricType = entity.L2 case "IP": metricType = entity.IP default: log.Warnf("[Milvus] Unknown MILVUS_METRIC_TYPE '%s', using default IP", mt) } } log.Infof("[Milvus] Using metric type: %s", metricType) res := &milvusRepository{ filter: filter{}, client: client, collectionBaseName: collectionBaseName, metricType: metricType, shardsNum: indexCfg.GetShardsNum(0), replicaNumber: indexCfg.GetReplicaNumber(0), } log.Info("[Milvus] Successfully initialized repository") return res } // getCollectionName returns the collection name for a specific dimension func (m *milvusRepository) getCollectionName(dimension int) string { return fmt.Sprintf("%s_%d", m.collectionBaseName, dimension) } // matchesCollection accepts only names produced by getCollectionName for a // positive dimension. A prefix alone also matches other tenants' namespaces, // migration backups and staging collections, which must not be read or updated. func matchesCollection(collectionName, collectionBaseName string) bool { suffix, ok := strings.CutPrefix(collectionName, collectionBaseName+"_") if !ok || collectionBaseName == "" { return false } dimension, err := strconv.Atoi(suffix) return err == nil && dimension > 0 && strconv.Itoa(dimension) == suffix } func newContentField() *entity.Field { return entity.NewField(). WithName(fieldContent). WithDataType(entity.FieldTypeVarChar). WithMaxLength(65535). WithEnableAnalyzer(true). WithAnalyzerParams(map[string]any{"type": "chinese"}). WithEnableMatch(true) } // ensureCollection ensures the collection exists for the given dimension func (m *milvusRepository) ensureCollection(ctx context.Context, dimension int) error { collectionName := m.getCollectionName(dimension) // Check cache first if _, ok := m.initializedCollections.Load(dimension); ok { return nil } log := logger.GetLogger(ctx) // Check if collection exists hasCollection, err := m.client.HasCollection(ctx, client.NewHasCollectionOption(collectionName)) if err != nil { log.Errorf("[Milvus] Failed to check collection existence: %v", err) return fmt.Errorf("failed to check collection existence: %w", err) } if !hasCollection { log.Infof("[Milvus] Creating collection %s with dimension %d", collectionName, dimension) // Define schema schema := &entity.Schema{ CollectionName: collectionName, Description: fmt.Sprintf("WeKnora embeddings collection with dimension %d", dimension), AutoID: false, Fields: []*entity.Field{ entity.NewField(). WithName(fieldID). WithDataType(entity.FieldTypeVarChar). WithIsPrimaryKey(true). WithMaxLength(1024), entity.NewField(). WithName(fieldEmbedding). WithDataType(entity.FieldTypeFloatVector). WithDim(int64(dimension)), newContentField(), entity.NewField(). WithName(fieldContentSparse). WithDataType(entity.FieldTypeSparseVector), entity.NewField(). WithName(fieldSourceID). WithDataType(entity.FieldTypeVarChar). WithMaxLength(255), entity.NewField(). WithName(fieldSourceType). WithDataType(entity.FieldTypeInt64), entity.NewField(). WithName(fieldChunkID). WithDataType(entity.FieldTypeVarChar). WithMaxLength(255), entity.NewField(). WithName(fieldKnowledgeID). WithDataType(entity.FieldTypeVarChar). WithMaxLength(255), entity.NewField(). WithName(fieldKnowledgeBaseID). WithDataType(entity.FieldTypeVarChar). WithMaxLength(255), entity.NewField(). WithName(fieldTagID). WithDataType(entity.FieldTypeVarChar). WithMaxLength(255), entity.NewField(). WithName(fieldIsEnabled). WithDataType(entity.FieldTypeBool), }, } // Add BM25 function for content sparse vector // ref: https://milvus.io/docs/zh/full-text-search.md schema.WithFunction(entity.NewFunction(). WithName("text_bm25_emb"). WithInputFields(fieldContent). WithOutputFields(fieldContentSparse). WithType(entity.FunctionTypeBM25)) indexOpts := make([]client.CreateIndexOption, 0) // hnsw index for embedding field indexOpts = append(indexOpts, client.NewCreateIndexOption(collectionName, fieldEmbedding, index.NewHNSWIndex(m.metricType, 16, 128))) indexOpts = append(indexOpts, client.NewCreateIndexOption(collectionName, fieldContentSparse, index.NewAutoIndex(entity.BM25))) // Create payload indexes for filtering indexFields := []string{fieldChunkID, fieldKnowledgeID, fieldKnowledgeBaseID, fieldSourceID, fieldIsEnabled} for _, fieldName := range indexFields { indexOpts = append(indexOpts, client.NewCreateIndexOption(collectionName, fieldName, index.NewAutoIndex(entity.IP))) } // Create collection createOpt := client.NewCreateCollectionOption(collectionName, schema).WithIndexOptions(indexOpts...) if m.shardsNum > 0 { createOpt = createOpt.WithShardNum(int32(m.shardsNum)) } err = m.client.CreateCollection(ctx, createOpt) if err != nil { log.Errorf("[Milvus] Failed to create collection: %v", err) return fmt.Errorf("failed to create collection: %w", err) } log.Infof("[Milvus] Successfully created collection %s", collectionName) } loadOpt := client.NewLoadCollectionOption(collectionName) if m.replicaNumber > 0 { loadOpt = loadOpt.WithReplica(m.replicaNumber) } loadTask, err := m.client.LoadCollection(ctx, loadOpt) if err != nil { log.Errorf("[Milvus] Failed to load collection: %v", err) return fmt.Errorf("failed to load collection: %w", err) } if err := loadTask.Await(ctx); err != nil { log.Errorf("[Milvus] Failed to await load collection: %v", err) return fmt.Errorf("failed to await load collection: %w", err) } // Mark as initialized m.initializedCollections.Store(dimension, true) return nil } func (m *milvusRepository) EngineType() types.RetrieverEngineType { return types.MilvusRetrieverEngineType } func (m *milvusRepository) Support() []types.RetrieverType { return []types.RetrieverType{types.KeywordsRetrieverType, types.VectorRetrieverType} } // EstimateStorageSize calculates the estimated storage size for a list of indices func (m *milvusRepository) EstimateStorageSize(ctx context.Context, indexInfoList []*types.IndexInfo, params map[string]any, ) int64 { var totalStorageSize int64 for _, embedding := range indexInfoList { embeddingDB := toMilvusVectorEmbedding(embedding, params) totalStorageSize += m.calculateStorageSize(embeddingDB) } logger.GetLogger(ctx).Infof( "[Milvus] Storage size for %d indices: %d bytes", len(indexInfoList), totalStorageSize, ) return totalStorageSize } // Save stores a single point in Milvus func (m *milvusRepository) Save(ctx context.Context, embedding *types.IndexInfo, additionalParams map[string]any, ) error { log := logger.GetLogger(ctx) log.Debugf("[Milvus] Saving index for chunk ID: %s", embedding.ChunkID) embeddingDB := toMilvusVectorEmbedding(embedding, additionalParams) if len(embeddingDB.Embedding) == 0 { err := fmt.Errorf("empty embedding vector for chunk ID: %s", embedding.ChunkID) log.Errorf("[Milvus] %v", err) return err } dimension := len(embeddingDB.Embedding) if err := m.ensureCollection(ctx, dimension); err != nil { return err } collectionName := m.getCollectionName(dimension) embeddingDB.ID = uuid.New().String() opts := createUpsert(collectionName, []*MilvusVectorEmbedding{embeddingDB}) _, err := m.client.Upsert(ctx, opts) if err != nil { log.Errorf("[Milvus] Failed to save index: %v", err) return err } log.Infof("[Milvus] Successfully saved index for chunk ID: %s", embedding.ChunkID) return nil } // BatchSave stores multiple points in Milvus using batch insert func (m *milvusRepository) BatchSave(ctx context.Context, embeddingList []*types.IndexInfo, additionalParams map[string]any, ) error { log := logger.GetLogger(ctx) if len(embeddingList) == 0 { log.Warn("[Milvus] Empty list provided to BatchSave, skipping") return nil } log.Infof("[Milvus] Batch saving %d indices", len(embeddingList)) // Group points by dimension embeddingsByDimension := make(map[int][]*types.IndexInfo) for _, embedding := range embeddingList { embeddingDB := toMilvusVectorEmbedding(embedding, additionalParams) if len(embeddingDB.Embedding) == 0 { log.Warnf("[Milvus] Skipping empty embedding for chunk ID: %s", embedding.ChunkID) continue } dimension := len(embeddingDB.Embedding) embeddingsByDimension[dimension] = append(embeddingsByDimension[dimension], embedding) log.Debugf("[Milvus] Added chunk ID %s to batch request (dimension: %d)", embedding.ChunkID, dimension) } if len(embeddingsByDimension) == 0 { log.Warn("[Milvus] No valid points to save after filtering") return nil } // Save points to each dimension-specific collection totalSaved := 0 for dimension, embeddings := range embeddingsByDimension { if err := m.ensureCollection(ctx, dimension); err != nil { return err } collectionName := m.getCollectionName(dimension) n := len(embeddings) embeddingDBList := make([]*MilvusVectorEmbedding, 0, n) for _, embedding := range embeddings { embeddingDB := toMilvusVectorEmbedding(embedding, additionalParams) embeddingDB.ID = uuid.New().String() embeddingDBList = append(embeddingDBList, embeddingDB) } opts := createUpsert(collectionName, embeddingDBList) _, err := m.client.Upsert(ctx, opts) if err != nil { log.Errorf("[Milvus] Failed to execute batch operation for dimension %d: %v", dimension, err) return fmt.Errorf("failed to batch save (dimension %d): %w", dimension, err) } totalSaved += n log.Infof("[Milvus] Saved %d points to collection %s", n, collectionName) } log.Infof("[Milvus] Successfully batch saved %d indices", totalSaved) return nil } // DeleteByChunkIDList removes points from the collection based on chunk IDs func (m *milvusRepository) DeleteByChunkIDList(ctx context.Context, chunkIDList []string, dimension int, knowledgeType string) error { log := logger.GetLogger(ctx) if len(chunkIDList) == 0 { log.Warn("[Milvus] Empty chunk ID list provided for deletion, skipping") return nil } collectionName := m.getCollectionName(dimension) log.Infof("[Milvus] Deleting indices by chunk IDs from %s, count: %d", collectionName, len(chunkIDList)) deleteOpt := client.NewDeleteOption(collectionName) deleteOpt.WithStringIDs(fieldChunkID, chunkIDList) _, err := m.client.Delete(ctx, deleteOpt) if err != nil { log.Errorf("[Milvus] Failed to delete by chunk IDs: %v", err) return fmt.Errorf("failed to delete by chunk IDs: %w", err) } log.Infof("[Milvus] Successfully deleted documents by chunk IDs") return nil } // DeleteByKnowledgeIDList removes points from the collection based on knowledge IDs func (m *milvusRepository) DeleteByKnowledgeIDList(ctx context.Context, knowledgeIDList []string, dimension int, knowledgeType string, ) error { log := logger.GetLogger(ctx) if len(knowledgeIDList) == 0 { log.Warn("[Milvus] Empty knowledge ID list provided for deletion, skipping") return nil } collectionName := m.getCollectionName(dimension) log.Infof("[Milvus] Deleting indices by knowledge IDs from %s, count: %d", collectionName, len(knowledgeIDList)) deleteOpt := client.NewDeleteOption(collectionName) deleteOpt.WithStringIDs(fieldKnowledgeID, knowledgeIDList) _, err := m.client.Delete(ctx, deleteOpt) if err != nil { log.Errorf("[Milvus] Failed to delete by knowledge IDs: %v", err) return fmt.Errorf("failed to delete by knowledge IDs: %w", err) } log.Infof("[Milvus] Successfully deleted documents by knowledge IDs") return nil } // DeleteBySourceIDList removes points from the collection based on source IDs func (m *milvusRepository) DeleteBySourceIDList(ctx context.Context, sourceIDList []string, dimension int, knowledgeType string, ) error { log := logger.GetLogger(ctx) if len(sourceIDList) == 0 { log.Warn("[Milvus] Empty source ID list provided for deletion, skipping") return nil } collectionName := m.getCollectionName(dimension) log.Infof("[Milvus] Deleting indices by source IDs from %s, count: %d", collectionName, len(sourceIDList)) deleteOpt := client.NewDeleteOption(collectionName) deleteOpt.WithStringIDs(fieldSourceID, sourceIDList) _, err := m.client.Delete(ctx, deleteOpt) if err != nil { log.Errorf("[Milvus] Failed to delete by source IDs: %v", err) return fmt.Errorf("failed to delete by source IDs: %w", err) } log.Infof("[Milvus] Successfully deleted documents by source IDs") return nil } // BatchUpdateChunkEnabledStatus updates the enabled status of chunks in batch func (m *milvusRepository) BatchUpdateChunkEnabledStatus(ctx context.Context, chunkStatusMap map[string]bool) error { log := logger.GetLogger(ctx) if len(chunkStatusMap) == 0 { log.Warn("[Milvus] Empty chunk status map provided, skipping") return nil } log.Infof("[Milvus] Batch updating chunk enabled status, count: %d", len(chunkStatusMap)) // Get all collections collections, err := m.client.ListCollections(ctx, client.NewListCollectionOption()) if err != nil { log.Errorf("[Milvus] Failed to list collections: %v", err) return fmt.Errorf("failed to list collections: %w", err) } // Group chunks by enabled status for batch updates enabledChunkIDs := make([]string, 0) disabledChunkIDs := make([]string, 0) for chunkID, enabled := range chunkStatusMap { if enabled { enabledChunkIDs = append(enabledChunkIDs, chunkID) } else { disabledChunkIDs = append(disabledChunkIDs, chunkID) } } // A disabled row in the primary DB must never remain searchable because an // index update failed silently. if err := updateChunkEnabledStatusInCollections( ctx, collections, m.collectionBaseName, enabledChunkIDs, disabledChunkIDs, m.updateChunkEnabledStatusInCollection, ); err != nil { log.Warnf("[Milvus] Failed to update chunk enabled status: %v", err) return err } log.Infof("[Milvus] Batch update chunk enabled status completed") return nil } func updateChunkEnabledStatusInCollections( ctx context.Context, collections []string, collectionBaseName string, enabledChunkIDs []string, disabledChunkIDs []string, update func(context.Context, string, []string, bool) error, ) error { var updateErrs []error for _, collectionName := range collections { if !matchesCollection(collectionName, collectionBaseName) { continue } if err := update(ctx, collectionName, enabledChunkIDs, true); err != nil { updateErrs = append(updateErrs, fmt.Errorf("update enabled chunks in %s: %w", collectionName, err)) } if err := update(ctx, collectionName, disabledChunkIDs, false); err != nil { updateErrs = append(updateErrs, fmt.Errorf("update disabled chunks in %s: %w", collectionName, err)) } } return errors.Join(updateErrs...) } func (m *milvusRepository) updateChunkEnabledStatusInCollection( ctx context.Context, collectionName string, chunkIDs []string, enabled bool, ) error { if len(chunkIDs) == 0 { return nil } embeddings, _, err := m.searchByFilter(ctx, collectionName, &universalFilterCondition{ Field: fieldChunkID, Operator: operatorIn, Value: chunkIDs, }, nil, nil) if err != nil { return err } upsertEmbeddings := make([]*MilvusVectorEmbedding, 0, len(embeddings)) for _, embedding := range embeddings { embedding.IsEnabled = enabled upsertEmbeddings = append(upsertEmbeddings, &embedding.MilvusVectorEmbedding) } if len(upsertEmbeddings) == 0 { return nil } req := createUpsert(collectionName, upsertEmbeddings) if _, err := m.client.Upsert(ctx, req); err != nil { return err } return nil } func (m *milvusRepository) searchByFilter(ctx context.Context, collectionName string, filter *universalFilterCondition, limit, offset *int) ([]*MilvusVectorEmbeddingWithScore, int, error) { params, err := m.filter.Convert(filter) if err != nil { return nil, 0, err } queryOpt := client.NewQueryOption(collectionName) if params.exprStr != "" { queryOpt.WithFilter(params.exprStr) for k, v := range params.params { queryOpt.WithTemplateParam(k, v) } } queryOpt.WithOutputFields(allFields...) if limit != nil { queryOpt.WithLimit(*limit) } if offset != nil { queryOpt.WithOffset(*offset) } resultSet, err := m.client.Query(ctx, queryOpt) if err != nil { return nil, 0, err } embeddings, _, err := convertResultSet([]client.ResultSet{resultSet}) if err != nil { return nil, 0, err } return embeddings, resultSet.ResultCount, nil } // BatchUpdateChunkTagID updates the tag ID of chunks in batch func (m *milvusRepository) BatchUpdateChunkTagID(ctx context.Context, chunkTagMap map[string]string) error { log := logger.GetLogger(ctx) if len(chunkTagMap) == 0 { log.Warn("[Milvus] Empty chunk tag map provided, skipping") return nil } log.Infof("[Milvus] Batch updating chunk tag ID, count: %d", len(chunkTagMap)) // Get all collections collections, err := m.client.ListCollections(ctx, client.NewListCollectionOption()) if err != nil { log.Errorf("[Milvus] Failed to list collections: %v", err) return fmt.Errorf("failed to list collections: %w", err) } // Group chunks by tag ID for batch updates tagGroups := make(map[string][]string) for chunkID, tagID := range chunkTagMap { tagGroups[tagID] = append(tagGroups[tagID], chunkID) } // Update in all matching collections for _, collectionName := range collections { if !matchesCollection(collectionName, m.collectionBaseName) { continue } // Update chunks for each tag ID for tagID, chunkIDs := range tagGroups { embeddings, _, err := m.searchByFilter(ctx, collectionName, &universalFilterCondition{ Field: fieldChunkID, Operator: operatorIn, Value: chunkIDs, }, nil, nil) if err != nil { log.Warnf("[Milvus] Failed to search chunks in %s: %v", collectionName, err) continue } upsertEmbeddings := make([]*MilvusVectorEmbedding, 0, len(embeddings)) for _, embedding := range embeddings { embedding.TagID = tagID upsertEmbeddings = append(upsertEmbeddings, &embedding.MilvusVectorEmbedding) } if len(upsertEmbeddings) > 0 { req := createUpsert(collectionName, upsertEmbeddings) _, err := m.client.Upsert(ctx, req) if err != nil { log.Warnf("[Milvus] Failed to update chunks in %s: %v", collectionName, err) continue } } } } log.Infof("[Milvus] Batch update chunk tag ID completed") return nil } func (m *milvusRepository) getBaseFilterForQuery(params types.RetrieveParams) (string, map[string]any, error) { filters := make([]*universalFilterCondition, 0) if len(params.KnowledgeBaseIDs) > 0 { filters = append(filters, &universalFilterCondition{ Field: fieldKnowledgeBaseID, Operator: operatorIn, Value: params.KnowledgeBaseIDs, }) } if len(params.KnowledgeIDs) > 0 { filters = append(filters, &universalFilterCondition{ Field: fieldKnowledgeID, Operator: operatorIn, Value: params.KnowledgeIDs, }) } if len(params.TagIDs) > 0 { filters = append(filters, &universalFilterCondition{ Field: fieldTagID, Operator: operatorIn, Value: params.TagIDs, }) } if len(params.ExcludeKnowledgeIDs) > 0 { filters = append(filters, &universalFilterCondition{ Field: fieldKnowledgeID, Operator: operatorNotIn, Value: params.ExcludeKnowledgeIDs, }) } if len(params.ExcludeChunkIDs) > 0 { filters = append(filters, &universalFilterCondition{ Field: fieldChunkID, Operator: operatorNotIn, Value: params.ExcludeChunkIDs, }) } filters = append(filters, &universalFilterCondition{ Field: fieldIsEnabled, Operator: operatorEqual, Value: true, }) if len(filters) == 0 { return "", nil, nil } f, err := m.filter.Convert(&universalFilterCondition{ Operator: operatorAnd, Value: filters, }) if err != nil { return "", nil, err } return f.exprStr, f.params, nil } // Retrieve dispatches the retrieval operation to the appropriate method based on retriever type func (m *milvusRepository) Retrieve(ctx context.Context, params types.RetrieveParams, ) ([]*types.RetrieveResult, error) { log := logger.GetLogger(ctx) log.Debugf("[Milvus] Processing retrieval request of type: %s", params.RetrieverType) switch params.RetrieverType { case types.VectorRetrieverType: return m.VectorRetrieve(ctx, params) case types.KeywordsRetrieverType: return m.KeywordsRetrieve(ctx, params) } err := fmt.Errorf("invalid retriever type: %v", params.RetrieverType) log.Errorf("[Milvus] %v", err) return nil, err } // VectorRetrieve performs vector similarity search func (m *milvusRepository) VectorRetrieve(ctx context.Context, params types.RetrieveParams, ) ([]*types.RetrieveResult, error) { log := logger.GetLogger(ctx) dimension := len(params.Embedding) log.Infof("[Milvus] Vector retrieval: dim=%d, topK=%d, threshold=%.4f", dimension, params.TopK, params.Threshold) // Get collection name based on embedding dimension collectionName := m.getCollectionName(dimension) // Check if collection exists hasCollection, err := m.client.HasCollection(ctx, client.NewHasCollectionOption(collectionName)) if err != nil { log.Errorf("[Milvus] Failed to check collection existence: %v", err) return nil, fmt.Errorf("failed to check collection: %w", err) } if !hasCollection { log.Warnf("[Milvus] Collection %s does not exist, returning empty results", collectionName) return buildRetrieveResult(nil, types.VectorRetrieverType), nil } expr, paramsMap, err := m.getBaseFilterForQuery(params) if err != nil { log.Errorf("[Milvus] Failed to build base filter: %v", err) return nil, fmt.Errorf("failed to build filter: %w", err) } var sp *index.CustomAnnParam if params.Threshold > 0 { ann := index.NewCustomAnnParam() ann.WithRadius(params.Threshold) sp = &ann } searchOption := client.NewSearchOption(collectionName, params.TopK, []entity.Vector{entity.FloatVector(params.Embedding)}) searchOption.WithANNSField(fieldEmbedding) if sp != nil { searchOption.WithAnnParam(sp) } if expr != "" { searchOption.WithFilter(expr) for k, v := range paramsMap { searchOption.WithTemplateParam(k, v) } } searchOption.WithOutputFields("*") resultSet, err := m.client.Search(ctx, searchOption) if err != nil { log.Errorf("[Milvus] Vector search failed: %v", err) return nil, fmt.Errorf("failed to search: %w", err) } sets, scores, err := convertResultSet(resultSet) if err != nil { log.Errorf("[Milvus] Failed to convert result set: %v", err) return nil, fmt.Errorf("failed to convert result set: %w", err) } var results []*types.IndexWithScore for i, set := range sets { set.Score = scores[i] results = append(results, fromMilvusVectorEmbedding(set.ID, set, types.MatchTypeEmbedding)) } if len(results) == 0 { log.Warnf("[Milvus] No vector matches found that meet threshold %.4f", params.Threshold) } else { log.Infof("[Milvus] Vector retrieval found %d results", len(results)) log.Debugf("[Milvus] Top result score: %.4f", results[0].Score) } return buildRetrieveResult(results, types.VectorRetrieverType), nil } // KeywordsRetrieve performs keyword-based search in document content func (m *milvusRepository) KeywordsRetrieve(ctx context.Context, params types.RetrieveParams, ) ([]*types.RetrieveResult, error) { log := logger.GetLogger(ctx) log.Infof("[Milvus] Performing keywords retrieval with query: %s, topK: %d", params.Query, params.TopK) // Get all collections collections, err := m.client.ListCollections(ctx, client.NewListCollectionOption()) if err != nil { log.Errorf("[Milvus] Failed to list collections: %v", err) return nil, fmt.Errorf("failed to list collections: %w", err) } return m.keywordsRetrieveInCollections(ctx, params, collections, func(ctx context.Context, option client.SearchOption) ([]client.ResultSet, error) { return m.client.Search(ctx, option) }) } func (m *milvusRepository) keywordsRetrieveInCollections( ctx context.Context, params types.RetrieveParams, collections []string, search func(context.Context, client.SearchOption) ([]client.ResultSet, error), ) ([]*types.RetrieveResult, error) { log := logger.GetLogger(ctx) if params.TopK <= 0 { return nil, fmt.Errorf("keyword retrieval topK must be positive") } expr, paramsMap, err := m.getBaseFilterForQuery(params) if err != nil { return nil, fmt.Errorf("failed to build keyword search filter: %w", err) } var allResults []*types.IndexWithScore var searchErrs []error successfulCollections := 0 // Search in all matching collections for _, collectionName := range collections { if !matchesCollection(collectionName, m.collectionBaseName) { continue } if err := ctx.Err(); err != nil { return nil, err } searchOpt := client.NewSearchOption(collectionName, params.TopK, []entity.Vector{entity.Text(params.Query)}) searchOpt.WithANNSField(fieldContentSparse) if expr != "" { searchOpt.WithFilter(expr) for k, v := range paramsMap { searchOpt.WithTemplateParam(k, v) } } searchOpt.WithOutputFields("*") resultSet, err := search(ctx, searchOpt) if err != nil { log.Errorf("[Milvus] Keywords search failed in %s: %v", collectionName, err) searchErrs = append(searchErrs, fmt.Errorf("search %s: %w", collectionName, err)) continue } sets, scores, err := convertResultSet(resultSet) if err == nil && len(scores) != len(sets) { err = fmt.Errorf("expected %d BM25 scores, received %d", len(sets), len(scores)) } if err != nil { log.Errorf("[Milvus] Failed to convert keyword results in %s: %v", collectionName, err) searchErrs = append(searchErrs, fmt.Errorf("convert keyword results in %s: %w", collectionName, err)) continue } successfulCollections++ for i, set := range sets { set.Score = scores[i] allResults = append(allResults, fromMilvusVectorEmbedding(set.ID, set, types.MatchTypeKeywords)) } } if err := ctx.Err(); err != nil { return nil, err } if successfulCollections == 0 && len(searchErrs) > 0 { return nil, fmt.Errorf("keyword retrieval failed in all %d matching Milvus collections: %w", len(searchErrs), errors.Join(searchErrs...)) } if len(searchErrs) > 0 { log.Warnf("[Milvus] Keyword retrieval is partial: %d collections succeeded, %d failed", successfulCollections, len(searchErrs)) } // BM25 scores are unbounded. Preserve them for rank fusion and merge all // dimensions before taking topK, instead of favoring collection listing order. slices.SortStableFunc(allResults, func(a, b *types.IndexWithScore) int { return cmp.Compare(b.Score, a.Score) }) if len(allResults) > params.TopK { allResults = allResults[:params.TopK] } if len(allResults) == 0 { log.Warnf("[Milvus] No keyword matches found for query: %s", params.Query) } else { log.Infof("[Milvus] Keywords retrieval found %d results", len(allResults)) } return buildRetrieveResult(allResults, types.KeywordsRetrieverType), nil } // CopyIndices copies index data from source knowledge base to target knowledge base func (m *milvusRepository) CopyIndices(ctx context.Context, sourceKnowledgeBaseID string, sourceToTargetKBIDMap map[string]string, sourceToTargetChunkIDMap map[string]string, targetKnowledgeBaseID string, dimension int, knowledgeType string, ) error { log := logger.GetLogger(ctx) log.Infof( "[Milvus] Copying indices from source knowledge base %s to target knowledge base %s, count: %d, dimension: %d", sourceKnowledgeBaseID, targetKnowledgeBaseID, len(sourceToTargetChunkIDMap), dimension, ) if len(sourceToTargetChunkIDMap) == 0 { log.Warn("[Milvus] Empty mapping, skipping copy") return nil } collectionName := m.getCollectionName(dimension) // Ensure target collection exists if err := m.ensureCollection(ctx, dimension); err != nil { return err } batchSize := 64 totalCopied := 0 var offset *int for { sourceEmbeddings, count, err := m.searchByFilter(ctx, collectionName, &universalFilterCondition{ Field: fieldKnowledgeBaseID, Operator: operatorEqual, Value: sourceKnowledgeBaseID, }, &batchSize, offset) if err != nil { log.Errorf("[Milvus] Failed to query source points: %v", err) return err } if len(sourceEmbeddings) == 0 { break } targetEmbeddings := make([]*MilvusVectorEmbedding, 0, len(sourceEmbeddings)) for _, sourceEmbedding := range sourceEmbeddings { sourceChunkID := sourceEmbedding.ChunkID sourceKnowledgeID := sourceEmbedding.KnowledgeID originalSourceID := sourceEmbedding.SourceID targetChunkID, ok := sourceToTargetChunkIDMap[sourceChunkID] if !ok { log.Warnf("[Milvus] Source chunk %s not found in target mapping, skipping", sourceChunkID) continue } targetKnowledgeID, ok := sourceToTargetKBIDMap[sourceKnowledgeID] if !ok { log.Warnf("[Milvus] Source knowledge %s not found in target mapping, skipping", sourceKnowledgeID) continue } var targetSourceID string if originalSourceID == sourceChunkID { targetSourceID = targetChunkID } else if strings.HasPrefix(originalSourceID, sourceChunkID+"-") { questionID := strings.TrimPrefix(originalSourceID, sourceChunkID+"-") targetSourceID = fmt.Sprintf("%s-%s", targetChunkID, questionID) } else { targetSourceID = uuid.New().String() } targetEmbedding := &MilvusVectorEmbedding{ ID: uuid.New().String(), Content: sourceEmbedding.Content, SourceID: targetSourceID, SourceType: sourceEmbedding.SourceType, ChunkID: targetChunkID, KnowledgeID: targetKnowledgeID, KnowledgeBaseID: targetKnowledgeBaseID, TagID: sourceEmbedding.TagID, Embedding: sourceEmbedding.Embedding, IsEnabled: sourceEmbedding.IsEnabled, } targetEmbeddings = append(targetEmbeddings, targetEmbedding) } if len(targetEmbeddings) > 0 { opts := createUpsert(collectionName, targetEmbeddings) _, err := m.client.Upsert(ctx, opts) if err != nil { log.Errorf("[Milvus] Failed to batch upsert target points: %v", err) return err } totalCopied += len(targetEmbeddings) log.Infof("[Milvus] Successfully copied batch, batch size: %d, total copied: %d", len(targetEmbeddings), totalCopied) } if count < batchSize { break } if offset == nil { offset = new(int) } *offset += count } log.Infof("[Milvus] Index copy completed, total copied: %d", totalCopied) return nil } func buildRetrieveResult(results []*types.IndexWithScore, retrieverType types.RetrieverType) []*types.RetrieveResult { return []*types.RetrieveResult{ { Results: results, RetrieverEngineType: types.MilvusRetrieverEngineType, RetrieverType: retrieverType, Error: nil, }, } } func (m *milvusRepository) calculateStorageSize(embedding *MilvusVectorEmbedding) int64 { // Payload fields payloadSizeBytes := int64(0) payloadSizeBytes += int64(len(embedding.Content)) // content string payloadSizeBytes += int64(len(embedding.SourceID)) // source_id string payloadSizeBytes += int64(len(embedding.ChunkID)) // chunk_id string payloadSizeBytes += int64(len(embedding.KnowledgeID)) // knowledge_id string payloadSizeBytes += int64(len(embedding.KnowledgeBaseID)) // knowledge_base_id string payloadSizeBytes += 8 // source_type int64 // Vector storage and index var vectorSizeBytes int64 = 0 var indexBytes int64 = 0 if embedding.Embedding != nil { dimensions := int64(len(embedding.Embedding)) vectorSizeBytes = dimensions * 4 // IVF_FLAT index: vectors are duplicated in inverted lists (grouped by cluster), // plus a small per-vector overhead for cluster assignment and list management. // The centroid table (nlist × dim × 4) is a shared structure and should NOT // be counted per-vector. indexBytes = vectorSizeBytes + 16 } // ID tracker and metadata: ~32 bytes per vector const metadataBytes int64 = 32 totalSizeBytes := payloadSizeBytes + vectorSizeBytes + indexBytes + metadataBytes return totalSizeBytes } // toMilvusVectorEmbedding converts IndexInfo to Milvus format func toMilvusVectorEmbedding(embedding *types.IndexInfo, additionalParams map[string]interface{}) *MilvusVectorEmbedding { vector := &MilvusVectorEmbedding{ Content: embedding.Content, SourceID: embedding.SourceID, SourceType: int(embedding.SourceType), ChunkID: embedding.ChunkID, KnowledgeID: embedding.KnowledgeID, KnowledgeBaseID: embedding.KnowledgeBaseID, TagID: embedding.TagID, IsEnabled: embedding.IsEnabled, } if additionalParams != nil && slices.Contains(slices.Collect(maps.Keys(additionalParams)), fieldEmbedding) { if embeddingMap, ok := additionalParams[fieldEmbedding].(map[string][]float32); ok { vector.Embedding = embeddingMap[embedding.SourceID] } } return vector } // fromMilvusVectorEmbedding converts Milvus result to IndexWithScore domain model func fromMilvusVectorEmbedding(id string, embedding *MilvusVectorEmbeddingWithScore, matchType types.MatchType, ) *types.IndexWithScore { return &types.IndexWithScore{ ID: id, SourceID: embedding.SourceID, SourceType: types.SourceType(embedding.SourceType), ChunkID: embedding.ChunkID, KnowledgeID: embedding.KnowledgeID, KnowledgeBaseID: embedding.KnowledgeBaseID, TagID: embedding.TagID, Content: embedding.Content, Score: embedding.Score, MatchType: matchType, } } func createUpsert(collectionName string, embeddings []*MilvusVectorEmbedding) client.UpsertOption { ids := make([]string, 0, len(embeddings)) embeddingsData := make([][]float32, 0, len(embeddings)) contents := make([]string, 0, len(embeddings)) sourceIDs := make([]string, 0, len(embeddings)) sourceTypes := make([]int64, 0, len(embeddings)) chunkIDs := make([]string, 0, len(embeddings)) knowledgeIDs := make([]string, 0, len(embeddings)) knowledgeBaseIDs := make([]string, 0, len(embeddings)) tagIDs := make([]string, 0, len(embeddings)) isEnableds := make([]bool, 0, len(embeddings)) var dimension int for _, embedding := range embeddings { ids = append(ids, embedding.ID) embeddingsData = append(embeddingsData, embedding.Embedding) contents = append(contents, embedding.Content) sourceIDs = append(sourceIDs, embedding.SourceID) sourceTypes = append(sourceTypes, int64(embedding.SourceType)) chunkIDs = append(chunkIDs, embedding.ChunkID) knowledgeIDs = append(knowledgeIDs, embedding.KnowledgeID) knowledgeBaseIDs = append(knowledgeBaseIDs, embedding.KnowledgeBaseID) tagIDs = append(tagIDs, embedding.TagID) isEnableds = append(isEnableds, embedding.IsEnabled) dimension = len(embedding.Embedding) } opt := client.NewColumnBasedInsertOption(collectionName). WithVarcharColumn(fieldID, ids). WithFloatVectorColumn(fieldEmbedding, dimension, embeddingsData). WithVarcharColumn(fieldContent, contents). WithVarcharColumn(fieldSourceID, sourceIDs). WithInt64Column(fieldSourceType, sourceTypes). WithVarcharColumn(fieldChunkID, chunkIDs). WithVarcharColumn(fieldKnowledgeID, knowledgeIDs). WithVarcharColumn(fieldKnowledgeBaseID, knowledgeBaseIDs). WithVarcharColumn(fieldTagID, tagIDs). WithBoolColumn(fieldIsEnabled, isEnableds) return opt } func convertResultSet(resultSet []client.ResultSet) ([]*MilvusVectorEmbeddingWithScore, []float64, error) { var results []*MilvusVectorEmbeddingWithScore var scores []float64 if len(resultSet) == 0 { return results, scores, nil } set := resultSet[0] if set.Err != nil { return nil, nil, set.Err } resultLen := set.Len() if resultLen == 0 { return results, scores, nil } for _, score := range set.Scores { scores = append(scores, float64(score)) } docs := make([]*MilvusVectorEmbeddingWithScore, 0, resultLen) for i := 0; i < resultLen; i++ { docs = append(docs, &MilvusVectorEmbeddingWithScore{}) } for _, field := range allFields { columns := set.GetColumn(field) if columns == nil || columns.Len() == 0 { continue } if field == fieldID { for i := 0; i < columns.Len(); i++ { val, err := columns.GetAsString(i) if err != nil { return nil, nil, err } docs[i].ID = val } } if field == fieldContent { for i := 0; i < columns.Len(); i++ { val, err := columns.GetAsString(i) if err != nil { return nil, nil, err } docs[i].Content = val } } if field == fieldSourceID { for i := 0; i < columns.Len(); i++ { val, err := columns.GetAsString(i) if err != nil { return nil, nil, err } docs[i].SourceID = val } } if field == fieldSourceType { for i := 0; i < columns.Len(); i++ { val, err := columns.GetAsInt64(i) if err != nil { return nil, nil, err } docs[i].SourceType = int(val) } } if field == fieldChunkID { for i := 0; i < columns.Len(); i++ { val, err := columns.GetAsString(i) if err != nil { return nil, nil, err } docs[i].ChunkID = val } } if field == fieldKnowledgeID { for i := 0; i < columns.Len(); i++ { val, err := columns.GetAsString(i) if err != nil { return nil, nil, err } docs[i].KnowledgeID = val } } if field == fieldKnowledgeBaseID { for i := 0; i < columns.Len(); i++ { val, err := columns.GetAsString(i) if err != nil { return nil, nil, err } docs[i].KnowledgeBaseID = val } } if field == fieldTagID { for i := 0; i < columns.Len(); i++ { val, err := columns.GetAsString(i) if err != nil { return nil, nil, err } docs[i].TagID = val } } if field == fieldIsEnabled { for i := 0; i < columns.Len(); i++ { val, err := columns.GetAsBool(i) if err != nil { return nil, nil, err } docs[i].IsEnabled = val } } if field == fieldEmbedding { for i := 0; i < columns.Len(); i++ { switch vectorColumn := columns.(type) { case *column.ColumnFloatVector: val, err := vectorColumn.Value(i) if err != nil { return nil, nil, fmt.Errorf("get vector failed: %w", err) } docs[i].Embedding = append([]float32(nil), val...) case *column.ColumnDoubleArray: val, err := vectorColumn.Value(i) if err != nil { return nil, nil, fmt.Errorf("get vector failed: %w", err) } docs[i].Embedding = make([]float32, len(val)) for j, v := range val { docs[i].Embedding[j] = float32(v) } default: return nil, nil, fmt.Errorf("unsupported embedding column type %T", columns) } } } } return docs, scores, nil }