Spaces:
Runtime error
Runtime error
Claude Opus 5.5
feat(embeddings): voyage-4-large remplace EmbeddingGemma (API Voyage, halfvec 1024)
57c8117 Download tests/test_scripts/test_regenerate_embeddings.py from rahim237cm/jurix-api: direct link, hf CLI and curl.
- Browser
- Download file 18.1 kB
-
https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/tests/test_scripts/test_regenerate_embeddings.py
- Command line
-
hf download hf://spaces/rahim237cm/jurix-api/tests/test_scripts/test_regenerate_embeddings.py
-
curl -L -o test_regenerate_embeddings.py https://huggingface.co/spaces/rahim237cm/jurix-api/resolve/main/tests/test_scripts/test_regenerate_embeddings.py
18.1 kB
| """ | |
| Tests de scripts/regenerate_embeddings.py : ce qui est a refaire, et la trace | |
| laissee. | |
| La selection repose sur la PROVENANCE des vecteurs (articles.embedding_model), | |
| plus seulement sur leur absence. C'est ce qui rend un changement de fournisseur | |
| sur : chaque vecteur de l'ancien modele est reconnu et refait, au lieu de | |
| cohabiter en silence avec ceux du nouveau — deux espaces dont les cosinus | |
| croises ne veulent rien dire. | |
| Usage: | |
| pytest tests/test_scripts/test_regenerate_embeddings.py -v | |
| """ | |
| import json | |
| import numpy as np | |
| import pytest | |
| from sqlalchemy import text | |
| from app.models.law import Article, Law | |
| from app.services.embedding_service import EmbeddingService, VoyageProvider | |
| from scripts import regenerate_embeddings as regen | |
| EMPREINTE = "doublure|actuelle" | |
| DIM = EmbeddingService.EMBEDDING_DIM | |
| def _vecteur(graine: int) -> np.ndarray: | |
| v = np.random.default_rng(graine).normal(size=DIM) | |
| return (v / np.linalg.norm(v)).astype(np.float32) | |
| def base(sync_db_session): | |
| """Un article dans chacun des etats possibles.""" | |
| session = sync_db_session | |
| session.add(Law(id=1, reference="LOI-REGEN", title="Loi", content="Contenu.", | |
| type="loi", language="fr", status="published")) | |
| session.flush() | |
| etats = { | |
| 1: (None, None, True), # jamais vectorise | |
| 2: (_vecteur(2), "gemini|ancien", True), # autre fournisseur | |
| 3: (_vecteur(3), None, True), # origine inconnue | |
| 4: (_vecteur(4), EMPREINTE, True), # a jour | |
| 5: (None, None, False), # ecarte par le raffineur | |
| } | |
| for article_id, (vecteur, empreinte, embed) in etats.items(): | |
| session.add(Article( | |
| id=article_id, law_id=1, number=str(article_id), | |
| content=f"Article {article_id}, contenu de test.", order=article_id, | |
| embedding=None if vecteur is None else vecteur.tolist(), | |
| embedding_model=empreinte, embed=embed, | |
| )) | |
| session.commit() | |
| return session | |
| def _ids(lot): | |
| return [r["id"] for r in lot] | |
| class TestSelection: | |
| def test_refait_ce_qui_manque_ou_vient_d_ailleurs(self, base): | |
| """ | |
| L'article 3 est le cas qui justifie IS DISTINCT FROM : avec `<>`, son | |
| empreinte NULL rendrait la condition NULL, et ce vecteur d'origine | |
| inconnue passerait pour bon. | |
| """ | |
| lot = regen._fetch_batch(base, None, False, EMPREINTE, 0, 100) | |
| assert _ids(lot) == [1, 2, 3] | |
| def test_le_compte_annonce_suit_la_meme_selection(self, base): | |
| assert regen._count_remaining(base, None, False, EMPREINTE) == 3 | |
| def test_force_reprend_tout_ce_qui_est_vectorisable(self, base): | |
| lot = regen._fetch_batch(base, None, True, EMPREINTE, 0, 100) | |
| assert _ids(lot) == [1, 2, 3, 4] | |
| assert regen._count_remaining(base, None, True, EMPREINTE) == 4 | |
| def test_un_autre_fournisseur_voit_tout_a_refaire(self, base): | |
| """Le changement de fournisseur, vu du script : rien n'est a jour.""" | |
| assert regen._count_remaining(base, None, False, "voyage|voyage-4-large") == 4 | |
| class TestEcriture: | |
| def test_le_vecteur_part_avec_son_empreinte(self, base): | |
| vecteurs = [_vecteur(10 + i) for i in range(3)] | |
| regen._write_batch(base, [1, 2, 3], vecteurs, EMPREINTE) | |
| base.commit() | |
| rows = base.execute(text( | |
| "SELECT id, embedding_model FROM articles WHERE embed ORDER BY id" | |
| )).all() | |
| assert [(r.id, r.embedding_model) for r in rows] == [ | |
| (i, EMPREINTE) for i in (1, 2, 3, 4) | |
| ] | |
| relu = np.array(base.get(Article, 1).embedding, dtype=np.float32) | |
| # halfvec = fp16, environ 3 chiffres significatifs : ecart maximal | |
| # mesure de 3e-5 sur un vecteur unitaire de 1024. 1e-6 echouerait. | |
| assert np.allclose(relu, vecteurs[0], atol=1e-4) | |
| assert float(relu @ vecteurs[0]) > 0.9999 | |
| assert regen._count_remaining(base, None, False, EMPREINTE) == 0 | |
| def test_un_vecteur_d_une_autre_dimension_est_refuse_par_la_colonne(self, base): | |
| """Le typmod de la colonne garde la dimension, au caractere pres.""" | |
| court = np.ones(768, dtype=np.float32) / np.sqrt(768) | |
| with pytest.raises(Exception, match="expected 1024 dimensions"): | |
| regen._write_batch(base, [1], [court], EMPREINTE) | |
| base.commit() | |
| base.rollback() | |
| class _Fournisseur: | |
| label = "doublure" | |
| fingerprint = EMPREINTE | |
| inter_batch_delay_s = 0.0 | |
| class _Service: | |
| """Doublure d'EmbeddingService : aucun modele, aucun reseau.""" | |
| MAX_TEXT_LENGTH = EmbeddingService.MAX_TEXT_LENGTH | |
| EMBEDDING_DIM = DIM | |
| encodes = [] | |
| def __init__(self, *args, **kwargs): | |
| self.provider = _Fournisseur() | |
| def generate_batch_embeddings(self, texts, batch_size=None, normalize=True): | |
| _Service.encodes.extend(texts) | |
| return [_vecteur(len(t)) for t in texts] | |
| class TestDeBoutEnBout: | |
| def _doublure(self, monkeypatch): | |
| _Service.encodes = [] | |
| monkeypatch.setattr(regen, "EmbeddingService", _Service) | |
| def test_une_passe_suffit_et_la_suivante_ne_trouve_rien(self, base): | |
| assert regen.main(["--all", "--batch-size", "2"]) == 0 | |
| assert len(_Service.encodes) == 3 | |
| assert regen._count_remaining(base, None, False, EMPREINTE) == 0 | |
| _Service.encodes = [] | |
| assert regen.main(["--all"]) == 0 | |
| assert _Service.encodes == [] | |
| def test_le_compte_a_blanc_n_ecrit_rien(self, base): | |
| assert regen.main(["--all", "--dry-run"]) == 0 | |
| assert _Service.encodes == [] | |
| assert regen._count_remaining(base, None, False, EMPREINTE) == 3 | |
| def test_une_cle_refusee_arrete_tout_au_premier_lot(self, base, monkeypatch): | |
| """ | |
| Sans cet arret, une cle revoquee envoyait des milliers de requetes | |
| vouees au meme refus, un lot apres l'autre. | |
| """ | |
| from app.services.embedding_service import EmbeddingDefinitiveError | |
| appels = [] | |
| def _refus(self, texts, **kwargs): | |
| appels.append(len(texts)) | |
| raise EmbeddingDefinitiveError("Voyage HTTP 401 : Provided API key is invalid.", code=401) | |
| monkeypatch.setattr(_Service, "generate_batch_embeddings", _refus) | |
| assert regen.main(["--all", "--batch-size", "1"]) == 2 | |
| assert len(appels) == 1 | |
| def test_un_arret_apres_des_lots_commites_vide_quand_meme_le_cache(self, base, monkeypatch): | |
| from app.services.embedding_service import EmbeddingDefinitiveError | |
| appels = [] | |
| def _refus_au_second(self, texts, **kwargs): | |
| appels.append(len(texts)) | |
| if len(appels) == 2: | |
| raise EmbeddingDefinitiveError("Voyage HTTP 401 : revoquee", code=401) | |
| return [_vecteur(len(t)) for t in texts] | |
| monkeypatch.setattr(_Service, "generate_batch_embeddings", _refus_au_second) | |
| base.execute(text( | |
| "INSERT INTO query_cache (cache_key, response_json, expires_at) " | |
| "VALUES ('k', '{}', now() + interval '1 hour')" | |
| )) | |
| base.commit() | |
| assert regen.main(["--all", "--batch-size", "1"]) == 2 | |
| assert base.execute(text("SELECT count(*) FROM query_cache")).scalar() == 0 | |
| def test_par_defaut_les_lots_sont_ceux_du_fournisseur(self, base, monkeypatch): | |
| """Des lots de 16 enverraient environ 2 000 requetes a Voyage pour le corpus.""" | |
| tailles = [] | |
| def _compter(self, texts, batch_size=None, **kwargs): | |
| tailles.append((len(texts), batch_size)) | |
| return [_vecteur(len(t)) for t in texts] | |
| monkeypatch.setattr(_Fournisseur, "TEXTES_PAR_REQUETE", 2, raising=False) | |
| monkeypatch.setattr(_Service, "generate_batch_embeddings", _compter) | |
| assert regen.main(["--all"]) == 0 | |
| assert tailles == [(2, 2), (1, 2)] | |
| def test_une_colonne_d_une_autre_dimension_arrete_avant_tout_appel(self, base, monkeypatch): | |
| """Sur une base non migree, chaque lot serait paye a Voyage, puis refuse.""" | |
| monkeypatch.setattr(_Service, "EMBEDDING_DIM", 768) | |
| assert regen.main(["--all"]) == 2 | |
| assert _Service.encodes == [] | |
| def test_un_lot_refuse_pour_son_texte_est_saute(self, base, monkeypatch): | |
| """Un 400 vise un texte : les autres lots continuent.""" | |
| from app.services.embedding_service import EmbeddingDefinitiveError | |
| appels = [] | |
| def _refus_du_premier(self, texts, **kwargs): | |
| appels.append(len(texts)) | |
| if len(appels) == 1: | |
| raise EmbeddingDefinitiveError("Voyage HTTP 400 : bad input", code=400) | |
| return [_vecteur(len(t)) for t in texts] | |
| monkeypatch.setattr(_Service, "generate_batch_embeddings", _refus_du_premier) | |
| assert regen.main(["--all", "--batch-size", "1"]) == 1 | |
| assert len(appels) == 3 | |
| assert regen._count_remaining(base, None, False, EMPREINTE) == 1 | |
| def test_une_passe_vide_le_cache_des_recherches(self, base): | |
| """Les reponses gardees pendant que les vecteurs manquaient etaient du plein texte seul.""" | |
| base.execute(text( | |
| "INSERT INTO query_cache (cache_key, response_json, expires_at) " | |
| "VALUES ('k', '{}', now() + interval '1 hour')" | |
| )) | |
| base.commit() | |
| assert regen.main(["--all"]) == 0 | |
| assert base.execute(text("SELECT count(*) FROM query_cache")).scalar() == 0 | |
| def _index_embedding(session): | |
| return session.execute(text( | |
| "SELECT indexrelid::regclass::text, indisvalid FROM pg_index " | |
| "WHERE indrelid = 'articles'::regclass " | |
| "AND indexrelid::regclass::text LIKE 'idx_articles_embedding_hnsw%'" | |
| )).all() | |
| class TestReconstructionDeLIndex: | |
| def test_le_nom_est_celui_de_la_migration(self): | |
| assert regen.INDEX_EMBEDDING == "idx_articles_embedding_hnsw_halfvec1024" | |
| def test_index_reconstruit_et_valide(self, base): | |
| regen.reindex(base) | |
| assert _index_embedding(base) == [(regen.INDEX_EMBEDDING, True)] | |
| def test_echec_ne_laisse_pas_de_copie_invalide(self, base, monkeypatch): | |
| """ | |
| Un REINDEX CONCURRENTLY interrompu laisse une copie _ccnew invalide, | |
| maintenue a chaque ecriture. On la simule, puis on fait echouer. | |
| """ | |
| with regen.sync_engine.connect().execution_options(isolation_level="AUTOCOMMIT") as conn: | |
| conn.execute(text( | |
| f"CREATE INDEX {regen.INDEX_EMBEDDING}_ccnew ON articles (id)" | |
| )) | |
| monkeypatch.setattr( | |
| regen, "text", | |
| lambda sql: text(sql.replace("REINDEX INDEX CONCURRENTLY", "REINDEX INDEX CONCURRENTLY inexistant_") | |
| if sql.startswith("REINDEX") else sql), | |
| ) | |
| with pytest.raises(Exception): | |
| regen.reindex(base) | |
| assert _index_embedding(base) == [(regen.INDEX_EMBEDDING, True)] | |
| # ==================== CHARGEMENT DES LOTS DEJA CALCULES ==================== | |
| def _texte(article_id): | |
| """Le texte vectorise de l'article : coalesce(embed_text, content).""" | |
| return f"Article {article_id}, contenu de test." | |
| def _instantane(dossier, textes): | |
| chemin = dossier / "morceaux.jsonl" | |
| with open(chemin, "w", encoding="utf-8") as f: | |
| for article_id, texte in textes.items(): | |
| f.write(json.dumps({"id": article_id, "law_id": 1, "texte": texte}, ensure_ascii=False) + "\n") | |
| return chemin | |
| def _lots(dossier, vecteurs, nom="lot"): | |
| dossier.mkdir(parents=True, exist_ok=True) | |
| ids = sorted(vecteurs) | |
| np.savez(dossier / f"{nom}.npz", ids=np.array(ids, dtype=np.int64), | |
| v=np.array([vecteurs[i] for i in ids], dtype=np.float32)) | |
| return dossier | |
| def lots(base, tmp_path): | |
| """ | |
| Un cas par motif : 1 a charger ; 2 vecteur non unitaire ; 3 texte modifie | |
| depuis l'instantane ; 4 deja a jour ; 5 non vectorisable ; 99 absent de la | |
| base ; 77 absent de l'instantane. | |
| """ | |
| textes = {i: _texte(i) for i in (1, 2, 4, 5, 99)} | |
| textes[3] = "Ancienne version de l'article 3." | |
| vecteurs = {i: _vecteur(100 + i) for i in (1, 3, 4, 5, 77, 99)} | |
| vecteurs[2] = _vecteur(102) * 0.5 | |
| dossier = _lots(tmp_path / "voyage-4-large", vecteurs) | |
| return dossier, _instantane(tmp_path, textes), vecteurs | |
| class TestChargementDesLots: | |
| def test_chaque_motif_est_compte_et_seul_le_bon_est_charge(self, base, lots): | |
| dossier, instantane, vecteurs = lots | |
| rapport = regen.charger_les_lots(base, dossier, instantane, EMPREINTE, DIM) | |
| assert dict(rapport) == { | |
| "charges": 1, | |
| "vecteur_invalide": 1, | |
| "texte_modifie_depuis_l_instantane": 1, | |
| "deja_a_jour": 1, | |
| "non_vectorisable": 1, | |
| "absent_de_la_base": 1, | |
| "absent_de_l_instantane": 1, | |
| } | |
| etat = {r.id: r.embedding_model for r in base.execute(text( | |
| "SELECT id, embedding_model FROM articles ORDER BY id" | |
| ))} | |
| assert etat == {1: EMPREINTE, 2: "gemini|ancien", 3: None, 4: EMPREINTE, 5: None} | |
| relu = np.array(base.get(Article, 1).embedding, dtype=np.float32) | |
| assert np.allclose(relu, vecteurs[1], atol=1e-4) | |
| def test_le_compte_a_blanc_n_ecrit_rien(self, base, lots): | |
| dossier, instantane, _ = lots | |
| rapport = regen.charger_les_lots(base, dossier, instantane, EMPREINTE, DIM, dry_run=True) | |
| assert rapport["a_charger"] == 1 | |
| assert rapport["a_charger_qui_manquaient"] == 1 | |
| assert rapport["charges"] == 0 | |
| assert base.execute(text("SELECT embedding IS NULL FROM articles WHERE id = 1")).scalar() | |
| def test_le_compte_a_blanc_avec_force_distingue_ce_qui_manquait(self, base, lots): | |
| """Avec --force, l'article 4, deja a jour, est recharge mais ne manquait pas.""" | |
| dossier, instantane, _ = lots | |
| rapport = regen.charger_les_lots( | |
| base, dossier, instantane, EMPREINTE, DIM, force=True, dry_run=True, | |
| ) | |
| assert rapport["a_charger"] == 2 | |
| assert rapport["a_charger_qui_manquaient"] == 1 | |
| def test_force_reecrit_aussi_ce_qui_est_a_jour(self, base, lots): | |
| dossier, instantane, vecteurs = lots | |
| rapport = regen.charger_les_lots(base, dossier, instantane, EMPREINTE, DIM, force=True) | |
| assert rapport["charges"] == 2 | |
| relu = np.array(base.get(Article, 4).embedding, dtype=np.float32) | |
| assert np.allclose(relu, vecteurs[4], atol=1e-4) | |
| def test_une_reprise_ne_recharge_rien(self, base, lots): | |
| dossier, instantane, _ = lots | |
| regen.charger_les_lots(base, dossier, instantane, EMPREINTE, DIM) | |
| rapport = regen.charger_les_lots(base, dossier, instantane, EMPREINTE, DIM) | |
| assert rapport["charges"] == 0 | |
| assert rapport["deja_a_jour"] == 2 | |
| def test_un_id_present_dans_deux_lots_est_ecarte(self, base, tmp_path): | |
| dossier = tmp_path / "voyage-4-large" | |
| _lots(dossier, {1: _vecteur(1)}, nom="a") | |
| _lots(dossier, {1: _vecteur(2)}, nom="b") | |
| instantane = _instantane(tmp_path, {1: _texte(1)}) | |
| rapport = regen.charger_les_lots(base, dossier, instantane, EMPREINTE, DIM) | |
| assert rapport["id_en_double_dans_les_lots"] == 1 | |
| assert rapport["charges"] == 0 | |
| def test_un_lot_d_une_autre_dimension_arrete_tout(self, base, tmp_path): | |
| dossier = tmp_path / "voyage-4-large" | |
| dossier.mkdir() | |
| np.savez(dossier / "lot.npz", ids=np.array([1], dtype=np.int64), | |
| v=np.ones((1, 768), dtype=np.float32)) | |
| instantane = _instantane(tmp_path, {1: _texte(1)}) | |
| with pytest.raises(SystemExit, match="768"): | |
| regen.charger_les_lots(base, dossier, instantane, EMPREINTE, DIM) | |
| class TestChargementParLaLigneDeCommande: | |
| def test_l_empreinte_ecrite_est_celle_du_fournisseur(self, base, lots): | |
| """ | |
| Calculee par le code, jamais recopiee : un caractere d'ecart, et les | |
| vecteurs charges passeraient pour etrangers. | |
| """ | |
| dossier, instantane, _ = lots | |
| assert regen.main(["--depuis-lots", str(dossier), "--instantane", str(instantane)]) == 0 | |
| empreinte = base.execute(text("SELECT embedding_model FROM articles WHERE id = 1")).scalar() | |
| assert empreinte == VoyageProvider(api_key="factice").fingerprint == "voyage|voyage-4-large" | |
| def test_le_chargement_vide_le_cache_des_recherches(self, base, lots): | |
| dossier, instantane, _ = lots | |
| base.execute(text( | |
| "INSERT INTO query_cache (cache_key, response_json, expires_at) " | |
| "VALUES ('k', '{}', now() + interval '1 hour')" | |
| )) | |
| base.commit() | |
| assert regen.main(["--depuis-lots", str(dossier), "--instantane", str(instantane)]) == 0 | |
| assert base.execute(text("SELECT count(*) FROM query_cache")).scalar() == 0 | |
| def test_l_instantane_est_exige(self, base, lots): | |
| dossier, _, _ = lots | |
| assert regen.main(["--depuis-lots", str(dossier)]) == 2 | |
| def test_refuse_si_le_fournisseur_n_est_pas_voyage(self, base, lots, monkeypatch): | |
| dossier, instantane, _ = lots | |
| monkeypatch.setattr(regen.settings, "EMBEDDING_PROVIDER", "gemini") | |
| assert regen.main(["--depuis-lots", str(dossier), "--instantane", str(instantane)]) == 2 | |
| def test_refuse_un_dossier_d_un_autre_modele(self, base, lots, tmp_path): | |
| """Des vecteurs voyage-context-4 ne doivent pas passer pour voyage-4-large.""" | |
| dossier, instantane, _ = lots | |
| autre = dossier.rename(tmp_path / "voyage-context-4") | |
| assert regen.main(["--depuis-lots", str(autre), "--instantane", str(instantane)]) == 2 | |
| assert base.execute(text("SELECT embedding IS NULL FROM articles WHERE id = 1")).scalar() | |
| def test_instantane_sans_depuis_lots_refuse(self, base, tmp_path): | |
| instantane = _instantane(tmp_path, {1: _texte(1)}) | |
| assert regen.main(["--all", "--instantane", str(instantane)]) == 2 | |