1use std::collections::HashMap;
7
8use rusqlite::{Connection, params};
9use serde::{Deserialize, Serialize};
10use uuid::Uuid;
11
12use crate::Grit;
13use crate::clock::TimestampMs;
14use crate::error::Result;
15use crate::model::{EDGE_COLS, EPISODE_COLS, Edge, Episode, NODE_COLS, Node, collect};
16use crate::query::ids_json;
17use crate::vecext::f32s_as_bytes;
18
19const RRF_K: f64 = 60.0;
21const LEG_LIMIT: i64 = 50;
23const EXPANSION_SEEDS: usize = 5;
25const CHARS_PER_TOKEN: usize = 4;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30pub enum Budget {
31 Items(usize),
33 ApproxTokens(usize),
36}
37
38impl Budget {
39 pub fn items(n: usize) -> Self {
41 Budget::Items(n)
42 }
43
44 pub fn approx_tokens(n: usize) -> Self {
46 Budget::ApproxTokens(n)
47 }
48
49 fn max_items(self) -> usize {
50 match self {
51 Budget::Items(n) => n,
52 Budget::ApproxTokens(n) => n.max(1),
54 }
55 }
56}
57
58#[derive(Debug, Clone)]
71pub struct Query {
72 text: String,
73 vector: Option<Vec<f32>>,
74 group_id: Option<String>,
75 as_of: Option<TimestampMs>,
76 as_at: Option<TimestampMs>,
77 budget: Budget,
78 targets: Option<Vec<SearchKind>>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83pub enum SearchKind {
84 Node,
86 Edge,
88 Episode,
90}
91
92impl Query {
93 pub fn text(text: impl Into<String>) -> Self {
95 Self {
96 text: text.into(),
97 vector: None,
98 group_id: None,
99 as_of: None,
100 as_at: None,
101 budget: Budget::Items(20),
102 targets: None,
103 }
104 }
105
106 pub fn targets(mut self, kinds: &[SearchKind]) -> Self {
112 self.targets = Some(kinds.to_vec());
113 self
114 }
115
116 pub fn vector(mut self, embedding: Vec<f32>) -> Self {
119 self.vector = Some(embedding);
120 self
121 }
122
123 pub fn group(mut self, group_id: impl Into<String>) -> Self {
125 self.group_id = Some(group_id.into());
126 self
127 }
128
129 pub fn as_of(mut self, t: TimestampMs) -> Self {
131 self.as_of = Some(t);
132 self
133 }
134
135 pub fn as_at(mut self, t: TimestampMs) -> Self {
137 self.as_at = Some(t);
138 self
139 }
140
141 pub fn budget(mut self, budget: Budget) -> Self {
143 self.budget = budget;
144 self
145 }
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
150#[serde(tag = "type", rename_all = "snake_case")]
151pub enum SearchTarget {
152 Node(Node),
154 Edge(Edge),
156 Episode(Episode),
158}
159
160#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct SearchHit {
163 pub target: SearchTarget,
165 pub score: f64,
167 pub episodes: Vec<Uuid>,
170}
171
172#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
173enum CandidateKind {
174 Node,
175 Edge,
176 Episode,
177}
178
179impl Grit {
180 pub fn search(&self, query: Query) -> Result<Vec<SearchHit>> {
183 let now = self.now_ms();
184 let as_of = query.as_of.unwrap_or(now);
185 let as_at = query.as_at.unwrap_or(now);
186 let group = query.group_id.as_deref();
187 let match_expr = fts_match_expr(&query.text);
188
189 let conn = self.read();
190 let tx = conn.unchecked_transaction()?;
193
194 let mut legs: Vec<Vec<(CandidateKind, Uuid)>> = Vec::new();
195 if let Some(expr) = &match_expr {
196 legs.push(tag(
197 CandidateKind::Node,
198 fts_nodes(&tx, expr, group, as_at)?,
199 ));
200 legs.push(tag(
201 CandidateKind::Edge,
202 fts_edges(&tx, expr, group, as_at, as_of)?,
203 ));
204 legs.push(tag(CandidateKind::Episode, fts_episodes(&tx, expr, group)?));
205 }
206 if let Some(vector) = &query.vector
207 && vec_tables_exist(&tx)?
208 {
209 legs.push(tag(CandidateKind::Node, vec_leg(&tx, "vec_nodes", vector)?));
210 legs.push(tag(CandidateKind::Edge, vec_leg(&tx, "vec_edges", vector)?));
211 }
212
213 let mut fused = rrf(&legs);
216 let seed_nodes: Vec<Uuid> = fused
217 .iter()
218 .filter(|((kind, _), _)| *kind == CandidateKind::Node)
219 .take(EXPANSION_SEEDS)
220 .map(|((_, id), _)| *id)
221 .collect();
222 if !seed_nodes.is_empty() {
223 legs.push(tag(
224 CandidateKind::Node,
225 expansion_leg(&tx, &seed_nodes, group, as_at, as_of)?,
226 ));
227 fused = rrf(&legs);
228 }
229
230 let mut hits = Vec::new();
233 let mut spent_chars = 0usize;
234 for ((kind, id), score) in fused {
235 if let Some(targets) = &query.targets {
236 let kind = match kind {
237 CandidateKind::Node => SearchKind::Node,
238 CandidateKind::Edge => SearchKind::Edge,
239 CandidateKind::Episode => SearchKind::Episode,
240 };
241 if !targets.contains(&kind) {
242 continue;
243 }
244 }
245 if hits.len() >= query.budget.max_items() {
246 break;
247 }
248 if let Budget::ApproxTokens(tokens) = query.budget
249 && spent_chars / CHARS_PER_TOKEN >= tokens
250 {
251 break;
252 }
253 let target = match kind {
254 CandidateKind::Node => fetch_node(&tx, id, group, as_at)?.map(SearchTarget::Node),
255 CandidateKind::Edge => {
256 fetch_edge(&tx, id, group, as_at, as_of)?.map(SearchTarget::Edge)
257 }
258 CandidateKind::Episode => fetch_episode(&tx, id, group)?.map(SearchTarget::Episode),
259 };
260 let Some(target) = target else { continue };
261 spent_chars += target_chars(&target);
262 let episodes = match kind {
263 CandidateKind::Episode => Vec::new(),
264 _ => episode_ids_for(&tx, id)?,
265 };
266 hits.push(SearchHit {
267 target,
268 score,
269 episodes,
270 });
271 }
272 Ok(hits)
273 }
274}
275
276fn fts_match_expr(text: &str) -> Option<String> {
280 let tokens: Vec<String> = text
281 .split_whitespace()
282 .map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
283 .collect();
284 if tokens.is_empty() {
285 None
286 } else {
287 Some(tokens.join(" "))
288 }
289}
290
291fn tag(kind: CandidateKind, ids: Vec<Uuid>) -> Vec<(CandidateKind, Uuid)> {
292 ids.into_iter().map(|id| (kind, id)).collect()
293}
294
295fn rrf(legs: &[Vec<(CandidateKind, Uuid)>]) -> Vec<((CandidateKind, Uuid), f64)> {
297 let mut scores: HashMap<(CandidateKind, Uuid), f64> = HashMap::new();
298 for leg in legs {
299 for (rank, key) in leg.iter().enumerate() {
300 *scores.entry(*key).or_insert(0.0) += 1.0 / (RRF_K + rank as f64 + 1.0);
301 }
302 }
303 let mut fused: Vec<_> = scores.into_iter().collect();
304 fused.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.1.cmp(&b.0.1)));
305 fused
306}
307
308fn parse_ids(rows: Vec<String>) -> Result<Vec<Uuid>> {
311 rows.iter()
312 .map(|s| {
313 Uuid::parse_str(s).map_err(|_| crate::Error::Corrupt(format!("bad uuid in index: {s}")))
314 })
315 .collect()
316}
317
318fn fts_nodes(
319 conn: &Connection,
320 expr: &str,
321 group: Option<&str>,
322 as_at: TimestampMs,
323) -> Result<Vec<Uuid>> {
324 let mut stmt = conn.prepare_cached(
325 "SELECT n.id FROM nodes_fts AS f
326 JOIN nodes AS n ON n.rowid = f.rowid
327 WHERE nodes_fts MATCH ?1
328 AND (?2 IS NULL OR n.group_id = ?2)
329 AND n.created_at <= ?3 AND (n.expired_at IS NULL OR n.expired_at > ?3)
330 ORDER BY f.rank LIMIT ?4",
331 )?;
332 let rows = stmt.query_map(params![expr, group, as_at, LEG_LIMIT], |r| r.get(0))?;
333 parse_ids(collect(rows)?)
334}
335
336fn fts_edges(
337 conn: &Connection,
338 expr: &str,
339 group: Option<&str>,
340 as_at: TimestampMs,
341 as_of: TimestampMs,
342) -> Result<Vec<Uuid>> {
343 let mut stmt = conn.prepare_cached(
344 "SELECT e.id FROM edges_fts AS f
345 JOIN edges AS e ON e.rowid = f.rowid
346 WHERE edges_fts MATCH ?1
347 AND (?2 IS NULL OR e.group_id = ?2)
348 AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
349 AND (e.valid_at IS NULL OR e.valid_at <= ?4)
350 AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
351 WHERE iv.edge_id = e.id
352 AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
353 ORDER BY f.rank LIMIT ?5",
354 )?;
355 let rows = stmt.query_map(params![expr, group, as_at, as_of, LEG_LIMIT], |r| r.get(0))?;
356 parse_ids(collect(rows)?)
357}
358
359fn fts_episodes(conn: &Connection, expr: &str, group: Option<&str>) -> Result<Vec<Uuid>> {
360 let mut stmt = conn.prepare_cached(
361 "SELECT e.id FROM episodes_fts AS f
362 JOIN episodes AS e ON e.rowid = f.rowid
363 WHERE episodes_fts MATCH ?1
364 AND (?2 IS NULL OR e.group_id = ?2)
365 ORDER BY f.rank LIMIT ?3",
366 )?;
367 let rows = stmt.query_map(params![expr, group, LEG_LIMIT], |r| r.get(0))?;
368 parse_ids(collect(rows)?)
369}
370
371fn vec_tables_exist(conn: &Connection) -> Result<bool> {
372 let n: i64 = conn.query_row(
373 "SELECT COUNT(*) FROM sqlite_master WHERE name IN ('vec_nodes', 'vec_edges')",
374 [],
375 |r| r.get(0),
376 )?;
377 Ok(n == 2)
378}
379
380fn vec_leg(conn: &Connection, table: &str, vector: &[f32]) -> Result<Vec<Uuid>> {
381 let registered_dim: Option<i64> = conn
382 .query_row("SELECT dim FROM embedding_meta WHERE id = 1", [], |r| {
383 r.get(0)
384 })
385 .map(Some)
386 .or_else(|e| match e {
387 rusqlite::Error::QueryReturnedNoRows => Ok(None),
388 other => Err(other),
389 })?;
390 if registered_dim != Some(vector.len() as i64) {
391 return Ok(Vec::new());
394 }
395 let mut stmt = conn.prepare_cached(&format!(
397 "SELECT id FROM {table} WHERE embedding MATCH ?1 AND k = ?2 ORDER BY distance"
398 ))?;
399 let rows = stmt.query_map(params![f32s_as_bytes(vector), LEG_LIMIT], |r| {
400 r.get::<_, String>(0)
401 })?;
402 parse_ids(collect(rows)?)
403}
404
405fn expansion_leg(
406 conn: &Connection,
407 seeds: &[Uuid],
408 group: Option<&str>,
409 as_at: TimestampMs,
410 as_of: TimestampMs,
411) -> Result<Vec<Uuid>> {
412 let mut stmt = conn.prepare_cached(
413 "SELECT DISTINCT CASE WHEN e.src = s.value THEN e.dst ELSE e.src END
414 FROM json_each(?1) AS s
415 JOIN edges AS e ON e.src = s.value OR e.dst = s.value
416 WHERE (?2 IS NULL OR e.group_id = ?2)
417 AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
418 AND (e.valid_at IS NULL OR e.valid_at <= ?4)
419 AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
420 WHERE iv.edge_id = e.id
421 AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
422 LIMIT ?5",
423 )?;
424 let rows = stmt.query_map(
425 params![ids_json(seeds.iter()), group, as_at, as_of, LEG_LIMIT],
426 |r| r.get(0),
427 )?;
428 parse_ids(collect(rows)?)
429}
430
431fn fetch_node(
432 conn: &Connection,
433 id: Uuid,
434 group: Option<&str>,
435 as_at: TimestampMs,
436) -> Result<Option<Node>> {
437 let mut stmt = conn.prepare_cached(&format!(
438 "SELECT {NODE_COLS} FROM nodes
439 WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)
440 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3)"
441 ))?;
442 let mut rows = collect(stmt.query_map(params![id.to_string(), group, as_at], Node::from_row)?)?;
443 Ok(rows.pop())
444}
445
446fn fetch_edge(
447 conn: &Connection,
448 id: Uuid,
449 group: Option<&str>,
450 as_at: TimestampMs,
451 as_of: TimestampMs,
452) -> Result<Option<Edge>> {
453 let mut stmt = conn.prepare_cached(&format!(
456 "SELECT {EDGE_COLS} FROM edges
457 WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)
458 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3)
459 AND (valid_at IS NULL OR valid_at <= ?4)
460 AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
461 WHERE iv.edge_id = edges.id
462 AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
463 AND EXISTS (SELECT 1 FROM nodes WHERE id = edges.src
464 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3))
465 AND EXISTS (SELECT 1 FROM nodes WHERE id = edges.dst
466 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3))"
467 ))?;
468 let mut rows =
469 collect(stmt.query_map(params![id.to_string(), group, as_at, as_of], Edge::from_row)?)?;
470 Ok(rows.pop())
471}
472
473fn fetch_episode(conn: &Connection, id: Uuid, group: Option<&str>) -> Result<Option<Episode>> {
474 let mut stmt = conn.prepare_cached(&format!(
475 "SELECT {EPISODE_COLS} FROM episodes
476 WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)"
477 ))?;
478 let mut rows = collect(stmt.query_map(params![id.to_string(), group], Episode::from_row)?)?;
479 Ok(rows.pop())
480}
481
482fn episode_ids_for(conn: &Connection, target: Uuid) -> Result<Vec<Uuid>> {
483 crate::query::episodes_mentioning(conn, &target.to_string())
485}
486
487fn target_chars(target: &SearchTarget) -> usize {
488 match target {
489 SearchTarget::Node(n) => n.name.len() + n.summary.len(),
490 SearchTarget::Edge(e) => e.fact.len(),
491 SearchTarget::Episode(e) => e.content.len(),
492 }
493}