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 W_DATE_MATCH: f32 = 0.4;
45const RECENCY_HALF_LIFE_DAYS: f32 = 30.0;
46const W_RERANK: f32 = 0.7;
50
51#[derive(Debug, Clone)]
52pub struct RecallOpts {
53 pub limit: usize,
54 pub budget_bytes: Option<usize>,
55 pub as_of: Option<String>,
58 pub expand_neighbors: bool,
64 pub tags: Vec<String>,
67}
68
69impl Default for RecallOpts {
70 fn default() -> Self {
71 Self {
72 limit: 10,
73 budget_bytes: None,
74 as_of: None,
75 expand_neighbors: false,
76 tags: Vec::new(),
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
82pub struct FactItem {
83 pub fact_id: i64,
84 pub subject: String,
85 pub predicate: String,
86 pub object: String,
87 pub confidence: f32,
88 pub valid_from: String,
89 pub valid_until: Option<String>,
90 pub status: String,
91}
92
93#[derive(Debug, Clone)]
94pub struct RecallItem {
95 pub chunk_id: i64,
96 pub episode_id: i64,
97 pub text: String,
98 pub score: f32,
99 pub source: Option<String>,
100 pub created_at: String,
101}
102
103impl RecallItem {
104 pub fn day(&self) -> &str {
109 self.created_at
110 .split('T')
111 .next()
112 .unwrap_or(&self.created_at)
113 }
114}
115
116#[derive(Debug, Default)]
117pub struct ContextPack {
118 pub facts: Vec<FactItem>,
120 pub items: Vec<RecallItem>,
121 pub degraded: Vec<String>,
123 pub returned_bytes: usize,
126 pub space_bytes: i64,
128}
129
130impl ContextPack {
131 pub fn context_reduction(&self) -> f64 {
133 if self.space_bytes <= 0 {
134 return 0.0;
135 }
136 1.0 - (self.returned_bytes as f64 / self.space_bytes as f64)
137 }
138}
139
140impl Engine {
141 pub fn recall(
142 &mut self,
143 space: &ScopedSpace,
144 query: &str,
145 opts: &RecallOpts,
146 ) -> Result<ContextPack> {
147 if query.trim().is_empty() {
148 return Err(SconeError::InvalidInput("query is empty".into()));
149 }
150 self.flush_indexes()?;
152 let mut degraded = Vec::new();
153 let date_windows = crate::timeparse::date_windows(query);
154
155 let facts = self.recall_facts(space, query, opts)?;
158
159 let fts_hits = match self.fts.search(
161 space.id() as u64,
162 &bm25_query(query),
163 CANDIDATES_PER_GENERATOR,
164 ) {
165 Ok(hits) => hits,
166 Err(SconeError::InvalidInput(msg)) => {
167 degraded.push(format!("fts: {msg}"));
168 Vec::new()
169 }
170 Err(other) => return Err(other),
171 };
172 let vec_hits = {
173 let q = self.embedder.embed(&[query])?;
174 match q.first() {
175 Some(qv) => self.vectors.search(qv, CANDIDATES_PER_GENERATOR)?,
176 None => Vec::new(),
177 }
178 };
179
180 let mut fused: HashMap<u64, f32> = HashMap::new();
182 for hits in [&fts_hits, &vec_hits] {
183 for (rank, (chunk_id, _)) in hits.iter().enumerate() {
184 *fused.entry(*chunk_id).or_insert(0.0) += 1.0 / (RRF_K + rank as f32 + 1.0);
185 }
186 }
187 let max_fused = fused
188 .values()
189 .cloned()
190 .fold(0.0f32, f32::max)
191 .max(f32::MIN_POSITIVE);
192
193 let mut items = Vec::new();
196 {
197 let normalized_tags: Vec<String> =
200 opts.tags.iter().map(|t| t.trim().to_lowercase()).collect();
201 let tag_filter = if normalized_tags.is_empty() {
202 String::new()
203 } else {
204 format!(
205 " AND (SELECT count(DISTINCT t.name) FROM episode_tags et
206 JOIN tags t ON t.id = et.tag_id
207 WHERE et.episode_id = e.id AND t.name IN ({})) = {}",
208 normalized_tags
209 .iter()
210 .map(|_| "?")
211 .collect::<Vec<_>>()
212 .join(","),
213 normalized_tags.len()
214 )
215 };
216 let sql = format!(
217 "SELECT c.episode_id, c.start_byte, c.end_byte, e.content, e.source,
218 e.created_at,
219 (julianday('now') - julianday(e.created_at)) AS age_days
220 FROM chunks c JOIN episodes e ON e.id = c.episode_id
221 WHERE c.id = ?1 AND e.space_id = ?2{tag_filter}"
222 );
223 let mut stmt = self.conn.prepare(&sql)?;
224 for (chunk_id, fused_score) in &fused {
225 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> =
226 vec![Box::new(*chunk_id as i64), Box::new(space.id())];
227 for tag in &normalized_tags {
228 params.push(Box::new(tag.clone()));
229 }
230 let row = stmt.query_row(
231 rusqlite::params_from_iter(params.iter().map(|p| p.as_ref())),
232 |r| {
233 Ok((
234 r.get::<_, i64>(0)?,
235 r.get::<_, i64>(1)?,
236 r.get::<_, i64>(2)?,
237 r.get::<_, String>(3)?,
238 r.get::<_, Option<String>>(4)?,
239 r.get::<_, String>(5)?,
240 r.get::<_, f64>(6)?,
241 ))
242 },
243 );
244 let (episode_id, start, end, content, source, created_at, age_days) = match row {
245 Ok(r) => r,
246 Err(rusqlite::Error::QueryReturnedNoRows) => continue,
247 Err(e) => return Err(SconeError::Db(e)),
248 };
249 let (start, end) = (start as usize, end as usize);
250 let text = content.get(start..end).unwrap_or_default().to_owned();
251 let recency = (-(age_days.max(0.0) as f32) / RECENCY_HALF_LIFE_DAYS).exp();
252 let date_bonus = if date_windows.iter().any(|w| {
253 created_at.as_str() >= w.start.as_str() && created_at.as_str() <= w.end.as_str()
254 }) {
255 W_DATE_MATCH
256 } else {
257 0.0
258 };
259 let score = W_FUSED * (fused_score / max_fused) + W_RECENCY * recency + date_bonus;
260 items.push(RecallItem {
261 chunk_id: *chunk_id as i64,
262 episode_id,
263 text,
264 score,
265 source,
266 created_at,
267 });
268 }
269 }
270 if let Some(reranker) = &self.reranker
273 && !items.is_empty()
274 {
275 let docs: Vec<&str> = items.iter().map(|i| i.text.as_str()).collect();
276 match reranker.rerank(query, &docs) {
277 Ok(scores) => {
278 let (lo, hi) = scores
279 .iter()
280 .fold((f32::MAX, f32::MIN), |(lo, hi), s| (lo.min(*s), hi.max(*s)));
281 let span = (hi - lo).max(f32::MIN_POSITIVE);
282 for (item, raw) in items.iter_mut().zip(&scores) {
283 let normalized = (raw - lo) / span;
284 item.score = W_RERANK * normalized + (1.0 - W_RERANK) * item.score;
285 }
286 }
287 Err(e) => degraded.push(format!("reranker: {e}")),
288 }
289 }
290
291 items.sort_by(|a, b| b.score.total_cmp(&a.score));
292
293 const MAX_CHUNKS_PER_EPISODE: usize = 2;
298 let mut per_episode: HashMap<i64, usize> = HashMap::new();
299 let mut picked = Vec::with_capacity(opts.limit);
300 let mut overflow = Vec::new();
301 for item in items {
302 let count = per_episode.entry(item.episode_id).or_insert(0);
303 if *count < MAX_CHUNKS_PER_EPISODE {
304 *count += 1;
305 picked.push(item);
306 } else {
307 overflow.push(item);
308 }
309 if picked.len() == opts.limit {
310 break;
311 }
312 }
313 for item in overflow {
315 if picked.len() == opts.limit {
316 break;
317 }
318 picked.push(item);
319 }
320 let items = picked;
321
322 let mut expanded: Vec<RecallItem> = Vec::with_capacity(items.len());
326 for mut item in items {
327 if !opts.expand_neighbors {
328 expanded.push(item);
329 continue;
330 }
331 let row = self.conn.query_row(
332 "SELECT e.content,
333 (SELECT min(start_byte) FROM chunks n
334 WHERE n.episode_id = c.episode_id AND n.pos BETWEEN c.pos - 1 AND c.pos + 1),
335 (SELECT max(end_byte) FROM chunks n
336 WHERE n.episode_id = c.episode_id AND n.pos BETWEEN c.pos - 1 AND c.pos + 1)
337 FROM chunks c JOIN episodes e ON e.id = c.episode_id
338 WHERE c.id = ?1",
339 [item.chunk_id],
340 |r| {
341 Ok((
342 r.get::<_, String>(0)?,
343 r.get::<_, i64>(1)?,
344 r.get::<_, i64>(2)?,
345 ))
346 },
347 );
348 if let Ok((content, start, end)) = row {
349 let (start, end) = (start as usize, end as usize);
350 if let Some(wider) = content.get(start..end) {
351 item.text = wider.to_owned();
352 }
353 }
354 let redundant = expanded
355 .iter()
356 .any(|kept| kept.episode_id == item.episode_id && kept.text.contains(&item.text));
357 if !redundant {
358 expanded.push(item);
359 }
360 }
361 let mut items = expanded;
362
363 if let Some(budget) = opts.budget_bytes {
366 let mut used = 0usize;
367 let mut kept = Vec::new();
368 for item in items {
369 if !kept.is_empty() && used + item.text.len() > budget {
370 break;
371 }
372 used += item.text.len();
373 kept.push(item);
374 }
375 items = kept;
376 }
377
378 if let Some(budget) = opts.budget_bytes {
381 let facts_bytes: usize = facts
382 .iter()
383 .map(|f| f.subject.len() + f.predicate.len() + f.object.len())
384 .sum();
385 let chunk_budget = budget.saturating_sub(facts_bytes);
386 let mut used = 0usize;
387 let mut kept = Vec::new();
388 for item in items {
389 if !kept.is_empty() && used + item.text.len() > chunk_budget {
390 break;
391 }
392 used += item.text.len();
393 kept.push(item);
394 }
395 items = kept;
396 }
397
398 let returned_bytes = items.iter().map(|i| i.text.len()).sum();
399 let space_bytes = self.conn.query_row(
400 "SELECT coalesce(sum(length(content)), 0) FROM episodes WHERE space_id = ?1",
401 [space.id()],
402 |r| r.get(0),
403 )?;
404 Ok(ContextPack {
405 facts,
406 items,
407 degraded,
408 returned_bytes,
409 space_bytes,
410 })
411 }
412
413 fn recall_facts(
414 &mut self,
415 space: &ScopedSpace,
416 query: &str,
417 opts: &RecallOpts,
418 ) -> Result<Vec<FactItem>> {
419 let terms: Vec<String> = query
420 .to_lowercase()
421 .split_whitespace()
422 .filter(|t| t.len() > 2)
423 .map(|t| format!("%{t}%"))
424 .collect();
425 if terms.is_empty() {
426 return Ok(Vec::new());
427 }
428 let as_of = opts
429 .as_of
430 .clone()
431 .unwrap_or_else(|| "now-sentinel".to_owned());
432 let mut found: Vec<FactItem> = Vec::new();
433 {
434 let mut stmt = self.conn.prepare(
435 "SELECT f.id, en.canonical, f.predicate, f.object, f.confidence,
436 f.valid_from, f.valid_until, f.status
437 FROM facts f
438 JOIN entities en ON en.id = f.subject_entity
439 WHERE f.space_id = ?1
440 AND f.valid_from <= ?2
441 AND (f.valid_until IS NULL OR f.valid_until > ?2)
442 AND (en.canonical LIKE ?3 OR f.predicate LIKE ?3 OR f.object LIKE ?3
443 OR EXISTS (SELECT 1 FROM entity_aliases a
444 WHERE a.entity_id = f.subject_entity AND a.alias LIKE ?3))
445 ORDER BY f.confidence DESC, f.access_count DESC
446 LIMIT ?4",
447 )?;
448 let now: String =
449 self.conn
450 .query_row("SELECT strftime('%Y-%m-%dT%H:%M:%fZ','now')", [], |r| {
451 r.get(0)
452 })?;
453 let effective = if as_of == "now-sentinel" { now } else { as_of };
454 for term in &terms {
455 let rows = stmt.query_map(
456 rusqlite::params![space.id(), effective, term, opts.limit as i64],
457 |r| {
458 Ok(FactItem {
459 fact_id: r.get(0)?,
460 subject: r.get(1)?,
461 predicate: r.get(2)?,
462 object: r.get(3)?,
463 confidence: r.get(4)?,
464 valid_from: r.get(5)?,
465 valid_until: r.get(6)?,
466 status: r.get(7)?,
467 })
468 },
469 )?;
470 for row in rows {
471 let row = row?;
472 if !found.iter().any(|f| f.fact_id == row.fact_id) {
473 found.push(row);
474 }
475 }
476 }
477 }
478 found.truncate(opts.limit);
479 if opts.as_of.is_none() {
482 for f in &found {
483 self.conn.execute(
484 "UPDATE facts SET access_count = access_count + 1,
485 last_accessed = strftime('%Y-%m-%dT%H:%M:%fZ','now')
486 WHERE id = ?1",
487 [f.fact_id],
488 )?;
489 }
490 }
491 Ok(found)
492 }
493}