feat: add multimodal search and granular performance diagnostics
Core CI / core (ubuntu-latest) (push) Failing after 3m31s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 5m39s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s

This commit is contained in:
karl committed 2026-10-12 19:16:23 +02:00
1 parent d3bb8c7618
commit b8400c4fbf
110 files changed
+79395 -250

No files matched your search

+109 -15
View File
@@ -1,9 +1,10 @@
//! Thin command-line adapter around the shared, CPU-only search engine.
//! Thin headless adapter around the shared local search engine.
use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use searchi_core::metrics::{MetricsMode, SearchResponse, TraceCollector};
use searchi_core::{IndexOptions, SearchIndex, SearchOptions};
use searchi_service::HybridSearch;
@@ -46,6 +47,12 @@ enum Command {
/// Emit a JSON array for scripting.
#[arg(long)]
json: bool,
/// Include basic measured statistics in JSON output.
#[arg(long)]
performance: bool,
/// Include bounded granular stage timings and diagnostic counters.
#[arg(long)]
debug_performance: bool,
/// Restrict results to this folder and descendants.
#[arg(long)]
folder: Option<PathBuf>,
@@ -56,6 +63,11 @@ enum Command {
min_size: Option<u64>,
#[arg(long)]
max_size: Option<u64>,
/// Inclusive media duration limit in milliseconds.
#[arg(long)]
min_duration_ms: Option<u32>,
#[arg(long)]
max_duration_ms: Option<u32>,
/// Inclusive modification time in Unix milliseconds.
#[arg(long)]
modified_after_ms: Option<i64>,
@@ -80,6 +92,14 @@ enum Command {
embedding_worker: PathBuf,
#[arg(long, default_value_t = 1, value_parser = clap::value_parser!(u8).range(1..=8))]
cpu_threads: u8,
/// Standalone decoder; normally discovered beside the installed CLI.
#[arg(long, requires = "ffprobe")]
ffmpeg: Option<PathBuf>,
#[arg(long, requires = "ffmpeg")]
ffprobe: Option<PathBuf>,
/// Explicitly build only document vectors, without media.
#[arg(long)]
text_only: bool,
},
/// Show the indexed file and content counts.
Stats {
@@ -122,10 +142,14 @@ fn main() -> Result<()> {
query,
limit,
json,
performance,
debug_performance,
folder,
extension,
min_size,
max_size,
min_duration_ms,
max_duration_ms,
modified_after_ms,
modified_before_ms,
model_dir,
@@ -141,30 +165,56 @@ fn main() -> Result<()> {
extension,
min_size,
max_size,
min_duration_ms,
max_duration_ms,
modified_after_unix_ms: modified_after_ms,
modified_before_unix_ms: modified_before_ms,
};
let mut trace = TraceCollector::new(if debug_performance {
MetricsMode::Debug
} else {
MetricsMode::Basic
});
let results = if let (Some(model), Some(runtime)) = (model_dir, embedding_worker) {
HybridSearch::open(&db, model, runtime, usize::from(cpu_threads))?
.search(&query, usize::from(limit), &options)
let mut service = trace.timed("cli.session_initialize", None, |_, _| {
HybridSearch::open(&db, model, runtime, usize::from(cpu_threads))
})?;
service
.search_traced(&query, usize::from(limit), &options, &mut trace, None)
.context("Hybrid search failed")?
} else {
SearchIndex::open(&db)
.with_context(|| format!("Cannot open index {}", db.display()))?
.search_with_options(&query, usize::from(limit), &options)
let index = trace
.timed("cli.index_open", None, |_, _| SearchIndex::open(&db))
.with_context(|| format!("Cannot open index {}", db.display()))?;
index
.search_with_options_traced(
&query,
usize::from(limit),
&options,
&mut trace,
None,
)
.context("Search failed")?
};
let response = SearchResponse {
metrics: trace.finish(results.len()),
results,
};
if json {
println!("{}", serde_json::to_string_pretty(&results)?);
} else if results.is_empty() {
if performance || debug_performance {
println!("{}", serde_json::to_string_pretty(&response)?);
} else {
println!("{}", serde_json::to_string_pretty(&response.results)?);
}
} else if response.results.is_empty() {
println!("No matching files.");
} else {
for result in results {
for result in &response.results {
println!("{} [{}]", result.path, result.match_kind);
if let Some(source) = result.source {
if let Some(source) = &result.source {
println!(" {} {}", source.kind, source.number);
}
if let Some(error) = result.extraction_error {
if let Some(error) = &result.extraction_error {
println!(" Text extraction unavailable: {error}");
}
if !result.snippet.is_empty() {
@@ -172,18 +222,36 @@ fn main() -> Result<()> {
}
}
}
if !json {
eprintln!(
"{} results · {:.2} ms (includes CLI initialization)",
response.metrics.result_count, response.metrics.total_ms
);
if debug_performance {
eprintln!("{}", serde_json::to_string_pretty(&response.metrics)?);
}
}
}
Command::Embed {
db,
model_dir,
embedding_worker,
cpu_threads,
ffmpeg,
ffprobe,
text_only,
} => {
require_existing_index(&db)?;
let report =
HybridSearch::open(&db, model_dir, embedding_worker, usize::from(cpu_threads))?
.index_embeddings()
.context("Semantic indexing failed; prior generation remains active")?;
let mut service =
HybridSearch::open(&db, model_dir, embedding_worker, usize::from(cpu_threads))?;
let report = if text_only {
service.index_embeddings()
} else {
let tools = resolve_media_tools(ffmpeg, ffprobe)?;
service
.index_embeddings_with_media(&tools, &searchi_service::MediaLimits::default())
}
.context("Semantic indexing failed; prior generation remains active")?;
println!("{}", serde_json::to_string_pretty(&report)?);
}
Command::Stats { db, json } => {
@@ -204,6 +272,32 @@ fn main() -> Result<()> {
Ok(())
}
fn resolve_media_tools(
ffmpeg: Option<PathBuf>,
ffprobe: Option<PathBuf>,
) -> Result<searchi_service::FfmpegTools> {
if let (Some(ffmpeg), Some(ffprobe)) = (ffmpeg, ffprobe) {
return searchi_service::FfmpegTools::new(ffmpeg, ffprobe);
}
if let Some(tools) = searchi_service::FfmpegTools::from_environment()? {
return Ok(tools);
}
let development =
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../.searchi/media-runtime/bin");
let directory = if cfg!(debug_assertions) && development.is_dir() {
development
} else {
std::env::current_exe()?
.parent()
.context("CLI installation directory is unavailable")?
.join("media-runtime/bin")
};
searchi_service::FfmpegTools::new(
directory.join(if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }),
directory.join(if cfg!(windows) { "ffprobe.exe" } else { "ffprobe" }),
).context("Media tools are missing from this installation; repair the headless package or explicitly use --text-only")
}
/// Read commands must not silently create a new empty database after a typo.
fn require_existing_index(db: &std::path::Path) -> Result<()> {
if !db.is_file() {