8.1 KiB
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 linkelibllama.soqui dépend delibggml-hexagon.soouvrant/dev/fastrpc-cdspaullama_backend_init()→ SIGABRT dans un APKuntrusted_app.n_gpu_layers=0ne suffit PAS (c'est l'énumération des devices qui plante). L'embedder DOIT rouler sur lelibllamaCPU-only (GGML_HEXAGON=OFF), comme LLM/TTS in-app. Les tests ci-dessous passent enadb shell(uid 2000, SELinux OK) mais c'est un FAUX positif pour l'app.
API
// 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 deplibggml-hexagon→ pas de FastRPC, pas de bloqueur SELinux. - À pousser dans
jniLibs/arm64-v8a/avec les libs CPU-onlydist/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 archbert+ tokenizer UGM charge sans souci. Pas de trou architectural. - Converter :
conversion/bert.pycontient déjà toute la machinerie XLM-R (_xlmroberta_tokenizer_init,_xlmroberta_set_vocabqui écrittokenizer_model="t5"+ vocab unigram). Mais seulNomicBertModella câblait.multilingual-e5-smalldéclarearchitectures:["BertModel"]+ tokenizerUnigram→ tombait dansBertModel.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
# 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)
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).- CPU only (
n_gpu_layers=0) + build CPU-only (cf bloqueur SELinux). - 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.