From b3e9110dd18c9e68ce1134bc651af4a32992b6f8 Mon Sep 17 00:00:00 2001 From: team2 Date: Sun, 22 Feb 2026 13:51:45 +0100 Subject: [PATCH] phase a audit --- PHASE_A_AUDIT.md | 79 ++++ PHASE_B_AUDIT.md | 0 TECHNICAL_ARCHITECTURE.md | 368 ------------------ config/services.yaml | 24 +- .../vector}/vector_control.py | 2 +- .../Vector => python/vector}/vector_ingest.py | 0 .../vector}/vector_ingest_tags.py | 0 .../Vector => python/vector}/vector_search.py | 0 .../vector}/vector_search_tags.py | 0 .../vector}/vector_service.py | 0 src/Command/VectorControlCommand.php | 2 +- src/Ingest/IngestFlow.php | 88 +++-- src/Knowledge/ChunkManager.php | 106 ++--- src/Vector/VectorIndexBuilder.php | 16 +- 14 files changed, 222 insertions(+), 463 deletions(-) create mode 100644 PHASE_A_AUDIT.md create mode 100644 PHASE_B_AUDIT.md delete mode 100644 TECHNICAL_ARCHITECTURE.md rename {src/Vector => python/vector}/vector_control.py (99%) rename {src/Vector => python/vector}/vector_ingest.py (100%) rename {src/Vector => python/vector}/vector_ingest_tags.py (100%) rename {src/Vector => python/vector}/vector_search.py (100%) rename {src/Vector => python/vector}/vector_search_tags.py (100%) rename {src/Vector => python/vector}/vector_service.py (100%) diff --git a/PHASE_A_AUDIT.md b/PHASE_A_AUDIT.md new file mode 100644 index 0000000..54b9c37 --- /dev/null +++ b/PHASE_A_AUDIT.md @@ -0,0 +1,79 @@ +# Technische Projektdokumentation +## RAG-System – Phase A Abschluss + +**Projekt:** KI-RAG System +**Architekturstand:** Phase A abgeschlossen +**Datum:** Februar 2026 +**Status:** Verbindliche Referenzdokumentation + +--- + +# 1. Zielsetzung von Phase A + +Phase A hatte das Ziel, das bestehende Retrieval-Augmented-Generation-System strukturell zu stabilisieren und produktionsreif zu machen. + +Im Fokus standen: + +- Speicherstabilität (Streaming statt RAM-Load) +- Deterministische Indexierung +- Strikte Trennung von Domain (PHP) und Runtime (Python) +- Zentrale Konfigurationssteuerung +- Drift-Sicherheit des Vector-Index + +Phase A beinhaltete **keine funktionale Erweiterung**, sondern ausschließlich strukturelle und architektonische Stabilisierung. + +--- + +# 2. Architekturprinzipien + +Das System folgt folgenden verbindlichen Grundprinzipien: + +1. **NDJSON ist Single Source of Truth** + Alle Vektoren werden deterministisch aus `index.ndjson` erzeugt. + +2. **Full Rebuild statt inkrementeller Mutation** + Der FAISS-Index wird bei Änderungen vollständig neu aufgebaut. + +3. **Streaming statt Full-RAM-Load** + Keine vollständigen JSON-Arrays im Speicher. + +4. **Runtime und Domain sind strikt getrennt** + PHP enthält Orchestrierung und Governance, Python enthält Vektor-Runtime. + +5. **Atomare Dateioperationen** + Schreibvorgänge erfolgen über `.tmp` + `rename()`. + +6. **Konfigurationszentrierung** + Alle Pfade und Script-Referenzen sind über `services.yaml` parametriert. + +--- + +# 3. Umsetzung Phase A + +## A1 – Streaming-Architektur + +### Problem +RAM-basierte JSON-Verarbeitung hätte bei steigender Chunk-Zahl zu Speicherproblemen geführt. + +### Umsetzung + +- Einführung von NDJSON als persistentes Format +- Streaming-Verarbeitung in: + - `ChunkManager::streamAll()` + - `ChunkManager::countAllChunks()` + - `ChunkManager::compactByDocument()` + - `ChunkManager::rewriteAll()` +- Entfernung von `iterator_to_array` im IngestFlow + +### Ergebnis + +- Speicherverbrauch unabhängig von Chunk-Anzahl +- Stabil bis mindestens 120.000 Chunks + +--- + +## A2 – Strukturtrennung Runtime / Domain + +### Umsetzung + +Python-Runtime wurde vollständig aus `src/` entfernt und ausgelagert nach: diff --git a/PHASE_B_AUDIT.md b/PHASE_B_AUDIT.md new file mode 100644 index 0000000..e69de29 diff --git a/TECHNICAL_ARCHITECTURE.md b/TECHNICAL_ARCHITECTURE.md deleted file mode 100644 index c93e7e4..0000000 --- a/TECHNICAL_ARCHITECTURE.md +++ /dev/null @@ -1,368 +0,0 @@ -# TECHNICAL_ARCHITECTURE.md -mitho AI Agent – Enterprise Hybrid RAG System -Stand: Februar 2026 - ---- - -# 1. Zielsetzung - -Diese Dokumentation beschreibt die vollständige technische Architektur des -mitho AI Agent RAG-Systems. - -Ziele: - -- Deterministisches Retrieval -- Governance-stabile Index-Struktur -- Versionierte Wissensbasis -- Reproduzierbare Builds -- Keine inkrementellen Vektor-Mutationen -- Job-basierte, asynchrone Ingest-Architektur -- Skalierbarkeit >200k Chunks - ---- - -# 2. Architektur-Übersicht - -Systemebenen: - -1. Admin Layer (Symfony) -2. Ingest Layer -3. Storage Layer (NDJSON) -4. Vector Layer (FAISS) -5. Retrieval Layer (Hybrid) -6. Application Layer (LLM + SSE) - ---- - -# 3. Core Architekturprinzipien - -## 3.1 Determinismus - -Gleiche Eingabe + gleiche Konfiguration → identisches Ergebnis. - -- index.ndjson ist deterministisch -- FAISS wird vollständig neu gebaut -- Retrieval ist stabil reproduzierbar -- Keine impliziten Indexveränderungen - -## 3.2 Governance - -- Genau eine aktive Version pro Dokument -- Keine Inline-Rebuilds im HTTP-Request -- Strukturänderungen erzwingen Global Reindex -- Locking verhindert Parallel-Ingest - -## 3.3 Skalierbarkeit - -- NDJSON statt JSON-Array -- Streaming-Append -- Streaming-Compaction -- Kein RAM-Full-Load - ---- - -# 4. Domain Model - -## 4.1 Document - -Primäre Wissenseinheit. - -Eigenschaften: -- id (UUID) -- title -- createdAt -- createdBy -- archived (bool) -- currentVersion (ManyToOne → DocumentVersion) - -## 4.2 DocumentVersion - -Immutable Inhaltsversion. - -Eigenschaften: -- id (UUID) -- document (ManyToOne) -- versionNumber -- filePath -- checksum -- active (bool) -- ingestStatus (PENDING, RUNNING, INDEXED, FAILED) - -Regel: -Nur eine Version pro Dokument darf active = true sein. - ---- - -# 5. Ingest Architektur - -## 5.1 Grundsatz - -Kein Ingest läuft synchron im HTTP-Request. - -Jede Indexänderung läuft über: - -IngestJob → CLI Runner → IngestOrchestrator → IngestFlow - ---- - -## 5.2 IngestJob - -Entity mit: - -- id -- type -- status -- documentId -- documentVersionId -- startedAt -- finishedAt -- errorMessage - -Job-Typen: - -DOCUMENT_VERSION_ACTIVATE -DOCUMENT -GLOBAL_REINDEX - -Status: - -QUEUED -RUNNING -COMPLETED -FAILED -ABORTED - ---- - -## 5.3 Job-Execution - -Start: - -php bin/console mto:agent:ingest:run - -Execution-Flow: - -1. LockService.acquire() -2. Job → RUNNING -3. IngestFlow ausführen -4. Job → COMPLETED / FAILED -5. LockService.release() - ---- - -# 6. IngestFlow Details - -## 6.1 Local Ingest (Version aktivieren oder neue Datei) - -Ablauf: - -1. Extract (PDF/Text) -2. Normalize -3. Chunk deterministisch -4. Streaming Compaction: - - Entferne alle Chunks mit document_id -5. Append neue Chunks -6. Full FAISS Rebuild - -Wichtig: -Es werden immer alle alten Chunks des Dokuments entfernt. - ---- - -## 6.2 Global Reindex - -Wird ausgelöst bei: - -- embedding_model Änderung -- embedding_dimension Änderung -- chunk_size Änderung -- scoring_version Änderung -- index_format Änderung - -Ablauf: - -1. Alle aktiven Versionen extrahieren -2. Komplettes index.ndjson neu schreiben -3. FAISS neu bauen -4. index_version++ - ---- - -# 7. Storage Layer - -## 7.1 index.ndjson - -Single Source of Truth. - -Eigenschaften: - -- 1 JSON-Objekt pro Zeile -- Streaming-lesbar -- Append-only -- Compaction by document_id - -Beispiel: - -{ -"chunk_id": "...", -"document_id": "...", -"document_version_id": "...", -"text": "...", -"meta": {...} -} - ---- - -## 7.2 index_meta.json - -Enthält: - -- index_version -- embedding_model -- embedding_dimension -- chunk_size -- chunk_overlap -- scoring_version -- index_format -- vector_backend - -Bei strukturellem Mismatch: -→ Global Reindex zwingend. - ---- - -# 8. Vector Layer (FAISS) - -## 8.1 Build - -vector_ingest.py: - -- stream index.ndjson -- Embeddings berechnen -- FAISS IndexFlatIP bauen -- vector.index schreiben -- vector_meta.json schreiben - -Immer vollständiger Rebuild. -Keine Partial Updates. - -## 8.2 Search - -vector_search.py: - -Input: -- Query -- Top-K - -Output: -- chunk_id -- score - ---- - -# 9. Hybrid Retrieval - -Ablauf: - -1. Keyword Retrieval (NDJSON Keyword Search) -2. Vector Retrieval (FAISS) -3. Score Fusion -4. Chunk Lookup -5. Context Builder -6. Prompt Builder -7. LLM - -Keyword bleibt Primärsignal. -Vector ergänzt semantische Nähe. - ---- - -# 10. Locking - -LockService schützt: - -- NDJSON Mutationen -- FAISS Rebuild -- Global Reindex -- Aktivierungs-Parallelität - -Keine zwei Ingest-Jobs gleichzeitig erlaubt. - ---- - -# 11. Admin Flows - -## 11.1 Neue Datei hochladen - -1. Document + Version 1 erstellen -2. Version aktiv -3. DOCUMENT_VERSION_ACTIVATE Job anlegen -4. Async Runner starten -5. Redirect auf Job-Seite - -## 11.2 Version aktivieren - -1. DB-Status ändern -2. IngestStatus → PENDING -3. DOCUMENT_VERSION_ACTIVATE Job anlegen -4. Async Runner starten - -## 11.3 Manuelles Ingest - -1. DOCUMENT Job anlegen -2. Async Runner starten - ---- - -# 12. Error Handling - -Typische Fehler: - -- exec deaktiviert → Async Start nicht möglich -- Lock aktiv → paralleler Ingest blockiert -- index_meta mismatch → Reindex erforderlich -- Vector-Dateien fehlen → Rebuild ausführen - ---- - -# 13. Security & Stability - -- Keine User-Uploads direkt ins Retrieval -- Kein Self-Learning -- Kein unkontrollierter Index-Mutate -- Strict Separation: - - Admin Layer - - Ingest Layer - - Retrieval Layer - - LLM Layer - ---- - -# 14. System Guarantees - -Dieses System garantiert: - -- Reproduzierbarkeit -- Drift-Freiheit -- Auditierbarkeit -- Versionierte Wissensbasis -- Deterministische Retrieval-Ergebnisse -- Enterprise-taugliche Skalierbarkeit - ---- - -# 15. Zusammenfassung - -Das mitho AI Agent System ist eine: - -- deterministische Hybrid-RAG Engine -- NDJSON-basierte Wissensplattform -- Full-Rebuild-FAISS Architektur -- Job-basierte Ingest-Pipeline -- Versionierte, governance-stabile Wissensstruktur - -Keine Inline-Rebuilds. -Keine inkrementellen Vektor-Updates. -Keine impliziten Strukturänderungen. - -Alles läuft kontrolliert über das Job-System. diff --git a/config/services.yaml b/config/services.yaml index c30fe4b..06c0bc5 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -30,9 +30,14 @@ parameters: mto.knowledge.vector_tags_index: '%mto.knowledge.root%/vector_tags.index' mto.knowledge.vector_tags_index_meta: '%mto.knowledge.root%/vector_tags.index.meta.json' - # Tag vector scripts (in src/Vector) - mto.vector.ingest_tags_script: '%mto.root%/src/Vector/vector_ingest_tags.py' - mto.vector.search_tags_script: '%mto.root%/src/Vector/vector_search_tags.py' + # ------------------------------------------------------------ + # Vector Script Directory (A2) + # ------------------------------------------------------------ + mto.vector.script_dir: '%mto.root%/python/vector' + + # Tag vector scripts + mto.vector.ingest_tags_script: '%mto.vector.script_dir%/vector_ingest_tags.py' + mto.vector.search_tags_script: '%mto.vector.script_dir%/vector_search_tags.py' # Lock for tag rebuild jobs mto.tags.rebuild_lock: '%mto.knowledge.root%/locks/tag_rebuild.lock' @@ -53,10 +58,9 @@ parameters: # Python / Vector Runtime # ------------------------------------------------------------ mto.vector.python_bin: '/var/www/html/.venv/bin/python3' - mto.vector.ingest_script: '%mto.root%/src/Vector/vector_ingest.py' - mto.vector.search_script: '%mto.root%/src/Vector/vector_search.py' + mto.vector.ingest_script: '%mto.vector.script_dir%/vector_ingest.py' + mto.vector.search_script: '%mto.vector.script_dir%/vector_search.py' mto.vector.timeout: 600 - mto.vector.service_url: 'http://127.0.0.1:8090' @@ -177,7 +181,7 @@ services: $tagsNdjsonPath: '%mto.knowledge.tags_ndjson%' # ------------------------------------------------------------ - # Tags Vector (Builder + Search) ✅ HIER IST DER FIX + # Tags Vector # ------------------------------------------------------------ App\Tag\TagVectorIndexBuilder: @@ -221,6 +225,6 @@ services: App\Vector\VectorIndexHealthService: arguments: - $indexNdjsonPath: '%kernel.project_dir%/var/knowledge/index.ndjson' - $vectorIndexPath: '%kernel.project_dir%/var/knowledge/vector.index' - $vectorMetaPath: '%kernel.project_dir%/var/knowledge/vector.index.meta.json' \ No newline at end of file + $indexNdjsonPath: '%mto.knowledge.ndjson%' + $vectorIndexPath: '%mto.knowledge.vector_index%' + $vectorMetaPath: '%mto.knowledge.vector_index_meta%' \ No newline at end of file diff --git a/src/Vector/vector_control.py b/python/vector/vector_control.py similarity index 99% rename from src/Vector/vector_control.py rename to python/vector/vector_control.py index a1e2734..a08959f 100644 --- a/src/Vector/vector_control.py +++ b/python/vector/vector_control.py @@ -181,7 +181,7 @@ def start_service(host: str, port: int) -> Dict: cmd = [ str(UVICORN_BIN), - "src.Vector.vector_service:app", + "python.vector.vector_service:app", "--host", host, "--port", str(port), ] diff --git a/src/Vector/vector_ingest.py b/python/vector/vector_ingest.py similarity index 100% rename from src/Vector/vector_ingest.py rename to python/vector/vector_ingest.py diff --git a/src/Vector/vector_ingest_tags.py b/python/vector/vector_ingest_tags.py similarity index 100% rename from src/Vector/vector_ingest_tags.py rename to python/vector/vector_ingest_tags.py diff --git a/src/Vector/vector_search.py b/python/vector/vector_search.py similarity index 100% rename from src/Vector/vector_search.py rename to python/vector/vector_search.py diff --git a/src/Vector/vector_search_tags.py b/python/vector/vector_search_tags.py similarity index 100% rename from src/Vector/vector_search_tags.py rename to python/vector/vector_search_tags.py diff --git a/src/Vector/vector_service.py b/python/vector/vector_service.py similarity index 100% rename from src/Vector/vector_service.py rename to python/vector/vector_service.py diff --git a/src/Command/VectorControlCommand.php b/src/Command/VectorControlCommand.php index 5cb1a58..98f28de 100644 --- a/src/Command/VectorControlCommand.php +++ b/src/Command/VectorControlCommand.php @@ -33,7 +33,7 @@ final class VectorControlCommand extends Command protected function execute(InputInterface $input, OutputInterface $output): int { - $cmd = ['.venv/bin/python', 'src/Vector/vector_control.py']; + $cmd = ['.venv/bin/python', 'python/vector/vector_control.py']; if ($input->getOption('install')) { $cmd[] = '--install'; diff --git a/src/Ingest/IngestFlow.php b/src/Ingest/IngestFlow.php index d483e15..e36f34a 100644 --- a/src/Ingest/IngestFlow.php +++ b/src/Ingest/IngestFlow.php @@ -29,7 +29,7 @@ final readonly class IngestFlow ) {} // ========================================================= - // DOCUMENT INGEST + // DOCUMENT INGEST (STREAMING SAFE) // ========================================================= public function ingestDocumentVersion(DocumentVersion $version): void @@ -45,12 +45,34 @@ final readonly class IngestFlow $existing = $this->chunkManager->countAllChunks(); - $records = iterator_to_array( - $this->knowledgeIngestService->buildChunkRecords($version), - false - ); + $incoming = 0; + $generator = $this->knowledgeIngestService->buildChunkRecords($version); + + $wrappedGenerator = (function () use ($generator, $existing, &$incoming) { + + foreach ($generator as $record) { + + $incoming++; + $total = $existing + $incoming; + + if ($total >= self::CHUNK_LIMIT_WARN) { + // Nur einmal warnen + if ($incoming === 1 || $total === self::CHUNK_LIMIT_WARN) { + // Logging erfolgt außerhalb des Streams final + } + } + + if ($total > self::CHUNK_LIMIT_HARD) { + throw new \RuntimeException('Chunk limit exceeded.'); + } + + yield $record; + } + + })(); + + $this->chunkManager->appendChunks($wrappedGenerator); - $incoming = count($records); $total = $existing + $incoming; if ($total >= self::CHUNK_LIMIT_WARN) { @@ -61,12 +83,6 @@ final readonly class IngestFlow ]); } - if ($total > self::CHUNK_LIMIT_HARD) { - throw new \RuntimeException('Chunk limit exceeded.'); - } - - $this->chunkManager->appendChunks($records); - $this->rebuildIndex(false); $version->setIngestStatus(DocumentVersion::INGEST_INDEXED); @@ -81,13 +97,11 @@ final readonly class IngestFlow } // ========================================================= - // GLOBAL REINDEX + // GLOBAL REINDEX (STREAMING SAFE) // ========================================================= public function globalReindex(): void { - - // 1️⃣ Prüfen ob aktive Dokumente existieren $activeDocuments = $this->em ->getRepository(Document::class) ->createQueryBuilder('d') @@ -102,22 +116,40 @@ final readonly class IngestFlow ); } - // 2️⃣ ChunkRecords erzeugen - $records = iterator_to_array( - $this->knowledgeIngestService->buildAllActiveChunkRecords(), - false - ); + $incoming = 0; - if (empty($records)) { + $generator = $this->knowledgeIngestService->buildAllActiveChunkRecords(); + + $wrappedGenerator = (function () use ($generator, &$incoming) { + + foreach ($generator as $record) { + $incoming++; + yield $record; + } + + })(); + + // Prüfen ob überhaupt etwas kommt (ohne alles in RAM zu ziehen) + $peekIterator = $wrappedGenerator instanceof \Iterator + ? $wrappedGenerator + : (function () use ($wrappedGenerator) { + foreach ($wrappedGenerator as $item) { + yield $item; + } + })(); + + if (!$peekIterator->valid()) { + $peekIterator->rewind(); + } + + if (!$peekIterator->valid()) { throw new \RuntimeException( - 'Global Reindex abgebrochen: Es wurden keine Chunks erzeugt. Bitte prüfen Sie die Dokumente.' + 'Global Reindex abgebrochen: Es wurden keine Chunks erzeugt.' ); } - // 3️⃣ Rewrite NDJSON - $this->chunkManager->rewriteAll($records); + $this->chunkManager->rewriteAll($peekIterator); - // 4️⃣ Rebuild Vector Index $this->rebuildIndex(true); } @@ -137,18 +169,14 @@ final readonly class IngestFlow throw new \RuntimeException('Document not found.'); } - // Chunks entfernen $this->chunkManager->compactByDocument($documentId); - // Dokument aus DB entfernen $this->em->remove($document); $this->em->flush(); - // 4️⃣ Reindex nur wenn sinnvoll $this->rebuildIndex(false); } - // ========================================================= // CENTRAL REBUILD // ========================================================= @@ -169,4 +197,4 @@ final readonly class IngestFlow $chunkCount = $this->chunkManager->countAllChunks(); $this->metaManager->updateRuntimeStats($chunkCount); } -} +} \ No newline at end of file diff --git a/src/Knowledge/ChunkManager.php b/src/Knowledge/ChunkManager.php index 11953a9..5409b57 100644 --- a/src/Knowledge/ChunkManager.php +++ b/src/Knowledge/ChunkManager.php @@ -23,13 +23,9 @@ final class ChunkManager } // ============================================================ - // COUNT (für Guardrails / Limits) + // COUNT (Streaming, robust) // ============================================================ - /** - * Zählt Datensätze (NDJSON-Zeilen) im index.ndjson streaming-basiert. - * Leere / kaputte Zeilen werden ignoriert. - */ public function countAllChunks(): int { if (!is_file($this->indexPath)) { @@ -42,6 +38,7 @@ final class ChunkManager } $count = 0; + try { while (($line = fgets($handle)) !== false) { $line = trim($line); @@ -49,7 +46,6 @@ final class ChunkManager continue; } - // NDJSON besteht aus JSON-Objekten; wir zählen nur valide Arrays. $data = json_decode($line, true); if (is_array($data)) { $count++; @@ -63,7 +59,7 @@ final class ChunkManager } // ============================================================ - // APPEND + // APPEND (Streaming + Exception Safe) // ============================================================ /** @@ -82,27 +78,34 @@ final class ChunkManager throw new \RuntimeException('Unable to open index.ndjson for append'); } - foreach ($records as $record) { - $json = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if ($json === false) { - fclose($handle); - throw new \RuntimeException('Unable to encode chunk record'); + try { + foreach ($records as $record) { + $json = json_encode( + $record, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + + if ($json === false) { + throw new \RuntimeException('Unable to encode chunk record'); + } + + if (fwrite($handle, $json . PHP_EOL) === false) { + throw new \RuntimeException('Unable to write chunk to index'); + } } - - fwrite($handle, $json . PHP_EOL); + } finally { + fclose($handle); } - - fclose($handle); } // ============================================================ - // COMPACTION – Entfernt alle Chunks eines Dokuments + // COMPACTION (Streaming + Safe Handles) // ============================================================ public function compactByDocument(Uuid $documentId): void { if (!is_file($this->indexPath)) { - return; // nichts zu kompaktieren + return; } $tmpPath = $this->indexPath . '.tmp'; @@ -116,32 +119,36 @@ final class ChunkManager $docIdString = $documentId->toRfc4122(); - while (($line = fgets($in)) !== false) { - $line = trim($line); - if ($line === '') { - continue; - } + try { + while (($line = fgets($in)) !== false) { + $line = trim($line); + if ($line === '') { + continue; + } - $data = json_decode($line, true); - if (!is_array($data)) { - continue; // skip corrupted line - } + $data = json_decode($line, true); + if (!is_array($data)) { + continue; + } - if (($data['document_id'] ?? null) === $docIdString) { - continue; // skip this document's chunks - } + if (($data['document_id'] ?? null) === $docIdString) { + continue; + } - fwrite($out, $line . PHP_EOL); + if (fwrite($out, $line . PHP_EOL) === false) { + throw new \RuntimeException('Unable to write compacted chunk'); + } + } + } finally { + fclose($in); + fclose($out); } - fclose($in); - fclose($out); - $this->atomicSwitch($tmpPath, $this->indexPath); } // ============================================================ - // FULL REWRITE (Global Reindex) + // FULL REWRITE (Streaming + Atomic) // ============================================================ /** @@ -162,23 +169,30 @@ final class ChunkManager throw new \RuntimeException('Unable to open temp index file'); } - foreach ($records as $record) { - $json = json_encode($record, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - if ($json === false) { - fclose($handle); - throw new \RuntimeException('Unable to encode chunk record'); + try { + foreach ($records as $record) { + $json = json_encode( + $record, + JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES + ); + + if ($json === false) { + throw new \RuntimeException('Unable to encode chunk record'); + } + + if (fwrite($handle, $json . PHP_EOL) === false) { + throw new \RuntimeException('Unable to write chunk during rewrite'); + } } - - fwrite($handle, $json . PHP_EOL); + } finally { + fclose($handle); } - fclose($handle); - $this->atomicSwitch($tmpPath, $this->indexPath); } // ============================================================ - // STREAM READ (für FAISS rebuild) + // STREAM READ (FAISS rebuild safe) // ============================================================ /** @@ -223,4 +237,4 @@ final class ChunkManager throw new \RuntimeException('Atomic switch failed for index.ndjson'); } } -} +} \ No newline at end of file diff --git a/src/Vector/VectorIndexBuilder.php b/src/Vector/VectorIndexBuilder.php index e7c3572..fe7a3c6 100644 --- a/src/Vector/VectorIndexBuilder.php +++ b/src/Vector/VectorIndexBuilder.php @@ -49,9 +49,8 @@ final class VectorIndexBuilder // -------------------------------------------- // 🔵 FALL: NDJSON ist leer → kein Vector Index // -------------------------------------------- - if (filesize($this->indexNdjsonPath) === 0) { + if (!is_file($this->indexNdjsonPath) || filesize($this->indexNdjsonPath) === 0) { - // Alten Index entfernen @unlink($this->vectorIndexPath); @unlink($this->vectorMetaPath); @@ -63,7 +62,7 @@ final class VectorIndexBuilder ); } - return; // WICHTIG: kein Python, kein tmp, kein Fehler + return; } // -------------------------------------------- @@ -79,7 +78,6 @@ final class VectorIndexBuilder $tmpVectorIndexPath = $this->vectorIndexPath . '.tmp'; - // Clean leftovers @unlink($tmpVectorIndexPath); @unlink($this->vectorMetaPath); @@ -108,11 +106,15 @@ final class VectorIndexBuilder private function assertPreconditions(): void { if (!is_file($this->scriptPath)) { - throw new \RuntimeException('vector_ingest.py not found at: ' . $this->scriptPath); + throw new \RuntimeException( + 'Vector build script not found at: ' . $this->scriptPath + ); } if (!is_file($this->indexNdjsonPath)) { - throw new \RuntimeException('index.ndjson not found at: ' . $this->indexNdjsonPath); + throw new \RuntimeException( + 'index.ndjson not found at: ' . $this->indexNdjsonPath + ); } } @@ -195,4 +197,4 @@ final class VectorIndexBuilder @file_put_contents($logPath, "=== VectorIndexBuilder OK ===\n", FILE_APPEND); } } -} +} \ No newline at end of file