-- Volltext über den *Inhalt* – die zweite Hälfte der Suche. -- -- Migration 000013 machte Datei*namen* durchsuchbar. Das reicht, solange -- Dateien sprechende Namen haben. Ein Scanner nennt seine Ablage aber -- "scan_0042.pdf", und genau die Belege sollen auffindbar werden. -- ── Der erkannte Text ───────────────────────────────────────────────────────── -- Eigene Tabelle statt einer Spalte an files: Der Text ist um Größenordnungen -- länger als der Rest der Zeile, und files wird bei jedem Auflisten gelesen. -- Postgres lagert lange Werte zwar aus (TOAST), aber die tsvector-Spalte und -- ihr GIN-Index gehörten dann trotzdem an eine Tabelle, die ständig in -- Bewegung ist. CREATE TABLE file_text ( file_id UUID PRIMARY KEY REFERENCES files(id) ON DELETE CASCADE, content TEXT NOT NULL, -- Woher der Text stammt. 'layer' = das PDF hatte bereits eine Textebene -- (pdftotext, verlustfrei); 'ocr' = aus dem Bild erkannt, kann Fehler -- enthalten. Der Unterschied ist für die Fehlersuche wichtig: Findet die -- Suche ein Wort nicht, sagt die Herkunft, ob OCR es verlesen hat. source TEXT NOT NULL CHECK (source IN ('layer', 'ocr')), -- Die Sprachen, mit denen erkannt wurde ("deu+eng"). Bei 'layer' leer. lang TEXT NOT NULL DEFAULT '', -- Zeichenzahl des Originals vor dem Kürzen – siehe maxTextBytes im Worker. chars INTEGER NOT NULL DEFAULT 0, pages INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), -- Berechnete Spalte statt eines Triggers: Sie kann nie mit dem Inhalt -- auseinanderlaufen, weil Postgres sie bei jedem Schreiben selbst füllt. -- -- 'german' fest verdrahtet, nicht 'simple': Ohne Stammformen fände -- "Rechnung" die "Rechnungen" nicht. Deutsch ist die Sprache der Belege, -- um die es hier geht; englische Wörter darin verlieren nur ihre -- Stammformbildung, sie bleiben wörtlich auffindbar. Eine Änderung wäre -- eine Migration – der Preis dafür, dass der Ausdruck IMMUTABLE sein muss. tsv tsvector GENERATED ALWAYS AS (to_tsvector('german', content)) STORED ); CREATE INDEX idx_file_text_tsv ON file_text USING gin (tsv); -- ── Die Warteschlange ───────────────────────────────────────────────────────── -- Warum eine Tabelle und nicht einfach ein Goroutine-Aufruf wie bei den -- Vorschaubildern: OCR dauert Sekunden bis Minuten. Startet der Server in -- dieser Zeit neu – Update, Absturz, Neustart des Wirts –, ist ein Auftrag im -- Speicher verloren, und die Datei bleibt für immer unauffindbar, ohne dass es -- jemand merkt. Bei einem Vorschaubild fällt das auf; bei fehlendem Text nicht. CREATE TABLE ocr_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), -- Ein Auftrag je Datei. Wird dieselbe Datei erneut hochgeladen, ersetzt der -- neue Auftrag den alten (ON CONFLICT), statt sich daneben zu stapeln. file_id UUID NOT NULL UNIQUE REFERENCES files(id) ON DELETE CASCADE, status TEXT NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'running', 'done', 'failed')), attempts INTEGER NOT NULL DEFAULT 0, last_error TEXT NOT NULL DEFAULT '', created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -- Der Arbeiter fragt nur nach offenen Aufträgen. Ein Teilindex bleibt klein, -- auch wenn sich zehntausend erledigte Zeilen ansammeln. CREATE INDEX idx_ocr_jobs_pending ON ocr_jobs(created_at) WHERE status IN ('pending', 'running');