1use crate::config::{DerivedVectorBackendPolicy, SearchConfig};
4use crate::episodes;
5use crate::error::MemoryError;
6use crate::types::{
7 ExplainedResult, ScoreBreakdown, SearchContext, SearchResult, SearchSource, SearchSourceType,
8 VectorSearchReceiptV1,
9};
10use rusqlite::types::Value as SqlValue;
11use rusqlite::Connection;
12#[allow(unused_imports)]
18use rusqlite::OptionalExtension;
19use stack_ids::DigestBuilder;
20#[cfg(feature = "turbo-quant-codec")]
21use std::collections::BinaryHeap;
22use std::collections::{HashMap, HashSet};
23use std::sync::atomic::{AtomicUsize, Ordering};
24
25const VECTOR_SCAN_WARN_THRESHOLD: usize = 50_000;
27const VECTOR_SCAN_HARD_LIMIT: usize = 250_000;
29
30static VECTOR_SCAN_WARN_LIMIT: AtomicUsize = AtomicUsize::new(VECTOR_SCAN_WARN_THRESHOLD);
31static VECTOR_SCAN_BLOCK_LIMIT: AtomicUsize = AtomicUsize::new(VECTOR_SCAN_HARD_LIMIT);
32
33#[allow(dead_code)]
37fn expand_query_for_fts(query: &str) -> String {
38 let terms: Vec<&str> = query.split_whitespace().collect();
39 let expanded: Vec<String> = terms
40 .iter()
41 .map(|term| {
42 if term.contains('-') {
43 let no_hyphen = term.replace('-', "");
44 if no_hyphen != *term {
45 format!("{term} OR {no_hyphen}")
46 } else {
47 term.to_string()
48 }
49 } else {
50 term.to_string()
51 }
52 })
53 .collect();
54 expanded.join(" ")
55}
56
57pub fn should_retrieve(query: &str) -> bool {
60 let trimmed = query.trim();
61 if trimmed.len() < 12 {
62 return false;
63 }
64 let lower = trimmed.to_lowercase();
65 let skip_phrases = [
66 "ok",
67 "yes",
68 "no",
69 "thanks",
70 "done",
71 "sure",
72 "yeah",
73 "right",
74 "correct",
75 "agreed",
76 "ok thanks",
77 "got it",
78 "sounds good",
79 "that works",
80 "makes sense",
81 "i see",
82 "understood",
83 "gotcha",
84 ];
85 for phrase in &skip_phrases {
86 if lower == *phrase {
87 return false;
88 }
89 }
90 if lower.starts_with("can you")
91 || lower.starts_with("could you")
92 || lower.starts_with("would you")
93 || lower.starts_with("will you")
94 {
95 if lower.len() <= 20 {
96 return false;
97 }
98 }
99 if trimmed.starts_with('/') {
100 return false;
101 }
102 true
103}
104
105pub fn sanitize_fts_query(raw: &str) -> Option<String> {
115 let cleaned: String = raw
116 .chars()
117 .map(|c| {
118 if c.is_alphanumeric() || c.is_whitespace() || c == '_' {
119 c
120 } else {
121 ' '
122 }
123 })
124 .collect();
125
126 let tokens: Vec<&str> = cleaned
127 .split_whitespace()
128 .filter(|t| !matches!(t.to_uppercase().as_str(), "AND" | "OR" | "NOT" | "NEAR"))
129 .collect();
130
131 if tokens.is_empty() {
132 None
133 } else {
134 Some(
135 tokens
136 .into_iter()
137 .map(|token| format!("\"{}\"", token.replace('"', "\"\"")))
138 .collect::<Vec<_>>()
139 .join(" OR "),
140 )
141 }
142}
143
144pub fn cosine_similarity(a: &[f32], b: &[f32]) -> Result<f32, MemoryError> {
146 if a.len() != b.len() {
147 return Err(MemoryError::EmbeddingDimensionMismatch {
148 expected: a.len(),
149 actual: b.len(),
150 });
151 }
152 if let Some((index, _)) = a.iter().enumerate().find(|(_, value)| !value.is_finite()) {
153 return Err(MemoryError::NonFiniteEmbeddingValue { index });
154 }
155 if let Some((index, _)) = b.iter().enumerate().find(|(_, value)| !value.is_finite()) {
156 return Err(MemoryError::NonFiniteEmbeddingValue { index });
157 }
158 let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
159 let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
160 let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
161 if norm_a == 0.0 || norm_b == 0.0 {
162 return Ok(0.0);
163 }
164 let similarity = dot / (norm_a * norm_b);
165 if !similarity.is_finite() {
166 return Err(MemoryError::Other(
167 "cosine similarity produced a non-finite score".to_string(),
168 ));
169 }
170 Ok(similarity)
171}
172
173fn days_since(timestamp: &str, evaluation_time: chrono::DateTime<chrono::Utc>) -> Option<f64> {
174 let dt = parse_search_timestamp(timestamp)?;
175 let duration = evaluation_time.naive_utc() - dt;
176 Some(duration.num_seconds() as f64 / 86_400.0)
177}
178
179fn parse_search_timestamp(timestamp: &str) -> Option<chrono::NaiveDateTime> {
180 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S") {
181 return Some(dt);
182 }
183 if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(timestamp, "%Y-%m-%d %H:%M:%S%.f") {
184 return Some(dt);
185 }
186 if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(timestamp) {
187 return Some(dt.naive_utc());
188 }
189 tracing::warn!(
190 timestamp,
191 "failed to parse search timestamp for recency scoring; recency contribution dropped"
192 );
193 None
194}
195
196fn recency_contribution(
197 config: &SearchConfig,
198 context: &SearchContext,
199 updated_at: Option<&str>,
200 best_rank: Option<usize>,
201) -> Option<f64> {
202 match (config.recency_half_life_days, updated_at) {
203 (Some(half_life), Some(ts)) if half_life > 0.0 => {
204 let age_days = days_since(ts, context.evaluation_time).map(|days| days.max(0.0))?;
205 let decay = 2.0_f64.powf(-age_days / half_life);
206 let rank = best_rank.unwrap_or(1).max(1) as f64;
207 Some(config.recency_weight * decay / (config.rrf_k + rank))
208 }
209 _ => None,
210 }
211}
212
213pub(crate) fn search_result_id(source: &SearchSource) -> String {
214 match source {
215 SearchSource::Fact { fact_id, .. } => format!("fact:{fact_id}"),
216 SearchSource::Chunk { chunk_id, .. } => format!("chunk:{chunk_id}"),
217 SearchSource::Message { message_id, .. } => format!("msg:{message_id}"),
218 SearchSource::Episode { episode_id, .. } => format!("episode:{episode_id}"),
219 SearchSource::Projection { projection_id, .. } => format!("projection:{projection_id}"),
220 }
221}
222
223pub fn source_dedup_key(source: &SearchSource) -> (u8, String) {
224 match source {
225 SearchSource::Fact { fact_id, .. } => (0, fact_id.clone()),
226 SearchSource::Chunk { chunk_id, .. } => (1, chunk_id.clone()),
227 SearchSource::Message {
228 message_id,
229 session_id,
230 ..
231 } => (2, format!("{session_id}:{message_id}")),
232 SearchSource::Episode { episode_id, .. } => (3, episode_id.clone()),
233 SearchSource::Projection { projection_id, .. } => (4, projection_id.clone()),
234 }
235}
236
237#[derive(Debug, Clone)]
239pub struct Bm25Hit {
240 pub id: String,
242 pub content: String,
244 pub source: SearchSource,
246 pub raw_score: f64,
248 pub updated_at: Option<String>,
250 pub temporal_weight: Option<f64>,
252 pub provenance_confidence: Option<f64>,
254}
255
256#[derive(Debug, Clone)]
258pub struct VectorHit {
259 pub id: String,
261 pub content: String,
263 pub source: SearchSource,
265 pub similarity: f64,
267 pub updated_at: Option<String>,
269 pub source_rank: Option<usize>,
271 pub source_similarity: Option<f64>,
273 pub reranked_from_f32: bool,
275 pub temporal_weight: Option<f64>,
277 pub provenance_confidence: Option<f64>,
279}
280
281#[derive(Debug, Clone)]
283struct SparseHit {
284 content: String,
285 source: SearchSource,
286 score: f64,
287 updated_at: Option<String>,
288 representation: String,
289}
290
291#[allow(dead_code)]
292struct VectorRow {
293 id: String,
294 content: String,
295 blob: Vec<u8>,
296 updated_at: Option<String>,
297 source_type: SearchSourceType,
298 filter_namespace: Option<String>,
299 filter_session_id: Option<String>,
300 source: SearchSource,
301}
302
303struct RrfCandidate {
304 content: String,
305 source: SearchSource,
306 updated_at: Option<String>,
307 bm25_score: Option<f64>,
308 bm25_rank: Option<usize>,
309 vector_score: Option<f64>,
310 vector_rank: Option<usize>,
311 vector_source_rank: Option<usize>,
312 vector_source_score: Option<f64>,
313 vector_reranked_from_f32: bool,
314 sparse_score: Option<f64>,
315 sparse_rank: Option<usize>,
316 late_interaction_rank: Option<usize>,
318 #[allow(dead_code)]
320 late_interaction_score: Option<f64>,
321 temporal_weight: Option<f64>,
323 provenance_confidence: Option<f64>,
325}
326
327impl RrfCandidate {
328 fn explained(self, config: &SearchConfig, context: &SearchContext) -> ExplainedResult {
329 let bm25_contribution = self
330 .bm25_rank
331 .map(|rank| config.bm25_weight / (config.rrf_k + rank as f64));
332 let vector_contribution = self
333 .vector_rank
334 .map(|rank| config.vector_weight / (config.rrf_k + rank as f64));
335 let sparse_contribution = self
336 .sparse_rank
337 .map(|rank| config.sparse_weight / (config.rrf_k + rank as f64));
338 let late_interaction_weight = config.late_interaction_weight;
341 let late_interaction_contribution = self
342 .late_interaction_rank
343 .map(|rank| late_interaction_weight / (config.rrf_k + rank as f64));
344 let best_rank = [self.bm25_rank, self.vector_rank, self.sparse_rank]
345 .into_iter()
346 .flatten()
347 .min();
348 let recency_score =
349 recency_contribution(config, context, self.updated_at.as_deref(), best_rank);
350 let base_score = bm25_contribution.unwrap_or(0.0)
351 + vector_contribution.unwrap_or(0.0)
352 + sparse_contribution.unwrap_or(0.0)
353 + late_interaction_contribution.unwrap_or(0.0)
354 + recency_score.unwrap_or(0.0);
355 let temporal_factor = self.temporal_weight.unwrap_or(1.0);
358 let provenance_factor = 1.0 + (self.provenance_confidence.unwrap_or(0.5) - 0.5) * 0.2;
359 let rrf_score = base_score * temporal_factor * provenance_factor;
360 let ns_weight = match &self.source {
362 SearchSource::Fact { namespace, .. } => config
363 .namespace_weights
364 .get(namespace)
365 .copied()
366 .unwrap_or(1.0),
367 _ => 1.0,
368 };
369 let rrf_score = rrf_score * ns_weight;
370
371 let breakdown = ScoreBreakdown {
372 rrf_score,
373 bm25_score: self.bm25_score,
374 vector_score: self.vector_score,
375 sparse_score: self.sparse_score,
376 recency_score,
377 bm25_rank: self.bm25_rank,
378 vector_rank: self.vector_rank,
379 sparse_rank: self.sparse_rank,
380 vector_source_rank: self.vector_source_rank,
381 vector_source_score: self.vector_source_score,
382 bm25_contribution,
383 vector_contribution,
384 sparse_contribution,
385 vector_reranked_from_f32: self.vector_reranked_from_f32,
386 bm25_weight: config.bm25_weight,
387 vector_weight: config.vector_weight,
388 sparse_weight: config.sparse_weight,
389 recency_weight: config.recency_half_life_days.map(|_| config.recency_weight),
390 rrf_k: config.rrf_k,
391 };
392
393 ExplainedResult {
394 result: SearchResult {
395 content: self.content,
396 source: self.source,
397 score: rrf_score,
398 bm25_rank: breakdown.bm25_rank,
399 vector_rank: breakdown.vector_rank,
400 cosine_similarity: breakdown.vector_score,
401 },
402 breakdown,
403 }
404 }
405}
406
407fn scan_vector_rows(
408 rows: impl Iterator<Item = Result<VectorRow, rusqlite::Error>>,
409 query_embedding: &[f32],
410 min_similarity: f64,
411 table_label: &str,
412) -> Result<(Vec<VectorHit>, usize), MemoryError> {
413 let expected_dims = query_embedding.len();
414 let mut hits = Vec::new();
415 let mut row_count = 0usize;
416 let warn_limit = VECTOR_SCAN_WARN_LIMIT.load(Ordering::Relaxed);
417 let hard_limit = VECTOR_SCAN_BLOCK_LIMIT.load(Ordering::Relaxed);
418
419 for row in rows {
420 let row = row?;
421 row_count += 1;
422 if warn_limit > 0 && row_count == warn_limit.saturating_add(1) {
423 tracing::warn!(
424 table = table_label,
425 count = row_count,
426 threshold = warn_limit,
427 "vector scan warning threshold exceeded"
428 );
429 }
430 if hard_limit > 0 && row_count > hard_limit {
431 return Err(MemoryError::VectorScanLimitExceeded {
432 table: table_label.to_string(),
433 scanned: row_count,
434 limit: hard_limit,
435 });
436 }
437
438 let stored_embedding = match crate::db::decode_f32_le(&row.blob, expected_dims) {
439 Ok(embedding) => embedding,
440 Err(error) => {
441 tracing::warn!(
442 error = %error,
443 table = table_label,
444 item = %row.id,
445 "Skipping row with invalid embedding blob"
446 );
447 continue;
448 }
449 };
450
451 if stored_embedding.len() != expected_dims {
452 tracing::warn!(
453 expected = expected_dims,
454 actual = stored_embedding.len(),
455 "Skipping {} with wrong embedding dimensions",
456 table_label
457 );
458 continue;
459 }
460
461 let similarity = cosine_similarity(query_embedding, &stored_embedding)? as f64;
462 if similarity >= min_similarity {
463 hits.push(VectorHit {
464 id: row.id,
465 content: row.content,
466 source: row.source,
467 similarity,
468 updated_at: row.updated_at,
469 source_rank: None,
470 source_similarity: None,
471 reranked_from_f32: false,
472 temporal_weight: None,
473 provenance_confidence: None,
474 });
475 }
476 }
477
478 Ok((hits, row_count))
479}
480
481fn rank_vector_hits(mut hits: Vec<VectorHit>, pool_size: usize) -> Vec<VectorHit> {
482 hits.sort_by(|a, b| {
483 b.similarity.partial_cmp(&a.similarity).unwrap_or_else(|| {
484 if a.similarity.is_nan() {
485 std::cmp::Ordering::Greater
486 } else {
487 std::cmp::Ordering::Less
488 }
489 })
490 });
491
492 for (idx, hit) in hits.iter_mut().enumerate() {
493 hit.source_rank = Some(idx + 1);
494 hit.source_similarity = Some(hit.similarity);
495 }
496
497 hits.truncate(pool_size);
498 hits
499}
500
501pub(crate) fn bm25_search(
503 conn: &Connection,
504 sanitized_query: &str,
505 pool_size: usize,
506 namespaces: Option<&[&str]>,
507 source_types: Option<&[SearchSourceType]>,
508 session_ids: Option<&[&str]>,
509) -> Result<Vec<Bm25Hit>, MemoryError> {
510 let mut hits = Vec::new();
511
512 let search_facts = source_types
513 .map(|st| st.contains(&SearchSourceType::Facts))
514 .unwrap_or(true);
515 let search_chunks = source_types
516 .map(|st| st.contains(&SearchSourceType::Chunks))
517 .unwrap_or(true);
518 let search_messages = source_types
519 .map(|st| st.contains(&SearchSourceType::Messages))
520 .unwrap_or(false);
521 let search_episodes = source_types
522 .map(|st| st.contains(&SearchSourceType::Episodes))
523 .unwrap_or(true);
524
525 if search_facts {
526 let (ns_clause, ns_params) = build_filter_clause("f.namespace", namespaces, 3);
527 let sql = format!(
528 "SELECT fm.fact_id, f.content, f.namespace, bm25(facts_fts) AS score, f.updated_at, f.temporal_weight
529 FROM facts_fts
530 JOIN facts_rowid_map fm ON facts_fts.rowid = fm.rowid
531 JOIN facts f ON f.id = fm.fact_id
532 WHERE facts_fts MATCH ?1 {}
533 ORDER BY score ASC
534 LIMIT ?2",
535 ns_clause
536 );
537
538 let mut params = vec![
539 SqlValue::Text(sanitized_query.to_string()),
540 SqlValue::Integer(pool_size as i64),
541 ];
542 params.extend(ns_params);
543
544 let mut stmt = conn.prepare(&sql)?;
545 let rows = stmt.query_map(rusqlite::params_from_iter(¶ms), |row| {
546 let fact_id: String = row.get(0)?;
547 let content: String = row.get(1)?;
548 let namespace: String = row.get(2)?;
549 let raw_score: f64 = row.get(3)?;
550 let updated_at: Option<String> = row.get(4)?;
551 let temporal_weight: Option<f64> = row.get(5)?;
552 Ok(Bm25Hit {
553 id: format!("fact:{fact_id}"),
554 content,
555 source: SearchSource::Fact { fact_id, namespace },
556 raw_score,
557 updated_at,
558 temporal_weight,
559 provenance_confidence: None,
560 })
561 })?;
562
563 for row in rows {
564 hits.push(row?);
565 }
566 }
567
568 if search_chunks {
569 let (ns_clause, ns_params) = build_filter_clause("d.namespace", namespaces, 3);
570 let sql = format!(
571 "SELECT cm.chunk_id, c.content, c.document_id, d.title, c.chunk_index,
572 bm25(chunks_fts) AS score, c.created_at
573 FROM chunks_fts
574 JOIN chunks_rowid_map cm ON chunks_fts.rowid = cm.rowid
575 JOIN chunks c ON c.id = cm.chunk_id
576 JOIN documents d ON d.id = c.document_id
577 WHERE chunks_fts MATCH ?1 {}
578 ORDER BY score ASC
579 LIMIT ?2",
580 ns_clause
581 );
582
583 let mut params = vec![
584 SqlValue::Text(sanitized_query.to_string()),
585 SqlValue::Integer(pool_size as i64),
586 ];
587 params.extend(ns_params);
588
589 let mut stmt = conn.prepare(&sql)?;
590 let rows = stmt.query_map(rusqlite::params_from_iter(¶ms), |row| {
591 let chunk_id: String = row.get(0)?;
592 let content: String = row.get(1)?;
593 let document_id: String = row.get(2)?;
594 let document_title: String = row.get(3)?;
595 let chunk_index: i64 = row.get(4)?;
596 let raw_score: f64 = row.get(5)?;
597 let updated_at: Option<String> = row.get(6)?;
598 Ok(Bm25Hit {
599 id: format!("chunk:{chunk_id}"),
600 content,
601 source: SearchSource::Chunk {
602 chunk_id,
603 document_id,
604 document_title,
605 chunk_index: chunk_index as usize,
606 },
607 raw_score,
608 updated_at,
609 temporal_weight: None,
610 provenance_confidence: None,
611 })
612 })?;
613
614 for row in rows {
615 hits.push(row?);
616 }
617 }
618
619 if search_messages {
620 let (sid_clause, sid_params) = build_filter_clause("m.session_id", session_ids, 3);
621 let sql = format!(
622 "SELECT mm.message_id, m.content, m.session_id, m.role,
623 bm25(messages_fts) AS score, m.created_at
624 FROM messages_fts
625 JOIN messages_rowid_map mm ON messages_fts.rowid = mm.rowid
626 JOIN messages m ON m.id = mm.message_id
627 WHERE messages_fts MATCH ?1 {}
628 ORDER BY score ASC
629 LIMIT ?2",
630 sid_clause
631 );
632
633 let mut params = vec![
634 SqlValue::Text(sanitized_query.to_string()),
635 SqlValue::Integer(pool_size as i64),
636 ];
637 params.extend(sid_params);
638
639 let mut stmt = conn.prepare(&sql)?;
640 let rows = stmt.query_map(rusqlite::params_from_iter(¶ms), |row| {
641 let message_id: i64 = row.get(0)?;
642 let content: String = row.get(1)?;
643 let session_id: String = row.get(2)?;
644 let role: String = row.get(3)?;
645 let raw_score: f64 = row.get(4)?;
646 let updated_at: Option<String> = row.get(5)?;
647 Ok(Bm25Hit {
648 id: format!("msg:{message_id}"),
649 content,
650 source: SearchSource::Message {
651 message_id,
652 session_id,
653 role,
654 },
655 raw_score,
656 updated_at,
657 temporal_weight: None,
658 provenance_confidence: None,
659 })
660 })?;
661
662 for row in rows {
663 hits.push(row?);
664 }
665 }
666
667 if search_episodes {
668 let (ns_clause, ns_params) = build_filter_clause("d.namespace", namespaces, 3);
669 let sql = format!(
670 "SELECT e.episode_id, e.document_id, e.search_text, e.effect_type, e.outcome,
671 bm25(episodes_fts) AS score, e.updated_at
672 FROM episodes_fts
673 JOIN episodes_rowid_map rm ON episodes_fts.rowid = rm.rowid
674 JOIN episodes e ON e.episode_id = rm.episode_id
675 JOIN documents d ON d.id = e.document_id
676 WHERE episodes_fts MATCH ?1 {}
677 ORDER BY score ASC
678 LIMIT ?2",
679 ns_clause
680 );
681
682 let mut params = vec![
683 SqlValue::Text(sanitized_query.to_string()),
684 SqlValue::Integer(pool_size as i64),
685 ];
686 params.extend(ns_params);
687
688 let mut stmt = conn.prepare(&sql)?;
689 let rows = stmt.query_map(rusqlite::params_from_iter(¶ms), |row| {
690 let episode_id: String = row.get(0)?;
691 let document_id: String = row.get(1)?;
692 let content: String = row.get(2)?;
693 let effect_type: String = row.get(3)?;
694 let outcome: String = row.get(4)?;
695 let raw_score: f64 = row.get(5)?;
696 let updated_at: Option<String> = row.get(6)?;
697 Ok(Bm25Hit {
698 id: episodes::episode_item_key(&episode_id),
699 content,
700 source: SearchSource::Episode {
701 episode_id,
702 document_id,
703 effect_type,
704 outcome,
705 },
706 raw_score,
707 updated_at,
708 temporal_weight: None,
709 provenance_confidence: None,
710 })
711 })?;
712
713 for row in rows {
714 hits.push(row?);
715 }
716 }
717
718 Ok(hits)
719}
720
721pub(crate) fn vector_search(
723 conn: &Connection,
724 query_embedding: &[f32],
725 pool_size: usize,
726 min_similarity: f64,
727 namespaces: Option<&[&str]>,
728 source_types: Option<&[SearchSourceType]>,
729 session_ids: Option<&[&str]>,
730) -> Result<Vec<VectorHit>, MemoryError> {
731 let mut hits = Vec::new();
732
733 let search_facts = source_types
734 .map(|st| st.contains(&SearchSourceType::Facts))
735 .unwrap_or(true);
736 let search_chunks = source_types
737 .map(|st| st.contains(&SearchSourceType::Chunks))
738 .unwrap_or(true);
739 let search_messages = source_types
740 .map(|st| st.contains(&SearchSourceType::Messages))
741 .unwrap_or(false);
742 let search_episodes = source_types
743 .map(|st| st.contains(&SearchSourceType::Episodes))
744 .unwrap_or(true);
745
746 if search_facts {
747 let (ns_clause, ns_params) = build_filter_clause("namespace", namespaces, 1);
748 let sql = format!(
749 "SELECT id, content, namespace, embedding, updated_at
750 FROM facts
751 WHERE embedding IS NOT NULL {}",
752 ns_clause
753 );
754
755 let mut stmt = conn.prepare(&sql)?;
756 let rows = stmt.query_map(rusqlite::params_from_iter(&ns_params), |row| {
757 let id: String = row.get(0)?;
758 let content: String = row.get(1)?;
759 let namespace: String = row.get(2)?;
760 let blob: Vec<u8> = row.get(3)?;
761 let updated_at: Option<String> = row.get(4)?;
762 Ok(VectorRow {
763 id: format!("fact:{id}"),
764 content,
765 blob,
766 updated_at,
767 source_type: SearchSourceType::Facts,
768 filter_namespace: Some(namespace.clone()),
769 filter_session_id: None,
770 source: SearchSource::Fact {
771 fact_id: id,
772 namespace,
773 },
774 })
775 })?;
776
777 let (fact_hits, fact_count) =
778 scan_vector_rows(rows, query_embedding, min_similarity, "fact")?;
779 hits.extend(fact_hits);
780
781 if vector_scan_warn_exceeded(fact_count) {
782 tracing::warn!(
783 count = fact_count,
784 "facts table exceeds vector scan threshold ({} rows)",
785 fact_count
786 );
787 }
788 }
789
790 if search_chunks {
791 let (ns_clause, ns_params) = build_filter_clause("d.namespace", namespaces, 1);
792 let sql = format!(
793 "SELECT c.id, c.content, c.document_id, d.title, c.chunk_index, c.embedding, c.created_at, d.namespace
794 FROM chunks c
795 JOIN documents d ON d.id = c.document_id
796 WHERE c.embedding IS NOT NULL {}",
797 ns_clause
798 );
799
800 let mut stmt = conn.prepare(&sql)?;
801 let rows = stmt.query_map(rusqlite::params_from_iter(&ns_params), |row| {
802 let id: String = row.get(0)?;
803 let content: String = row.get(1)?;
804 let document_id: String = row.get(2)?;
805 let document_title: String = row.get(3)?;
806 let chunk_index: i64 = row.get(4)?;
807 let blob: Vec<u8> = row.get(5)?;
808 let updated_at: Option<String> = row.get(6)?;
809 let namespace: String = row.get(7)?;
810 Ok(VectorRow {
811 id: format!("chunk:{id}"),
812 content,
813 blob,
814 updated_at,
815 source_type: SearchSourceType::Chunks,
816 filter_namespace: Some(namespace),
817 filter_session_id: None,
818 source: SearchSource::Chunk {
819 chunk_id: id,
820 document_id,
821 document_title,
822 chunk_index: chunk_index as usize,
823 },
824 })
825 })?;
826
827 let (chunk_hits, chunk_count) =
828 scan_vector_rows(rows, query_embedding, min_similarity, "chunk")?;
829 hits.extend(chunk_hits);
830
831 if vector_scan_warn_exceeded(chunk_count) {
832 tracing::warn!(
833 count = chunk_count,
834 "chunks table exceeds vector scan threshold ({} rows)",
835 chunk_count
836 );
837 }
838 }
839
840 if search_messages {
841 let (sid_clause, sid_params) = build_filter_clause("m.session_id", session_ids, 1);
842 let sql = format!(
843 "SELECT m.id, m.content, m.session_id, m.role, m.embedding, m.created_at
844 FROM messages m
845 WHERE m.embedding IS NOT NULL {}",
846 sid_clause
847 );
848
849 let mut stmt = conn.prepare(&sql)?;
850 let rows = stmt.query_map(rusqlite::params_from_iter(&sid_params), |row| {
851 let message_id: i64 = row.get(0)?;
852 let content: String = row.get(1)?;
853 let session_id: String = row.get(2)?;
854 let role: String = row.get(3)?;
855 let blob: Vec<u8> = row.get(4)?;
856 let updated_at: Option<String> = row.get(5)?;
857 Ok(VectorRow {
858 id: format!("msg:{message_id}"),
859 content,
860 blob,
861 updated_at,
862 source_type: SearchSourceType::Messages,
863 filter_namespace: None,
864 filter_session_id: Some(session_id.clone()),
865 source: SearchSource::Message {
866 message_id,
867 session_id,
868 role,
869 },
870 })
871 })?;
872
873 let (message_hits, message_count) =
874 scan_vector_rows(rows, query_embedding, min_similarity, "message")?;
875 hits.extend(message_hits);
876
877 if vector_scan_warn_exceeded(message_count) {
878 tracing::warn!(
879 count = message_count,
880 "messages table exceeds vector scan threshold ({} rows)",
881 message_count
882 );
883 }
884 }
885
886 if search_episodes {
887 let (ns_clause, ns_params) = build_filter_clause("d.namespace", namespaces, 1);
888 let sql = format!(
889 "SELECT e.episode_id, e.document_id, e.search_text, e.effect_type, e.outcome, e.embedding, e.updated_at, d.namespace
890 FROM episodes e
891 JOIN documents d ON d.id = e.document_id
892 WHERE e.embedding IS NOT NULL {}",
893 ns_clause
894 );
895
896 let mut stmt = conn.prepare(&sql)?;
897 let rows = stmt.query_map(rusqlite::params_from_iter(&ns_params), |row| {
898 let episode_id: String = row.get(0)?;
899 let document_id: String = row.get(1)?;
900 let content: String = row.get(2)?;
901 let effect_type: String = row.get(3)?;
902 let outcome: String = row.get(4)?;
903 let blob: Vec<u8> = row.get(5)?;
904 let updated_at: Option<String> = row.get(6)?;
905 let namespace: String = row.get(7)?;
906 Ok(VectorRow {
907 id: episodes::episode_item_key(&episode_id),
908 content,
909 blob,
910 updated_at,
911 source_type: SearchSourceType::Episodes,
912 filter_namespace: Some(namespace),
913 filter_session_id: None,
914 source: SearchSource::Episode {
915 episode_id,
916 document_id,
917 effect_type,
918 outcome,
919 },
920 })
921 })?;
922
923 let (episode_hits, episode_count) =
924 scan_vector_rows(rows, query_embedding, min_similarity, "episode")?;
925 hits.extend(episode_hits);
926
927 if vector_scan_warn_exceeded(episode_count) {
928 tracing::warn!(
929 count = episode_count,
930 "episodes table exceeds vector scan threshold ({} rows)",
931 episode_count
932 );
933 }
934 }
935
936 Ok(rank_vector_hits(hits, pool_size))
937}
938
939fn brute_force_vector_outcome(
940 conn: &Connection,
941 query_embedding: &[f32],
942 pool_size: usize,
943 min_similarity: f64,
944 namespaces: Option<&[&str]>,
945 source_types: Option<&[SearchSourceType]>,
946 session_ids: Option<&[&str]>,
947) -> Result<VectorSearchOutcome, MemoryError> {
948 let hits = vector_search(
949 conn,
950 query_embedding,
951 pool_size,
952 min_similarity,
953 namespaces,
954 source_types,
955 session_ids,
956 )?;
957 Ok(VectorSearchOutcome {
958 requested_candidates: pool_size,
959 returned_candidates: hits.len(),
960 post_filter_candidates: hits.len(),
961 hits,
962 candidate_backend: "brute_force_f32".to_string(),
963 fallback: None,
964 exact_rerank: true,
965 degradations: Vec::new(),
966 receipt_metadata: VectorReceiptMetadata::default(),
967 })
968}
969
970#[allow(clippy::too_many_arguments)]
977fn compressed_candidate_vector_outcome(
978 conn: &Connection,
979 query_embedding: &[f32],
980 pool_size: usize,
981 min_similarity: f64,
982 namespaces: Option<&[&str]>,
983 source_types: Option<&[SearchSourceType]>,
984 session_ids: Option<&[&str]>,
985) -> Result<VectorSearchOutcome, MemoryError> {
986 use crate::quantize::unpack_quantized;
987
988 let dims = query_embedding.len();
989 let candidate_k = (pool_size * 4).max(40);
990
991 let search_facts = source_types
992 .map(|st| st.contains(&SearchSourceType::Facts))
993 .unwrap_or(true);
994
995 let mut candidates: Vec<(String, f64)> = Vec::new();
996
997 if search_facts {
998 let (ns_clause, ns_params) = build_filter_clause("namespace", namespaces, 1);
999 let sql = format!(
1000 "SELECT id, embedding_q8 FROM facts WHERE embedding_q8 IS NOT NULL {}",
1001 ns_clause
1002 );
1003 let mut stmt = conn.prepare(&sql)?;
1004 let rows = stmt.query_map(rusqlite::params_from_iter(&ns_params), |row| {
1005 let id: String = row.get(0)?;
1006 let blob: Vec<u8> = row.get(1)?;
1007 Ok((id, blob))
1008 })?;
1009
1010 for row in rows {
1011 let (id, blob) = row?;
1012 let qv = match unpack_quantized(&blob, dims) {
1013 Ok(qv) => qv,
1014 Err(_) => continue,
1015 };
1016 let approx_vec: Vec<f32> = qv
1017 .data
1018 .iter()
1019 .map(|&q| (q as f32 - qv.zero_point as f32) * qv.scale)
1020 .collect();
1021 let approx_sim = cosine_similarity(query_embedding, &approx_vec).unwrap_or(0.0) as f64;
1022 candidates.push((format!("fact:{id}"), approx_sim));
1023 }
1024 }
1025
1026 candidates.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
1027 candidates.truncate(candidate_k);
1028
1029 if candidates.is_empty() {
1030 return brute_force_vector_outcome(
1031 conn,
1032 query_embedding,
1033 pool_size,
1034 min_similarity,
1035 namespaces,
1036 source_types,
1037 session_ids,
1038 );
1039 }
1040
1041 let mut hits = Vec::new();
1043 let fact_ids: Vec<&str> = candidates
1044 .iter()
1045 .filter_map(|(id, _)| id.strip_prefix("fact:"))
1046 .collect();
1047
1048 if !fact_ids.is_empty() {
1049 let placeholders = fact_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
1050 let sql = format!(
1051 "SELECT id, content, namespace, embedding FROM facts WHERE id IN ({placeholders})"
1052 );
1053 let mut stmt = conn.prepare(&sql)?;
1054 let rows = stmt.query_map(rusqlite::params_from_iter(fact_ids.iter()), |row| {
1055 let id: String = row.get(0)?;
1056 let content: String = row.get(1)?;
1057 let namespace: String = row.get(2)?;
1058 let blob: Vec<u8> = row.get(3)?;
1059 Ok((id, content, namespace, blob))
1060 })?;
1061 for row in rows {
1062 let (id, content, namespace, blob) = row?;
1063 let stored = match crate::db::decode_f32_le(&blob, dims) {
1064 Ok(v) => v,
1065 Err(_) => continue,
1066 };
1067 let sim = cosine_similarity(query_embedding, &stored)? as f64;
1068 if sim >= min_similarity {
1069 let source = SearchSource::Fact {
1070 fact_id: id.clone(),
1071 namespace,
1072 };
1073 hits.push(VectorHit {
1074 id: format!("fact:{id}"),
1075 content,
1076 source,
1077 similarity: sim,
1078 source_rank: None,
1079 source_similarity: None,
1080 reranked_from_f32: true,
1081 updated_at: None,
1082 temporal_weight: None,
1083 provenance_confidence: None,
1084 });
1085 }
1086 }
1087 }
1088
1089 let hits = rank_vector_hits(hits, pool_size);
1090
1091 Ok(VectorSearchOutcome {
1092 requested_candidates: pool_size,
1093 returned_candidates: hits.len(),
1094 post_filter_candidates: hits.len(),
1095 hits,
1096 candidate_backend: "q8_compressed_reranked".to_string(),
1097 fallback: None,
1098 exact_rerank: true,
1099 degradations: Vec::new(),
1100 receipt_metadata: VectorReceiptMetadata::default(),
1101 })
1102}
1103
1104#[allow(clippy::too_many_arguments)]
1105fn vector_search_with_backend(
1106 conn: &Connection,
1107 query_embedding: &[f32],
1108 pool_size: usize,
1109 min_similarity: f64,
1110 config: &SearchConfig,
1111 context: &SearchContext,
1112 namespaces: Option<&[&str]>,
1113 source_types: Option<&[SearchSourceType]>,
1114 session_ids: Option<&[&str]>,
1115) -> Result<VectorSearchOutcome, MemoryError> {
1116 if context.exactness_profile == crate::types::ExactnessProfile::PreferExact {
1117 return brute_force_vector_outcome(
1118 conn,
1119 query_embedding,
1120 pool_size,
1121 min_similarity,
1122 namespaces,
1123 source_types,
1124 session_ids,
1125 );
1126 }
1127
1128 if config.use_compressed_candidates {
1132 return compressed_candidate_vector_outcome(
1133 conn,
1134 query_embedding,
1135 pool_size,
1136 min_similarity,
1137 namespaces,
1138 source_types,
1139 session_ids,
1140 )
1141 .or_else(|e| {
1142 tracing::warn!(error = %e, "compressed candidate search failed, falling back to brute-force");
1143 brute_force_vector_outcome(
1144 conn,
1145 query_embedding,
1146 pool_size,
1147 min_similarity,
1148 namespaces,
1149 source_types,
1150 session_ids,
1151 )
1152 });
1153 }
1154
1155 match config.derived_vector_backend {
1156 DerivedVectorBackendPolicy::Disabled => brute_force_vector_outcome(
1157 conn,
1158 query_embedding,
1159 pool_size,
1160 min_similarity,
1161 namespaces,
1162 source_types,
1163 session_ids,
1164 ),
1165 DerivedVectorBackendPolicy::TurboQuantCandidateOnly => turbo_quant_vector_outcome(
1166 conn,
1167 query_embedding,
1168 pool_size,
1169 min_similarity,
1170 config,
1171 namespaces,
1172 source_types,
1173 session_ids,
1174 ),
1175 DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly => provekv_pool_vector_outcome(
1176 conn,
1177 query_embedding,
1178 pool_size,
1179 min_similarity,
1180 config,
1181 namespaces,
1182 source_types,
1183 session_ids,
1184 ),
1185 }
1186}
1187
1188#[allow(clippy::too_many_arguments)]
1189fn provekv_pool_vector_outcome(
1190 conn: &Connection,
1191 query_embedding: &[f32],
1192 pool_size: usize,
1193 min_similarity: f64,
1194 config: &SearchConfig,
1195 namespaces: Option<&[&str]>,
1196 source_types: Option<&[SearchSourceType]>,
1197 session_ids: Option<&[&str]>,
1198) -> Result<VectorSearchOutcome, MemoryError> {
1199 if !config.turbo_quant_require_exact_rerank {
1200 return Err(MemoryError::InvalidConfig {
1201 field: "search.turbo_quant_require_exact_rerank",
1202 reason: "proveKV pool candidate backend requires exact f32 rerank".to_string(),
1203 });
1204 }
1205
1206 let mut outcome = brute_force_vector_outcome(
1207 conn,
1208 query_embedding,
1209 pool_size,
1210 min_similarity,
1211 namespaces,
1212 source_types,
1213 session_ids,
1214 )?;
1215 outcome.candidate_backend = "provekv_pool_candidate_then_exact_f32".to_string();
1216 outcome.receipt_metadata.codec_family = Some("provekv_pool".to_string());
1217 match crate::db::latest_ready_provekv_pool_generation(conn)? {
1218 Some(row) => {
1219 let item_map =
1220 crate::db::load_provekv_pool_item_map(conn, &row.generation.generation_id)?;
1221 let _payload =
1222 crate::db::load_provekv_pool_payload(conn, &row.generation.generation_id)?;
1223 outcome.receipt_metadata.artifact_generation_id = Some(row.generation.generation_id);
1224 outcome.receipt_metadata.vector_artifact_manifest_digest =
1225 Some(row.generation.pool_manifest_digest);
1226 outcome.receipt_metadata.vector_artifact_count = Some(item_map.len());
1227 outcome.degradations.push(
1228 "proveKV pool generation materialized for candidate provenance; authoritative f32 exact rerank remains final"
1229 .to_string(),
1230 );
1231 }
1232 None => {
1233 outcome.fallback = Some("provekv_pool_generation_not_materialized".to_string());
1234 outcome.degradations.push(
1235 "proveKV pool backend requested; using authoritative f32 exact path until a pool generation is materialized"
1236 .to_string(),
1237 );
1238 }
1239 }
1240 Ok(outcome)
1241}
1242
1243#[cfg(not(feature = "turbo-quant-codec"))]
1244#[allow(clippy::too_many_arguments)]
1245fn turbo_quant_vector_outcome(
1246 conn: &Connection,
1247 query_embedding: &[f32],
1248 pool_size: usize,
1249 min_similarity: f64,
1250 _config: &SearchConfig,
1251 namespaces: Option<&[&str]>,
1252 source_types: Option<&[SearchSourceType]>,
1253 session_ids: Option<&[&str]>,
1254) -> Result<VectorSearchOutcome, MemoryError> {
1255 let mut outcome = brute_force_vector_outcome(
1256 conn,
1257 query_embedding,
1258 pool_size,
1259 min_similarity,
1260 namespaces,
1261 source_types,
1262 session_ids,
1263 )?;
1264 outcome.candidate_backend = "turbo_quant_candidate_then_exact_f32".to_string();
1265 outcome.fallback = Some("turbo_quant_feature_disabled".to_string());
1266 outcome
1267 .degradations
1268 .push("TurboQuant backend requested without turbo-quant-codec feature".to_string());
1269 Ok(outcome)
1270}
1271
1272#[cfg(feature = "turbo-quant-codec")]
1273#[allow(clippy::too_many_arguments)]
1274fn turbo_quant_vector_outcome(
1275 conn: &Connection,
1276 query_embedding: &[f32],
1277 pool_size: usize,
1278 min_similarity: f64,
1279 config: &SearchConfig,
1280 namespaces: Option<&[&str]>,
1281 source_types: Option<&[SearchSourceType]>,
1282 session_ids: Option<&[&str]>,
1283) -> Result<VectorSearchOutcome, MemoryError> {
1284 use crate::vector_codec::{TurboQuantCodec, VectorArtifactV1, VectorCodec};
1285
1286 if !config.turbo_quant_require_exact_rerank {
1287 return Err(MemoryError::InvalidConfig {
1288 field: "search.turbo_quant_require_exact_rerank",
1289 reason: "TurboQuant candidate backend requires exact f32 rerank".to_string(),
1290 });
1291 }
1292
1293 let dim = query_embedding.len();
1294 let codec = TurboQuantCodec::new(
1295 dim,
1296 config.turbo_quant_bits,
1297 config.turbo_quant_projections,
1298 config.turbo_quant_seed,
1299 )?;
1300 let profile = codec.profile().clone();
1301 let profile_digest = profile.digest();
1302 let mut metadata = VectorReceiptMetadata {
1303 codec_family: Some("turbo_quant".to_string()),
1304 codec_profile_digest: Some(profile_digest.clone()),
1305 ..VectorReceiptMetadata::default()
1306 };
1307
1308 let filtered = namespaces.is_some_and(|values| !values.is_empty())
1309 || source_types.is_some_and(|values| !values.is_empty())
1310 || session_ids.is_some_and(|values| !values.is_empty());
1311 metadata.filter_strategy = Some(if filtered {
1312 "adaptive_oversampling_after_approximate_scoring".to_string()
1313 } else {
1314 "unfiltered_top_k_heap".to_string()
1315 });
1316
1317 let raw_count = authoritative_vector_row_count(conn)?;
1318 let (current_source_snapshot_digest, current_source_row_count) =
1319 crate::db::current_source_snapshot_digest(conn, dim)?;
1320 let Some(generation) =
1321 crate::db::current_derived_vector_generation(conn, "turbo_quant", &profile_digest)?
1322 else {
1323 metadata.artifact_missing_count = Some(raw_count);
1324 metadata.vector_artifact_missing_count = Some(raw_count);
1325 let mut outcome = brute_force_vector_outcome(
1326 conn,
1327 query_embedding,
1328 pool_size,
1329 min_similarity,
1330 namespaces,
1331 source_types,
1332 session_ids,
1333 )?;
1334 outcome.candidate_backend = "turbo_quant_candidate_then_exact_f32".to_string();
1335 outcome.fallback = Some("turbo_quant_generation_missing_or_invalidated".to_string());
1336 outcome.degradations.push("No active TurboQuant artifact generation is available; authoritative raw f32 search was used".to_string());
1337 outcome.receipt_metadata = metadata;
1338 return Ok(outcome);
1339 };
1340
1341 metadata.artifact_generation_id = Some(generation.generation_id.clone());
1342 metadata.vector_artifact_manifest_digest = Some(generation.artifact_manifest_digest.clone());
1343 metadata.artifact_count = Some(generation.artifact_count);
1344
1345 let artifacts =
1346 crate::db::load_derived_vector_artifacts_by_generation(conn, &generation.generation_id)?;
1347 metadata.vector_artifact_count = Some(artifacts.len());
1348
1349 if generation.dim != dim
1350 || generation.encoding != "turbo_code_wire_v1"
1351 || generation.status != "active"
1352 || generation.source_row_count != raw_count
1353 || generation.source_row_count != current_source_row_count
1354 || generation.source_snapshot_digest != current_source_snapshot_digest
1355 || generation.artifact_count != artifacts.len()
1356 {
1357 let missing = raw_count.saturating_sub(artifacts.len());
1358 metadata.artifact_missing_count = Some(missing);
1359 metadata.vector_artifact_missing_count = Some(missing);
1360 let mut outcome = brute_force_vector_outcome(
1361 conn,
1362 query_embedding,
1363 pool_size,
1364 min_similarity,
1365 namespaces,
1366 source_types,
1367 session_ids,
1368 )?;
1369 outcome.candidate_backend = "turbo_quant_candidate_then_exact_f32".to_string();
1370 outcome.fallback = Some("turbo_quant_generation_incomplete_or_stale".to_string());
1371 outcome.degradations.push(format!(
1372 "TurboQuant generation validation failed: generation={}, status={}, dim={}, source_rows={}, artifacts={}, authoritative_rows={}, snapshot_current={}",
1373 generation.generation_id,
1374 generation.status,
1375 generation.dim,
1376 generation.source_row_count,
1377 artifacts.len(),
1378 raw_count,
1379 generation.source_snapshot_digest == current_source_snapshot_digest
1380 ));
1381 outcome.receipt_metadata = metadata;
1382 return Ok(outcome);
1383 }
1384
1385 let prepared = codec.prepare_query(query_embedding)?;
1386 let candidate_cap = if filtered {
1387 artifacts
1388 .len()
1389 .min(pool_size.saturating_mul(16).max(pool_size))
1390 } else {
1391 pool_size.min(artifacts.len())
1392 };
1393 let mut scored = BinaryHeap::with_capacity(candidate_cap.saturating_add(1));
1394 let mut corrupt_count = 0usize;
1395 let mut scanned_count = 0usize;
1396 for (seq, artifact_row) in artifacts.into_iter().enumerate() {
1397 scanned_count += 1;
1398 if artifact_row.encoding != "turbo_code_wire_v1"
1399 || artifact_row.dim != dim
1400 || artifact_row.status != "active"
1401 {
1402 corrupt_count += 1;
1403 continue;
1404 }
1405 let artifact = VectorArtifactV1::new(profile.clone(), artifact_row.encoded);
1406 if artifact.profile_digest != artifact_row.codec_profile_digest
1407 || artifact.artifact_digest != artifact_row.encoded_digest
1408 {
1409 corrupt_count += 1;
1410 continue;
1411 }
1412 let approx = match codec.score_inner_product_prepared(&artifact, &prepared) {
1413 Ok(score) if score.is_finite() => score as f64,
1414 Ok(_) => {
1415 corrupt_count += 1;
1416 continue;
1417 }
1418 Err(err) => {
1419 tracing::warn!(
1420 error = %err,
1421 item = %artifact_row.item_key,
1422 "corrupt TurboQuant artifact encountered; falling back to raw f32"
1423 );
1424 corrupt_count += 1;
1425 continue;
1426 }
1427 };
1428 if candidate_cap == 0 {
1429 continue;
1430 }
1431 let candidate = ApproxCandidate {
1432 score: approx,
1433 seq,
1434 item_key: artifact_row.item_key,
1435 };
1436 if scored.len() < candidate_cap {
1437 scored.push(candidate);
1438 } else if scored
1439 .peek()
1440 .is_some_and(|worst: &ApproxCandidate| candidate.score > worst.score)
1441 {
1442 scored.pop();
1443 scored.push(candidate);
1444 }
1445 }
1446
1447 metadata.artifact_corruption_count = Some(corrupt_count);
1448 metadata.approximate_scanned_count = Some(scanned_count);
1449 if corrupt_count > 0 {
1450 let mut outcome = brute_force_vector_outcome(
1451 conn,
1452 query_embedding,
1453 pool_size,
1454 min_similarity,
1455 namespaces,
1456 source_types,
1457 session_ids,
1458 )?;
1459 outcome.candidate_backend = "turbo_quant_candidate_then_exact_f32".to_string();
1460 outcome.fallback = Some("turbo_quant_artifact_validation_failed".to_string());
1461 outcome.degradations.push(format!(
1462 "TurboQuant artifact validation failed: {corrupt_count} corrupt artifacts in generation {}",
1463 generation.generation_id
1464 ));
1465 outcome.receipt_metadata = metadata;
1466 return Ok(outcome);
1467 }
1468
1469 let mut scored = scored.into_vec();
1470 scored.sort_by(|a, b| {
1471 b.score
1472 .partial_cmp(&a.score)
1473 .unwrap_or(std::cmp::Ordering::Equal)
1474 .then_with(|| a.seq.cmp(&b.seq))
1475 });
1476 let approximate_returned = scored.len();
1477 metadata.approximate_candidate_count = Some(approximate_returned);
1478 metadata.approximate_returned_count = Some(approximate_returned);
1479 let mut exact_hits = Vec::new();
1480 let mut raw_rows_loaded_count = 0usize;
1481 let mut missing_count = 0usize;
1482 for (approx_rank_0, candidate) in scored.into_iter().enumerate() {
1483 let Some(row) = load_vector_row_by_item_key(conn, &candidate.item_key)? else {
1484 missing_count += 1;
1485 continue;
1486 };
1487 raw_rows_loaded_count += 1;
1488 if !vector_row_matches_filters(&row, namespaces, source_types, session_ids) {
1489 continue;
1490 }
1491 let stored_embedding = crate::db::decode_f32_le(&row.blob, dim)?;
1492 let similarity = cosine_similarity(query_embedding, &stored_embedding)? as f64;
1493 if similarity >= min_similarity {
1494 exact_hits.push(VectorHit {
1495 id: row.id,
1496 content: row.content,
1497 source: row.source,
1498 similarity,
1499 updated_at: row.updated_at,
1500 source_rank: Some(approx_rank_0 + 1),
1501 source_similarity: Some(candidate.score),
1502 reranked_from_f32: true,
1503 temporal_weight: None,
1504 provenance_confidence: None,
1505 });
1506 }
1507 }
1508 let post_filter_candidates = exact_hits.len();
1509 metadata.artifact_missing_count = Some(missing_count);
1510 metadata.vector_artifact_missing_count = Some(missing_count);
1511 metadata.vector_artifact_stale_count = Some(0);
1512 metadata.raw_rows_loaded_count = Some(raw_rows_loaded_count);
1513 metadata.exact_rerank_count = Some(raw_rows_loaded_count);
1514 let mut degradations = Vec::new();
1515 if filtered && post_filter_candidates < pool_size && candidate_cap < scanned_count {
1516 degradations.push(format!(
1517 "TurboQuant filter-aware candidate generation under-returned {post_filter_candidates} candidates for requested pool {pool_size} after scanning {scanned_count} artifacts with candidate budget {candidate_cap}"
1518 ));
1519 }
1520 if missing_count > 0 {
1521 degradations.push(format!(
1522 "TurboQuant exact rerank skipped {missing_count} candidates whose authoritative rows were missing"
1523 ));
1524 }
1525 let hits = rank_vector_hits(exact_hits, pool_size);
1526 Ok(VectorSearchOutcome {
1527 hits,
1528 candidate_backend: "turbo_quant_candidate_then_exact_f32".to_string(),
1529 requested_candidates: pool_size,
1530 returned_candidates: approximate_returned,
1531 post_filter_candidates,
1532 fallback: None,
1533 exact_rerank: true,
1534 degradations,
1535 receipt_metadata: metadata,
1536 })
1537}
1538
1539#[cfg(feature = "turbo-quant-codec")]
1540#[derive(Debug, Clone)]
1541struct ApproxCandidate {
1542 score: f64,
1543 seq: usize,
1544 item_key: String,
1545}
1546
1547#[cfg(feature = "turbo-quant-codec")]
1548impl PartialEq for ApproxCandidate {
1549 fn eq(&self, other: &Self) -> bool {
1550 self.score == other.score && self.seq == other.seq
1551 }
1552}
1553
1554#[cfg(feature = "turbo-quant-codec")]
1555impl Eq for ApproxCandidate {}
1556
1557#[cfg(feature = "turbo-quant-codec")]
1558impl PartialOrd for ApproxCandidate {
1559 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
1560 Some(self.cmp(other))
1561 }
1562}
1563
1564#[cfg(feature = "turbo-quant-codec")]
1565impl Ord for ApproxCandidate {
1566 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
1567 other
1568 .score
1569 .partial_cmp(&self.score)
1570 .unwrap_or(std::cmp::Ordering::Equal)
1571 .then_with(|| other.seq.cmp(&self.seq))
1572 }
1573}
1574
1575fn vector_row_matches_filters(
1576 row: &VectorRow,
1577 namespaces: Option<&[&str]>,
1578 source_types: Option<&[SearchSourceType]>,
1579 session_ids: Option<&[&str]>,
1580) -> bool {
1581 if source_types.is_some_and(|values| !values.contains(&row.source_type)) {
1582 return false;
1583 }
1584 if let Some(namespaces) = namespaces.filter(|values| !values.is_empty()) {
1585 let Some(namespace) = row.filter_namespace.as_deref() else {
1586 return false;
1587 };
1588 if !namespaces.contains(&namespace) {
1589 return false;
1590 }
1591 }
1592 if let Some(session_ids) = session_ids.filter(|values| !values.is_empty()) {
1593 let Some(session_id) = row.filter_session_id.as_deref() else {
1594 return false;
1595 };
1596 if !session_ids.contains(&session_id) {
1597 return false;
1598 }
1599 }
1600 true
1601}
1602
1603#[cfg(feature = "turbo-quant-codec")]
1604fn authoritative_vector_row_count(conn: &Connection) -> Result<usize, MemoryError> {
1605 let count: i64 = conn.query_row(
1606 "SELECT
1607 (SELECT COUNT(*) FROM facts WHERE embedding IS NOT NULL) +
1608 (SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL) +
1609 (SELECT COUNT(*) FROM messages WHERE embedding IS NOT NULL) +
1610 (SELECT COUNT(*) FROM episodes WHERE embedding IS NOT NULL)",
1611 [],
1612 |row| row.get(0),
1613 )?;
1614 usize::try_from(count)
1615 .map_err(|err| MemoryError::Other(format!("authoritative vector count overflow: {err}")))
1616}
1617
1618fn load_vector_row_by_item_key(
1619 conn: &Connection,
1620 item_key: &str,
1621) -> Result<Option<VectorRow>, MemoryError> {
1622 let Some((domain, id)) = item_key.split_once(':') else {
1623 return Ok(None);
1624 };
1625 match domain {
1626 "fact" => conn
1627 .query_row(
1628 "SELECT id, content, namespace, embedding, updated_at
1629 FROM facts WHERE id = ?1 AND embedding IS NOT NULL",
1630 [id],
1631 |row| {
1632 let fact_id: String = row.get(0)?;
1633 let content: String = row.get(1)?;
1634 let namespace: String = row.get(2)?;
1635 let blob: Vec<u8> = row.get(3)?;
1636 let updated_at: Option<String> = row.get(4)?;
1637 Ok(VectorRow {
1638 id: format!("fact:{fact_id}"),
1639 content,
1640 blob,
1641 updated_at,
1642 source_type: SearchSourceType::Facts,
1643 filter_namespace: Some(namespace.clone()),
1644 filter_session_id: None,
1645 source: SearchSource::Fact { fact_id, namespace },
1646 })
1647 },
1648 )
1649 .optional()
1650 .map_err(MemoryError::from),
1651 "chunk" => conn
1652 .query_row(
1653 "SELECT c.id, c.content, c.document_id, d.title, c.chunk_index, c.embedding, c.created_at, d.namespace
1654 FROM chunks c
1655 JOIN documents d ON d.id = c.document_id
1656 WHERE c.id = ?1 AND c.embedding IS NOT NULL",
1657 [id],
1658 |row| {
1659 let chunk_id: String = row.get(0)?;
1660 let content: String = row.get(1)?;
1661 let document_id: String = row.get(2)?;
1662 let document_title: String = row.get(3)?;
1663 let chunk_index: i64 = row.get(4)?;
1664 let blob: Vec<u8> = row.get(5)?;
1665 let updated_at: Option<String> = row.get(6)?;
1666 let namespace: String = row.get(7)?;
1667 Ok(VectorRow {
1668 id: format!("chunk:{chunk_id}"),
1669 content,
1670 blob,
1671 updated_at,
1672 source_type: SearchSourceType::Chunks,
1673 filter_namespace: Some(namespace),
1674 filter_session_id: None,
1675 source: SearchSource::Chunk {
1676 chunk_id,
1677 document_id,
1678 document_title,
1679 chunk_index: chunk_index as usize,
1680 },
1681 })
1682 },
1683 )
1684 .optional()
1685 .map_err(MemoryError::from),
1686 "msg" => {
1687 let Ok(message_id) = id.parse::<i64>() else {
1688 return Ok(None);
1689 };
1690 conn.query_row(
1691 "SELECT id, content, session_id, role, embedding, created_at
1692 FROM messages WHERE id = ?1 AND embedding IS NOT NULL",
1693 [message_id],
1694 |row| {
1695 let message_id: i64 = row.get(0)?;
1696 let content: String = row.get(1)?;
1697 let session_id: String = row.get(2)?;
1698 let role: String = row.get(3)?;
1699 let blob: Vec<u8> = row.get(4)?;
1700 let updated_at: Option<String> = row.get(5)?;
1701 Ok(VectorRow {
1702 id: format!("msg:{message_id}"),
1703 content,
1704 blob,
1705 updated_at,
1706 source_type: SearchSourceType::Messages,
1707 filter_namespace: None,
1708 filter_session_id: Some(session_id.clone()),
1709 source: SearchSource::Message {
1710 message_id,
1711 session_id,
1712 role,
1713 },
1714 })
1715 },
1716 )
1717 .optional()
1718 .map_err(MemoryError::from)
1719 }
1720 "episode" => conn
1721 .query_row(
1722 "SELECT e.episode_id, e.document_id, e.search_text, e.effect_type, e.outcome, e.embedding, e.updated_at, d.namespace
1723 FROM episodes e
1724 JOIN documents d ON d.id = e.document_id
1725 WHERE e.episode_id = ?1 AND e.embedding IS NOT NULL",
1726 [id],
1727 |row| {
1728 let episode_id: String = row.get(0)?;
1729 let document_id: String = row.get(1)?;
1730 let content: String = row.get(2)?;
1731 let effect_type: String = row.get(3)?;
1732 let outcome: String = row.get(4)?;
1733 let blob: Vec<u8> = row.get(5)?;
1734 let updated_at: Option<String> = row.get(6)?;
1735 let namespace: String = row.get(7)?;
1736 Ok(VectorRow {
1737 id: episodes::episode_item_key(&episode_id),
1738 content,
1739 blob,
1740 updated_at,
1741 source_type: SearchSourceType::Episodes,
1742 filter_namespace: Some(namespace),
1743 filter_session_id: None,
1744 source: SearchSource::Episode {
1745 episode_id,
1746 document_id,
1747 effect_type,
1748 outcome,
1749 },
1750 })
1751 },
1752 )
1753 .optional()
1754 .map_err(MemoryError::from),
1755 _ => Ok(None),
1756 }
1757}
1758
1759#[allow(clippy::too_many_arguments)]
1760fn sparse_search(
1761 conn: &Connection,
1762 query: &crate::SparseWeights,
1763 config: &SearchConfig,
1764 namespaces: Option<&[&str]>,
1765 source_types: Option<&[SearchSourceType]>,
1766 session_ids: Option<&[&str]>,
1767) -> Result<Vec<SparseHit>, MemoryError> {
1768 if config.sparse_weight == 0.0 || query.is_empty() {
1769 return Ok(Vec::new());
1770 }
1771 let scan_limit = config
1774 .sparse_top_k
1775 .saturating_mul(8)
1776 .max(config.sparse_top_k);
1777 let rows = crate::db::search_sparse_vectors(conn, query, scan_limit, config.sparse_min_score)?;
1778 let mut hits = Vec::with_capacity(config.sparse_top_k.min(rows.len()));
1779 for (sparse_row, sql_score) in rows {
1780 let Some(source_row) = load_vector_row_by_item_key(conn, &sparse_row.item_key)? else {
1781 continue;
1782 };
1783 if !vector_row_matches_filters(&source_row, namespaces, source_types, session_ids) {
1784 continue;
1785 }
1786 let score = f64::from(sparse_row.weights.dot(query));
1787 if !score.is_finite() || score < config.sparse_min_score {
1788 continue;
1789 }
1790 debug_assert!((score - sql_score).abs() < 1e-4);
1791 hits.push(SparseHit {
1792 content: source_row.content,
1793 source: source_row.source,
1794 score,
1795 updated_at: source_row.updated_at,
1796 representation: sparse_row.representation,
1797 });
1798 if hits.len() == config.sparse_top_k {
1799 break;
1800 }
1801 }
1802 Ok(hits)
1803}
1804
1805fn vector_scan_warn_exceeded(count: usize) -> bool {
1806 let limit = VECTOR_SCAN_WARN_LIMIT.load(Ordering::Relaxed);
1807 limit > 0 && count > limit
1808}
1809
1810#[derive(Debug, Clone)]
1811pub(crate) struct SearchExecution {
1812 pub results: Vec<ExplainedResult>,
1813 pub receipt: Option<VectorSearchReceiptV1>,
1814}
1815
1816#[derive(Debug, Clone, Default)]
1817struct VectorReceiptMetadata {
1818 codec_family: Option<String>,
1819 codec_profile_digest: Option<String>,
1820 artifact_count: Option<usize>,
1821 artifact_corruption_count: Option<usize>,
1822 artifact_missing_count: Option<usize>,
1823 vector_artifact_manifest_digest: Option<String>,
1824 artifact_generation_id: Option<String>,
1825 approximate_scanned_count: Option<usize>,
1826 approximate_returned_count: Option<usize>,
1827 raw_rows_loaded_count: Option<usize>,
1828 filter_strategy: Option<String>,
1829 vector_artifact_count: Option<usize>,
1830 vector_artifact_missing_count: Option<usize>,
1831 vector_artifact_stale_count: Option<usize>,
1832 exact_rerank_count: Option<usize>,
1833 approximate_candidate_count: Option<usize>,
1834 sparse_weight: Option<f64>,
1835 sparse_query_nonzero_count: Option<usize>,
1836 sparse_candidate_count: Option<usize>,
1837 sparse_representations: Vec<String>,
1838}
1839
1840#[derive(Debug, Clone)]
1841struct VectorSearchOutcome {
1842 hits: Vec<VectorHit>,
1843 candidate_backend: String,
1844 requested_candidates: usize,
1845 returned_candidates: usize,
1846 post_filter_candidates: usize,
1847 fallback: Option<String>,
1848 exact_rerank: bool,
1849 degradations: Vec<String>,
1850 receipt_metadata: VectorReceiptMetadata,
1851}
1852
1853fn rrf_fuse_three_detailed_with_context(
1854 bm25_hits: &[Bm25Hit],
1855 vector_hits: &[VectorHit],
1856 sparse_hits: &[SparseHit],
1857 config: &SearchConfig,
1858 context: &SearchContext,
1859 top_k: usize,
1860) -> Vec<ExplainedResult> {
1861 let mut candidates: HashMap<(u8, String), RrfCandidate> = HashMap::new();
1863
1864 for (rank_0, hit) in bm25_hits.iter().enumerate() {
1865 let key = source_dedup_key(&hit.source);
1866 let rank = rank_0 + 1;
1867 candidates
1868 .entry(key)
1869 .and_modify(|candidate| {
1870 candidate.bm25_rank = Some(rank);
1871 candidate.bm25_score = Some(hit.raw_score);
1872 if candidate.updated_at.is_none() {
1873 candidate.updated_at = hit.updated_at.clone();
1874 }
1875 })
1876 .or_insert_with(|| RrfCandidate {
1877 content: hit.content.clone(),
1878 source: hit.source.clone(),
1879 updated_at: hit.updated_at.clone(),
1880 bm25_score: Some(hit.raw_score),
1881 bm25_rank: Some(rank),
1882 vector_score: None,
1883 vector_rank: None,
1884 vector_source_rank: None,
1885 vector_source_score: None,
1886 vector_reranked_from_f32: false,
1887 sparse_score: None,
1888 sparse_rank: None,
1889 late_interaction_rank: None,
1890 late_interaction_score: None,
1891 temporal_weight: hit.temporal_weight,
1892 provenance_confidence: None,
1893 });
1894 }
1895
1896 for (rank_0, hit) in vector_hits.iter().enumerate() {
1897 let key = source_dedup_key(&hit.source);
1898 let rank = rank_0 + 1;
1899 candidates
1900 .entry(key)
1901 .and_modify(|candidate| {
1902 candidate.vector_rank = Some(rank);
1903 candidate.vector_score = Some(hit.similarity);
1904 candidate.vector_source_rank = hit.source_rank.or(Some(rank));
1905 candidate.vector_source_score = hit.source_similarity.or(Some(hit.similarity));
1906 candidate.vector_reranked_from_f32 = hit.reranked_from_f32;
1907 if candidate.updated_at.is_none() {
1908 candidate.updated_at = hit.updated_at.clone();
1909 }
1910 })
1911 .or_insert_with(|| RrfCandidate {
1912 content: hit.content.clone(),
1913 source: hit.source.clone(),
1914 updated_at: hit.updated_at.clone(),
1915 bm25_score: None,
1916 bm25_rank: None,
1917 vector_score: Some(hit.similarity),
1918 vector_rank: Some(rank),
1919 vector_source_rank: hit.source_rank.or(Some(rank)),
1920 vector_source_score: hit.source_similarity.or(Some(hit.similarity)),
1921 vector_reranked_from_f32: hit.reranked_from_f32,
1922 sparse_score: None,
1923 sparse_rank: None,
1924 late_interaction_rank: None,
1925 late_interaction_score: None,
1926 temporal_weight: None,
1927 provenance_confidence: None,
1928 });
1929 }
1930
1931 for (rank_0, hit) in sparse_hits.iter().enumerate() {
1932 let key = source_dedup_key(&hit.source);
1933 let rank = rank_0 + 1;
1934 candidates
1935 .entry(key)
1936 .and_modify(|candidate| {
1937 candidate.sparse_rank = Some(rank);
1938 candidate.sparse_score = Some(hit.score);
1939 if candidate.updated_at.is_none() {
1940 candidate.updated_at = hit.updated_at.clone();
1941 }
1942 })
1943 .or_insert_with(|| RrfCandidate {
1944 content: hit.content.clone(),
1945 source: hit.source.clone(),
1946 updated_at: hit.updated_at.clone(),
1947 bm25_score: None,
1948 bm25_rank: None,
1949 vector_score: None,
1950 vector_rank: None,
1951 vector_source_rank: None,
1952 vector_source_score: None,
1953 vector_reranked_from_f32: false,
1954 sparse_score: Some(hit.score),
1955 sparse_rank: Some(rank),
1956 late_interaction_rank: None,
1957 late_interaction_score: None,
1958 temporal_weight: None,
1959 provenance_confidence: None,
1960 });
1961 }
1962
1963 let mut explained: Vec<ExplainedResult> = candidates
1964 .into_values()
1965 .map(|candidate| candidate.explained(config, context))
1966 .collect();
1967
1968 explained.sort_by(|a, b| {
1969 b.result
1970 .score
1971 .partial_cmp(&a.result.score)
1972 .unwrap_or(std::cmp::Ordering::Equal)
1973 .then_with(|| {
1974 source_dedup_key(&a.result.source).cmp(&source_dedup_key(&b.result.source))
1975 })
1976 });
1977 explained.truncate(top_k);
1978 explained
1979}
1980
1981fn rrf_fuse_detailed_with_context(
1982 bm25_hits: &[Bm25Hit],
1983 vector_hits: &[VectorHit],
1984 config: &SearchConfig,
1985 context: &SearchContext,
1986 top_k: usize,
1987) -> Vec<ExplainedResult> {
1988 rrf_fuse_three_detailed_with_context(bm25_hits, vector_hits, &[], config, context, top_k)
1989}
1990
1991fn rrf_fuse_detailed(
1992 bm25_hits: &[Bm25Hit],
1993 vector_hits: &[VectorHit],
1994 config: &SearchConfig,
1995 top_k: usize,
1996) -> Vec<ExplainedResult> {
1997 let context = SearchContext::default_now();
1998 rrf_fuse_detailed_with_context(bm25_hits, vector_hits, config, &context, top_k)
1999}
2000
2001pub fn rrf_fuse_with_context(
2002 bm25_hits: &[Bm25Hit],
2003 vector_hits: &[VectorHit],
2004 config: &SearchConfig,
2005 context: &SearchContext,
2006 top_k: usize,
2007) -> Vec<SearchResult> {
2008 rrf_fuse_detailed_with_context(bm25_hits, vector_hits, config, context, top_k)
2009 .into_iter()
2010 .map(|result| result.result)
2011 .collect()
2012}
2013
2014pub fn rrf_fuse(
2016 bm25_hits: &[Bm25Hit],
2017 vector_hits: &[VectorHit],
2018 config: &SearchConfig,
2019 top_k: usize,
2020) -> Vec<SearchResult> {
2021 rrf_fuse_detailed(bm25_hits, vector_hits, config, top_k)
2022 .into_iter()
2023 .map(|result| result.result)
2024 .collect()
2025}
2026
2027#[cfg(feature = "late-interaction")]
2034pub fn rrf_fuse_with_late_interaction(
2035 bm25_hits: &[Bm25Hit],
2036 vector_hits: &[VectorHit],
2037 late_interaction_scores: &[(String, f64)],
2038 config: &SearchConfig,
2039 context: &SearchContext,
2040 top_k: usize,
2041) -> Vec<ExplainedResult> {
2042 let mut candidates: HashMap<(u8, String), RrfCandidate> = HashMap::new();
2043
2044 for (rank_0, hit) in bm25_hits.iter().enumerate() {
2046 let key = source_dedup_key(&hit.source);
2047 let rank = rank_0 + 1;
2048 candidates
2049 .entry(key)
2050 .and_modify(|c| {
2051 c.bm25_rank = Some(rank);
2052 c.bm25_score = Some(hit.raw_score);
2053 if c.updated_at.is_none() {
2054 c.updated_at = hit.updated_at.clone();
2055 }
2056 })
2057 .or_insert_with(|| RrfCandidate {
2058 content: hit.content.clone(),
2059 source: hit.source.clone(),
2060 updated_at: hit.updated_at.clone(),
2061 bm25_score: Some(hit.raw_score),
2062 bm25_rank: Some(rank),
2063 vector_score: None,
2064 vector_rank: None,
2065 vector_source_rank: None,
2066 vector_source_score: None,
2067 vector_reranked_from_f32: false,
2068 sparse_score: None,
2069 sparse_rank: None,
2070 late_interaction_rank: None,
2071 late_interaction_score: None,
2072 temporal_weight: hit.temporal_weight,
2073 provenance_confidence: None,
2074 });
2075 }
2076
2077 for (rank_0, hit) in vector_hits.iter().enumerate() {
2079 let key = source_dedup_key(&hit.source);
2080 let rank = rank_0 + 1;
2081 candidates
2082 .entry(key)
2083 .and_modify(|c| {
2084 c.vector_rank = Some(rank);
2085 c.vector_score = Some(hit.similarity);
2086 c.vector_source_rank = hit.source_rank.or(Some(rank));
2087 c.vector_source_score = hit.source_similarity.or(Some(hit.similarity));
2088 c.vector_reranked_from_f32 = hit.reranked_from_f32;
2089 if c.updated_at.is_none() {
2090 c.updated_at = hit.updated_at.clone();
2091 }
2092 })
2093 .or_insert_with(|| RrfCandidate {
2094 content: hit.content.clone(),
2095 source: hit.source.clone(),
2096 updated_at: hit.updated_at.clone(),
2097 bm25_score: None,
2098 bm25_rank: None,
2099 vector_score: Some(hit.similarity),
2100 vector_rank: Some(rank),
2101 vector_source_rank: hit.source_rank.or(Some(rank)),
2102 vector_source_score: hit.source_similarity.or(Some(hit.similarity)),
2103 vector_reranked_from_f32: hit.reranked_from_f32,
2104 sparse_score: None,
2105 sparse_rank: None,
2106 late_interaction_rank: None,
2107 late_interaction_score: None,
2108 temporal_weight: None,
2109 provenance_confidence: None,
2110 });
2111 }
2112
2113 let mut li_sorted: Vec<&(String, f64)> = late_interaction_scores.iter().collect();
2116 li_sorted.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
2117 for (rank_0, (item_key, score)) in li_sorted.iter().enumerate() {
2118 let rank = rank_0 + 1;
2119 let matched = candidates.iter_mut().find(|(_, c)| {
2123 c.content.contains(item_key.as_str())
2124 || format!("{:?}", c.source).contains(item_key.as_str())
2125 });
2126 if let Some((_, c)) = matched {
2127 c.late_interaction_rank = Some(rank);
2128 c.late_interaction_score = Some(*score);
2129 }
2130 }
2134
2135 let mut explained: Vec<ExplainedResult> = candidates
2136 .into_values()
2137 .map(|c| c.explained(config, context))
2138 .collect();
2139
2140 explained.sort_by(|a, b| {
2141 b.result
2142 .score
2143 .partial_cmp(&a.result.score)
2144 .unwrap_or(std::cmp::Ordering::Equal)
2145 .then_with(|| {
2146 source_dedup_key(&a.result.source).cmp(&source_dedup_key(&b.result.source))
2147 })
2148 });
2149 explained.truncate(top_k);
2150 explained
2151}
2152
2153fn compute_proxy_late_interaction_scores(
2163 query_embedding: &[f32],
2164 vector_hits: &[VectorHit],
2165) -> Vec<(String, f64)> {
2166 let segment_size = 64;
2167 let query_segments: Vec<&[f32]> = query_embedding.chunks(segment_size).collect();
2168
2169 vector_hits
2170 .iter()
2171 .map(|hit| {
2172 let segment_factor = if !query_segments.is_empty() {
2173 1.0 + (query_segments.len() as f64 - 1.0) * 0.01
2174 } else {
2175 1.0
2176 };
2177 let proxy_score = hit.similarity * segment_factor;
2178 let key = format!("{:?}", hit.source);
2179 (key, proxy_score)
2180 })
2181 .collect()
2182}
2183
2184pub(crate) fn query_embedding_digest(query_embedding: &[f32]) -> String {
2185 let mut builder = DigestBuilder::new();
2186 builder
2187 .update_str("semantic-memory.query_embedding.v1")
2188 .separator()
2189 .update(&(query_embedding.len() as u64).to_le_bytes())
2190 .separator();
2191 for value in query_embedding {
2192 builder.update(&value.to_le_bytes());
2193 }
2194 format!("blake3:{}", builder.finalize().hex())
2195}
2196
2197#[cfg_attr(not(feature = "hnsw"), allow(dead_code))]
2198#[allow(clippy::too_many_arguments)]
2199fn build_receipt(
2200 context: &SearchContext,
2201 query_embedding: &[f32],
2202 search_profile: &str,
2203 candidate_backend: &str,
2204 requested_candidates: usize,
2205 returned_candidates: usize,
2206 post_filter_candidates: usize,
2207 fallback: Option<String>,
2208 exact_rerank: bool,
2209 results: &[ExplainedResult],
2210 degradations: Vec<String>,
2211) -> Option<VectorSearchReceiptV1> {
2212 build_receipt_with_metadata(
2213 context,
2214 query_embedding,
2215 search_profile,
2216 candidate_backend,
2217 requested_candidates,
2218 returned_candidates,
2219 post_filter_candidates,
2220 fallback,
2221 exact_rerank,
2222 results,
2223 degradations,
2224 VectorReceiptMetadata::default(),
2225 )
2226}
2227
2228#[allow(clippy::too_many_arguments)]
2229fn build_receipt_with_metadata(
2230 context: &SearchContext,
2231 query_embedding: &[f32],
2232 search_profile: &str,
2233 candidate_backend: &str,
2234 requested_candidates: usize,
2235 returned_candidates: usize,
2236 post_filter_candidates: usize,
2237 fallback: Option<String>,
2238 exact_rerank: bool,
2239 results: &[ExplainedResult],
2240 degradations: Vec<String>,
2241 metadata: VectorReceiptMetadata,
2242) -> Option<VectorSearchReceiptV1> {
2243 if !context.receipts_enabled() {
2244 return None;
2245 }
2246 Some(VectorSearchReceiptV1 {
2247 schema_version: "vector_search_receipt_v1".to_string(),
2248 receipt_digest: None,
2249 receipt_id: context
2250 .request_id
2251 .clone()
2252 .unwrap_or_else(|| uuid::Uuid::new_v4().to_string()),
2253 evaluation_time: context.evaluation_time,
2254 trace_id: context.trace_id.clone(),
2255 attempt_family_id: context.attempt_family_id.clone(),
2256 attempt_id: context.attempt_id.clone(),
2257 replay_of: context.replay_of.clone(),
2258 query_embedding_digest: Some(query_embedding_digest(query_embedding)),
2259 query_text_digest: context.query_text_digest.clone(),
2260 query_input_digest: context.query_input_digest.clone(),
2261 filter_digest: context.filter_digest.clone(),
2262 redaction_state: context.redaction_state.clone(),
2263 budget_id: context.budget_id.clone(),
2264 deadline_at: context.deadline_at,
2265 search_profile: search_profile.to_string(),
2266 candidate_backend: candidate_backend.to_string(),
2267 codec_family: metadata.codec_family.clone(),
2268 codec_profile_digest: metadata.codec_profile_digest.clone(),
2269 artifact_profile_digest: metadata.codec_profile_digest.clone(),
2270 artifact_count: metadata.artifact_count,
2271 artifact_corruption_count: metadata.artifact_corruption_count,
2272 artifact_missing_count: metadata.artifact_missing_count,
2273 vector_artifact_manifest_digest: metadata.vector_artifact_manifest_digest.clone(),
2274 artifact_generation_id: metadata.artifact_generation_id.clone(),
2275 approximate_scanned_count: metadata.approximate_scanned_count,
2276 approximate_returned_count: metadata.approximate_returned_count,
2277 raw_rows_loaded_count: metadata.raw_rows_loaded_count,
2278 filter_strategy: metadata.filter_strategy,
2279 vector_artifact_count: metadata.vector_artifact_count.or(metadata.artifact_count),
2280 vector_artifact_missing_count: metadata
2281 .vector_artifact_missing_count
2282 .or(metadata.artifact_missing_count),
2283 vector_artifact_stale_count: metadata.vector_artifact_stale_count,
2284 exact_rerank_count: metadata.exact_rerank_count.or(if exact_rerank {
2285 Some(post_filter_candidates)
2286 } else {
2287 None
2288 }),
2289 approximate_candidate_count: metadata.approximate_candidate_count,
2290 approximate: candidate_backend.contains("hnsw")
2291 || candidate_backend.contains("turbo_quant"),
2292 requested_candidates,
2293 returned_candidates,
2294 post_filter_candidates,
2295 sparse_enabled: metadata.sparse_candidate_count.is_some(),
2296 sparse_weight: metadata.sparse_weight,
2297 sparse_query_nonzero_count: metadata.sparse_query_nonzero_count,
2298 sparse_candidate_count: metadata.sparse_candidate_count,
2299 sparse_representations: metadata.sparse_representations,
2300 sparse_result_ranks: results
2301 .iter()
2302 .filter_map(|result| {
2303 result
2304 .breakdown
2305 .sparse_rank
2306 .map(|rank| crate::types::SparseRankReceiptV1 {
2307 result_id: search_result_id(&result.result.source),
2308 rank,
2309 })
2310 })
2311 .collect(),
2312 fallback_reason: fallback.clone(),
2313 derived_candidate: if candidate_backend == "provekv_pool_candidate_then_exact_f32" {
2314 Some(crate::types::DerivedCandidateReceiptV1 {
2315 candidate_backend: candidate_backend.to_string(),
2316 codec_family: metadata.codec_family.clone(),
2317 generation_id: metadata.artifact_generation_id.clone(),
2318 embedding_snapshot_digest: None,
2319 pool_manifest_digest: metadata.vector_artifact_manifest_digest.clone(),
2320 exact_rerank,
2321 approximate: false,
2322 fallback: fallback.clone(),
2323 raw_candidate_count: returned_candidates,
2324 post_filter_count: post_filter_candidates,
2325 final_result_count: results.len(),
2326 })
2327 } else {
2328 None
2329 },
2330 fallback,
2331 exact_rerank,
2332 result_ids: results
2333 .iter()
2334 .map(|result| search_result_id(&result.result.source))
2335 .collect(),
2336 degradations,
2337 })
2338}
2339
2340#[cfg(feature = "hnsw")]
2341fn filters_are_active(
2342 namespaces: Option<&[&str]>,
2343 source_types: Option<&[SearchSourceType]>,
2344 session_ids: Option<&[&str]>,
2345) -> bool {
2346 namespaces.is_some_and(|values| !values.is_empty())
2347 || source_types.is_some_and(|values| !values.is_empty())
2348 || session_ids.is_some_and(|values| !values.is_empty())
2349}
2350
2351#[allow(dead_code)]
2354fn rerank_hit_with_full_embedding(
2355 conn: &Connection,
2356 query_embedding: &[f32],
2357 hit: &VectorHit,
2358) -> Result<f64, MemoryError> {
2359 let blob: Option<Vec<u8>> = match &hit.source {
2361 SearchSource::Fact { fact_id, .. } => conn
2362 .query_row(
2363 "SELECT embedding FROM facts WHERE id = ?1 AND embedding IS NOT NULL",
2364 rusqlite::params![fact_id],
2365 |row| row.get::<_, Vec<u8>>(0),
2366 )
2367 .ok(),
2368 SearchSource::Chunk { chunk_id, .. } => conn
2369 .query_row(
2370 "SELECT embedding FROM chunks WHERE id = ?1 AND embedding IS NOT NULL",
2371 rusqlite::params![chunk_id],
2372 |row| row.get::<_, Vec<u8>>(0),
2373 )
2374 .ok(),
2375 SearchSource::Message { message_id, .. } => conn
2376 .query_row(
2377 "SELECT embedding FROM messages WHERE id = ?1 AND embedding IS NOT NULL",
2378 rusqlite::params![message_id],
2379 |row| row.get::<_, Vec<u8>>(0),
2380 )
2381 .ok(),
2382 SearchSource::Episode { episode_id, .. } => conn
2383 .query_row(
2384 "SELECT embedding FROM episodes WHERE episode_id = ?1 AND embedding IS NOT NULL",
2385 rusqlite::params![episode_id],
2386 |row| row.get::<_, Vec<u8>>(0),
2387 )
2388 .ok(),
2389 SearchSource::Projection { projection_id, .. } => conn
2390 .query_row(
2391 "SELECT embedding FROM projections WHERE id = ?1 AND embedding IS NOT NULL",
2392 rusqlite::params![projection_id],
2393 |row| row.get::<_, Vec<u8>>(0),
2394 )
2395 .ok(),
2396 };
2397
2398 let blob = match blob {
2399 Some(b) if !b.is_empty() => b,
2400 _ => return Ok(hit.similarity), };
2402
2403 let stored = crate::db::decode_f32_le(&blob, query_embedding.len())?;
2404 if stored.len() != query_embedding.len() {
2405 return Ok(hit.similarity); }
2407
2408 Ok(cosine_similarity(query_embedding, &stored)? as f64)
2409}
2410
2411#[allow(clippy::too_many_arguments)]
2412pub(crate) fn hybrid_search_detailed_with_context(
2413 conn: &Connection,
2414 query: &str,
2415 query_embedding: &[f32],
2416 query_sparse: Option<&crate::SparseWeights>,
2417 config: &SearchConfig,
2418 context: &SearchContext,
2419 top_k: usize,
2420 namespaces: Option<&[&str]>,
2421 source_types: Option<&[SearchSourceType]>,
2422 session_ids: Option<&[&str]>,
2423) -> Result<SearchExecution, MemoryError> {
2424 let bm25_hits = match sanitize_fts_query(query) {
2425 Some(sanitized) => bm25_search(
2426 conn,
2427 &sanitized,
2428 config.candidate_pool_size,
2429 namespaces,
2430 source_types,
2431 session_ids,
2432 )?,
2433 None => Vec::new(),
2434 };
2435
2436 #[allow(unused_mut)]
2437 let mut vector_outcome = vector_search_with_backend(
2438 conn,
2439 query_embedding,
2440 config.candidate_pool_size,
2441 config.min_similarity,
2442 config,
2443 context,
2444 namespaces,
2445 source_types,
2446 session_ids,
2447 )?;
2448
2449 #[cfg(feature = "matryoshka")]
2453 {
2454 if let Some(candidate_dim) = config.candidate_dims {
2455 if candidate_dim > 0
2456 && candidate_dim < query_embedding.len()
2457 && context.exactness_profile != crate::types::ExactnessProfile::PreferExact
2458 {
2459 use crate::matryoshka::truncate_embedding;
2460 let truncated_query = truncate_embedding(query_embedding, candidate_dim);
2461 match vector_search_with_backend(
2462 conn,
2463 &truncated_query,
2464 config.candidate_pool_size.saturating_mul(2),
2465 config.min_similarity * 0.5,
2466 config,
2467 context,
2468 namespaces,
2469 source_types,
2470 session_ids,
2471 ) {
2472 Ok(coarse_outcome) => {
2473 let reranked_hits: Vec<VectorHit> = coarse_outcome
2475 .hits
2476 .into_iter()
2477 .map(|mut hit| {
2478 if let Ok(full_sim) =
2479 rerank_hit_with_full_embedding(conn, query_embedding, &hit)
2480 {
2481 hit.similarity = full_sim;
2482 hit.reranked_from_f32 = true;
2483 }
2484 hit
2485 })
2486 .filter(|hit| hit.similarity >= config.min_similarity)
2487 .collect();
2488 let mut reranked = reranked_hits;
2489 reranked.sort_by(|a, b| {
2490 b.similarity
2491 .partial_cmp(&a.similarity)
2492 .unwrap_or(std::cmp::Ordering::Equal)
2493 });
2494 reranked.truncate(config.candidate_pool_size);
2495 if reranked.is_empty() {
2496 vector_outcome.degradations.push(format!(
2501 "matryoshka {}d coarse stage returned no candidates above threshold; kept full {}d outcome",
2502 candidate_dim,
2503 query_embedding.len()
2504 ));
2505 } else {
2506 let new_receipt_metadata = coarse_outcome.receipt_metadata.clone();
2507 vector_outcome = VectorSearchOutcome {
2508 hits: reranked,
2509 candidate_backend: format!(
2510 "matryoshka_2stage_{}d_to_{}d",
2511 candidate_dim,
2512 query_embedding.len()
2513 ),
2514 receipt_metadata: new_receipt_metadata,
2515 ..coarse_outcome
2516 };
2517 }
2518 }
2519 Err(_) => { }
2520 }
2521 }
2522 }
2523 }
2524
2525 let sparse_hits =
2526 if let Some(query_sparse) = query_sparse.filter(|_| config.sparse_weight > 0.0) {
2527 sparse_search(
2528 conn,
2529 query_sparse,
2530 config,
2531 namespaces,
2532 source_types,
2533 session_ids,
2534 )?
2535 } else {
2536 Vec::new()
2537 };
2538
2539 let results = if config.sparse_weight > 0.0 {
2540 rrf_fuse_three_detailed_with_context(
2541 &bm25_hits,
2542 &vector_outcome.hits,
2543 &sparse_hits,
2544 config,
2545 context,
2546 top_k,
2547 )
2548 } else if config.late_interaction_weight > 0.0 {
2549 let li_scores =
2554 compute_proxy_late_interaction_scores(query_embedding, &vector_outcome.hits);
2555 #[cfg(feature = "late-interaction")]
2556 {
2557 rrf_fuse_with_late_interaction(
2558 &bm25_hits,
2559 &vector_outcome.hits,
2560 &li_scores,
2561 config,
2562 context,
2563 top_k,
2564 )
2565 }
2566 #[cfg(not(feature = "late-interaction"))]
2567 {
2568 let _ = li_scores;
2569 rrf_fuse_detailed_with_context(&bm25_hits, &vector_outcome.hits, config, context, top_k)
2570 }
2571 } else {
2572 rrf_fuse_detailed_with_context(&bm25_hits, &vector_outcome.hits, config, context, top_k)
2573 };
2574 let mut receipt_metadata = vector_outcome.receipt_metadata;
2575 if config.sparse_weight > 0.0 {
2576 receipt_metadata.sparse_weight = Some(config.sparse_weight);
2577 if let Some(query_sparse) = query_sparse {
2578 receipt_metadata.sparse_query_nonzero_count = Some(query_sparse.len());
2579 receipt_metadata.sparse_candidate_count = Some(sparse_hits.len());
2580 let mut representations: Vec<String> = sparse_hits
2581 .iter()
2582 .map(|hit| hit.representation.clone())
2583 .collect();
2584 representations.sort();
2585 representations.dedup();
2586 receipt_metadata.sparse_representations = representations;
2587 } else {
2588 vector_outcome.degradations.push(
2589 "sparse retrieval was requested but the active embedder produced no sparse query representation"
2590 .to_string(),
2591 );
2592 }
2593 }
2594 let receipt = build_receipt_with_metadata(
2595 context,
2596 query_embedding,
2597 "hybrid",
2598 &vector_outcome.candidate_backend,
2599 vector_outcome.requested_candidates,
2600 vector_outcome.returned_candidates,
2601 vector_outcome.post_filter_candidates,
2602 vector_outcome.fallback,
2603 vector_outcome.exact_rerank,
2604 &results,
2605 vector_outcome.degradations,
2606 receipt_metadata,
2607 );
2608 Ok(SearchExecution { results, receipt })
2609}
2610
2611#[allow(clippy::too_many_arguments)]
2612pub(crate) fn hybrid_search_detailed(
2613 conn: &Connection,
2614 query: &str,
2615 query_embedding: &[f32],
2616 config: &SearchConfig,
2617 top_k: usize,
2618 namespaces: Option<&[&str]>,
2619 source_types: Option<&[SearchSourceType]>,
2620 session_ids: Option<&[&str]>,
2621) -> Result<Vec<ExplainedResult>, MemoryError> {
2622 let context = SearchContext::default_now();
2623 Ok(hybrid_search_detailed_with_context(
2624 conn,
2625 query,
2626 query_embedding,
2627 None,
2628 config,
2629 &context,
2630 top_k,
2631 namespaces,
2632 source_types,
2633 session_ids,
2634 )?
2635 .results)
2636}
2637
2638#[allow(clippy::too_many_arguments)]
2640pub fn hybrid_search_explained(
2641 conn: &Connection,
2642 query: &str,
2643 query_embedding: &[f32],
2644 config: &SearchConfig,
2645 top_k: usize,
2646 namespaces: Option<&[&str]>,
2647 source_types: Option<&[SearchSourceType]>,
2648 session_ids: Option<&[&str]>,
2649) -> Result<Vec<ExplainedResult>, MemoryError> {
2650 hybrid_search_detailed(
2651 conn,
2652 query,
2653 query_embedding,
2654 config,
2655 top_k,
2656 namespaces,
2657 source_types,
2658 session_ids,
2659 )
2660}
2661
2662#[allow(clippy::too_many_arguments)]
2664pub fn hybrid_search(
2665 conn: &Connection,
2666 query: &str,
2667 query_embedding: &[f32],
2668 config: &SearchConfig,
2669 top_k: usize,
2670 namespaces: Option<&[&str]>,
2671 source_types: Option<&[SearchSourceType]>,
2672 session_ids: Option<&[&str]>,
2673) -> Result<Vec<SearchResult>, MemoryError> {
2674 let results: Vec<SearchResult> = hybrid_search_detailed(
2675 conn,
2676 query,
2677 query_embedding,
2678 config,
2679 top_k,
2680 namespaces,
2681 source_types,
2682 session_ids,
2683 )?
2684 .into_iter()
2685 .map(|result| result.result)
2686 .collect();
2687
2688 let mut seen_content: std::collections::HashSet<String> = std::collections::HashSet::new();
2692 let deduped: Vec<SearchResult> = results
2693 .into_iter()
2694 .filter(|r| {
2695 let fingerprint: String = r
2698 .content
2699 .split_whitespace()
2700 .take(30)
2701 .collect::<Vec<_>>()
2702 .join(" ")
2703 .to_lowercase();
2704 seen_content.insert(fingerprint)
2705 })
2706 .collect();
2707
2708 Ok(deduped)
2709}
2710
2711#[cfg(feature = "hnsw")]
2712#[derive(Clone)]
2713struct HnswCandidateSeed {
2714 source_rank: usize,
2715 source_similarity: f64,
2716}
2717
2718#[cfg(feature = "hnsw")]
2719#[allow(clippy::type_complexity)]
2720fn resolve_hnsw_hits_batched(
2721 conn: &Connection,
2722 query_embedding: &[f32],
2723 config: &SearchConfig,
2724 namespaces: Option<&[&str]>,
2725 source_types: Option<&[SearchSourceType]>,
2726 session_ids: Option<&[&str]>,
2727 hnsw_hits: &[crate::hnsw::HnswHit],
2728) -> Result<Vec<VectorHit>, MemoryError> {
2729 let search_facts = source_types
2730 .map(|st| st.contains(&SearchSourceType::Facts))
2731 .unwrap_or(true);
2732 let search_chunks = source_types
2733 .map(|st| st.contains(&SearchSourceType::Chunks))
2734 .unwrap_or(true);
2735 let search_messages = source_types
2736 .map(|st| st.contains(&SearchSourceType::Messages))
2737 .unwrap_or(false);
2738 let search_episodes = source_types
2739 .map(|st| st.contains(&SearchSourceType::Episodes))
2740 .unwrap_or(true);
2741
2742 let mut fact_entries: HashMap<String, HnswCandidateSeed> = HashMap::new();
2744 let mut chunk_entries: HashMap<String, HnswCandidateSeed> = HashMap::new();
2746 let mut message_entries: HashMap<i64, HnswCandidateSeed> = HashMap::new();
2748 let mut episode_entries: HashMap<String, HnswCandidateSeed> = HashMap::new();
2750
2751 for (rank_0, hit) in hnsw_hits.iter().enumerate() {
2752 let similarity = hit.similarity() as f64;
2753 if similarity < config.min_similarity {
2754 continue;
2755 }
2756
2757 let (domain, raw_id) = hit.parse_key()?;
2758 let seed = HnswCandidateSeed {
2759 source_rank: rank_0 + 1,
2760 source_similarity: similarity,
2761 };
2762
2763 match domain {
2764 "fact" if search_facts => {
2765 fact_entries.entry(raw_id.to_string()).or_insert(seed);
2766 }
2767 "chunk" if search_chunks => {
2768 chunk_entries.entry(raw_id.to_string()).or_insert(seed);
2769 }
2770 "msg" if search_messages => {
2771 if let Ok(message_id) = raw_id.parse::<i64>() {
2772 message_entries.entry(message_id).or_insert(seed);
2773 }
2774 }
2775 "episode" if search_episodes => {
2776 episode_entries.entry(raw_id.to_string()).or_insert(seed);
2777 }
2778 _ => {}
2779 }
2780 }
2781
2782 let mut hits = Vec::new();
2783 batch_load_fact_hits(
2784 conn,
2785 query_embedding,
2786 config,
2787 namespaces,
2788 &fact_entries,
2789 &mut hits,
2790 )?;
2791 batch_load_chunk_hits(
2792 conn,
2793 query_embedding,
2794 config,
2795 namespaces,
2796 &chunk_entries,
2797 &mut hits,
2798 )?;
2799 batch_load_message_hits(
2800 conn,
2801 query_embedding,
2802 config,
2803 session_ids,
2804 &message_entries,
2805 &mut hits,
2806 )?;
2807 batch_load_episode_hits(
2808 conn,
2809 query_embedding,
2810 config,
2811 namespaces,
2812 &episode_entries,
2813 &mut hits,
2814 )?;
2815
2816 hits.sort_by(|a, b| {
2817 b.similarity
2818 .partial_cmp(&a.similarity)
2819 .unwrap_or(std::cmp::Ordering::Equal)
2820 .then_with(|| {
2821 a.source_rank
2822 .unwrap_or(usize::MAX)
2823 .cmp(&b.source_rank.unwrap_or(usize::MAX))
2824 })
2825 });
2826 hits.truncate(config.candidate_pool_size);
2827 Ok(hits)
2828}
2829
2830#[cfg(feature = "hnsw")]
2831fn exact_similarity_from_blob(
2832 query_embedding: &[f32],
2833 blob: &[u8],
2834) -> Result<Option<f64>, MemoryError> {
2835 if blob.is_empty() {
2836 return Ok(None);
2837 }
2838 let stored = crate::db::bytes_to_embedding(blob)?;
2839 if stored.len() != query_embedding.len() {
2840 return Ok(None);
2841 }
2842 Ok(Some(cosine_similarity(query_embedding, &stored)? as f64))
2843}
2844
2845#[cfg(feature = "hnsw")]
2846#[allow(clippy::too_many_arguments)]
2847fn build_ranked_vector_hit(
2848 id: String,
2849 content: String,
2850 source: SearchSource,
2851 updated_at: Option<String>,
2852 embedding_blob: Option<Vec<u8>>,
2853 seed: &HnswCandidateSeed,
2854 query_embedding: &[f32],
2855 config: &SearchConfig,
2856) -> Result<Option<VectorHit>, MemoryError> {
2857 let similarity = if config.rerank_from_f32 {
2858 match embedding_blob {
2859 Some(blob) => exact_similarity_from_blob(query_embedding, &blob)?,
2860 None => None,
2861 }
2862 .unwrap_or(seed.source_similarity)
2863 } else {
2864 seed.source_similarity
2865 };
2866
2867 if similarity < config.min_similarity {
2868 return Ok(None);
2869 }
2870
2871 Ok(Some(VectorHit {
2872 id,
2873 content,
2874 source,
2875 similarity,
2876 updated_at,
2877 source_rank: Some(seed.source_rank),
2878 source_similarity: Some(seed.source_similarity),
2879 reranked_from_f32: config.rerank_from_f32,
2880 temporal_weight: None,
2881 provenance_confidence: None,
2882 }))
2883}
2884
2885#[cfg(feature = "hnsw")]
2886fn batch_load_fact_hits(
2887 conn: &Connection,
2888 query_embedding: &[f32],
2889 config: &SearchConfig,
2890 namespaces: Option<&[&str]>,
2891 entries: &HashMap<String, HnswCandidateSeed>,
2893 output: &mut Vec<VectorHit>,
2894) -> Result<(), MemoryError> {
2895 if entries.is_empty() {
2896 return Ok(());
2897 }
2898
2899 let placeholders = (1..=entries.len())
2900 .map(|idx| format!("?{idx}"))
2901 .collect::<Vec<_>>()
2902 .join(", ");
2903 let sql = format!(
2904 "SELECT id, content, namespace, updated_at, embedding
2905 FROM facts
2906 WHERE id IN ({placeholders})"
2907 );
2908 let params: Vec<SqlValue> = entries
2909 .keys()
2910 .map(|id| SqlValue::Text(id.clone()))
2911 .collect();
2912 let mut stmt = conn.prepare(&sql)?;
2913 let rows = stmt.query_map(rusqlite::params_from_iter(¶ms), |row| {
2914 Ok((
2915 row.get::<_, String>(0)?,
2916 row.get::<_, String>(1)?,
2917 row.get::<_, String>(2)?,
2918 row.get::<_, Option<String>>(3)?,
2919 row.get::<_, Option<Vec<u8>>>(4)?,
2920 ))
2921 })?;
2922
2923 for row in rows {
2924 let (fact_id, content, namespace, updated_at, embedding_blob) = row?;
2925 if let Some(filter) = namespaces {
2926 if !filter.contains(&namespace.as_str()) {
2927 continue;
2928 }
2929 }
2930 if let Some(seed) = entries.get(&fact_id) {
2931 if let Some(hit) = build_ranked_vector_hit(
2932 format!("fact:{fact_id}"),
2933 content,
2934 SearchSource::Fact { fact_id, namespace },
2935 updated_at,
2936 embedding_blob,
2937 seed,
2938 query_embedding,
2939 config,
2940 )? {
2941 output.push(hit);
2942 }
2943 }
2944 }
2945
2946 Ok(())
2947}
2948
2949#[cfg(feature = "hnsw")]
2950fn batch_load_chunk_hits(
2951 conn: &Connection,
2952 query_embedding: &[f32],
2953 config: &SearchConfig,
2954 namespaces: Option<&[&str]>,
2955 entries: &HashMap<String, HnswCandidateSeed>,
2957 output: &mut Vec<VectorHit>,
2958) -> Result<(), MemoryError> {
2959 if entries.is_empty() {
2960 return Ok(());
2961 }
2962
2963 let placeholders = (1..=entries.len())
2964 .map(|idx| format!("?{idx}"))
2965 .collect::<Vec<_>>()
2966 .join(", ");
2967 let sql = format!(
2968 "SELECT c.id, c.content, c.document_id, d.title, c.chunk_index, c.created_at, d.namespace, c.embedding
2969 FROM chunks c
2970 JOIN documents d ON d.id = c.document_id
2971 WHERE c.id IN ({placeholders})"
2972 );
2973 let params: Vec<SqlValue> = entries
2974 .keys()
2975 .map(|id| SqlValue::Text(id.clone()))
2976 .collect();
2977 let mut stmt = conn.prepare(&sql)?;
2978 let rows = stmt.query_map(rusqlite::params_from_iter(¶ms), |row| {
2979 Ok((
2980 row.get::<_, String>(0)?,
2981 row.get::<_, String>(1)?,
2982 row.get::<_, String>(2)?,
2983 row.get::<_, String>(3)?,
2984 row.get::<_, i64>(4)?,
2985 row.get::<_, Option<String>>(5)?,
2986 row.get::<_, String>(6)?,
2987 row.get::<_, Option<Vec<u8>>>(7)?,
2988 ))
2989 })?;
2990
2991 for row in rows {
2992 let (
2993 chunk_id,
2994 content,
2995 document_id,
2996 document_title,
2997 chunk_index,
2998 updated_at,
2999 namespace,
3000 embedding_blob,
3001 ) = row?;
3002 if let Some(filter) = namespaces {
3003 if !filter.contains(&namespace.as_str()) {
3004 continue;
3005 }
3006 }
3007 if let Some(seed) = entries.get(&chunk_id) {
3008 if let Some(hit) = build_ranked_vector_hit(
3009 format!("chunk:{chunk_id}"),
3010 content,
3011 SearchSource::Chunk {
3012 chunk_id,
3013 document_id,
3014 document_title,
3015 chunk_index: chunk_index as usize,
3016 },
3017 updated_at,
3018 embedding_blob,
3019 seed,
3020 query_embedding,
3021 config,
3022 )? {
3023 output.push(hit);
3024 }
3025 }
3026 }
3027
3028 Ok(())
3029}
3030
3031#[cfg(feature = "hnsw")]
3032fn batch_load_message_hits(
3033 conn: &Connection,
3034 query_embedding: &[f32],
3035 config: &SearchConfig,
3036 session_ids: Option<&[&str]>,
3037 entries: &HashMap<i64, HnswCandidateSeed>,
3039 output: &mut Vec<VectorHit>,
3040) -> Result<(), MemoryError> {
3041 if entries.is_empty() {
3042 return Ok(());
3043 }
3044
3045 let placeholders = (1..=entries.len())
3046 .map(|idx| format!("?{idx}"))
3047 .collect::<Vec<_>>()
3048 .join(", ");
3049 let sql = format!(
3050 "SELECT id, content, session_id, role, created_at, embedding
3051 FROM messages
3052 WHERE id IN ({placeholders})"
3053 );
3054 let params: Vec<SqlValue> = entries.keys().map(|id| SqlValue::Integer(*id)).collect();
3055 let mut stmt = conn.prepare(&sql)?;
3056 let rows = stmt.query_map(rusqlite::params_from_iter(¶ms), |row| {
3057 Ok((
3058 row.get::<_, i64>(0)?,
3059 row.get::<_, String>(1)?,
3060 row.get::<_, String>(2)?,
3061 row.get::<_, String>(3)?,
3062 row.get::<_, Option<String>>(4)?,
3063 row.get::<_, Option<Vec<u8>>>(5)?,
3064 ))
3065 })?;
3066
3067 for row in rows {
3068 let (message_id, content, session_id, role, updated_at, embedding_blob) = row?;
3069 if let Some(filter) = session_ids {
3070 if !filter.contains(&session_id.as_str()) {
3071 continue;
3072 }
3073 }
3074 if let Some(seed) = entries.get(&message_id) {
3075 if let Some(hit) = build_ranked_vector_hit(
3076 format!("msg:{message_id}"),
3077 content,
3078 SearchSource::Message {
3079 message_id,
3080 session_id,
3081 role,
3082 },
3083 updated_at,
3084 embedding_blob,
3085 seed,
3086 query_embedding,
3087 config,
3088 )? {
3089 output.push(hit);
3090 }
3091 }
3092 }
3093
3094 Ok(())
3095}
3096
3097#[cfg(feature = "hnsw")]
3098fn batch_load_episode_hits(
3099 conn: &Connection,
3100 query_embedding: &[f32],
3101 config: &SearchConfig,
3102 namespaces: Option<&[&str]>,
3103 entries: &HashMap<String, HnswCandidateSeed>,
3105 output: &mut Vec<VectorHit>,
3106) -> Result<(), MemoryError> {
3107 if entries.is_empty() {
3108 return Ok(());
3109 }
3110
3111 let placeholders = (1..=entries.len())
3112 .map(|idx| format!("?{idx}"))
3113 .collect::<Vec<_>>()
3114 .join(", ");
3115 let sql = format!(
3116 "SELECT e.episode_id, e.document_id, e.search_text, e.effect_type, e.outcome, e.updated_at, d.namespace, e.embedding
3117 FROM episodes e
3118 JOIN documents d ON d.id = e.document_id
3119 WHERE e.episode_id IN ({placeholders})"
3120 );
3121 let params: Vec<SqlValue> = entries
3122 .keys()
3123 .map(|id| SqlValue::Text(id.clone()))
3124 .collect();
3125 let mut stmt = conn.prepare(&sql)?;
3126 let rows = stmt.query_map(rusqlite::params_from_iter(¶ms), |row| {
3127 Ok((
3128 row.get::<_, String>(0)?,
3129 row.get::<_, String>(1)?,
3130 row.get::<_, String>(2)?,
3131 row.get::<_, String>(3)?,
3132 row.get::<_, String>(4)?,
3133 row.get::<_, Option<String>>(5)?,
3134 row.get::<_, String>(6)?,
3135 row.get::<_, Option<Vec<u8>>>(7)?,
3136 ))
3137 })?;
3138
3139 for row in rows {
3140 let (
3141 episode_id,
3142 document_id,
3143 content,
3144 effect_type,
3145 outcome,
3146 updated_at,
3147 namespace,
3148 embedding_blob,
3149 ) = row?;
3150 if let Some(filter) = namespaces {
3151 if !filter.contains(&namespace.as_str()) {
3152 continue;
3153 }
3154 }
3155 if let Some(seed) = entries.get(&episode_id) {
3156 if let Some(hit) = build_ranked_vector_hit(
3157 episodes::episode_item_key(&episode_id),
3158 content,
3159 SearchSource::Episode {
3160 episode_id,
3161 document_id,
3162 effect_type,
3163 outcome,
3164 },
3165 updated_at,
3166 embedding_blob,
3167 seed,
3168 query_embedding,
3169 config,
3170 )? {
3171 output.push(hit);
3172 }
3173 }
3174 }
3175
3176 Ok(())
3177}
3178
3179#[cfg(feature = "hnsw")]
3181#[allow(clippy::too_many_arguments)]
3182pub fn hybrid_search_with_hnsw(
3183 conn: &Connection,
3184 query: &str,
3185 query_embedding: &[f32],
3186 config: &SearchConfig,
3187 top_k: usize,
3188 namespaces: Option<&[&str]>,
3189 source_types: Option<&[SearchSourceType]>,
3190 session_ids: Option<&[&str]>,
3191 hnsw_hits: &[crate::hnsw::HnswHit],
3192) -> Result<Vec<SearchResult>, MemoryError> {
3193 Ok(hybrid_search_with_hnsw_detailed(
3194 conn,
3195 query,
3196 query_embedding,
3197 config,
3198 top_k,
3199 namespaces,
3200 source_types,
3201 session_ids,
3202 hnsw_hits,
3203 )?
3204 .into_iter()
3205 .map(|result| result.result)
3206 .collect())
3207}
3208
3209#[cfg(feature = "hnsw")]
3210#[allow(clippy::too_many_arguments)]
3211pub(crate) fn hybrid_search_with_hnsw_detailed_with_context(
3212 conn: &Connection,
3213 query: &str,
3214 query_embedding: &[f32],
3215 query_sparse: Option<&crate::SparseWeights>,
3216 config: &SearchConfig,
3217 context: &SearchContext,
3218 top_k: usize,
3219 namespaces: Option<&[&str]>,
3220 source_types: Option<&[SearchSourceType]>,
3221 session_ids: Option<&[&str]>,
3222 hnsw_hits: &[crate::hnsw::HnswHit],
3223) -> Result<SearchExecution, MemoryError> {
3224 let bm25_hits = match sanitize_fts_query(query) {
3225 Some(sanitized) => bm25_search(
3226 conn,
3227 &sanitized,
3228 config.candidate_pool_size,
3229 namespaces,
3230 source_types,
3231 session_ids,
3232 )?,
3233 None => Vec::new(),
3234 };
3235
3236 let mut vector_hits = resolve_hnsw_hits_batched(
3237 conn,
3238 query_embedding,
3239 config,
3240 namespaces,
3241 source_types,
3242 session_ids,
3243 hnsw_hits,
3244 )?;
3245 let mut fallback = None;
3246 let mut degradations = Vec::new();
3247 let mut backend = "hnsw";
3248 let mut exact_rerank = config.rerank_from_f32;
3249
3250 if !hnsw_hits.is_empty()
3251 && vector_hits.len() < top_k
3252 && filters_are_active(namespaces, source_types, session_ids)
3253 {
3254 fallback = Some("hnsw_filtered_underreturn_fallback".to_string());
3255 degradations.push(format!(
3256 "HNSW returned {} post-filter vector candidates for requested top_k {}; exact filtered fallback was used",
3257 vector_hits.len(),
3258 top_k
3259 ));
3260 vector_hits = vector_search(
3261 conn,
3262 query_embedding,
3263 config.candidate_pool_size,
3264 config.min_similarity,
3265 namespaces,
3266 source_types,
3267 session_ids,
3268 )?;
3269 backend = "hnsw_then_brute_force_f32";
3270 exact_rerank = true;
3271 }
3272
3273 let sparse_hits =
3274 if let Some(query_sparse) = query_sparse.filter(|_| config.sparse_weight > 0.0) {
3275 sparse_search(
3276 conn,
3277 query_sparse,
3278 config,
3279 namespaces,
3280 source_types,
3281 session_ids,
3282 )?
3283 } else {
3284 Vec::new()
3285 };
3286 let results = if config.sparse_weight > 0.0 {
3287 rrf_fuse_three_detailed_with_context(
3288 &bm25_hits,
3289 &vector_hits,
3290 &sparse_hits,
3291 config,
3292 context,
3293 top_k,
3294 )
3295 } else {
3296 rrf_fuse_detailed_with_context(&bm25_hits, &vector_hits, config, context, top_k)
3297 };
3298 let mut metadata = VectorReceiptMetadata::default();
3299 if config.sparse_weight > 0.0 {
3300 metadata.sparse_weight = Some(config.sparse_weight);
3301 if let Some(query_sparse) = query_sparse {
3302 metadata.sparse_query_nonzero_count = Some(query_sparse.len());
3303 metadata.sparse_candidate_count = Some(sparse_hits.len());
3304 metadata.sparse_representations = sparse_hits
3305 .iter()
3306 .map(|hit| hit.representation.clone())
3307 .collect();
3308 metadata.sparse_representations.sort();
3309 metadata.sparse_representations.dedup();
3310 } else {
3311 degradations.push(
3312 "sparse retrieval was requested but the active embedder produced no sparse query representation"
3313 .to_string(),
3314 );
3315 }
3316 }
3317 let receipt = build_receipt_with_metadata(
3318 context,
3319 query_embedding,
3320 "hybrid",
3321 backend,
3322 config.candidate_pool_size,
3323 hnsw_hits.len(),
3324 vector_hits.len(),
3325 fallback,
3326 exact_rerank,
3327 &results,
3328 degradations,
3329 metadata,
3330 );
3331
3332 Ok(SearchExecution { results, receipt })
3333}
3334
3335#[cfg(feature = "hnsw")]
3336#[allow(clippy::too_many_arguments)]
3337pub(crate) fn hybrid_search_with_hnsw_detailed(
3338 conn: &Connection,
3339 query: &str,
3340 query_embedding: &[f32],
3341 config: &SearchConfig,
3342 top_k: usize,
3343 namespaces: Option<&[&str]>,
3344 source_types: Option<&[SearchSourceType]>,
3345 session_ids: Option<&[&str]>,
3346 hnsw_hits: &[crate::hnsw::HnswHit],
3347) -> Result<Vec<ExplainedResult>, MemoryError> {
3348 let context = SearchContext::default_now();
3349 Ok(hybrid_search_with_hnsw_detailed_with_context(
3350 conn,
3351 query,
3352 query_embedding,
3353 None,
3354 config,
3355 &context,
3356 top_k,
3357 namespaces,
3358 source_types,
3359 session_ids,
3360 hnsw_hits,
3361 )?
3362 .results)
3363}
3364
3365#[cfg(feature = "hnsw")]
3367#[allow(clippy::too_many_arguments)]
3368pub fn hybrid_search_explained_with_hnsw(
3369 conn: &Connection,
3370 query: &str,
3371 query_embedding: &[f32],
3372 config: &SearchConfig,
3373 top_k: usize,
3374 namespaces: Option<&[&str]>,
3375 source_types: Option<&[SearchSourceType]>,
3376 session_ids: Option<&[&str]>,
3377 hnsw_hits: &[crate::hnsw::HnswHit],
3378) -> Result<Vec<ExplainedResult>, MemoryError> {
3379 hybrid_search_with_hnsw_detailed(
3380 conn,
3381 query,
3382 query_embedding,
3383 config,
3384 top_k,
3385 namespaces,
3386 source_types,
3387 session_ids,
3388 hnsw_hits,
3389 )
3390}
3391
3392pub(crate) fn fts_only_search_detailed(
3393 conn: &Connection,
3394 query: &str,
3395 config: &SearchConfig,
3396 top_k: usize,
3397 namespaces: Option<&[&str]>,
3398 source_types: Option<&[SearchSourceType]>,
3399 session_ids: Option<&[&str]>,
3400) -> Result<Vec<ExplainedResult>, MemoryError> {
3401 let sanitized = match sanitize_fts_query(query) {
3402 Some(value) => value,
3403 None => return Ok(Vec::new()),
3404 };
3405 let bm25_hits = bm25_search(
3406 conn,
3407 &sanitized,
3408 top_k,
3409 namespaces,
3410 source_types,
3411 session_ids,
3412 )?;
3413 Ok(rrf_fuse_detailed(&bm25_hits, &[], config, top_k))
3414}
3415
3416#[allow(clippy::too_many_arguments)]
3417pub(crate) fn fts_only_search_detailed_with_context(
3418 conn: &Connection,
3419 query: &str,
3420 config: &SearchConfig,
3421 context: &SearchContext,
3422 top_k: usize,
3423 namespaces: Option<&[&str]>,
3424 source_types: Option<&[SearchSourceType]>,
3425 session_ids: Option<&[&str]>,
3426) -> Result<SearchExecution, MemoryError> {
3427 let results = fts_only_search_detailed(
3428 conn,
3429 query,
3430 config,
3431 top_k,
3432 namespaces,
3433 source_types,
3434 session_ids,
3435 )?;
3436 let count = results.len();
3437 let mut receipt = build_receipt(
3438 context,
3439 &[],
3440 "fts_only",
3441 "sqlite_fts5_bm25",
3442 top_k,
3443 count,
3444 count,
3445 None,
3446 false,
3447 &results,
3448 Vec::new(),
3449 );
3450 if let Some(receipt) = receipt.as_mut() {
3451 receipt.query_embedding_digest = None;
3452 }
3453 Ok(SearchExecution { results, receipt })
3454}
3455
3456pub fn fts_only_search(
3458 conn: &Connection,
3459 query: &str,
3460 config: &SearchConfig,
3461 top_k: usize,
3462 namespaces: Option<&[&str]>,
3463 source_types: Option<&[SearchSourceType]>,
3464 session_ids: Option<&[&str]>,
3465) -> Result<Vec<SearchResult>, MemoryError> {
3466 Ok(fts_only_search_detailed(
3467 conn,
3468 query,
3469 config,
3470 top_k,
3471 namespaces,
3472 source_types,
3473 session_ids,
3474 )?
3475 .into_iter()
3476 .map(|result| result.result)
3477 .collect())
3478}
3479
3480#[allow(clippy::too_many_arguments)]
3481pub(crate) fn vector_only_search_detailed_with_context(
3482 conn: &Connection,
3483 query_embedding: &[f32],
3484 config: &SearchConfig,
3485 context: &SearchContext,
3486 top_k: usize,
3487 namespaces: Option<&[&str]>,
3488 source_types: Option<&[SearchSourceType]>,
3489 session_ids: Option<&[&str]>,
3490) -> Result<SearchExecution, MemoryError> {
3491 let vector_outcome = vector_search_with_backend(
3492 conn,
3493 query_embedding,
3494 top_k,
3495 config.min_similarity,
3496 config,
3497 context,
3498 namespaces,
3499 source_types,
3500 session_ids,
3501 )?;
3502 let results = rrf_fuse_detailed_with_context(&[], &vector_outcome.hits, config, context, top_k);
3503 let receipt = build_receipt_with_metadata(
3504 context,
3505 query_embedding,
3506 "vector_only",
3507 &vector_outcome.candidate_backend,
3508 vector_outcome.requested_candidates,
3509 vector_outcome.returned_candidates,
3510 vector_outcome.post_filter_candidates,
3511 vector_outcome.fallback,
3512 vector_outcome.exact_rerank,
3513 &results,
3514 vector_outcome.degradations,
3515 vector_outcome.receipt_metadata,
3516 );
3517 Ok(SearchExecution { results, receipt })
3518}
3519
3520pub(crate) fn vector_only_search_detailed(
3521 conn: &Connection,
3522 query_embedding: &[f32],
3523 config: &SearchConfig,
3524 top_k: usize,
3525 namespaces: Option<&[&str]>,
3526 source_types: Option<&[SearchSourceType]>,
3527 session_ids: Option<&[&str]>,
3528) -> Result<Vec<ExplainedResult>, MemoryError> {
3529 let context = SearchContext::default_now();
3530 Ok(vector_only_search_detailed_with_context(
3531 conn,
3532 query_embedding,
3533 config,
3534 &context,
3535 top_k,
3536 namespaces,
3537 source_types,
3538 session_ids,
3539 )?
3540 .results)
3541}
3542
3543pub fn vector_only_search(
3545 conn: &Connection,
3546 query_embedding: &[f32],
3547 config: &SearchConfig,
3548 top_k: usize,
3549 namespaces: Option<&[&str]>,
3550 source_types: Option<&[SearchSourceType]>,
3551 session_ids: Option<&[&str]>,
3552) -> Result<Vec<SearchResult>, MemoryError> {
3553 Ok(vector_only_search_detailed(
3554 conn,
3555 query_embedding,
3556 config,
3557 top_k,
3558 namespaces,
3559 source_types,
3560 session_ids,
3561 )?
3562 .into_iter()
3563 .map(|result| result.result)
3564 .collect())
3565}
3566
3567#[cfg(test)]
3568mod digest_tests {
3569 use super::query_embedding_digest;
3570
3571 #[test]
3572 fn query_embedding_digest_includes_dimension_and_bytes() {
3573 let two_dims = query_embedding_digest(&[1.0, 2.0]);
3574 let three_dims = query_embedding_digest(&[1.0, 2.0, 0.0]);
3575 let changed_byte = query_embedding_digest(&[1.0, 2.000_001]);
3576
3577 assert!(two_dims.starts_with("blake3:"));
3578 assert_eq!(two_dims.len(), 71);
3579 assert_ne!(two_dims, three_dims);
3580 assert_ne!(two_dims, changed_byte);
3581 assert_eq!(two_dims, query_embedding_digest(&[1.0, 2.0]));
3582 }
3583}
3584
3585#[cfg(feature = "hnsw")]
3587#[allow(clippy::too_many_arguments)]
3588pub fn vector_only_search_with_hnsw(
3589 conn: &Connection,
3590 query_embedding: &[f32],
3591 config: &SearchConfig,
3592 top_k: usize,
3593 namespaces: Option<&[&str]>,
3594 source_types: Option<&[SearchSourceType]>,
3595 session_ids: Option<&[&str]>,
3596 hnsw_hits: &[crate::hnsw::HnswHit],
3597) -> Result<Vec<SearchResult>, MemoryError> {
3598 Ok(vector_only_search_with_hnsw_detailed(
3599 conn,
3600 query_embedding,
3601 config,
3602 top_k,
3603 namespaces,
3604 source_types,
3605 session_ids,
3606 hnsw_hits,
3607 )?
3608 .into_iter()
3609 .map(|result| result.result)
3610 .collect())
3611}
3612
3613#[cfg(feature = "hnsw")]
3614#[allow(clippy::too_many_arguments)]
3615pub(crate) fn vector_only_search_with_hnsw_detailed_with_context(
3616 conn: &Connection,
3617 query_embedding: &[f32],
3618 config: &SearchConfig,
3619 context: &SearchContext,
3620 top_k: usize,
3621 namespaces: Option<&[&str]>,
3622 source_types: Option<&[SearchSourceType]>,
3623 session_ids: Option<&[&str]>,
3624 hnsw_hits: &[crate::hnsw::HnswHit],
3625) -> Result<SearchExecution, MemoryError> {
3626 let mut vector_hits = resolve_hnsw_hits_batched(
3627 conn,
3628 query_embedding,
3629 config,
3630 namespaces,
3631 source_types,
3632 session_ids,
3633 hnsw_hits,
3634 )?;
3635 let mut fallback = None;
3636 let mut degradations = Vec::new();
3637 let mut backend = "hnsw";
3638 let mut exact_rerank = config.rerank_from_f32;
3639
3640 if !hnsw_hits.is_empty()
3641 && vector_hits.len() < top_k
3642 && filters_are_active(namespaces, source_types, session_ids)
3643 {
3644 fallback = Some("hnsw_filtered_underreturn_fallback".to_string());
3645 degradations.push(format!(
3646 "HNSW returned {} post-filter vector candidates for requested top_k {}; exact filtered fallback was used",
3647 vector_hits.len(),
3648 top_k
3649 ));
3650 vector_hits = vector_search(
3651 conn,
3652 query_embedding,
3653 top_k,
3654 config.min_similarity,
3655 namespaces,
3656 source_types,
3657 session_ids,
3658 )?;
3659 backend = "hnsw_then_brute_force_f32";
3660 exact_rerank = true;
3661 }
3662
3663 let results = rrf_fuse_detailed_with_context(&[], &vector_hits, config, context, top_k);
3664 let receipt = build_receipt(
3665 context,
3666 query_embedding,
3667 "vector_only",
3668 backend,
3669 top_k,
3670 hnsw_hits.len(),
3671 vector_hits.len(),
3672 fallback,
3673 exact_rerank,
3674 &results,
3675 degradations,
3676 );
3677 Ok(SearchExecution { results, receipt })
3678}
3679
3680#[cfg(feature = "hnsw")]
3681#[allow(clippy::too_many_arguments)]
3682pub(crate) fn vector_only_search_with_hnsw_detailed(
3683 conn: &Connection,
3684 query_embedding: &[f32],
3685 config: &SearchConfig,
3686 top_k: usize,
3687 namespaces: Option<&[&str]>,
3688 source_types: Option<&[SearchSourceType]>,
3689 session_ids: Option<&[&str]>,
3690 hnsw_hits: &[crate::hnsw::HnswHit],
3691) -> Result<Vec<ExplainedResult>, MemoryError> {
3692 let context = SearchContext::default_now();
3693 Ok(vector_only_search_with_hnsw_detailed_with_context(
3694 conn,
3695 query_embedding,
3696 config,
3697 &context,
3698 top_k,
3699 namespaces,
3700 source_types,
3701 session_ids,
3702 hnsw_hits,
3703 )?
3704 .results)
3705}
3706
3707fn build_filter_clause(
3708 column: &str,
3709 values: Option<&[&str]>,
3710 param_offset: usize,
3711) -> (String, Vec<SqlValue>) {
3712 match values {
3713 Some(values) if !values.is_empty() => {
3714 let placeholders = (0..values.len())
3715 .map(|idx| format!("?{}", param_offset + idx))
3716 .collect::<Vec<_>>();
3717 let clause = format!(" AND {} IN ({})", column, placeholders.join(", "));
3718 let params = values
3719 .iter()
3720 .map(|value| SqlValue::Text((*value).to_string()))
3721 .collect();
3722 (clause, params)
3723 }
3724 _ => (String::new(), Vec::new()),
3725 }
3726}
3727
3728pub fn deduplicate_results(results: Vec<SearchResult>) -> Vec<SearchResult> {
3730 let mut seen = HashSet::new();
3731 results
3732 .into_iter()
3733 .filter(|result| seen.insert(source_dedup_key(&result.source)))
3734 .collect()
3735}
3736
3737#[cfg(test)]
3738mod tests {
3739 use super::*;
3740
3741 fn vector_row(id: &str) -> VectorRow {
3742 VectorRow {
3743 id: id.to_string(),
3744 content: format!("content {id}"),
3745 blob: bytemuck::cast_slice(&[1.0_f32, 0.0]).to_vec(),
3746 updated_at: None,
3747 source_type: SearchSourceType::Facts,
3748 filter_namespace: Some("default".to_string()),
3749 filter_session_id: None,
3750 source: SearchSource::Fact {
3751 fact_id: id.to_string(),
3752 namespace: "default".to_string(),
3753 },
3754 }
3755 }
3756
3757 #[test]
3758 fn timestamp_parser_accepts_sql_fractional_and_rfc3339_and_warns_by_returning_none() {
3759 assert!(parse_search_timestamp("2026-05-07 12:34:56").is_some());
3760 assert!(parse_search_timestamp("2026-05-07 12:34:56.123").is_some());
3761 assert!(parse_search_timestamp("2026-05-07T12:34:56Z").is_some());
3762 assert!(parse_search_timestamp("not-a-timestamp").is_none());
3763 }
3764
3765 #[test]
3766 fn vector_scan_hard_limit_blocks_before_unbounded_scan() {
3767 let old_warn = VECTOR_SCAN_WARN_LIMIT.swap(1, Ordering::SeqCst);
3768 let old_hard = VECTOR_SCAN_BLOCK_LIMIT.swap(2, Ordering::SeqCst);
3769 let rows = ["a", "b", "c"].into_iter().map(|id| Ok(vector_row(id)));
3770 let result = scan_vector_rows(rows, &[1.0, 0.0], -1.0, "fact");
3771 VECTOR_SCAN_WARN_LIMIT.store(old_warn, Ordering::SeqCst);
3772 VECTOR_SCAN_BLOCK_LIMIT.store(old_hard, Ordering::SeqCst);
3773
3774 match result {
3775 Err(MemoryError::VectorScanLimitExceeded {
3776 table,
3777 scanned,
3778 limit,
3779 }) => {
3780 assert_eq!(table, "fact");
3781 assert_eq!(scanned, 3);
3782 assert_eq!(limit, 2);
3783 }
3784 other => panic!("expected vector scan limit error, got {other:?}"),
3785 }
3786 }
3787}