-
-
-
{{ pendingApproval.detail }}
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
{{ pendingApproval.detail }}
+
+
+ v-for="(opt, idx) in pendingApproval.options"
+ :key="idx"
+ @click="approvePending(opt)"
+ class="px-3 py-1.5 text-xs font-medium rounded-lg bg-slate-50 hover:bg-slate-100 text-slate-700 border border-slate-200 transition-colors"
+ >{{ opt }}
+
+
+
-
-
-
-
-
-
-
-
-
-
-
需要人工确认
-
{{ pendingApproval.tool_name }}
-
-
-
{{ pendingApproval.detail }}
-
-
-
-
+
+
+
+
+
+
+
+
+
{{ pendingApproval.detail }}
+
{{ pendingApproval.tool_name }}
+
+
+
+
+
+
+
+
diff --git a/internal/agent/prompt.go b/internal/agent/prompt.go
index 670c0e5..6367d4e 100644
--- a/internal/agent/prompt.go
+++ b/internal/agent/prompt.go
@@ -60,11 +60,15 @@ func buildReActSystemPrompt(ctx context.Context, allTools []einoTool.BaseTool, i
// ── 工作原则 ──
sb.WriteString("## 工作原则\n")
sb.WriteString("1. **先检索再回答**:第一步始终是 knowledge_search。即使你认为知道答案也必须先检索知识库\n")
- sb.WriteString("2. **按需补充**:知识库结果不足时,再调用其他工具(grep_chunks 精准查找、get_document_info 查元数据等)\n")
- sb.WriteString("3. **不重复调用**:已获得足够信息时,直接给出答案,不要为了'再确认'重复调用\n")
- sb.WriteString("4. **工具上限 3 次**:工具调用总数不超过 3 次,用完必须收敛\n")
- sb.WriteString("5. **强制收敛**:达到最大推理轮次或用完工具次数时,立即总结已有信息给出最终答案。禁止再规划'下一步应该'、'我还需要'等思考性输出\n")
- sb.WriteString("6. **答案分层**:有 ToolCalls 的轮次 Message.Content 只写 1-2 句简短推理(不会展示给用户);只有 ToolCalls 为空的轮次才是完整、可读、面向最终用户的答案正文\n")
+ sb.WriteString("2. **知识库内部工具各有分工**:knowledge_search 是语义搜索(找相关内容片段),list_knowledge_bases / list_knowledge_chunks 是列清单(看有哪些知识库/文档),grep_chunks 是关键词精准查找,get_document_info 是查文档详情。根据用户意图选择合适的工具——如果 knowledge_search 不满足需求(比如用户要'列出所有文档'而不是'找内容'),可以继续调用其他知识库内部工具\n")
+ sb.WriteString("3. **联网搜索谨慎使用**:knowledge_search 或其他知识库内部工具已经返回了相关内容时,**不要主动联网搜索补充细节**。联网搜索仅在以下情况使用:\n")
+ sb.WriteString(" - 用户明确要求'联网'、'最新'、'实时'、'当前'、'2024年后'等时效性内容\n")
+ sb.WriteString(" - 所有知识库工具都返回空结果或结果与问题完全不匹配\n")
+ sb.WriteString(" - 知识库内容有明显时间戳且已过时\n")
+ sb.WriteString("4. **不重复调用**:已获得足够信息时,直接给出答案,不要为了'再确认'重复调用\n")
+ sb.WriteString("5. **工具上限 3 次**:工具调用总数不超过 3 次,用完必须收敛\n")
+ sb.WriteString("6. **强制收敛**:达到最大推理轮次或用完工具次数时,立即总结已有信息给出最终答案。禁止再规划'下一步应该'、'我还需要'等思考性输出\n")
+ sb.WriteString("7. **答案分层**:有 ToolCalls 的轮次 Message.Content 只写 1-2 句简短推理(不会展示给用户);只有 ToolCalls 为空的轮次才是完整、可读、面向最终用户的答案正文\n")
// 危险工具补充说明
hasDangerous := false
@@ -75,7 +79,7 @@ func buildReActSystemPrompt(ctx context.Context, allTools []einoTool.BaseTool, i
}
}
if hasDangerous {
- sb.WriteString("7. **危险工具审批**:delete_document 等危险工具会在执行前暂停并等待用户审批,调用后流程中断,用户确认后自动继续\n")
+ sb.WriteString("8. **危险工具审批**:delete_document 等危险工具会在执行前暂停并等待用户审批,调用后流程中断,用户确认后自动继续\n")
sb.WriteString(" - ⚠️ **目标不明确先反问**:当用户说'删除那个文档'、'清理一下'、'把上面的删了'这类模糊指令,且从对话历史无法唯一确定目标时,**绝对不能编造参数调用工具**。先调用 ask_clarify 反问用户明确目标(例如:'你要删除的是《压力 - 07/13 16:03》那个文档吗?还是另一个?')\n")
sb.WriteString(" - ⚠️ **禁止猜测参数**:document_id 等关键参数必须来自可靠来源(用户明确提供、get_document_info 工具查询结果、历史对话中已确认的 ID)。严禁从模糊描述或'看起来像是'的文本中猜测或编造\n")
sb.WriteString(" - 调用危险工具时务必在参数里写清楚目标和原因,便于用户决策\n")
@@ -105,15 +109,15 @@ func buildReActSystemPrompt(ctx context.Context, allTools []einoTool.BaseTool, i
}
}
if len(externals) > 0 {
- sb.WriteString(fmt.Sprintf("8. **联网搜索**:当 knowledge_search 返回空结果或与问题不相关时,必须调用 %s 联网搜索。用户明确要求'联网'、'最新'时即使知识库有结果也应联网\n",
- strings.Join(externals, " 或 ")))
+ sb.WriteString(fmt.Sprintf("\n## 联网搜索(%s)\n", strings.Join(externals, " 或 ")))
+ sb.WriteString("谨慎使用,触发条件见上面的工作原则第 3 条。**知识库已有相关内容或可以用其他内部工具解决时,不要主动联网补充。**\n")
}
sb.WriteString("\n")
// ── 禁止 ──
sb.WriteString("## 禁止\n")
sb.WriteString("- 不检索知识库就用自身知识回答\n")
- sb.WriteString("- 知识库检索失败不联网搜索就直接回答\n")
+ sb.WriteString("- 知识库已有相关结果时,为了'再确认'或'补充细节'而联网搜索\n")
sb.WriteString("- 在有可用工具时直接给用户'请去界面手动操作'的建议——你应该调用工具来完成\n\n")
// ── 回答要求 ──
diff --git a/internal/app/app.go b/internal/app/app.go
index c9eb512..155ec0d 100644
--- a/internal/app/app.go
+++ b/internal/app/app.go
@@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/signal"
+ "sync"
"syscall"
"time"
@@ -16,6 +17,7 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
+ "golang.org/x/sync/singleflight"
"gorm.io/gorm"
"solvify-agent/internal/agent"
@@ -127,25 +129,28 @@ func (a *App) initDatabase() error {
return fmt.Errorf("初始化 PostgreSQL 失败: %w", err)
}
a.postgresqlDB = postgresqlDB
- //自动迁移数据库表结构
- //if err := postgresqlDB.AutoMigrate(
- // &entity.User{},
- // &entity.Model{},
- // &entity.UserModelConfig{},
- // &entity.KnowledgeBase{},
- // &entity.StorageQuota{},
- // &entity.Document{},
- // &entity.DocumentProcessingJob{},
- // &entity.DocumentVersion{},
- // &entity.DocumentChunk{},
- // &entity.ChatSession{},
- // &entity.ChatMessage{},
- // &entity.ToolType{},
- // &entity.ToolProvider{},
- // &entity.UserToolConfig{},
- //); err != nil {
- // return fmt.Errorf("数据库自动迁移失败: %w", err)
- //}
+
+ // pgvector 索引健康检查(仅在 enable_pgvector 时执行)
+ if a.cfg.Database.Postgres.EnablePGVector {
+ if err := database.EnsurePGVectorIndex(postgresqlDB); err != nil {
+ logger.Warnf("pgvector 索引检查异常(不阻塞启动): %v", err)
+ }
+ }
+
+ // keywords GIN 索引健康检查(关键词检索核心加速,不依赖 pgvector 开关)
+ if err := database.EnsureKeywordsGINIndex(postgresqlDB); err != nil {
+ logger.Warnf("keywords GIN 索引检查异常(不阻塞启动): %v", err)
+ }
+
+ // 上下文加载链路高频索引(chat_messages / chat_sessions / user_memories)
+ if err := database.EnsureContextIndexes(postgresqlDB); err != nil {
+ logger.Warnf("上下文索引检查异常(不阻塞启动): %v", err)
+ }
+
+ // message_feedback 表 schema 补齐(早期 AutoMigrate 建表后 entity 新增列,AutoMigrate 不会 ADD COLUMN)
+ if err := database.EnsureMessageFeedbackSchema(postgresqlDB); err != nil {
+ logger.Warnf("message_feedback schema 补齐异常(不阻塞启动): %v", err)
+ }
// Redis 缓存连接
redisClient, err := database.OpenRedis(&a.cfg.Database.Redis)
@@ -165,7 +170,19 @@ func (a *App) ensureStorageQuotaUniqueIndex(db *gorm.DB) error {
return nil
}
-// initEmbedding 初始化 Embedding 客户端,返回带缓存的向量化函数
+const (
+ embeddingInMemCacheSize = 2048
+ embeddingRedisTTL = 24 * time.Hour
+)
+
+// initEmbedding 初始化 Embedding 客户端,返回带两级缓存 + singleflight 去重的向量化函数
+//
+// 缓存层级:
+// 1. 进程内 sync.Map fast-path(零网络 IO,容量 embeddingInMemCacheSize,LRU 清理)
+// 2. Redis 共享缓存(跨进程,24h TTL)
+// 3. Embedding API 调用
+//
+// singleflight 防止并发击穿:同一 key 的并发请求只打一次 API。
func (a *App) initEmbedding() rag.EmbeddingFunc {
embeddingClient, err := llm.NewEmbeddingClientFromConfig(context.Background(), &a.cfg.Embedding)
@@ -173,32 +190,86 @@ func (a *App) initEmbedding() rag.EmbeddingFunc {
logger.Fatal("初始化 Embedding 客户端失败", zap.Error(err))
}
- // Embedding 缓存(相同文本 → 相同向量,缓存 24 小时)
- embeddingCache := cache.New(a.redis, "emb:", 24*time.Hour)
+ redisCache := cache.New(a.redis, "emb:", embeddingRedisTTL)
+ var inMem sync.Map // map[string][]float64
+ var sf singleflight.Group
+ var inMemMu sync.Mutex
+ inMemCount := 0
return func(ctx context.Context, text string) ([]float64, error) {
cacheKey := fmt.Sprintf("%x", sha256.Sum256([]byte(text)))
- var vec []float64
- if found, _ := embeddingCache.Get(ctx, cacheKey, &vec); found {
- logger.Infof("[Embedding] 缓存命中: key=%s text=%q dim=%d", cacheKey[:8], text, len(vec))
- return vec, nil
- }
- logger.Infof("[Embedding] 缓存未命中: key=%s text=%q, 调用 API...", cacheKey[:8], text)
- vec, err := embeddingClient.Embed(ctx, text)
- if err != nil {
- logger.Errorf("[Embedding] API 调用失败: %v", err)
- return nil, err
+
+ // 层级 1:进程内 fast-path
+ if v, ok := inMem.Load(cacheKey); ok {
+ logger.Debugf("[Embedding] 内存缓存命中: key=%s", cacheKey[:8])
+ return v.([]float64), nil
}
- logger.Infof("[Embedding] API 返回: dim=%d", len(vec))
- if err := embeddingCache.Set(ctx, cacheKey, vec, 0); err != nil {
- logger.Warnf("[Embedding] 缓存写入失败: %v", err)
- } else {
- logger.Infof("[Embedding] 已写入缓存: key=%s", cacheKey[:8])
+
+ // singleflight 包裹:并发只调一次
+ ch := sf.DoChan(cacheKey, func() (interface{}, error) {
+ // 层级 2:Redis
+ var vec []float64
+ if found, _ := redisCache.Get(ctx, cacheKey, &vec); found {
+ logger.Infof("[Embedding] Redis 缓存命中: key=%s dim=%d", cacheKey[:8], len(vec))
+ inMem.Store(cacheKey, vec)
+ return vec, nil
+ }
+
+ // 层级 3:调 API
+ logger.Infof("[Embedding] 全缓存未命中: key=%s text=%q, 调用 API...", cacheKey[:8], truncateText(text, 60))
+ vec, err := embeddingClient.Embed(ctx, text)
+ if err != nil {
+ logger.Errorf("[Embedding] API 调用失败: %v", err)
+ return nil, err
+ }
+
+ if err := redisCache.Set(ctx, cacheKey, vec, 0); err != nil {
+ logger.Warnf("[Embedding] Redis 缓存写入失败: %v", err)
+ }
+ inMem.Store(cacheKey, vec)
+
+ // 简单容量管控:超阈值时删除约 20% 条目
+ inMemMu.Lock()
+ inMemCount++
+ if inMemCount > embeddingInMemCacheSize {
+ cleaned := 0
+ inMem.Range(func(key, _ any) bool {
+ if cleaned >= embeddingInMemCacheSize/5 {
+ return false
+ }
+ inMem.Delete(key)
+ cleaned++
+ return true
+ })
+ inMemCount -= cleaned
+ logger.Infof("[Embedding] 内存缓存清理: 删除 %d 条, 剩余约 %d", cleaned, inMemCount)
+ }
+ inMemMu.Unlock()
+
+ logger.Infof("[Embedding] API 返回: dim=%d, 已写两级缓存", len(vec))
+ return vec, nil
+ })
+
+ select {
+ case res := <-ch:
+ if res.Err != nil {
+ return nil, res.Err
+ }
+ return res.Val.([]float64), nil
+ case <-ctx.Done():
+ return nil, ctx.Err()
}
- return vec, nil
}
}
+func truncateText(s string, n int) string {
+ r := []rune(s)
+ if len(r) <= n {
+ return s
+ }
+ return string(r[:n]) + "..."
+}
+
// initRetriever 初始化 RAG 检索器(混合检索 + 可选装饰器链)
func (a *App) initRetriever(embeddingFunc rag.EmbeddingFunc) rag.Retriever {
// 使用混合检索器(向量 + 关键词 + RRF 融合)
diff --git a/internal/llm/factory.go b/internal/llm/factory.go
index 8a45b58..71b0553 100644
--- a/internal/llm/factory.go
+++ b/internal/llm/factory.go
@@ -101,5 +101,6 @@ func NewEmbeddingClientFromConfig(ctx context.Context, cfg *config.EmbeddingConf
BaseURL: cfg.BaseURL,
Model: cfg.Model,
Dimension: cfg.Dimension,
+ Timeout: cfg.Timeout,
})
}
diff --git a/internal/model/entity/chat_message.go b/internal/model/entity/chat_message.go
index 109d756..88c4444 100644
--- a/internal/model/entity/chat_message.go
+++ b/internal/model/entity/chat_message.go
@@ -9,7 +9,7 @@ import (
// ChatMessage 映射聊天消息表
type ChatMessage struct {
ID string `gorm:"column:id;type:uuid;default:gen_random_uuid();primaryKey"`
- SessionID string `gorm:"column:session_id;type:uuid;not null"`
+ SessionID string `gorm:"column:session_id;type:uuid;not null;index:idx_chat_messages_session_created,priority:1"`
Role string `gorm:"column:role;type:varchar(20);not null"`
Content string `gorm:"column:content;type:text;not null;default:''"`
ModelID string `gorm:"column:model_id;type:varchar(36)"`
@@ -17,9 +17,8 @@ type ChatMessage struct {
KnowledgeBaseIDs datatypes.JSON `gorm:"column:knowledge_base_ids;type:jsonb;not null;default:'[]'::jsonb"`
Sources datatypes.JSON `gorm:"column:sources;type:jsonb"`
Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb;not null;default:'{}'::jsonb"`
- // Embedding 消息内容的向量表示,用于语义相关历史检索
- Embedding FloatVector `gorm:"column:embedding;type:vector(1024)"`
- CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
+ Embedding FloatVector `gorm:"column:embedding;type:vector(1024)"`
+ CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_chat_messages_session_created,priority:2"`
}
// TableName 返回聊天消息表名
diff --git a/internal/model/entity/chat_session.go b/internal/model/entity/chat_session.go
index 808a256..4412a5c 100644
--- a/internal/model/entity/chat_session.go
+++ b/internal/model/entity/chat_session.go
@@ -10,10 +10,10 @@ import (
// ChatSession 映射聊天会话表
type ChatSession struct {
ID string `gorm:"column:id;type:uuid;default:gen_random_uuid();primaryKey"`
- UserID string `gorm:"column:user_id;type:uuid;not null"`
+ UserID string `gorm:"column:user_id;type:uuid;not null;index:idx_chat_sessions_user_status,priority:1"`
Title string `gorm:"column:title;size:200;not null;default:''"`
ModelID string `gorm:"column:model_id;type:varchar(36);not null"`
- Status string `gorm:"column:status;type:varchar(20);not null;default:'active'"`
+ Status string `gorm:"column:status;type:varchar(20);not null;default:'active';index:idx_chat_sessions_user_status,priority:2"`
PendingClarify datatypes.JSON `gorm:"column:pending_clarify;type:jsonb;not null;default:'{}'::jsonb"`
PendingCheckpoint datatypes.JSON `gorm:"column:pending_checkpoint;type:jsonb;not null;default:'{}'::jsonb"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime"`
diff --git a/internal/model/entity/user_memory.go b/internal/model/entity/user_memory.go
index 0175314..7f608a2 100644
--- a/internal/model/entity/user_memory.go
+++ b/internal/model/entity/user_memory.go
@@ -10,12 +10,12 @@ import (
// UserMemory 用户长期记忆:保存跨会话的事实、偏好、约束和决策结论
type UserMemory struct {
ID string `gorm:"type:uuid;primaryKey"`
- UserID string `gorm:"type:uuid;not null"`
- MemoryType string `gorm:"type:varchar(30);not null"` // fact / preference / constraint / decision
+ UserID string `gorm:"type:uuid;not null;index:idx_user_memories_user_active,priority:1"`
+ MemoryType string `gorm:"type:varchar(30);not null"`
Content string `gorm:"type:text;not null"`
SourceSession *string `gorm:"type:uuid"`
Confidence float64 `gorm:"type:float;default:1.0"`
- IsActive bool `gorm:"default:true"`
+ IsActive bool `gorm:"default:true;index:idx_user_memories_user_active,priority:2"`
CreatedAt time.Time
UpdatedAt time.Time
}
diff --git a/internal/observability/eino_callback.go b/internal/observability/eino_callback.go
index 4c6bd3a..314db80 100644
--- a/internal/observability/eino_callback.go
+++ b/internal/observability/eino_callback.go
@@ -234,6 +234,9 @@ func einoOnEnd(rec Recorder) func(ctx context.Context, info *callbacks.RunInfo,
rec.Incr(ctx, "eino_retriever_requests_total", rl, 1)
rec.Observe(ctx, "eino_retriever_duration_seconds", rl, dur.Seconds())
rec.Observe(ctx, "eino_retriever_hit_count", rl, float64(hitN))
+ if hitN == 0 {
+ rec.Incr(ctx, "eino_retriever_empty_results_total", rl, 1)
+ }
case components.ComponentOfTool:
tl := mergeToolEndAttrs(attrs, output, info, rec, baseLabels)
rec.Incr(ctx, "eino_tool_calls_total", tl, 1)
diff --git a/internal/rag/hybrid_retriever.go b/internal/rag/hybrid_retriever.go
index 07b6745..96e7be0 100644
--- a/internal/rag/hybrid_retriever.go
+++ b/internal/rag/hybrid_retriever.go
@@ -10,8 +10,10 @@ import (
"github.com/go-ego/gse"
"gorm.io/gorm"
+ "solvify-agent/internal/observability"
"solvify-agent/pkg/config"
"solvify-agent/pkg/logger"
+ "solvify-agent/pkg/stopwords"
)
var (
@@ -156,21 +158,29 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query Query) (Result, er
vr := <-vectorCh
kr := <-keywordCh
+ rec := observability.RecorderFromContext(ctx)
+
// 向量检索失败时降级:仅用关键词结果,不阻断检索
if vr.err != nil {
logger.Warnf("向量检索失败,降级为纯关键词检索: %v", vr.err)
vr.docs = nil // 清空,后续只用关键词结果
+ rec.Incr(ctx, "rag_retriever_degradation_total", map[string]string{"side": "vector", "reason": "search_error"}, 1)
}
if kr.err != nil {
logger.Warnf("关键词检索失败,降级为纯向量检索: %v", kr.err)
kr.docs = nil
+ rec.Incr(ctx, "rag_retriever_degradation_total", map[string]string{"side": "keyword", "reason": "search_error"}, 1)
}
// 两种检索都失败才报错
if vr.err != nil && kr.err != nil {
+ rec.Incr(ctx, "rag_retriever_degradation_total", map[string]string{"side": "both", "reason": "search_error"}, 1)
return Result{}, fmt.Errorf("混合检索完全失败: 向量(%v), 关键词(%v)", vr.err, kr.err)
}
+ observeStage(rec, ctx, "vector_raw", float64(len(vr.docs)))
+ observeStage(rec, ctx, "keyword_raw", float64(len(kr.docs)))
+
logger.Infof("向量检索命中: %d 条, 关键词检索命中: %d 条", len(vr.docs), len(kr.docs))
// ===== Step 1: 同源质量检查(各自独立过滤) =====
@@ -182,6 +192,7 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query Query) (Result, er
filteredVector = append(filteredVector, doc)
}
}
+ observeStage(rec, ctx, "vector_filtered", float64(len(filteredVector)))
// 1b. 关键词侧:陡峭度检测 + 最低匹配过滤
filteredKeyword := r.keywordSourceFilter(kr.docs)
@@ -189,7 +200,9 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query Query) (Result, er
// 1c. 向量全灭时,对关键词结果加最低匹配比例过滤
if len(filteredVector) == 0 && len(filteredKeyword) > 0 {
filteredKeyword = filterByMinScore(filteredKeyword, r.keywordScoreThreshold, "关键词")
+ rec.Incr(ctx, "rag_retriever_degradation_total", map[string]string{"side": "keyword_only", "reason": "min_score_filter"}, 1)
}
+ observeStage(rec, ctx, "keyword_filtered", float64(len(filteredKeyword)))
// ===== Step 2: 同源内 Min-Max 归一化 =====
vectorNorm := minMaxNormalize(filteredVector)
@@ -197,9 +210,11 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query Query) (Result, er
// ===== Step 3: RRF 融合 =====
fused := r.reciprocalRankFusion(filteredVector, filteredKeyword)
+ observeStage(rec, ctx, "rrf_fused", float64(len(fused)))
// ===== Step 4: 跨源交叉验证 =====
fused = r.crossSourceFilter(fused, filteredVector, filteredKeyword, vectorNorm, keywordNorm)
+ observeStage(rec, ctx, "cross_filtered", float64(len(fused)))
// ===== Step 5: TopK 截取 =====
docs := make([]Document, 0, len(fused))
@@ -218,6 +233,7 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query Query) (Result, er
Score: item.Score,
})
}
+ observeStage(rec, ctx, "final", float64(len(docs)))
logger.Infof("混合检索最终结果: %d 条 (向量过滤阈值=%.2f, TopK=%d, 向量候选=%d, 关键词候选=%d)",
len(docs), r.scoreThreshold, topK, len(filteredVector), len(filteredKeyword))
@@ -229,6 +245,9 @@ func (r *HybridRetriever) Retrieve(ctx context.Context, query Query) (Result, er
}
// vectorSearch 执行向量检索
+// 优化:主查询只查 document_chunks 表(不 LEFT JOIN documents),
+// 向量距离排序在 chunks 表上直接跑,拿 topK*2 后再批量查 documents 表的 title。
+// 避免对所有候选 chunk 做额外 JOIN。
func (r *HybridRetriever) vectorSearch(ctx context.Context, query Query) ([]scoredChunk, error) {
embedding, err := r.embeddingFunc(ctx, query.Question)
if err != nil {
@@ -236,130 +255,97 @@ func (r *HybridRetriever) vectorSearch(ctx context.Context, query Query) ([]scor
}
vectorStr := vectorToString(embedding)
- topK := query.TopK * 2 // 多召回一些用于融合
+ topK := query.TopK * 2
var results []scoredChunk
err = r.db.WithContext(ctx).Raw(`
- SELECT id, knowledge_base_id, document_id, version_id, chunk_index, title, content, score, keywords
- FROM (
- SELECT
- dc.id,
- dc.knowledge_base_id,
- dc.document_id,
- dc.version_id,
- dc.chunk_index,
- COALESCE(d.title, '') as title,
- dc.content,
- 1 - (dc.embedding <=> ?::vector) AS score,
- COALESCE(dc.keywords::text, '{}') as keywords
- FROM document_chunks dc
- LEFT JOIN documents d ON d.id = dc.document_id
- WHERE dc.knowledge_base_id IN (?)
- AND dc.embedding IS NOT NULL
- AND dc.user_id = ?
- ORDER BY dc.embedding <=> ?::vector
- LIMIT ?
- ) sub
+ SELECT
+ dc.id,
+ dc.knowledge_base_id,
+ dc.document_id,
+ dc.version_id,
+ dc.chunk_index,
+ dc.content,
+ 1 - (dc.embedding <=> ?::vector) AS score,
+ COALESCE(dc.keywords::text, '{}') as keywords
+ FROM document_chunks dc
+ WHERE dc.knowledge_base_id IN (?)
+ AND dc.embedding IS NOT NULL
+ AND dc.user_id = ?
+ ORDER BY dc.embedding <=> ?::vector
+ LIMIT ?
`, vectorStr, query.KnowledgeBaseIDs, query.UserID, vectorStr, topK).Scan(&results).Error
if err != nil {
return nil, err
}
+ // 批量填 title(只对 topK*2 条查,开销可忽略)
+ batchFillTitles(r.db, results)
+
logger.Infof("向量检索原始结果: %d 条", len(results))
return results, nil
}
// keywordSearch 执行关键词检索
// 优化:GIN 索引加速 && overlap 过滤(主收益),unnest 仅对过滤后的少量行计算分数
+// 也去掉了 LEFT JOIN documents,title 在主查询完成后批量填
func (r *HybridRetriever) keywordSearch(ctx context.Context, query Query) ([]scoredChunk, error) {
- // 从问题中提取关键词(简单的分词策略)
keywords := extractKeywords(query.Question)
if len(keywords) == 0 {
return nil, nil
}
- topK := query.TopK * 2 // 多召回一些用于融合
+ topK := query.TopK * 2
var results []scoredChunk
- // 构建关键词数组字面量
keywordArray := buildPostgresArray(keywords)
err := r.db.WithContext(ctx).Raw(`
- SELECT id, knowledge_base_id, document_id, version_id, chunk_index, title, content, score, keywords
- FROM (
- SELECT
- dc.id,
- dc.knowledge_base_id,
- dc.document_id,
- dc.version_id,
- dc.chunk_index,
- COALESCE(d.title, '') as title,
- dc.content,
- -- 计算匹配关键词占比:匹配数 / 查询关键词总数
- -- unnest 仅在 && 过滤后的少量行上执行,GIN 索引保证过滤极快
- (
- SELECT COUNT(*)::float / GREATEST(cardinality(?::text[]), 1)
- FROM unnest(dc.keywords) AS kw
- WHERE kw = ANY(?::text[])
- ) AS score,
- COALESCE(dc.keywords::text, '{}') as keywords
- FROM document_chunks dc
- LEFT JOIN documents d ON d.id = dc.document_id
- WHERE dc.knowledge_base_id IN (?)
- AND dc.keywords IS NOT NULL
- AND dc.keywords && ?::text[] -- GIN 索引加速:先快速过滤有交集的 chunk
- AND dc.user_id = ?
- ORDER BY score DESC
- LIMIT ?
- ) sub
- WHERE score > 0
+ SELECT
+ dc.id,
+ dc.knowledge_base_id,
+ dc.document_id,
+ dc.version_id,
+ dc.chunk_index,
+ dc.content,
+ (
+ SELECT COUNT(*)::float / GREATEST(cardinality(?::text[]), 1)
+ FROM unnest(dc.keywords) AS kw
+ WHERE kw = ANY(?::text[])
+ ) AS score,
+ COALESCE(dc.keywords::text, '{}') as keywords
+ FROM document_chunks dc
+ WHERE dc.knowledge_base_id IN (?)
+ AND dc.keywords IS NOT NULL
+ AND dc.keywords && ?::text[]
+ AND dc.user_id = ?
+ AND dc.embedding IS NOT NULL
+ ORDER BY score DESC
+ LIMIT ?
`, keywordArray, keywordArray, query.KnowledgeBaseIDs, keywordArray, query.UserID, topK).Scan(&results).Error
if err != nil {
return nil, err
}
- logger.Infof("关键词检索原始结果: %d 条, 关键词: %v", len(results), keywords)
- return results, nil
-}
+ batchFillTitles(r.db, results)
-// extractKeywords 使用 gse 分词提取关键词
-func extractKeywords(question string) []string {
- stopWords := map[string]bool{
- "的": true, "了": true, "在": true, "是": true, "我": true,
- "有": true, "和": true, "就": true, "不": true, "人": true,
- "都": true, "一": true, "一个": true, "上": true, "也": true,
- "很": true, "到": true, "说": true, "要": true, "去": true,
- "你": true, "会": true, "着": true, "没有": true, "看": true,
- "好": true, "自己": true, "这": true, "他": true, "她": true,
- "它": true, "们": true, "那": true, "什么": true,
- "怎么": true, "如何": true, "为什么": true, "哪": true, "哪个": true,
- "哪些": true, "吗": true, "呢": true, "吧": true, "啊": true,
- "the": true, "a": true, "an": true, "is": true, "are": true,
- "was": true, "were": true, "be": true, "been": true, "being": true,
- "have": true, "has": true, "had": true, "do": true, "does": true,
- "did": true, "will": true, "would": true, "could": true, "should": true,
- "may": true, "might": true, "can": true, "shall": true, "must": true,
- "to": true, "of": true, "in": true, "for": true, "on": true,
- "with": true, "at": true, "by": true, "from": true, "as": true,
- "into": true, "through": true, "during": true, "before": true, "after": true,
- "above": true, "below": true, "between": true, "and": true, "but": true,
- "or": true, "nor": true, "not": true, "so": true, "yet": true,
- "both": true, "either": true, "neither": true, "each": true, "every": true,
- "all": true, "any": true, "few": true, "more": true, "most": true,
- "other": true, "some": true, "such": true, "no": true, "only": true,
- "own": true, "same": true, "than": true, "too": true, "very": true,
- "just": true, "because": true, "if": true, "when": true, "where": true,
- "how": true, "what": true, "which": true, "who": true, "whom": true,
- "this": true, "that": true, "these": true, "those": true, "i": true,
- "me": true, "my": true, "we": true, "our": true, "you": true,
- "your": true, "he": true, "him": true, "his": true, "she": true,
- "her": true, "it": true, "its": true, "they": true, "them": true,
- "their": true,
+ // 过滤零分结果
+ filtered := results[:0]
+ for _, r := range results {
+ if r.Score > 0 {
+ filtered = append(filtered, r)
+ }
}
+ logger.Infof("关键词检索原始结果: %d 条(有效), 关键词: %v", len(filtered), keywords)
+ return filtered, nil
+}
+
+// extractKeywords 使用 gse 分词提取关键词,过滤停用词
+func extractKeywords(question string) []string {
seg := getSegmenter()
words := seg.Cut(question, true)
@@ -370,7 +356,7 @@ func extractKeywords(question string) []string {
if w == "" || len(w) < 2 {
continue
}
- if stopWords[w] {
+ if stopwords.IsStopWord(w) {
continue
}
if seen[w] {
@@ -379,10 +365,50 @@ func extractKeywords(question string) []string {
seen[w] = true
keywords = append(keywords, w)
}
-
return keywords
}
+// batchFillTitles 对检索结果批量填充文档标题。
+// 把 LEFT JOIN documents 从主查询里拆出来——主查询只跑 chunks 表排序取 topK,
+// 然后对这少量结果的 document_id 做一次 IN 查询拿 title,JOIN 开销从 O(全量 chunks) 降到 O(topK)。
+func batchFillTitles(db *gorm.DB, chunks []scoredChunk) {
+ if db == nil || len(chunks) == 0 {
+ return
+ }
+ // 收集去重的 document_id
+ docIDs := make(map[string]struct{})
+ for _, c := range chunks {
+ if c.DocumentID != "" {
+ docIDs[c.DocumentID] = struct{}{}
+ }
+ }
+ if len(docIDs) == 0 {
+ return
+ }
+ idList := make([]string, 0, len(docIDs))
+ for id := range docIDs {
+ idList = append(idList, id)
+ }
+
+ var rows []struct {
+ ID string
+ Title string
+ }
+ if err := db.Raw("SELECT id, title FROM documents WHERE id IN ?", idList).Scan(&rows).Error; err != nil {
+ logger.Warnf("batchFillTitles 查 documents 失败: %v", err)
+ return
+ }
+ titleMap := make(map[string]string, len(rows))
+ for _, r := range rows {
+ titleMap[r.ID] = r.Title
+ }
+ for i := range chunks {
+ if t, ok := titleMap[chunks[i].DocumentID]; ok {
+ chunks[i].Title = t
+ }
+ }
+}
+
// buildPostgresArray 构建 PostgreSQL 数组字面量
func buildPostgresArray(items []string) string {
if len(items) == 0 {
@@ -593,3 +619,12 @@ func (r *HybridRetriever) crossSourceFilter(
return result
}
+
+// observeStage 记录混合检索各阶段的候选条数,供 Prometheus 观测管线漏斗。
+// rec 为 nil 时静默跳过(单元测试或未注册 observability 的场景)。
+func observeStage(rec observability.Recorder, ctx context.Context, stage string, count float64) {
+ if rec == nil {
+ return
+ }
+ rec.Observe(ctx, "rag_retriever_stage_count", map[string]string{"stage": stage}, count)
+}
diff --git a/internal/rag/rerank_retriever.go b/internal/rag/rerank_retriever.go
index 561d764..e1bceaf 100644
--- a/internal/rag/rerank_retriever.go
+++ b/internal/rag/rerank_retriever.go
@@ -24,6 +24,8 @@ type RerankRetriever struct {
timeout time.Duration
scoreThreshold float64
httpClient *http.Client
+ maxRetries int
+ baseBackoff time.Duration
}
// RerankRetrieverConfig 描述重排序检索器配置
@@ -37,31 +39,40 @@ type RerankRetrieverConfig struct {
ScoreThreshold float64
}
+const (
+ defaultRerankTimeout = 5
+ defaultRerankTopN = 3
+ defaultRerankThreshold = 0.5
+ defaultRerankMaxRetries = 3
+ defaultRerankBaseBackoff = 100 * time.Millisecond
+)
+
// NewRerankRetriever 创建重排序检索器
func NewRerankRetriever(cfg RerankRetrieverConfig) *RerankRetriever {
timeout := cfg.Timeout
if timeout <= 0 {
- timeout = 5
+ timeout = defaultRerankTimeout
}
topN := cfg.TopN
if topN <= 0 {
- topN = 3
+ topN = defaultRerankTopN
}
threshold := cfg.ScoreThreshold
if threshold <= 0 {
- threshold = 0.5
+ threshold = defaultRerankThreshold
}
+ d := time.Duration(timeout) * time.Second
return &RerankRetriever{
inner: cfg.Inner,
endpoint: cfg.Endpoint,
model: cfg.Model,
apiKey: cfg.APIKey,
topN: topN,
- timeout: time.Duration(timeout) * time.Second,
+ timeout: d,
scoreThreshold: threshold,
- httpClient: &http.Client{
- Timeout: time.Duration(timeout) * time.Second,
- },
+ httpClient: &http.Client{Timeout: d},
+ maxRetries: defaultRerankMaxRetries,
+ baseBackoff: defaultRerankBaseBackoff,
}
}
@@ -103,13 +114,11 @@ type rerankResponse struct {
func (r *RerankRetriever) Retrieve(ctx context.Context, query Query) (Result, error) {
logger.Infof("[Rerank] 开始重排序, query=%q, endpoint=%s, model=%s", query.Question, r.endpoint, r.model)
- // 先调用内层检索器
result, err := r.inner.Retrieve(ctx, query)
if err != nil {
return Result{}, err
}
- // 无结果则直接返回
if !result.Hit || len(result.Documents) == 0 {
logger.Infof("[Rerank] 内层检索无结果,跳过重排序")
return result, nil
@@ -117,76 +126,100 @@ func (r *RerankRetriever) Retrieve(ctx context.Context, query Query) (Result, er
logger.Infof("[Rerank] 内层检索返回 %d 条,开始调用 Rerank API", len(result.Documents))
for i, doc := range result.Documents {
- logger.Infof("[Rerank] 输入#%d: [%s] score=%.4f chunk#%d title=%q content=%q",
+ logger.Debugf("[Rerank] 输入#%d: [%s] score=%.4f chunk#%d title=%q content=%q",
i, doc.DocumentID, doc.Score, doc.ChunkIndex, doc.Title, truncate(doc.Content, 60))
}
- // 调用 Rerank API
- reranked, err := r.rerank(ctx, query.Question, result.Documents)
+ reranked, err := r.rerankWithRetry(ctx, query.Question, result.Documents)
if err != nil {
- // 优雅降级:Rerank 失败时返回原始结果
- logger.Warnf("[Rerank] Rerank API 调用失败,降级返回原始结果: %v", err)
+ logger.Warnf("[Rerank] Rerank API 调用失败(已重试 %d 次),降级返回原始结果: %v", r.maxRetries, err)
return result, nil
}
- logger.Infof("[Rerank] API 返回 %d 条结果", len(reranked))
- for _, item := range reranked {
- logger.Infof("[Rerank] API结果#%d: relevance_score=%.4f", item.Index, item.RelevanceScore)
- }
-
- // 用重排序分数替换原始分数
for _, item := range reranked {
if item.Index >= 0 && item.Index < len(result.Documents) {
oldScore := result.Documents[item.Index].Score
result.Documents[item.Index].Score = item.RelevanceScore
- logger.Infof("[Rerank] 文档#%d [%s] 分数: %.4f → %.4f",
+ logger.Debugf("[Rerank] 文档#%d [%s] 分数: %.4f → %.4f",
item.Index, result.Documents[item.Index].DocumentID, oldScore, item.RelevanceScore)
}
}
- // 按新分数降序排序
sort.Slice(result.Documents, func(i, j int) bool {
return result.Documents[i].Score > result.Documents[j].Score
})
- // 过滤低分结果
filtered := make([]Document, 0, len(result.Documents))
for _, doc := range result.Documents {
if doc.Score >= r.scoreThreshold {
filtered = append(filtered, doc)
} else {
- logger.Infof("[Rerank] 过滤: [%s] score=%.4f < 阈值 %.2f", doc.DocumentID, doc.Score, r.scoreThreshold)
+ logger.Debugf("[Rerank] 过滤: [%s] score=%.4f < 阈值 %.2f", doc.DocumentID, doc.Score, r.scoreThreshold)
}
}
- // 截取 TopN
if len(filtered) > r.topN {
logger.Infof("[Rerank] 截取 TopN: %d → %d", len(filtered), r.topN)
filtered = filtered[:r.topN]
}
logger.Infof("[Rerank] 重排序完成: 原始 %d 条 → 过滤后 %d 条", len(result.Documents), len(filtered))
- for i, doc := range filtered {
- logger.Infof("[Rerank] 最终#%d: [%s] score=%.4f chunk#%d title=%q content=%q",
- i, doc.DocumentID, doc.Score, doc.ChunkIndex, doc.Title, truncate(doc.Content, 60))
- }
-
return Result{
Hit: len(filtered) > 0,
Documents: filtered,
}, nil
}
-// rerank 调用外部 Rerank API
-func (r *RerankRetriever) rerank(ctx context.Context, query string, docs []Document) ([]rerankResult, error) {
+// rerankWithRetry 调用 Rerank API,带指数退避重试
+//
+// 重试策略:
+// - 4xx(除 429): 客户端错误,立即放弃(参数/鉴权问题,重试无意义)
+// - 429 / 5xx / 网络错误: 重试,baseBackoff * 2^attempt,最多 maxRetries 次
+// - context 取消: 立即放弃
+func (r *RerankRetriever) rerankWithRetry(ctx context.Context, query string, docs []Document) ([]rerankResult, error) {
+ var lastErr error
+
+ for attempt := 0; attempt <= r.maxRetries; attempt++ {
+ if attempt > 0 {
+ // 计算退避时间
+ backoff := r.baseBackoff * time.Duration(1<
= 500 {
+ // 5xx 服务端错误,可重试
+ return nil, true, fmt.Errorf("rerank API 返回 %d (5xx): %s", resp.StatusCode, truncate(string(respBody), 200))
+ }
+ // 其他 4xx(400, 401, 403, 404 等):客户端错误,不可重试
+ return nil, false, fmt.Errorf("rerank API 返回 %d (4xx, 不可重试): %s", resp.StatusCode, truncate(string(respBody), 200))
+ }
}
diff --git a/internal/repository/chat_message_repository.go b/internal/repository/chat_message_repository.go
index f46ae89..fe4bcde 100644
--- a/internal/repository/chat_message_repository.go
+++ b/internal/repository/chat_message_repository.go
@@ -108,15 +108,24 @@ func (r *chatMessageRepository) SearchRecentByKeywords(ctx context.Context, sess
return r.FindRecent(ctx, sessionID, limit)
}
- query := r.db.WithContext(ctx).
- Where("session_id = ?", sessionID)
-
- // 多个关键词用 OR 连接,任意匹配即可
- for _, kw := range keywords {
- if kw == "" {
- continue
+ // 关键:session_id 是硬过滤,多个关键词之间用 OR 连接,但必须整体包在 AND 里。
+ // 错误写法:Where(session_id).Or(ILIKE)... → 实际 SQL 是 WHERE session_id = ? OR content ILIKE ?
+ // 会匹配到全库所有包含关键词的消息,再 ORDER BY + LIMIT,数据量上来必炸。
+ // 正确 SQL:WHERE session_id = ? AND (content ILIKE ? OR content ILIKE ? ...)
+ query := r.db.WithContext(ctx).Where("session_id = ?", sessionID)
+
+ if len(keywords) == 1 {
+ query = query.Where("content ILIKE ?", "%"+keywords[0]+"%")
+ } else {
+ sub := r.db.Session(&gorm.Session{NewDB: true})
+ for i, kw := range keywords {
+ if i == 0 {
+ sub = sub.Where("content ILIKE ?", "%"+kw+"%")
+ } else {
+ sub = sub.Or("content ILIKE ?", "%"+kw+"%")
+ }
}
- query = query.Or("content ILIKE ?", "%"+kw+"%")
+ query = query.Where(sub)
}
var messages []entity.ChatMessage
diff --git a/internal/repository/observability_repository.go b/internal/repository/observability_repository.go
index f6f792b..8d0df7d 100644
--- a/internal/repository/observability_repository.go
+++ b/internal/repository/observability_repository.go
@@ -341,28 +341,40 @@ func AttrsFromRoot(root *observability.Span) map[string]any {
}
// stripInternalSpanAttrs 递归清理 span 树中 __ 开头的内部 attrs(写 span_tree JSON 前调用)。
+//
+// 并发安全说明:
+// - 此函数可能在 FlushTrace 的后台 goroutine 执行时,与主请求 goroutine 里尚未结束的
+// SetSpanAttrs 并发读写同一份 span.Attrs。Go 的原生 map 不支持并发读写,任何一边的
+// delete/写都会触发 fatal error: concurrent map iteration and map write。
+// - 修复方式:不原地 delete,而是先拷贝一份 attrs,在副本上过滤,再赋回 s.Attrs。
+// 这样迭代读的是独立副本,就算主 goroutine 同时在写,最坏情况是 strip 拿到的是
+// 旧快照,不会 crash——FlushTrace 完后 span 对象就丢了,不影响业务。
func stripInternalSpanAttrs(s *observability.Span) {
if s == nil {
return
}
- if s.Attrs != nil {
- for k := range s.Attrs {
- if strings.HasPrefix(k, "__") {
- delete(s.Attrs, k)
+ if len(s.Attrs) > 0 {
+ clean := make(observability.Attrs, len(s.Attrs))
+ for k, v := range s.Attrs { // 迭代期间无任何 delete/写,只是读
+ if !strings.HasPrefix(k, "__") {
+ clean[k] = v
}
}
+ s.Attrs = clean
}
for i := range s.Children {
stripInternalSpanAttrs(s.Children[i])
}
for i := range s.Events {
- if s.Events[i] == nil || s.Events[i].Attrs == nil {
+ if s.Events[i] == nil || len(s.Events[i].Attrs) == 0 {
continue
}
- for k := range s.Events[i].Attrs {
- if strings.HasPrefix(k, "__") {
- delete(s.Events[i].Attrs, k)
+ clean := make(observability.Attrs, len(s.Events[i].Attrs))
+ for k, v := range s.Events[i].Attrs {
+ if !strings.HasPrefix(k, "__") {
+ clean[k] = v
}
}
+ s.Events[i].Attrs = clean
}
}
diff --git a/internal/service/chat_error_messages.go b/internal/service/chat_error_messages.go
index aecb6b9..6bc0809 100644
--- a/internal/service/chat_error_messages.go
+++ b/internal/service/chat_error_messages.go
@@ -1,5 +1,7 @@
package service
+import "strings"
+
// ErrorMessage 用户友好的错误消息
type ErrorMessage struct {
Title string // 错误标题
@@ -8,8 +10,9 @@ type ErrorMessage struct {
}
// errorMessages 错误消息映射表
+// key 用于模糊匹配:getFriendlyError 会检查 err.Error() 和 rawError 是否包含 key
var errorMessages = map[string]ErrorMessage{
- // 模型相关
+ // 模型配置相关
"模型配置无效或无权访问": {
Title: "模型加载失败",
Detail: "请在设置中检查模型配置,或选择其他模型",
@@ -43,7 +46,59 @@ var errorMessages = map[string]ErrorMessage{
Retryable: false,
},
- // LLM 相关
+ // LLM 服务端错误(HTTP 状态码 + 关键词)
+ "503": {
+ Title: "AI 服务暂时不可用",
+ Detail: "模型端点暂时无法响应,请稍后重试",
+ Retryable: true,
+ },
+ "Service Unavailable": {
+ Title: "AI 服务暂时不可用",
+ Detail: "模型端点暂时无法响应,请稍后重试",
+ Retryable: true,
+ },
+ "Service temporarily unavailable": {
+ Title: "AI 服务暂时不可用",
+ Detail: "模型端点暂时无法响应,请稍后重试",
+ Retryable: true,
+ },
+ "429": {
+ Title: "AI 服务请求过于频繁",
+ Detail: "模型端点限流了,请稍等一会儿再试",
+ Retryable: true,
+ },
+ "Too Many Requests": {
+ Title: "AI 服务请求过于频繁",
+ Detail: "模型端点限流了,请稍等一会儿再试",
+ Retryable: true,
+ },
+ "context length": {
+ Title: "对话太长了",
+ Detail: "当前对话超出了模型的上下文限制,请开启新对话继续",
+ Retryable: false,
+ },
+ "token limit": {
+ Title: "对话太长了",
+ Detail: "当前对话超出了模型的上下文限制,请开启新对话继续",
+ Retryable: false,
+ },
+ "超时": {
+ Title: "AI 服务响应超时",
+ Detail: "模型端点响应过慢,请稍后重试或切换更快的模型",
+ Retryable: true,
+ },
+ "timeout": {
+ Title: "AI 服务响应超时",
+ Detail: "模型端点响应过慢,请稍后重试或切换更快的模型",
+ Retryable: true,
+ },
+ "context deadline exceeded": {
+ Title: "AI 服务响应超时",
+ Detail: "模型端点响应过慢,请稍后重试或切换更快的模型",
+ Retryable: true,
+ },
+
+ // LLM 调用通用错误
"LLM 调用失败": {
Title: "AI 服务异常",
Detail: "AI 服务暂时不可用,请稍后重试",
@@ -72,6 +127,18 @@ var errorMessages = map[string]ErrorMessage{
Retryable: true,
},
+ // Graph 执行错误(快速模式)
+ "快速检索执行失败": {
+ Title: "快速检索链路异常",
+ Detail: "请稍后重试或切换到深度模式",
+ Retryable: true,
+ },
+ "快速检索流式生成失败": {
+ Title: "AI 生成中断",
+ Detail: "回答生成过程中断,请重试",
+ Retryable: true,
+ },
+
// 会话相关
"会话不存在": {
Title: "会话已失效",
@@ -118,20 +185,27 @@ var errorMessages = map[string]ErrorMessage{
}
// getFriendlyError 获取用户友好的错误消息
-func getFriendlyError(errMsg string) ErrorMessage {
- // 精确匹配
- if msg, ok := errorMessages[errMsg]; ok {
+// 匹配顺序:先检查 err.Error()(底层错误详情,如 503/429),再检查 rawError(业务层自定义描述)
+// 这样像 "快速检索执行失败" 这种通用描述不会掩盖掉底层真正的错误原因
+func getFriendlyError(err error, rawError string) ErrorMessage {
+ var combined strings.Builder
+ if err != nil {
+ combined.WriteString(err.Error())
+ }
+ combined.WriteString("|")
+ combined.WriteString(rawError)
+ text := combined.String()
+
+ if msg, ok := errorMessages[text]; ok {
return msg
}
- // 模糊匹配
for key, msg := range errorMessages {
- if contains(errMsg, key) {
+ if contains(text, key) {
return msg
}
}
- // 默认错误消息
return ErrorMessage{
Title: "操作失败",
Detail: "请稍后重试,如问题持续请联系管理员",
diff --git a/internal/service/chat_event_helpers.go b/internal/service/chat_event_helpers.go
index cf603f9..25db296 100644
--- a/internal/service/chat_event_helpers.go
+++ b/internal/service/chat_event_helpers.go
@@ -6,8 +6,9 @@ import (
)
// sendErrorEvent 发送友好的错误事件
+// 匹配顺序:先查 err.Error()(包含底层错误详情如 503/429/timeout),再查 rawError(自定义描述)
func sendErrorEvent(eventCh chan<- dto.StreamEvent, err error, rawError string) {
- friendly := getFriendlyError(rawError)
+ friendly := getFriendlyError(err, rawError)
logger.Errorf("错误事件: title=%s, raw=%s, err=%v", friendly.Title, rawError, err)
diff --git a/internal/service/chat_service.go b/internal/service/chat_service.go
index 6d7dd3a..934cf45 100644
--- a/internal/service/chat_service.go
+++ b/internal/service/chat_service.go
@@ -351,29 +351,76 @@ func (s *chatService) initContext(ctx context.Context, userID, sessionID, modelI
}
historyBudget, retrievalBudget, memoryBudget := calculateContextBudgets(maxCtx, toolsTokens)
- // 3. 加载用户基本信息
- userCtx := s.loadUserContext(ctx, userID)
+ // 3. 并行加载:BuildContext / 用户信息 / 用户偏好 —— 三者无依赖,全部 goroutine
+ type userLoadResult struct {
+ entity *entity.User
+ err error
+ }
+ type prefLoadResult struct {
+ pref *entity.UserPreference
+ err error
+ }
+ type ctxLoadResult struct {
+ ctx *EnhancedContext
+ err error
+ }
- // 4. 使用 ContextService 构建增强上下文
- var enhancedCtx *EnhancedContext
- if s.contextSvc != nil {
- t1 = time.Now()
- enhancedCtx, err = s.contextSvc.BuildContext(ctx, userID, sessionID, currentQuery, BuildContextConfig{
- MaxTokens: historyBudget,
- MaxMemories: 10,
- MaxRecentMessages: 20,
- RetrievalBudget: retrievalBudget,
- MemoryBudget: memoryBudget,
- ModelName: modelName,
- ToolsTokens: toolsTokens,
- }, client.ChatModel())
- if err != nil {
- logger.Warnf("构建增强上下文失败,降级为传统方式: %v", err)
+ userCh := make(chan userLoadResult, 1)
+ prefCh := make(chan prefLoadResult, 1)
+ ctxCh := make(chan ctxLoadResult, 1)
+
+ // goroutine A: 用户信息(只查一次,UserCtx 和 Profile 共用结果)
+ go func() {
+ var r userLoadResult
+ if s.userRepo != nil && userID != "" {
+ u, err := s.userRepo.FindByID(userID)
+ r = userLoadResult{entity: u, err: err}
}
- }
+ userCh <- r
+ }()
- // 兜底:传统截断
+ // goroutine B: 用户偏好
+ go func() {
+ var r prefLoadResult
+ if s.prefSvc != nil && userID != "" {
+ p, err := s.prefSvc.GetByUserID(ctx, userID)
+ r = prefLoadResult{pref: p, err: err}
+ }
+ prefCh <- r
+ }()
+
+ // goroutine C: BuildContext(内部自己已经是 4 路并行:summary/memories/recent/relevant)
+ go func() {
+ var r ctxLoadResult
+ if s.contextSvc != nil {
+ enhancedCtx, bErr := s.contextSvc.BuildContext(ctx, userID, sessionID, currentQuery, BuildContextConfig{
+ MaxTokens: historyBudget,
+ MaxMemories: 10,
+ MaxRecentMessages: 20,
+ RetrievalBudget: retrievalBudget,
+ MemoryBudget: memoryBudget,
+ ModelName: modelName,
+ ToolsTokens: toolsTokens,
+ }, client.ChatModel())
+ if bErr != nil {
+ logger.Warnf("构建增强上下文失败,降级为传统方式: %v", bErr)
+ }
+ r = ctxLoadResult{ctx: enhancedCtx, err: bErr}
+ }
+ ctxCh <- r
+ }()
+
+ userRes := <-userCh
+ prefRes := <-prefCh
+ ctxRes := <-ctxCh
+
+ // 填 enhancedCtx
+ var enhancedCtx *EnhancedContext
+ if ctxRes.err == nil && ctxRes.ctx != nil {
+ enhancedCtx = ctxRes.ctx
+ }
if enhancedCtx == nil {
+ // 兜底:传统截断
msg, _ := s.messageRepo.FindRecentForContext(ctx, sessionID, 20)
enhancedCtx = &EnhancedContext{
History: truncateHistoryByTokens(msg, historyBudget, modelName),
@@ -381,22 +428,29 @@ func (s *chatService) initContext(ctx context.Context, userID, sessionID, modelI
RetrievalBudget: retrievalBudget,
}
}
- enhancedCtx.UserCtx = userCtx
- // 填充阶段二用户画像、偏好(任何失败不阻断主流程)
- if userEntity, err := s.userRepo.FindByID(userID); err == nil && userEntity != nil {
- enhancedCtx.Profile = userEntity
- if s.prefSvc != nil {
- if p, e := s.prefSvc.GetByUserID(ctx, userID); e == nil {
- enhancedCtx.Preference = p
- }
- }
+ // 用 goroutine A 的结果同时填 UserCtx 和 Profile(之前 loadUserContext + 后面 FindByID 查了两次,现在一次搞定)
+ if userRes.err != nil {
+ logger.Warnf("加载用户信息失败, userID=%s: %v", userID, userRes.err)
+ }
+ if userRes.entity != nil {
+ enhancedCtx.UserCtx = NewUserContext(*userRes.entity)
+ enhancedCtx.Profile = userRes.entity
+ } else {
+ enhancedCtx.UserCtx = NewUserContext(entity.User{})
+ }
+
+ if prefRes.err != nil {
+ logger.Warnf("加载用户偏好失败, userID=%s: %v", userID, prefRes.err)
+ }
+ if prefRes.pref != nil {
+ enhancedCtx.Preference = prefRes.pref
}
logger.Infof("增强上下文: 历史 %d 条(预算 %d), 记忆 %d 条(预算 %d), 检索预算 %d, 工具预留 %d, 摘要存在=%v, 模型窗口=%d, 用户=%s, 偏好=%v",
len(enhancedCtx.History), enhancedCtx.HistoryBudget,
len(enhancedCtx.Memories), memoryBudget,
- enhancedCtx.RetrievalBudget, toolsTokens, enhancedCtx.Summary != nil, maxCtx, userCtx.Username,
+ enhancedCtx.RetrievalBudget, toolsTokens, enhancedCtx.Summary != nil, maxCtx, enhancedCtx.UserCtx.Username,
enhancedCtx.Preference != nil)
// P1-⑨:分块 token 指标(Prometheus /metrics 直接聚合可看"到底是哪一块把窗口撑爆了")
diff --git a/internal/service/chat_service_graph_quick.go b/internal/service/chat_service_graph_quick.go
index bfc2dae..ce9d76e 100644
--- a/internal/service/chat_service_graph_quick.go
+++ b/internal/service/chat_service_graph_quick.go
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
+ "regexp"
"sort"
"strings"
"time"
@@ -66,6 +67,10 @@ type quickGraphState struct {
ClarifyOptions []string // 追问选项(可选)
Keywords []string // 改写时提取的关键词,可用于日志/调试
RetrievedDocs []*schema.Document
+
+ // RewriteDone 后台 goroutine 完成 LLM 改写后关闭,Retrieve 阶段可选等待
+ RewriteDone chan struct{}
+ RewriteErr error
}
// 查询改写意图类型
@@ -129,16 +134,18 @@ const (
graphQuickNodeGenerate = "generate"
)
-// genState 每次 Graph 执行新建一个本地状态
-func genState(_ context.Context) *quickGraphState {
- return &quickGraphState{}
-}
-
// buildQuickGraph 构建 START → rewrite → retrieve → build_msgs → generate → END 流水线。
+// graphState 必须在 Invoke 前创建好并传入——它既是 eino compose 的 stateGenerator 返回值,
+// 也是 Invoke 返回后外部读取 RetrievedDocs 的唯一入口。
func buildQuickGraph(
- _ context.Context,
+ graphState *quickGraphState,
einoRetriever *rag.EinoRetrieverAdapter,
) (*einoCompose.Graph[*quickGraphInput, *schema.StreamReader[*schema.Message]], error) {
+ // genState 是 buildQuickGraph 的局部闭包,始终返回同一个 graphState 实例。
+ // eino compose 在 runCtx 里只是用 internalState 包装 graphState 指针,
+ // 所以 StatePostHandler 写入的字段和外部 graphState 是同一对象——Invoke 返回后还能读到。
+ genState := func(_ context.Context) *quickGraphState { return graphState }
+
g := einoCompose.NewGraph[*quickGraphInput, *schema.StreamReader[*schema.Message]](
einoCompose.WithGenLocalState(genState),
)
@@ -173,73 +180,168 @@ func addQuickRewriteNode(g *einoCompose.Graph[*quickGraphInput, *schema.StreamRe
)
}
-// quickRewriteFn 节点 1 实现:调 LLM 对用户问题做改写 + 意图识别。
-// 降级策略:LLM 改写失败 → fallback 原始 query,不阻塞主流程。
-// 短路优化:如果 graphInput.PreRewrittenQuery 已填(Graph 执行前已算过),直接复用不再调 LLM。
-// SkipRetrieve=true 时返回空串,Retriever 收到空串会快速返回空 docs。
+// quickRewriteFn 节点 1 实现:触发查询改写 + 意图识别,立即返回原始 query 让 Retrieve 先行。
+// 并行策略:LLM 改写放到后台 goroutine 异步执行,Retrieve 用 OriginalQuery 先跑,
+// 改写结果通过 State.RewriteDone channel 通知下游,Retrieve StatePostHandler 可选等待并补检索。
+// 短路优化:如果 graphInput.PreRewrittenQuery 已填(Graph 执行前已算过),直接同步执行不再起 goroutine。
func quickRewriteFn(ctx context.Context, input *quickGraphInput) (string, error) {
if input == nil {
return "", apperrors.NewDefault(apperrors.CodeInvalidParam)
}
+
+ // 初始化 State:存 Input + 创建 RewriteDone channel
if err := einoCompose.ProcessState(ctx, func(_ context.Context, state *quickGraphState) error {
state.Input = input
+ state.RewriteDone = make(chan struct{})
return nil
}); err != nil {
return "", err
}
- startAt := time.Now()
- var (
- rewritten string
- intent string
- keywords []string
- skipRetrieve bool
- needClarify bool
- clarifyQuestion string
- clarifyOptions []string
- )
-
- // 短路:Graph 执行前已算好,直接复用
+ // 短路:Graph 执行前已算好 → 同步写 state,不走并行
if input.PreRewrittenQuery != "" {
- rewritten = input.PreRewrittenQuery
- intent = input.PreIntent
- keywords = input.PreKeywords
- skipRetrieve = input.PreSkipRetrieve
- needClarify = input.PreNeedClarify
- clarifyQuestion = input.PreClarifyQuestion
- clarifyOptions = input.PreClarifyOptions
- } else {
- rewritten, intent, keywords, skipRetrieve, needClarify, clarifyQuestion, clarifyOptions = doRewriteWithLLM(ctx, input)
- }
-
- _ = einoCompose.ProcessState(ctx, func(_ context.Context, state *quickGraphState) error {
- state.RewrittenQuery = rewritten
- state.Intent = intent
- state.Keywords = keywords
- state.SkipRetrieve = skipRetrieve
- state.NeedClarify = needClarify
- state.ClarifyQuestion = clarifyQuestion
- state.ClarifyOptions = clarifyOptions
- return nil
- })
+ rewritten, intent, keywords := input.PreRewrittenQuery, input.PreIntent, input.PreKeywords
+ skipRetrieve, needClarify := input.PreSkipRetrieve, input.PreNeedClarify
+ clarifyQ, clarifyO := input.PreClarifyQuestion, input.PreClarifyOptions
+ _ = einoCompose.ProcessState(ctx, func(_ context.Context, state *quickGraphState) error {
+ state.RewrittenQuery = rewritten
+ state.Intent = intent
+ state.Keywords = keywords
+ state.SkipRetrieve = skipRetrieve
+ state.NeedClarify = needClarify
+ state.ClarifyQuestion = clarifyQ
+ state.ClarifyOptions = clarifyO
+ close(state.RewriteDone)
+ return nil
+ })
+ observability.SetSpanAttrs(ctx, observability.Attrs{
+ "original_query": input.OriginalQuery,
+ "rewritten_query": rewritten,
+ "rewrite_mode": "precomputed",
+ })
+ return rewritten, nil
+ }
+
+ // 正常路径:后台 goroutine 异步跑 LLM 改写,立即返回 OriginalQuery 让 Retrieve 并行
+ go func() {
+ startAt := time.Now()
+ rewritten, intent, keywords, skipRetrieve, needClarify, clarifyQ, clarifyO := doRewriteWithLLM(ctx, input)
+ _ = einoCompose.ProcessState(ctx, func(_ context.Context, state *quickGraphState) error {
+ state.RewrittenQuery = rewritten
+ state.Intent = intent
+ state.Keywords = keywords
+ state.SkipRetrieve = skipRetrieve
+ state.NeedClarify = needClarify
+ state.ClarifyQuestion = clarifyQ
+ state.ClarifyOptions = clarifyO
+ close(state.RewriteDone)
+ return nil
+ })
+ observability.SetSpanAttrs(ctx, observability.Attrs{
+ "rewrite_ms": time.Since(startAt).Milliseconds(),
+ "rewrite_mode": "async",
+ "original_query": input.OriginalQuery,
+ "rewritten_query": rewritten,
+ "intent": intent,
+ "skip_retrieve": fmt.Sprintf("%v", skipRetrieve),
+ "need_clarify": fmt.Sprintf("%v", needClarify),
+ })
+ }()
- durMs := time.Since(startAt).Milliseconds()
observability.SetSpanAttrs(ctx, observability.Attrs{
- "original_query": input.OriginalQuery,
- "rewritten_query": rewritten,
- "intent": intent,
- "skip_retrieve": fmt.Sprintf("%v", skipRetrieve),
- "need_clarify": fmt.Sprintf("%v", needClarify),
- "rewrite_ms": durMs,
- "model_id": input.ModelName,
+ "original_query": input.OriginalQuery,
+ "rewrite_mode": "async_started",
})
- return rewritten, nil
+ return input.OriginalQuery, nil
+}
+
+// matchLocalIntent 本地快速意图匹配(纯正则 + 关键词,0ms)。
+// 返回 (intent, matched) —— matched=false 表示交给 LLM 判定。
+//
+// 覆盖四类场景:
+// greeting: 你好 / hi / 早上好 / 在吗
+// identity: 你是谁 / 你能做什么 / 介绍一下自己
+// chitchat: 今天星期几 / 讲个笑话 / 随便聊聊(含"今天/现在+时间查询")
+// meta: 我的历史 / 刚才说了什么
+//
+// 不命中时返回 ("", false),交给 LLM 做更精细的意图判定。
+func matchLocalIntent(raw string) (string, bool) {
+ q := strings.TrimSpace(strings.ToLower(raw))
+ if q == "" {
+ return intentQuestion, true
+ }
+
+ // ── greeting ──
+ greetingRegex := `^(你好|您好|hi+|hello+|嗨|哈喽|在吗|在不在|早|早上好|下午好|晚上好|晚安|早安|午安|晚安)$`
+ if matchRegex(greetingRegex, q) {
+ return intentGreeting, true
+ }
+
+ // ── identity ──
+ identityRegex := `^(你是谁|你是谁呀|你叫什么|你叫什么名字|你能做什么|你能干什么|你是干什么的|介绍一下你自己|自我介绍|你是什么模型|你是什么)$`
+ if matchRegex(identityRegex, q) {
+ return intentIdentity, true
+ }
+
+ // ── chitchat(闲聊 + 系统信息查询,LLM 容易误识别成 question 的场景)──
+ // 时间日期类
+ timeRegex := `(今天|现在|当前|明天|后天)+(星期几|礼拜几|几号|多少号|日期|几号了|几点|几点钟|时间|日期是)`
+ // 纯闲聊类
+ chitchatRegex := `^(讲个笑话|来个笑话|随便聊聊|聊聊呗|聊聊天|说点什么|有什么好玩的|今天天气怎么样|天气怎么样|心情不好|我心情不好|安慰一下我|夸夸我)$`
+ if matchRegex(timeRegex, q) || matchRegex(chitchatRegex, q) {
+ return intentChitchat, true
+ }
+
+ // ── meta ──
+ metaRegex := `(我的历史|聊天记录|你刚才说了什么|刚才说的什么|上一个问题|前一个问题|回顾对话|我们聊了什么|你还记得|之前说的)`
+ if matchRegex(metaRegex, q) {
+ return intentMeta, true
+ }
+
+ return "", false
+}
+
+// matchRegex 简单的正则匹配封装,避免每次都 re.Compile
+var (
+ reGreeting = regexp.MustCompile(`^(你好|您好|hi+|hello+|嗨|哈喽|在吗|在不在|早|早上好|下午好|晚上好|晚安|早安|午安|晚安)$`)
+ reIdentity = regexp.MustCompile(`^(你是谁|你是谁呀|你叫什么|你叫什么名字|你能做什么|你能干什么|你是干什么的|介绍一下你自己|自我介绍|你是什么模型|你是什么)$`)
+ reTimeInfo = regexp.MustCompile(`(今天|现在|当前|明天|后天)+(星期几|礼拜几|几号|多少号|日期|几号了|几点|几点钟|时间|日期是)`)
+ reChitchat = regexp.MustCompile(`^(讲个笑话|来个笑话|随便聊聊|聊聊呗|聊聊天|说点什么|有什么好玩的|今天天气怎么样|天气怎么样|心情不好|我心情不好|安慰一下我|夸夸我)$`)
+ reMeta = regexp.MustCompile(`(我的历史|聊天记录|你刚才说了什么|刚才说的什么|上一个问题|前一个问题|回顾对话|我们聊了什么|你还记得|之前说的)`)
+)
+
+func matchRegex(pattern string, q string) bool {
+ switch pattern {
+ case `^(你好|您好|hi+|hello+|嗨|哈喽|在吗|在不在|早|早上好|下午好|晚上好|晚安|早安|午安|晚安)$`:
+ return reGreeting.MatchString(q)
+ case `^(你是谁|你是谁呀|你叫什么|你叫什么名字|你能做什么|你能干什么|你是干什么的|介绍一下你自己|自我介绍|你是什么模型|你是什么)$`:
+ return reIdentity.MatchString(q)
+ case `(今天|现在|当前|明天|后天)+(星期几|礼拜几|几号|多少号|日期|几号了|几点|几点钟|时间|日期是)`:
+ return reTimeInfo.MatchString(q)
+ case `^(讲个笑话|来个笑话|随便聊聊|聊聊呗|聊聊天|说点什么|有什么好玩的|今天天气怎么样|天气怎么样|心情不好|我心情不好|安慰一下我|夸夸我)$`:
+ return reChitchat.MatchString(q)
+ case `(我的历史|聊天记录|你刚才说了什么|刚才说的什么|上一个问题|前一个问题|回顾对话|我们聊了什么|你还记得|之前说的)`:
+ return reMeta.MatchString(q)
+ default:
+ return false
+ }
}
// doRewriteWithLLM 调 LLM 做改写,失败时 fallback 原始 query。
// 返回 (rewritten, intent, keywords, skipRetrieve, needClarify, clarifyQuestion, clarifyOptions)
+//
+// 优化:先本地快速意图匹配(0ms,覆盖问候/身份/闲聊/系统查询等常见场景),
+// 命中后直接返回,省掉 LLM 调用。只有本地判定为 question 或不确定时才调 LLM。
func doRewriteWithLLM(ctx context.Context, input *quickGraphInput) (string, string, []string, bool, bool, string, []string) {
- // 1. 从 context 拿 ChatModel
+ // ── Step 0: 本地快速意图匹配(0ms) ──
+ if localIntent, ok := matchLocalIntent(input.OriginalQuery); ok {
+ skip := localIntent == intentGreeting || localIntent == intentChitchat || localIntent == intentIdentity || localIntent == intentMeta
+ logger.Infof("[意图识别-本地] original=%q → intent=%s, skipRetrieve=%v, cost=0ms",
+ input.OriginalQuery, localIntent, skip)
+ return input.OriginalQuery, localIntent, nil, skip, false, "", nil
+ }
+
+ // ── Step 1: 本地没命中 → 调 LLM ──
cm, ok := graphChatModelFromContext(ctx)
if !ok || cm == nil {
logger.Warnf("quickRewriteFn: context 中没有 ChatModel,跳过改写")
@@ -291,8 +393,14 @@ func doRewriteWithLLM(ctx context.Context, input *quickGraphInput) (string, stri
result.Intent = intentQuestion
}
- // 7. 判定是否跳过检索(greeting/chitchat 不需要知识库)
- skipRetrieve := result.Intent == intentGreeting || result.Intent == intentChitchat
+ // 7. 判定是否跳过检索
+ // greeting/chitchat → 无需知识库,直接闲聊
+ // identity → "你是谁/你能做什么",System Prompt 里已定义,不需要检索
+ // meta → "我的历史记录/你刚才说了什么",属于会话层,不走知识检索
+ skipRetrieve := result.Intent == intentGreeting ||
+ result.Intent == intentChitchat ||
+ result.Intent == intentIdentity ||
+ result.Intent == intentMeta
// 8. 澄清检查: need_clarify=true 且有 question 才生效
needClarify := result.NeedClarify && strings.TrimSpace(result.ClarifyQuestion) != ""
@@ -347,17 +455,114 @@ func buildRewriteHistory(msgs []*schema.Message, currentUserMsgIdx, maxRounds in
return strings.Join(pairs, "\n")
}
-// addQuickRetrieveNode 节点 2:Retrieve,PostHandler 把结果写回 State。
+// rewriteParallelMaxWait Retrieve 阶段等待后台 LLM 改写的最长时间。
+// 原始 query 检索通常 0.3-0.8s,LLM 改写通常 0.5-2s——等 500ms 给快模型一个机会,
+// 超时就放弃,不阻塞主路径。
+const rewriteParallelMaxWait = 500 * time.Millisecond
+
+// addQuickRetrieveNode 节点 2:Retrieve
+// 改进:用 LambdaNode 替代 AddRetrieverNode,在 Lambda 内部提前检查 SkipRetrieve / NeedClarify,
+// 避免 EinoRetrieverAdapter 被实例化后才被 PostHandler 清空——那样知识库查询的开销已经花出去了。
func addQuickRetrieveNode(g *einoCompose.Graph[*quickGraphInput, *schema.StreamReader[*schema.Message]], einoRetriever *rag.EinoRetrieverAdapter) error {
- return g.AddRetrieverNode(graphQuickNodeRetrieve, einoRetriever,
- einoCompose.WithNodeName("KnowledgeRetrieve"),
- einoCompose.WithStatePostHandler(func(_ context.Context, docs []*schema.Document, state *quickGraphState) ([]*schema.Document, error) {
+ return g.AddLambdaNode(graphQuickNodeRetrieve,
+ einoCompose.InvokableLambda(func(ctx context.Context, query string) ([]*schema.Document, error) {
+ var state *quickGraphState
+ if err := einoCompose.ProcessState(ctx, func(_ context.Context, s *quickGraphState) error {
+ state = s
+ return nil
+ }); err != nil || state == nil {
+ return nil, apperrors.NewDefault(apperrors.CodeInternalError)
+ }
+
+ // 提前短路:Rewrite 阶段已判定不需要检索 → 不查知识库,直接返回空 docs
+ if state.SkipRetrieve || state.NeedClarify {
+ state.RetrievedDocs = nil
+ return nil, nil
+ }
+
+ // 构造 retriever.Option(KBIDs / UserID / TopK)
+ opts := buildRetrieverOpts(state.Input)
+
+ // 用当前 query(Rewrite 返回的原始或改写后 query)先查
+ docs, err := einoRetriever.Retrieve(ctx, query, opts...)
+ if err != nil {
+ logger.Warnf("quickRetrieveFn: 检索失败,降级为空结果: %v", err)
+ state.RetrievedDocs = nil
+ return nil, nil
+ }
state.RetrievedDocs = docs
+
+ // 等后台 LLM 改写(最多 rewriteParallelMaxWait),改写完成后补一次检索 + 合并去重
+ if state.RewriteDone != nil {
+ select {
+ case <-state.RewriteDone:
+ if state.RewrittenQuery != "" && state.RewrittenQuery != state.Input.OriginalQuery {
+ if !state.SkipRetrieve && !state.NeedClarify {
+ rewrittenDocs, rErr := einoRetriever.Retrieve(ctx, state.RewrittenQuery, opts...)
+ if rErr == nil && len(rewrittenDocs) > 0 {
+ docs = mergeDocsByScore(docs, rewrittenDocs)
+ state.RetrievedDocs = docs
+ }
+ }
+ }
+ case <-time.After(rewriteParallelMaxWait):
+ }
+ }
return docs, nil
}),
+ einoCompose.WithNodeName("KnowledgeRetrieve"),
)
}
+// buildRetrieverOpts 从 quickGraphInput 构造 retriever.Option 切片,
+// 替代之前 quickRetrieverCallOpts 通过 einoCompose.WithRetrieverOption 注入的方式。
+func buildRetrieverOpts(input *quickGraphInput) []retriever.Option {
+ var opts []retriever.Option
+ if input != nil {
+ if len(input.KnowledgeBaseIDs) > 0 {
+ opts = append(opts, rag.WithKnowledgeBaseIDs(input.KnowledgeBaseIDs))
+ }
+ if input.UserID != "" {
+ opts = append(opts, rag.WithUserID(input.UserID))
+ }
+ }
+ if cfg := config.Get(); cfg != nil && cfg.RAG.TopK > 0 {
+ opts = append(opts, retriever.WithTopK(cfg.RAG.TopK))
+ }
+ return opts
+}
+
+// mergeDocsByScore 合并两组 docs,按 ID 去重取最高分,最后按分数降序。
+func mergeDocsByScore(a, b []*schema.Document) []*schema.Document {
+ scoreMap := make(map[string]*schema.Document, len(a)+len(b))
+ for _, d := range a {
+ if d == nil {
+ continue
+ }
+ scoreMap[d.ID] = d
+ }
+ for _, d := range b {
+ if d == nil {
+ continue
+ }
+ if existing, ok := scoreMap[d.ID]; ok {
+ if d.Score() > existing.Score() {
+ scoreMap[d.ID] = d
+ }
+ } else {
+ scoreMap[d.ID] = d
+ }
+ }
+ merged := make([]*schema.Document, 0, len(scoreMap))
+ for _, d := range scoreMap {
+ merged = append(merged, d)
+ }
+ sort.Slice(merged, func(i, j int) bool {
+ return merged[i].Score() > merged[j].Score()
+ })
+ return merged
+}
+
// addQuickBuildMsgsNode 节点 3:BuildPromptMessages。
// 从 State 拿 Input,在 userQuestionIndex 前插入检索上下文。
func addQuickBuildMsgsNode(g *einoCompose.Graph[*quickGraphInput, *schema.StreamReader[*schema.Message]]) error {
@@ -708,7 +913,10 @@ func (s *chatService) processMessageGraphQuick(
// 3.5) 预执行 Rewrite + 澄清检查:needClarify=true 时短路返回,不浪费后续节点
rewriteCheckCtx := withGraphChatModel(ctx, chatModel)
+ rewriteStart := time.Now()
rewritten, intent, keywords, skipRetrieve, needClarify, clarifyQuestion, clarifyOptions := doRewriteWithLLM(rewriteCheckCtx, graphInput)
+ logger.Infof("[意图识别] original=%q → intent=%s, skipRetrieve=%v, needClarify=%v, rewritten=%q, keywords=%v, cost=%dms",
+ req.Content, intent, skipRetrieve, needClarify, rewritten, keywords, time.Since(rewriteStart).Milliseconds())
if needClarify {
// 存 PendingClarify 到 session
@@ -746,11 +954,15 @@ func (s *chatService) processMessageGraphQuick(
graphInput.PreKeywords = keywords
graphInput.PreSkipRetrieve = skipRetrieve
- // 4) 构建并编译 compose.Graph(内部已经 push error 事件)
+ // 4) 提前创建 graphState:既是 eino stateGenerator 返回值,也是 Invoke 后外部读取 RetrievedDocs 的入口。
+ // RewriteDone channel 在这里初始化——rewriteFn 后台 goroutine 完成后 close 它,retrieve StatePostHandler 等它。
+ graphState := &quickGraphState{RewriteDone: make(chan struct{})}
+
+ // 5) 构建并编译 compose.Graph(内部已经 push error 事件)
sendProgressEvent(eventCh, "正在组装快速检索链路...")
graphCtx, cancel := context.WithCancel(ctx)
defer cancel()
- runnable, err := compileQuickGraphLocal(graphCtx, s.einoRetriever, req.ModelID, s.obs, obsOk, eventCh)
+ runnable, err := compileQuickGraphLocal(graphState, s.einoRetriever, req.ModelID, s.obs, obsOk, eventCh)
if err != nil {
if obsOk {
s.obs.MarkTraceError(ctx, err)
@@ -758,11 +970,11 @@ func (s *chatService) processMessageGraphQuick(
return
}
- // 5) 注入 per-request ChatModel + Retriever 节点选项
+ // 6) 注入 per-request ChatModel + Retriever 节点选项
graphCtx = withGraphChatModel(graphCtx, chatModel)
callOpts := quickRetrieverCallOpts(req, userID)
- // 6) 生成助手消息 ID + 流式驱动 Graph 执行
+ // 7) 生成助手消息 ID + 流式驱动 Graph 执行
assistantMsgID := uuid.New().String()
if obsOk {
s.obs.AddRootAttrs(ctx, observability.Attrs{"assistant_message_id": assistantMsgID})
@@ -781,8 +993,15 @@ func (s *chatService) processMessageGraphQuick(
return
}
- // 7) 从 Graph State 取 RetrievedDocs → SourceInfo(引用展示用)
- sources, docsCount := extractSourcesFromStateLocal(graphCtx)
+ // 8) 直接从 graphState 读 RetrievedDocs(genState 返回的就是这个对象,Invoke 内部 StatePostHandler 写的就是它)
+ var (
+ sources []dto.SourceInfo
+ docsCount int
+ )
+ if len(graphState.RetrievedDocs) > 0 {
+ sources = einoDocsToSourceInfos(graphState.RetrievedDocs)
+ docsCount = len(graphState.RetrievedDocs)
+ }
if obsOk {
s.obs.AddRootAttrs(ctx, observability.Attrs{
"assistant_chars": fmt.Sprintf("%d", len([]rune(fullContent))),
@@ -842,23 +1061,25 @@ func buildQuickInput(
}
// compileQuickGraphLocal build + compile graph,失败时自动推 error 事件
+// graphState 在调用处提前创建好,此函数会把它传给 buildQuickGraph,使其成为 eino stateGenerator 的返回值。
+// 这样 Invoke 返回后外部直接读 graphState.RetrievedDocs 即可,不需要再从 context 里 ProcessState。
func compileQuickGraphLocal(
- ctx context.Context,
+ graphState *quickGraphState,
einoRetriever *rag.EinoRetrieverAdapter,
modelID string,
obs observability.Recorder,
obsOk bool,
eventCh chan<- dto.StreamEvent,
) (einoCompose.Runnable[*quickGraphInput, *schema.StreamReader[*schema.Message]], error) {
- g, err := buildQuickGraph(ctx, einoRetriever)
+ g, err := buildQuickGraph(graphState, einoRetriever)
if err != nil {
- quickIncrError(ctx, obs, obsOk, "build_graph")
+ quickIncrError(nil, obs, obsOk, "build_graph")
sendErrorEvent(eventCh, err, "快速检索链路初始化失败")
return nil, err
}
- r, err := g.Compile(ctx, einoCompose.WithGraphName("quick_rag_pipeline"))
+ r, err := g.Compile(nil, einoCompose.WithGraphName("quick_rag_pipeline"))
if err != nil {
- quickIncrError(ctx, obs, obsOk, "compile_graph")
+ quickIncrError(nil, obs, obsOk, "compile_graph")
sendErrorEvent(eventCh, err, "快速检索链路编译失败")
return nil, err
}
@@ -894,25 +1115,6 @@ func runQuickStream(
return consumeQuickGraphStream(graphCtx, reader, assistantMsgID, eventCh)
}
-// extractSourcesFromStateLocal 从 Graph Local State 取出 RetrievedDocs → SourceInfo
-func extractSourcesFromStateLocal(graphCtx context.Context) ([]dto.SourceInfo, int) {
- var (
- sources []dto.SourceInfo
- docsCount int
- )
- err := einoCompose.ProcessState(graphCtx, func(_ context.Context, state *quickGraphState) error {
- if len(state.RetrievedDocs) > 0 {
- sources = einoDocsToSourceInfos(state.RetrievedDocs)
- docsCount = len(state.RetrievedDocs)
- }
- return nil
- })
- if err != nil {
- logger.Warnf("quick graph ProcessState 取 RetrievedDocs 失败: %v", err)
- }
- return sources, docsCount
-}
-
// consumeQuickGraphStream 消费 ChatModel 输出的 StreamReader[*schema.Message]
// 转成 dto.StreamEvent 推给前端,返回最终完整内容。
func consumeQuickGraphStream(
@@ -984,22 +1186,9 @@ func quickIncrError(ctx context.Context, obs observability.Recorder, obsOk bool,
obs.Incr(ctx, "chat_quick_graph_errors_total", map[string]string{"stage": stage}, 1)
}
-// quickRetrieverCallOpts 把 KBIDs/UserID/TopK 组合成只作用在 retrieve 节点的 compose.Option
-func quickRetrieverCallOpts(req requestdto.SendMessageRequest, userID string) []einoCompose.Option {
- opts := []retriever.Option{}
- if len(req.KnowledgeBaseIDs) > 0 {
- opts = append(opts, rag.WithKnowledgeBaseIDs(req.KnowledgeBaseIDs))
- }
- if userID != "" {
- opts = append(opts, rag.WithUserID(userID))
- }
- if cfg := config.Get(); cfg != nil && cfg.RAG.TopK > 0 {
- opts = append(opts, retriever.WithTopK(cfg.RAG.TopK))
- }
- if len(opts) == 0 {
- return nil
- }
- return []einoCompose.Option{
- einoCompose.WithRetrieverOption(opts...).DesignateNode(graphQuickNodeRetrieve),
- }
+// quickRetrieverCallOpts 现在返回 nil——Retrieve 节点已改为 LambdaNode,
+// retriever.Option 通过 buildRetrieverOpts 在 Lambda 内部直接构造。
+// 保留函数签名以减少调用处改动。
+func quickRetrieverCallOpts(_ requestdto.SendMessageRequest, _ string) []einoCompose.Option {
+ return nil
}
diff --git a/internal/service/context_service.go b/internal/service/context_service.go
index 47065ee..85ff20f 100644
--- a/internal/service/context_service.go
+++ b/internal/service/context_service.go
@@ -17,6 +17,7 @@ import (
"solvify-agent/internal/observability"
"solvify-agent/internal/repository"
"solvify-agent/pkg/logger"
+ "solvify-agent/pkg/stopwords"
"solvify-agent/pkg/tokenutil"
)
@@ -96,10 +97,20 @@ func (s *contextService) BuildContext(ctx context.Context, userID, sessionID, cu
summary *entity.ChatSummary
memories []entity.UserMemory
recent []entity.ChatMessage
+ relevant []entity.ChatMessage
err error
}
- resultCh := make(chan loadResult, 3)
+ // 提前算好关键词——纯计算,直接在主线程做
+ var keywords []string
+ if currentQuery != "" {
+ keywords = cfg.PreExtractedKeywords
+ if len(keywords) == 0 {
+ keywords = extractKeywords(currentQuery)
+ }
+ }
+
+ resultCh := make(chan loadResult, 4)
go func() {
summary, err := s.summaryRepo.GetBySessionID(ctx, sessionID)
@@ -116,10 +127,25 @@ func (s *contextService) BuildContext(ctx context.Context, userID, sessionID, cu
resultCh <- loadResult{recent: recent, err: err}
}()
- var summary *entity.ChatSummary
- var memories []entity.UserMemory
- var recent []entity.ChatMessage
- for i := 0; i < 3; i++ {
+ go func() {
+ var relevant []entity.ChatMessage
+ if len(keywords) > 0 {
+ var err error
+ relevant, err = s.messageRepo.SearchRecentByKeywords(ctx, sessionID, keywords, 5)
+ if err != nil {
+ logger.Warnf("检索相关历史失败: %v", err)
+ }
+ }
+ resultCh <- loadResult{relevant: relevant}
+ }()
+
+ var (
+ summary *entity.ChatSummary
+ memories []entity.UserMemory
+ recent []entity.ChatMessage
+ relevant []entity.ChatMessage
+ )
+ for i := 0; i < 4; i++ {
r := <-resultCh
if r.err != nil {
logger.Warnf("加载上下文组件失败: %v", r.err)
@@ -134,20 +160,8 @@ func (s *contextService) BuildContext(ctx context.Context, userID, sessionID, cu
if r.recent != nil {
recent = r.recent
}
- }
-
- var relevant []entity.ChatMessage
- if currentQuery != "" {
- keywords := cfg.PreExtractedKeywords
- if len(keywords) == 0 {
- keywords = extractKeywords(currentQuery)
- }
- if len(keywords) > 0 {
- var err error
- relevant, err = s.messageRepo.SearchRecentByKeywords(ctx, sessionID, keywords, 5)
- if err != nil {
- logger.Warnf("检索相关历史失败: %v", err)
- }
+ if r.relevant != nil {
+ relevant = r.relevant
}
}
@@ -504,24 +518,8 @@ func buildDialogueText(messages []entity.ChatMessage) string {
return sb.String()
}
-// extractKeywords 从查询中提取关键词
+// extractKeywords 从查询中提取关键词,过滤停用词
func extractKeywords(query string) []string {
- // 简单停用词表
- stopWords := map[string]struct{}{
- "的": {}, "了": {}, "是": {}, "我": {}, "你": {}, "他": {}, "她": {}, "它": {},
- "我们": {}, "你们": {}, "他们": {}, "这个": {}, "那个": {}, "什么": {}, "怎么": {},
- "为什么": {}, "如何": {}, "多少": {}, "哪些": {}, "谁": {}, "哪里": {}, "何时": {},
- "可以": {}, "能够": {}, "需要": {}, "想要": {}, "请": {}, "谢谢": {}, "你好": {},
- "这": {}, "那": {}, "什": {}, "么": {}, "怎": {}, "样": {},
- "a": {}, "an": {}, "the": {}, "is": {}, "are": {}, "was": {}, "were": {},
- "i": {}, "you": {}, "he": {}, "she": {}, "it": {}, "we": {}, "they": {},
- "this": {}, "that": {}, "these": {}, "those": {}, "what": {}, "how": {}, "why": {},
- "where": {}, "when": {}, "who": {}, "which": {}, "can": {}, "could": {}, "do": {},
- "does": {}, "did": {}, "will": {}, "would": {}, "should": {}, "may": {}, "might": {},
- "in": {}, "on": {}, "at": {}, "of": {}, "to": {}, "for": {}, "with": {},
- }
-
- // 按中文连续序列 或 英文/数字连续序列切分(复用包级已编译正则,避免每次重复编译)
parts := tokenRegexp.FindAllString(query, -1)
seen := make(map[string]struct{})
@@ -531,14 +529,13 @@ func extractKeywords(query string) []string {
if p == "" {
continue
}
- if _, ok := stopWords[p]; ok {
+ if stopwords.IsStopWord(p) {
continue
}
if len([]rune(p)) < 2 {
continue
}
- // 纯中文且每个字都是停用词,则跳过
- if isChineseString(p) && allRunesInSet(p, stopWords) {
+ if isChineseString(p) && allRunesAreStopWord(p) {
continue
}
if _, ok := seen[p]; ok {
@@ -565,10 +562,10 @@ func isChineseString(s string) bool {
return true
}
-// allRunesInSet 判断字符串中每个 rune 是否都在集合中
-func allRunesInSet(s string, set map[string]struct{}) bool {
+// allRunesAreStopWord 判断字符串中每个 rune(单字)是否都是停用词
+func allRunesAreStopWord(s string) bool {
for _, r := range s {
- if _, ok := set[string(r)]; !ok {
+ if !stopwords.IsStopWord(string(r)) {
return false
}
}
diff --git a/internal/tool/knowledge_search.go b/internal/tool/knowledge_search.go
index 2462f09..7a5db23 100644
--- a/internal/tool/knowledge_search.go
+++ b/internal/tool/knowledge_search.go
@@ -102,6 +102,7 @@ func (t *KnowledgeSearchTool) InvokableRun(ctx context.Context, argumentsInJSON
Content: doc.Content,
})
}
+ contentBuilder.WriteString("以上为知识库检索结果,不需要联网搜索来补充。如果这些内容满足用户需求,直接组织答案;如果需要列出文档清单、关键词精准查找等其他操作,可以继续调用知识库内部工具。\n")
// 记录来源(Agent 结束后从 CollectedSources 读取)
t.CollectedSources = append(t.CollectedSources, sources...)
diff --git a/pkg/config/config.go b/pkg/config/config.go
index cd04038..d72377d 100644
--- a/pkg/config/config.go
+++ b/pkg/config/config.go
@@ -91,6 +91,7 @@ type EmbeddingConfig struct {
BaseURL string `mapstructure:"base_url"`
Dimension int `mapstructure:"dimension"`
BatchSize int `mapstructure:"batch_size"`
+ Timeout int `mapstructure:"timeout"`
}
// RAGConfig 描述检索增强配置
@@ -313,6 +314,7 @@ func Default() *Config {
Model: "text-embedding-v4",
Dimension: 1024,
BatchSize: 10,
+ Timeout: 15,
},
RAG: RAGConfig{
Enabled: true,
@@ -530,6 +532,9 @@ func applyEnv(cfg *Config) {
if value := os.Getenv("EMBEDDING_BATCH_SIZE"); value != "" {
cfg.Embedding.BatchSize = parseInt(value, cfg.Embedding.BatchSize)
}
+ if value := os.Getenv("EMBEDDING_TIMEOUT"); value != "" {
+ cfg.Embedding.Timeout = parseInt(value, cfg.Embedding.Timeout)
+ }
// 数据库配置
cfg.Database.Postgres.Host = getEnv("POSTGRES_HOST", cfg.Database.Postgres.Host)
diff --git a/pkg/database/postgresql.go b/pkg/database/postgresql.go
index ab7820e..f312c19 100644
--- a/pkg/database/postgresql.go
+++ b/pkg/database/postgresql.go
@@ -86,3 +86,241 @@ func enablePGVector(db *gorm.DB) error {
logger.Info("pgvector 扩展检查完成")
return nil
}
+
+// EnsurePGVectorIndex 检查并确保 document_chunks 表的 pgvector 向量索引存在。
+//
+// 逻辑:
+// 1. 查 pg_indexes 看 idx_document_chunks_embedding 是否已存在
+// 2. 不存在 → 自动创建 ivfflat 索引(lists=100, cosine, partial WHERE embedding IS NOT NULL)
+// 3. 存在但类型不是 ivfflat/hnsw → 打警告(可能失效或全表扫描)
+// 4. 表不存在或无 embedding 列 → 跳过(AutoMigrate 或 schema 会补上)
+func EnsurePGVectorIndex(db *gorm.DB) error {
+ // 先检查表是否存在
+ var tableExists bool
+ if err := db.Raw(
+ `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'document_chunks')`,
+ ).Scan(&tableExists).Error; err != nil {
+ return fmt.Errorf("检查 document_chunks 表存在性失败: %w", err)
+ }
+ if !tableExists {
+ logger.Warn("[pgvector] document_chunks 表不存在,跳过向量索引检查")
+ return nil
+ }
+
+ // 检查 embedding 列是否存在
+ var colExists bool
+ if err := db.Raw(
+ `SELECT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'document_chunks' AND column_name = 'embedding'
+ )`,
+ ).Scan(&colExists).Error; err != nil {
+ return fmt.Errorf("检查 embedding 列存在性失败: %w", err)
+ }
+ if !colExists {
+ logger.Warn("[pgvector] document_chunks.embedding 列不存在,跳过向量索引检查")
+ return nil
+ }
+
+ // 查询索引信息
+ type indexInfo struct {
+ IndexName string `gorm:"column:indexname"`
+ IndexType string `gorm:"column:indexdef"`
+ }
+ var existing []indexInfo
+ if err := db.Raw(
+ `SELECT indexname, indexdef FROM pg_indexes WHERE tablename = 'document_chunks' AND indexname = 'idx_document_chunks_embedding'`,
+ ).Scan(&existing).Error; err != nil {
+ return fmt.Errorf("查询向量索引状态失败: %w", err)
+ }
+
+ if len(existing) > 0 {
+ idxDef := existing[0].IndexType
+ isValidType := contains(idxDef, "ivfflat") || contains(idxDef, "hnsw") || contains(idxDef, "pgvector")
+ if !isValidType {
+ logger.Warnf("[pgvector] 向量索引 idx_document_chunks_embedding 存在但类型异常,建议重建。当前定义: %s", idxDef)
+ } else {
+ logger.Infof("[pgvector] 向量索引 idx_document_chunks_embedding 已就绪: %s", idxDef)
+ }
+ return nil
+ }
+
+ // 自动创建 ivfflat 索引
+ logger.Info("[pgvector] 向量索引 idx_document_chunks_embedding 不存在,正在创建 ivfflat 索引...")
+ err := db.Exec(`
+ CREATE INDEX IF NOT EXISTS idx_document_chunks_embedding
+ ON document_chunks
+ USING ivfflat (embedding vector_cosine_ops)
+ WITH (lists = 100)
+ WHERE embedding IS NOT NULL
+ `).Error
+ if err != nil {
+ logger.Warnf("[pgvector] 自动创建 ivfflat 索引失败: %v(低流量环境可能需要先执行 VACUUM ANALYZE document_chunks)", err)
+ return nil // 不阻塞启动,只是警告
+ }
+ logger.Info("[pgvector] 向量索引 idx_document_chunks_embedding 创建完成")
+ return nil
+}
+
+// EnsureKeywordsGINIndex 检查并确保 document_chunks 表的 keywords 列有 GIN 索引。
+// keywords && ?::text[] 这种数组重叠操作必须用 GIN 索引加速,否则每次关键词检索都是全表扫描。
+func EnsureKeywordsGINIndex(db *gorm.DB) error {
+ var tableExists bool
+ if err := db.Raw(
+ `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'document_chunks')`,
+ ).Scan(&tableExists).Error; err != nil {
+ return fmt.Errorf("检查 document_chunks 表存在性失败: %w", err)
+ }
+ if !tableExists {
+ logger.Warn("[pgvector] document_chunks 表不存在,跳过 keywords GIN 索引检查")
+ return nil
+ }
+
+ var colExists bool
+ if err := db.Raw(
+ `SELECT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'document_chunks' AND column_name = 'keywords'
+ )`,
+ ).Scan(&colExists).Error; err != nil {
+ return fmt.Errorf("检查 keywords 列存在性失败: %w", err)
+ }
+ if !colExists {
+ logger.Warn("[pgvector] document_chunks.keywords 列不存在,跳过 GIN 索引检查")
+ return nil
+ }
+
+ var exists bool
+ if err := db.Raw(
+ `SELECT EXISTS (
+ SELECT 1 FROM pg_indexes
+ WHERE tablename = 'document_chunks' AND indexname = 'idx_document_chunks_keywords'
+ )`,
+ ).Scan(&exists).Error; err != nil {
+ return fmt.Errorf("查询 keywords GIN 索引状态失败: %w", err)
+ }
+ if exists {
+ logger.Info("[pgvector] keywords GIN 索引 idx_document_chunks_keywords 已就绪")
+ return nil
+ }
+
+ logger.Info("[pgvector] keywords GIN 索引不存在,正在创建...")
+ err := db.Exec(`
+ CREATE INDEX IF NOT EXISTS idx_document_chunks_keywords
+ ON document_chunks
+ USING gin (keywords)
+ WHERE keywords IS NOT NULL
+ `).Error
+ if err != nil {
+ logger.Warnf("[pgvector] 自动创建 keywords GIN 索引失败: %v", err)
+ return nil
+ }
+ logger.Info("[pgvector] keywords GIN 索引 idx_document_chunks_keywords 创建完成")
+ return nil
+}
+
+// EnsureContextIndexes 检查并确保 RAG 上下文加载链路高频查询涉及的三张表有正确索引。
+// chat_messages 的 (session_id, created_at) 复合索引是 initContext → BuildContext 里 FindRecent / SearchRecentByKeywords 的核心加速。
+// chat_sessions 和 user_memories 同理,ListActive / FindByUserID 都是高频操作。
+func EnsureContextIndexes(db *gorm.DB) error {
+ type ctxIndex struct {
+ table string
+ index string
+ sql string
+ }
+ idxs := []ctxIndex{
+ {
+ table: "chat_messages", index: "idx_chat_messages_session_created",
+ sql: `CREATE INDEX IF NOT EXISTS idx_chat_messages_session_created ON chat_messages (session_id, created_at DESC)`,
+ },
+ {
+ table: "chat_sessions", index: "idx_chat_sessions_user_status",
+ sql: `CREATE INDEX IF NOT EXISTS idx_chat_sessions_user_status ON chat_sessions (user_id, status)`,
+ },
+ {
+ table: "user_memories", index: "idx_user_memories_user_active",
+ sql: `CREATE INDEX IF NOT EXISTS idx_user_memories_user_active ON user_memories (user_id, is_active) WHERE is_active = true`,
+ },
+ }
+ for _, it := range idxs {
+ var exists bool
+ if err := db.Raw(`SELECT EXISTS (SELECT 1 FROM pg_indexes WHERE tablename = ? AND indexname = ?)`, it.table, it.index).Scan(&exists).Error; err != nil {
+ logger.Warnf("[context] 查询索引状态失败 table=%s index=%s: %v", it.table, it.index, err)
+ continue
+ }
+ if exists {
+ continue
+ }
+ logger.Infof("[context] 创建索引 table=%s index=%s", it.table, it.index)
+ if err := db.Exec(it.sql).Error; err != nil {
+ logger.Warnf("[context] 自动创建索引失败 table=%s index=%s: %v", it.table, it.index, err)
+ } else {
+ logger.Infof("[context] 索引已就绪 index=%s", it.index)
+ }
+ }
+ return nil
+}
+
+// EnsureMessageFeedbackSchema 补齐 message_feedback 表缺失的列。
+// 这张表是早期 AutoMigrate 创建的,后来 entity 加了 reasons / is_quick / trace_id 等列,
+// 但 AutoMigrate 不会给已存在的表 ADD COLUMN,导致 INSERT 时报 column "xxx" does not exist。
+func EnsureMessageFeedbackSchema(db *gorm.DB) error {
+ var tableExists bool
+ if err := db.Raw(
+ `SELECT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'message_feedback')`,
+ ).Scan(&tableExists).Error; err != nil {
+ return fmt.Errorf("检查 message_feedback 表存在性失败: %w", err)
+ }
+ if !tableExists {
+ return nil
+ }
+
+ type colDef struct {
+ name string
+ ddl string // ALTER TABLE ... ADD COLUMN ... 的列定义(不含列名)
+ }
+ missingCols := []colDef{
+ {name: "reasons", ddl: "jsonb"},
+ {name: "is_quick", ddl: "boolean NOT NULL DEFAULT false"},
+ {name: "trace_id", ddl: "varchar(128)"},
+ {name: "reason_tag", ddl: "varchar(64)"},
+ }
+
+ for _, mc := range missingCols {
+ var exists bool
+ if err := db.Raw(
+ `SELECT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'message_feedback' AND column_name = ?
+ )`, mc.name,
+ ).Scan(&exists).Error; err != nil {
+ logger.Warnf("[feedback] 检查列 %s 失败: %v", mc.name, err)
+ continue
+ }
+ if exists {
+ continue
+ }
+ logger.Infof("[feedback] 列 %s 不存在,正在 ALTER TABLE ADD COLUMN", mc.name)
+ if err := db.Exec(
+ fmt.Sprintf("ALTER TABLE message_feedback ADD COLUMN IF NOT EXISTS %s %s", mc.name, mc.ddl),
+ ).Error; err != nil {
+ logger.Warnf("[feedback] 自动补列 %s 失败: %v", mc.name, err)
+ } else {
+ logger.Infof("[feedback] 列 %s 已补齐", mc.name)
+ }
+ }
+ return nil
+}
+
+func contains(s, substr string) bool {
+ return len(s) >= len(substr) && searchSubstring(s, substr)
+}
+
+func searchSubstring(s, substr string) bool {
+ for i := 0; i <= len(s)-len(substr); i++ {
+ if s[i:i+len(substr)] == substr {
+ return true
+ }
+ }
+ return false
+}
diff --git a/pkg/logger/logger.go b/pkg/logger/logger.go
index 0fe2376..9564153 100644
--- a/pkg/logger/logger.go
+++ b/pkg/logger/logger.go
@@ -23,6 +23,11 @@ var (
sugar *zap.SugaredLogger
)
+func init() {
+ log = zap.NewNop()
+ sugar = log.Sugar()
+}
+
// CustomLevelEncoder 自定义日志级别编码器,支持控制台颜色显示
func CustomLevelEncoder(level zapcore.Level, enc zapcore.PrimitiveArrayEncoder) {
var color string
diff --git a/pkg/stopwords/chinese.go b/pkg/stopwords/chinese.go
new file mode 100644
index 0000000..9acb900
--- /dev/null
+++ b/pkg/stopwords/chinese.go
@@ -0,0 +1,52 @@
+// Package stopwords 提供中英文停用词统一来源。
+// 所有需要过滤停用词的模块(检索、查询改写、上下文构建)都应引用本包,避免重复定义和不一致。
+package stopwords
+
+var stopWords = map[string]struct{}{
+ // 中文
+ "的": {}, "了": {}, "在": {}, "是": {}, "我": {},
+ "有": {}, "和": {}, "就": {}, "不": {}, "人": {},
+ "都": {}, "一": {}, "一个": {}, "上": {}, "也": {},
+ "很": {}, "到": {}, "说": {}, "要": {}, "去": {},
+ "你": {}, "会": {}, "着": {}, "没有": {}, "看": {},
+ "好": {}, "自己": {}, "这": {}, "他": {}, "她": {},
+ "它": {}, "们": {}, "那": {}, "什么": {},
+ "怎么": {}, "如何": {}, "为什么": {}, "哪": {}, "哪个": {},
+ "哪些": {}, "吗": {}, "呢": {}, "吧": {}, "啊": {},
+ "哈哈": {}, "哦": {}, "嗯": {}, "然后": {}, "就是": {},
+ "我们": {}, "你们": {}, "他们": {}, "这个": {}, "那个": {},
+ "多少": {}, "谁": {}, "哪里": {}, "何时": {},
+ "可以": {}, "能够": {}, "需要": {}, "想要": {},
+ "请": {}, "谢谢": {}, "你好": {},
+ "什": {}, "么": {}, "怎": {}, "样": {},
+
+ // 英文
+ "the": {}, "a": {}, "an": {}, "is": {}, "are": {},
+ "was": {}, "were": {}, "be": {}, "been": {}, "being": {},
+ "have": {}, "has": {}, "had": {}, "do": {}, "does": {},
+ "did": {}, "will": {}, "would": {}, "could": {}, "should": {},
+ "may": {}, "might": {}, "can": {}, "shall": {}, "must": {},
+ "to": {}, "of": {}, "in": {}, "for": {}, "on": {},
+ "with": {}, "at": {}, "by": {}, "from": {}, "as": {},
+ "into": {}, "through": {}, "during": {}, "before": {}, "after": {},
+ "above": {}, "below": {}, "between": {}, "and": {}, "but": {},
+ "or": {}, "nor": {}, "not": {}, "so": {}, "yet": {},
+ "both": {}, "either": {}, "neither": {}, "each": {}, "every": {},
+ "all": {}, "any": {}, "few": {}, "more": {}, "most": {},
+ "other": {}, "some": {}, "such": {}, "no": {}, "only": {},
+ "own": {}, "same": {}, "than": {}, "too": {}, "very": {},
+ "just": {}, "because": {}, "if": {}, "when": {}, "where": {},
+ "how": {}, "what": {}, "which": {}, "who": {}, "whom": {},
+ "why": {},
+ "this": {}, "that": {}, "these": {}, "those": {},
+ "i": {}, "me": {}, "my": {}, "we": {}, "our": {},
+ "you": {}, "your": {}, "he": {}, "him": {}, "his": {},
+ "she": {}, "her": {}, "it": {}, "its": {},
+ "they": {}, "them": {}, "their": {},
+}
+
+// IsStopWord 判断一个词是否为停用词。输入应为小写且已 trim 空格。
+func IsStopWord(word string) bool {
+ _, ok := stopWords[word]
+ return ok
+}
diff --git a/scripts/init_knowledge_schema.sql b/scripts/init_knowledge_schema.sql
index c730294..87b659b 100644
--- a/scripts/init_knowledge_schema.sql
+++ b/scripts/init_knowledge_schema.sql
@@ -1,424 +1,1129 @@
-CREATE
-EXTENSION IF NOT EXISTS pgcrypto;
-CREATE
-EXTENSION IF NOT EXISTS vector;
-
--- 用户表作为当前阶段的数据隔离边界
-CREATE TABLE IF NOT EXISTS users
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 用户 ID
- username VARCHAR(64) NOT NULL, -- 用户名
- email VARCHAR(128) NOT NULL, -- 邮箱
- password TEXT NOT NULL DEFAULT '', -- 密码哈希
- status INT NOT NULL DEFAULT 1, -- 用户状态,1 正常,2 禁用,3 注销,4 待验证
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 更新时间
-
- CONSTRAINT users_username_unique UNIQUE (username),
- CONSTRAINT users_email_unique UNIQUE (email)
-);
-
-COMMENT ON TABLE users IS '用户基础表,用于隔离每个用户自己的知识库和文档';
-COMMENT ON COLUMN users.id IS '用户 ID';
-COMMENT ON COLUMN users.username IS '用户名';
-COMMENT ON COLUMN users.email IS '邮箱';
-COMMENT ON COLUMN users.password IS '密码哈希';
-COMMENT ON COLUMN users.status IS '用户状态,1 正常,2 禁用,3 注销,4 待验证';
-COMMENT ON COLUMN users.created_at IS '创建时间';
-COMMENT ON COLUMN users.updated_at IS '更新时间';
-
--- 知识库表保存用户自建、同步和联网搜索知识库
-CREATE TABLE IF NOT EXISTS knowledge_bases
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 知识库 ID
- user_id UUID NOT NULL, -- 所属用户 ID
- name VARCHAR(128) NOT NULL, -- 知识库名称
- category VARCHAR(64) NOT NULL DEFAULT '', -- 知识库分类
- description TEXT DEFAULT '', -- 知识库描述
- source_type VARCHAR(32) NOT NULL DEFAULT 'local', -- 知识库来源类型,local 自建,sync 同步,web_search 联网搜索
- source_platform VARCHAR(32) NOT NULL DEFAULT '', -- 同步来源平台
- document_count INT NOT NULL DEFAULT 0, -- 文档数量
- storage_bytes BIGINT NOT NULL DEFAULT 0, -- 已占用存储字节数
- status INT NOT NULL DEFAULT 1, -- 知识库状态,1 正常,2 已删除
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 更新时间
- deleted_at TIMESTAMPTZ, -- 删除时间
- delete_expired_at TIMESTAMPTZ -- 删除保留到期时间
-);
-
-COMMENT ON TABLE knowledge_bases IS '知识库主表,所有知识库按用户隔离';
-COMMENT ON COLUMN knowledge_bases.id IS '知识库 ID';
-COMMENT ON COLUMN knowledge_bases.user_id IS '所属用户 ID';
-COMMENT ON COLUMN knowledge_bases.name IS '知识库名称';
-COMMENT ON COLUMN knowledge_bases.category IS '知识库分类';
-COMMENT ON COLUMN knowledge_bases.description IS '知识库描述';
-COMMENT ON COLUMN knowledge_bases.source_type IS '知识库来源类型,local 自建,sync 同步,web_search 联网搜索';
-COMMENT ON COLUMN knowledge_bases.source_platform IS '同步来源平台';
-COMMENT ON COLUMN knowledge_bases.document_count IS '文档数量';
-COMMENT ON COLUMN knowledge_bases.storage_bytes IS '已占用存储字节数';
-COMMENT ON COLUMN knowledge_bases.status IS '知识库状态,1 正常,2 已删除';
-COMMENT ON COLUMN knowledge_bases.created_at IS '创建时间';
-COMMENT ON COLUMN knowledge_bases.updated_at IS '更新时间';
-COMMENT ON COLUMN knowledge_bases.deleted_at IS '删除时间';
-COMMENT ON COLUMN knowledge_bases.delete_expired_at IS '删除保留到期时间';
-
--- 文档表记录上传文件和处理状态
-CREATE TABLE IF NOT EXISTS documents
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 文档 ID
- user_id UUID NOT NULL, -- 所属用户 ID
- knowledge_base_id UUID NOT NULL, -- 所属知识库 ID
- title VARCHAR(255) NOT NULL, -- 文档标题
- file_name VARCHAR(255) NOT NULL, -- 原始文件名
- file_type VARCHAR(32) NOT NULL DEFAULT '', -- 文件类型
- file_size BIGINT NOT NULL DEFAULT 0, -- 文件大小字节数
- storage_path TEXT NOT NULL DEFAULT '', -- 文件存储路径
- file_hash VARCHAR(128) NOT NULL DEFAULT '', -- 原始文件内容指纹
- source_type VARCHAR(32) NOT NULL DEFAULT 'upload', -- 文档来源类型,upload 上传,edit 编辑,sync 同步,web_search 联网搜索
- external_id VARCHAR(255) NOT NULL DEFAULT '', -- 外部平台文档 ID
- external_url TEXT NOT NULL DEFAULT '', -- 外部平台文档链接
- source_updated_at TIMESTAMPTZ, -- 外部平台更新时间
- status INT NOT NULL DEFAULT 1, -- 文档状态,1 已上传,2 处理中,3 已就绪,4 处理失败,5 已删除
- error_message TEXT NOT NULL DEFAULT '', -- 处理失败原因
- ready_at TIMESTAMPTZ, -- 文档就绪时间
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 更新时间
- deleted_at TIMESTAMPTZ, -- 删除时间
- delete_expired_at TIMESTAMPTZ -- 删除保留到期时间
-
-);
-
-COMMENT ON TABLE documents IS '文档主表,记录知识库下的文件和处理状态';
-COMMENT ON COLUMN documents.id IS '文档 ID';
-COMMENT ON COLUMN documents.user_id IS '所属用户 ID';
-COMMENT ON COLUMN documents.knowledge_base_id IS '所属知识库 ID';
-COMMENT ON COLUMN documents.title IS '文档标题';
-COMMENT ON COLUMN documents.file_name IS '原始文件名';
-COMMENT ON COLUMN documents.file_type IS '文件类型';
-COMMENT ON COLUMN documents.file_size IS '文件大小字节数';
-COMMENT ON COLUMN documents.storage_path IS '文件存储路径';
-COMMENT ON COLUMN documents.file_hash IS '原始文件内容指纹';
-COMMENT ON COLUMN documents.source_type IS '文档来源类型,upload 上传,edit 编辑,sync 同步,web_search 联网搜索';
-COMMENT ON COLUMN documents.external_id IS '外部平台文档 ID';
-COMMENT ON COLUMN documents.external_url IS '外部平台文档链接';
-COMMENT ON COLUMN documents.source_updated_at IS '外部平台更新时间';
-COMMENT ON COLUMN documents.status IS '文档状态,1 已上传,2 处理中,3 已就绪,4 处理失败,5 已删除';
-COMMENT ON COLUMN documents.error_message IS '处理失败原因';
-COMMENT ON COLUMN documents.ready_at IS '文档就绪时间';
-COMMENT ON COLUMN documents.deleted_at IS '删除时间';
-COMMENT ON COLUMN documents.delete_expired_at IS '删除保留到期时间';
-COMMENT ON COLUMN documents.created_at IS '创建时间';
-COMMENT ON COLUMN documents.updated_at IS '更新时间';
-
-ALTER TABLE documents
- ADD COLUMN IF NOT EXISTS external_id VARCHAR(255) NOT NULL DEFAULT '',
- ADD COLUMN IF NOT EXISTS external_url TEXT NOT NULL DEFAULT '',
- ADD COLUMN IF NOT EXISTS source_updated_at TIMESTAMPTZ;
-
--- 文档版本表支持在线编辑和重新向量化
-CREATE TABLE IF NOT EXISTS document_versions
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 版本 ID
- user_id UUID NOT NULL, -- 所属用户 ID
- document_id UUID NOT NULL, -- 所属文档 ID
- version_no INT NOT NULL DEFAULT 1, -- 版本号
- content TEXT NOT NULL DEFAULT '', -- 版本正文内容
- content_hash VARCHAR(128) NOT NULL DEFAULT '', -- 版本内容哈希
- change_summary TEXT NOT NULL DEFAULT '', -- 变更摘要
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
-
- CONSTRAINT document_versions_document_version_unique UNIQUE (document_id, version_no)
-);
-
-COMMENT ON TABLE document_versions IS '文档版本表,用于记录原始解析内容和在线编辑历史';
-COMMENT ON COLUMN document_versions.id IS '版本 ID';
-COMMENT ON COLUMN document_versions.user_id IS '所属用户 ID';
-COMMENT ON COLUMN document_versions.document_id IS '所属文档 ID';
-COMMENT ON COLUMN document_versions.version_no IS '版本号';
-COMMENT ON COLUMN document_versions.content IS '版本正文内容';
-COMMENT ON COLUMN document_versions.content_hash IS '版本内容哈希';
-COMMENT ON COLUMN document_versions.change_summary IS '变更摘要';
-COMMENT ON COLUMN document_versions.created_at IS '创建时间';
-
--- 文档分块表保存文本分块和向量数据
-CREATE TABLE IF NOT EXISTS document_chunks
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 分块 ID
- user_id UUID NOT NULL, -- 所属用户 ID
- knowledge_base_id UUID NOT NULL, -- 所属知识库 ID
- document_id UUID NOT NULL, -- 所属文档 ID
- version_id UUID NOT NULL, -- 所属文档版本 ID
- chunk_index INT NOT NULL, -- 分块序号
- section_title TEXT NOT NULL DEFAULT '', -- 分块所属章节标题
- content TEXT NOT NULL, -- 分块文本内容
- token_count INT NOT NULL DEFAULT 0, -- 分块 token 数
- page_number INT, -- 来源页码
- embedding_model VARCHAR(128) NOT NULL DEFAULT '', -- 向量模型名称
- embedding vector(1024), -- 分块向量数据
- keywords TEXT[] NOT NULL DEFAULT '{}'::text[], -- 分块关键词
- metadata JSONB NOT NULL DEFAULT '{}'::jsonb, -- 分块扩展元数据
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
-
- CONSTRAINT document_chunks_version_index_unique UNIQUE (version_id, chunk_index)
-);
-
-COMMENT ON TABLE document_chunks IS '文档分块表,保存可检索文本片段和 embedding 向量';
-COMMENT ON COLUMN document_chunks.id IS '分块 ID';
-COMMENT ON COLUMN document_chunks.user_id IS '所属用户 ID';
-COMMENT ON COLUMN document_chunks.knowledge_base_id IS '所属知识库 ID';
-COMMENT ON COLUMN document_chunks.document_id IS '所属文档 ID';
-COMMENT ON COLUMN document_chunks.version_id IS '所属文档版本 ID';
-COMMENT ON COLUMN document_chunks.chunk_index IS '分块序号';
-COMMENT ON COLUMN document_chunks.section_title IS '分块所属章节标题';
-COMMENT ON COLUMN document_chunks.content IS '分块文本内容';
-COMMENT ON COLUMN document_chunks.token_count IS '分块 token 数';
-COMMENT ON COLUMN document_chunks.page_number IS '来源页码';
-COMMENT ON COLUMN document_chunks.embedding_model IS '向量模型名称';
-COMMENT ON COLUMN document_chunks.embedding IS '分块向量数据';
-COMMENT ON COLUMN document_chunks.keywords IS '分块关键词';
-COMMENT ON COLUMN document_chunks.metadata IS '分块扩展元数据';
-COMMENT ON COLUMN document_chunks.created_at IS '创建时间';
-
--- 文档处理任务表记录解析、分块、向量化和重建索引状态
-CREATE TABLE IF NOT EXISTS document_processing_jobs
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 任务 ID
- user_id UUID NOT NULL, -- 所属用户 ID
- document_id UUID NOT NULL, -- 所属文档 ID
- job_type VARCHAR(32) NOT NULL, -- 任务类型,parse 解析,chunk 分块,embed 向量化,reindex 重建索引
- status INT NOT NULL DEFAULT 1, -- 任务状态,1 待处理,2 运行中,3 成功,4 失败
- error_message TEXT NOT NULL DEFAULT '', -- 任务失败原因
- started_at TIMESTAMPTZ, -- 开始时间
- finished_at TIMESTAMPTZ, -- 完成时间
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -- 更新时间
-
-);
-
-COMMENT ON TABLE document_processing_jobs IS '文档处理任务表,记录解析、分块、向量化和重建索引状态';
-COMMENT ON COLUMN document_processing_jobs.id IS '任务 ID';
-COMMENT ON COLUMN document_processing_jobs.user_id IS '所属用户 ID';
-COMMENT ON COLUMN document_processing_jobs.document_id IS '所属文档 ID';
-COMMENT ON COLUMN document_processing_jobs.job_type IS '任务类型,parse 解析,chunk 分块,embed 向量化,reindex 重建索引';
-COMMENT ON COLUMN document_processing_jobs.status IS '任务状态,1 待处理,2 运行中,3 成功,4 失败';
-COMMENT ON COLUMN document_processing_jobs.error_message IS '任务失败原因';
-COMMENT ON COLUMN document_processing_jobs.started_at IS '开始时间';
-COMMENT ON COLUMN document_processing_jobs.finished_at IS '完成时间';
-COMMENT ON COLUMN document_processing_jobs.created_at IS '创建时间';
-COMMENT ON COLUMN document_processing_jobs.updated_at IS '更新时间';
-
--- 同步源表记录钉钉等外部平台同步配置
-CREATE TABLE IF NOT EXISTS sync_sources
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 同步源 ID
- user_id UUID NOT NULL, -- 所属用户 ID
- knowledge_base_id UUID NOT NULL, -- 绑定知识库 ID
- name VARCHAR(128) NOT NULL, -- 同步源名称
- platform VARCHAR(32) NOT NULL, -- 同步平台,当前支持 dingtalk
- source_config JSONB NOT NULL DEFAULT '{}'::jsonb, -- 非敏感同步配置
- status INT NOT NULL DEFAULT 1, -- 同步源状态,1 正常,2 禁用,3 已删除
- last_sync_at TIMESTAMPTZ, -- 最近同步时间
- last_error_message TEXT NOT NULL DEFAULT '', -- 最近同步失败原因
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 更新时间
- deleted_at TIMESTAMPTZ -- 删除时间
-);
-
-COMMENT ON TABLE sync_sources IS '同步源配置表,记录钉钉等外部平台同步入口';
-COMMENT ON COLUMN sync_sources.id IS '同步源 ID';
-COMMENT ON COLUMN sync_sources.user_id IS '所属用户 ID';
-COMMENT ON COLUMN sync_sources.knowledge_base_id IS '绑定知识库 ID';
-COMMENT ON COLUMN sync_sources.name IS '同步源名称';
-COMMENT ON COLUMN sync_sources.platform IS '同步平台,当前支持 dingtalk';
-COMMENT ON COLUMN sync_sources.source_config IS '非敏感同步配置';
-COMMENT ON COLUMN sync_sources.status IS '同步源状态,1 正常,2 禁用,3 已删除';
-COMMENT ON COLUMN sync_sources.last_sync_at IS '最近同步时间';
-COMMENT ON COLUMN sync_sources.last_error_message IS '最近同步失败原因';
-COMMENT ON COLUMN sync_sources.created_at IS '创建时间';
-COMMENT ON COLUMN sync_sources.updated_at IS '更新时间';
-COMMENT ON COLUMN sync_sources.deleted_at IS '删除时间';
-
--- 同步任务表记录每次手动触发同步的执行状态
-CREATE TABLE IF NOT EXISTS sync_jobs
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 同步任务 ID
- user_id UUID NOT NULL, -- 所属用户 ID
- sync_source_id UUID NOT NULL, -- 同步源 ID
- knowledge_base_id UUID NOT NULL, -- 绑定知识库 ID
- job_type VARCHAR(32) NOT NULL, -- 任务类型,manual 手动同步
- status INT NOT NULL DEFAULT 1, -- 任务状态,1 待同步,2 同步中,3 成功,4 失败
- total_count INT NOT NULL DEFAULT 0, -- 同步总数
- success_count INT NOT NULL DEFAULT 0, -- 同步成功数
- failed_count INT NOT NULL DEFAULT 0, -- 同步失败数
- error_message TEXT NOT NULL DEFAULT '', -- 任务失败原因
- started_at TIMESTAMPTZ, -- 开始时间
- finished_at TIMESTAMPTZ, -- 完成时间
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now() -- 更新时间
-);
-
-COMMENT ON TABLE sync_jobs IS '同步任务表,记录外部平台同步执行状态';
-COMMENT ON COLUMN sync_jobs.id IS '同步任务 ID';
-COMMENT ON COLUMN sync_jobs.user_id IS '所属用户 ID';
-COMMENT ON COLUMN sync_jobs.sync_source_id IS '同步源 ID';
-COMMENT ON COLUMN sync_jobs.knowledge_base_id IS '绑定知识库 ID';
-COMMENT ON COLUMN sync_jobs.job_type IS '任务类型,manual 手动同步';
-COMMENT ON COLUMN sync_jobs.status IS '任务状态,1 待同步,2 同步中,3 成功,4 失败';
-COMMENT ON COLUMN sync_jobs.total_count IS '同步总数';
-COMMENT ON COLUMN sync_jobs.success_count IS '同步成功数';
-COMMENT ON COLUMN sync_jobs.failed_count IS '同步失败数';
-COMMENT ON COLUMN sync_jobs.error_message IS '任务失败原因';
-COMMENT ON COLUMN sync_jobs.started_at IS '开始时间';
-COMMENT ON COLUMN sync_jobs.finished_at IS '完成时间';
-COMMENT ON COLUMN sync_jobs.created_at IS '创建时间';
-COMMENT ON COLUMN sync_jobs.updated_at IS '更新时间';
-
--- 同步目录项表记录外部知识库中的目录和文件元数据
-CREATE TABLE IF NOT EXISTS sync_items
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 同步目录项 ID
- user_id UUID NOT NULL, -- 所属用户 ID
- sync_source_id UUID NOT NULL, -- 同步源 ID
- knowledge_base_id UUID NOT NULL, -- 绑定知识库 ID
- external_id VARCHAR(255) NOT NULL, -- 外部节点 ID
- parent_external_id VARCHAR(255) NOT NULL DEFAULT '', -- 外部父节点 ID
- name VARCHAR(255) NOT NULL, -- 节点名称
- item_type VARCHAR(32) NOT NULL, -- 节点类型,FILE 或 FOLDER
- category VARCHAR(64) NOT NULL DEFAULT '', -- 钉钉节点分类
- extension VARCHAR(32) NOT NULL DEFAULT '', -- 文件扩展名
- external_url TEXT NOT NULL DEFAULT '', -- 外部原文链接
- file_size BIGINT NOT NULL DEFAULT 0, -- 文件大小
- has_children BOOLEAN NOT NULL DEFAULT FALSE, -- 是否有子节点
- source_updated_at TIMESTAMPTZ, -- 外部更新时间
- local_document_id UUID, -- 已导入本地文档 ID
- import_status INT NOT NULL DEFAULT 1, -- 导入状态,1 未导入,2 导入中,3 已导入,4 导入失败
- error_message TEXT NOT NULL DEFAULT '', -- 导入失败原因
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 更新时间
-
- CONSTRAINT sync_items_user_source_external_unique UNIQUE (user_id, sync_source_id, external_id)
-);
-
-COMMENT ON TABLE sync_items IS '同步目录项表,记录外部知识库中的目录和文件元数据';
-COMMENT ON COLUMN sync_items.id IS '同步目录项 ID';
-COMMENT ON COLUMN sync_items.user_id IS '所属用户 ID';
-COMMENT ON COLUMN sync_items.sync_source_id IS '同步源 ID';
-COMMENT ON COLUMN sync_items.knowledge_base_id IS '绑定知识库 ID';
-COMMENT ON COLUMN sync_items.external_id IS '外部节点 ID';
-COMMENT ON COLUMN sync_items.parent_external_id IS '外部父节点 ID';
-COMMENT ON COLUMN sync_items.name IS '节点名称';
-COMMENT ON COLUMN sync_items.item_type IS '节点类型,FILE 或 FOLDER';
-COMMENT ON COLUMN sync_items.category IS '钉钉节点分类';
-COMMENT ON COLUMN sync_items.extension IS '文件扩展名';
-COMMENT ON COLUMN sync_items.external_url IS '外部原文链接';
-COMMENT ON COLUMN sync_items.file_size IS '文件大小';
-COMMENT ON COLUMN sync_items.has_children IS '是否有子节点';
-COMMENT ON COLUMN sync_items.source_updated_at IS '外部更新时间';
-COMMENT ON COLUMN sync_items.local_document_id IS '已导入本地文档 ID';
-COMMENT ON COLUMN sync_items.import_status IS '导入状态,1 未导入,2 导入中,3 已导入,4 导入失败';
-COMMENT ON COLUMN sync_items.error_message IS '导入失败原因';
-COMMENT ON COLUMN sync_items.created_at IS '创建时间';
-COMMENT ON COLUMN sync_items.updated_at IS '更新时间';
-
--- 钉钉用户绑定表保存系统用户与钉钉身份的对应关系
-CREATE TABLE IF NOT EXISTS dingtalk_user_bindings
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 绑定 ID
- user_id UUID NOT NULL, -- 系统用户 ID
- ding_open_id VARCHAR(128) NOT NULL DEFAULT '', -- 钉钉 openid
- ding_union_id VARCHAR(128) NOT NULL, -- 钉钉 unionId
- corp_id VARCHAR(128) NOT NULL DEFAULT '', -- 钉钉企业 ID
- nickname VARCHAR(128) NOT NULL DEFAULT '', -- 钉钉用户昵称
- avatar TEXT NOT NULL DEFAULT '', -- 钉钉用户头像
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 更新时间
-
- CONSTRAINT dingtalk_user_bindings_user_unique UNIQUE (user_id),
- CONSTRAINT dingtalk_user_bindings_union_unique UNIQUE (ding_union_id)
-);
-
-COMMENT ON TABLE dingtalk_user_bindings IS '钉钉用户绑定表,用于保存系统用户与钉钉身份的对应关系';
-COMMENT ON COLUMN dingtalk_user_bindings.id IS '绑定 ID';
-COMMENT ON COLUMN dingtalk_user_bindings.user_id IS '系统用户 ID';
-COMMENT ON COLUMN dingtalk_user_bindings.ding_open_id IS '钉钉 openid';
-COMMENT ON COLUMN dingtalk_user_bindings.ding_union_id IS '钉钉 unionId';
-COMMENT ON COLUMN dingtalk_user_bindings.corp_id IS '钉钉企业 ID';
-COMMENT ON COLUMN dingtalk_user_bindings.nickname IS '钉钉用户昵称';
-COMMENT ON COLUMN dingtalk_user_bindings.avatar IS '钉钉用户头像';
-COMMENT ON COLUMN dingtalk_user_bindings.created_at IS '创建时间';
-COMMENT ON COLUMN dingtalk_user_bindings.updated_at IS '更新时间';
-
--- 存储配额表记录用户存储上限和已用容量
-CREATE TABLE IF NOT EXISTS storage_quotas
-(
- id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- 配额记录 ID
- user_id UUID NOT NULL, -- 所属用户 ID
- max_storage_bytes BIGINT NOT NULL DEFAULT 10737418240, -- 最大可用存储字节数
- used_storage_bytes BIGINT NOT NULL DEFAULT 0, -- 已用存储字节数
- created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 创建时间
- updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 更新时间
-
- CONSTRAINT storage_quotas_user_unique UNIQUE (user_id)
-);
-
-COMMENT ON TABLE storage_quotas IS '用户存储配额表,用于限制单用户知识库容量';
-COMMENT ON COLUMN storage_quotas.id IS '配额记录 ID';
-COMMENT ON COLUMN storage_quotas.user_id IS '所属用户 ID';
-COMMENT ON COLUMN storage_quotas.max_storage_bytes IS '最大可用存储字节数';
-COMMENT ON COLUMN storage_quotas.used_storage_bytes IS '已用存储字节数';
-COMMENT ON COLUMN storage_quotas.created_at IS '创建时间';
-COMMENT ON COLUMN storage_quotas.updated_at IS '更新时间';
-
--- 常用查询索引
-CREATE INDEX IF NOT EXISTS idx_knowledge_bases_user_id
- ON knowledge_bases(user_id);
-
-CREATE UNIQUE INDEX IF NOT EXISTS knowledge_bases_user_name_normal_unique
- ON knowledge_bases(user_id, name)
- WHERE status = 1;
-
-CREATE INDEX IF NOT EXISTS idx_documents_user_kb
- ON documents(user_id, knowledge_base_id);
-
-CREATE INDEX IF NOT EXISTS idx_documents_user_external
- ON documents(user_id, source_type, external_id)
- WHERE external_id <> '';
-
-CREATE INDEX IF NOT EXISTS idx_document_versions_document_id
- ON document_versions(document_id);
-
-CREATE INDEX IF NOT EXISTS idx_document_chunks_user_kb
- ON document_chunks(user_id, knowledge_base_id);
-
-CREATE INDEX IF NOT EXISTS idx_document_chunks_document_id
- ON document_chunks(document_id);
-
-CREATE INDEX IF NOT EXISTS idx_document_processing_jobs_document_id
- ON document_processing_jobs(document_id);
-
-CREATE INDEX IF NOT EXISTS idx_sync_sources_user_kb
- ON sync_sources(user_id, knowledge_base_id);
-
-CREATE INDEX IF NOT EXISTS idx_sync_jobs_source_id
- ON sync_jobs(sync_source_id);
-
-CREATE INDEX IF NOT EXISTS idx_sync_jobs_user_kb
- ON sync_jobs(user_id, knowledge_base_id);
-
-CREATE INDEX IF NOT EXISTS idx_sync_items_source_parent
- ON sync_items(sync_source_id, parent_external_id);
-
-CREATE INDEX IF NOT EXISTS idx_sync_items_user_kb
- ON sync_items(user_id, knowledge_base_id);
-
-CREATE INDEX IF NOT EXISTS idx_dingtalk_user_bindings_user_id
- ON dingtalk_user_bindings(user_id);
-
-CREATE INDEX IF NOT EXISTS idx_dingtalk_user_bindings_corp_id
- ON dingtalk_user_bindings(corp_id);
-
-CREATE INDEX IF NOT EXISTS idx_document_chunks_embedding
- ON document_chunks
- USING ivfflat (embedding vector_cosine_ops)
- WITH (lists = 100)
- WHERE embedding IS NOT NULL;
+-- Solvify-Agent 生产数据库初始化基线
+-- 仅在空 PostgreSQL 数据卷首次启动时执行
+
+\set ON_ERROR_STOP on
+
+BEGIN;
+
+CREATE EXTENSION IF NOT EXISTS pgcrypto;
+CREATE EXTENSION IF NOT EXISTS vector;
+
+-- ----------------------------
+-- Table structure for agent_task_steps
+-- ----------------------------
+CREATE TABLE "public"."agent_task_steps" (
+ "id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
+ "task_id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
+ "step_index" int4 NOT NULL DEFAULT 0,
+ "started_at" timestamptz(6) NOT NULL,
+ "ended_at" timestamptz(6),
+ "thinking_summary" text COLLATE "pg_catalog"."default",
+ "tool_name" varchar(128) COLLATE "pg_catalog"."default",
+ "tool_input_masked" text COLLATE "pg_catalog"."default",
+ "tool_result_summary" text COLLATE "pg_catalog"."default",
+ "tool_status" varchar(32) COLLATE "pg_catalog"."default",
+ "tool_error" text COLLATE "pg_catalog"."default",
+ "latency_ms" int8 NOT NULL DEFAULT 0,
+ "tokens_delta" int4 NOT NULL DEFAULT 0,
+ "attrs" jsonb
+)
+;
+
+-- ----------------------------
+-- Table structure for agent_tasks
+-- ----------------------------
+CREATE TABLE "public"."agent_tasks" (
+ "id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
+ "trace_id" varchar(128) COLLATE "pg_catalog"."default",
+ "session_id" varchar(64) COLLATE "pg_catalog"."default",
+ "user_id" varchar(64) COLLATE "pg_catalog"."default",
+ "model_id" varchar(128) COLLATE "pg_catalog"."default",
+ "search_mode" varchar(32) COLLATE "pg_catalog"."default",
+ "started_at" timestamptz(6) NOT NULL,
+ "ended_at" timestamptz(6),
+ "total_steps" int4 NOT NULL DEFAULT 0,
+ "tool_calls" int4 NOT NULL DEFAULT 0,
+ "status" varchar(32) COLLATE "pg_catalog"."default",
+ "abort_reason" varchar(128) COLLATE "pg_catalog"."default",
+ "tokens_prompt" int4 NOT NULL DEFAULT 0,
+ "tokens_completion" int4 NOT NULL DEFAULT 0,
+ "total_cost" float8 NOT NULL DEFAULT 0,
+ "error_summary" text COLLATE "pg_catalog"."default",
+ "feedback_rating" int4
+)
+;
+
+-- ----------------------------
+-- Table structure for chat_messages
+-- ----------------------------
+CREATE TABLE "public"."chat_messages" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "session_id" uuid NOT NULL,
+ "role" varchar(20) COLLATE "pg_catalog"."default" NOT NULL,
+ "content" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "model_id" varchar(36) COLLATE "pg_catalog"."default",
+ "search_mode" varchar(20) COLLATE "pg_catalog"."default" NOT NULL DEFAULT 'quick'::character varying,
+ "knowledge_base_ids" jsonb NOT NULL DEFAULT '[]'::jsonb,
+ "sources" jsonb,
+ "metadata" jsonb NOT NULL DEFAULT '{}'::jsonb,
+ "created_at" timestamptz(6)
+)
+;
+
+-- ----------------------------
+-- Table structure for chat_sessions
+-- ----------------------------
+CREATE TABLE "public"."chat_sessions" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "title" varchar(200) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "model_id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "status" varchar(20) COLLATE "pg_catalog"."default" NOT NULL DEFAULT 'active'::character varying,
+ "message_count" int8 NOT NULL DEFAULT 0,
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6)
+)
+;
+
+-- ----------------------------
+-- Table structure for chat_summaries
+-- ----------------------------
+CREATE TABLE "public"."chat_summaries" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "session_id" uuid NOT NULL,
+ "summary" text COLLATE "pg_catalog"."default" NOT NULL,
+ "covered_count" int4 NOT NULL DEFAULT 0,
+ "last_message_id" uuid,
+ "created_at" timestamptz(6) NOT NULL DEFAULT now(),
+ "updated_at" timestamptz(6) NOT NULL DEFAULT now()
+)
+;
+COMMENT ON COLUMN "public"."chat_summaries"."session_id" IS '会话 ID';
+COMMENT ON COLUMN "public"."chat_summaries"."summary" IS '摘要文本';
+COMMENT ON COLUMN "public"."chat_summaries"."covered_count" IS '覆盖消息数';
+COMMENT ON COLUMN "public"."chat_summaries"."last_message_id" IS '摘要覆盖到的最后一条消息 ID';
+COMMENT ON TABLE "public"."chat_summaries" IS '会话摘要表,保存单一会话的摘要以替代早期原始消息';
+
+-- ----------------------------
+-- Table structure for chat_traces
+-- ----------------------------
+CREATE TABLE "public"."chat_traces" (
+ "id" varchar(128) COLLATE "pg_catalog"."default" NOT NULL,
+ "request_id" varchar(128) COLLATE "pg_catalog"."default",
+ "user_id" varchar(64) COLLATE "pg_catalog"."default",
+ "session_id" varchar(64) COLLATE "pg_catalog"."default",
+ "sample_rate" float8 NOT NULL DEFAULT 0,
+ "sampled" bool NOT NULL DEFAULT false,
+ "duration_ms" int8 NOT NULL DEFAULT 0,
+ "status" varchar(32) COLLATE "pg_catalog"."default",
+ "error" text COLLATE "pg_catalog"."default",
+ "attrs" jsonb,
+ "span_tree" jsonb,
+ "created_at" timestamptz(6) NOT NULL DEFAULT now()
+)
+;
+
+-- ----------------------------
+-- Table structure for dingtalk_user_bindings
+-- ----------------------------
+CREATE TABLE "public"."dingtalk_user_bindings" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "ding_open_id" varchar(128) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "ding_union_id" varchar(128) COLLATE "pg_catalog"."default" NOT NULL,
+ "corp_id" varchar(128) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "nickname" varchar(128) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "avatar" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "created_at" timestamptz(6) NOT NULL DEFAULT now(),
+ "updated_at" timestamptz(6) NOT NULL DEFAULT now()
+)
+;
+COMMENT ON COLUMN "public"."dingtalk_user_bindings"."id" IS '绑定 ID';
+COMMENT ON COLUMN "public"."dingtalk_user_bindings"."user_id" IS '系统用户 ID';
+COMMENT ON COLUMN "public"."dingtalk_user_bindings"."ding_open_id" IS '钉钉 openid';
+COMMENT ON COLUMN "public"."dingtalk_user_bindings"."ding_union_id" IS '钉钉 unionId';
+COMMENT ON COLUMN "public"."dingtalk_user_bindings"."corp_id" IS '钉钉企业 ID';
+COMMENT ON COLUMN "public"."dingtalk_user_bindings"."nickname" IS '钉钉用户昵称';
+COMMENT ON COLUMN "public"."dingtalk_user_bindings"."avatar" IS '钉钉用户头像';
+COMMENT ON COLUMN "public"."dingtalk_user_bindings"."created_at" IS '创建时间';
+COMMENT ON COLUMN "public"."dingtalk_user_bindings"."updated_at" IS '更新时间';
+COMMENT ON TABLE "public"."dingtalk_user_bindings" IS '钉钉用户绑定表,用于保存系统用户与钉钉身份的对应关系';
+
+-- ----------------------------
+-- Table structure for document_chunks
+-- ----------------------------
+CREATE TABLE "public"."document_chunks" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "knowledge_base_id" uuid NOT NULL,
+ "document_id" uuid NOT NULL,
+ "version_id" uuid NOT NULL,
+ "chunk_index" int8 NOT NULL,
+ "section_title" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "content" text COLLATE "pg_catalog"."default" NOT NULL,
+ "token_count" int8 NOT NULL DEFAULT 0,
+ "page_number" int8,
+ "embedding_model" varchar(128) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "embedding" "public"."vector"(1024),
+ "metadata" jsonb NOT NULL DEFAULT '{}'::jsonb,
+ "created_at" timestamptz(6),
+ "keywords" text[] COLLATE "pg_catalog"."default" DEFAULT '{}'::text[]
+)
+;
+COMMENT ON COLUMN "public"."document_chunks"."id" IS '分块 ID';
+COMMENT ON COLUMN "public"."document_chunks"."user_id" IS '所属用户 ID';
+COMMENT ON COLUMN "public"."document_chunks"."knowledge_base_id" IS '所属知识库 ID';
+COMMENT ON COLUMN "public"."document_chunks"."document_id" IS '所属文档 ID';
+COMMENT ON COLUMN "public"."document_chunks"."version_id" IS '所属文档版本 ID';
+COMMENT ON COLUMN "public"."document_chunks"."chunk_index" IS '分块序号';
+COMMENT ON COLUMN "public"."document_chunks"."section_title" IS '分块所属章节标题';
+COMMENT ON COLUMN "public"."document_chunks"."content" IS '分块文本内容';
+COMMENT ON COLUMN "public"."document_chunks"."token_count" IS '分块 token 数';
+COMMENT ON COLUMN "public"."document_chunks"."page_number" IS '来源页码';
+COMMENT ON COLUMN "public"."document_chunks"."embedding_model" IS '向量模型名称';
+COMMENT ON COLUMN "public"."document_chunks"."embedding" IS '分块向量数据';
+COMMENT ON COLUMN "public"."document_chunks"."metadata" IS '分块扩展元数据';
+COMMENT ON COLUMN "public"."document_chunks"."created_at" IS '创建时间';
+COMMENT ON COLUMN "public"."document_chunks"."keywords" IS '分块关键词';
+COMMENT ON TABLE "public"."document_chunks" IS '文档分块表,保存可检索文本片段和 embedding 向量';
+
+-- ----------------------------
+-- Table structure for document_processing_jobs
+-- ----------------------------
+CREATE TABLE "public"."document_processing_jobs" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "document_id" uuid NOT NULL,
+ "job_type" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,
+ "status" int8 NOT NULL DEFAULT 1,
+ "error_message" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "started_at" timestamptz(6),
+ "finished_at" timestamptz(6),
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6)
+)
+;
+COMMENT ON COLUMN "public"."document_processing_jobs"."id" IS '任务 ID';
+COMMENT ON COLUMN "public"."document_processing_jobs"."user_id" IS '所属用户 ID';
+COMMENT ON COLUMN "public"."document_processing_jobs"."document_id" IS '所属文档 ID';
+COMMENT ON COLUMN "public"."document_processing_jobs"."job_type" IS '任务类型,parse 解析,chunk 分块,embed 向量化,reindex 重建索引';
+COMMENT ON COLUMN "public"."document_processing_jobs"."status" IS '任务状态,1 待处理,2 运行中,3 成功,4 失败';
+COMMENT ON COLUMN "public"."document_processing_jobs"."error_message" IS '任务失败原因';
+COMMENT ON COLUMN "public"."document_processing_jobs"."started_at" IS '开始时间';
+COMMENT ON COLUMN "public"."document_processing_jobs"."finished_at" IS '完成时间';
+COMMENT ON COLUMN "public"."document_processing_jobs"."created_at" IS '创建时间';
+COMMENT ON COLUMN "public"."document_processing_jobs"."updated_at" IS '更新时间';
+COMMENT ON TABLE "public"."document_processing_jobs" IS '文档处理任务表,记录解析、分块、向量化和重建索引状态';
+
+-- ----------------------------
+-- Table structure for document_versions
+-- ----------------------------
+CREATE TABLE "public"."document_versions" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "document_id" uuid NOT NULL,
+ "version_no" int8 NOT NULL DEFAULT 1,
+ "content" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "content_hash" varchar(128) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "change_summary" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "created_at" timestamptz(6)
+)
+;
+COMMENT ON COLUMN "public"."document_versions"."id" IS '版本 ID';
+COMMENT ON COLUMN "public"."document_versions"."user_id" IS '所属用户 ID';
+COMMENT ON COLUMN "public"."document_versions"."document_id" IS '所属文档 ID';
+COMMENT ON COLUMN "public"."document_versions"."version_no" IS '版本号';
+COMMENT ON COLUMN "public"."document_versions"."content" IS '版本正文内容';
+COMMENT ON COLUMN "public"."document_versions"."content_hash" IS '版本内容哈希';
+COMMENT ON COLUMN "public"."document_versions"."change_summary" IS '变更摘要';
+COMMENT ON COLUMN "public"."document_versions"."created_at" IS '创建时间';
+COMMENT ON TABLE "public"."document_versions" IS '文档版本表,用于记录原始解析内容和在线编辑历史';
+
+-- ----------------------------
+-- Table structure for documents
+-- ----------------------------
+CREATE TABLE "public"."documents" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "knowledge_base_id" uuid NOT NULL,
+ "title" varchar(255) COLLATE "pg_catalog"."default" NOT NULL,
+ "file_name" varchar(255) COLLATE "pg_catalog"."default" NOT NULL,
+ "file_type" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "file_size" int8 NOT NULL DEFAULT 0,
+ "storage_path" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "file_hash" varchar(128) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "source_type" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT 'upload'::character varying,
+ "status" int8 NOT NULL DEFAULT 1,
+ "error_message" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "ready_at" timestamptz(6),
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6),
+ "deleted_at" timestamptz(6),
+ "delete_expired_at" timestamptz(6),
+ "external_id" varchar(255) COLLATE "pg_catalog"."default" DEFAULT ''::character varying,
+ "external_url" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "source_updated_at" timestamptz(6)
+)
+;
+COMMENT ON COLUMN "public"."documents"."id" IS '文档 ID';
+COMMENT ON COLUMN "public"."documents"."user_id" IS '所属用户 ID';
+COMMENT ON COLUMN "public"."documents"."knowledge_base_id" IS '所属知识库 ID';
+COMMENT ON COLUMN "public"."documents"."title" IS '文档标题';
+COMMENT ON COLUMN "public"."documents"."file_name" IS '原始文件名';
+COMMENT ON COLUMN "public"."documents"."file_type" IS '文件类型';
+COMMENT ON COLUMN "public"."documents"."file_size" IS '文件大小字节数';
+COMMENT ON COLUMN "public"."documents"."storage_path" IS '文件存储路径';
+COMMENT ON COLUMN "public"."documents"."file_hash" IS '原始文件内容指纹';
+COMMENT ON COLUMN "public"."documents"."source_type" IS '文档来源类型,upload 上传,edit 编辑,sync 同步,web_search 联网搜索';
+COMMENT ON COLUMN "public"."documents"."status" IS '文档状态,1 已上传,2 处理中,3 已就绪,4 处理失败,5 已删除';
+COMMENT ON COLUMN "public"."documents"."error_message" IS '处理失败原因';
+COMMENT ON COLUMN "public"."documents"."ready_at" IS '文档就绪时间';
+COMMENT ON COLUMN "public"."documents"."created_at" IS '创建时间';
+COMMENT ON COLUMN "public"."documents"."updated_at" IS '更新时间';
+COMMENT ON COLUMN "public"."documents"."deleted_at" IS '删除时间';
+COMMENT ON COLUMN "public"."documents"."delete_expired_at" IS '删除保留到期时间';
+COMMENT ON COLUMN "public"."documents"."external_id" IS '外部平台文档 ID';
+COMMENT ON COLUMN "public"."documents"."external_url" IS '外部平台文档链接';
+COMMENT ON COLUMN "public"."documents"."source_updated_at" IS '外部平台更新时间';
+COMMENT ON TABLE "public"."documents" IS '文档主表,记录知识库下的文件和处理状态';
+
+-- ----------------------------
+-- Table structure for knowledge_bases
+-- ----------------------------
+CREATE TABLE "public"."knowledge_bases" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "name" varchar(128) COLLATE "pg_catalog"."default" NOT NULL,
+ "category" varchar(128) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "description" text COLLATE "pg_catalog"."default",
+ "source_type" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT 'local'::character varying,
+ "source_platform" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "document_count" int8 NOT NULL DEFAULT 0,
+ "storage_bytes" int8 NOT NULL DEFAULT 0,
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6),
+ "status" int8 NOT NULL DEFAULT 1,
+ "deleted_at" timestamptz(6),
+ "delete_expired_at" timestamptz(6)
+)
+;
+COMMENT ON COLUMN "public"."knowledge_bases"."id" IS '知识库 ID';
+COMMENT ON COLUMN "public"."knowledge_bases"."user_id" IS '所属用户 ID';
+COMMENT ON COLUMN "public"."knowledge_bases"."name" IS '知识库名称';
+COMMENT ON COLUMN "public"."knowledge_bases"."category" IS '知识库分类';
+COMMENT ON COLUMN "public"."knowledge_bases"."description" IS '知识库描述';
+COMMENT ON COLUMN "public"."knowledge_bases"."source_type" IS '知识库来源类型,local 自建,sync 同步,web_search 联网搜索';
+COMMENT ON COLUMN "public"."knowledge_bases"."source_platform" IS '同步来源平台';
+COMMENT ON COLUMN "public"."knowledge_bases"."document_count" IS '文档数量';
+COMMENT ON COLUMN "public"."knowledge_bases"."storage_bytes" IS '已占用存储字节数';
+COMMENT ON COLUMN "public"."knowledge_bases"."created_at" IS '创建时间';
+COMMENT ON COLUMN "public"."knowledge_bases"."updated_at" IS '更新时间';
+COMMENT ON COLUMN "public"."knowledge_bases"."status" IS '知识库状态,1 正常,2 已删除';
+COMMENT ON COLUMN "public"."knowledge_bases"."deleted_at" IS '删除时间';
+COMMENT ON COLUMN "public"."knowledge_bases"."delete_expired_at" IS '删除保留到期时间';
+COMMENT ON TABLE "public"."knowledge_bases" IS '知识库主表,所有知识库按用户隔离';
+
+-- ----------------------------
+-- Table structure for message_feedback
+-- ----------------------------
+CREATE TABLE "public"."message_feedback" (
+ "id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
+ "message_id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
+ "user_id" varchar(64) COLLATE "pg_catalog"."default" NOT NULL,
+ "session_id" varchar(64) COLLATE "pg_catalog"."default",
+ "rating" int4 NOT NULL DEFAULT 0,
+ "reason_tag" varchar(64) COLLATE "pg_catalog"."default",
+ "comment" text COLLATE "pg_catalog"."default",
+ "trace_id" varchar(128) COLLATE "pg_catalog"."default",
+ "created_at" timestamptz(6) NOT NULL DEFAULT now()
+)
+;
+
+-- ----------------------------
+-- Table structure for models
+-- ----------------------------
+CREATE TABLE "public"."models" (
+ "id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "name" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
+ "provider" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
+ "model_id" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
+ "is_enabled" bool DEFAULT true,
+ "config" jsonb,
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6),
+ "base_url" varchar(500) COLLATE "pg_catalog"."default",
+ "api_key" varchar(500) COLLATE "pg_catalog"."default",
+ "max_context_length" int4 NOT NULL DEFAULT 8192
+)
+;
+COMMENT ON COLUMN "public"."models"."max_context_length" IS '模型最大上下文 token 长度,用于计算历史消息和检索预算';
+COMMENT ON TABLE "public"."models" IS '系统预置模型配置表';
+
+-- ----------------------------
+-- Table structure for role_templates
+-- ----------------------------
+CREATE TABLE "public"."role_templates" (
+ "id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "builtin_key" varchar(50) COLLATE "pg_catalog"."default",
+ "name" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
+ "description" varchar(255) COLLATE "pg_catalog"."default",
+ "target_role" int2 NOT NULL DEFAULT 0,
+ "department" varchar(100) COLLATE "pg_catalog"."default",
+ "is_default" bool NOT NULL DEFAULT false,
+ "system_prompt" text COLLATE "pg_catalog"."default" NOT NULL,
+ "is_enabled" bool NOT NULL DEFAULT true,
+ "display_order" int4 NOT NULL DEFAULT 0,
+ "created_at" timestamp(6) NOT NULL DEFAULT now(),
+ "updated_at" timestamp(6) NOT NULL DEFAULT now()
+)
+;
+COMMENT ON COLUMN "public"."role_templates"."builtin_key" IS '内置模板唯一标识:default/engineer/hr/finance;自定义为空';
+COMMENT ON COLUMN "public"."role_templates"."target_role" IS '适用角色:0通用/1普通用户/2管理员';
+COMMENT ON COLUMN "public"."role_templates"."department" IS '适用部门;空=全部';
+COMMENT ON COLUMN "public"."role_templates"."is_default" IS '是否为缺省模板;仅 1 条为 true';
+COMMENT ON COLUMN "public"."role_templates"."system_prompt" IS '额外追加到默认系统提示词的角色专属内容';
+COMMENT ON COLUMN "public"."role_templates"."is_enabled" IS '是否启用';
+COMMENT ON COLUMN "public"."role_templates"."display_order" IS '展示顺序';
+COMMENT ON TABLE "public"."role_templates" IS '角色模板:按用户角色/部门加载不同的 System Prompt';
+
+-- ----------------------------
+-- Table structure for storage_quotas
+-- ----------------------------
+CREATE TABLE "public"."storage_quotas" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "max_storage_bytes" int8 NOT NULL DEFAULT '10737418240'::bigint,
+ "used_storage_bytes" int8 NOT NULL DEFAULT 0,
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6)
+)
+;
+COMMENT ON COLUMN "public"."storage_quotas"."id" IS '配额记录 ID';
+COMMENT ON COLUMN "public"."storage_quotas"."user_id" IS '所属用户 ID';
+COMMENT ON COLUMN "public"."storage_quotas"."max_storage_bytes" IS '最大可用存储字节数';
+COMMENT ON COLUMN "public"."storage_quotas"."used_storage_bytes" IS '已用存储字节数';
+COMMENT ON COLUMN "public"."storage_quotas"."created_at" IS '创建时间';
+COMMENT ON COLUMN "public"."storage_quotas"."updated_at" IS '更新时间';
+COMMENT ON TABLE "public"."storage_quotas" IS '用户存储配额表,用于限制单用户知识库容量';
+
+-- ----------------------------
+-- Table structure for sync_items
+-- ----------------------------
+CREATE TABLE "public"."sync_items" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "sync_source_id" uuid NOT NULL,
+ "knowledge_base_id" uuid NOT NULL,
+ "external_id" varchar(255) COLLATE "pg_catalog"."default" NOT NULL,
+ "parent_external_id" varchar(255) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "name" varchar(255) COLLATE "pg_catalog"."default" NOT NULL,
+ "item_type" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,
+ "category" varchar(64) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "extension" varchar(32) COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::character varying,
+ "external_url" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "file_size" int8 NOT NULL DEFAULT 0,
+ "has_children" bool NOT NULL DEFAULT false,
+ "source_updated_at" timestamptz(6),
+ "local_document_id" uuid,
+ "import_status" int4 NOT NULL DEFAULT 1,
+ "error_message" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "created_at" timestamptz(6) NOT NULL DEFAULT now(),
+ "updated_at" timestamptz(6) NOT NULL DEFAULT now()
+)
+;
+COMMENT ON COLUMN "public"."sync_items"."id" IS '同步目录项 ID';
+COMMENT ON COLUMN "public"."sync_items"."user_id" IS '所属用户 ID';
+COMMENT ON COLUMN "public"."sync_items"."sync_source_id" IS '同步源 ID';
+COMMENT ON COLUMN "public"."sync_items"."knowledge_base_id" IS '绑定知识库 ID';
+COMMENT ON COLUMN "public"."sync_items"."external_id" IS '外部节点 ID';
+COMMENT ON COLUMN "public"."sync_items"."parent_external_id" IS '外部父节点 ID';
+COMMENT ON COLUMN "public"."sync_items"."name" IS '节点名称';
+COMMENT ON COLUMN "public"."sync_items"."item_type" IS '节点类型,FILE 或 FOLDER';
+COMMENT ON COLUMN "public"."sync_items"."category" IS '钉钉节点分类';
+COMMENT ON COLUMN "public"."sync_items"."extension" IS '文件扩展名';
+COMMENT ON COLUMN "public"."sync_items"."external_url" IS '外部原文链接';
+COMMENT ON COLUMN "public"."sync_items"."file_size" IS '文件大小';
+COMMENT ON COLUMN "public"."sync_items"."has_children" IS '是否有子节点';
+COMMENT ON COLUMN "public"."sync_items"."source_updated_at" IS '外部更新时间';
+COMMENT ON COLUMN "public"."sync_items"."local_document_id" IS '已导入本地文档 ID';
+COMMENT ON COLUMN "public"."sync_items"."import_status" IS '导入状态,1 未导入,2 导入中,3 已导入,4 导入失败';
+COMMENT ON COLUMN "public"."sync_items"."error_message" IS '导入失败原因';
+COMMENT ON COLUMN "public"."sync_items"."created_at" IS '创建时间';
+COMMENT ON COLUMN "public"."sync_items"."updated_at" IS '更新时间';
+COMMENT ON TABLE "public"."sync_items" IS '同步目录项表,记录外部知识库中的目录和文件元数据';
+
+-- ----------------------------
+-- Table structure for sync_jobs
+-- ----------------------------
+CREATE TABLE "public"."sync_jobs" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "sync_source_id" uuid NOT NULL,
+ "knowledge_base_id" uuid NOT NULL,
+ "job_type" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,
+ "status" int8 NOT NULL DEFAULT 1,
+ "total_count" int8 NOT NULL DEFAULT 0,
+ "success_count" int8 NOT NULL DEFAULT 0,
+ "failed_count" int8 NOT NULL DEFAULT 0,
+ "error_message" text COLLATE "pg_catalog"."default" NOT NULL DEFAULT ''::text,
+ "started_at" timestamptz(6),
+ "finished_at" timestamptz(6),
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6)
+)
+;
+COMMENT ON COLUMN "public"."sync_jobs"."id" IS '同步任务 ID';
+COMMENT ON COLUMN "public"."sync_jobs"."user_id" IS '所属用户 ID';
+COMMENT ON COLUMN "public"."sync_jobs"."sync_source_id" IS '同步源 ID';
+COMMENT ON COLUMN "public"."sync_jobs"."knowledge_base_id" IS '绑定知识库 ID';
+COMMENT ON COLUMN "public"."sync_jobs"."job_type" IS '任务类型,manual 手动同步';
+COMMENT ON COLUMN "public"."sync_jobs"."status" IS '任务状态,1 待同步,2 同步中,3 成功,4 失败';
+COMMENT ON COLUMN "public"."sync_jobs"."total_count" IS '同步总数';
+COMMENT ON COLUMN "public"."sync_jobs"."success_count" IS '同步成功数';
+COMMENT ON COLUMN "public"."sync_jobs"."failed_count" IS '同步失败数';
+COMMENT ON COLUMN "public"."sync_jobs"."error_message" IS '任务失败原因';
+COMMENT ON COLUMN "public"."sync_jobs"."started_at" IS '开始时间';
+COMMENT ON COLUMN "public"."sync_jobs"."finished_at" IS '完成时间';
+COMMENT ON COLUMN "public"."sync_jobs"."created_at" IS '创建时间';
+COMMENT ON COLUMN "public"."sync_jobs"."updated_at" IS '更新时间';
+COMMENT ON TABLE "public"."sync_jobs" IS '同步任务表,记录外部平台同步执行状态';
+
+-- ----------------------------
+-- Table structure for sync_sources
+-- ----------------------------
+CREATE TABLE "public"."sync_sources" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "knowledge_base_id" uuid NOT NULL,
+ "name" varchar(128) COLLATE "pg_catalog"."default" NOT NULL,
+ "platform" varchar(32) COLLATE "pg_catalog"."default" NOT NULL,
+ "source_config" jsonb NOT NULL DEFAULT '{}'::jsonb,
+ "status" int8 NOT NULL DEFAULT 1,
+ "last_sync_at" timestamptz(6),
+ "last_error_message" text COLLATE "pg_catalog"."default" DEFAULT ''::text,
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6),
+ "deleted_at" timestamptz(6)
+)
+;
+COMMENT ON COLUMN "public"."sync_sources"."id" IS '同步源 ID';
+COMMENT ON COLUMN "public"."sync_sources"."user_id" IS '所属用户 ID';
+COMMENT ON COLUMN "public"."sync_sources"."knowledge_base_id" IS '绑定知识库 ID';
+COMMENT ON COLUMN "public"."sync_sources"."name" IS '同步源名称';
+COMMENT ON COLUMN "public"."sync_sources"."platform" IS '同步平台,当前支持 dingtalk';
+COMMENT ON COLUMN "public"."sync_sources"."source_config" IS '非敏感同步配置';
+COMMENT ON COLUMN "public"."sync_sources"."status" IS '同步源状态,1 正常,2 禁用,3 已删除';
+COMMENT ON COLUMN "public"."sync_sources"."last_sync_at" IS '最近同步时间';
+COMMENT ON COLUMN "public"."sync_sources"."last_error_message" IS '最近同步失败原因';
+COMMENT ON COLUMN "public"."sync_sources"."created_at" IS '创建时间';
+COMMENT ON COLUMN "public"."sync_sources"."updated_at" IS '更新时间';
+COMMENT ON COLUMN "public"."sync_sources"."deleted_at" IS '删除时间';
+COMMENT ON TABLE "public"."sync_sources" IS '同步源配置表,记录钉钉等外部平台同步入口';
+
+-- ----------------------------
+-- Table structure for tool_providers
+-- ----------------------------
+CREATE TABLE "public"."tool_providers" (
+ "id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "tool_type_id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "provider_key" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
+ "name" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
+ "description" text COLLATE "pg_catalog"."default",
+ "provider_type" varchar(20) COLLATE "pg_catalog"."default" NOT NULL DEFAULT 'http'::character varying,
+ "config_schema" jsonb,
+ "input_schema" jsonb,
+ "provider_config" jsonb,
+ "admin_config" jsonb,
+ "rate_limit" jsonb,
+ "is_enabled" bool DEFAULT true,
+ "display_order" int8 DEFAULT 0,
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6)
+)
+;
+
+-- ----------------------------
+-- Table structure for tool_types
+-- ----------------------------
+CREATE TABLE "public"."tool_types" (
+ "id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "name" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
+ "tool_key" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
+ "description" text COLLATE "pg_catalog"."default",
+ "execution_mode" varchar(20) COLLATE "pg_catalog"."default" DEFAULT 'sync'::character varying,
+ "input_schema" jsonb,
+ "is_enabled" bool DEFAULT true,
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6)
+)
+;
+
+-- ----------------------------
+-- Table structure for user_memories
+-- ----------------------------
+CREATE TABLE "public"."user_memories" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "memory_type" varchar(30) COLLATE "pg_catalog"."default" NOT NULL,
+ "content" text COLLATE "pg_catalog"."default" NOT NULL,
+ "source_session" uuid,
+ "confidence" float8 NOT NULL DEFAULT 1.0,
+ "is_active" bool NOT NULL DEFAULT true,
+ "created_at" timestamptz(6) NOT NULL DEFAULT now(),
+ "updated_at" timestamptz(6) NOT NULL DEFAULT now()
+)
+;
+COMMENT ON COLUMN "public"."user_memories"."user_id" IS '所属用户 ID';
+COMMENT ON COLUMN "public"."user_memories"."memory_type" IS '记忆类型:fact / preference / constraint / decision';
+COMMENT ON COLUMN "public"."user_memories"."content" IS '记忆内容';
+COMMENT ON COLUMN "public"."user_memories"."source_session" IS '来源会话 ID';
+COMMENT ON COLUMN "public"."user_memories"."confidence" IS '置信度 0-1';
+COMMENT ON COLUMN "public"."user_memories"."is_active" IS '是否有效';
+COMMENT ON TABLE "public"."user_memories" IS '用户长期记忆表,保存跨会话的事实、偏好、约束和决策结论';
+
+-- ----------------------------
+-- Table structure for user_model_configs
+-- ----------------------------
+CREATE TABLE "public"."user_model_configs" (
+ "id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "user_id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "display_name" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
+ "api_format" varchar(20) COLLATE "pg_catalog"."default" NOT NULL,
+ "base_url" varchar(500) COLLATE "pg_catalog"."default" NOT NULL,
+ "model_id" varchar(100) COLLATE "pg_catalog"."default" NOT NULL,
+ "api_key" varchar(500) COLLATE "pg_catalog"."default",
+ "config" jsonb,
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6),
+ "max_context_length" int4 NOT NULL DEFAULT 8192
+)
+;
+COMMENT ON COLUMN "public"."user_model_configs"."max_context_length" IS '用户自定义模型的最大上下文 token 长度';
+COMMENT ON TABLE "public"."user_model_configs" IS '用户自定义模型配置表';
+
+-- ----------------------------
+-- Table structure for user_preferences
+-- ----------------------------
+CREATE TABLE "public"."user_preferences" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "user_id" uuid NOT NULL,
+ "default_model_id" varchar(36) COLLATE "pg_catalog"."default",
+ "preferred_kb_ids" jsonb,
+ "answer_style" varchar(20) COLLATE "pg_catalog"."default" NOT NULL DEFAULT 'balanced'::character varying,
+ "auto_deep_mode" bool NOT NULL DEFAULT false,
+ "auto_deep_threshold" int4 NOT NULL DEFAULT 2,
+ "use_markdown_table" bool NOT NULL DEFAULT true,
+ "citation_style" varchar(20) COLLATE "pg_catalog"."default" NOT NULL DEFAULT 'section_title'::character varying,
+ "created_at" timestamp(6) NOT NULL DEFAULT now(),
+ "updated_at" timestamp(6) NOT NULL DEFAULT now()
+)
+;
+COMMENT ON COLUMN "public"."user_preferences"."default_model_id" IS '默认使用的模型ID(系统模型表ID,可空)';
+COMMENT ON COLUMN "public"."user_preferences"."preferred_kb_ids" IS '常用知识库ID列表,JSON array[string]';
+COMMENT ON COLUMN "public"."user_preferences"."answer_style" IS '回答风格:concise(简洁)/balanced(平衡)/detailed(详细)/step_by_step(分步)';
+COMMENT ON COLUMN "public"."user_preferences"."auto_deep_mode" IS '是否自动切深度模式(true=复杂问题自动切;false=始终用户手动)';
+COMMENT ON COLUMN "public"."user_preferences"."auto_deep_threshold" IS '自动切深度模式的信号阈值 1~5,越大越不容易切';
+COMMENT ON COLUMN "public"."user_preferences"."use_markdown_table" IS '回答尽量用表格呈现结构化数据';
+COMMENT ON COLUMN "public"."user_preferences"."citation_style" IS '引用格式:none(不标)/section_title(章节标题)/doc_title_only(仅文档名)';
+COMMENT ON TABLE "public"."user_preferences" IS '用户偏好:常用模型、常用知识库、回答风格、是否自动深度模式';
+
+-- ----------------------------
+-- Table structure for user_tool_configs
+-- ----------------------------
+CREATE TABLE "public"."user_tool_configs" (
+ "id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "user_id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "tool_type_id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "provider_id" varchar(36) COLLATE "pg_catalog"."default" NOT NULL,
+ "display_name" varchar(100) COLLATE "pg_catalog"."default",
+ "config" jsonb NOT NULL,
+ "is_enabled" bool DEFAULT true,
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6)
+)
+;
+
+-- ----------------------------
+-- Table structure for users
+-- ----------------------------
+CREATE TABLE "public"."users" (
+ "id" uuid NOT NULL DEFAULT gen_random_uuid(),
+ "username" varchar(50) COLLATE "pg_catalog"."default" NOT NULL,
+ "password" varchar(255) COLLATE "pg_catalog"."default" NOT NULL,
+ "email" varchar(100) COLLATE "pg_catalog"."default",
+ "avatar" varchar(255) COLLATE "pg_catalog"."default",
+ "status" int2 DEFAULT 1,
+ "role" int2 DEFAULT 1,
+ "last_model" varchar(255) COLLATE "pg_catalog"."default",
+ "created_at" timestamptz(6),
+ "updated_at" timestamptz(6),
+ "department" varchar(100) COLLATE "pg_catalog"."default",
+ "position" varchar(100) COLLATE "pg_catalog"."default",
+ "expertise" varchar(255) COLLATE "pg_catalog"."default",
+ "preferred_language" varchar(20) COLLATE "pg_catalog"."default" DEFAULT 'zh-CN'::character varying,
+ "timezone" varchar(50) COLLATE "pg_catalog"."default" DEFAULT 'Asia/Shanghai'::character varying,
+ "role_template_id" varchar(36) COLLATE "pg_catalog"."default"
+)
+;
+COMMENT ON COLUMN "public"."users"."id" IS '用户 ID';
+COMMENT ON COLUMN "public"."users"."username" IS '用户名';
+COMMENT ON COLUMN "public"."users"."password" IS '密码哈希';
+COMMENT ON COLUMN "public"."users"."email" IS '邮箱';
+COMMENT ON COLUMN "public"."users"."avatar" IS '头像';
+COMMENT ON COLUMN "public"."users"."status" IS '用户状态,1 正常,2 禁用,3 注销,4 待验证';
+COMMENT ON COLUMN "public"."users"."role" IS '角色:1普通用户, 2管理员';
+COMMENT ON COLUMN "public"."users"."last_model" IS '上次使用的模型';
+COMMENT ON COLUMN "public"."users"."created_at" IS '创建时间';
+COMMENT ON COLUMN "public"."users"."updated_at" IS '更新时间';
+COMMENT ON COLUMN "public"."users"."department" IS '部门';
+COMMENT ON COLUMN "public"."users"."position" IS '职位';
+COMMENT ON COLUMN "public"."users"."expertise" IS '擅长领域/业务方向,逗号分隔';
+COMMENT ON COLUMN "public"."users"."preferred_language" IS '偏好回答语言:zh-CN/en-US/ja-JP 等';
+COMMENT ON COLUMN "public"."users"."timezone" IS '时区 IANA,如 Asia/Shanghai';
+COMMENT ON COLUMN "public"."users"."role_template_id" IS '关联角色模板ID(可为空=默认模板)';
+COMMENT ON TABLE "public"."users" IS '用户基础表,用于隔离每个用户自己的知识库和文档';
+
+-- ----------------------------
+-- Indexes structure for table agent_task_steps
+-- ----------------------------
+CREATE INDEX "idx_agent_task_steps_started_at" ON "public"."agent_task_steps" USING btree (
+ "started_at" "pg_catalog"."timestamptz_ops" DESC NULLS FIRST
+);
+CREATE INDEX "idx_agent_task_steps_task_id_step" ON "public"."agent_task_steps" USING btree (
+ "task_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
+ "step_index" "pg_catalog"."int4_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table agent_task_steps
+-- ----------------------------
+ALTER TABLE "public"."agent_task_steps" ADD CONSTRAINT "agent_task_steps_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table agent_tasks
+-- ----------------------------
+CREATE INDEX "idx_agent_tasks_session_id" ON "public"."agent_tasks" USING btree (
+ "session_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_agent_tasks_started_at" ON "public"."agent_tasks" USING btree (
+ "started_at" "pg_catalog"."timestamptz_ops" DESC NULLS FIRST
+);
+CREATE INDEX "idx_agent_tasks_trace_id" ON "public"."agent_tasks" USING btree (
+ "trace_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_agent_tasks_user_id" ON "public"."agent_tasks" USING btree (
+ "user_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table agent_tasks
+-- ----------------------------
+ALTER TABLE "public"."agent_tasks" ADD CONSTRAINT "agent_tasks_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table chat_messages
+-- ----------------------------
+CREATE INDEX "idx_chat_messages_session_id_created_at" ON "public"."chat_messages" USING btree (
+ "session_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "created_at" "pg_catalog"."timestamptz_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table chat_messages
+-- ----------------------------
+ALTER TABLE "public"."chat_messages" ADD CONSTRAINT "chat_messages_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table chat_sessions
+-- ----------------------------
+CREATE INDEX "idx_chat_sessions_user_id_updated_at" ON "public"."chat_sessions" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "updated_at" "pg_catalog"."timestamptz_ops" DESC NULLS FIRST
+);
+
+-- ----------------------------
+-- Primary Key structure for table chat_sessions
+-- ----------------------------
+ALTER TABLE "public"."chat_sessions" ADD CONSTRAINT "chat_sessions_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table chat_summaries
+-- ----------------------------
+CREATE INDEX "idx_chat_summaries_session_id" ON "public"."chat_summaries" USING btree (
+ "session_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Uniques structure for table chat_summaries
+-- ----------------------------
+ALTER TABLE "public"."chat_summaries" ADD CONSTRAINT "chat_summaries_session_unique" UNIQUE ("session_id");
+
+-- ----------------------------
+-- Primary Key structure for table chat_summaries
+-- ----------------------------
+ALTER TABLE "public"."chat_summaries" ADD CONSTRAINT "chat_summaries_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table chat_traces
+-- ----------------------------
+CREATE INDEX "idx_chat_traces_created_at" ON "public"."chat_traces" USING btree (
+ "created_at" "pg_catalog"."timestamptz_ops" DESC NULLS FIRST
+);
+CREATE INDEX "idx_chat_traces_request_id" ON "public"."chat_traces" USING btree (
+ "request_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_chat_traces_session_id" ON "public"."chat_traces" USING btree (
+ "session_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_chat_traces_user_id" ON "public"."chat_traces" USING btree (
+ "user_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table chat_traces
+-- ----------------------------
+ALTER TABLE "public"."chat_traces" ADD CONSTRAINT "chat_traces_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table dingtalk_user_bindings
+-- ----------------------------
+CREATE INDEX "idx_dingtalk_user_bindings_corp_id" ON "public"."dingtalk_user_bindings" USING btree (
+ "corp_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_dingtalk_user_bindings_user_id" ON "public"."dingtalk_user_bindings" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Uniques structure for table dingtalk_user_bindings
+-- ----------------------------
+ALTER TABLE "public"."dingtalk_user_bindings" ADD CONSTRAINT "dingtalk_user_bindings_user_unique" UNIQUE ("user_id");
+ALTER TABLE "public"."dingtalk_user_bindings" ADD CONSTRAINT "dingtalk_user_bindings_union_unique" UNIQUE ("ding_union_id");
+
+-- ----------------------------
+-- Primary Key structure for table dingtalk_user_bindings
+-- ----------------------------
+ALTER TABLE "public"."dingtalk_user_bindings" ADD CONSTRAINT "dingtalk_user_bindings_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table document_chunks
+-- ----------------------------
+CREATE INDEX "idx_document_chunks_document_id" ON "public"."document_chunks" USING btree (
+ "document_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_document_chunks_embedding" ON "public"."document_chunks" USING ivfflat (
+ "embedding" "public"."vector_cosine_ops"
+) WITH (lists = 100) WHERE embedding IS NOT NULL;
+CREATE INDEX "idx_document_chunks_keywords_gin" ON "public"."document_chunks" USING gin (
+ "keywords" COLLATE "pg_catalog"."default" "pg_catalog"."array_ops"
+) WHERE keywords IS NOT NULL;
+CREATE INDEX "idx_document_chunks_user_kb" ON "public"."document_chunks" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "knowledge_base_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Uniques structure for table document_chunks
+-- ----------------------------
+ALTER TABLE "public"."document_chunks" ADD CONSTRAINT "document_chunks_version_index_unique" UNIQUE ("version_id", "chunk_index");
+
+-- ----------------------------
+-- Primary Key structure for table document_chunks
+-- ----------------------------
+ALTER TABLE "public"."document_chunks" ADD CONSTRAINT "document_chunks_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table document_processing_jobs
+-- ----------------------------
+CREATE INDEX "idx_document_processing_jobs_document_id" ON "public"."document_processing_jobs" USING btree (
+ "document_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table document_processing_jobs
+-- ----------------------------
+ALTER TABLE "public"."document_processing_jobs" ADD CONSTRAINT "document_processing_jobs_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table document_versions
+-- ----------------------------
+CREATE INDEX "idx_document_versions_document_id" ON "public"."document_versions" USING btree (
+ "document_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Uniques structure for table document_versions
+-- ----------------------------
+ALTER TABLE "public"."document_versions" ADD CONSTRAINT "document_versions_document_version_unique" UNIQUE ("document_id", "version_no");
+
+-- ----------------------------
+-- Primary Key structure for table document_versions
+-- ----------------------------
+ALTER TABLE "public"."document_versions" ADD CONSTRAINT "document_versions_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table documents
+-- ----------------------------
+CREATE INDEX "idx_documents_user_external" ON "public"."documents" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "source_type" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
+ "external_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+) WHERE external_id::text <> ''::text;
+CREATE INDEX "idx_documents_user_kb" ON "public"."documents" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "knowledge_base_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table documents
+-- ----------------------------
+ALTER TABLE "public"."documents" ADD CONSTRAINT "documents_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table knowledge_bases
+-- ----------------------------
+CREATE INDEX "idx_knowledge_bases_user_id" ON "public"."knowledge_bases" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+CREATE UNIQUE INDEX "knowledge_bases_user_name_normal_unique" ON "public"."knowledge_bases" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "name" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+) WHERE status = 1;
+
+-- ----------------------------
+-- Primary Key structure for table knowledge_bases
+-- ----------------------------
+ALTER TABLE "public"."knowledge_bases" ADD CONSTRAINT "knowledge_bases_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table message_feedback
+-- ----------------------------
+CREATE INDEX "idx_message_feedback_message_id" ON "public"."message_feedback" USING btree (
+ "message_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_message_feedback_session_id" ON "public"."message_feedback" USING btree (
+ "session_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_message_feedback_trace_id" ON "public"."message_feedback" USING btree (
+ "trace_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_message_feedback_user_id" ON "public"."message_feedback" USING btree (
+ "user_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table message_feedback
+-- ----------------------------
+ALTER TABLE "public"."message_feedback" ADD CONSTRAINT "message_feedback_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table models
+-- ----------------------------
+CREATE INDEX "idx_models_model_id" ON "public"."models" USING btree (
+ "model_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE UNIQUE INDEX "idx_models_model_id_unique" ON "public"."models" USING btree (
+ "model_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+) WHERE is_enabled = true;
+
+-- ----------------------------
+-- Primary Key structure for table models
+-- ----------------------------
+ALTER TABLE "public"."models" ADD CONSTRAINT "models_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table role_templates
+-- ----------------------------
+CREATE UNIQUE INDEX "idx_role_templates_builtin_key" ON "public"."role_templates" USING btree (
+ "builtin_key" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+) WHERE builtin_key::text <> ''::text;
+CREATE INDEX "idx_role_templates_is_enabled" ON "public"."role_templates" USING btree (
+ "is_enabled" "pg_catalog"."bool_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table role_templates
+-- ----------------------------
+ALTER TABLE "public"."role_templates" ADD CONSTRAINT "role_templates_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table storage_quotas
+-- ----------------------------
+CREATE UNIQUE INDEX "storage_quotas_user_unique" ON "public"."storage_quotas" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table storage_quotas
+-- ----------------------------
+ALTER TABLE "public"."storage_quotas" ADD CONSTRAINT "storage_quotas_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table sync_items
+-- ----------------------------
+CREATE INDEX "idx_sync_items_source_parent" ON "public"."sync_items" USING btree (
+ "sync_source_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "parent_external_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_sync_items_user_kb" ON "public"."sync_items" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "knowledge_base_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Uniques structure for table sync_items
+-- ----------------------------
+ALTER TABLE "public"."sync_items" ADD CONSTRAINT "sync_items_user_source_external_unique" UNIQUE ("user_id", "sync_source_id", "external_id");
+
+-- ----------------------------
+-- Primary Key structure for table sync_items
+-- ----------------------------
+ALTER TABLE "public"."sync_items" ADD CONSTRAINT "sync_items_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table sync_jobs
+-- ----------------------------
+CREATE INDEX "idx_sync_jobs_source_id" ON "public"."sync_jobs" USING btree (
+ "sync_source_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_sync_jobs_user_kb" ON "public"."sync_jobs" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "knowledge_base_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table sync_jobs
+-- ----------------------------
+ALTER TABLE "public"."sync_jobs" ADD CONSTRAINT "sync_jobs_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table sync_sources
+-- ----------------------------
+CREATE INDEX "idx_sync_sources_user_kb" ON "public"."sync_sources" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "knowledge_base_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table sync_sources
+-- ----------------------------
+ALTER TABLE "public"."sync_sources" ADD CONSTRAINT "sync_sources_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table tool_providers
+-- ----------------------------
+CREATE INDEX "idx_tool_providers_is_enabled" ON "public"."tool_providers" USING btree (
+ "is_enabled" "pg_catalog"."bool_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_tool_providers_tool_type_id" ON "public"."tool_providers" USING btree (
+ "tool_type_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table tool_providers
+-- ----------------------------
+ALTER TABLE "public"."tool_providers" ADD CONSTRAINT "tool_providers_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table tool_types
+-- ----------------------------
+CREATE INDEX "idx_tool_types_is_enabled" ON "public"."tool_types" USING btree (
+ "is_enabled" "pg_catalog"."bool_ops" ASC NULLS LAST
+);
+CREATE UNIQUE INDEX "idx_tool_types_tool_key" ON "public"."tool_types" USING btree (
+ "tool_key" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table tool_types
+-- ----------------------------
+ALTER TABLE "public"."tool_types" ADD CONSTRAINT "tool_types_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table user_memories
+-- ----------------------------
+CREATE INDEX "idx_user_memories_user_active" ON "public"."user_memories" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST,
+ "is_active" "pg_catalog"."bool_ops" ASC NULLS LAST,
+ "updated_at" "pg_catalog"."timestamptz_ops" DESC NULLS FIRST
+);
+
+-- ----------------------------
+-- Uniques structure for table user_memories
+-- ----------------------------
+ALTER TABLE "public"."user_memories" ADD CONSTRAINT "user_memories_user_content_unique" UNIQUE ("user_id", "content");
+
+-- ----------------------------
+-- Primary Key structure for table user_memories
+-- ----------------------------
+ALTER TABLE "public"."user_memories" ADD CONSTRAINT "user_memories_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table user_model_configs
+-- ----------------------------
+CREATE INDEX "idx_user_model_configs_user_id" ON "public"."user_model_configs" USING btree (
+ "user_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+CREATE INDEX "idx_user_model_configs_user_id_model_id" ON "public"."user_model_configs" USING btree (
+ "user_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST,
+ "model_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table user_model_configs
+-- ----------------------------
+ALTER TABLE "public"."user_model_configs" ADD CONSTRAINT "user_model_configs_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table user_preferences
+-- ----------------------------
+CREATE INDEX "idx_user_preferences_user_id" ON "public"."user_preferences" USING btree (
+ "user_id" "pg_catalog"."uuid_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Uniques structure for table user_preferences
+-- ----------------------------
+ALTER TABLE "public"."user_preferences" ADD CONSTRAINT "user_preferences_user_unique" UNIQUE ("user_id");
+
+-- ----------------------------
+-- Primary Key structure for table user_preferences
+-- ----------------------------
+ALTER TABLE "public"."user_preferences" ADD CONSTRAINT "user_preferences_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table user_tool_configs
+-- ----------------------------
+CREATE INDEX "idx_user_tool_configs_user_id" ON "public"."user_tool_configs" USING btree (
+ "user_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table user_tool_configs
+-- ----------------------------
+ALTER TABLE "public"."user_tool_configs" ADD CONSTRAINT "user_tool_configs_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Indexes structure for table users
+-- ----------------------------
+CREATE INDEX "idx_users_role_template_id" ON "public"."users" USING btree (
+ "role_template_id" COLLATE "pg_catalog"."default" "pg_catalog"."text_ops" ASC NULLS LAST
+);
+
+-- ----------------------------
+-- Primary Key structure for table users
+-- ----------------------------
+ALTER TABLE "public"."users" ADD CONSTRAINT "users_pkey" PRIMARY KEY ("id");
+
+-- ----------------------------
+-- Foreign Keys structure for table tool_providers
+-- ----------------------------
+ALTER TABLE "public"."tool_providers" ADD CONSTRAINT "fk_tool_providers_tool_type" FOREIGN KEY ("tool_type_id") REFERENCES "public"."tool_types" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
+
+-- ----------------------------
+-- Foreign Keys structure for table user_tool_configs
+-- ----------------------------
+ALTER TABLE "public"."user_tool_configs" ADD CONSTRAINT "fk_user_tool_configs_tool_provider" FOREIGN KEY ("provider_id") REFERENCES "public"."tool_providers" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
+ALTER TABLE "public"."user_tool_configs" ADD CONSTRAINT "fk_user_tool_configs_tool_type" FOREIGN KEY ("tool_type_id") REFERENCES "public"."tool_types" ("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
+
+COMMIT;