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

@@ -16,7 +16,10 @@ from __future__ import annotations
import hashlib
import json
import logging
import re
import threading
import time
import unicodedata
from typing import Any
@@ -27,6 +30,8 @@ from .accounts import held_for_account
from .nomenclator_seed import FALLBACK_NOMENCLATOR
from .validation import validate_prezentare
log = logging.getLogger(__name__)
# Cont implicit cat timp auth API-key (CORE) nu e implementat: ingestiile vin cu
# account_id NULL si le atribuim contului seed-at in schema (id=1).
DEFAULT_ACCOUNT_ID = 1
@@ -631,6 +636,13 @@ def delete_text_rule(conn, account_id: int | None, pattern: str) -> None:
# irelevante cand corpus-ul e mic sau neindexat corect).
EMB_MIN_SIMILARITATE = 0.5
# Protejeaza secventa hash->load->embed->save->purge->index (embedding_cache) de
# executie concurenta intre warmup-ul de fundal (block=True) si calea de request
# (block=False, dupa ce modelul e deja incarcat) -- altfel purjarea uneia ar sterge
# randuri tocmai scrise de cealalta. Calea de request obtine lock-ul neblocant
# (nu asteapta warmup-ul in curs); doar warmup-ul asteapta normal.
_embeddings_lock = threading.Lock()
def _corpus_signature_silver(rows: list) -> str:
"""Semnatura stabila a corpusului SILVER (mapping_suggestions) pentru cache.
@@ -646,7 +658,7 @@ def _corpus_signature_silver(rows: list) -> str:
return hashlib.sha256(blob.encode("utf-8")).hexdigest()
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> None:
def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None, *, block: bool = False) -> None:
"""Construieste/actualizeaza corpusul embeddings din corpusul ETICHETAT.
Sursa corpusului = `mapping_suggestions` (SILVER): exemple reale etichetate
@@ -658,9 +670,24 @@ def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> Non
Gated pe `AUTOPASS_EMBEDDINGS_ENABLED` (default ON; OFF in teste): cand e
dezactivat, e un no-op total -> /mapari instant + suita de teste rapida.
Cand e activat: indexeaza corpusul o singura data (lazy-load modelul ~230MB la
prima chemare), re-indexeaza doar cand semnatura corpusului SILVER s-a schimbat.
Itemii NUL (is_nul=1, cod NULL) raman in corpus: un vecin NUL e semnal de supresie.
Cand e activat: indexeaza corpusul o singura data, re-indexeaza doar cand
semnatura corpusului SILVER s-a schimbat. Itemii NUL (is_nul=1, cod NULL) raman
in corpus: un vecin NUL e semnal de supresie.
`block=False` (default, calea de request): daca modelul NU e inca incarcat,
return imediat — incarcarea modelului (~230MB, zeci de secunde) NU are voie sa
blocheze un request HTTP; o face warmup-ul de la startup (block=True, in thread).
Odata modelul incarcat insa, warmup-ul mai poate fi INCA in curs de vectorizare
a corpusului (~1-2 min): calea de request NU asteapta dupa lock in acest caz —
incearca sa il obtina neblocant, iar daca e ocupat, iese imediat (degradare
gratioasa, sugestii lipsa pana termina warmup-ul). block=True (warmup) asteapta
normal dupa lock.
Cache persistent (embedding_cache): hash-ul se calculeaza pe lista FILTRATA de
`denumire` (EXACT ce intra in `index_corpus`), citeste vectorii existenti pentru
modelul curent, vectorizeaza doar miss-urile si salveaza-i. Purjarea orfanelor
ruleaza DUPA indexare, doar daca indexarea a reusit efectiv (semnatura noua
confirmata) — un esec de indexare nu trebuie sa goleasca cache-ul.
Degradare gratioasa: orice eroare lasa corpusul gol -> enrich cade pe restul.
"""
from .config import get_settings
@@ -668,24 +695,55 @@ def ensure_embeddings_corpus(conn, nomenclator: list[dict] | None = None) -> Non
return
try:
from . import embeddings as _emb
rows = conn.execute(
"SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions"
).fetchall()
if not rows:
return
sig = _corpus_signature_silver(rows)
if _emb.corpus_signature() == sig and _emb.has_corpus():
return # deja indexat pe acelasi corpus SILVER -> nimic de facut
items = [
{
"denumire": str(r["denumire_normalizata"]),
"cod": (str(r["cod_prestatie"]) if r["cod_prestatie"] is not None else None),
"is_nul": bool(r["is_nul"]),
}
for r in rows
if r["denumire_normalizata"]
]
_emb.index_corpus(items, signature=sig)
if not block and not _emb.is_loaded():
return # warmup-ul din fundal nu a terminat inca; nu bloca request-ul
if not _embeddings_lock.acquire(blocking=block):
return # warmup in curs; calea de request nu asteapta (nu bloca request-ul)
try:
rows = conn.execute(
"SELECT denumire_normalizata, cod_prestatie, is_nul FROM mapping_suggestions"
).fetchall()
if not rows:
return
sig = _corpus_signature_silver(rows)
if _emb.corpus_signature() == sig and _emb.has_corpus():
return # deja indexat pe acelasi corpus SILVER -> nimic de facut
items = [
{
"denumire": str(r["denumire_normalizata"]),
"cod": (str(r["cod_prestatie"]) if r["cod_prestatie"] is not None else None),
"is_nul": bool(r["is_nul"]),
}
for r in rows
if r["denumire_normalizata"]
]
if not items:
return
from . import embedding_cache as _cache
texts = [item["denumire"] for item in items]
hashes = [_cache.text_hash(t) for t in texts]
miss_count = 0
def _embed_fn(missing_texts: list[str]) -> list:
nonlocal miss_count
miss_count += len(missing_texts)
return _emb.embed_texts(missing_texts)
t0 = time.monotonic()
vectors = _cache.sync_corpus_vectors(conn, _emb.FASTEMBED_MODEL, texts, _embed_fn)
_emb.index_corpus(items, signature=sig, vectors=vectors)
if _emb.corpus_signature() == sig and _emb.has_corpus():
_cache.purge_stale(conn, _emb.FASTEMBED_MODEL, set(hashes))
eticheta = "warmup ok" if block else "corpus reindexat"
log.info(
"embeddings: %s cache=%d embed=%d in %.1fs",
eticheta, len(texts) - miss_count, miss_count, time.monotonic() - t0,
)
finally:
_embeddings_lock.release()
except Exception:
pass # degradare gratioasa: esecul indexarii nu blocheaza editorul