1226 lines
40 KiB
Go
1226 lines
40 KiB
Go
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
|
|||
|
|
}
|