app/mcp_server.py: FastMCP (mcp SDK), streamable-http auf /mcp, statischer Bearer-Token (constant-time ASGI-Middleware), Fail-Fast ohne RAG_MCP_TOKEN. Tools rag_search (mit semester/fach/typ-Filter) + get_file_chunks. Läuft aus demselben Image wie der Ingestor und reused den Embed-Pfad → Vektoren sind garantiert kompatibel zum Ingest (der offizielle qdrant-MCP-Server kann nur fastembed → Dimension-/Schema-Mismatch). app/qdrant_store.py: search_chunks (query_points + optionaler Payload-Filter) und get_chunks_by_path (scroll, nach chunk_index sortiert). app/bulk.py: Amplification-Guard — /bulk-import lehnt mit 409 ab solange ein vorheriger Bulk noch BackgroundTasks abarbeitet. docker-compose.coolify.yml: rag-mcp-Service (nicht public, externes metamcp-net statt Stack-Coupling) + Traefik-Rate-Limit-Middleware am ingestor. tests/conftest.py: Settings-env_file in Tests neutralisieren (Dev-.env darf die Suite nicht kontaminieren). 68 passed, ruff clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
6.5 KiB
rag-ingestor
Microservice der Dateien aus Nextcloud (Documents/THB/Studium/) in Qdrant indexiert. Embeddings via Ollama.
Endpoints
POST /webhook(HeaderX-Webhook-Secret): Nextcloud-Event-Empfang (created/updated/deleted).POST /bulk-import(HeaderX-Webhook-Secret): Body{"path": "..."}→ rekursiver Re-Index. Bulk-Pipeline-Stages laufen mit Concurrency 4 (sieheBULK_CONCURRENCYinapp/bulk.py).GET /health: Liveness-Probe.
Webhook-Payload-Format
Der Service erwartet ein vorgeformtes JSON. Nextcloud-Roh-Events werden nicht direkt akzeptiert — sie müssen via Flow-Webhook in dieses Schema übersetzt werden:
{
"event_type": "created",
"file_path": "Documents/THB/Studium/2.Semester/Databases/DBS1.pdf",
"file_name": "DBS1.pdf"
}
event_type ∈ {"created", "updated", "deleted"}. Auth via Header X-Webhook-Secret, der mit WEBHOOK_SECRET aus der Konfiguration übereinstimmen muss.
Beispielaufruf:
curl -X POST http://localhost:8000/webhook \
-H "Content-Type: application/json" \
-H "X-Webhook-Secret: $WEBHOOK_SECRET" \
-d '{"event_type": "created", "file_path": "Documents/THB/Studium/2.Semester/Databases/DBS1.pdf", "file_name": "DBS1.pdf"}'
Erwartete Ordnerstruktur
Documents/THB/Studium/<N>.Semester/<Fach>/[<Unterordner>]/<datei>
Unterstützte Dateitypen: .pdf, .md, .docx, .xlsx (XLSX wird nur als Filename indexiert, kein Inhalt).
Konfiguration
Siehe .env.example. Alle Werte über Env-Vars, kein Config-File.
Lokale Entwicklung
uv sync
uv run pytest
uv run uvicorn app.main:app --reload
Deployment
Image bauen und in Coolify neben Qdrant + Ollama deployen:
docker build -f docker/Dockerfile -t rag-ingestor .
Ollama-Ressourcenlimits
Embedding-Inferenz ist CPU-only und skaliert per Default auf alle verfügbaren Cores. Für Produktion daher Ollama hart limitieren, damit der Host nicht von Ingest-Spikes blockiert wird:
cpus: "2.0"(Container-Cap)OLLAMA_NUM_PARALLEL=1(serialisiert Embedding-Requests intern)
Beide Werte sind in docker-compose.yml für die lokale Entwicklung gesetzt und sollten in Coolify entsprechend mitgepflegt werden. Folge: konstante ~2 CPU statt Peaks bis 8 CPU, dafür längere Bulk-Laufzeiten.
MCP-Server (Retrieval via MetaMCP)
app/mcp_server.py exponiert das indexierte Wissen als MCP-Server (streamable-http, Endpoint /mcp). Er läuft aus demselben Image wie der Ingestor und nutzt denselben Embed-Pfad (Ollama qwen3-embedding:0.6b) — Query-Vektoren sind damit garantiert kompatibel zu den beim Ingest geschriebenen 1024-Dim-Vektoren. Der offizielle qdrant/mcp-server-qdrant ist nicht nutzbar: er kann nur fastembed (Dimension-/Modell-Mismatch), kennt unser Payload-Schema nicht und bietet kein Metadata-Filtering.
Tools:
rag_search(query, limit=5, semester?, fach?, typ?)— semantische Suche mit optionalen Payload-Filtern; lieferttext+ Quell-Metadaten +score.get_file_chunks(file_path)— alle Chunks eines Dokuments inchunk_index-Reihenfolge.
Lokal starten
RAG_MCP_TOKEN=dev-token uv run python -m app.mcp_server
Lauscht auf 0.0.0.0:${RAG_MCP_PORT:-9009}. Ohne RAG_MCP_TOKEN verweigert der Server den Start (Fail-Fast). Jeder Request braucht Authorization: Bearer <RAG_MCP_TOKEN>.
Coolify + MetaMCP
Der rag-mcp-Service in docker-compose.coolify.yml ist bewusst nicht public (keine Domain / kein expose). Erreichbar ist er nur über das dedizierte externe Netz metamcp-net — qdrant/ollama/ingestor bleiben außen vor (kein Shared-Network-Coupling des ganzen Stacks).
- Netz einmalig anlegen:
docker network create metamcp-net - In der MetaMCP-Compose
networks: [metamcp-net]ergänzen, damit MetaMCP demselben Netz beitritt. RAG_MCP_TOKENals Secret in Coolify setzen (Wert aus.env.coolify).- In MetaMCP einen streamable-http-Downstream registrieren: URL
http://rag-mcp:9009/mcp, HeaderAuthorization: Bearer <RAG_MCP_TOKEN>.
Externe Kontrolle/Sharing macht MetaMCP als Single-Endpoint; der Token + die Netz-Isolation sind die zwei Kontrollschichten auf dem internen Hop.
Webhook-Härtung
-
Rate-Limit:
docker-compose.coolify.ymldefiniert amingestoreine Traefik-Middlewarerag-ratelimit(30 req/min, Burst 15). Das Binding an den Coolify-generierten Router trägt eine env-spezifische UUID und gehört daher nicht ins Repo, sondern als Custom-Label in die Coolify-UI des ingestor-Service:traefik.http.routers.https-0-<COOLIFY-UUID>-ingestor.middlewares=gzip,rag-ratelimit(UUID aus der von Coolify gerenderten Compose entnehmen; bestehende
gzip-Middleware beibehalten.) -
Bulk-Amplification-Guard:
/bulk-importlehnt mit409ab, solange ein vorheriger Bulk noch Tasks abarbeitet — verhindert unbegrenztes Anhäufen von BackgroundTasks durch wiederholte Calls oder einen fehlfeuernden Nextcloud-Flow. -
CORS ist bewusst nicht konfiguriert: der Webhook wird Server-zu-Server gerufen, CORS ist kein Spam-Schutz und für Browser-Clients hier irrelevant.
Tests
uv run pytest -v
Tests deckt Pure-Logic ab (Metadata-Parser, Chunker, Extractors, Auth, Pipeline-Orchestrierung mit gemockten externen Services). Keine Integration-Tests gegen echte Ollama/Qdrant/WebDAV-Instanzen.
Recovery-Runbook
Einbettungs-Modell oder -Dimension geändert
Beim Boot crasht der Service mit qdrant collection ... dimension mismatch, falls die existierende Collection eine andere Vektor-Dimension hat als das aktuelle Embedding-Modell. Dies ist Absicht (Fail-Fast). Vorgehen:
- Collection in Qdrant manuell droppen:
curl -X DELETE "$QDRANT_URL/collections/$QDRANT_COLLECTION" - Service neu starten — Lifespan legt die Collection mit der neuen Dimension an.
- Bulk-Import auf den Studium-Root anstoßen, um alle Inhalte neu zu indexieren:
curl -X POST http://localhost:8000/bulk-import \ -H "Content-Type: application/json" \ -H "X-Webhook-Secret: $WEBHOOK_SECRET" \ -d '{"path": "Documents/THB/Studium"}'
Webhook-Ausfall / fehlende In-Flight-Jobs nach Crash
Der Service hat keinen persistenten Job-Store; In-Flight-BackgroundTasks gehen bei Crash verloren. Recovery erfolgt über den Bulk-Import-Endpoint auf den betroffenen Pfad (siehe oben).
Ein einzelnes File neu indexieren
Webhook mit event_type: "updated" an /webhook POSTen — alte Chunks werden via delete_by_filter(file_path) entfernt, dann frisch indexiert.