Files
ragflow/cmd/ragflow_server.go
2026-10-10 17:01:43 +08:00

1738 lines
68 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//
// 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"
"net/http"
_ "net/http/pprof"
"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"
smartagentic "ragflow/internal/agentic_rag"
"ragflow/internal/channels"
"ragflow/internal/deepdoc/native"
"ragflow/internal/deepdoc/parser/pdf"
modelModule "ragflow/internal/entity/models"
"ragflow/internal/handler"
"ragflow/internal/ingestion/knowledge_compile"
ingestion "ragflow/internal/ingestion/service"
agentic_rag "ragflow/internal/rag/agentic-rag"
"ragflow/internal/router"
"ragflow/internal/server/local"
"ragflow/internal/service"
"ragflow/internal/service/chunk"
"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"
goruntime "runtime"
"strconv"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
"ragflow/internal/agent/component"
"ragflow/internal/common"
"ragflow/internal/dao"
infnative "ragflow/internal/deepdoc/parser/pdf/inference/native_analyzer"
"ragflow/internal/engine"
"ragflow/internal/engine/kvrocks"
_ "ragflow/internal/ingestion/wire"
"ragflow/internal/server"
"ragflow/internal/server/config"
"ragflow/internal/utility"
)
type serverArgs struct {
mode *string // admin | api | ingestor | syncer | deepdoc | migrate
helpFlag bool
versionFlag bool
logLevel *string
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, deepdoc for heartbeat
adminPort *int // Used by api, ingestor, syncer, deepdoc for heartbeat, "ip:port"
name *string // server name
enablePProf bool // enable pprof
// deepdocInferenceConcurrency, when set, overrides the DeepDoc inference
// concurrency from env/config. nil means "unspecified".
deepdocInferenceConcurrency *int
// ingestorMaxConcurrentWorkers, when set, overrides the ingestor worker
// count (NATS consumer count K) from env/config. nil means "unspecified".
ingestorMaxConcurrentWorkers *int
// ingestorPageConcurrency, when set, overrides the per-document page
// concurrency (N) from env/config. nil means "unspecified".
ingestorPageConcurrency *int
// deepdocInferenceCPUCores, when set, overrides the DeepDoc inference CPU-core
// budget from env/config. nil means "unspecified" (0 means "all cores").
deepdocInferenceCPUCores *int
}
func parseArgs() (*serverArgs, error) {
args := &serverArgs{}
// Mode flags share one variable, so the last one passed wins.
var serverMode string
var configPath string
for i := 1; i < len(os.Args); i++ {
arg := os.Args[i]
if key, value, ok := strings.Cut(arg, "="); ok {
switch key {
case "--log-level":
if err := validateLogLevel(value); err != nil {
return nil, err
}
args.logLevel = &value
continue
case "--deepdoc-inference-concurrency":
n, convErr := strconv.Atoi(value)
if convErr != nil {
return nil, fmt.Errorf("invalid --deepdoc-inference-concurrency: %w", convErr)
}
if n <= 0 {
return nil, fmt.Errorf("invalid --deepdoc-inference-concurrency %d: must be positive", n)
}
args.deepdocInferenceConcurrency = &n
continue
case "--ingestor-max-concurrent-workers":
n, convErr := strconv.Atoi(value)
if convErr != nil {
return nil, fmt.Errorf("invalid --ingestor-max-concurrent-workers: %w", convErr)
}
if n <= 0 {
return nil, fmt.Errorf("invalid --ingestor-max-concurrent-workers %d: must be positive", n)
}
args.ingestorMaxConcurrentWorkers = &n
continue
case "--ingestor-page-concurrency":
n, convErr := strconv.Atoi(value)
if convErr != nil {
return nil, fmt.Errorf("invalid --ingestor-page-concurrency: %w", convErr)
}
if n <= 0 {
return nil, fmt.Errorf("invalid --ingestor-page-concurrency %d: must be positive", n)
}
args.ingestorPageConcurrency = &n
continue
case "--deepdoc-inference-cpu-cores":
n, convErr := strconv.Atoi(value)
if convErr != nil {
return nil, fmt.Errorf("invalid --deepdoc-inference-cpu-cores: %w", convErr)
}
if n < 0 {
return nil, fmt.Errorf("invalid --deepdoc-inference-cpu-cores %d: must be >= 0 (0 means all cores)", n)
}
args.deepdocInferenceCPUCores = &n
continue
}
}
switch arg {
case "--admin":
serverMode = "admin"
args.mode = &serverMode
case "--ingestor":
serverMode = "ingestor"
args.mode = &serverMode
case "--api":
serverMode = "api"
args.mode = &serverMode
case "--syncer":
serverMode = "syncer"
args.mode = &serverMode
case "--deepdoc":
serverMode = "deepdoc"
args.mode = &serverMode
case "--migrate":
serverMode = "migrate"
args.mode = &serverMode
case "-h", "--help":
args.helpFlag = true
case "-v", "--version":
args.versionFlag = true
case "--log-level":
if i+1 >= len(os.Args) {
return nil, errors.New("--log-level requires a value")
}
i++
level := os.Args[i]
if err := validateLogLevel(level); err != nil {
return nil, err
}
args.logLevel = &level
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]
case "--profile":
args.enablePProf = true
case "--deepdoc-inference-concurrency":
if i+1 >= len(os.Args) {
return nil, errors.New("--deepdoc-inference-concurrency requires a value")
}
i++
n, convErr := strconv.Atoi(os.Args[i])
if convErr != nil {
return nil, fmt.Errorf("invalid --deepdoc-inference-concurrency: %w", convErr)
}
if n <= 0 {
return nil, fmt.Errorf("invalid --deepdoc-inference-concurrency %d: must be positive", n)
}
args.deepdocInferenceConcurrency = &n
case "--ingestor-max-concurrent-workers":
if i+1 >= len(os.Args) {
return nil, errors.New("--ingestor-max-concurrent-workers requires a value")
}
i++
n, convErr := strconv.Atoi(os.Args[i])
if convErr != nil {
return nil, fmt.Errorf("invalid --ingestor-max-concurrent-workers: %w", convErr)
}
if n <= 0 {
return nil, fmt.Errorf("invalid --ingestor-max-concurrent-workers %d: must be positive", n)
}
args.ingestorMaxConcurrentWorkers = &n
case "--ingestor-page-concurrency":
if i+1 >= len(os.Args) {
return nil, errors.New("--ingestor-page-concurrency requires a value")
}
i++
n, convErr := strconv.Atoi(os.Args[i])
if convErr != nil {
return nil, fmt.Errorf("invalid --ingestor-page-concurrency: %w", convErr)
}
if n <= 0 {
return nil, fmt.Errorf("invalid --ingestor-page-concurrency %d: must be positive", n)
}
args.ingestorPageConcurrency = &n
case "--deepdoc-inference-cpu-cores":
if i+1 >= len(os.Args) {
return nil, errors.New("--deepdoc-inference-cpu-cores requires a value")
}
i++
n, convErr := strconv.Atoi(os.Args[i])
if convErr != nil {
return nil, fmt.Errorf("invalid --deepdoc-inference-cpu-cores: %w", convErr)
}
if n < 0 {
return nil, fmt.Errorf("invalid --deepdoc-inference-cpu-cores %d: must be >= 0 (0 means all cores)", n)
}
args.deepdocInferenceCPUCores = &n
default:
return nil, fmt.Errorf("unknown parameter: %s", arg)
}
}
return args, nil
}
func validateLogLevel(level string) error {
switch level {
case "debug", "info", "warn", "error":
return nil
default:
return fmt.Errorf("invalid --log-level %q: must be debug, info, warn, or error", level)
}
}
func selectedLogLevel(args *serverArgs, configured string) string {
if args.logLevel != nil {
return *args.logLevel
}
if configured == "" {
return "info"
}
return configured
}
// resolveIngestorMaxConcurrentWorkers applies the precedence
// CLI flag > environment variable > config file > default(1) and returns the
// resolved ingestor worker count (K), validating it against the inclusive
// range [MinIngestorWorkers, MaxIngestorWorkers]. An out-of-range or
// noninteger value at any layer is a fatal startup error.
func resolveIngestorMaxConcurrentWorkers(args *serverArgs, configured int) (int, error) {
val := config.MinIngestorWorkers
if configured > 0 {
val = configured
}
if v := strings.TrimSpace(os.Getenv(common.EnvIngestorMaxConcurrentWorkers)); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("invalid %s %q: %w", common.EnvIngestorMaxConcurrentWorkers, v, err)
}
val = n
}
if args.ingestorMaxConcurrentWorkers != nil {
val = *args.ingestorMaxConcurrentWorkers
}
if val < config.MinIngestorWorkers || val > config.MaxIngestorWorkers {
return 0, fmt.Errorf("ingestor max_concurrent_workers %d out of range [%d, %d]",
val, config.MinIngestorWorkers, config.MaxIngestorWorkers)
}
return val, nil
}
// resolveIngestorPageConcurrency applies the precedence
// CLI flag > environment variable > config file > default(2) and returns the
// resolved per-document page concurrency (N), validating it against the
// inclusive range [MinPageConcurrency, MaxPageConcurrency]. An out-of-range or
// noninteger value at any layer is a fatal startup error.
func resolveIngestorPageConcurrency(args *serverArgs, configured int) (int, error) {
val := 2 // default page concurrency
if configured > 0 {
val = configured
}
if v := strings.TrimSpace(os.Getenv(common.EnvIngestorPageConcurrency)); v != "" {
n, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf("invalid %s %q: %w", common.EnvIngestorPageConcurrency, v, err)
}
val = n
}
if args.ingestorPageConcurrency != nil {
val = *args.ingestorPageConcurrency
}
if val < config.MinPageConcurrency || val > config.MaxPageConcurrency {
return 0, fmt.Errorf("ingestor page_concurrency %d out of range [%d, %d]",
val, config.MinPageConcurrency, config.MaxPageConcurrency)
}
return val, nil
}
func printHelp(args *serverArgs) {
switch {
case args.mode == nil || *args.mode == "migrate":
fmt.Fprintf(os.Stderr, "Usage: %s --api|--admin|--ingestor|--syncer|--deepdoc [OPTIONS]\n", os.Args[0])
fmt.Fprintf(os.Stderr, " %s --migrate [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")
fmt.Fprintf(os.Stderr, " --deepdoc \tRun as DeepDoc server\n\n")
fmt.Fprintf(os.Stderr, "Standalone action (mutually exclusive with a mode):\n")
fmt.Fprintf(os.Stderr, " --migrate \tRun database migrations and exit\n\n")
fmt.Fprintf(os.Stderr, "Common options:\n")
fmt.Fprintf(os.Stderr, " -f, --config string\tPath to configuration file\n")
fmt.Fprintf(os.Stderr, " -p, --port int \tServer port (overrides config file)\n")
fmt.Fprintf(os.Stderr, " --admin-host string\tAdmin server host:port (ingestor, syncer, deepdoc)\n")
fmt.Fprintf(os.Stderr, " --name string \tServer name (ingestor, syncer, deepdoc)\n")
fmt.Fprintf(os.Stderr, " --ingestor-max-concurrent-workers int\tIngestor NATS worker count K, range [1, 256] (default: 1)\n")
fmt.Fprintf(os.Stderr, " --ingestor-page-concurrency int\tProcess-wide page concurrency N, range [1, 16] (default: 2)\n")
fmt.Fprintf(os.Stderr, " --init-superuser\tInitialize superuser account (admin)\n")
fmt.Fprintf(os.Stderr, " -v, --version \tPrint version information and exit\n")
fmt.Fprintf(os.Stderr, " --log-level string\tLog level: debug, info, warn, error (default: warn)\n")
fmt.Fprintf(os.Stderr, " --profile \tEnable pprof server\n")
fmt.Fprintf(os.Stderr, " -h, --help \tShow this help message and exit\n\n")
fmt.Fprintln(os.Stderr, "MCP settings: service_conf.yaml mcp section or RAGFLOW_MCP_* environment variables.")
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])
fmt.Fprintf(os.Stderr, "Run '%s --deepdoc --help' for DeepDoc server 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, " --log-level string\tLog level: debug, info, warn, error (default: warn)\n")
fmt.Fprintf(os.Stderr, " --profile \t\tEnable pprof server\n")
fmt.Fprintf(os.Stderr, " -h, --help \tShow this help message and exit\n")
fmt.Fprintln(os.Stderr, "\nMCP settings: service_conf.yaml mcp section or RAGFLOW_MCP_* environment variables.")
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, " --log-level string\t\tLog level: debug, info, warn, error (default: warn)\n")
fmt.Fprintf(os.Stderr, " --profile \t\t\tEnable pprof server\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, " --ingestor-max-concurrent-workers int\tIngestor NATS worker count K, range [1, 256] (default: 1)\n")
fmt.Fprintf(os.Stderr, " --ingestor-page-concurrency int\tProcess-wide page concurrency N, range [1, 16] (default: 2)\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, " --log-level string\tLog level: debug, info, warn, error (default: warn)\n")
fmt.Fprintf(os.Stderr, " --profile \t\tEnable pprof server\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, " --log-level string\tLog level: debug, info, warn, error (default: warn)\n")
fmt.Fprintf(os.Stderr, " --profile \t\tEnable pprof server\n")
fmt.Fprintf(os.Stderr, " -h, --help \t\tShow this help message and exit\n")
case *args.mode == "deepdoc":
fmt.Fprintf(os.Stderr, "Usage: %s --deepdoc [OPTIONS]\n\n", os.Args[0])
fmt.Fprintf(os.Stderr, "RAGFlow DeepDoc Inference Service - DeepDoc model inference service\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, " --profile \t\tEnable pprof server\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 serverName string
if arguments.name != nil {
serverName = *arguments.name
} else {
serverName = fmt.Sprintf("%s_server", *arguments.mode)
}
logLevel := selectedLogLevel(arguments, "")
if higherThanInfo := common.LogLevelHigherThanInfo(logLevel); higherThanInfo {
logLevel = "info"
}
if err = common.InitLogger(logLevel, common.FileOutput{}, 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
// NOTE: this switch must stay side-effect-free on the LOG (no
// registerNativeDeepDoc here): it runs while only the temporary
// stdout-only logger exists, so anything it logs is lost from the file.
// Side effects that log (DeepDoc registration) move below, after the
// real file-backed logger is up.
switch *arguments.mode {
case "api":
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":
if serverName == "" {
uuid := common.GenerateUUID()
serverName = fmt.Sprintf("ingestor_server_%s", uuid)
}
case "syncer":
if serverName == "" {
uuid := common.GenerateUUID()
serverName = fmt.Sprintf("syncer_server_%s", uuid)
}
case "deepdoc":
if serverName == "" {
uuid := common.GenerateUUID()
serverName = fmt.Sprintf("deepdoc_server_%s", uuid)
}
case "migrate":
if arguments.name == nil {
serverName = "migrate"
}
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)
// Wire the in-process DeepDoc backend only after the REAL file-backed
// logger exists: its registration lines (and the Fatal abort on a missing
// backend) must land in the run's log file, not in the pre-config
// stdout-only window.
switch *arguments.mode {
case "api", "ingestor":
if err = registerNativeDeepDoc(arguments); err != nil {
common.Error("Failed to register in-process DeepDoc backend", err)
os.Exit(1)
}
default:
}
// Print all configuration settings
common.Info(fmt.Sprintf("Starting %s server: %s, mode: %s", *arguments.mode, serverName, globalConfig.GetMode()))
server.PrintAll()
// Start pprof server if requested
if arguments.enablePProf {
go func() {
common.Info("Starting pprof server", zap.String("addr", "localhost:6060"))
if pprofErr := http.ListenAndServe("localhost:6060", nil); pprofErr != nil {
common.Error("pprof server failed", pprofErr)
}
}()
}
// Initialize database. The migrate mode stops here: it needs neither the
// downgrade check nor any of the engines started below, so it can run
// before any server mode boots (see docker/entrypoint.sh and
// docker/launch_backend_service.sh).
migrate := *arguments.mode == "migrate"
if migrate {
common.Info("Running database migrations")
}
if err = dao.InitDB(ctx, migrate); err != nil {
common.Fatal("Failed to initialize database", zap.Error(err))
}
if migrate {
common.Info("Database migrations completed")
return
}
if err = checkDatabaseVersion(ctx); err != nil {
common.Fatal("Database has a newer version", 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 Kvrocks cache
if err = kvrocks.Init(ctx); err != nil {
common.Fatal("Failed to initialize Kvrocks", zap.Error(err))
}
defer kvrocks.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(kvrocks.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, serverName, arguments); err != nil {
fmt.Printf("Failed to start API server: %v\n", err)
os.Exit(1)
}
case "admin":
if err = runAdmin(ctx, serverName, arguments); err != nil {
fmt.Printf("Failed to start ADMIN server: %v\n", err)
os.Exit(1)
}
case "ingestor":
if err = runIngestor(ctx, cancel, serverName, arguments); err != nil {
fmt.Printf("Failed to start INGESTION worker: %v\n", err)
os.Exit(1)
}
case "syncer":
if err = runSyncer(ctx, cancel, serverName, arguments); err != nil {
fmt.Printf("Failed to start SYNCER: %v\n", err)
os.Exit(1)
}
case "deepdoc":
if err = runDeepDoc(ctx, serverName, arguments); err != nil {
fmt.Printf("Failed to start DEEPDOC: %v\n", err)
os.Exit(1)
}
default:
fmt.Printf("Invalid server mode: %s\n", *arguments.mode)
os.Exit(1)
}
}
func setLogger(serverName string, arguments *serverArgs) {
// log filename
logFileName := fmt.Sprintf("%s.log", serverName)
globalConfig := server.GetConfig()
logConfig := globalConfig.GetLogConfig()
// Reinitialize logger with the configured level and CLI overrides.
logLevel := selectedLogLevel(arguments, logConfig.Level)
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)
}
}
// checkDatabaseVersion refuses to run a server when the running code is older
// than the version recorded in the system_settings migration marker. Migrating
// the database forward is a one-way operation, so an older binary would read and
// write a schema it does not understand.
//
// A missing marker, or a version on either side that cannot be parsed, never
// blocks startup: without a usable comparison there is no evidence that the
// database is ahead of the code.
//
// RAGFLOW_DEV_MODE turns the check off entirely. A development build can carry
// a marker for a release that is not tagged yet, in which case the comparison
// would reject the build that wrote the marker.
func checkDatabaseVersion(ctx context.Context) error {
if common.DevModeEnabled() {
common.Warn("Development mode is enabled, skipping the database downgrade check",
zap.String("env", common.EnvRAGFlowDevMode))
return nil
}
databaseVersion, err := dao.GetDatabaseMigrationVersion(ctx, dao.DB)
if err != nil {
return fmt.Errorf("read database version marker: %w", err)
}
if databaseVersion == "" {
return nil
}
codeVersion := common.GetRAGFlowVersion()
older, comparable := common.IsOlderReleaseThan(codeVersion, databaseVersion)
if !comparable {
common.Warn("Cannot compare code version with database version, skipping the downgrade check",
zap.String("code_version", codeVersion),
zap.String("database_version", databaseVersion))
return nil
}
if older {
return fmt.Errorf("code version %s is older than database version %s: upgrade this deployment to %s or newer before starting",
codeVersion, databaseVersion, databaseVersion)
}
common.Info("Database version check passed",
zap.String("code_version", codeVersion),
zap.String("database_version", databaseVersion))
return nil
}
func runAdmin(ctx context.Context, serverName string, 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
// Only honor X-Forwarded-For / X-Real-IP from the configured proxies
// (default: the loopback nginx bundled in the image), never from every peer.
if err := common.ConfigureTrustedProxies(ginEngine, globalConfig.GetAPIServerConfig().TrustedProxies); err != nil {
common.Fatal("Failed to configure trusted proxies", zap.Error(err))
}
// 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()))
// Set log level
setLogger(serverName, args)
// 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, serverName string, 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))
}
logTokenizerCounters()
// 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()
// Resolve the ingestor worker count (K) from CLI > env > config > default(1)
// and fail fast on an out-of-range value.
workers, err := resolveIngestorMaxConcurrentWorkers(args, ingestorCfg.MaxConcurrentWorkers)
if err != nil {
return err
}
// 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(workers), []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.
navService := nlp.NewNavService(service.NewNavEmbedder(service.NewModelFactory(), ""))
// The cluster namer/merger is the counterpart of Python's
// _llm_create_summary / _llm_merge: a new cluster is named after the model's
// topic title and an existing cluster's description is fused with the joining
// document. Passing an empty model ref resolves each tenant's default chat
// model on demand; without one the nav service keeps its deterministic
// behavior.
navService.SetNavMergeLLM(service.NewNavMergeLLM(service.NewModelFactory(), ""))
nav.SetNavService(navService)
// 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()))
// Set log level
setLogger(serverName, args)
// Start heartbeat reporter to admin server
if hb := startHeartbeat(
common.ServerTypeIngestion,
fmt.Sprintf("ingestor-%s", ingestor.ID()[:8]),
0,
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, serverName string, 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()))
// Set log level
setLogger(serverName, args)
// Start heartbeat reporter to admin server
if hb := startHeartbeat(
common.ServerTypeFileSyncer,
fmt.Sprintf("syncer-%s", fileSyncer.ID()[:8]),
0,
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, serverName string, 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))
}
if err := startServer(ctx, serverName, args); err != nil {
return err
}
common.Info("Server exited")
return nil
}
func startServer(ctx context.Context, serverName string, arguments *serverArgs) error {
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()
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()
modelFactory := service.NewModelFactory()
modelCallService := service.NewModelCallServiceWithProviderService(modelProviderService, modelFactory)
// 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)
// Keep the concrete adapter: it is both the canvas/agent-tool backend and the
// target of the agentic-RAG bridge wired below. The bridge must hold it
// directly (not look it up in the registry) because the registry is shared —
// agenttool.SetRetrievalService and runtime.SetRetrievalService write the same
// singleton, so a bridge that resolved its target per call would find itself.
retrievalAdapter := agenttool.NewNLPRetrievalAdapterFromDeps(
docEngine,
documentDAO,
agenttool.RetrievalModelConstructors{
Embedding: func(ctx context.Context, tenantID, modelRef string) (*modelModule.EmbeddingModel, error) {
access := service.ModelAccess{TenantID: tenantID}
if strings.TrimSpace(modelRef) == "" {
return modelFactory.NewDefaultEmbeddingModel(ctx, access)
}
return modelFactory.NewEmbeddingModel(ctx, access, modelRef)
},
Chat: func(ctx context.Context, tenantID, modelRef string) (*modelModule.ChatModel, error) {
access := service.ModelAccess{TenantID: tenantID}
if strings.TrimSpace(modelRef) == "" {
return modelFactory.NewDefaultChatModel(ctx, access)
}
return modelFactory.NewChatModel(ctx, access, modelRef)
},
Rerank: func(ctx context.Context, tenantID, modelRef string) (*modelModule.RerankModel, error) {
return modelFactory.NewRerankModel(ctx, service.ModelAccess{TenantID: tenantID}, modelRef)
},
},
retrievalEnhancer,
)
agenttool.SetRetrievalService(retrievalAdapter)
agenttool.SetMemoryRetrievalService(retrievalbridge.NewMemoryAdapter(memoryService))
common.Info("agent: retrieval service adapter installed")
// The smart-reasoning agent's corpus tools (internal/agentic_rag): regex
// pushdown and lexical BM25 over the same doc engine the retrieval adapter
// wraps. The locate tools resolve these services per call, so they must be
// registered before the server starts serving.
runtime.SetGrepService(smartagentic.NewGrepAdapter(docEngine))
bm25Adapter := smartagentic.NewBm25Adapter(docEngine)
bm25Adapter.SetQueryBuilder(nlp.GetQueryBuilder())
runtime.SetBm25Service(bm25Adapter)
common.Info("agent: smart-reasoning corpus services installed (grep + bm25)")
// Wire the agentic-RAG runtime as the Go chat pipeline's evidence engine
// (internal/service/chat_pipeline.retrieveViaHarness): it runs each request on a
// model resolved from the caller's ModelID and searches through the adapter
// above. Activating the agentic loop here enables the full planner/SCA path for
// reasoning chats.
runtime.SetRetrievalService(retrievalbridge.NewRuntimeAdapter(retrievalAdapter))
agentic_rag.SetAgenticLoop(agentic_rag.NewAgenticLoop())
service.SetHarnessRetriever(retrievalbridge.NewHarnessRetriever(modelFactory, metadataService, docEngine))
common.Info("agent: runtime 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)
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(datasetsService, chatService)
skillSearchHandler := handler.NewSkillSearchHandler(docEngine, documentService)
providerHandler := handler.NewProviderHandler(userService, modelProviderService, modelCallService)
// Install the agent service's Kvrocks-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,
)
agentHandler := handler.NewAgentHandler(ctx, agentService, fileService)
// 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.
botService := service.NewBotService(agentService)
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(modelCallService)
searchBotHandler := handler.NewSearchBotHandler(
searchService,
tenantService,
modelFactory,
chunkService,
)
askService := service.NewAskService(chunkService, nil, 0, 0)
searchBotHandler.SetAskService(askService)
chatHandler.SetMindMapDependencies(searchService, tenantService, modelFactory, chunkService)
searchHandler.SetCompletionDependencies(modelFactory, 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,
modelFactory,
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 runtime LLM calls work in production. Without this,
// chat.GetDefaultInvoker() stays nil and the runtime 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,
// and the cluster namer/merger resolves the tenant's default chat model the
// same way (see the ingestor's wrapping of SetNavMergeLLM).
apiNavService := nlp.NewNavService(service.NewNavEmbedder(modelFactory, ""))
apiNavService.SetNavMergeLLM(service.NewNavMergeLLM(modelFactory, ""))
nav.SetNavService(apiNavService)
// 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,
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
// Only honor X-Forwarded-For / X-Real-IP from the configured proxies
// (default: the loopback nginx bundled in the image), never from every
// peer. c.ClientIP() feeds the agent webhook ip_whitelist gate and the
// login audit records, so gin's trust-everything default would let any
// caller pick its own address.
if err := common.ConfigureTrustedProxies(ginEngine, globalConfig.GetAPIServerConfig().TrustedProxies); err != nil {
common.Fatal("Failed to configure trusted proxies", zap.Error(err))
}
// 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 {
return fmt.Errorf("start chat-channel: %w", 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,
BaseContext: func(net.Listener) context.Context { return ctx },
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
// WriteTimeout spans "request header read → response written", so it is a
// ceiling on the WHOLE request, not on a slow client's reads. Measured
// (2026-09-15, FRAMES 20q): three multi-hop questions take 150–225s to reach
// their response, and at 120s the server closed the connection with no
// response at all — the client reports
// `RemoteDisconnected('Remote end closed connection without response')` and
// the benchmark re-runs the whole question (max_retries: 2), so one slow
// question cost three full pipelines. 180s clears the measured distribution's
// middle; questions whose composition alone runs past it still need streaming
// or a larger budget.
WriteTimeout: 180 * time.Second,
IdleTimeout: 120 * time.Second,
}
apiListener, err := net.Listen("tcp", addr)
if err != nil {
return fmt.Errorf("listen API server on %s: %w", addr, err)
}
defer apiListener.Close()
serveErr := make(chan error, 2)
serve := func(name string, srv *http.Server, listener net.Listener) {
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
serveErr <- fmt.Errorf("%s server failed: %w", name, err)
}
}
var mcpSrv *http.Server
var mcpCloser interface{ Close() error }
var mcpListener net.Listener
mcpConfig := apiServerConfig.MCP
if mcpConfig.Enabled {
mcpHandler, err := mcpServerHandler.NewStandalone(ctx, authHandler, mcpConfig)
if err != nil {
return err
}
mcpCloser = mcpHandler
defer mcpHandler.Close()
mcpAddr := net.JoinHostPort(strings.Trim(mcpConfig.Host, "[]"), strconv.Itoa(mcpConfig.Port))
mcpListener, err = net.Listen("tcp", mcpAddr)
if err != nil {
return fmt.Errorf("listen MCP server on %s: %w", mcpAddr, err)
}
defer mcpListener.Close()
mcpSrv = &http.Server{
Addr: mcpAddr,
Handler: mcpHandler,
BaseContext: func(net.Listener) context.Context { return ctx },
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 60 * time.Second,
WriteTimeout: 0, // SSE streams outlive individual tool deadlines
IdleTimeout: 120 * time.Second,
}
go func() {
common.Info(fmt.Sprintf("MCP server starting on %s", mcpSrv.Addr))
serve("MCP", mcpSrv, mcpListener)
}()
}
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))
// Set log level
setLogger(serverName, arguments)
// Start server in a goroutine
go func() {
serve(serverName, srv, apiListener)
}()
// Start heartbeat reporter to admin server
if hb := startHeartbeat(
common.ServerTypeAPI,
fmt.Sprintf("ragflow-server-%s", common.GenerateUUID()[:8]),
apiServerConfig.HTTPPort,
globalConfig.GetHeartbeatInterval(),
); hb != nil {
defer hb.Stop()
}
// Wait for either shutdown signal or serving failure.
var runErr error
select {
case <-ctx.Done():
common.Info("Received shutdown signal")
case err := <-serveErr:
runErr = err
common.Error("Server failed; shutting down", err)
}
common.Info("Shutting down server...")
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second)
defer shutdownCancel()
if mcpCloser != nil {
if err = mcpCloser.Close(); err != nil {
common.Warn("Failed to close MCP handler", zap.Error(err))
}
}
if err = shutdownHTTPServer(shutdownCtx, "API", srv); err != nil {
return err
}
if mcpSrv != nil {
if err = shutdownHTTPServer(shutdownCtx, "MCP", mcpSrv); err != nil {
return err
}
}
return runErr
}
func shutdownHTTPServer(ctx context.Context, name string, srv *http.Server) error {
if err := srv.Shutdown(ctx); err != nil {
_ = srv.Close()
return fmt.Errorf("shutdown %s server: %w", name, err)
}
return nil
}
// 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 Kvrocks-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 !kvrocks.IsEnabled() || kvrocks.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.NewKvrocksCheckPointStore(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(modelCallService *service.ModelCallService) {
if modelCallService == nil {
common.Info("agent: model call service not initialised; TTS in no-op echo mode")
audio.SetModelProviderSynthesizer(nil)
return
}
audio.SetModelProviderSynthesizer(audio.NewTTSDispatchFunc(modelCallService))
common.Info("agent: TTS model-call dispatch installed (audio.Synthesize → ModelCallService.AudioSpeech)")
}
// inferenceTotalCores returns the CPU budget the DeepDoc inference config is
// validated against. It uses runtime.GOMAXPROCS(0) instead of runtime.NumCPU()
// so the budget reflects the cgroup CPU quota inside containers: Go 1.25+ derives
// GOMAXPROCS from the quota (CGroups v2 cpu.max / v1 cpu.cfs_quota_us), whereas
// runtime.NumCPU() reports the host's affinity-mask core count and ignores a
// container's CPU limit. Without this, a 2-CPU-limit pod on a 64-core host would
// "resolve" inference_cpu_cores: 0 to 64 and let inference_concurrency: 16 pass
// validation, oversubscribing the box the fail-fast guard in
// native.ValidateInferenceConfig exists to prevent.
func inferenceTotalCores() int {
n := goruntime.GOMAXPROCS(0)
if n < 1 {
return 1
}
return n
}
// 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(arguments *serverArgs) error {
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))
// Resolve the DeepDoc in-process inference configuration: the concurrency
// budget K (max in-flight Runs) and the CPU-core budget N (max cores
// inference may occupy). Both resolve from CLI > env > config > default. K
// defaults to 1; N defaults to "unset", which is derived from K so each Run
// stays single-threaded by default (the prior behaviour). An explicit N = 0
// means "all cores". All values are fail-fast validated against the process's
// cgroup-aware CPU budget at startup (see native.ValidateInferenceConfig and
// inferenceTotalCores), so a misconfiguration aborts rather than silently
// oversubscribing the box.
cfg := server.GetConfig()
K, _, errK := cfg.ResolveDeepDocInferenceConcurrency(arguments.deepdocInferenceConcurrency)
if errK != nil {
common.Fatal("invalid deepdoc inference concurrency", zap.Error(errK))
}
rawN, explicitN, errN := cfg.ResolveDeepDocInferenceCPUCores(arguments.deepdocInferenceCPUCores)
if errN != nil {
common.Fatal("invalid deepdoc inference cpu cores", zap.Error(errN))
}
// When N is unset, derive it from K so each Run opens with a single intra-op
// thread (coresPerInference = max(1, K/K) = 1), preserving the prior
// single-core-per-run semantics. An explicit N (including 0 = all cores) is
// honoured as-is below.
resolvedN := rawN
if !explicitN {
resolvedN = K
}
totalCores := inferenceTotalCores()
// Validate K and N against the process's CPU budget (cgroup-aware) and
// derive the per-Run intra-op thread count (max(1, N/K)). N == 0 resolves to
// all cores here.
coresPerInference, totalCPUCores, errV := native.ValidateInferenceConfig(totalCores, resolvedN, K)
if errV != nil {
common.Fatal("invalid deepdoc inference configuration", zap.Error(errV))
}
// Register the per-session intra-op thread count before any model session is
// created (the native package reads it on every SessionOptions build).
native.SetIntraOpThreads(coresPerInference)
pdf.SetDeepDocConcurrency(K)
native.SetInferenceLimit(K)
common.Info("in-process DeepDoc inference limit registered",
zap.Int("max_concurrent_inference", K),
zap.Int("cpu_cores_per_inference", coresPerInference),
zap.Int("total_cpu_cores_budget", totalCPUCores),
zap.Int("gomaxprocs", goruntime.GOMAXPROCS(0)))
// Resolve the per-document page concurrency (N) from CLI > env > config >
// default(2) and register it with the shared page worker pool. Fail fast on
// an out-of-range value.
pageConcurrency, err := resolveIngestorPageConcurrency(arguments, server.GetConfig().GetIngestorConfig().PageConcurrency)
if err != nil {
return err
}
pdf.SetPageConcurrency(pageConcurrency)
common.Info("in-process DeepDoc page worker pool registered",
zap.Int("page_concurrency", pageConcurrency))
return nil
}
// logTokenizerCounters reports, once at startup, which embedding tokenizers this process
// can count with. An unavailable counter is not fatal here: the process still starts, and
// untagged models keep counting with the calibrated estimate by design. But the models that
// declare it cannot be ingested - the embedder refuses to substitute the calibrated count -
// so this report is what tells an operator which asset to restore before documents fail.
func logTokenizerCounters() {
var available, unavailable []string
for _, status := range tokenizer.CounterStatuses() {
if status.Available {
available = append(available, status.ID)
continue
}
unavailable = append(unavailable, status.ID)
}
common.Info("embedding tokenizer counters", zap.Strings("available", available))
if len(unavailable) > 0 {
common.Warn("embedding tokenizers unavailable; models that declare them cannot be ingested until the asset is restored",
zap.Strings("unavailable", unavailable),
zap.String("hint", "run `uv run ragflow_deps/download_deps.py`, or set "+common.EnvModelAssetsDir+" to a directory holding them"))
}
}
// resolveDeepDocModelDir picks the model directory: the explicit DEEPDOC_MODEL_DIR
// env, else the RAGFlow default model dir (internal/rag/res/deepdoc),
// else the assets 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()
// MODEL_ASSETS_DIR first: the shared model-asset root keeps this layout too, so one
// variable can point at the DeepDoc weights as well as the embedding tokenizers.
candidates := append([]string(nil), common.ModelAssetCandidates("huggingface.co/InfiniFlow/deepdoc")...)
candidates = append(candidates,
filepath.Join(wd, "internal", "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, "internal", "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)
}