Homelab
Proof

U4 / Apps under test

Paperless-ngx OCR speed: 100 scanned pages on 4 vCPU

Tested on Paperless-ngx 3.4.1

Test result

On 4 vCPU Paperless-ngx 3.4.1 consumed 100 image-only pages (10 documents) in 272 s, about 2.7 s per page, with all containers peaking at 1,022 MiB. Against the text drawn on the pages the OCR had a median word error rate of 0.08 %, and 10 of 10 exact-phrase searches found the right document, but the pages were clean, generated renderings and real scans will do worse.

Line drawing: a stack of blank paper sheets passing under a scanning light bar into a small server unit.

OCR is where a document server spends its CPU, so this test fed Paperless-ngx 100 pages with no text layer and measured how long consumption took, how much memory it used, and how much of the text came back correctly.

The test documents

Ten public-domain books from Project Gutenberg (A Christmas Carol, Pride and Prejudice, Frankenstein, Moby Dick, A Tale of Two Cities, The Adventures of Sherlock Holmes, Tom Sawyer, Dracula, Treasure Island, The Picture of Dorian Gray) each contributed 10 pages, 47,139 words in total. gen.py drew each page in DejaVu Serif at 200 dpi on A4, then rotated it by less than 0.6 degrees, blurred it, added noise and a vignette, and saved JPEGs inside PDFs. The 10 PDFs total 58.6 MB and contain images only. The exact text drawn on each page is saved next to the OCR output, so accuracy has a ground truth.

These pages are generated, labeled as such, and easier than most real material: the font is clean and regular, there are no stains, folds, handwriting, tables, stamps or colored backgrounds, and the language is English, which the default OCR setup handles. Treat the speed as a reference for 200 dpi text pages of about 470 words, and the accuracy as an upper bound.

Paperless-ngx document list with 10 generated book documents of 10 pages each, with thumbnails of text pages

Speed and memory

All 10 PDFs were sent through POST /api/documents/post_document/ in about 1 s, so they queued the way a batch scan would, and the test polled the task API until each finished. The stack’s default settings were used; nproc in the container showed 4.

Paperless-ngx 3.4.1 consuming 100 scanned-looking pages
MeasurementResult
Documents / pages / words10 / 100 / 47,139
Consumed successfully10 of 10 documents
Upload of all PDFs (queued)1.0 s
First upload to last document finished272 s
seconds per page, whole batch2.7
Seconds per page, per document (task start to done)2.5 (2.4 to 2.5)
Total RAM, all containers: idle before, median (MiB)670
Total RAM during the batch: median / peak (MiB)834 / 1022
Total CPU during the batch: mean / peak (% of one vCPU)215 / 401
OCR character error rate, per document0.01 % (0.00 to 0.05 %)
OCR word error rate, per document0.08 % (0.00 to 0.30 %)
Phrase searches that returned the right document10 of 10
Phrase search time (30 queries)54 ms (34 to 69 ms)

One batch on a 4 vCPU, 4 GB VM with the stack's default settings. Per-document figures are median (range) over the 10 documents. Error rates: Levenshtein distance between the stored OCR text and the text drawn on the pages, divided by the length of the drawn text, after collapsing whitespace. The pages are clean, generated renderings, which is easier than real scans.

RAM and CPU per container
ContainerIdle before (MiB)Peak during the batch (MiB)Peak CPU (% of one vCPU)
broker13130
db738017
webserver584930400

Samples come from docker stats --no-stream and are about 2 s apart, during the batch and at idle.

  • Per page: 2.5 s median per document (2.4 to 2.5 s, from each task’s own duration) and 2.7 s counting the whole batch. Ten documents give ten timings, which are close together, so the figure is stable on this input; the test did not repeat the whole batch.
  • The webserver container did the OCR. It peaked at 930 MiB and 400 % of one vCPU (all 4 cores) while the batch ran (RAM and CPU samples come from docker stats --no-stream and are about 2 s apart), and averaged 215 % across all containers.
  • The stack idled at 670 MiB before the batch (the stack table’s figure, taken at first run, is lower). The media volume ended at 120.2 MB for 58.6 MB of input PDFs; what the extra consists of was not itemized.
Idle footprint, measured on the reference test box. Median of 12 samples after settling.
StackVersionRAM at idleCPU at idleImages on diskReady after
Paperless-ngx3.4.1615 MiB0.40%4273.4 MB26.1 s

OCR accuracy

For each document the stored text was compared with the drawn text after collapsing whitespace. Character error rate is the Levenshtein distance divided by the length of the drawn text; word error rate is the same on words. Every difference counts, including punctuation and capitalization.

Per document: consumption time and OCR error
DocumentPagesTask timeCharacter errorWord error
a-christmas-carol1025 s0.05 %0.30 %
pride-and-prejudice1025 s0.01 %0.13 %
frankenstein1025 s0.00 %0.02 %
moby-dick1025 s0.00 %0.00 %
tale-of-two-cities1024 s0.01 %0.13 %
sherlock-holmes1025 s0.00 %0.00 %
tom-sawyer1024 s0.01 %0.09 %
dracula1025 s0.01 %0.08 %
treasure-island1024 s0.01 %0.12 %
dorian-gray1025 s0.00 %0.02 %

Source texts are public-domain Project Gutenberg books.

The median document had a 0.01 % character error rate and 0.08 % word error rate. The worst was A Christmas Carol at 0.05 % and 0.30 %. Looking at that document’s differences, its 13 differences were of three kinds: straight quotes in the source that came back as curly quotes ("At became “At), a few cases where words were joined (in a became ina, half-recumbent became halfrecumbent), and one doubled apostrophe. Under a stricter or looser comparison the percentages would move, and only this one document’s differences were reviewed.

For each document, six consecutive words from page 5 were searched as an exact phrase through GET /api/documents/?query="...".

Phrase search: six consecutive words from page 5 of each document
DocumentPhraseFound in that documentDocuments returnedMedian query time
a-christmas-carolstrutted in the stables; and theyes161 ms
pride-and-prejudicewere very minute, and then byyes141 ms
frankensteinhe repeated my favourite poems, oryes143 ms
moby-dicksea, and a great lord ofyes144 ms
tale-of-two-citiesechoes of other steps that neveryes140 ms
sherlock-holmesa very coarse one and wasyes164 ms
tom-sawyerflower-pots with him. The old ladyyes161 ms
draculavelvet band again covered the redyes138 ms
treasure-islandthe south-west of us we sawyes154 ms
dorian-grayempty street. The air was heavyyes158 ms

GET /api/documents/?query="..." (full-text index), 3 passes.

All 10 phrases returned exactly one document, the right one, with a median of 54 ms (34 to 69 ms, 30 queries). That tests the full-text index on clean text. It does not test fuzzy or partial queries, rare words across many thousands of documents, or non-English text.

Reproduce it

python3 gen.py ~/scans 10 10                 # generated scans + ground truth
docker compose -f compose.yml -f admin.yml up -d   # admin.yml, shown below
python3 bench.py ~/scans ~/out               # upload, wait for the tasks, compare text, search

The admin.yml used in the test (the password is the lab’s placeholder; pick your own before using this anywhere real):

services:
  webserver:
    environment:
      PAPERLESS_ADMIN_USER: admin
      PAPERLESS_ADMIN_PASSWORD: lab-only-not-a-secret
compose.yml, as tested (Paperless-ngx 3.4.1)

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

services:
  broker:
    image: docker.io/valkey/valkey:9-alpine
    restart: unless-stopped
    volumes:
      - redisdata:/data
  db:
    image: docker.io/library/postgres:18
    restart: unless-stopped
    volumes:
      - pgdata:/var/lib/postgresql
    environment:
      POSTGRES_DB: paperless
      POSTGRES_USER: paperless
      POSTGRES_PASSWORD: lab-only-not-a-secret
  webserver:
    image: ghcr.io/paperless-ngx/paperless-ngx:3.4.1
    restart: unless-stopped
    depends_on: [db, broker]
    ports:
      - "127.0.0.1:8080:8000"
    volumes:
      - data:/usr/src/paperless/data
      - media:/usr/src/paperless/media
      - ./export:/usr/src/paperless/export
      - ./consume:/usr/src/paperless/consume
    environment:
      PAPERLESS_REDIS: redis://broker:6379
      PAPERLESS_DBHOST: db
      PAPERLESS_DBPASS: lab-only-not-a-secret
      PAPERLESS_SECRET_KEY: lab-only-not-a-secret-lab-only-not-a-secret
volumes:
  data:
  media:
  pgdata:
  redisdata:
lab/tests/paperless/bench.py

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env python3
"""Runs in the VM. bench.py <scans_dir> <outdir>
Uploads every PDF in <scans_dir>/pdf through POST /api/documents/post_document/ one after another (queued, as a scanner or a script would),
waits for the consumption tasks, then compares the OCR text Paperless stored with the exact text that was drawn on the pages (truth/),
and runs phrase searches through GET /api/documents/?query=."""
import sys, os, json, time, glob, re, urllib.request, urllib.error, urllib.parse, uuid, datetime, statistics as st
from rapidfuzz.distance import Levenshtein
scans, out = sys.argv[1:3]; os.makedirs(out, exist_ok=True); U = "http://127.0.0.1:8080"
def call(method, path, body=None, headers=None, raw=None):
    h = dict(headers or {}); data = raw
    if body is not None: data = json.dumps(body).encode(); h["Content-Type"] = "application/json"
    r = urllib.request.Request(U + path, data=data, method=method, headers=h)
    try:
        with urllib.request.urlopen(r, timeout=300) as x: t = x.read(); return x.status, (json.loads(t) if t else None)
    except urllib.error.HTTPError as e: return e.code, e.read().decode()[:400]
st_, r = call("POST", "/api/token/", {"username": "admin", "password": "lab-only-not-a-secret"}); TOK = {"Authorization": "Token " + r["token"]}
def upload(path, title):
    b = uuid.uuid4().hex; name = os.path.basename(path); data = open(path, "rb").read()
    def f(k, v): return f'--{b}\r\nContent-Disposition: form-data; name="{k}"\r\n\r\n{v}\r\n'.encode()
    body = f(  "title", title) + f'--{b}\r\nContent-Disposition: form-data; name="document"; filename="{name}"\r\nContent-Type: application/pdf\r\n\r\n'.encode() + data + f"\r\n--{b}--\r\n".encode()
    return call("POST", "/api/documents/post_document/", headers={**TOK, "Content-Type": f"multipart/form-data; boundary={b}"}, raw=body)
meta = {m["doc"]: m for m in json.load(open(f"{scans}/meta.json"))}; pdfs = sorted(glob.glob(f"{scans}/pdf/*.pdf")); tasks = {}
res = {"docs": len(pdfs), "pages": sum(m["pages"] for m in meta.values())}
t0 = time.time()
for p in pdfs:
    slug = os.path.basename(p)[:-4]; s, r = upload(p, slug); assert s == 200, (s, r); tasks[slug] = r.strip('"') if isinstance(r, str) else r
res["upload_secs"] = time.time() - t0; print("uploaded", res["upload_secs"], flush=True)
def task(tid):
    s, r = call("GET", f"/api/tasks/?task_id={tid}", headers=TOK)
    res_ = r.get("results") if isinstance(r, dict) else r
    return res_[0] if res_ else None
done = {}; res["poll_log"] = []
while len(done) < len(tasks) and time.time() - t0 < 5400:
    for slug, tid in tasks.items():
        if slug in done: continue
        t = task(tid)
        if t and t.get("status") in ("success", "failure"): done[slug] = (t, time.time())
    res["poll_log"].append([round(time.time() - t0, 1), len(done)]); time.sleep(3)
t_end = time.time(); res["total_secs"] = t_end - t0; res["tasks"] = {}
for slug, (t, _) in done.items():
    res["tasks"][slug] = {"status": t["status"], "pages": meta[slug]["pages"], "started_to_done": t.get("duration_seconds"), "wait_secs": t.get("wait_time_seconds"), "result": str(t.get("result_data"))[:200]}
res["finished_ok"] = sum(1 for v in res["tasks"].values() if v["status"] == "success")
# accuracy: stored OCR text vs the text drawn on the pages
s, r = call("GET", "/api/documents/?page_size=100&fields=id,title,content,page_count", headers=TOK); docs = {d["title"]: d for d in r["results"]}
norm = lambda t: " ".join(t.replace("\f", " ").split())
acc = []
for slug in meta:
    if slug not in docs: acc.append({"doc": slug, "missing": True}); continue
    truth = norm(open(f"{scans}/truth/{slug}.txt").read()); got = norm(docs[slug]["content"])
    cer = Levenshtein.distance(truth, got) / len(truth); tw, gw = truth.split(), got.split(); wer = Levenshtein.distance(tw, gw) / len(tw)
    acc.append({"doc": slug, "chars": len(truth), "words": len(tw), "cer": cer, "wer": wer, "page_count": docs[slug].get("page_count")})
res["accuracy"] = acc
os.makedirs(f"{out}/ocr", exist_ok=True); os.makedirs(f"{out}/truth", exist_ok=True)
for slug in meta:
    if slug in docs: open(f"{out}/ocr/{slug}.txt", "w").write(docs[slug]["content"])
    open(f"{out}/truth/{slug}.txt", "w").write(open(f"{scans}/truth/{slug}.txt").read())
# search: 6 consecutive words from page 5 of each document, as an exact phrase, 3 passes for timing
searches = []
for slug in meta:
    pages = open(f"{scans}/truth/{slug}.txt").read().split("\f"); words = [w for w in " ".join(pages[min(4, len(pages) - 1)].split()).split()]
    phrase = " ".join(words[40:46]); q = urllib.parse.quote(f'"{phrase}"'); times = []; ids = []
    for _ in range(3):
        t = time.perf_counter(); s, r = call("GET", f"/api/documents/?query={q}&fields=id,title", headers=TOK); times.append((time.perf_counter() - t) * 1000)
        ids = [d["title"] for d in r["results"]] if s == 200 else [f"HTTP {s}"]
    searches.append({"doc": slug, "phrase": phrase, "hits": ids, "found_in_expected": slug in ids, "ms": times})
res["search"] = searches
json.dump(res, open(f"{out}/bench.json", "w"), indent=1); print("done", res["total_secs"], res["finished_ok"])

What went wrong

The first complete run was thrown away. Two of the ten books (Alice in Wonderland and The Yellow Wallpaper) ran out of long paragraphs after a few pages, so about a dozen of the “100” pages were blank, and the page-5 search phrases for them were empty. The generator now refuses to produce a page with fewer than 25 lines, and those two books were replaced by A Christmas Carol and Treasure Island. The same run’s task polling also assumed upper-case status values from an older API, so the script waited on tasks that had already finished; it now reads duration_seconds and lower-case statuses from the 3.4.1 API. The reported run is the second complete one. The logs of the discarded run were not kept, so that description cannot be checked against evidence.

What this test did not cover

Sources and how this was made

lab/tests/paperless/run.sh

This file uses the public test value lab-only-not-a-secret. Replace every instance with your own secret before you run it.

Show the full file
#!/usr/bin/env bash
# paperless: Paperless-ngx 3.4.1 OCR on 4 vCPU. 100 pages (10 documents x 10 pages) of public-domain Project Gutenberg text rendered
# as scanned-looking, image-only PDFs; consumed through POST /api/documents/post_document/. Time per page, RAM, OCR error rate, phrase search.
set -uo pipefail
source "$(dirname "$0")/../_e/lib.sh"
T=paperless; D="$ROOT/lab/tests/$T"; OUT="$ROOT/evidence/tests/$T"; RAW="$OUT/raw"
rm -rf "$OUT"; mkdir -p "$RAW" "$ROOT/public/shots/$T"
start_vm e-$T || exit 1
collect_env "$OUT"
gx 'sudo apt-get install -y -qq python3-pil python3-numpy python3-rapidfuzz fonts-dejavu-core >/dev/null 2>&1; mkdir -p ~/pl'
vm scp-to "$D/gen.py" gen.py; vm scp-to "$D/bench.py" bench.py; vm scp-to "$ROOT/lab/tests/_e/sampler.py" sampler.py; vm scp-to "$ROOT/lab/stacks/paperless/compose.yml" pl/compose.yml
gx 'python3 gen.py ~/scans 10 10' | tee "$RAW/dataset.txt"
gx 'cat ~/scans/meta.json' > "$RAW/dataset-meta.json"
# admin account through environment variables (not in the stack compose)
gx 'cd ~/pl && printf "services:\n  webserver:\n    environment:\n      PAPERLESS_ADMIN_USER: admin\n      PAPERLESS_ADMIN_PASSWORD: lab-only-not-a-secret\n" > admin.yml; docker compose -f compose.yml -f admin.yml pull -q 2>&1 | tail -1; docker images --format "{{.Repository}}:{{.Tag}} {{.ID}}" | sort' > "$RAW/images.txt" 2>&1
gx 'cd ~/pl && docker compose -f compose.yml -f admin.yml up -d >/dev/null 2>&1; for i in $(seq 1 200); do [ "$(curl -s -o /dev/null -w %{http_code} http://127.0.0.1:8080/api/)" != 000 ] && curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/accounts/login/ | grep -q 200 && exit 0; sleep 3; done; exit 1'
gx 'sleep 90'
gx 'cd ~/pl && docker compose exec -T webserver sh -c "nproc; env | grep -E \"PAPERLESS_(TASK|THREADS|OCR|WORKERS)\" ; python3 -c \"import sys;print(sys.version.split()[0])\"; tesseract --version 2>&1 | head -1; ocrmypdf --version 2>&1 | head -1"' > "$RAW/paperless-env.txt" 2>&1
gx "timeout 40 python3 sampler.py /tmp/idle0.csv 2 || true; cat /tmp/idle0.csv" > "$RAW/mem-idle-before.csv"
gx 'nohup python3 sampler.py /tmp/run.csv 1 >/dev/null 2>&1 & echo $! > /tmp/sampler.pid'
gx 'python3 bench.py ~/scans ~/out' 2>&1 | tee "$RAW/bench.log" | cut -c1-200
gx 'sleep 60; kill $(cat /tmp/sampler.pid); sleep 2; cat /tmp/run.csv' > "$RAW/mem-run.csv"
gx 'cat ~/out/bench.json' > "$RAW/bench.json"; mkdir -p "$RAW/text"; vm scp-from '~/out/ocr' "$RAW/text/"; vm scp-from '~/out/truth' "$RAW/text/"
gx 'cd ~/pl && sudo du -sb /var/lib/docker/volumes/pl_media /var/lib/docker/volumes/pl_data /var/lib/docker/volumes/pl_pgdata | cat' > "$RAW/disk.txt" 2>&1
gx 'cd ~/pl && docker compose logs --no-color webserver 2>&1 | grep -iE "warn|error|ocr" | tail -15 | cut -c1-250' > "$RAW/webserver-log-tail.txt" 2>&1
tunnel 18092 8080
( cd "$D" && node shot.mjs http://127.0.0.1:18092 "$ROOT/public/shots/$T/documents.png" ) > "$RAW/shot.txt" 2>&1
untunnel
python3 "$D/summarize.py" "$RAW" "$OUT"

How this page was made: the commands above were run by an AI agent on the test box on November 1, 2026, and the same agent drafted this text from the saved output. The site's owner reviewed the site before publication. Test method and editorial policy explain how results are checked, and what is not. Spotted an error? Tell us.