feat: introduce storage provider configuration and enhance backward compatibility

- Added a new `StorageProviderConfig` struct to the `KnowledgeBase` for improved storage provider management, separating provider selection from storage credentials.
- Updated the `UnmarshalJSON` method to auto-populate `StorageProviderConfig` from the legacy `StorageConfig` for backward compatibility.
- Refactored various service methods to utilize the new storage provider configuration, ensuring a smooth transition from the legacy system.
- Implemented database migrations to add the `storage_provider_config` column and migrate existing data, maintaining compatibility with historical knowledge bases.

This update enhances the clarity and flexibility of storage configurations while ensuring compatibility with existing data structures.
This commit is contained in:
wizardchen
2026-03-02 11:02:19 +08:00
committed by lyingbug
parent e4d3eabc28
commit dbab7b0405
9 changed files with 231 additions and 46 deletions
+11 -4
View File
@@ -25,9 +25,10 @@ type KnowledgeBase struct {
FAQConfig *FAQConfig `json:"faq_config"`
EmbeddingModelID string `json:"embedding_model_id"`
SummaryModelID string `json:"summary_model_id"`
VLMConfig VLMConfig `json:"vlm_config"`
StorageConfig StorageConfig `json:"storage_config"`
ExtractConfig *ExtractConfig `json:"extract_config"`
VLMConfig VLMConfig `json:"vlm_config"`
StorageProviderConfig *StorageProviderConfig `json:"storage_provider_config"`
StorageConfig StorageConfig `json:"storage_config"`
ExtractConfig *ExtractConfig `json:"extract_config"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Computed fields (not stored in database)
@@ -68,7 +69,13 @@ type VLMConfig struct {
ModelID string `json:"model_id"`
}
// StorageConfig represents the storage configuration
// StorageProviderConfig stores the KB-level storage provider selection.
type StorageProviderConfig struct {
Provider string `json:"provider"`
}
// StorageConfig represents the legacy storage configuration (cos_config).
// Deprecated: use StorageProviderConfig for provider selection.
type StorageConfig struct {
SecretID string `json:"secret_id"`
SecretKey string `json:"secret_key"`
+49 -16
View File
@@ -190,8 +190,8 @@ func (s *knowledgeService) CreateKnowledgeFromFile(ctx context.Context,
if !IsImageType(getFileType(fileName)) {
logger.Info(ctx, "Non-image file with multimodal enabled, skipping COS/VLM validation")
} else {
// 解析有效 provider:优先 KB 级别,其次租户默认
provider := strings.ToLower(strings.TrimSpace(kb.StorageConfig.Provider))
// 解析有效 provider:优先 KB 级别(新字段 > 旧字段),其次租户默认
provider := kb.GetStorageProvider()
tenant, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if provider == "" && tenant != nil && tenant.StorageEngineConfig != nil {
provider = strings.ToLower(strings.TrimSpace(tenant.StorageEngineConfig.DefaultProvider))
@@ -2286,9 +2286,9 @@ func (s *knowledgeService) GetKnowledgeFile(ctx context.Context, id string) (io.
return nil, "", err
}
// Resolve KB-level file service
// Resolve KB-level file service with FilePath fallback protection
kb, _ := s.kbService.GetKnowledgeBaseByID(ctx, knowledge.KnowledgeBaseID)
file, err := s.resolveFileService(ctx, kb).GetFile(ctx, knowledge.FilePath)
file, err := s.resolveFileServiceForPath(ctx, kb, knowledge.FilePath).GetFile(ctx, knowledge.FilePath)
if err != nil {
return nil, "", err
}
@@ -6737,13 +6737,13 @@ func (s *knowledgeService) getVLMConfig(ctx context.Context, kb *types.Knowledge
}
func (s *knowledgeService) buildStorageConfig(ctx context.Context, kb *types.KnowledgeBase) *types.DocParserStorageConfig {
sc := &kb.StorageConfig
provider := strings.ToLower(strings.TrimSpace(sc.Provider))
provider := kb.GetStorageProvider()
if provider == "" {
provider = "local"
}
// Backward compatibility: if KB has full params for the chosen provider, use them.
// Backward compatibility: if legacy cos_config has full params for the chosen provider, use them.
sc := &kb.StorageConfig
hasKBFull := false
switch provider {
case "cos":
@@ -6751,12 +6751,11 @@ func (s *knowledgeService) buildStorageConfig(ctx context.Context, kb *types.Kno
case "minio":
hasKBFull = sc.BucketName != ""
case "local":
// Local has no credentials; always merge from tenant if available
hasKBFull = false
}
if hasKBFull {
logger.Infof(ctx, "[storage] buildStorageConfig use kb config: kb=%s provider=%s bucket=%s path_prefix=%s",
logger.Infof(ctx, "[storage] buildStorageConfig use legacy kb config: kb=%s provider=%s bucket=%s path_prefix=%s",
kb.ID, provider, sc.BucketName, sc.PathPrefix)
return &types.DocParserStorageConfig{
Provider: strings.ToUpper(provider),
@@ -6769,7 +6768,7 @@ func (s *knowledgeService) buildStorageConfig(ctx context.Context, kb *types.Kno
}
}
// Otherwise merge from tenant's StorageEngineConfig.
// Merge from tenant's StorageEngineConfig.
var out types.DocParserStorageConfig
out.Provider = strings.ToUpper(provider)
@@ -6817,7 +6816,7 @@ func (s *knowledgeService) buildStorageConfig(ctx context.Context, kb *types.Kno
}
// resolveFileService returns the FileService for the given knowledge base,
// based on the KB's StorageConfig.Provider and the tenant's StorageEngineConfig.
// based on the KB's StorageProviderConfig (or legacy StorageConfig.Provider) and the tenant's StorageEngineConfig.
// Falls back to the global fileSvc when no tenant-level storage config is found.
func (s *knowledgeService) resolveFileService(ctx context.Context, kb *types.KnowledgeBase) interfaces.FileService {
if kb == nil {
@@ -6825,7 +6824,7 @@ func (s *knowledgeService) resolveFileService(ctx context.Context, kb *types.Kno
return s.fileSvc
}
provider := strings.ToLower(strings.TrimSpace(kb.StorageConfig.Provider))
provider := kb.GetStorageProvider()
tenant, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if provider == "" && tenant != nil && tenant.StorageEngineConfig != nil {
@@ -6915,6 +6914,40 @@ func (s *knowledgeService) resolveFileService(ctx context.Context, kb *types.Kno
return svc
}
// resolveFileServiceForPath is like resolveFileService but adds a safety check:
// if the resolved provider doesn't match what the filePath implies, fall back to
// the provider inferred from the file path. This protects historical data when
// tenant/KB config changes but files were stored under the old provider.
func (s *knowledgeService) resolveFileServiceForPath(ctx context.Context, kb *types.KnowledgeBase, filePath string) interfaces.FileService {
svc := s.resolveFileService(ctx, kb)
if filePath == "" {
return svc
}
inferred := types.InferStorageFromFilePath(filePath)
if inferred == "" {
return svc
}
configured := kb.GetStorageProvider()
if configured == "" {
tenant, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenant != nil && tenant.StorageEngineConfig != nil {
configured = strings.ToLower(strings.TrimSpace(tenant.StorageEngineConfig.DefaultProvider))
}
}
if configured == "" {
configured = strings.ToLower(strings.TrimSpace(os.Getenv("STORAGE_TYPE")))
}
if configured != "" && configured != inferred {
logger.Warnf(ctx, "[storage] FilePath format mismatch: configured=%s inferred=%s filePath=%s, using global fallback",
configured, inferred, filePath)
return s.fileSvc
}
return svc
}
func IsImageType(fileType string) bool {
switch fileType {
case "jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "tiff":
@@ -7304,7 +7337,7 @@ func (s *knowledgeService) convert(
imageStorage := s.buildImageStorageConfig(ctx, kb)
if imageStorage == nil {
logger.Infof(ctx, "[storage] chunk image storage: kb=%s provider=%s image_storage=nil(use local temp/files)",
kb.ID, strings.ToLower(strings.TrimSpace(kb.StorageConfig.Provider)))
kb.ID, kb.GetStorageProvider())
} else {
logger.Infof(ctx, "[storage] chunk image storage: kb=%s provider=%s bucket=%s endpoint=%s path_prefix=%s",
kb.ID,
@@ -7325,7 +7358,7 @@ func (s *knowledgeService) convert(
}
if !isURL {
fileReader, err := s.resolveFileService(ctx, kb).GetFile(ctx, payload.FilePath)
fileReader, err := s.resolveFileServiceForPath(ctx, kb, payload.FilePath).GetFile(ctx, payload.FilePath)
if err != nil {
return s.failKnowledge(ctx, knowledge, isLastRetry, "failed to get file: %v", err)
}
@@ -7402,7 +7435,7 @@ func (s *knowledgeService) reuploadImagesToStorage(
images []docparser.StoredImage,
markdown string,
) (string, []docparser.StoredImage) {
provider := strings.ToLower(strings.TrimSpace(kb.StorageConfig.Provider))
provider := kb.GetStorageProvider()
tenant, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if provider == "" && tenant != nil && tenant.StorageEngineConfig != nil {
provider = strings.ToLower(strings.TrimSpace(tenant.StorageEngineConfig.DefaultProvider))
@@ -7456,7 +7489,7 @@ func (s *knowledgeService) reuploadImagesToStorage(
func (s *knowledgeService) buildImageStorageConfig(ctx context.Context, kb *types.KnowledgeBase) map[string]string {
provider := ""
if kb != nil {
provider = strings.ToLower(strings.TrimSpace(kb.StorageConfig.Provider))
provider = kb.GetStorageProvider()
}
tenant, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if provider == "" && tenant != nil && tenant.StorageEngineConfig != nil {
@@ -575,6 +575,7 @@ func (s *knowledgeBaseService) CopyKnowledgeBase(ctx context.Context,
EmbeddingModelID: sourceKB.EmbeddingModelID,
SummaryModelID: sourceKB.SummaryModelID,
VLMConfig: sourceKB.VLMConfig,
StorageProviderConfig: sourceKB.StorageProviderConfig,
StorageConfig: sourceKB.StorageConfig,
FAQConfig: faqConfig,
}
+26
View File
@@ -413,6 +413,11 @@ func initDatabase(cfg *config.Config) (*gorm.DB, error) {
"Continuing with application startup. Please run migrations manually if needed.",
)
}
// Post-migration: resolve __pending_env__ storage provider markers for historical KBs.
// The SQL migration marks KBs that have documents but no provider with "__pending_env__";
// we replace that with the actual STORAGE_TYPE from the environment.
resolveStorageProviderPending(db)
} else {
logger.Infof(context.Background(), "Auto-migration is disabled (AUTO_MIGRATE=false)")
}
@@ -437,6 +442,27 @@ func initDatabase(cfg *config.Config) (*gorm.DB, error) {
return db, nil
}
// resolveStorageProviderPending replaces the "__pending_env__" sentinel in
// knowledge_bases.storage_provider_config with the actual STORAGE_TYPE from the environment.
// This runs once after SQL migrations to bind historical KBs to their real storage provider.
func resolveStorageProviderPending(db *gorm.DB) {
storageType := strings.TrimSpace(os.Getenv("STORAGE_TYPE"))
if storageType == "" {
storageType = "local"
}
storageType = strings.ToLower(storageType)
result := db.Exec(
`UPDATE knowledge_bases SET storage_provider_config = ? WHERE storage_provider_config IS NOT NULL AND storage_provider_config->>'provider' = '__pending_env__'`,
fmt.Sprintf(`{"provider":"%s"}`, storageType),
)
if result.Error != nil {
logger.Warnf(context.Background(), "Failed to resolve __pending_env__ storage providers: %v", result.Error)
} else if result.RowsAffected > 0 {
logger.Infof(context.Background(), "Resolved %d knowledge bases with __pending_env__ storage provider → %s", result.RowsAffected, storageType)
}
}
// initFileService initializes file storage service
// Creates the appropriate file storage service based on configuration
// Supports multiple storage backends (MinIO, COS, local filesystem)
+17 -10
View File
@@ -297,12 +297,12 @@ func (h *InitializationHandler) UpdateKBConfig(c *gin.Context) {
kb.VLMConfig.ModelID = ""
}
// 存储引擎:仅写入 provider,参数从租户全局 StorageEngineConfig 读取
// 存储引擎:仅写入 provider 到新字段,参数从租户全局 StorageEngineConfig 读取
provider := strings.ToLower(strings.TrimSpace(req.StorageProvider))
if provider == "" {
provider = "local"
}
oldProvider := strings.ToLower(strings.TrimSpace(kb.StorageConfig.Provider))
oldProvider := kb.GetStorageProvider()
if oldProvider == "" {
oldProvider = "local"
}
@@ -315,7 +315,7 @@ func (h *InitializationHandler) UpdateKBConfig(c *gin.Context) {
return
}
}
kb.StorageConfig = types.StorageConfig{Provider: provider}
kb.SetStorageProvider(provider)
// 更新知识图谱配置
if req.NodeExtract.Enabled {
@@ -711,6 +711,8 @@ func (h *InitializationHandler) applyKnowledgeBaseInitialization(
switch req.Multimodal.StorageType {
case "cos":
if req.Multimodal.COS != nil {
kb.SetStorageProvider("cos")
// Legacy: also write to cos_config for backward compat with old code paths
kb.StorageConfig = types.StorageConfig{
Provider: req.Multimodal.StorageType,
BucketName: req.Multimodal.COS.BucketName,
@@ -723,6 +725,8 @@ func (h *InitializationHandler) applyKnowledgeBaseInitialization(
}
case "minio":
if req.Multimodal.Minio != nil {
kb.SetStorageProvider("minio")
// Legacy: also write to cos_config for backward compat with old code paths
kb.StorageConfig = types.StorageConfig{
Provider: req.Multimodal.StorageType,
BucketName: req.Multimodal.Minio.BucketName,
@@ -734,6 +738,7 @@ func (h *InitializationHandler) applyKnowledgeBaseInitialization(
}
} else {
kb.VLMConfig = types.VLMConfig{}
kb.SetStorageProvider("")
kb.StorageConfig = types.StorageConfig{}
}
@@ -1333,15 +1338,16 @@ func (h *InitializationHandler) buildConfigResponse(ctx context.Context, models
}
}
// 判断多模态是否启用:有VLM模型ID或有存储配置
// 判断多模态是否启用:有VLM模型ID或有存储配置(兼容新旧字段)
storageProvider := kb.GetStorageProvider()
hasMultimodal := (kb.VLMConfig.IsEnabled() ||
kb.StorageConfig.SecretID != "" || kb.StorageConfig.BucketName != "")
kb.StorageConfig.SecretID != "" || kb.StorageConfig.BucketName != "" ||
(storageProvider != "" && storageProvider != "local"))
if config["multimodal"] == nil {
config["multimodal"] = map[string]interface{}{
"enabled": hasMultimodal,
}
} else {
// 如果已经设置过 multimodal,更新 enabled 状态
config["multimodal"].(map[string]interface{})["enabled"] = hasMultimodal
}
@@ -1363,16 +1369,17 @@ func (h *InitializationHandler) buildConfigResponse(ctx context.Context, models
"separators": kb.ChunkingConfig.Separators,
}
// 添加多模态的COS配置信息
if kb.StorageConfig.SecretID != "" {
// 添加多模态的存储配置信息(优先读新字段,兼容旧 cos_config)
effectiveProvider := kb.GetStorageProvider()
if kb.StorageConfig.SecretID != "" || (effectiveProvider != "" && effectiveProvider != "local") {
if config["multimodal"] == nil {
config["multimodal"] = map[string]interface{}{
"enabled": true,
}
}
multimodal := config["multimodal"].(map[string]interface{})
multimodal["storageType"] = kb.StorageConfig.Provider
switch kb.StorageConfig.Provider {
multimodal["storageType"] = effectiveProvider
switch effectiveProvider {
case "cos":
multimodal["cos"] = map[string]interface{}{
"secretId": kb.StorageConfig.SecretID,
+79 -16
View File
@@ -3,6 +3,7 @@ package types
import (
"database/sql/driver"
"encoding/json"
"strings"
"time"
"gorm.io/gorm"
@@ -59,8 +60,10 @@ type KnowledgeBase struct {
SummaryModelID string `yaml:"summary_model_id" json:"summary_model_id"`
// VLM config
VLMConfig VLMConfig `yaml:"vlm_config" json:"vlm_config" gorm:"type:json"`
// Storage config
StorageConfig StorageConfig `yaml:"storage_config" json:"storage_config" gorm:"column:cos_config;type:json"`
// Storage provider config (new): only stores provider selection; credentials from tenant StorageEngineConfig
StorageProviderConfig *StorageProviderConfig `yaml:"storage_provider_config" json:"storage_provider_config" gorm:"column:storage_provider_config;type:jsonb"`
// Deprecated: legacy COS config column. Kept for backward compatibility with old data.
StorageConfig StorageConfig `yaml:"-" json:"storage_config" gorm:"column:cos_config;type:json"`
// Extract config
ExtractConfig *ExtractConfig `yaml:"extract_config" json:"extract_config" gorm:"column:extract_config;type:json"`
// FAQConfig stores FAQ specific configuration such as indexing strategy
@@ -130,22 +133,37 @@ func (c ChunkingConfig) ResolveParserEngine(fileType string) string {
return ""
}
// COSConfig represents the COS configuration
// StorageProviderConfig stores the KB-level storage provider selection.
// Credentials are managed at the tenant level (StorageEngineConfig).
type StorageProviderConfig struct {
Provider string `yaml:"provider" json:"provider"` // "local", "minio", "cos", "tos"
}
func (c StorageProviderConfig) Value() (driver.Value, error) {
return json.Marshal(c)
}
func (c *StorageProviderConfig) Scan(value interface{}) error {
if value == nil {
return nil
}
b, ok := value.([]byte)
if !ok {
return nil
}
return json.Unmarshal(b, c)
}
// Deprecated: StorageConfig is the legacy COS configuration stored in the cos_config column.
// New code should use StorageProviderConfig. Kept for backward compatibility with old data.
type StorageConfig struct {
// Secret ID
SecretID string `yaml:"secret_id" json:"secret_id"`
// Secret Key
SecretKey string `yaml:"secret_key" json:"secret_key"`
// Region
Region string `yaml:"region" json:"region"`
// Bucket Name
SecretID string `yaml:"secret_id" json:"secret_id"`
SecretKey string `yaml:"secret_key" json:"secret_key"`
Region string `yaml:"region" json:"region"`
BucketName string `yaml:"bucket_name" json:"bucket_name"`
// App ID
AppID string `yaml:"app_id" json:"app_id"`
// Path Prefix
AppID string `yaml:"app_id" json:"app_id"`
PathPrefix string `yaml:"path_prefix" json:"path_prefix"`
// Provider
Provider string `yaml:"provider" json:"provider"`
Provider string `yaml:"provider" json:"provider"`
}
func (c StorageConfig) Value() (driver.Value, error) {
@@ -164,7 +182,7 @@ func (c *StorageConfig) Scan(value interface{}) error {
}
// UnmarshalJSON keeps backward compatibility for legacy clients that still send
// `cos_config`, while standardizing the API field as `storage_config`.
// `cos_config` or `storage_config`, while migrating to `storage_provider_config`.
func (kb *KnowledgeBase) UnmarshalJSON(data []byte) error {
type alias KnowledgeBase
aux := struct {
@@ -176,12 +194,57 @@ func (kb *KnowledgeBase) UnmarshalJSON(data []byte) error {
if err := json.Unmarshal(data, &aux); err != nil {
return err
}
// Backward compat: populate legacy StorageConfig from cos_config
if aux.LegacyStorageConfig != nil && kb.StorageConfig == (StorageConfig{}) {
kb.StorageConfig = *aux.LegacyStorageConfig
}
// Auto-populate StorageProviderConfig from legacy StorageConfig if not set
if kb.StorageProviderConfig == nil && kb.StorageConfig.Provider != "" {
kb.StorageProviderConfig = &StorageProviderConfig{Provider: kb.StorageConfig.Provider}
}
return nil
}
// GetStorageProvider returns the effective storage provider for this KB.
// Priority: StorageProviderConfig (new) > StorageConfig.Provider (legacy cos_config).
func (kb *KnowledgeBase) GetStorageProvider() string {
if kb == nil {
return ""
}
if kb.StorageProviderConfig != nil {
p := strings.ToLower(strings.TrimSpace(kb.StorageProviderConfig.Provider))
if p != "" && p != "__pending_env__" {
return p
}
}
return strings.ToLower(strings.TrimSpace(kb.StorageConfig.Provider))
}
// SetStorageProvider writes the provider to the new StorageProviderConfig field.
func (kb *KnowledgeBase) SetStorageProvider(provider string) {
if kb == nil {
return
}
kb.StorageProviderConfig = &StorageProviderConfig{Provider: provider}
}
// InferStorageFromFilePath deduces the storage provider from a file path format.
// Used as a safety fallback when the KB's configured provider doesn't match the data.
func InferStorageFromFilePath(filePath string) string {
switch {
case strings.HasPrefix(filePath, "minio://"):
return "minio"
case strings.HasPrefix(filePath, "tos://"):
return "tos"
case strings.HasPrefix(filePath, "https://") && strings.Contains(filePath, ".cos."):
return "cos"
case strings.HasPrefix(filePath, "/"):
return "local"
default:
return ""
}
}
// ImageProcessingConfig represents the image processing configuration
type ImageProcessingConfig struct {
// Model ID
+1
View File
@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS knowledge_bases (
embedding_model_id VARCHAR(64) NOT NULL,
summary_model_id VARCHAR(64) NOT NULL,
cos_config TEXT NOT NULL DEFAULT '{}',
storage_provider_config TEXT DEFAULT NULL,
vlm_config TEXT NOT NULL DEFAULT '{}',
extract_config TEXT NULL DEFAULT NULL,
faq_config TEXT,
@@ -0,0 +1,16 @@
-- Rollback: copy provider back to cos_config if needed, then drop new column
UPDATE knowledge_bases
SET cos_config = jsonb_set(
COALESCE(cos_config, '{}'::jsonb),
'{provider}',
to_jsonb(storage_provider_config->>'provider')
)
WHERE storage_provider_config IS NOT NULL
AND storage_provider_config->>'provider' IS NOT NULL
AND storage_provider_config->>'provider' != ''
AND storage_provider_config->>'provider' != '__pending_env__'
AND (cos_config IS NULL
OR cos_config->>'provider' IS NULL
OR cos_config->>'provider' = '');
ALTER TABLE knowledge_bases DROP COLUMN IF EXISTS storage_provider_config;
@@ -0,0 +1,31 @@
-- Description: Add storage_provider_config column to knowledge_bases, migrating from legacy cos_config.
-- This separates the storage provider selection (KB-level) from storage credentials (tenant-level).
DO $$ BEGIN RAISE NOTICE '[Migration 000014] Adding storage_provider_config to knowledge_bases'; END $$;
-- Step 1: Add new column
ALTER TABLE knowledge_bases ADD COLUMN IF NOT EXISTS storage_provider_config JSONB DEFAULT NULL;
COMMENT ON COLUMN knowledge_bases.storage_provider_config IS 'Storage provider config for this KB. Only stores provider name; credentials come from tenant StorageEngineConfig.';
-- Step 2: Migrate existing provider from legacy cos_config
UPDATE knowledge_bases
SET storage_provider_config = jsonb_build_object('provider', cos_config->>'provider')
WHERE cos_config IS NOT NULL
AND cos_config->>'provider' IS NOT NULL
AND cos_config->>'provider' != ''
AND (storage_provider_config IS NULL
OR storage_provider_config->>'provider' IS NULL
OR storage_provider_config->>'provider' = '');
-- Step 3: For KBs that have documents but no provider set, mark them with the
-- sentinel value so the application can fill in the actual STORAGE_TYPE on startup.
-- We use 'pending_migration' as a marker; the app replaces it with the real env value.
UPDATE knowledge_bases kb
SET storage_provider_config = '{"provider": "__pending_env__"}'
WHERE (kb.storage_provider_config IS NULL
OR kb.storage_provider_config->>'provider' IS NULL
OR kb.storage_provider_config->>'provider' = '')
AND EXISTS (
SELECT 1 FROM knowledges k
WHERE k.knowledge_base_id = kb.id
AND k.deleted_at IS NULL
);