Files
rar-autopass/app/embedding_cache.py
Claude Agent 705ad030fe feat(embeddings): cache persistent de vectori in SQLite + warmup in fundal
Vectorii corpusului k-NN persista in tabela embedding_cache (PK model+text_hash,
blob float32 LE); la warmup se vectorizeaza doar textele lipsa din cache, deci
restartul cu corpus neschimbat nu mai plateste ~1-2 min de embed (embed=0).

- app/embedding_cache.py: serializare array('f'), load/save/purge chunk 500 cu
  BEGIN/COMMIT explicit (conexiuni autocommit), validare dimensiune la scriere
  si citire, orchestrare sync_corpus_vectors cu embed_fn injectat
- index_corpus(vectors=): vectori precalculati cu validare aliniere; mismatch
  -> fallback embed complet
- ensure_embeddings_corpus: warmup in thread la startup (block=True), calea de
  request ne-blocanta (acquire non-blocking pe lock; warmup in curs -> return
  imediat); purjare orfane + modele vechi doar dupa indexare reusita
- log warmup: cache=N embed=M in Xs
- 31 teste noi (cold/warm/incremental, model schimbat, concurenta, ranking
  exact, echivalenta float32); suita completa 1596 passed

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 21:57:49 +00:00

211 lines
8.3 KiB
Python

"""Cache persistent de vectori embeddings in SQLite (tabela `embedding_cache`).
Design:
- Cheie (model, text_hash): schimbarea modelului nu foloseste vectori vechi.
- Vectorii raman `array('f')` (float32) end-to-end -- fara conversie la list[float].
- Scrieri/stergeri in tranzactii scurte, chunk-uite (conexiunile sunt autocommit,
vezi app/db.py -- BEGIN/COMMIT explicit per chunk, altfel fiecare INSERT/DELETE
e propria tranzactie).
- Degradare gratioasa: orice eroare SQLite -> log.warning, fara exceptie propagata
din load/save/purge (caller-ul, ensure_embeddings_corpus, ramane neschimbat).
"""
from __future__ import annotations
import hashlib
import logging
import sqlite3
import sys
from array import array
from typing import Callable, Iterable, Sequence
log = logging.getLogger(__name__)
EMB_DIM = 384 # paraphrase-multilingual-MiniLM-L12-v2 (app/embeddings.py::FASTEMBED_MODEL)
_CHUNK_SIZE = 500 # A2/A3: tranzactii scurte vs worker BEGIN IMMEDIATE pe submissions
def text_hash(text: str) -> str:
"""SHA-256 hex al textului dat (apelantul normalizeaza inainte de hash)."""
return hashlib.sha256(str(text).encode("utf-8")).hexdigest()
def vector_to_blob(vector: Sequence[float]) -> bytes:
"""Serializeaza un vector la float32 little-endian (independent de platforma)."""
arr = array("f", vector)
if sys.byteorder != "little":
arr = array("f", arr)
arr.byteswap()
return arr.tobytes()
def blob_to_vector(blob: bytes) -> array:
"""Deserializeaza un blob float32 little-endian la `array('f')`."""
arr = array("f")
arr.frombytes(blob)
if sys.byteorder != "little":
arr.byteswap()
return arr
def _chunks(seq: Sequence, size: int = _CHUNK_SIZE) -> Iterable[Sequence]:
for i in range(0, len(seq), size):
yield seq[i : i + size]
def load_cached_vectors(conn: sqlite3.Connection, model: str, hashes: Sequence[str]) -> dict[str, array]:
"""Citeste vectorii existenti pentru `model` + `hashes`. Blob corupt (lungime
gresita) = tratat ca miss (nu apare in rezultat), cu log.warning.
Degradare gratioasa: orice eroare SQLite -> dict gol/partial, fara exceptie.
"""
out: dict[str, array] = {}
if not hashes:
return out
expected_bytes = EMB_DIM * 4
unique_hashes = list(dict.fromkeys(hashes))
try:
for chunk in _chunks(unique_hashes):
placeholders = ",".join("?" for _ in chunk)
rows = conn.execute(
f"SELECT text_hash, vector FROM embedding_cache "
f"WHERE model = ? AND text_hash IN ({placeholders})",
(model, *chunk),
).fetchall()
for row in rows:
blob = row["vector"]
if len(blob) != expected_bytes:
log.warning(
"embedding_cache: blob lungime gresita pentru hash=%s (asteptat %d, primit %d) -- tratat ca miss",
row["text_hash"], expected_bytes, len(blob),
)
continue
out[row["text_hash"]] = blob_to_vector(blob)
except sqlite3.OperationalError as exc:
log.warning("embedding_cache: load_cached_vectors esuat (%s) -- fallback embed complet", exc)
return out
def save_vectors(conn: sqlite3.Connection, model: str, items: Sequence[tuple[str, Sequence[float]]]) -> None:
"""INSERT OR REPLACE in chunk-uri de 500 randuri, BEGIN/COMMIT explicit per chunk
(conexiunile sunt autocommit -- conn.commit() singur e no-op).
Valideaza `len(vector) == EMB_DIM` la scriere: vector gresit = respins (log.warning),
NU scris. Degradare gratioasa: eroare SQLite pe un chunk -> log.warning, chunk-urile
ramase continua (cache partial e idempotent, se completeaza la urmatorul warmup).
Invarianta: `conn` trebuie sa fie in autocommit (fara tranzactie deschisa de
apelant) -- ROLLBACK-ul din except ar anula altfel tranzactia apelantului.
"""
valid = []
for h, vec in items:
if len(vec) != EMB_DIM:
log.warning(
"embedding_cache: vector dimensiune gresita pentru hash=%s (asteptat %d, primit %d) -- respins",
h, EMB_DIM, len(vec),
)
continue
valid.append((h, model, vector_to_blob(vec)))
for chunk in _chunks(valid):
try:
conn.execute("BEGIN")
conn.executemany(
"INSERT OR REPLACE INTO embedding_cache (text_hash, model, vector) VALUES (?, ?, ?)",
chunk,
)
conn.execute("COMMIT")
except sqlite3.OperationalError as exc:
try:
conn.execute("ROLLBACK")
except sqlite3.OperationalError:
pass
log.warning("embedding_cache: save_vectors esuat pe un chunk (%s) -- cache ramane partial", exc)
def purge_stale(conn: sqlite3.Connection, model: str, corpus_hashes: Iterable[str]) -> None:
"""Sterge intrarile care nu mai apartin corpusului curent: orfane ale
modelului curent (text_hash absent din `corpus_hashes`) SI toate intrarile
modelelor VECHI (model != curent). Diff calculat in Python, DELETE chunk-uit
pe PK (model, text_hash) -- tranzactii scurte.
Degradare gratioasa: eroare SQLite -> log.warning, orfanele raman pana la
urmatoarea trecere.
Invarianta: `conn` trebuie sa fie in autocommit (fara tranzactie deschisa de
apelant) -- ROLLBACK-ul din except ar anula altfel tranzactia apelantului.
Apelantul trebuie sa cheme aceasta functie DOAR dupa o indexare reusita
(vezi `ensure_embeddings_corpus`) -- un esec de indexare nu trebuie sa goleasca
cache-ul de randuri inca valide.
"""
keep = set(corpus_hashes)
try:
rows = conn.execute("SELECT model, text_hash FROM embedding_cache").fetchall()
except sqlite3.OperationalError as exc:
log.warning("embedding_cache: purge_stale citire esuata (%s)", exc)
return
to_delete = [
(r["model"], r["text_hash"])
for r in rows
if r["model"] != model or r["text_hash"] not in keep
]
if not to_delete:
return
for chunk in _chunks(to_delete):
try:
conn.execute("BEGIN")
conn.executemany(
"DELETE FROM embedding_cache WHERE model = ? AND text_hash = ?",
chunk,
)
conn.execute("COMMIT")
except sqlite3.OperationalError as exc:
try:
conn.execute("ROLLBACK")
except sqlite3.OperationalError:
pass
log.warning("embedding_cache: purge_stale esuat pe un chunk (%s) -- orfanele raman", exc)
def sync_corpus_vectors(
conn: sqlite3.Connection,
model: str,
texts: Sequence[str],
embed_fn: Callable[[list[str]], Sequence[Sequence[float]]],
) -> list[array]:
"""Orchestreaza hash -> load -> embed(doar miss-uri) -> save -> vectori aliniati.
Returneaza o lista de `array('f')` aliniata pozitional cu `texts`.
`embed_fn` primeste lista textelor lipsa din cache si intoarce vectorii lor
(aceeasi ordine). Daca `embed_fn` esueaza (arunca), exceptia se propaga;
apelantul (ensure_embeddings_corpus) are deja degradare gratioasa (except -> pass).
Un save partial esuat (lock SQLite) NU opreste intoarcerea vectorilor din RAM
(A13c): vectorii noi raman in `cached` indiferent de rezultatul persistarii.
NU purjeaza orfanele: purjarea e responsabilitatea apelantului, DUPA ce corpusul
a fost indexat cu succes (`index_corpus`) -- un esec de indexare nu trebuie sa
goleasca din greseala cache-ul de randuri inca valide.
"""
hashes = [text_hash(t) for t in texts]
cached = load_cached_vectors(conn, model, hashes)
missing_positions = [i for i, h in enumerate(hashes) if h not in cached]
if missing_positions:
new_texts = [texts[i] for i in missing_positions]
new_vecs = embed_fn(new_texts)
if len(new_vecs) != len(new_texts):
raise ValueError(
f"embed_fn a intors {len(new_vecs)} vectori pentru {len(new_texts)} texte"
)
to_save = []
for pos, vec in zip(missing_positions, new_vecs):
arr = array("f", vec)
cached[hashes[pos]] = arr
to_save.append((hashes[pos], arr))
save_vectors(conn, model, to_save)
return [cached[h] for h in hashes]