breadsearch/breadmill/src/main.rs
Breadway 0990969722 Migrate embedding pipeline, EP session building, and model download to bread-onnx
embed.rs's OrtEmbedder now delegates its tokenize -> tensor build ->
mean-pool -> L2-normalize pipeline to bread_onnx::embedding::EmbeddingSession
(near-byte-identical to breadarrd's own OrtEmbedder — same duplication, now
shared, path dependency for now, see the TODO in breadmill/Cargo.toml), and
its per-EP session builders (npu_session/rocm_session/cuda_session/
openvino_session) collapse into a single to_provider() mapping onto
bread_onnx::Provider, which this crate's own breadmill/src/session.rs
counterpart now handles generically. This crate's Backend enum, cargo
feature gates (npu/rocm/cuda/openvino/full - unchanged, still control which
onnxruntime EPs actually link/load), and NPU vaip_config.json discovery all
stay local since they're genuinely breadsearch-specific. main.rs's Backend
construction and CLI flag handling are untouched.

This is also the reference implementation the MIGraphX-not-ROCm default in
bread-onnx's provider module was promoted from (see this machine's own
breadsearch-gpu-backends operator notes) — breadpad's ONNX migration, which
had the actual silent-fallback bug, follows in a later commit.

download_if_missing is replaced with bread_onnx::download::ensure_file
(same sync/ureq approach, now shared with breadarrd's downloader).

Builds clean with default features AND --features full (npu+rocm+cuda+
openvino all compiling together, matching how this crate already combined
them). All existing tests pass across the whole workspace.
2026-07-17 09:41:58 +08:00

278 lines
9.4 KiB
Rust

use std::{
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;
mod sync_ext;
use embed::{Backend, OrtEmbedder};
use indexer::{Indexer, SharedState};
use store::Store;
use sync_ext::MutexExt;
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() {
// Surfaces ort's EP-registration warnings/errors (e.g. a GPU EP silently
// falling back to CPU) by default, without requiring RUST_LOG to be set.
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn,ort=info")),
)
.init();
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");
let use_cuda = raw_args.iter().any(|a| a == "--cuda");
let use_openvino = raw_args.iter().any(|a| a == "--openvino");
// Build a view of argv without backend flags for command matching.
let backend_flags = ["--npu", "--rocm", "--cuda", "--openvino"];
let args: Vec<&str> = raw_args
.iter()
.skip(1)
.filter(|a| !backend_flags.contains(&a.as_str()))
.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, use_cuda, use_openvino) {
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, use_cuda, use_openvino) {
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|--cuda|--openvino] [--version]"
);
std::process::exit(1);
}
}
}
// ---- Daemon -----------------------------------------------------------------
fn run_daemon(
force_reindex: bool,
use_npu: bool,
use_rocm: bool,
use_cuda: bool,
use_openvino: 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())?;
// CLI flags always override config — otherwise an explicit --cuda/--npu on
// the command line would silently lose to an unrelated `backend = "..."`
// already sitting in config.toml, since that's whatever earlier branch a
// fixed if/else-if priority order happened to check first.
let backend_name = if use_npu {
"npu"
} else if use_rocm {
"rocm"
} else if use_cuda {
"cuda"
} else if use_openvino {
"openvino"
} else {
config.model.backend.as_str()
};
let backend = match backend_name {
"npu" => {
eprintln!("breadmill: NPU backend selected");
Backend::Npu { cache_dir: cache_dir.clone() }
}
"rocm" => {
eprintln!("breadmill: ROCm backend selected");
Backend::Rocm
}
"cuda" => {
eprintln!("breadmill: CUDA backend selected");
Backend::Cuda
}
"openvino" => {
eprintln!("breadmill: OpenVINO backend selected");
Backend::OpenVino { cache_dir: cache_dir.clone() }
}
_ => Backend::Cpu,
};
let store = Store::open(&state_dir, dim)?;
let state = Arc::new(SharedState::new(store));
// Load the embedder on a background thread — an OpenVINO/CUDA/etc EP
// compile can take minutes or hang outright, and doing this inline used
// to block the socket bind below until it finished. That turned "model
// still loading" into an indistinguishable "connection refused" for
// every client, including the GUI, for as long as the load took. The
// socket now opens immediately; serve.rs already answers "model not
// ready" (via `model_ready`) for any request that arrives before the
// background load finishes.
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() {
let state_clone = Arc::clone(&state);
std::thread::spawn(move || {
eprintln!("breadmill: loading model...");
match OrtEmbedder::load(&model_path, &tokenizer_path, dim, backend) {
Ok(embedder) => {
*state_clone.embedder.lock_recover() = Some(embedder);
state_clone.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(());
}
// Shared with breadarrd's own (previously reqwest/async, now also this
// same sync/ureq implementation) model downloader — see
// bread_onnx::download's doc comment.
bread_onnx::download::ensure_file(url, dest, None).map_err(|e| e.to_string())?;
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),
}
}