Warum Docling im eigenen Netzwerk?
Docling ist ein Open-Source-Werkzeug von IBM, das Dokumente in strukturiertes Markdown oder JSON umwandelt. PDF, DOCX, PPTX und HTML gehen hinein, sauber gegliederter Text mit erkannten Tabellen und extrahierten Grafiken kommt heraus. Genau das braucht man, wenn Dokumente für ein LLM nutzbar gemacht werden sollen.
Bei mir ist Docling der Einstieg in ein LLM Wiki. Die Idee dahinter: Statt Dokumente bei jeder Frage neu zu durchsuchen, wird das Wissen einmal in eine strukturierte, verlinkte Wissensbasis überführt und dort fortlaufend gepflegt. Damit das funktioniert, müssen die Quelldokumente erst einmal maschinenlesbar werden. Diesen Schritt übernimmt Docling.
Ich installiere Docling lokal auf einem Server mit GPU. Der Vorteil einer Installation als Netzwerkdienst statt als lokales Python-Paket: Docling steht danach allen anderen Diensten im Heimnetz zur Verfügung. Open WebUI kann es zur Dokumentenverarbeitung nutzen, eigene Skripte und Agenten greifen per HTTP darauf zu, und andere Rechner im Netz brauchen keine eigene Installation. Ein Dienst, viele Konsumenten. Dazu kommt der Datenschutzaspekt: Kein Dokument verlässt das eigene Netzwerk.
Auf demselben Server läuft bei mir zusätzlich Ollama mit einem Vision-Modell. Damit lassen sich die aus den Dokumenten extrahierten Abbildungen automatisch beschreiben. Ein Diagramm ohne Beschreibung ist für ein LLM wertlos. Mit Beschreibung wird der Bildinhalt Teil des durchsuchbaren Wissens. Wie gut das funktioniert und wo die Grenzen liegen, zeige ich am Ende ehrlich.
Die Aufteilung auf zwei Rechner
In dieser Anleitung sind zwei Maschinen im Spiel. Das ist wichtig, weil sonst unklar bleibt, wo welcher Befehl auszuführen ist.
Der GPU-Server beherbergt Docling und Ollama. Bei mir ist das die 192.168.2.119 mit einer RTX 6000 Ada. Hier laufen die Schritte 1 bis 9.
Der Application Server ist die Maschine, auf der meine Dokumente liegen und auf der später die Wiki-Anwendung entsteht. Bei mir die 192.168.178.142, ein Dell OptiPlex mit Ubuntu und ohne nennenswerte GPU. Hier laufen die Schritte 10 bis 14.
Genau darin liegt der Sinn der Sache: Der Application Server braucht selbst keine GPU und keine Docling-Installation. Er schickt die Dokumente über das Netzwerk und bekommt strukturiertes Markdown zurück.
Wer beides auf einer Maschine betreibt, ersetzt in den Schritten 10 bis 14 einfach die IP durch localhost.
Die Konfiguration auf dem GPU-Server steckt am Ende in zwei Dateien:
~/docling/
.env zentrale Einstellungen
docker-compose.yml Dienstdefinition
Auf dem Application Server nutze ich diese Struktur:
~/llm_wiki/
documents/ die Quell-PDFs
out/ die Konvertierungsergebnisse
show.py Auswertungsskript (entsteht in Schritt 11)
describe_images.py Bildbeschreibung (entsteht in Schritt 13)

Docling als Netzwerkdienst, Docling, docling-serve, PDF nach Markdown, Dokumentenextraktion, Docling Docker, GPU Beschleunigung, NVIDIA Container Toolkit, RTX 6000 Ada, CUDA, OCR, Texterkennung, Ollama, qwen3-vl, Vision-Modell, Bildbeschreibung, VLM Halluzination, LLM Wiki, Wissensbasis, RAG Vorverarbeitung, Docker Compose, Selfhosting, lokale KI, souveräne KI, Heimnetz, Open WebUI, Markdown, JSON, asynchrone Verarbeitung, Ubuntu
Voraussetzungen
Für den GPU-Server: Linux mit NVIDIA-GPU, installiertem Treiber sowie Docker inklusive Compose-Plugin.
Befehl: docker info
Läuft der Befehl ohne Fehler durch, kann es losgehen.
Schritt 1: Treiber und CUDA-Stand prüfen
Ab hier arbeiten wir auf dem GPU-Server.
Befehl: nvidia-smi
Oben rechts steht die CUDA-Version. Sie bestimmt, welches Image du brauchst:
- CUDA 12.8 bis 12.9: Image
docling-serve-cu128 - CUDA 13.0 oder höher: Image
docling-serve-cu130
Bei mir meldet der Treiber CUDA 13.3, also nutze ich cu130. Die RTX 6000 Ada (sm_89, Ada Lovelace) wird von beiden Builds unterstützt. Ein zu alter CUDA-Build führt zu Fehlern der Art „no kernel image is available for execution on the device“.
Schritt 2: NVIDIA Container Toolkit prüfen
Wenn auf der Maschine schon andere GPU-Container laufen, ist das Toolkit vermutlich vorhanden.
Befehl: docker run --rm --gpus all nvidia/cuda:13.0.0-base-ubuntu24.04 nvidia-smi
Zeigt der Befehl deine GPU an, geht es weiter mit Schritt 3. Falls nicht, installierst du das Toolkit mit den folgenden Befehlen.
Befehl:
curl -fsSL https://nvidia.github.io/libnvidia-container/gpgkey | \
sudo gpg --dearmor -o /usr/share/keyrings/nvidia-container-toolkit-keyring.gpg
Befehl:
curl -s -L https://nvidia.github.io/libnvidia-container/stable/deb/nvidia-container-toolkit.list | \
sed 's#deb https://#deb [signed-by=/usr/share/keyrings/nvidia-container-toolkit-keyring.gpg] https://#g' | \
sudo tee /etc/apt/sources.list.d/nvidia-container-toolkit.list
Befehl: sudo apt update && sudo apt install -y nvidia-container-toolkit
Befehl: sudo nvidia-ctk runtime configure --runtime=docker && sudo systemctl restart docker
Danach den Test von oben wiederholen.
Schritt 3: Portbelegung prüfen
Docling nutzt Port 5001. Bei mir laufen auf derselben Maschine noch SearXNG auf 8888 und Firecrawl auf 3002, da gibt es keinen Konflikt.
Befehl: sudo ss -tlnp | grep 5001
Keine Ausgabe bedeutet, der Port ist frei. Sonst passt du in der .env die Variable DOCLING_PORT an.
Schritt 4: Aktuellen Release-Tag ermitteln
Die CUDA-Images von docling-serve tragen bewusst kein latest-Tag. Das Projekt will verhindern, dass jemand versehentlich einen veralteten CUDA-Build zieht. Du brauchst die konkrete Versionsnummer:
Befehl:
curl -s https://api.github.com/repos/docling-project/docling-serve/releases/latest | grep tag_name
Bei mir kam dabei heraus:
"tag_name": "v1.30.0",
Schritt 5: Vision-Modell in Ollama bereitstellen
Für die Bildbeschreibung in Schritt 13 brauchst du ein Vision-Modell. Zur Modellgröße gleich mehr, sie ist wichtiger, als man denkt. Ich lade zunächst die kleine Variante für erste Tests und die große für den produktiven Einsatz.
Befehl: ollama pull qwen3-vl:8b
Befehl: ollama pull qwen3-vl:32b
Prüfe danach die exakten Modellnamen, die brauchst du später wörtlich:
Befehl: ollama list
Das Skript in Schritt 13 spricht Ollama direkt an, also muss Ollama im Netzwerk erreichbar sein. Standardmäßig bindet es nur an 127.0.0.1.
Befehl: sudo ss -tlnp | grep 11434
Steht dort 127.0.0.1:11434, musst du das ändern:
Befehl: sudo systemctl edit ollama
Folgenden Abschnitt eintragen und speichern:
[Service]
Environment="OLLAMA_HOST=0.0.0.0"
Environment="OLLAMA_CONTEXT_LENGTH=8192"
Die zweite Zeile begrenzt das Kontextfenster. Ohne sie reserviert Ollama für manche Vision-Modelle den Speicher für ein riesiges Kontextfenster, was die GPU vollständig füllen und andere Dienste verdrängen kann. 8192 Token reichen für Bildbeschreibungen mit großem Abstand.
Befehl: sudo systemctl daemon-reload && sudo systemctl restart ollama
Schritt 6: Arbeitsverzeichnis und zentrale Einstellungen
Ab hier wird konfiguriert. Alle veränderlichen Werte landen in einer einzigen Datei. Ein Hinweis zur Sauberkeit: Keine Leerzeichen am Zeilenende. Docker Compose verzeiht sie meistens, aber spätestens wenn ein eigenes Skript die Datei einliest, werden sie zur schwer auffindbaren Fehlerquelle.
Befehl: mkdir -p ~/docling && cd ~/docling
Befehl: nano .env
Inhalt:
# Docling-Image und Version
DOCLING_IMAGE=ghcr.io/docling-project/docling-serve-cu130
DOCLING_TAG=v1.30.0
# Port auf dem Host
DOCLING_PORT=5001
# Zeitlimit fuer synchrone Anfragen in Sekunden
DOCLING_MAX_SYNC_WAIT=1800
Speichern mit Strg+X, dann Y, dann Enter.
Schritt 7: Compose-Datei anlegen
Wenn Du die nano docker-compose.yml Datei anlegst achte bitte darauf die Einrückungen exakt zu machen. Hier hatte ich immer Probleme beim Copy & Paste aus meinem Blog-Bericht heraus.
Befehl: nano docker-compose.yml
Inhalt:
services:
docling:
image: ${DOCLING_IMAGE}:${DOCLING_TAG}
container_name: docling
ports:
- "${DOCLING_PORT}:5001"
environment:
DOCLING_SERVE_ENABLE_UI: "true"
DOCLING_SERVE_MAX_SYNC_WAIT: "${DOCLING_MAX_SYNC_WAIT}"
volumes:
- docling-cache:/opt/app-root/src/.cache
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:5001/health"]
interval: 30s
timeout: 5s
retries: 3
start_period: 120s
restart: unless-stopped
volumes:
docling-cache:
Docker Compose liest die .env automatisch und setzt die Werte in die geschweiften Klammern ein. Ein Versionswechsel ist damit eine Änderung an einer Zeile in der .env, nicht an der Compose-Datei.
Drei Punkte sind wichtig.
DOCLING_SERVE_MAX_SYNC_WAIT hebt das Zeitlimit für synchrone Anfragen an. Der Standardwert liegt bei 120 Sekunden. Das reicht für ein zweiseitiges Dokument, aber nicht für ein hundertseitiges Magazin. Wer den Wert nicht anhebt, bekommt eine Fehlermeldung, obwohl Docling im Hintergrund noch weiterarbeitet.
healthcheck macht den Zustand des Dienstes in docker ps sichtbar. Die Startphase ist großzügig bemessen, weil das Laden der Modelle beim ersten Start dauert.
Das Cache-Volume speichert Laufzeitdaten und nachgeladene Zusatzmodelle. Die Basismodelle für Layout- und Tabellenerkennung sind bereits im Image enthalten, daher dessen Größe von rund 18 Gigabyte.
Ein Hinweis für alle, die Doclings eingebaute Bildbeschreibung ausprobieren wollen: Sie erfordert zusätzlich die Umgebungsvariablen DOCLING_SERVE_ALLOW_CUSTOM_PICTURE_DESCRIPTION_CONFIG und DOCLING_SERVE_ENABLE_REMOTE_SERVICES. Ich habe diesen Weg ausführlich getestet und bin am Ende bei einer eigenen Lösung gelandet, aus Gründen, die ich in Schritt 13 erkläre. Für die reine Textextraktion braucht es diese Schalter nicht.
Befehl: docker compose up -d
Befehl: docker logs -f docling
Warte auf die Zeile „Server started at http://0.0.0.0:5001“. Der erste Pull dauert entsprechend lange.
Schritt 8: GPU-Nutzung verifizieren
Diesen Schritt lassen viele aus und wundern sich später über CPU-Geschwindigkeit. Dass der Container läuft, heißt nicht, dass die GPU genutzt wird. Der verlässlichste Nachweis steht im Startlog:
Befehl: docker logs docling | grep -i accelerator
Dort muss Accelerator device: 'cuda:0' erscheinen. Steht dort cpu, läuft die Verarbeitung auf dem Prozessor.
Ein Blick von innen bestätigt den GPU-Durchgriff:
Befehl: docker exec docling nvidia-smi
Deine Karte muss auftauchen. Beachte: In der Prozessliste erscheint der Docling-Prozess nicht, weil der Container einen eigenen PID-Namespace hat. Achte stattdessen auf den belegten Speicher. Nach dem Start steigt er typischerweise um etwa ein Gigabyte.
Schritt 9: Ollama-Erreichbarkeit prüfen
Da das Bildbeschreibungs-Skript später Ollama über das Netzwerk anspricht, prüfe ich einmal, ob das Modell antwortet. Ersetze die IP durch die deines GPU-Servers:
Befehl: curl -s http://192.168.2.119:11434/api/tags | head -c 300
Kommt eine JSON-Liste mit deinen Modellen zurück, ist Ollama erreichbar. Damit ist der GPU-Server fertig eingerichtet.
Schritt 10: Wechsel auf den Application Server
Ab hier arbeiten wir auf der anderen Maschine. Bei mir ist das der OptiPlex mit Ubuntu unter 192.168.178.142. Dort liegen meine Dokumente, und von dort aus wird Docling künftig angesprochen.
Als Testmaterial nutze ich das Raspberry-Pi-Magazin MagPi. Es steht unter einer Creative-Commons-Lizenz, ist frei verfügbar und hat ein anspruchsvolles Layout mit mehreren Spalten, Seitenleisten, Code-Listings und vielen Abbildungen. Wenn ein Parser damit klarkommt, kommt er mit fast allem klar.
Meine Ablage:
Befehl: mkdir -p ~/llm_wiki/documents ~/llm_wiki/out
Befehl: cd ~/llm_wiki/documents && ls
Bei mir liegen dort rund 150 Ausgaben:
MagPi01.pdf MagPi02.pdf MagPi03.pdf ... MagPi147.pdf
Zuerst prüfen wir, ob Docling über das Netzwerk erreichbar ist. Ersetze die IP durch die deines GPU-Servers:
Befehl: curl -s -m 5 http://192.168.2.119:5001/health
Die Antwort muss lauten:
{"status":"ok"}
Kommt keine Antwort, prüfst du auf dem GPU-Server die Firewall. Bei aktivem ufw gibst du den Port für das lokale Netz frei:
Befehl (auf dem GPU-Server):
sudo ufw allow from 192.168.2.0/24 to any port 5001
Ein wichtiger Punkt zum Verständnis: Die PDF-Datei wird per HTTP hochgeladen. Sie muss also nicht auf dem GPU-Server liegen, und es braucht auch keine Netzwerkfreigabe. Der Application Server schickt die Datei, Docling verarbeitet sie und schickt das Ergebnis zurück.
Schritt 11: Funktionstest über das Netzwerk
Jetzt der erste echte Durchlauf. Eine MagPi-Ausgabe hat gut hundert Seiten, deshalb beschränke ich den Test mit page_range auf die Seiten 10 bis 14. Das liefert in kurzer Zeit ein aussagekräftiges Ergebnis.
Ein Hinweis vorab: Ich schreibe den curl-Aufruf bewusst in eine einzige Zeile. Mehrzeilige Befehle mit Backslash am Zeilenende gehen beim Kopieren aus einer Webseite gern kaputt. Landet der Backslash mitten in der Zeile, zerlegt er die Argumente, und Docling meldet dann irreführend „Field required“ für Felder, die eigentlich da sind.
Befehl: cd ~/llm_wiki/documents
Befehl: time curl -s -X POST http://192.168.2.119:5001/v1/convert/file -F "files=@MagPi147.pdf" -F "page_range=10" -F "page_range=14" -F "to_formats=md" -F "image_export_mode=referenced" -F "do_pdf_heading_hierarchy=true" -o ~/llm_wiki/out/magpi147_test.json
Für die Auswertung lege ich ein kleines Python-Skript an. Ich nutze dafür bewusst den Editor statt eines Terminal-Tricks, denn in nano bleiben die Zeilenumbrüche beim Einfügen erhalten. Falls dein Terminal beim Einfügen von Code die Einrückung verdoppelt, hilft in nano vorher Alt+I, das schaltet den automatischen Einzug ab.
Befehl: nano ~/llm_wiki/show.py
Inhalt:
import json, sys
d = json.load(open(sys.argv[1]))
print('Status:', d['status'], '| Verarbeitung:', round(d['processing_time'], 1), 's')
print('=' * 60)
print(d['document']['md_content'][:1500])
Speichern mit Strg+X, dann Y, dann Enter. Damit werte ich das Ergebnis aus:
Befehl: python3 ~/llm_wiki/show.py ~/llm_wiki/out/magpi147_test.json
Die Ausgabe bei mir:
Status: success | Verarbeitung: 5.5 s
============================================================
### Raspberry Pi AI Camera
Camera Module with built-in artificial intelligence on sale now. By Naush Patuck

The AI Camera is built around a Sony IMX500 image sensor with an integrated AI accelerator
P eople have been using Raspberry Pi products to build artificial intelligence projects ...
Key features of the Raspberry Pi AI Camera include:
- 12 MP Sony IMX500 Intelligent Vision Sensor
- Sensor modes: 4056×3040 at 10fps, 2028×1520 at 30fps
Beachtenswert ist der Zeitunterschied. Das time-Kommando misst den kompletten Aufruf inklusive Upload der 31 MB großen Datei und der Wartezeit, bis die Modelle auf der GPU bereit sind. Das Feld processing_time misst nur die reine Konvertierung, hier 5,5 Sekunden für fünf Seiten. Der erste Aufruf nach einem Containerstart ist immer deutlich langsamer, danach sind die Modelle warm.
Worauf du im Markdown achtest: Läuft der Fließtext bei mehrspaltigem Layout in der richtigen Reihenfolge durch oder springt er zwischen den Spalten? Sind Bildunterschriften korrekt zugeordnet? Bleiben Aufzählungen als Markdown-Listen erhalten? In meinem Ergebnis stimmt alles. Nur der Initialbuchstabe „P eople“ zeigt eine Eigenheit: Drop Caps im Magazinlayout erkennt Docling als eigenes Element. Für die Weiterverarbeitung ist das unkritisch.
Zwei Optionen im Aufruf sind wichtig und standardmäßig nicht aktiv. image_export_mode=referenced ersetzt den Standardwert placeholder, ohne den werden Abbildungen nur vermerkt, aber nicht exportiert. do_pdf_heading_hierarchy=true ist entscheidend für die Struktur: Ohne diesen Schalter landet jede Überschrift auf Ebene 1 und die Gliederung ist flach.
Schritt 12: Texterkennung an einem echten Scan testen
Der bisherige Test lief ohne Texterkennung, weil das MagPi ein digitales PDF mit eingebettetem Textlayer ist. Docling liest den Text dann direkt aus und startet OCR gar nicht erst. Um die Texterkennung zu testen, brauchen wir einen Scan. Den bauen wir uns aus dem vorhandenen Material selbst: Zwei Seiten werden als Bild gerendert und wieder als PDF verpackt. Der Textlayer ist damit weg, OCR muss arbeiten.
Befehl: sudo apt install -y poppler-utils img2pdf
Befehl: cd ~/llm_wiki/documents && pdftoppm -png -r 200 -f 10 -l 11 MagPi147.pdf /tmp/seite && img2pdf /tmp/seite-*.png -o scan_ohne_textlayer.pdf
Jetzt der Aufruf mit aktivierter Texterkennung. Da das MagPi englisch ist, gebe ich en an. Bei deutschen Vorlagen wäre es de, gern kombiniert mit en, weil in deutschen Fachdokumenten regelmäßig englische Begriffe stehen:
Befehl: time curl -s -X POST http://192.168.2.119:5001/v1/convert/file -F "files=@scan_ohne_textlayer.pdf" -F "to_formats=md" -F "image_export_mode=referenced" -F "do_ocr=true" -F "ocr_lang=en" -o ~/llm_wiki/out/scan_test.json
Befehl: python3 ~/llm_wiki/show.py ~/llm_wiki/out/scan_test.json
Bei mir dauerte die reine Verarbeitung 5,3 Sekunden für zwei Seiten. Der Text ist fast vollständig korrekt aus reinen Pixeln rekonstruiert, inklusive Markdown-Liste und Auflösungsangaben. Der Vergleich mit Schritt 11 zeigt aber die typischen OCR-Artefakte: In der klein gesetzten Bildunterschrift wird aus „AI“ ein „Al“, weil großes I und kleines l pixelidentisch sind. Ein angeschnittenes Grafikelement wird als verirrtes „E“ gelesen. Und die Überschrift kommt als ## statt ###, weil ohne Textlayer die Fontinformationen fehlen.
Daraus folgt eine klare Regel für die Praxis: Wenn ein Dokument sowohl als digitales PDF als auch als Scan existiert, nimm immer das Original. OCR ist der Rettungsweg für Papier, nicht der Normalfall.
Zwei Hinweise noch. Jede OCR-Engine benutzt eigene Sprachkürzel. Tesseract erwartet deu statt de, bei der automatischen Engine-Auswahl funktionieren die zweibuchstabigen Codes. Und wenn ein PDF zwar einen Textlayer hat, dieser aber schlecht ist, erzwingst du die Neuerkennung mit force_ocr=true.
Schritt 13: Bildbeschreibung mit einem lokalen Vision-Modell
Jetzt der spannendste und zugleich kniffligste Teil. Ziel ist, dass jede extrahierte Abbildung eine Textbeschreibung bekommt, damit ihr Inhalt durchsuchbar wird.
Docling bringt dafür eine eingebaute Funktion mit, die ein Vision-Modell über eine API aufruft. Ich habe sie ausgiebig getestet und bin daran gescheitert, sie zuverlässig zum Laufen zu bringen. Der Grund liegt in der Natur moderner Vision-Modelle wie Qwen3-VL: Sie arbeiten mit einem Reasoning-Modus, denken also erst sichtbar nach, bevor sie antworten. Über die OpenAI-kompatible Schnittstelle, die Docling nutzt, landet die eigentliche Antwort dann im falschen Feld oder das Token-Budget ist vom Nachdenken aufgebraucht, bevor die Beschreibung kommt. Das Ergebnis waren leere Beschreibungen, mal bei allen, mal bei einzelnen Bildern.
Der robuste Weg ist eine Trennung der Aufgaben: Docling extrahiert nur, ein eigenes kleines Skript beschreibt. Das Skript spricht Ollama über dessen native Schnittstelle an, wo sich der Reasoning-Modus zuverlässig steuern lässt, und es bearbeitet jedes Bild als eigenen Aufruf mit vollem Token-Budget. Genau diese Kontrolle fehlt der eingebauten Variante.
Erst extrahieren, mit eingebetteten Bildern
Für die Bildbeschreibung müssen die Bilddaten im JSON stehen, nicht nur als Dateiname. Deshalb hier image_export_mode=embedded statt referenced:
Befehl: cd ~/llm_wiki/documents && curl -s -X POST http://192.168.2.119:5001/v1/convert/file -F "files=@MagPi147.pdf" -F "page_range=10" -F "page_range=12" -F "to_formats=md" -F "to_formats=json" -F "image_export_mode=embedded" -F "do_pdf_heading_hierarchy=true" -o ~/llm_wiki/out/magpi_embedded.json
Das Beschreibungs-Skript
Es liest die Docling-JSON, schickt jedes eingebettete Bild an Ollama und schreibt die Beschreibung in die Bildmetadaten zurück. Anlegen mit nano, vorher Alt+I gegen die Einzug-Verdopplung:
Befehl: nano ~/llm_wiki/describe_images.py
Inhalt:
import json, sys, time, urllib.request, urllib.error
OLLAMA_HOST = "http://192.168.2.119:11434"
MODEL = "qwen3-vl:32b"
NUM_PREDICT = 4096
TIMEOUT = 600
MIN_BASE64_LEN = 5000
PROMPT = (
"Beschreibe diese Abbildung strukturiert und sachlich. "
"Nenne alle sichtbaren Objekte, Beschriftungen, Zahlen und Masse. "
"Erfinde nichts, was nicht zu sehen ist. Antworte auf Deutsch."
)
def describe(b64):
payload = {
"model": MODEL,
"messages": [{"role": "user", "content": PROMPT, "images": [b64]}],
"think": False,
"stream": False,
"options": {"temperature": 0, "num_predict": NUM_PREDICT, "num_ctx": 8192},
}
req = urllib.request.Request(
OLLAMA_HOST + "/api/chat",
data=json.dumps(payload).encode(),
headers={"Content-Type": "application/json"})
resp = json.loads(urllib.request.urlopen(req, timeout=TIMEOUT).read())
msg = resp.get("message", {})
return (msg.get("content") or "").strip(), resp.get("done_reason")
def main():
infile = sys.argv[1]
outfile = sys.argv[2] if len(sys.argv) > 2 else infile.rsplit(".", 1)[0] + ".described.json"
d = json.load(open(infile))
pics = (d["document"].get("json_content") or {}).get("pictures", [])
print(f"{len(pics)} Abbildungen gefunden")
ok = 0
for i, p in enumerate(pics):
uri = (p.get("image") or {}).get("uri", "")
b64 = uri.split(",", 1)[1] if "," in uri else uri
if len(b64) < MIN_BASE64_LEN:
print(f" Bild {i}: uebersprungen (zu klein)")
continue
t0 = time.time()
try:
text, done = describe(b64)
except Exception as e:
print(f" Bild {i}: Fehler - {e}")
continue
dt = round(time.time() - t0, 1)
if not text:
print(f" Bild {i}: LEER (done={done}, {dt}s)")
continue
if not isinstance(p.get("meta"), dict):
p["meta"] = {}
p["meta"]["description"] = {"confidence": None,
"created_by": f"{MODEL}", "text": text}
p["annotations"] = [{"kind": "description", "text": text,
"provenance": f"{MODEL}"}]
ok += 1
print(f" Bild {i}: OK ({len(text)} Zeichen, done={done}, {dt}s)")
json.dump(d, open(outfile, "w"), ensure_ascii=False, indent=2)
print(f"\n{ok} von {len(pics)} Bildern beschrieben -> {outfile}")
if __name__ == "__main__":
main()
Speichern, prüfen, ausführen:
Befehl: python3 -m py_compile ~/llm_wiki/describe_images.py && echo OK
Befehl: python3 ~/llm_wiki/describe_images.py ~/llm_wiki/out/magpi_embedded.json
Bei mir kam heraus:
3 Abbildungen gefunden
Bild 0: OK (1515 Zeichen, done=stop, 51.0s)
Bild 1: OK (1342 Zeichen, done=stop, 32.4s)
Bild 2: OK (2030 Zeichen, done=stop, 41.8s)
3 von 3 Bildern beschrieben -> magpi_embedded.described.json
Alle drei Bilder tragen jetzt eine ausführliche deutsche Beschreibung. Die fertige JSON liegt als magpi_embedded.described.json vor. Wichtig sind zwei Werte im Skript, an denen fast alles hängt: "think": false versucht den Reasoning-Modus zu dämpfen, und das großzügige num_predict von 4096 stellt sicher, dass nach dem Nachdenken noch genug Token für die eigentliche Antwort bleiben. Mit einem knappen Budget bleiben die Beschreibungen leer, weil das Modell die Token im Denken verbraucht.
Warum die Modellgröße entscheidend ist
Jetzt kommt der ehrliche Teil, den man selten in Anleitungen liest. Ich habe die Bildbeschreibung zuerst mit dem kleinen qwen3-vl:8b gemacht. Es lief schnell und lieferte flüssige, gut strukturierte deutsche Beschreibungen. Beim genauen Hinsehen zeigte sich aber ein ernstes Problem: Das kleine Modell erfindet Details.
Auf einem Produktfoto der Raspberry Pi AI Camera steht klar lesbar der Sensor „IMX500“. Das kleine Modell las daraus je nach Durchlauf „IMX477“, „IMX505“ oder „IMX523“. Es rät bei kleinen Ziffern, statt sie zu lesen. Der Wechsel auf das große qwen3-vl:32b verbesserte Stil und Struktur deutlich, aber die Genauigkeit der Zahlen blieb ein Problem. Auch das große Modell ergänzte plausibel klingende, aber frei erfundene technische Angaben wie exakte Kabellängen und Pin-Zahlen, die auf dem Bild gar nicht stehen.
Die Lehre daraus ist wichtiger als jede Konfiguration: Bildbeschreibungen durch lokale Vision-Modelle sind hervorragend geeignet, um den groben Inhalt einer Grafik durchsuchbar zu machen, etwa „grünes Kameramodul mit orangem Flachbandkabel“. Sie sind nicht geeignet als Quelle für exakte Zahlen, Maße oder Beschriftungen. Je kleiner das Modell, desto mehr wird geraten, aber selbst große Modelle erfinden plausible Details.
Für ein Wiki, das aus dem Material später weiterverarbeitete Inhalte erzeugt, heißt das konkret: Die Bildbeschreibung dient der Auffindbarkeit, die harten Fakten müssen aus dem OCR-Text danebenstehen oder von einem Menschen geprüft werden. Wer diese Grenze kennt, kann lokale VLMs sinnvoll einsetzen. Wer sie ignoriert, baut sich eine Wissensbasis voller plausibler Fehler.
Schritt 14: Lange Dokumente asynchron verarbeiten
Bis hierher haben wir mit page_range nur Ausschnitte verarbeitet. Für eine vollständige MagPi-Ausgabe mit hundert Seiten ist der synchrone Aufruf die falsche Wahl. Er hält die Verbindung minutenlang offen, und jede Netzwerkstörung bricht den Job ab. Genau daran bin ich beim ersten Versuch gescheitert: Nach exakt 120 Sekunden kam die Meldung „Conversion is taking too long“, obwohl Docling im Hintergrund weiterarbeitete. Für solche Fälle gibt es den asynchronen Weg.
Befehl: cd ~/llm_wiki/documents
Befehl: TASK=$(curl -s -X POST http://192.168.2.119:5001/v1/convert/file/async -F "files=@MagPi147.pdf" -F "to_formats=md" -F "image_export_mode=referenced" -F "do_pdf_heading_hierarchy=true" | python3 -c "import json,sys; print(json.load(sys.stdin)['task_id'])") && echo "Task: $TASK"
Den Status fragst du danach ab. Der Parameter wait lässt den Aufruf bis zu 30 Sekunden auf eine Statusänderung warten, das spart unnötiges Pollen:
Befehl: curl -s "http://192.168.2.119:5001/v1/status/poll/$TASK?wait=30" | python3 -m json.tool
Sobald task_status auf success steht, holst du das Ergebnis:
Befehl: curl -s "http://192.168.2.119:5001/v1/result/$TASK" -o ~/llm_wiki/out/magpi147_komplett.json
Ein Hinweis: Abgeholte Ergebnisse bleiben nicht ewig liegen. Der Dienst räumt Task-Ergebnisse nach einigen Minuten automatisch weg. Das Ergebnis also zeitnah abholen und lokal speichern.
Schritt 15: Reboot-Test
Zum Abschluss zurück auf den GPU-Server. Die Restart-Policy sorgt dafür, dass Docling nach einem Neustart automatisch wieder hochkommt. Prüfen sollte man das trotzdem einmal:
Befehl (auf dem GPU-Server): sudo reboot
Nach dem Hochfahren muss docker ps den Container mit Status healthy zeigen. Zusätzlich wiederholst du vom Application Server aus den Health-Check aus Schritt 10. Prüfe außerdem, ob Ollama wieder auf allen Schnittstellen lauscht.
Fazit
Docling läuft jetzt als Docker-Container mit GPU-Beschleunigung und ist als Netzwerkdienst im gesamten Heimnetz nutzbar. Der Application Server braucht dafür weder GPU noch eigene Installation. Er schickt eine PDF-Datei über HTTP und bekommt strukturiertes Markdown zurück, auf Wunsch mit deutscher Texterkennung. Die Textextraktion arbeitet zuverlässig und schnell, die Layout-Erkennung ist auch bei anspruchsvollem Magazinsatz überzeugend.
Die Bildbeschreibung ist möglich, aber sie war der aufwendigste Teil. Der robuste Weg führt nicht über Doclings eingebaute Funktion, sondern über einen sauberen Zweischritt: Docling extrahiert die Bilder, ein eigenes Skript beschreibt sie über Ollamas native Schnittstelle. Und man muss die Grenzen kennen. Lokale Vision-Modelle machen Bildinhalte durchsuchbar, taugen aber nicht als Quelle für exakte Zahlen. Diese Ehrlichkeit erspart später viel Ärger.
Damit ist die Grundlage gelegt für alles, was strukturierten Text aus Dokumenten braucht. Bei mir ist der nächste Schritt das LLM Wiki, in dem die extrahierten Inhalte zu einer verlinkten Wissensbasis zusammenwachsen.






Ein toller Guide der leicht zugänglich und verständlich ist. Perfekt für ein kleines Side-Project geeignet. Aktuell half mir noch mein…
Thank you for this great tutorial, could you share n8n workflow and comfyui workflow please?
Hallo Anton, die Meldung besagt das in meinem Beisiel Methoden verwendet werden die veraltet (deprecated) sind. Also müsstest Du die…
Danke für das Tool! Ich habe erst kürzlich angefangen mich mit der Thematik zu beschäftigen und bin für meine Erwartungen…
Hallo, ich habe ihre Anleitung befolgt und bekomme im letzten Schritt leider immer folgende Meldung im Terminal: bash <(wget -qO-…