fix(captain): 试验场不查 FAQ 知识库 + 添加常见问题报 400

三个问题修复:

1. 添加常见问题报 400 (assistant_id 类型不匹配)
   - CreateResponseDialog.vue: route.params.assistantId 是字符串,
     传给后端 uint 字段导致 JSON 反序列化失败
   - 修复: Number(route.params.assistantId) 转为数字
   - 同时修复 POST /assistant_responses 无尾部斜杠 307 重定向问题

2. 试验场 playground 不做 RAG 检索
   - generatePlaygroundLLMResponse 只构建 system prompt + 对话历史,
     从不查 FAQ 知识库
   - 新增 retrieveFAQContext(): embed 用户问题 → pgvector 搜索 approved
     FAQ → 注入 system prompt
   - 受 feature_faq 配置开关控制

3. FAQ embedding 无法写入 (pgvector 序列化 + 维度问题)
   - pgvector stub 无 driver.Valuer, GORM Save() 报 SQLSTATE 42804
   - 新增 UpdateEmbedding() 用 ?::vector 原始 SQL 绕过
   - SimilaritySearch 排除 embedding 列 + 手动格式化向量字面量
   - embedding 列从 vector(1536) 改为 vector (跟随模型维度)
   - FAQ 创建/更新时自动索引 embedding (SetRAGService 注入)
This commit is contained in:
Rogee
2026-08-01 19:37:19 +08:00
parent 31238476b1
commit 63df5b8b91
9 changed files with 124 additions and 12 deletions
+1
View File
@@ -606,6 +606,7 @@ func Bootstrap(env string) (*App, error) {
// RAG service — knowledge base Q&A (embedding search + LLM generation)
ragService := service.NewRAGService(captainAssistantResponseRepo, captainAssistantRepo, llmProvider)
captainAssistantResponseService.SetRAGService(ragService)
// Auto-reply rule service — CRUD + condition matching + LLM reply composition
autoReplyRuleService := service.NewAutoReplyRuleService(captainAutoReplyRuleRepo, captainAssistantRepo, conversationRepo, llmProvider)
+2 -2
View File
@@ -218,8 +218,8 @@ type CaptainAssistantResponse struct {
Answer string `gorm:"type:text;not null" json:"answer"`
Status ResponseStatus `gorm:"size:50;default:approved;not null" json:"status"`
Edited bool `gorm:"default:false;not null" json:"edited"`
// pgvector-go Vector type for 1536-dimensional embeddings
Embedding pgvector.Vector `gorm:"type:vector(1536)" json:"embedding,omitempty"`
// pgvector-go Vector type for embeddings (dimension follows the configured embedding model)
Embedding pgvector.Vector `gorm:"type:vector" json:"embedding,omitempty"`
Assistant CaptainAssistant `gorm:"foreignKey:AssistantID" json:"assistant,omitempty"`
}
@@ -2,6 +2,8 @@ package repository
import (
"context"
"strconv"
"strings"
"github.com/gochat/gochat/internal/model"
"github.com/pgvector/pgvector-go"
@@ -45,6 +47,21 @@ func (r *CaptainAssistantResponseRepo) Update(ctx context.Context, resp *model.C
return r.db.WithContext(ctx).Save(resp).Error
}
// UpdateEmbedding updates only the embedding column using raw SQL with explicit
// ::vector cast to avoid GORM/pgvector serialization issue (SQLSTATE 42804).
func (r *CaptainAssistantResponseRepo) UpdateEmbedding(ctx context.Context, id uint, embedding pgvector.Vector) error {
// Format as PG vector literal: [0.1,0.2,...]
strs := make([]string, len(embedding))
for i, v := range embedding {
strs[i] = strconv.FormatFloat(float64(v), 'f', -1, 32)
}
vecStr := "[" + strings.Join(strs, ",") + "]"
return r.db.WithContext(ctx).Exec(
"UPDATE captain_assistant_responses SET embedding = ?::vector WHERE id = ?",
vecStr, id,
).Error
}
func (r *CaptainAssistantResponseRepo) Delete(ctx context.Context, id uint) error {
return r.db.WithContext(ctx).Delete(&model.CaptainAssistantResponse{}, id).Error
}
@@ -80,10 +97,18 @@ func (r *CaptainAssistantResponseRepo) ListByDocument(ctx context.Context, docum
// Reference: Chatwoot Captain::AssistantResponsesSearchService
func (r *CaptainAssistantResponseRepo) SimilaritySearch(ctx context.Context, assistantID uint, embedding pgvector.Vector, limit int) ([]model.CaptainAssistantResponse, error) {
var responses []model.CaptainAssistantResponse
// Format embedding as PG vector literal for the <=> operator (stub has no driver.Valuer).
strs := make([]string, len(embedding))
for i, v := range embedding {
strs[i] = strconv.FormatFloat(float64(v), 'f', -1, 32)
}
vecStr := "[" + strings.Join(strs, ",") + "]"
// Cosine distance (<=>) orders by closest first.
// Omit embedding column from SELECT — the pgvector stub can't scan it back.
if err := r.db.WithContext(ctx).
Select("id, account_id, assistant_id, documentable_id, documentable_type, question, answer, status, edited, created_at, updated_at").
Where("assistant_id = ? AND status = ?", assistantID, model.ResponseStatusApproved).
Order(gorm.Expr("embedding <=> ?", embedding)).
Order(gorm.Expr("embedding <=> ?::vector", vecStr)).
Limit(limit).
Find(&responses).Error; err != nil {
return nil, err
+3
View File
@@ -1471,7 +1471,10 @@ func registerV1Routes(g *gin.RouterGroup, h *Handlers) {
assistantResponses := captain.Group("/assistant_responses")
{
// Chatwoot: resources :assistant_responses (standard CRUD)
// Register both "" and "/" so Gin does not 307-redirect between them.
assistantResponses.POST("", h.CaptainAssistantResponse.Create)
assistantResponses.POST("/", h.CaptainAssistantResponse.Create)
assistantResponses.GET("", h.CaptainAssistantResponse.List)
assistantResponses.GET("/", h.CaptainAssistantResponse.List)
assistantResponses.GET("/:response_id", h.CaptainAssistantResponse.Get)
assistantResponses.PUT("/:response_id", h.CaptainAssistantResponse.Update)
@@ -22,6 +22,7 @@ type CaptainAssistantResponseService struct {
messageRepo *repository.MessageRepo
preferenceRepo *repository.CaptainPreferenceRepo
llmProvider llm.Provider
ragService *RAGService
}
func NewCaptainAssistantResponseService(
@@ -42,6 +43,11 @@ func NewCaptainAssistantResponseService(
}
}
// SetRAGService injects the RAG service for auto-indexing FAQ embeddings.
func (s *CaptainAssistantResponseService) SetRAGService(rag *RAGService) {
s.ragService = rag
}
// --- Request/Response DTOs ---
// ProcessResponseRequest is the input for generating and storing an assistant response.
@@ -269,6 +275,12 @@ func (s *CaptainAssistantResponseService) Create(ctx context.Context, accountID
if err := s.responseRepo.Create(ctx, resp); err != nil {
return nil, fmt.Errorf("create response: %w", err)
}
// Auto-index embedding for RAG search
if s.ragService != nil {
if err := s.ragService.IndexResponse(ctx, resp.ID); err != nil {
applogger.L().Warnf("Create assistant response: auto-index embedding failed: %v", err)
}
}
if created, err := s.responseRepo.GetByAccountAndID(ctx, accountID, resp.ID); err == nil {
return created, nil
}
@@ -301,6 +313,12 @@ func (s *CaptainAssistantResponseService) Update(ctx context.Context, accountID
if err := s.responseRepo.Update(ctx, resp); err != nil {
return nil, fmt.Errorf("update response: %w", err)
}
// Re-index embedding when question or answer changed
if (questionChanged || answerChanged) && s.ragService != nil {
if err := s.ragService.IndexResponse(ctx, resp.ID); err != nil {
applogger.L().Warnf("Update assistant response: re-index embedding failed: %v", err)
}
}
return resp, nil
}
@@ -14,6 +14,7 @@ import (
"github.com/gochat/gochat/internal/model"
"github.com/gochat/gochat/internal/repository"
applogger "github.com/gochat/gochat/pkg/logger"
"github.com/pgvector/pgvector-go"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@@ -723,7 +724,17 @@ func (s *CaptainAssistantService) generatePlaygroundLLMResponse(ctx context.Cont
cfg, _ := assistant.GetConfig()
ctx = withAssistantGenerationConfig(ctx, cfg)
messages := []llm.ChatMessage{{Role: "system", Content: s.promptBuilder.BuildAssistantPrompt(assistant, cfg)}}
// RAG: embed the latest user message and search approved FAQ responses.
// This mirrors Chatwoot's Captain playground which injects knowledge base context.
systemPrompt := s.promptBuilder.BuildAssistantPrompt(assistant, cfg)
ragContext := s.retrieveFAQContext(ctx, assistant.ID, cfg, history)
if ragContext != "" {
systemPrompt += "\n\n" + ragContext
systemPrompt += "\n\nUse the above FAQ entries as reference when answering. If the FAQ entries are relevant, incorporate their information. If not, rely on your general knowledge."
}
messages := []llm.ChatMessage{{Role: "system", Content: systemPrompt}}
for _, message := range history {
if message.Role == "" || message.Content == "" {
continue
@@ -751,6 +762,61 @@ func (s *CaptainAssistantService) generatePlaygroundLLMResponse(ctx context.Cont
return resp.Choices[0].Message.Content, nil
}
// retrieveFAQContext generates an embedding for the latest user message,
// searches approved FAQ responses via pgvector, and returns formatted context.
// Returns empty string if RAG is disabled, no embedding available, or no results.
func (s *CaptainAssistantService) retrieveFAQContext(ctx context.Context, assistantID uint, cfg *model.AssistantConfig, history []PlaygroundMessage) string {
if cfg != nil && !cfg.FeatureFAQ {
return ""
}
// Extract the latest user message
userMsg := ""
for i := len(history) - 1; i >= 0; i-- {
if history[i].Role == "user" {
userMsg = history[i].Content
break
}
}
if userMsg == "" {
return ""
}
// Generate embedding for the question
embedResp, err := s.llmProvider.CreateEmbedding(ctx, llm.EmbeddingRequest{
Input: []string{userMsg},
})
if err != nil {
applogger.L().Warnf("retrieveFAQContext: embedding generation failed: %v", err)
return ""
}
if len(embedResp.Data) == 0 {
return ""
}
float32Emb := make([]float32, len(embedResp.Data[0].Embedding))
for i, v := range embedResp.Data[0].Embedding {
float32Emb[i] = float32(v)
}
pgvectorEmb := pgvector.NewVector(float32Emb)
// Search approved FAQ responses by embedding similarity
results, err := s.responseRepo.SearchByEmbedding(ctx, assistantID, pgvectorEmb, 5)
if err != nil {
applogger.L().Warnf("retrieveFAQContext: FAQ search failed: %v", err)
return ""
}
if len(results) == 0 {
return ""
}
var contextParts []string
for i, r := range results {
contextParts = append(contextParts, fmt.Sprintf("[FAQ %d]\nQ: %s\nA: %s", i+1, r.Question, r.Answer))
}
return "Knowledge Base Context:\n" + strings.Join(contextParts, "\n\n")
}
func withAssistantGenerationConfig(ctx context.Context, cfg *model.AssistantConfig) context.Context {
if cfg == nil || !cfg.TemperatureConfigured {
return ctx
+2 -3
View File
@@ -54,7 +54,7 @@ type AssistantRepoIface interface {
type ResponseRepoIface interface {
SearchByEmbedding(ctx context.Context, assistantID uint, embedding pgvector.Vector, limit int) ([]model.CaptainAssistantResponse, error)
GetByID(ctx context.Context, id uint) (*model.CaptainAssistantResponse, error)
Update(ctx context.Context, resp *model.CaptainAssistantResponse) error
UpdateEmbedding(ctx context.Context, id uint, embedding pgvector.Vector) error
}
// RAGService orchestrates embedding search + LLM generation for knowledge base Q&A.
@@ -265,8 +265,7 @@ func (s *RAGService) IndexResponse(ctx context.Context, responseID uint) error {
for i, v := range embedResp.Data[0].Embedding {
float32Emb[i] = float32(v)
}
resp.Embedding = pgvector.NewVector(float32Emb)
if err := s.responseRepo.Update(ctx, resp); err != nil {
if err := s.responseRepo.UpdateEmbedding(ctx, resp.ID, pgvector.NewVector(float32Emb)); err != nil {
applogger.L().Errorf("RAG update response embedding: %v", err)
return fmt.Errorf("store embedding failed: %w", err)
}
+3 -3
View File
@@ -31,7 +31,7 @@ type mockResponseRepo struct {
searchByEmbeddingError error
getByIDResult *model.CaptainAssistantResponse
getByIDError error
updateError error
updateEmbeddingError error
}
func (m *mockResponseRepo) SearchByEmbedding(ctx context.Context, assistantID uint, embedding pgvector.Vector, limit int) ([]model.CaptainAssistantResponse, error) {
@@ -42,8 +42,8 @@ func (m *mockResponseRepo) GetByID(ctx context.Context, id uint) (*model.Captain
return m.getByIDResult, m.getByIDError
}
func (m *mockResponseRepo) Update(ctx context.Context, resp *model.CaptainAssistantResponse) error {
return m.updateError
func (m *mockResponseRepo) UpdateEmbedding(ctx context.Context, id uint, embedding pgvector.Vector) error {
return m.updateEmbeddingError
}
// ========== RAG Service Tests ==========
@@ -43,12 +43,12 @@ const handleSubmit = async updatedResponse => {
if (props.type === 'edit') {
await updateResponse({
...updatedResponse,
assistant_id: route.params.assistantId,
assistant_id: Number(route.params.assistantId),
});
} else {
await createResponse({
...updatedResponse,
assistant_id: route.params.assistantId,
assistant_id: Number(route.params.assistantId),
});
}
useAlert(t(`${i18nKey.value}.SUCCESS_MESSAGE`));