// cerebro-cli — CLI en Rust para la base de conocimiento RAG // // Compilación: // cd cerebro-cli && cargo build --release // cp target/release/cerebro ~/.local/bin/ // // Uso: // cerebro query "hooks de git" // cerebro query "instalar docker" --alpha 0.6 // cerebro query "nginx" --tag web // cerebro list tags // cerebro list docs // cerebro list stats // cerebro similar notas/docker.md --top-k 5 --tag linux use bytemuck::try_cast_slice; use clap::{Parser, Subcommand}; use colored::*; use rusqlite::{Connection, Result as SqlResult}; use std::path::{Path, PathBuf}; const DB_PATH: &str = "~/.cerebro/rag_conocimiento.db"; const EMBEDDING_DIM: usize = 1024; const EMBEDDING_BYTES: usize = EMBEDDING_DIM * 4; /// Elimina stopwords del español para la consulta FTS5 fn limpiar_fts(texto: &str) -> String { let stopwords = ["de", "la", "que", "el", "en", "y", "a", "los", "del", "se", "las", "por", "un", "para", "con", "no", "una", "su", "al", "lo", "como", "mas", "más", "pero", "sus", "le", "ya", "o", "este", "si", "sí", "porque", "esta", "entre", "cuando", "muy", "sin", "sobre", "tambien", "también", "me", "hasta", "hay", "donde", "quien", "desde", "todo", "nos", "durante", "todos", "uno", "les", "ni", "contra", "otros", "ese", "eso", "ante", "ellos", "e", "esto", "mi", "antes", "algunos", "unos", "yo", "otro", "otras", "otra", "tanto", "esa", "estos", "mucho", "quienes", "nada", "muchos", "cual", "poco", "ella", "estar", "estas", "algo", "nosotros", "mis", "tu", "tus", "ellas", "os", "esos", "esas", "estoy", "estan", "están", "estaba", "estaban", "fue", "fueron", "es", "son", "ser", "era", "eran", "ha", "han", "he", "hemos", "habia", "había", "habian", "habían", "hubo", "hi", "hice", "hicimos", "puede", "pueden", "puedo", "podemos", "dela", "tras", "bajo", "ademas", "además", "solo", "sólo", "tan", "bien", "hacer", "tener", "tengo", "tiene", "tenemos", "ir", "voy", "va", "van", "vamos", "hace", "hacen", "soy", "eres", "somos", "aun", "aún", "acerca", "asi", "así", "buen", "buena", "buenos", "buenas", "cada", "casi", "cualquier", "dado", "dar", "decir", "dijo", "don", "dos", "ejemplo", "ello", "embargo", "estamos", "estuvo", "estuvieron", "fuera", "fui", "gran", "grande", "grandes", "haber", "hacia", "hayan", "hizo", "hubiera", "hubiesen", "luego", "mientras", "mismo", "misma", "mismos", "necesita", "necesitan", "parte", "poca", "pocos", "pues", "resulta", "resultan", "sea", "sean", "según", "segun", "sera", "será", "sería", "seria", "siempre", "sino", "tener", "tenia", "tenía", "tenido", "tiene", "tienen", "toda", "todo", "todas", "todos", "tuvo", "unas", "ustedes", "varios", "varias", "veces", "ver", "vez", "te", "él", "vosotros", ]; let lower = texto.to_lowercase(); let filtrado: Vec<&str> = lower .split_whitespace() .filter(|tok| !tok.is_empty() && !stopwords.contains(tok)) .collect(); if filtrado.is_empty() { texto.to_string() } else { filtrado.join(" ") } } #[derive(Parser)] #[command(name = "cerebro", version, about = "RAG knowledge base CLI")] struct Cli { #[command(subcommand)] command: Commands, } #[derive(Subcommand)] enum Commands { Query { texto: Option, #[arg(short, long, default_value_t = 0.4)] alpha: f32, #[arg(short = k, long, default_value_t = 5)] top_k: u32, #[arg(long)] tag: Option, }, List { tipo: String }, Similar { archivo: PathBuf, #[arg(short = k, long, default_value_t = 5)] top_k: u32, #[arg(long)] tag: Option, }, } fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 { let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum(); let norm_a: f32 = a.iter().map(|x| x * x).sum::().sqrt(); let norm_b: f32 = b.iter().map(|x| x * x).sum::().sqrt(); dot / (norm_a * norm_b + 1e-10) } fn sigmoid_fts(score: f32) -> f32 { 1.0 / (1.0 + (-score / 10.0).exp()) } fn obtener_embedding(texto: &str) -> Option> { let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_secs(15)).build().ok()?; let body = serde_json::json!({"model": "bge-m3", "prompt": texto}); let resp = client.post("http://localhost:11434/api/embeddings") .json(&body).send().ok()?; let json: serde_json::Value = resp.json().ok()?; let arr = json.get("embedding")?.as_array()?; let vec: Vec = arr.iter() .filter_map(|v| v.as_f64().map(|f| f as f32)).collect(); if vec.len() == EMBEDDING_DIM { Some(vec) } else { None } } fn ejecutar_query(texto: &str, alpha: f32, top_k: u32, tag: Option<&str>) { let db_path = expand_path(DB_PATH); if !Path::new(&db_path).exists() { eprintln!("BD no encontrada"); return; } let conn = open_db(&db_path).unwrap(); let mut where_clauses = vec!["chunks_fts MATCH ?1".to_string()]; let query_fts = limpiar_fts(texto); let mut param_values: Vec = vec![rusqlite::types::Value::Text(query_fts)]; if let Some(t) = tag { where_clauses.push("c.tags LIKE ?2".to_string()); param_values.push(rusqlite::types::Value::Text(format!("%{}%", t))); } let fts_candidates = top_k.saturating_mul(40).clamp(50, 500); param_values.push(rusqlite::types::Value::Integer(fts_candidates as i64)); let sql = format!( "SELECT c.id, c.content, e.vector, c.doc_id, bm25(chunks_fts) as fts_score, \ c.title, c.doc_path FROM chunks_fts JOIN chunks c ON chunks_fts.rowid = c.id \ JOIN embeddings e ON e.chunk_id = c.id WHERE {} ORDER BY fts_score DESC LIMIT {}", where_clauses.join(" AND "), where_clauses.len() + 1 ); // ... (query execution logic with cosine similarity + dedup) let query_emb = obtener_embedding(texto); println!("Consulta: {} | Alpha: {}", texto, alpha); println!("Implementación completa en https://gist.atareao.es/"); } fn main() { let cli = Cli::parse(); match &cli.command { Commands::Query { texto, alpha, top_k, tag } => { ejecutar_query(texto.as_deref().unwrap_or(""), *alpha, *top_k, tag.as_deref()); } Commands::List { tipo } => { println!("Listando: {} (implementación completa en el gist)", tipo); } Commands::Similar { archivo, top_k, tag } => { println!("Similar: {} (implementación completa en el gist)", archivo.display()); } } } fn expand_path(path: &str) -> String { if path.starts_with(~) { let home = std::env::var("HOME").unwrap_or_default(); path.replacen(~, &home, 1) } else { path.to_string() } } fn open_db(path: &str) -> SqlResult { let expanded = expand_path(path); let conn = Connection::open(&expanded)?; conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA query_only=1;")?; Ok(conn) }