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:
102
app/mapping.py
102
app/mapping.py
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user