← Zurück zur Übersicht

AKTA — unter der Haube

Veröffentlicht:

Technische Vertiefung zum AKTA-Projekt: Architektur, Stack, Engineering-Prinzipien und die wichtigsten ADRs. Für Leser, die wissen wollen wie es gebaut ist.

Aufbau im Großen

AKTA besteht aus drei Diensten, die sich klare Verantwortungen teilen.

  • Core — die fachliche Wahrheit. Dokumente, Metadaten, Suche, Frage-Antwort, Audit-Log. Eine Spring-Boot-Anwendung in Java, spricht REST mit der Außenwelt und Postgres mit sich selbst.
  • File-Manager — der einzige Dienst, der das Dateisystem auf der Synology sehen darf. Erkennt neue Dateien im Eingangsordner, schiebt sie durch die Pipeline, archiviert sie am Ende. Ebenfalls Spring Boot.
  • Processing — alles was mit Inhalten zu tun hat: PDF-Text rausziehen, Texterkennung als Fallback, dann das lokale LLM für Metadaten-Vorschläge, Fristen und die Antworten auf freie Fragen. Python mit FastAPI, weil die OCR- und LLM-Bibliotheken dort einfach besser zu Hause sind.

Drumherum eine React-Oberfläche, in der ich Dokumente prüfe, korrigiere, suche und Fragen an meine Ablage stelle. Die Trennung der Dienste ist fachlich, nicht etwas was der Nutzer spürt.

Tech-Stack

  • Backend: Java mit Spring Boot; Python mit FastAPI
  • Datenbank: Postgres, Schema-Versionierung via Flyway
  • Suche und Frage-Antwort: Postgres-Volltextsuche mit eigener Konfiguration für Haushaltsdokumente (Umlaut-Toleranz, Synonyme), kombiniert mit Vektor-Embeddings via pgvector. Auf derselben Grundlage läuft eine RAG-Pipeline für freie Fragen — die Top-Treffer aus der hybriden Suche werden in einem LLM-Call zur Antwort verdichtet, mit Pflicht-Zitaten aus den Dokumenten.
  • LLM & OCR: Ollama lokal mit einem Open-Source-Sprachmodell, Tesseract für gescannte Dokumente
  • Betrieb: Docker Compose, Container-Images aus eigener Harbor-Registry, CI/CD über Gitea Actions mit Schwachstellen-Scan vor jedem Deploy
  • Tests: JUnit mit Testcontainers für die JVM-Seite, pytest für Python, Playwright für End-to-End

Engineering-Prinzipien

Ein paar Leitsätze, an denen ich mich entlanggehangelt habe:

  • Vorschlag, keine Automatik. Das LLM darf raten, der Mensch entscheidet. Klingt banal, ändert aber wo Validierung sitzt und wie die Oberfläche aussieht.
  • Findbarkeit schlägt perfekte Ablage. Wenn ich ein Dokument in fünf Sekunden wiederfinde, ist mir egal ob die Kategorie hundertprozentig präzise war.
  • Quellen statt Erfindungen. Antworten auf freie Fragen kommen aus den Dokumenten, nicht aus dem LLM. Der Prompt verlangt explizit Quellen-IDs aus dem Kontext; ohne Quelle keine Antwort. Lieber "weiß ich nicht aus den Akten" als ein erfundenes Datum.
  • Eine Quelle der Wahrheit. Metadaten leben in Postgres. Nicht parallel in Suche, Cache oder Dateisystem. Caches sind dann tatsächlich nur Caches.
  • Ports und Adapter über alle Dienste. Jeder Dienst hat klare fachliche Ports; die technischen Adapter (HTTP, JDBC, Ollama, Dateisystem) sitzen außen. Ollama gegen was anderes tauschen wäre ein Adapter-Wechsel, kein Refactor.
  • Untrusted in, sanitized out. OCR-Text und LLM-Output behandle ich wie User-Input — also als potenziell bösartig. Längen werden gekappt, Inhalte vor der Ablage geprüft.

Wichtige Entscheidungen (ADRs)

Die ADRs leben im Repo. Ein paar Highlights:

  • Content-Hash als optionale Unique-Spalte. Doppelt gescannte Dokumente erkenne ich am SHA-256 des Inhalts. Damit ältere Datensätze legal bleiben, läuft das als partial-unique Index — neue Zeilen ohne Hash sind verboten, alte dürfen bleiben.
  • LLM-Output ist untrusted. Der Processing-Service kappt Titel- und Kategorie-Vorschläge, bevor sie zu Core gehen. Core sanitisiert ein zweites Mal. Zwei Stationen, beide unabhängig.
  • Fristen extrahieren, Fristen erinnern. Ein zweiter LLM-Call zieht ein Datum aus dem Dokumenttext, idempotent gespeichert mit Marker-Spalten für die drei Erinnerungsstufen. Quelle ("vom LLM" vs "vom Mensch") ist Teil des Modells.
  • Prompts in die Datenbank. Eine Iteration früher lagen die Prompts als Dateien im Container, mit Bind-Mount für Live-Edits aus der Oberfläche. Jeder Deploy mit rsync --delete hat sie überschrieben. Saubere Lösung: Prompts sind Daten, nicht Code.
  • Hybride Suche per Reciprocal Rank Fusion. Klassische Volltextsuche und Vektor-Ähnlichkeit liefern getrennte Trefferlisten. RRF kombiniert sie, ohne dass eine Seite die andere überrollt — und der FTS-Fallback hält die Suche am Leben, wenn die Embeddings mal ausfallen.
  • RAG-Pipeline mit Pflichtquellen. Für freie Fragen ("wann läuft die Hausratversicherung aus") liefert die hybride Suche die Top-K Treffer, deren Snippets werden zum Kontext eines LLM-Calls. Der Prompt zwingt Quellen-IDs in die Antwort und verbietet Aussagen ohne Kontextbeleg. Im Frontend gibt's eine Seite "Fragen", in der Antwort und klickbare Quellen nebeneinander stehen — Halluzinationsschutz als Akzeptanzkriterium, nicht als Nice-to-have.

Was ich heute anders machen würde

  • Den Prompt-Editor von Anfang an gegen die Datenbank schreiben lassen. Der Dateipfad war eine halbe Stunde Setup und drei Wochen Schmerz.
  • Das LLM-Trust-Modell schon im ersten Sprint formalisieren. Dass ich Längen-Caps erst später eingeführt habe, hat mich genau einmal "Newsletter" als Mahnschreiben-Kategorie gekostet.
  • Container-Schwachstellen-Scans schon vor dem ersten produktiven Deploy ernst nehmen. Eine Woche Image-Hardening rückwirkend ist mühsamer als zwei Tage proaktiv.