feat(nous-real): cache de embeddings + write-through al CAS de arje

Cierra el ciclo del feedback: el modelo real (fastembed-allMiniLML6V2,
~1-50ms por archivo) era invocado ciegamente en cada re-cluster del
watcher. Ahora se cachea por sha256(bytes-vistos) + model_id, con
write-through al CAS de arje.

Pipeline en handle_file:
1. Lee primeros 8 KiB del archivo (igual que antes).
2. file_sha = ente_cas::sha256_of(buf) — hash de los bytes que el
   modelo *realmente* verá. Garantiza que un archivo creciendo mas
   alla de la ventana sin tocar la cabeza siga sirviendo cache hits.
3. Cache lookup -> HIT: respuesta en us, sin invocar fastembed.
4. MISS: ente_cas::store(&buf) (write-through, no-fatal si falla) ->
   backend.embed_one(text) -> cache.put(...).

Backend de cache: sled local en
$XDG_CACHE_HOME/brahman/nouser-nous-real-embed-cache.sled. Tree
versionado embed_cache_v1; el MODEL_ID viaja en la key, asi que
cambiar de modelo invalida el cache implicitamente. Override por env
NOUSER_NOUS_REAL_CACHE.

Encoding compacto: cada Vec<f32> se serializa como bytes little-endian
(4B por f32, sin overhead). Para 384-d son 1.5 KiB por entry. Decode
tolera bytes corruptos (longitud no-multiplo de 4 -> None, no panic).

Por que sled y no ente-cas directo: el CAS de arje es flat
sha256-keyed; la cache necesita un mapeo (file_sha, model_id) ->
embedding, no expresable como entry CAS. El write-through a CAS queda
como registro consultable + futura GC.

Mock NO se modifica — su embedding pseudo-32d es metadata-hashing puro,
sin costo. Cachearlo seria overhead.

Tests: 5 unitarios verdes (roundtrip, miss, model collision, content
collision, corrupted value). Stub mode (sin feature) sigue compilando
sin tocar cache.
This commit is contained in:
Sergio
2026-05-09 02:57:55 +00:00
parent 79d42aba28
commit b23ddf2980
6 changed files with 334 additions and 7 deletions
+19 -2
View File
@@ -40,6 +40,8 @@ use nouser_nous::{transport, FLOW_EMBED_REQUEST, FLOW_EMBED_RESULT, FLOW_TYPE_NA
use tokio::net::UnixListener;
use tracing::info;
#[cfg(feature = "embeddings")]
mod cache;
#[cfg(feature = "embeddings")]
mod embeddings;
#[cfg(not(feature = "embeddings"))]
@@ -90,15 +92,30 @@ async fn main() -> std::io::Result<()> {
#[cfg(feature = "embeddings")]
let backend = std::sync::Arc::new(backend);
// 4. Accept loop.
// 4. Abrir el cache de embeddings (sled local, sha256-keyed).
// Si falla, seguimos sin cache — degrada a "siempre embed".
#[cfg(feature = "embeddings")]
let embed_cache = match cache::EmbedCache::open() {
Ok(c) => {
info!(entries = c.len(), "embed-cache abierto");
Some(c)
}
Err(e) => {
tracing::warn!(error = %e, "embed-cache no disponible — todas las requests irán al modelo");
None
}
};
// 5. Accept loop.
loop {
let (stream, _addr) = listener.accept().await?;
#[cfg(feature = "embeddings")]
{
let backend = backend.clone();
let cache = embed_cache.clone();
tokio::spawn(async move {
if let Err(e) = embeddings::handle_conn(stream, backend).await {
if let Err(e) = embeddings::handle_conn(stream, backend, cache).await {
tracing::warn!(error = %e, "conn falló");
}
});