1use std::collections::HashMap;
23
24use rusqlite::{Connection, params};
25use serde::{Deserialize, Serialize};
26use uuid::Uuid;
27
28use crate::Grit;
29use crate::clock::TimestampMs;
30use crate::error::Result;
31use crate::model::{EDGE_COLS, EPISODE_COLS, Edge, Episode, NODE_COLS, Node, collect};
32use crate::query::ids_json;
33use crate::vecext::f32s_as_bytes;
34
35const RRF_K: f64 = 60.0;
37const LEG_LIMIT: i64 = 50;
39const EXPANSION_SEEDS: usize = 5;
41const CHARS_PER_TOKEN: usize = 4;
43
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
46pub enum Budget {
47 Items(usize),
49 ApproxTokens(usize),
52}
53
54impl Budget {
55 pub fn items(n: usize) -> Self {
57 Budget::Items(n)
58 }
59
60 pub fn approx_tokens(n: usize) -> Self {
62 Budget::ApproxTokens(n)
63 }
64
65 fn max_items(self) -> usize {
66 match self {
67 Budget::Items(n) => n,
68 Budget::ApproxTokens(n) => n.max(1),
70 }
71 }
72}
73
74#[derive(Debug, Clone)]
87pub struct Query {
88 text: String,
89 vector: Option<Vec<f32>>,
90 group_id: Option<String>,
91 as_of: Option<TimestampMs>,
92 as_at: Option<TimestampMs>,
93 budget: Budget,
94 targets: Option<Vec<SearchKind>>,
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq)]
99pub enum SearchKind {
100 Node,
102 Edge,
104 Episode,
106}
107
108impl Query {
109 pub fn text(text: impl Into<String>) -> Self {
111 Self {
112 text: text.into(),
113 vector: None,
114 group_id: None,
115 as_of: None,
116 as_at: None,
117 budget: Budget::Items(20),
118 targets: None,
119 }
120 }
121
122 pub fn targets(mut self, kinds: &[SearchKind]) -> Self {
128 self.targets = Some(kinds.to_vec());
129 self
130 }
131
132 pub fn vector(mut self, embedding: Vec<f32>) -> Self {
135 self.vector = Some(embedding);
136 self
137 }
138
139 pub fn group(mut self, group_id: impl Into<String>) -> Self {
141 self.group_id = Some(group_id.into());
142 self
143 }
144
145 pub fn as_of(mut self, t: TimestampMs) -> Self {
147 self.as_of = Some(t);
148 self
149 }
150
151 pub fn as_at(mut self, t: TimestampMs) -> Self {
153 self.as_at = Some(t);
154 self
155 }
156
157 pub fn budget(mut self, budget: Budget) -> Self {
159 self.budget = budget;
160 self
161 }
162}
163
164#[derive(Debug, Clone, Serialize, Deserialize)]
166#[serde(tag = "type", rename_all = "snake_case")]
167pub enum SearchTarget {
168 Node(Node),
170 Edge(Edge),
172 Episode(Episode),
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct SearchHit {
179 pub target: SearchTarget,
181 pub score: f64,
183 pub episodes: Vec<Uuid>,
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
189enum CandidateKind {
190 Node,
191 Edge,
192 Episode,
193}
194
195impl Grit {
196 pub fn search(&self, query: Query) -> Result<Vec<SearchHit>> {
199 let now = self.now_ms();
200 let as_of = query.as_of.unwrap_or(now);
201 let as_at = query.as_at.unwrap_or(now);
202 let group = query.group_id.as_deref();
203 let match_expr = fts_match_expr(&query.text);
204 let trigram_expr = trigram_match_expr(&query.text);
205
206 let conn = self.read();
207 let tx = conn.unchecked_transaction()?;
210
211 let mut legs: Vec<Vec<(CandidateKind, Uuid)>> = Vec::new();
212 if let Some(expr) = &match_expr {
213 legs.push(tag(
214 CandidateKind::Node,
215 fts_nodes(&tx, "nodes_fts", expr, group, as_at)?,
216 ));
217 legs.push(tag(
218 CandidateKind::Edge,
219 fts_edges(&tx, "edges_fts", expr, group, as_at, as_of)?,
220 ));
221 legs.push(tag(
222 CandidateKind::Episode,
223 fts_episodes(&tx, "episodes_fts", expr, group)?,
224 ));
225 }
226 if let Some(expr) = &trigram_expr {
227 legs.push(tag(
228 CandidateKind::Node,
229 fts_nodes(&tx, "nodes_fts_tri", expr, group, as_at)?,
230 ));
231 legs.push(tag(
232 CandidateKind::Edge,
233 fts_edges(&tx, "edges_fts_tri", expr, group, as_at, as_of)?,
234 ));
235 legs.push(tag(
236 CandidateKind::Episode,
237 fts_episodes(&tx, "episodes_fts_tri", expr, group)?,
238 ));
239 }
240 if let Some(vector) = &query.vector
241 && vec_tables_exist(&tx)?
242 {
243 legs.push(tag(
244 CandidateKind::Node,
245 vec_leg(&tx, "vec_nodes", vector, group)?,
246 ));
247 legs.push(tag(
248 CandidateKind::Edge,
249 vec_leg(&tx, "vec_edges", vector, group)?,
250 ));
251 }
252
253 let mut fused = rrf(&legs);
256 let seed_nodes: Vec<Uuid> = fused
257 .iter()
258 .filter(|((kind, _), _)| *kind == CandidateKind::Node)
259 .take(EXPANSION_SEEDS)
260 .map(|((_, id), _)| *id)
261 .collect();
262 if !seed_nodes.is_empty() {
263 legs.push(tag(
264 CandidateKind::Node,
265 expansion_leg(&tx, &seed_nodes, group, as_at, as_of)?,
266 ));
267 fused = rrf(&legs);
268 }
269
270 let mut hits = Vec::new();
273 let mut spent_chars = 0usize;
274 for ((kind, id), score) in fused {
275 if let Some(targets) = &query.targets {
276 let kind = match kind {
277 CandidateKind::Node => SearchKind::Node,
278 CandidateKind::Edge => SearchKind::Edge,
279 CandidateKind::Episode => SearchKind::Episode,
280 };
281 if !targets.contains(&kind) {
282 continue;
283 }
284 }
285 if hits.len() >= query.budget.max_items() {
286 break;
287 }
288 if let Budget::ApproxTokens(tokens) = query.budget
289 && spent_chars / CHARS_PER_TOKEN >= tokens
290 {
291 break;
292 }
293 let target = match kind {
294 CandidateKind::Node => fetch_node(&tx, id, group, as_at)?.map(SearchTarget::Node),
295 CandidateKind::Edge => {
296 fetch_edge(&tx, id, group, as_at, as_of)?.map(SearchTarget::Edge)
297 }
298 CandidateKind::Episode => fetch_episode(&tx, id, group)?.map(SearchTarget::Episode),
299 };
300 let Some(target) = target else { continue };
301 spent_chars += target_chars(&target);
302 let episodes = match kind {
303 CandidateKind::Episode => Vec::new(),
304 _ => episode_ids_for(&tx, id)?,
305 };
306 hits.push(SearchHit {
307 target,
308 score,
309 episodes,
310 });
311 }
312 Ok(hits)
313 }
314}
315
316fn fts_match_expr(text: &str) -> Option<String> {
320 let tokens: Vec<String> = text
321 .split_whitespace()
322 .map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
323 .collect();
324 if tokens.is_empty() {
325 None
326 } else {
327 Some(tokens.join(" "))
328 }
329}
330
331fn trigram_match_expr(text: &str) -> Option<String> {
336 let tokens: Vec<String> = text
337 .split_whitespace()
338 .filter(|t| t.chars().count() >= 3)
339 .map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
340 .collect();
341 if tokens.is_empty() {
342 None
343 } else {
344 Some(tokens.join(" "))
345 }
346}
347
348fn tag(kind: CandidateKind, ids: Vec<Uuid>) -> Vec<(CandidateKind, Uuid)> {
349 ids.into_iter().map(|id| (kind, id)).collect()
350}
351
352fn rrf(legs: &[Vec<(CandidateKind, Uuid)>]) -> Vec<((CandidateKind, Uuid), f64)> {
354 let mut scores: HashMap<(CandidateKind, Uuid), f64> = HashMap::new();
355 for leg in legs {
356 for (rank, key) in leg.iter().enumerate() {
357 *scores.entry(*key).or_insert(0.0) += 1.0 / (RRF_K + rank as f64 + 1.0);
358 }
359 }
360 let mut fused: Vec<_> = scores.into_iter().collect();
361 fused.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.1.cmp(&b.0.1)));
362 fused
363}
364
365fn parse_ids(rows: Vec<String>) -> Result<Vec<Uuid>> {
368 rows.iter()
369 .map(|s| {
370 Uuid::parse_str(s).map_err(|_| crate::Error::Corrupt(format!("bad uuid in index: {s}")))
371 })
372 .collect()
373}
374
375fn fts_nodes(
380 conn: &Connection,
381 table: &str,
382 expr: &str,
383 group: Option<&str>,
384 as_at: TimestampMs,
385) -> Result<Vec<Uuid>> {
386 let mut stmt = conn.prepare_cached(&format!(
387 "SELECT n.id FROM {table} AS f
388 JOIN nodes AS n ON n.rowid = f.rowid
389 WHERE {table} MATCH ?1
390 AND (?2 IS NULL OR n.group_id = ?2)
391 AND n.created_at <= ?3 AND (n.expired_at IS NULL OR n.expired_at > ?3)
392 ORDER BY f.rank LIMIT ?4"
393 ))?;
394 let rows = stmt.query_map(params![expr, group, as_at, LEG_LIMIT], |r| r.get(0))?;
395 parse_ids(collect(rows)?)
396}
397
398fn fts_edges(
399 conn: &Connection,
400 table: &str,
401 expr: &str,
402 group: Option<&str>,
403 as_at: TimestampMs,
404 as_of: TimestampMs,
405) -> Result<Vec<Uuid>> {
406 let mut stmt = conn.prepare_cached(&format!(
407 "SELECT e.id FROM {table} AS f
408 JOIN edges AS e ON e.rowid = f.rowid
409 WHERE {table} MATCH ?1
410 AND (?2 IS NULL OR e.group_id = ?2)
411 AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
412 AND (e.valid_at IS NULL OR e.valid_at <= ?4)
413 AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
414 WHERE iv.edge_id = e.id
415 AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
416 ORDER BY f.rank LIMIT ?5"
417 ))?;
418 let rows = stmt.query_map(params![expr, group, as_at, as_of, LEG_LIMIT], |r| r.get(0))?;
419 parse_ids(collect(rows)?)
420}
421
422fn fts_episodes(
423 conn: &Connection,
424 table: &str,
425 expr: &str,
426 group: Option<&str>,
427) -> Result<Vec<Uuid>> {
428 let mut stmt = conn.prepare_cached(&format!(
429 "SELECT e.id FROM {table} AS f
430 JOIN episodes AS e ON e.rowid = f.rowid
431 WHERE {table} MATCH ?1
432 AND (?2 IS NULL OR e.group_id = ?2)
433 ORDER BY f.rank LIMIT ?3"
434 ))?;
435 let rows = stmt.query_map(params![expr, group, LEG_LIMIT], |r| r.get(0))?;
436 parse_ids(collect(rows)?)
437}
438
439fn vec_tables_exist(conn: &Connection) -> Result<bool> {
440 let n: i64 = conn.query_row(
441 "SELECT COUNT(*) FROM sqlite_master WHERE name IN ('vec_nodes', 'vec_edges')",
442 [],
443 |r| r.get(0),
444 )?;
445 Ok(n == 2)
446}
447
448fn vec_leg(
449 conn: &Connection,
450 table: &str,
451 vector: &[f32],
452 group: Option<&str>,
453) -> Result<Vec<Uuid>> {
454 let registered_dim: Option<i64> = conn
455 .query_row("SELECT dim FROM embedding_meta WHERE id = 1", [], |r| {
456 r.get(0)
457 })
458 .map(Some)
459 .or_else(|e| match e {
460 rusqlite::Error::QueryReturnedNoRows => Ok(None),
461 other => Err(other),
462 })?;
463 if registered_dim != Some(vector.len() as i64) {
464 return Ok(Vec::new());
467 }
468 let rows = if let Some(group) = group {
475 let mut stmt = conn.prepare_cached(&format!(
476 "SELECT id FROM {table}
477 WHERE embedding MATCH ?1 AND k = ?2 AND group_id = ?3
478 ORDER BY distance"
479 ))?;
480 let rows = stmt.query_map(params![f32s_as_bytes(vector), LEG_LIMIT, group], |r| {
481 r.get::<_, String>(0)
482 })?;
483 collect(rows)?
484 } else {
485 let mut stmt = conn.prepare_cached(&format!(
486 "SELECT id FROM {table} WHERE embedding MATCH ?1 AND k = ?2 ORDER BY distance"
487 ))?;
488 let rows = stmt.query_map(params![f32s_as_bytes(vector), LEG_LIMIT], |r| {
489 r.get::<_, String>(0)
490 })?;
491 collect(rows)?
492 };
493 parse_ids(rows)
494}
495
496fn expansion_leg(
497 conn: &Connection,
498 seeds: &[Uuid],
499 group: Option<&str>,
500 as_at: TimestampMs,
501 as_of: TimestampMs,
502) -> Result<Vec<Uuid>> {
503 let mut stmt = conn.prepare_cached(
504 "SELECT DISTINCT CASE WHEN e.src = s.value THEN e.dst ELSE e.src END
505 FROM json_each(?1) AS s
506 JOIN edges AS e ON e.src = s.value OR e.dst = s.value
507 WHERE (?2 IS NULL OR e.group_id = ?2)
508 AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
509 AND (e.valid_at IS NULL OR e.valid_at <= ?4)
510 AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
511 WHERE iv.edge_id = e.id
512 AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
513 LIMIT ?5",
514 )?;
515 let rows = stmt.query_map(
516 params![ids_json(seeds.iter()), group, as_at, as_of, LEG_LIMIT],
517 |r| r.get(0),
518 )?;
519 parse_ids(collect(rows)?)
520}
521
522fn fetch_node(
523 conn: &Connection,
524 id: Uuid,
525 group: Option<&str>,
526 as_at: TimestampMs,
527) -> Result<Option<Node>> {
528 let mut stmt = conn.prepare_cached(&format!(
529 "SELECT {NODE_COLS} FROM nodes
530 WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)
531 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3)"
532 ))?;
533 let mut rows = collect(stmt.query_map(params![id.to_string(), group, as_at], Node::from_row)?)?;
534 Ok(rows.pop())
535}
536
537fn fetch_edge(
538 conn: &Connection,
539 id: Uuid,
540 group: Option<&str>,
541 as_at: TimestampMs,
542 as_of: TimestampMs,
543) -> Result<Option<Edge>> {
544 let mut stmt = conn.prepare_cached(&format!(
547 "SELECT {EDGE_COLS} FROM edges
548 WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)
549 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3)
550 AND (valid_at IS NULL OR valid_at <= ?4)
551 AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
552 WHERE iv.edge_id = edges.id
553 AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
554 AND EXISTS (SELECT 1 FROM nodes WHERE id = edges.src
555 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3))
556 AND EXISTS (SELECT 1 FROM nodes WHERE id = edges.dst
557 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3))"
558 ))?;
559 let mut rows =
560 collect(stmt.query_map(params![id.to_string(), group, as_at, as_of], Edge::from_row)?)?;
561 Ok(rows.pop())
562}
563
564fn fetch_episode(conn: &Connection, id: Uuid, group: Option<&str>) -> Result<Option<Episode>> {
565 let mut stmt = conn.prepare_cached(&format!(
566 "SELECT {EPISODE_COLS} FROM episodes
567 WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)"
568 ))?;
569 let mut rows = collect(stmt.query_map(params![id.to_string(), group], Episode::from_row)?)?;
570 Ok(rows.pop())
571}
572
573fn episode_ids_for(conn: &Connection, target: Uuid) -> Result<Vec<Uuid>> {
574 crate::query::episodes_mentioning(conn, &target.to_string())
576}
577
578fn target_chars(target: &SearchTarget) -> usize {
579 match target {
580 SearchTarget::Node(n) => n.name.len() + n.summary.len(),
581 SearchTarget::Edge(e) => e.fact.len(),
582 SearchTarget::Episode(e) => e.content.len(),
583 }
584}