feat(rag): module RAG on-device (indépendant de l'engine), flag OFF par défaut
Module com.kazeia.rag, conçu d'après le RAG PC de Damien mais porté on-device : - Embedder : interface + FakeEmbedder (déterministe, test hors engine) + EngineEmbedder (réel, branché sur EngineJni.loadEmbedder/embedText — inerte via UnsatisfiedLinkError tant que la lib n'expose pas ces symboles). - VectorIndex : recherche exhaustive cosinus (produit scalaire, vecteurs L2), top-k + seuil. Pas d'ANN : injustifié à l'échelle (10^2–10^3 chunks). - Chunker : découpage par phrases + overlap, tag [Source: x] (mieux que le découpage au caractère du PC). - RagDb : SQLite (vecteurs en BLOB float32 LE), garde-fou modèle+dim. - Rag : façade ingest/loadIndex/retrieve -> bloc CONTEXTE budgété en tokens. Bindings JNI embedder ajoutés à EngineJni (loadEmbedder/embedText/freeEmbedder). Flag ragEnabled (ConfigStore + provider rag_enabled), DEFAULT OFF -> production inchangée. Câblage live dans KazeiaService : si activé + embedder prêt + contexte au-dessus du seuil, préfixe le bloc CONTEXTE au prompt patient (défensif, le RAG ne porte jamais la gestion de crise). Validé device via l'intent `rag_test` (FakeEmbedder) : ingest 2 docs -> SQLite -> index -> retrieve classe correctement (requête sommeil -> doc insomnie en tête). Démarrage prod : "[RAG] désactivé (config)", rag_enabled=0. Aucune régression. Reste (dépend de l'engine) : embedText livré + modèle e5/bge FR + ingestion d'un vrai corpus côté admin + entrée catalogue. Cf docs/RAG_EMBEDDINGS_ENGINE_SPEC.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
6be3e47148
commit
3eab29beb2
|
|
@ -47,7 +47,11 @@ class ConfigStore private constructor(private val file: File) {
|
||||||
/** Backend TTS : "prod" = Qwen3TtsEngine legacy, "lib" = libkazeia_tts unifié.
|
/** Backend TTS : "prod" = Qwen3TtsEngine legacy, "lib" = libkazeia_tts unifié.
|
||||||
* Defaults safe = "prod". Bascule "lib" nécessite libllama.so fork ql + retrait
|
* Defaults safe = "prod". Bascule "lib" nécessite libllama.so fork ql + retrait
|
||||||
* des classes legacy `tts_talker_cpu`/`tts_cp_cpu` (incompat ABI struct). */
|
* des classes legacy `tts_talker_cpu`/`tts_cp_cpu` (incompat ABI struct). */
|
||||||
val ttsEngine: String = "prod"
|
val ttsEngine: String = "prod",
|
||||||
|
/** RAG (récupération de contexte injecté au prompt). Default OFF : tant que
|
||||||
|
* kazeia-engine n'expose pas embedText + qu'aucun corpus n'est ingéré,
|
||||||
|
* l'activer est inerte (l'embedder n'est pas prêt). Pilotable via admin. */
|
||||||
|
val ragEnabled: Boolean = false
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|
@ -146,6 +150,7 @@ class ConfigStore private constructor(private val file: File) {
|
||||||
root.put("stt_engine", cfg.sttEngine)
|
root.put("stt_engine", cfg.sttEngine)
|
||||||
root.put("llm_engine", cfg.llmEngine)
|
root.put("llm_engine", cfg.llmEngine)
|
||||||
root.put("tts_engine", cfg.ttsEngine)
|
root.put("tts_engine", cfg.ttsEngine)
|
||||||
|
root.put("rag_enabled", cfg.ragEnabled)
|
||||||
root.put("speaker", JSONObject().apply {
|
root.put("speaker", JSONObject().apply {
|
||||||
put("model_id", cfg.speaker.modelId)
|
put("model_id", cfg.speaker.modelId)
|
||||||
put("system_prompt", cfg.speaker.systemPrompt)
|
put("system_prompt", cfg.speaker.systemPrompt)
|
||||||
|
|
@ -168,6 +173,7 @@ class ConfigStore private constructor(private val file: File) {
|
||||||
sttEngine = root.optString("stt_engine", defaults.sttEngine),
|
sttEngine = root.optString("stt_engine", defaults.sttEngine),
|
||||||
llmEngine = root.optString("llm_engine", defaults.llmEngine),
|
llmEngine = root.optString("llm_engine", defaults.llmEngine),
|
||||||
ttsEngine = root.optString("tts_engine", defaults.ttsEngine),
|
ttsEngine = root.optString("tts_engine", defaults.ttsEngine),
|
||||||
|
ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled),
|
||||||
speaker = root.optJSONObject("speaker")?.let { js ->
|
speaker = root.optJSONObject("speaker")?.let { js ->
|
||||||
ModelConfig(
|
ModelConfig(
|
||||||
modelId = js.optString("model_id", defaults.speaker.modelId),
|
modelId = js.optString("model_id", defaults.speaker.modelId),
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,13 @@ class EngineJni {
|
||||||
external fun reset(h: Long)
|
external fun reset(h: Long)
|
||||||
external fun free(h: Long)
|
external fun free(h: Long)
|
||||||
|
|
||||||
|
// -- Embeddings RAG (modèle e5/bge dédié). Cf docs/RAG_EMBEDDINGS_ENGINE_SPEC.md.
|
||||||
|
// Tant que la lib ne fournit pas ces symboles, l'appel jette UnsatisfiedLinkError,
|
||||||
|
// attrapé par EngineEmbedder -> RAG se désactive proprement.
|
||||||
|
external fun loadEmbedder(ggufPath: String, nThreads: Int, pooling: Int): Long // handle, 0 = échec
|
||||||
|
external fun embedText(handle: Long, text: String): FloatArray? // vecteur L2-normalisé
|
||||||
|
external fun freeEmbedder(handle: Long)
|
||||||
|
|
||||||
// -- TTS Talker (Qwen3-TTS) : I/O en embeddings, pas en tokens (vocab=3072 codes audio, pas de BPE).
|
// -- TTS Talker (Qwen3-TTS) : I/O en embeddings, pas en tokens (vocab=3072 codes audio, pas de BPE).
|
||||||
// Le caller (cf. TalkerEngine.kt) construit les embeds de prefill (text+x-vector) et de step
|
// Le caller (cf. TalkerEngine.kt) construit les embeds de prefill (text+x-vector) et de step
|
||||||
// (sum 16 codecs + tts_pad + trailing_text_hidden), fait l'échantillonnage, et compose le
|
// (sum 16 codecs + tts_pad + trailing_text_hidden), fait l'échantillonnage, et compose le
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,77 @@
|
||||||
|
package com.kazeia.rag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Découpage sémantique léger (meilleur que le découpage au caractère brut de la
|
||||||
|
* version PC) : on segmente par phrases, puis on agglomère jusqu'à [maxChars]
|
||||||
|
* avec un [overlap] de fin de chunk reporté au suivant pour ne pas couper le
|
||||||
|
* sens. Chaque chunk est préfixé `[Source: <nom>]` pour que le LLM puisse
|
||||||
|
* sourcer. Vise des chunks < ~512 tokens (contrainte ubatch de l'embedder).
|
||||||
|
*/
|
||||||
|
object Chunker {
|
||||||
|
|
||||||
|
fun chunk(
|
||||||
|
text: String,
|
||||||
|
source: String,
|
||||||
|
maxChars: Int = 1200, // ~300 tokens
|
||||||
|
overlap: Int = 200
|
||||||
|
): List<String> {
|
||||||
|
val clean = text.replace(Regex("[ \\t]+"), " ").replace(Regex("\\n{3,}"), "\n\n").trim()
|
||||||
|
if (clean.isEmpty()) return emptyList()
|
||||||
|
|
||||||
|
val sentences = splitSentences(clean)
|
||||||
|
val out = ArrayList<String>()
|
||||||
|
val buf = StringBuilder()
|
||||||
|
|
||||||
|
fun flush() {
|
||||||
|
if (buf.isNotBlank()) out.add("[Source: $source] ${buf.toString().trim()}")
|
||||||
|
buf.setLength(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (s in sentences) {
|
||||||
|
if (buf.isNotEmpty() && buf.length + s.length + 1 > maxChars) {
|
||||||
|
val carried = tail(buf.toString(), overlap)
|
||||||
|
flush()
|
||||||
|
if (carried.isNotEmpty()) buf.append(carried).append(' ')
|
||||||
|
}
|
||||||
|
// Phrase plus longue qu'un chunk entier : on la coupe durement.
|
||||||
|
if (s.length > maxChars) {
|
||||||
|
flush()
|
||||||
|
var i = 0
|
||||||
|
while (i < s.length) {
|
||||||
|
val end = minOf(i + maxChars, s.length)
|
||||||
|
out.add("[Source: $source] ${s.substring(i, end).trim()}")
|
||||||
|
i += maxChars - overlap
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
buf.append(s).append(' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Découpe naïve mais robuste en phrases (FR : . ! ? … ; retours ligne). */
|
||||||
|
private fun splitSentences(text: String): List<String> {
|
||||||
|
val parts = ArrayList<String>()
|
||||||
|
val sb = StringBuilder()
|
||||||
|
for (c in text) {
|
||||||
|
sb.append(c)
|
||||||
|
if (c == '.' || c == '!' || c == '?' || c == '\n') {
|
||||||
|
val t = sb.toString().trim()
|
||||||
|
if (t.isNotEmpty()) parts.add(t)
|
||||||
|
sb.setLength(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val rest = sb.toString().trim()
|
||||||
|
if (rest.isNotEmpty()) parts.add(rest)
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derniers [n] caractères en s'alignant sur un début de mot. */
|
||||||
|
private fun tail(s: String, n: Int): String {
|
||||||
|
if (s.length <= n) return s.trim()
|
||||||
|
val cut = s.length - n
|
||||||
|
val sp = s.indexOf(' ', cut)
|
||||||
|
return (if (sp in 0 until s.length) s.substring(sp) else s.substring(cut)).trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,116 @@
|
||||||
|
package com.kazeia.rag
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import kotlin.math.sqrt
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Abstraction du moteur d'embedding pour le RAG. La seule partie qui dépend de
|
||||||
|
* kazeia-engine. Les vecteurs renvoyés DOIVENT être normalisés L2 (le cosinus
|
||||||
|
* se réduit alors à un produit scalaire côté [VectorIndex]).
|
||||||
|
*/
|
||||||
|
interface Embedder {
|
||||||
|
/** Dimension des vecteurs (0 tant qu'inconnue/non prête). */
|
||||||
|
val dim: Int
|
||||||
|
/** true si l'embedder peut réellement produire des vecteurs. */
|
||||||
|
val isReady: Boolean
|
||||||
|
/** Texte -> vecteur L2-normalisé de taille [dim]. Renvoie null si indisponible. */
|
||||||
|
fun embed(text: String): FloatArray?
|
||||||
|
fun close() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Normalise un vecteur en place (L2) et le renvoie. */
|
||||||
|
internal fun l2normalize(v: FloatArray): FloatArray {
|
||||||
|
var n = 0.0
|
||||||
|
for (x in v) n += x.toDouble() * x
|
||||||
|
if (n > 0.0) {
|
||||||
|
val inv = (1.0 / sqrt(n)).toFloat()
|
||||||
|
for (i in v.indices) v[i] *= inv
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedder réel basé sur kazeia-engine (libkazeia_engine.so : loadEmbedder /
|
||||||
|
* embedText / freeEmbedder — cf docs/RAG_EMBEDDINGS_ENGINE_SPEC.md).
|
||||||
|
*
|
||||||
|
* Tant que la lib ne fournit pas encore ces entrypoints, [isReady] reste false
|
||||||
|
* (UnsatisfiedLinkError attrapé), le RAG se désactive proprement et n'injecte
|
||||||
|
* rien. Aucun impact sur la production. Dès que la .so est livrée, ça marche
|
||||||
|
* sans changement Kotlin.
|
||||||
|
*
|
||||||
|
* @param pooling -1 = défaut du modèle (recommandé), 1 = MEAN (e5), 2 = CLS (bge).
|
||||||
|
*/
|
||||||
|
class EngineEmbedder(
|
||||||
|
private val modelPath: String,
|
||||||
|
private val nThreads: Int = 4,
|
||||||
|
private val pooling: Int = -1,
|
||||||
|
private val queryPrefix: String = "", // e5 : "query: "
|
||||||
|
private val passagePrefix: String = "", // e5 : "passage: "
|
||||||
|
private val log: (String) -> Unit = {}
|
||||||
|
) : Embedder {
|
||||||
|
|
||||||
|
private val jni = com.kazeia.llm.EngineJni()
|
||||||
|
@Volatile private var handle: Long = 0L
|
||||||
|
@Volatile override var dim: Int = 0
|
||||||
|
private set
|
||||||
|
|
||||||
|
init { tryLoad() }
|
||||||
|
|
||||||
|
private fun tryLoad() {
|
||||||
|
try {
|
||||||
|
handle = jni.loadEmbedder(modelPath, nThreads, pooling)
|
||||||
|
if (handle != 0L) {
|
||||||
|
// Sonde la dimension via un embed neutre.
|
||||||
|
val probe = jni.embedText(handle, "test")
|
||||||
|
dim = probe?.size ?: 0
|
||||||
|
log("[RAG] embedder chargé: $modelPath (dim=$dim, pooling=$pooling)")
|
||||||
|
} else {
|
||||||
|
log("[RAG] embedder load FAIL (handle=0): $modelPath")
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
// UnsatisfiedLinkError tant que la lib n'expose pas embedText, ou modèle absent.
|
||||||
|
log("[RAG] embedder indisponible (${e.javaClass.simpleName}: ${e.message}) — RAG désactivé")
|
||||||
|
handle = 0L; dim = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override val isReady: Boolean get() = handle != 0L && dim > 0
|
||||||
|
|
||||||
|
fun embedQuery(text: String): FloatArray? = embed(queryPrefix + text)
|
||||||
|
fun embedPassage(text: String): FloatArray? = embed(passagePrefix + text)
|
||||||
|
|
||||||
|
override fun embed(text: String): FloatArray? {
|
||||||
|
if (handle == 0L) return null
|
||||||
|
return try {
|
||||||
|
jni.embedText(handle, text)?.also { dim = it.size }
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
Log.w("RAG", "embedText fail: ${e.message}"); null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun close() {
|
||||||
|
if (handle != 0L) { try { jni.freeEmbedder(handle) } catch (_: Throwable) {} ; handle = 0L }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Embedder factice DÉTERMINISTE pour tester toute la chaîne RAG (chunking,
|
||||||
|
* stockage, index, retrieval, injection) SANS kazeia-engine. Sac de trigrammes
|
||||||
|
* de caractères haché dans [dim] buckets puis normalisé : même texte -> même
|
||||||
|
* vecteur, textes partageant des sous-chaînes -> cosinus plus élevé (assez de
|
||||||
|
* signal pour valider le classement). N'a AUCUNE valeur sémantique réelle —
|
||||||
|
* ne jamais l'utiliser en production.
|
||||||
|
*/
|
||||||
|
class FakeEmbedder(override val dim: Int = 96) : Embedder {
|
||||||
|
override val isReady: Boolean = true
|
||||||
|
override fun embed(text: String): FloatArray {
|
||||||
|
val v = FloatArray(dim)
|
||||||
|
val s = " ${text.lowercase()} "
|
||||||
|
for (i in 0..s.length - 3) {
|
||||||
|
val tri = s.substring(i, i + 3)
|
||||||
|
val b = (tri.hashCode() and 0x7fffffff) % dim
|
||||||
|
v[b] += 1f
|
||||||
|
}
|
||||||
|
return l2normalize(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
package com.kazeia.rag
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Façade RAG : ingestion (chunk -> embed -> SQLite) et récupération (embed
|
||||||
|
* requête -> index brute-force -> bloc CONTEXTE budgété pour le prompt).
|
||||||
|
*
|
||||||
|
* Indépendant de kazeia-engine SAUF l'[Embedder] injecté. Avec un
|
||||||
|
* [EngineEmbedder] non encore prêt (lib sans embedText), [isReady] = false et
|
||||||
|
* [retrieve] renvoie null → aucune injection, production intacte. Avec un
|
||||||
|
* [FakeEmbedder], toute la chaîne est testable hors engine.
|
||||||
|
*/
|
||||||
|
class Rag(
|
||||||
|
context: Context,
|
||||||
|
private val embedder: Embedder,
|
||||||
|
private val modelTag: String, // identité du modèle d'embedding (invariant ingestion=requête)
|
||||||
|
private val log: (String) -> Unit = { Log.i("RAG", it) }
|
||||||
|
) {
|
||||||
|
private val db = RagDb.get(context)
|
||||||
|
private var index: VectorIndex? = null
|
||||||
|
|
||||||
|
val isReady: Boolean get() = embedder.isReady
|
||||||
|
|
||||||
|
/** Construit/rafraîchit l'index RAM depuis la base. À appeler au démarrage. */
|
||||||
|
@Synchronized
|
||||||
|
fun loadIndex() {
|
||||||
|
if (!embedder.isReady) { log("loadIndex: embedder non prêt — RAG inactif"); return }
|
||||||
|
val rows = db.loadAll()
|
||||||
|
if (rows.isEmpty()) { log("loadIndex: corpus vide"); index = null; return }
|
||||||
|
val dim = rows.first().vec.size
|
||||||
|
if (dim != embedder.dim) {
|
||||||
|
log("loadIndex: INCOHÉRENCE dim corpus=$dim vs embedder=${embedder.dim} (modèle changé ?) — RAG inactif")
|
||||||
|
index = null; return
|
||||||
|
}
|
||||||
|
val idx = VectorIndex(dim)
|
||||||
|
for (r in rows) if (r.vec.size == dim) idx.add(r.id, r.vec)
|
||||||
|
index = idx
|
||||||
|
log("loadIndex: ${idx.size} chunks (dim=$dim, modèle=${db.distinctModel()})")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ingestion d'un document (remplace les chunks d'une même source). */
|
||||||
|
@Synchronized
|
||||||
|
fun ingest(source: String, rawText: String): Int {
|
||||||
|
if (!embedder.isReady) { log("ingest: embedder non prêt"); return 0 }
|
||||||
|
db.deleteSource(source)
|
||||||
|
val chunks = Chunker.chunk(rawText, source)
|
||||||
|
var n = 0
|
||||||
|
for ((i, ch) in chunks.withIndex()) {
|
||||||
|
val v = embedder.embed(ch) ?: continue
|
||||||
|
db.insert(source, i, ch, modelTag, v); n++
|
||||||
|
}
|
||||||
|
log("ingest '$source': $n chunks")
|
||||||
|
loadIndex()
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère le contexte pertinent pour [query] et le formate en bloc prêt à
|
||||||
|
* injecter, ou null si rien au-dessus du seuil. Le seuil évite d'injecter du
|
||||||
|
* hors-sujet (risque clinique). [budgetChars] borne la taille (~4 c/token).
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun retrieve(
|
||||||
|
query: String,
|
||||||
|
k: Int = 4,
|
||||||
|
threshold: Float = 0.35f,
|
||||||
|
budgetChars: Int = 900
|
||||||
|
): String? {
|
||||||
|
val idx = index ?: return null
|
||||||
|
val qv = (embedder as? EngineEmbedder)?.embedQuery(query) ?: embedder.embed(query) ?: return null
|
||||||
|
val hits = idx.search(qv, k, threshold)
|
||||||
|
if (hits.isEmpty()) return null
|
||||||
|
|
||||||
|
val sb = StringBuilder("CONTEXTE DES DOCUMENTS :\n")
|
||||||
|
for (h in hits) {
|
||||||
|
val t = db.textOf(h.chunkId) ?: continue
|
||||||
|
val line = "- $t\n"
|
||||||
|
if (sb.length + line.length > budgetChars + 24) break
|
||||||
|
sb.append(line)
|
||||||
|
}
|
||||||
|
val out = sb.toString().trim()
|
||||||
|
log("retrieve: ${hits.size} hits (top=${"%.2f".format(hits.first().score)}), ${out.length} chars injectés")
|
||||||
|
return if (out.length > "CONTEXTE DES DOCUMENTS :".length) out else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun count(): Int = db.count()
|
||||||
|
fun clear() { db.clear(); index = null }
|
||||||
|
fun close() { embedder.close() }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
package com.kazeia.rag
|
||||||
|
|
||||||
|
import android.content.ContentValues
|
||||||
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import android.database.sqlite.SQLiteOpenHelper
|
||||||
|
import android.util.Log
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base SQLite du corpus RAG (chunks + vecteurs en BLOB float32 LE).
|
||||||
|
*
|
||||||
|
* Volontairement EN CLAIR (pas SQLCipher comme conversations.db) : c'est une
|
||||||
|
* base de connaissances de référence, pas une donnée patient secrète. Ça permet
|
||||||
|
* aussi de distribuer un corpus pré-construit via le catalogue. Si un jour on
|
||||||
|
* veut du RAG par-patient confidentiel, basculer sur le pattern SQLCipher de
|
||||||
|
* ConversationDb (passphrase SecureKeyStore) — changement local.
|
||||||
|
*
|
||||||
|
* `model` + `dim` stockés par chunk : garde-fou de l'invariant « même modèle
|
||||||
|
* d'embedding à l'ingestion et à la requête ».
|
||||||
|
*/
|
||||||
|
class RagDb private constructor(context: Context)
|
||||||
|
: SQLiteOpenHelper(context.applicationContext, DB_NAME, null, DB_VERSION) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DB_NAME = "kazeia_rag.db"
|
||||||
|
const val DB_VERSION = 1
|
||||||
|
const val TABLE = "rag_chunks"
|
||||||
|
const val COL_ID = "id"
|
||||||
|
const val COL_SOURCE = "source"
|
||||||
|
const val COL_POS = "position"
|
||||||
|
const val COL_TEXT = "text"
|
||||||
|
const val COL_MODEL = "model"
|
||||||
|
const val COL_DIM = "dim"
|
||||||
|
const val COL_VEC = "vec"
|
||||||
|
|
||||||
|
@Volatile private var INSTANCE: RagDb? = null
|
||||||
|
fun get(context: Context): RagDb = INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: RagDb(context).also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun vecToBlob(v: FloatArray): ByteArray {
|
||||||
|
val bb = ByteBuffer.allocate(v.size * 4).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
for (x in v) bb.putFloat(x)
|
||||||
|
return bb.array()
|
||||||
|
}
|
||||||
|
fun blobToVec(b: ByteArray): FloatArray {
|
||||||
|
val bb = ByteBuffer.wrap(b).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
return FloatArray(b.size / 4) { bb.float }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(db: SQLiteDatabase) {
|
||||||
|
db.execSQL("""
|
||||||
|
CREATE TABLE $TABLE (
|
||||||
|
$COL_ID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
$COL_SOURCE TEXT NOT NULL,
|
||||||
|
$COL_POS INTEGER NOT NULL,
|
||||||
|
$COL_TEXT TEXT NOT NULL,
|
||||||
|
$COL_MODEL TEXT NOT NULL,
|
||||||
|
$COL_DIM INTEGER NOT NULL,
|
||||||
|
$COL_VEC BLOB NOT NULL
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
db.execSQL("CREATE INDEX idx_rag_source ON $TABLE ($COL_SOURCE)")
|
||||||
|
Log.i("RagDb", "created $DB_NAME v$DB_VERSION")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { /* futur */ }
|
||||||
|
|
||||||
|
fun insert(source: String, position: Int, text: String, model: String, vec: FloatArray): Long {
|
||||||
|
val cv = ContentValues().apply {
|
||||||
|
put(COL_SOURCE, source); put(COL_POS, position); put(COL_TEXT, text)
|
||||||
|
put(COL_MODEL, model); put(COL_DIM, vec.size); put(COL_VEC, vecToBlob(vec))
|
||||||
|
}
|
||||||
|
return writableDatabase.insert(TABLE, null, cv)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Charge tous les chunks (id, texte, vecteur) pour peupler l'index RAM. */
|
||||||
|
fun loadAll(): List<Row> {
|
||||||
|
val out = ArrayList<Row>()
|
||||||
|
readableDatabase.query(TABLE, arrayOf(COL_ID, COL_TEXT, COL_VEC), null, null, null, null, COL_ID).use { c ->
|
||||||
|
while (c.moveToNext()) {
|
||||||
|
out.add(Row(c.getLong(0), c.getString(1), blobToVec(c.getBlob(2))))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
fun textOf(id: Long): String? =
|
||||||
|
readableDatabase.query(TABLE, arrayOf(COL_TEXT), "$COL_ID=?", arrayOf(id.toString()), null, null, null).use {
|
||||||
|
if (it.moveToFirst()) it.getString(0) else null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun count(): Int =
|
||||||
|
readableDatabase.rawQuery("SELECT COUNT(*) FROM $TABLE", null).use { if (it.moveToFirst()) it.getInt(0) else 0 }
|
||||||
|
|
||||||
|
fun distinctModel(): String? =
|
||||||
|
readableDatabase.rawQuery("SELECT $COL_MODEL FROM $TABLE LIMIT 1", null).use { if (it.moveToFirst()) it.getString(0) else null }
|
||||||
|
|
||||||
|
fun clear() { writableDatabase.delete(TABLE, null, null) }
|
||||||
|
|
||||||
|
fun deleteSource(source: String): Int =
|
||||||
|
writableDatabase.delete(TABLE, "$COL_SOURCE=?", arrayOf(source))
|
||||||
|
|
||||||
|
data class Row(val id: Long, val text: String, val vec: FloatArray)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,46 @@
|
||||||
|
package com.kazeia.rag
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Index vectoriel en RAM, recherche EXHAUSTIVE (brute-force) par cosinus.
|
||||||
|
*
|
||||||
|
* Choix assumé : à l'échelle de Kazeia (10^2–10^3 chunks thérapeutiques, un
|
||||||
|
* patient), le brute-force est exact et coûte ~1–3 ms. Aucun index ANN
|
||||||
|
* (HNSW/IVF) n'est justifié avant ~10^5 vecteurs. Voir l'analyse d'archi.
|
||||||
|
*
|
||||||
|
* Les vecteurs sont supposés L2-normalisés (cf [Embedder]) → cosinus = produit
|
||||||
|
* scalaire.
|
||||||
|
*/
|
||||||
|
class VectorIndex(val dim: Int) {
|
||||||
|
|
||||||
|
private val ids = ArrayList<Long>()
|
||||||
|
private val vecs = ArrayList<FloatArray>()
|
||||||
|
|
||||||
|
val size: Int get() = ids.size
|
||||||
|
|
||||||
|
fun clear() { ids.clear(); vecs.clear() }
|
||||||
|
|
||||||
|
fun add(chunkId: Long, vec: FloatArray) {
|
||||||
|
require(vec.size == dim) { "VectorIndex: dim attendue=$dim, reçue=${vec.size}" }
|
||||||
|
ids.add(chunkId); vecs.add(vec)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dot(a: FloatArray, b: FloatArray): Float {
|
||||||
|
var s = 0f
|
||||||
|
for (i in 0 until dim) s += a[i] * b[i]
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Top-k par score décroissant, filtré par [threshold]. */
|
||||||
|
fun search(query: FloatArray, k: Int, threshold: Float): List<Hit> {
|
||||||
|
if (query.size != dim || ids.isEmpty()) return emptyList()
|
||||||
|
val hits = ArrayList<Hit>(ids.size)
|
||||||
|
for (i in ids.indices) {
|
||||||
|
val s = dot(query, vecs[i])
|
||||||
|
if (s >= threshold) hits.add(Hit(ids[i], s))
|
||||||
|
}
|
||||||
|
hits.sortByDescending { it.score }
|
||||||
|
return if (hits.size > k) hits.subList(0, k).toList() else hits
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Hit(val chunkId: Long, val score: Float)
|
||||||
|
}
|
||||||
|
|
@ -72,6 +72,9 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
||||||
private var runtimeConfig: com.kazeia.config.ConfigStore.RuntimeConfig =
|
private var runtimeConfig: com.kazeia.config.ConfigStore.RuntimeConfig =
|
||||||
com.kazeia.config.ConfigStore.defaultConfig()
|
com.kazeia.config.ConfigStore.defaultConfig()
|
||||||
private var configReloadReceiver: android.content.BroadcastReceiver? = null
|
private var configReloadReceiver: android.content.BroadcastReceiver? = null
|
||||||
|
// RAG optionnel (default OFF). Inerte tant que l'embedder kazeia-engine n'est
|
||||||
|
// pas prêt (embedText absent / modèle d'embedding non présent) -> aucune injection.
|
||||||
|
@Volatile private var rag: com.kazeia.rag.Rag? = null
|
||||||
private lateinit var stt: SttEngine
|
private lateinit var stt: SttEngine
|
||||||
|
|
||||||
// Cycle de vie Whisper STT — lazy load + swap intelligent.
|
// Cycle de vie Whisper STT — lazy load + swap intelligent.
|
||||||
|
|
@ -618,6 +621,22 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
||||||
} catch (e: Exception) { log("LLM test error: ${e.message}") }
|
} catch (e: Exception) { log("LLM test error: ${e.message}") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
intent?.getStringExtra("rag_test")?.let { query ->
|
||||||
|
// Valide TOUTE la chaîne RAG (chunk -> embed -> SQLite -> index -> retrieve)
|
||||||
|
// SANS kazeia-engine, via FakeEmbedder. Seuil 0 (le fake a un signal faible).
|
||||||
|
log("RAG test: '$query'")
|
||||||
|
serviceScope.launch {
|
||||||
|
try {
|
||||||
|
val rag = com.kazeia.rag.Rag(this@KazeiaService, com.kazeia.rag.FakeEmbedder(), "fake-test") { m -> log(m) }
|
||||||
|
rag.clear()
|
||||||
|
rag.ingest("insomnie", "L'insomnie est un trouble du sommeil fréquent. Pour mieux dormir, évitez les écrans le soir et gardez une heure de coucher régulière. La respiration lente aide à l'endormissement.")
|
||||||
|
rag.ingest("solitude", "Le sentiment de solitude peut être douloureux. Renouer un petit lien social, même bref, aide beaucoup. Noter trois choses positives par jour réduit la rumination.")
|
||||||
|
rag.loadIndex()
|
||||||
|
val ctx = rag.retrieve(query, threshold = 0.0f)
|
||||||
|
log("RAG test RESULT (corpus=${rag.count()} chunks):\n${ctx ?: "(rien au-dessus du seuil)"}")
|
||||||
|
} catch (e: Exception) { log("RAG test error: ${e.message}"); e.printStackTrace() }
|
||||||
|
}
|
||||||
|
}
|
||||||
if (intent?.hasExtra("cfg_set") == true) {
|
if (intent?.hasExtra("cfg_set") == true) {
|
||||||
// Maintenance/bench : persiste engine+model+prompt sans passer par le
|
// Maintenance/bench : persiste engine+model+prompt sans passer par le
|
||||||
// ContentProvider CLI (qui casse sur les valeurs contenant ':'). Les
|
// ContentProvider CLI (qui casse sur les valeurs contenant ':'). Les
|
||||||
|
|
@ -784,6 +803,19 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RAG (optionnel, default OFF). Embedder = kazeia-engine. Inerte si la
|
||||||
|
// lib n'expose pas encore embedText ou si le modèle d'embedding est absent
|
||||||
|
// (isReady=false -> retrieve() renvoie null -> aucune injection).
|
||||||
|
rag = if (runtimeConfig.ragEnabled) try {
|
||||||
|
val embModel = "${KazeiaApplication.MODELS_DIR}/embed-e5-small.gguf"
|
||||||
|
val emb = com.kazeia.rag.EngineEmbedder(embModel, nThreads = 4, pooling = -1,
|
||||||
|
queryPrefix = "query: ", passagePrefix = "passage: ", log = { m -> log(m) })
|
||||||
|
com.kazeia.rag.Rag(this@KazeiaService, emb, "e5-small") { m -> log(m) }.also {
|
||||||
|
it.loadIndex(); log("[RAG] activé (ready=${it.isReady}, chunks=${it.count()})")
|
||||||
|
}
|
||||||
|
} catch (e: Throwable) { log("[RAG] init échec: ${e.message}"); null }
|
||||||
|
else { log("[RAG] désactivé (config)"); null }
|
||||||
|
|
||||||
_loadingState.value = LoadingState(80, "Audio…")
|
_loadingState.value = LoadingState(80, "Audio…")
|
||||||
// Audio
|
// Audio
|
||||||
audioPlayback = AudioPlaybackManager()
|
audioPlayback = AudioPlaybackManager()
|
||||||
|
|
@ -1681,11 +1713,24 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
val prompt = promptBuilder.build(
|
var prompt = promptBuilder.build(
|
||||||
message = patientMessage,
|
message = patientMessage,
|
||||||
history = _messages.value
|
history = _messages.value
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Injection RAG : si activé + embedder prêt + contexte au-dessus du seuil,
|
||||||
|
// on préfixe le bloc CONTEXTE au prompt patient. Le seuil dans Rag.retrieve
|
||||||
|
// évite d'injecter du hors-sujet (garde-fou clinique). Le RAG ne porte
|
||||||
|
// JAMAIS la gestion de crise.
|
||||||
|
rag?.takeIf { it.isReady }?.let { r ->
|
||||||
|
try {
|
||||||
|
r.retrieve(patientMessage)?.let { ctx ->
|
||||||
|
prompt = "$ctx\n\n$prompt"
|
||||||
|
log("[RAG] contexte injecté (${ctx.length} chars)")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) { log("[RAG] retrieve échec: ${e.message}") }
|
||||||
|
}
|
||||||
|
|
||||||
// === Opt TTS-B: the TTS streaming session opens BEFORE llm.generate,
|
// === Opt TTS-B: the TTS streaming session opens BEFORE llm.generate,
|
||||||
// so the first complete sentence emitted by the LLM (on terminal
|
// so the first complete sentence emitted by the LLM (on terminal
|
||||||
// punctuation) is enqueued for synthesis while the LLM is still
|
// punctuation) is enqueued for synthesis while the LLM is still
|
||||||
|
|
|
||||||
|
|
@ -187,7 +187,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
val cfg = com.kazeia.config.ConfigStore.get(ctx).current()
|
val cfg = com.kazeia.config.ConfigStore.get(ctx).current()
|
||||||
val cols = arrayOf(
|
val cols = arrayOf(
|
||||||
"cascade_enabled", "tts_enabled",
|
"cascade_enabled", "tts_enabled",
|
||||||
"stt_engine", "llm_engine", "tts_engine",
|
"stt_engine", "llm_engine", "tts_engine", "rag_enabled",
|
||||||
"speaker_model_id", "speaker_system_prompt", "speaker_temperature",
|
"speaker_model_id", "speaker_system_prompt", "speaker_temperature",
|
||||||
"thinker_model_id", "thinker_system_prompt", "thinker_temperature"
|
"thinker_model_id", "thinker_system_prompt", "thinker_temperature"
|
||||||
)
|
)
|
||||||
|
|
@ -196,6 +196,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
if (cfg.cascadeEnabled) 1 else 0,
|
if (cfg.cascadeEnabled) 1 else 0,
|
||||||
if (cfg.ttsEnabled) 1 else 0,
|
if (cfg.ttsEnabled) 1 else 0,
|
||||||
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
|
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
|
||||||
|
if (cfg.ragEnabled) 1 else 0,
|
||||||
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature,
|
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature,
|
||||||
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature
|
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature
|
||||||
))
|
))
|
||||||
|
|
@ -286,6 +287,8 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
||||||
sttEngine = values.getAsString("stt_engine") ?: cur.sttEngine,
|
sttEngine = values.getAsString("stt_engine") ?: cur.sttEngine,
|
||||||
llmEngine = values.getAsString("llm_engine") ?: cur.llmEngine,
|
llmEngine = values.getAsString("llm_engine") ?: cur.llmEngine,
|
||||||
ttsEngine = values.getAsString("tts_engine") ?: cur.ttsEngine,
|
ttsEngine = values.getAsString("tts_engine") ?: cur.ttsEngine,
|
||||||
|
ragEnabled = if (values.containsKey("rag_enabled"))
|
||||||
|
values.getAsBoolean("rag_enabled") else cur.ragEnabled,
|
||||||
speaker = com.kazeia.config.ConfigStore.ModelConfig(
|
speaker = com.kazeia.config.ConfigStore.ModelConfig(
|
||||||
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId,
|
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId,
|
||||||
systemPrompt = values.getAsString("speaker_system_prompt")
|
systemPrompt = values.getAsString("speaker_system_prompt")
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue