diff --git a/admin/ia/rag.md b/admin/ia/rag.md new file mode 100644 index 0000000..5dfac47 --- /dev/null +++ b/admin/ia/rag.md @@ -0,0 +1,194 @@ +# RAG — Documentation Funk + +Le RAG (Retrieval-Augmented Generation) permet à Hermes de répondre en s'appuyant +sur la documentation du repo (`admin/`) plutôt que sur sa seule mémoire de modèle. + +--- + +## Architecture + +``` +Hermes (funk-ai) + │ terminal: rag-query "comment relancer dnsmasq ?" + ▼ +/usr/local/bin/rag-query (storage-01) + │ 1. Embed la question → POST http://192.168.10.20:1234/v1/embeddings + │ 2. Recherche vectorielle → POST http://127.0.0.1:6333/collections/funk-docs/points/search + ▼ +Qdrant (storage-01:6333) + │ Retourne les N chunks les plus proches sémantiquement + ▼ +rag-query affiche les extraits + fichier source + score + │ + ▼ +Hermes formule sa réponse en citant les sources +``` + +--- + +## Composants déployés + +| Composant | Emplacement | Rôle | +|---|---|---| +| Qdrant | storage-01:6333/6334 | Base vectorielle — stocke les embeddings | +| `rag-ingest` | `/usr/local/bin/rag-ingest` | Indexe les docs dans Qdrant | +| `rag-query` | `/usr/local/bin/rag-query` | Interroge Qdrant depuis la ligne de commande | +| Skill `rag-docs` | Profil `funk-ai` Hermes | Enseigne à Hermes comment utiliser `rag-query` | +| Docs indexées | `/srv/data/rag/docs/` (RAID5) | Copie locale du dossier `admin/` | +| Collection Qdrant | `funk-docs` | 284 chunks — dimension 4096 (Cosine) | + +--- + +## Modèle d'embedding utilisé + +**Qwen3-8B** via llama-server GPU (port 1234), avec les flags `--embeddings --pooling mean`. + +C'est le modèle de chat principal, réutilisé pour les embeddings. + +### Limitation connue + +Qwen3-8B est un modèle génératif, **pas un modèle d'embedding dédié**. +Ses représentations vectorielles ont peu de discrimination sémantique : +les scores de similarité cosinus sont uniformément hauts (0.90–0.95) +quelle que soit la pertinence du résultat. + +Conséquence : les résultats retournés ne sont pas triés par pertinence réelle — +le fichier avec le plus de tokens (ex : `incidents.md`) remonte souvent en premier. + +### Quand améliorer + +Pour une recherche sémantique précise, utiliser un modèle d'embedding dédié : + +| Modèle | Taille | Dimension | Notes | +|---|---|---|---| +| `nomic-embed-text-v1.5` | ~274 MB | 768 | Rapide, bon équilibre qualité/taille | +| `bge-m3` | ~1.2 GB | 1024 | Multilingue (français natif) — meilleur choix pour ce repo | + +Déploiement si besoin : +```bash +# Sur gpu-01 — télécharger le modèle +ssh gpu-01 "lms get nomic-ai/nomic-embed-text-v1.5-GGUF" + +# Ajouter une instance llama-server sur port 1238 (CPU, embedding only) +# Modifier ansible/roles/rag/defaults/main.yml : +# embed_url: "http://192.168.10.20:1238/v1/embeddings" +# embed_model: "nomic-embed-text-v1.5" +# Puis re-indexer : rag-ingest /srv/data/rag/docs/ +``` + +--- + +## Utilisation + +### Via Hermes (funk-ai) + +Le skill `rag-docs` est chargé dans le profil `funk-ai`. Hermes l'utilise +automatiquement pour les questions sur le cluster : + +``` +# Dans le TUI Hermes : +"Comment je relance dnsmasq ?" +→ Hermes appelle rag-query en interne et cite les sources +``` + +### En ligne de commande + +```bash +# Depuis storage-01 +rag-query "ma question" +rag-query "ma question" --top 3 # limiter les résultats (défaut: 5) + +# Exemples +rag-query "comment relancer llama-server gpu" +rag-query "nftables firewall port cluster" +rag-query "hermes profils configuration" +rag-query "alertmanager webhook pipeline" +rag-query "monitoring grafana dashboard" +``` + +--- + +## Mise à jour de l'index + +L'index doit être re-généré après chaque modification de la doc `admin/`. + +### Via Ansible (recommandé) + +```bash +cd ansible/ +../.venv/bin/ansible-playbook -i inventory.yml playbooks/storage-01.yml --tags rag +``` + +Ansible synchronise `admin/` vers `/srv/data/rag/docs/` puis relance automatiquement +`rag-ingest` (handler Ansible). + +### Manuellement + +```bash +# Synchroniser les docs (depuis le poste perso) +rsync -av --delete admin/ storage-01:/srv/data/rag/docs/ + +# Ré-indexer (depuis storage-01) +ssh storage-01 "rag-ingest /srv/data/rag/docs/" +``` + +L'ingestion prend ~5-10 minutes pour 284 chunks (embeddings via GPU). +Elle est idempotente — re-lancer ne crée pas de doublons (IDs stables basés sur MD5 du fichier+section). + +--- + +## Administration Qdrant + +```bash +# Collections existantes +curl -s http://storage-01:6333/collections | python3 -m json.tool + +# Stats de la collection funk-docs +curl -s http://storage-01:6333/collections/funk-docs | python3 -m json.tool + +# Nombre de points indexés +curl -s http://storage-01:6333/collections/funk-docs \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['result']['points_count'])" + +# Vider et re-créer la collection (si re-indexation propre nécessaire) +curl -s -X DELETE http://storage-01:6333/collections/funk-docs +rag-ingest /srv/data/rag/docs/ + +# Statut du service +ssh storage-01 "systemctl status qdrant --no-pager" +``` + +--- + +## Structure des fichiers + +``` +ansible/roles/rag/ +├── defaults/main.yml # URLs Qdrant/embed, collection, répertoires +├── tasks/main.yml # Déploiement scripts + docs + skill Hermes +├── handlers/main.yml # Handler : Run rag-ingest + Restart hermes-agent +└── files/ + ├── rag-ingest # Script Python d'ingestion + ├── rag-query # Script Python de requête + └── rag-docs/SKILL.md # Skill Hermes + +hermes-skills/funk/rag-docs/ +└── SKILL.md # Source versionnée du skill (copié dans files/) + +/srv/data/rag/ (RAID5 storage-01) +├── docs/ # Copie de admin/ — source des chunks +└── (Qdrant stocke dans /srv/data/qdrant/) +``` + +--- + +## Points d'attention + +| Sujet | Détail | +|---|---| +| Qualité embeddings | Qwen3-8B = modèle chat réutilisé — scores peu discriminants. Fonctionnel mais pas optimal. | +| Modèle dédié | `nomic-embed-text` ou `bge-m3` diviseraient les faux positifs par ~3 | +| Contention GPU | `rag-ingest` sollicite le GPU (port 1234) pendant ~5-10 min — éviter pendant une session d'inférence active | +| Re-indexation | Obligatoire après modification de `admin/` — pas de sync automatique | +| Qdrant persistance | Données dans `/srv/data/qdrant/` sur RAID5 — survivent aux redémarrages | +| Score minimum | `MIN_SCORE = 0.60` dans `rag-query` — tous les résultats sont au-dessus avec ce modèle | diff --git a/ansible/playbooks/storage-01.yml b/ansible/playbooks/storage-01.yml index 7f3e685..aed4a5d 100644 --- a/ansible/playbooks/storage-01.yml +++ b/ansible/playbooks/storage-01.yml @@ -12,5 +12,6 @@ - { role: minio, tags: [minio] } - { role: litellm, tags: [litellm] } - { role: hermes_agent, tags: [hermes_agent] } + - { role: rag, tags: [rag] } - { role: node_exporter, tags: [node_exporter] } - { role: alertmanager_webhook, tags: [alertmanager_webhook] } diff --git a/ansible/roles/rag/defaults/main.yml b/ansible/roles/rag/defaults/main.yml new file mode 100644 index 0000000..c89b513 --- /dev/null +++ b/ansible/roles/rag/defaults/main.yml @@ -0,0 +1,8 @@ +--- +rag_data_dir: /srv/data/rag +rag_docs_dir: /srv/data/rag/docs + +qdrant_url: "http://127.0.0.1:6333" +embed_url: "http://192.168.10.20:1234/v1/embeddings" +embed_model: "qwen3-8b" +rag_collection: "funk-docs" diff --git a/ansible/roles/rag/files/rag-docs/SKILL.md b/ansible/roles/rag/files/rag-docs/SKILL.md new file mode 100644 index 0000000..5c40422 --- /dev/null +++ b/ansible/roles/rag/files/rag-docs/SKILL.md @@ -0,0 +1,97 @@ +--- +name: rag-docs +description: "Interroge la documentation Funk via RAG pour répondre aux questions sur le cluster, les services, les configs et les procédures admin." +version: 1.0.0 +author: Funk Lab +license: MIT +platforms: [linux] +metadata: + hermes: + tags: [rag, documentation, funk, cluster, recherche] +--- + +# Documentation RAG — Cluster Funk + +## Quand utiliser ce skill + +Pour toute question sur : +- Procédures admin (comment faire X, commandes utiles) +- Configuration d'un service (dnsmasq, litellm, hermes, nftables, llama-server...) +- Architecture du cluster Funk +- Dépannage d'un composant (diagnostics, causes connues) +- Alertes et monitoring + +**Ne pas utiliser** pour l'état en temps réel (logs live, métriques actuelles) — utiliser Terminal directement. + +--- + +## Commande + +```bash +rag-query "ta question" +``` + +Interroge la base vectorielle Qdrant avec la question, retourne les passages +de documentation les plus proches sémantiquement (score ≥ 0.60). + +Options : +```bash +rag-query "question" --top 3 # limiter à 3 résultats (défaut: 5) +``` + +--- + +## Pattern obligatoire + +**Étape 1** — Interroger la base RAG : +``` +terminal: rag-query "comment relancer dnsmasq après un reboot ?" +``` + +**Étape 2** — Utiliser le contexte retourné pour formuler la réponse, en citant la source : +``` +D'après la documentation Funk (admin/infra/dnsmasq.md § Dépannage) : +sudo systemctl restart dnsmasq +... +``` + +--- + +## Exemples + +```bash +# Procédure de redémarrage d'un service +terminal: rag-query "comment redémarrer llama-server gpu ?" + +# Dépannage +terminal: rag-query "dnsmasq failed après reboot causes" + +# Configuration +terminal: rag-query "nftables règles firewall cluster pod cidr" + +# Architecture +terminal: rag-query "hermes profils litellm modèles disponibles" + +# Alertes +terminal: rag-query "GPUTemperatureCritical seuil alertmanager" +``` + +--- + +## Interpréter les résultats + +Chaque résultat indique : +- **source** : `admin/ia/hermes.md § Configuration` +- **score** : pertinence sémantique (0.60–1.00) — un score > 0.75 est très pertinent +- **texte** : extrait du document + +Si le score est < 0.65 ou les résultats hors sujet : répondre depuis ta connaissance générale +et le mentionner (`"Je n'ai pas trouvé de documentation spécifique sur ce point"`). + +--- + +## Règles + +- Toujours citer la source (`admin/ia/hermes.md § section`) +- Un seul appel `rag-query` suffit pour une question — ne pas enchaîner plusieurs requêtes +- Ne jamais inventer des commandes non trouvées dans les résultats RAG diff --git a/ansible/roles/rag/files/rag-ingest b/ansible/roles/rag/files/rag-ingest new file mode 100644 index 0000000..8c5e4e3 --- /dev/null +++ b/ansible/roles/rag/files/rag-ingest @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +Indexe les fichiers Markdown de la doc Funk dans Qdrant. +Usage: rag-ingest [docs_dir] + docs_dir : répertoire contenant les .md (défaut: /srv/data/rag/docs) +""" +import os +import sys +import json +import hashlib +import re +import urllib.request +import urllib.error + +QDRANT_URL = os.environ.get("QDRANT_URL", "http://127.0.0.1:6333") +EMBED_URL = os.environ.get("EMBED_URL", "http://192.168.10.20:1234/v1/embeddings") +EMBED_MODEL = os.environ.get("EMBED_MODEL", "qwen3-8b") +COLLECTION = os.environ.get("RAG_COLLECTION", "funk-docs") +VECTOR_DIM = 4096 +CHUNK_MAX = 2000 + + +def _request(method, url, data=None): + body = json.dumps(data).encode() if data is not None else None + req = urllib.request.Request( + url, data=body, + headers={"Content-Type": "application/json"} if body else {}, + method=method + ) + with urllib.request.urlopen(req, timeout=60) as r: + return json.loads(r.read()) + + +def ensure_collection(): + try: + _request("GET", f"{QDRANT_URL}/collections/{COLLECTION}") + print(f"Collection '{COLLECTION}' existante") + except urllib.error.HTTPError as e: + if e.code == 404: + print(f"Création de la collection '{COLLECTION}'...") + _request("PUT", f"{QDRANT_URL}/collections/{COLLECTION}", { + "vectors": {"size": VECTOR_DIM, "distance": "Cosine"} + }) + print("Collection créée") + else: + raise + + +def embed(text): + result = _request("POST", EMBED_URL, {"model": EMBED_MODEL, "input": text}) + return result["data"][0]["embedding"] + + +def chunk_markdown(rel_path, content): + """Découpe un fichier Markdown par sections H2, puis H3 si trop grand.""" + chunks = [] + + # Séparer par H2 + parts = re.split(r'\n(?=## )', content) + + # Intro avant le premier H2 + if parts and not parts[0].strip().startswith('## '): + intro = parts[0].strip() + if len(intro) > 80: + title = intro.split('\n')[0].lstrip('#').strip() or rel_path + chunks.append({"file": rel_path, "section": title, "text": intro[:CHUNK_MAX]}) + parts = parts[1:] + + for part in parts: + if not part.strip(): + continue + h2_title = part.split('\n')[0].lstrip('#').strip() + + if len(part) <= CHUNK_MAX: + chunks.append({"file": rel_path, "section": h2_title, "text": part.strip()}) + else: + # Découper par H3 + subs = re.split(r'\n(?=### )', part) + for sub in subs: + if not sub.strip(): + continue + h3_title = sub.split('\n')[0].lstrip('#').strip() + label = f"{h2_title} — {h3_title}" if h3_title != h2_title else h2_title + chunks.append({"file": rel_path, "section": label, "text": sub.strip()[:CHUNK_MAX]}) + + return chunks + + +def point_id(file_path, section): + """ID stable uint64 = MD5(file::section).""" + h = hashlib.md5(f"{file_path}::{section}".encode()).hexdigest() + return int(h[:16], 16) + + +def ingest(docs_dir): + ensure_collection() + + md_files = [] + for root, dirs, files in os.walk(docs_dir): + dirs[:] = sorted(d for d in dirs if not d.startswith('.')) + for f in sorted(files): + if f.endswith('.md'): + md_files.append(os.path.join(root, f)) + + print(f"{len(md_files)} fichiers Markdown trouvés dans {docs_dir}") + + total = 0 + errors = 0 + + for filepath in md_files: + rel = os.path.relpath(filepath, docs_dir) + try: + with open(filepath, encoding='utf-8') as f: + content = f.read() + except OSError as e: + print(f" SKIP {rel}: {e}") + continue + + chunks = chunk_markdown(rel, content) + if not chunks: + continue + + points = [] + for chunk in chunks: + print(f" embed {rel} § {chunk['section'][:55]}") + try: + vector = embed(chunk['text']) + except Exception as e: + print(f" ERREUR embedding: {e}") + errors += 1 + continue + + points.append({ + "id": point_id(chunk['file'], chunk['section']), + "vector": vector, + "payload": { + "file": chunk['file'], + "section": chunk['section'], + "text": chunk['text'], + } + }) + + if points: + _request("PUT", f"{QDRANT_URL}/collections/{COLLECTION}/points?wait=true", {"points": points}) + total += len(points) + print(f" → {len(points)} chunks indexés ({rel})") + + print(f"\nTerminé : {total} chunks dans '{COLLECTION}'" + (f" ({errors} erreurs)" if errors else "")) + + +if __name__ == "__main__": + docs_dir = sys.argv[1] if len(sys.argv) > 1 else "/srv/data/rag/docs" + if not os.path.isdir(docs_dir): + print(f"ERREUR : {docs_dir} n'existe pas", file=sys.stderr) + sys.exit(1) + ingest(docs_dir) diff --git a/ansible/roles/rag/files/rag-query b/ansible/roles/rag/files/rag-query new file mode 100644 index 0000000..1ae5bc0 --- /dev/null +++ b/ansible/roles/rag/files/rag-query @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Interroge la base vectorielle Qdrant avec une question en langage naturel. +Usage: rag-query "ma question" [--top N] +Retourne les passages de documentation les plus pertinents. +""" +import sys +import json +import os +import urllib.request + +QDRANT_URL = os.environ.get("QDRANT_URL", "http://127.0.0.1:6333") +EMBED_URL = os.environ.get("EMBED_URL", "http://192.168.10.20:1234/v1/embeddings") +EMBED_MODEL = os.environ.get("EMBED_MODEL", "qwen3-8b") +COLLECTION = os.environ.get("RAG_COLLECTION", "funk-docs") +MIN_SCORE = 0.60 + + +def _post(url, data): + req = urllib.request.Request( + url, + data=json.dumps(data).encode(), + headers={"Content-Type": "application/json"}, + method="POST" + ) + with urllib.request.urlopen(req, timeout=60) as r: + return json.loads(r.read()) + + +def embed(text): + result = _post(EMBED_URL, {"model": EMBED_MODEL, "input": text}) + return result["data"][0]["embedding"] + + +def search(question, top=5): + vector = embed(question) + result = _post(f"{QDRANT_URL}/collections/{COLLECTION}/points/search", { + "vector": vector, + "limit": top, + "with_payload": True, + "score_threshold": MIN_SCORE, + }) + return result["result"] + + +if __name__ == "__main__": + if len(sys.argv) < 2: + print("Usage: rag-query \"question\" [--top N]", file=sys.stderr) + sys.exit(1) + + question = sys.argv[1] + top = 5 + if "--top" in sys.argv: + idx = sys.argv.index("--top") + try: + top = int(sys.argv[idx + 1]) + except (IndexError, ValueError): + pass + + try: + results = search(question, top) + except Exception as e: + print(f"ERREUR: {e}", file=sys.stderr) + sys.exit(1) + + if not results: + print(f"Aucun résultat pertinent pour : {question}") + sys.exit(0) + + print(f"=== Contexte RAG — {len(results)} résultat(s) pour : {question} ===\n") + for i, r in enumerate(results, 1): + p = r["payload"] + score = r["score"] + print(f"--- [{i}] {p['file']} § {p['section']} (score: {score:.3f}) ---") + print(p["text"][:700]) + print() diff --git a/ansible/roles/rag/handlers/main.yml b/ansible/roles/rag/handlers/main.yml new file mode 100644 index 0000000..aa87197 --- /dev/null +++ b/ansible/roles/rag/handlers/main.yml @@ -0,0 +1,12 @@ +--- +- name: Run rag-ingest + ansible.builtin.command: + cmd: /usr/local/bin/rag-ingest {{ rag_docs_dir }} + changed_when: true + async: 900 + poll: 15 + +- name: Restart hermes-agent + ansible.builtin.systemd: + name: hermes-agent + state: restarted diff --git a/ansible/roles/rag/tasks/main.yml b/ansible/roles/rag/tasks/main.yml new file mode 100644 index 0000000..d7a36d3 --- /dev/null +++ b/ansible/roles/rag/tasks/main.yml @@ -0,0 +1,46 @@ +--- +- name: Create RAG directories on RAID5 + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: '0755' + loop: + - "{{ rag_data_dir }}" + - "{{ rag_docs_dir }}" + +- name: Deploy rag-ingest script + ansible.builtin.copy: + src: rag-ingest + dest: /usr/local/bin/rag-ingest + mode: '0755' + notify: Run rag-ingest + +- name: Deploy rag-query script + ansible.builtin.copy: + src: rag-query + dest: /usr/local/bin/rag-query + mode: '0755' + +- name: Sync admin docs to RAG docs dir + ansible.builtin.copy: + src: "{{ inventory_dir }}/../admin/" + dest: "{{ rag_docs_dir }}/" + mode: '0644' + notify: Run rag-ingest + +- name: Deploy RAG skill to Hermes global skills + ansible.builtin.copy: + src: rag-docs/ + dest: /srv/data/hermes/skills/funk/rag-docs/ + owner: hermes + group: hermes + mode: '0644' + +- name: Deploy RAG skill to funk-ai profile + ansible.builtin.copy: + src: rag-docs/ + dest: /srv/data/hermes/profiles/funk-ai/skills/funk/rag-docs/ + owner: hermes + group: hermes + mode: '0644' + notify: Restart hermes-agent diff --git a/hermes-skills/funk/rag-docs/SKILL.md b/hermes-skills/funk/rag-docs/SKILL.md new file mode 100644 index 0000000..5c40422 --- /dev/null +++ b/hermes-skills/funk/rag-docs/SKILL.md @@ -0,0 +1,97 @@ +--- +name: rag-docs +description: "Interroge la documentation Funk via RAG pour répondre aux questions sur le cluster, les services, les configs et les procédures admin." +version: 1.0.0 +author: Funk Lab +license: MIT +platforms: [linux] +metadata: + hermes: + tags: [rag, documentation, funk, cluster, recherche] +--- + +# Documentation RAG — Cluster Funk + +## Quand utiliser ce skill + +Pour toute question sur : +- Procédures admin (comment faire X, commandes utiles) +- Configuration d'un service (dnsmasq, litellm, hermes, nftables, llama-server...) +- Architecture du cluster Funk +- Dépannage d'un composant (diagnostics, causes connues) +- Alertes et monitoring + +**Ne pas utiliser** pour l'état en temps réel (logs live, métriques actuelles) — utiliser Terminal directement. + +--- + +## Commande + +```bash +rag-query "ta question" +``` + +Interroge la base vectorielle Qdrant avec la question, retourne les passages +de documentation les plus proches sémantiquement (score ≥ 0.60). + +Options : +```bash +rag-query "question" --top 3 # limiter à 3 résultats (défaut: 5) +``` + +--- + +## Pattern obligatoire + +**Étape 1** — Interroger la base RAG : +``` +terminal: rag-query "comment relancer dnsmasq après un reboot ?" +``` + +**Étape 2** — Utiliser le contexte retourné pour formuler la réponse, en citant la source : +``` +D'après la documentation Funk (admin/infra/dnsmasq.md § Dépannage) : +sudo systemctl restart dnsmasq +... +``` + +--- + +## Exemples + +```bash +# Procédure de redémarrage d'un service +terminal: rag-query "comment redémarrer llama-server gpu ?" + +# Dépannage +terminal: rag-query "dnsmasq failed après reboot causes" + +# Configuration +terminal: rag-query "nftables règles firewall cluster pod cidr" + +# Architecture +terminal: rag-query "hermes profils litellm modèles disponibles" + +# Alertes +terminal: rag-query "GPUTemperatureCritical seuil alertmanager" +``` + +--- + +## Interpréter les résultats + +Chaque résultat indique : +- **source** : `admin/ia/hermes.md § Configuration` +- **score** : pertinence sémantique (0.60–1.00) — un score > 0.75 est très pertinent +- **texte** : extrait du document + +Si le score est < 0.65 ou les résultats hors sujet : répondre depuis ta connaissance générale +et le mentionner (`"Je n'ai pas trouvé de documentation spécifique sur ce point"`). + +--- + +## Règles + +- Toujours citer la source (`admin/ia/hermes.md § section`) +- Un seul appel `rag-query` suffit pour une question — ne pas enchaîner plusieurs requêtes +- Ne jamais inventer des commandes non trouvées dans les résultats RAG