Files
LoreMind/brain/app/infrastructure/vector_store.py
IETM_FIXE\ietm6 e26d11a99f Analyse approfondie : index de resumes pour ne relire que les lots pertinents
A la premiere analyse d une source, chaque lot est resume (1 appel LLM,
cache disque, purge avec la source) et son resume embedde. Aux questions
suivantes, la question est comparee aux resumes et seuls les lots proches
du meilleur score (marge 0.10, plancher 3 lots) sont relus -> 3-5x moins
d appels sur un gros livre pour les questions ciblees. Selection
volontairement conservatrice ; best-effort (tout echec -> plein scan) ;
desactivable via DEEP_SUMMARY_FILTER=false (exhaustivite maximale).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:51:09 +02:00

219 lines
8.4 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)
_summaries_path(source_id).unlink(missing_ok=True)
# --- Index de résumés (analyse approfondie) ----------------------------------
# Cache disque des résumés PAR LOT d'une source : construit paresseusement à la
# première analyse approfondie, réutilisé ensuite pour ne relire que les lots
# pertinents. Invalidé avec la source (delete) et si batch_tokens change.
def _summaries_path(source_id: str) -> Path:
safe = _SAFE_ID.sub("_", str(source_id))
return _STORE_DIR / f"{safe}.summaries.json"
def save_summaries(source_id: str, batch_tokens: int, entries: list[dict]) -> None:
"""Persiste les résumés de lots ({"summary": str, "vector": [...]})."""
_STORE_DIR.mkdir(parents=True, exist_ok=True)
payload = {"batch_tokens": int(batch_tokens), "entries": entries}
_summaries_path(source_id).write_text(
json.dumps(payload, ensure_ascii=False), encoding="utf-8")
def load_summaries(source_id: str, batch_tokens: int) -> list[dict] | None:
"""Résumés de lots d'une source, ou None si absents / construits avec une
autre taille de lot (le découpage ne correspondrait plus)."""
p = _summaries_path(source_id)
if not p.exists():
return None
try:
data = json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return None
if not isinstance(data, dict) or data.get("batch_tokens") != int(batch_tokens):
return None
entries = data.get("entries")
return entries if isinstance(entries, list) else None
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
# Alias public du cosinus (réutilisé par l'index de résumés de l'analyse
# approfondie — même métrique que la recherche).
cosine_similarity = _cosine
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]