chantier RAG #2 : modèle FR tranché (e5-small voie a) + .so CPU-only + patch converter
Réponses aux 3 demandes du dev : #1 .so CPU-only livrée : dist/b-jni-cpu/libkazeia_engine.so (64 KB). DT_NEEDED = libllama/libggml/libggml-base (CPU-only dist/lib-cpu/) + système, AUCUNE dep libggml-hexagon -> pas de FastRPC -> pas de bloqueur SELinux. Validée tablette : charge e5+nomic, n_embd auto, déterministe, norme 1.0. #2 modèle FR tranché = VOIE A (multilingual-e5-small), blocage XLM-R levé. Question décisive "petit patch ou trou architectural ?" -> PETIT PATCH, mesuré : - runtime C++ a déjà UGM (unigram/SPM), découplé de l'arch (tokenizer_model=t5 -> LLAMA_VOCAB_TYPE_UGM). Pas de trou. - converter a déjà _xlmroberta_set_vocab (écrit t5/UGM) mais seul NomicBert le câblait. e5 declare architectures=[BertModel]+tokenizer Unigram -> tombait en BertModel.set_vocab -> chemin BPE -> get_vocab_base_pre échoue (hash inconnu). - fix = wiring dans BertModel (3 méthodes : __init__ détecte Unigram, set_vocab route xlmroberta, modify_tensors choppe positions) calqué sur Roberta/Nomic. Patch : dist/patches/bert_xlmroberta_unigram.diff (~25 lignes, à appliquer dans le fork ql par le dev — non commité dans ql par respect AGENTS.md). PROUVÉ end-to-end : converti e5-small-f16.gguf (384-dim) via converter patché, chargé sur build CPU-only tablette : cos(dormir,insomnie)=0.909 > cos(dormir,tarte)=0.821, marge FR 0.089 (nomic 0.04 -> e5 2.2x mieux), ranking propre, norme 1.0, déterministe. Voie c (nomic stopgap) documentée si besoin de dérisquer l'E2E in-app d'abord. #3 interface FIGÉE confirmée : loadEmbedder/embedText/freeEmbedder inchangées. Bit-identique garanti sur le MÊME build (CPU vs HTP divergent ~1.5e-2 sur Q4_K_M ; sans effet car ingestion+requête tournent sur le même build in-app). Doc dist/RAG_INTEGRATION.md mise à jour (décision modèle, patch, reproduction GGUF, interface figée, .so CPU-only). GGUF e5 = artefact distribution (non versionné git).
This commit is contained in:
parent
5cc8c22fc9
commit
655d65a655
|
|
@ -30,20 +30,64 @@ emb.release()
|
||||||
|
|
||||||
Handle **séparé** du LLM Speaker/TTS. CPU pur, aucun HTP. Déterministe (pas d'échantillonnage).
|
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 |
|
**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é.
|
||||||
|---|---|
|
|
||||||
| **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** |
|
|
||||||
|
|
||||||
**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) :
|
Impératifs (contrat de distribution) :
|
||||||
- **Le MÊME GGUF à l'ingestion ET à la requête** (vecteurs incompatibles sinon). Versionner le n° de modèle.
|
- **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.
|
- **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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -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:
|
||||||
Loading…
Reference in New Issue