Files
Enterprise-Knowledge-Base/internal/application/service/knowledgebase.go
T
wizardchen 4a9977b11f feat: enhance document loading and state management in doc-content component
- Introduced a loading state (`loadingChunks`) to manage document chunk loading more effectively.
- Updated the scroll handling logic to prevent multiple requests while loading chunks.
- Adjusted the pagination logic to ensure correct page increments based on the total document count.

This update improves the user experience by ensuring smoother document loading and better state management during scrolling interactions.
2026-03-02 21:21:49 +08:00

1473 lines
49 KiB
Go

package service
import (
"context"
"encoding/json"
"errors"
"slices"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/application/service/retriever"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/models/embedding"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/google/uuid"
"github.com/hibiken/asynq"
)
// ErrInvalidTenantID represents an error for invalid tenant ID
var ErrInvalidTenantID = errors.New("invalid tenant ID")
// knowledgeBaseService implements the knowledge base service interface
type knowledgeBaseService struct {
repo interfaces.KnowledgeBaseRepository
kgRepo interfaces.KnowledgeRepository
chunkRepo interfaces.ChunkRepository
shareRepo interfaces.KBShareRepository
kbShareService interfaces.KBShareService
modelService interfaces.ModelService
retrieveEngine interfaces.RetrieveEngineRegistry
tenantRepo interfaces.TenantRepository
fileSvc interfaces.FileService
graphEngine interfaces.RetrieveGraphRepository
asynqClient interfaces.TaskEnqueuer
}
// NewKnowledgeBaseService creates a new knowledge base service
func NewKnowledgeBaseService(repo interfaces.KnowledgeBaseRepository,
kgRepo interfaces.KnowledgeRepository,
chunkRepo interfaces.ChunkRepository,
shareRepo interfaces.KBShareRepository,
kbShareService interfaces.KBShareService,
modelService interfaces.ModelService,
retrieveEngine interfaces.RetrieveEngineRegistry,
tenantRepo interfaces.TenantRepository,
fileSvc interfaces.FileService,
graphEngine interfaces.RetrieveGraphRepository,
asynqClient interfaces.TaskEnqueuer,
) interfaces.KnowledgeBaseService {
return &knowledgeBaseService{
repo: repo,
kgRepo: kgRepo,
chunkRepo: chunkRepo,
shareRepo: shareRepo,
kbShareService: kbShareService,
modelService: modelService,
retrieveEngine: retrieveEngine,
tenantRepo: tenantRepo,
fileSvc: fileSvc,
graphEngine: graphEngine,
asynqClient: asynqClient,
}
}
// GetRepository gets the knowledge base repository
// Parameters:
// - ctx: Context with authentication and request information
//
// Returns:
// - interfaces.KnowledgeBaseRepository: Knowledge base repository
func (s *knowledgeBaseService) GetRepository() interfaces.KnowledgeBaseRepository {
return s.repo
}
// CreateKnowledgeBase creates a new knowledge base
func (s *knowledgeBaseService) CreateKnowledgeBase(ctx context.Context,
kb *types.KnowledgeBase,
) (*types.KnowledgeBase, error) {
// Generate UUID and set creation timestamps
if kb.ID == "" {
kb.ID = uuid.New().String()
}
kb.CreatedAt = time.Now()
kb.TenantID = types.MustTenantIDFromContext(ctx)
kb.UpdatedAt = time.Now()
kb.EnsureDefaults()
logger.Infof(ctx, "Creating knowledge base, ID: %s, tenant ID: %d, name: %s", kb.ID, kb.TenantID, kb.Name)
if err := s.repo.CreateKnowledgeBase(ctx, kb); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": kb.ID,
"tenant_id": kb.TenantID,
})
return nil, err
}
logger.Infof(ctx, "Knowledge base created successfully, ID: %s, name: %s", kb.ID, kb.Name)
return kb, nil
}
// GetKnowledgeBaseByID retrieves a knowledge base by its ID
func (s *knowledgeBaseService) GetKnowledgeBaseByID(ctx context.Context, id string) (*types.KnowledgeBase, error) {
if id == "" {
logger.Error(ctx, "Knowledge base ID is empty")
return nil, errors.New("knowledge base ID cannot be empty")
}
kb, err := s.repo.GetKnowledgeBaseByID(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
})
return nil, err
}
kb.EnsureDefaults()
return kb, nil
}
// GetKnowledgeBaseByIDOnly retrieves knowledge base by ID without tenant filter
// Used for cross-tenant shared KB access where permission is checked elsewhere
func (s *knowledgeBaseService) GetKnowledgeBaseByIDOnly(ctx context.Context, id string) (*types.KnowledgeBase, error) {
if id == "" {
logger.Error(ctx, "Knowledge base ID is empty")
return nil, errors.New("knowledge base ID cannot be empty")
}
kb, err := s.repo.GetKnowledgeBaseByID(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
})
return nil, err
}
kb.EnsureDefaults()
return kb, nil
}
// GetKnowledgeBasesByIDsOnly retrieves knowledge bases by IDs without tenant filter (batch).
func (s *knowledgeBaseService) GetKnowledgeBasesByIDsOnly(ctx context.Context, ids []string) ([]*types.KnowledgeBase, error) {
if len(ids) == 0 {
return nil, nil
}
kbs, err := s.repo.GetKnowledgeBaseByIDs(ctx, ids)
if err != nil {
return nil, err
}
for _, kb := range kbs {
if kb != nil {
kb.EnsureDefaults()
}
}
return kbs, nil
}
// ListKnowledgeBases returns all knowledge bases for a tenant
func (s *knowledgeBaseService) ListKnowledgeBases(ctx context.Context) ([]*types.KnowledgeBase, error) {
tenantID := types.MustTenantIDFromContext(ctx)
kbs, err := s.repo.ListKnowledgeBasesByTenantID(ctx, tenantID)
if err != nil {
for _, kb := range kbs {
kb.EnsureDefaults()
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": tenantID,
})
return nil, err
}
// Query knowledge count and chunk count for each knowledge base
for _, kb := range kbs {
kb.EnsureDefaults()
// Get knowledge count
switch kb.Type {
case types.KnowledgeBaseTypeDocument:
knowledgeCount, err := s.kgRepo.CountKnowledgeByKnowledgeBaseID(ctx, tenantID, kb.ID)
if err != nil {
logger.Warnf(ctx, "Failed to get knowledge count for knowledge base %s: %v", kb.ID, err)
} else {
kb.KnowledgeCount = knowledgeCount
}
case types.KnowledgeBaseTypeFAQ:
// Get chunk count
chunkCount, err := s.chunkRepo.CountChunksByKnowledgeBaseID(ctx, tenantID, kb.ID)
if err != nil {
logger.Warnf(ctx, "Failed to get chunk count for knowledge base %s: %v", kb.ID, err)
} else {
kb.ChunkCount = chunkCount
}
}
// Check if there is a processing import task
processingCount, err := s.kgRepo.CountKnowledgeByStatus(
ctx,
tenantID,
kb.ID,
[]string{"pending", "processing"},
)
if err != nil {
logger.Warnf(ctx, "Failed to check processing status for knowledge base %s: %v", kb.ID, err)
} else {
kb.IsProcessing = processingCount > 0
kb.ProcessingCount = processingCount
}
}
return kbs, nil
}
// ListKnowledgeBasesByTenantID returns all knowledge bases for the given tenant (e.g. for shared agent context).
func (s *knowledgeBaseService) ListKnowledgeBasesByTenantID(ctx context.Context, tenantID uint64) ([]*types.KnowledgeBase, error) {
kbs, err := s.repo.ListKnowledgeBasesByTenantID(ctx, tenantID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": tenantID,
})
return nil, err
}
for _, kb := range kbs {
kb.EnsureDefaults()
switch kb.Type {
case types.KnowledgeBaseTypeDocument:
if cnt, err := s.kgRepo.CountKnowledgeByKnowledgeBaseID(ctx, tenantID, kb.ID); err == nil {
kb.KnowledgeCount = cnt
}
case types.KnowledgeBaseTypeFAQ:
if cnt, err := s.chunkRepo.CountChunksByKnowledgeBaseID(ctx, tenantID, kb.ID); err == nil {
kb.ChunkCount = cnt
}
}
if processingCount, err := s.kgRepo.CountKnowledgeByStatus(ctx, tenantID, kb.ID, []string{"pending", "processing"}); err == nil {
kb.IsProcessing = processingCount > 0
kb.ProcessingCount = processingCount
}
}
return kbs, nil
}
// FillKnowledgeBaseCounts fills KnowledgeCount, ChunkCount, IsProcessing, ProcessingCount for the given KB using kb.TenantID.
func (s *knowledgeBaseService) FillKnowledgeBaseCounts(ctx context.Context, kb *types.KnowledgeBase) error {
if kb == nil {
return nil
}
tenantID := kb.TenantID
kb.EnsureDefaults()
switch kb.Type {
case types.KnowledgeBaseTypeDocument:
if cnt, err := s.kgRepo.CountKnowledgeByKnowledgeBaseID(ctx, tenantID, kb.ID); err == nil {
kb.KnowledgeCount = cnt
}
case types.KnowledgeBaseTypeFAQ:
if cnt, err := s.chunkRepo.CountChunksByKnowledgeBaseID(ctx, tenantID, kb.ID); err == nil {
kb.ChunkCount = cnt
}
}
if processingCount, err := s.kgRepo.CountKnowledgeByStatus(ctx, tenantID, kb.ID, []string{"pending", "processing"}); err == nil {
kb.IsProcessing = processingCount > 0
kb.ProcessingCount = processingCount
}
return nil
}
// UpdateKnowledgeBase updates a knowledge base's properties
func (s *knowledgeBaseService) UpdateKnowledgeBase(ctx context.Context,
id string,
name string,
description string,
config *types.KnowledgeBaseConfig,
) (*types.KnowledgeBase, error) {
if id == "" {
logger.Error(ctx, "Knowledge base ID is empty")
return nil, errors.New("knowledge base ID cannot be empty")
}
logger.Infof(ctx, "Updating knowledge base, ID: %s, name: %s", id, name)
// Get existing knowledge base
kb, err := s.repo.GetKnowledgeBaseByID(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
})
return nil, err
}
// Update the knowledge base properties
kb.Name = name
kb.Description = description
kb.ChunkingConfig = config.ChunkingConfig
kb.ImageProcessingConfig = config.ImageProcessingConfig
// Update FAQ config if provided
if config.FAQConfig != nil {
kb.FAQConfig = config.FAQConfig
}
kb.UpdatedAt = time.Now()
kb.EnsureDefaults()
logger.Info(ctx, "Saving knowledge base update")
if err := s.repo.UpdateKnowledgeBase(ctx, kb); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
})
return nil, err
}
logger.Infof(ctx, "Knowledge base updated successfully, ID: %s, name: %s", kb.ID, kb.Name)
return kb, nil
}
// DeleteKnowledgeBase deletes a knowledge base by its ID
// This method marks the knowledge base as deleted and enqueues an async task
// to handle the heavy cleanup operations (embeddings, chunks, files, graph data)
func (s *knowledgeBaseService) DeleteKnowledgeBase(ctx context.Context, id string) error {
if id == "" {
logger.Error(ctx, "Knowledge base ID is empty")
return errors.New("knowledge base ID cannot be empty")
}
logger.Infof(ctx, "Deleting knowledge base, ID: %s", id)
// Get tenant ID from context
tenantID := types.MustTenantIDFromContext(ctx)
tenantInfo, _ := types.TenantInfoFromContext(ctx)
// Step 1: Delete the knowledge base record first (mark as deleted)
logger.Infof(ctx, "Deleting knowledge base from database")
err := s.repo.DeleteKnowledgeBase(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
})
return err
}
// Step 1b: Remove all organization shares for this KB so org settings no longer show them
if delErr := s.shareRepo.DeleteByKnowledgeBaseID(ctx, id); delErr != nil {
logger.Warnf(ctx, "Failed to delete KB shares for knowledge base %s: %v", id, delErr)
}
// Step 2: Enqueue async task for heavy cleanup operations
payload := types.KBDeletePayload{
TenantID: tenantID,
KnowledgeBaseID: id,
EffectiveEngines: tenantInfo.GetEffectiveEngines(),
}
payloadBytes, err := json.Marshal(payload)
if err != nil {
logger.Warnf(ctx, "Failed to marshal KB delete payload: %v", err)
// Don't fail the request, the KB record is already deleted
return nil
}
task := asynq.NewTask(types.TypeKBDelete, payloadBytes, asynq.Queue("low"), asynq.MaxRetry(3))
info, err := s.asynqClient.Enqueue(task)
if err != nil {
logger.Warnf(ctx, "Failed to enqueue KB delete task: %v", err)
// Don't fail the request, the KB record is already deleted
return nil
}
logger.Infof(ctx, "KB delete task enqueued: %s, knowledge base ID: %s", info.ID, id)
logger.Infof(ctx, "Knowledge base deleted successfully, ID: %s", id)
return nil
}
// ProcessKBDelete handles async knowledge base deletion task
// This method performs heavy cleanup operations: deleting embeddings, chunks, files, and graph data
func (s *knowledgeBaseService) ProcessKBDelete(ctx context.Context, t *asynq.Task) error {
var payload types.KBDeletePayload
if err := json.Unmarshal(t.Payload(), &payload); err != nil {
logger.Errorf(ctx, "Failed to unmarshal KB delete payload: %v", err)
return err
}
tenantID := payload.TenantID
kbID := payload.KnowledgeBaseID
// Set tenant context for downstream services
ctx = context.WithValue(ctx, types.TenantIDContextKey, tenantID)
logger.Infof(ctx, "Processing KB delete task for knowledge base: %s", kbID)
// Step 1: Get all knowledge entries in this knowledge base
logger.Infof(ctx, "Fetching all knowledge entries in knowledge base, ID: %s", kbID)
knowledgeList, err := s.kgRepo.ListKnowledgeByKnowledgeBaseID(ctx, tenantID, kbID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": kbID,
})
return err
}
logger.Infof(ctx, "Found %d knowledge entries to delete", len(knowledgeList))
// Step 2: Delete all knowledge entries and their resources
if len(knowledgeList) > 0 {
knowledgeIDs := make([]string, 0, len(knowledgeList))
for _, knowledge := range knowledgeList {
knowledgeIDs = append(knowledgeIDs, knowledge.ID)
}
logger.Infof(ctx, "Deleting all knowledge entries and their resources")
// Delete embeddings from vector store
logger.Infof(ctx, "Deleting embeddings from vector store")
retrieveEngine, err := retriever.NewCompositeRetrieveEngine(
s.retrieveEngine,
payload.EffectiveEngines,
)
if err != nil {
logger.Warnf(ctx, "Failed to create retrieve engine: %v", err)
} else {
// Group knowledge by embedding model and type
type groupKey struct {
EmbeddingModelID string
Type string
}
embeddingGroups := make(map[groupKey][]string)
for _, knowledge := range knowledgeList {
key := groupKey{EmbeddingModelID: knowledge.EmbeddingModelID, Type: knowledge.Type}
embeddingGroups[key] = append(embeddingGroups[key], knowledge.ID)
}
for key, knowledgeGroup := range embeddingGroups {
embeddingModel, err := s.modelService.GetEmbeddingModel(ctx, key.EmbeddingModelID)
if err != nil {
logger.Warnf(ctx, "Failed to get embedding model %s: %v", key.EmbeddingModelID, err)
continue
}
if err := retrieveEngine.DeleteByKnowledgeIDList(ctx, knowledgeGroup, embeddingModel.GetDimensions(), key.Type); err != nil {
logger.Warnf(ctx, "Failed to delete embeddings for model %s: %v", key.EmbeddingModelID, err)
}
}
}
// Delete all chunks
logger.Infof(ctx, "Deleting all chunks in knowledge base")
for _, knowledgeID := range knowledgeIDs {
if err := s.chunkRepo.DeleteChunksByKnowledgeID(ctx, tenantID, knowledgeID); err != nil {
logger.Warnf(ctx, "Failed to delete chunks for knowledge %s: %v", knowledgeID, err)
}
}
// Delete physical files and adjust storage
logger.Infof(ctx, "Deleting physical files")
storageAdjust := int64(0)
for _, knowledge := range knowledgeList {
if knowledge.FilePath != "" {
if err := s.fileSvc.DeleteFile(ctx, knowledge.FilePath); err != nil {
logger.Warnf(ctx, "Failed to delete file %s: %v", knowledge.FilePath, err)
}
}
storageAdjust -= knowledge.StorageSize
}
if storageAdjust != 0 {
if err := s.tenantRepo.AdjustStorageUsed(ctx, tenantID, storageAdjust); err != nil {
logger.Warnf(ctx, "Failed to adjust tenant storage: %v", err)
}
}
// Delete knowledge graph data
logger.Infof(ctx, "Deleting knowledge graph data")
namespaces := make([]types.NameSpace, 0, len(knowledgeList))
for _, knowledge := range knowledgeList {
namespaces = append(namespaces, types.NameSpace{
KnowledgeBase: knowledge.KnowledgeBaseID,
Knowledge: knowledge.ID,
})
}
if s.graphEngine != nil && len(namespaces) > 0 {
if err := s.graphEngine.DelGraph(ctx, namespaces); err != nil {
logger.Warnf(ctx, "Failed to delete knowledge graph: %v", err)
}
}
// Delete all knowledge entries from database
logger.Infof(ctx, "Deleting knowledge entries from database")
if err := s.kgRepo.DeleteKnowledgeList(ctx, tenantID, knowledgeIDs); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": kbID,
})
return err
}
}
logger.Infof(ctx, "KB delete task completed successfully, knowledge base ID: %s", kbID)
return nil
}
// SetEmbeddingModel sets the embedding model for a knowledge base
func (s *knowledgeBaseService) SetEmbeddingModel(ctx context.Context, id string, modelID string) error {
if id == "" {
logger.Error(ctx, "Knowledge base ID is empty")
return errors.New("knowledge base ID cannot be empty")
}
if modelID == "" {
logger.Error(ctx, "Model ID is empty")
return errors.New("model ID cannot be empty")
}
logger.Infof(ctx, "Setting embedding model for knowledge base, knowledge base ID: %s, model ID: %s", id, modelID)
// Get the knowledge base
kb, err := s.repo.GetKnowledgeBaseByID(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
})
return err
}
// Update the knowledge base's embedding model
kb.EmbeddingModelID = modelID
kb.UpdatedAt = time.Now()
logger.Info(ctx, "Saving knowledge base embedding model update")
err = s.repo.UpdateKnowledgeBase(ctx, kb)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
"embedding_model_id": modelID,
})
return err
}
logger.Infof(
ctx,
"Knowledge base embedding model set successfully, knowledge base ID: %s, model ID: %s",
id,
modelID,
)
return nil
}
// CopyKnowledgeBase copies a knowledge base to a new knowledge base (shallow copy).
// Source and target must belong to the tenant in context; cross-tenant access is rejected.
func (s *knowledgeBaseService) CopyKnowledgeBase(ctx context.Context,
srcKB string, dstKB string,
) (*types.KnowledgeBase, *types.KnowledgeBase, error) {
tenantID := types.MustTenantIDFromContext(ctx)
// Load source KB with tenant scope to prevent cross-tenant cloning
sourceKB, err := s.repo.GetKnowledgeBaseByIDAndTenant(ctx, srcKB, tenantID)
if err != nil {
logger.Errorf(ctx, "Get source knowledge base failed: %v", err)
return nil, nil, err
}
sourceKB.EnsureDefaults()
var targetKB *types.KnowledgeBase
if dstKB != "" {
// Load target KB with tenant scope so we only clone into the caller's tenant
targetKB, err = s.repo.GetKnowledgeBaseByIDAndTenant(ctx, dstKB, tenantID)
if err != nil {
return nil, nil, err
}
} else {
var faqConfig *types.FAQConfig
if sourceKB.FAQConfig != nil {
cfg := *sourceKB.FAQConfig
faqConfig = &cfg
}
targetKB = &types.KnowledgeBase{
ID: uuid.New().String(),
Name: sourceKB.Name,
Type: sourceKB.Type,
Description: sourceKB.Description,
TenantID: tenantID,
ChunkingConfig: sourceKB.ChunkingConfig,
ImageProcessingConfig: sourceKB.ImageProcessingConfig,
EmbeddingModelID: sourceKB.EmbeddingModelID,
SummaryModelID: sourceKB.SummaryModelID,
VLMConfig: sourceKB.VLMConfig,
StorageConfig: sourceKB.StorageConfig,
FAQConfig: faqConfig,
}
targetKB.EnsureDefaults()
if err := s.repo.CreateKnowledgeBase(ctx, targetKB); err != nil {
return nil, nil, err
}
}
return sourceKB, targetKB, nil
}
// HybridSearch performs hybrid search, including vector retrieval and keyword retrieval
func (s *knowledgeBaseService) HybridSearch(ctx context.Context,
id string,
params types.SearchParams,
) ([]*types.SearchResult, error) {
logger.Infof(ctx, "Hybrid search parameters, knowledge base ID: %s, query text: %s", id, params.QueryText)
tenantInfo, _ := types.TenantInfoFromContext(ctx)
currentTenantID := types.MustTenantIDFromContext(ctx)
// Create a composite retrieval engine with tenant's configured retrievers
retrieveEngine, err := retriever.NewCompositeRetrieveEngine(s.retrieveEngine, tenantInfo.GetEffectiveEngines())
if err != nil {
logger.Errorf(ctx, "Failed to create retrieval engine: %v", err)
return nil, err
}
var retrieveParams []types.RetrieveParams
var embeddingModel embedding.Embedder
var kb *types.KnowledgeBase
kb, err = s.repo.GetKnowledgeBaseByID(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
})
return nil, err
}
matchCount := params.MatchCount * 3
// Add vector retrieval params if supported
if retrieveEngine.SupportRetriever(types.VectorRetrieverType) && !params.DisableVectorMatch {
logger.Info(ctx, "Vector retrieval supported, preparing vector retrieval parameters")
logger.Infof(ctx, "Getting embedding model, model ID: %s", kb.EmbeddingModelID)
// Check if this is a cross-tenant shared knowledge base
// For shared KB, we must use the source tenant's embedding model to ensure vector compatibility
if kb.TenantID != currentTenantID {
logger.Infof(ctx, "Cross-tenant knowledge base detected, using source tenant's embedding model. KB tenant: %d, current tenant: %d", kb.TenantID, currentTenantID)
embeddingModel, err = s.modelService.GetEmbeddingModelForTenant(ctx, kb.EmbeddingModelID, kb.TenantID)
} else {
embeddingModel, err = s.modelService.GetEmbeddingModel(ctx, kb.EmbeddingModelID)
}
if err != nil {
logger.Errorf(ctx, "Failed to get embedding model, model ID: %s, error: %v", kb.EmbeddingModelID, err)
return nil, err
}
logger.Infof(ctx, "Embedding model retrieved: %v", embeddingModel)
// Generate embedding vector for the query text
logger.Info(ctx, "Starting to generate query embedding")
queryEmbedding, err := embeddingModel.Embed(ctx, params.QueryText)
if err != nil {
logger.Errorf(ctx, "Failed to embed query text, query text: %s, error: %v", params.QueryText, err)
return nil, err
}
logger.Infof(ctx, "Query embedding generated successfully, embedding vector length: %d", len(queryEmbedding))
vectorParams := types.RetrieveParams{
Query: params.QueryText,
Embedding: queryEmbedding,
KnowledgeBaseIDs: []string{id},
TopK: matchCount,
Threshold: params.VectorThreshold,
RetrieverType: types.VectorRetrieverType,
KnowledgeIDs: params.KnowledgeIDs,
TagIDs: params.TagIDs,
}
// For FAQ knowledge base, use FAQ index
if kb.Type == types.KnowledgeBaseTypeFAQ {
vectorParams.KnowledgeType = types.KnowledgeTypeFAQ
}
retrieveParams = append(retrieveParams, vectorParams)
logger.Info(ctx, "Vector retrieval parameters setup completed")
}
// Add keyword retrieval params if supported and not FAQ
if retrieveEngine.SupportRetriever(types.KeywordsRetrieverType) && !params.DisableKeywordsMatch &&
kb.Type != types.KnowledgeBaseTypeFAQ {
logger.Info(ctx, "Keyword retrieval supported, preparing keyword retrieval parameters")
retrieveParams = append(retrieveParams, types.RetrieveParams{
Query: params.QueryText,
KnowledgeBaseIDs: []string{id},
TopK: matchCount,
Threshold: params.KeywordThreshold,
RetrieverType: types.KeywordsRetrieverType,
KnowledgeIDs: params.KnowledgeIDs,
TagIDs: params.TagIDs,
})
logger.Info(ctx, "Keyword retrieval parameters setup completed")
}
if len(retrieveParams) == 0 {
logger.Error(ctx, "No retrieval parameters available")
return nil, errors.New("no retrieve params")
}
// Execute retrieval using the configured engines
logger.Infof(ctx, "Starting retrieval, parameter count: %d", len(retrieveParams))
retrieveResults, err := retrieveEngine.Retrieve(ctx, retrieveParams)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"knowledge_base_id": id,
"query_text": params.QueryText,
})
return nil, err
}
// Collect all results from different retrievers and deduplicate by chunk ID
logger.Infof(ctx, "Processing retrieval results")
// Separate results by retriever type for RRF fusion
var vectorResults []*types.IndexWithScore
var keywordResults []*types.IndexWithScore
for _, retrieveResult := range retrieveResults {
logger.Infof(ctx, "Retrieval results, engine: %v, retriever: %v, count: %v",
retrieveResult.RetrieverEngineType,
retrieveResult.RetrieverType,
len(retrieveResult.Results),
)
if retrieveResult.RetrieverType == types.VectorRetrieverType {
vectorResults = append(vectorResults, retrieveResult.Results...)
} else {
keywordResults = append(keywordResults, retrieveResult.Results...)
}
}
// Early return if no results
if len(vectorResults) == 0 && len(keywordResults) == 0 {
logger.Info(ctx, "No search results found")
return nil, nil
}
logger.Infof(ctx, "Result count before fusion: vector=%d, keyword=%d", len(vectorResults), len(keywordResults))
var deduplicatedChunks []*types.IndexWithScore
// If only vector results (no keyword results), keep original embedding scores
// This is important for FAQ search which only uses vector retrieval
if len(keywordResults) == 0 {
logger.Info(ctx, "Only vector results, keeping original embedding scores")
chunkInfoMap := make(map[string]*types.IndexWithScore)
for _, r := range vectorResults {
// Keep the highest score for each chunk (FAQ may have multiple similar questions)
if existing, exists := chunkInfoMap[r.ChunkID]; !exists || r.Score > existing.Score {
chunkInfoMap[r.ChunkID] = r
}
}
deduplicatedChunks = make([]*types.IndexWithScore, 0, len(chunkInfoMap))
for _, info := range chunkInfoMap {
deduplicatedChunks = append(deduplicatedChunks, info)
}
slices.SortFunc(deduplicatedChunks, func(a, b *types.IndexWithScore) int {
if a.Score > b.Score {
return -1
} else if a.Score < b.Score {
return 1
}
return 0
})
logger.Infof(ctx, "Result count after deduplication: %d", len(deduplicatedChunks))
} else {
// Use RRF (Reciprocal Rank Fusion) to merge results from multiple retrievers
// RRF score = sum(1 / (k + rank)) for each retriever where the chunk appears
// k=60 is a common choice that works well in practice
const rrfK = 60
// Build rank maps for each retriever (already sorted by score from retriever)
vectorRanks := make(map[string]int)
for i, r := range vectorResults {
if _, exists := vectorRanks[r.ChunkID]; !exists {
vectorRanks[r.ChunkID] = i + 1 // 1-indexed rank
}
}
keywordRanks := make(map[string]int)
for i, r := range keywordResults {
if _, exists := keywordRanks[r.ChunkID]; !exists {
keywordRanks[r.ChunkID] = i + 1 // 1-indexed rank
}
}
// Collect all unique chunks and compute RRF scores
// Keep the highest score for each chunk from each retriever
chunkInfoMap := make(map[string]*types.IndexWithScore)
rrfScores := make(map[string]float64)
// Process vector results - keep highest score per chunk
for _, r := range vectorResults {
if existing, exists := chunkInfoMap[r.ChunkID]; !exists || r.Score > existing.Score {
chunkInfoMap[r.ChunkID] = r
}
}
// Process keyword results - only add if not already from vector
for _, r := range keywordResults {
if _, exists := chunkInfoMap[r.ChunkID]; !exists {
chunkInfoMap[r.ChunkID] = r
}
}
// Compute RRF scores
for chunkID := range chunkInfoMap {
rrfScore := 0.0
if rank, ok := vectorRanks[chunkID]; ok {
rrfScore += 1.0 / float64(rrfK+rank)
}
if rank, ok := keywordRanks[chunkID]; ok {
rrfScore += 1.0 / float64(rrfK+rank)
}
rrfScores[chunkID] = rrfScore
}
// Convert to slice and sort by RRF score
deduplicatedChunks = make([]*types.IndexWithScore, 0, len(chunkInfoMap))
for chunkID, info := range chunkInfoMap {
// Store RRF score in the Score field for downstream processing
info.Score = rrfScores[chunkID]
deduplicatedChunks = append(deduplicatedChunks, info)
}
slices.SortFunc(deduplicatedChunks, func(a, b *types.IndexWithScore) int {
if a.Score > b.Score {
return -1
} else if a.Score < b.Score {
return 1
}
return 0
})
logger.Infof(ctx, "Result count after RRF fusion: %d", len(deduplicatedChunks))
// Log top results after RRF fusion for debugging
for i, chunk := range deduplicatedChunks {
if i < 15 {
vRank, vOk := vectorRanks[chunk.ChunkID]
kRank, kOk := keywordRanks[chunk.ChunkID]
logger.Debugf(ctx, "RRF rank %d: chunk_id=%s, rrf_score=%.6f, vector_rank=%v(%v), keyword_rank=%v(%v)",
i, chunk.ChunkID, chunk.Score, vRank, vOk, kRank, kOk)
}
}
}
kb.EnsureDefaults()
// Check if we need iterative retrieval for FAQ with separate indexing
// Only use iterative retrieval if we don't have enough unique chunks after first deduplication
needsIterativeRetrieval := len(deduplicatedChunks) < params.MatchCount &&
kb.Type == types.KnowledgeBaseTypeFAQ && len(vectorResults) == matchCount
if needsIterativeRetrieval {
logger.Info(ctx, "Not enough unique chunks, using iterative retrieval for FAQ")
// Use iterative retrieval to get more unique chunks (with negative question filtering inside)
deduplicatedChunks = s.iterativeRetrieveWithDeduplication(
ctx,
retrieveEngine,
retrieveParams,
params.MatchCount,
params.QueryText,
)
} else if kb.Type == types.KnowledgeBaseTypeFAQ {
// Filter by negative questions if not using iterative retrieval
deduplicatedChunks = s.filterByNegativeQuestions(ctx, deduplicatedChunks, params.QueryText)
logger.Infof(ctx, "Result count after negative question filtering: %d", len(deduplicatedChunks))
}
// Limit to MatchCount
if len(deduplicatedChunks) > params.MatchCount {
deduplicatedChunks = deduplicatedChunks[:params.MatchCount]
}
return s.processSearchResults(ctx, deduplicatedChunks)
}
// iterativeRetrieveWithDeduplication performs iterative retrieval until enough unique chunks are found
// This is used for FAQ knowledge bases with separate indexing mode
// Negative question filtering is applied after each iteration with chunk data caching
func (s *knowledgeBaseService) iterativeRetrieveWithDeduplication(ctx context.Context,
retrieveEngine *retriever.CompositeRetrieveEngine,
retrieveParams []types.RetrieveParams,
matchCount int,
queryText string,
) []*types.IndexWithScore {
maxIterations := 5
// Start with a larger TopK since we're called when first retrieval wasn't enough
// The first retrieval already used matchCount*3, so start from there
currentTopK := matchCount * 3
uniqueChunks := make(map[string]*types.IndexWithScore)
// Cache chunk data to avoid repeated DB queries across iterations
chunkDataCache := make(map[string]*types.Chunk)
// Track chunks that have been filtered out by negative questions
filteredOutChunks := make(map[string]struct{})
queryTextLower := strings.ToLower(strings.TrimSpace(queryText))
tenantID := types.MustTenantIDFromContext(ctx)
for i := 0; i < maxIterations; i++ {
// Update TopK in retrieve params
updatedParams := make([]types.RetrieveParams, len(retrieveParams))
for j := range retrieveParams {
updatedParams[j] = retrieveParams[j]
updatedParams[j].TopK = currentTopK
}
// Execute retrieval
retrieveResults, err := retrieveEngine.Retrieve(ctx, updatedParams)
if err != nil {
logger.Warnf(ctx, "Iterative retrieval failed at iteration %d: %v", i+1, err)
break
}
// Collect results
iterationResults := []*types.IndexWithScore{}
for _, retrieveResult := range retrieveResults {
iterationResults = append(iterationResults, retrieveResult.Results...)
}
if len(iterationResults) == 0 {
logger.Infof(ctx, "No results found at iteration %d", i+1)
break
}
totalRetrieved := len(iterationResults)
// Collect new chunk IDs that need to be fetched from DB
newChunkIDs := make([]string, 0)
for _, result := range iterationResults {
if _, cached := chunkDataCache[result.ChunkID]; !cached {
if _, filtered := filteredOutChunks[result.ChunkID]; !filtered {
newChunkIDs = append(newChunkIDs, result.ChunkID)
}
}
}
// Batch fetch only new chunks
if len(newChunkIDs) > 0 {
newChunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, newChunkIDs)
if err != nil {
logger.Warnf(ctx, "Failed to fetch chunks at iteration %d: %v", i+1, err)
} else {
for _, chunk := range newChunks {
chunkDataCache[chunk.ID] = chunk
}
}
}
// Deduplicate, merge, and filter in one pass
for _, result := range iterationResults {
// Skip if already filtered out
if _, filtered := filteredOutChunks[result.ChunkID]; filtered {
continue
}
// Check negative questions using cached data
if chunkData, ok := chunkDataCache[result.ChunkID]; ok {
if chunkData.ChunkType == types.ChunkTypeFAQ {
if meta, err := chunkData.FAQMetadata(); err == nil && meta != nil {
if s.matchesNegativeQuestions(queryTextLower, meta.NegativeQuestions) {
filteredOutChunks[result.ChunkID] = struct{}{}
delete(uniqueChunks, result.ChunkID)
continue
}
}
}
}
// Keep highest score for each chunk
if existing, ok := uniqueChunks[result.ChunkID]; !ok || result.Score > existing.Score {
uniqueChunks[result.ChunkID] = result
}
}
logger.Infof(
ctx,
"After iteration %d: retrieved %d results, found %d valid unique chunks (target: %d)",
i+1,
totalRetrieved,
len(uniqueChunks),
matchCount,
)
// Early stop: Check if we have enough unique chunks after deduplication and filtering
if len(uniqueChunks) >= matchCount {
logger.Infof(ctx, "Found enough unique chunks after %d iterations", i+1)
break
}
// Early stop: If we got fewer results than TopK, there are no more results to retrieve
if totalRetrieved < currentTopK {
logger.Infof(ctx, "No more results available (got %d < %d), stopping iteration", totalRetrieved, currentTopK)
break
}
// Increase TopK for next iteration
currentTopK *= 2
}
// Convert map to slice and sort by score
result := make([]*types.IndexWithScore, 0, len(uniqueChunks))
for _, chunk := range uniqueChunks {
result = append(result, chunk)
}
// Sort by score descending
slices.SortFunc(result, func(a, b *types.IndexWithScore) int {
if a.Score > b.Score {
return -1
} else if a.Score < b.Score {
return 1
}
return 0
})
logger.Infof(ctx, "Iterative retrieval completed: %d unique chunks found after filtering", len(result))
return result
}
// filterByNegativeQuestions filters out chunks that match negative questions for FAQ knowledge bases.
func (s *knowledgeBaseService) filterByNegativeQuestions(ctx context.Context,
chunks []*types.IndexWithScore,
queryText string,
) []*types.IndexWithScore {
if len(chunks) == 0 {
return chunks
}
queryTextLower := strings.ToLower(strings.TrimSpace(queryText))
if queryTextLower == "" {
return chunks
}
tenantID := types.MustTenantIDFromContext(ctx)
// Collect chunk IDs
chunkIDs := make([]string, 0, len(chunks))
for _, chunk := range chunks {
chunkIDs = append(chunkIDs, chunk.ChunkID)
}
// Batch fetch chunks to get negative questions
allChunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, chunkIDs)
if err != nil {
logger.Warnf(ctx, "Failed to fetch chunks for negative question filtering: %v", err)
// If we can't fetch chunks, return original results
return chunks
}
// Build chunk map for quick lookup
chunkMap := make(map[string]*types.Chunk, len(allChunks))
for _, chunk := range allChunks {
chunkMap[chunk.ID] = chunk
}
// Filter out chunks that match negative questions
filteredChunks := make([]*types.IndexWithScore, 0, len(chunks))
for _, chunk := range chunks {
chunkData, ok := chunkMap[chunk.ChunkID]
if !ok {
// If chunk not found, keep it (shouldn't happen, but be safe)
filteredChunks = append(filteredChunks, chunk)
continue
}
// Only filter FAQ type chunks
if chunkData.ChunkType != types.ChunkTypeFAQ {
filteredChunks = append(filteredChunks, chunk)
continue
}
// Get FAQ metadata and check negative questions
meta, err := chunkData.FAQMetadata()
if err != nil || meta == nil {
// If we can't parse metadata, keep the chunk
filteredChunks = append(filteredChunks, chunk)
continue
}
// Check if query matches any negative question
if s.matchesNegativeQuestions(queryTextLower, meta.NegativeQuestions) {
logger.Debugf(ctx, "Filtered FAQ chunk %s due to negative question match", chunk.ChunkID)
continue
}
// Keep the chunk
filteredChunks = append(filteredChunks, chunk)
}
return filteredChunks
}
// matchesNegativeQuestions checks if the query text matches any negative questions.
// Returns true if the query matches any negative question, false otherwise.
func (s *knowledgeBaseService) matchesNegativeQuestions(queryTextLower string, negativeQuestions []string) bool {
if len(negativeQuestions) == 0 {
return false
}
for _, negativeQ := range negativeQuestions {
negativeQLower := strings.ToLower(strings.TrimSpace(negativeQ))
if negativeQLower == "" {
continue
}
// Check if query text is exactly the same as the negative question
if queryTextLower == negativeQLower {
return true
}
}
return false
}
// processSearchResults handles the processing of search results, optimizing database queries
func (s *knowledgeBaseService) processSearchResults(ctx context.Context,
chunks []*types.IndexWithScore,
) ([]*types.SearchResult, error) {
if len(chunks) == 0 {
return nil, nil
}
tenantID := types.MustTenantIDFromContext(ctx)
// Prepare data structures for efficient processing
var knowledgeIDs []string
var chunkIDs []string
chunkScores := make(map[string]float64)
chunkMatchTypes := make(map[string]types.MatchType)
chunkMatchedContents := make(map[string]string)
processedKnowledgeIDs := make(map[string]bool)
// Collect all knowledge and chunk IDs
for _, chunk := range chunks {
if !processedKnowledgeIDs[chunk.KnowledgeID] {
knowledgeIDs = append(knowledgeIDs, chunk.KnowledgeID)
processedKnowledgeIDs[chunk.KnowledgeID] = true
}
chunkIDs = append(chunkIDs, chunk.ChunkID)
chunkScores[chunk.ChunkID] = chunk.Score
chunkMatchTypes[chunk.ChunkID] = chunk.MatchType
chunkMatchedContents[chunk.ChunkID] = chunk.Content
}
// Batch fetch knowledge data (include shared KB so cross-tenant retrieval works)
logger.Infof(ctx, "Fetching knowledge data for %d IDs", len(knowledgeIDs))
knowledgeMap, err := s.fetchKnowledgeDataWithShared(ctx, tenantID, knowledgeIDs)
if err != nil {
return nil, err
}
// Batch fetch chunks (include shared KB chunks: first by tenant, then by ID-only for missing with permission check)
logger.Infof(ctx, "Fetching chunk data for %d IDs", len(chunkIDs))
allChunks, err := s.listChunksByIDWithShared(ctx, tenantID, chunkIDs)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": tenantID,
"chunk_ids": chunkIDs,
})
return nil, err
}
logger.Infof(ctx, "Chunk data fetched successfully, count: %d", len(allChunks))
// Build chunk map and collect additional IDs to fetch
chunkMap := make(map[string]*types.Chunk, len(allChunks))
var additionalChunkIDs []string
processedChunkIDs := make(map[string]bool)
// First pass: Build chunk map and collect parent IDs
for _, chunk := range allChunks {
chunkMap[chunk.ID] = chunk
processedChunkIDs[chunk.ID] = true
// Collect parent chunks
if chunk.ParentChunkID != "" && !processedChunkIDs[chunk.ParentChunkID] {
additionalChunkIDs = append(additionalChunkIDs, chunk.ParentChunkID)
processedChunkIDs[chunk.ParentChunkID] = true
// Pass score to parent
chunkScores[chunk.ParentChunkID] = chunkScores[chunk.ID]
chunkMatchTypes[chunk.ParentChunkID] = types.MatchTypeParentChunk
}
// Collect related chunks
relationChunkIDs := s.collectRelatedChunkIDs(chunk, processedChunkIDs)
for _, chunkID := range relationChunkIDs {
additionalChunkIDs = append(additionalChunkIDs, chunkID)
chunkMatchTypes[chunkID] = types.MatchTypeRelationChunk
}
// Add nearby chunks (prev and next)
if slices.Contains([]string{types.ChunkTypeText}, chunk.ChunkType) {
if chunk.NextChunkID != "" && !processedChunkIDs[chunk.NextChunkID] {
additionalChunkIDs = append(additionalChunkIDs, chunk.NextChunkID)
processedChunkIDs[chunk.NextChunkID] = true
chunkMatchTypes[chunk.NextChunkID] = types.MatchTypeNearByChunk
}
if chunk.PreChunkID != "" && !processedChunkIDs[chunk.PreChunkID] {
additionalChunkIDs = append(additionalChunkIDs, chunk.PreChunkID)
processedChunkIDs[chunk.PreChunkID] = true
chunkMatchTypes[chunk.PreChunkID] = types.MatchTypeNearByChunk
}
}
}
// Fetch all additional chunks in one go if needed (include shared KB)
if len(additionalChunkIDs) > 0 {
logger.Infof(ctx, "Fetching %d additional chunks", len(additionalChunkIDs))
additionalChunks, err := s.listChunksByIDWithShared(ctx, tenantID, additionalChunkIDs)
if err != nil {
logger.Warnf(ctx, "Failed to fetch some additional chunks: %v", err)
// Continue with what we have
} else {
// Add to chunk map
for _, chunk := range additionalChunks {
chunkMap[chunk.ID] = chunk
}
}
}
// Build final search results - preserve original order from input chunks
var searchResults []*types.SearchResult
addedChunkIDs := make(map[string]bool)
// First pass: Add results in the original order from input chunks
for _, inputChunk := range chunks {
chunk, exists := chunkMap[inputChunk.ChunkID]
if !exists {
logger.Debugf(ctx, "Chunk not found in chunkMap: %s", inputChunk.ChunkID)
continue
}
if !s.isValidTextChunk(chunk) {
logger.Debugf(ctx, "Chunk is not valid text chunk: %s, type: %s", chunk.ID, chunk.ChunkType)
continue
}
if addedChunkIDs[chunk.ID] {
continue
}
score := chunkScores[chunk.ID]
if knowledge, ok := knowledgeMap[chunk.KnowledgeID]; ok {
matchType := chunkMatchTypes[chunk.ID]
matchedContent := chunkMatchedContents[chunk.ID]
searchResults = append(searchResults, s.buildSearchResult(chunk, knowledge, score, matchType, matchedContent))
addedChunkIDs[chunk.ID] = true
} else {
logger.Warnf(ctx, "Knowledge not found for chunk: %s, knowledge_id: %s", chunk.ID, chunk.KnowledgeID)
}
}
// Second pass: Add additional chunks (parent, nearby, relation) that weren't in original input
for chunkID, chunk := range chunkMap {
if addedChunkIDs[chunkID] || !s.isValidTextChunk(chunk) {
continue
}
score, hasScore := chunkScores[chunkID]
if !hasScore || score <= 0 {
score = 0.0
}
if knowledge, ok := knowledgeMap[chunk.KnowledgeID]; ok {
matchType := types.MatchTypeParentChunk
if specificType, exists := chunkMatchTypes[chunkID]; exists {
matchType = specificType
} else {
logger.Warnf(ctx, "Unkonwn match type for chunk: %s", chunkID)
continue
}
matchedContent := chunkMatchedContents[chunkID]
searchResults = append(searchResults, s.buildSearchResult(chunk, knowledge, score, matchType, matchedContent))
}
}
logger.Infof(ctx, "Search results processed, total: %d", len(searchResults))
return searchResults, nil
}
// collectRelatedChunkIDs extracts related chunk IDs from a chunk
func (s *knowledgeBaseService) collectRelatedChunkIDs(chunk *types.Chunk, processedIDs map[string]bool) []string {
var relatedIDs []string
// Process direct relations
if len(chunk.RelationChunks) > 0 {
var relations []string
if err := json.Unmarshal(chunk.RelationChunks, &relations); err == nil {
for _, id := range relations {
if !processedIDs[id] {
relatedIDs = append(relatedIDs, id)
processedIDs[id] = true
}
}
}
}
return relatedIDs
}
// buildSearchResult creates a search result from chunk and knowledge
func (s *knowledgeBaseService) buildSearchResult(chunk *types.Chunk,
knowledge *types.Knowledge,
score float64,
matchType types.MatchType,
matchedContent string,
) *types.SearchResult {
return &types.SearchResult{
ID: chunk.ID,
Content: chunk.Content,
KnowledgeID: chunk.KnowledgeID,
ChunkIndex: chunk.ChunkIndex,
KnowledgeTitle: knowledge.Title,
StartAt: chunk.StartAt,
EndAt: chunk.EndAt,
Seq: chunk.ChunkIndex,
Score: score,
MatchType: matchType,
Metadata: knowledge.GetMetadata(),
ChunkType: string(chunk.ChunkType),
ParentChunkID: chunk.ParentChunkID,
ImageInfo: chunk.ImageInfo,
KnowledgeFilename: knowledge.FileName,
KnowledgeSource: knowledge.Source,
ChunkMetadata: chunk.Metadata,
MatchedContent: matchedContent,
}
}
// isValidTextChunk checks if a chunk is a valid text chunk
func (s *knowledgeBaseService) isValidTextChunk(chunk *types.Chunk) bool {
return slices.Contains([]types.ChunkType{
types.ChunkTypeText, types.ChunkTypeSummary,
types.ChunkTypeTableColumn, types.ChunkTypeTableSummary,
types.ChunkTypeFAQ,
}, chunk.ChunkType)
}
// fetchKnowledgeData gets knowledge data in batch
func (s *knowledgeBaseService) fetchKnowledgeData(ctx context.Context,
tenantID uint64,
knowledgeIDs []string,
) (map[string]*types.Knowledge, error) {
knowledges, err := s.kgRepo.GetKnowledgeBatch(ctx, tenantID, knowledgeIDs)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": tenantID,
"knowledge_ids": knowledgeIDs,
})
return nil, err
}
knowledgeMap := make(map[string]*types.Knowledge, len(knowledges))
for _, knowledge := range knowledges {
knowledgeMap[knowledge.ID] = knowledge
}
return knowledgeMap, nil
}
// fetchKnowledgeDataWithShared gets knowledge data in batch, including knowledge from shared KBs the user has access to.
func (s *knowledgeBaseService) fetchKnowledgeDataWithShared(ctx context.Context,
tenantID uint64,
knowledgeIDs []string,
) (map[string]*types.Knowledge, error) {
knowledges, err := s.kgRepo.GetKnowledgeBatch(ctx, tenantID, knowledgeIDs)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"tenant_id": tenantID,
"knowledge_ids": knowledgeIDs,
})
return nil, err
}
knowledgeMap := make(map[string]*types.Knowledge, len(knowledges))
for _, k := range knowledges {
knowledgeMap[k.ID] = k
}
// Count how many IDs are missing (not found in current tenant)
var missingIDs []string
for _, id := range knowledgeIDs {
if knowledgeMap[id] == nil {
missingIDs = append(missingIDs, id)
}
}
if len(missingIDs) == 0 {
return knowledgeMap, nil
}
logger.Infof(ctx, "[fetchKnowledgeDataWithShared] %d knowledge IDs not found in current tenant, attempting shared KB lookup", len(missingIDs))
userIDVal := ctx.Value(types.UserIDContextKey)
if userIDVal == nil {
logger.Warnf(ctx, "[fetchKnowledgeDataWithShared] userID not found in context, skipping shared KB lookup")
return knowledgeMap, nil
}
userID, ok := userIDVal.(string)
if !ok || userID == "" {
logger.Warnf(ctx, "[fetchKnowledgeDataWithShared] userID is empty, skipping shared KB lookup")
return knowledgeMap, nil
}
logger.Infof(ctx, "[fetchKnowledgeDataWithShared] Looking up %d missing knowledge IDs with userID=%s", len(missingIDs), userID)
for _, id := range missingIDs {
k, err := s.kgRepo.GetKnowledgeByIDOnly(ctx, id)
if err != nil || k == nil || k.KnowledgeBaseID == "" {
logger.Debugf(ctx, "[fetchKnowledgeDataWithShared] Knowledge %s not found or has no KB", id)
continue
}
hasPermission, err := s.kbShareService.HasKBPermission(ctx, k.KnowledgeBaseID, userID, types.OrgRoleViewer)
if err != nil {
logger.Debugf(ctx, "[fetchKnowledgeDataWithShared] Permission check error for KB %s: %v", k.KnowledgeBaseID, err)
continue
}
if !hasPermission {
logger.Debugf(ctx, "[fetchKnowledgeDataWithShared] No permission for KB %s", k.KnowledgeBaseID)
continue
}
logger.Debugf(ctx, "[fetchKnowledgeDataWithShared] Found shared knowledge %s in KB %s", id, k.KnowledgeBaseID)
knowledgeMap[k.ID] = k
}
logger.Infof(ctx, "[fetchKnowledgeDataWithShared] After shared lookup, total knowledge found: %d", len(knowledgeMap))
return knowledgeMap, nil
}
// listChunksByIDWithShared fetches chunks by IDs, including chunks from shared KBs the user has access to.
func (s *knowledgeBaseService) listChunksByIDWithShared(ctx context.Context,
tenantID uint64,
chunkIDs []string,
) ([]*types.Chunk, error) {
chunks, err := s.chunkRepo.ListChunksByID(ctx, tenantID, chunkIDs)
if err != nil {
return nil, err
}
foundSet := make(map[string]bool)
for _, c := range chunks {
if c != nil {
foundSet[c.ID] = true
}
}
var missing []string
for _, id := range chunkIDs {
if !foundSet[id] {
missing = append(missing, id)
}
}
if len(missing) == 0 {
return chunks, nil
}
logger.Infof(ctx, "[listChunksByIDWithShared] %d chunks not found in current tenant, attempting shared KB lookup", len(missing))
userIDVal := ctx.Value(types.UserIDContextKey)
if userIDVal == nil {
logger.Warnf(ctx, "[listChunksByIDWithShared] userID not found in context, skipping shared KB lookup")
return chunks, nil
}
userID, ok := userIDVal.(string)
if !ok || userID == "" {
logger.Warnf(ctx, "[listChunksByIDWithShared] userID is empty, skipping shared KB lookup")
return chunks, nil
}
logger.Infof(ctx, "[listChunksByIDWithShared] Looking up %d missing chunks with userID=%s", len(missing), userID)
crossChunks, err := s.chunkRepo.ListChunksByIDOnly(ctx, missing)
if err != nil {
logger.Warnf(ctx, "[listChunksByIDWithShared] Failed to fetch chunks by ID only: %v", err)
return chunks, nil
}
logger.Infof(ctx, "[listChunksByIDWithShared] Found %d chunks without tenant filter", len(crossChunks))
for _, c := range crossChunks {
if c == nil || c.KnowledgeBaseID == "" {
continue
}
hasPermission, err := s.kbShareService.HasKBPermission(ctx, c.KnowledgeBaseID, userID, types.OrgRoleViewer)
if err != nil {
logger.Debugf(ctx, "[listChunksByIDWithShared] Permission check error for KB %s: %v", c.KnowledgeBaseID, err)
continue
}
if !hasPermission {
logger.Debugf(ctx, "[listChunksByIDWithShared] No permission for KB %s", c.KnowledgeBaseID)
continue
}
chunks = append(chunks, c)
}
logger.Infof(ctx, "[listChunksByIDWithShared] After shared lookup, total chunks: %d", len(chunks))
return chunks, nil
}