IcePaperlessAI
IcePaperlessAI
Betriebsdokumentation und Fixes für eine selbstgehostete Dokumenten-Pipeline aus Netzwerkscanner → Paperless-ngx → paperless-ai → lokales LLM (Ollama).
Dieses Repository entstand aus einer Fehlersuche, bei der die Pipeline scheinbar lief, aber seit Monaten keine Scans mehr verarbeitete und Dokumente falsch verschlagwortete. Es waren sechs voneinander unabhängige Fehler auf drei Hosts — jeder für sich harmlos aussehend, zusammen ein Totalausfall.
Der Inhalt ist bewusst so geschrieben, dass er auch für andere Betreiber dieses Stacks nützlich ist. Zwei der Befunde sind Bugs in Upstream-Software, nicht Fehlkonfigurationen.
Inhaltsverzeichnis
- Architektur
- Zusammenfassung der Befunde
- Befund 1 — Der Scanner war nie angeschlossen
- Befund 2 — OCR lief auf Englisch
- Befund 3 — RESTRICT_TO_EXISTING_TAGS war wirkungslos (Upstream-Bug)
- Befund 4 — TOKEN_LIMIT über dem Modellkontext
- Befund 5 — Ollama-Blockade ohne Timeout
- Befund 6 — Paperless’ eigenes Tag-Matching war abgeschaltet
- Härtung des LLM-Hosts
- Verifikation
- Repository-Inhalt
- Betriebs-Runbook
- Fallstricke
- Upstream-Meldungen
Architektur
Brother MFC (Scan-to-Folder, SMB)
│
▼
/srv/scanner ← Samba-Share auf dem Docker/K3s-Host
│
│ scan-mover.timer (systemd, 60 s) ◀── Befund 1: fehlte komplett
▼
<consume-dir> ← hostPath, in den Paperless-Pod gemountet
│
│ Paperless-Consumer (Polling 30 s)
│ OCRmyPDF / Tesseract ◀── Befund 2: Sprache "eng"
▼
Paperless-ngx ──────────────┐
(PostgreSQL, Redis, Tika) │ REST-API
▼
paperless-ai
│ Prompt + Tag-/Korrespondentenliste
│ ◀── Befund 3: Liste immer leer
│ ◀── Befund 4: num_ctx > Modell
▼
Ollama (eigener GPU-Host) ◀── Befund 5: Blockade + OOM
Quer dazu: Paperless' eigenes Tag-Matching ◀── Befund 6: faktisch abgeschaltet
Alle Komponenten laufen im LAN. Paperless-ngx und paperless-ai liegen als Deployments in einem K3s-Cluster, Ollama auf einem separaten Rechner mit zwei GPUs.
Zusammenfassung der Befunde
| # | Komponente | Fehler | Auswirkung | Art |
|---|---|---|---|---|
| 1 | scan-mover | Bei Migration docker-compose → K3s nicht mit übernommen | Scans erreichten Paperless nie; Backlog von Monaten | Migrationsfehler |
| 2 | Paperless-ngx | PAPERLESS_OCR_LANGUAGE nicht gesetzt → Default eng | Deutsche Belege ohne Umlaute; kaputte Namen | Fehlkonfiguration |
| 3 | paperless-ai | _formatTagsList() verwirft String-Arrays | RESTRICT_TO_EXISTING_TAGS wirkungslos; Modell sah nie die Tags | Upstream-Bug |
| 4 | paperless-ai | TOKEN_LIMIT (128000) > Modellkontext (32768) | num_ctx jenseits des trainierten Fensters | Fehlkonfiguration |
| 5 | paperless-ai / Ollama | validateOllamaConfig() ruft axios.post ohne Timeout | Ein blockierendes Ollama verhindert den Start jedes Scans | Upstream-Bug |
| 6 | Paperless-ngx | Tag-Regeln leer bzw. doppelt escaped | Deterministische Zuordnungen dem LLM überlassen — es riet | Fehlkonfiguration |
Zusätzlich: Der LLM-Host hatte kein Speicherlimit für Ollama und ging zweimal an einem globalen OOM in die Knie (siehe Härtung).
Befund 1 — Der Scanner war nie angeschlossen
Symptom
Dutzende PDFs lagen unberührt im Samba-Share, das älteste Monate alt. Paperless kannte keines davon.
Ursache
Die ursprüngliche docker-compose.yml enthielt einen kleinen Hilfscontainer:
scan-mover:
image: alpine:3.19
volumes:
- /srv/scanner:/scan-inbox
- ./data/inbox:/paperless-consume
command: >
sh -c 'apk add --no-cache inotify-tools &&
inotifywait -m -e close_write -e moved_to --format "%f" /scan-inbox |
while read FILE; do ... mv "/scan-inbox/$$FILE" "/paperless-consume/$$FILE"; done'
Beim Umzug nach Kubernetes wurden Paperless und paperless-ai als Deployments neu gebaut — der scan-mover fiel dabei ersatzlos weg. Niemandem fiel es auf, weil Paperless weiterhin Dokumente aus anderen Quellen (E-Mail-Import) erhielt.
Zweiter, subtilerer Fehler im Original
inotifywait -m reagiert ausschließlich auf neu eintreffende Dateien. Selbst wenn der Container weitergelaufen wäre, hätte er einen einmal entstandenen Rückstand nie abgearbeitet. Ein Neustart des Containers hätte den Backlog dauerhaft liegen lassen.
Lösung
Ein systemd-Timer statt eines Watch-Containers. Er ist zustandslos, räumt bestehende Dateien mit ab und übersteht Neustarts:
# scripts/nuc-ha/scan-mover.sh (Auszug)
find "$SRC" -maxdepth 1 -type f -mmin +0.5 \
\( -iname '*.pdf' -o -iname '*.jpg' -o ... \) -print0 |
while IFS= read -r -d '' f; do
# Noch von smbd geöffnet? Dann später nochmal.
if command -v fuser >/dev/null 2>&1 && fuser -s "$f" 2>/dev/null; then
continue
fi
...
mv -n "$f" "$tgt"
done
Zwei Schutzmechanismen gegen halb geschriebene Uploads:
-mmin +0.5— nur Dateien, die seit 30 s nicht mehr verändert wurden.fuser -s— überspringt Dateien, diesmbdnoch offen hält.
mv -n verhindert stilles Überschreiben; bei Namenskollision wird ein Zeitstempel angehängt.
Deployment: systemd/nuc-ha/scan-mover.{service,timer}, Intervall 60 s.
Befund 2 — OCR lief auf Englisch
Symptom
Auffällig war eine Zeile im Paperless-Log:
[DEBUG] [paperless.parsing.tesseract] Calling OCRmyPDF with args: {..., 'language': 'eng', ...}
PAPERLESS_OCR_LANGUAGE war nirgends gesetzt. Paperless-ngx fällt dann auf eng zurück — auch wenn deu im Image vorhanden ist (tesseract --list-langs zeigte deu eng fra ita osd spa).
Auswirkung
Deutsche Belege wurden ohne Umlaute erkannt. Der Schaden reicht weiter als Kosmetik: Er trifft jede nachgelagerte Stufe, weil das LLM auf diesem Text arbeitet und Paperless darauf sucht.
Ein reales Beispiel — derselbe Doppelname, vor und nach der Umstellung (anonymisiert; Tesseract las i als l):
eng: ... Mueller-Schmldt , <Vorname> , ... ← Volltextsuche findet den Namen nicht
deu: ... Müller-Schmidt , <Vorname> , ...
Ein weiteres, an einem Behördenformular:
eng: Anschrift der inlandischen juristischen Person
deu: Anschrift der inländischen juristischen Person
Nachweis
Der sauberste Beleg ergab sich aus den 25 nachgeholten Scans: Sie wurden nacheinander konsumiert, während mitten im Lauf die OCR-Sprache umgestellt wurde. Die Dokument-IDs folgen der Konsum-Reihenfolge, gezählt wurden die Umlaute im erkannten Text:
ID Zeichen Umlaute
1496 1382 0 ┐
1497 16987 0 │ OCR-Sprache: eng
1500 9038 0 │
1501 3880 0 ┘
1502 5237 43 ┐
1503 2519 17 │ OCR-Sprache: deu
1505 15607 235 │
1513 15716 156 ┘
Der Umschaltpunkt ist exakt an der erwarteten Stelle sichtbar. Das ist kein Indiz, sondern ein Beweis.
Lösung
kubectl -n <ns> set env deploy/paperless PAPERLESS_OCR_LANGUAGE=deu
Bei gemischtsprachigen Beständen ist deu+eng sinnvoll — dann zuerst die häufigste Sprache nennen. Mehrere Sprachen kosten OCR-Laufzeit.
Bestehende Dokumente nachträglich neu erkennen
Paperless-ngx bringt dafür ein Management-Kommando mit. Es erzeugt die Archivfassung neu und aktualisiert den Textinhalt:
python manage.py document_archiver -f -d <DOCUMENT_ID> --no-progress-bar
Ohne -d läuft es über den gesamten Bestand — bei großen Archiven sehr teuer, deshalb besser gezielt auf betroffene IDs.
Grenze: Dokumente ohne maschinenlesbaren Text (Unterschriften, Fotos, handschriftliche Notizen) bleiben auch nach der Neu-OCR leer. paperless-ai überspringt sie mit
has no content, skipping analysis— das ist korrekt und kein Fehler.
Befund 3 — RESTRICT_TO_EXISTING_TAGS war wirkungslos (Upstream-Bug)
Dies ist der interessanteste Befund, weil die Konfiguration korrekt aussah und die Software keinerlei Fehler meldete.
Symptom
Trotz RESTRICT_TO_EXISTING_TAGS=yes vergab das Modell Tags erkennbar „aus dem Bauch”: Es traf vorhandene Tags nur zufällig, und die Trefferquote war schlecht. Gleichzeitig wuchsen Korrespondenten und Dokumenttypen ungebremst (im untersuchten Bestand 617 Korrespondenten und 389 Dokumenttypen auf 1445 Dokumente).
Analyse
paperless-ai spielt die Bestandslisten nur über Platzhalter in den System-Prompt ein. Ohne %RESTRICTED_TAGS% im eigenen Prompt sieht das Modell nie eine Tag-Liste:
// services/ollamaService.js — _buildPrompt()
systemPrompt = RestrictionPromptService.processRestrictionsInPrompt(
systemPrompt, existingTags, correspondentList, existingDocumentTypes, config
);
Das allein ist bereits eine Falle: Wer den mitgelieferten Prompt durch einen eigenen ersetzt, verliert die Platzhalter — und damit die Funktion — ohne Warnung.
Doch das Einsetzen des Platzhalters genügt nicht. Der Aufrufer übergibt ein String-Array:
// server.js
const existingTagNames = existingTags.map(tag => tag.name); // ← Strings
...
const result = await processDocument(doc, existingTagNames, ...);
Der Formatierer erwartet dagegen Objekte:
// services/restrictionPromptService.js — Original
static _formatTagsList(existingTags) {
if (!Array.isArray(existingTags) || existingTags.length === 0) return '';
return existingTags
.filter(tag => tag && tag.name) // ← wirft jeden String weg
.map(tag => tag.name)
.join(', ');
}
"Rechnung".name ist undefined, der Filter entfernt also jeden Eintrag. _formatTagsList liefert den Leerstring, %RESTRICTED_TAGS% wird durch nichts ersetzt.
Dass es sich um einen Bug und nicht um Absicht handelt, zeigt die Schwesterfunktion in derselben Datei — sie behandelt beide Formen korrekt:
static _formatCorrespondentsList(existingCorrespondentList) {
...
return existingCorrespondentList
.filter(Boolean)
.map(correspondent => {
if (typeof correspondent === 'string') return correspondent; // ← hier schon richtig
return correspondent?.name || '';
})
.filter(name => name.length > 0)
.join(', ');
}
Warnung
Wer %RESTRICTED_TAGS% in den Prompt schreibt, ohne den Bug zu beheben, erhält eine Anweisung der Form:
ERLAUBTE TAGS - vergib ausschliesslich Tags aus dieser Liste:
— gefolgt von nichts. Das ist schlechter als der Ausgangszustand, weil das Modell angewiesen wird, aus einer leeren Menge zu wählen. Beide Änderungen gehören zusammen.
Lösung
patches/restrictionPromptService.js spiegelt die Logik der Korrespondenten-Funktion. Der Patch wird als ConfigMap über die Originaldatei gemountet, überlebt damit Container-Neustarts und imagePullPolicy: Always:
kubectl -n <ns> create configmap paperless-ai-patch \
--from-file=restrictionPromptService.js=patches/restrictionPromptService.js
volumeMounts:
- name: patch
mountPath: /app/services/restrictionPromptService.js
subPath: restrictionPromptService.js
readOnly: true
Wirkungsnachweis
ollamaService protokolliert die Prompt-Größe. Vor und nach dem Patch, gleicher Bestand (444 Tags ≈ 2360 Tokens):
vorher: Prompt Token Count: 1321 Dynamic calculated num_ctx: 2367
nachher: Prompt Token Count: 3696 Dynamic calculated num_ctx: 4720
Zusätzlich direkt gegen die gepatchte Funktion geprüft:
$ node -e 'const R=require("/app/services/restrictionPromptService.js");
console.log(R.processRestrictionsInPrompt("Tags: %RESTRICTED_TAGS%",
["Rechnung","Vertrag","steuerjahr:2025"], [], {}))'
Tags: Rechnung, Vertrag, steuerjahr:2025
Nebenbefund
Die Signatur passt nicht zum Aufruf — fünf Argumente, vier Parameter:
static processRestrictionsInPrompt(prompt, existingTags, existingCorrespondentList, config)
config erhält damit die Dokumenttypen-Liste. Folgenlos, weil config im Rumpf ungenutzt ist, aber ein Hinweis darauf, dass dieser Codepfad wenig Aufmerksamkeit bekommen hat.
Befund 4 — TOKEN_LIMIT über dem Modellkontext
TOKEN_LIMIT steuert bei paperless-ai zweierlei: die Kürzung des Dokumentinhalts und die Obergrenze für das an Ollama gesendete num_ctx:
_calculateNumCtx(promptTokenCount, expectedResponseTokens) {
const totalTokenUsage = promptTokenCount + expectedResponseTokens;
const maxCtxLimit = Number(config.tokenLimit);
return Math.min(totalTokenUsage, maxCtxLimit);
}
Konfiguriert war TOKEN_LIMIT=128000. Das verwendete Modell kann 32768:
$ curl -s http://<ollama>:11434/api/show -d '{"model":"<modell>"}' | jq '.model_info | with_entries(select(.key|test("context_length")))'
{ "qwen2.context_length": 32768 }
Bei langen Dokumenten wird damit ein num_ctx jenseits des trainierten Fensters angefordert. Ollama alloziert den KV-Cache trotzdem — das kostet Speicher und die Ausgabequalität bricht ein.
Regel: TOKEN_LIMIT ≤ context_length des Modells. Hier auf 32000 gesetzt.
Befund 5 — Ollama-Blockade ohne Timeout
Symptom
paperless-ai lief, antwortete auf HTTP, aber es tauchte nie wieder ein Starting scheduled scan im Log auf. Die letzte Zeile war stets:
Validating Paperless config for: http://.../api/documents/
AI provider: ollama
← und dann nichts mehr
Analyse
Der Cron wird erst nach einer erfolgreichen Validierung registriert:
async function startScanning() {
const isConfigured = await setupService.isConfigured(); // ← validiert u. a. Ollama
...
if (config.disableAutomaticProcessing != 'yes') {
await scanInitial();
cron.schedule(config.scanInterval, async () => { ... }); // ← erst hier
}
}
Und die Validierung ruft Ollama ohne Timeout:
async validateOllamaConfig(url, model) {
try {
const response = await axios.post(`${url}/api/generate`, {
model: model || 'llama3.2', prompt: 'Test', stream: false
}); // ← kein timeout, kein AbortSignal
return response.data && response.data.response;
} catch (error) { ... }
}
Antwortet Ollama nicht, wartet paperless-ai unbegrenzt. Es gibt keinen Fehler, keinen Neustart, keinen Scan. Der Prozess wirkt gesund: Der Express-Server läuft, ein HTTP-Health-Check ist grün. Eine Kubernetes-livenessProbe auf / hätte diesen Zustand nicht erkannt.
Warum Ollama blockierte
Zwei Ursachen, die sich gegenseitig verstärkten:
-
Modell-Karussell. Eine andere Anwendung auf demselben Ollama hielt ein 14B-Modell mit
keep_alive=30mim VRAM (22 GB von 24 GB). paperless-ai forderte ein anderes Modell an. Ollama kann es nicht daneben laden und stellt die Anfrage in eine Warteschlange — ohne Rückmeldung. Innerhalb von 30 Minuten zählte das Log 72 Modell-Ladevorgänge: die Clients warfen sich gegenseitig aus dem Speicher. -
Speichernot. Modelle, die nicht ins VRAM passen, laufen teilweise im System-RAM. Auf einem Host mit 15 GB RAM und
OLLAMA_MAX_LOADED_MODELS=3genügte das für zwei globale OOM-Ereignisse an einem Tag:oom-kill:constraint=CONSTRAINT_NONE,...,global_oom,task_memcg=/system.slice/ollama.service,task=llama-server Out of memory: Killed process (llama-server) total-vm:110887672kB, anon-rss:7218084kBDanach war der Host in einem tückischen Zustand: Er antwortete auf Ping, TCP-Handshakes kamen zustande (der Kernel lebte), aber kein einziger Userspace-Prozess reagierte — weder SSH noch Ollama. Ein Reboot war nur über die Stromversorgung möglich.
Lösung
Der eleganteste Fix kostet keine Zeile Code: dasselbe Modell verwenden wie der andere Client. Dann entfällt der Modellwechsel, das Modell bleibt geladen, es gibt keine Verdrängung und keinen RAM-Überlauf. Als angenehme Nebenwirkung folgt das größere Modell den Prompt-Anweisungen zuverlässiger.
kubectl -n <ns> set env deploy/paperless-ai OLLAMA_MODEL=<gemeinsames-modell>
Fallstrick: paperless-ai lädt seine
.envmitdotenv.config({ path })— ohneoverride. StehtOLLAMA_MODELbereits in der Container-Umgebung (z. B. überenvFrom.secretRef), gewinnt diese und die.env-Änderung bleibt wirkungslos. In Kubernetes hat ein explizitesenvVorrang vorenvFrom— dort ansetzen. Kontrolle:kubectl exec <pod> -- printenv OLLAMA_MODEL
Ergänzend wurde der LLM-Host abgesichert (nächster Abschnitt), damit ein einzelner Modelllauf nicht mehr die ganze Maschine mitreißt.
Befund 6 — Paperless’ eigenes Tag-Matching war abgeschaltet
Symptom
Ein Kontoauszug, der den Namen der betreffenden Person fünfmal wörtlich enthält, bekam den zugehörigen Personen-Tag nicht. Der System-Prompt beschrieb die Regel unmissverständlich („Tag X nur, wenn einer dieser Begriffe wörtlich vorkommt”). Das Modell hielt sich trotzdem nicht daran.
Analyse
Zwei getrennte Ursachen — und beide zeigen, dass hier auf das falsche Werkzeug gesetzt wurde.
a) 442 von 444 Tags hatten matching_algorithm = "any word" mit leerem match-Feld. Ohne Suchbegriffe trifft dieses Verfahren nie. Paperless’ eingebautes, deterministisches Matching war damit faktisch deaktiviert.
b) Die beiden verbliebenen Regex-Regeln waren doppelt escaped und trafen ebenfalls nichts:
>>> gespeichert = r'(?i)(\\bsteuer(?:jahr)?[_\\-\\s]*2025\\b|...)'
>>> re.search(gespeichert, "Rechnung 2025")
None # \\b = Backslash gefolgt von 'b', nicht Wortgrenze
>>> re.search(gespeichert.replace('\\\\','\\'), "Rechnung 2025")
<re.Match ...> # einfach escaped: Treffer
Solche Regeln scheitern stumm. In der Oberfläche sieht die Regex korrekt aus, es gibt keine Fehlermeldung, und der Tag wird trotzdem nie automatisch vergeben. Wer nicht misst, merkt es nie.
Lösung — die richtige Aufgabenteilung
Ein LLM ist ein schlechter Regel-Interpreter. Es kann „vergib Tag X nur bei Begriff Y” befolgen, aber es tut es nicht zuverlässig — und Zuverlässigkeit ist bei einer deterministischen Regel der ganze Punkt.
Deterministisches gehört in Paperless-Regeln. Das LLM ist für das Unscharfe da.
Konkret: Tags, die an einem wörtlichen Merkmal hängen (Namen, Kontonummern, Jahreszahlen, Absender), werden als Regex-Regel hinterlegt. Paperless wendet sie bei jeder Konsumierung automatisch an — ohne Modell, ohne Token, ohne Zufall.
curl -X PATCH -H "Authorization: Token $TOKEN" -H 'Content-Type: application/json' \
-d '{"matching_algorithm":4,"match":"(Nachname-A|Vorname\\s+Nachname-B|Strassenname)","is_insensitive":true}' \
http://<paperless>/api/tags/<id>/
matching_algorithm: 0 keine · 1 any word · 2 all words · 3 literal · 4 regex · 5 fuzzy · 6 auto (ML-Klassifizierer)
Rückwirkend auf den Bestand anwenden:
python manage.py document_retagger -T --suggest --no-progress-bar # Trockenlauf
python manage.py document_retagger -T --no-progress-bar # anwenden
-f/--overwritenicht verwenden, solange die meisten Tags keine Regeln haben: Das Flag entfernt alle Tags, die nicht mehr auf eine Regel passen — also praktisch den gesamten manuell oder per Import vergebenen Bestand. Ohne-fwird nur hinzugefügt.
Greift die Regel neben paperless-ai?
Ja. paperlessService.updateDocument() mischt die Tags, statt sie zu ersetzen:
const combinedTags = [...new Set([...currentDoc.tags, ...updates.tags])];
Reihenfolge im Betrieb: Paperless setzt beim Konsumieren die Regel-Tags, paperless-ai ergänzt später seine eigenen. Nichts geht verloren.
Nachweis
Ein erzeugtes Test-PDF mit dem Trigger-Begriff im Text wurde in den Scanner-Share gelegt und durchlief die komplette Kette. Ergebnis nach der Konsumierung, bevor paperless-ai das Dokument überhaupt gesehen hatte:
#1516 REGELTEST Tags: ['<Personen-Tag>']
Rückwirkend fügte document_retagger -T den Tag auf 23 weiteren Bestandsdokumenten hinzu.
Ergänzend: Nützt die Tag-Liste im Prompt überhaupt?
Der Patch aus Befund 3 macht die Bestandsliste für das Modell sichtbar. Ob das die Qualität hebt, wurde nicht geglaubt, sondern gemessen — identischer Inhalt, identische Sampling-Parameter, einmal mit und einmal ohne Liste im System-Prompt:
--- Dokument A (Aufhebungsvertrag)
ohne Liste: ['steuerjahr:2026'] → 1 gültig
mit Liste: ['steuerjahr:2026', 'PersonA'] → 2 gültig
--- Dokument B (Mitteilung Finanzverwaltung)
ohne Liste: ['PersonA'] → 1 gültig
mit Liste: ['PersonA', 'steuerjahr:2025'] → 2 gültig
--- Dokument C (Jahresbericht)
ohne Liste: ['PersonA'] → 1 gültig
mit Liste: ['PersonA', 'ordner/.../Behörden'] → 2 gültig
Summe: ohne Liste 3/3 gültig · mit Liste 6/6 gültig
Die Liste verdoppelt die Zahl brauchbarer Vorschläge, ohne Ausschuss zu erzeugen — sie bleibt aktiv. Bemerkenswert: In keinem der Läufe vergab das Modell den Personen-Tag, obwohl der Name im Text stand. Das ist kein Argument gegen die Liste, sondern für die Regel — und die beste Begründung für die Aufgabenteilung oben.
Härtung des LLM-Hosts
1. Speichergrenze für Ollama (cgroup)
Ohne Limit kann ein ins RAM ausgelagertes Modell den gesamten Host in einen globalen OOM treiben. Mit Limit stirbt nur der Dienst und wird neu gestartet.
# systemd/ki02/ollama-10-memory-guard.conf
[Service]
MemoryHigh=11G
MemoryMax=12G
OOMPolicy=continue # llama-server darf sterben, ohne den Dienst zu stoppen
Restart=always
RestartSec=5
Wichtig:
memory.currenteines cgroups zählt auch den Page-Cache der Modell-Blobs mit. Ein zu knappesMemoryHighführt daher zu ständigem Cache-Reclaim und damit zu wiederholtem Nachladen der Modelldateien von der Platte. Genügend Luft lassen und live gegenprüfen:cat /sys/fs/cgroup/system.slice/ollama.service/memory.{max,high,current}
2. earlyoom als Netz für alles Übrige
Greift, bevor der Kernel-OOM den Host ins Thrashing schickt.
EARLYOOM_ARGS="-m 6,3 -s 100,50 -r 60
--avoid '(^|/)(systemd|sshd|dbus-daemon|nvidia-persistenced)$'
--prefer '(^|/)(llama-server|ollama)$'"
--avoid schützt den Weg zurück auf die Maschine (sshd!), --prefer opfert zuerst die LLM-Prozesse.
3. Auto-Reboot bei totalem Freeze
Der Board-Watchdog wäre die saubere Lösung, ist auf dieser Hardware aber vom BIOS gesperrt:
iTCO_wdt iTCO_wdt.1.auto: unable to reset NO_REBOOT flag, device disabled by hardware/BIOS
Ersatz ist softdog. Er genügt für den beobachteten Fehlerfall — der Kernel lebte, nur der Userspace stand.
# systemd/ki02/systemd-10-watchdog.conf
[Manager]
RuntimeWatchdogSec=120 # 120 s statt 60 s: keine Fehlalarme bei Lastspitzen
RebootWatchdogSec=5min
echo softdog > /etc/modules-load.d/softdog.conf
systemctl daemon-reexec
# Kontrolle: journalctl -b | grep -i watchdog
# systemd[1]: Using hardware watchdog 'Software Watchdog', device /dev/watchdog0
softdogschützt nicht gegen einen echten Kernel-Hänger — dafür braucht es einen Hardware-Watchdog. Wenn das BIOS ihn erlaubt, istiTCO_wdtvorzuziehen.
4. Dienst-Watchdog für Ollama
Für den häufigeren Fall, dass nur Ollama klemmt und der Host gesund ist. Als Sonde dient /api/ps: billig, lädt kein Modell — und hing im Störfall genauso wie /api/generate.
# scripts/ki02/ollama-watchdog.sh (Auszug)
if curl -sf -m 20 -o /dev/null http://127.0.0.1:11434/api/ps; then
rm -f "$STATE"; exit 0
fi
fails=$(( $(cat "$STATE" 2>/dev/null || echo 0) + 1 ))
echo "$fails" > "$STATE"
[ "$fails" -ge 3 ] && { systemctl restart ollama.service; rm -f "$STATE"; }
Timer alle 2 Minuten, Auslösung nach 3 aufeinanderfolgenden Fehlschlägen (≈ 6 Minuten), damit ein einzelner langer Modell-Ladevorgang keinen Neustart provoziert.
Verifikation
Jede Änderung wurde am laufenden System gegengeprüft, nicht nur ausgerollt.
Scanner-Strecke, end-to-end. Eine erzeugte Test-PDF wurde in den Samba-Share gelegt, vom Timer verschoben, von Paperless konsumiert und anschließend wieder gelöscht:
/srv/scanner: 1 → 0 consume: 0 → 1 → 0 Dokumente: 1444 → 1445 → 1444
Rückstand. Alle 25 liegengebliebenen Scans wurden konsumiert (1420 → 1445 Dokumente), /srv/scanner ist leer.
OCR-Sprache. Im laufenden Betrieb gegengelesen:
$ kubectl exec <paperless-pod> -- tail -n 200 .../paperless.log | grep -oE "'language': '[a-z+]+'"
'language': 'deu'
Tag-Injektion. Prompt-Größe von 1321 auf 3696 Tokens gestiegen (siehe Befund 3).
Modellwahl. kubectl exec <pod> -- printenv OLLAMA_MODEL gegen die Pod-Spec geprüft — nicht gegen die .env, die hier bewusst nicht maßgeblich ist.
Ollama stabil. /api/ps zeigt dauerhaft ein einziges geladenes Modell; die Ladevorgänge im Log sind versiegt.
Eine Warnung zur Methodik
Zwei plausible Hypothesen hielten der Messung nicht stand:
-
„Die vielen Tags aus dem Altsystem-Import sind Müll und verwirren das Modell.” Falsch — sie waren fachlich gewollt.
-
„Die literalen
\nim System-Prompt verschlechtern die Anweisungsbefolgung.” Sie sind Upstream-Absicht (routes/setup.jsescaped Zeilenumbrüche beim Speichern) und richteten messbar keinen Schaden an.
Ebenso hielt der Verdacht der Übervergabe eines Personen-Tags nicht: Eine Auszählung gegen die im Prompt definierten Trigger-Begriffe ergab 110 von 116 Zuweisungen regelkonform. Das tatsächliche Problem war die Gegenrichtung — 92 Dokumente, die einen Trigger-Begriff wörtlich enthielten, trugen den Tag nicht. Genau diese Trefferquote adressiert der Patch aus Befund 3.
Erst messen, dann reparieren. Eine überzeugende Erklärung ist kein Beweis.
Repository-Inhalt
scripts/nuc-ha/scan-mover.sh Scanner-Share → Paperless-Consume (Ersatz für scan-mover-Container)
scripts/ki02/ollama-watchdog.sh Health-Check + Neustart von Ollama
scripts/ki02/earlyoom.default earlyoom-Konfiguration
systemd/nuc-ha/scan-mover.{service,timer} Timer-Einheit, 60 s
systemd/ki02/ollama-10-memory-guard.conf cgroup-Speichergrenzen für Ollama
systemd/ki02/systemd-10-watchdog.conf softdog + RuntimeWatchdogSec
systemd/ki02/ollama-watchdog.{service,timer}
patches/restrictionPromptService.js Fix für den leeren %RESTRICTED_TAGS%-Platzhalter
manifests/paperless.yaml Deployment inkl. PAPERLESS_OCR_LANGUAGE
manifests/paperless-ai.yaml Deployment inkl. Modell-Override und Patch-Mount
manifests/paperless-ai-patch-configmap.yaml
Namespaces, Hostnamen und IP-Adressen in den Manifesten sind an die eigene Umgebung anzupassen. Zugangsdaten enthält dieses Repository nicht — sie liegen in einem Secret, auf das die Deployments per envFrom verweisen.
Betriebs-Runbook
Scans kommen nicht an
systemctl status scan-mover.timer
journalctl -t scan-mover --since -1h
ls -1 <scanner-share> <consume-dir>
Liegen Dateien im Consume-Verzeichnis und verschwinden nicht, prüft man den Consumer:
kubectl exec <paperless-pod> -- tail -n 200 /usr/src/paperless/data/log/paperless.log \
| grep -iE 'consum|duplicate|error'
Bleiben Dateien mit It is a duplicate of … liegen, ist das erwartetes Verhalten — Paperless löscht sie nicht. Sie müssen von Hand weggeräumt werden, sonst laufen sie bei jedem Poll erneut in den Fehler.
paperless-ai verarbeitet nichts
Zuerst prüfen, ob der Scan überhaupt startet:
kubectl logs <paperless-ai-pod> | grep -E 'Configured scan interval|Starting (initial|scheduled) scan'
Fehlt Configured scan interval, hängt der Start in der Ollama-Validierung. Gegenprobe — und zwar mit /api/generate, nicht mit /api/version, denn letzteres antwortet auch bei blockierter Inferenz:
curl -m 30 -X POST http://<ollama>:11434/api/generate \
-d '{"model":"<modell>","prompt":"Test","stream":false}'
Ollama blockiert
curl -s http://<ollama>:11434/api/ps # welches Modell hält das VRAM?
journalctl -u ollama --since -30min | grep -c 'loaded model' # Modell-Karussell?
journalctl -b -1 -k | grep -iE 'oom|killed process' # OOM im letzten Boot?
Viele loaded model-Einträge bedeuten, dass sich mehrere Clients gegenseitig aus dem Speicher werfen. Abhilfe: gemeinsames Modell, OLLAMA_MAX_LOADED_MODELS an die reale VRAM-Kapazität anpassen, keep_alive senken.
Ein Tag wird nicht vergeben, obwohl der Begriff im Dokument steht
Prüfen, ob der Tag überhaupt eine Regel hat und ob sie greift:
curl -s -H "Authorization: Token $TOKEN" http://<paperless>/api/tags/<id>/ \
| python3 -c 'import sys,json;t=json.load(sys.stdin);print(t["matching_algorithm"], repr(t["match"]))'
1 mit leerem match bedeutet: kein Matching. 4 mit \\b statt \b: doppelt escaped, trifft nichts. Regel setzen, dann document_retagger -T --suggest zum Trockenlauf.
Nach Konfigurationsänderung an paperless-ai
.env und Container-Umgebung können sich widersprechen. Maßgeblich ist immer:
kubectl exec <pod> -- printenv | grep -E 'OLLAMA_|TOKEN_LIMIT|RESTRICT_'
Fallstricke
kubectl execdirekt nach einem Rollout trifft womöglich noch den alten Pod. Immer gegen.status.startTimeoder den Pod-Namen gegenprüfen — sonst misst man den Zustand vor der eigenen Änderung. (Genau das ist während dieser Fehlersuche passiert.)dotenvüberschreibt keine bestehenden Umgebungsvariablen. Was ausenvFromkommt, gewinnt gegen die.env.TOKEN_LIMITist kein reiner Kürzungsparameter, sondern auch die Obergrenze fürnum_ctx.- Ein eigener
SYSTEM_PROMPTdeaktiviert stillschweigend die Restriktionsfunktion, wenn die Platzhalter fehlen. /api/versionist kein Health-Check für Ollama. Es antwortet auch dann noch, wenn keine einzige Inferenz mehr durchgeht.- Ein HTTP-200 des paperless-ai-Servers bedeutet nicht, dass der Scan-Cron läuft.
document_archiverohne-dläuft über den gesamten Bestand. Bei großen Archiven gezielt einzelne IDs angeben.- Verwaiste Prozesse tarnen sich als Systemlast. Auf dem Cluster-Host lief ein vergessener
ugrepseit elf Tagen mit über 500 % CPU und trug maßgeblich zu einem node-weiten OOM bei.ps -eo pcpu,etime,args --sort=-pcpu | headgehört in jede Erstdiagnose. - Ein Tag mit
matching_algorithm: any wordund leeremmatchmatcht nie. Das ist der Auslieferungszustand vieler importierter Tags und sieht in der Oberfläche unauffällig aus. - Doppelt escapte Regex-Regeln scheitern stumm.
\\bist keine Wortgrenze, sondern ein Backslash gefolgt vonb. Immer gegen einen echten Dokumenttext gegenprüfen, nie gegen die Anzeige vertrauen. document_retagger -fentfernt Tags, die auf keine Regel mehr passen. Bei Beständen mit überwiegend regellosen Tags ist das ein Datenverlust.- Ein LLM ist kein Regel-Interpreter. Was sich als Regex ausdrücken lässt, gehört als Paperless-Regel hinterlegt — nicht in den System-Prompt.
Upstream-Meldungen
Zwei Befunde betreffen clusterzx/paperless-ai und sollten dort gemeldet werden:
-
_formatTagsList()verwirft String-Arrays (services/restrictionPromptService.js).server.jsübergibtexistingTagNames(Strings), der Filtertag => tag && tag.nameentfernt sie alle. Folge:%RESTRICTED_TAGS%bleibt leer,RESTRICT_TO_EXISTING_TAGSist wirkungslos. Die Schwesterfunktion_formatCorrespondentsList()behandelt beide Formen bereits korrekt und dient als Vorlage. Betrifft alle AI-Provider, dieRestrictionPromptServicenutzen. Fix:patches/restrictionPromptService.js. -
validateOllamaConfig()setzt kein Timeout (services/setupService.js).axios.post(...)ohnetimeout/AbortSignalblockiertstartScanning()unbegrenzt, wenn Ollama nicht antwortet. Der Cron wird nie registriert, der Prozess wirkt gesund. Eintimeoutvon einigen Sekunden plus aussagekräftige Log-Meldung würde genügen.
Kleinerer Nebenbefund: processRestrictionsInPrompt() wird mit fünf Argumenten bei vier Parametern aufgerufen (ollamaService.js); der Parameter config empfängt dadurch die Dokumenttypen-Liste. Derzeit folgenlos, da im Rumpf ungenutzt.
Lizenz
Die Skripte und der Patch stehen unter der MIT-Lizenz. patches/restrictionPromptService.js ist eine abgeleitete Fassung einer Datei aus clusterzx/paperless-ai und unterliegt der Lizenz des Ursprungsprojekts.