1use std::collections::HashMap;
6
7use crate::Engine;
8use crate::auth::ScopedSpace;
9use crate::error::{Result, SconeError};
10
11const CANDIDATES_PER_GENERATOR: usize = 50;
12
13const QUERY_STOPWORDS: [&str; 33] = [
18 "a", "an", "the", "i", "me", "my", "we", "our", "you", "your", "it", "is", "was", "were",
19 "are", "be", "been", "do", "does", "did", "have", "has", "had", "what", "when", "where",
20 "which", "who", "how", "why", "say", "said", "about",
21];
22
23fn bm25_query(query: &str) -> String {
24 let kept: Vec<&str> = query
25 .split_whitespace()
26 .filter(|t| {
27 !QUERY_STOPWORDS.contains(
28 &t.to_lowercase()
29 .trim_matches(|c: char| !c.is_alphanumeric()),
30 )
31 })
32 .collect();
33 if kept.is_empty() {
34 query.to_owned()
35 } else {
36 kept.join(" ")
37 }
38}
39const RRF_K: f32 = 60.0;
40const W_FUSED: f32 = 0.8;
41const W_RECENCY: f32 = 0.2;
42const RECENCY_HALF_LIFE_DAYS: f32 = 30.0;
43const W_RERANK: f32 = 0.7;
47
48#[derive(Debug, Clone)]
49pub struct RecallOpts {
50 pub limit: usize,
51 pub budget_bytes: Option<usize>,
52 pub as_of: Option<String>,
55 pub expand_neighbors: bool,
61 pub tags: Vec<String>,
64}
65
66impl Default for RecallOpts {
67 fn default() -> Self {
68 Self {
69 limit: 10,
70 budget_bytes: None,
71 as_of: None,
72 expand_neighbors: false,
73 tags: Vec::new(),
74 }
75 }
76}
77
78#[derive(Debug, Clone)]
79pub struct FactItem {
80 pub fact_id: i64,
81 pub subject: String,
82 pub predicate: String,
83 pub object: String,
84 pub confidence: f32,
85 pub valid_from: String,
86 pub valid_until: Option<String>,
87 pub status: String,
88}
89
90#[derive(Debug, Clone)]
91pub struct RecallItem {
92 pub chunk_id: i64,
93 pub episode_id: i64,
94 pub text: String,
95 pub score: f32,
96 pub source: Option<String>,
97 pub created_at: String,
98}
99
100#[derive(Debug, Default)]
101pub struct ContextPack {
102 pub facts: Vec<FactItem>,
104 pub items: Vec<RecallItem>,
105 pub degraded: Vec<String>,
107 pub returned_bytes: usize,
110 pub space_bytes: i64,
112}
113
114impl ContextPack {
115 pub fn context_reduction(&self) -> f64 {
117 if self.space_bytes <= 0 {
118 return 0.0;
119 }
120 1.0 - (self.returned_bytes as f64 / self.space_bytes as f64)
121 }
122}
123
124impl Engine {
125 pub fn recall(
126 &mut self,
127 space: &ScopedSpace,
128 query: &str,
129 opts: &RecallOpts,
130 ) -> Result<ContextPack> {
131 if query.trim().is_empty() {
132 return Err(SconeError::InvalidInput("query is empty".into()));
133 }
134 self.flush_indexes()?;
136 let mut degraded = Vec::new();
137
138 let facts = self.recall_facts(space, query, opts)?;
141
142 let fts_hits = match self.fts.search(
144 space.id() as u64,
145 &bm25_query(query),
146 CANDIDATES_PER_GENERATOR,
147 ) {
148 Ok(hits) => hits,
149 Err(SconeError::InvalidInput(msg)) => {
150 degraded.push(format!("fts: {msg}"));
151 Vec::new()
152 }
153 Err(other) => return Err(other),
154 };
155 let vec_hits = {
156 let q = self.embedder.embed(&[query])?;
157 match q.first() {
158 Some(qv) => self.vectors.search(qv, CANDIDATES_PER_GENERATOR)?,
159 None => Vec::new(),
160 }
161 };
162
163 let mut fused: HashMap<u64, f32> = HashMap::new();
165 for hits in [&fts_hits, &vec_hits] {
166 for (rank, (chunk_id, _)) in hits.iter().enumerate() {
167 *fused.entry(*chunk_id).or_insert(0.0) += 1.0 / (RRF_K + rank as f32 + 1.0);
168 }
169 }
170 let max_fused = fused
171 .values()
172 .cloned()
173 .fold(0.0f32, f32::max)
174 .max(f32::MIN_POSITIVE);
175
176 let mut items = Vec::new();
179 {
180 let normalized_tags: Vec<String> =
183 opts.tags.iter().map(|t| t.trim().to_lowercase()).collect();
184 let tag_filter = if normalized_tags.is_empty() {
185 String::new()
186 } else {
187 format!(
188 " AND (SELECT count(DISTINCT t.name) FROM episode_tags et
189 JOIN tags t ON t.id = et.tag_id
190 WHERE et.episode_id = e.id AND t.name IN ({})) = {}",
191 normalized_tags
192 .iter()
193 .map(|_| "?")
194 .collect::<Vec<_>>()
195 .join(","),
196 normalized_tags.len()
197 )
198 };
199 let sql = format!(
200 "SELECT c.episode_id, c.start_byte, c.end_byte, e.content, e.source,
201 e.created_at,
202 (julianday('now') - julianday(e.created_at)) AS age_days
203 FROM chunks c JOIN episodes e ON e.id = c.episode_id
204 WHERE c.id = ?1 AND e.space_id = ?2{tag_filter}"
205 );
206 let mut stmt = self.conn.prepare(&sql)?;
207 for (chunk_id, fused_score) in &fused {
208 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
209 vec![Box::new(*chunk_id as i64), Box::new(space.id())];
210 for tag in &normalized_tags {
211 params.push(Box::new(tag.clone()));
212 }
213 let row = stmt.query_row(
214 rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())),
215 |r| {
216 Ok((
217 r.get::<_, i64>(0)?,
218 r.get::<_, i64>(1)?,
219 r.get::<_, i64>(2)?,
220 r.get::<_, String>(3)?,
221 r.get::<_, Option<String>>(4)?,
222 r.get::<_, String>(5)?,
223 r.get::<_, f64>(6)?,
224 ))
225 },
226 );
227 let (episode_id, start, end, content, source, created_at, age_days) = match row {
228 Ok(r) => r,
229 Err(rusqlite::Error::QueryReturnedNoRows) => continue,
230 Err(e) => return Err(SconeError::Db(e)),
231 };
232 let (start, end) = (start as usize, end as usize);
233 let text = content.get(start..end).unwrap_or_default().to_owned();
234 let recency = (-(age_days.max(0.0) as f32) / RECENCY_HALF_LIFE_DAYS).exp();
235 let score = W_FUSED * (fused_score / max_fused) + W_RECENCY * recency;
236 items.push(RecallItem {
237 chunk_id: *chunk_id as i64,
238 episode_id,
239 text,
240 score,
241 source,
242 created_at,
243 });
244 }
245 }
246 if let Some(reranker) = &self.reranker
249 && !items.is_empty()
250 {
251 let docs: Vec<&str> = items.iter().map(|i| i.text.as_str()).collect();
252 match reranker.rerank(query, &docs) {
253 Ok(scores) => {
254 let (lo, hi) = scores
255 .iter()
256 .fold((f32::MAX, f32::MIN), |(lo, hi), s| (lo.min(*s), hi.max(*s)));
257 let span = (hi - lo).max(f32::MIN_POSITIVE);
258 for (item, raw) in items.iter_mut().zip(&scores) {
259 let normalized = (raw - lo) / span;
260 item.score = W_RERANK * normalized + (1.0 - W_RERANK) * item.score;
261 }
262 }
263 Err(e) => degraded.push(format!("reranker: {e}")),
264 }
265 }
266
267 items.sort_by(|a, b| b.score.total_cmp(&a.score));
268 items.truncate(opts.limit);
269
270 let mut expanded: Vec<RecallItem> = Vec::with_capacity(items.len());
274 for mut item in items {
275 if !opts.expand_neighbors {
276 expanded.push(item);
277 continue;
278 }
279 let row = self.conn.query_row(
280 "SELECT e.content,
281 (SELECT min(start_byte) FROM chunks n
282 WHERE n.episode_id = c.episode_id AND n.pos BETWEEN c.pos - 1 AND c.pos + 1),
283 (SELECT max(end_byte) FROM chunks n
284 WHERE n.episode_id = c.episode_id AND n.pos BETWEEN c.pos - 1 AND c.pos + 1)
285 FROM chunks c JOIN episodes e ON e.id = c.episode_id
286 WHERE c.id = ?1",
287 [item.chunk_id],
288 |r| {
289 Ok((
290 r.get::<_, String>(0)?,
291 r.get::<_, i64>(1)?,
292 r.get::<_, i64>(2)?,
293 ))
294 },
295 );
296 if let Ok((content, start, end)) = row {
297 let (start, end) = (start as usize, end as usize);
298 if let Some(wider) = content.get(start..end) {
299 item.text = wider.to_owned();
300 }
301 }
302 let redundant = expanded
303 .iter()
304 .any(|kept| kept.episode_id == item.episode_id && kept.text.contains(&item.text));
305 if !redundant {
306 expanded.push(item);
307 }
308 }
309 let mut items = expanded;
310
311 if let Some(budget) = opts.budget_bytes {
314 let mut used = 0usize;
315 let mut kept = Vec::new();
316 for item in items {
317 if !kept.is_empty() && used + item.text.len() > budget {
318 break;
319 }
320 used += item.text.len();
321 kept.push(item);
322 }
323 items = kept;
324 }
325
326 if let Some(budget) = opts.budget_bytes {
329 let facts_bytes: usize = facts
330 .iter()
331 .map(|f| f.subject.len() + f.predicate.len() + f.object.len())
332 .sum();
333 let chunk_budget = budget.saturating_sub(facts_bytes);
334 let mut used = 0usize;
335 let mut kept = Vec::new();
336 for item in items {
337 if !kept.is_empty() && used + item.text.len() > chunk_budget {
338 break;
339 }
340 used += item.text.len();
341 kept.push(item);
342 }
343 items = kept;
344 }
345
346 let returned_bytes = items.iter().map(|i| i.text.len()).sum();
347 let space_bytes = self.conn.query_row(
348 "SELECT coalesce(sum(length(content)), 0) FROM episodes WHERE space_id = ?1",
349 [space.id()],
350 |r| r.get(0),
351 )?;
352 Ok(ContextPack {
353 facts,
354 items,
355 degraded,
356 returned_bytes,
357 space_bytes,
358 })
359 }
360
361 fn recall_facts(
362 &mut self,
363 space: &ScopedSpace,
364 query: &str,
365 opts: &RecallOpts,
366 ) -> Result<Vec<FactItem>> {
367 let terms: Vec<String> = query
368 .to_lowercase()
369 .split_whitespace()
370 .filter(|t| t.len() > 2)
371 .map(|t| format!("%{t}%"))
372 .collect();
373 if terms.is_empty() {
374 return Ok(Vec::new());
375 }
376 let as_of = opts
377 .as_of
378 .clone()
379 .unwrap_or_else(|| "now-sentinel".to_owned());
380 let mut found: Vec<FactItem> = Vec::new();
381 {
382 let mut stmt = self.conn.prepare(
383 "SELECT f.id, en.canonical, f.predicate, f.object, f.confidence,
384 f.valid_from, f.valid_until, f.status
385 FROM facts f
386 JOIN entities en ON en.id = f.subject_entity
387 WHERE f.space_id = ?1
388 AND f.valid_from <= ?2
389 AND (f.valid_until IS NULL OR f.valid_until > ?2)
390 AND (en.canonical LIKE ?3 OR f.predicate LIKE ?3 OR f.object LIKE ?3
391 OR EXISTS (SELECT 1 FROM entity_aliases a
392 WHERE a.entity_id = f.subject_entity AND a.alias LIKE ?3))
393 ORDER BY f.confidence DESC, f.access_count DESC
394 LIMIT ?4",
395 )?;
396 let now: String =
397 self.conn
398 .query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')", [], |r| {
399 r.get(0)
400 })?;
401 let effective = if as_of == "now-sentinel" { now } else { as_of };
402 for term in &terms {
403 let rows = stmt.query_map(
404 rusqlite::params![space.id(), effective, term, opts.limit as i64],
405 |r| {
406 Ok(FactItem {
407 fact_id: r.get(0)?,
408 subject: r.get(1)?,
409 predicate: r.get(2)?,
410 object: r.get(3)?,
411 confidence: r.get(4)?,
412 valid_from: r.get(5)?,
413 valid_until: r.get(6)?,
414 status: r.get(7)?,
415 })
416 },
417 )?;
418 for row in rows {
419 let row = row?;
420 if !found.iter().any(|f| f.fact_id == row.fact_id) {
421 found.push(row);
422 }
423 }
424 }
425 }
426 found.truncate(opts.limit);
427 if opts.as_of.is_none() {
430 for f in &found {
431 self.conn.execute(
432 "UPDATE facts SET access_count = access_count + 1,
433 last_accessed = strftime('%Y-%m-%dT%H:%M:%fZ','now')
434 WHERE id = ?1",
435 [f.fact_id],
436 )?;
437 }
438 }
439 Ok(found)
440 }
441}