133 lines
8.1 KiB
Markdown
133 lines
8.1 KiB
Markdown
# 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.
|