Teil 4 · Crash-Resumable Ingestion: DBOS, SHA-256 und wie man ein kill -9 überlebt

Mai 5, 2026·
Ndimofor Aretas
Ndimofor Aretas
· 9 Min Lesezeit
blog AI Engineering

Teil einer Serie über den Bau von Gemma CogniVault. Zuvor: Zweiphasen-Streaming mit Strands Agents.

Alle Abkürzungen werden im Anhang am Ende der Seite ausführlich erklärt.

Es gibt zwei Dinge, die deine RAG-Ingestion-Pipeline auf keinen Fall tun sollte:

  1. Ein 200-seitiges PDF neu einbetten, weil du einen Tippfehler auf Seite 12 korrigiert hast.
  2. Ihren Fortschritt verlieren, wenn du auf halber Strecke den Laptop zuklappst.

Das Erste verschwendet Zeit und Rechenressourcen. Das Zweite führt zu Misstrauen in das System. Beides hat denselben Ursprung: Die Ingestion wird wie eine Fire-and-Forget-Funktion behandelt, obwohl sie eigentlich eine lang laufende Pipeline ist, deren Zwischenzustände es wert sind, erhalten zu bleiben.

CogniVault behandelt Ingestion als einen Durable Workflow. Genauer gesagt als einen DBOS-Workflow, der in Postgres mit Checkpoints versehen ist und Content-Hashing für inkrementelle Arbeit nutzt. In diesem Beitrag schauen wir uns beides an.

Die Pipeline

1. Scan docs/      → SHA-256 hash per file
                     ├── New file     → queue for embedding
                     ├── Changed file → soft-delete old chunks, re-embed
                     └── Unchanged    → skip (idempotent)

2. Extract text    → per-format extractor (PDF/OCR, DOCX, PPTX, XLSX, MD, CSV, TXT, HTML)
3. Chunk           → RecursiveCharacterTextSplitter (1000 chars, 100 overlap)
4. Embed           → embeddinggemma via Ollama, batches of 5
5. Save            → append to FAISS IndexFlatIP + JSON metadata on disk

Die rechenintensiven Stufen laufen als DBOS-Schritte innerhalb eines übergeordneten Workflows und sind alle mit Checkpoints versehen: Wenn der Prozess zwischen den Schritten stirbt, macht der nächste Start genau beim letzten abgeschlossenen Schritt weiter.

SHA-256 als einzige Quelle der Wahrheit

Der naive Ansatz ist, die Ingestion anhand des Dateinamens zu verfolgen. Das geht genau dann schief, wenn jemand eine Datei direkt bearbeitet. Der Dateiname ist derselbe; der Inhalt nicht. Der Vector-Store schleppt dann klammheimlich veraltete Chunks mit sich herum.

Die Lösung ist inhaltsadressiert: Hashe die Datei-Bytes und speichere den Hash zusammen mit den Chunks. Bei jedem Ingestion-Durchlauf passiert Folgendes:

current_hash = hashlib.sha256(file_bytes).hexdigest()
stored_hash = chunk_metadata_for(filename).get("file_hash")

if stored_hash is None:
    schedule_ingest(filename)              # new file
elif stored_hash == current_hash:
    skip(filename)                         # unchanged
else:
    soft_delete_chunks_for(filename)       # changed
    schedule_ingest(filename)

Das verleiht der Ingestion eine idempotente Eigenschaft, die Gold wert ist: Die Pipeline zweimal hintereinander laufen zu lassen, bewirkt beim zweiten Mal fast nichts. Das ist nicht nur eine Optimierung — erst dadurch wird der nächste Abschnitt überhaupt möglich.

DBOS-Workflows

DBOS ist eine Python-Bibliothek, die normale Funktionen in Checkpoint-basierte Workflows verwandelt, die von Postgres gestützt werden. Das Modell ist kinderleicht: Dekoriere eine Funktion mit @DBOS.workflow(), markiere jeden lang laufenden Aufruf darin als @DBOS.step(), und DBOS speichert während der Ausführung für jeden Schritt Input, Output und Status in Postgres.

Wenn der Workflow abstürzt — Prozess gekillt, OS-Reboot, Abbruch der Postgres-Verbindung — sieht der nächste Start, dass ein unvollendeter Workflow mit derselben ID existiert, spielt die aufgezeichneten Schritt-Outputs aus Postgres ab (ohne sie neu auszuführen) und macht beim ersten unvollständigen Schritt weiter.

Hier ist die eigentliche Schrittstruktur (leicht vereinfacht aus backend/services/ingest.py):

@DBOS.workflow()
def ingest_workflow() -> int:
    filenames = list_document_files()          # @DBOS.step — scan + hash check
    docs = []
    for name in filenames:
        docs += process_single_document(name)  # @DBOS.step — extract text, one file each
    chunks = chunk(docs)                       # plain Python — fast, re-runs freely
    embeddings = []
    for batch in batches_of_5(chunks):
        embeddings += embed_batch(batch)       # @DBOS.step — the slow one, retried on failure
    save_vector_store(embeddings, chunks)      # @DBOS.step — append to FAISS + metadata
    return len(chunks)

Die Granularität von @DBOS.step entspricht der Granularität der Crash-Recovery und wurde bewusst so gewählt. Die Extraktion ist ein Schritt pro Datei, sodass bei einem Absturz während Datei 9 von 10 die ersten acht nicht neu gelesen werden. Embedding ist ein Schritt pro Batch von fünf Chunks, und zwar aus einem bestimmten Grund: embed_batch ist der langsame Part. Wenn der Laptop während der Embeddings den Geist aufgibt, setzen wir den Embedding-Loop beim fehlgeschlagenen Batch fort, nicht bei der PDF-Extraktion.

Fällt dir auf, was kein Schritt ist? Das Chunking. Text aufzuteilen ist schnelle, reine Python-Arbeit — es mit Checkpoints zu versehen, würde mehr Buchhaltung im Ledger kosten, als es bei einer Fortsetzung einfach neu zu machen.

In der Batch-Größe verbirgt sich noch ein kleiner Trick. DBOS speichert den Output jedes Schritts in Postgres, und embed_batch gibt seine Vektoren zurück — also enthält jeder Ledger-Eintrag Float-Werte für fünf Embeddings. Kleine Batches halten jeden Checkpoint-Datensatz klein und jeden erneuten Versuch (Retry) günstig. Ein riesiger “Bette alles ein”-Schritt würde eine riesige Ledger-Zeile und null Resume-Granularität bedeuten.

Die Format-Extraktoren

Schritt 2 (process_single_document) ist eine Weiche basierend auf der Dateiendung. Jeder Extraktor ist klein und einleuchtend; die interessanten Entscheidungen liegen in der Chunking-Strategie, die jeder nachgelagert füttert.

FormatLibraryChunking note
PDFpypdf Seite für Seite; pytesseract OCR-Fallback für Bild-SeitenRekursiver Splitter, 1000/100
DOCXpython-docx (Absätze + Tabellenzeilen als Text verbunden)Rekursiver Splitter
PPTXpython-pptxEin Chunk pro Folie (Titel + Body-Text)
XLSXopenpyxlHeader + 20-Zeilen-Batches, pro Arbeitsblatt
MDMarkdownHeaderTextSplitterEin Chunk pro H1/H2/H3-Abschnitt, Breadcrumbs davor
CSVManueller ReaderHeader-Zeile + 20-Zeilen-Batches
TXTRohes UTF-8 LesenRekursiver Splitter
HTMLtrafilatura sauberer TextRekursiver Splitter

Der OCR-Fallback ist es wert, kurz innezuhalten. PDFs gibt es in zwei Ausführungen: solche mit einer echten Textebene und solche, die im Grunde nur gescannte Bilder in einem PDF-Kostüm sind. pypdf liefert für die zweite Sorte nichts Brauchbares zurück, wirft aber auch keinen Fehler — es gibt einfach leere Strings zurück. Ohne ein Fallback lügt dich dein “Ingestion erfolgreich”-Log an.

Der Detektor ist eine Heuristik: Wenn pypdf weniger als 50 Zeichen für eine Seite zurückgibt, leite die Seite durch pymupdfPillowpytesseract OCR. Langsamer, aber es produziert immerhin Text. Der Schwellenwert ist so eingestellt, dass er sensibel genug ist, um gescannte Seiten abzufangen, ohne legitimerweise kurze Seiten (wie ein Kapitel-Deckblatt oder ein Impressum) zu bestrafen.

Soft Delete, nicht Hard Delete

Wenn sich eine Datei ändert und wir sie neu einlesen, müssen die alten Chunks weg. Es ist verlockend, sie physisch aus dem FAISS-Index zu entfernen, aber FAISS IndexFlatIP unterstützt kein effizientes Löschen — du müsstest ihn neu aufbauen.

Stattdessen Soft Delete: Bei geänderten Dateien werden die alten Chunks in den Metadaten mit einem deleted: true-Flag markiert; neue Chunks werden ohne Flag angehängt. Bei einer Suchanfrage wird nach diesem Flag gefiltert, sodass veraltete Vektoren völlig harmlos im Index liegen bleiben. Wenn sich jemals genug totes Gewicht ansammelt, ist das Ventil offensichtlich — bau den Index nur mit aktiven Chunks neu auf —, aber in der Praxis habe ich das noch nie gebraucht.

Das ist dasselbe Muster, das die meisten Append-only-Systeme verwenden. Es passt natürlich perfekt zum Content-Hashing — Markieren-und-Anhängen ist viel billiger als Entfernen-und-Neubauen. Eine Feinheit dabei: Der Keyword-Index muss mitziehen. CogniVaults VectorDB.delete_by_source() setzt die Flags und baut BM25 neu auf, und zwar über die verbleibenden aktiven Chunks, sodass sich die beiden Retriever nie uneinig darüber sind, was eigentlich existiert.

Was der User sieht

Das Starten einer Ingestion (POST /ingest) liefert eine workflow_id zurück, und das Frontend fragt regelmäßig GET /ingest/status/{workflow_id} ab, um eine Live-Timeline der Workflow-Schritte zu zeichnen — Scannen, Extraktion pro Datei (“Lese Seiten… 3 von 21”), Einbetten (“Kalibriere Batch 4 von 12”), Speichern. Wenn der User den Tab mitten in der Ingestion schließt, fünf Minuten später wiederkommt und ihn neu öffnet — der Workflow ist im Hintergrund sowieso fertig gelaufen. Der nächste Aufruf von GET /api/vault/stats spiegelt die neue Chunk-Anzahl wider. Kein “Klicken zum Fortsetzen”-Button, kein manueller Recovery-Tanz.

Als ich das erste Mal mitten im Einbetten den Deckel zugeklappt habe und dann beim Aufwecken zusehen konnte, wie der Workflow sich den nächsten Schritt geschnappt und einfach weitergemacht hat, war ich, ehrlich gesagt, ein bisschen stolz. Das ist genau die Eigenschaft, die ich wollte, und das mit überraschend wenig Code.

Fallstricke und Randfälle

Ein paar Dinge, die ich auf die harte Tour lernen musste:

  • Mach embed_batch nicht zu groß. Ollama ist nicht besonders gut im Umgang mit Backpressure. Batches von 5 sind ein Sweetspot für embeddinggemma auf einer Maschine mit 16 GB RAM — größere Batches bleiben am Speicher hängen, kleinere verschwenden Overhead für die Round-Trips. (Und wie oben erwähnt: Die Batch-Größe bestimmt gleichzeitig die Größe deines Checkpoint-Datensatzes.)
  • Sei vorsichtig beim Löschen von Dateien. Soft-gelöschte Chunks müssen auch aus dem Korpus von BM25 verschwinden, sonst liefert die Keyword-Suche weiterhin Text, den die Dense Search (Vektorsuche) gar nicht mehr sieht. Wenn du BM25 innerhalb von delete_by_source() neu aufbaust, bleiben die beiden im Gleichschritt.
  • OCR ist langsam. Ein 50-seitiger Scan kann eine Minute oder länger dauern. Mach diese Wartezeit für den User sichtbar, sonst denken sie, das System hat sich aufgehängt.

Fazit

Durable Workflows sind nicht nur etwas für verteilte Systeme. Eine lokale App für einen einzelnen Nutzer profitiert davon auf genau die gleiche Weise: inkrementelle Arbeit, Crash-Recovery, idempotente Retries. DBOS macht die Einstiegskosten dafür extrem niedrig — dekoriere deine Funktion, lass Postgres lokal laufen, und du bekommst eine Pipeline, die das Zuklappen des Laptops, OS-Updates und dein eigenes Ctrl-C überlebt.

In Kombination mit inhaltsadressiertem Hashing ist die Ingestion nicht länger etwas, das du meidest, aus Angst, 20 Minuten warten zu müssen. Es wird zu etwas, das du einfach neu startest, wann immer du Lust dazu hast — denn ein Neustart kostet nichts, wenn sich nichts geändert hat.


Anhang: Abkürzungen in diesem Beitrag

AbbreviationFull formMeaning
DBOSDatabase-Oriented Operating SystemEine Bibliothek, die Workflow-Schritte in Postgres sichert, sodass abgestürzte Jobs fortgesetzt statt neu gestartet werden
SHA-256Secure Hash Algorithm, 256-bitEin Content-Fingerabdruck: Änderst du ein Byte einer Datei, ändert sich der Hash komplett
RAGRetrieval-Augmented GenerationRufe zuerst relevante Passagen aus deinen eigenen Dokumenten ab; lass das Modell daraus antworten
OCROptical Character RecognitionDas Umwandeln von Bildern von Text (gescannte Seiten) in maschinenlesbaren Text
FAISSFacebook AI Similarity SearchDer Vektorindex, an den die Embeddings angehängt werden
IP (in IndexFlatIP)Inner ProductFAISS’s Ähnlichkeitsmaß; entspricht der Cosinus-Ähnlichkeit bei normalisierten Vektoren
BM25Best Match 25Der Keyword-Index, der beim Löschen mit FAISS im Gleichschritt bleiben muss
PDF / DOCX / PPTX / XLSX / MD / CSV / TXT / HTMLPortable Document Format / Word / PowerPoint / Excel / Markdown / Comma-Separated Values / plain text / HyperText Markup LanguageDie Formate, die von den entsprechenden Extraktoren verarbeitet werden
JSONJavaScript Object NotationDas Format der Chunk-Metadaten-Datei neben dem FAISS-Index
UTF-8Unicode Transformation Format, 8-bitDie Textkodierung, die beim Lesen von Klartextdateien verwendet wird
OSOperating SystemDas, was mitten in der Ingestion unter dir neu startet

Als Nächstes: Wie man zuverlässiges JSON aus einem lokalen LLM bekommt — was passiert, wenn Gemma 4 enthusiastisch {"questions": [{"text": "..."},}] zurückgibt.