- 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>
178 lines
6.7 KiB
Python
178 lines
6.7 KiB
Python
"""Stockage vectoriel fichier (RAG des notebooks) — sans dépendance lourde.
|
||
|
||
Chaque SOURCE est persistée en un fichier JSON sur le volume `data/` du Brain :
|
||
data/notebooks/{source_id}.json = {"dim": N, "chunks": [{"text":..., "vector":[...]}]}
|
||
|
||
À 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
|
||
|
||
import json
|
||
import math
|
||
import re
|
||
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))
|
||
return _STORE_DIR / f"{safe}.json"
|
||
|
||
|
||
def save(
|
||
source_id: str,
|
||
chunks: list[str],
|
||
vectors: list[list[float]],
|
||
pages: list[int] | None = None,
|
||
) -> int:
|
||
"""Persiste les (chunk, vecteur[, page]) d'une source. Renvoie le nb d'extraits."""
|
||
if len(chunks) != len(vectors):
|
||
raise ValueError("chunks et vectors de tailles différentes")
|
||
if pages is not None and len(pages) != len(chunks):
|
||
raise ValueError("pages et chunks de tailles différentes")
|
||
_STORE_DIR.mkdir(parents=True, exist_ok=True)
|
||
items = []
|
||
for i, (c, v) in enumerate(zip(chunks, vectors)):
|
||
item = {"text": c, "vector": v}
|
||
if pages is not None:
|
||
item["page"] = pages[i]
|
||
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)
|
||
|
||
|
||
def exists(source_id: str) -> bool:
|
||
return _path(source_id).exists()
|
||
|
||
|
||
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)
|
||
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 []
|
||
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]:
|
||
"""Tous les extraits d'une source (texte + page), sans vecteurs — pour le mode
|
||
« analyse approfondie » (map-reduce sur tout le document)."""
|
||
return [{"text": c.get("text", ""), "page": c.get("page")} for c in _load(source_id)]
|
||
|
||
|
||
def _cosine(a: list[float], b: list[float]) -> float:
|
||
if not a or not b or len(a) != len(b):
|
||
return 0.0
|
||
dot = 0.0
|
||
na = 0.0
|
||
nb = 0.0
|
||
for x, y in zip(a, b):
|
||
dot += x * y
|
||
na += x * x
|
||
nb += y * y
|
||
if na == 0.0 or nb == 0.0:
|
||
return 0.0
|
||
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.
|
||
|
||
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 []
|
||
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,
|
||
"source_id": sid,
|
||
"page": chunk.get("page"),
|
||
})
|
||
scored.sort(key=lambda c: c["score"], reverse=True)
|
||
return scored[:top_k]
|