Kazeia-engine/dist/RAG_INTEGRATION.md

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

// 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

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