Cortex-0 / src /memory /rag.py
Frankenstein-Labs's picture
feat(memory): Phase 11 — RAG + apprentissage permanent
850e745 verified
Raw History Blame Contribute Delete
9.37 kB
"""Phase 11 — mémoire / « apprendre des cours », écrit à la main.
Deux modes, implémentés **séparément** :
**Mode A — RAG (réaliste).** Les cours (texte ; et pour les vidéos/audio, leurs
transcriptions + légendes de frames clés) sont découpés en passages, encodés en
vecteurs et indexés dans une base vectorielle maison (numPy, similarité cosinus).
À la question, on récupère les passages pertinents et on répond à partir d'eux.
**Mode B — apprentissage permanent (EXPÉRIMENTAL ET INSTABLE).** Mise à jour des
poids pendant l'« ingestion » d'un cours (quelques pas de gradient), avec un tampon
de rejeu pour limiter l'oubli catastrophique. Voir `src/memory/permanent.py` et
docs/architecture.md §10. On ne promet rien d'irréaliste : cela mémorise un texte
court, ne remplace pas un vrai fine-tuning, et peut dégrader les autres capacités.
Aucun modèle pré-entraîné, aucun service externe : embeddings par *feature hashing*
déterministe (pur Python), ou embeddings du décodeur maison si l'on veut.
"""
from __future__ import annotations
import hashlib
import re
from dataclasses import dataclass, field
import numpy as np
_WORD_RE = re.compile(r"[\wàâäéèêëîïôöùûüç'-]+", re.UNICODE)
_SENT_RE = re.compile(r"(?<=[.!?])\s+|\n+")
# ---------------------------------------------------------------------------
# Embeddings maison (feature hashing) — déterministe, sans entraînement
# ---------------------------------------------------------------------------
def _hash(word: str, dim: int) -> tuple[int, float]:
"""Hash stable d'un mot vers (index, signe) pour une projection aléatoire."""
h = int.from_bytes(hashlib.blake2b(word.encode("utf-8"), digest_size=8).digest(), "big")
return h % dim, 1.0 if (h >> 63) & 1 else -1.0
def hashed_embed(text: str, dim: int = 256, idf: dict[str, float] | None = None) -> np.ndarray:
"""Vecteur L2-normalisé d'un texte par feature hashing (sac de mots signé).
Aucun paramètre appris : deux textes proches lexicalement le sont aussi
vectoriellement. Suffisant et honnête pour un RAG jouet à petite échelle.
"""
vec = np.zeros(dim, dtype=np.float32)
for m in _WORD_RE.finditer(text.lower()):
w = m.group(0)
idx, sign = _hash(w, dim)
weight = idf.get(w, 1.0) if idf else 1.0
vec[idx] += sign * weight
norm = np.linalg.norm(vec)
return vec / norm if norm > 0 else vec
def compute_idf(texts: list[str]) -> dict[str, float]:
n = max(1, len(texts))
df: dict[str, int] = {}
for t in texts:
for w in {m.group(0) for m in _WORD_RE.finditer(t.lower())}:
df[w] = df.get(w, 0) + 1
return {w: float(np.log((1 + n) / (1 + c)) + 1.0) for w, c in df.items()}
# ---------------------------------------------------------------------------
# Base vectorielle maison (numpy)
# ---------------------------------------------------------------------------
class VectorIndex:
"""Index vectoriel par similarité cosinus (numPy), écrit à la main."""
def __init__(self, dim: int = 256):
self.dim = dim
self._vectors: np.ndarray | None = None
def add(self, vectors: np.ndarray) -> None:
v = np.asarray(vectors, dtype=np.float32)
if v.ndim == 1:
v = v[None, :]
self._vectors = v if self._vectors is None else np.vstack([self._vectors, v])
def search(self, query: np.ndarray, k: int = 3) -> list[tuple[int, float]]:
"""Retourne ``(index, score)`` des ``k`` passages les plus proches."""
if self._vectors is None or len(self._vectors) == 0:
return []
q = np.asarray(query, dtype=np.float32)
q = q / (np.linalg.norm(q) + 1e-9)
mat = self._vectors / (np.linalg.norm(self._vectors, axis=1, keepdims=True) + 1e-9)
scores = mat @ q
k = min(k, len(scores))
top = np.argsort(-scores)[:k]
return [(int(i), float(scores[i])) for i in top]
def __len__(self) -> int:
return 0 if self._vectors is None else len(self._vectors)
# ---------------------------------------------------------------------------
# Découpage en passages
# ---------------------------------------------------------------------------
@dataclass
class Passage:
text: str
source: str = "manual"
start: float = 0.0 # position (s) dans la vidéo/audio source, si applicable
def chunk_text(text: str, max_words: int = 40, source: str = "manual") -> list[Passage]:
"""Découpe un texte en passages (phrases regroupées jusqu'à ``max_words``)."""
sentences = [s.strip() for s in _SENT_RE.split(text) if s.strip()]
passages: list[Passage] = []
buf: list[str] = []
count = 0
for s in sentences:
n = len(_WORD_RE.findall(s))
if buf and count + n > max_words:
passages.append(Passage(" ".join(buf), source=source))
buf, count = [], 0
buf.append(s)
count += n
if buf:
passages.append(Passage(" ".join(buf), source=source))
return passages
# ---------------------------------------------------------------------------
# Mémoire de cours (mode A — RAG)
# ---------------------------------------------------------------------------
@dataclass
class LessonMemory:
"""Base RAG maison : ingestion de cours -> passages -> réponses extractives."""
dim: int = 256
max_words: int = 40
passages: list[Passage] = field(default_factory=list)
index: VectorIndex = field(default_factory=VectorIndex)
_idf: dict[str, float] = field(default_factory=dict)
def __post_init__(self) -> None:
if self.index.dim != self.dim:
self.index = VectorIndex(self.dim)
# -- ingestion ---------------------------------------------------------
def add_text(self, text: str, source: str = "manual") -> None:
for p in chunk_text(text, max_words=self.max_words, source=source):
self.passages.append(p)
self._reindex()
def add_transcript(self, segments: list[tuple[float, str]], source: str = "video") -> None:
"""Ajoute une transcription horodatée (vidéo/audio) : ``[(t, texte), ...]``."""
for t, seg in segments:
for p in chunk_text(seg, max_words=self.max_words, source=source):
p.start = float(t)
self.passages.append(p)
self._reindex()
def add_video(self, transcript: list[tuple[float, str]],
frame_captions: list[tuple[float, str]] | None = None,
source: str = "video") -> None:
"""Un « cours vidéo » = transcription + légendes de frames clés."""
self.add_transcript(transcript, source=source)
if frame_captions:
self.add_transcript(frame_captions, source=f"{source}:frames")
def _reindex(self) -> None:
texts = [p.text for p in self.passages]
self._idf = compute_idf(texts)
self.index = VectorIndex(self.dim)
if texts:
self.index.add(np.stack([hashed_embed(t, self.dim, self._idf) for t in texts]))
# -- requête -----------------------------------------------------------
def retrieve(self, query: str, k: int = 3) -> list[tuple[Passage, float]]:
qv = hashed_embed(query, self.dim, self._idf)
return [(self.passages[i], s) for i, s in self.index.search(qv, k)]
def answer(self, query: str, k: int = 3) -> dict:
"""Réponse **extractive** : renvoie le meilleur passage + les sources.
Mode A réaliste : pas d'hallucination, la réponse est un passage réel du cours.
La confiance est le score cosinus du meilleur passage.
"""
hits = self.retrieve(query, k=k)
if not hits:
return {"answer": "", "sources": [], "confidence": 0.0, "retrieved": []}
best, score = hits[0]
return {
"answer": best.text,
"sources": sorted({p.source for p, _ in hits}),
"confidence": round(score, 4),
"retrieved": [{"text": p.text, "source": p.source, "start": p.start,
"score": round(s, 4)} for p, s in hits],
}
def answer_with_model(self, query: str, model, tokenizer, k: int = 3,
max_new_tokens: int = 60) -> dict:
"""Réponse **générative** : le contexte récupéré est mis dans le prompt.
Le modèle maison génère la réponse à partir des passages retrouvés. On
renvoie aussi les passages (traçabilité) : si la génération est faible, la
réponse extractive reste disponible via :meth:`answer`.
"""
from src.generate import generate_text
retrieved = self.retrieve(query, k=k)
context = " ".join(p.text for p, _ in retrieved)
prompt = f"Contexte : {context}\nQuestion : {query}\nRéponse :"
text = generate_text(model, tokenizer, prompt, max_new_tokens=max_new_tokens,
greedy=True)
return {
"answer": text.split("Réponse :")[-1].strip(),
"sources": sorted({p.source for p, _ in retrieved}),
"confidence": round(retrieved[0][1], 4) if retrieved else 0.0,
"retrieved": [{"text": p.text, "source": p.source, "score": round(s, 4)}
for p, s in retrieved],
}