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:
@@ -13,8 +13,19 @@ from __future__ import annotations
|
|||||||
CHUNK_TARGET_TOKENS = 6000
|
CHUNK_TARGET_TOKENS = 6000
|
||||||
|
|
||||||
|
|
||||||
def chunk_text(full_text: str, target_tokens: int = CHUNK_TARGET_TOKENS) -> list[str]:
|
def chunk_text(
|
||||||
"""Découpe `full_text` en morceaux ~`target_tokens` tokens (frontières de §)."""
|
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():
|
if not full_text.strip():
|
||||||
return []
|
return []
|
||||||
|
|
||||||
@@ -26,32 +37,65 @@ def chunk_text(full_text: str, target_tokens: int = CHUNK_TARGET_TOKENS) -> list
|
|||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
current: list[str] = []
|
current: list[str] = []
|
||||||
current_tokens = 0
|
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:
|
for para in paragraphs:
|
||||||
para_tokens = len(enc.encode(para))
|
para_tokens = len(enc.encode(para))
|
||||||
# Un paragraphe seul plus gros que la cible : on le coupe en sous-blocs.
|
# Un paragraphe seul plus gros que la cible : on le coupe en sous-blocs.
|
||||||
if para_tokens > target_tokens:
|
if para_tokens > target_tokens:
|
||||||
if current:
|
if current and fresh:
|
||||||
chunks.append("\n\n".join(current))
|
chunks.append("\n\n".join(current))
|
||||||
current, current_tokens = [], 0
|
current, current_tokens, fresh = [], 0, False
|
||||||
chunks.extend(_split_oversized(para, enc, target_tokens))
|
chunks.extend(_split_oversized(para, enc, target_tokens, overlap_tokens))
|
||||||
continue
|
continue
|
||||||
if current_tokens + para_tokens > target_tokens and current:
|
if current_tokens + para_tokens > target_tokens and current:
|
||||||
chunks.append("\n\n".join(current))
|
if fresh:
|
||||||
current, current_tokens = [], 0
|
chunks.append("\n\n".join(current))
|
||||||
|
current, current_tokens = _overlap_tail(current, enc, overlap_tokens)
|
||||||
|
fresh = False
|
||||||
current.append(para)
|
current.append(para)
|
||||||
current_tokens += para_tokens
|
current_tokens += para_tokens
|
||||||
|
fresh = True
|
||||||
|
|
||||||
if current:
|
if current and fresh:
|
||||||
chunks.append("\n\n".join(current))
|
chunks.append("\n\n".join(current))
|
||||||
return chunks
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
def _split_oversized(paragraph: str, enc, target_tokens: int) -> list[str]:
|
def _overlap_tail(parts: list[str], enc, overlap_tokens: int) -> tuple[list[str], int]:
|
||||||
"""Coupe un paragraphe géant en sous-blocs ~`target_tokens` tokens."""
|
"""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)
|
tokens = enc.encode(paragraph)
|
||||||
|
step = max(1, target_tokens - overlap_tokens)
|
||||||
out: list[str] = []
|
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]))
|
out.append(enc.decode(tokens[i : i + target_tokens]))
|
||||||
|
if i + target_tokens >= len(tokens):
|
||||||
|
break
|
||||||
|
i += step
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ from app.infrastructure import vector_store
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_RAG_CHUNK_TOKENS = 600
|
_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
|
# 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
|
# 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.
|
# conservateur : on ne coupe QUE les fragments quasi-vides, jamais une vraie phrase.
|
||||||
@@ -36,10 +39,14 @@ class NotebookRagUseCase:
|
|||||||
extractor: PdfTextExtractor,
|
extractor: PdfTextExtractor,
|
||||||
embedder: EmbeddingProvider,
|
embedder: EmbeddingProvider,
|
||||||
chunk_target_tokens: int = _RAG_CHUNK_TOKENS,
|
chunk_target_tokens: int = _RAG_CHUNK_TOKENS,
|
||||||
|
min_score: float = 0.0,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._extractor = extractor
|
self._extractor = extractor
|
||||||
self._embedder = embedder
|
self._embedder = embedder
|
||||||
self._chunk_target_tokens = chunk_target_tokens
|
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:
|
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
|
"""Extrait, découpe PAR PAGE (pour garder le n° de page → citations), embed
|
||||||
@@ -48,7 +55,9 @@ class NotebookRagUseCase:
|
|||||||
chunks: list[str] = []
|
chunks: list[str] = []
|
||||||
pages: list[int] = []
|
pages: list[int] = []
|
||||||
for page in doc.pages:
|
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):
|
if not _has_enough_text(piece):
|
||||||
continue # fragment quasi-vide (en-tête/pied/numéro) → ignoré
|
continue # fragment quasi-vide (en-tête/pied/numéro) → ignoré
|
||||||
chunks.append(piece)
|
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]:
|
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)]
|
ids = [s for s in source_ids if vector_store.exists(s)]
|
||||||
if not ids or not query.strip():
|
if not ids or not query.strip():
|
||||||
return []
|
return []
|
||||||
query_vectors = await self._embedder.embed([query])
|
query_vectors = await self._embedder.embed([query])
|
||||||
if not query_vectors:
|
if not query_vectors:
|
||||||
return []
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -83,6 +83,13 @@ class Settings(BaseSettings):
|
|||||||
# mais prompt plus long. 8 par défaut (montable jusqu'à ~20 sur grand contexte).
|
# mais prompt plus long. 8 par défaut (montable jusqu'à ~20 sur grand contexte).
|
||||||
rag_top_k: int = 8
|
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).
|
# 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
|
# 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.
|
# import plus rapide, MAIS il faut que ca tienne dans la fenetre du modele.
|
||||||
|
|||||||
@@ -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
|
À 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.
|
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
|
from __future__ import annotations
|
||||||
|
|
||||||
@@ -16,6 +22,27 @@ from pathlib import Path
|
|||||||
_STORE_DIR = Path("data/notebooks")
|
_STORE_DIR = Path("data/notebooks")
|
||||||
_SAFE_ID = re.compile(r"[^A-Za-z0-9_-]")
|
_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:
|
def _path(source_id: str) -> Path:
|
||||||
safe = _SAFE_ID.sub("_", str(source_id))
|
safe = _SAFE_ID.sub("_", str(source_id))
|
||||||
@@ -42,6 +69,7 @@ def save(
|
|||||||
items.append(item)
|
items.append(item)
|
||||||
payload = {"dim": len(vectors[0]) if vectors else 0, "chunks": items}
|
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")
|
_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)
|
return len(chunks)
|
||||||
|
|
||||||
|
|
||||||
@@ -50,18 +78,29 @@ def exists(source_id: str) -> bool:
|
|||||||
|
|
||||||
|
|
||||||
def delete(source_id: str) -> None:
|
def delete(source_id: str) -> None:
|
||||||
|
_CACHE.pop(source_id, None)
|
||||||
_path(source_id).unlink(missing_ok=True)
|
_path(source_id).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
def _load(source_id: str) -> list[dict]:
|
def _load(source_id: str) -> list[dict]:
|
||||||
p = _path(source_id)
|
p = _path(source_id)
|
||||||
if not p.exists():
|
try:
|
||||||
|
mtime = p.stat().st_mtime_ns
|
||||||
|
except OSError:
|
||||||
|
_CACHE.pop(source_id, None)
|
||||||
return []
|
return []
|
||||||
|
cached = _CACHE.get(source_id)
|
||||||
|
if cached is not None and cached[0] == mtime:
|
||||||
|
return cached[1]
|
||||||
try:
|
try:
|
||||||
data = json.loads(p.read_text(encoding="utf-8"))
|
data = json.loads(p.read_text(encoding="utf-8"))
|
||||||
except (OSError, json.JSONDecodeError):
|
except (OSError, json.JSONDecodeError):
|
||||||
return []
|
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]:
|
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))
|
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(
|
def search(
|
||||||
source_ids: list[str],
|
source_ids: list[str],
|
||||||
query_vector: list[float],
|
query_vector: list[float],
|
||||||
top_k: int = 6,
|
top_k: int = 6,
|
||||||
|
query_text: str = "",
|
||||||
|
min_score: float = 0.0,
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Renvoie les `top_k` extraits les plus proches, toutes sources confondues.
|
"""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] = []
|
scored: list[dict] = []
|
||||||
for sid in source_ids:
|
for sid in source_ids:
|
||||||
for chunk in _load(sid):
|
for chunk in _load(sid):
|
||||||
vector = chunk.get("vector") or []
|
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({
|
scored.append({
|
||||||
"text": chunk.get("text", ""),
|
"text": chunk.get("text", ""),
|
||||||
"score": score,
|
"score": score,
|
||||||
|
|||||||
84
brain/scripts/sanity_rag_check.py
Normal file
84
brain/scripts/sanity_rag_check.py
Normal 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.")
|
||||||
Reference in New Issue
Block a user