95 lines
3.8 KiB
Kotlin
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() }
|
|
}
|