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>
211 lines
8.3 KiB
Python
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]
|