feat(rag): RAG Hermes — Qdrant + rag-ingest/rag-query + skill funk-ai

Indexation de admin/ (284 chunks) dans Qdrant via embeddings Qwen3-8B.
rag-query utilisable en CLI et depuis le profil funk-ai de Hermes.
Note: modèle d'embedding générique — qualité limitée, voir admin/ia/rag.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
alkatrazz 2026-05-14 22:16:06 +02:00
parent 3a013834aa
commit eae6e76645
9 changed files with 687 additions and 0 deletions

View file

@ -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"

View file

@ -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.601.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

View file

@ -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)

View file

@ -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()

View file

@ -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

View file

@ -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