From 5cc8c22fc9cf6f717faa0166b00636f023c1f732 Mon Sep 17 00:00:00 2001 From: Richard Loyer Date: Tue, 9 Jun 2026 22:02:30 +0200 Subject: [PATCH] =?UTF-8?q?chantier=20RAG=20#1=20:=20entrypoint=20embeddin?= =?UTF-8?q?gs=20(texte=20->=20vecteur=20pool=C3=A9)=20pour=20le=20RAG=20mo?= =?UTF-8?q?bile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec /opt/Kazeia/docs/RAG_EMBEDDINGS_ENGINE_SPEC.md implémentée. Préflight (point #2 du spec = LE bloqueur potentiel) : - Arch BERT/nomic-bert/gemma-embedding TOUJOURS présentes dans le fork ql (llama-arch.cpp + src/models/bert.cpp), malgré les mods Qwen/TTS. - Outil de référence examples/embedding/embedding.cpp dispo = source de vérité. - Confirmé : BERT encoder-only routé via llama_decode (PAS llama_encode) dans ce fork ; le code ne bascule sur erreur que pour encoder+decoder (T5). Ajouts kazeia_engine_jni.cpp (handle KEmbedder SÉPARÉ du KEngine, CPU pur) : - loadEmbedder(path, nThreads, pooling) : embeddings=true, pooling MEAN/CLS/auto, n_ctx=n_batch=n_ubatch=512 (contrainte pooling = séquence dans 1 ubatch), n_gpu_layers=0, refus explicite des modèles encoder-decoder. - embedText(h, text) : tokenize add_special + batch logits=1 + llama_decode + llama_get_embeddings_seq + normalisation L2 -> float[n_embd] ou null. - freeEmbedder(h). - Reproduit À L'IDENTIQUE examples/embedding/embedding.cpp. Bindings Kotlin (EngineLlmEngine.kt) : loadEmbedder/embedText/freeEmbedder + wrapper EmbedderEngine(model, nThreads, pooling).embed(text). Validation sur tablette SM8750 (adb shell, nomic-embed Q4_K_M, CPU) : - Déterminisme : max|diff| = 0.0 bit-identique sur 2 runs - Norme L2 = 1.0000 - PARITÉ RÉFÉRENCE : mon embedText vs llama-embedding = max|diff| 5e-7, cos 1.000000 (probe rag_probe.cpp répliquant le chemin exact) - Cohérence sémantique : OK avec bons préfixes FINDING modèle (point #1 du spec confirmé par mesure) : nomic-embed est anglo-centré, marge FR fine (cos insomnie 0.556 vs tarte 0.517 = 0.04, et ordre s'inverse avec mauvais préfixe). => SHIPPER multilingual-e5-small comme le spec l'exige, pas nomic. Le code est agnostique au modèle (n_embd auto). ⚠ Bloqueur SELinux identique LLM/TTS : l'embedder linke libllama->libggml-hexagon qui ouvre /dev/fastrpc-cdsp au backend_init -> crash untrusted_app. DOIT tourner sur le build CPU-only (GGML_HEXAGON=OFF). Documenté dans le code + RAG_INTEGRATION.md. Doc : dist/RAG_INTEGRATION.md (format des 3 autres) + overview à jour (4 sous-systèmes). --- dist/KAZEIA_ENGINE_OVERVIEW.md | 3 +- dist/RAG_INTEGRATION.md | 88 ++++++++++++++++++++++++++ dist/jni/EngineLlmEngine.kt | 23 +++++++ dist/jni/kazeia_engine_jni.cpp | 109 +++++++++++++++++++++++++++++++++ 4 files changed, 222 insertions(+), 1 deletion(-) create mode 100644 dist/RAG_INTEGRATION.md diff --git a/dist/KAZEIA_ENGINE_OVERVIEW.md b/dist/KAZEIA_ENGINE_OVERVIEW.md index 89c53ed..4451553 100644 --- a/dist/KAZEIA_ENGINE_OVERVIEW.md +++ b/dist/KAZEIA_ENGINE_OVERVIEW.md @@ -12,7 +12,8 @@ Pour intégrer dans l'app Kazeia, lis ces 3 docs (un par sous-système) : |---|---|---|---| | **STT** (Whisper-Small NPU) | [`STT_INTEGRATION.md`](STT_INTEGRATION.md) | `libkazeia_stt.so` 183 KB | `SttEngine.kt` | | **TTS** (Qwen3-TTS + clonage vocal) | [`TTS_INTEGRATION.md`](TTS_INTEGRATION.md) | `libkazeia_tts.so` 929 KB | `TtsEngine.kt` | -| **LLM** (Qwen3.5 hybride / Qwen3 dense) | [`LLM_INTEGRATION.md`](LLM_INTEGRATION.md) | `libkazeia_engine.so` 60 KB | `EngineLlmEngine.kt` | +| **LLM** (Qwen3.5 hybride / Qwen3 dense) | [`LLM_INTEGRATION.md`](LLM_INTEGRATION.md) | `libkazeia_engine.so` 64 KB | `EngineLlmEngine.kt` | +| **RAG** (embeddings e5/bge, CPU) | [`RAG_INTEGRATION.md`](RAG_INTEGRATION.md) | `libkazeia_engine.so` (mêmes symboles) | `EmbedderEngine` | Chaque doc a la même structure : libs à pousser, façade Kotlin, exemples, codes d'erreur, bench standalone, checklist d'intégration, pièges connus, perf détaillée. diff --git a/dist/RAG_INTEGRATION.md b/dist/RAG_INTEGRATION.md new file mode 100644 index 0000000..7f3f000 --- /dev/null +++ b/dist/RAG_INTEGRATION.md @@ -0,0 +1,88 @@ +# 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). + +--- + +## Modèle d'embedding — décision critique (point #1 du spec) + +| | 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** | + +**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.** + +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. + +--- + +## 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. diff --git a/dist/jni/EngineLlmEngine.kt b/dist/jni/EngineLlmEngine.kt index 6b7227d..f49251d 100644 --- a/dist/jni/EngineLlmEngine.kt +++ b/dist/jni/EngineLlmEngine.kt @@ -24,9 +24,32 @@ class EngineJni { external fun prefillEmbeds(h: Long, embdFlat: FloatArray, t: Int, outHidden: FloatArray): Int external fun decodeEmbed(h: Long, embd: FloatArray, outLogits: FloatArray, outHidden: FloatArray): Int + // -- Embeddings (RAG) : modèle dédié BERT-like (e5/bge), handle SÉPARÉ du LLM/TTS, CPU pur. + // pooling: -1 = défaut du modèle (recommandé) ; 1 = MEAN (e5) ; 2 = CLS (bge). + // embedText renvoie un float[n_embd] L2-normalisé (cosinus = dot product), ou null si échec. + // Préfixes e5 ("query:" / "passage:") = côté appelant, PAS ici. + external fun loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long + external fun embedText(handle: Long, text: String): FloatArray? + external fun freeEmbedder(handle: Long) + companion object { init { System.loadLibrary("kazeia_engine") } } } +// Wrapper RAG-friendly. Un seul embedder par process (le modèle est petit ~120 MB). +// Le MÊME GGUF doit servir à l'ingestion ET à la requête (vecteurs incompatibles sinon) : +// versionne le modèle dans le contrat de distribution. +class EmbedderEngine(model: String, nThreads: Int = 4, pooling: Int = -1) { + private val jni = EngineJni() + private val h = jni.loadEmbedder(model, nThreads, pooling) + init { require(h != 0L) { "Kazeia-Engine: échec du chargement de l'embedder ($model)" } } + + /** Texte -> vecteur L2-normalisé (float[n_embd]). Déterministe. */ + fun embed(text: String): FloatArray = + jni.embedText(h, text) ?: error("embedText a échoué pour: \"${text.take(40)}\"") + + fun release() = jni.freeEmbedder(h) +} + class EngineLlmEngine(model: String, ctx: Int = 4096, nThreads: Int = 6) { private val jni = EngineJni() private val h = jni.load(model, ctx, nThreads) diff --git a/dist/jni/kazeia_engine_jni.cpp b/dist/jni/kazeia_engine_jni.cpp index c55e412..a548c94 100644 --- a/dist/jni/kazeia_engine_jni.cpp +++ b/dist/jni/kazeia_engine_jni.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include "llama.h" #include "ggml-backend.h" #include "gguf.h" @@ -326,3 +327,111 @@ Java_com_kazeia_llm_EngineJni_decodeEmbed(JNIEnv* e, jobject, jlong h, e->SetFloatArrayRegion(out_hidden, 0, n_embd, hidden.data()); return 0; } + +// ============================================================================ +// EMBEDDINGS (RAG) : modèle dédié BERT-like (e5/bge/gemma-embedding), CPU pur, +// pooling de séquence. Handle SÉPARÉ du KEngine Speaker/TTS — aucun HTP/HMX. +// +// ⚠ Bloqueur SELinux connu (cf REBUILD_CPU_ONLY.md / project_engine_selinux_fastrpc_blocker) : +// libllama linke libggml-hexagon qui ouvre /dev/fastrpc-cdsp à l'init backend -> +// SIGABRT dans un APK untrusted_app. L'embedder DOIT rouler sur le libllama +// CPU-only (GGML_HEXAGON=OFF), comme LLM/TTS in-app. n_gpu_layers=0 ne suffit pas +// à éviter le crash : c'est l'ÉNUMÉRATION des devices au backend_init qui plante. +// +// Chemin reproduit à l'identique de examples/embedding/embedding.cpp (source de +// vérité du fork) : llama_decode (PAS llama_encode — BERT encoder-only y est routé +// via decode), pooling != NONE -> llama_get_embeddings_seq, normalisation L2. +// ============================================================================ +struct KEmbedder { + llama_model* m; + llama_context* c; + const llama_vocab* v; + int n_embd; +}; + +// pooling: -1 = défaut du modèle (recommandé) ; 1 = MEAN (e5) ; 2 = CLS (bge) +extern "C" JNIEXPORT jlong JNICALL +Java_com_kazeia_llm_EngineJni_loadEmbedder(JNIEnv* e, jobject, jstring path, jint nThreads, jint pooling) { + const char* pc = e->GetStringUTFChars(path, 0); + std::string p(pc); e->ReleaseStringUTFChars(path, pc); + + llama_backend_init(); // idempotent + auto mp = llama_model_default_params(); mp.n_gpu_layers = 0; // CPU only + llama_model* m = llama_model_load_from_file(p.c_str(), mp); + if (!m) { fprintf(stderr, "kazeia-engine: EMBEDDER load FAIL (%s)\n", p.c_str()); return 0; } + + // Encoder-decoder (T5) non supporté pour l'embedding (cf embedding.cpp). + // BERT pur (encoder-only) est routé via llama_decode dans ce fork -> OK. + if (llama_model_has_encoder(m) && llama_model_has_decoder(m)) { + fprintf(stderr, "kazeia-engine: EMBEDDER refuse un modèle encoder-decoder (%s)\n", p.c_str()); + llama_model_free(m); return 0; + } + + auto cp = llama_context_default_params(); + cp.n_threads = (nThreads > 0) ? nThreads : 4; + cp.n_threads_batch = cp.n_threads; + cp.embeddings = true; // <-- clé + cp.pooling_type = (pooling == 1) ? LLAMA_POOLING_TYPE_MEAN + : (pooling == 2) ? LLAMA_POOLING_TYPE_CLS + : LLAMA_POOLING_TYPE_UNSPECIFIED; // défaut = métadonnées du modèle + // Pooling MEAN/CLS exige que TOUTE la séquence tienne dans un seul ubatch : + cp.n_ctx = 512; cp.n_batch = 512; cp.n_ubatch = 512; // chunks <= 512 tokens + // (ne PAS réutiliser make_ctx : pas de flash_attn ni de KV f16 ici) + llama_context* c = llama_init_from_model(m, cp); + if (!c) { fprintf(stderr, "kazeia-engine: EMBEDDER ctx FAIL (%s)\n", p.c_str()); llama_model_free(m); return 0; } + + auto* k = new KEmbedder{ m, c, llama_model_get_vocab(m), llama_model_n_embd(m) }; + fprintf(stderr, "kazeia-engine: EMBEDDER chargé (%s, n_embd=%d, pooling=%d, threads=%d)\n", + p.c_str(), k->n_embd, pooling, cp.n_threads); + return (jlong) k; +} + +// texte -> float[n_embd] L2-normalisé. nullptr en cas d'échec. +extern "C" JNIEXPORT jfloatArray JNICALL +Java_com_kazeia_llm_EngineJni_embedText(JNIEnv* e, jobject, jlong h, jstring text) { + auto* k = (KEmbedder*) h; + if (!k) return nullptr; + const char* tc = e->GetStringUTFChars(text, 0); + std::string s(tc ? tc : ""); e->ReleaseStringUTFChars(text, tc); + + // 1) tokenize (add_special=true -> CLS/BOS + SEP/EOS selon le modèle) + int n = -llama_tokenize(k->v, s.c_str(), (int)s.size(), nullptr, 0, true, true); + if (n <= 0) return nullptr; + if (n > 512) n = 512; // garde-fou ubatch (chunks plus courts en amont) + std::vector toks(n); + if (llama_tokenize(k->v, s.c_str(), (int)s.size(), toks.data(), n, true, true) < 0) return nullptr; + + // 2) batch : tous les tokens, seq 0, sortie activée sur chaque token (requis pour le pooling) + llama_memory_clear(llama_get_memory(k->c), true); + llama_batch b = llama_batch_init(n, 0, 1); + for (int i = 0; i < n; ++i) { + b.token[i] = toks[i]; b.pos[i] = i; + b.n_seq_id[i] = 1; b.seq_id[i][0] = 0; b.logits[i] = 1; + } + b.n_tokens = n; + if (llama_decode(k->c, b) != 0) { llama_batch_free(b); return nullptr; } + + // 3) embedding poolé de la séquence 0 + const float* emb = llama_get_embeddings_seq(k->c, 0); + if (!emb) { llama_batch_free(b); return nullptr; } + + // 4) normalisation L2 (cosinus = produit scalaire côté Android) + std::vector out(k->n_embd); + double norm = 0.0; for (int i = 0; i < k->n_embd; ++i) norm += (double)emb[i] * emb[i]; + norm = norm > 0 ? 1.0 / std::sqrt(norm) : 0.0; + for (int i = 0; i < k->n_embd; ++i) out[i] = (float)(emb[i] * norm); + llama_batch_free(b); + + jfloatArray arr = e->NewFloatArray(k->n_embd); + e->SetFloatArrayRegion(arr, 0, k->n_embd, out.data()); + return arr; +} + +extern "C" JNIEXPORT void JNICALL +Java_com_kazeia_llm_EngineJni_freeEmbedder(JNIEnv*, jobject, jlong h) { + auto* k = (KEmbedder*) h; + if (!k) return; + if (k->c) llama_free(k->c); + if (k->m) llama_model_free(k->m); + delete k; +}