From paperless-gpt to Paperless-NGX v3: dropping a container, cutting complexity

Upgrading to Paperless-NGX v3, replacing the paperless-gpt side container with the new built-in AI configured for Gemini via the OpenAI-compatible endpoint. Includes the one embedding model gotcha that took me ten minutes to find.

From paperless-gpt to Paperless-NGX v3: dropping a container, cutting complexity
Photo by Conny Schneider / Unsplash

The 404 that took me ten minutes to understand

OpenAIError("Error code: 404 - {'error': {'code': 404, 'message':
'models/text-embedding-004 is not found for API version v1main, or is
not supported for embedContent...'}}")

That was Paperless-NGX v3, after I'd upgraded my 1,805-document library, ripped out the side container that used to handle AI, and pointed the new built-in AI at Gemini. text-embedding-004 is the name Google's own docs use for their embedding model. It's the name every tutorial uses. It is not the name Gemini's OpenAI-compatible endpoint accepts.

The correct name is gemini-embedding-001. Nothing on the internet told me that. This post exists so you don't spend ten minutes staring at a stack trace to find it.

Everything else about the v3 upgrade - the DB migration, the rollback plan, dropping the paperless-gpt container, the config-as-code work - was routine. That one line was the whole trick.


Before I upgraded, I built a rollback

The v3 migration doc has one line worth taking seriously: "the document contains no rollback procedures or downgrade guidance." Once v3's Postgres migrations run, you're on the new schema. There is no downgrade command.

So the plan started with backups - not the daily one that's been running for months, but a fresh one, five minutes before the pull:

# 1. Fresh Paperless export (the app's own backup)
./paperless.sh backup

# 2. Direct pg_dump into a file under my control
docker exec paperless-db pg_dump -U paperless -Fc paperless \
  > pre-v3-dump.pgcustom

# 3. Hardlink snapshots of media/ and data/ (zero extra disk on the same FS)
cp -al media media.pre-v3
cp -al data  data.pre-v3

# 4. Pin the current 2.20.15 image by digest
docker inspect paperless-ngx --format '{{.Image}}' > pre-v3-image-digest.txt

# 5. Snapshot .env and docker-compose.yml
cp .env .env.pre-v3
cp docker-compose.yml docker-compose.yml.pre-v3

The cp -al trick is the one worth knowing: on the same filesystem it makes a hardlink tree that costs zero bytes until v3 actually rewrites a file. If anything had gone sideways, I could stop the container, pg_restore the dump, rename my snapshots back into place, revert the compose file, and be running 2.20.15 again in a couple of minutes.

I never had to. But knowing I could is what let me hit "go" at all.

The four config changes v3 forced

Reading the migration doc, I made a list of what would break in my existing docker-compose.yml:

Before (v2.20.15) After (v3.0.0) Why
image: paperless-ngx:latest image: paperless-ngx:3.0.0 Never let a docker compose pull do a major-version upgrade unattended. Pin.
(no PAPERLESS_DBENGINE) PAPERLESS_DBENGINE: postgresql v3 no longer infers the engine from DBHOST. Must be explicit.
PAPERLESS_OCR_MODE: skip PAPERLESS_OCR_MODE: skip_noarchive The skip variant was removed. skip_noarchive is the closest match.
PAPERLESS_OCR_SKIP_ARCHIVE_FILE: with_text (deleted) The setting is gone; behaviour is folded into OCR_MODE.

That's it. Everything else - 40-ish other env vars for barcodes, ASN, storage paths, mail rules, workflows - carried through untouched.

The upgrade itself: 11 minutes

I made the four edits, changed the image tag, ran docker compose pull paperless && docker compose up -d paperless, and tailed the logs.

Running migrations:
  Applying documents.0003_remove_document_storage_type... OK
  Applying documents.0004_workflowtrigger_filter_has_any_correspondents_and_more... OK
  ... (through 0015) ...
[paperless.migrations] Recomputing SHA-256 checksums for 1809 document(s)...
[paperless.migrations] SHA-256 checksum progress: 500/1809 (27%)

The checksum recompute is a v3 thing - every document gets re-hashed under the new integrity system. It ran for about six minutes. Then the search index switched from Whoosh (v2's default) to Tantivy (v3's default) in the background. docker compose ps said healthy after eleven minutes total.

Nothing broke. All documents present, 20 workflows intact, 522 correspondents there, mail rules still firing.

Ripping out paperless-gpt

My old setup had a companion container called paperless-gpt - a Go service that watched Paperless via API, called Gemini for each new document, and wrote back title/tag/correspondent/type suggestions. It worked well. It also meant:

  • Two containers to keep updated
  • A Google Cloud service-account JSON file baked into a bind mount
  • API tokens and prompt templates to remember to back up
  • A layer of indirection every time I wanted to change an AI setting

v3 folds all of that into the main app. What v3's built-in AI actually does:

  • Every document details page has a Suggestions drawer with LLM-generated proposals for title, correspondent, document type, tags, storage path, and dates. One click accepts each.
  • The LLM index gets consulted internally as RAG context (similar prior documents feed the suggestion prompt, so your existing correspondent and tag conventions carry over to new docs).
  • There's also a chat interface for asking questions across the library (/api/chat/ streams answers; the UI exposes it as an "Ask AI" panel).
  • A weekly cron rebuilds the index for consistency; incremental updates happen automatically on ingest.
  • One PAPERLESS_AI_ENABLED flag turns the whole thing on or off.

Same behaviour paperless-gpt gave me, minus a container.

Wiring Gemini into v3 (the gotcha, in context)

v3's dropdown for LLM Backend offers two options: openai-like and ollama. That's it. No "Gemini" option.

Gemini exposes an OpenAI-compatible endpoint, so openai-like is the right choice - you just fill in Gemini's URL. Here's what I have in my docker-compose.yml:

environment:
  PAPERLESS_AI_ENABLED: "true"
  PAPERLESS_AI_LLM_BACKEND: "openai-like"
  PAPERLESS_AI_LLM_ENDPOINT: "https://generativelanguage.googleapis.com/v1beta/openai/"
  PAPERLESS_AI_LLM_MODEL: "gemini-2.5-flash"
  PAPERLESS_AI_LLM_API_KEY: "<your key from aistudio.google.com/apikey>"
  PAPERLESS_AI_LLM_OUTPUT_LANGUAGE: "de"
  PAPERLESS_AI_LLM_REQUEST_TIMEOUT: 60
  PAPERLESS_AI_LLM_EMBEDDING_BACKEND: "openai-like"
  PAPERLESS_AI_LLM_EMBEDDING_ENDPOINT: "https://generativelanguage.googleapis.com/v1beta/openai/"
  PAPERLESS_AI_LLM_EMBEDDING_MODEL: "gemini-embedding-001"

Every line except the last one you could work out from the upstream docs and a copy of Google's Gemini quickstart. PAPERLESS_AI_LLM_EMBEDDING_MODEL: "gemini-embedding-001" you cannot - the docs say text-embedding-004, and that value returns the 404 from the top of this post. Google's OpenAI-compat gateway routes to a different API surface (v1main) than their native (v1beta), and that surface only accepts the newer model name. Nothing in Google's documentation makes that obvious.

Once flipped, the LLM index rebuild ran in 691.9 seconds - 11 min 32 sec - and embedded the whole library into a 125 MB local vector database with zero rate-limit errors.

Cost sanity check

Numbers before turning it loose on the whole library. Paid tier:

  • One-time index build: ~$0.54 (3.6M embedding tokens)
  • Per new document (embedding + auto-tag call): ~$0.003
  • Weekly consistency task: near-zero, incremental
  • 20 documents a day: ~$0.06/day → about $2/month

Google AI Studio free tier absorbed the entire rebuild - 1,805 embedding calls in 11 minutes, no throttling - so this is realistically free for a personal library at normal ingest rates. Batch imports of hundreds of docs at once will spike above the free daily quota and either 429 or start billing, depending on how you have the key configured.

Config in the compose file, not in the UI

One thing bothered me after I first got it working. I'd set the AI options through Paperless's Application Configuration screen, which stores them in Postgres. That's fine while running - but a DB restore from any pre-today backup would silently lose the AI config, and my docker-compose.yml was no longer a complete description of the running system.

The upstream docs are explicit: every one of those settings has an equivalent env var. So the compose block above isn't just illustrative - that's where the config actually lives on my box. The rest of my Paperless setup is already configured this way; AI shouldn't be different.

Clearing the UI overrides so the env vars actually take effect is one API call:

curl -X PATCH -H "Authorization: Token $PAPERLESS_TOKEN" \
     -H "Content-Type: application/json" \
     "$PAPERLESS_URL/api/config/1/" \
     -d '{"ai_enabled": null, "llm_backend": null, "llm_endpoint": null,
          "llm_model": null, "llm_api_key": null, "llm_output_language": null,
          "llm_request_timeout": null, "llm_embedding_backend": null,
          "llm_embedding_endpoint": null, "llm_embedding_model": null}'

Null in the DB means "inherit from env." After that, the AI Settings tab in the UI shows every field blank - matching the pattern Barcode and OCR settings have followed forever.

The installer already knew v2. Now it knows v3.

The public installer at tural-ali/paperless-overconfigured needed the same treatment. In case you're running it: PAPERLESS_VERSION=3.0.0 is pinned in the generated .env, the four v3 config changes are baked in, and step [4/9] now offers Gemini (with the correct embedding model name), OpenAI native, or Ollama - no more paperless-gpt service in the compose. The Google Document AI OCR sub-question is gone, since v3 has no native slot for Document AI and Tesseract has been good enough for me since Paperless improved its multilingual handling.

The commit is 84c8075 on main. bash <(curl -fsSL …/install.sh) from today wires up v3 native AI in one go.

What I'd tell someone else doing this upgrade

Three things I didn't figure out until I'd already done them:

  1. Watch llmindex.db grow to estimate ongoing cost. The file starts at zero and reaches a plateau size when the initial embed is done. Divide that size by your document count to get bytes-per-doc; multiply the byte total by your provider's per-token rate to size a year of ingest before you commit. On Gemini I hit ~$0.30/1,000 docs. Cheap enough that I stopped thinking about it after the first estimate.
  2. Delay the paperless-gpt teardown by a few days. Stopping the container is free; deleting it is not, because the compose block is what documents how it worked. I kept the container stopped-but-not-deleted for a bit so I could bring it back if v3's native AI turned out worse at some specific job. It wasn't, but I liked having the option.

Test the embedding model with one curl before you trust the whole rebuild. One request against the OpenAI-compat endpoint per candidate model name catches the text-embedding-004 trap in ten seconds instead of after 1,800 celery errors. The command:

curl -sS -o /dev/null -w "%{http_code}\n" \
  -H "Authorization: Bearer $GEMINI_KEY" \
  -H "Content-Type: application/json" \
  "https://generativelanguage.googleapis.com/v1beta/openai/embeddings" \
  -d '{"model":"gemini-embedding-001","input":"hello"}'

200 means Paperless will accept it. 404 means don't schedule the rebuild.

Wrap

Under thirty minutes of active work. One fewer container, forty fewer lines of compose, every AI feature paperless-gpt gave me still present as a first-class part of the app. The migration doc doesn't hand you a downgrade path, so you build your own; once it's built, the upgrade itself is quiet.


Related: