Files
rag-ingestor/README.md
Jean-Luc Makiola 9643011e64
All checks were successful
CI / ci (push) Successful in 49s
Release / release (push) Successful in 1m2s
feat: MCP-Server für RAG-Retrieval + Webhook-Härtung
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>
2026-05-18 22:08:37 +02:00

6.5 KiB

rag-ingestor

Microservice der Dateien aus Nextcloud (Documents/THB/Studium/) in Qdrant indexiert. Embeddings via Ollama.

Endpoints

  • POST /webhook (Header X-Webhook-Secret): Nextcloud-Event-Empfang (created / updated / deleted).
  • POST /bulk-import (Header X-Webhook-Secret): Body {"path": "..."} → rekursiver Re-Index. Bulk-Pipeline-Stages laufen mit Concurrency 4 (siehe BULK_CONCURRENCY in app/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; liefert text + Quell-Metadaten + score.
  • get_file_chunks(file_path) — alle Chunks eines Dokuments in chunk_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).

  1. Netz einmalig anlegen: docker network create metamcp-net
  2. In der MetaMCP-Compose networks: [metamcp-net] ergänzen, damit MetaMCP demselben Netz beitritt.
  3. RAG_MCP_TOKEN als Secret in Coolify setzen (Wert aus .env.coolify).
  4. In MetaMCP einen streamable-http-Downstream registrieren: URL http://rag-mcp:9009/mcp, Header Authorization: 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.yml definiert am ingestor eine Traefik-Middleware rag-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-import lehnt mit 409 ab, 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:

  1. Collection in Qdrant manuell droppen:
    curl -X DELETE "$QDRANT_URL/collections/$QDRANT_COLLECTION"
    
  2. Service neu starten — Lifespan legt die Collection mit der neuen Dimension an.
  3. 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.