mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-20 21:18:08 +02:00
Harden old CPU fallback paths (#71)
This commit is contained in:
parent
536776c9d6
commit
ef2073d4a4
2 changed files with 201 additions and 79 deletions
|
|
@ -343,14 +343,18 @@ impl EmbeddingService {
|
|||
Self { _unused: () }
|
||||
}
|
||||
|
||||
/// Check if the model is ready
|
||||
/// Check if the model has already been initialized.
|
||||
///
|
||||
/// This must stay side-effect free: health/status paths call it during
|
||||
/// startup and must not download or load ONNX models just to report state.
|
||||
pub fn is_ready(&self) -> bool {
|
||||
match get_backend() {
|
||||
Ok(_) => true,
|
||||
Err(e) => {
|
||||
match EMBEDDING_BACKEND_RESULT.get() {
|
||||
Some(Ok(backend)) => backend.lock().is_ok(),
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!("Embedding model not ready: {}", e);
|
||||
false
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -365,6 +365,36 @@ impl SqliteMemoryStore {
|
|||
None
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
fn vector_search_available(&self) -> bool {
|
||||
self.vector_index.is_some()
|
||||
}
|
||||
|
||||
#[cfg(not(all(feature = "embeddings", feature = "vector-search")))]
|
||||
fn vector_search_available(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
fn regular_ingest_result(
|
||||
&self,
|
||||
input: IngestInput,
|
||||
reason: impl Into<String>,
|
||||
) -> Result<SmartIngestResult> {
|
||||
let node = self.ingest(input)?;
|
||||
Ok(SmartIngestResult {
|
||||
decision: "create".to_string(),
|
||||
node,
|
||||
superseded_id: None,
|
||||
similarity: None,
|
||||
prediction_error: Some(1.0),
|
||||
reason: reason.into(),
|
||||
previous_content: None,
|
||||
merged_from: None,
|
||||
merge_preview: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn data_dir_from_env() -> Option<PathBuf> {
|
||||
std::env::var_os(DATA_DIR_ENV).and_then(|value| {
|
||||
if value.is_empty() {
|
||||
|
|
@ -742,19 +772,17 @@ impl SqliteMemoryStore {
|
|||
|
||||
// Generate embedding for new content
|
||||
if !self.embedding_service.is_ready() {
|
||||
// Fall back to regular ingest if embeddings not available
|
||||
let node = self.ingest(input)?;
|
||||
return Ok(SmartIngestResult {
|
||||
decision: "create".to_string(),
|
||||
node,
|
||||
superseded_id: None,
|
||||
similarity: None,
|
||||
prediction_error: Some(1.0),
|
||||
reason: "Embeddings not available, falling back to regular ingest".to_string(),
|
||||
previous_content: None,
|
||||
merged_from: None,
|
||||
merge_preview: None,
|
||||
});
|
||||
return self.regular_ingest_result(
|
||||
input,
|
||||
"Embeddings not available, falling back to regular ingest",
|
||||
);
|
||||
}
|
||||
|
||||
if !self.vector_search_available() {
|
||||
return self.regular_ingest_result(
|
||||
input,
|
||||
"Vector search unavailable, falling back to regular ingest",
|
||||
);
|
||||
}
|
||||
|
||||
let new_embedding = self
|
||||
|
|
@ -1063,10 +1091,10 @@ impl SqliteMemoryStore {
|
|||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
{
|
||||
// Remove old embedding from index
|
||||
if let Some(index) = self.vector_index.as_ref() {
|
||||
if let Ok(mut index) = index.lock() {
|
||||
let _ = index.remove(id);
|
||||
}
|
||||
if let Some(index) = self.vector_index.as_ref()
|
||||
&& let Ok(mut index) = index.lock()
|
||||
{
|
||||
let _ = index.remove(id);
|
||||
}
|
||||
// Generate new embedding
|
||||
if let Err(e) = self.generate_embedding_for_node(id, new_content) {
|
||||
|
|
@ -1276,8 +1304,12 @@ impl SqliteMemoryStore {
|
|||
}
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
SearchMode::Semantic => {
|
||||
let results = self.semantic_search(&input.query, input.limit, 0.3)?;
|
||||
results.into_iter().map(|r| r.node).collect()
|
||||
if !self.vector_search_available() {
|
||||
self.keyword_search(&input.query, input.limit, input.min_retention)?
|
||||
} else {
|
||||
let results = self.semantic_search(&input.query, input.limit, 0.3)?;
|
||||
results.into_iter().map(|r| r.node).collect()
|
||||
}
|
||||
}
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
SearchMode::Hybrid => {
|
||||
|
|
@ -1456,36 +1488,36 @@ impl SqliteMemoryStore {
|
|||
// Content-aware cross-memory reinforcement: boost semantically similar neighbors
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
{
|
||||
if let Some(index) = self.vector_index.as_ref() {
|
||||
if let Ok(Some(embedding)) = self.get_node_embedding(id) {
|
||||
let index = index.lock().map_err(|_| {
|
||||
StorageError::Init("Vector index lock poisoned".to_string())
|
||||
})?;
|
||||
if let Some(index) = self.vector_index.as_ref()
|
||||
&& let Ok(Some(embedding)) = self.get_node_embedding(id)
|
||||
{
|
||||
let index = index
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Vector index lock poisoned".to_string()))?;
|
||||
|
||||
// Query top-6 similar (one will be self, so we get ~5 neighbors)
|
||||
let neighbors_result = index.search(&embedding, 6);
|
||||
drop(index);
|
||||
// Query top-6 similar (one will be self, so we get ~5 neighbors)
|
||||
let neighbors_result = index.search(&embedding, 6);
|
||||
drop(index);
|
||||
|
||||
if let Ok(neighbors) = neighbors_result {
|
||||
let writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
|
||||
for (neighbor_id, similarity) in neighbors {
|
||||
if neighbor_id == id || similarity < 0.7 {
|
||||
continue;
|
||||
}
|
||||
// Diminished boost: 0.02 * similarity (max ~0.02)
|
||||
let boost = 0.02 * similarity as f64;
|
||||
let retention_boost = 0.008 * similarity as f64;
|
||||
let _ = writer.execute(
|
||||
"UPDATE knowledge_nodes SET
|
||||
retrieval_strength = MIN(1.0, retrieval_strength + ?1),
|
||||
retention_strength = MIN(1.0, retention_strength + ?2)
|
||||
WHERE id = ?3",
|
||||
params![boost, retention_boost, neighbor_id],
|
||||
);
|
||||
if let Ok(neighbors) = neighbors_result {
|
||||
let writer = self
|
||||
.writer
|
||||
.lock()
|
||||
.map_err(|_| StorageError::Init("Writer lock poisoned".into()))?;
|
||||
for (neighbor_id, similarity) in neighbors {
|
||||
if neighbor_id == id || similarity < 0.7 {
|
||||
continue;
|
||||
}
|
||||
// Diminished boost: 0.02 * similarity (max ~0.02)
|
||||
let boost = 0.02 * similarity as f64;
|
||||
let retention_boost = 0.008 * similarity as f64;
|
||||
let _ = writer.execute(
|
||||
"UPDATE knowledge_nodes SET
|
||||
retrieval_strength = MIN(1.0, retrieval_strength + ?1),
|
||||
retention_strength = MIN(1.0, retention_strength + ?2)
|
||||
WHERE id = ?3",
|
||||
params![boost, retention_boost, neighbor_id],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2100,12 +2132,11 @@ impl SqliteMemoryStore {
|
|||
|
||||
// Clean up vector index to prevent stale search results
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
if rows > 0 {
|
||||
if let Some(index) = self.vector_index.as_ref() {
|
||||
if let Ok(mut index) = index.lock() {
|
||||
let _ = index.remove(id);
|
||||
}
|
||||
}
|
||||
if rows > 0
|
||||
&& let Some(index) = self.vector_index.as_ref()
|
||||
&& let Ok(mut index) = index.lock()
|
||||
{
|
||||
let _ = index.remove(id);
|
||||
}
|
||||
|
||||
Ok(rows > 0)
|
||||
|
|
@ -2224,10 +2255,10 @@ impl SqliteMemoryStore {
|
|||
tx.commit()?;
|
||||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
if let Some(index) = self.vector_index.as_ref() {
|
||||
if let Ok(mut index) = index.lock() {
|
||||
let _ = index.remove(id);
|
||||
}
|
||||
if let Some(index) = self.vector_index.as_ref()
|
||||
&& let Ok(mut index) = index.lock()
|
||||
{
|
||||
let _ = index.remove(id);
|
||||
}
|
||||
|
||||
Ok(PurgeReport {
|
||||
|
|
@ -2661,16 +2692,16 @@ impl SqliteMemoryStore {
|
|||
limit: i32,
|
||||
min_similarity: f32,
|
||||
) -> Result<Vec<SimilarityResult>> {
|
||||
if !self.embedding_service.is_ready() {
|
||||
return Err(StorageError::Init("Embedding model not ready".to_string()));
|
||||
}
|
||||
|
||||
let Some(index_lock) = self.vector_index.as_ref() else {
|
||||
return Err(StorageError::Init(
|
||||
"Vector search unavailable: disabled for this machine".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
if !self.embedding_service.is_ready() {
|
||||
return Err(StorageError::Init("Embedding model not ready".to_string()));
|
||||
}
|
||||
|
||||
let query_embedding = self.get_query_embedding(query)?;
|
||||
|
||||
let index = index_lock
|
||||
|
|
@ -2733,11 +2764,12 @@ impl SqliteMemoryStore {
|
|||
exclude_types,
|
||||
)?;
|
||||
|
||||
let semantic_results = if self.embedding_service.is_ready() {
|
||||
self.semantic_search_raw(query, limit * overfetch_factor)?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
let semantic_results =
|
||||
if self.vector_search_available() && self.embedding_service.is_ready() {
|
||||
self.semantic_search_raw(query, limit * overfetch_factor)?
|
||||
} else {
|
||||
vec![]
|
||||
};
|
||||
|
||||
let combined = if !semantic_results.is_empty() {
|
||||
linear_combination(
|
||||
|
|
@ -2987,12 +3019,12 @@ impl SqliteMemoryStore {
|
|||
/// Semantic search returning scores
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
fn semantic_search_raw(&self, query: &str, limit: i32) -> Result<Vec<(String, f32)>> {
|
||||
if !self.vector_search_available() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
if !self.embedding_service.is_ready() {
|
||||
return Ok(vec![]);
|
||||
}
|
||||
self.vector_index.as_ref().ok_or_else(|| {
|
||||
StorageError::Init("Vector search unavailable: disabled for this machine".to_string())
|
||||
})?;
|
||||
|
||||
// HyDE query expansion: for conceptual queries, embed expanded variants
|
||||
// and use the centroid for broader semantic coverage
|
||||
|
|
@ -7177,13 +7209,12 @@ impl SqliteMemoryStore {
|
|||
|
||||
// Clean up vector index
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
if deleted > 0 {
|
||||
if let Some(index) = self.vector_index.as_ref() {
|
||||
if let Ok(mut index) = index.lock() {
|
||||
for id in &doomed_ids {
|
||||
let _ = index.remove(id);
|
||||
}
|
||||
}
|
||||
if deleted > 0
|
||||
&& let Some(index) = self.vector_index.as_ref()
|
||||
&& let Ok(mut index) = index.lock()
|
||||
{
|
||||
for id in &doomed_ids {
|
||||
let _ = index.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -9392,11 +9423,16 @@ impl crate::storage::memory_store::MemoryStoreSend for SqliteMemoryStore {
|
|||
mod tests {
|
||||
use super::*;
|
||||
use crate::advanced::{MatchClass, MergePolicy};
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
use std::panic::{AssertUnwindSafe, catch_unwind, resume_unwind};
|
||||
use tempfile::tempdir;
|
||||
// The public struct was renamed from Storage to SqliteMemoryStore; this
|
||||
// alias keeps all existing tests compiling without modification.
|
||||
use SqliteMemoryStore as Storage;
|
||||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
static ENV_LOCK: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn create_test_storage() -> Storage {
|
||||
let dir = tempdir().unwrap();
|
||||
let db_path = dir.path().join("test.db");
|
||||
|
|
@ -9407,6 +9443,88 @@ mod tests {
|
|||
Storage::new(Some(dir.path().join(name))).unwrap()
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
fn with_vector_search_disabled<T>(f: impl FnOnce() -> T) -> T {
|
||||
let _guard = ENV_LOCK.lock().unwrap();
|
||||
let previous = std::env::var_os(VESTIGE_DISABLE_VECTOR_SEARCH);
|
||||
|
||||
// Tests serialize access with ENV_LOCK because process environment
|
||||
// mutation is global and unsafe under Rust 2024.
|
||||
unsafe {
|
||||
std::env::set_var(VESTIGE_DISABLE_VECTOR_SEARCH, "1");
|
||||
}
|
||||
|
||||
let result = catch_unwind(AssertUnwindSafe(f));
|
||||
|
||||
unsafe {
|
||||
if let Some(value) = previous {
|
||||
std::env::set_var(VESTIGE_DISABLE_VECTOR_SEARCH, value);
|
||||
} else {
|
||||
std::env::remove_var(VESTIGE_DISABLE_VECTOR_SEARCH);
|
||||
}
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(value) => value,
|
||||
Err(payload) => resume_unwind(payload),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
#[test]
|
||||
fn test_runtime_vector_gate_env_disables_index_creation() {
|
||||
with_vector_search_disabled(|| {
|
||||
assert!(!Storage::vector_search_enabled_by_cpu());
|
||||
assert_eq!(
|
||||
Storage::vector_search_unavailable_reason(),
|
||||
Some("disabled by VESTIGE_DISABLE_VECTOR_SEARCH")
|
||||
);
|
||||
|
||||
let dir = tempdir().unwrap();
|
||||
let storage = create_test_storage_at(&dir, "vector-disabled.db");
|
||||
|
||||
assert!(storage.vector_index.is_none());
|
||||
assert!(storage.query_cache.is_none());
|
||||
|
||||
let stats = storage.get_stats().unwrap();
|
||||
assert_eq!(stats.total_nodes, 0);
|
||||
|
||||
let schema = storage.schema_introspection().unwrap();
|
||||
assert!(schema.schema_version >= 1);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
#[test]
|
||||
fn test_runtime_vector_gate_disabled_hybrid_search_uses_keyword_fallback() {
|
||||
with_vector_search_disabled(|| {
|
||||
let dir = tempdir().unwrap();
|
||||
let storage = create_test_storage_at(&dir, "vector-disabled-search.db");
|
||||
|
||||
storage
|
||||
.ingest(IngestInput {
|
||||
content: "runtime gate fallback keyword anchor".to_string(),
|
||||
node_type: "fact".to_string(),
|
||||
..Default::default()
|
||||
})
|
||||
.unwrap();
|
||||
|
||||
let results = storage
|
||||
.hybrid_search("runtime gate fallback keyword", 10, 0.3, 0.7)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(results.len(), 1);
|
||||
assert_eq!(results[0].match_type, MatchType::Keyword);
|
||||
assert!(results[0].semantic_score.is_none());
|
||||
assert!(
|
||||
results[0]
|
||||
.node
|
||||
.content
|
||||
.contains("runtime gate fallback keyword anchor")
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "embeddings", feature = "vector-search"))]
|
||||
#[test]
|
||||
fn test_embedding_model_family_matching() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue