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é.
|
||||
* Defaults safe = "prod". Bascule "lib" nécessite libllama.so fork ql + retrait
|
||||
* 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 {
|
||||
|
|
@ -146,6 +150,7 @@ class ConfigStore private constructor(private val file: File) {
|
|||
root.put("stt_engine", cfg.sttEngine)
|
||||
root.put("llm_engine", cfg.llmEngine)
|
||||
root.put("tts_engine", cfg.ttsEngine)
|
||||
root.put("rag_enabled", cfg.ragEnabled)
|
||||
root.put("speaker", JSONObject().apply {
|
||||
put("model_id", cfg.speaker.modelId)
|
||||
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),
|
||||
llmEngine = root.optString("llm_engine", defaults.llmEngine),
|
||||
ttsEngine = root.optString("tts_engine", defaults.ttsEngine),
|
||||
ragEnabled = root.optBoolean("rag_enabled", defaults.ragEnabled),
|
||||
speaker = root.optJSONObject("speaker")?.let { js ->
|
||||
ModelConfig(
|
||||
modelId = js.optString("model_id", defaults.speaker.modelId),
|
||||
|
|
|
|||
|
|
@ -14,6 +14,13 @@ class EngineJni {
|
|||
external fun reset(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).
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
com.kazeia.config.ConfigStore.defaultConfig()
|
||||
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
|
||||
|
||||
// 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}") }
|
||||
}
|
||||
}
|
||||
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) {
|
||||
// Maintenance/bench : persiste engine+model+prompt sans passer par le
|
||||
// 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…")
|
||||
// Audio
|
||||
audioPlayback = AudioPlaybackManager()
|
||||
|
|
@ -1681,11 +1713,24 @@ Pas d'introduction, pas d'explication. Juste les 4 lignes en francais. /no_think
|
|||
null
|
||||
}
|
||||
|
||||
val prompt = promptBuilder.build(
|
||||
var prompt = promptBuilder.build(
|
||||
message = patientMessage,
|
||||
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,
|
||||
// so the first complete sentence emitted by the LLM (on terminal
|
||||
// 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 cols = arrayOf(
|
||||
"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",
|
||||
"thinker_model_id", "thinker_system_prompt", "thinker_temperature"
|
||||
)
|
||||
|
|
@ -196,6 +196,7 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
if (cfg.cascadeEnabled) 1 else 0,
|
||||
if (cfg.ttsEnabled) 1 else 0,
|
||||
cfg.sttEngine, cfg.llmEngine, cfg.ttsEngine,
|
||||
if (cfg.ragEnabled) 1 else 0,
|
||||
cfg.speaker.modelId, cfg.speaker.systemPrompt, cfg.speaker.temperature,
|
||||
cfg.thinker.modelId, cfg.thinker.systemPrompt, cfg.thinker.temperature
|
||||
))
|
||||
|
|
@ -286,6 +287,8 @@ class KazeiaTelemetryProvider : ContentProvider() {
|
|||
sttEngine = values.getAsString("stt_engine") ?: cur.sttEngine,
|
||||
llmEngine = values.getAsString("llm_engine") ?: cur.llmEngine,
|
||||
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(
|
||||
modelId = values.getAsString("speaker_model_id") ?: cur.speaker.modelId,
|
||||
systemPrompt = values.getAsString("speaker_system_prompt")
|
||||
|
|
|
|||
Loading…
Reference in New Issue