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:
Richard Loyer 2026-06-09 22:46:02 +02:00
parent 5cc8c22fc9
commit 655d65a655
3 changed files with 115 additions and 8 deletions

View File

@ -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.
---

BIN
dist/b-jni-cpu/libkazeia_engine.so vendored Executable file

Binary file not shown.

View File

@ -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: