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

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
![Image](image_000000_f2d0936398a29ffe70247cef4f323580cfd94dbc9a9acc952edceda729914d5a.png)
 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.