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 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 14:41:25 +02:00
parent 341f6a5aae
commit b0e8fade03
5 changed files with 236 additions and 18 deletions

View File

@@ -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:
if fresh:
chunks.append("\n\n".join(current))
current, current_tokens = [], 0
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

View File

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

View File

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

View File

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

View File

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