// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package main import ( "context" "errors" "fmt" "net/http" "os" "os/signal" "path/filepath" "ragflow/internal/admin" "ragflow/internal/agent/audio" "ragflow/internal/agent/canvas" "ragflow/internal/agent/retrievalbridge" "ragflow/internal/agent/runtime" agenttool "ragflow/internal/agent/tool" "ragflow/internal/channels" modelModule "ragflow/internal/entity/models" "ragflow/internal/handler" "ragflow/internal/ingestion/knowledge_compile" ingestion "ragflow/internal/ingestion/service" "ragflow/internal/mcp" "ragflow/internal/rag/advanced_rag" "ragflow/internal/rag/advanced_rag/harness" "ragflow/internal/router" "ragflow/internal/server/local" "ragflow/internal/service" "ragflow/internal/service/chunk" dataset "ragflow/internal/service/dataset" "ragflow/internal/service/document" "ragflow/internal/service/file" "ragflow/internal/service/nav" "ragflow/internal/service/nlp" "ragflow/internal/service/wikisearch" "ragflow/internal/storage" "ragflow/internal/syncer" "ragflow/internal/tokenizer" "strconv" "strings" "syscall" "time" "github.com/gin-gonic/gin" "go.uber.org/zap" "ragflow/internal/agent/component" "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/deepdoc/parser/pdf/inference/native_analyzer" "ragflow/internal/engine" "ragflow/internal/engine/redis" et "ragflow/internal/engine/types" "ragflow/internal/entity" _ "ragflow/internal/ingestion/wire" "ragflow/internal/server" "ragflow/internal/utility" ) type serverArgs struct { mode *string // admin | api | ingestor | syncer helpFlag bool versionFlag bool debugLog bool migrateDB bool configPath *string // Used by admin, api; user defined config path initSuperUser bool // Used by admin; port *int // Used by admin, api adminHost *string // Used by api, ingestor, syncer for heartbeat adminPort *int // Used by api, ingestor, syncer for heartbeat, "ip:port" name *string // server name } // engineDocEngine is the small slice of the engine surface the doc-chunk pager // needs, kept as a local interface so it is trivially unit-testable without a // live engine (the production value is engine.Get()). type engineDocEngine interface { Search(ctx context.Context, req *et.SearchRequest) (*et.SearchResult, error) } // docChunkPager is the production harness.DocChunkLister. It pages one // document's chunks in reading order straight off the chunk index, mirroring // Python settings.retriever.chunk_list with sort_by_position=True // (rag/nlp/search.py:824). This is what the document-level tools // (summarize_document / fetch_full_document) consume. // // The index name is derived from the tenant id (ragflow_), exactly as // retrieval does, so it needs no user-scope resolution: DocChunksRequest // already carries the tenant and the bound datasets. type docChunkPager struct { docEngine engineDocEngine } // DocChunks implements harness.DocChunkLister. func (p *docChunkPager) DocChunks(ctx context.Context, req harness.DocChunksRequest) ([]map[string]any, error) { if p == nil || p.docEngine == nil { return nil, nil } if req.DocID == "" { return nil, nil } orderBy := (&et.OrderByExpr{}). Asc("chunk_order_int"). Asc("page_num_int"). Asc("top_int"). Desc("create_timestamp_flt") resp, err := p.docEngine.Search(ctx, &et.SearchRequest{ IndexNames: []string{tenantChunkIndexName(req.TenantID)}, KbIDs: req.DatasetIDs, Offset: req.Offset, Limit: req.Limit, OrderBy: orderBy, SelectFields: []string{ "id", "doc_id", "docnm", "content_with_weight", "content", "available_int", }, Filter: map[string]interface{}{ "doc_id": req.DocID, "available_int": 1, "must_not": map[string]interface{}{"exists": "compile_kwd"}, }, }) if err != nil { return nil, err } if resp == nil { return nil, nil } rows := make([]map[string]any, 0, len(resp.Chunks)) for _, ck := range resp.Chunks { row := make(map[string]any, 8) // The harness chunk rows need the canonical keys its citation pipeline // reads: chunk_id (dedup), docnm_kwd (doc title), doc_id and the text // under content/content_with_weight (chunkText prefers content first). if id, ok := ck["id"].(string); ok && id != "" { row["chunk_id"] = id } if d, ok := ck["doc_id"]; ok { row["doc_id"] = d } if d, ok := ck["docnm"]; ok { row["docnm_kwd"] = d } if c, ok := ck["content"]; ok { row["content"] = c } if c, ok := ck["content_with_weight"]; ok { row["content_with_weight"] = c } // Keep whatever else the engine returned (e.g. position_int, img) so the // merged Kbinfos rows stay rich, as retrieval does. for k, v := range ck { if _, seen := row[k]; !seen { row[k] = v } } rows = append(rows, row) } return rows, nil } func parseArgs() (*serverArgs, error) { args := &serverArgs{} var serverMode string var configPath string for i := 1; i < len(os.Args); i++ { arg := os.Args[i] switch arg { case "--admin": serverMode = "admin" args.mode = &serverMode case "--migrate": args.migrateDB = true case "--ingestor": serverMode = "ingestor" args.mode = &serverMode case "--api": serverMode = "api" args.mode = &serverMode case "--syncer": serverMode = "syncer" args.mode = &serverMode case "-h", "--help": args.helpFlag = true case "-v", "--version": args.versionFlag = true case "--debug": args.debugLog = true case "-f", "--config": if i+1 >= len(os.Args) { return nil, fmt.Errorf("%s requires a value", arg) } i++ configPath = os.Args[i] args.configPath = &configPath case "--init-superuser": args.initSuperUser = true case "-p", "--port": if i+1 >= len(os.Args) { return nil, errors.New("--port requires a value") } i++ port, convErr := strconv.Atoi(os.Args[i]) if convErr != nil { return nil, fmt.Errorf("invalid port: %w", convErr) } args.port = &port if port <= 0 || port > 65535 { return nil, fmt.Errorf("invalid port: %d", port) } case "--admin-host": if i+1 >= len(os.Args) { return nil, errors.New("--admin-host requires a value") } i++ parts := strings.SplitN(os.Args[i], ":", 2) if len(parts) != 2 || parts[0] == "" || parts[1] == "" { return nil, errors.New("--admin-host must be in the form 'ip:port'") } ip, portStr := parts[0], parts[1] port, convErr := strconv.Atoi(portStr) if convErr != nil { return nil, fmt.Errorf("failed to parse admin port: %w", convErr) } args.adminHost = &ip args.adminPort = &port case "--name": if i+1 >= len(os.Args) { return nil, errors.New("--name requires a value") } i++ args.name = &os.Args[i] default: return nil, fmt.Errorf("unknown parameter: %s", arg) } } return args, nil } // registerNativeDeepDoc wires the in-process (Go) DeepDoc backend as the local // fallback used when no external DeepDoc HTTP service is configured. It is // compiled into the server built with -tags cgo, which statically links the // ONNX Runtime backend (libonnxruntime.a); the unit-test tier builds without // cgo and stays free of the onnxruntime dependency. func printHelp(args *serverArgs) { switch { case args.mode == nil: fmt.Fprintf(os.Stderr, "Usage: %s --api|--admin|--ingestor|--syncer [OPTIONS]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "RAGFlow Server - Open-source RAG engine based on deep document understanding\n\n") fmt.Fprintf(os.Stderr, "Mode selection (default: --api):\n") fmt.Fprintf(os.Stderr, " --api \tRun as API server\n") fmt.Fprintf(os.Stderr, " --admin \tRun as admin server\n") fmt.Fprintf(os.Stderr, " --ingestor \tRun as ingestion worker\n") fmt.Fprintf(os.Stderr, " --syncer \tRun as file sync service\n\n") fmt.Fprintf(os.Stderr, "Common options:\n") fmt.Fprintf(os.Stderr, " --config string\tPath to configuration file\n") fmt.Fprintf(os.Stderr, " -v, --version \tPrint version information and exit\n") fmt.Fprintf(os.Stderr, " --debug \tEnable debug-level logging\n") fmt.Fprintf(os.Stderr, " -h, --help \tShow this help message and exit\n\n") fmt.Fprintf(os.Stderr, "Run '%s --api --help' for API server options.\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Run '%s --admin --help' for admin server options.\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Run '%s --ingestor --help' for ingester options.\n", os.Args[0]) fmt.Fprintf(os.Stderr, "Run '%s --syncer --help' for syncer options.\n", os.Args[0]) case *args.mode == "api": fmt.Fprintf(os.Stderr, "Usage: %s --api [OPTIONS]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "RAGFlow API Server\n\n") fmt.Fprintf(os.Stderr, "Options:\n") fmt.Fprintf(os.Stderr, " --port int \tServer port (overrides config file)\n") fmt.Fprintf(os.Stderr, " -f --config string\tPath to configuration file\n") fmt.Fprintf(os.Stderr, " -v, --version \tPrint version information and exit\n") fmt.Fprintf(os.Stderr, " --debug \tEnable debug-level logging\n") fmt.Fprintf(os.Stderr, " -h, --help \tShow this help message and exit\n") case *args.mode == "admin": fmt.Fprintf(os.Stderr, "Usage: %s --admin [OPTIONS]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "RAGFlow Admin Server\n\n") fmt.Fprintf(os.Stderr, "Options:\n") fmt.Fprintf(os.Stderr, " -f --config string\t\tPath to configuration file\n") fmt.Fprintf(os.Stderr, " --port int \t\t\tServer port (overrides config file)\n") fmt.Fprintf(os.Stderr, " --init-superuser\t\t\tInitialize superuser account\n") fmt.Fprintf(os.Stderr, " -v, --version \t\t\tPrint version information and exit\n") fmt.Fprintf(os.Stderr, " --debug \t\t\tEnable debug-level logging\n") fmt.Fprintf(os.Stderr, " -h, --help \t\t\tShow this help message and exit\n") case *args.mode == "ingestor": fmt.Fprintf(os.Stderr, "Usage: %s --ingestor [OPTIONS]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "RAGFlow Ingestion Worker - Document ingestion processing\n\n") fmt.Fprintf(os.Stderr, "Options:\n") fmt.Fprintf(os.Stderr, " -f --config string\tPath to config file\n") fmt.Fprintf(os.Stderr, " --name string\t\t\tIngestion server name (default: \"default_ingestion\")\n") fmt.Fprintf(os.Stderr, " --admin-host string\tAdmin server host:port (overrides config file)\n") fmt.Fprintf(os.Stderr, " -v, --version \t\tPrint version information and exit\n") fmt.Fprintf(os.Stderr, " --debug \t\tEnable debug-level logging\n") fmt.Fprintf(os.Stderr, " -h, --help \t\tShow this help message and exit\n") case *args.mode == "syncer": fmt.Fprintf(os.Stderr, "Usage: %s --syncer [OPTIONS]\n\n", os.Args[0]) fmt.Fprintf(os.Stderr, "RAGFlow Sync Service - Sync files from source to RAGFlow\n\n") fmt.Fprintf(os.Stderr, "Options:\n") fmt.Fprintf(os.Stderr, " -f --config string\tPath to config file\n") fmt.Fprintf(os.Stderr, " --name string\t\t\tSync service server name (default: \"default_syncer\")\n") fmt.Fprintf(os.Stderr, " --admin-host string\tAdmin server host:port (overrides config file)\n") fmt.Fprintf(os.Stderr, " -v, --version \t\tPrint version information and exit\n") fmt.Fprintf(os.Stderr, " --debug \t\tEnable debug-level logging\n") fmt.Fprintf(os.Stderr, " -h, --help \t\tShow this help message and exit\n") } } func main() { ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) defer cancel() arguments, err := parseArgs() if err != nil { fmt.Printf("Failed to parse arguments: %v\n", err) os.Exit(1) } if arguments.helpFlag || arguments.mode == nil { printHelp(arguments) os.Exit(1) } if arguments.versionFlag { fmt.Printf("RAGFlow version: %s\n", common.GetRAGFlowVersion()) os.Exit(1) } // Initialize local variables (runtime variables from Redis) err = server.InitLocalVariables() if err != nil { fmt.Printf("Failed to start %s server: %v\n", *arguments.mode, err) os.Exit(1) } // Temporary logger initialization var logFileName string var serverName string if arguments.name != nil { serverName = *arguments.name } else { serverName = fmt.Sprintf("%s_server", *arguments.mode) } logFileName = fmt.Sprintf("%s.log", serverName) logLevel := "info" if arguments.debugLog { logLevel = "debug" } if err = common.InitLogger(logLevel, common.FileOutput{Filename: logFileName, Path: "logs"}, serverName); err != nil { panic("failed to initialize logger: " + err.Error()) } // Initialize configuration var configPath string if arguments.configPath != nil { configPath = *arguments.configPath } if err = server.Init(configPath); err != nil { common.Error("Failed to initialize configuration", err) os.Exit(1) } globalConfig := server.GetConfig() // override default port if provided switch *arguments.mode { case "api": registerNativeDeepDoc() apiServerConfig := globalConfig.GetAPIServerConfig() port := apiServerConfig.HTTPPort if arguments.port != nil { port = *arguments.port apiServerConfig.HTTPPort = port } if arguments.name == nil { serverName = fmt.Sprintf("api_server_%d", port) } case "admin": adminServerConfig := globalConfig.GetAdminServerConfig() port := adminServerConfig.HTTPPort if arguments.port != nil { port = *arguments.port adminServerConfig.HTTPPort = port } if arguments.name == nil { serverName = fmt.Sprintf("admin_server_%d", port) } case "ingestor": registerNativeDeepDoc() if serverName == "" { uuid := utility.GenerateUUID() serverName = fmt.Sprintf("ingestor_server_%s", uuid) } case "syncer": if serverName == "" { uuid := utility.GenerateUUID() serverName = fmt.Sprintf("syncer_server_%s", uuid) } default: err = errors.New(*arguments.mode) common.Error("invalid server mode", err) os.Exit(1) } // set server name and log file path server.SetServerName(serverName) // rename log filename logFileName = fmt.Sprintf("%s.log", serverName) logConfig := globalConfig.GetLogConfig() // Reinitialize logger with configured level if different logLevel = logConfig.Level if logLevel == "" { logLevel = "info" } if arguments.debugLog { logLevel = "debug" } globalConfig.SetLogLevel(logLevel) fileOut := common.FileOutput{ Filename: logFileName, Path: logConfig.Path, MaxSize: logConfig.MaxSize, MaxBackups: logConfig.MaxBackups, MaxAge: logConfig.MaxAge, Compress: logConfig.Compress, } common.SyncLog() if err = common.InitLogger(logLevel, fileOut, serverName); err != nil { common.Error("Failed to reinitialize logger with configured level", err) } // Print all configuration settings common.Info(fmt.Sprintf("Starting %s server: %s, mode: %s", *arguments.mode, serverName, globalConfig.GetMode())) server.PrintAll() // Initialize database if err = dao.InitDB(ctx, arguments.migrateDB); err != nil { common.Fatal("Failed to initialize database", zap.Error(err)) } // Initialize doc engine if err = engine.InitDocEngine(ctx); err != nil { common.Fatal("Failed to initialize doc engine", zap.Error(err)) } defer engine.Close() // Initialize Redis cache if err = redis.Init(ctx); err != nil { common.Fatal("Failed to initialize Redis", zap.Error(err)) } defer redis.Close() if err = storage.Init(ctx); err != nil { common.Error("Failed to initialize storage factory", err) } defer storage.CloseStorage() if err = engine.InitMessageQueue(); err != nil { common.Fatal("Failed to initialize message queue engine", zap.Error(err)) } // Initialize server variables (runtime variables that can change during operation) // This must be done after Cache is initialized if err = server.InitVariables(redis.Get()); err != nil { common.Warn("Failed to initialize server variables from Redis, using defaults", zap.String("error", err.Error())) } if err = server.StartServer(ctx, cancel, serverName); err != nil { common.Error("Failed to start EE server", err) os.Exit(1) } defer server.ShutdownServer(ctx) if arguments.name == nil { arguments.name = &serverName } switch *arguments.mode { case "api": if err = runAPI(ctx, arguments); err != nil { fmt.Printf("Failed to start API server: %v\n", err) os.Exit(1) } case "admin": if err = runAdmin(ctx, arguments); err != nil { fmt.Printf("Failed to start ADMIN server: %v\n", err) os.Exit(1) } case "ingestor": if err = runIngestor(ctx, cancel, arguments); err != nil { fmt.Printf("Failed to start INGESTION worker: %v\n", err) os.Exit(1) } case "syncer": if err = runSyncer(ctx, cancel, arguments); err != nil { fmt.Printf("Failed to start SYNCER: %v\n", err) os.Exit(1) } default: fmt.Printf("Invalid server mode: %s\n", *arguments.mode) os.Exit(1) } } func runAdmin(ctx context.Context, args *serverArgs) error { globalConfig := server.GetConfig() serverMode := globalConfig.GetMode() // Set Gin mode if serverMode == "debug" { gin.SetMode(gin.DebugMode) } else { gin.SetMode(gin.ReleaseMode) } adminService := admin.NewService() adminHandler := admin.NewHandler(adminService) if err := admin.InitLicense(); err != nil { common.Warn("Failed to initialize license", zap.Error(err)) } if args.initSuperUser { // Initialize default admin user if err := adminService.InitDefaultAdmin(); err != nil { common.Error("Failed to initialize default admin user", err) } } // Initialize router r := admin.NewRouter(adminHandler) // Create Gin engine ginEngine := gin.New() // Mirror Quart's merge_slashes: collapse duplicate slashes before routing. ginEngine.RemoveExtraSlash = true // Middleware ginEngine.Use(common.GinLogger()) ginEngine.Use(gin.Recovery()) // Setup routes r.Setup(ginEngine) adminConfig := globalConfig.GetAdminServerConfig() addr := fmt.Sprintf(":%d", adminConfig.HTTPPort) srv := &http.Server{ Addr: addr, Handler: ginEngine, } // Print RAGFlow Admin logo common.Info("" + "\n ____ ___ ______________ ___ __ _ \n" + " / __ \\/ | / ____/ ____/ /___ _ __ / | ____/ /___ ___ (_)___ \n" + " / /_/ / /| |/ / __/ /_ / / __ \\ | /| / / / /| |/ __ / __ `__ \\/ / __ \\ \n" + " / _, _/ ___ / /_/ / __/ / / /_/ / |/ |/ / / ___ / /_/ / / / / / / / / / /\n" + " /_/ |_/_/ |_\\____/_/ /_/\\____/|__/|__/ /_/ |_\\__,_/_/ /_/ /_/_/_/ /_/ \n") // Print RAGFlow version common.Info(fmt.Sprintf("RAGFlow admin version: %s", common.GetRAGFlowVersion())) // Start HTTP server in a goroutine go func() { common.Info(fmt.Sprintf("Starting RAGFlow admin HTTP server on port: %d", adminConfig.HTTPPort)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { common.Fatal("Failed to start server", zap.Error(err)) } }() // Wait for shutdown signal from main's signal.NotifyContext <-ctx.Done() common.Info("Received shutdown signal") common.Info("Shutting down RAGFlow HTTP server...") // Create context with timeout for graceful shutdown quitCtx, quitCancel := context.WithTimeout(context.Background(), 30*time.Second) defer quitCancel() // Shutdown HTTP server if err := srv.Shutdown(quitCtx); err != nil { common.Fatal("Server forced to shutdown", zap.Error(err)) } common.Info("Admin HTTP server exited") return nil } // startHeartbeat initializes and starts the heartbeat reporter to the admin server. // It is shared by API, ingestion, and syncer server modes. // The caller must defer the returned *utility.ScheduledTask's Stop() method. func startHeartbeat(serverType common.ServerType, serverID string, port int, heartBeatInterval time.Duration) *utility.ScheduledTask { localIP, err := utility.GetLocalIP() if err != nil { common.Fatal("fail to get local ip address") } service.AdminServiceClient = service.NewAdminClient( serverType, serverID, localIP, port, ) if err = service.AdminServiceClient.InitHTTPClient(); err != nil { common.Warn("Failed to initialize heartbeat service", zap.Error(err)) return nil } heartbeatReporter := utility.NewScheduledTask("Heartbeat reporter", heartBeatInterval, func() { if err = service.AdminServiceClient.SendHeartbeat(); err == nil { local.SetAdminStatus(0, "") } else { local.SetAdminStatus(1, err.Error()) } }) heartbeatReporter.Start() return heartbeatReporter } func runIngestor(ctx context.Context, cancel context.CancelFunc, args *serverArgs) error { // Initialize tokenizer (rag_analyzer) // tokenizer.Init handles DictPath fallback: env var → /usr/share/infinity/resource if err := tokenizer.Init(&tokenizer.PoolConfig{}); err != nil { common.Fatal("Failed to initialize tokenizer", zap.Error(err)) } defer tokenizer.Close() // Fail fast if the cl100k_base BPE table is missing. NumTokensFromString / // TrimContentToTokenLimit now panic rather than degrading silently, so this // trades a mid-request panic for a clear startup failure. if err := tokenizer.InitCL100KEncoder(); err != nil { common.Fatal("Failed to initialize cl100k_base tokenizer", zap.Error(err)) } // The dataset-level post-processing consumer cluster (§11) is owned and run by // the Ingestor: it is started inside ingestor.Start() and joined inside // ingestor.Stop(), so its lifecycle matches the ingestor. The configured // default LLM/embedding ids are passed so the LLM deduper is used (instead // of the noop fallback that still emits merged products). Best-effort: a // provisioning error is logged by the Ingestor and the pipeline still // writes available_int=0 compiled chunks; they just won't be merged until // the consumer is available. globalConfig := server.GetConfig() ingestorCfg := globalConfig.GetIngestorConfig() const maxIngestorConcurrency = int32(1<<30 - 1) if ingestorCfg.MaxConcurrentWorkers > int(maxIngestorConcurrency) { return fmt.Errorf("ingestor max_concurrent_workers %d exceeds maximum %d", ingestorCfg.MaxConcurrentWorkers, maxIngestorConcurrency) } // Apply the configured compiler pool size (no-op when 0; the pool keeps its // vCPU default, overridable via KC_COMPILE_CONCURRENCY). knowledge_compile.SetCompilerConcurrency(ingestorCfg.CompilerPoolSize) ingestor := ingestion.NewIngestor(*args.name, int32(ingestorCfg.MaxConcurrentWorkers), []string{"pdf", "docx", "txt"}) ingestor.SetKnowledgeCompileModelConfig( globalConfig.GetDefaultChatModel().Name, globalConfig.GetDefaultEmbeddingModel().Name, ) // The dataset-level knowledge-compile consumer (tree/structure products) upserts // into the dataset-nav tree, so the Ingestor must install the same ES-backed // NavService the API server installs. Without this, nav.GetNavService() returns // nil and tree/structure products are dropped (the consumer logs "nav service // unavailable, skipping dataset-nav upsert"), leaving the dataset tree empty. // The embedder resolves the tenant's embedding model on demand, so both // Search and UpsertDoc can embed queries/summaries automatically. nav.SetNavService(nlp.NewNavService(service.NewNavEmbedder(service.NewModelProviderService(), ""))) // Memory extraction runs on the Ingestor's shared NATS consumer + worker // pool (task_type="memory" dispatched by handleAndExecute -> executeMemoryTask), // so there is no longer a dedicated Redis memory consumer to start. ingestor.SetMemoryMessageService(service.NewMemoryMessageService(service.NewMemoryService())) // Start returns immediately (it launches the owned consume/compile // goroutines and joins them via Stop); a provisioning failure here must // fail the server (main's os.Exit(1) path) instead of reporting a // healthy ingestor that can never consume. if err := ingestor.Start(); err != nil { common.Error("Failed to initialize ingestor", err) return err } common.Info("\n ____ __ _\n" + " / _/___ ____ ____ _____/ /_(_)___ ____ ________ ______ _____ _____\n" + " / // __ \\/ __ `/ _ \\/ ___/ __/ / __ \\/ __ \\ / ___/ _ \\/ ___/ | / / _ \\/ ___/\n" + " _/ // / / / /_/ / __(__ ) /_/ / /_/ / / / / (__ ) __/ / | |/ / __/ /\n" + "/___/_/ /_/\\__, /\\___/____/\\__/_/\\____/_/ /_/ /____/\\___/_/ |___/\\___/_/\n" + " /____/\n") // Print RAGFlow version common.Info(fmt.Sprintf("RAGFlow ingestion service version: %s", common.GetRAGFlowVersion())) // Start heartbeat reporter to admin server if hb := startHeartbeat( common.ServerTypeIngestion, fmt.Sprintf("ingestor-%s", ingestor.ID()), -1, globalConfig.GetHeartbeatInterval(), ); hb != nil { defer hb.Stop() } // Wait for either an OS shutdown signal or a shutdown command from the admin select { case <-ctx.Done(): common.Info("Received shutdown signal") common.Info(fmt.Sprintf("Shutting down RAGFlow ingestor %s ...", *args.name)) case <-ingestor.ShutdownCh: common.Info(fmt.Sprintf("Received shutdown command from admin, stopping ingestor %s ...", *args.name)) cancel() } // Create context with timeout for graceful shutdown shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() ingestor.Stop(shutdownCtx) common.Info(fmt.Sprintf("Ingestor %s shutdown complete", *args.name)) return nil } func runSyncer(ctx context.Context, cancel context.CancelFunc, args *serverArgs) error { globalConfig := server.GetConfig() syncerConfig := globalConfig.GetSyncerConfig() fileSyncer := syncer.NewSyncer(syncerConfig.MaxConcurrentSyncs) if err := fileSyncer.StartContext(ctx); err != nil { common.Error("Failed to initialize file syncer", err) return err } common.Info("\n _______ __ _____\n" + " / ____(_) /__ / ___/__ ______ ________ _____\n" + " / /_ / / / _ \\ \\__ \\/ / / / __ \\/ ___/ _ \\/ ___/\n" + " / __/ / / / __/ ___/ / /_/ / / / / /__/ __/ /\n" + " /_/ /_/_/\\___/ /____/\\__, /_/ /_/\\___/\\___/_/\n" + " /____/ \n") // Print RAGFlow version common.Info(fmt.Sprintf("RAGFlow file syncer service version: %s", common.GetRAGFlowVersion())) // Start heartbeat reporter to admin server if hb := startHeartbeat( common.ServerTypeFileSyncer, fmt.Sprintf("syncer-%s", fileSyncer.ID()), -1, globalConfig.GetHeartbeatInterval(), ); hb != nil { defer hb.Stop() } // Wait for either an OS shutdown signal or a shutdown command from the admin select { case <-ctx.Done(): common.Info("Received shutdown signal") common.Info(fmt.Sprintf("Shutting down RAGFlow file syncer %s ...", *args.name)) case <-fileSyncer.ShutdownCh: common.Info(fmt.Sprintf("Received shutdown command from admin, stopping file syncer %s ...", *args.name)) cancel() } fileSyncer.Stop() common.Info(fmt.Sprintf("File syncer %s shutdown complete", *args.name)) return nil } func runAPI(ctx context.Context, args *serverArgs) error { // Initialize admin status (default: unavailable=1) local.InitAdminStatus(1, "admin server not connected") // Initialize tokenizer (rag_analyzer) // tokenizer.Init fills DictPath from env var or default, so // tokenizerCfg.DictPath carries the resolved path for downstream use. tokenizerCfg := &tokenizer.PoolConfig{} if err := tokenizer.Init(tokenizerCfg); err != nil { common.Fatal("Failed to initialize tokenizer", zap.Error(err)) } defer tokenizer.Close() // Fail fast if the cl100k_base BPE table is missing. NumTokensFromString / // TrimContentToTokenLimit now panic rather than degrading silently, so this // trades a mid-request panic for a clear startup failure. if err := tokenizer.InitCL100KEncoder(); err != nil { common.Fatal("Failed to initialize cl100k_base tokenizer", zap.Error(err)) } // Initialize global QueryBuilder using tokenizer's DictPath // This ensures the Synonym uses the same wordnet directory as tokenizer if err := nlp.InitQueryBuilderFromTokenizer(tokenizerCfg.DictPath); err != nil { common.Fatal("Failed to initialize query builder", zap.Error(err)) } startServer(ctx) common.Info("Server exited") return nil } func startServer(ctx context.Context) { globalConfig := server.GetConfig() serverMode := globalConfig.GetMode() // Set Gin mode if serverMode == "debug" { gin.SetMode(gin.DebugMode) } else { gin.SetMode(gin.ReleaseMode) } // Initialize service layer userService := service.NewUserService() documentService := document.NewDocumentService() datasetsService := dataset.NewDatasetService() metadataService := service.NewMetadataService() chunkService := chunk.NewChunkService() llmService := service.NewLLMService() tenantService := service.NewTenantService() chatService := service.NewChatService() chatChannelService := service.NewChatChannelService() langfuseService := service.NewLangfuseService() chatSessionService := service.NewChatSessionService() openaiChatService := service.NewOpenAIChatService() systemService := service.NewSystemService() statsService := service.NewStatsService() connectorService := service.NewConnectorService() searchService := service.NewSearchService() searchService.SetTenantService(tenantService) fileService := file.NewFileService(service.CheckFileTeamPermission, documentService) memoryService := service.NewMemoryService() mcpService := service.NewMCPService() modelProviderService := service.NewModelProviderService() // Wire the real MemorySaver so the Message component can persist // conversation turns to memory stores declared in the canvas DSL. component.SetMemorySaver(service.NewMemorySaverAdapter(memoryService)) // Initialize doc engine for skill search docEngine := engine.Get() documentDAO := dao.NewDocumentDAO() retrievalEnhancer := retrievalbridge.NewEnhancer(docEngine, metadataService) agenttool.SetRetrievalService(agenttool.NewNLPRetrievalAdapterFromDeps( docEngine, documentDAO, modelProviderService, retrievalEnhancer, )) agenttool.SetMemoryRetrievalService(retrievalbridge.NewMemoryAdapter(memoryService)) common.Info("agent: retrieval service adapter installed") // Wire the agentic-RAG harness as the Go chat pipeline's evidence engine // (internal/service/chat_pipeline.retrieveViaHarness): it searches through the // runtime retrieval singleton below and runs each request on a model resolved // from the caller's ModelID. Activating the agentic loop here enables the full // planner/SCA path for reasoning chats. runtime.SetRetrievalService(retrievalbridge.NewRuntimeAdapter()) advanced_rag.SetAgenticLoop(advanced_rag.NewAgenticLoop()) service.SetHarnessRetriever(func(ctx context.Context, req service.HarnessRequest) (service.HarnessResult, error) { // Resolve the tenant's actual chat model, mirroring Python where RAGTools // receives a fully-resolved LLMBundle: chat.LLMID may be a UUID/tenant_model // id that the default invoker cannot split into a provider, so resolving here // avoids falling through to a dummy driver. If resolution fails we leave model // nil so the harness degrades to a direct search (no dummy fallback). var model harness.SessionModel // outerModel mirrors Python RAGTools.chat_mdl: the fully-resolved chat // model that drives the outer rag_agent react loop (tools=[rag, // summarize_document], terminal_tools={"rag"}). Wired into RAGTools.Outer // so Rag() can run that loop instead of the direct graph. var outerModel *modelModule.ChatModel // resolvedModelName is the resolved chat model's own name (e.g. // "gpt-4o"), i.e. Python chat_mdl.llm_name. It is the gen_json reply-cache // key's model component; empty disables that cache. resolvedModelName := "" if req.ModelID != "" { if driver, modelName, apiCfg, contentLen, mErr := modelProviderService.ResolveModelConfig(ctx, req.TenantID, entity.ModelTypeChat, req.ModelID); mErr == nil { if inv := component.NewResolvedInvoker(driver, modelName, apiCfg); inv != nil { // MaxLength mirrors Python LLMBundle.max_length (the model's // context window in tokens); message-fitting nodes (calculate, // structure_qa) use it as their chat.FitMessages budget. model = &harness.InvokerSessionModel{Invoker: inv, DB: dao.DB, MaxLength: contentLen} resolvedModelName = modelName // Reuse the same resolved driver/name/api as the invoker so // the outer loop and the inner tool calls share one model. outerModel = modelModule.NewChatModel(driver, &modelName, apiCfg) } } else { common.Warn("harness: failed to resolve chat model for reasoning; harness will degrade to direct search", zap.Error(mErr)) } } // OuterSupportsTools mirrors Python `if not chat_mdl.is_tools`: gate the // outer react loop on the model's tool-calling capability, not mere // existence of an outer model. A model that can't emit tool_calls must fall // back to the direct graph (Python async_chat) rather than bind tools and // return a retrieval-less direct answer. outerSupportsTools := false if req.ModelID != "" { if ts, tsErr := modelProviderService.ResolveModelToolSupport(ctx, req.TenantID, entity.ModelTypeChat, req.ModelID); tsErr == nil { outerSupportsTools = ts } } // Load the KB objects (mirroring Python RAGTools' self.kbs via // KnowledgebaseService.get_by_ids(kb_ids)) so the agentic tool can // derive rank features from parser_config.tag_kb_ids. Best-effort: a // load failure leaves KBs empty and the adapter resolves them itself. var kbs []*entity.Knowledgebase if len(req.DatasetIDs) > 0 { if loaded, lErr := dao.NewKnowledgebaseDAO().GetByIDs(ctx, dao.DB, req.DatasetIDs); lErr == nil { kbs = loaded } } // validate_dataset_embedding_models runs FIRST upstream // (dialog_service.py:358-360) and mixing is a hard failure there, not a // fallback to keyword-only retrieval. if err := validateDatasetEmbeddingModels(ctx, kbs); err != nil { return service.HarnessResult{}, err } // HasEmbedder mirrors Python `embd_mdl = ... if kbs and kbs[0].embd_id else // None` (dialog_service.py:362): the gate is the FIRST dataset's embd_id. It // is what hybrid_search applies to its vector leg (search.py:143), so // getting it wrong silently degrades search_chunks to keyword-only. hasEmbedder := hasEmbedderFor(kbs) deps := advanced_rag.RAGTools{ Model: model, ModelName: resolvedModelName, Outer: outerModel, // OriginalQuestion mirrors Python RAGTools(original_user_question=...): // the user's own, unrewritten question as received from the chat // layer. The outer model's `rag(question=...)` argument is // model-generated and often compresses a multi-hop question to its // first hop; resolveEffectiveQuestion (agentic_rag.py:865) prefers // this original over that rewrite when both describe the same turn. OriginalQuestion: req.Question, // OuterSupportsTools gates the outer react loop on tool capability // (Python is_tools); false → fall back to direct RunAgenticRAG. OuterSupportsTools: outerSupportsTools, // DocScope mirrors Python RAGTools(doc_scope=...): the narrowed doc_ids // (chat-level doc_ids + meta_data_filter) restrict every agentic // retrieval to the user-selected documents instead of the whole kb. DocScope: req.DocIDs, DocIDVerifier: advanced_rag.NewDocIDLookup(), // DocChunks pages one document's chunks in reading order off the // chunk index (Python retriever.chunk_list), backing the // document-level tools (summarize_document / fetch_full_document). DocChunks: &docChunkPager{docEngine: docEngine}, // Expand runs search_chunks' compiled-structure expansion // (Python hybrid_search use_compiled=True → _expand_with_compiled). // Backed by the same document engine the chunk reads use, with the // dense seed leg wired to the bound dataset's own embedding model. // The scope config lets each dataset be scanned under its own tenant // and a doc scope be grouped by real owner, like graph_explore. Expand: harness.NewCompiledExpander( newDatasetCompiledStore(docEngine, modelProviderService, kbs), harness.CompiledScopeConfig{ DatasetIDs: req.DatasetIDs, TenantID: req.TenantID, KBs: kbs, DocTenantResolver: advanced_rag.NewDocTenantResolver(), }, ), // WebSearch backs the web_search tool (Python RAGTools.web_search). // The pipeline supplies a callback only when the chat enables // internet web search; a nil one also keeps the tool off the surface. WebSearch: harnessWebSearcher(req.WebSearch), KBs: kbs, HasEmbedder: hasEmbedder, // Embedder backs claim recall's KNN leg (Python // recall_dataset_claims, navigation.py:1837-1909, which embeds the // query with tools.embed_mdl — the FIRST dataset's embedding model, // dialog_service.py:362-366) and the structure-drill seed vector. // Without it the claim leg silently degrades to BM25-only and // paraphrase-phrased claims are never recalled. Bound to // kbs[0].EmbdID (validateDatasetEmbeddingModels above guarantees // every bound dataset shares it), query-side encoded. Embedder: embedderForDatasets(kbs, modelProviderService), // Tagger is the Go equivalent of Python's label_question // (agentic_rag.py:668): classifies the query into question-type // tags the retriever boosts on. metadataService implements it // (service.MetadataService.LabelQuestion). Tagger: metadataService, // SystemPrompt mirrors Python RAGTools(system_prompt= // _render_reasoning_system_prompt(dialog, prompt_config, kwargs), // dialog_service.py:2084) — the dialog-level UI configuration the // final-answer compose appends after the agentic contract // (agentic_rag_graph.py:923-933). SystemPrompt: req.SystemPrompt, // CiteRules and EvidenceMaxTokens deliberately stay unset: Python's // dialog path constructs RAGTools WITHOUT user_defined_prompts // (dialog_service.py:2072-2090), so citation_prompt defaults apply, // and Python has no evidence-token override (the compose always // uses min(chat_mdl.max_length, _EVIDENCE_BUDGET_TOKENS=8000), // which EvidenceMaxTokens<=0 reproduces). } // Diagnose WHY compiled expansion is disabled: NewCompiledExpander // returns nil for three reasons (store==nil / no datasets / no tenant) // and RAGTools.Expand==nil silences the whole channel with no other // trace — the observed "Compiled expansion enabled = 0" was invisible. if deps.Expand == nil { switch { case docEngine == nil: common.Warn("compiled expansion disabled: document engine unavailable (store==nil)") case len(req.DatasetIDs) == 0: common.Warn("compiled expansion disabled: no bound dataset (DatasetIDs empty)") case strings.TrimSpace(req.TenantID) == "": common.Warn("compiled expansion disabled: no tenant (TenantID empty)") default: common.Warn("compiled expansion disabled: unknown reason") } } if req.AnswerSink != nil { deps.AnswerSink = &advanced_rag.AnswerSink{ OnDelta: req.AnswerSink, } // Engine-stage progress (planner/orchestrator/research/SCA) is // research-time think content — mirroring Python think_log, which // forwarded the tagged lines into the block. Deliver each // line as an isThink=true delta so the live reasoning block shows // the research as it happens. deps.Progress = func(line string) { req.AnswerSink(line, true) } } r := advanced_rag.Rag(ctx, deps, harness.RunRequest{ Question: req.Question, ThinkingMode: req.ThinkingMode, DatasetIDs: req.DatasetIDs, TenantID: req.TenantID, SessionID: req.SessionID, Images: req.Images, TextAttachments: req.TextAttachments, }) res := service.HarnessResult{Chunks: r.Chunks, DocAggs: r.DocAggs, Answer: r.Answer} if r.Kbinfos != nil { res.Memory = r.Kbinfos.Memory res.PreSummary = r.Kbinfos.PreSummary } return res, nil }) common.Info("agent: harness chat retriever wired (runtime retrieval + agentic loop)") // Initialize handler layer authHandler := handler.NewAuthHandler() userHandler := handler.NewUserHandler(userService) tenantHandler := handler.NewTenantHandler(tenantService, userService, datasetsService) documentHandler := handler.NewDocumentHandler(documentService, datasetsService, fileService) datasetsHandler := handler.NewDatasetsHandler(datasetsService, metadataService) systemHandler := handler.NewSystemHandler(systemService) statsHandler := handler.NewStatsHandler(statsService) chunkHandler := handler.NewChunkHandler(chunkService, userService) llmHandler := handler.NewLLMHandler(llmService, userService) chatHandler := handler.NewChatHandler(chatService, userService) chatChannelHandler := handler.NewChatChannelHandler(chatChannelService) langfuseHandler := handler.NewLangfuseHandler(langfuseService) chatSessionHandler := handler.NewChatSessionHandler(chatSessionService, userService) openaiChatHandler := handler.NewOpenAIChatHandler(openaiChatService) connectorHandler := handler.NewConnectorHandler(connectorService, userService) searchHandler := handler.NewSearchHandler(searchService, userService) fileHandler := handler.NewFileHandler(fileService, userService) memoryHandler := handler.NewMemoryHandler(memoryService) mcpHandler := handler.NewMCPHandler(mcpService) // MCP server endpoint — exposes RAGFlow capabilities as MCP tools // (ragflow_retrieval, ragflow_list_datasets, ragflow_list_chats) to // external AI clients via JSON-RPC over HTTP. mcpServerHandler := handler.NewMCPServerHandler( func(ctx context.Context, userID string, page, pageSize int, orderBy string, desc bool) ([]map[string]interface{}, int64, error) { return handler.MCPListDatasets(ctx, datasetsService, userID, page, pageSize, orderBy, desc) }, func(ctx context.Context, userID string, page, pageSize int, orderBy string, desc bool) ([]map[string]interface{}, int64, error) { return handler.MCPListChats(ctx, chatService, userID, page, pageSize, orderBy, desc) }, func(ctx context.Context, userID string, req mcp.RetrievalRequest) (string, error) { return handler.MCPRetrieval(ctx, datasetsService, userID, req) }, ) skillSearchHandler := handler.NewSkillSearchHandler(docEngine, documentService) providerHandler := handler.NewProviderHandler(userService, modelProviderService) // Install the agent service's Redis-backed run infrastructure // (CheckPointStore / StateSerializer / RunTracker). When Redis // is unreachable (degraded boot, stand-alone mode, no-redis CI) // the constructors return errors, and we fall through to the // in-memory / no-tracking path: the agent service treats nil // options as the in-memory test path, so graceful degradation // is a 1-line if-not-nil pass-through — no separate "boot" mode // required. agentOpts := buildAgentRunOptions() agentService := service.NewAgentServiceWithOptions( agentOpts.checkpointStore, agentOpts.stateSerializer, agentOpts.runTracker, ) // WithDocumentService wires the rerun dependency used by // POST /api/v1/agents/rerun (dataflow "re-run" in the pipeline // result viewer). RerunAgent fails closed without it, so this must // stay attached to NewAgentHandler. agentHandler := handler.NewAgentHandler(ctx, agentService, fileService). WithDocumentService(documentService) // Public chatbot/agentbot endpoints (api/v1/chatbots/..., // api/v1/agentbots/...) and the agent attachment download. // BotService delegates the agentBot completion to agentService so // both paths share the same canvas runner. Reuse the llmService // already constructed above (line 222) — do NOT redeclare with // `:=` since the variable is in scope. botService := service.NewBotService(agentService, llmService) botHandler := handler.NewBotHandler(botService) // Wire the TTS synthesizer to the per-tenant model-provider // dispatch. SynthesizeRequest is routed through // ModelProviderService.AudioSpeech, which fans out to the // tenant's configured TTS model driver. When the model // provider is unconfigured, the synthesizer falls back to a // no-op echo (the audio package contract), so this is always // safe to call. configureTTSSynthesizer(modelProviderService) searchBotHandler := handler.NewSearchBotHandler( searchService, tenantService, modelProviderService, chunkService, ) searchBotHandler.SetStreamLLM(modelProviderService) askService := service.NewAskService(chunkService, nil, 0, 0) searchBotHandler.SetAskService(askService) chatHandler.SetMindMapDependencies(searchService, tenantService, modelProviderService, chunkService) searchHandler.SetCompletionDependencies(modelProviderService, askService) pluginHandler := handler.NewPluginHandler(service.NewPluginService()) modelHandler := handler.NewModelHandler(service.NewModelProviderService()) fileCommitHandler := handler.NewFileCommitHandler(file.NewFileCommitService()) // Dify retrieval handler retrievalService := nlp.NewRetrievalService(docEngine, documentDAO) difyRetrievalHandler := handler.NewDifyRetrievalHandler( datasetsService, modelProviderService, metadataService, retrievalService, documentDAO, docEngine, ) componentsSvc := service.NewComponentsService() componentsHandler := handler.NewComponentsHandler(componentsSvc) pipelineHandler := handler.NewPipelineHandler() compilationTemplateHandler := handler.NewCompilationTemplateHandler(service.NewCompilationTemplateService()) compilationTemplateGroupHandler := handler.NewCompilationTemplateGroupHandler(service.NewCompilationTemplateGroupService()) datasetArtifactHandler := handler.NewDatasetArtifactHandler(service.NewDatasetArtifactService(), datasetsService, file.NewFileCommitService()) // Install the production eino-based chat invoker as the shared chat default, // so agentic-search harness LLM calls work in production. Without this, // chat.GetDefaultInvoker() stays nil and the harness falls back gracefully. component.InstallDefaultChatInvoker() // Install the dataset-nav ES-backed service (internal/service/nav + // internal/service/nlp). The embedder resolves the tenant's embedding model // on demand so Search/UpsertDoc can embed queries/summaries automatically. nav.SetNavService(nlp.NewNavService(service.NewNavEmbedder(modelProviderService, ""))) // Install the compiled-wiki search service. It is backed directly by the // document engine: QueryPages filters the tenant-scoped index to // compile_kwd="wiki_page" (+ supported kinds) so ordinary source chunks are // never relabeled as wiki pages, and BackfillChunks fetches original chunks // by id. When the engine is unavailable the service degrades to empty so the // agent falls back to hybrid search (no failing call). wikisearch.SetService(wikisearch.NewEngineService(engine.Get())) // Initialize router r := router.NewRouter(authHandler, userHandler, tenantHandler, documentHandler, datasetsHandler, systemHandler, statsHandler, chunkHandler, llmHandler, chatHandler, chatChannelHandler, langfuseHandler, chatSessionHandler, connectorHandler, searchHandler, fileHandler, memoryHandler, mcpHandler, mcpServerHandler, skillSearchHandler, providerHandler, agentHandler, searchBotHandler, difyRetrievalHandler, pluginHandler, modelHandler, fileCommitHandler, openaiChatHandler, botHandler, componentsHandler, pipelineHandler, compilationTemplateHandler, compilationTemplateGroupHandler, datasetArtifactHandler) // Create Gin engine ginEngine := gin.New() // Mirror Quart's merge_slashes: collapse duplicate slashes before routing. ginEngine.RemoveExtraSlash = true // Middleware // Note: common.GinLogger() is registered inside router.Setup so the // HTTP request log captures every endpoint the router owns (including // those registered by Setup itself). Registering it here would run // it twice for those endpoints and double every access-log line. ginEngine.Use(gin.Recovery()) // Setup routes r.Setup(ginEngine) _, err := channels.Start(ctx) if err != nil { common.Fatal("Fail to start chat-channel", zap.Error(err)) } apiServerConfig := globalConfig.GetAPIServerConfig() // Create HTTP server with timeouts to prevent slow clients from blocking shutdown addr := fmt.Sprintf(":%d", apiServerConfig.HTTPPort) srv := &http.Server{ Addr: addr, Handler: ginEngine, ReadHeaderTimeout: 10 * time.Second, ReadTimeout: 60 * time.Second, WriteTimeout: 120 * time.Second, IdleTimeout: 120 * time.Second, } // Start server in a goroutine go func() { common.Info( "\n ____ ___ ______ ______ __\n" + " / __ \\ / | / ____// ____// /____ _ __\n" + " / /_/ // /| | / / __ / /_ / // __ \\| | /| / /\n" + " / _, _// ___ |/ /_/ // __/ / // /_/ /| |/ |/ /\n" + " /_/ |_|/_/ |_|\\____//_/ /_/ \\____/ |__/|__/\n", ) common.Info(fmt.Sprintf("RAGFlow Go Version: %s", common.GetRAGFlowVersion())) common.Info(fmt.Sprintf("Server starting on port: %d", apiServerConfig.HTTPPort)) if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { common.Fatal("Failed to start server", zap.Error(err)) } }() // Start heartbeat reporter to admin server if hb := startHeartbeat( common.ServerTypeAPI, fmt.Sprintf("ragflow-server-%d", apiServerConfig.HTTPPort), apiServerConfig.HTTPPort, globalConfig.GetHeartbeatInterval(), ); hb != nil { defer hb.Stop() } // Wait for shutdown signal from main's signal.NotifyContext <-ctx.Done() common.Info(fmt.Sprintf("Received shutdown signal")) common.Info("Shutting down server...") // Create context with timeout for graceful shutdown shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) defer shutdownCancel() // Shutdown server if err := srv.Shutdown(shutdownCtx); err != nil { common.Fatal("Server forced to shutdown", zap.Error(err)) } } // agentRunOptions bundles the three optional injection slots the // agent service accepts via NewAgentServiceWithOptions: the Redis- // backed CheckPointStore, StateSerializer, and RunTracker. The // fields stay nil when the underlying constructors fail (Redis // unreachable, etc.); the agent service treats nil as "in-memory // / no-tracking" so the server continues to serve traffic without // requiring Redis to be up. type agentRunOptions struct { checkpointStore canvas.CheckPointStore stateSerializer canvas.StateSerializer runTracker *canvas.RunTracker } // buildAgentRunOptions installs the Redis-backed run infrastructure // when Redis is available. The Redis client is the one already // initialized at the top of main; the TTL is a conservative 24h for // both the checkpoint store and the run tracker. On any error // (Redis down at boot, constructor panic, nil-Redis fallback) we // log and return a zero-value struct — the agent service falls back // to the in-memory path transparently. func buildAgentRunOptions() agentRunOptions { var out agentRunOptions if !redis.IsEnabled() || redis.Get() == nil { common.Info("agent: redis client not initialised; agent run infra in in-memory mode (no checkpoints, no run tracker)") return out } cp := canvas.NewRedisCheckPointStore(24 * time.Hour) out.checkpointStore = cp // stateSerializer is intentionally left nil. eino's default // InternalSerializer (used when no compose.WithSerializer is // passed at compile time) already knows how to round-trip // runtime.CanvasState because the runtime package registers // it via compose.RegisterSerializableType[CanvasState] in // init(). Overriding with RAGFlow's plain-JSON // CanvasStateSerializer (json.Marshal/Unmarshal) produces // bytes the InternalSerializer cannot decode on the resume // pass — the UserFillUp two-node pattern surfaces this as // "load checkpoint from store fail: cannot unmarshal object // into Go struct field checkpoint.Channels of type // compose.channel". Rely on eino's default instead. rt := canvas.NewRunTracker(24 * time.Hour) out.runTracker = rt common.Info("agent: redis-backed run infra installed (24h TTL on checkpoint store + run tracker; eino default serializer)") return out } // configureTTSSynthesizer installs the audio.ModelProviderFunc // that dispatches Synthesize requests through the project's // ModelProviderService. The model provider's AudioSpeech method // (internal/service/model_service.go) resolves the per-tenant TTS // model driver, sends the request upstream, and returns // synthesized audio bytes. // // The audio package's NewTTSDispatchFunc helper converts the // audio.SynthesizeRequest shape into the model's dispatch shape // (audioContent = req.Text, voice/lang → TTSConfig.Params, // ModelName from req.Engine). When the model provider is // unconfigured (nil dispatcher) the helper returns nil, which // reverts the audio package to its default stub. func configureTTSSynthesizer(modelProviderService *service.ModelProviderService) { if modelProviderService == nil { common.Info("agent: model provider service not initialised; TTS in no-op echo mode") audio.SetModelProviderSynthesizer(nil) return } audio.SetModelProviderSynthesizer(audio.NewTTSDispatchFunc(modelProviderService)) common.Info("agent: TTS model-provider dispatch installed (audio.Synthesize → ModelProviderService.AudioSpeech)") } // registerNativeDeepDoc wires the in-process (Go) DeepDoc backend as the local // inference backend. The server is built with -tags cgo and links ONNX Runtime // statically (libonnxruntime.a, resolved at runtime via dlopen(NULL) from the // running binary — see github.com/infiniflow/onnxruntime_go, the org mirror of // yalue/onnxruntime_go), so there is no external // DeepDoc HTTP service and no dynamic .so deployment. // // Fail-fast contract (P0): the in-process backend must be available at startup // (ORT + models present). There is NO silent degradation to an empty analyzer: // if the backend is not serving, the server aborts. func registerNativeDeepDoc() { modelDir := resolveDeepDocModelDir() dropScore := resolveDeepDocDropScore() if err := infnative.Register(modelDir, dropScore); err != nil { common.Warn("in-process DeepDoc backend unavailable", zap.String("reason", err.Error())) } // The in-process (Go) DeepDoc backend is the ONLY production backend. Fail // fast rather than silently parsing without layout/table/OCR if the local // backend cannot serve (ORT + models must be present when built with -tags // cgo). if !infnative.Serving() { common.Fatal("no in-process DeepDoc backend serving: provide the local ORT "+ "runtime + models and build with -tags cgo", zap.String("model_dir", modelDir), zap.String("ort_lib", "static (libonnxruntime.a via dlopen(NULL))")) } common.Info("in-process DeepDoc backend registered (production backend)", zap.String("model_dir", modelDir)) } // resolveDeepDocModelDir picks the model directory: the explicit DEEPDOC_MODEL_DIR // env, else the RAGFlow default (rag/res/deepdoc, mirroring deepdoc_server.py), // else the snapshot fetched by ragflow_deps/download_deps.py. The first // candidate that actually contains the required weights wins. func resolveDeepDocModelDir() string { if v := strings.TrimSpace(common.GetEnv(common.EnvDeepDocModelDir)); v != "" { return v } wd, _ := os.Getwd() candidates := []string{ filepath.Join(wd, "rag", "res", "deepdoc"), filepath.Join(wd, "huggingface.co", "InfiniFlow", "deepdoc"), } for _, c := range candidates { if dirHasModels(c) { return c } } // None verified; return the canonical default so any error message points // at the conventional location. return filepath.Join(wd, "rag", "res", "deepdoc") } // resolveDeepDocDropScore returns the explicit DEEPDOC_DROP_SCORE env, else the // in-process backend's default (infnative.DefaultDropScore, which mirrors // the Python inference service's Recognizer.drop_score). func resolveDeepDocDropScore() float64 { if v := strings.TrimSpace(common.GetEnv(common.EnvDeepDocDropScore)); v != "" { if f, err := strconv.ParseFloat(v, 64); err == nil { return f } common.Warn("invalid DEEPDOC_DROP_SCORE, using default", zap.String("value", v), zap.Float64("default", infnative.DefaultDropScore)) } return infnative.DefaultDropScore } // dirHasModels reports whether dir contains every required model file. func dirHasModels(dir string) bool { return common.HasModelFiles(dir) } // hasEmbedderFor mirrors Python `embd_mdl = LLMBundle(...) if kbs and // kbs[0].embd_id else None` (dialog_service.py:362). The gate is the FIRST bound // dataset's embd_id, not "any dataset has one": a dialog whose first dataset // carries no embedding model yields embd_mdl=None upstream even if later ones do. // // Callers must run validateDatasetEmbeddingModels first — with validation // passing, all datasets share one model and this equals "all have one". func hasEmbedderFor(kbs []*entity.Knowledgebase) bool { return len(kbs) > 0 && kbs[0] != nil && kbs[0].EmbdID != "" } // embedderForDatasets builds the embedding handle the agentic harness uses for // query-side encoding outside the main retrieval leg: claim recall's KNN leg // (Python recall_dataset_claims, navigation.py:1837-1909, embedding with // tools.embed_mdl) and the structure-drill seed vector. The model is the FIRST // dataset's embedding (Python dialog_service.py:362-366 resolves // kbs[0].embd_id under kbs[0].tenant_id; validateDatasetEmbeddingModels // guarantees the rest share it). Nil when no dataset carries an embedding // model — the claim leg then degrades to BM25-only, matching a Python run // without embed_mdl. func embedderForDatasets(kbs []*entity.Knowledgebase, modelSvc *service.ModelProviderService) nlp.NavEmbedder { if len(kbs) == 0 || kbs[0] == nil || kbs[0].EmbdID == "" { return nil } return service.NewNavEmbedder(modelSvc, kbs[0].EmbdID) } // validateDatasetEmbeddingModels mirrors Python validate_dataset_embedding_models // (knowledgebase_service.py:62-94): every bound dataset must use the same embedding // model, or none at all. Upstream this runs before embd_mdl is resolved // (dialog_service.py:358-360) and a violation RAISES — it is not a "no embedding // model" fallback: treating mixing as HasEmbedder=false would silently serve // keyword-only results where Python errors. func validateDatasetEmbeddingModels(ctx context.Context, kbs []*entity.Knowledgebase) error { var withEmbd int for _, kb := range kbs { if kb != nil && kb.EmbdID != "" { withEmbd++ } } hasEmbd := withEmbd > 0 if hasEmbd && withEmbd != len(kbs) { return errors.New("cannot search across datasets where some have embedding models and others do not") } if !hasEmbd { return nil } // Mirror Python's grouping key exactly: // // ref = tenant_embd_id or (embd_id when it is a raw tenant_model id) // composite = resolved_names.get(ref) # "model@instance@provider" // key = _base_model_name(composite) # == tenant_model.model_name // // The @instance@provider suffix is stripped by _base_model_name // (knowledgebase_service.py:33), so only the MODEL NAME is compared — the // instance and provider tables are not consulted at all. refs := make([]string, 0, len(kbs)) for _, kb := range kbs { if kb == nil || kb.EmbdID == "" { continue } ref := "" if kb.TenantEmbdID != nil { ref = strings.TrimSpace(*kb.TenantEmbdID) } if ref == "" && !strings.Contains(kb.EmbdID, "@") { ref = strings.TrimSpace(kb.EmbdID) } if ref != "" { refs = append(refs, ref) } } // Python resolves the refs with one batched lookup // (tenant_model_service.py:562 get_by_ids) and silently skips ids that no // longer resolve — a dangling id must not fail the request. resolved := make(map[string]string, len(refs)) // A nil/absent DB or any query error must not fail the request: Python skips // unresolvable ids (tenant_model_service.py:557) and falls back to the raw // id, so validation degrades to a stricter but safe comparison. if len(refs) > 0 && dao.DB != nil { if models, err := dao.NewTenantModelDAO().GetByIDs(ctx, dao.DB, refs); err == nil { for _, m := range models { if m != nil { resolved[m.ID] = m.ModelName } } } } keys := make(map[string]struct{}, len(kbs)) for _, kb := range kbs { if kb == nil || kb.EmbdID == "" { continue } embdID := strings.TrimSpace(kb.EmbdID) ref := "" if kb.TenantEmbdID != nil { ref = strings.TrimSpace(*kb.TenantEmbdID) } if ref == "" && !strings.Contains(embdID, "@") { ref = embdID } key := "" if name, ok := resolved[ref]; ok && name != "" { key = name } else if embdID != "" && embdID != ref { key = baseModelName(embdID) } else { key = ref } if key != "" { keys[key] = struct{}{} } } if len(keys) > 1 { return fmt.Errorf("datasets use different embedding models") } return nil } // baseModelName mirrors Python _base_model_name // (knowledgebase_service.py:33): strip the @instance@provider suffix, keeping the // model name. Python uses rsplit("@", 2)[0], which for the purposes of taking // the leading segment is the same as splitting once on "@". func baseModelName(embdID string) string { name, _, _ := strings.Cut(embdID, "@") return name } // --------------------------------------------------------------------------- // Harness seams (compiled expansion + open-web search) // --------------------------------------------------------------------------- // engineCompiledStore implements harness.CompiledStore over the document // engine, backing search_chunks' compiled-structure expansion (Python // tools/compiled_expansion.py). The harness owns the expansion strategy; this // adapter only maps its (scope, filters, free text) request onto one engine // search, mirroring Python's settings.docStoreConn.search calls. type engineCompiledStore struct { engine engineDocEngine // embed encodes the seed query for the dense leg. It must be the QUERY // encoding of the dataset's own embedding model (Python // _search_compiled_rows → _dense_expr → settings.retriever.get_vector → // emb_mdl.encode_queries). Nil leaves the keyword leg only. embed compiledQueryEncoder // embedTenantID is the tenant owning the embedder's model (Python // embd_owner_tenant_id = kbs[0].tenant_id). It is distinct from the searched // scope's tenant, because Python builds ONE embedder from the leading bound // dataset and reuses it for every scope. embedTenantID string } var _ harness.CompiledStore = (*engineCompiledStore)(nil) // compiledQueryEncoder is the query-side embedding seam used for the compiled // dense seed leg. *service.NavEmbedder implements it via EncodeQueries. type compiledQueryEncoder interface { EncodeQueries(ctx context.Context, tenantID string, texts []string) ([][]float32, error) } // Mirror Python compiled_expansion._VECTOR_NUM_CANDIDATES / _VECTOR_SIMILARITY. // The HNSW ef_search floor (num_candidates) must be >= topN or the ANN search is // bounded by the candidate list instead of by relevance. const ( compiledVectorNumCandidates = 256 compiledVectorSimilarity = 0.1 ) // newEngineCompiledStore returns nil when no engine is available so // harness.NewCompiledExpander disables expansion instead of searching nowhere. // A nil embed (or empty embedTenantID) keeps the keyword-only leg. func newEngineCompiledStore(e engineDocEngine, embed compiledQueryEncoder, embedTenantID string) harness.CompiledStore { if e == nil { return nil } return &engineCompiledStore{engine: e, embed: embed, embedTenantID: embedTenantID} } // newDatasetCompiledStore builds the per-request compiled-row store, wiring the // dense seed leg to the FIRST bound dataset's own embedding model. Python builds // embd_mdl from kbs[0] (dialog_service.py:362-365) and reuses it for the whole // expansion; using the tenant-default model instead would compare the query // against rows embedded by a different model — different vector spaces, so the // dense hits would be noise. func newDatasetCompiledStore(e engineDocEngine, modelSvc *service.ModelProviderService, kbs []*entity.Knowledgebase) harness.CompiledStore { if e == nil { return nil } if hasEmbedderFor(kbs) { return newEngineCompiledStore(e, service.NewNavEmbedder(modelSvc, kbs[0].EmbdID), kbs[0].TenantID) } return newEngineCompiledStore(e, nil, "") } const tenantChunkIndexPrefix = "ragflow_" func tenantChunkIndexName(tenantID string) string { return tenantChunkIndexPrefix + tenantID } // compiledSynthesisKinds are the standalone synthesis-page compile_kwd values. // Python scopes those rows by source_doc_ids, while entity/relation rows are // scoped by doc_id (compiled_expansion.py _search_compiled_rows / // _search_synthesis_pages). var compiledSynthesisKinds = map[string]bool{ "wiki_page": true, "artifact_page": true, "essence": true, } // compiledRowSelectFields is the projection the expander reads off a compiled // row: content_with_weight (seed name parsing), the entity/relation endpoints, // exact name lookups, and provenance back to source chunks. var compiledRowSelectFields = []string{ "id", "kb_id", "doc_id", "docnm_kwd", "content_with_weight", "summary_with_weight", "source_chunk_ids", "name_kwd", "from_entity_kwd", "to_entity_kwd", "available_int", } // compiledChunkSelectFields is the projection for source chunks promoted into // evidence by compiled expansion. var compiledChunkSelectFields = []string{ "id", "kb_id", "doc_id", "docnm_kwd", "content_with_weight", "source_chunk_ids", "similarity", } // SearchCompiled implements harness.CompiledStore. func (s *engineCompiledStore) SearchCompiled(ctx context.Context, kbID, tenantID string, docIDs []string, filters map[string][]string, matchText string, topN int) ([]map[string]any, error) { if s == nil || s.engine == nil || kbID == "" || strings.TrimSpace(tenantID) == "" { return nil, nil } if topN <= 0 { topN = 1 } filter := compiledEngineFilter(filters) if len(docIDs) > 0 { filter[compiledDocScopeKey(filters)] = docIDs } req := &et.SearchRequest{ IndexNames: []string{tenantChunkIndexName(tenantID)}, KbIDs: []string{kbID}, Limit: topN, SelectFields: compiledRowSelectFields, Filter: filter, } // Python prefers a dense seed leg and only falls back to a keyword match // when no embedder is available or the vector build fails // (compiled_expansion.py _search_compiled_rows:180-194 / // _search_synthesis_pages:423-438). Exactly ONE expr is appended. if text := strings.TrimSpace(matchText); text != "" { if dense := s.denseExpr(ctx, text, topN); dense != nil { req.MatchExprs = []interface{}{dense} } else { req.MatchExprs = []interface{}{&et.MatchTextExpr{ Fields: []string{"content_ltks", "content_sm_ltks"}, MatchingText: text, TopN: topN, }} } } res, err := s.engine.Search(ctx, req) if err != nil || res == nil { return nil, err } return res.Chunks, nil } // denseExpr builds the compiled-row dense match for the seed text, mirroring // Python _dense_expr (compiled_expansion.py:31-47): the query is embedded with // the dataset model's QUERY encoding, and the match carries the HNSW // num_candidates floor and similarity threshold. Returns nil when no embedder is // wired, the encode fails, or it panics — every caller then uses the keyword leg, // exactly like Python's `except Exception` around get_vector. func (s *engineCompiledStore) denseExpr(ctx context.Context, text string, topN int) *et.MatchDenseExpr { if s == nil || s.embed == nil || strings.TrimSpace(s.embedTenantID) == "" { return nil } var ( vecs [][]float32 err error ) func() { defer func() { if r := recover(); r != nil { common.Warn("compiled expansion: seed encode panicked; falling back to keyword match") } }() vecs, err = s.embed.EncodeQueries(ctx, s.embedTenantID, []string{text}) }() if err != nil || len(vecs) == 0 || len(vecs[0]) == 0 { return nil } vec := make([]float64, len(vecs[0])) for i, v := range vecs[0] { vec[i] = float64(v) } return &et.MatchDenseExpr{ VectorColumnName: fmt.Sprintf("q_%d_vec", len(vec)), EmbeddingData: vec, EmbeddingDataType: "float", DistanceType: "cosine", TopN: topN, ExtraOptions: map[string]interface{}{ "similarity": compiledVectorSimilarity, "num_candidates": max(topN, compiledVectorNumCandidates), }, } } // LoadChunks implements harness.CompiledStore: fetch the referenced source // chunks by id (Python compiled_expansion.py:_load_chunks_for_doc searches the // tenant index with an {"id": chunk_ids} condition). func (s *engineCompiledStore) LoadChunks(ctx context.Context, kbID, tenantID string, chunkIDs []string) ([]map[string]any, error) { if s == nil || s.engine == nil || kbID == "" || strings.TrimSpace(tenantID) == "" || len(chunkIDs) == 0 { return nil, nil } res, err := s.engine.Search(ctx, &et.SearchRequest{ IndexNames: []string{tenantChunkIndexName(tenantID)}, KbIDs: []string{kbID}, Limit: len(chunkIDs), SelectFields: compiledChunkSelectFields, Filter: map[string]interface{}{"id": chunkIDs}, }) if err != nil || res == nil { return nil, err } return res.Chunks, nil } // Vectorize implements harness.CompiledStore. The ported expander assigns // compiled chunks the similarity stored on the row and blends by that field, so // expansion itself never needs a query embedding. func (s *engineCompiledStore) Vectorize(context.Context, string) ([]float64, error) { return nil, fmt.Errorf("compiled store: vectorize is not wired") } // compiledDocScopeKey mirrors Python's doc-scope column choice: synthesis pages // are scoped by source_doc_ids, entity/relation rows by doc_id. func compiledDocScopeKey(filters map[string][]string) string { for _, ck := range filters["compile_kwd"] { if compiledSynthesisKinds[strings.ToLower(strings.TrimSpace(ck))] { return "source_doc_ids" } } return "doc_id" } // compiledEngineFilter maps the harness' OR-list filters onto the engine filter // shape. available_int is a numeric column, so its string values are coerced to // ints (Python passes the literal 1). func compiledEngineFilter(filters map[string][]string) map[string]interface{} { out := make(map[string]interface{}, len(filters)) for k, vals := range filters { if k == "available_int" { ints := make([]int, 0, len(vals)) for _, v := range vals { if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil { ints = append(ints, n) } } if len(ints) > 0 { out[k] = ints } continue } out[k] = vals } return out } // harnessWebSearcherFunc adapts the chat pipeline's web-search callback to the // harness.WebSearcher seam consumed by the web_search tool. The callback is a // plain func so internal/service does not have to depend on the harness package. type harnessWebSearcherFunc func(ctx context.Context, queries []string) ([]string, error) // Search implements harness.WebSearcher. func (f harnessWebSearcherFunc) Search(ctx context.Context, queries []string) ([]string, error) { return f(ctx, queries) } var _ harness.WebSearcher = harnessWebSearcherFunc(nil) // harnessWebSearcher wraps a non-nil callback, returning a nil WebSearcher when // the pipeline did not supply one — which is what hides the web_search tool. func harnessWebSearcher(fn func(context.Context, []string) ([]string, error)) harness.WebSearcher { if fn == nil { return nil } return harnessWebSearcherFunc(fn) }