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
@@ -10,6 +10,7 @@ anyhow.workspace = true
|
||||
serde.workspace = true
|
||||
searchi-core = { path = "../searchi-core" }
|
||||
searchi-semantic = { path = "../searchi-semantic" }
|
||||
searchi-media = { path = "../searchi-media" }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
sha2 = "0.10"
|
||||
fs2 = "0.4"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
# Shared hybrid retrieval
|
||||
|
||||
`HybridSearch` combines genuine lexical and Google multimodal retrieval. The
|
||||
CLI and desktop use the same service; it has no desktop-shell dependency.
|
||||
|
||||
`search`, `search_semantic` and `search_semantic_with_origin` preserve grouped
|
||||
parent files. ANN candidates expand until enough unique eligible parents exist
|
||||
or the complete bounded eligible chunk set is requested. Filename/type/folder,
|
||||
size/date/duration and explicit source filters apply before ANN top-k. Media
|
||||
candidate filesystem metadata is checked once per parent per query. Ninety-six
|
||||
near-identical video vectors cannot hide a second eligible parent (regression
|
||||
tested). HNSW remains approximate retrieval, not an exact recall guarantee.
|
||||
|
||||
Explicit spoken/audio wording prefers actual audio-source vectors, preserving
|
||||
exact lexical filename priority. Conservative English/German token cues detect
|
||||
requests such as “someone mentions”, “spoken”, “audio”, “jemand erwähnt” and
|
||||
“Gespräch”. Visual/document cues veto automatic routing. This finite parser is
|
||||
not language understanding; paraphrases, negation and mixed intents can remain
|
||||
ambiguous. If the preferred lane has no eligible results, ordinary retrieval
|
||||
is used. `SemanticOrigin::All` bypasses the hint; `Audio`, `Image`, `Video` and
|
||||
`Text` select an explicit channel without fallback or reembedding.
|
||||
|
||||
Persisted custom sampling limits can be reopened without generating embeddings
|
||||
again only when the complete model and preprocessing fingerprint matches.
|
||||
The custom suffix must contain the eight valid numeric sampling limits. Model
|
||||
or preprocessing changes never silently reuse incompatible vectors.
|
||||
|
||||
`search_measured(query,limit,options,debug)` returns `SearchResponse` with results
|
||||
and monotonic total milliseconds/result count. Basic mode stores no trace.
|
||||
`search_traced(...,&mut TraceCollector,parent)` shares the native request clock
|
||||
so queue/lock time and actual engine stages remain comparable. Debug mode adds
|
||||
bounded nested spans for lexical SQL, query embedding, worker diagnostics,
|
||||
filtered eligibility and its cache, ANN read/checksum/deserialization/search
|
||||
passes, parent materialization and validation, and rank fusion. Static stage
|
||||
names and numerical counters exclude query/source text and paths. Worker-local
|
||||
microsecond counters have a separate clock and are not aligned request spans.
|
||||
|
||||
Measured/source APIs retain existing result behavior except the explicit
|
||||
spoken/audio preference. Debug instrumentation does not change vector policy.
|
||||
Audio timestamps represent segment starts, not exact word locations. Smaller
|
||||
windows alone have not demonstrated accurate spoken navigation. No-answer
|
||||
calibration, music discrimination, exact spoken positions, process isolation,
|
||||
unchanged-vector reuse and production platform checks remain release gates.
|
||||
@@ -1,10 +1,15 @@
|
||||
//! Shared offline hybrid search using real Google embeddings and persisted,
|
||||
//! filtered HNSW retrieval. Production-scale validation remains a release gate.
|
||||
mod media;
|
||||
mod routing;
|
||||
pub use routing::SemanticOrigin;
|
||||
mod store;
|
||||
pub use searchi_media::{FfmpegTools, MediaLimits};
|
||||
|
||||
use anyhow::{ensure, Result};
|
||||
use searchi_core::metrics::{MetricsMode, SpanToken, TraceCollector};
|
||||
use searchi_core::SearchIndex;
|
||||
pub use searchi_core::{SearchOptions, SearchResult};
|
||||
pub use searchi_core::{SearchMetrics, SearchOptions, SearchResponse, SearchResult};
|
||||
pub use searchi_semantic::WorkerInfo;
|
||||
use searchi_semantic::{GemmaEmbedder, TextWindow, GEMMA_DIMENSIONS, GEMMA_FINGERPRINT};
|
||||
use serde::Serialize;
|
||||
@@ -17,7 +22,24 @@ const BYTE_OVERLAP: usize = 512;
|
||||
const MAX_PASSAGE_WINDOWS: usize = 2048;
|
||||
const CHUNK_POLICY: &str = "byte64k-overlap512-title512-v1";
|
||||
fn generation_fingerprint(model: &str) -> String {
|
||||
format!("{model}:{CHUNK_POLICY}")
|
||||
format!(
|
||||
"{model}:{CHUNK_POLICY}:{}:image280-frame70-audio30s-overlap2-distributed240-scene120-v1",
|
||||
searchi_semantic::GEMMA_MEDIA_FINGERPRINT
|
||||
)
|
||||
}
|
||||
|
||||
fn reusable_policy(expected: &str, active: &str) -> bool {
|
||||
if active == expected || active == format!("{expected}:text-only") {
|
||||
return true;
|
||||
}
|
||||
let Some(policy) = active.strip_prefix(&format!("{expected}:custom-")) else {
|
||||
return false;
|
||||
};
|
||||
let values = policy
|
||||
.split('-')
|
||||
.map(str::parse::<u64>)
|
||||
.collect::<std::result::Result<Vec<_>, _>>();
|
||||
matches!(values,Ok(v) if v.len()==8 && v[0]>=2 && (5000..=30000).contains(&v[3]) && v[4]<v[3] && v[5]>0 && v[6]<=u32::MAX as u64)
|
||||
}
|
||||
|
||||
/// Model-specific query/document prompts and actual tokenizer windows belong
|
||||
@@ -28,6 +50,18 @@ pub trait EmbeddingProvider: Send {
|
||||
fn split_windows(&mut self, text: &str, title: &str) -> Result<Vec<TextWindow>>;
|
||||
fn embed_documents(&mut self, texts: &[&str], title: &str) -> Result<Vec<Vec<f32>>>;
|
||||
fn embed_query(&mut self, query: &str) -> Result<Vec<f32>>;
|
||||
fn embed_query_debug(&mut self, query: &str) -> Result<Vec<f32>> {
|
||||
self.embed_query(query)
|
||||
}
|
||||
fn query_diagnostics(&self) -> std::collections::BTreeMap<String, u64> {
|
||||
Default::default()
|
||||
}
|
||||
fn embed_images(&mut self, _paths: &[&Path], _budget: u32) -> Result<Vec<Vec<f32>>> {
|
||||
anyhow::bail!("Provider does not support real images")
|
||||
}
|
||||
fn embed_audio(&mut self, _pcm: &[f32]) -> Result<Vec<f32>> {
|
||||
anyhow::bail!("Provider does not support real audio")
|
||||
}
|
||||
fn worker_info(&self) -> Option<searchi_semantic::WorkerInfo> {
|
||||
None
|
||||
}
|
||||
@@ -49,13 +83,34 @@ impl EmbeddingProvider for GemmaEmbedder {
|
||||
fn embed_query(&mut self, query: &str) -> Result<Vec<f32>> {
|
||||
self.embed_query(query)
|
||||
}
|
||||
fn embed_query_debug(&mut self, query: &str) -> Result<Vec<f32>> {
|
||||
GemmaEmbedder::embed_query_debug(self, query)
|
||||
}
|
||||
fn query_diagnostics(&self) -> std::collections::BTreeMap<String, u64> {
|
||||
GemmaEmbedder::query_diagnostics(self)
|
||||
}
|
||||
fn embed_images(&mut self, paths: &[&Path], budget: u32) -> Result<Vec<Vec<f32>>> {
|
||||
self.embed_images_with_budget(paths, budget)
|
||||
}
|
||||
fn embed_audio(&mut self, pcm: &[f32]) -> Result<Vec<f32>> {
|
||||
self.embed_audio_pcm(pcm)
|
||||
}
|
||||
fn worker_info(&self) -> Option<searchi_semantic::WorkerInfo> {
|
||||
Some(self.info.clone())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MediaFailure {
|
||||
pub path: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EmbeddingReport {
|
||||
pub media_files: usize,
|
||||
pub media_failures: usize,
|
||||
pub failures: Vec<MediaFailure>,
|
||||
pub passages: usize,
|
||||
pub chunks: usize,
|
||||
pub generation: i64,
|
||||
@@ -64,6 +119,7 @@ pub struct EmbeddingReport {
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct EmbeddingStatus {
|
||||
pub failures: Vec<MediaFailure>,
|
||||
pub generation: Option<i64>,
|
||||
pub chunks: usize,
|
||||
pub model_fingerprint: Option<String>,
|
||||
@@ -98,9 +154,10 @@ impl HybridSearch {
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let pending: usize =
|
||||
connection.query_row("SELECT count(*) FROM passages", [], |row| row.get(0))?;
|
||||
connection.query_row("SELECT (SELECT count(*) FROM passages)+(SELECT count(*) FROM files WHERE extension IN ('png','jpg','jpeg','webp','bmp','mp4','mkv','mov','avi','webm','m4v','mpeg','mpg','mp3','flac','wav','m4a','ogg','opus','aac','aiff','aif'))", [], |row| row.get(0))?;
|
||||
if !exists {
|
||||
return Ok(EmbeddingStatus {
|
||||
failures: Vec::new(),
|
||||
generation: None,
|
||||
chunks: 0,
|
||||
model_fingerprint: None,
|
||||
@@ -113,8 +170,9 @@ impl HybridSearch {
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
if version < 5 {
|
||||
if version < 6 {
|
||||
return Ok(EmbeddingStatus {
|
||||
failures: Vec::new(),
|
||||
generation: None,
|
||||
chunks: 0,
|
||||
model_fingerprint: None,
|
||||
@@ -123,7 +181,7 @@ impl HybridSearch {
|
||||
});
|
||||
}
|
||||
ensure!(
|
||||
version == 5,
|
||||
version == 6,
|
||||
"Unsupported semantic schema version {version}"
|
||||
);
|
||||
let (generation,fingerprint,revision,current):(Option<i64>,Option<String>,Option<i64>,i64)=connection.query_row(
|
||||
@@ -134,9 +192,19 @@ impl HybridSearch {
|
||||
[generation],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
let mut failures_query=connection.prepare("SELECT path,message FROM semantic_media_errors WHERE generation=?1 ORDER BY path LIMIT 1000")?;
|
||||
let failures = failures_query
|
||||
.query_map([generation], |r| {
|
||||
Ok(MediaFailure {
|
||||
path: r.get(0)?,
|
||||
message: r.get(1)?,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
let stale = revision != Some(current)
|
||||
|| fingerprint.as_deref() != Some(generation_fingerprint(GEMMA_FINGERPRINT).as_str());
|
||||
Ok(EmbeddingStatus {
|
||||
failures,
|
||||
generation,
|
||||
chunks,
|
||||
model_fingerprint: fingerprint,
|
||||
@@ -162,7 +230,12 @@ impl HybridSearch {
|
||||
) -> Result<Self> {
|
||||
let lexical = SearchIndex::open(&db)?;
|
||||
let store = Store::open(db.as_ref(), embedder.dimensions())?;
|
||||
let generation_fingerprint = generation_fingerprint(embedder.fingerprint());
|
||||
let expected = generation_fingerprint(embedder.fingerprint());
|
||||
let active = store.active_fingerprint()?;
|
||||
let generation_fingerprint = match active {
|
||||
Some(active) if reusable_policy(&expected, &active) => active,
|
||||
_ => expected,
|
||||
};
|
||||
Ok(Self {
|
||||
lexical,
|
||||
embedder,
|
||||
@@ -189,6 +262,11 @@ impl HybridSearch {
|
||||
/// storage errors leave the previous generation active. A crashed build is
|
||||
/// reclaimed on the next explicit rebuild under an OS advisory lock.
|
||||
pub fn index_embeddings(&mut self) -> Result<EmbeddingReport> {
|
||||
let previous_fingerprint = self.generation_fingerprint.clone();
|
||||
self.generation_fingerprint = format!(
|
||||
"{}:text-only",
|
||||
generation_fingerprint(self.embedder.fingerprint())
|
||||
);
|
||||
let _lock = self.store.build_lock()?;
|
||||
let generation = self.store.begin(&self.generation_fingerprint)?;
|
||||
let built = self.build(generation);
|
||||
@@ -199,6 +277,7 @@ impl HybridSearch {
|
||||
}
|
||||
Err(error) => {
|
||||
self.store.abandon(generation)?;
|
||||
self.generation_fingerprint = previous_fingerprint;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
@@ -206,6 +285,9 @@ impl HybridSearch {
|
||||
|
||||
fn build(&mut self, generation: i64) -> Result<EmbeddingReport> {
|
||||
let mut report = EmbeddingReport {
|
||||
media_files: 0,
|
||||
media_failures: 0,
|
||||
failures: Vec::new(),
|
||||
passages: 0,
|
||||
chunks: 0,
|
||||
generation,
|
||||
@@ -253,21 +335,81 @@ impl HybridSearch {
|
||||
query: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
let mut trace = TraceCollector::new(MetricsMode::Basic);
|
||||
self.search_traced(query, limit, options, &mut trace, None)
|
||||
}
|
||||
pub fn search_measured(
|
||||
&mut self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
debug: bool,
|
||||
) -> Result<SearchResponse<SearchResult>> {
|
||||
let mut trace = TraceCollector::new(if debug {
|
||||
MetricsMode::Debug
|
||||
} else {
|
||||
MetricsMode::Basic
|
||||
});
|
||||
let results = self.search_traced(query, limit, options, &mut trace, None)?;
|
||||
let metrics = trace.finish(results.len());
|
||||
Ok(SearchResponse { results, metrics })
|
||||
}
|
||||
pub fn search_traced(
|
||||
&mut self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
trace: &mut TraceCollector,
|
||||
parent: Option<SpanToken>,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
trace.timed("hybrid.search", parent, |trace, parent| {
|
||||
self.search_inner(query, limit, options, trace, parent)
|
||||
})
|
||||
}
|
||||
fn search_inner(
|
||||
&mut self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
trace: &mut TraceCollector,
|
||||
parent: Option<SpanToken>,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
let candidates = limit.saturating_mul(4).clamp(20, 1000);
|
||||
// Core validates filters and query budgets even for empty requests.
|
||||
let mut lexical = self
|
||||
.lexical
|
||||
.search_with_options(query, candidates, options)?;
|
||||
.search_with_options_traced(query, candidates, options, trace, parent)?;
|
||||
if query.trim().is_empty() || limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
promote_identifiers(query.trim(), &mut lexical);
|
||||
let vector = self.embedder.embed_query(query.trim())?;
|
||||
trace.timed("hybrid.identifier_priority", parent, |_, _| {
|
||||
promote_identifiers(query.trim(), &mut lexical)
|
||||
});
|
||||
let embedding_span = trace.begin("semantic.query_embedding", parent);
|
||||
let vector = if trace.is_debug() {
|
||||
self.embedder.embed_query_debug(query.trim())
|
||||
} else {
|
||||
self.embedder.embed_query(query.trim())
|
||||
};
|
||||
trace.counter(embedding_span, "failed", u64::from(vector.is_err()));
|
||||
trace.end(
|
||||
embedding_span,
|
||||
if trace.is_debug() {
|
||||
self.embedder.query_diagnostics()
|
||||
} else {
|
||||
Default::default()
|
||||
},
|
||||
);
|
||||
let vector = vector?;
|
||||
let semantic =
|
||||
self.store
|
||||
.search(&vector, &self.generation_fingerprint, candidates, options)?;
|
||||
Ok(fuse(lexical, semantic, limit.min(1000)))
|
||||
self.routed_semantic_traced(query, &vector, candidates, options, trace, parent)?;
|
||||
let results = trace.timed("hybrid.rank_fusion", parent, |trace, span| {
|
||||
trace.counter(span, "lexical_files", lexical.len() as u64);
|
||||
trace.counter(span, "semantic_files", semantic.len() as u64);
|
||||
fuse(lexical, semantic, limit.min(1000))
|
||||
});
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Semantic lane for reproducible relevance evaluation and diagnostics.
|
||||
@@ -282,12 +424,7 @@ impl HybridSearch {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let vector = self.embedder.embed_query(query.trim())?;
|
||||
self.store.search(
|
||||
&vector,
|
||||
&self.generation_fingerprint,
|
||||
limit.min(1000),
|
||||
options,
|
||||
)
|
||||
self.routed_semantic(query, &vector, limit.min(1000), options)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -417,6 +554,58 @@ fn fuse(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn failed_query_inference_retains_diagnostics_in_shared_trace() -> Result<()> {
|
||||
// Failure-only provider: no fabricated embeddings are generated.
|
||||
struct FailingProvider;
|
||||
impl EmbeddingProvider for FailingProvider {
|
||||
fn fingerprint(&self) -> &str {
|
||||
"failure-injection"
|
||||
}
|
||||
fn dimensions(&self) -> usize {
|
||||
768
|
||||
}
|
||||
fn split_windows(&mut self, _: &str, _: &str) -> Result<Vec<TextWindow>> {
|
||||
anyhow::bail!("unused")
|
||||
}
|
||||
fn embed_documents(&mut self, _: &[&str], _: &str) -> Result<Vec<Vec<f32>>> {
|
||||
anyhow::bail!("unused")
|
||||
}
|
||||
fn embed_query(&mut self, _: &str) -> Result<Vec<f32>> {
|
||||
anyhow::bail!("injected worker failure")
|
||||
}
|
||||
fn query_diagnostics(&self) -> std::collections::BTreeMap<String, u64> {
|
||||
std::collections::BTreeMap::from([("request_write_us".into(), 7)])
|
||||
}
|
||||
}
|
||||
let directory = tempfile::tempdir()?;
|
||||
let db = directory.path().join("index.db");
|
||||
let mut service = HybridSearch::with_provider(&db, Box::new(FailingProvider))?;
|
||||
let mut trace = TraceCollector::new(MetricsMode::Debug);
|
||||
assert!(service
|
||||
.search_traced(
|
||||
"ordinary query",
|
||||
5,
|
||||
&SearchOptions::default(),
|
||||
&mut trace,
|
||||
None
|
||||
)
|
||||
.is_err());
|
||||
let metrics = trace.finish(0);
|
||||
let spans = metrics.trace.unwrap();
|
||||
let embedding = spans
|
||||
.iter()
|
||||
.find(|s| s.name == "semantic.query_embedding")
|
||||
.unwrap();
|
||||
assert_eq!(embedding.counters["failed"], 1);
|
||||
assert_eq!(embedding.counters["request_write_us"], 7);
|
||||
let parent = &spans[embedding.parent.unwrap()];
|
||||
assert!(
|
||||
embedding.start_ms + embedding.duration_ms
|
||||
<= parent.start_ms + parent.duration_ms + 1e-6
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
fn result(path: &str, kind: &str) -> SearchResult {
|
||||
SearchResult {
|
||||
path: path.into(),
|
||||
|
||||
@@ -0,0 +1,232 @@
|
||||
use super::*;
|
||||
use searchi_media::*;
|
||||
|
||||
#[derive(Debug)]
|
||||
struct InferenceFailure(anyhow::Error);
|
||||
impl std::fmt::Display for InferenceFailure {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "Media inference failed: {}", self.0)
|
||||
}
|
||||
}
|
||||
impl std::error::Error for InferenceFailure {}
|
||||
|
||||
impl HybridSearch {
|
||||
/// Publish text and actual image/frame/audio vectors in one atomic generation.
|
||||
pub fn index_embeddings_with_media(
|
||||
&mut self,
|
||||
tools: &FfmpegTools,
|
||||
limits: &MediaLimits,
|
||||
) -> Result<EmbeddingReport> {
|
||||
let previous_fingerprint = self.generation_fingerprint.clone();
|
||||
self.generation_fingerprint = generation_fingerprint(self.embedder.fingerprint());
|
||||
let defaults = MediaLimits::default();
|
||||
if limits.max_frames != defaults.max_frames
|
||||
|| limits.frame_interval_ms != defaults.frame_interval_ms
|
||||
|| limits.max_scene_frames != defaults.max_scene_frames
|
||||
|| limits.audio_window_ms != defaults.audio_window_ms
|
||||
|| limits.audio_overlap_ms != defaults.audio_overlap_ms
|
||||
|| limits.max_audio_windows != defaults.max_audio_windows
|
||||
|| limits.max_duration_ms != defaults.max_duration_ms
|
||||
|| limits.max_file_bytes != defaults.max_file_bytes
|
||||
{
|
||||
self.generation_fingerprint.push_str(&format!(
|
||||
":custom-{}-{}-{}-{}-{}-{}-{}-{}",
|
||||
limits.max_frames,
|
||||
limits.frame_interval_ms,
|
||||
limits.max_scene_frames,
|
||||
limits.audio_window_ms,
|
||||
limits.audio_overlap_ms,
|
||||
limits.max_audio_windows,
|
||||
limits.max_duration_ms,
|
||||
limits.max_file_bytes
|
||||
));
|
||||
}
|
||||
let _lock = self.store.build_lock()?;
|
||||
let generation = self.store.begin(&self.generation_fingerprint)?;
|
||||
let built = (|| {
|
||||
let mut report = self.build(generation)?;
|
||||
let mut after = 0;
|
||||
let mut media_count = 0usize;
|
||||
loop {
|
||||
let files = self.store.media_batch(after)?;
|
||||
if files.is_empty() {
|
||||
break;
|
||||
}
|
||||
for (id, path, _, _) in files {
|
||||
after = id;
|
||||
let path = Path::new(&path);
|
||||
let Some(kind) = classify(path) else {
|
||||
continue;
|
||||
};
|
||||
media_count += 1;
|
||||
ensure!(
|
||||
media_count <= 10000,
|
||||
"Media generation exceeds 10000-file resource budget"
|
||||
);
|
||||
let before_chunks = report.chunks;
|
||||
match self.build_media_file(
|
||||
generation,
|
||||
id,
|
||||
path,
|
||||
kind,
|
||||
tools,
|
||||
limits,
|
||||
&mut report,
|
||||
) {
|
||||
Ok(()) => report.media_files += 1,
|
||||
Err(error) => {
|
||||
if error.downcast_ref::<InferenceFailure>().is_some() {
|
||||
return Err(error);
|
||||
}
|
||||
self.store.media_failure(
|
||||
generation,
|
||||
&path.to_string_lossy(),
|
||||
&format!("{error:#}"),
|
||||
)?;
|
||||
report.chunks = before_chunks;
|
||||
if report.failures.len() < 1000 {
|
||||
report.failures.push(MediaFailure {
|
||||
path: path.to_string_lossy().into_owned(),
|
||||
message: format!("{error:#}").chars().take(2048).collect(),
|
||||
});
|
||||
}
|
||||
report.media_failures += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(report)
|
||||
})();
|
||||
match built {
|
||||
Ok(report) => {
|
||||
self.store.activate(generation)?;
|
||||
Ok(report)
|
||||
}
|
||||
Err(error) => {
|
||||
self.store.abandon(generation)?;
|
||||
self.generation_fingerprint = previous_fingerprint;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn build_media_file(
|
||||
&mut self,
|
||||
generation: i64,
|
||||
id: i64,
|
||||
path: &Path,
|
||||
kind: MediaKind,
|
||||
tools: &FfmpegTools,
|
||||
limits: &MediaLimits,
|
||||
report: &mut EmbeddingReport,
|
||||
) -> Result<()> {
|
||||
let identity = MediaIdentity::read(
|
||||
path,
|
||||
if kind == MediaKind::Image {
|
||||
64 * 1024 * 1024
|
||||
} else {
|
||||
limits.max_file_bytes
|
||||
},
|
||||
)?;
|
||||
let mut ordinal = 0;
|
||||
if kind == MediaKind::Image {
|
||||
validate_image(path)?;
|
||||
ensure!(
|
||||
report.chunks < MAX_SCAN_CHUNKS,
|
||||
"Media exceeds generation chunk budget"
|
||||
);
|
||||
let vectors = self
|
||||
.embedder
|
||||
.embed_images(&[path], 280)
|
||||
.map_err(|e| anyhow::Error::new(InferenceFailure(e)))?;
|
||||
ensure!(vectors.len() == 1, "Image embedding count mismatch");
|
||||
self.store
|
||||
.insert_media(generation, id, ordinal, &identity, "image", 0, &vectors[0])?;
|
||||
report.chunks += 1;
|
||||
} else {
|
||||
let metadata = probe(tools, path, limits)?;
|
||||
let tags = metadata
|
||||
.tags
|
||||
.iter()
|
||||
.map(|(k, v)| format!("{k}: {v}"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
self.lexical.set_media_metadata(
|
||||
&path.to_string_lossy(),
|
||||
&tags,
|
||||
metadata.duration_ms,
|
||||
identity.bytes,
|
||||
identity.modified_ns,
|
||||
)?;
|
||||
if metadata.has_video && kind == MediaKind::Video {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut scenes = 0;
|
||||
for time in frame_times(&metadata, limits)? {
|
||||
let frames = decode_frames(
|
||||
tools,
|
||||
path,
|
||||
&metadata,
|
||||
time,
|
||||
scenes < limits.max_scene_frames,
|
||||
limits,
|
||||
)?;
|
||||
for (i, frame) in frames.frames.iter().enumerate() {
|
||||
if !seen.insert(frame.timestamp_ms) {
|
||||
continue;
|
||||
}
|
||||
if i > 0 {
|
||||
scenes += 1;
|
||||
}
|
||||
ensure!(
|
||||
report.chunks < MAX_SCAN_CHUNKS,
|
||||
"Media exceeds generation chunk budget"
|
||||
);
|
||||
let vectors = self
|
||||
.embedder
|
||||
.embed_images(&[frame.path.as_path()], 70)
|
||||
.map_err(|e| anyhow::Error::new(InferenceFailure(e)))?;
|
||||
ensure!(vectors.len() == 1, "Frame embedding count mismatch");
|
||||
self.store.insert_media(
|
||||
generation,
|
||||
id,
|
||||
ordinal,
|
||||
&identity,
|
||||
"video",
|
||||
frame.timestamp_ms,
|
||||
&vectors[0],
|
||||
)?;
|
||||
ordinal += 1;
|
||||
report.chunks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
if metadata.has_audio {
|
||||
for window in audio_windows(&metadata, limits)? {
|
||||
ensure!(
|
||||
report.chunks < MAX_SCAN_CHUNKS,
|
||||
"Audio exceeds generation chunk budget"
|
||||
);
|
||||
let pcm = decode_audio(tools, path, window, limits)?;
|
||||
let vector = self
|
||||
.embedder
|
||||
.embed_audio(&pcm)
|
||||
.map_err(|e| anyhow::Error::new(InferenceFailure(e)))?;
|
||||
self.store.insert_media(
|
||||
generation,
|
||||
id,
|
||||
ordinal,
|
||||
&identity,
|
||||
"audio",
|
||||
window.start_ms,
|
||||
&vector,
|
||||
)?;
|
||||
ordinal += 1;
|
||||
report.chunks += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
identity.ensure_unchanged()?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use super::*;
|
||||
|
||||
/// Explicit semantic source channel, independent of filename/type filters.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum SemanticOrigin {
|
||||
All,
|
||||
Text,
|
||||
Image,
|
||||
Video,
|
||||
Audio,
|
||||
}
|
||||
impl SemanticOrigin {
|
||||
fn source_type(self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::All => None,
|
||||
Self::Text => Some("text"),
|
||||
Self::Image => Some("image"),
|
||||
Self::Video => Some("video"),
|
||||
Self::Audio => Some("audio"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A conservative intent hint, not a language understanding model. Explicit
|
||||
/// document/visual cues veto it; ordinary topic and instrument queries do not
|
||||
/// silently restrict modality. Word boundaries avoid filename substrings.
|
||||
fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
||||
if query.split_whitespace().count() == 1 && Path::new(query).extension().is_some() {
|
||||
return None;
|
||||
}
|
||||
let lower = query.to_lowercase();
|
||||
let words: Vec<_> = lower
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect();
|
||||
let has = |values: &[&str]| words.iter().any(|word| values.contains(word));
|
||||
if has(&[
|
||||
"image",
|
||||
"photo",
|
||||
"picture",
|
||||
"screenshot",
|
||||
"diagram",
|
||||
"document",
|
||||
"pdf",
|
||||
"text",
|
||||
"paper",
|
||||
"bild",
|
||||
"foto",
|
||||
"screenshot",
|
||||
"dokument",
|
||||
"textdatei",
|
||||
]) {
|
||||
return None;
|
||||
}
|
||||
if has(&[
|
||||
"audio",
|
||||
"spoken",
|
||||
"speech",
|
||||
"recording",
|
||||
"conversation",
|
||||
"interview",
|
||||
"podcast",
|
||||
"sound",
|
||||
"music",
|
||||
"gesprochen",
|
||||
"gespräch",
|
||||
"gespraech",
|
||||
"geräusch",
|
||||
"geraeusch",
|
||||
"musik",
|
||||
"hörbar",
|
||||
"hörbarer",
|
||||
"hörbare",
|
||||
"hoerbar",
|
||||
"hoerbarer",
|
||||
"hoerbare",
|
||||
"tonaufnahme",
|
||||
]) {
|
||||
return Some(SemanticOrigin::Audio);
|
||||
}
|
||||
if (has(&["someone", "speaker"])
|
||||
&& has(&[
|
||||
"mentions",
|
||||
"mentioned",
|
||||
"says",
|
||||
"said",
|
||||
"speaks",
|
||||
"speaking",
|
||||
]))
|
||||
|| (has(&["jemand", "sprecher", "sprecherin"])
|
||||
&& has(&["erwähnt", "erwaehnt", "sagt", "gesagt", "spricht"]))
|
||||
{
|
||||
return Some(SemanticOrigin::Audio);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
impl HybridSearch {
|
||||
pub(super) fn routed_semantic(
|
||||
&mut self,
|
||||
query: &str,
|
||||
vector: &[f32],
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
let mut trace = TraceCollector::new(MetricsMode::Basic);
|
||||
self.routed_semantic_traced(query, vector, limit, options, &mut trace, None)
|
||||
}
|
||||
pub(super) fn routed_semantic_traced(
|
||||
&mut self,
|
||||
query: &str,
|
||||
vector: &[f32],
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
trace: &mut TraceCollector,
|
||||
parent: Option<SpanToken>,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
trace.timed("semantic.routing", parent, |trace, parent| {
|
||||
if let Some(origin) = preferred_origin(query) {
|
||||
trace.counter(parent, "audio_preference", 1);
|
||||
let preferred = self.store.search_origin_traced(
|
||||
vector,
|
||||
&self.generation_fingerprint,
|
||||
limit,
|
||||
options,
|
||||
origin.source_type(),
|
||||
trace,
|
||||
parent,
|
||||
)?;
|
||||
if !preferred.is_empty() {
|
||||
return Ok(preferred);
|
||||
}
|
||||
}
|
||||
trace.counter(parent, "general_lane", 1);
|
||||
self.store.search_origin_traced(
|
||||
vector,
|
||||
&self.generation_fingerprint,
|
||||
limit,
|
||||
options,
|
||||
None,
|
||||
trace,
|
||||
parent,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Query one actual source channel, or All to bypass automatic spoken/audio
|
||||
/// preference. This never embeds files again or changes an active policy.
|
||||
pub fn search_semantic_with_origin(
|
||||
&mut self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
origin: SemanticOrigin,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
let _ = self.lexical.search_with_options(query, 0, options)?;
|
||||
if query.trim().is_empty() || limit == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let vector = self.embedder.embed_query(query.trim())?;
|
||||
self.store.search_origin(
|
||||
&vector,
|
||||
&self.generation_fingerprint,
|
||||
limit.min(1000),
|
||||
options,
|
||||
origin.source_type(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn persisted_custom_limits_require_identical_model_and_preprocessing() {
|
||||
let expected = "pinned-model:pinned-media:preprocessing-v1";
|
||||
assert!(reusable_policy(
|
||||
expected,
|
||||
&format!("{expected}:custom-240-2000-120-10000-2000-1800-14400000-17179869184")
|
||||
));
|
||||
for active in ["other-model:pinned-media:preprocessing-v1:custom-240-2000-120-10000-2000-1800-14400000-17179869184","pinned-model:pinned-media:preprocessing-v2:custom-240-2000-120-10000-2000-1800-14400000-17179869184","pinned-model:pinned-media:preprocessing-v1:custom-unknown"] {assert!(!reusable_policy(expected,active));}
|
||||
}
|
||||
#[test]
|
||||
fn explicit_spoken_intent_is_conservative_and_bilingual() {
|
||||
for query in [
|
||||
"where someone mentions a contract",
|
||||
"a speaker says the date",
|
||||
"spoken discussion of ecology",
|
||||
"audio of a waterfall",
|
||||
"jemand erwähnt das Budget",
|
||||
"Wo spricht jemand über Kündigung?",
|
||||
"Gespräch über die neue Wohnung",
|
||||
"hörbarer Regen",
|
||||
] {
|
||||
assert_eq!(
|
||||
preferred_origin(query),
|
||||
Some(SemanticOrigin::Audio),
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
for query in [
|
||||
"contract deadline",
|
||||
"violin solo",
|
||||
"photo of someone speaking",
|
||||
"Bild von einem Sprecher",
|
||||
"a document that someone mentioned",
|
||||
"speech.png",
|
||||
"a screenshot of an audio waveform",
|
||||
] {
|
||||
assert_eq!(preferred_origin(query), None, "{query}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use std::{
|
||||
use anyhow::{ensure, Context, Result};
|
||||
use fs2::FileExt;
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use searchi_core::metrics::{MetricsMode, SpanToken, TraceCollector};
|
||||
use searchi_core::{IndexedPassage, SearchOptions, SearchResult, SourceLocation};
|
||||
use searchi_semantic::TextWindow;
|
||||
use sha2::{Digest, Sha256};
|
||||
@@ -24,6 +25,9 @@ struct FilterKey {
|
||||
max_size: Option<i64>,
|
||||
modified_after: Option<i64>,
|
||||
modified_before: Option<i64>,
|
||||
min_duration: Option<u32>,
|
||||
max_duration: Option<u32>,
|
||||
source_type: Option<String>,
|
||||
}
|
||||
|
||||
struct EligibilityCache {
|
||||
@@ -57,8 +61,13 @@ pub(crate) fn content_hash(text: &str) -> String {
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! { static HASH_CALLS:std::cell::Cell<usize>=const {std::cell::Cell::new(0)}; }
|
||||
#[cfg(test)]
|
||||
thread_local! { static MEDIA_STAT_CALLS:std::cell::Cell<usize>=const {std::cell::Cell::new(0)}; }
|
||||
|
||||
impl Store {
|
||||
pub(crate) fn active_fingerprint(&self) -> Result<Option<String>> {
|
||||
Ok(self.connection.query_row("SELECT g.fingerprint FROM semantic_meta m JOIN semantic_generations g ON g.id=m.active_generation WHERE m.singleton=1",[],|r|r.get(0)).optional()?)
|
||||
}
|
||||
pub(crate) fn open(db: &Path, dimensions: usize) -> Result<Self> {
|
||||
ensure!(
|
||||
(1..=4096).contains(&dimensions),
|
||||
@@ -85,7 +94,7 @@ impl Store {
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
ensure!(
|
||||
(1..=5).contains(&version),
|
||||
(1..=6).contains(&version),
|
||||
"Unsupported semantic schema version {version}"
|
||||
);
|
||||
initialization.execute_batch("
|
||||
@@ -130,8 +139,25 @@ impl Store {
|
||||
UPDATE semantic_meta SET corpus_revision=corpus_revision+1 WHERE singleton=1;
|
||||
INSERT INTO semantic_dirty_paths(path,revision) VALUES(old.path,(SELECT corpus_revision FROM semantic_meta WHERE singleton=1))
|
||||
ON CONFLICT(path) DO UPDATE SET revision=excluded.revision; END;
|
||||
UPDATE semantic_meta SET schema_version=5 WHERE singleton=1;",
|
||||
UPDATE semantic_meta SET schema_version=6 WHERE singleton=1;",
|
||||
)?;
|
||||
for (column, declaration) in [
|
||||
("source_type", "TEXT NOT NULL DEFAULT 'text'"),
|
||||
("file_size", "INTEGER"),
|
||||
("file_mtime", "INTEGER"),
|
||||
] {
|
||||
let exists: bool = initialization.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('semantic_chunks') WHERE name=?1)",
|
||||
[column],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
if !exists {
|
||||
initialization.execute_batch(&format!(
|
||||
"ALTER TABLE semantic_chunks ADD COLUMN {column} {declaration}"
|
||||
))?;
|
||||
}
|
||||
}
|
||||
initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_errors(generation INTEGER NOT NULL REFERENCES semantic_generations(id) ON DELETE CASCADE,path TEXT NOT NULL,message TEXT NOT NULL,PRIMARY KEY(generation,path))")?;
|
||||
initialization.commit()?;
|
||||
let canonical = db.canonicalize()?;
|
||||
let lock_path = PathBuf::from(format!("{}.semantic.lock", canonical.to_string_lossy()));
|
||||
@@ -170,6 +196,67 @@ impl Store {
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub(crate) fn media_batch(&self, after: i64) -> Result<Vec<(i64, String, i64, i64)>> {
|
||||
let mut q = self.connection.prepare(
|
||||
"SELECT rowid,path,size,mtime FROM files WHERE rowid>?1 ORDER BY rowid LIMIT 64",
|
||||
)?;
|
||||
let rows = q
|
||||
.query_map([after], |r| {
|
||||
Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?))
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(rows)
|
||||
}
|
||||
pub(crate) fn media_failure(&self, generation: i64, path: &str, message: &str) -> Result<()> {
|
||||
self.connection.execute(
|
||||
"DELETE FROM semantic_chunks WHERE generation=?1 AND path=?2 AND source_type!='text'",
|
||||
params![generation, path],
|
||||
)?;
|
||||
self.connection.execute("INSERT OR REPLACE INTO semantic_media_errors(generation,path,message) VALUES(?1,?2,?3)",params![generation,path,message.chars().take(2048).collect::<String>()])?;
|
||||
Ok(())
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn insert_media(
|
||||
&self,
|
||||
generation: i64,
|
||||
file_id: i64,
|
||||
ordinal: usize,
|
||||
identity: &searchi_media::MediaIdentity,
|
||||
source_type: &str,
|
||||
timestamp: u32,
|
||||
vector: &[f32],
|
||||
) -> Result<()> {
|
||||
ensure!(
|
||||
vector.len() == self.dimensions && vector.iter().all(|v| v.is_finite()),
|
||||
"Invalid media embedding"
|
||||
);
|
||||
let norm = vector.iter().map(|v| v * v).sum::<f32>().sqrt();
|
||||
ensure!(
|
||||
(norm - 1.).abs() < 1e-3,
|
||||
"Media embeddings must be normalized"
|
||||
);
|
||||
let path = identity.path.to_string_lossy();
|
||||
let valid:bool=self.connection.query_row("SELECT EXISTS(SELECT 1 FROM files WHERE rowid=?1 AND path=?2 AND size=?3 AND mtime=?4)",params![file_id,path,identity.bytes as i64,identity.modified_ns],|r|r.get(0))?;
|
||||
ensure!(valid, "Media file changed since filesystem indexing");
|
||||
let kind = if source_type == "image" {
|
||||
"image"
|
||||
} else {
|
||||
"timestamp_ms"
|
||||
};
|
||||
let snippet = if source_type == "image" {
|
||||
"Image content".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"{} content at {:.3} seconds",
|
||||
source_type,
|
||||
f64::from(timestamp) / 1000.
|
||||
)
|
||||
};
|
||||
let bytes: Vec<u8> = vector.iter().flat_map(|v| v.to_le_bytes()).collect();
|
||||
self.connection.execute("INSERT INTO semantic_chunks(generation,passage_id,ordinal,path,kind,number,content_hash,start_byte,end_byte,snippet,vector,source_type,file_size,file_mtime) VALUES(?1,?2,?3,?4,?5,?6,?7,0,0,?8,?9,?10,?11,?12)",params![generation,-file_id,ordinal as i64,path,kind,timestamp,identity.sha256,snippet,bytes,source_type,identity.bytes as i64,identity.modified_ns])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn insert(
|
||||
&mut self,
|
||||
generation: i64,
|
||||
@@ -312,6 +399,61 @@ impl Store {
|
||||
fingerprint: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
self.search_origin(query, fingerprint, limit, options, None)
|
||||
}
|
||||
pub(crate) fn search_origin(
|
||||
&mut self,
|
||||
query: &[f32],
|
||||
fingerprint: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
source_type: Option<&str>,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
let mut trace = TraceCollector::new(MetricsMode::Basic);
|
||||
self.search_origin_traced(
|
||||
query,
|
||||
fingerprint,
|
||||
limit,
|
||||
options,
|
||||
source_type,
|
||||
&mut trace,
|
||||
None,
|
||||
)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) fn search_origin_traced(
|
||||
&mut self,
|
||||
query: &[f32],
|
||||
fingerprint: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
source_type: Option<&str>,
|
||||
trace: &mut TraceCollector,
|
||||
parent: Option<SpanToken>,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
trace.timed("semantic.retrieval", parent, |trace, parent| {
|
||||
self.search_inner(
|
||||
query,
|
||||
fingerprint,
|
||||
limit,
|
||||
options,
|
||||
source_type,
|
||||
trace,
|
||||
parent,
|
||||
)
|
||||
})
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn search_inner(
|
||||
&mut self,
|
||||
query: &[f32],
|
||||
fingerprint: &str,
|
||||
limit: usize,
|
||||
options: &SearchOptions,
|
||||
source_type: Option<&str>,
|
||||
trace: &mut TraceCollector,
|
||||
parent: Option<SpanToken>,
|
||||
) -> Result<Vec<SearchResult>> {
|
||||
ensure!(
|
||||
query.len() == self.dimensions && query.iter().all(|v| v.is_finite()),
|
||||
@@ -321,17 +463,30 @@ impl Store {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// A consistent read snapshot covers eligibility and materialization.
|
||||
let generation_span = trace.begin("semantic.generation_snapshot", parent);
|
||||
let snapshot = self.connection.unchecked_transaction()?;
|
||||
let active:Option<i64>=snapshot.query_row("SELECT g.id FROM semantic_generations g JOIN semantic_meta m ON m.active_generation=g.id WHERE g.fingerprint=?1 AND g.dimensions=?2",
|
||||
params![fingerprint,self.dimensions as i64],|row|row.get(0)).optional()?;
|
||||
let Some(active) = active else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
trace.end(generation_span, Default::default());
|
||||
let ann_load = trace.begin("semantic.ann_load", parent);
|
||||
trace.counter(
|
||||
ann_load,
|
||||
"cache_hit",
|
||||
u64::from(
|
||||
self.ann_cache
|
||||
.as_ref()
|
||||
.is_some_and(|(generation, _)| *generation == active),
|
||||
),
|
||||
);
|
||||
if !self
|
||||
.ann_cache
|
||||
.as_ref()
|
||||
.is_some_and(|(generation, _)| *generation == active)
|
||||
{
|
||||
let blob_span = trace.begin("semantic.ann_blob_read", ann_load);
|
||||
let length: Option<usize> = snapshot.query_row(
|
||||
"SELECT length(ann_blob) FROM semantic_generations WHERE id=?1",
|
||||
[active],
|
||||
@@ -346,10 +501,15 @@ impl Store {
|
||||
[active],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
trace.counter(blob_span, "bytes", serialized.len() as u64);
|
||||
trace.end(blob_span, Default::default());
|
||||
let checksum_span = trace.begin("semantic.ann_checksum", ann_load);
|
||||
ensure!(
|
||||
format!("{:x}", Sha256::digest(&serialized)) == checksum,
|
||||
"ANN generation SHA256 mismatch; rebuild embeddings explicitly"
|
||||
);
|
||||
trace.end(checksum_span, Default::default());
|
||||
let deserialize_span = trace.begin("semantic.ann_deserialize", ann_load);
|
||||
let ann = self.new_ann()?;
|
||||
ann.load_from_buffer(&serialized)?;
|
||||
ensure!(
|
||||
@@ -357,8 +517,12 @@ impl Store {
|
||||
"ANN dimensions differ from embedding provider"
|
||||
);
|
||||
ann.reserve_capacity_and_threads(ann.size().max(1), 1)?;
|
||||
trace.counter(deserialize_span, "chunks", ann.size() as u64);
|
||||
trace.end(deserialize_span, Default::default());
|
||||
self.ann_cache = Some((active, ann));
|
||||
}
|
||||
trace.end(ann_load, Default::default());
|
||||
let filter_span = trace.begin("semantic.filter_normalization", parent);
|
||||
let folder = options
|
||||
.folder
|
||||
.as_ref()
|
||||
@@ -384,13 +548,25 @@ impl Store {
|
||||
max_size,
|
||||
modified_after: options.modified_after_unix_ms,
|
||||
modified_before: options.modified_before_unix_ms,
|
||||
min_duration: options.min_duration_ms,
|
||||
max_duration: options.max_duration_ms,
|
||||
source_type: source_type.map(str::to_owned),
|
||||
};
|
||||
trace.end(filter_span, Default::default());
|
||||
let eligibility_span = trace.begin("semantic.eligibility", parent);
|
||||
let (revision, floor): (i64, i64) = snapshot.query_row(
|
||||
"SELECT corpus_revision,dirty_floor FROM semantic_meta WHERE singleton=1",
|
||||
[],
|
||||
|row| Ok((row.get(0)?, row.get(1)?)),
|
||||
)?;
|
||||
let existing = self.eligibility_cache.take();
|
||||
trace.counter(
|
||||
eligibility_span,
|
||||
"cache_hit",
|
||||
u64::from(existing.as_ref().is_some_and(|c| {
|
||||
c.generation == active && c.filter == filter && c.revision == revision
|
||||
})),
|
||||
);
|
||||
let mut cache = match existing {
|
||||
Some(mut cache)
|
||||
if cache.generation == active
|
||||
@@ -404,8 +580,17 @@ impl Store {
|
||||
row.get::<_, String>(0)
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
trace.counter(eligibility_span, "dirty_paths", paths.len() as u64);
|
||||
if paths.len() > 1000 {
|
||||
cache = collect_eligibility(&snapshot, active, revision, &filter, None)?;
|
||||
cache = collect_eligibility_traced(
|
||||
&snapshot,
|
||||
active,
|
||||
revision,
|
||||
&filter,
|
||||
None,
|
||||
trace,
|
||||
eligibility_span,
|
||||
)?;
|
||||
} else {
|
||||
for path in paths {
|
||||
if let Some(old) = cache.by_path.remove(&path) {
|
||||
@@ -413,12 +598,14 @@ impl Store {
|
||||
Arc::make_mut(&mut cache.keys).remove(&key);
|
||||
}
|
||||
}
|
||||
let changed = collect_eligibility(
|
||||
let changed = collect_eligibility_traced(
|
||||
&snapshot,
|
||||
active,
|
||||
revision,
|
||||
&filter,
|
||||
Some(&path),
|
||||
trace,
|
||||
eligibility_span,
|
||||
)?;
|
||||
Arc::make_mut(&mut cache.keys).extend(changed.keys.iter().copied());
|
||||
cache.by_path.extend(changed.by_path);
|
||||
@@ -428,12 +615,22 @@ impl Store {
|
||||
}
|
||||
cache
|
||||
}
|
||||
_ => collect_eligibility(&snapshot, active, revision, &filter, None)?,
|
||||
_ => collect_eligibility_traced(
|
||||
&snapshot,
|
||||
active,
|
||||
revision,
|
||||
&filter,
|
||||
None,
|
||||
trace,
|
||||
eligibility_span,
|
||||
)?,
|
||||
};
|
||||
ensure!(
|
||||
cache.keys.len() <= MAX_SCAN_CHUNKS,
|
||||
"Eligibility cache exceeds chunk resource budget"
|
||||
);
|
||||
trace.counter(eligibility_span, "eligible_chunks", cache.keys.len() as u64);
|
||||
trace.end(eligibility_span, Default::default());
|
||||
cache.revision = revision;
|
||||
let eligible = Arc::clone(&cache.keys);
|
||||
self.eligibility_cache = Some(cache);
|
||||
@@ -443,40 +640,97 @@ impl Store {
|
||||
let ann = &self.ann_cache.as_ref().expect("loaded ANN cache").1;
|
||||
let mut candidate_count = limit.saturating_mul(4).min(eligible.len()).max(1);
|
||||
let mut results;
|
||||
let mut filesystem_validity = HashMap::<String, bool>::new();
|
||||
let mut passes = 0u64;
|
||||
loop {
|
||||
passes += 1;
|
||||
let pass_span = trace.begin("semantic.ann_pass", parent);
|
||||
trace.counter(pass_span, "requested_chunks", candidate_count as u64);
|
||||
let ann_span = trace.begin("semantic.ann_search", pass_span);
|
||||
let matches =
|
||||
ann.filtered_search(query, candidate_count, |key| eligible.contains(&key))?;
|
||||
trace.counter(ann_span, "returned_chunks", matches.keys.len() as u64);
|
||||
trace.end(ann_span, Default::default());
|
||||
let materialization_span = trace.begin("semantic.parent_materialization", pass_span);
|
||||
let mut ranked: Vec<_> = matches.keys.into_iter().zip(matches.distances).collect();
|
||||
ranked.sort_by(|a, b| a.1.total_cmp(&b.1).then_with(|| a.0.cmp(&b.0)));
|
||||
let mut best = HashMap::<String, SearchResult>::new();
|
||||
let mut materialize=snapshot.prepare("SELECT c.path,f.filename,c.snippet,c.kind,c.number FROM semantic_chunks c JOIN files f ON f.path=c.path WHERE c.rowid=?1 AND c.generation=?2")?;
|
||||
let mut materialize=snapshot.prepare("SELECT c.path,f.filename,c.snippet,c.kind,c.number,c.source_type,c.file_size,c.file_mtime FROM semantic_chunks c JOIN files f ON f.path=c.path WHERE c.rowid=?1 AND c.generation=?2")?;
|
||||
for (key, distance) in ranked {
|
||||
let cosine = 1.0 - distance;
|
||||
ensure!(cosine.is_finite(), "ANN produced nonfinite distance");
|
||||
if cosine < MIN_COSINE {
|
||||
continue;
|
||||
}
|
||||
let result =
|
||||
let (result, size, mtime) =
|
||||
materialize.query_row(params![i64::try_from(key)?, active], |row| {
|
||||
Ok(SearchResult {
|
||||
path: row.get(0)?,
|
||||
filename: row.get(1)?,
|
||||
snippet: row.get(2)?,
|
||||
score: f64::from(cosine),
|
||||
match_kind: "semantic".into(),
|
||||
source: Some(SourceLocation {
|
||||
kind: row.get(3)?,
|
||||
number: row.get(4)?,
|
||||
}),
|
||||
extraction_error: None,
|
||||
})
|
||||
Ok((
|
||||
SearchResult {
|
||||
path: row.get(0)?,
|
||||
filename: row.get(1)?,
|
||||
snippet: row.get(2)?,
|
||||
score: f64::from(cosine),
|
||||
match_kind: match row.get::<_, String>(5)?.as_str() {
|
||||
"text" => "semantic".into(),
|
||||
other => format!("semantic_{other}"),
|
||||
},
|
||||
source: Some(SourceLocation {
|
||||
kind: row.get(3)?,
|
||||
number: row.get(4)?,
|
||||
}),
|
||||
extraction_error: None,
|
||||
},
|
||||
row.get::<_, Option<i64>>(6)?,
|
||||
row.get::<_, Option<i64>>(7)?,
|
||||
))
|
||||
})?;
|
||||
if let (Some(size), Some(mtime)) = (size, mtime) {
|
||||
let valid = *filesystem_validity
|
||||
.entry(result.path.clone())
|
||||
.or_insert_with(|| {
|
||||
#[cfg(test)]
|
||||
MEDIA_STAT_CALLS.with(|count| count.set(count.get() + 1));
|
||||
let validation_span = trace.begin(
|
||||
"semantic.parent_filesystem_validation",
|
||||
materialization_span,
|
||||
);
|
||||
let valid =
|
||||
std::fs::metadata(&result.path)
|
||||
.ok()
|
||||
.is_some_and(|metadata| {
|
||||
metadata.is_file()
|
||||
&& i64::try_from(metadata.len()).ok() == Some(size)
|
||||
&& metadata
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| {
|
||||
t.duration_since(std::time::UNIX_EPOCH).ok()
|
||||
})
|
||||
.and_then(|d| i64::try_from(d.as_nanos()).ok())
|
||||
== Some(mtime)
|
||||
});
|
||||
trace.counter(validation_span, "valid", u64::from(valid));
|
||||
trace.end(validation_span, Default::default());
|
||||
valid
|
||||
});
|
||||
if !valid {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if best.contains_key(&result.path) {
|
||||
continue;
|
||||
}
|
||||
best.insert(result.path.clone(), result);
|
||||
}
|
||||
results = best.into_values().collect::<Vec<_>>();
|
||||
trace.counter(materialization_span, "unique_parents", results.len() as u64);
|
||||
trace.counter(
|
||||
materialization_span,
|
||||
"filesystem_checks",
|
||||
filesystem_validity.len() as u64,
|
||||
);
|
||||
trace.end(materialization_span, Default::default());
|
||||
trace.end(pass_span, Default::default());
|
||||
if results.len() >= limit || candidate_count == eligible.len() {
|
||||
break;
|
||||
}
|
||||
@@ -488,6 +742,13 @@ impl Store {
|
||||
.then_with(|| a.path.cmp(&b.path))
|
||||
});
|
||||
results.truncate(limit);
|
||||
trace.counter(parent, "ann_passes", passes);
|
||||
trace.counter(parent, "result_files", results.len() as u64);
|
||||
trace.counter(
|
||||
parent,
|
||||
"filesystem_checks",
|
||||
filesystem_validity.len() as u64,
|
||||
);
|
||||
snapshot.commit()?;
|
||||
Ok(results)
|
||||
}
|
||||
@@ -614,6 +875,30 @@ impl Store {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn collect_eligibility_traced(
|
||||
connection: &Connection,
|
||||
generation: i64,
|
||||
revision: i64,
|
||||
filter: &FilterKey,
|
||||
path: Option<&str>,
|
||||
trace: &mut TraceCollector,
|
||||
parent: Option<SpanToken>,
|
||||
) -> Result<EligibilityCache> {
|
||||
trace.timed(
|
||||
"semantic.eligibility_filtered_scan_and_hash",
|
||||
parent,
|
||||
|trace, span| {
|
||||
let result = collect_eligibility(connection, generation, revision, filter, path);
|
||||
if let Ok(cache) = &result {
|
||||
trace.counter(span, "eligible_chunks", cache.keys.len() as u64);
|
||||
}
|
||||
trace.counter(span, "single_dirty_file", u64::from(path.is_some()));
|
||||
result
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn collect_eligibility(
|
||||
connection: &Connection,
|
||||
generation: i64,
|
||||
@@ -621,12 +906,15 @@ fn collect_eligibility(
|
||||
filter: &FilterKey,
|
||||
path: Option<&str>,
|
||||
) -> Result<EligibilityCache> {
|
||||
let mut statement=connection.prepare("SELECT c.rowid,c.passage_id,c.content_hash,p.text,c.path FROM semantic_chunks c
|
||||
JOIN passages p ON p.rowid=c.passage_id AND p.file_path=c.path AND p.kind=c.kind AND p.number=c.number
|
||||
JOIN files f ON f.path=c.path WHERE c.generation=?1
|
||||
let mut statement=connection.prepare("SELECT c.rowid,c.passage_id,c.content_hash,p.text,c.path,c.source_type FROM semantic_chunks c
|
||||
LEFT JOIN passages p ON c.source_type='text' AND p.rowid=c.passage_id AND p.file_path=c.path AND p.kind=c.kind AND p.number=c.number
|
||||
JOIN files f ON f.path=c.path WHERE c.generation=?1 AND ((c.source_type='text' AND p.rowid IS NOT NULL) OR (c.source_type!='text' AND c.file_size=f.size AND c.file_mtime=f.mtime))
|
||||
AND (?2 IS NULL OR substr(f.path,1,length(?2))=?2) AND (?3 IS NULL OR f.extension=?3)
|
||||
AND (?4 IS NULL OR f.size>=?4) AND (?5 IS NULL OR f.size<=?5)
|
||||
AND (?6 IS NULL OR f.mtime/1000000>=?6) AND (?7 IS NULL OR f.mtime/1000000<=?7)
|
||||
AND (?10 IS NULL OR EXISTS(SELECT 1 FROM media_metadata m WHERE m.path=f.path AND m.duration_ms>=?10))
|
||||
AND (?11 IS NULL OR EXISTS(SELECT 1 FROM media_metadata m WHERE m.path=f.path AND m.duration_ms<=?11))
|
||||
AND (?12 IS NULL OR c.source_type=?12)
|
||||
AND (?9 IS NULL OR c.path=?9) ORDER BY c.passage_id,c.ordinal LIMIT ?8")?;
|
||||
let mut rows = statement.query(params![
|
||||
generation,
|
||||
@@ -637,7 +925,10 @@ fn collect_eligibility(
|
||||
filter.modified_after,
|
||||
filter.modified_before,
|
||||
MAX_SCAN_CHUNKS as i64 + 1,
|
||||
path
|
||||
path,
|
||||
filter.min_duration,
|
||||
filter.max_duration,
|
||||
filter.source_type
|
||||
])?;
|
||||
let mut hashes = HashMap::<i64, String>::new();
|
||||
let mut keys = HashSet::new();
|
||||
@@ -650,7 +941,9 @@ fn collect_eligibility(
|
||||
"Corpus exceeds {MAX_SCAN_CHUNKS} chunk resource budget"
|
||||
);
|
||||
let passage_id: i64 = row.get(1)?;
|
||||
let hash = if let Some(hash) = hashes.get(&passage_id) {
|
||||
let hash = if row.get::<_, String>(5)? != "text" {
|
||||
row.get::<_, String>(2)?
|
||||
} else if let Some(hash) = hashes.get(&passage_id) {
|
||||
hash.clone()
|
||||
} else {
|
||||
let hash = content_hash(&row.get::<_, String>(3)?);
|
||||
@@ -678,6 +971,340 @@ mod tests {
|
||||
use searchi_core::SearchIndex;
|
||||
const DIMENSIONS: usize = 768;
|
||||
|
||||
#[test]
|
||||
fn many_video_windows_do_not_crowd_out_other_parent_files() -> Result<()> {
|
||||
// Geometry verifies grouped retrieval, not model inference or codecs.
|
||||
let directory = tempfile::tempdir()?;
|
||||
let root = directory.path().join("media");
|
||||
std::fs::create_dir(&root)?;
|
||||
for name in ["long.mp4", "other.mp4", "excluded.png"] {
|
||||
std::fs::write(root.join(name), b"parent-grouping identity fixture")?;
|
||||
}
|
||||
let db = directory.path().join("index.db");
|
||||
let mut lexical = SearchIndex::open(&db)?;
|
||||
lexical.index_folder(&root)?;
|
||||
let mut store = Store::open(&db, DIMENSIONS)?;
|
||||
let generation = store.begin("grouped-retrieval-proof")?;
|
||||
let mut query = vec![0.; DIMENSIONS];
|
||||
query[0] = 1.;
|
||||
let mut second = query.clone();
|
||||
second[0] = 0.95;
|
||||
second[1] = (1.0_f32 - 0.95_f32.powi(2)).sqrt();
|
||||
for (id, path, _, _) in store.media_batch(0)? {
|
||||
let identity = searchi_media::MediaIdentity::read(Path::new(&path), 1024)?;
|
||||
if path.ends_with("long.mp4") {
|
||||
for ordinal in 0..96 {
|
||||
store.insert_media(
|
||||
generation,
|
||||
id,
|
||||
ordinal,
|
||||
&identity,
|
||||
"video",
|
||||
ordinal as u32 * 2000,
|
||||
&query,
|
||||
)?;
|
||||
}
|
||||
} else if path.ends_with("other.mp4") {
|
||||
store.insert_media(generation, id, 0, &identity, "video", 4000, &second)?;
|
||||
} else {
|
||||
store.insert_media(generation, id, 0, &identity, "image", 0, &query)?;
|
||||
}
|
||||
}
|
||||
store.activate(generation)?;
|
||||
let options = SearchOptions {
|
||||
extension: Some("mp4".into()),
|
||||
..Default::default()
|
||||
};
|
||||
MEDIA_STAT_CALLS.with(|count| count.set(0));
|
||||
let mut trace = TraceCollector::new(MetricsMode::Debug);
|
||||
let results = store.search_origin_traced(
|
||||
&query,
|
||||
"grouped-retrieval-proof",
|
||||
2,
|
||||
&options,
|
||||
None,
|
||||
&mut trace,
|
||||
None,
|
||||
)?;
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results[0].path.ends_with("long.mp4"));
|
||||
assert!(results[1].path.ends_with("other.mp4"));
|
||||
assert_eq!(results[1].source.as_ref().unwrap().number, 4000);
|
||||
assert_eq!(
|
||||
MEDIA_STAT_CALLS.with(std::cell::Cell::get),
|
||||
2,
|
||||
"Each parent is stat-checked once despite repeated ANN expansions"
|
||||
);
|
||||
let metrics = trace.finish(results.len());
|
||||
let spans = metrics.trace.unwrap();
|
||||
assert!(
|
||||
spans
|
||||
.iter()
|
||||
.filter(|s| s.name == "semantic.ann_pass")
|
||||
.count()
|
||||
> 1
|
||||
);
|
||||
assert_eq!(
|
||||
spans
|
||||
.iter()
|
||||
.filter(|s| s.name == "semantic.parent_filesystem_validation")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
assert!(
|
||||
spans
|
||||
.iter()
|
||||
.find(|s| s.name == "semantic.retrieval")
|
||||
.unwrap()
|
||||
.counters["ann_passes"]
|
||||
> 1
|
||||
);
|
||||
for span in &spans {
|
||||
if let Some(parent) = span.parent {
|
||||
let parent = &spans[parent];
|
||||
assert!(span.start_ms >= parent.start_ms);
|
||||
assert!(
|
||||
span.start_ms + span.duration_ms <= parent.start_ms + parent.duration_ms + 1e-6
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_origins_persist_filters_and_changes_remove_stale_candidates() -> Result<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let root = directory.path().join("media");
|
||||
std::fs::create_dir(&root)?;
|
||||
let file = root.join("clip.mp4");
|
||||
std::fs::write(&file, b"storage identity fixture; not an inference test")?;
|
||||
let db = directory.path().join("index.db");
|
||||
let mut lexical = SearchIndex::open(&db)?;
|
||||
lexical.index_folder(&root)?;
|
||||
let mut store = Store::open(&db, DIMENSIONS)?;
|
||||
let (id, path, _, _) = store.media_batch(0)?.remove(0);
|
||||
let identity = searchi_media::MediaIdentity::read(Path::new(&path), 1024)?;
|
||||
lexical.set_media_metadata(
|
||||
&path,
|
||||
"artist: real extracted metadata fixture",
|
||||
65000,
|
||||
identity.bytes,
|
||||
identity.modified_ns,
|
||||
)?;
|
||||
let mut vector = vec![0.; DIMENSIONS];
|
||||
vector[0] = 1.;
|
||||
let generation = store.begin("media-storage-proof")?;
|
||||
store.insert_media(generation, id, 0, &identity, "video", 2345, &vector)?;
|
||||
store.insert_media(generation, id, 1, &identity, "audio", 28000, &vector)?;
|
||||
store.activate(generation)?;
|
||||
drop(store);
|
||||
let mut store = Store::open(&db, DIMENSIONS)?;
|
||||
let mut cold = TraceCollector::new(MetricsMode::Debug);
|
||||
let results = store.search_origin_traced(
|
||||
&vector,
|
||||
"media-storage-proof",
|
||||
5,
|
||||
&SearchOptions::default(),
|
||||
None,
|
||||
&mut cold,
|
||||
None,
|
||||
)?;
|
||||
let cold = cold.finish(results.len()).trace.unwrap();
|
||||
assert_eq!(
|
||||
cold.iter()
|
||||
.find(|s| s.name == "semantic.ann_load")
|
||||
.unwrap()
|
||||
.counters["cache_hit"],
|
||||
0
|
||||
);
|
||||
assert!(cold.iter().any(|s| s.name == "semantic.ann_checksum"));
|
||||
let mut warm = TraceCollector::new(MetricsMode::Debug);
|
||||
let warm_results = store.search_origin_traced(
|
||||
&vector,
|
||||
"media-storage-proof",
|
||||
5,
|
||||
&SearchOptions::default(),
|
||||
None,
|
||||
&mut warm,
|
||||
None,
|
||||
)?;
|
||||
assert_eq!(
|
||||
serde_json::to_value(&results)?,
|
||||
serde_json::to_value(&warm_results)?
|
||||
);
|
||||
let warm = warm.finish(warm_results.len()).trace.unwrap();
|
||||
assert_eq!(
|
||||
warm.iter()
|
||||
.find(|s| s.name == "semantic.ann_load")
|
||||
.unwrap()
|
||||
.counters["cache_hit"],
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
warm.iter()
|
||||
.find(|s| s.name == "semantic.eligibility")
|
||||
.unwrap()
|
||||
.counters["cache_hit"],
|
||||
1
|
||||
);
|
||||
assert!(!warm.iter().any(|s| s.name == "semantic.ann_blob_read"));
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].source.as_ref().unwrap().kind, "timestamp_ms");
|
||||
assert!([2345, 28000].contains(&results[0].source.as_ref().unwrap().number));
|
||||
let audio = store.search_origin(
|
||||
&vector,
|
||||
"media-storage-proof",
|
||||
5,
|
||||
&SearchOptions::default(),
|
||||
Some("audio"),
|
||||
)?;
|
||||
assert_eq!(audio.len(), 1);
|
||||
assert_eq!(audio[0].match_kind, "semantic_audio");
|
||||
assert_eq!(audio[0].source.as_ref().unwrap().number, 28000);
|
||||
let video = store.search_origin(
|
||||
&vector,
|
||||
"media-storage-proof",
|
||||
5,
|
||||
&SearchOptions::default(),
|
||||
Some("video"),
|
||||
)?;
|
||||
assert_eq!(video.len(), 1);
|
||||
assert_eq!(video[0].match_kind, "semantic_video");
|
||||
assert_eq!(video[0].source.as_ref().unwrap().number, 2345);
|
||||
assert!(store
|
||||
.search_origin(
|
||||
&vector,
|
||||
"media-storage-proof",
|
||||
5,
|
||||
&SearchOptions::default(),
|
||||
Some("image")
|
||||
)?
|
||||
.is_empty());
|
||||
assert!(
|
||||
results[0].match_kind == "semantic_video" || results[0].match_kind == "semantic_audio"
|
||||
);
|
||||
let filter = SearchOptions {
|
||||
extension: Some("png".into()),
|
||||
..SearchOptions::default()
|
||||
};
|
||||
assert!(store
|
||||
.search(&vector, "media-storage-proof", 5, &filter)?
|
||||
.is_empty());
|
||||
let duration_filter = SearchOptions {
|
||||
min_duration_ms: Some(66000),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(store
|
||||
.search(&vector, "media-storage-proof", 5, &duration_filter)?
|
||||
.is_empty());
|
||||
let duration_filter = SearchOptions {
|
||||
min_duration_ms: Some(64000),
|
||||
max_duration_ms: Some(66000),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(
|
||||
store
|
||||
.search(&vector, "media-storage-proof", 5, &duration_filter)?
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
std::fs::write(&file, b"changed")?;
|
||||
// Foreground hits check only actual candidate metadata, before watcher
|
||||
// reconciliation; they never rehash the complete filesystem corpus.
|
||||
assert!(store
|
||||
.search(&vector, "media-storage-proof", 5, &SearchOptions::default())?
|
||||
.is_empty());
|
||||
lexical.index_folder(&root)?;
|
||||
assert!(store
|
||||
.search(&vector, "media-storage-proof", 5, &SearchOptions::default())?
|
||||
.is_empty());
|
||||
assert!(store
|
||||
.insert_media(generation, id, 2, &identity, "video", 0, &vector)
|
||||
.is_err());
|
||||
std::fs::remove_file(&file)?;
|
||||
lexical.index_folder(&root)?;
|
||||
assert!(store
|
||||
.search(&vector, "media-storage-proof", 5, &SearchOptions::default())?
|
||||
.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn media_failure_removes_partial_vectors_and_persists_reason() -> Result<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let file = directory.path().join("picture.png");
|
||||
std::fs::write(&file, b"identity fixture")?;
|
||||
let db = directory.path().join("index.db");
|
||||
let mut lexical = SearchIndex::open(&db)?;
|
||||
lexical.index_folder(directory.path())?;
|
||||
let mut store = Store::open(&db, DIMENSIONS)?;
|
||||
let (id, path, _, _) = store
|
||||
.media_batch(0)?
|
||||
.into_iter()
|
||||
.find(|(_, p, _, _)| p.ends_with("picture.png"))
|
||||
.unwrap();
|
||||
let identity = searchi_media::MediaIdentity::read(Path::new(&path), 1024)?;
|
||||
let mut vector = vec![0.; DIMENSIONS];
|
||||
vector[0] = 1.;
|
||||
let generation = store.begin("failure-proof")?;
|
||||
store.insert_media(generation, id, 0, &identity, "image", 0, &vector)?;
|
||||
store.media_failure(generation, &path, "Decoder rejected corrupt image")?;
|
||||
let chunks: usize = store.connection.query_row(
|
||||
"SELECT count(*) FROM semantic_chunks WHERE generation=?1",
|
||||
[generation],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
assert_eq!(chunks, 0);
|
||||
let reason: String = store.connection.query_row(
|
||||
"SELECT message FROM semantic_media_errors WHERE generation=?1",
|
||||
[generation],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
assert!(reason.contains("corrupt"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleted_media_without_reconciliation_does_not_hide_later_valid_top_k() -> Result<()> {
|
||||
let directory = tempfile::tempdir()?;
|
||||
let root = directory.path().join("images");
|
||||
std::fs::create_dir(&root)?;
|
||||
for name in ["a.png", "b.png"] {
|
||||
std::fs::write(root.join(name), b"storage fixture")?;
|
||||
}
|
||||
let db = directory.path().join("index.db");
|
||||
let mut lexical = SearchIndex::open(&db)?;
|
||||
lexical.index_folder(&root)?;
|
||||
let mut store = Store::open(&db, DIMENSIONS)?;
|
||||
let generation = store.begin("candidate-stat-proof")?;
|
||||
let mut vector = vec![0.; DIMENSIONS];
|
||||
vector[0] = 1.;
|
||||
for (id, path, _, _) in store.media_batch(0)? {
|
||||
let identity = searchi_media::MediaIdentity::read(Path::new(&path), 1024)?;
|
||||
store.insert_media(generation, id, 0, &identity, "image", 0, &vector)?;
|
||||
}
|
||||
store.activate(generation)?;
|
||||
std::fs::remove_file(root.join("a.png"))?;
|
||||
let results = store.search(
|
||||
&vector,
|
||||
"candidate-stat-proof",
|
||||
1,
|
||||
&SearchOptions::default(),
|
||||
)?;
|
||||
assert_eq!(results.len(), 1);
|
||||
assert!(results[0].path.ends_with("b.png"));
|
||||
std::fs::remove_file(root.join("b.png"))?;
|
||||
assert!(store
|
||||
.search(
|
||||
&vector,
|
||||
"candidate-stat-proof",
|
||||
1,
|
||||
&SearchOptions::default()
|
||||
)?
|
||||
.is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generation_persists_failed_staging_preserves_active_and_changes_hide_stale_vectors(
|
||||
) -> Result<()> {
|
||||
|
||||
Reference in new issue
Block a user