Skip to main content

semantic_memory/
search.rs

1//! Hybrid search engine: BM25 + vector similarity + Reciprocal Rank Fusion.
2
3use 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// `OptionalExtension` provides `Result::optional()` for `rusqlite::query_row`.
13// Four unconditional call sites in this file use it; keep the import
14// always available. The trait is light (zero runtime cost) so the
15// `#[allow(unused_imports)]` is the only cost when no callsite is in
16// scope on a given feature set.
17#[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
25/// Per-table row count above which vector search emits a warning.
26const VECTOR_SCAN_WARN_THRESHOLD: usize = 50_000;
27/// Per-table row count above which brute-force vector search is refused.
28const 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/// Expand query terms to match hyphenated variants.
34/// "turbo-quant" -> "turbo-quant OR turboquant"
35/// This improves BM25 recall for technical terms with hyphens.
36#[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
57/// Classify whether a query needs retrieval at all.
58/// Simple greetings, confirmations, and single-word responses don't need search.
59pub 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
105/// Sanitize a raw query string for safe use in an FTS5 MATCH expression.
106///
107/// Replaces any character that is not alphanumeric, whitespace, or a Unicode
108/// letter/digit with a space, then strips FTS5 boolean keywords (`AND`, `OR`,
109/// `NOT`, `NEAR`).  Returns `None` when no searchable tokens remain.
110///
111/// This uses an allowlist strategy so that *any* FTS5 operator or punctuation
112/// — including `?`, `.`, `/`, `!`, etc. — is neutralised without needing an
113/// exhaustive denylist.
114pub 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
144/// Compute cosine similarity between two vectors.
145pub 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/// A BM25 search hit from FTS5.
238#[derive(Debug, Clone)]
239pub struct Bm25Hit {
240    /// Search item key such as `fact:{uuid}` or `episode:{episode_id}`.
241    pub id: String,
242    /// Text content returned to callers.
243    pub content: String,
244    /// Source info.
245    pub source: SearchSource,
246    /// Raw BM25 score reported by SQLite FTS5.
247    pub raw_score: f64,
248    /// Timestamp used for recency scoring.
249    pub updated_at: Option<String>,
250    /// Temporal weight for stale-fact downranking (0.0-1.0, default 1.0).
251    pub temporal_weight: Option<f64>,
252    /// Provenance confidence (0.0-1.0, default 0.5).
253    pub provenance_confidence: Option<f64>,
254}
255
256/// A vector search hit.
257#[derive(Debug, Clone)]
258pub struct VectorHit {
259    /// Search item key such as `fact:{uuid}` or `episode:{episode_id}`.
260    pub id: String,
261    /// Text content returned to callers.
262    pub content: String,
263    /// Source info.
264    pub source: SearchSource,
265    /// Final similarity used for vector ranking.
266    pub similarity: f64,
267    /// Timestamp used for recency scoring.
268    pub updated_at: Option<String>,
269    /// Rank from the underlying retrieval stage before exact reranking.
270    pub source_rank: Option<usize>,
271    /// Similarity from the underlying retrieval stage before exact reranking.
272    pub source_similarity: Option<f64>,
273    /// Whether exact f32 reranking changed or confirmed this candidate ordering.
274    pub reranked_from_f32: bool,
275    /// Temporal weight for stale-fact downranking (0.0-1.0, default 1.0).
276    pub temporal_weight: Option<f64>,
277    /// Provenance confidence (0.0-1.0, default 0.5).
278    pub provenance_confidence: Option<f64>,
279}
280
281/// A genuine sparse dot-product search hit.
282#[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 (ColBERT MaxSim) rank — 3rd RRF signal.
317    late_interaction_rank: Option<usize>,
318    /// Late interaction raw score. Populated only with the `late-interaction` feature.
319    #[allow(dead_code)]
320    late_interaction_score: Option<f64>,
321    /// Temporal weight for stale-fact downranking (0.0-1.0, default 1.0).
322    temporal_weight: Option<f64>,
323    /// Provenance confidence (0.0-1.0, default 0.5). Higher = more trustworthy.
324    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        // Late interaction contribution: uses same RRF formula with late_interaction_weight.
339        // Defaults to 0.0 weight when not configured (backward compatible).
340        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        // Apply temporal weight: stale facts (weight < 1.0) get downranked.
356        // Default weight is 1.0 (no effect) when temporal feature is not active.
357        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        // Apply namespace weight if configured
361        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
501/// Run BM25 search over facts_fts, chunks_fts, episodes_fts, and optionally messages_fts.
502pub(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(&params), |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(&params), |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(&params), |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(&params), |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
721/// Run brute-force vector search over facts, chunks, episodes, and optionally messages.
722pub(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/// Compressed candidate selection: scan q8 embeddings for approximate
971/// candidate filtering, then rerank top candidates with exact f32 cosine.
972///
973/// This reduces the number of full f32 cosine computations from N (all
974/// rows) to ~4×pool_size (just the top candidates), giving ~20x speedup
975/// on large stores with zero recall loss.
976#[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    // Phase 2: Exact f32 rerank
1042    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    // Compressed candidate selection: use q8 embeddings for initial filter,
1129    // then exact f32 rerank on top candidates. Falls back to brute-force on
1130    // any error (fail-open).
1131    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    // Oversampling is bounded and allows post-score namespace/source/session
1772    // filtering without admitting an unbounded candidate set to fusion.
1773    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    // CONVENTION EXCEPTION: O(1) lookup required for performance-critical search path
1862    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
2014/// Fuse BM25 and vector results via Reciprocal Rank Fusion.
2015pub 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/// Fuse BM25, vector, and late interaction results via Reciprocal Rank
2028/// Fusion. This is the 3-signal RRF pipeline: BM25 + dense vector +
2029/// ColBERT-style late interaction.
2030///
2031/// `late_interaction_scores` is a list of (item_key, score) pairs where
2032/// item_key is the dedup key string (same format as source_dedup_key).
2033#[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    // Insert BM25 hits.
2045    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    // Insert vector hits.
2078    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    // Insert late interaction hits (ranked by score descending).
2114    // Match against existing candidates by scanning for matching content/source.
2115    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        // Try to find an existing candidate whose content or source matches item_key.
2120        // This is a simple string match — in production the caller would
2121        // provide proper dedup keys matching the source_dedup_key format.
2122        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        // If no match, the late interaction score doesn't contribute to
2131        // any existing candidate. We don't create new candidates for
2132        // late-interaction-only items since we don't have content/source info.
2133    }
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
2153/// Compute proxy late interaction scores by splitting the query embedding
2154/// into segments and running MaxSim against each vector hit's embedding.
2155///
2156/// This is an approximation of ColBERT late interaction using existing
2157/// dense embeddings. The query embedding is split into N segments (where
2158/// N = embedding_dim / segment_size), and for each segment, the maximum
2159/// cosine similarity with segments of the document embedding is computed.
2160///
2161/// Returns a list of (source_dedup_key_string, score) pairs.
2162fn 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/// Rerank a vector hit by recomputing cosine similarity with the full embedding.
2352/// Fetches the stored embedding for the hit's source from SQLite.
2353#[allow(dead_code)]
2354fn rerank_hit_with_full_embedding(
2355    conn: &Connection,
2356    query_embedding: &[f32],
2357    hit: &VectorHit,
2358) -> Result<f64, MemoryError> {
2359    // Fetch the stored embedding blob for this hit's source.
2360    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), // keep existing if no blob
2401    };
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); // dimension mismatch, keep existing
2406    }
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    // Task 3: Matryoshka 2-stage search — truncate query embedding to candidate_dims
2450    // for coarse retrieval, then rerank with full embedding. Falls back to direct
2451    // search if the 64d index doesn't exist or matryoshka feature is off.
2452    #[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                        // Rerank coarse candidates with full-dimension embedding.
2474                        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                            // Coarse stage produced no usable candidates (e.g. dimension
2497                            // mismatch because stored embeddings are not truncated to the
2498                            // matryoshka candidate dimension). Keep the original full-dimension
2499                            // outcome rather than silently discarding vector evidence.
2500                            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(_) => { /* keep original vector_outcome */ }
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        // Late interaction 3rd RRF signal: compute proxy MaxSim scores by
2550        // splitting the query embedding into segments and comparing against
2551        // document embeddings. This is an approximation of ColBERT late
2552        // interaction using existing dense embeddings.
2553        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/// Perform a hybrid search and return the exact score decomposition.
2639#[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/// Perform a hybrid search (BM25 + vector + RRF).
2663#[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    // Content dedup: remove results with identical or near-identical content,
2689    // keeping the highest-scoring one. This prevents duplicate chunks from
2690    // different document copies appearing in search results.
2691    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            // Normalize whitespace and use first 200 chars as fingerprint.
2696            // This catches near-duplicates with minor whitespace differences.
2697            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    // CONVENTION EXCEPTION: O(1) lookup required for performance-critical search path
2743    let mut fact_entries: HashMap<String, HnswCandidateSeed> = HashMap::new();
2744    // CONVENTION EXCEPTION: O(1) lookup required for performance-critical search path
2745    let mut chunk_entries: HashMap<String, HnswCandidateSeed> = HashMap::new();
2746    // CONVENTION EXCEPTION: O(1) lookup required for performance-critical search path
2747    let mut message_entries: HashMap<i64, HnswCandidateSeed> = HashMap::new();
2748    // CONVENTION EXCEPTION: O(1) lookup required for performance-critical search path
2749    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    // CONVENTION EXCEPTION: O(1) lookup required for performance-critical search path
2892    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(&params), |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    // CONVENTION EXCEPTION: O(1) lookup required for performance-critical search path
2956    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(&params), |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    // CONVENTION EXCEPTION: O(1) lookup required for performance-critical search path
3038    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(&params), |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    // CONVENTION EXCEPTION: O(1) lookup required for performance-critical search path
3104    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(&params), |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/// Perform a hybrid search using pre-computed HNSW hits for the vector component.
3180#[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/// Perform a hybrid HNSW-backed search and return the exact score decomposition.
3366#[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
3456/// Full-text search only (no embeddings needed). Synchronous.
3457pub 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
3543/// Vector-only search. Called after embedding the query.
3544pub 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/// Vector-only search using pre-computed HNSW hits.
3586#[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
3728/// Deduplicate results by (source_type, source_id), keeping the first occurrence.
3729pub 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}