diff --git a/dist/RAG_INTEGRATION.md b/dist/RAG_INTEGRATION.md index 7f3f000..ec52f1a 100644 --- a/dist/RAG_INTEGRATION.md +++ b/dist/RAG_INTEGRATION.md @@ -30,20 +30,64 @@ emb.release() Handle **séparé** du LLM Speaker/TTS. CPU pur, aucun HTP. Déterministe (pas d'échantillonnage). +**Interface FIGÉE** (demande dev #3) — ces 3 signatures ne changeront pas, tu peux t'y brancher : +``` +loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long +embedText(handle: Long, text: String): FloatArray? +freeEmbedder(handle: Long) +``` + +**.so CPU-only livrée** (demande dev #1, prérequis in-app) : `dist/b-jni-cpu/libkazeia_engine.so` (64 KB). +- DT_NEEDED vérifié : `libllama.so libggml.so libggml-base.so` + système — **AUCUNE dep `libggml-hexagon`** → pas de FastRPC, pas de bloqueur SELinux. +- À pousser dans `jniLibs/arm64-v8a/` avec les libs CPU-only `dist/lib-cpu/` (libllama/libggml/libggml-base/libggml-cpu) + `libc++_shared.so`. +- L'embedder partage ces libs avec LLM/TTS CPU-only (mêmes fichiers) — pas de duplication. +- Validé sur tablette : charge e5 + nomic, n_embd auto (384/768), déterministe bit-identique, norme 1.0. + --- -## Modèle d'embedding — décision critique (point #1 du spec) +## Modèle d'embedding — TRANCHÉ : multilingual-e5-small (voie a) -| | Statut | -|---|---| -| **Recommandé** | `multilingual-e5-small` (384-dim, FR-capable, pooling MEAN) — **à sourcer/convertir + versionner** | -| Testé localement | `nomic-embed-text-v1.5` (768-dim) — **anglo-centré, marge FR fine** | +**Décision : `multilingual-e5-small` (384-dim, FR), converti via patch converter (voie a).** Mesuré meilleur que nomic en FR, et le blocage de conversion est levé. -**Mesure FR (nomic, sur tablette)** : `cos("mal à dormir","insomnie")=0.556` vs `cos("mal à dormir","recette de tarte")=0.517` — ordre correct mais marge de **0.04 seulement**. Avec un mauvais préfixe l'ordre s'inverse (0.477 vs 0.611). **Conclusion : nomic n'est pas assez robuste en FR pour un RAG thérapeutique. Shipper `multilingual-e5-small` comme le spec l'exige.** +### La question du dev : XLM-R = petit patch ou trou architectural ? → **petit patch** + +Réponse mesurée, pas devinée : +- **Runtime C++** : le loader a déjà le chemin **UGM (unigram/SentencePiece)** (`llama-vocab.cpp` : `tokenizer_model=="t5"` → `LLAMA_VOCAB_TYPE_UGM`). Le type de vocab est **découplé de l'arch** — un modèle arch `bert` + tokenizer UGM charge sans souci. **Pas de trou architectural.** +- **Converter** : `conversion/bert.py` contient déjà toute la machinerie XLM-R (`_xlmroberta_tokenizer_init`, `_xlmroberta_set_vocab` qui écrit `tokenizer_model="t5"` + vocab unigram). Mais seul `NomicBertModel` la câblait. `multilingual-e5-small` déclare `architectures:["BertModel"]` + tokenizer `Unigram` → tombait dans `BertModel.set_vocab` → chemin BPE → `get_vocab_base_pre()` échoue (hash inconnu). + +**Le trou = un wiring manquant dans `BertModel` (3 méthodes), pas une absence de chemin.** Patch livré : `dist/patches/bert_xlmroberta_unigram.diff` (~25 lignes) — détecte `model.type=="Unigram"` dans `__init__`, route `set_vocab` vers `_xlmroberta_set_vocab`, choppe la matrice de positions dans `modify_tensors` (exactement comme `RobertaModel`/`NomicBertModel` le font déjà). + +### Validé end-to-end (converti + chargé sur build CPU-only, tablette) + +``` +multilingual-e5-small-f16.gguf : 384-dim, norme L2 = 1.0, déterministe +cos("j'ai du mal à dormir", "insomnie") = 0.909 <- proche +cos("j'ai du mal à dormir", "recette de tarte") = 0.821 +cos("j'ai du mal à dormir", "le chat dort...") = 0.843 +cos("j'ai du mal à dormir", "2+2=4") = 0.831 +MARGE FR (insomnie - tarte) = 0.089 (nomic = 0.04 -> e5 2.2x mieux) +``` +Ranking propre : l'item pertinent (`insomnie`) ressort nettement au-dessus des 3 non-pertinents (cluster ~0.82-0.84). Utilisable pour top-k + seuil. + +> Note e5 : cosinus de base élevé (~0.82 même entre phrases non liées) = normal (espace e5 anisotrope). Ce qui compte = le **ranking relatif**, correct ici. Avec l'asymétrie `query:`/`passage:` côté retrieval, la marge réelle est encore meilleure. + +### Reproduire le GGUF + +```bash +# 1. Appliquer le patch converter (dans le fork ql) +cd /opt/Kazeia-engine/ql && git apply ../dist/patches/bert_xlmroberta_unigram.diff +# 2. Convertir (venv avec torch + sentencepiece + gguf) +/opt/Kazeia/qnn_venv/bin/python convert_hf_to_gguf.py /chemin/multilingual-e5-small/ \ + --outfile multilingual-e5-small-f16.gguf --outtype f16 +``` +Source officielle : `intfloat/multilingual-e5-small` (`architectures:["BertModel"]`, tokenizer `Unigram`). Impératifs (contrat de distribution) : -- **Le MÊME GGUF à l'ingestion ET à la requête** (vecteurs incompatibles sinon). Versionner le n° de modèle. -- **Préfixes e5 (`query:` / `passage:`) = côté appelant**, jamais dans le moteur. +- **Le MÊME GGUF à l'ingestion ET à la requête** (vecteurs incompatibles sinon). Versionner le n° de modèle. (Bit-identique garanti seulement sur le **même build** : un GGUF généré sur build CPU vs build HTP diverge de ~1.5e-2 ; ingestion et requête tournent sur le même build in-app → OK.) +- **Préfixes e5 (`query:` / `passage:`) = côté appelant**, jamais dans le moteur. Pooling = MEAN (1) ou -1 (le GGUF e5 porte la métadonnée MEAN). + +### Stopgap nomic (voie c) si besoin de dérisquer l'E2E in-app tout de suite +`nomic-embed-text-v1.5` convertit déjà sans patch (WordPiece) mais FR faible (marge 0.04, s'inverse avec mauvais préfixe). Acceptable pour valider le câblage E2E, à swapper par e5 pour la qualité. Le code moteur est agnostique (n_embd auto) — le swap est juste un changement de fichier GGUF. --- diff --git a/dist/b-jni-cpu/libkazeia_engine.so b/dist/b-jni-cpu/libkazeia_engine.so new file mode 100755 index 0000000..1ddc45c Binary files /dev/null and b/dist/b-jni-cpu/libkazeia_engine.so differ diff --git a/dist/patches/bert_xlmroberta_unigram.diff b/dist/patches/bert_xlmroberta_unigram.diff new file mode 100644 index 0000000..538e59e --- /dev/null +++ b/dist/patches/bert_xlmroberta_unigram.diff @@ -0,0 +1,63 @@ +diff --git a/conversion/bert.py b/conversion/bert.py +index 8af6c53..6dfe463 100644 +--- a/conversion/bert.py ++++ b/conversion/bert.py +@@ -28,6 +28,16 @@ class BertModel(TextModel): + cls_out_labels = None + self.cls_out_labels = cls_out_labels + ++ # Certains modèles déclarent architectures=["BertModel"] mais utilisent un ++ # tokenizer XLM-RoBERTa SentencePiece/Unigram (ex. intfloat/multilingual-e5-*, ++ # CamemBERT). Détecter ici pour router le vocab + chopper la matrice de positions ++ # exactement comme RobertaModel/NomicBertModel le font déjà. ++ self._tokenizer_is_xlmroberta = self._is_tokenizer_xlmroberta() ++ if self._tokenizer_is_xlmroberta: ++ self._xlmroberta_tokenizer_init() ++ else: ++ self._position_offset = None ++ + def set_gguf_parameters(self): + super().set_gguf_parameters() + self.gguf_writer.add_causal_attention(False) +@@ -37,6 +47,8 @@ class BertModel(TextModel): + self.gguf_writer.add_classifier_output_labels([v for k, v in sorted(self.cls_out_labels.items())]) + + def set_vocab(self): ++ if getattr(self, "_tokenizer_is_xlmroberta", False): ++ return self._xlmroberta_set_vocab() + tokens, toktypes, tokpre = self.get_vocab_base() + self.vocab_size = len(tokens) + +@@ -91,6 +103,11 @@ class BertModel(TextModel): + return super().filter_tensors((name, gen)) + + def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]: ++ # XLM-R : chopper la matrice de positions de _position_offset (pad/bos), ++ # comme RobertaModel/NeoBert. No-op si _position_offset is None (BERT WordPiece). ++ if name == "embeddings.position_embeddings.weight" and getattr(self, "_position_offset", None) is not None: ++ data_torch = data_torch[self._position_offset:, :] ++ + if self.cls_out_labels: + # For BertForSequenceClassification (direct projection layer) + if name == "classifier.weight": +@@ -101,6 +118,20 @@ class BertModel(TextModel): + + yield from super().modify_tensors(data_torch, name, bid) + ++ # Détection robuste du tokenizer XLM-RoBERTa (SentencePiece/Unigram). ++ # Présent aussi sur NomicBertModel ; remonté en base pour que tout modèle ++ # déclaré "BertModel" mais à tokenizer Unigram (e5 multilingue, CamemBERT) marche. ++ def _is_tokenizer_xlmroberta(self) -> bool: ++ tj = self.dir_model / "tokenizer.json" ++ if not tj.is_file(): ++ return False ++ try: ++ with open(tj) as f: ++ toktyp = json.load(f)["model"]["type"] ++ except Exception: ++ return False ++ return toktyp == "Unigram" ++ + def _xlmroberta_tokenizer_init(self) -> None: + # we need the pad_token_id to know how to chop down position_embd matrix + if (pad_token_id := self.hparams.get("pad_token_id")) is not None: