Initial commit
This commit is contained in:
commit
2778f14574
29 changed files with 7110 additions and 0 deletions
142
breadmill/src/chunk.rs
Normal file
142
breadmill/src/chunk.rs
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
pub struct Chunk {
|
||||
pub text: String,
|
||||
pub start: usize,
|
||||
pub end: usize,
|
||||
}
|
||||
|
||||
/// Split `text` into overlapping word-based windows, then enforce `max_chunk_chars`.
|
||||
///
|
||||
/// Any word-window that exceeds `max_chunk_chars` characters is split further at
|
||||
/// character boundaries so that no chunk passed to the embedder is pathologically
|
||||
/// large (e.g. minified JSON where a single "word" is hundreds of KB).
|
||||
///
|
||||
/// Set `max_chunk_chars = 0` to skip the character cap.
|
||||
pub fn chunk_text(text: &str, words_per_chunk: usize, overlap_words: usize, max_chunk_chars: usize) -> Vec<Chunk> {
|
||||
let word_chunks = chunk_by_words(text, words_per_chunk, overlap_words);
|
||||
|
||||
if max_chunk_chars == 0 {
|
||||
return word_chunks;
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
for chunk in word_chunks {
|
||||
if chunk.text.len() <= max_chunk_chars {
|
||||
result.push(chunk);
|
||||
} else {
|
||||
result.extend(split_by_chars(chunk, max_chunk_chars));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn chunk_by_words(text: &str, words_per_chunk: usize, overlap_words: usize) -> Vec<Chunk> {
|
||||
let mut positions: Vec<(usize, usize)> = Vec::new();
|
||||
let mut in_word = false;
|
||||
let mut word_start = 0;
|
||||
|
||||
for (i, c) in text.char_indices() {
|
||||
if c.is_whitespace() {
|
||||
if in_word {
|
||||
positions.push((word_start, i));
|
||||
in_word = false;
|
||||
}
|
||||
} else if !in_word {
|
||||
word_start = i;
|
||||
in_word = true;
|
||||
}
|
||||
}
|
||||
if in_word {
|
||||
positions.push((word_start, text.len()));
|
||||
}
|
||||
|
||||
if positions.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let step = words_per_chunk.saturating_sub(overlap_words).max(1);
|
||||
let mut chunks = Vec::new();
|
||||
let mut i = 0;
|
||||
|
||||
while i < positions.len() {
|
||||
let last = (i + words_per_chunk - 1).min(positions.len() - 1);
|
||||
let start = positions[i].0;
|
||||
let end = positions[last].1;
|
||||
|
||||
chunks.push(Chunk {
|
||||
text: text[start..end].to_string(),
|
||||
start,
|
||||
end,
|
||||
});
|
||||
|
||||
if last == positions.len() - 1 {
|
||||
break;
|
||||
}
|
||||
i += step;
|
||||
}
|
||||
|
||||
chunks
|
||||
}
|
||||
|
||||
fn split_by_chars(chunk: Chunk, max_chars: usize) -> Vec<Chunk> {
|
||||
let text = &chunk.text;
|
||||
let mut result = Vec::new();
|
||||
let mut seg_start = 0usize;
|
||||
let mut count = 0usize;
|
||||
|
||||
for (byte_idx, _) in text.char_indices() {
|
||||
if count > 0 && count % max_chars == 0 {
|
||||
result.push(Chunk {
|
||||
text: text[seg_start..byte_idx].to_string(),
|
||||
start: chunk.start + seg_start,
|
||||
end: chunk.start + byte_idx,
|
||||
});
|
||||
seg_start = byte_idx;
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
if seg_start < text.len() {
|
||||
result.push(Chunk {
|
||||
text: text[seg_start..].to_string(),
|
||||
start: chunk.start + seg_start,
|
||||
end: chunk.end,
|
||||
});
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_chunk() {
|
||||
let text = "one two three four five six seven eight nine ten";
|
||||
let chunks = chunk_text(text, 4, 1, 0);
|
||||
assert!(!chunks.is_empty());
|
||||
for c in &chunks {
|
||||
assert!(!c.text.is_empty());
|
||||
assert!(c.start <= c.end);
|
||||
assert_eq!(&text[c.start..c.end], c.text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_cap_splits_large_chunks() {
|
||||
// Simulate a "word" that is 200 chars long — exceeds cap of 50.
|
||||
let text = "a".repeat(200);
|
||||
let chunks = chunk_text(&text, 1, 0, 50);
|
||||
assert_eq!(chunks.len(), 4);
|
||||
for c in &chunks {
|
||||
assert!(c.text.len() <= 50);
|
||||
assert_eq!(&text[c.start..c.end], c.text);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn char_cap_disabled() {
|
||||
let text = "a".repeat(200);
|
||||
let chunks = chunk_text(&text, 1, 0, 0);
|
||||
assert_eq!(chunks.len(), 1);
|
||||
assert_eq!(chunks[0].text.len(), 200);
|
||||
}
|
||||
}
|
||||
258
breadmill/src/embed.rs
Normal file
258
breadmill/src/embed.rs
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
use std::path::{Path, PathBuf};
|
||||
|
||||
use ort::{
|
||||
session::{Session, builder::{GraphOptimizationLevel, SessionBuilder}},
|
||||
value::Tensor,
|
||||
};
|
||||
use tokenizers::Tokenizer;
|
||||
|
||||
const DOCUMENT_PREFIX: &str = "search_document: ";
|
||||
const QUERY_PREFIX: &str = "search_query: ";
|
||||
|
||||
/// Hard token cap for nomic-embed-text-v1.5 (8192-token context window).
|
||||
/// Sequences longer than this are truncated before ONNX inference to prevent
|
||||
/// quadratic attention memory blowup.
|
||||
pub const MAX_SEQ_LEN: usize = 8192;
|
||||
|
||||
pub enum Backend {
|
||||
Cpu,
|
||||
/// AMD XDNA NPU via the VitisAI ONNX Runtime execution provider.
|
||||
/// `cache_dir` is used to store the compiled NPU model between runs.
|
||||
Npu { cache_dir: PathBuf },
|
||||
/// AMD iGPU via the ROCm ONNX Runtime execution provider.
|
||||
Rocm,
|
||||
}
|
||||
|
||||
pub struct OrtEmbedder {
|
||||
session: Session,
|
||||
tokenizer: Tokenizer,
|
||||
dim: usize,
|
||||
}
|
||||
|
||||
impl OrtEmbedder {
|
||||
pub fn load(model_path: &Path, tokenizer_path: &Path, dim: usize, backend: Backend) -> Result<Self, String> {
|
||||
let builder = Session::builder()
|
||||
.map_err(|e| e.to_string())?
|
||||
.with_optimization_level(GraphOptimizationLevel::All)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut builder = configure_eps(builder, &backend)?;
|
||||
let session = builder.commit_from_file(model_path).map_err(|e| e.to_string())?;
|
||||
let tokenizer = Tokenizer::from_file(tokenizer_path).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(Self { session, tokenizer, dim })
|
||||
}
|
||||
|
||||
pub fn embed_document(&mut self, text: &str) -> Result<Vec<f32>, String> {
|
||||
self.embed_with_prefix(text, DOCUMENT_PREFIX)
|
||||
}
|
||||
|
||||
pub fn embed_query(&mut self, text: &str) -> Result<Vec<f32>, String> {
|
||||
self.embed_with_prefix(text, QUERY_PREFIX)
|
||||
}
|
||||
|
||||
fn embed_with_prefix(&mut self, text: &str, prefix: &str) -> Result<Vec<f32>, String> {
|
||||
let input = format!("{}{}", prefix, text);
|
||||
|
||||
let encoding = self
|
||||
.tokenizer
|
||||
.encode(input, true)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
|
||||
let mut mask: Vec<i64> = encoding
|
||||
.get_attention_mask()
|
||||
.iter()
|
||||
.map(|&x| x as i64)
|
||||
.collect();
|
||||
let mut type_ids: Vec<i64> = encoding
|
||||
.get_type_ids()
|
||||
.iter()
|
||||
.map(|&x| x as i64)
|
||||
.collect();
|
||||
|
||||
if ids.len() > MAX_SEQ_LEN {
|
||||
eprintln!(
|
||||
"breadmill: truncating {} tokens to {} (chunk too large)",
|
||||
ids.len(),
|
||||
MAX_SEQ_LEN
|
||||
);
|
||||
ids.truncate(MAX_SEQ_LEN);
|
||||
mask.truncate(MAX_SEQ_LEN);
|
||||
type_ids.truncate(MAX_SEQ_LEN);
|
||||
}
|
||||
|
||||
let seq_len = ids.len() as i64;
|
||||
|
||||
let id_tensor =
|
||||
Tensor::<i64>::from_array((vec![1i64, seq_len], ids.clone())).map_err(|e| e.to_string())?;
|
||||
let mask_tensor =
|
||||
Tensor::<i64>::from_array((vec![1i64, seq_len], mask.clone())).map_err(|e| e.to_string())?;
|
||||
let type_tensor =
|
||||
Tensor::<i64>::from_array((vec![1i64, seq_len], type_ids)).map_err(|e| e.to_string())?;
|
||||
|
||||
let outputs = self
|
||||
.session
|
||||
.run(ort::inputs! {
|
||||
"input_ids" => id_tensor,
|
||||
"attention_mask" => mask_tensor,
|
||||
"token_type_ids" => type_tensor,
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// last_hidden_state: shape [1, seq_len, dim]
|
||||
let (shape, data) = outputs["last_hidden_state"]
|
||||
.try_extract_tensor::<f32>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let actual_seq = shape[1] as usize;
|
||||
let actual_dim = shape[2] as usize;
|
||||
|
||||
// Mean-pool over non-padding positions
|
||||
let mut result = vec![0.0f32; actual_dim];
|
||||
let mut count = 0usize;
|
||||
|
||||
for t in 0..actual_seq {
|
||||
if mask[t] > 0 {
|
||||
for d in 0..actual_dim {
|
||||
result[d] += data[t * actual_dim + d];
|
||||
}
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if count > 0 {
|
||||
for x in &mut result {
|
||||
*x /= count as f32;
|
||||
}
|
||||
}
|
||||
|
||||
l2_normalize(&mut result);
|
||||
|
||||
// Clamp/pad to configured dim
|
||||
result.truncate(self.dim);
|
||||
while result.len() < self.dim {
|
||||
result.push(0.0);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
fn l2_normalize(v: &mut Vec<f32>) {
|
||||
let norm: f32 = v.iter().map(|x| x * x).sum::<f32>().sqrt();
|
||||
if norm > 1e-10 {
|
||||
for x in v.iter_mut() {
|
||||
*x /= norm;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Execution provider selection -------------------------------------------
|
||||
|
||||
fn configure_eps(builder: SessionBuilder, backend: &Backend) -> Result<SessionBuilder, String> {
|
||||
match backend {
|
||||
Backend::Cpu => Ok(builder),
|
||||
Backend::Npu { cache_dir } => npu_session(builder, cache_dir),
|
||||
Backend::Rocm => rocm_session(builder),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "npu")]
|
||||
fn npu_session(builder: SessionBuilder, cache_dir: &Path) -> Result<SessionBuilder, String> {
|
||||
let vitis_ep = build_vitis_ep(cache_dir)?;
|
||||
eprintln!("breadmill: using NPU (VitisAI) execution provider");
|
||||
if std::env::var("ORT_DYLIB_PATH").is_err() {
|
||||
eprintln!(
|
||||
"breadmill: hint — set ORT_DYLIB_PATH to the Ryzen AI SDK ORT, e.g.:\n \
|
||||
ORT_DYLIB_PATH=~/.local/share/ryzen-ai-1.7.1/lib/libonnxruntime.so"
|
||||
);
|
||||
}
|
||||
builder
|
||||
.with_execution_providers([vitis_ep, ort::ep::CPU::default().build()])
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "npu"))]
|
||||
fn npu_session(builder: SessionBuilder, _cache_dir: &Path) -> Result<SessionBuilder, String> {
|
||||
eprintln!("breadmill: NPU backend requested but not compiled in (rebuild with --features npu); using CPU");
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
// ---- VitisAI EP (NPU) -------------------------------------------------------
|
||||
|
||||
#[cfg(feature = "npu")]
|
||||
fn build_vitis_ep(cache_dir: &Path) -> Result<ort::ep::ExecutionProviderDispatch, String> {
|
||||
let vaip_config = find_vaip_config()?;
|
||||
let npu_cache = cache_dir.join("npu");
|
||||
std::fs::create_dir_all(&npu_cache).map_err(|e| e.to_string())?;
|
||||
eprintln!("breadmill: vaip_config: {}", vaip_config.display());
|
||||
eprintln!("breadmill: NPU model cache: {}", npu_cache.display());
|
||||
Ok(ort::ep::Vitis::default()
|
||||
.with_config_file(vaip_config.to_string_lossy())
|
||||
.with_cache_dir(npu_cache.to_string_lossy())
|
||||
.with_cache_key("nomic-embed-text-v1.5")
|
||||
.build())
|
||||
}
|
||||
|
||||
// ---- ROCm EP (AMD iGPU) -----------------------------------------------------
|
||||
|
||||
#[cfg(feature = "rocm")]
|
||||
fn rocm_session(builder: SessionBuilder) -> Result<SessionBuilder, String> {
|
||||
eprintln!("breadmill: using ROCm execution provider (device 0)");
|
||||
builder
|
||||
.with_execution_providers([
|
||||
ort::execution_providers::ROCmExecutionProvider::default().build(),
|
||||
ort::ep::CPU::default().build(),
|
||||
])
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "rocm"))]
|
||||
fn rocm_session(builder: SessionBuilder) -> Result<SessionBuilder, String> {
|
||||
eprintln!("breadmill: ROCm backend requested but not compiled in (rebuild with --features rocm); using CPU");
|
||||
Ok(builder)
|
||||
}
|
||||
|
||||
/// Locate the VitisAI EP config file required by the AMD Ryzen AI SDK.
|
||||
///
|
||||
/// Search order:
|
||||
/// 1. `VAIP_CONFIG` environment variable
|
||||
/// 2. `~/.config/breadsearch/vaip_config.json`
|
||||
/// 3. `/etc/vaip_config.json`
|
||||
/// 4. `/opt/xilinx/vaip_config.json`
|
||||
#[cfg(feature = "npu")]
|
||||
fn find_vaip_config() -> Result<PathBuf, String> {
|
||||
if let Ok(p) = std::env::var("VAIP_CONFIG") {
|
||||
let path = PathBuf::from(&p);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
return Err(format!("VAIP_CONFIG={p} does not exist"));
|
||||
}
|
||||
|
||||
let user_path = breadsearch_shared::config_dir().join("vaip_config.json");
|
||||
if user_path.exists() {
|
||||
return Ok(user_path);
|
||||
}
|
||||
|
||||
// Standard system / SDK paths (checked in priority order)
|
||||
let home = std::env::var("HOME").unwrap_or_default();
|
||||
let sdk_paths = [
|
||||
format!("{home}/.local/share/ryzen-ai-1.7.1/voe-4.0-linux_x86_64/vaip_config.json"),
|
||||
"/etc/vaip_config.json".into(),
|
||||
"/opt/xilinx/vaip_config.json".into(),
|
||||
];
|
||||
for p in &sdk_paths {
|
||||
let path = Path::new(p.as_str());
|
||||
if path.exists() {
|
||||
return Ok(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
Err(
|
||||
"vaip_config.json not found; set VAIP_CONFIG=/path/to/vaip_config.json, \
|
||||
copy to ~/.config/breadsearch/vaip_config.json, or install the AMD Ryzen AI SDK"
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
84
breadmill/src/extract.rs
Normal file
84
breadmill/src/extract.rs
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
use std::{
|
||||
fs, io::Read, path::Path,
|
||||
};
|
||||
|
||||
pub type ExtractResult = Result<String, String>;
|
||||
|
||||
/// Extract plain text from a file based on its extension.
|
||||
/// Returns Err on hard failures; Err with message if format unsupported.
|
||||
pub fn extract(path: &Path) -> ExtractResult {
|
||||
let ext = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
|
||||
match ext.as_str() {
|
||||
"md" | "txt" | "org" => read_text(path),
|
||||
"pdf" => extract_pdf(path),
|
||||
"docx" => extract_docx(path),
|
||||
"odt" => extract_odt(path),
|
||||
other => Err(format!("unsupported extension: {}", other)),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_text(path: &Path) -> ExtractResult {
|
||||
fs::read_to_string(path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn extract_pdf(path: &Path) -> ExtractResult {
|
||||
// pdf-extract panics on some malformed PDFs; catch_unwind prevents indexer thread death.
|
||||
let path = path.to_path_buf();
|
||||
match std::panic::catch_unwind(|| pdf_extract::extract_text(&path)) {
|
||||
Ok(result) => result.map_err(|e| e.to_string()),
|
||||
Err(_) => Err("pdf-extract panicked on malformed content stream".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_docx(path: &Path) -> ExtractResult {
|
||||
extract_office_xml(path, "word/document.xml", "w:t")
|
||||
}
|
||||
|
||||
fn extract_odt(path: &Path) -> ExtractResult {
|
||||
extract_office_xml(path, "content.xml", "text:p")
|
||||
}
|
||||
|
||||
/// Open a zip-based office format and concatenate text from the named XML entry.
|
||||
/// We grab all Text events as a best-effort extraction.
|
||||
fn extract_office_xml(path: &Path, xml_entry: &str, _tag_hint: &str) -> ExtractResult {
|
||||
let file = fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let mut archive = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
|
||||
|
||||
let mut xml_bytes = Vec::new();
|
||||
archive
|
||||
.by_name(xml_entry)
|
||||
.map_err(|e| format!("entry '{}' not found: {}", xml_entry, e))?
|
||||
.read_to_end(&mut xml_bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let xml_str = String::from_utf8_lossy(&xml_bytes);
|
||||
let mut reader = quick_xml::Reader::from_str(&xml_str);
|
||||
reader.config_mut().trim_text(true);
|
||||
|
||||
let mut text = String::new();
|
||||
let mut buf = Vec::new();
|
||||
|
||||
loop {
|
||||
match reader.read_event_into(&mut buf) {
|
||||
Ok(quick_xml::events::Event::Text(e)) => {
|
||||
if let Ok(s) = e.decode() {
|
||||
if !text.is_empty() {
|
||||
text.push(' ');
|
||||
}
|
||||
text.push_str(&s);
|
||||
}
|
||||
}
|
||||
Ok(quick_xml::events::Event::Eof) => break,
|
||||
Err(e) => return Err(e.to_string()),
|
||||
_ => {}
|
||||
}
|
||||
buf.clear();
|
||||
}
|
||||
|
||||
Ok(text)
|
||||
}
|
||||
414
breadmill/src/indexer.rs
Normal file
414
breadmill/src/indexer.rs
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
use std::{
|
||||
collections::{HashMap, HashSet},
|
||||
fs,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, Mutex, atomic::{AtomicBool, AtomicUsize, Ordering}},
|
||||
time::{Duration, Instant, UNIX_EPOCH},
|
||||
};
|
||||
|
||||
use ignore::WalkBuilder;
|
||||
use notify::{RecommendedWatcher, RecursiveMode, Watcher, EventKind};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::{embed::OrtEmbedder, extract, chunk, power, store::Store};
|
||||
|
||||
pub struct SharedState {
|
||||
pub store: Mutex<Store>,
|
||||
pub embedder: Mutex<Option<OrtEmbedder>>,
|
||||
pub model_ready: AtomicBool,
|
||||
pub indexed: AtomicUsize,
|
||||
pub pending: AtomicUsize,
|
||||
pub reindex_signal: AtomicBool,
|
||||
}
|
||||
|
||||
impl SharedState {
|
||||
pub fn new(store: Store) -> Self {
|
||||
SharedState {
|
||||
store: Mutex::new(store),
|
||||
embedder: Mutex::new(None),
|
||||
model_ready: AtomicBool::new(false),
|
||||
indexed: AtomicUsize::new(0),
|
||||
pending: AtomicUsize::new(0),
|
||||
reindex_signal: AtomicBool::new(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Indexer {
|
||||
state: Arc<SharedState>,
|
||||
config: breadsearch_shared::Config,
|
||||
state_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Indexer {
|
||||
pub fn new(state: Arc<SharedState>, config: breadsearch_shared::Config, state_dir: PathBuf) -> Self {
|
||||
Indexer { state, config, state_dir }
|
||||
}
|
||||
|
||||
pub fn run(self) {
|
||||
self.initial_scan();
|
||||
self.watch_loop();
|
||||
}
|
||||
|
||||
/// True when embedding should be skipped right now: the user turned
|
||||
/// indexing off entirely, or the machine is on battery and
|
||||
/// `power.run_on_battery` is not set. Cheap sysfs reads — safe to call
|
||||
/// per-file and on every watch_loop tick.
|
||||
fn indexing_paused(&self) -> bool {
|
||||
if !self.config.power.enabled {
|
||||
return true;
|
||||
}
|
||||
!self.config.power.run_on_battery && !power::on_ac_power()
|
||||
}
|
||||
|
||||
pub fn full_reindex(&self) {
|
||||
eprintln!("breadmill: full reindex triggered");
|
||||
{
|
||||
let mut store = self.state.store.lock().unwrap();
|
||||
// Clear all state
|
||||
let _ = store.conn.execute_batch("DELETE FROM chunks; DELETE FROM files;");
|
||||
let _ = store.index.reserve(4096);
|
||||
}
|
||||
self.initial_scan();
|
||||
}
|
||||
|
||||
fn initial_scan(&self) {
|
||||
eprintln!("breadmill: scanning roots...");
|
||||
|
||||
let roots: Vec<PathBuf> = self.config.index.roots
|
||||
.iter()
|
||||
.map(|r| expand_home(r))
|
||||
.collect();
|
||||
|
||||
let excludes: Vec<PathBuf> = self.config.index.excludes
|
||||
.iter()
|
||||
.map(|r| expand_home(r))
|
||||
.collect();
|
||||
|
||||
// Snapshot existing indexed files
|
||||
let known: HashMap<String, (i64, String)> = {
|
||||
let store = self.state.store.lock().unwrap();
|
||||
store.all_files()
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|f| (f.path, (f.mtime, f.hash)))
|
||||
.collect()
|
||||
};
|
||||
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let max_bytes = (self.config.index.max_file_mb * 1024.0 * 1024.0) as u64;
|
||||
|
||||
for root in &roots {
|
||||
if !root.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
for entry in WalkBuilder::new(root)
|
||||
.hidden(false)
|
||||
.ignore(true)
|
||||
.git_ignore(true)
|
||||
.build()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.path().is_file())
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
if excludes.iter().any(|excl| path.starts_with(excl)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.is_indexed_extension(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let meta = match fs::metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
if meta.len() > max_bytes {
|
||||
continue;
|
||||
}
|
||||
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
seen.insert(path_str.clone());
|
||||
|
||||
let mtime = mtime_secs(&meta);
|
||||
|
||||
if let Some((known_mtime, _)) = known.get(&path_str) {
|
||||
if *known_mtime == mtime {
|
||||
continue; // unchanged
|
||||
}
|
||||
}
|
||||
|
||||
self.state.pending.fetch_add(1, Ordering::Relaxed);
|
||||
self.index_file(path, &path_str, mtime);
|
||||
self.state.pending.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
// Drop files that were deleted
|
||||
let to_delete: Vec<String> = known
|
||||
.keys()
|
||||
.filter(|p| !seen.contains(*p))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if !to_delete.is_empty() {
|
||||
let mut store = self.state.store.lock().unwrap();
|
||||
for path in to_delete {
|
||||
eprintln!("breadmill: removing deleted file: {}", path);
|
||||
let _ = store.delete_file(&path);
|
||||
}
|
||||
}
|
||||
|
||||
let count = {
|
||||
let store = self.state.store.lock().unwrap();
|
||||
let n = store.chunk_count();
|
||||
let _ = store.save_index(&self.state_dir);
|
||||
n
|
||||
};
|
||||
self.state.indexed.store(count, Ordering::Relaxed);
|
||||
eprintln!("breadmill: initial scan done — {} chunks indexed", count);
|
||||
}
|
||||
|
||||
fn watch_loop(self) {
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
|
||||
let mut watcher: RecommendedWatcher = match notify::recommended_watcher(tx) {
|
||||
Ok(w) => w,
|
||||
Err(e) => {
|
||||
eprintln!("breadmill: watcher init failed: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for root in self.config.index.roots.iter().map(|r| expand_home(r)) {
|
||||
if root.exists() {
|
||||
let _ = watcher.watch(&root, RecursiveMode::Recursive);
|
||||
}
|
||||
}
|
||||
|
||||
let mut pending_paths: HashSet<PathBuf> = HashSet::new();
|
||||
let mut last_event = Instant::now();
|
||||
let quiet = Duration::from_secs(2);
|
||||
let mut was_paused = self.indexing_paused();
|
||||
let mut last_power_check = Instant::now();
|
||||
|
||||
eprintln!("breadmill: watching for changes");
|
||||
|
||||
loop {
|
||||
// Drain the reindex signal
|
||||
if self.state.reindex_signal.swap(false, Ordering::Relaxed) {
|
||||
self.full_reindex();
|
||||
}
|
||||
|
||||
// Re-check the power gate periodically (sysfs reads are cheap but
|
||||
// no need to do it every 500ms tick). On a paused->active
|
||||
// transition, re-run the incremental scan to catch up anything
|
||||
// skipped while gated.
|
||||
if last_power_check.elapsed() >= Duration::from_secs(30) {
|
||||
last_power_check = Instant::now();
|
||||
let now_paused = self.indexing_paused();
|
||||
if was_paused && !now_paused {
|
||||
eprintln!("breadmill: power gate opened — resuming indexing");
|
||||
self.initial_scan();
|
||||
}
|
||||
was_paused = now_paused;
|
||||
}
|
||||
|
||||
match rx.recv_timeout(Duration::from_millis(500)) {
|
||||
Ok(Ok(event)) => {
|
||||
match event.kind {
|
||||
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
|
||||
for p in event.paths {
|
||||
pending_paths.insert(p);
|
||||
}
|
||||
last_event = Instant::now();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => eprintln!("breadmill: watch error: {}", e),
|
||||
Err(_) => {} // timeout — check quiet period
|
||||
}
|
||||
|
||||
if !pending_paths.is_empty() && last_event.elapsed() >= quiet {
|
||||
for path in pending_paths.drain() {
|
||||
self.handle_fs_event(&path);
|
||||
}
|
||||
let count = {
|
||||
let store = self.state.store.lock().unwrap();
|
||||
let n = store.chunk_count();
|
||||
let _ = store.save_index(&self.state_dir);
|
||||
n
|
||||
};
|
||||
self.state.indexed.store(count, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_fs_event(&self, path: &Path) {
|
||||
let excludes: Vec<PathBuf> = self.config.index.excludes
|
||||
.iter()
|
||||
.map(|r| expand_home(r))
|
||||
.collect();
|
||||
|
||||
if excludes.iter().any(|excl| path.starts_with(excl)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if !path.is_file() {
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
// File deleted — remove from index
|
||||
let mut store = self.state.store.lock().unwrap();
|
||||
let _ = store.delete_file(&path_str);
|
||||
return;
|
||||
}
|
||||
|
||||
if !self.is_indexed_extension(path) {
|
||||
return;
|
||||
}
|
||||
|
||||
let meta = match fs::metadata(path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let max_bytes = (self.config.index.max_file_mb * 1024.0 * 1024.0) as u64;
|
||||
if meta.len() > max_bytes {
|
||||
return;
|
||||
}
|
||||
|
||||
let path_str = path.to_string_lossy().into_owned();
|
||||
let mtime = mtime_secs(&meta);
|
||||
|
||||
self.state.pending.fetch_add(1, Ordering::Relaxed);
|
||||
self.index_file(path, &path_str, mtime);
|
||||
self.state.pending.fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
|
||||
fn index_file(&self, path: &Path, path_str: &str, mtime: i64) {
|
||||
if self.indexing_paused() {
|
||||
// Leave the file unrecorded so it's picked up again once indexing
|
||||
// resumes (initial_scan/watch_loop treat it as not-yet-indexed).
|
||||
return;
|
||||
}
|
||||
|
||||
eprintln!("breadmill: extracting {}", path_str);
|
||||
let text = match extract::extract(path) {
|
||||
Ok(t) if !t.trim().is_empty() => t,
|
||||
Ok(_) => return,
|
||||
Err(e) => {
|
||||
eprintln!("breadmill: extract {}: {}", path_str, e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let hash = sha256_str(text.as_bytes());
|
||||
|
||||
// Check if hash changed (catches content changes without mtime change)
|
||||
{
|
||||
let store = self.state.store.lock().unwrap();
|
||||
if let Ok(files) = store.all_files() {
|
||||
if files.iter().any(|f| f.path == path_str && f.hash == hash) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2000 char cap keeps even minified single-line files to ~500–2000 tokens,
|
||||
// avoiding quadratic attention blowup while still splitting at word boundaries
|
||||
// for natural-language files.
|
||||
let chunks = chunk::chunk_text(&text, 400, 80, 2_000);
|
||||
eprintln!("breadmill: embedding {} ({} chars, {} chunks)", path_str, text.len(), chunks.len());
|
||||
let mut embedder_guard = self.state.embedder.lock().unwrap();
|
||||
|
||||
if !self.state.model_ready.load(Ordering::Relaxed) {
|
||||
eprintln!("breadmill: model not ready, skipping embed for {}", path_str);
|
||||
return;
|
||||
}
|
||||
|
||||
let embedder = match embedder_guard.as_mut() {
|
||||
Some(e) => e,
|
||||
None => return,
|
||||
};
|
||||
|
||||
{
|
||||
let mut store = self.state.store.lock().unwrap();
|
||||
let _ = store.delete_file(path_str); // remove old chunks/vectors first
|
||||
}
|
||||
|
||||
let mut any_ok = false;
|
||||
let mut chunks_added = 0usize;
|
||||
|
||||
for (i, chunk) in chunks.iter().enumerate() {
|
||||
eprintln!("breadmill: embed chunk {}/{} ({} chars) for {}", i + 1, chunks.len(), chunk.text.len(), path_str);
|
||||
match embedder.embed_document(&chunk.text) {
|
||||
Ok(embedding) => {
|
||||
let mut store = self.state.store.lock().unwrap();
|
||||
// Ensure file row exists before inserting chunks (FK constraint)
|
||||
let _ = store.upsert_file(path_str, mtime, &hash);
|
||||
let _ = store.insert_chunk(
|
||||
path_str,
|
||||
&chunk.text,
|
||||
chunk.start,
|
||||
chunk.end,
|
||||
&embedding,
|
||||
);
|
||||
any_ok = true;
|
||||
chunks_added += 1;
|
||||
}
|
||||
Err(e) => eprintln!("breadmill: embed error for {}: {}", path_str, e),
|
||||
}
|
||||
}
|
||||
|
||||
if !any_ok {
|
||||
eprintln!("breadmill: no chunks embedded for {}", path_str);
|
||||
// Record the file so the mtime+hash check skips it on the next startup
|
||||
// rather than re-entering the same embed-fail loop.
|
||||
let store = self.state.store.lock().unwrap();
|
||||
let _ = store.upsert_file(path_str, mtime, &hash);
|
||||
} else {
|
||||
// Increment live so `status` reflects progress before the full scan ends.
|
||||
self.state.indexed.fetch_add(chunks_added, Ordering::Relaxed);
|
||||
eprintln!("breadmill: done {} ({} chunks indexed)", path_str, chunks_added);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_indexed_extension(&self, path: &Path) -> bool {
|
||||
path.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|ext| {
|
||||
self.config
|
||||
.index
|
||||
.extensions
|
||||
.iter()
|
||||
.any(|e| e.eq_ignore_ascii_case(ext))
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
fn mtime_secs(meta: &fs::Metadata) -> i64 {
|
||||
meta.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn sha256_str(bytes: &[u8]) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(bytes);
|
||||
hex::encode(hasher.finalize())
|
||||
}
|
||||
|
||||
pub fn expand_home(path: &str) -> PathBuf {
|
||||
if path.starts_with("~/") {
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".into());
|
||||
PathBuf::from(home).join(&path[2..])
|
||||
} else {
|
||||
PathBuf::from(path)
|
||||
}
|
||||
}
|
||||
242
breadmill/src/main.rs
Normal file
242
breadmill/src/main.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
use std::{
|
||||
io::Read,
|
||||
path::{Path, PathBuf},
|
||||
sync::{Arc, atomic::Ordering},
|
||||
};
|
||||
|
||||
use breadsearch_shared::{Request, Response};
|
||||
|
||||
mod chunk;
|
||||
mod embed;
|
||||
mod extract;
|
||||
mod indexer;
|
||||
mod power;
|
||||
mod serve;
|
||||
mod store;
|
||||
|
||||
use embed::{Backend, OrtEmbedder};
|
||||
use indexer::{Indexer, SharedState};
|
||||
use store::Store;
|
||||
|
||||
const MODEL_URL: &str =
|
||||
"https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/resolve/main/onnx/model.onnx";
|
||||
const TOKENIZER_URL: &str =
|
||||
"https://huggingface.co/nomic-ai/nomic-embed-text-v1.5/resolve/main/tokenizer.json";
|
||||
|
||||
fn main() {
|
||||
let raw_args: Vec<String> = std::env::args().collect();
|
||||
|
||||
// Extract global flags before command dispatch.
|
||||
let use_npu = raw_args.iter().any(|a| a == "--npu");
|
||||
let use_rocm = raw_args.iter().any(|a| a == "--rocm");
|
||||
|
||||
// Build a view of argv without backend flags for command matching.
|
||||
let args: Vec<&str> = raw_args
|
||||
.iter()
|
||||
.skip(1)
|
||||
.filter(|a| a.as_str() != "--npu" && a.as_str() != "--rocm")
|
||||
.map(|s| s.as_str())
|
||||
.collect();
|
||||
|
||||
match args.first().copied() {
|
||||
Some("--version") | Some("-V") => {
|
||||
println!("breadmill {}", env!("CARGO_PKG_VERSION"));
|
||||
}
|
||||
Some("--fetch-model") | Some("fetch-model") => {
|
||||
if let Err(e) = fetch_model() {
|
||||
eprintln!("breadmill: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some("--reindex") | Some("reindex") => {
|
||||
if let Err(e) = run_daemon(true, use_npu, use_rocm) {
|
||||
eprintln!("breadmill: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some("query") => {
|
||||
let q = args.get(1).copied().unwrap_or("");
|
||||
if q.is_empty() {
|
||||
eprintln!("usage: breadmill query <text>");
|
||||
std::process::exit(1);
|
||||
}
|
||||
cli_query(q);
|
||||
}
|
||||
Some("status") => {
|
||||
cli_status();
|
||||
}
|
||||
None | Some("serve") | Some("--serve") => {
|
||||
if let Err(e) = run_daemon(false, use_npu, use_rocm) {
|
||||
eprintln!("breadmill: {}", e);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
Some(cmd) => {
|
||||
eprintln!("breadmill: unknown command: {}", cmd);
|
||||
eprintln!(
|
||||
"usage: breadmill [serve|reindex|fetch-model|query <text>|status] [--npu|--rocm] [--version]"
|
||||
);
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Daemon -----------------------------------------------------------------
|
||||
|
||||
fn run_daemon(force_reindex: bool, use_npu: bool, use_rocm: bool) -> Result<(), String> {
|
||||
let config = breadsearch_shared::Config::load();
|
||||
let state_dir = breadsearch_shared::state_dir();
|
||||
let cache_dir = breadsearch_shared::cache_dir();
|
||||
let socket_path = breadsearch_shared::socket_path();
|
||||
let dim = config.model.dim;
|
||||
let snippet_len = config.search.snippet_len;
|
||||
let search_limit = config.search.limit;
|
||||
|
||||
std::fs::create_dir_all(&state_dir).map_err(|e| e.to_string())?;
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let backend = if use_npu || config.model.backend == "npu" {
|
||||
eprintln!("breadmill: NPU backend selected");
|
||||
Backend::Npu { cache_dir: cache_dir.clone() }
|
||||
} else if use_rocm || config.model.backend == "rocm" {
|
||||
eprintln!("breadmill: ROCm backend selected");
|
||||
Backend::Rocm
|
||||
} else {
|
||||
Backend::Cpu
|
||||
};
|
||||
|
||||
let store = Store::open(&state_dir, dim)?;
|
||||
let state = Arc::new(SharedState::new(store));
|
||||
|
||||
// Load embedder if model files present
|
||||
let model_dir = model_dir(&cache_dir);
|
||||
let model_path = model_dir.join("model.onnx");
|
||||
let tokenizer_path = model_dir.join("tokenizer.json");
|
||||
|
||||
if model_path.exists() && tokenizer_path.exists() {
|
||||
eprintln!("breadmill: loading model...");
|
||||
match OrtEmbedder::load(&model_path, &tokenizer_path, dim, backend) {
|
||||
Ok(embedder) => {
|
||||
*state.embedder.lock().unwrap() = Some(embedder);
|
||||
state.model_ready.store(true, Ordering::Relaxed);
|
||||
eprintln!("breadmill: model loaded");
|
||||
}
|
||||
Err(e) => eprintln!("breadmill: model load failed: {} — run --fetch-model", e),
|
||||
}
|
||||
} else {
|
||||
eprintln!(
|
||||
"breadmill: model files not found in {} — run: breadmill --fetch-model",
|
||||
model_dir.display()
|
||||
);
|
||||
}
|
||||
|
||||
// Indexer runs in a background thread
|
||||
{
|
||||
let state_clone = Arc::clone(&state);
|
||||
let config_clone = config.clone();
|
||||
let state_dir_clone = state_dir.clone();
|
||||
|
||||
std::thread::spawn(move || {
|
||||
let indexer = Indexer::new(state_clone, config_clone, state_dir_clone);
|
||||
if force_reindex {
|
||||
indexer.full_reindex();
|
||||
}
|
||||
indexer.run();
|
||||
});
|
||||
}
|
||||
|
||||
// Server runs on the main thread (blocking)
|
||||
serve::run(&socket_path, Arc::clone(&state), snippet_len, search_limit);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---- Model fetch ------------------------------------------------------------
|
||||
|
||||
fn fetch_model() -> Result<(), String> {
|
||||
let cache_dir = breadsearch_shared::cache_dir();
|
||||
let model_dir = model_dir(&cache_dir);
|
||||
std::fs::create_dir_all(&model_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
download_if_missing(MODEL_URL, &model_dir.join("model.onnx"))?;
|
||||
download_if_missing(TOKENIZER_URL, &model_dir.join("tokenizer.json"))?;
|
||||
|
||||
eprintln!("breadmill: model files ready in {}", model_dir.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn download_if_missing(url: &str, dest: &Path) -> Result<(), String> {
|
||||
if dest.exists() {
|
||||
eprintln!(" already present: {}", dest.display());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!(" downloading {} ...", url);
|
||||
let agent = ureq::AgentBuilder::new()
|
||||
.timeout(std::time::Duration::from_secs(300))
|
||||
.build();
|
||||
|
||||
let response = agent.get(url).call().map_err(|e| e.to_string())?;
|
||||
let mut bytes = Vec::new();
|
||||
response
|
||||
.into_reader()
|
||||
.read_to_end(&mut bytes)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if bytes.is_empty() {
|
||||
return Err(format!("empty download from {}", url));
|
||||
}
|
||||
|
||||
// Write atomically via temp file
|
||||
let tmp = dest.with_extension("tmp");
|
||||
std::fs::write(&tmp, &bytes).map_err(|e| e.to_string())?;
|
||||
std::fs::rename(&tmp, dest).map_err(|e| e.to_string())?;
|
||||
|
||||
eprintln!(" saved {} ({:.1} MB)", dest.display(), bytes.len() as f64 / 1_048_576.0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn model_dir(cache_dir: &Path) -> PathBuf {
|
||||
cache_dir.join("models")
|
||||
}
|
||||
|
||||
// ---- CLI helpers ------------------------------------------------------------
|
||||
|
||||
fn cli_query(query: &str) {
|
||||
let req = Request::Query {
|
||||
query: query.to_string(),
|
||||
limit: 10,
|
||||
};
|
||||
match breadsearch_shared::send_request(&req) {
|
||||
Ok(Response::Hits { hits }) => {
|
||||
if hits.is_empty() {
|
||||
println!("no results");
|
||||
}
|
||||
for (i, h) in hits.iter().enumerate() {
|
||||
println!(
|
||||
"{:2}. {} ({:.3})\n {}\n {}\n",
|
||||
i + 1,
|
||||
h.title,
|
||||
h.score,
|
||||
h.path,
|
||||
h.snippet.lines().next().unwrap_or(""),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Response::Error { message }) => eprintln!("error: {}", message),
|
||||
Ok(_) => eprintln!("unexpected response"),
|
||||
Err(e) => eprintln!("could not reach breadmill: {}", e),
|
||||
}
|
||||
}
|
||||
|
||||
fn cli_status() {
|
||||
match breadsearch_shared::send_request(&Request::Status) {
|
||||
Ok(Response::StatusInfo(s)) => {
|
||||
println!("indexed: {}", s.indexed);
|
||||
println!("pending: {}", s.pending);
|
||||
println!("model ready: {}", s.model_ready);
|
||||
}
|
||||
Ok(_) => eprintln!("unexpected response"),
|
||||
Err(e) => eprintln!("could not reach breadmill: {}", e),
|
||||
}
|
||||
}
|
||||
31
breadmill/src/power.rs
Normal file
31
breadmill/src/power.rs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Best-effort check for whether the system is currently on AC/mains power.
|
||||
/// Scans `/sys/class/power_supply` for a Mains or USB supply with `online=1`.
|
||||
/// Systems with no such supply at all (desktops, no battery) are treated as
|
||||
/// always on power, so this never blocks indexing on hardware without a
|
||||
/// battery to protect.
|
||||
pub fn on_ac_power() -> bool {
|
||||
let dir = Path::new("/sys/class/power_supply");
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let mut found_mains = false;
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let supply_type = fs::read_to_string(path.join("type")).unwrap_or_default();
|
||||
let supply_type = supply_type.trim();
|
||||
if supply_type != "Mains" && supply_type != "USB" {
|
||||
continue;
|
||||
}
|
||||
found_mains = true;
|
||||
let online = fs::read_to_string(path.join("online")).unwrap_or_default();
|
||||
if online.trim() == "1" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
!found_mains
|
||||
}
|
||||
127
breadmill/src/serve.rs
Normal file
127
breadmill/src/serve.rs
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
use std::{
|
||||
io::{BufRead, BufReader, Write},
|
||||
os::unix::net::{UnixListener, UnixStream},
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
use breadsearch_shared::{Request, Response, StatusInfo};
|
||||
|
||||
use crate::indexer::SharedState;
|
||||
|
||||
pub fn run(socket_path: &Path, state: Arc<SharedState>, snippet_len: usize, search_limit: usize) {
|
||||
let _ = std::fs::remove_file(socket_path);
|
||||
|
||||
let listener = match UnixListener::bind(socket_path) {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("breadmill: bind {}: {}", socket_path.display(), e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
eprintln!("breadmill: listening on {}", socket_path.display());
|
||||
|
||||
for stream in listener.incoming() {
|
||||
match stream {
|
||||
Ok(s) => {
|
||||
let state = Arc::clone(&state);
|
||||
std::thread::spawn(move || {
|
||||
handle(s, state, snippet_len, search_limit);
|
||||
});
|
||||
}
|
||||
Err(e) => eprintln!("breadmill: accept error: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle(stream: UnixStream, state: Arc<SharedState>, snippet_len: usize, search_limit: usize) {
|
||||
let stream_write = match stream.try_clone() {
|
||||
Ok(s) => s,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut reader = BufReader::new(&stream);
|
||||
let mut writer = std::io::BufWriter::new(stream_write);
|
||||
|
||||
let mut line = String::new();
|
||||
if reader.read_line(&mut line).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let response = match serde_json::from_str::<Request>(line.trim()) {
|
||||
Ok(req) => {
|
||||
let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
dispatch(req, &state, snippet_len, search_limit)
|
||||
}));
|
||||
match r {
|
||||
Ok(resp) => resp,
|
||||
Err(_) => Response::Error { message: "internal error".into() },
|
||||
}
|
||||
}
|
||||
Err(e) => Response::Error { message: e.to_string() },
|
||||
};
|
||||
|
||||
if let Ok(mut json) = serde_json::to_string(&response) {
|
||||
json.push('\n');
|
||||
let _ = writer.write_all(json.as_bytes());
|
||||
let _ = writer.flush();
|
||||
}
|
||||
}
|
||||
|
||||
fn dispatch(
|
||||
req: Request,
|
||||
state: &SharedState,
|
||||
snippet_len: usize,
|
||||
search_limit: usize,
|
||||
) -> Response {
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
match req {
|
||||
Request::Query { query, limit } => {
|
||||
if !state.model_ready.load(Ordering::Relaxed) {
|
||||
return Response::Error {
|
||||
message: "model not ready — run breadmill --fetch-model".into(),
|
||||
};
|
||||
}
|
||||
|
||||
let embedding = {
|
||||
let mut embedder = state.embedder.lock().unwrap();
|
||||
match embedder.as_mut() {
|
||||
Some(e) => match e.embed_query(&query) {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Response::Error { message: e },
|
||||
},
|
||||
None => {
|
||||
return Response::Error {
|
||||
message: "embedder unavailable".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let limit = limit.min(search_limit).max(1);
|
||||
let store = state.store.lock().unwrap();
|
||||
|
||||
match store.search(&embedding, limit, snippet_len) {
|
||||
Ok(hits) => Response::Hits { hits },
|
||||
Err(e) => Response::Error { message: e },
|
||||
}
|
||||
}
|
||||
|
||||
Request::Status => {
|
||||
use std::sync::atomic::Ordering;
|
||||
Response::StatusInfo(StatusInfo {
|
||||
indexed: state.indexed.load(Ordering::Relaxed),
|
||||
pending: state.pending.load(Ordering::Relaxed),
|
||||
model_ready: state.model_ready.load(Ordering::Relaxed),
|
||||
})
|
||||
}
|
||||
|
||||
Request::Reindex => {
|
||||
use std::sync::atomic::Ordering;
|
||||
state.reindex_signal.store(true, Ordering::Relaxed);
|
||||
Response::Ok
|
||||
}
|
||||
}
|
||||
}
|
||||
235
breadmill/src/store.rs
Normal file
235
breadmill/src/store.rs
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
use std::path::Path;
|
||||
|
||||
use rusqlite::{Connection, params};
|
||||
use usearch::{Index, IndexOptions, MetricKind, ScalarKind, new_index};
|
||||
|
||||
pub struct Store {
|
||||
pub conn: Connection,
|
||||
pub index: Index,
|
||||
pub dim: usize,
|
||||
}
|
||||
|
||||
// usearch::Index wraps a raw C++ pointer; access is serialized by the Mutex<Store>.
|
||||
unsafe impl Send for Store {}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FileMeta {
|
||||
pub path: String,
|
||||
pub mtime: i64,
|
||||
pub hash: String,
|
||||
}
|
||||
|
||||
impl Store {
|
||||
pub fn open(state_dir: &Path, dim: usize) -> Result<Self, String> {
|
||||
std::fs::create_dir_all(state_dir).map_err(|e| e.to_string())?;
|
||||
|
||||
let db_path = state_dir.join("meta.db");
|
||||
let conn = Connection::open(&db_path).map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
CREATE TABLE IF NOT EXISTS files (
|
||||
path TEXT PRIMARY KEY,
|
||||
mtime INTEGER NOT NULL,
|
||||
hash TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS chunks (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL REFERENCES files(path) ON DELETE CASCADE,
|
||||
chunk_text TEXT NOT NULL,
|
||||
chunk_start INTEGER NOT NULL,
|
||||
chunk_end INTEGER NOT NULL
|
||||
);",
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let idx_path = state_dir.join("vectors.usearch");
|
||||
let options = IndexOptions {
|
||||
dimensions: dim,
|
||||
metric: MetricKind::Cos,
|
||||
quantization: ScalarKind::F32,
|
||||
connectivity: 16,
|
||||
expansion_add: 128,
|
||||
expansion_search: 64,
|
||||
multi: false,
|
||||
};
|
||||
let index = new_index(&options).map_err(|e| e.to_string())?;
|
||||
|
||||
if idx_path.exists() {
|
||||
index
|
||||
.load(idx_path.to_str().unwrap())
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
index.reserve(4096).map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(Self { conn, index, dim })
|
||||
}
|
||||
|
||||
// ---- file state ---------------------------------------------------------
|
||||
|
||||
pub fn all_files(&self) -> Result<Vec<FileMeta>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT path, mtime, hash FROM files")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let rows = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(FileMeta {
|
||||
path: row.get(0)?,
|
||||
mtime: row.get(1)?,
|
||||
hash: row.get(2)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.collect();
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
pub fn upsert_file(&self, path: &str, mtime: i64, hash: &str) -> Result<(), String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT OR REPLACE INTO files (path, mtime, hash) VALUES (?1, ?2, ?3)",
|
||||
params![path, mtime, hash],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a file and all its chunks from SQLite; also remove chunk vectors.
|
||||
pub fn delete_file(&mut self, path: &str) -> Result<(), String> {
|
||||
// Collect chunk IDs before deletion for usearch removal
|
||||
let ids = self.chunk_ids_for(path)?;
|
||||
|
||||
self.conn
|
||||
.execute("DELETE FROM files WHERE path = ?1", params![path])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
for id in ids {
|
||||
let _ = self.index.remove(id); // best-effort; stale entries are harmless
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn chunk_ids_for(&self, path: &str) -> Result<Vec<u64>, String> {
|
||||
let mut stmt = self
|
||||
.conn
|
||||
.prepare("SELECT id FROM chunks WHERE path = ?1")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let ids = stmt
|
||||
.query_map(params![path], |row| row.get::<_, i64>(0))
|
||||
.map_err(|e| e.to_string())?
|
||||
.filter_map(|r| r.ok())
|
||||
.map(|id| id as u64)
|
||||
.collect();
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
// ---- chunk operations ---------------------------------------------------
|
||||
|
||||
pub fn insert_chunk(
|
||||
&mut self,
|
||||
path: &str,
|
||||
text: &str,
|
||||
start: usize,
|
||||
end: usize,
|
||||
embedding: &[f32],
|
||||
) -> Result<u64, String> {
|
||||
self.conn
|
||||
.execute(
|
||||
"INSERT INTO chunks (path, chunk_text, chunk_start, chunk_end)
|
||||
VALUES (?1, ?2, ?3, ?4)",
|
||||
params![path, text, start as i64, end as i64],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let id = self.conn.last_insert_rowid() as u64;
|
||||
|
||||
// Grow index if needed
|
||||
if self.index.size() + 1 > self.index.capacity() {
|
||||
self.index
|
||||
.reserve(self.index.capacity() + 4096)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
self.index.add(id, embedding).map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub fn chunk_count(&self) -> usize {
|
||||
self.conn
|
||||
.query_row("SELECT COUNT(*) FROM chunks", [], |row| {
|
||||
row.get::<_, i64>(0)
|
||||
})
|
||||
.unwrap_or(0) as usize
|
||||
}
|
||||
|
||||
// ---- query --------------------------------------------------------------
|
||||
|
||||
pub fn search(
|
||||
&self,
|
||||
embedding: &[f32],
|
||||
limit: usize,
|
||||
snippet_len: usize,
|
||||
) -> Result<Vec<breadsearch_shared::Hit>, String> {
|
||||
let results = self
|
||||
.index
|
||||
.search(embedding, limit)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let mut hits = Vec::new();
|
||||
|
||||
for (key, distance) in results.keys.iter().zip(results.distances.iter()) {
|
||||
// Convert cosine distance → similarity score (higher = better)
|
||||
let score = 1.0 - distance;
|
||||
|
||||
let maybe_chunk = self
|
||||
.conn
|
||||
.query_row(
|
||||
"SELECT chunk_text, path FROM chunks WHERE id = ?1",
|
||||
params![*key as i64],
|
||||
|row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
|
||||
)
|
||||
.ok();
|
||||
|
||||
if let Some((chunk_text, path)) = maybe_chunk {
|
||||
let snippet = truncate_to_chars(&chunk_text, snippet_len);
|
||||
let title = std::path::Path::new(&path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(&path)
|
||||
.to_string();
|
||||
|
||||
hits.push(breadsearch_shared::Hit {
|
||||
title,
|
||||
path,
|
||||
snippet,
|
||||
score,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(hits)
|
||||
}
|
||||
|
||||
// ---- persistence --------------------------------------------------------
|
||||
|
||||
pub fn save_index(&self, state_dir: &Path) -> Result<(), String> {
|
||||
let idx_path = state_dir.join("vectors.usearch");
|
||||
self.index
|
||||
.save(idx_path.to_str().unwrap())
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn truncate_to_chars(s: &str, max_chars: usize) -> String {
|
||||
if s.chars().count() <= max_chars {
|
||||
return s.to_string();
|
||||
}
|
||||
let truncated: String = s.chars().take(max_chars).collect();
|
||||
format!("{}…", truncated.trim_end())
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue