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
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:
1 parent
d3bb8c7618
commit
b8400c4fbf
110 files changed
+79395
-250
No files matched your search
+109
-15
@@ -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() {
|
||||
|
||||
Reference in new issue
Block a user