use std::collections::HashMap;
use std::path::Path;
use rusqlite::{Connection, OpenFlags};
#[derive(Debug, Clone)]
pub struct ChunkHit {
pub collection: String,
pub path: String,
pub title: String,
pub hash: String,
pub seq: usize,
pub pos: usize,
pub score: f32,
}
const SKIPPED_MODEL: &str = "skipped-too-large";
fn decode_embedding(bytes: &[u8]) -> Vec<f32> {
bytes
.chunks_exact(4)
.map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
.collect()
}
pub fn search_chunks(
db_path: &Path,
query_embedding: &[f32],
limit: usize,
collection: Option<&str>,
) -> Result<Vec<ChunkHit>, String> {
if query_embedding.is_empty() || limit == 0 {
return Ok(Vec::new());
}
let conn = Connection::open_with_flags(
db_path,
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)
.map_err(|e| format!("Failed to open memory store for vector search: {e}"))?;
let sql = "
SELECT d.collection, d.path, d.title, d.hash, cv.seq, cv.pos, v.embedding
FROM documents d
JOIN content_vectors cv ON cv.hash = d.hash
JOIN vectors_vec v ON v.hash_seq = d.hash || '_' || cv.seq
WHERE d.active = 1 AND cv.model <> ?1
AND (?2 IS NULL OR d.collection = ?2)
";
let mut stmt = conn
.prepare(sql)
.map_err(|e| format!("Failed to prepare vector search: {e}"))?;
let rows = stmt
.query_map(rusqlite::params![SKIPPED_MODEL, collection], |row| {
let embedding: Vec<u8> = row.get(6)?;
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
row.get::<_, String>(3)?,
row.get::<_, i64>(4)? as usize,
row.get::<_, i64>(5)? as usize,
embedding,
))
})
.map_err(|e| format!("Vector search query failed: {e}"))?;
let mut best: HashMap<(String, String), ChunkHit> = HashMap::new();
for row in rows {
let (collection, path, title, hash, seq, pos, blob) =
row.map_err(|e| format!("Vector row decode failed: {e}"))?;
let embedding = decode_embedding(&blob);
if embedding.len() != query_embedding.len() {
continue;
}
let score = qmd::cosine_similarity(query_embedding, &embedding);
let key = (collection.clone(), path.clone());
let better = best.get(&key).is_none_or(|prev| score > prev.score);
if better {
best.insert(
key,
ChunkHit {
collection,
path,
title,
hash,
seq,
pos,
score,
},
);
}
}
let mut hits: Vec<ChunkHit> = best.into_values().collect();
hits.sort_by(|a, b| {
b.score
.partial_cmp(&a.score)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.path.cmp(&b.path))
.then_with(|| a.seq.cmp(&b.seq))
});
hits.truncate(limit);
Ok(hits)
}