Skip to content

Production deployment

bibr chew processes files and directories locally. For production use, batch processing, or team-shared infrastructure, bibr serve runs the same pipeline behind an HTTP API — with an external OCR server and optional Redis caching. This guide covers running it: the API itself, its concurrency model, authentication, the OCR server it depends on, the bundled Docker stack, reference extraction at scale, and hardware sizing.

One-machine API

bibr serve

This starts a LitServe application exposing the full extraction pipeline over HTTP, bound by default to loopback at 127.0.0.1:8000. Two flags control the bind address:

Flag Description Default
--host Host to bind to 127.0.0.1
--port Port to bind to 8000
bibr serve --host 127.0.0.1 --port 9000

Binding to a non-loopback address is refused unless AUTH_API_KEY is set and is at least 32 characters long. Core installs run local layout and classifiers through ONNX Runtime. The bundled serve image selects the PyTorch runtime; see local model runtime. Add cache to use Redis and mcp for the optional agent endpoint.

The REST surface itself — /papers/extract, the async job API, health and readiness probes, request/response shapes, caching, and error codes — is documented in the REST API reference; this page focuses on running the server. /ready checks the OCR endpoint, configured classifier artifacts, and Redis when response caching is enabled. Without a valid bearer token, it reports only the overall status; authenticated callers also receive the individual checks and BIBR_BUILD_SHA. The OCR, Redis and job-store results are reused for 2 seconds, so probe traffic, which needs no credentials, cannot multiply requests to those services.

Upload ingress and worker handoff

POST /papers/extract is the public multipart/form-data ingress. LitServe's underlying POST /_bibr/inference route is private descriptor dispatch, not a second upload API; direct HTTP requests to it always receive 404.

The API process accepts exactly one file part and the nine documented option fields, each at most once; text fields it does not know are ignored, up to 25 text parts in all. A duplicate field, a second file, more text parts or any option value over 64 bytes is rejected with 400 before descriptor creation. Starlette retains at most 1 MiB of the one file in API memory by default before its multipart spool rolls to disk, then bibr streams it into an owner-only temporary directory. The default limits are separate:

Variable Default Applies to
PIPELINE_MAX_FILE_SIZE 50 MiB File bytes after multipart parsing
PIPELINE_MULTIPART_OVERHEAD_BYTES 1 MiB Extra headroom for boundaries, headers, and form fields
PIPELINE_UPLOAD_SPOOL_MEMORY_BYTES 1 MiB Upload bytes retained in API memory before disk spill

Consequently, the default complete multipart-body envelope is 51 MiB while the file itself remains capped at 50 MiB. Increasing envelope headroom does not increase the accepted file size. With MCP_ENABLED=true the envelope grows to fit a 50 MiB file in base64 form (about 68 MiB), because chew_paper carries its upload inside a JSON-RPC body; the file limit is unchanged.

Only an opaque descriptor crosses LitServe's multiprocessing queue: the canonical UUID, filename, byte size, SHA-256, and extraction options. Neither file content nor a filesystem path is queued. The worker derives the owned path from the UUID, opens only a regular non-symlink file, reads and verifies it once, and deletes it after that read even when integrity validation fails. The API-side store leases each persisted UUID through synchronous dispatch or the complete queued-job lifetime. Stale sweeping skips leased entries and only removes unowned orphans; remove, completion, failure, queued discard, and shutdown release ownership. The original Starlette spool is closed immediately after private persistence, before long-running inference begins. Graceful application shutdown removes remaining entries and the temporary root.

PIPELINE_RESTART_WORKERS=false is the safe default. On LitServe 0.2.17, a replacement worker cannot reliably notify the API process whose in-flight request belonged to the dead worker, so transparent replacement can strand that request. The default therefore fail-stops the API and inference processes; use an external supervisor to restart the service. Setting the variable to true is an unsupported opt-in until the locked real-process compatibility gate proves reliable death-path completion notification. It does not provide durable request replay: uploads and dispatch remain process-local. Redis-backed job status/results can survive an instance restart, but do not replay a lost upload.

The private manager/worker startup and fail-stop death contracts are covered by real spawned-process compatibility tests. Keep LitServe constrained to <0.3; deliberately migrate those tests together with the private startup helpers before allowing 0.3 or newer.

Concurrency model

BibrPipelineAPI runs as a single async worker that serves many requests concurrently on one event loop, overlapping the I/O-bound stages (OCR/LLM/Crossref HTTP calls) instead of blocking one request at a time. The GPU-bound stages — layout detection and sentence segmentation — don't duplicate this way: they route through a shared GpuBatcher (one collector coroutine + one worker thread per model) that serializes forward passes and coalesces concurrent requests' pages/texts into fuller batches, so peak VRAM stays bounded to one batch regardless of how many requests are in flight. When a batch fails, each of its items is run once on its own, so a page or text that breaks the model fails only the request it came from.

Scale concurrency with these settings:

Variable Default Meaning
PIPELINE_MAX_ACTIVE_UPLOADS 8 Fail-fast API-server admission cap applied before multipart parsing; excess uploads receive 429. Also covers the MCP chew tools: a large /mcp body holds a slot while it is received, and the tool holds one during extraction.
PIPELINE_MAX_INFLIGHT_REQUESTS 8 Max requests running the pipeline concurrently per worker (0 = unlimited). Bounds peak host RAM (page images) under an upload flood.
PIPELINE_RESTART_WORKERS false Fail-stop on worker death. true is an unsupported opt-in until the locked death-path gate proves reliable completion notification.
PIPELINE_MAX_PAGES 200 Hard per-file processing cap for compact many-page PDF protection.
LAYOUT_MAX_RENDER_PIXELS 25000000 Pixel budget for one rasterized PDF page. A page over it (or over LAYOUT_MAX_RENDER_DIMENSION on a side) at the configured DPI renders at the largest DPI that fits, down to 24, with a PAGE_DPI_REDUCED warning; a page that does not fit even at 24 DPI is rejected before rasterization.
LAYOUT_MAX_RENDER_DIMENSION 10000 Largest rasterized width or height for one PDF page, handled like LAYOUT_MAX_RENDER_PIXELS.
LAYOUT_BATCH_TIMEOUT_MS 5 Coalescing window for the layout GpuBatcher — how long it waits for more concurrent pages before flushing a partial batch.
SEGMENTER_BATCH_TIMEOUT_MS 5 Same coalescing window for the sentence segmenter's GpuBatcher.
LLM_MAX_CONCURRENCY 6 in serve Shared limit for concurrent LLM calls. An explicit setting overrides the server default.
JOBS_MAX_RUNNING 2 Maximum async jobs dispatched to inference at once.
JOBS_MAX_ACTIVE 32 Maximum queued plus running async jobs.
JOBS_MAX_RETAINED 128 Maximum completed job results held in memory; oldest completed records are evicted.
JOBS_DEDUPE_INFLIGHT false Answer an upload whose file, filename and options match a job this replica still has queued or running with that job. Single-tenant deployments only: every caller shares the one API key.

PIPELINE_MAX_PAGES caps the requested processing range; raise it explicitly for longer documents and size memory for the concurrent file count. PIPELINE_TIMEOUT (default 300 seconds) bounds a whole extraction request, not only the pipeline run: time spent waiting for a PIPELINE_MAX_INFLIGHT_REQUESTS slot or for an identical extraction (the response cache's single-flight) counts against it, and a request that runs out answers 504.

Why there is no worker-count setting

bibr serve runs exactly one inference worker, pinned in build_server() and not configurable. That is a deliberate design point, not a conservative default, so scale with the async concurrency settings above instead.

Additional workers would duplicate model weights and runtime memory, and split the requests that the shared batchers combine. The supported scaling controls are async request concurrency, model batch sizes, and the coalescing windows.

PIPELINE_WORKERS_PER_DEVICE was removed. Left in an existing .env it is ignored rather than rejected, but it no longer does anything and should be deleted.

bibr serve always uses one HTTP API-server process, even when jobs are disabled or the inference-worker count is raised. The upload root, leases, dispatch tracker, admission accounting, readiness state, and the job queue are process-local and share one destructive cleanup lifecycle. To add capacity beyond one instance, run more instances of bibr serve — each with its own API process, inference worker, and upload root — behind a load balancer, and share job state between them through Redis as described next.

Multiple bibr-serve replicas

One instance scales by async concurrency (above). Past that, run several instances — replicas — behind a load balancer. /papers/extract needs nothing extra: each request is served entirely by the replica that receives it. The async job API does, because a status poll can land on a different replica than the upload did. JOBS_STORE=redis moves job state into a Redis that every replica shares:

Shared through Redis Stays on the replica that took the upload
Job status and timestamps (GET /papers/jobs/{id}) The uploaded file, in that replica's owner-only temporary directory
Results (GET /papers/jobs/{id}/result; stored zlib-compressed under their own key) Execution: the replica's own JOBS_MAX_RUNNING dispatcher runs the job through its inference worker
The active-job cap: JOBS_MAX_ACTIVE counts queued + running jobs across all replicas Admission (PIPELINE_MAX_ACTIVE_UPLOADS) and the in-flight limits
Retention: JOBS_TTL_SECONDS, JOBS_MAX_RETAINED, JOBS_MAX_RETAINED_BYTES bound the whole namespace, oldest first

Every job status carries replica, the id of the instance executing it.

# .env on every replica
JOBS_STORE=redis
# Redis for job state. Unset it to reuse the cache's REDIS_URL / REDIS_PASSWORD
# (the usual choice); set it to keep job state on a separate Redis or database.
# REDIS_PASSWORD is added to it when it names the same host and port as REDIS_URL.
JOBS_REDIS_URL=redis://redis-host:6379/1
JOBS_KEY_PREFIX=bibr:jobs   # identical on every replica that shares a namespace
JOBS_REPLICA_ID=api-1       # optional; defaults to <hostname>:<pid>

Point the load balancer at every replica's /ready and use plain round-robin; no session affinity is needed. A replica that cannot reach the job store reports "jobs_store": "error" (HTTP 503) there, and until Redis is back its job routes answer 503 {"detail": "job store unavailable"} — the upload is dropped and nothing is queued, so the client can simply retry. Every job-store call is bounded by REDIS_CONNECT_TIMEOUT_SECONDS + REDIS_SOCKET_TIMEOUT_SECONDS, the same budgets the response cache uses, so a stalled Redis cannot wedge a request. TLS termination, the bearer token, and /papers/extract are unchanged. Replicas that share a Redis but must not see each other's jobs (staging next to production, say) get distinct JOBS_KEY_PREFIX values.

A Redis blip as a job finishes does not lose its result: the replica retries recording the outcome with backoff for up to a minute.

Two consequences of keeping execution on the receiving replica:

  • Each queued/running job carries a one-minute lease in Redis that the replica executing it keeps renewing. A replica that crashes mid-job (or cannot reach Redis for longer than that minute) stops renewing; once the lease runs out, the next upload to any replica marks the job failed with 503 and {"detail": "replica lost the job before it finished", "error_code": "job_lost"} and frees its cap slot, so clients know to resubmit. Jobs accepted by a replica running a bibr release without job leases still wait for the 24-hour safety TTL (mixed versions during a rolling upgrade are fine). A clean shutdown fails the jobs it abandons at once, with 503 replica shut down before the job finished (it gives up after five seconds if Redis does not answer, and leaves the rest to the leases). Drain a replica before stopping it (stop routing new uploads to it, let its running jobs finish) to avoid even that.
  • Work spreads by which replica receives the upload, not by queue depth.

ROR request budget. ROR matching, which runs with enrichment on, keeps its request budget per process: each replica, like each bibr batch or bibr chew process, sends at most 1000 ROR requests per 5 minutes (2000 with ROR_CLIENT_ID), and a replica's concurrent requests and JOBS_MAX_RUNNING jobs share that one budget. ROR allows 2000 requests per 5 minutes per IP address, shared by every bibr process and any other ROR client behind one outgoing address, so with N of them set ROR_REQUESTS_PER_5MIN to at most 2000/N on each (500 for four replicas). If ROR still answers 429, that process pauses ROR lookups for 5 minutes (api.ror.org sends no Retry-After) and halves its budget, not below 50. The first ROR lookup after an hour without a 429 doubles it back, and the next doubling needs another such hour, up to the setting, so a process that sat idle for hours gains one doubling, not one per idle hour. A 403 means ROR has blocked the address, or ROR_URL names a wrong path: the process logs a warning that quotes ROR's answer and pauses ROR lookups for 5 minutes, without halving. Strings skipped during a pause stay unmatched, and the paper gets ROR_MATCHING_FAILED.

Follow-up (not implemented): a shared queue. Letting an idle replica execute a job another replica accepted would need the upload bytes on shared storage and a Redis work queue with a visibility timeout, so a dead replica's jobs are re-run instead of stranded. The job record already separates state from executor; the queue and the upload hand-off are the missing pieces.

Logging and metering

bibr serve configures one log sink per process: the API process and the spawned inference worker each write formatted lines (time level logger: message) to stderr, where Docker and systemd collect them. SERVE_LOG_LEVEL (default info) sets the level for bibr's own loggers and is handed to uvicorn and LitServe; HTTP client libraries are held at warning so request URLs are not logged. Every sink carries the secret scrubber, so bearer tokens, API-key headers, URL credentials, ?key=-style query strings and URL signatures, quoted "api_key": "…" entries (any key ending in api_key, token, secret or password) and known key shapes (Google, OpenAI, Anthropic, Groq, Hugging Face, GitHub, AWS access key IDs) are masked before they are written, tracebacks included. Enrichment warnings in the export name a failed lookup by its error type and HTTP status, not by its request URL.

Metering (METER_ENABLED, default on) emits one JSON line per HTTP request from the API process and one per extraction — with LLM token usage — from the worker. With METER_LOG_PATH unset they go to stderr with the other logs; set it to route them to size-rotated JSONL files instead (METER_LOG_MAX_BYTES, METER_LOG_BACKUP_COUNT). The API process writes METER_LOG_PATH itself (request records), while the worker writes the sibling <stem>.worker<suffix> file (extraction records, the only ones carrying llm_usage_totals): each process rotates only its own file, so tallying usage from METER_LOG_PATH alone misses every extraction — read both files. Metering does not follow SERVE_LOG_LEVEL.

Authentication

# .env
AUTH_API_KEY=<output of: openssl rand -hex 32>

When AUTH_API_KEY is set, every route except /health and /ready requires an Authorization: Bearer <token> header — the probes stay open so orchestrators (Docker, Kubernetes) can check liveness/readiness without credentials.

curl -X POST http://localhost:8000/papers/extract \
  -H "Authorization: Bearer your-secret-token" \
  -F "file=@paper.pdf"

Without AUTH_API_KEY the server binds only 127.0.0.1, ::1 or localhost, and it refuses requests that a web page open in your browser could make on your behalf: any request whose Host header is not one of those names (localhost, 127.0.0.1, [::1], any port; a DNS-rebinding page sends its own) gets a 421, and a POST or other state-changing request from another site's Origin (or with Sec-Fetch-Site: cross-site) gets a 403. REST routes and /mcp apply the same rule. bibr batch --serve-url, MCP clients, curl, and your own browser on http://127.0.0.1:8000/docs keep working; origins listed by name in CORS_ORIGINS are accepted too, but CORS_ORIGINS=* admits none here. To put a reverse proxy or tunnel in front of the server, set AUTH_API_KEY: with a key the bearer token is the boundary and these checks are off.

When ENVIRONMENT=production, the server refuses to start at all unless its production hardening checks pass:

  • AUTH_API_KEY is set — an unauthenticated production deployment is refused outright, and the key must be at least 32 characters.
  • REDIS_PASSWORD is set.
  • CORS_ORIGINS does not contain * — set it to an explicit list of allowed origins instead.

MCP endpoint (optional)

With the mcp extra installed (Dockerfile.serve bakes it in, so the Compose image needs no custom build), MCP_ENABLED=true mounts a Model Context Protocol endpoint at /mcp (streamable HTTP), so remote agents can chew and query papers as tools. It shares the bearer auth above and dispatches extraction through the same worker path as POST /papers/extract.

OCR server

Unlike bibr chew, bibr serve has no in-process OCR mode: PDF regions that need recognition use the external OCR service at OCR_BASE_URL (default http://localhost:8080; Compose overrides it with its private bibr-ocr service name). Native document formats skip OCR, and eligible PDF regions use embedded text; the server still checks the configured OCR service in /ready. A PDF job still needs that service to be reachable, even for a born-digital paper: tables, formulas and regions without usable embedded text go to OCR, and when the service cannot be reached the whole job fails after about two minutes with OCR server is unreachable.

OCR_BASE_URL is the server root: bibr appends /v1/models and /v1/chat/completions itself and probes /health at the root, so do not include a /v1 suffix (a trailing /v1 is stripped with a warning). For a PaddleOCR-VL-1.6 service behind an OpenAI-compatible endpoint, name the server family with OCR_BACKEND=paddle-http; that alone selects the Paddle served-model alias and profile, and OCR_MODEL / OCR_PROFILE override them:

OCR_BACKEND=paddle-http
OCR_BASE_URL=https://ocr.example.internal
OCR_MODEL=paddle-ocr-vl-1.6   # optional under paddle-http
OCR_PROFILE=paddle            # optional under paddle-http

The bundled bibr-ocr Compose service is an explicit GLM-OCR compatibility deployment — an SGLang server, GPU required — covered in Docker deployment below. To use it or another GLM service, opt in explicitly:

python -m sglang.launch_server \
  --model zai-org/GLM-OCR \
  --revision ca5d8b3e287e52589e37c28385d9655ee4372f9d \
  --port 8080 --served-model-name glm-ocr

OCR_BACKEND=glm-http
OCR_MODEL=glm-ocr
OCR_PROFILE=glm

If your server serves a model under a different name, set OCR_MODEL to match — a mismatch 404s. Custom aliases additionally require OCR_PROFILE so bibr can choose the correct prompt and output normalizer.

Remote OCR URLs must use HTTPS by default. Set OCR_API_KEY to send a bearer token. Plain HTTP to a non-loopback host requires the explicit OCR_ALLOW_INSECURE_HTTP=true override and should only be used on a trusted private network.

The local paddle-* and glm-* backends, and the cloud vision OCR providers (gemini/openai/anthropic), are bibr chew options for single-machine runs; bibr serve always expects an external HTTP OCR endpoint. Its runtime selection is explicit, so serving GLM does not create a silent per-request GLM fallback for a configured Paddle endpoint.

Docker deployment

The public release provides Dockerfiles and Compose configuration for building locally. Prebuilt GHCR images are not part of the release. Start from the source of the release you want: replace <VERSION> with a version number such as 0.6.0, without the v that the tags in the release list carry (bibr --version prints the one you have installed):

git clone --branch v<VERSION> --depth 1 https://github.com/scienceverse/bibr.git
cd bibr

docker-compose.yml defines three services:

Service Built from Purpose Enabled by
redis redis:7-alpine Response caching Always (no profile)
bibr-ocr Dockerfile.ocr SGLang server for GLM-OCR --profile ocr
bibr-serve Dockerfile.serve LitServe API + pipeline orchestrator + GPU models (layout, segmenter) --profile serve

Redis has no profile, so it always starts; bibr-serve and bibr-ocr are opt-in via profile flags. Plain docker compose up therefore only starts Redis — bring up the API and/or OCR sidecar explicitly:

# All-in-one on a single GPU host
cp .env.example .env      # set your LLM provider + API key
# Also set REDIS_PASSWORD and a 32+ character AUTH_API_KEY.
# Select the bundled GLM service explicitly in .env:
# OCR_BACKEND=glm-http
# OCR_MODEL=glm-ocr
# OCR_PROFILE=glm
# Remove a copied OCR_BASE_URL=http://localhost:8080 so Compose uses bibr-ocr.
docker compose --profile serve --profile ocr up -d --build

# Verify readiness (OCR, classifier artifacts, and enabled Redis cache)
curl http://localhost:8000/ready

Compose publishes the API on the host's loopback interface only (127.0.0.1:8000): Docker bypasses host firewalls for ports it publishes on 0.0.0.0, and the API speaks plaintext HTTP with a bearer token. Put a TLS-terminating reverse proxy in front for remote clients, or set BIBR_PUBLISH_HOST=0.0.0.0 when you deliberately want the port exposed. REDIS_PASSWORD reaches bibr-serve as its own variable and is URL-encoded into REDIS_URL at startup, so passwords containing @, :, /, ? or # work.

Split deployment (bibr-serve and the OCR server on different hosts): the bundled OCR container has no published host port. Expose it through a reverse proxy or an explicit Compose override before pointing another host at it, and set the same OCR_API_KEY in the .env on both hosts: bibr-ocr then rejects requests without it as a bearer token (its /health stays open for health checks), and bibr-serve sends it. For example, publish port 8080 only on the GPU host's private interface:

# compose.ocr-port.yml — replace the address with your GPU host's private IP
services:
  bibr-ocr:
    ports:
      - "10.0.0.10:8080:8080"
# Both hosts, in .env: OCR_API_KEY=<output of: openssl rand -hex 32>

# GPU host — OCR only
docker compose -f docker-compose.yml -f compose.ocr-port.yml --profile ocr up -d

# CPU (or smaller-GPU) host — API, pointed at the GPU host
# The bundled bibr-ocr server speaks plain HTTP; put a TLS-terminating
# reverse proxy in front of it if you need OCR_BASE_URL to be https://.
OCR_BASE_URL=http://10.0.0.10:8080 docker compose --profile serve up -d

This HTTP example relies on Compose's explicit OCR_ALLOW_INSECURE_HTTP=true default for a trusted private network. For an external endpoint, use HTTPS and set OCR_ALLOW_INSECURE_HTTP=false. Redis is also private to the Compose network; plain docker compose up does not expose a Redis port to a host-side bibr chew process.

bibr-ocr requests an NVIDIA GPU via Compose's device reservations (OCR_LOCAL_GPUS, default 1, also sets tensor parallelism) and needs the NVIDIA Container Toolkit on the host. bibr-serve's own GPU models (layout detector, sentence segmenter) are lighter; building with --build-arg WITH_GPU=true swaps in the onnxruntime-gpu build uv.lock pins, but the compose file only declares a GPU device reservation for bibr-ocr — add one to bibr-serve too (or rely on nvidia as the Docker default runtime) if you want its models on GPU as well. Without a GPU, bibr-serve starts and its layout detector and segmenter fall back to CPU (see LAYOUT_USE_GPU / SEGMENTER_USE_GPU below), but it can only process PDFs when an OCR server is reachable: the bundled bibr-ocr needs a GPU, so a CPU-only host needs an external OCR_BASE_URL (see the split deployment above).

Environment variables

Set these in .env (run bibr config example --full or see the settings reference for the full list):

Variable Description Default
LLM_PROVIDER LLM backend: google, openai, anthropic, groq, or ollama google
LLM_MODEL Model for the selected provider. Unset, it follows LLM_PROVIDER: claude-haiku-5-5 for anthropic, gpt-5-nano for openai, llama-3.3-70b-versatile for groq, gpt-oss:20b for ollama. Set it for an openai provider with LLM_BASE_URL gemini-3.5-flash-lite
GOOGLE_API_KEY Google AI API key (google provider) —
LLM_API_KEY OpenAI-compatible API key (openai provider) —
ANTHROPIC_API_KEY Anthropic API key (anthropic provider) —
GROQ_API_KEY Groq API key (groq provider) —
OCR_BACKEND Which family of server sits at OCR_BASE_URL: paddle-http (Paddle alias + profile defaults) or glm-http (GLM defaults). bibr serve always proxies over HTTP; the local paddle-*/glm-* runtimes are bibr chew only paddle (GLM defaults for bibr serve; set paddle-http for a Paddle endpoint)
OCR_BASE_URL External OCR server root URL, without a /v1 suffix http://localhost:8080 (Compose: private bibr-ocr)
OCR_MODEL Served model name glm-ocr; paddle-ocr-vl-1.6 under OCR_BACKEND=paddle-http
OCR_PROFILE paddle or glm; required for custom aliases inferred for known names
OCR_API_KEY Bearer credential sent to a protected OCR server; Compose's bibr-ocr requires it when set —
OCR_ALLOW_INSECURE_HTTP Permit non-loopback plain HTTP (private networks only) false (Compose: true)
WTPSPLIT_MODEL Short wtpsplit name, full Hugging Face repo ID, or existing local bundle directory written as a path (absolute, or starting with ./, ../ or ~) sat-6l-sm
WTPSPLIT_THRESHOLD Optional explicit sentence-boundary threshold in [0, 1] wtpsplit model default
WTPSPLIT_BLOCK_SIZE Optional explicit inference block size; set with WTPSPLIT_STRIDE wtpsplit model default
WTPSPLIT_STRIDE Optional explicit inference stride; set with WTPSPLIT_BLOCK_SIZE wtpsplit model default
AUTH_API_KEY API bearer token; at least 32 characters for network binds or production —
REDIS_PASSWORD Redis password (required when ENVIRONMENT=production) —
ENVIRONMENT development or production development

Common commands

docker compose --profile serve --profile ocr logs -f bibr-serve  # tail logs
docker compose --profile serve --profile ocr up -d --build        # rebuild after code changes
docker compose down                                                # stop everything
docker compose down -v                                             # also clear the Redis cache volume

Custom sentence segmenter

The multilingual segment-any-text/sat-6l-sm remains the default. A deployment can opt into a complete Hugging Face repo ID or an existing local ONNX bundle:

# Hugging Face deployment bundle
WTPSPLIT_MODEL=scienceverse/bibr-sat-science-en
WTPSPLIT_THRESHOLD=0.47
WTPSPLIT_BLOCK_SIZE=256
WTPSPLIT_STRIDE=128

# Local bundle containing model_optimized.onnx, config.json, and tokenizer assets
WTPSPLIT_MODEL=/models/bibr-sat-science-en

Sealed training bundles include segmenter_manifest.json with a calibrated threshold. bibr validates its schema, optimized-model identity, every declared checksum, and the exact bundle file set before loading; unsealed, tampered, path-escaping, or symlinked bundles are rejected. A legacy manifestless local directory can still be selected explicitly, but it supplies no manifest settings. Threshold precedence is an explicit Python constructor value, then WTPSPLIT_THRESHOLD, then a validated local manifest. Windowing precedence is the paired WTPSPLIT_BLOCK_SIZE/WTPSPLIT_STRIDE settings, then a validated local manifest. If absent, bibr preserves wtpsplit's model defaults. Remote model IDs do not supply implicit manifest settings, so set all three values from their release metadata. Every explicit threshold must be between 0 and 1 inclusive, and stride cannot exceed block size.

To bake a full Hugging Face bundle into the serve image with the same resolver used at runtime:

docker build -f Dockerfile.serve \
  --build-arg WTPSPLIT_MODEL=scienceverse/bibr-sat-science-en \
  -t bibr-serve:science .

# Or copy a bundle directory from the Docker build context into the image
docker build -f Dockerfile.serve \
  --build-arg WTPSPLIT_LOCAL_BUNDLE=models/bibr-sat-science-en \
  --build-arg WTPSPLIT_MODEL=/app/segmenter_bundle \
  -t bibr-serve:science-local .

WTPSPLIT_LOCAL_BUNDLE is a directory relative to the Docker build context. Alternatively, mount a local bundle read-only at runtime and set its in-container path as WTPSPLIT_MODEL. The v1 scientific bundle is English-only and bibr does not auto-detect language or switch models per request. Roll back by setting WTPSPLIT_MODEL=sat-6l-sm and removing WTPSPLIT_THRESHOLD, WTPSPLIT_BLOCK_SIZE, and WTPSPLIT_STRIDE.

Production considerations

  • Compose forces ENVIRONMENT=production; set REDIS_PASSWORD, a strong AUTH_API_KEY, and non-wildcard CORS_ORIGINS (see Authentication above).
  • Put a reverse proxy (nginx, Caddy) in front for TLS.
  • /health (liveness) and /ready (readiness) are standard Docker/Kubernetes health-check endpoints.

Reference extraction at scale

Reference segmentation and parsing have two independent controls:

# .env
REF_SEG_STRATEGY=geom   # "geom" (default) | "region" | "llm" | "crf"
REF_PARSE_STRATEGY=ner  # "ner" (default) | "llm" | "llm-chunked" | "ref-llm" | "off"

By default, segmentation runs on a local geometry model (geom), which cascades to LLM assistance only when its own confidence is low, and parsing runs on a local encoder-CRF model (ner) — zero per-reference LLM cost in the common case. REF_PARSE_STRATEGY=llm trades that cost for LLM-based field extraction by parsing the bibliography with the configured LLM in batches (size controlled by REF_PARSE_BATCH_SIZE, default 15); REF_PARSE_STRATEGY=off skips reference extraction entirely (empty bib/bib_match/xref) while keeping core metadata. See Architecture for how the segmentation cascade and parsing strategies fit together internally.

Crossref reference enrichment is off by default (CROSSREF_ENRICH=false): requests get the extracted bib table with an empty bib_match. Set CROSSREF_ENRICH=true to enrich every request, or let callers decide per request with the crossref=true|false form field on /papers/extract (and the crossref knob on the MCP chew_paper/chew_url tools), which overrides the setting either way. The response cache keys on the effective value, so an enriched and an unenriched result for the same file never collide. Deployments that upgraded from a version where enrichment was on by default must now set CROSSREF_ENRICH=true to keep that behaviour.

At volume, Crossref enrichment is rate-limited (CROSSREF_RATE_LIMIT_RPM, default 200; raise it once you've set CROSSREF_API_EMAIL or have an API key) and can optionally be cached in Redis across requests (CROSSREF_REDIS_CACHE, off by default — falls back to REDIS_URL). With REDIS_URL set, every process that uses it shares one Crossref rate limit through Redis. The LLM rate limit is shared per provider and model: processes with the same LLM_PROVIDER and LLM_MODEL share one LLM_RATE_LIMIT_RPM budget, and a process calling another provider or model has its own. While a rolling upgrade from an earlier release is in progress, old and new replicas meter LLM calls separately. If Redis stops answering, each process keeps enforcing the limit on its own, logs one warning, and tries Redis again every 30 seconds. Both cache tiers also remember a DOI lookup's 404 for CROSSREF_NOT_FOUND_TTL_SECONDS (default one day; 0 disables), so a re-run does not spend a request on each DOI Crossref has no record of. CROSSREF_CONSOLIDATE (off/fill/replace, or the consolidate form field on /papers/extract) controls whether accepted Crossref matches get merged back into bib, or left only in bib_match. CROSSREF_VERIFY (default true) checks each reference's parsed volume, issue and pages against its confident match before that: a value the parser missed or misread is replaced by the match's value only when the reference string prints it, and every comparison is recorded in extraction.diagnostics.verification.

Optional external resolver (BIBR_RESOLVER_URL)

bibr can query the optional bibr-resolver service before falling back to Crossref, to speed up reference resolution and reduce Crossref load. It's off by default — leaving BIBR_RESOLVER_URL unset keeps bibr's enrichment behavior unchanged. bibr-resolver is not publicly released yet, so leave BIBR_RESOLVER_URL unset unless you run one. It is only queried when Crossref enrichment is on (--crossref or CROSSREF_ENRICH=true).

Env var Default Meaning
BIBR_RESOLVER_URL (unset) Resolver base URL, e.g. http://resolver-host:2010. Unset = disabled.
BIBR_RESOLVER_ENRICH true Master gate; false disables even when a URL is set.
BIBR_RESOLVER_TIMEOUT 30.0 Per-request timeout (seconds).
BIBR_RESOLVER_RETRIES 3 Times a call is tried again after a busy answer (429/502/503/504), a timeout (once at most) or a dropped connection. 0 disables retries.
BIBR_RESOLVER_RETRY_BACKOFF 1.0 First wait (seconds) before a retry; it doubles each time, with jitter, up to 30 s, and is never shorter than the resolver's Retry-After.
BIBR_RESOLVER_MAX_CONCURRENCY 8 Max resolver requests in flight across all papers in one bibr process. 0 = no cap.
BIBR_RESOLVER_LIMIT 20 Max candidates requested from /search.
BIBR_RESOLVER_SEARCH_CONCURRENCY 4 Max concurrent /search calls when prefetching one paper's title searches.
BIBR_RESOLVER_RAW_SEARCH true Search again with the whole reference string for references the title searches left unmatched, accepting a candidate only when its title, year and first author (or volume and first page) are printed in it, and never a work another reference of the paper already holds.

A resolver error falls through to Crossref. Misses also fall through by default; BIBR_RESOLVER_AUTHORITATIVE=true makes a clean resolver miss authoritative and skips the Crossref fallback.

BIBR_RESOLVER_SEARCH_CONCURRENCY applies per paper, so a batch with many papers open at once multiplies it; BIBR_RESOLVER_MAX_CONCURRENCY is the cap for the whole process. When several bibr processes or machines share one resolver, keep their caps together at or below the resolver's own search_concurrency (16 by default), or it queues and answers "busy".

Hardware sizing

Size the API host separately from the OCR and LLM services. The serve image includes PyTorch, the layout model, classifiers, and the sentence segmenter; it is not a torch-free installation even when OCR and LLM calls are remote. The API can run these local stages on CPU. The bundled SGLang OCR service requires an NVIDIA GPU. Exact RAM and VRAM requirements depend on the model, PDF page dimensions, batch sizes, and concurrently processed pages; measure a representative workload before raising concurrency.

For local CLI runtime and platform choices, see Installation and Quickstart.

bibr serve keeps its GPU models resident for the life of the process — there's no per-run memory mode like bibr chew's --memory flag. On a box that co-locates the OCR server and is tight on VRAM, force the layout detector and/or sentence segmenter to CPU independently with LAYOUT_USE_GPU=false / SEGMENTER_USE_GPU=false (both auto-detect CUDA by default).