kazeia/kazeia-android/app/src/main/java/com/kazeia/rag/Rag.kt

95 lines
3.8 KiB
Kotlin

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()) {
// e5 : les passages doivent porter le préfixe "passage: " (symétrique au
// "query: " de retrieve). EngineEmbedder.embedPassage le gère ; le fallback
// FakeEmbedder embed() sans préfixe (symétrique aussi).
val v = (embedder as? EngineEmbedder)?.embedPassage(ch) ?: 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() }
}