feat: embed on the worker, and stop batching the ONNX pass

The API embeds every query it serves, so it held its own copy of the model:
~890 MB it never needed. EMBEDDINGS_DELEGATE_TO_WORKER (on by default) sends
the text to the Celery worker instead and gets the vector back, taking an API
process from 1176 MB to 285 MB with no ONNX Runtime imported at all. The client
embeds locally when it finds itself inside a worker task, so the worker never
dispatches to itself -- the same self-deadlock DOCUMENT_PARSE_QUEUE avoids on
the parsing side. EMBEDDINGS_BASE_URL still wins over it, and remains the right
answer for production.

ensure_vector_schema was constructing the embeddings instance purely to read
.dimension off it, loading several hundred MB of ONNX into every API and worker
process at import. For a model the registry describes that is a lookup; only an
unregistered name now falls back to loading.

EMBEDDINGS_BATCH_SIZE was sizing two unrelated things: chunks per store
transaction (and per remote embed request) and documents per ONNX forward pass.
Each pass pads every input up to its longest, and that waste grows with the
square of chunk length, so at the 1250-token default a batch of 32 peaked at
6.6 GB and took 326s where a batch of 1 peaked at 2.9 GB and took 90s. The
forward pass is now sized by EMBEDDINGS_MODEL_BATCH_SIZE, defaulting to 1;
storage and remote batching are unchanged at 32.

reembed embeds in-process: a batch job that walks the whole index should not
round-trip every chunk through a broker, and loading the model there reports a
real failure instead of timing out against an empty queue.

Also drops the mpnet zip download from the docs and the devcontainer, which
pointed at a SentenceTransformers export with no ONNX graph and had been inert
since the FastEmbed swap; corrects the claim that any sentence-transformers
model works; and settles the Configuring/Settings pages on what the registry
and the repository metadata actually decide.
This commit is contained in:
Alex committed 2026-08-28 12:23:18 +01:00
1 parent ef51ee97b3
commit 47e53a71c4
20 files changed
+679 -266

No files matched your search

+13 -2
View File
@@ -13,7 +13,10 @@ result_serializer = 'json'
accept_content = ['json']
# Autodiscover tasks
imports = ('application.api.user.tasks',)
imports = (
'application.api.user.tasks',
'application.vectorstore.embeddings_tasks',
)
# Project-scoped queue so a stray sibling worker on the same broker
# (other repo, same default ``celery`` queue) can't grab DocsGPT tasks.
@@ -25,8 +28,13 @@ task_default_routing_key = "docsgpt"
# Celery worker (headless/scheduled agent) is served by a separate parsing worker
# and never self-deadlocks the awaiting worker. The tool also passes the queue at
# apply_async time, so this routing is the default for any other enqueuer.
# Query embedding gets its own queue for the same reason parsing does: a query
# waiting behind a multi-minute ingest is a query that has timed out. A bare
# worker still consumes it, but its concurrency is shared -- run a separate
# ``-Q embeddings`` worker to actually isolate query latency from ingest.
task_routes = {
"application.api.user.tasks.parse_document": {"queue": settings.DOCUMENT_PARSE_QUEUE},
"application.vectorstore.embeddings_tasks.embed_texts": {"queue": settings.EMBEDDINGS_QUEUE},
}
# Declare every queue so a bare ``celery worker`` (no -Q) consumes ALL of them —
@@ -34,7 +42,10 @@ task_routes = {
# heavy OCR isolated run one worker with ``-Q docsgpt`` and another with
# ``-Q parsing``. (dict.fromkeys dedupes if DOCUMENT_PARSE_QUEUE == "docsgpt".)
task_queues = tuple(
Queue(name) for name in dict.fromkeys(["docsgpt", settings.DOCUMENT_PARSE_QUEUE])
Queue(name)
for name in dict.fromkeys(
["docsgpt", settings.DOCUMENT_PARSE_QUEUE, settings.EMBEDDINGS_QUEUE]
)
)
beat_scheduler = "redbeat.RedBeatScheduler"
+112 -191
View File
@@ -33,10 +33,8 @@ class Settings(BaseSettings):
OIDC_GROUPS_CLAIM: str = "groups" # ID-token/userinfo claim carrying group membership
OIDC_ADMIN_GROUPS: Optional[str] = None # comma-separated groups granted admin; unset = no OIDC admin mapping
# RBAC (admin/user roles). Persisted admin grants live in the user_roles
# table and apply only under AUTH_TYPE=oidc. LOCAL_MODE_ADMIN is the only
# non-DB admin path and applies only to AUTH_TYPE=None (no-auth self-host).
# It MUST stay False in any networked deployment.
# RBAC: persisted admin grants live in user_roles (AUTH_TYPE=oidc only). This is the
# only non-DB admin path, for AUTH_TYPE=None self-host. MUST stay False if networked.
LOCAL_MODE_ADMIN: bool = False
# SCIM 2.0 provisioning (IdP-driven user create/deactivate at /scim/v2)
@@ -45,59 +43,56 @@ class Settings(BaseSettings):
LLM_PROVIDER: str = "docsgpt"
LLM_NAME: Optional[str] = None # if LLM_PROVIDER is openai, LLM_NAME can be gpt-4 or gpt-3.5-turbo
# Deliberately the legacy model, not the current recommendation. An
# existing deployment that never pinned EMBEDDINGS_NAME falls through to
# this default, and its stored vectors were produced by this model --
# changing the default here would silently retrieve against a different
# vector space (both are 768-dimensional, so no dimension check fires).
# New installs get granite from .env-template / setup.sh; existing ones
# switch by setting this and running application.scripts.reembed.
# Legacy model on purpose: an install that never pinned this has vectors from it, and
# granite is the same width so a swap would fail silently. New installs get granite from
# .env-template; existing ones switch by setting this and running application.scripts.reembed.
EMBEDDINGS_NAME: str = "huggingface_sentence-transformers/all-mpnet-base-v2"
EMBEDDINGS_BASE_URL: Optional[str] = None # Remote embeddings API URL (OpenAI-compatible)
EMBEDDINGS_KEY: Optional[str] = None # api key for embeddings (if using openai, just copy API_KEY)
EMBEDDINGS_MAX_INPUT_TOKENS: Optional[int] = None # truncate each remote embed input to N tokens (overflow lost)
EMBEDDINGS_BATCH_SIZE: int = 32 # chunks per embed request during ingest (1 = legacy per-chunk behaviour)
# Intra-op threads for the local ONNX runner. None = every core. ONNX Runtime
# scales sub-linearly across threads, so several single-threaded worker
# processes beat one many-threaded process on the same cores.
EMBEDDINGS_BATCH_SIZE: int = 32 # chunks per store transaction / remote embed request
# Documents per local ONNX forward pass. Each pass pads to its longest input, and that
# waste grows with the square of chunk length: at 1250 tokens, 32 peaked at 6.6 GB, 1 at 2.9 GB.
EMBEDDINGS_MODEL_BATCH_SIZE: int = 1
# Intra-op threads for the local ONNX runner; None = every core. It scales sub-linearly,
# so several single-threaded workers beat one many-threaded process on the same cores.
EMBEDDINGS_THREADS: Optional[int] = None
EMBEDDINGS_CACHE_DIR: Optional[str] = None # where FastEmbed caches model artifacts
# How a local model turns token vectors into one vector, and whether the
# result is L2-normalised. Both are normally read from the model's own
# repository; set these only for a repository that declares neither, or to
# override what it declares. "cls" or "mean".
# Pooling ("cls"/"mean") and L2 normalisation. Read from the model's own repository;
# set these only for a repository that declares neither, or to override what it declares.
EMBEDDINGS_POOLING: Optional[str] = None
EMBEDDINGS_NORMALIZE: Optional[bool] = None
# Embed on the worker so the API holds no model (~890 MB), at one broker round trip per
# query. Ignored when EMBEDDINGS_BASE_URL is set, which is the better answer for production.
EMBEDDINGS_DELEGATE_TO_WORKER: bool = True
EMBEDDINGS_QUEUE: str = "embeddings" # queue the embed task is routed to
EMBEDDINGS_DELEGATE_TIMEOUT: int = 60 # seconds to wait for the worker
GITHUB_INGEST_MAX_FILE_BYTES: int = 1048576 # skip repo blobs larger than this (0 = no cap)
GITHUB_INGEST_MAX_WORKERS: int = 8 # parallel file fetches per GitHub repo ingest
# Optional directory of operator-supplied model YAMLs, loaded after the
# built-in catalog under application/core/models/. Later wins on
# Operator-supplied model YAMLs, loaded after the built-in catalog; later wins on
# duplicate model id. See application/core/models/README.md.
MODELS_CONFIG_DIR: Optional[str] = None
CELERY_BROKER_URL: str = "redis://localhost:6379/0"
CELERY_RESULT_BACKEND: str = "redis://localhost:6379/1"
# Prefetch=1 caps SIGKILL loss to one task. Visibility timeout must exceed
# the longest legitimate task runtime (ingest, agent webhook) but stay
# short enough that SIGKILLed tasks redeliver promptly. 1h matches Onyx
# and Dify defaults; long ingests can override via env.
# Prefetch=1 caps SIGKILL loss to one task. Visibility timeout must exceed the longest
# legitimate task runtime but stay short enough that SIGKILLed tasks redeliver promptly.
CELERY_WORKER_PREFETCH_MULTIPLIER: int = 1
CELERY_VISIBILITY_TIMEOUT: int = 3600
# Recycle the prefork worker child once its resident size crosses this many
# kilobytes — backstops native-heap growth from docling/torch parsing. 0 disables.
# Recycle a prefork child past this resident size in KB; backstops docling/torch heap growth.
# Checked between tasks, so it does not bound the peak within one. 0 disables.
CELERY_WORKER_MAX_MEMORY_PER_CHILD: int = 4194304
# Recycle the child after this many tasks; 0 disables (memory cap is the primary knob).
CELERY_WORKER_MAX_TASKS_PER_CHILD: int = 0
CELERY_WORKER_MAX_TASKS_PER_CHILD: int = 0 # recycle after N tasks; 0 disables
# Only consulted when VECTOR_STORE=mongodb or when running scripts/db/backfill.py; user data lives in Postgres.
MONGO_URI: Optional[str] = None
# User-data Postgres DB.
POSTGRES_URI: Optional[str] = None
# On app startup, apply pending Alembic migrations. Default ON for dev; disable in prod if you manage schema out-of-band.
# On startup, apply pending Alembic migrations. Disable if you manage schema out-of-band.
AUTO_MIGRATE: bool = True
# On app startup, create the target Postgres database if it's missing (requires CREATEDB privilege). Dev-friendly default.
# On startup, create the target Postgres database if missing (needs CREATEDB privilege).
AUTO_CREATE_DB: bool = True
# On app startup, create the pgvector/graph tables and verify the embedding dimension. Set False to manage them
# out-of-band — there is no Alembic migration for the vector DB because it may be a separate cluster.
# On startup, create the pgvector/graph tables and verify the embedding dimension. No Alembic
# migration covers the vector DB (it may be a separate cluster); set False to manage it yourself.
AUTO_VECTOR_SCHEMA: bool = True
LLM_PATH: str = os.path.join(current_dir, "models/docsgpt-7b-f16.gguf")
DEFAULT_MAX_HISTORY: int = 150
@@ -112,8 +107,7 @@ class Settings(BaseSettings):
"request_limit": 500,
}
UPLOAD_FOLDER: str = "inputs"
# Public upload request cap is applied by Flask before multipart parsing.
# The per-file cap is also enforced while copying each controlled stream.
# Request cap is applied by Flask before multipart parsing; the per-file cap also while copying.
UPLOAD_MAX_REQUEST_BYTES: int = Field(default=256 * 1024 * 1024, gt=0)
UPLOAD_MAX_FILE_BYTES: int = Field(default=100 * 1024 * 1024, gt=0)
PARSE_SPEC_MAX_BYTES: int = Field(default=10 * 1024 * 1024, gt=0)
@@ -126,49 +120,35 @@ class Settings(BaseSettings):
PARSE_IMAGE_REMOTE: bool = False
DOCLING_OCR_ENABLED: bool = False # Enable OCR for docling parsers (PDF, images)
DOCLING_OCR_ATTACHMENTS_ENABLED: bool = False # Enable OCR for docling when parsing attachments
# Pages docling's threaded pipeline buffers in flight; the library
# default (100) drives worker RSS to ~3 GB on a mid-size PDF.
# Pages docling buffers in flight; its default of 100 drives worker RSS to ~3 GB on a mid-size PDF.
DOCLING_PIPELINE_QUEUE_MAX_SIZE: int = 2
DOCLING_COMPILE_TORCH_MODELS: bool = False
DOCLING_TABULAR_MAX_BYTES: int = 2_000_000
DOCLING_MARKUP_MAX_BYTES: int = 8_000_000
# Chars-per-page floor below which an OCR'd PDF/image parse is treated as a docling
# pipeline dropout (long-running workers were observed returning zero characters for
# every scanned page after a long scanned PDF, with no error) rather than as content.
# Such a parse is retried once on a fresh full-page-OCR converter and then fails
# loudly instead of indexing an empty document. 0 disables the guard.
# Chars-per-page floor below which an OCR'd parse is treated as a docling dropout rather than
# content: retried once on a fresh converter, then failed loudly instead of indexing an empty
# document. Long-running workers were seen returning zero chars per page with no error. 0 disables.
DOCLING_OCR_MIN_CHARS_PER_PAGE: int = 20
# Read PDF *attachments* via their embedded text layer (pypdfium2) instead
# of docling, falling back to docling when there is no text layer to read.
# Attachments go into a prompt, so docling's structural markdown earns far
# less than the tens of seconds per file it costs; source ingestion is
# unaffected and always uses docling, because chunking and retrieval do
# depend on that structure.
# Read PDF *attachments* via their text layer (pypdfium2), falling back to docling when there
# is none. Attachments go into a prompt, where docling's structure does not earn its tens of
# seconds per file. Source ingestion is unaffected and always uses docling.
ATTACHMENT_PDF_TEXT_FAST_PATH: bool = True
# Median characters per sampled page below which a PDF is treated as a scan
# and handed to docling. Measured separation on real uploads: scans at
# 0-17 chars/page, text-layer documents at 433-6834.
# Median chars per sampled page below which a PDF is treated as a scan and handed to docling.
# Measured on real uploads: scans at 0-17 chars/page, text-layer documents at 433-6834.
ATTACHMENT_PDF_TEXT_MIN_MEDIAN_CHARS: int = 32
ATTACHMENT_TEXT_MAX_BYTES: int = 5_000_000
AGENT_IMAGE_MAX_BYTES: int = 5_000_000
AGENT_IMAGE_MAX_PIXELS: int = 16_777_216
VECTOR_STORE: str = "faiss" # "faiss" or "elasticsearch" or "qdrant" or "milvus" or "lancedb" or "pgvector"
# Allow-list of retriever keys an agent may use. Values must match the
# ``RetrieverCreator.retrievers`` registry keys (``classic`` / ``default``),
# Retriever keys an agent may use; must match RetrieverCreator.retrievers registry keys,
# NOT the legacy ``classic_rag`` label which never matched the registry.
RETRIEVERS_ENABLED: list = ["classic", "default"]
# Concurrent per-source vector searches within one retrieval (multi-source chats);
# the query is embedded once and shared across sources.
# Concurrent per-source searches in one retrieval; the query is embedded once and shared.
RETRIEVAL_MAX_PARALLEL_SOURCES: int = 4
# Kill-switch for per-source retrieval dispatch. When False the retrieval
# path collapses to today's single-retriever behavior (consumed by the
# Dispatcher in a later change; defined here so the flag exists up front).
# Kill-switch for per-source retrieval dispatch; False collapses to a single retriever.
PER_SOURCE_RETRIEVAL_ENABLED: bool = True
# Flagship GraphRAG flag. Reserved and unused for now; gates graph-aware
# ingestion/retrieval when that feature lands.
GRAPHRAG_ENABLED: bool = False
# Model for ingest-time graph extraction; None reuses the instance default
# model (LLM_PROVIDER/LLM_NAME). Operator-overridable (e.g. a cheaper model).
GRAPHRAG_ENABLED: bool = False # gates graph-aware ingestion/retrieval
# Model for ingest-time graph extraction; None reuses LLM_PROVIDER/LLM_NAME.
GRAPHRAG_EXTRACTION_MODEL: Optional[str] = None
# Hard cap on chunks extracted per source (cost control).
GRAPHRAG_MAX_CHUNKS_FOR_EXTRACTION: int = 2000
@@ -231,9 +211,8 @@ class Settings(BaseSettings):
ELASTIC_URL: Optional[str] = None # url for elasticsearch
ELASTIC_INDEX: Optional[str] = "docsgpt" # index name for elasticsearch
# Legacy AWS credentials from the retired SageMaker LLM provider.
# Still read as a deprecated fallback by S3 storage (see the S3_*
# block below); do not use for new deployments.
# Legacy AWS credentials from the retired SageMaker provider. Still read as a deprecated
# fallback by S3 storage; do not use for new deployments.
SAGEMAKER_REGION: Optional[str] = None
SAGEMAKER_ACCESS_KEY: Optional[str] = None
SAGEMAKER_SECRET_KEY: Optional[str] = None
@@ -253,16 +232,11 @@ class Settings(BaseSettings):
QDRANT_PATH: Optional[str] = None
QDRANT_DISTANCE_FUNC: str = "Cosine"
# PGVector vectorstore config. Write the URI in whichever form you
# prefer — ``postgres://``, ``postgresql://``, or even the SQLAlchemy
# dialect form (``postgresql+psycopg://``) are all accepted and
# normalized internally for ``psycopg.connect()``.
# PGVector config. postgres://, postgresql:// and postgresql+psycopg:// are all accepted
# and normalized internally for psycopg.connect().
PGVECTOR_CONNECTION_STRING: Optional[str] = None
# Per-process psycopg connection pool for the vector store; 0 = one direct
# connection per store instance, the legacy behaviour.
PGVECTOR_POOL_MAX_SIZE: int = 8
# IVFFlat probes for vector search. ``None`` derives sqrt(lists) from the
# index itself; set an integer to pin it. Higher = better recall, more scan.
PGVECTOR_POOL_MAX_SIZE: int = 8 # per-process pool; 0 = one direct connection per store
# IVFFlat probes; None derives sqrt(lists) from the index. Higher = better recall, more scan.
PGVECTOR_IVFFLAT_PROBES: Optional[int] = None
# Milvus vectorstore config
MILVUS_COLLECTION_NAME: Optional[str] = "docsgpt"
@@ -276,11 +250,8 @@ class Settings(BaseSettings):
FLASK_DEBUG_MODE: bool = False
STORAGE_TYPE: str = "local" # local or s3
# S3-compatible object storage (used when STORAGE_TYPE=s3). Works with AWS
# S3 and any S3-compatible service (MinIO, Cloudflare R2, Backblaze B2,
# DigitalOcean Spaces, ...). For non-AWS services, set S3_ENDPOINT_URL and
# usually S3_PATH_STYLE=true. The SAGEMAKER_* credentials are still read as
# a deprecated fallback for backward compatibility.
# S3-compatible object storage (STORAGE_TYPE=s3): AWS S3, MinIO, R2, B2, Spaces, ...
# For non-AWS, set S3_ENDPOINT_URL and usually S3_PATH_STYLE=true.
S3_BUCKET_NAME: str = "docsgpt-test-bucket"
S3_ENDPOINT_URL: Optional[str] = None # custom endpoint for S3-compatible services; omit for AWS
S3_ACCESS_KEY_ID: Optional[str] = None
@@ -309,34 +280,21 @@ class Settings(BaseSettings):
# Tool pre-fetch settings
ENABLE_TOOL_PREFETCH: bool = True
# When True, OpenAI Responses API calls are persisted server-side
# (store=true) so a previous_response_id can chain turns. When False
# (the default) Responses calls are stateless (store=false) and any
# reasoning is carried across the in-turn tool loop via encrypted
# reasoning items instead.
# True persists Responses API calls server-side so previous_response_id can chain turns.
# False keeps them stateless, carrying reasoning across the tool loop as encrypted items.
OPENAI_RESPONSES_STORE: bool = False
OPENAI_REASONING_SUMMARY: str = "auto"
# OpenAI-compatible clients can identify a logical chat with session
# headers even though chat-completions itself has no conversation field.
# Lets OpenAI-compatible clients identify a logical chat by session header, which
# chat-completions itself has no field for.
V1_SESSION_TTL_SECONDS: int = 24 * 60 * 60
# Optional cheaper model for first-party conversation titles. When unset,
# listed conversations use their answer model, but title work is still
# dispatched off the response path.
# Optional cheaper model for conversation titles; unset reuses the answer model.
TITLE_MODEL_ID: Optional[str] = None
# Config-free tools on by default in agentless chats. ``scheduler`` is
# dual-registered (also in ``BUILTIN_AGENT_TOOLS``) so the same synthetic id
# resolves whether reached via defaults or the agent picker.
#
# ``code_executor`` and ``artifact_generator`` belong here too on any
# deployment that runs a sandbox (see SANDBOX_BACKEND / SANDBOX_GATEWAY_URL):
# ``artifact_generator`` renders the .docx/.pdf/.xlsx/.pptx files users ask
# chat for. They are left out of the shipped default because both execute
# through the sandbox runner and would fail on every call without one — add
# them explicitly once a runner is configured:
# DEFAULT_CHAT_TOOLS = [..., "code_executor", "artifact_generator"]
# Config-free tools on by default in agentless chats. ``scheduler`` is dual-registered in
# BUILTIN_AGENT_TOOLS so one synthetic id resolves via defaults or the agent picker.
# Add "code_executor" and "artifact_generator" once a sandbox runner is configured — both
# execute through it and would fail on every call without one.
DEFAULT_CHAT_TOOLS: list = [
"memory",
"read_webpage",
@@ -349,81 +307,62 @@ class Settings(BaseSettings):
COMPRESSION_MODEL_OVERRIDE: Optional[str] = None # Use different model for compression
COMPRESSION_PROMPT_VERSION: str = "v1.0" # Track prompt iterations
COMPRESSION_MAX_HISTORY_POINTS: int = 3 # Keep only last N compression points to prevent DB bloat
COMPRESSION_RECENT_FIELD_MAX_TOKENS: int = 8000 # Per-field cap on the verbatim tail kept after a compression point (0 disables)
TOOL_RESULT_MAX_TOKENS: int = 20000 # Cap on a single tool result entering the LLM context (0 disables); journal/DB keep the full result
# Per-field cap on the verbatim tail kept after a compression point (0 disables).
COMPRESSION_RECENT_FIELD_MAX_TOKENS: int = 8000
# Cap on one tool result entering the LLM context (0 disables); journal/DB keep it whole.
TOOL_RESULT_MAX_TOKENS: int = 20000
# Agent Guardrails
# Master switch. When False, no guardrail stage runs regardless of what an
# agent's config says.
GUARDRAILS_ENABLED: bool = True
# Registry-key allowlist; values must match GuardrailCreator.checks keys.
# Empty means "every registered check".
GUARDRAILS_ENABLED: bool = True # master switch; False disables every stage
# Allowlist of GuardrailCreator.checks keys; empty means every registered check.
GUARDRAILS_CHECKS_ENABLED: list = []
# Instance floor: a GuardrailsConfig fragment every agent inherits and
# cannot weaken. Agents may add controls or make an action stricter, never
# looser. "enabled" is required — without it the floor parses but applies
# to nothing. Example:
# A GuardrailsConfig fragment every agent inherits and cannot weaken; agents may add
# controls or make an action stricter, never looser. "enabled" is required — without it
# the floor parses but applies to nothing. Example:
# {"enabled": true, "mode": "scan_all",
# "controls": [{"check": "secrets", "stage": "output",
# "action": "redact"}]}
# "controls": [{"check": "secrets", "stage": "output", "action": "redact"}]}
GUARDRAILS_FLOOR: dict = {}
# Judge model for the topic/policy checks. None reuses the request's model.
# Judge model for the topic/policy checks; None reuses the request's model.
GUARDRAILS_JUDGE_MODEL: Optional[str] = None
# Persist scanned text alongside guardrail_events. Off by default: the
# pre-redaction text is exactly the sensitive material a PII control exists
# to keep out of storage.
# Persist scanned text alongside guardrail_events. Off by default: pre-redaction text is
# exactly the material a PII control exists to keep out of storage.
GUARDRAILS_STORE_SCANNED_TEXT: bool = False
GUARDRAILS_EVENTS_RETENTION_DAYS: int = Field(default=30, ge=1)
# Internal SSE push channel (notifications + durable replay journal)
# Master switch — when False, /api/events emits a "push_disabled" comment
# and returns; clients fall back to polling. Publisher becomes a no-op.
# Internal SSE push channel (notifications + durable replay journal).
# False makes /api/events emit "push_disabled" and return; clients fall back to polling.
ENABLE_SSE_PUSH: bool = True
# Per-user durable backlog cap (~entries). At typical event rates this
# gives ~24h of replay; tune up for verbose feeds, down for memory.
# Per-user durable backlog cap in entries; ~24h of replay at typical rates.
EVENTS_STREAM_MAXLEN: int = 1000
# Bounds uvicorn's graceful-shutdown drain (uvicorn_worker doesn't forward
# --graceful-timeout). Keep below the gunicorn --timeout (180) watchdog.
# Used by gunicorn_worker.BoundedDrainUvicornWorker.
# Bounds uvicorn's shutdown drain (uvicorn_worker doesn't forward --graceful-timeout).
# Keep below the gunicorn --timeout (180) watchdog. Used by BoundedDrainUvicornWorker.
GRACEFUL_SHUTDOWN_TIMEOUT_SECONDS: int = 30
WSGI_THREADPOOL_WORKERS: int = 96
SSE_KEEPALIVE_SECONDS: int = Field(default=15, ge=1)
# Cap on simultaneous SSE connections per user. Each connection holds
# one WSGI thread (32 per gunicorn worker) and one Redis pub/sub
# connection. 8 covers normal multi-tab use without letting one user
# starve the pool. Set to 0 to disable the cap.
# Simultaneous SSE connections per user; each holds a WSGI thread and a Redis pub/sub
# connection. 8 covers multi-tab use without one user starving the pool. 0 disables.
SSE_MAX_CONCURRENT_PER_USER: int = 8
# Per-request cap on the number of backlog entries XRANGE returns
# for ``/api/events`` snapshots. Bounds the bytes a single replay
# can move from Redis to the wire — a malicious client looping
# ``Last-Event-ID=<oldest>`` reconnects can only enumerate this
# many entries per round-trip. Combined with the per-user
# connection cap above and the windowed budget below, total
# enumeration throughput is bounded.
# Backlog entries XRANGE returns per /api/events snapshot. Bounds what one replay moves
# from Redis to the wire: a client looping Last-Event-ID reconnects enumerates at most
# this many per round-trip, and the budget below bounds total throughput.
EVENTS_REPLAY_MAX_PER_REQUEST: int = 200
EVENTS_REPLAY_MAX_AGE_HOURS: int = 48
# Sliding-window cap on snapshot replays per user. Once the budget
# is exhausted the route returns HTTP 429 with the cursor pinned;
# the client backs off and retries after the window rolls over.
# Sliding-window cap on snapshot replays per user; exhausting it returns 429 with the
# cursor pinned so the client backs off until the window rolls over.
EVENTS_REPLAY_BUDGET_REQUESTS_PER_WINDOW: int = 30
EVENTS_REPLAY_BUDGET_WINDOW_SECONDS: int = 60
# Retention for the ``message_events`` journal. The ``cleanup_message_events``
# beat task deletes rows older than this. Reconnect-replay only
# needs the journal for streams a client could still be tailing,
# so 14 days is a generous default that covers paused/tool-action
# flows without unbounded table growth.
# Retention for the message_events journal, enforced by the cleanup_message_events beat
# task. Replay only needs streams a client could still be tailing.
MESSAGE_EVENTS_RETENTION_DAYS: int = 14
# Remote Device feature.
REMOTE_DEVICE_SESSION_IDLE_SECONDS: int = 60
REMOTE_DEVICE_REQUIRE_SIGNATURE: bool = False
REMOTE_DEVICE_PAIRING_TTL_SECONDS: int = 600
# Redis-backed broker tunables (route invocations cross-process so a
# scheduled/Celery run reaches the web-held device session). The command
# queue TTL must exceed the max command drain deadline (the tool caps
# timeout_ms at 600s, drained with a +5s margin = 605s) so a queued command
# for a briefly-offline device isn't evicted before its own drain gives up.
# Redis broker tunables, routing invocations cross-process so a scheduled run reaches the
# web-held device session. The queue TTL must exceed the max drain deadline (605s) so a
# command for a briefly-offline device isn't evicted before its own drain gives up.
REMOTE_DEVICE_CMD_QUEUE_TTL_SECONDS: int = 900
REMOTE_DEVICE_INVOCATION_TTL_SECONDS: int = 900
REMOTE_DEVICE_OUTPUT_STREAM_MAXLEN: int = 10_000
@@ -438,23 +377,19 @@ class Settings(BaseSettings):
SCHEDULE_ONCE_MAX_HORIZON: int = 31_536_000
SCHEDULE_RUN_OUTPUT_RETENTION_DAYS: int = 90
# Code-execution sandbox (see artifacts-code-execution-spec.md §4 C2).
# The app is a CLIENT of an always-on runner; defaults are safe so app
# import never fails when the sandbox is unconfigured.
# Code-execution sandbox. The app is a CLIENT of an always-on runner; defaults are safe so
# app import never fails when the sandbox is unconfigured.
SANDBOX_BACKEND: str = "jupyter" # "jupyter" (self-host) | "daytona" (Daytona Cloud)
# URL of the Jupyter Kernel Gateway runner (the docsgpt-sandbox service).
SANDBOX_GATEWAY_URL: str = "http://localhost:8888"
SANDBOX_GATEWAY_AUTH_TOKEN: Optional[str] = None # gateway auth token, if set
# Kernelspec launched per session. Defaults to the env-scrubbing "docsgpt-python"
# spec (shipped by the docsgpt-sandbox runner) so kernel code cannot read the
# gateway auth token or operator secrets from os.environ. The stock "python3"
# spec inherits the gateway env verbatim and must not be used with untrusted code.
# Kernelspec per session. The env-scrubbing "docsgpt-python" spec keeps kernel code from
# reading the gateway token or operator secrets from os.environ; the stock "python3" spec
# inherits the gateway env verbatim and must not be used with untrusted code.
SANDBOX_KERNEL_NAME: str = "docsgpt-python"
SANDBOX_MAX_TTL: int = 1200 # hard cap (s) on agent-selectable keep-alive TTL
# Per-process/worker cap on concurrent live sandbox sessions. Backend-agnostic
# (complements DAYTONA_MAX_SANDBOXES); when reached, an LRU-idle session is
# evicted to make room. This bound is local to each app/worker process.
# 0 (or any non-positive value) disables the cap (unlimited sessions).
# Concurrent live sessions per process, backend-agnostic; at the cap an LRU-idle session is
# evicted. 0 or negative disables the cap.
SANDBOX_MAX_SESSIONS: int = 32
SANDBOX_EXEC_TIMEOUT: int = 60 # default wall-clock cap (s) per exec call
SANDBOX_HTTP_TIMEOUT: int = 10 # fixed cap (s) for REST control calls (create/delete/alive/interrupt)
@@ -464,44 +399,31 @@ class Settings(BaseSettings):
# ``read_document`` parsing on a dedicated Celery ``parsing`` queue (backend parser).
DOCUMENT_PARSE_QUEUE: str = "parsing" # queue the parse_document task is routed to
DOCUMENT_PARSE_TIMEOUT: int = 120 # seconds the tool awaits the enqueued parse before degrading
# The base timeout is a FLOOR: the awaited window (and the task's per-call Celery
# time limits) grow with the document's size, because OCR cost scales with pages
# -- a 30-page scan needs ~60s, a 100-page scan several minutes. Without this a
# large scan is silently dropped from the node/tool at the base window.
# The base timeout is a FLOOR: the window grows with document size, because OCR cost scales
# with pages. Without this a large scan is silently dropped at the base window.
DOCUMENT_PARSE_TIMEOUT_PER_MB: int = 60 # extra seconds of parse window per MiB of input
DOCUMENT_PARSE_TIMEOUT_MAX: int = 900 # absolute ceiling on the size-scaled parse window
DOCUMENT_PARSE_MAX_BYTES: int = 0 # cap on a parsed document's bytes (0 = reuse SANDBOX_MAX_INPUT_BYTES)
DOCUMENT_MAX_DECOMPRESSED_BYTES: int = 300 * 1024 * 1024
DOCUMENT_MAX_ARCHIVE_ENTRIES: int = 10000
# Per-agent-node cap on files passed natively to the node's LLM (vision/doc
# inputs). Files past the cap are extracted to text or dropped, not attached
# natively, to bound context/cost. Re-uses SANDBOX_MAX_INPUT_BYTES per file.
# Files per node passed natively to the LLM; past the cap they are extracted to text or
# dropped, to bound context and cost. Re-uses SANDBOX_MAX_INPUT_BYTES per file.
WORKFLOW_NODE_NATIVE_MAX_FILES: int = 5
# Per-agent-node cap on documents extracted to text via the parsing worker.
# Each non-native, non-text document issues a separate blocking parse, so a
# node referencing many documents (e.g. the ``*`` token) is bounded here to
# avoid serializing dozens of parses; documents past the cap are skipped with
# a truncation note instead of extracted.
# Documents per node extracted via the parsing worker. Each issues a separate blocking
# parse; past the cap they are skipped with a truncation note.
WORKFLOW_NODE_EXTRACT_MAX_FILES: int = 5
# Total wall clock one node may spend on blocking document parses, shared
# across all of them. The per-document window scales with size (up to
# DOCUMENT_PARSE_TIMEOUT_MAX), so without a shared budget a node could
# serialize WORKFLOW_NODE_EXTRACT_MAX_FILES full windows and hold a web
# threadpool slot for that whole time.
# Wall clock one node may spend on blocking parses, shared across all of them. Without it a
# node could serialize WORKFLOW_NODE_EXTRACT_MAX_FILES full windows on a web threadpool slot.
WORKFLOW_NODE_EXTRACT_BUDGET_SECONDS: int = 900
# A workflow run row is pre-created as ``running`` and finalized when its
# generator completes; a client disconnect or worker crash can strand it in
# ``running`` forever. The beat reaper fails runs still ``running`` past this
# many seconds. Generous so a legitimately long run is never cut off.
# A run row is pre-created as ``running``; a disconnect or crash can strand it there. The
# beat reaper fails runs still ``running`` past this. Generous so a long run is never cut off.
WORKFLOW_RUN_STALE_SECONDS: int = 3600
# Runner container resource caps — consumed by the docsgpt-sandbox compose
# service (deployment/sandbox), not by the app client. cgroup CPU/mem caps
# are part of the untrusted-code security boundary.
# Runner container caps, consumed by the docsgpt-sandbox compose service, not the app.
# These cgroup limits are part of the untrusted-code security boundary.
SANDBOX_MEMORY: str = "1g" # docker mem_limit for the runner container
SANDBOX_CPUS: str = "1.0" # docker cpu quota for the runner container
# Daytona Cloud managed backend (used only when SANDBOX_BACKEND="daytona").
# The app is a REST client of Daytona Cloud authenticated by DAYTONA_API_KEY;
# all knobs are optional so app import never fails when the backend is unused.
# Daytona Cloud backend (SANDBOX_BACKEND="daytona"). All knobs are optional so app import
# never fails when the backend is unused.
DAYTONA_API_KEY: Optional[str] = None # Daytona Cloud API key (secret)
DAYTONA_API_URL: Optional[str] = None # override Daytona API base URL, if self-targeting
DAYTONA_TARGET: Optional[str] = None # Daytona region/target, e.g. "us"
@@ -510,8 +432,7 @@ class Settings(BaseSettings):
DAYTONA_AUTO_STOP_INTERVAL: int = 15 # minutes idle before Daytona auto-stops a sandbox (0 disables)
DAYTONA_AUTO_DELETE_INTERVAL: int = 60 # minutes after stop before Daytona auto-deletes (-1 disables)
DAYTONA_MAX_SANDBOXES: int = 50 # cap on concurrent live Daytona sandboxes (cost-DoS guard)
# Per-user artifact quotas (generous defaults; enforced at persistence time).
# For all three, 0 (or any non-positive value) disables that quota (unlimited).
# Per-user artifact quotas, enforced at persistence time. 0 or negative disables a quota.
ARTIFACT_MAX_BYTES: int = 50 * 1024 * 1024 # cap on a single stored artifact version's bytes
ARTIFACT_MAX_COUNT_PER_USER: int = 5000 # cap on artifacts a user may own
ARTIFACT_MAX_TOTAL_BYTES_PER_USER: int = 5 * 1024 * 1024 * 1024 # cap on a user's total stored bytes
+11
View File
@@ -411,6 +411,17 @@ def main(argv: Optional[Sequence[str]] = None) -> int:
args = build_parser().parse_args(argv)
_log_setup(args.verbose)
# Embed in this process. ``EMBEDDINGS_DELEGATE_TO_WORKER`` exists to keep a
# model out of the API, which serves one query at a time and holds the
# model for nothing in between. This is the opposite case: a batch job that
# embeds every chunk in the index, where a broker round trip per batch adds
# latency and a dependency on a worker running. Loading the model here also
# means the script reports a real failure for a model it cannot load,
# instead of timing out against an empty queue.
if getattr(settings, "EMBEDDINGS_DELEGATE_TO_WORKER", False):
logger.info("Embedding in-process; worker delegation does not apply here.")
settings.EMBEDDINGS_DELEGATE_TO_WORKER = False
store_type = (settings.VECTOR_STORE or "").lower()
if store_type not in SUPPORTED_STORES:
logger.error(
+23 -14
View File
@@ -127,21 +127,30 @@ def ensure_vector_schema(*, logger: Optional[logging.Logger] = None) -> None:
PGVectorStore,
)
# Loading the model here is deliberate: the process loads it on first
# retrieval anyway, and EmbeddingsSingleton caches it.
dim: Optional[int] = None
try:
from application.vectorstore.base import get_embeddings
# All this needs is an integer, and for a model the registry describes that
# is a lookup. It used to construct the embeddings instance, which loaded
# ~800 MB of ONNX into every API and worker process at import purely to
# read ``.dimension`` off it.
from application.vectorstore.model_registry import dimension_for
dim = getattr(get_embeddings(), "dimension", None)
except Exception as exc: # noqa: BLE001 — never block boot on the model
log.warning(
"ensure_vector_schema: could not load the embeddings model (%s); "
"creating the table with %d dimensions and skipping the dimension "
"check.",
exc,
DEFAULT_EMBEDDING_DIM,
)
dim: Optional[int] = dimension_for(settings.EMBEDDINGS_NAME)
if dim is None:
# An unregistered model only reports its width once something has run
# it. Build it in-process rather than through ``get_embeddings``: at
# boot there is no Celery task in flight, so a delegating client would
# dispatch to a worker that may not be up yet.
try:
from application.vectorstore.base import build_local_embeddings
dim = getattr(build_local_embeddings(), "dimension", None)
except Exception as exc: # noqa: BLE001 — never block boot on the model
log.warning(
"ensure_vector_schema: could not load the embeddings model (%s); "
"creating the table with %d dimensions and skipping the dimension "
"check.",
exc,
DEFAULT_EMBEDDING_DIM,
)
if dim is None:
log.warning(
"ensure_vector_schema: the embeddings model exposes no dimension; "
+43
View File
@@ -270,6 +270,16 @@ def _azure_configured() -> bool:
)
def _delegation_enabled() -> bool:
"""True when this process should embed on the worker rather than locally.
Compared against ``True`` rather than coerced: tests patch ``settings``
with a ``MagicMock``, whose every attribute is a truthy object, and
``bool()`` on that would silently route them through the broker.
"""
return getattr(settings, "EMBEDDINGS_DELEGATE_TO_WORKER", False) is True
def get_embeddings(
embeddings_name: Optional[str] = None, embeddings_key: Optional[str] = None
):
@@ -279,6 +289,11 @@ def get_embeddings(
skip the remote dispatch and the OpenAI/Azure key handling. Route every
caller through here.
With ``EMBEDDINGS_DELEGATE_TO_WORKER`` this returns a client that runs the
model on the Celery worker, so an API process never loads one. The client
embeds locally when it finds itself inside a worker task, so the worker is
unaffected.
Args:
embeddings_name: Model name; defaults to ``settings.EMBEDDINGS_NAME``.
embeddings_key: API key; defaults to ``settings.EMBEDDINGS_KEY``.
@@ -287,6 +302,34 @@ def get_embeddings(
The shared embeddings instance for the resolved model.
"""
embeddings_name = embeddings_name or settings.EMBEDDINGS_NAME
if not settings.EMBEDDINGS_BASE_URL and _delegation_enabled():
cache_key = f"delegated_{embeddings_name}"
if cache_key not in EmbeddingsSingleton._instances:
from application.vectorstore.embeddings_delegated import DelegatedEmbeddings
EmbeddingsSingleton._instances[cache_key] = DelegatedEmbeddings(
embeddings_name, embeddings_key
)
return EmbeddingsSingleton._instances[cache_key]
return build_local_embeddings(embeddings_name, embeddings_key)
def build_local_embeddings(
embeddings_name: Optional[str] = None, embeddings_key: Optional[str] = None
):
"""Resolve the embeddings instance that runs in *this* process.
Bypasses worker delegation, so it is what the worker's embed task and the
boot hook use. Everything else should call :func:`get_embeddings`.
Args:
embeddings_name: Model name; defaults to ``settings.EMBEDDINGS_NAME``.
embeddings_key: API key; defaults to ``settings.EMBEDDINGS_KEY``.
Returns:
The shared in-process embeddings instance for the resolved model.
"""
embeddings_name = embeddings_name or settings.EMBEDDINGS_NAME
embeddings_key = (
embeddings_key if embeddings_key is not None else settings.EMBEDDINGS_KEY
)
@@ -0,0 +1,118 @@
"""Query embedding executed in the Celery worker instead of in the API.
The API embeds every query it serves, so it needs an embedder -- and a local
one costs roughly 800 MB of ONNX Runtime per process. That is the whole
footprint of an API container that otherwise holds no model.
This client keeps the interface (``embed_query``/``embed_documents``/
``dimension``) and moves only the computation: the text goes to the worker over
Celery and the vector comes back. The API pays a broker round trip per query
and no resident model.
Inside a worker there is nothing to delegate to -- dispatching would queue work
behind the task already running and wait on itself -- so a call made while a
task is executing runs locally, on a model this process loads once and caches.
``DOCUMENT_PARSE_QUEUE`` exists for the same reason on the parsing side.
Production deployments should point ``EMBEDDINGS_BASE_URL`` at a real embedding
service instead: that removes the model from *both* processes and costs a
network hop rather than a broker round trip.
"""
from __future__ import annotations
import logging
from typing import Any, List, Optional
from application.core.settings import settings
from application.vectorstore.model_registry import dimension_for
logger = logging.getLogger(__name__)
#: Dispatched by name so the API never imports the task module -- and through
#: it ``application.worker``, which pulls in the whole parsing stack.
EMBED_TASK = "application.vectorstore.embeddings_tasks.embed_texts"
def _in_worker() -> bool:
"""True when a Celery task is executing in this process."""
try:
from application.celery_init import celery
return celery.current_worker_task is not None
except Exception:
return False
class DelegatedEmbeddings:
"""Embeds by dispatching to the Celery worker, or locally inside one."""
def __init__(self, embeddings_name: str, embeddings_key: Optional[str] = None) -> None:
self.embeddings_name = embeddings_name
self.embeddings_key = embeddings_key
self._local: Any = None
self._dimension: Optional[int] = dimension_for(embeddings_name)
def _local_embeddings(self):
"""The in-process model, built once, for use inside a worker task."""
if self._local is None:
from application.vectorstore.base import build_local_embeddings
self._local = build_local_embeddings(self.embeddings_name, self.embeddings_key)
return self._local
def _dispatch(self, texts: List[str]) -> List[List[float]]:
"""Run the embed task on the worker and wait for its vectors."""
from application.celery_init import celery
queue = getattr(settings, "EMBEDDINGS_QUEUE", "embeddings")
timeout = getattr(settings, "EMBEDDINGS_DELEGATE_TIMEOUT", 60)
result = celery.send_task(EMBED_TASK, args=[texts, self.embeddings_name], queue=queue)
try:
return result.get(timeout=timeout)
except Exception as exc:
raise RuntimeError(
f"Embedding request to the Celery worker timed out or failed ({exc}). "
f"A worker must be consuming the {queue!r} queue for retrieval to "
"work. Start one, point EMBEDDINGS_BASE_URL at an embedding "
"service, or set EMBEDDINGS_DELEGATE_TO_WORKER=false to load the "
"model in this process instead."
) from exc
def embed_documents(self, documents: List[str]) -> List[List[float]]:
"""Embed a list of texts, preserving order."""
if not documents:
return []
if _in_worker():
return self._local_embeddings().embed_documents(documents)
vectors = self._dispatch(list(documents))
if self._dimension is None and vectors:
self._dimension = len(vectors[0])
return vectors
def embed_query(self, query: str) -> List[float]:
"""Embed a single query string."""
return self.embed_documents([query])[0]
@property
def dimension(self) -> Optional[int]:
"""Vector width, from the registry where possible.
Falls back to one round trip for a model the registry does not
describe, and to ``None`` when even that fails -- callers already treat
an unknown width as "nothing to compare yet" rather than an error.
"""
if self._dimension is None:
try:
self._dimension = len(self.embed_query("dimension probe"))
except Exception as exc:
logger.warning("Could not determine embedding width: %s", exc)
return None
return self._dimension
def __call__(self, text):
if isinstance(text, str):
return self.embed_query(text)
elif isinstance(text, list):
return self.embed_documents(text)
raise ValueError("Input must be a string or a list of strings")
+10 -9
View File
@@ -312,20 +312,21 @@ class EmbeddingsWrapper:
def embed_documents(self, documents: List[str]) -> List[List[float]]:
"""Embed a list of documents, preserving input order.
Inputs are grouped by length before batching. ONNX needs a rectangular
tensor, so every input in a batch is padded up to the longest one in
it; with mixed lengths that padding is most of the work. Grouping
similar lengths together measured 19% faster and 45% lower peak memory
on production-sized chunks, and at full context an unsorted batch of 4
was slower than no batching at all.
Batched by ``EMBEDDINGS_MODEL_BATCH_SIZE``, not by the pipeline's
``EMBEDDINGS_BATCH_SIZE``: one is documents per forward pass, the other
is chunks per store transaction, and sizing the forward pass from the
transaction is what made ingest peak at 6.6 GB.
The original order is restored before returning, so callers zipping
these against their texts are unaffected.
Inputs are grouped by length first. ONNX needs a rectangular tensor, so
every input in a pass is padded up to the longest one in it; with mixed
lengths that padding is most of the work. The original order is
restored before returning, so callers zipping these against their texts
are unaffected.
"""
if not documents:
return []
batch_size: Optional[int] = None
raw = getattr(settings, "EMBEDDINGS_BATCH_SIZE", None)
raw = getattr(settings, "EMBEDDINGS_MODEL_BATCH_SIZE", None)
if isinstance(raw, int) and not isinstance(raw, bool) and raw > 0:
batch_size = raw
@@ -0,0 +1,29 @@
"""The Celery task behind :mod:`application.vectorstore.embeddings_delegated`.
Kept out of ``application.api.user.tasks`` deliberately: that module imports
``application.worker`` and the whole parsing stack with it, which is the
opposite of what delegation is for.
"""
from __future__ import annotations
from typing import List, Optional
from application.celery_init import celery
from application.vectorstore.embeddings_delegated import EMBED_TASK
@celery.task(name=EMBED_TASK, acks_late=False, ignore_result=False)
def embed_texts(texts: List[str], embeddings_name: Optional[str] = None) -> List[List[float]]:
"""Embed ``texts`` with the worker's local model.
Args:
texts: Strings to embed.
embeddings_name: Model to use; the configured one when omitted.
Returns:
One vector per input, in input order.
"""
from application.vectorstore.base import get_embeddings
return get_embeddings(embeddings_name).embed_documents(list(texts))