mirror of
https://github.com/Alkatrazz24/Funk-lab.git
synced 2026-07-08 05:34:43 +02:00
* fix(rag): ubatch=2048 pour llama-embed + retry rag-ingest + nettoie defaults rag L'instance llama-embed (:1238) tournait avec l'ubatch llama.cpp par défaut (512). En mode --embeddings + pooling, toute l'entrée doit tenir dans un seul ubatch → tout chunk > 512 tokens échouait en HTTP 500 « input too large to process. increase the physical batch size ». ~46 chunks du RAG (CHUNK_MAX=2000 chars ≈ 700 tokens) n'étaient pas indexés. - llama_server : ajoute llama_embed_ubatch_size (2048 = ctx) + flags --batch-size/--ubatch-size dans le template llama-embed.service. - rag-ingest : retry (5×, backoff) sur erreurs transitoires 5xx/réseau de l'embed. - rag/defaults : aligne embed_url/embed_model sur nomic :1238 (étaient des vars mortes pointant encore qwen3-8b :1234 — trompeuses). Résultat : ré-ingestion 436/436 chunks, 0 erreur. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(cluster): aligne la doc sur l'état réel (relevé 2026-06-21) + snapshot Passe de vérification terrain (SSH s01/g01 + kubectl) pour corriger les écarts doc↔réalité, et ajout d'un document de référence vérifié. - admin/ops/etat-cluster.md (nouveau) : snapshot daté — machines, services+ports par hôte, workloads k8s, namespaces, IngressRoute, bases PG, modèles LiteLLM, RAG/Qdrant, + procédure pour régénérer le relevé. - OS AlmaLinux 9.7 → 9.8 (s01/g01) ; précise k8s v1.33.1 (CLAUDE/README/install/talos). - RAG funk-docs : retire le warning périmé « supprimée 2026-06-17 / à re-ingérer » → reconstruite (436 chunks, nomic-embed-text :1238 dim 768, CPU). Maj rag.md, incidents.md, stt.md. - Namespace sacrifice (hors-repo, hors ArgoCD) + bases PG grafana/sacrifice + services ai via IngressRoute + registry in-cluster (CLAUDE.md, k9s.md). - Services de résilience (llm-heartbeat, llama-watchdog/embed) et instances CPU manuelles :1236/:1237 (souvent éteintes) documentés. - Journal progress/2026-06-21.md : section relevé + RAG. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
187 lines
6.5 KiB
Python
187 lines
6.5 KiB
Python
#!/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 time
|
|
import hashlib
|
|
import re
|
|
import urllib.request
|
|
import urllib.error
|
|
|
|
QDRANT_URL = os.environ.get("QDRANT_URL", "http://127.0.0.1:6333")
|
|
# Embedder DÉDIÉ nomic-embed-text (gpu-01 :1238, dim 768) — pas le modèle de chat :1234.
|
|
# nomic est un vrai modèle d'embedding → similarités bien plus discriminantes que qwen3-8b.
|
|
# Doit rester aligné avec rag-query (même modèle/dim) et l'instance llama-embed (rôle llama_server).
|
|
EMBED_URL = os.environ.get("EMBED_URL", "http://192.168.10.20:1238/v1/embeddings")
|
|
EMBED_MODEL = os.environ.get("EMBED_MODEL", "nomic-embed-text")
|
|
COLLECTION = os.environ.get("RAG_COLLECTION", "funk-docs")
|
|
VECTOR_DIM = int(os.environ.get("RAG_VECTOR_DIM", "768"))
|
|
CHUNK_MAX = 2000
|
|
|
|
# Dossiers (relatifs à docs_dir) exclus de l'index : rapports auto-générés par
|
|
# hermes-auto-improve (méta-commentaires sur la doc, pas de la connaissance) — ils
|
|
# noyaient la vraie doc (~84% des points). Surchargeable via RAG_EXCLUDE (séparé par ":").
|
|
EXCLUDE_DIRS = [
|
|
p for p in os.environ.get("RAG_EXCLUDE", "hermes/builtin").split(":") if p
|
|
]
|
|
|
|
|
|
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):
|
|
# L'instance llama-embed (:1238) renvoie occasionnellement des 500 transitoires sous charge
|
|
# soutenue (slot saturé) → retry avec backoff. Les chunks sont bornés (CHUNK_MAX=2000) donc
|
|
# un 500 n'est pas dû à un dépassement de contexte.
|
|
last = None
|
|
for attempt in range(5):
|
|
try:
|
|
result = _request("POST", EMBED_URL, {"model": EMBED_MODEL, "input": text})
|
|
return result["data"][0]["embedding"]
|
|
except (urllib.error.HTTPError, urllib.error.URLError) as e:
|
|
last = e
|
|
code = getattr(e, "code", None)
|
|
# Ne réessaie que sur erreurs transitoires (5xx) ou réseau
|
|
if code is not None and code < 500:
|
|
raise
|
|
time.sleep(0.5 * (attempt + 1))
|
|
raise last
|
|
|
|
|
|
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()
|
|
|
|
excluded = {os.path.normpath(os.path.join(docs_dir, p)) for p in EXCLUDE_DIRS}
|
|
md_files = []
|
|
for root, dirs, files in os.walk(docs_dir):
|
|
# élague les dossiers cachés et exclus (ex. hermes/builtin)
|
|
dirs[:] = sorted(
|
|
d for d in dirs
|
|
if not d.startswith('.')
|
|
and os.path.normpath(os.path.join(root, d)) not in excluded
|
|
)
|
|
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)
|