Files
xinmi/internal/application/repository/retriever/qdrant/repository.go
T
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

996 lines
33 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package qdrant
import (
"context"
"fmt"
"strings"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/google/uuid"
"github.com/qdrant/go-client/qdrant"
)
const (
envQdrantCollection = "QDRANT_COLLECTION"
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"
)
// NewQdrantRetrieveEngineRepository creates and initializes a new Qdrant repository.
// indexCfg is optional — pass nil to use env var / default values (env path).
func NewQdrantRetrieveEngineRepository(client *qdrant.Client, indexCfg *types.IndexConfig) interfaces.RetrieveEngineRepository {
log := logger.GetLogger(context.Background())
log.Info("[Qdrant] Initializing Qdrant retriever engine repository")
collectionBaseName := types.ResolveCollectionName(indexCfg, envQdrantCollection, defaultCollectionName)
res := &qdrantRepository{
client: client,
collectionBaseName: collectionBaseName,
shardNumber: indexCfg.GetShardNumber(0),
replicationFactor: indexCfg.GetReplicationFactor(0),
}
log.Info("[Qdrant] Successfully initialized repository")
return res
}
// getCollectionName returns the collection name for a specific dimension
func (q *qdrantRepository) getCollectionName(dimension int) string {
return fmt.Sprintf("%s_%d", q.collectionBaseName, dimension)
}
// ensureCollection ensures the collection exists for the given dimension
func (q *qdrantRepository) ensureCollection(ctx context.Context, dimension int) error {
collectionName := q.getCollectionName(dimension)
// Check cache first
if _, ok := q.initializedCollections.Load(dimension); ok {
return nil
}
log := logger.GetLogger(ctx)
// Check if collection exists
exists, err := q.client.CollectionExists(ctx, collectionName)
if err != nil {
log.Errorf("[Qdrant] Failed to check collection existence: %v", err)
return fmt.Errorf("failed to check collection existence: %w", err)
}
if !exists {
log.Infof("[Qdrant] Creating collection %s with dimension %d", collectionName, dimension)
err = q.client.CreateCollection(ctx, &qdrant.CreateCollection{
CollectionName: collectionName,
VectorsConfig: qdrant.NewVectorsConfig(&qdrant.VectorParams{
Size: uint64(dimension),
Distance: qdrant.Distance_Cosine,
}),
ShardNumber: types.OptionalUint32(q.shardNumber),
ReplicationFactor: types.OptionalUint32(q.replicationFactor),
})
if err != nil {
log.Errorf("[Qdrant] Failed to create collection: %v", err)
return fmt.Errorf("failed to create collection: %w", err)
}
// Create payload indexes for filtering
indexFields := []string{fieldChunkID, fieldKnowledgeID, fieldKnowledgeBaseID, fieldSourceID}
for _, field := range indexFields {
_, err = q.client.CreateFieldIndex(ctx, &qdrant.CreateFieldIndexCollection{
CollectionName: collectionName,
FieldName: field,
FieldType: qdrant.FieldType_FieldTypeKeyword.Enum(),
})
if err != nil {
log.Warnf("[Qdrant] Failed to create index for field %s: %v", field, err)
}
}
// Create bool index for is_enabled
_, err = q.client.CreateFieldIndex(ctx, &qdrant.CreateFieldIndexCollection{
CollectionName: collectionName,
FieldName: fieldIsEnabled,
FieldType: qdrant.FieldType_FieldTypeBool.Enum(),
})
if err != nil {
log.Warnf("[Qdrant] Failed to create index for field %s: %v", fieldIsEnabled, err)
}
// Create text index for content (for keyword search) with multilingual tokenizer
// This supports Chinese, Japanese, Korean and other languages
lowercase := true
_, err = q.client.CreateFieldIndex(ctx, &qdrant.CreateFieldIndexCollection{
CollectionName: collectionName,
FieldName: fieldContent,
FieldType: qdrant.FieldType_FieldTypeText.Enum(),
FieldIndexParams: &qdrant.PayloadIndexParams{
IndexParams: &qdrant.PayloadIndexParams_TextIndexParams{
TextIndexParams: &qdrant.TextIndexParams{
Tokenizer: qdrant.TokenizerType_Multilingual,
Lowercase: &lowercase,
},
},
},
})
if err != nil {
log.Warnf("[Qdrant] Failed to create text index for content: %v", err)
}
log.Infof("[Qdrant] Successfully created collection %s", collectionName)
}
// Mark as initialized
q.initializedCollections.Store(dimension, true)
return nil
}
func (q *qdrantRepository) EngineType() types.RetrieverEngineType {
return types.QdrantRetrieverEngineType
}
func (q *qdrantRepository) Support() []types.RetrieverType {
return []types.RetrieverType{types.KeywordsRetrieverType, types.VectorRetrieverType}
}
// EstimateStorageSize calculates the estimated storage size for a list of indices
func (q *qdrantRepository) EstimateStorageSize(ctx context.Context,
indexInfoList []*types.IndexInfo, params map[string]any,
) int64 {
var totalStorageSize int64
for _, embedding := range indexInfoList {
embeddingDB := toQdrantVectorEmbedding(embedding, params)
totalStorageSize += q.calculateStorageSize(embeddingDB)
}
logger.GetLogger(ctx).Infof(
"[Qdrant] Storage size for %d indices: %d bytes", len(indexInfoList), totalStorageSize,
)
return totalStorageSize
}
// Save stores a single point in Qdrant
func (q *qdrantRepository) Save(ctx context.Context,
embedding *types.IndexInfo,
additionalParams map[string]any,
) error {
log := logger.GetLogger(ctx)
log.Debugf("[Qdrant] Saving index for chunk ID: %s", embedding.ChunkID)
embeddingDB := toQdrantVectorEmbedding(embedding, additionalParams)
if len(embeddingDB.Embedding) == 0 {
err := fmt.Errorf("empty embedding vector for chunk ID: %s", embedding.ChunkID)
log.Errorf("[Qdrant] %v", err)
return err
}
dimension := len(embeddingDB.Embedding)
if err := q.ensureCollection(ctx, dimension); err != nil {
return err
}
collectionName := q.getCollectionName(dimension)
pointID := uuid.New().String()
point := &qdrant.PointStruct{
Id: qdrant.NewID(pointID),
Vectors: qdrant.NewVectors(embeddingDB.Embedding...),
Payload: createPayload(embeddingDB),
}
_, err := q.client.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: collectionName,
Points: []*qdrant.PointStruct{point},
})
if err != nil {
log.Errorf("[Qdrant] Failed to save index: %v", err)
return fmt.Errorf("failed to save index for chunk ID %s: %w", embedding.ChunkID, err)
}
log.Infof("[Qdrant] Successfully saved index for chunk ID: %s, point ID: %s", embedding.ChunkID, pointID)
return nil
}
// BatchSave stores multiple points in Qdrant using batch upsert
func (q *qdrantRepository) BatchSave(ctx context.Context,
embeddingList []*types.IndexInfo, additionalParams map[string]any,
) error {
log := logger.GetLogger(ctx)
if len(embeddingList) == 0 {
log.Warn("[Qdrant] Empty list provided to BatchSave, skipping")
return nil
}
log.Infof("[Qdrant] Batch saving %d indices", len(embeddingList))
// Group points by dimension
pointsByDimension := make(map[int][]*qdrant.PointStruct)
for _, embedding := range embeddingList {
embeddingDB := toQdrantVectorEmbedding(embedding, additionalParams)
if len(embeddingDB.Embedding) == 0 {
log.Warnf("[Qdrant] Skipping empty embedding for chunk ID: %s", embedding.ChunkID)
continue
}
dimension := len(embeddingDB.Embedding)
point := &qdrant.PointStruct{
Id: qdrant.NewID(uuid.New().String()),
Vectors: qdrant.NewVectors(embeddingDB.Embedding...),
Payload: createPayload(embeddingDB),
}
pointsByDimension[dimension] = append(pointsByDimension[dimension], point)
log.Debugf("[Qdrant] Added chunk ID %s to batch request (dimension: %d)", embedding.ChunkID, dimension)
}
if len(pointsByDimension) == 0 {
log.Warn("[Qdrant] No valid points to save after filtering")
return nil
}
// Save points to each dimension-specific collection
totalSaved := 0
const batchSize = 100
for dimension, points := range pointsByDimension {
if err := q.ensureCollection(ctx, dimension); err != nil {
return err
}
collectionName := q.getCollectionName(dimension)
for i := 0; i < len(points); i += batchSize {
end := i + batchSize
if end > len(points) {
end = len(points)
}
batch := points[i:end]
_, err := q.client.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: collectionName,
Points: batch,
})
if err != nil {
return fmt.Errorf("failed to upsert batch: %w", err)
}
}
totalSaved += len(points)
log.Infof("[Qdrant] Saved %d points to collection %s", len(points), collectionName)
}
log.Infof("[Qdrant] Successfully batch saved %d indices", totalSaved)
return nil
}
// DeleteByChunkIDList removes points from the collection based on chunk IDs
func (q *qdrantRepository) DeleteByChunkIDList(ctx context.Context, chunkIDList []string, dimension int, knowledgeType string) error {
log := logger.GetLogger(ctx)
if len(chunkIDList) == 0 {
log.Warn("[Qdrant] Empty chunk ID list provided for deletion, skipping")
return nil
}
collectionName := q.getCollectionName(dimension)
log.Infof("[Qdrant] Deleting indices by chunk IDs from %s, count: %d", collectionName, len(chunkIDList))
_, err := q.client.Delete(ctx, &qdrant.DeletePoints{
CollectionName: collectionName,
Points: qdrant.NewPointsSelectorFilter(&qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatchKeywords(fieldChunkID, chunkIDList...),
},
}),
})
if err != nil {
log.Errorf("[Qdrant] Failed to delete by chunk IDs: %v", err)
return fmt.Errorf("failed to delete by chunk IDs: %w", err)
}
log.Infof("[Qdrant] Successfully deleted documents by chunk IDs")
return nil
}
// DeleteByKnowledgeIDList removes points from the collection based on knowledge IDs
func (q *qdrantRepository) DeleteByKnowledgeIDList(ctx context.Context,
knowledgeIDList []string, dimension int, knowledgeType string,
) error {
log := logger.GetLogger(ctx)
if len(knowledgeIDList) == 0 {
log.Warn("[Qdrant] Empty knowledge ID list provided for deletion, skipping")
return nil
}
collectionName := q.getCollectionName(dimension)
log.Infof("[Qdrant] Deleting indices by knowledge IDs from %s, count: %d", collectionName, len(knowledgeIDList))
_, err := q.client.Delete(ctx, &qdrant.DeletePoints{
CollectionName: collectionName,
Points: qdrant.NewPointsSelectorFilter(&qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatchKeywords(fieldKnowledgeID, knowledgeIDList...),
},
}),
})
if err != nil {
log.Errorf("[Qdrant] Failed to delete by knowledge IDs: %v", err)
return fmt.Errorf("failed to delete by knowledge IDs: %w", err)
}
log.Infof("[Qdrant] Successfully deleted documents by knowledge IDs")
return nil
}
// DeleteBySourceIDList removes points from the collection based on source IDs
func (q *qdrantRepository) DeleteBySourceIDList(ctx context.Context,
sourceIDList []string, dimension int, knowledgeType string,
) error {
log := logger.GetLogger(ctx)
if len(sourceIDList) == 0 {
log.Warn("[Qdrant] Empty source ID list provided for deletion, skipping")
return nil
}
collectionName := q.getCollectionName(dimension)
log.Infof("[Qdrant] Deleting indices by source IDs from %s, count: %d", collectionName, len(sourceIDList))
_, err := q.client.Delete(ctx, &qdrant.DeletePoints{
CollectionName: collectionName,
Points: qdrant.NewPointsSelectorFilter(&qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatchKeywords(fieldSourceID, sourceIDList...),
},
}),
})
if err != nil {
log.Errorf("[Qdrant] Failed to delete by source IDs: %v", err)
return fmt.Errorf("failed to delete by source IDs: %w", err)
}
log.Infof("[Qdrant] Successfully deleted documents by source IDs")
return nil
}
// BatchUpdateChunkEnabledStatus updates the enabled status of chunks in batch
// This method operates on all collections since dimension is not provided
func (q *qdrantRepository) BatchUpdateChunkEnabledStatus(ctx context.Context, chunkStatusMap map[string]bool) error {
log := logger.GetLogger(ctx)
if len(chunkStatusMap) == 0 {
log.Warn("[Qdrant] Empty chunk status map provided, skipping")
return nil
}
log.Infof("[Qdrant] Batch updating chunk enabled status, count: %d", len(chunkStatusMap))
// Get all collections that match our base name pattern
collections, err := q.client.ListCollections(ctx)
if err != nil {
log.Errorf("[Qdrant] 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)
}
}
// Update in all matching collections
for _, collectionName := range collections {
// Only process collections that start with our base name
if len(collectionName) <= len(q.collectionBaseName) ||
collectionName[:len(q.collectionBaseName)] != q.collectionBaseName {
continue
}
// Update enabled chunks
if len(enabledChunkIDs) > 0 {
_, err := q.client.SetPayload(ctx, &qdrant.SetPayloadPoints{
CollectionName: collectionName,
Payload: qdrant.NewValueMap(map[string]any{fieldIsEnabled: true}),
PointsSelector: qdrant.NewPointsSelectorFilter(&qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatchKeywords(fieldChunkID, enabledChunkIDs...),
},
}),
})
if err != nil {
log.Warnf("[Qdrant] Failed to update enabled chunks in %s: %v", collectionName, err)
}
}
// Update disabled chunks
if len(disabledChunkIDs) > 0 {
_, err := q.client.SetPayload(ctx, &qdrant.SetPayloadPoints{
CollectionName: collectionName,
Payload: qdrant.NewValueMap(map[string]any{fieldIsEnabled: false}),
PointsSelector: qdrant.NewPointsSelectorFilter(&qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatchKeywords(fieldChunkID, disabledChunkIDs...),
},
}),
})
if err != nil {
log.Warnf("[Qdrant] Failed to update disabled chunks in %s: %v", collectionName, err)
}
}
}
log.Infof("[Qdrant] Batch update chunk enabled status completed")
return nil
}
// BatchUpdateChunkTagID updates the tag ID of chunks in batch
func (q *qdrantRepository) BatchUpdateChunkTagID(ctx context.Context, chunkTagMap map[string]string) error {
log := logger.GetLogger(ctx)
if len(chunkTagMap) == 0 {
log.Warn("[Qdrant] Empty chunk tag map provided, skipping")
return nil
}
log.Infof("[Qdrant] Batch updating chunk tag ID, count: %d", len(chunkTagMap))
// Get all collections that match our base name pattern
collections, err := q.client.ListCollections(ctx)
if err != nil {
log.Errorf("[Qdrant] 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 {
// Only process collections that start with our base name
if len(collectionName) <= len(q.collectionBaseName) ||
collectionName[:len(q.collectionBaseName)] != q.collectionBaseName {
continue
}
// Update chunks for each tag ID
for tagID, chunkIDs := range tagGroups {
_, err := q.client.SetPayload(ctx, &qdrant.SetPayloadPoints{
CollectionName: collectionName,
Payload: qdrant.NewValueMap(map[string]any{fieldTagID: tagID}),
PointsSelector: qdrant.NewPointsSelectorFilter(&qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatchKeywords(fieldChunkID, chunkIDs...),
},
}),
})
if err != nil {
log.Warnf("[Qdrant] Failed to update chunks with tag_id %s in %s: %v", tagID, collectionName, err)
}
}
}
log.Infof("[Qdrant] Batch update chunk tag ID completed")
return nil
}
func (q *qdrantRepository) getBaseFilter(params types.RetrieveParams) *qdrant.Filter {
must := make([]*qdrant.Condition, 0)
mustNot := make([]*qdrant.Condition, 0)
// Only retrieve enabled chunks
must = append(must, qdrant.NewMatchBool(fieldIsEnabled, true))
// KnowledgeBaseIDs and KnowledgeIDs use AND logic
// - If only KnowledgeBaseIDs: search entire knowledge bases
// - If only KnowledgeIDs: search specific documents
// - If both: search specific documents within the knowledge bases (AND)
if len(params.KnowledgeBaseIDs) > 0 {
must = append(must, qdrant.NewMatchKeywords(fieldKnowledgeBaseID, params.KnowledgeBaseIDs...))
}
if len(params.KnowledgeIDs) > 0 {
must = append(must, qdrant.NewMatchKeywords(fieldKnowledgeID, params.KnowledgeIDs...))
}
// Filter by tag IDs if specified
if len(params.TagIDs) > 0 {
must = append(must, qdrant.NewMatchKeywords(fieldTagID, params.TagIDs...))
}
if len(params.ExcludeKnowledgeIDs) > 0 {
mustNot = append(mustNot, qdrant.NewMatchKeywords(fieldKnowledgeID, params.ExcludeKnowledgeIDs...))
}
if len(params.ExcludeChunkIDs) > 0 {
mustNot = append(mustNot, qdrant.NewMatchKeywords(fieldChunkID, params.ExcludeChunkIDs...))
}
filter := &qdrant.Filter{
Must: must,
MustNot: mustNot,
}
return filter
}
// Retrieve dispatches the retrieval operation to the appropriate method based on retriever type
func (q *qdrantRepository) Retrieve(ctx context.Context,
params types.RetrieveParams,
) ([]*types.RetrieveResult, error) {
log := logger.GetLogger(ctx)
log.Debugf("[Qdrant] Processing retrieval request of type: %s", params.RetrieverType)
switch params.RetrieverType {
case types.VectorRetrieverType:
return q.VectorRetrieve(ctx, params)
case types.KeywordsRetrieverType:
return q.KeywordsRetrieve(ctx, params)
}
err := fmt.Errorf("invalid retriever type: %v", params.RetrieverType)
log.Errorf("[Qdrant] %v", err)
return nil, err
}
// VectorRetrieve performs vector similarity search
func (q *qdrantRepository) VectorRetrieve(ctx context.Context,
params types.RetrieveParams,
) ([]*types.RetrieveResult, error) {
log := logger.GetLogger(ctx)
dimension := len(params.Embedding)
log.Infof("[Qdrant] Vector retrieval: dim=%d, topK=%d, threshold=%.4f",
dimension, params.TopK, params.Threshold)
// Get collection name based on embedding dimension
collectionName := q.getCollectionName(dimension)
// Check if collection exists
exists, err := q.client.CollectionExists(ctx, collectionName)
if err != nil {
log.Errorf("[Qdrant] Failed to check collection existence: %v", err)
return nil, fmt.Errorf("failed to check collection: %w", err)
}
if !exists {
log.Warnf("[Qdrant] Collection %s does not exist, returning empty results", collectionName)
return buildRetrieveResult(nil, types.VectorRetrieverType), nil
}
filter := q.getBaseFilter(params)
limit := uint64(params.TopK)
scoreThreshold := float32(params.Threshold)
searchResult, err := q.client.Query(ctx, &qdrant.QueryPoints{
CollectionName: collectionName,
Query: qdrant.NewQuery(params.Embedding...),
Filter: filter,
Limit: &limit,
ScoreThreshold: &scoreThreshold,
WithPayload: qdrant.NewWithPayload(true),
})
if err != nil {
log.Errorf("[Qdrant] Vector search failed: %v", err)
return nil, fmt.Errorf("%s: %w", collectionName, err)
}
var results []*types.IndexWithScore
for _, point := range searchResult {
payload := point.Payload
embedding := &QdrantVectorEmbeddingWithScore{
QdrantVectorEmbedding: QdrantVectorEmbedding{
Content: payload[fieldContent].GetStringValue(),
SourceID: payload[fieldSourceID].GetStringValue(),
SourceType: int(payload[fieldSourceType].GetIntegerValue()),
ChunkID: payload[fieldChunkID].GetStringValue(),
KnowledgeID: payload[fieldKnowledgeID].GetStringValue(),
KnowledgeBaseID: payload[fieldKnowledgeBaseID].GetStringValue(),
TagID: payload[fieldTagID].GetStringValue(),
},
Score: float64(point.Score),
}
pointID := point.Id.GetUuid()
results = append(results, fromQdrantVectorEmbedding(pointID, embedding, types.MatchTypeEmbedding))
}
if len(results) == 0 {
log.Warnf("[Qdrant] No vector matches found that meet threshold %.4f", params.Threshold)
} else {
log.Infof("[Qdrant] Vector retrieval found %d results", len(results))
log.Debugf("[Qdrant] Top result score: %.4f", results[0].Score)
}
return buildRetrieveResult(results, types.VectorRetrieverType), nil
}
// KeywordsRetrieve performs keyword-based search in document content
// This searches across all collections since keyword search doesn't depend on dimension
func (q *qdrantRepository) KeywordsRetrieve(ctx context.Context,
params types.RetrieveParams,
) ([]*types.RetrieveResult, error) {
log := logger.GetLogger(ctx)
log.Infof("[Qdrant] Performing keywords retrieval with query: %s, topK: %d", params.Query, params.TopK)
// Get all collections that match our base name pattern
collections, err := q.client.ListCollections(ctx)
if err != nil {
log.Errorf("[Qdrant] Failed to list collections: %v", err)
return nil, fmt.Errorf("failed to list collections: %w", err)
}
var allResults []*types.IndexWithScore
limit := uint32(params.TopK)
log.Debugf("[Qdrant] Found %d collections, base name: %s", len(collections), q.collectionBaseName)
// Tokenize query for OR-based search (better for Chinese and multi-word queries)
queryTokens := tokenizeQuery(params.Query)
log.Debugf("[Qdrant] Tokenized query into %d tokens: %v", len(queryTokens), queryTokens)
// Search in all matching collections
for _, collectionName := range collections {
log.Debugf("[Qdrant] Checking collection: %s", collectionName)
// Only process collections that start with our base name
if len(collectionName) <= len(q.collectionBaseName) ||
collectionName[:len(q.collectionBaseName)] != q.collectionBaseName {
log.Debugf("[Qdrant] Skipping collection %s (doesn't match base name %s)", collectionName, q.collectionBaseName)
continue
}
filter := q.getBaseFilter(params)
// Build should conditions for each token (OR logic)
// This allows matching documents that contain any of the query tokens
if len(queryTokens) > 0 {
shouldConditions := make([]*qdrant.Condition, 0, len(queryTokens))
for _, token := range queryTokens {
shouldConditions = append(shouldConditions, qdrant.NewMatchText(fieldContent, token))
}
filter.Should = shouldConditions
} else {
// Fallback to original query if tokenization fails
filter.Must = append(filter.Must, qdrant.NewMatchText(fieldContent, params.Query))
}
log.Debugf("[Qdrant] Searching in collection %s with %d should conditions", collectionName, len(filter.Should))
scrollResult, err := q.client.Scroll(ctx, &qdrant.ScrollPoints{
CollectionName: collectionName,
Filter: filter,
Limit: &limit,
WithPayload: qdrant.NewWithPayload(true),
})
if err != nil {
log.Warnf("[Qdrant] Keywords search failed in %s: %v", collectionName, err)
continue
}
log.Debugf("[Qdrant] Found %d results in collection %s", len(scrollResult), collectionName)
for _, point := range scrollResult {
payload := point.Payload
embedding := &QdrantVectorEmbeddingWithScore{
QdrantVectorEmbedding: QdrantVectorEmbedding{
Content: payload[fieldContent].GetStringValue(),
SourceID: payload[fieldSourceID].GetStringValue(),
SourceType: int(payload[fieldSourceType].GetIntegerValue()),
ChunkID: payload[fieldChunkID].GetStringValue(),
KnowledgeID: payload[fieldKnowledgeID].GetStringValue(),
KnowledgeBaseID: payload[fieldKnowledgeBaseID].GetStringValue(),
TagID: payload[fieldTagID].GetStringValue(),
},
Score: 1.0,
}
pointID := point.Id.GetUuid()
allResults = append(allResults, fromQdrantVectorEmbedding(pointID, embedding, types.MatchTypeKeywords))
}
}
// Limit results to topK
if len(allResults) > params.TopK {
allResults = allResults[:params.TopK]
}
if len(allResults) == 0 {
log.Warnf("[Qdrant] No keyword matches found for query: %s", params.Query)
} else {
log.Infof("[Qdrant] 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 (q *qdrantRepository) 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(
"[Qdrant] 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("[Qdrant] Empty mapping, skipping copy")
return nil
}
collectionName := q.getCollectionName(dimension)
// Ensure target collection exists
if err := q.ensureCollection(ctx, dimension); err != nil {
return err
}
batchSize := uint32(64)
var offset *qdrant.PointId = nil
totalCopied := 0
for {
scrollResult, err := q.client.Scroll(ctx, &qdrant.ScrollPoints{
CollectionName: collectionName,
Filter: &qdrant.Filter{
Must: []*qdrant.Condition{
qdrant.NewMatch(fieldKnowledgeBaseID, sourceKnowledgeBaseID),
},
},
Limit: &batchSize,
Offset: offset,
WithPayload: qdrant.NewWithPayload(true),
WithVectors: qdrant.NewWithVectors(true),
})
if err != nil {
log.Errorf("[Qdrant] Failed to query source points: %v", err)
return err
}
pointsCount := len(scrollResult)
if pointsCount == 0 {
break
}
log.Infof("[Qdrant] Found %d source points in batch", pointsCount)
targetPoints := make([]*qdrant.PointStruct, 0, pointsCount)
for _, sourcePoint := range scrollResult {
payload := sourcePoint.Payload
sourceChunkID := payload[fieldChunkID].GetStringValue()
sourceKnowledgeID := payload[fieldKnowledgeID].GetStringValue()
originalSourceID := payload[fieldSourceID].GetStringValue()
targetChunkID, ok := sourceToTargetChunkIDMap[sourceChunkID]
if !ok {
log.Warnf("[Qdrant] Source chunk %s not found in target mapping, skipping", sourceChunkID)
continue
}
targetKnowledgeID, ok := sourceToTargetKBIDMap[sourceKnowledgeID]
if !ok {
log.Warnf("[Qdrant] Source knowledge %s not found in target mapping, skipping", sourceKnowledgeID)
continue
}
// Handle SourceID transformation for generated questions
// Generated questions have SourceID format: {chunkID}-{questionID}
// Regular chunks have SourceID == ChunkID
var targetSourceID string
if originalSourceID == sourceChunkID {
// Regular chunk, use targetChunkID as SourceID
targetSourceID = targetChunkID
} else if strings.HasPrefix(originalSourceID, sourceChunkID+"-") {
// This is a generated question, preserve the questionID part
questionID := strings.TrimPrefix(originalSourceID, sourceChunkID+"-")
targetSourceID = fmt.Sprintf("%s-%s", targetChunkID, questionID)
} else {
// For other complex scenarios, generate new unique SourceID
targetSourceID = uuid.New().String()
}
isEnabled := true
if v, ok := payload[fieldIsEnabled]; ok {
isEnabled = v.GetBoolValue()
}
newPayload := qdrant.NewValueMap(map[string]any{
fieldContent: payload[fieldContent].GetStringValue(),
fieldSourceID: targetSourceID,
fieldSourceType: payload[fieldSourceType].GetIntegerValue(),
fieldChunkID: targetChunkID,
fieldKnowledgeID: targetKnowledgeID,
fieldKnowledgeBaseID: targetKnowledgeBaseID,
fieldTagID: payload[fieldTagID].GetStringValue(),
fieldIsEnabled: isEnabled,
})
var vectors *qdrant.Vectors
if vectorOutput := sourcePoint.Vectors.GetVector(); vectorOutput != nil {
if denseVector := vectorOutput.GetDenseVector(); denseVector != nil {
vectors = qdrant.NewVectors(denseVector.Data...)
}
}
if vectors == nil {
log.Warnf("[Qdrant] No vectors found for source point with chunk %s, skipping", sourceChunkID)
continue
}
newPoint := &qdrant.PointStruct{
Id: qdrant.NewID(uuid.New().String()),
Vectors: vectors,
Payload: newPayload,
}
targetPoints = append(targetPoints, newPoint)
}
if len(targetPoints) > 0 {
_, err := q.client.Upsert(ctx, &qdrant.UpsertPoints{
CollectionName: collectionName,
Points: targetPoints,
})
if err != nil {
log.Errorf("[Qdrant] Failed to batch upsert target points: %v", err)
return fmt.Errorf("failed to batch upsert target points during copy: %w", err)
}
totalCopied += len(targetPoints)
log.Infof("[Qdrant] Successfully copied batch, batch size: %d, total copied: %d",
len(targetPoints), totalCopied)
}
if pointsCount > 0 {
offset = scrollResult[pointsCount-1].Id
}
if pointsCount < int(batchSize) {
break
}
}
log.Infof("[Qdrant] Index copy completed, total copied: %d", totalCopied)
return nil
}
func createPayload(embedding *QdrantVectorEmbedding) map[string]*qdrant.Value {
payload := map[string]any{
fieldContent: embedding.Content,
fieldSourceID: embedding.SourceID,
fieldSourceType: int64(embedding.SourceType),
fieldChunkID: embedding.ChunkID,
fieldKnowledgeID: embedding.KnowledgeID,
fieldKnowledgeBaseID: embedding.KnowledgeBaseID,
fieldTagID: embedding.TagID,
fieldIsEnabled: embedding.IsEnabled,
}
return qdrant.NewValueMap(payload)
}
func buildRetrieveResult(results []*types.IndexWithScore, retrieverType types.RetrieverType) []*types.RetrieveResult {
return []*types.RetrieveResult{
{
Results: results,
RetrieverEngineType: types.QdrantRetrieverEngineType,
RetrieverType: retrieverType,
Error: nil,
},
}
}
// Ref: https://github.com/qdrant/qdrant-sizing-calculator
func (q *qdrantRepository) calculateStorageSize(embedding *QdrantVectorEmbedding) 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 hnswIndexBytes int64 = 0
if embedding.Embedding != nil {
dimensions := int64(len(embedding.Embedding))
vectorSizeBytes = dimensions * 4
// HNSW graph links per vector: M×2 neighbors in layer 0, ~8 bytes per link
// (4 bytes PointOffsetType + multi-layer amortization).
// Graph link count depends on M, NOT on vector dimensions.
const hnswM = 16
hnswIndexBytes = hnswM * 2 * 8
}
// ID tracker metadata: 24 bytes per vector
// (forward refs + backward refs + version tracking = 8 + 8 + 8)
const idTrackerBytes int64 = 24
totalSizeBytes := payloadSizeBytes + vectorSizeBytes + hnswIndexBytes + idTrackerBytes
return totalSizeBytes
}
// toQdrantVectorEmbedding converts IndexInfo to Qdrant payload format
func toQdrantVectorEmbedding(embedding *types.IndexInfo, additionalParams map[string]interface{}) *QdrantVectorEmbedding {
vector := &QdrantVectorEmbedding{
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 {
if val, exists := additionalParams[fieldEmbedding]; exists {
if embeddingMap, ok := val.(map[string][]float32); ok {
vector.Embedding = embeddingMap[embedding.SourceID]
}
}
}
return vector
}
// fromQdrantVectorEmbedding converts Qdrant point to IndexWithScore domain model
func fromQdrantVectorEmbedding(id string,
embedding *QdrantVectorEmbeddingWithScore,
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,
}
}
// tokenizeQuery splits a query string into tokens for OR-based full-text search.
// It uses jieba for professional Chinese word segmentation.
func tokenizeQuery(query string) []string {
query = strings.TrimSpace(query)
if query == "" {
return nil
}
// Use jieba for segmentation (search mode for better recall)
words := types.Jieba.CutForSearch(query, true)
// Filter and deduplicate
seen := make(map[string]bool)
result := make([]string, 0, len(words))
for _, word := range words {
word = strings.TrimSpace(strings.ToLower(word))
// Skip empty, single-char, and already seen words
if utf8.RuneCountInString(word) < 2 || seen[word] {
continue
}
seen[word] = true
result = append(result, word)
}
return result
}