Ship floating launcher, automatic discovery and bounded media search improvements
This commit is contained in:
1 parent
01279a6b25
commit
521966a2ac
43 files changed
+3165
-267
No files matched your search
@@ -11,6 +11,7 @@ pub use speech::{speech_policy_key, BudgetedSpeechProvider, SpeechExcerpt, Speec
|
||||
mod prepared;
|
||||
pub use prepared::PreparedGraphs;
|
||||
mod pipeline;
|
||||
mod relevance;
|
||||
mod routing;
|
||||
pub use routing::SemanticOrigin;
|
||||
mod store;
|
||||
@@ -280,6 +281,12 @@ pub struct HybridSearch {
|
||||
}
|
||||
|
||||
impl HybridSearch {
|
||||
/// Prepare storage before background discovery starts. No model is loaded.
|
||||
pub fn prepare_database(db: impl AsRef<Path>) -> Result<()> {
|
||||
let _ = SearchIndex::open(&db)?;
|
||||
let _ = Store::open(db.as_ref(), GEMMA_DIMENSIONS)?;
|
||||
Ok(())
|
||||
}
|
||||
/// Attach shared admission without replacing the warmed model or query LRU.
|
||||
pub fn with_budget(
|
||||
mut self,
|
||||
@@ -485,7 +492,11 @@ impl HybridSearch {
|
||||
/// Execute real query inference and load the active ANN generation before
|
||||
/// foreground use. Call on a background worker after assets are verified.
|
||||
pub fn warmup(&mut self) -> Result<()> {
|
||||
self.lexical.prewarm()?;
|
||||
if let Err(error) = self.lexical.prewarm() {
|
||||
if !is_busy_sqlite_error(&error) {
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
let vector = self.embedder.embed_query("warm up local file search")?;
|
||||
let _ = self.store.search(
|
||||
&vector,
|
||||
@@ -580,8 +591,14 @@ impl HybridSearch {
|
||||
promote_identifiers(query.trim(), &mut lexical)
|
||||
});
|
||||
let vector = self.query_vector(query, options.code_only, trace, parent)?;
|
||||
let semantic =
|
||||
let mut semantic =
|
||||
self.routed_semantic_traced(query, &vector, candidates, options, trace, parent)?;
|
||||
trace.timed("hybrid.semantic_shortlist", parent, |trace, span| {
|
||||
trace.counter(span, "candidates_before", semantic.len() as u64);
|
||||
let removed = relevance::prune_semantic_tail(&mut semantic);
|
||||
trace.counter(span, "tail_candidates_pruned", removed as u64);
|
||||
trace.counter(span, "candidates_after", semantic.len() as u64);
|
||||
});
|
||||
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);
|
||||
@@ -684,6 +701,11 @@ impl HybridSearch {
|
||||
}
|
||||
}
|
||||
|
||||
fn is_busy_sqlite_error(error: &anyhow::Error) -> bool {
|
||||
matches!(error.downcast_ref::<rusqlite::Error>(), Some(rusqlite::Error::SqliteFailure(error, _))
|
||||
if matches!(error.code, rusqlite::ErrorCode::DatabaseBusy | rusqlite::ErrorCode::DatabaseLocked))
|
||||
}
|
||||
|
||||
fn bounded_title(title: &str) -> &str {
|
||||
let mut end = title.len().min(512);
|
||||
while !title.is_char_boundary(end) {
|
||||
@@ -995,3 +1017,62 @@ mod tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod coverage_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod startup_lock_tests {
|
||||
use super::*;
|
||||
use std::sync::{
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
Arc,
|
||||
};
|
||||
struct QueryProbe(Arc<AtomicUsize>);
|
||||
impl EmbeddingProvider for QueryProbe {
|
||||
fn fingerprint(&self) -> &str {
|
||||
"query-path-probe"
|
||||
}
|
||||
fn dimensions(&self) -> usize {
|
||||
GEMMA_DIMENSIONS
|
||||
}
|
||||
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>> {
|
||||
self.0.fetch_add(1, Ordering::SeqCst);
|
||||
anyhow::bail!("Reached query inference; no fabricated vector")
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn optional_lexical_cache_busy_does_not_abort_query_preparation() -> Result<()> {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let db = temporary.path().join("index.db");
|
||||
HybridSearch::prepare_database(&db)?;
|
||||
let mut writer = rusqlite::Connection::open(&db)?;
|
||||
let transaction =
|
||||
writer.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut service = HybridSearch::with_provider(&db, Box::new(QueryProbe(calls.clone())))?;
|
||||
assert!(service
|
||||
.warmup()
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Reached query inference"));
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 1);
|
||||
transaction.rollback()?;
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn non_busy_sqlite_cache_errors_remain_visible() -> Result<()> {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let db = temporary.path().join("index.db");
|
||||
HybridSearch::prepare_database(&db)?;
|
||||
let calls = Arc::new(AtomicUsize::new(0));
|
||||
let mut service = HybridSearch::with_provider(&db, Box::new(QueryProbe(calls.clone())))?;
|
||||
rusqlite::Connection::open(&db)?.execute("DROP TABLE name_statistics_state", [])?;
|
||||
assert!(service.warmup().is_err());
|
||||
assert_eq!(calls.load(Ordering::SeqCst), 0);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
//! Hybrid result shortlisting, not a confidence or no-answer classifier.
|
||||
use searchi_core::SearchResult;
|
||||
|
||||
// Rounded upward from the largest adjacent gap (0.0650497) observed in the
|
||||
// eight frozen calibration absence requests. A flat/noisy list stays intact;
|
||||
// the strongest semantic candidate and every independent lexical match remain
|
||||
// accessible. Raw semantic diagnostics are deliberately not changed.
|
||||
const MIN_CLEAR_GAP: f64 = 0.07;
|
||||
const NUMERIC_TIE: f64 = 2.0 * f32::EPSILON as f64;
|
||||
|
||||
/// Keep the strongest semantic cluster only when one clear score gap separates
|
||||
/// it from the tail. Inputs are already source-checked, filtered and file-deduped.
|
||||
pub(super) fn prune_semantic_tail(results: &mut Vec<SearchResult>) -> usize {
|
||||
if results.len() < 2
|
||||
|| results.iter().any(|result| !result.score.is_finite())
|
||||
|| results.windows(2).any(|pair| pair[0].score < pair[1].score)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
let mut largest = 0.0;
|
||||
let mut second = 0.0;
|
||||
let mut boundary = results.len();
|
||||
for (index, pair) in results.windows(2).enumerate() {
|
||||
let gap = pair[0].score - pair[1].score;
|
||||
if gap > largest {
|
||||
second = largest;
|
||||
largest = gap;
|
||||
boundary = index + 1;
|
||||
} else if gap > second {
|
||||
second = gap;
|
||||
}
|
||||
}
|
||||
// Equal large gaps indicate no unique cluster boundary, e.g. a uniform
|
||||
// staircase. Do not pick an arbitrary split due to f32 rounding.
|
||||
if largest <= MIN_CLEAR_GAP || largest - second <= NUMERIC_TIE {
|
||||
return 0;
|
||||
}
|
||||
let removed = results.len() - boundary;
|
||||
results.truncate(boundary);
|
||||
removed
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
fn candidates(scores: &[f64]) -> Vec<SearchResult> {
|
||||
scores
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, score)| SearchResult {
|
||||
path: format!("candidate-{index}"),
|
||||
filename: format!("candidate-{index}"),
|
||||
score: *score,
|
||||
snippet: String::new(),
|
||||
match_kind: "semantic".into(),
|
||||
source: None,
|
||||
extraction_error: None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
#[test]
|
||||
fn weak_correct_best_and_multiple_strong_candidates_survive() {
|
||||
let mut weak = candidates(&[0.6766, 0.50, 0.49]);
|
||||
assert_eq!(prune_semantic_tail(&mut weak), 2);
|
||||
assert_eq!(weak[0].score, 0.6766);
|
||||
let mut multiple = candidates(&[0.82, 0.79, 0.57, 0.55]);
|
||||
assert_eq!(prune_semantic_tail(&mut multiple), 2);
|
||||
assert_eq!(multiple.len(), 2);
|
||||
}
|
||||
#[test]
|
||||
fn small_flat_and_ambiguous_lists_are_preserved() {
|
||||
for scores in [
|
||||
&[][..],
|
||||
&[0.55],
|
||||
&[0.61, 0.60],
|
||||
&[0.65, 0.64, 0.63],
|
||||
&[0.9, 0.8, 0.7, 0.6],
|
||||
&[0.61, 0.61001, 0.60],
|
||||
&[0.60, 0.599999, 0.599998],
|
||||
] {
|
||||
let mut results = candidates(scores);
|
||||
assert_eq!(prune_semantic_tail(&mut results), 0, "{scores:?}");
|
||||
assert_eq!(results.len(), scores.len());
|
||||
}
|
||||
let mut two = candidates(&[0.82, 0.52]);
|
||||
assert_eq!(prune_semantic_tail(&mut two), 1);
|
||||
assert_eq!(two.len(), 1);
|
||||
}
|
||||
#[test]
|
||||
fn independent_exact_lexical_match_survives_semantic_tail_pruning() {
|
||||
let mut semantic = candidates(&[0.82, 0.52]);
|
||||
let mut exact = candidates(&[0.52]).remove(0);
|
||||
exact.path = "candidate-1".into();
|
||||
exact.match_kind = "exact_filename".into();
|
||||
assert_eq!(prune_semantic_tail(&mut semantic), 1);
|
||||
let results = crate::fuse(vec![exact], semantic, 10);
|
||||
assert_eq!(results.len(), 2);
|
||||
assert_eq!(results[0].path, "candidate-1");
|
||||
assert_eq!(results[0].match_kind, "exact_filename");
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ impl SemanticOrigin {
|
||||
}
|
||||
|
||||
/// A conservative intent hint, not a language understanding model. Explicit
|
||||
/// document/visual/code cues veto it; ordinary topic and instrument queries do not
|
||||
/// document/code 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() {
|
||||
@@ -37,18 +37,10 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
||||
.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",
|
||||
"code",
|
||||
@@ -57,9 +49,33 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
||||
"quellcode",
|
||||
"funktion",
|
||||
"implementierung",
|
||||
"algorithm",
|
||||
"algorithms",
|
||||
"algorithmus",
|
||||
"algorithmen",
|
||||
]) {
|
||||
return None;
|
||||
}
|
||||
// Explicit visual requests should not be answered by unrelated audio or
|
||||
// document neighbors. Check these before speech/audio words: a photo of a
|
||||
// speaker and a screenshot of a waveform still request visual evidence.
|
||||
let visual = has(&[
|
||||
"image",
|
||||
"photo",
|
||||
"picture",
|
||||
"screenshot",
|
||||
"diagram",
|
||||
"bild",
|
||||
"foto",
|
||||
"fotografie",
|
||||
]);
|
||||
let video = has(&["video", "movie", "clip", "film"]);
|
||||
if visual && video {
|
||||
return None;
|
||||
}
|
||||
if visual {
|
||||
return Some(SemanticOrigin::Image);
|
||||
}
|
||||
if has(&[
|
||||
"spoken",
|
||||
"speech",
|
||||
@@ -86,7 +102,6 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
||||
if has(&[
|
||||
"audio",
|
||||
"recording",
|
||||
"sound",
|
||||
"music",
|
||||
"geräusch",
|
||||
"geraeusch",
|
||||
@@ -98,9 +113,13 @@ fn preferred_origin(query: &str) -> Option<SemanticOrigin> {
|
||||
"hoerbarer",
|
||||
"hoerbare",
|
||||
"tonaufnahme",
|
||||
]) {
|
||||
]) || (has(&["sound"]) && has(&["of", "effect", "effects"]))
|
||||
{
|
||||
return Some(SemanticOrigin::Audio);
|
||||
}
|
||||
if video {
|
||||
return Some(SemanticOrigin::Video);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
@@ -130,7 +149,7 @@ impl HybridSearch {
|
||||
} else {
|
||||
preferred_origin(query)
|
||||
} {
|
||||
trace.counter(parent, "audio_preference", 1);
|
||||
trace.counter(parent, "explicit_source_channel", 1);
|
||||
let preferred = self.store.search_origin_traced(
|
||||
vector,
|
||||
&self.generation_fingerprint,
|
||||
@@ -157,6 +176,12 @@ impl HybridSearch {
|
||||
return Ok(raw);
|
||||
}
|
||||
}
|
||||
// An empty requested channel is useful evidence. Falling back
|
||||
// to every source type turns an absent recording/photo into an
|
||||
// unrelated result; lexical filename/content hits remain in
|
||||
// the independently fused lane.
|
||||
trace.counter(parent, "requested_channel_empty", 1);
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
trace.counter(parent, "general_lane", 1);
|
||||
self.store.search_origin_traced(
|
||||
@@ -226,7 +251,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_spoken_intent_is_conservative_and_bilingual() {
|
||||
fn explicit_media_intent_is_conservative_and_bilingual() {
|
||||
for query in [
|
||||
"where someone mentions a contract",
|
||||
"a speaker says the date",
|
||||
@@ -234,6 +259,7 @@ mod tests {
|
||||
"jemand erwähnt das Budget",
|
||||
"Wo spricht jemand über Kündigung?",
|
||||
"Gespräch über die neue Wohnung",
|
||||
"video where someone mentions a contract",
|
||||
] {
|
||||
assert_eq!(
|
||||
preferred_origin(query),
|
||||
@@ -241,6 +267,29 @@ mod tests {
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
for query in [
|
||||
"photo of someone speaking",
|
||||
"Bild von einem Sprecher",
|
||||
"a screenshot of an audio waveform",
|
||||
"Foto mit Bergen",
|
||||
] {
|
||||
assert_eq!(
|
||||
preferred_origin(query),
|
||||
Some(SemanticOrigin::Image),
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
for query in [
|
||||
"video of a boat",
|
||||
"Film mit einem Wasserfall",
|
||||
"a movie with a blue car",
|
||||
] {
|
||||
assert_eq!(
|
||||
preferred_origin(query),
|
||||
Some(SemanticOrigin::Video),
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
for query in [
|
||||
"audio of a waterfall",
|
||||
"hörbarer Regen",
|
||||
@@ -256,17 +305,19 @@ mod tests {
|
||||
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",
|
||||
"code that chooses audio search when a query mentions speech",
|
||||
"an implementation for identifying music",
|
||||
"a function that decodes audio samples",
|
||||
"Quellcode für die Suche nach gesprochenen Themen",
|
||||
"eine Funktion für die Verarbeitung von Musik",
|
||||
"Implementierung für die Erkennung von Geräuschen",
|
||||
"photo or video of a boat",
|
||||
"Bild oder Film vom Wasserfall",
|
||||
"image compression algorithm",
|
||||
"Algorithmus zur Bildkomprimierung",
|
||||
"sound financial advice",
|
||||
] {
|
||||
assert_eq!(preferred_origin(query), None, "{query}");
|
||||
}
|
||||
|
||||
@@ -18,6 +18,99 @@ use usearch::{Index, IndexOptions, MetricKind, ScalarKind};
|
||||
|
||||
pub(crate) const MAX_SCAN_CHUNKS: usize = 100_000;
|
||||
pub(crate) const MIN_COSINE: f32 = 0.25;
|
||||
const SCHEMA_VERSION: i64 = 9;
|
||||
|
||||
fn current_schema_ready(connection: &Connection) -> Result<bool> {
|
||||
const TABLES: &[(&str, &[&str])] = &[
|
||||
(
|
||||
"semantic_meta",
|
||||
&[
|
||||
"schema_version",
|
||||
"active_generation",
|
||||
"corpus_revision",
|
||||
"dirty_floor",
|
||||
],
|
||||
),
|
||||
(
|
||||
"semantic_generations",
|
||||
&[
|
||||
"id",
|
||||
"fingerprint",
|
||||
"dimensions",
|
||||
"corpus_revision",
|
||||
"ann_blob",
|
||||
"ann_sha256",
|
||||
"incremental",
|
||||
],
|
||||
),
|
||||
(
|
||||
"semantic_chunks",
|
||||
&[
|
||||
"generation",
|
||||
"passage_id",
|
||||
"ordinal",
|
||||
"path",
|
||||
"kind",
|
||||
"number",
|
||||
"content_hash",
|
||||
"start_byte",
|
||||
"end_byte",
|
||||
"snippet",
|
||||
"vector",
|
||||
"source_type",
|
||||
"file_size",
|
||||
"file_mtime",
|
||||
"speech_end_ms",
|
||||
"task_key",
|
||||
"file_change_revision",
|
||||
],
|
||||
),
|
||||
("semantic_dirty_paths", &["path", "revision"]),
|
||||
("semantic_media_errors", &["generation", "path", "message"]),
|
||||
(
|
||||
"semantic_media_metadata_revision",
|
||||
&["singleton", "revision"],
|
||||
),
|
||||
(
|
||||
"semantic_tasks",
|
||||
&[
|
||||
"generation",
|
||||
"task_key",
|
||||
"path",
|
||||
"source_type",
|
||||
"source_hash",
|
||||
"title_hash",
|
||||
"policy",
|
||||
"passage_id",
|
||||
"state",
|
||||
"attempts",
|
||||
"expected_chunks",
|
||||
"message",
|
||||
],
|
||||
),
|
||||
];
|
||||
for (table, required) in TABLES {
|
||||
let mut query = connection.prepare("SELECT name FROM pragma_table_info(?1)")?;
|
||||
let columns = query
|
||||
.query_map([table], |row| row.get::<_, String>(0))?
|
||||
.collect::<rusqlite::Result<HashSet<_>>>()?;
|
||||
if required.iter().any(|name| !columns.contains(*name)) {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
let triggers: i64 = connection.query_row(
|
||||
"SELECT count(*) FROM sqlite_schema WHERE type='trigger' AND name IN ('semantic_files_insert','semantic_files_update','semantic_files_delete','semantic_media_metadata_insert','semantic_media_metadata_update','semantic_media_metadata_delete')", [], |row| row.get(0),
|
||||
)?;
|
||||
let indexes: i64 = connection.query_row(
|
||||
"SELECT count(*) FROM sqlite_schema WHERE type='index' AND name IN ('semantic_dirty_revision','semantic_chunk_path','semantic_task_reuse','semantic_chunk_task')", [], |row| row.get(0),
|
||||
)?;
|
||||
let metadata_row: bool = connection.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM semantic_media_metadata_revision WHERE singleton=1)",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
Ok(triggers == 6 && indexes == 4 && metadata_row)
|
||||
}
|
||||
|
||||
// Disjoint negative namespaces preserve existing document/raw-media IDs without a table rebuild.
|
||||
pub(crate) fn media_passage_id(file_id: i64, source_type: &str) -> Result<i64> {
|
||||
@@ -122,24 +215,46 @@ impl Store {
|
||||
let mut connection = Connection::open(db)?;
|
||||
connection.busy_timeout(std::time::Duration::from_secs(5))?;
|
||||
connection.execute_batch("PRAGMA foreign_keys=ON;")?;
|
||||
let initialization =
|
||||
connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
initialization.execute_batch(
|
||||
let has_meta: bool = connection.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='table' AND name='semantic_meta')",
|
||||
[], |row| row.get(0),
|
||||
)?;
|
||||
let current_version = if has_meta {
|
||||
let version: i64 = connection.query_row(
|
||||
"SELECT schema_version FROM semantic_meta WHERE singleton=1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
ensure!(
|
||||
(1..=SCHEMA_VERSION).contains(&version),
|
||||
"Unsupported semantic schema version {version}"
|
||||
);
|
||||
Some(version)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// WAL readers must not acquire a writer lock just to reopen an already
|
||||
// prepared index. Some earlier schema-9 builds added columns without a
|
||||
// version bump, so incomplete legacy shapes still run normal migration.
|
||||
if current_version != Some(SCHEMA_VERSION) || !current_schema_ready(&connection)? {
|
||||
let initialization =
|
||||
connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
initialization.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS semantic_meta (
|
||||
singleton INTEGER PRIMARY KEY CHECK(singleton=1), schema_version INTEGER NOT NULL,
|
||||
active_generation INTEGER, corpus_revision INTEGER NOT NULL DEFAULT 0, dirty_floor INTEGER NOT NULL DEFAULT 0);
|
||||
INSERT OR IGNORE INTO semantic_meta(singleton,schema_version) VALUES(1,5);",
|
||||
)?;
|
||||
let version: i64 = initialization.query_row(
|
||||
"SELECT schema_version FROM semantic_meta WHERE singleton=1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
ensure!(
|
||||
(1..=9).contains(&version),
|
||||
"Unsupported semantic schema version {version}"
|
||||
);
|
||||
initialization.execute_batch("
|
||||
let version: i64 = initialization.query_row(
|
||||
"SELECT schema_version FROM semantic_meta WHERE singleton=1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
ensure!(
|
||||
(1..=9).contains(&version),
|
||||
"Unsupported semantic schema version {version}"
|
||||
);
|
||||
initialization.execute_batch("
|
||||
CREATE TABLE IF NOT EXISTS semantic_generations(id INTEGER PRIMARY KEY, fingerprint TEXT NOT NULL, dimensions INTEGER NOT NULL, corpus_revision INTEGER NOT NULL DEFAULT 0, ann_blob BLOB, ann_sha256 TEXT);
|
||||
CREATE TABLE IF NOT EXISTS semantic_chunks(
|
||||
generation INTEGER NOT NULL REFERENCES semantic_generations(id) ON DELETE CASCADE,
|
||||
@@ -148,8 +263,8 @@ impl Store {
|
||||
content_hash TEXT NOT NULL, start_byte INTEGER NOT NULL, end_byte INTEGER NOT NULL,
|
||||
snippet TEXT NOT NULL, vector BLOB NOT NULL,
|
||||
PRIMARY KEY(generation,passage_id,ordinal));")?;
|
||||
if version < 5 {
|
||||
for (table,column,alter) in [
|
||||
if version < 5 {
|
||||
for (table,column,alter) in [
|
||||
("semantic_meta","corpus_revision","ALTER TABLE semantic_meta ADD COLUMN corpus_revision INTEGER NOT NULL DEFAULT 0"),
|
||||
("semantic_meta","dirty_floor","ALTER TABLE semantic_meta ADD COLUMN dirty_floor INTEGER NOT NULL DEFAULT 0"),
|
||||
("semantic_generations","dimensions","ALTER TABLE semantic_generations ADD COLUMN dimensions INTEGER NOT NULL DEFAULT 384"),
|
||||
@@ -160,10 +275,10 @@ impl Store {
|
||||
let exists:bool=initialization.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info(?1) WHERE name=?2)",params![table,column],|row| row.get(0))?;
|
||||
if !exists { initialization.execute_batch(alter)?; }
|
||||
}
|
||||
initialization.execute_batch("DROP TRIGGER IF EXISTS semantic_files_insert;
|
||||
initialization.execute_batch("DROP TRIGGER IF EXISTS semantic_files_insert;
|
||||
DROP TRIGGER IF EXISTS semantic_files_update; DROP TRIGGER IF EXISTS semantic_files_delete;")?;
|
||||
}
|
||||
initialization.execute_batch(
|
||||
}
|
||||
initialization.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS semantic_dirty_paths(path TEXT PRIMARY KEY, revision INTEGER NOT NULL);
|
||||
CREATE INDEX IF NOT EXISTS semantic_dirty_revision ON semantic_dirty_paths(revision);
|
||||
CREATE INDEX IF NOT EXISTS semantic_chunk_path ON semantic_chunks(generation,path);
|
||||
@@ -183,45 +298,50 @@ impl Store {
|
||||
ON CONFLICT(path) DO UPDATE SET revision=excluded.revision; END;
|
||||
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(
|
||||
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}"
|
||||
))?;
|
||||
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))")?;
|
||||
let has_speech_end:bool=initialization.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('semantic_chunks') WHERE name='speech_end_ms')",[],|row|row.get(0))?;
|
||||
if !has_speech_end {
|
||||
initialization.execute(
|
||||
"ALTER TABLE semantic_chunks ADD COLUMN speech_end_ms INTEGER",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
// Probe publication changes filter eligibility, not source discovery identity.
|
||||
// Keep its epoch separate so indexing does not invalidate its own corpus snapshot.
|
||||
initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_metadata_revision(singleton INTEGER PRIMARY KEY CHECK(singleton=1), revision INTEGER NOT NULL);
|
||||
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))")?;
|
||||
let has_speech_end:bool=initialization.query_row("SELECT EXISTS(SELECT 1 FROM pragma_table_info('semantic_chunks') WHERE name='speech_end_ms')",[],|row|row.get(0))?;
|
||||
if !has_speech_end {
|
||||
initialization.execute(
|
||||
"ALTER TABLE semantic_chunks ADD COLUMN speech_end_ms INTEGER",
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
// Probe publication changes filter eligibility, not source discovery identity.
|
||||
// Keep its epoch separate so indexing does not invalidate its own corpus snapshot.
|
||||
initialization.execute_batch("CREATE TABLE IF NOT EXISTS semantic_media_metadata_revision(singleton INTEGER PRIMARY KEY CHECK(singleton=1), revision INTEGER NOT NULL);
|
||||
INSERT OR IGNORE INTO semantic_media_metadata_revision(singleton,revision) VALUES(1,0);")?;
|
||||
for (name, event) in [
|
||||
("insert", "INSERT"),
|
||||
("update", "UPDATE"),
|
||||
("delete", "DELETE"),
|
||||
] {
|
||||
initialization.execute_batch(&format!("DROP TRIGGER IF EXISTS semantic_media_metadata_{name};
|
||||
for (name, event) in [
|
||||
("insert", "INSERT"),
|
||||
("update", "UPDATE"),
|
||||
("delete", "DELETE"),
|
||||
] {
|
||||
initialization.execute_batch(&format!("DROP TRIGGER IF EXISTS semantic_media_metadata_{name};
|
||||
CREATE TRIGGER semantic_media_metadata_{name} AFTER {event} ON media_metadata BEGIN
|
||||
UPDATE semantic_media_metadata_revision SET revision=revision+1 WHERE singleton=1; END;"))?;
|
||||
}
|
||||
incremental::migrate(&initialization)?;
|
||||
initialization.commit()?;
|
||||
ensure!(
|
||||
current_schema_ready(&connection)?,
|
||||
"Semantic schema initialization incomplete"
|
||||
);
|
||||
}
|
||||
incremental::migrate(&initialization)?;
|
||||
initialization.commit()?;
|
||||
let canonical = db.canonicalize()?;
|
||||
let lock_path = PathBuf::from(format!("{}.semantic.lock", canonical.to_string_lossy()));
|
||||
Ok(Self {
|
||||
@@ -2068,3 +2188,59 @@ mod tests {
|
||||
#[cfg(test)]
|
||||
#[path = "ann_benchmark.rs"]
|
||||
mod ann_benchmark;
|
||||
|
||||
#[cfg(test)]
|
||||
mod reopen_tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn initialized_wal_store_reopens_during_an_owned_write_transaction() -> Result<()> {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let db = temporary.path().join("index.db");
|
||||
crate::HybridSearch::prepare_database(&db)?;
|
||||
let mut writer = Connection::open(&db)?;
|
||||
let transaction =
|
||||
writer.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
transaction.execute(
|
||||
"UPDATE semantic_meta SET corpus_revision=corpus_revision+1 WHERE singleton=1",
|
||||
[],
|
||||
)?;
|
||||
let started = std::time::Instant::now();
|
||||
let reader = Store::open(&db, 768)?;
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(2));
|
||||
assert_eq!(
|
||||
reader.connection.query_row(
|
||||
"SELECT corpus_revision FROM semantic_meta WHERE singleton=1",
|
||||
[],
|
||||
|row| row.get::<_, i64>(0)
|
||||
)?,
|
||||
0
|
||||
);
|
||||
transaction.rollback()?;
|
||||
Ok(())
|
||||
}
|
||||
#[test]
|
||||
fn legacy_schema_nine_additions_are_repaired_and_future_versions_rejected() -> Result<()> {
|
||||
let temporary = tempfile::tempdir()?;
|
||||
let db = temporary.path().join("index.db");
|
||||
crate::HybridSearch::prepare_database(&db)?;
|
||||
let connection = Connection::open(&db)?;
|
||||
connection.execute_batch("ALTER TABLE semantic_chunks DROP COLUMN speech_end_ms; DROP TRIGGER semantic_media_metadata_update;")?;
|
||||
assert!(!current_schema_ready(&connection)?);
|
||||
drop(Store::open(&db, 768)?);
|
||||
assert!(current_schema_ready(&connection)?);
|
||||
connection.execute("UPDATE semantic_meta SET schema_version=10", [])?;
|
||||
let error = match Store::open(&db, 768) {
|
||||
Ok(_) => panic!("future schema accepted"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("Unsupported semantic schema version 10"));
|
||||
assert_eq!(
|
||||
connection.query_row("SELECT schema_version FROM semantic_meta", [], |row| row
|
||||
.get::<_, i64>(0))?,
|
||||
10
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
//! Actual query retrieval against the owned, verified README fixture index.
|
||||
use anyhow::{Context, Result};
|
||||
use searchi_service::{HybridSearch, SearchOptions};
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn required(name: &str) -> Result<PathBuf> {
|
||||
std::env::var_os(name)
|
||||
.map(PathBuf::from)
|
||||
.with_context(|| format!("Set {name}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires verified owned README fixture database and actual Google CPU model/runtime"]
|
||||
fn real_google_hybrid_shortlist_removes_unrelated_media_without_changing_raw_search() -> Result<()>
|
||||
{
|
||||
assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1");
|
||||
let db = required("SEARCHI_TEST_RELEVANCE_DATABASE")?;
|
||||
let model = required("SEARCHI_GEMMA_MODEL_DIR")?;
|
||||
let worker = required("SEARCHI_GEMMA_WORKER")?;
|
||||
let mut search = HybridSearch::open_query(db, model, worker, 1)?;
|
||||
let options = SearchOptions::default();
|
||||
let contract = "cancel the supplier partnership";
|
||||
let raw = search.search_semantic(contract, 50, &options)?;
|
||||
assert_eq!(raw[0].filename, "Supplier Agreement.pdf");
|
||||
assert!(raw
|
||||
.iter()
|
||||
.any(|result| result.filename == "Mountain Lake.jpg"));
|
||||
let measured = search.search_measured(contract, 50, &options, true)?;
|
||||
assert_eq!(measured.results[0].filename, "Supplier Agreement.pdf");
|
||||
assert!(measured.results.iter().all(
|
||||
|result| result.filename != "Mountain Lake.jpg" && result.filename != "Night Pier.jpg"
|
||||
));
|
||||
assert!(measured
|
||||
.metrics
|
||||
.trace
|
||||
.as_ref()
|
||||
.context("debug trace")?
|
||||
.iter()
|
||||
.any(|span| span.name == "hybrid.semantic_shortlist"
|
||||
&& span
|
||||
.counters
|
||||
.get("tail_candidates_pruned")
|
||||
.is_some_and(|count| *count > 0)));
|
||||
let raw_after = search.search_semantic(contract, 50, &options)?;
|
||||
assert_eq!(raw_after.len(), raw.len());
|
||||
assert_eq!(raw_after[0].score, raw[0].score);
|
||||
let image = search.search("mountains reflected in calm water", 50, &options)?;
|
||||
assert_eq!(image[0].filename, "Mountain Lake.jpg");
|
||||
assert!(image
|
||||
.iter()
|
||||
.all(|result| result.match_kind == "semantic_image"));
|
||||
assert_eq!(
|
||||
search.search("Mountain Lake.jpg", 1, &options)?[0].match_kind,
|
||||
"exact_filename"
|
||||
);
|
||||
let pdf = SearchOptions {
|
||||
extension: Some("pdf".into()),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(search
|
||||
.search(contract, 50, &pdf)?
|
||||
.iter()
|
||||
.all(|result| result.filename == "Supplier Agreement.pdf"));
|
||||
assert_eq!(
|
||||
search.search(contract, 1, &options)?[0].filename,
|
||||
"Supplier Agreement.pdf"
|
||||
);
|
||||
let excluded = SearchOptions {
|
||||
max_size: Some(1),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(search.search(contract, 50, &excluded)?.is_empty());
|
||||
eprintln!(
|
||||
"actual raw contract candidates={}, hybrid={}, image={}",
|
||||
raw.len(),
|
||||
measured.results.len(),
|
||||
image.len()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires the owned verified README fixture database and real Google CPU runtime"]
|
||||
fn real_google_query_warms_during_an_owned_wal_writer() -> Result<()> {
|
||||
assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1");
|
||||
let db = required("SEARCHI_TEST_RELEVANCE_DATABASE")?;
|
||||
let model = required("SEARCHI_GEMMA_MODEL_DIR")?;
|
||||
let worker = required("SEARCHI_GEMMA_WORKER")?;
|
||||
let mut writer = rusqlite::Connection::open(&db)?;
|
||||
let old_dirty: i64 = writer.query_row(
|
||||
"SELECT dirty FROM name_statistics_state WHERE singleton=1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)?;
|
||||
writer.execute(
|
||||
"UPDATE name_statistics_state SET dirty=1 WHERE singleton=1",
|
||||
[],
|
||||
)?;
|
||||
let transaction = writer.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
|
||||
let outcome = (|| -> Result<()> {
|
||||
let mut search = HybridSearch::open_query(&db, model, worker, 1)?;
|
||||
search.warmup()?;
|
||||
let hits = search.search_semantic(
|
||||
"cancel the supplier partnership",
|
||||
1,
|
||||
&SearchOptions::default(),
|
||||
)?;
|
||||
assert_eq!(hits[0].filename, "Supplier Agreement.pdf");
|
||||
assert_eq!(
|
||||
search.worker_info().context("real worker info")?.device,
|
||||
"cpu"
|
||||
);
|
||||
eprintln!("Actual Google CPU query initialization, warmup and prepared retrieval succeeded with the WAL writer held");
|
||||
Ok(())
|
||||
})();
|
||||
transaction.rollback()?;
|
||||
writer.execute(
|
||||
"UPDATE name_statistics_state SET dirty=?1 WHERE singleton=1",
|
||||
[old_dirty],
|
||||
)?;
|
||||
outcome
|
||||
}
|
||||
@@ -3,6 +3,69 @@ use searchi_core::SearchIndex;
|
||||
use searchi_service::{FfmpegTools, HybridSearch, MediaLimits, SearchOptions, SemanticOrigin};
|
||||
use std::{fs, path::PathBuf};
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires verified Google CPU worker/model and FFmpeg fixtures"]
|
||||
fn explicit_media_requests_do_not_fall_back_to_unrelated_channels() -> Result<()> {
|
||||
assert_eq!(std::env::var("SEARCHI_FORCE_CPU")?, "1");
|
||||
let model = std::env::var_os("SEARCHI_GEMMA_MODEL_DIR").expect("Set model directory");
|
||||
let worker = std::env::var_os("SEARCHI_GEMMA_WORKER").expect("Set worker path");
|
||||
let tools = FfmpegTools {
|
||||
ffmpeg: std::env::var_os("SEARCHI_TEST_FFMPEG")
|
||||
.expect("Set FFmpeg")
|
||||
.into(),
|
||||
ffprobe: std::env::var_os("SEARCHI_TEST_FFPROBE")
|
||||
.expect("Set FFprobe")
|
||||
.into(),
|
||||
};
|
||||
let fixture =
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../fixtures/media/assets/image01.jpg");
|
||||
let temp = tempfile::tempdir()?;
|
||||
let corpus = temp.path().join("corpus");
|
||||
fs::create_dir(&corpus)?;
|
||||
fs::copy(fixture, corpus.join("cat.jpg"))?;
|
||||
let database = temp.path().join("index.db");
|
||||
let mut lexical = SearchIndex::open(&database)?;
|
||||
lexical.index_folder(&corpus)?;
|
||||
let mut indexer = HybridSearch::open(&database, &model, &worker, 1)?;
|
||||
let report = indexer.index_embeddings_with_media(&tools, &MediaLimits::default())?;
|
||||
assert!(report.failures.is_empty(), "{report:?}");
|
||||
assert_eq!(report.media_files, 1);
|
||||
drop(indexer);
|
||||
let mut search = HybridSearch::open_query(&database, &model, &worker, 1)?;
|
||||
let options = SearchOptions::default();
|
||||
// The unfiltered real model does return the nearest photo for these requests.
|
||||
// The correction rejects its wrong channel, not its similarity score.
|
||||
for query in [
|
||||
"music with violin",
|
||||
"audio of thunder",
|
||||
"someone mentions a contract",
|
||||
"video of a boat",
|
||||
"Musik mit Klavier",
|
||||
"Film mit einem Wasserfall",
|
||||
] {
|
||||
assert!(
|
||||
!search
|
||||
.search_semantic_with_origin(query, 10, &options, SemanticOrigin::All)?
|
||||
.is_empty(),
|
||||
"{query}"
|
||||
);
|
||||
assert!(
|
||||
search.search_semantic(query, 10, &options)?.is_empty(),
|
||||
"{query}"
|
||||
);
|
||||
}
|
||||
for query in ["photo of a cat indoors", "Bild einer Katze", "cat indoors"] {
|
||||
let hits = search.search_semantic(query, 10, &options)?;
|
||||
assert_eq!(hits[0].filename, "cat.jpg", "{query}");
|
||||
assert_eq!(hits[0].match_kind, "semantic_image", "{query}");
|
||||
}
|
||||
assert_eq!(
|
||||
search.search("cat.jpg", 1, &options)?[0].match_kind,
|
||||
"exact_filename"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires verified Google CPU worker/model and FFmpeg fixtures"]
|
||||
fn explicit_code_intent_preserves_code_among_real_audio_candidates() -> Result<()> {
|
||||
|
||||
Reference in new issue
Block a user