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>
145 lines
6.5 KiB
Markdown
145 lines
6.5 KiB
Markdown
# 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:
|
|
|
|
```json
|
|
{
|
|
"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:
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
uv sync
|
|
uv run pytest
|
|
uv run uvicorn app.main:app --reload
|
|
```
|
|
|
|
## Deployment
|
|
|
|
Image bauen und in Coolify neben Qdrant + Ollama deployen:
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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:
|
|
```bash
|
|
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:
|
|
```bash
|
|
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-`BackgroundTask`s 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.
|