KI-Architektur¶
Diese Seite beschreibt die technische Architektur des KI-Assistenten (REQ-031). Die Implementierung folgt dem Adapter-Pattern aus REQ-011 und integriert sich in die bestehende 5-Schicht-Architektur.
Systemarchitektur¶
graph TB
subgraph "Frontend (React/MUI)"
TC[TipCardsPanel]
CD[AiChatDrawer]
DM[DiagnosisModePanel]
end
subgraph "API-Layer (FastAPI)"
AR["/api/v1/t/slug/ai/tips"]
AC["/api/v1/t/slug/ai/chat"]
AD["/api/v1/t/slug/ai/diagnose"]
APC["/api/v1/t/slug/ai/providers"]
end
subgraph "Business Logic"
AAS[AiAssistantService]
CB[ContextBuilder]
RR[RagRetriever]
PA[PromptAssembler]
TG[TipGeneratorService]
end
subgraph "Provider-Adapter"
OA[OllamaAdapter]
OAI[OpenAiAdapter]
ANT[AnthropicAdapter]
LC[LlamaCppAdapter]
OC[OpenAiCompatibleAdapter]
FB[RuleBasedFallback]
end
subgraph "Datenschicht"
ARD[(ArangoDB<br/>Stammdaten + Kontext)]
TS[(TimescaleDB<br/>pgvector)]
RD[(Redis<br/>Cache 4h TTL)]
end
subgraph "Hintergrundaufgaben (Celery)"
GDT[generate_daily_tips]
RVC[reindex_vector_chunks]
end
TC --> AR
CD --> AC
DM --> AD
AR --> AAS
AC --> AAS
AD --> AAS
AAS --> CB
AAS --> RR
AAS --> PA
CB --> ARD
RR --> TS
PA --> OA
PA --> OAI
PA --> ANT
PA --> LC
PA --> OC
PA --> FB
AAS --> RD
GDT --> AAS
RVC --> TS
RVC --> ARD IAiProvider — Adapter-Interface¶
Alle KI-Provider implementieren das IAiProvider-Interface. Neue Provider können hinzugefügt werden, ohne bestehenden Code zu ändern (Open/Closed Principle, analog zum ExternalSourceAdapter in REQ-011).
# app/domain/interfaces/ai_provider.py
class IAiProvider(ABC):
"""Abstraktes Interface für KI-Provider-Adapter.
Implementierungen: OllamaAdapter, OpenAiAdapter,
AnthropicAdapter, LlamaCppAdapter, OpenAiCompatibleAdapter,
RuleBasedFallback.
"""
@abstractmethod
async def chat(
self,
messages: list[ChatMessage],
*,
max_tokens: int = 1024,
temperature: float = 0.3,
) -> AiResponse:
"""Vollständige Antwort (für Tipp-Karten)."""
...
@abstractmethod
async def chat_stream(
self,
messages: list[ChatMessage],
*,
max_tokens: int = 1024,
temperature: float = 0.3,
) -> AsyncIterator[str]:
"""Token-für-Token-Streaming (für Chat, SSE)."""
...
@abstractmethod
async def health_check(self) -> bool:
"""Erreichbarkeit und Funktionsfähigkeit prüfen."""
...
Provider-Registrierung¶
Provider werden über eine Registry aufgelöst, analog zum AdapterRegistry-Pattern in REQ-011:
# app/data_access/ai_providers/registry.py
class AiProviderRegistry:
_providers: dict[str, type[IAiProvider]] = {}
@classmethod
def register(cls, provider_type: str):
"""Dekorator zur Provider-Registrierung."""
def decorator(klass):
cls._providers[provider_type] = klass
return klass
return decorator
@classmethod
def resolve(cls, config: AiProviderConfig) -> IAiProvider:
"""Liefert eine initialisierte Provider-Instanz."""
klass = cls._providers.get(config.provider_type)
if klass is None:
raise ValueError(f"Unknown provider type: {config.provider_type}")
return klass(config)
RAG-Pipeline¶
Embedding-Modell¶
- Modell:
sentence-transformers/all-MiniLM-L6-v2 - Dimensionen: 384
- Modellgröße: ~23 MB
- Betrieb: Lokal, kein API-Key, kein externer Dienst
Das Embedding-Modell läuft als Python-Prozess im Backend-Container. Es erzeugt Vektoren für: - Neue oder aktualisierte Stammdaten-Dokumente (Celery-Task reindex_vector_chunks) - Eingehende Nutzer-Anfragen zur Ähnlichkeitssuche
Vektorspeicher (pgvector auf TimescaleDB)¶
CREATE TABLE ai_vector_chunks (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
source_type VARCHAR(64) NOT NULL,
-- 'species' | 'cultivar' | 'growth_phase' | 'care_rule' | 'pest' | 'disease'
source_key VARCHAR(128) NOT NULL,
chunk_index INT NOT NULL DEFAULT 0,
chunk_text TEXT NOT NULL,
embedding vector(384) NOT NULL,
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- IVFFlat-Index für Cosine-Similarity-Suche
CREATE INDEX idx_ai_vector_chunks_embedding
ON ai_vector_chunks USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
TimescaleDB wurde als Vektorspeicher gewählt, da es bereits im Stack vorhanden ist (kein zusätzlicher Dienst wie Qdrant oder Chroma).
Chunk-Konfiguration¶
| Parameter | Wert | Begründung |
|---|---|---|
| Chunk-Größe | 512 Tokens | Optimum aus Präzision und Kontext |
| Chunk-Overlap | 64 Tokens | Verhindert Informationsverlust an Grenzen |
| Top-K Retrieval | 5 Chunks | Balance aus Kontext und Prompt-Länge |
| Similarity-Schwelle | 0,65 | Cosine-Distanz; filtert irrelevante Chunks |
Retrieval-Strategie¶
# app/domain/engines/rag_retriever.py
class RagRetriever:
async def retrieve(
self,
query: str,
*,
top_k: int = 5,
source_type_filter: list[str] | None = None,
metadata_filter: dict | None = None,
) -> list[RagChunk]:
"""Cosine-Ähnlichkeitssuche auf ai_vector_chunks.
Args:
query: Nutzer-Anfrage oder Kontext-Beschreibung.
top_k: Anzahl zurückgegebener Chunks.
source_type_filter: Optionale Einschränkung auf bestimmte
Quelltypen (z.B. ['pest', 'disease'] für Diagnose).
metadata_filter: Optionaler JSONB-Filter (z.B. Phase).
"""
query_embedding = self._embed(query)
# pgvector Cosine-Similarity: <=>
# (1 - cosine_distance) >= similarity_threshold
...
Re-Ranking-Stufe (Cross-Encoder)¶
Optionale Komponente
Re-Ranking ist optional. Ohne konfigurierte RERANKER_URL arbeitet die Pipeline unverändert mit Hybrid-Search-only-Modus. Siehe ADR-007 für die Entscheidungsbegründung.
Einordnung in die Pipeline¶
Der Re-Ranker sitzt zwischen Retrieval und LLM-Generation. Die Hybrid-Search ruft bewusst mehr Chunks ab als letztlich an den LLM übergeben werden (Over-Retrieval-Strategie): 20 Kandidaten werden abgerufen, durch den Cross-Encoder nach semantischer Relevanz neu sortiert, und nur die besten 5 landen im Kontext-Fenster des LLM.
Warum Cross-Encoder?¶
Bi-Encoder (E5-base) und BM25 ranken unabhängig voneinander. Keyword-reiche Chunks erhalten einen hohen BM25-Score, auch wenn sie semantisch nicht zur Anfrage passen. Der Cross-Encoder bewertet jedes Query-Chunk-Paar gemeinsam und erzeugt präzisere Relevanz-Scores. Das reduziert die dominierende Fehlerklasse GENERATION_MISS (LLM halluziniert wegen irrelevantem Kontext).
Separater Microservice (ONNX Runtime)¶
Der Re-Ranker läuft als eigenständiger reranker-service — analog zum Embedding-Service:
- Kein PyTorch im Container — nur ONNX Runtime und Hugging Face Tokenizer
- Multi-Stage Dockerfile: Modell-Download und ONNX-Export in einem gecachten Build-Stage; Runtime-Image bleibt schlank
- Port 8081, FastAPI mit zwei Endpunkten:
/rerank(POST) und/health(GET) - Modell:
BAAI/bge-reranker-v2-m3— multilingual (DE/EN), 568M Parameter, Apache-2.0-Lizenz
sequenceDiagram
participant KS as Knowledge Service
participant RE as Reranker Service<br/>(Port 8081)
KS->>KS: Hybrid Search → 20 Kandidaten
KS->>RE: POST /rerank<br/>{query, documents[20], top_k: 5}
RE->>RE: Cross-Encoder Inferenz<br/>ONNX Runtime, ~500ms
RE-->>KS: {results: [{index, score, text}×5]}
KS->>KS: Chunks nach Score sortieren
KS->>KS: Kontext für LLM aufbauen Graceful Degradation¶
Ist RERANKER_URL leer oder nicht gesetzt, gibt RerankerEngine.available False zurück. In diesem Fall wird die ursprüngliche Chunk-Liste auf top_k Einträge gekürzt und direkt an den LLM-Kontext übergeben. Ein Timeout oder HTTP-Fehler des Reranker-Service löst ebenfalls diesen Fallback aus — mit einem WARNING-Logeintrag (reranker_fallback).
Ressourcenbedarf¶
| Szenario | RAM | CPU | Latenz/Query |
|---|---|---|---|
| Reranker aktiv (20→5) | 1,5–4 GB | 1–2 Kerne | +~500ms |
| Reranker deaktiviert | 0 | 0 | 0ms |
Erster Docker-Build
Der erste Build des reranker-service-Images dauert 10–15 Minuten, da BAAI/bge-reranker-v2-m3 heruntergeladen und via optimum nach ONNX exportiert wird. Folge-Builds nutzen den gecachten Layer und sind in Sekunden abgeschlossen.
Context-Builder¶
Der ContextBuilder holt zur Laufzeit den aktuellen Zustand einer Pflanze oder eines Pflanzdurchlaufs aus ArangoDB und formatiert ihn als strukturierten Text für den System-Prompt.
# app/domain/engines/ai_context_builder.py
class AiContextBuilder:
async def build_plant_context(
self,
tenant_key: str,
context_key: str,
context_type: str,
) -> PlantContext:
"""Holt und formatiert den Pflanzen-Kontext.
Returns:
PlantContext mit: Pflanzenart, Sorte, aktuelle Phase,
Phase-Tag, EC/pH/VPD (letzte Messung), aktive IPM-Events,
letzte 3 Dünge-Ereignisse, Substrat-Typ.
"""
...
Geholte Daten (AQL-Traversal):
planting_runs→ aktuellegrowth_phase→ Ziel-EC, -pH, -VPDplant_instances→cultivar→species→ Pflegeprofileobservation_readings(TimescaleDB) → letzte Messwerteipm_inspections→ aktive Befälle und laufende Behandlungenfeeding_events→ letzte 3 Ereignisse mit Produkten und Mengen
Prompt-Assembler¶
Der PromptAssembler kombiniert alle Informationen zu einem strukturierten System-Prompt:
[System-Rolle]
Du bist ein Pflanzenberatungs-Assistent für Kamerplanter. Du antwortest
ausschließlich auf Basis der bereitgestellten Kontext-Informationen.
[Aktueller Pflanzen-Kontext]
Art: Cannabis sativa | Sorte: Northern Lights
Phase: Flowering (Tag 21/56) | Substrat: Coco
EC-Ziel: 1.4–1.8 mS/cm | EC-Ist: 1.2 mS/cm
pH-Ziel: 5.8–6.2 | pH-Ist: 5.8
VPD-Ziel: 0.8–1.2 kPa | VPD-Ist: 1.1 kPa
[Wissensbasis-Chunks]
[Chunk 1 — species]: Cannabis sativa Flowering NPK-Profil...
[Chunk 2 — care_rule/diagnostik/naehrstoffmangel-symptome#mangel-stickstoff]:
Stickstoff-Mangel: untere Blätter gelb...
[Chunk 3 — care_rule/phasen/bluete-management]:
N-Bedarf sinkt ab Woche 3 der Blüte...
[Erfahrungsstufe des Nutzers]
intermediate — Technische Details zeigen, keine Code-Beispiele.
[Chat-Verlauf]
(letzte 5 Nachrichten)
[Nutzer-Anfrage]
Meine unteren Blätter werden gelb — was kann das sein?
Prompt-Längen nach Feature¶
| Feature | Tokens Input | Tokens Output |
|---|---|---|
| Tipp-Karten (JSON) | ~800 | ~200 |
| Chat-Einzelfrage | ~1.500 | ~300 |
| Chat mit 10 Nachrichten Verlauf | ~3.000 | ~400 |
| Diagnose-Anfrage | ~2.000 | ~500 |
Caching-Strategie¶
Redis (Hot-Cache)¶
Tipp-Karten werden in Redis mit 4 Stunden TTL gecacht. Cache-Key-Schema:
Celery Batch-Task¶
Der tägliche Celery-Task generate_daily_tips (06:00 UTC) generiert Tipp-Karten für alle aktiven Pflanzdurchläufe im Hintergrund und schreibt sie in Redis und ArangoDB (ai_tip_cache-Collection).
# app/tasks/ai_tasks.py
@celery_app.task(name="generate_daily_tips")
def generate_daily_tips():
"""Generiert Tipp-Karten für alle aktiven Pflanzdurchläufe.
Läuft täglich um 06:00 UTC. Verarbeitet Runs sequentiell
bei CPU-only Inference (max_concurrent_tips=1, konfigurierbar).
"""
...
Cache-Invalidierung¶
Tipp-Karten werden sofort neu generiert bei: - Phasenwechsel (phase_transition-Event) - EC/pH außerhalb Toleranzband (±10 % vom Zielwert) - Neuem IPM-Ereignis
Consent-Middleware¶
Cloud-Provider (OpenAI, Anthropic) erfordern eine explizite DSGVO-Einwilligung (REQ-025). Die Consent-Middleware prüft vor jeder Anfrage, ob die Einwilligung vorliegt.
# app/common/dependencies.py
async def require_ai_consent(
provider_config: AiProviderConfig,
current_user: User,
consent_service: ConsentService,
) -> None:
"""Prüft DSGVO-Einwilligung für Cloud-AI-Provider.
Raises:
ConsentRequiredError: Wenn provider.requires_consent == True
und keine gültige Einwilligung vorliegt.
"""
if provider_config.requires_consent:
consent = await consent_service.get_consent(
user_key=current_user.key,
purpose="ai_cloud_processing",
)
if not consent or not consent.is_valid:
raise ConsentRequiredError(
"Cloud-AI-Provider erfordert DSGVO-Einwilligung.",
consent_purpose="ai_cloud_processing",
)
Lokale Provider (ollama, llamacpp) haben requires_consent: false und benötigen keine Einwilligung.
Eval-Framework¶
Die Antwortqualität wird automatisch evaluiert:
| Methode | Beschreibung |
|---|---|
| Topic-Match | Sind die RAG-Chunks semantisch relevant für die Anfrage? (Cosine-Score > 0,70) |
| LLM-as-Judge | Ein zweites Modell bewertet Faktentreue und Handlungsrelevanz (1–5 Punkte) |
| Benchmark-Suite | 100 vordefinierte Fragen mit Referenzantworten; Regressionstest bei Modelländerungen |
| A/B-Vergleich | Bei neuen Modellen oder Guide-Versionen: automatischer Vergleich gegen Baseline |
Datenmodell-Übersicht¶
ArangoDB Collections¶
| Collection | Beschreibung | Retention |
|---|---|---|
ai_provider_configs | Provider-Konfigurationen pro Tenant | Dauerhaft |
ai_conversations | Chat-Verläufe mit Nachrichtenhistorie | 90 Tage |
ai_tip_cache | Gecachte Tipp-Karten | 7 Tage |
TimescaleDB Tabellen¶
| Tabelle | Beschreibung |
|---|---|
ai_vector_chunks | Vektor-Index (384-dim, all-MiniLM-L6-v2) für RAG |
Edge Collections (ArangoDB)¶
| Collection | Von → Nach | Zweck |
|---|---|---|
ai_tip_references_plant | ai_tip_cache → plant_instances | Zuordnung Tip zu Pflanze |
ai_tip_references_run | ai_tip_cache → planting_runs | Zuordnung Tip zu Durchlauf |
ai_conversation_about | ai_conversations → plant_instances / planting_runs | Konversationskontext |
Deployment-Konfiguration (Helm)¶
# helm/kamerplanter/values.yaml — KI-Konfiguration
ollama:
enabled: true # Ollama als Sidecar oder eigener Pod
controllers:
main:
containers:
main:
env:
OLLAMA_MODELS: /models
OLLAMA_NUM_PARALLEL: "1"
OLLAMA_MAX_LOADED_MODELS: "1"
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
cpu: "4"
memory: 6Gi # Für gemma3:4b Q4_K_M
backend:
env:
AI_DEFAULT_PROVIDER: ollama # ollama | openai | anthropic | none
AI_OLLAMA_BASE_URL: http://ollama:11434
AI_OLLAMA_MODEL: gemma3:4b
AI_TIP_CACHE_TTL_HOURS: "4"
AI_MAX_CONCURRENT_TIPS: "5" # 1 für CPU-only
AI_EMBEDDING_MODEL: sentence-transformers/all-MiniLM-L6-v2
AI_RAG_TOP_K: "5"
AI_CONVERSATION_RETENTION_DAYS: "90"
Referenzen¶
- REQ-031 — KI-Assistent & Pflanzenberatung (
spec/req/REQ-031_KI-Assistent-Pflanzenberatung.md) - REQ-011 — Externe Stammdatenanreicherung (
spec/req/REQ-011_Externe-Stammdatenanreicherung.md) - REQ-025 — Datenschutz & DSGVO (
spec/req/REQ-025_Datenschutz-Betroffenenrechte.md) - ADR-006 — Embedding-Modell E5-base und Hybrid Search
- ADR-007 — Cross-Encoder Re-Ranking für RAG-Pipeline
- RAG-Wissensbasis verstehen
- KI-Assistent verwenden
- pgvector Dokumentation
- BAAI/bge-reranker-v2-m3
- sentence-transformers/all-MiniLM-L6-v2