From b0e8fade037b65414bea78580734a73590b8118c Mon Sep 17 00:00:00 2001 From: "IETM_FIXE\\ietm6" Date: Wed, 10 Jun 2026 14:41:25 +0200 Subject: [PATCH] Optimisation du RAG des ateliers : seuil de pertinence, recherche hybride, cache et overlap - Seuil rag_min_score (defaut 0.30) : plus d'extraits hors-sujet injectes dans le prompt - Recherche hybride : cosinus + bonus lexical (noms propres JdR mieux retrouves) - Cache memoire du vector store (invalidation mtime) : plus de re-parse JSON par question - Overlap de 80 tokens entre extraits RAG consecutifs (phrases a cheval retrouvables) - Script de non-regression brain/scripts/sanity_rag_check.py Co-Authored-By: Claude Opus 4.8 --- brain/app/application/chunking.py | 66 +++++++++++++++---- brain/app/application/notebook_rag.py | 21 +++++- brain/app/core/config.py | 7 ++ brain/app/infrastructure/vector_store.py | 76 +++++++++++++++++++-- brain/scripts/sanity_rag_check.py | 84 ++++++++++++++++++++++++ 5 files changed, 236 insertions(+), 18 deletions(-) create mode 100644 brain/scripts/sanity_rag_check.py diff --git a/brain/app/application/chunking.py b/brain/app/application/chunking.py index 186fae8..45f9d3e 100644 --- a/brain/app/application/chunking.py +++ b/brain/app/application/chunking.py @@ -13,8 +13,19 @@ from __future__ import annotations CHUNK_TARGET_TOKENS = 6000 -def chunk_text(full_text: str, target_tokens: int = CHUNK_TARGET_TOKENS) -> list[str]: - """Découpe `full_text` en morceaux ~`target_tokens` tokens (frontières de §).""" +def chunk_text( + full_text: str, + target_tokens: int = CHUNK_TARGET_TOKENS, + overlap_tokens: int = 0, +) -> list[str]: + """Découpe `full_text` en morceaux ~`target_tokens` tokens (frontières de §). + + `overlap_tokens` > 0 : chaque morceau reprend la fin du précédent (les derniers + paragraphes, jusqu'à ~`overlap_tokens` tokens). Utile pour le RAG : une phrase-clé + à cheval sur deux morceaux reste retrouvable dans au moins l'un des deux. À + laisser à 0 pour les imports (recopie) : un overlap y DUPLIQUERAIT du texte. + Un morceau peut légèrement dépasser la cible (jusqu'à target + overlap). + """ if not full_text.strip(): return [] @@ -26,32 +37,65 @@ def chunk_text(full_text: str, target_tokens: int = CHUNK_TARGET_TOKENS) -> list chunks: list[str] = [] current: list[str] = [] current_tokens = 0 + fresh = False # `current` contient-il du contenu pas encore émis ? (évite de + # ré-émettre un morceau composé uniquement de l'overlap en fin de texte) for para in paragraphs: para_tokens = len(enc.encode(para)) # Un paragraphe seul plus gros que la cible : on le coupe en sous-blocs. if para_tokens > target_tokens: - if current: + if current and fresh: chunks.append("\n\n".join(current)) - current, current_tokens = [], 0 - chunks.extend(_split_oversized(para, enc, target_tokens)) + current, current_tokens, fresh = [], 0, False + chunks.extend(_split_oversized(para, enc, target_tokens, overlap_tokens)) continue if current_tokens + para_tokens > target_tokens and current: - chunks.append("\n\n".join(current)) - current, current_tokens = [], 0 + if fresh: + chunks.append("\n\n".join(current)) + current, current_tokens = _overlap_tail(current, enc, overlap_tokens) + fresh = False current.append(para) current_tokens += para_tokens + fresh = True - if current: + if current and fresh: chunks.append("\n\n".join(current)) return chunks -def _split_oversized(paragraph: str, enc, target_tokens: int) -> list[str]: - """Coupe un paragraphe géant en sous-blocs ~`target_tokens` tokens.""" +def _overlap_tail(parts: list[str], enc, overlap_tokens: int) -> tuple[list[str], int]: + """Derniers paragraphes de `parts` totalisant au plus `overlap_tokens` tokens — + le « rappel » recopié en tête du morceau suivant.""" + if overlap_tokens <= 0 or not parts: + return [], 0 + tail: list[str] = [] + total = 0 + for para in reversed(parts): + para_tokens = len(enc.encode(para)) + if total + para_tokens > overlap_tokens: + break + tail.insert(0, para) + total += para_tokens + if not tail: + # Aucun paragraphe entier ne tient dans le budget (paragraphes longs) : + # on reprend la FIN du dernier paragraphe pour garantir le recouvrement. + tokens = enc.encode(parts[-1]) + tail = [enc.decode(tokens[-overlap_tokens:])] + total = min(overlap_tokens, len(tokens)) + return tail, total + + +def _split_oversized(paragraph: str, enc, target_tokens: int, overlap_tokens: int = 0) -> list[str]: + """Coupe un paragraphe géant en sous-blocs ~`target_tokens` tokens (fenêtre + glissante avec recouvrement si `overlap_tokens` > 0).""" tokens = enc.encode(paragraph) + step = max(1, target_tokens - overlap_tokens) out: list[str] = [] - for i in range(0, len(tokens), target_tokens): + i = 0 + while i < len(tokens): out.append(enc.decode(tokens[i : i + target_tokens])) + if i + target_tokens >= len(tokens): + break + i += step return out diff --git a/brain/app/application/notebook_rag.py b/brain/app/application/notebook_rag.py index a8db28f..0bfa721 100644 --- a/brain/app/application/notebook_rag.py +++ b/brain/app/application/notebook_rag.py @@ -20,6 +20,9 @@ from app.infrastructure import vector_store logger = logging.getLogger(__name__) _RAG_CHUNK_TOKENS = 600 +# Recouvrement entre extraits consécutifs (~13% de la cible) : une phrase-clé à +# cheval sur deux extraits reste retrouvable dans au moins l'un des deux. +_RAG_OVERLAP_TOKENS = 80 # Un extrait avec quasi aucun texte réel (en-tête/pied de page, fragment de numéro # de page isolé « 249 250 ») ne sert à rien en RAG → on l'écarte. Seuil bas et # conservateur : on ne coupe QUE les fragments quasi-vides, jamais une vraie phrase. @@ -36,10 +39,14 @@ class NotebookRagUseCase: extractor: PdfTextExtractor, embedder: EmbeddingProvider, chunk_target_tokens: int = _RAG_CHUNK_TOKENS, + min_score: float = 0.0, ) -> None: self._extractor = extractor self._embedder = embedder self._chunk_target_tokens = chunk_target_tokens + # Cosinus minimal pour qu'un extrait soit injecté dans le prompt : sous ce + # seuil, l'extrait n'a aucun rapport avec la question → bruit. 0 = désactivé. + self._min_score = min_score async def index_source(self, source_id: str, pdf_bytes: bytes) -> dict: """Extrait, découpe PAR PAGE (pour garder le n° de page → citations), embed @@ -48,7 +55,9 @@ class NotebookRagUseCase: chunks: list[str] = [] pages: list[int] = [] for page in doc.pages: - for piece in chunk_text(page.text, self._chunk_target_tokens): + for piece in chunk_text( + page.text, self._chunk_target_tokens, overlap_tokens=_RAG_OVERLAP_TOKENS + ): if not _has_enough_text(piece): continue # fragment quasi-vide (en-tête/pied/numéro) → ignoré chunks.append(piece) @@ -69,11 +78,17 @@ class NotebookRagUseCase: } async def retrieve(self, source_ids: list[str], query: str, top_k: int = 6) -> list[dict]: - """Passages les plus pertinents (toutes sources) pour `query`.""" + """Passages les plus pertinents (toutes sources) pour `query`. + + Recherche hybride (cosinus + bonus lexical sur les mots de la question) ; + peut renvoyer moins de `top_k` passages si le seuil de pertinence écarte + les extraits hors-sujet.""" ids = [s for s in source_ids if vector_store.exists(s)] if not ids or not query.strip(): return [] query_vectors = await self._embedder.embed([query]) if not query_vectors: return [] - return vector_store.search(ids, query_vectors[0], top_k) + return vector_store.search( + ids, query_vectors[0], top_k, query_text=query, min_score=self._min_score + ) diff --git a/brain/app/core/config.py b/brain/app/core/config.py index 3696416..007d6f6 100644 --- a/brain/app/core/config.py +++ b/brain/app/core/config.py @@ -83,6 +83,13 @@ class Settings(BaseSettings): # mais prompt plus long. 8 par défaut (montable jusqu'à ~20 sur grand contexte). rag_top_k: int = 8 + # Cosinus minimal pour qu'un extrait soit injecté dans le prompt du chat + # atelier : en dessous, l'extrait n'a aucun rapport avec la question → mieux + # vaut moins d'extraits que du bruit. Défaut conservateur (0.30) : les paires + # pertinentes scorent typiquement 0.6+ avec nomic-embed-text/mistral-embed, + # les hors-sujet 0.2-0.4. Montable à ~0.4 si trop de bruit, 0 = désactivé. + rag_min_score: float = 0.30 + # Taille cible d'un morceau (en tokens) pour l'import de PDF (regles/campagne). # Plus c'est gros, moins il y a de morceaux => moins de fragmentation et un # import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele. diff --git a/brain/app/infrastructure/vector_store.py b/brain/app/infrastructure/vector_store.py index 383e92a..380b714 100644 --- a/brain/app/infrastructure/vector_store.py +++ b/brain/app/infrastructure/vector_store.py @@ -5,6 +5,12 @@ Chaque SOURCE est persistée en un fichier JSON sur le volume `data/` du Brain : À l'échelle d'un livre (quelques centaines d'extraits), une recherche cosinus en Python pur est instantanée — inutile d'ajouter numpy/pgvector/une base vectorielle. +Les fichiers sont mis en cache mémoire (invalidation par mtime) : le coûteux est +le re-parse JSON des vecteurs, pas le cosinus. + +Recherche HYBRIDE : score = cosinus + bonus lexical (mots significatifs de la +question présents dans l'extrait). Sur du JdR, les requêtes sont souvent des noms +propres exacts (« Strahd », « Barovia ») où le lexical bat l'embedding. """ from __future__ import annotations @@ -16,6 +22,27 @@ from pathlib import Path _STORE_DIR = Path("data/notebooks") _SAFE_ID = re.compile(r"[^A-Za-z0-9_-]") +# Cache mémoire {source_id: (mtime_ns, chunks)} — évite de relire/re-parser le JSON +# (vecteurs = gros) à chaque question. Invalidé si le fichier change (mtime). +_CACHE: dict[str, tuple[int, list[dict]]] = {} +_CACHE_MAX_SOURCES = 32 # garde-fou mémoire : ~10 Mo par gros livre en cache + +# Poids du bonus lexical dans le score hybride. Le cosinus reste dominant ; le +# bonus (0..0.15) sert surtout à départager / repêcher les correspondances exactes. +_LEX_WEIGHT = 0.15 +_WORD_RE = re.compile(r"[a-z0-9àâäçéèêëîïôöùûüœæ]{3,}") +# Mots-outils FR/EN fréquents (≥3 lettres) : sans eux, le bonus lexical serait +# dominé par « les », « pour », « the »… au lieu des termes porteurs de sens. +_STOPWORDS = frozenset({ + "les", "des", "une", "est", "son", "ses", "aux", "par", "pour", "dans", + "sur", "avec", "qui", "que", "quoi", "dont", "mais", "comme", "plus", + "pas", "tout", "tous", "toute", "toutes", "ils", "elles", "leur", "leurs", + "nous", "vous", "cette", "ces", "cet", "ont", "sont", "fait", "etre", + "être", "avoir", "peut", "quel", "quelle", "quels", "quelles", "ainsi", + "the", "and", "for", "with", "this", "that", "are", "was", "has", "have", + "not", "you", "his", "her", "its", "they", "them", "from", "what", "which", +}) + def _path(source_id: str) -> Path: safe = _SAFE_ID.sub("_", str(source_id)) @@ -42,6 +69,7 @@ def save( items.append(item) payload = {"dim": len(vectors[0]) if vectors else 0, "chunks": items} _path(source_id).write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + _CACHE.pop(source_id, None) # le mtime suffirait, mais soyons explicites return len(chunks) @@ -50,18 +78,29 @@ def exists(source_id: str) -> bool: def delete(source_id: str) -> None: + _CACHE.pop(source_id, None) _path(source_id).unlink(missing_ok=True) def _load(source_id: str) -> list[dict]: p = _path(source_id) - if not p.exists(): + try: + mtime = p.stat().st_mtime_ns + except OSError: + _CACHE.pop(source_id, None) return [] + cached = _CACHE.get(source_id) + if cached is not None and cached[0] == mtime: + return cached[1] try: data = json.loads(p.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): return [] - return data.get("chunks", []) if isinstance(data, dict) else [] + chunks = data.get("chunks", []) if isinstance(data, dict) else [] + if len(_CACHE) >= _CACHE_MAX_SOURCES: + _CACHE.pop(next(iter(_CACHE))) # éviction FIFO simple + _CACHE[source_id] = (mtime, chunks) + return chunks def all_chunks(source_id: str) -> list[dict]: @@ -85,20 +124,49 @@ def _cosine(a: list[float], b: list[float]) -> float: return dot / (math.sqrt(na) * math.sqrt(nb)) +def _significant_words(text: str) -> frozenset[str]: + """Mots porteurs de sens d'un texte (minuscules, ≥3 lettres, hors mots-outils).""" + return frozenset(w for w in _WORD_RE.findall(text.lower()) if w not in _STOPWORDS) + + +def _chunk_words(chunk: dict) -> frozenset[str]: + """Mots significatifs d'un extrait, mémoïsés sur le dict caché (calculés à la + 1ère recherche, réutilisés tant que la source reste en cache).""" + words = chunk.get("_words") + if words is None: + words = _significant_words(chunk.get("text", "")) + chunk["_words"] = words + return words + + def search( source_ids: list[str], query_vector: list[float], top_k: int = 6, + query_text: str = "", + min_score: float = 0.0, ) -> list[dict]: """Renvoie les `top_k` extraits les plus proches, toutes sources confondues. - Chaque résultat : {"text": str, "score": float, "source_id": str}. + Score HYBRIDE : cosinus + `_LEX_WEIGHT` × (part des mots significatifs de + `query_text` présents dans l'extrait). Les extraits dont le cosinus est sous + `min_score` sont écartés (peut donc renvoyer MOINS de `top_k` résultats — + mieux vaut aucun extrait que du bruit injecté dans le prompt). + + Chaque résultat : {"text": str, "score": float, "source_id": str, "page": int|None}. """ + query_words = _significant_words(query_text) if query_text else frozenset() scored: list[dict] = [] for sid in source_ids: for chunk in _load(sid): vector = chunk.get("vector") or [] - score = _cosine(query_vector, vector) + cos = _cosine(query_vector, vector) + if cos < min_score: + continue + score = cos + if query_words: + overlap = len(query_words & _chunk_words(chunk)) / len(query_words) + score += _LEX_WEIGHT * overlap scored.append({ "text": chunk.get("text", ""), "score": score, diff --git a/brain/scripts/sanity_rag_check.py b/brain/scripts/sanity_rag_check.py new file mode 100644 index 0000000..633c9fe --- /dev/null +++ b/brain/scripts/sanity_rag_check.py @@ -0,0 +1,84 @@ +# -*- coding: utf-8 -*- +"""Sanity check temporaire : overlap du chunking + recherche hybride du vector store.""" +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +from app.application.chunking import chunk_text + +# --- 1. Chunking avec overlap --- +paras = [f"Paragraphe {i} : " + ("lorem ipsum dolor sit amet " * 8) for i in range(12)] +text = "\n\n".join(paras) + +no_overlap = chunk_text(text, target_tokens=200) +with_overlap = chunk_text(text, target_tokens=200, overlap_tokens=40) + +assert len(with_overlap) >= len(no_overlap), "l'overlap ne doit pas réduire le nb de chunks" +# Chaque chunk (sauf le 1er) doit commencer par la fin du précédent +overlapped = 0 +for prev, cur in zip(with_overlap, with_overlap[1:]): + first_para = cur.split("\n\n")[0] + if first_para in prev: + overlapped += 1 +assert overlapped >= len(with_overlap) - 2, f"overlap absent: {overlapped}/{len(with_overlap)-1}" +# Pas de chunk composé uniquement de l'overlap (dernier chunk dupliqué) +assert with_overlap[-1] != with_overlap[-2], "dernier chunk = pur overlap (dupliqué)" +# overlap_tokens=0 → comportement identique à l'ancien +assert no_overlap == chunk_text(text, target_tokens=200, overlap_tokens=0) +print(f"[OK] chunking : {len(no_overlap)} chunks sans overlap, {len(with_overlap)} avec ({overlapped} recouvrements)") + +# --- Paragraphe géant --- +giant = "mot " * 2000 +sub = chunk_text(giant, target_tokens=300, overlap_tokens=50) +assert len(sub) > 1 +print(f"[OK] paragraphe géant coupé en {len(sub)} sous-blocs") + +# --- 2. Vector store : hybride + seuil + cache --- +import tempfile, os +from app.infrastructure import vector_store + +with tempfile.TemporaryDirectory() as tmp: + vector_store._STORE_DIR = Path(tmp) + chunks = [ + "Strahd von Zarovich règne sur la sombre vallée de Barovia depuis son château.", + "Les règles de combat utilisent un d20 plus le modificateur de caractéristique.", + "La taverne du village sert un ragoût de navets aux voyageurs fatigués.", + ] + # Vecteurs factices : chunk 0 et 1 proches de la query, chunk 2 orthogonal + vectors = [[1.0, 0.1, 0.0], [0.9, 0.4, 0.1], [0.0, 0.0, 1.0]] + vector_store.save("src1", chunks, vectors, pages=[10, 20, 30]) + + q = [1.0, 0.2, 0.0] + # Sans seuil ni texte : 3 résultats, ordre cosinus + r = vector_store.search(["src1"], q, top_k=10) + assert len(r) == 3 and r[0]["page"] == 10 + + # Avec seuil : le chunk orthogonal (cos~0) est écarté + r = vector_store.search(["src1"], q, top_k=10, min_score=0.30) + assert len(r) == 2, f"seuil non appliqué: {len(r)}" + print(f"[OK] seuil : 2/3 extraits gardés (orthogonal écarté)") + + # Bonus lexical : la query mentionne « Strahd Barovia » → chunk 0 doit dominer + r = vector_store.search(["src1"], q, top_k=10, query_text="Parle-moi de Strahd et de Barovia", min_score=0.30) + assert r[0]["text"].startswith("Strahd"), r[0]["text"] + assert r[0]["score"] > vector_store._cosine(q, vectors[0]), "bonus lexical absent" + print(f"[OK] hybride : bonus lexical appliqué (score={r[0]['score']:.3f})") + + # Le set "_words" mémoïsé ne doit PAS fuiter dans les résultats + assert all("_words" not in res for res in r) + + # Cache : 2e recherche sert depuis la mémoire (même objet liste) + c1 = vector_store._load("src1") + c2 = vector_store._load("src1") + assert c1 is c2, "cache mtime inopérant" + # save() invalide le cache + vector_store.save("src1", chunks[:1], vectors[:1], pages=[10]) + c3 = vector_store._load("src1") + assert len(c3) == 1, "cache non invalidé après save" + # delete() purge cache + fichier + vector_store.delete("src1") + assert vector_store._load("src1") == [] + print("[OK] cache mémoire : hit, invalidation save, purge delete") + +print("\nTous les sanity checks passent.")