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>
This commit is contained in:
Claude Agent
2026-07-06 21:57:49 +00:00
parent a46e364594
commit 705ad030fe
11 changed files with 1437 additions and 35 deletions

View File

@@ -10,9 +10,9 @@ Design:
- NU apelat din resolve_prestatii/load_mapping
API public (nivel modul):
index_corpus(items) -> None
suggest_nearest(text, top_k) -> [{cod, is_nul, similaritate}]
is_available() -> bool
index_corpus(items, signature, vectors) -> None
suggest_nearest(text, top_k) -> [{cod, is_nul, similaritate}]
is_available() -> bool
Clase (pentru teste / injectare backend):
EmbeddingEngine(backend) -- motor testabil cu backend injectabil
@@ -22,6 +22,7 @@ from __future__ import annotations
import logging
import math
import threading
from typing import Protocol, runtime_checkable
log = logging.getLogger(__name__)
@@ -107,8 +108,19 @@ class EmbeddingEngine:
doar cand semnatura nomenclatorului s-a schimbat (evita re-embed inutil)."""
return self._corpus_sig
def index_corpus(self, items: list[dict], signature: str | None = None) -> None:
"""Vectorizeaza corpus [{denumire, cod}] si il pastreaza in memorie.
def index_corpus(
self,
items: list[dict],
signature: str | None = None,
vectors: list | None = None,
) -> None:
"""Indexeaza corpus [{denumire, cod}] si il pastreaza in memorie.
`vectors`: vectori precalculati, aliniati POZITIONAL cu `items` (ex. din
embedding_cache). `None` (default) = comportamentul existent, embed complet
prin backend. Daca `vectors` e furnizat dar lungimea nu corespunde cu `items`
sau contine `None`, se ignora (log.warning) si se cade pe embed complet (A8) --
o dezaliniere silentioasa ar produce coduri sugerate GRESITE.
Ignora silentios daca backend-ul lipseste, corpus-ul e gol sau apare
orice exceptie la vectorizare (degradare gratioasa).
@@ -120,16 +132,35 @@ class EmbeddingEngine:
if not items or not self.is_available():
return
if vectors is not None and (len(vectors) != len(items) or any(v is None for v in vectors)):
log.warning(
"embeddings: index_corpus vectors (%d) nealiniat cu items (%d) sau contine None -- fallback embed complet",
len(vectors), len(items),
)
vectors = None
try:
texts = [str(item["denumire"]) for item in items]
vecs = self._backend.embed(texts)
self._corpus_vecs = vecs
if vectors is not None:
self._corpus_vecs = list(vectors)
else:
texts = [str(item["denumire"]) for item in items]
self._corpus_vecs = self._backend.embed(texts)
self._corpus_items = list(items)
self._corpus_sig = signature
except Exception as exc:
log.warning("embeddings: index_corpus esuat: %s", exc)
# corpus ramane gol -- suggest_nearest va returna []
def embed(self, texts: list[str]) -> list[list[float]]:
"""Vectorizeaza texte brute prin backend (folosit la miss-uri de cache).
Arunca daca backend-ul lipseste sau embed() esueaza -- apelantul
(sync_corpus_vectors) propaga eroarea, fara degradare gratioasa aici.
"""
if not self.is_available():
raise RuntimeError("embeddings: backend indisponibil")
return self._backend.embed(texts)
def suggest_nearest(
self,
denumire: str,
@@ -168,6 +199,7 @@ class EmbeddingEngine:
# --------------------------------------------------------------------------- #
_engine: EmbeddingEngine | None = None
_engine_lock = threading.Lock()
def _load_engine() -> EmbeddingEngine:
@@ -194,13 +226,24 @@ def _load_engine() -> EmbeddingEngine:
def _get_engine() -> EmbeddingEngine:
"""Returneaza engine-ul global (lazy-init)."""
"""Returneaza engine-ul global (lazy-init, thread-safe).
Lock-ul previne incarcarea dubla a modelului cand warmup-ul de la startup
si un request concurent ajung aici simultan.
"""
global _engine
if _engine is None:
_engine = _load_engine()
with _engine_lock:
if _engine is None:
_engine = _load_engine()
return _engine
def is_loaded() -> bool:
"""True daca engine-ul global a fost deja construit. NU forteaza incarcarea."""
return _engine is not None
# --------------------------------------------------------------------------- #
# API public la nivel de modul (wiring L14-S6) #
# --------------------------------------------------------------------------- #
@@ -233,12 +276,22 @@ def corpus_signature() -> str | None:
return _engine.corpus_signature()
def index_corpus(items: list[dict], signature: str | None = None) -> None:
def embed_texts(texts: list[str]) -> list[list[float]]:
"""Vectorizeaza texte brute prin motorul global (folosit la miss-uri de cache).
Arunca daca engine-ul e indisponibil -- apelantul (embedding_cache.sync_corpus_vectors)
propaga eroarea catre ensure_embeddings_corpus (degradare gratioasa acolo).
"""
return _get_engine().embed(texts)
def index_corpus(items: list[dict], signature: str | None = None, vectors: list | None = None) -> None:
"""Vectorizeaza corpus [{denumire, cod}] in motorul global.
`vectors`: vezi EmbeddingEngine.index_corpus (precalculati, aliniati cu `items`).
Silentios pe eroare (degradare gratioasa).
"""
_get_engine().index_corpus(items, signature=signature)
_get_engine().index_corpus(items, signature=signature, vectors=vectors)
def suggest_nearest(denumire: str, top_k: int = 3) -> list[dict]: