# Runtime # Verbosity of the app's own loggers (api.* and eval.*): CRITICAL/ERROR/WARNING/INFO/DEBUG. # Applied via dictConfig at app startup; does not touch uvicorn's own loggers. LOG_LEVEL=INFO # text (default) or json: one JSON object per line, with the request id. LOG_FORMAT=text # API # Host port and address docker-compose publishes the API on (compose-only; AppSettings # has no such fields — inside the container the API always binds 0.0.0.0:8000 per the # Dockerfile CMD). The address defaults to loopback: set API_BIND=0.0.0.0 to reach the # UI from other devices, and set API_KEY when you do. API_PORT=8000 API_BIND=127.0.0.1 # Optional single shared API key. Empty (default) leaves every route open. When set, # every /documents* route (list, upload, delete, content, original, jobs, reindex, # tags), /questions*, /search, /traces*, /feedback, /export, /import, /access, # /metrics and the OpenAPI docs require a matching X-API-Key header # (checked before a request body is read) — so a Prometheus scraper needs the # header in its scrape config. Only /health, /health/ready, and /config stay open, so # container healthchecks, probes, and the UI's own boot request (which has to succeed # before there's anywhere to type a key) keep working either way. # Not multi-user auth — one key for the whole API. API_KEY= # Optional read-only key (needs API_KEY too, and must differ): it can ask questions and # read documents, traces and jobs, but uploading, deleting, re-indexing, tagging, # GET /export, POST /import, GET /feedback and /metrics answer 403 to it. For sharing the UI with # people who should use the corpus but not change it. API_READ_KEY= # Qdrant QDRANT_IMAGE=qdrant/qdrant:v1.19.1 # Host ports compose publishes Qdrant on. QDRANT_GRPC_PORT is also the port the API # dials for gRPC when it runs on the host (in compose the API always uses 6334). QDRANT_PORT=6333 QDRANT_GRPC_PORT=6334 QDRANT_URL=http://qdrant:6333 QDRANT_COLLECTION=docrag_documents QDRANT_DENSE_VECTOR_NAME=dense QDRANT_SPARSE_VECTOR_NAME=sparse QDRANT_DENSE_VECTOR_SIZE=384 # gRPC avoids per-call JSON (de)serialization versus Qdrant's REST API. Requires the # gRPC port above to be reachable from wherever the API process runs. QDRANT_PREFER_GRPC=false # Qdrant's own API key. Compose publishes Qdrant's ports on 127.0.0.1 only; set this # (and uncomment QDRANT__SERVICE__API_KEY in docker-compose.yml, which makes Qdrant # require it) if anything else can reach them. Empty (default): no key is sent. QDRANT_API_KEY= # Retrieval RRF_K=60 DENSE_RETRIEVAL_LIMIT=50 SPARSE_RETRIEVAL_LIMIT=50 FUSED_TOP_N=50 # How many of the fused top-N candidates are actually sent through the (expensive) # cross-encoder reranker. Clamped to FUSED_TOP_N — lower this to cut rerank latency # without changing how many candidates fusion itself produces. # # This is the single largest latency knob in the whole query path, and its cost is # LINEAR in this value. Measured on an 80-chunk corpus, 8 CPU cores in Docker, against # the default reranker: 50 candidates cost ~52s of a ~66s question (79% of total # latency), 20 cost ~15s. With RERANKER_MODEL=Xenova/ms-marco-MiniLM-L-6-v2 the same # 20 cost ~2.6s. Selected context chunks were unchanged at 20 vs 50 on that corpus, so # the coverage given up was real but unused — re-check that on your own corpus before # assuming it holds, because a lower value can only ever narrow what rerank can see. RERANK_CANDIDATES=50 RERANK_TOP_K=8 # Query-document pairs scored per cross-encoder forward pass. This value ALONE bounds # the reranker's peak memory — not its ratio to RERANK_CANDIDATES. Measured over 50 # candidates of ~448 tokens: batch 8 peaked at 2.66 GB (the loaded-model baseline, so # scoring costs nothing extra), batch 16 at 3.53 GB, batch 50 at 6.33 GB, which # OOM-kills the container on a default ~6 GB Docker Desktop. Latency is flat across the # whole range, so a bigger batch buys no speed and no answer quality. Raise it only # against known memory headroom; the API logs a warning at startup past 8. # Keeping it <= RERANK_CANDIDATES is a separate, weaker rule: above that batching is # just a no-op. Satisfying it does not make you safe — batch 50 with 50 candidates # satisfies it and still peaks at 6.33 GB. RERANKER_BATCH_SIZE=8 # Rerank scores memoized per (query, passage). Regenerate/Retry re-ask the identical # query, so a cache hit skips the costliest stage entirely. 0 disables. RERANK_CACHE_SIZE=4096 # ONNX Runtime threads per session; 0 = ORT default (all cores). Lowering these makes a # single question SLOWER — they exist to stop ingest embedding and query-time rerank # oversubscribing the same cores when uploads and questions overlap. EMBEDDING_THREADS=0 RERANKER_THREADS=0 MAX_CONTEXT_CHUNKS=6 # After rerank, each selected chunk's generation context is expanded with up to this # many neighboring chunks on each side (small-to-big retrieval). 0 disables it. CONTEXT_NEIGHBOR_RADIUS=1 # Caps the neighbour text added on each side of a chunk, in characters (the text # nearest the chunk is kept): less prompt, so the first token comes sooner. 0 = no cap. CONTEXT_NEIGHBOR_MAX_CHARS=0 # Post-rerank dedup: drop a survivor that near-duplicates a higher-ranked kept chunk # (same-doc ordinal neighbor, or word-shingle Jaccard >= the similarity below), refilling # from lower-ranked survivors. Pure selection — rerank scores/order are untouched. CONTEXT_DIVERSITY_ENABLED=true CONTEXT_DIVERSITY_MAX_SIMILARITY=0.6 # Cross-encoder logits are sigmoid-normalized to [0, 1] before comparing against this # floor; chunks scoring below it never reach the LLM context or citations, however # high they ranked in fusion. 0.15 blocks clearly off-topic chunks while still # letting paraphrase-level (not exact-phrase) matches through — see api/settings.py # for the real-world calibration trace behind this default. Measured on the handbook # corpus (eval/datasets/*_eval.jsonl, jina int8): questions it can't answer top out at # ~0.04 (off-topic) and ~0.065 (on-topic, e.g. "What dental plan do we get?"), while # loose paraphrases of an answer score 0.08-0.10 — 0.15 drops those (3/8 of # paraphrase_eval.jsonl found at 0.15, 7/8 at 0.07). Lower it only after measuring on # your own corpus: the margin is thin, and int8 scores shift slightly between CPUs. # Set for the default reranker; another model's scores sit on another scale (the # server warns at startup when RERANKER_MODEL changes and this doesn't). RERANK_MIN_SCORE=0.15 # When no passage clears RERANK_MIN_SCORE, this many of the best-ranked ones are sent to # the model anyway, marked as weak matches: it answers from them only if they state the # answer, else it says the documents don't cover it (the answer carries weak_matches, # shown as "weak match"). Each reranker scores on its own scale, so a fixed floor # refused questions the documents answer ("what are foundational models": 0.006 with # MiniLM, 0.154 with jina). 0 refuses at once without asking the model. RERANK_FALLBACK_COUNT=3 # Opt-in prompt trimming (0 = off): of the passages over RERANK_MIN_SCORE, keep those # within this share of the way from the median candidate's score to the best one, and # always the best two. Any reranker's scale; never causes a refusal. RERANK_RELATIVE_FLOOR=0 # Favour newer documents among the passages kept for an answer: ordered by rerank score # plus this x how recent the document is (1 the newest kept, 0 a year or more older). # Never adds or drops a passage. 0 = off. RECENCY_BOOST_WEIGHT=0 # Local embedding and reranking (embeddings always run locally; there is no # provider switch here by design — see CLAUDE.md's provider rules) DENSE_EMBEDDING_MODEL=BAAI/bge-small-en-v1.5 SPARSE_EMBEDDING_MODEL=Qdrant/BM25 # jina-reranker-v2-base-multilingual is the strongest cross-encoder fastembed # supports. BAAI/bge-reranker-v2-m3 is NOT loadable by fastembed at any version — do # not set it here. # # It is also, by a wide margin, the most expensive stage of a question. Measured on a # 158-chunk corpus, 7 questions (5 English, 1 Spanish, 1 French), Docker on an M1: # jina fp32 20 cands 11.7s 50 cands 31.2s 2.3 GB # jina int8 20 cands 4.6s 50 cands 12.7s 1.4 GB 81-83% same top-6, 0/7 lost # MiniLM-L-6 20 cands 1.7s 50 cands 4.3s 0.8 GB 43-45% same top-6, 2/7 lost # ("lost" = no candidate cleared RERANK_MIN_SCORE, so the answer was "insufficient # context"; one of MiniLM's was the Spanish question — it is English-only). Different # cross-encoders put logits on different scales, so if you switch models re-check how # many chunks clear RERANK_MIN_SCORE (GET /traces/{id} shows rerank_score and # drop_reason) rather than assuming 0.15 transfers. RERANKER_MODEL=jinaai/jina-reranker-v2-base-multilingual # Which ONNX export to load. "auto": jina's int8 export (~0.28 GB download vs 1.1 GB # fp32), fastembed's registered file for any other model. Empty: always the registered # file (jina: fp32 onnx/model.onnx). Or a path in the model repo, e.g. # onnx/model_quantized.onnx. RERANKER_ONNX_FILE=auto EMBEDDING_MODEL_TAG=fastembed:BAAI/bge-small-en-v1.5 # Prepended to the dense query (not the corpus) before embedding. Defaults to # bge-small's recommended instruction, "Represent this sentence for searching relevant # passages: " (with its trailing space, which an env file line can't show reliably). # Query-side only, so changing it needs no re-ingest. Set empty to disable. # DENSE_QUERY_INSTRUCTION= # Prepended to corpus text only, before dense embedding ("passage: " for e5 models). DENSE_PASSAGE_PREFIX= # BM25's stemmer language (english, german, french, spanish, italian, dutch, ...). SPARSE_LANGUAGE=english # # Multilingual profile (opt-in; a new vector space, so re-index — see README # "Multilingual documents"). Either model, with the matching tag and size: # DENSE_EMBEDDING_MODEL=sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2 # EMBEDDING_MODEL_TAG=fastembed:paraphrase-multilingual-MiniLM-L12-v2 # QDRANT_DENSE_VECTOR_SIZE=384 # DENSE_QUERY_INSTRUCTION= # or, stronger and heavier (2.2 GB, ~100 languages): # DENSE_EMBEDDING_MODEL=intfloat/multilingual-e5-large # EMBEDDING_MODEL_TAG=fastembed:multilingual-e5-large # QDRANT_DENSE_VECTOR_SIZE=1024 # DENSE_QUERY_INSTRUCTION=query: # DENSE_PASSAGE_PREFIX=passage: # and SPARSE_LANGUAGE for the corpus's main language. The reranker is multilingual already. # Generation via LiteLLM: ollama (default), openai, openai_compatible or anthropic (below) LLM_PROVIDER=ollama # Providers a question may switch to per request, as a JSON list. Unset: any configured # provider (LLM_PROVIDER is always allowed). Set it to stop callers picking a paid one: # ALLOWED_REQUEST_PROVIDERS=["ollama"] LLM_MODEL=llama3.1:8b LLM_TEMPERATURE=0 LLM_MAX_TOKENS=512 # Bounds each LLM call only (condense, generation, citation retry), not the whole # question — retrieval and reranking run before it, so a question can take longer # than this before the timeout error arrives. Raise it for larger local models. The UI # waits on streamed answers while tokens keep arriving (STREAM_IDLE_TIMEOUT_MS, 180s # of silence); only the non-streaming path is capped at LONG_RUNNING_TIMEOUT_MS # (frontend/src/api/client.ts), so raise that too if you use it. LLM_REQUEST_TIMEOUT_SECONDS=60 # Ollama and OpenAI-compatible servers send nothing while they read the prompt, so a # long prompt on modest hardware hit the timeout above before its first token. Each # local call's timeout adds its estimated prompt tokens divided by this rate (or the # slower rate this server has been measured at), e.g. 60s + 6000/40 = 210s for a long # question. 0 = a flat LLM_REQUEST_TIMEOUT_SECONDS. Hosted providers never scale. LLM_PREFILL_TOKENS_PER_SECOND=40 LLM_NUM_RETRIES=0 # host.docker.internal only resolves from *inside* a Docker container. If the API # runs directly on your host (not via `docker compose`), set this to # http://localhost:11434 instead — the value below is for the Docker Compose path. OLLAMA_BASE_URL=http://host.docker.internal:11434 # How long Ollama keeps the model loaded in memory after the last request. Without # this, Ollama's own 5-minute default unloads the model and the next question pays a # multi-second reload; raise further for always-on deployments, lower to free RAM # sooner on shared/constrained hosts. OLLAMA_KEEP_ALIVE=30m # Context window (tokens) requested from Ollama; 0 lets Ollama choose (recent Ollama # uses the model's full trained context, e.g. 32768 for qwen2.5). This is a memory # knob, not a speed knob: on an M1, 8192 left prefill/decode speed unchanged but cut # qwen2.5's resident size from 8.2 GB to 5.3 GB. Too small truncates the FRONT of the # prompt — the grounding rules go first — so keep room for 6 context chunks plus # history (~8192 minimum). When set, the prompt is also fitted to it (see below). OLLAMA_NUM_CTX=0 # LLM calls this process sends Ollama at once; the rest wait here (the UI shows # "waiting for the model") and their timeout starts only once sent. Ollama answers one # request at a time by default, and a request queued inside Ollama timed out unread. # Raise it with Ollama's own OLLAMA_NUM_PARALLEL; 0 = no limit. OLLAMA_MAX_PARALLEL=1 # The generation model's context window in tokens. When known — this, or OLLAMA_NUM_CTX # for Ollama — the prompt drops the lowest-ranked sources, then the oldest history, # until it fits with LLM_MAX_TOKENS to spare, instead of the model server cutting off # its front (the grounding rules). 0 (default): OLLAMA_NUM_CTX for Ollama; for OpenAI and # Anthropic models, the window LiteLLM's model table lists; else no fitting. LLM_CONTEXT_WINDOW=0 # Opt-in (0 = off): the most estimated tokens a question's prompt may take. Over it, # neighbour text goes first, then the lowest-ranked passages, then the oldest history. # A local model's wait before the first token is mostly reading the prompt. PROMPT_TOKEN_BUDGET=0 # Dense vectors also kept as int8 in RAM ("scalar": about a quarter of the memory for a # big corpus), with the top candidates re-scored against the full vectors (oversampled # this many times). "none" (default): full vectors only. Applied in place, no re-index. QDRANT_QUANTIZATION=none QDRANT_QUANTIZATION_OVERSAMPLING=2.0 # Finished answers are cached per process for repeat questions (same question, scope, # settings and corpus), skipping retrieval and the LLM. Any upload, delete or tag # change invalidates every entry; questions with chat history are never cached, and # Regenerate always asks again. 0 turns the cache off. ANSWER_CACHE_SIZE=256 ANSWER_CACHE_TTL_SECONDS=3600 # Questions answered at once. Past this a question gets 429 + Retry-After immediately # instead of tying up a request thread for a minute. 0 = no cap. MAX_CONCURRENT_QUESTIONS=4 # Of those, how many may be summaries or comparisons, which run for minutes; the rest # stay free for questions. 0 = no separate cap. MAX_CONCURRENT_SUMMARIES=2 # The language answers and summaries are written in: empty = the model's choice, # "match" = the question's language (useful for a corpus in several languages), or a # language name such as German. ANSWER_LANGUAGE= # How answers are laid out: default (the model's choice), bullets, table (a Markdown # table where the content allows it) or brief (two or three sentences). A question can # ask for another. Never replaces the grounding rules. ANSWER_FORMAT=default # Requests per minute per client that change something (questions, searches, uploads, # feedback, deletes...) or carry a wrong API key; past it, 429 + Retry-After. Reads and # upload progress polls are never limited. Behind a reverse proxy, set # FORWARDED_ALLOW_IPS below so the client is the real one, not the proxy. 0 = off. RATE_LIMIT_PER_MINUTE=0 # Read by uvicorn, not DocRAG: the reverse proxy addresses whose X-Forwarded-For header # to trust, comma-separated (in Docker, the proxy as the API container sees it — often # the compose network's gateway). Unset, every request appears to come from the proxy, # so all users share one rate-limit bucket and logs show the proxy's address. # FORWARDED_ALLOW_IPS=172.18.0.1 # Optional self-hosted OpenAI-compatible server (vLLM, llama.cpp's server, LM Studio, # LocalAI), used with LLM_PROVIDER=openai_compatible or picked per question in the UI. # The base URL is the one ending in /v1; the key is optional for most local servers. OPENAI_COMPATIBLE_BASE_URL= OPENAI_COMPATIBLE_MODEL= OPENAI_COMPATIBLE_API_KEY= # Optional OpenAI generation OPENAI_API_KEY= OPENAI_MODEL=gpt-4o-mini # Reasoning models (gpt-5 family, o-series) spend hidden reasoning tokens from the # same budget as the visible answer. OPENAI_REASONING_EFFORT tunes that spend # (minimal|low|medium|high, or empty for the provider default); it is only sent to # models LiteLLM reports as reasoning-capable. REASONING_TOKEN_HEADROOM is added on # top of LLM_MAX_TOKENS for those models so reasoning can't starve the answer — with # a 512 budget, reasoning alone consumes it all and the answer comes back empty. OPENAI_REASONING_EFFORT=low REASONING_TOKEN_HEADROOM=3072 # Separate corpora on one server, chosen per request (the X-Workspace header; the UI's # workspace menu). Each is its own Qdrant collection and originals directory. Off by # default; without the header the default workspace (QDRANT_COLLECTION) is used. WORKSPACES_ENABLED=false # Keep the index in step with a folder (see WATCH_WORKSPACE). Off when empty. Each # WATCH_INTERVAL_SECONDS, new or changed supported files are indexed like uploads; # WATCH_DELETE_MISSING also deletes documents whose files were removed from it. WATCH_DIR= WATCH_INTERVAL_SECONDS=30 WATCH_DELETE_MISSING=false # A file whose read or indexing job fails is tried again after WATCH_INTERVAL_SECONDS # x 2^n (at most WATCH_RETRY_MAX_SECONDS), up to WATCH_MAX_RETRIES times; after that it # waits until the file changes. GET /watch lists what is retrying and what gave up. WATCH_MAX_RETRIES=3 WATCH_RETRY_MAX_SECONDS=3600 # Comma-separated glob patterns: a name ("*.py") or a path under WATCH_DIR ("drafts/*"). # WATCH_INCLUDE keeps only matching files; WATCH_EXCLUDE leaves matching files (and # directories, by name: "node_modules") out — e.g. *.py,*.js,*.ts,*.json,*.yaml for a # folder that also holds code. Excluded files are never deleted. Empty = every file. WATCH_INCLUDE= WATCH_EXCLUDE= # The workspace it indexes into (needs WORKSPACES_ENABLED; created at startup if # missing). Empty = the default workspace. WATCH_WORKSPACE= # Index a document from a URL (POST /documents/from-url; the corpus panel's "From URL"). # Off by default. The server fetches it, so only public addresses are allowed unless # URL_INGEST_ALLOW_PRIVATE is set (a private address could reach Qdrant or cloud # metadata from inside the deployment). MAX_UPLOAD_BYTES caps the download. URL_INGEST_ENABLED=false URL_INGEST_ALLOW_PRIVATE=false URL_INGEST_TIMEOUT_SECONDS=30 # Fetch URL-ingested documents again in the background, each one whose last check is # URL_REFRESH_INTERVAL_MINUTES old, at most URL_REFRESH_BATCH per pass (one pass a # minute). A conditional request: an unchanged page (304, or the same bytes) isn't # re-indexed. 0 = off. URL_REFRESH_INTERVAL_MINUTES=0 URL_REFRESH_BATCH=4 # A sqlite file for when each document was last checked, and failures in a row (a # failing document waits twice as long each time, up to a day): a restart then doesn't # make every document due at once. Empty = kept in memory. URL_REFRESH_STATE_PATH= # Whole-document summaries and comparisons (POST /questions/summary). A document is # read in parts of at most SUMMARY_BATCH_CHARS characters (fewer when LLM_CONTEXT_WINDOW # or OLLAMA_NUM_CTX is set), at most SUMMARY_MAX_BATCHES parts per request (each part is # one LLM call; a longer document is read at evenly spaced passages). # SUMMARY_MAX_TOKENS is the final answer's output budget. SUMMARY_BATCH_CHARS=9000 SUMMARY_MAX_BATCHES=8 SUMMARY_MAX_TOKENS=1024 # Parts turned into notes at once with a hosted provider (Ollama: always one at a time), # and the most documents one brief ("mode": "brief") may cover (at most 10; lowered to # SUMMARY_MAX_BATCHES when that is smaller, since each document takes at least one part). SUMMARY_PARALLEL_PARTS=4 SUMMARY_MAX_DOCUMENTS=5 # Optional Anthropic (Claude) generation, used with LLM_PROVIDER=anthropic or picked # per question in the UI. Hosted and optional: uploads and indexing never call it. ANTHROPIC_API_KEY= ANTHROPIC_MODEL=claude-sonnet-5-5 # Document ingestion MAX_UPLOAD_BYTES=52428800 # Largest backup zip POST /import accepts (each original inside: MAX_UPLOAD_BYTES). MAX_IMPORT_BYTES=268435456 # CHUNK_SIZE_TOKENS counts the dense model's real subword tokens (the chunker uses the # model's own tokenizer). Windows never exceed this budget minus the contextual-embedding # prefix, so the embedded string stays under bge-small's 512-token limit. Changing this # alters chunk contents — re-upload documents afterward so old/new chunks stay consistent. CHUNK_SIZE_TOKENS=448 CHUNK_OVERLAP_TOKENS=100 # Markdown heading sections under this word count are folded into a neighboring # section before chunking, instead of becoming their own low-information chunk. MIN_SECTION_WORDS=40 # A CSV upload is grouped into sections of this many data rows for citation labeling. CSV_ROWS_PER_SECTION=50 # A PDF with more pages than this is refused before parsing. MAX_PDF_PAGES=2000 # Only this many text-less (scanned) pages of one PDF are OCR'd; the rest are skipped. MAX_OCR_PAGES=100 # An XLSX workbook with more cells than this (all sheets together) is refused. MAX_SPREADSHEET_CELLS=200000 # Seconds an upload may take to parse, in its own process, before it is stopped and # the job fails. 0 parses inside the API process with no limit. PARSE_TIMEOUT_SECONDS=300.0 # Chunks are embedded and upserted in batches of this size during ingestion, so a # single huge document reports incremental job progress instead of one giant call. INGEST_BATCH_SIZE=64 # Points written to Qdrant per upsert call (independent of the embedding batch above). INGEST_UPSERT_BATCH_SIZE=256 # Re-indexing a changed document embeds only the chunks that changed; the others keep # their stored vectors. false = embed every chunk again. INGEST_REUSE_VECTORS=true # Re-index stale documents in the background (needs RAW_DOCUMENT_DIR): after an # upgrade that changes chunking, documents are brought up to date without clicking # "re-index all", at most AUTO_REINDEX_BATCH jobs at a time so uploads still get through. AUTO_REINDEX_STALE=false AUTO_REINDEX_BATCH=4 # With JOB_STORE_BACKEND=sqlite, a re-index or URL fetch that a restart cut off is # started again once at startup (as its Retry would); uploads can't be (their bytes # weren't kept). REQUEUE_INTERRUPTED_JOBS=true # Scheduled backups: the same zip GET /export downloads, of every workspace, written to # BACKUP_DIR//docrag-