use crate::bm25::{tokenize, Bm25Index};
use crate::embed::Embedder;
use crate::index::Chunk;
use crate::vectors::cosine;
use std::collections::HashMap;
use std::io;
use std::path::Path;
#[derive(Debug)]
pub struct Hit {
pub score: f32,
pub chunk: Chunk,
}
pub fn rrf_fuse(rankings: &[Vec<usize>], rrf_k: f32, k: usize) -> Vec<(usize, f32)> {
let mut fused: HashMap<usize, f32> = HashMap::new();
for ranking in rankings {
for (rank, &idx) in ranking.iter().enumerate() {
*fused.entry(idx).or_insert(0.0) += 1.0 / (rrf_k + rank as f32);
}
}
let mut out: Vec<(usize, f32)> = fused.into_iter().collect();
out.sort_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))
});
out.truncate(k);
out
}
pub async fn semantic_ranks<E: Embedder>(
embedder: &E,
store: &Path,
model: &str,
query: &str,
chunk_texts: &[String],
batch_size: usize,
) -> io::Result<Vec<usize>> {
let qv = crate::vectors::get_or_embed(embedder, store, model, &[query.to_string()], 1).await?;
let cvs = crate::vectors::get_or_embed(embedder, store, model, chunk_texts, batch_size).await?;
let q = &qv[0];
let mut sims: Vec<(usize, f32)> = cvs.iter().enumerate().map(|(i, cv)| (i, cosine(q, cv))).collect();
sims.sort_by(|a, b| {
b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal).then(a.0.cmp(&b.0))
});
Ok(sims.into_iter().map(|(i, _)| i).collect())
}
fn load_index(dir: &Path) -> io::Result<(Vec<Chunk>, Bm25Index, serde_json::Value)> {
let chunks_path = dir.join("chunks.jsonl");
if !chunks_path.is_file() {
return Err(io::Error::other("no index found — run `kibble index` first"));
}
let mut chunks = Vec::new();
for line in std::fs::read_to_string(&chunks_path)?.lines() {
if line.trim().is_empty() {
continue;
}
chunks.push(serde_json::from_str::<Chunk>(line).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?);
}
if chunks.is_empty() {
return Err(io::Error::other("index is empty — run `kibble index` first"));
}
let bm25: Bm25Index = serde_json::from_str(&std::fs::read_to_string(dir.join("bm25.json"))?)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
let meta: serde_json::Value = serde_json::from_str(&std::fs::read_to_string(dir.join("meta.json"))?)
.map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok((chunks, bm25, meta))
}
pub async fn search(repo_root: &Path, query: &str, k: usize) -> io::Result<Vec<Hit>> {
let cfg = crate::config::load_config(&repo_root.join(crate::config::CONFIG_FILE));
let dir = repo_root.join(&cfg.index.dir);
let (chunks, bm25, meta) = tokio::task::spawn_blocking(move || load_index(&dir))
.await
.map_err(|e| io::Error::other(format!("index load task panicked: {e}")))??;
let lex = bm25.scores(&tokenize(query));
let lex_rank: Vec<usize> = lex.into_iter().map(|(i, _)| i).collect();
let mut rankings: Vec<Vec<usize>> = vec![lex_rank];
let has_vectors = meta.get("has_vectors").and_then(|v| v.as_bool()).unwrap_or(false);
let meta_model = meta.get("embed_model").and_then(|v| v.as_str()).unwrap_or("");
let embed = &cfg.understand.embed;
if has_vectors && !embed.base_url.is_empty() {
if meta_model != embed.model {
eprintln!("kibble: index model '{meta_model}' != configured '{}' — lexical only", embed.model);
} else {
match crate::embed::EndpointEmbedder::new(embed, cfg.network.proxy.as_deref()) {
Ok(e) => {
let store = repo_root.join(&embed.store);
let texts: Vec<String> = chunks.iter().map(|c| c.text.clone()).collect();
match semantic_ranks(&e, &store, &embed.model, query, &texts, embed.batch_size).await {
Ok(sem) => rankings.push(sem),
Err(err) => eprintln!("kibble: semantic search skipped (embed failed): {err}"),
}
}
Err(err) => eprintln!("kibble: semantic search skipped (embed init failed): {err}"),
}
}
}
let fused = rrf_fuse(&rankings, cfg.index.rrf_k, k);
Ok(fused.into_iter().map(|(idx, score)| Hit { score, chunk: chunks[idx].clone() }).collect())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::embed::StubEmbedder;
#[test]
fn rrf_prefers_chunk_high_in_both() {
let a = vec![2, 1, 0];
let b = vec![0, 2, 1];
let fused = rrf_fuse(&[a, b], 60.0, 3);
assert_eq!(fused[0].0, 2); }
#[tokio::test]
async fn semantic_ranks_orders_by_similarity() {
let dir = std::env::temp_dir().join(format!("kibble_ret_sem_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let e = StubEmbedder::new();
let chunks = vec!["alpha beta".to_string(), "the query text".to_string(), "gamma delta".to_string()];
let ranks = semantic_ranks(&e, &dir, "m", "the query text", &chunks, 8).await.unwrap();
assert_eq!(ranks[0], 1);
std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn search_lexical_only_finds_planted_chunk() {
let dir = std::env::temp_dir().join(format!("kibble_ret_search_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("data/notes")).unwrap();
std::fs::write(dir.join("data/notes/fox.md"), "the quick brown fox jumps over").unwrap();
std::fs::write(dir.join("data/notes/dog.md"), "a sleepy dog lies in the sun").unwrap();
std::fs::write(dir.join(crate::config::CONFIG_FILE),
"[index]\nsources=[\"data/notes\"]\nchunk_chars=1000\n[understand.embed]\nbase_url=\"\"\n").unwrap();
crate::index::build_index(&dir, None).await.unwrap();
let hits = search(&dir, "fox", 5).await.unwrap();
assert!(!hits.is_empty());
assert!(hits[0].chunk.source.contains("fox.md")); std::fs::remove_dir_all(&dir).ok();
}
#[tokio::test]
async fn search_errors_without_index() {
let dir = std::env::temp_dir().join(format!("kibble_ret_noidx_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(dir.join(crate::config::CONFIG_FILE), "[index]\n").unwrap();
assert!(search(&dir, "anything", 5).await.is_err());
std::fs::remove_dir_all(&dir).ok();
}
}