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