# Intégration RAG (embeddings) Kazeia-Engine — Guide développeur **Date** : 09/06/2026 **Cible** : exposer `texte → vecteur de phrase poolé` pour le RAG de Kazeia mobile, sans toucher au chemin Speaker/TTS. **Statut** : entrypoint JNI livré + validé (parité bit-identique vs `llama-embedding` du fork). > ⚠️ **Même bloqueur SELinux que LLM/TTS** (cf `REBUILD_CPU_ONLY.md`). L'embedder linke `libllama.so` > qui dépend de `libggml-hexagon.so` ouvrant `/dev/fastrpc-cdsp` au `llama_backend_init()` → SIGABRT > dans un APK `untrusted_app`. `n_gpu_layers=0` ne suffit PAS (c'est l'énumération des devices qui > plante). **L'embedder DOIT rouler sur le `libllama` CPU-only** (`GGML_HEXAGON=OFF`), comme LLM/TTS in-app. > Les tests ci-dessous passent en `adb shell` (uid 2000, SELinux OK) mais c'est un FAUX positif pour l'app. --- ## API ```kotlin // EngineJni (dist/jni/EngineLlmEngine.kt) external fun loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long external fun embedText(handle: Long, text: String): FloatArray? // float[n_embd] L2-normalisé, ou null external fun freeEmbedder(handle: Long) // Wrapper fourni val emb = EmbedderEngine("$dir/multilingual-e5-small.gguf", nThreads = 4, pooling = -1) val v = emb.embed("query: j'ai du mal à dormir") // FloatArray(384), norme 1.0 emb.release() ``` `pooling` : **-1 = défaut du modèle (recommandé)** ; 1 = MEAN (e5) ; 2 = CLS (bge). 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 — TRANCHÉ : multilingual-e5-small (voie a) **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é. ### 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. (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. --- ## Validation effectuée (tests d'acceptation du spec) Sur tablette SM8750, `adb shell`, nomic-embed-text-v1.5 Q4_K_M, CPU : | Test | Résultat | |---|---| | Arch BERT/nomic-bert charge end-to-end | ✅ vecteur produit | | `embedText` renvoie `float[n_embd]` | ✅ 768 (nomic) / 384 attendu (e5-small) | | **Déterminisme** (2 runs, même texte) | ✅ **max\|diff\| = 0.0 bit-identique** | | **Norme L2 ≈ 1.0** | ✅ 1.0000 | | Cohérence sémantique (proche > loin) | ✅ avec bons préfixes (model-dépendant, cf ci-dessus) | | **Parité référence** vs `llama-embedding` | ✅ **max\|diff\| = 5e-7, cos = 1.000000** | La parité prouve que le chemin C++ (`tokenize add_special` + batch manuel `logits=1` + `llama_decode` + `llama_get_embeddings_seq` + L2) est **bit-identique** à l'outil de référence du fork. Confirmé : BERT encoder-only est routé via `llama_decode` (pas `llama_encode`) dans ce fork — `examples/embedding/embedding.cpp` ne bascule sur erreur que pour les modèles encoder **ET** decoder (T5). --- ## Contraintes techniques (impératifs) 1. **`n_ctx = n_batch = n_ubatch = 512`** : le pooling MEAN/CLS exige toute la séquence dans un seul ubatch. Cap à 512 tokens (chunker plus court en amont). 2. **CPU only** (`n_gpu_layers=0`) + **build CPU-only** (cf bloqueur SELinux). 3. **Pas de réutilisation** de `prefillEmbeds`/`decodeEmbed` (I/O float du Talker TTS, pas un embedding de phrase poolé). --- ## Build Rebuild `libkazeia_engine.so` (les 3 symboles `loadEmbedder`/`embedText`/`freeEmbedder` y sont exportés). **Aucune nouvelle dépendance** (tout est dans libllama). Pour l'in-app : build CPU-only (`REBUILD_CPU_ONLY.md`). Livrer la `.so` + un GGUF d'embedding **multilingual-e5-small** versionné. --- ## Reste côté app (hors moteur, pour info) Schéma SQLite (`chunk_text`, `source`, `embedding BLOB`) dans SQLCipher → ingestion admin (chunk sémantique + `embedText`) → runtime : matrice en RAM → brute-force cosinus NEON top-k 3-5 + seuil → bloc « CONTEXTE » budgété en tokens dans le system prompt. Pas de vector-DB, pas d'ANN, pas d'ONNX. Le RAG ne porte jamais la gestion de crise.