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}
79
80impl Query {
81 pub fn text(text: impl Into<String>) -> Self {
83 Self {
84 text: text.into(),
85 vector: None,
86 group_id: None,
87 as_of: None,
88 as_at: None,
89 budget: Budget::Items(20),
90 }
91 }
92
93 pub fn vector(mut self, embedding: Vec<f32>) -> Self {
96 self.vector = Some(embedding);
97 self
98 }
99
100 pub fn group(mut self, group_id: impl Into<String>) -> Self {
102 self.group_id = Some(group_id.into());
103 self
104 }
105
106 pub fn as_of(mut self, t: TimestampMs) -> Self {
108 self.as_of = Some(t);
109 self
110 }
111
112 pub fn as_at(mut self, t: TimestampMs) -> Self {
114 self.as_at = Some(t);
115 self
116 }
117
118 pub fn budget(mut self, budget: Budget) -> Self {
120 self.budget = budget;
121 self
122 }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize)]
127#[serde(tag = "type", rename_all = "snake_case")]
128pub enum SearchTarget {
129 Node(Node),
131 Edge(Edge),
133 Episode(Episode),
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct SearchHit {
140 pub target: SearchTarget,
142 pub score: f64,
144 pub episodes: Vec<Uuid>,
147}
148
149#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
150enum CandidateKind {
151 Node,
152 Edge,
153 Episode,
154}
155
156impl Grit {
157 pub fn search(&self, query: Query) -> Result<Vec<SearchHit>> {
160 let now = self.now_ms();
161 let as_of = query.as_of.unwrap_or(now);
162 let as_at = query.as_at.unwrap_or(now);
163 let group = query.group_id.as_deref();
164 let match_expr = fts_match_expr(&query.text);
165
166 let conn = self.read();
167 let tx = conn.unchecked_transaction()?;
170
171 let mut legs: Vec<Vec<(CandidateKind, Uuid)>> = Vec::new();
172 if let Some(expr) = &match_expr {
173 legs.push(tag(
174 CandidateKind::Node,
175 fts_nodes(&tx, expr, group, as_at)?,
176 ));
177 legs.push(tag(
178 CandidateKind::Edge,
179 fts_edges(&tx, expr, group, as_at, as_of)?,
180 ));
181 legs.push(tag(CandidateKind::Episode, fts_episodes(&tx, expr, group)?));
182 }
183 if let Some(vector) = &query.vector
184 && vec_tables_exist(&tx)?
185 {
186 legs.push(tag(CandidateKind::Node, vec_leg(&tx, "vec_nodes", vector)?));
187 legs.push(tag(CandidateKind::Edge, vec_leg(&tx, "vec_edges", vector)?));
188 }
189
190 let mut fused = rrf(&legs);
193 let seed_nodes: Vec<Uuid> = fused
194 .iter()
195 .filter(|((kind, _), _)| *kind == CandidateKind::Node)
196 .take(EXPANSION_SEEDS)
197 .map(|((_, id), _)| *id)
198 .collect();
199 if !seed_nodes.is_empty() {
200 legs.push(tag(
201 CandidateKind::Node,
202 expansion_leg(&tx, &seed_nodes, group, as_at, as_of)?,
203 ));
204 fused = rrf(&legs);
205 }
206
207 let mut hits = Vec::new();
210 let mut spent_chars = 0usize;
211 for ((kind, id), score) in fused {
212 if hits.len() >= query.budget.max_items() {
213 break;
214 }
215 if let Budget::ApproxTokens(tokens) = query.budget
216 && spent_chars / CHARS_PER_TOKEN >= tokens
217 {
218 break;
219 }
220 let target = match kind {
221 CandidateKind::Node => fetch_node(&tx, id, group, as_at)?.map(SearchTarget::Node),
222 CandidateKind::Edge => {
223 fetch_edge(&tx, id, group, as_at, as_of)?.map(SearchTarget::Edge)
224 }
225 CandidateKind::Episode => fetch_episode(&tx, id, group)?.map(SearchTarget::Episode),
226 };
227 let Some(target) = target else { continue };
228 spent_chars += target_chars(&target);
229 let episodes = match kind {
230 CandidateKind::Episode => Vec::new(),
231 _ => episode_ids_for(&tx, id)?,
232 };
233 hits.push(SearchHit {
234 target,
235 score,
236 episodes,
237 });
238 }
239 Ok(hits)
240 }
241}
242
243fn fts_match_expr(text: &str) -> Option<String> {
247 let tokens: Vec<String> = text
248 .split_whitespace()
249 .map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
250 .collect();
251 if tokens.is_empty() {
252 None
253 } else {
254 Some(tokens.join(" "))
255 }
256}
257
258fn tag(kind: CandidateKind, ids: Vec<Uuid>) -> Vec<(CandidateKind, Uuid)> {
259 ids.into_iter().map(|id| (kind, id)).collect()
260}
261
262fn rrf(legs: &[Vec<(CandidateKind, Uuid)>]) -> Vec<((CandidateKind, Uuid), f64)> {
264 let mut scores: HashMap<(CandidateKind, Uuid), f64> = HashMap::new();
265 for leg in legs {
266 for (rank, key) in leg.iter().enumerate() {
267 *scores.entry(*key).or_insert(0.0) += 1.0 / (RRF_K + rank as f64 + 1.0);
268 }
269 }
270 let mut fused: Vec<_> = scores.into_iter().collect();
271 fused.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.1.cmp(&b.0.1)));
272 fused
273}
274
275fn parse_ids(rows: Vec<String>) -> Result<Vec<Uuid>> {
278 rows.iter()
279 .map(|s| {
280 Uuid::parse_str(s).map_err(|_| crate::Error::Corrupt(format!("bad uuid in index: {s}")))
281 })
282 .collect()
283}
284
285fn fts_nodes(
286 conn: &Connection,
287 expr: &str,
288 group: Option<&str>,
289 as_at: TimestampMs,
290) -> Result<Vec<Uuid>> {
291 let mut stmt = conn.prepare_cached(
292 "SELECT n.id FROM nodes_fts AS f
293 JOIN nodes AS n ON n.rowid = f.rowid
294 WHERE nodes_fts MATCH ?1
295 AND (?2 IS NULL OR n.group_id = ?2)
296 AND n.created_at <= ?3 AND (n.expired_at IS NULL OR n.expired_at > ?3)
297 ORDER BY f.rank LIMIT ?4",
298 )?;
299 let rows = stmt.query_map(params![expr, group, as_at, LEG_LIMIT], |r| r.get(0))?;
300 parse_ids(collect(rows)?)
301}
302
303fn fts_edges(
304 conn: &Connection,
305 expr: &str,
306 group: Option<&str>,
307 as_at: TimestampMs,
308 as_of: TimestampMs,
309) -> Result<Vec<Uuid>> {
310 let mut stmt = conn.prepare_cached(
311 "SELECT e.id FROM edges_fts AS f
312 JOIN edges AS e ON e.rowid = f.rowid
313 WHERE edges_fts MATCH ?1
314 AND (?2 IS NULL OR e.group_id = ?2)
315 AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
316 AND (e.valid_at IS NULL OR e.valid_at <= ?4)
317 AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
318 WHERE iv.edge_id = e.id
319 AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
320 ORDER BY f.rank LIMIT ?5",
321 )?;
322 let rows = stmt.query_map(params![expr, group, as_at, as_of, LEG_LIMIT], |r| r.get(0))?;
323 parse_ids(collect(rows)?)
324}
325
326fn fts_episodes(conn: &Connection, expr: &str, group: Option<&str>) -> Result<Vec<Uuid>> {
327 let mut stmt = conn.prepare_cached(
328 "SELECT e.id FROM episodes_fts AS f
329 JOIN episodes AS e ON e.rowid = f.rowid
330 WHERE episodes_fts MATCH ?1
331 AND (?2 IS NULL OR e.group_id = ?2)
332 ORDER BY f.rank LIMIT ?3",
333 )?;
334 let rows = stmt.query_map(params![expr, group, LEG_LIMIT], |r| r.get(0))?;
335 parse_ids(collect(rows)?)
336}
337
338fn vec_tables_exist(conn: &Connection) -> Result<bool> {
339 let n: i64 = conn.query_row(
340 "SELECT COUNT(*) FROM sqlite_master WHERE name IN ('vec_nodes', 'vec_edges')",
341 [],
342 |r| r.get(0),
343 )?;
344 Ok(n == 2)
345}
346
347fn vec_leg(conn: &Connection, table: &str, vector: &[f32]) -> Result<Vec<Uuid>> {
348 let registered_dim: Option<i64> = conn
349 .query_row("SELECT dim FROM embedding_meta WHERE id = 1", [], |r| {
350 r.get(0)
351 })
352 .map(Some)
353 .or_else(|e| match e {
354 rusqlite::Error::QueryReturnedNoRows => Ok(None),
355 other => Err(other),
356 })?;
357 if registered_dim != Some(vector.len() as i64) {
358 return Ok(Vec::new());
361 }
362 let mut stmt = conn.prepare_cached(&format!(
364 "SELECT id FROM {table} WHERE embedding MATCH ?1 AND k = ?2 ORDER BY distance"
365 ))?;
366 let rows = stmt.query_map(params![f32s_as_bytes(vector), LEG_LIMIT], |r| {
367 r.get::<_, String>(0)
368 })?;
369 parse_ids(collect(rows)?)
370}
371
372fn expansion_leg(
373 conn: &Connection,
374 seeds: &[Uuid],
375 group: Option<&str>,
376 as_at: TimestampMs,
377 as_of: TimestampMs,
378) -> Result<Vec<Uuid>> {
379 let mut stmt = conn.prepare_cached(
380 "SELECT DISTINCT CASE WHEN e.src = s.value THEN e.dst ELSE e.src END
381 FROM json_each(?1) AS s
382 JOIN edges AS e ON e.src = s.value OR e.dst = s.value
383 WHERE (?2 IS NULL OR e.group_id = ?2)
384 AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
385 AND (e.valid_at IS NULL OR e.valid_at <= ?4)
386 AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
387 WHERE iv.edge_id = e.id
388 AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
389 LIMIT ?5",
390 )?;
391 let rows = stmt.query_map(
392 params![ids_json(seeds.iter()), group, as_at, as_of, LEG_LIMIT],
393 |r| r.get(0),
394 )?;
395 parse_ids(collect(rows)?)
396}
397
398fn fetch_node(
399 conn: &Connection,
400 id: Uuid,
401 group: Option<&str>,
402 as_at: TimestampMs,
403) -> Result<Option<Node>> {
404 let mut stmt = conn.prepare_cached(&format!(
405 "SELECT {NODE_COLS} FROM nodes
406 WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)
407 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3)"
408 ))?;
409 let mut rows = collect(stmt.query_map(params![id.to_string(), group, as_at], Node::from_row)?)?;
410 Ok(rows.pop())
411}
412
413fn fetch_edge(
414 conn: &Connection,
415 id: Uuid,
416 group: Option<&str>,
417 as_at: TimestampMs,
418 as_of: TimestampMs,
419) -> Result<Option<Edge>> {
420 let mut stmt = conn.prepare_cached(&format!(
423 "SELECT {EDGE_COLS} FROM edges
424 WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)
425 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3)
426 AND (valid_at IS NULL OR valid_at <= ?4)
427 AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
428 WHERE iv.edge_id = edges.id
429 AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
430 AND EXISTS (SELECT 1 FROM nodes WHERE id = edges.src
431 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3))
432 AND EXISTS (SELECT 1 FROM nodes WHERE id = edges.dst
433 AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3))"
434 ))?;
435 let mut rows =
436 collect(stmt.query_map(params![id.to_string(), group, as_at, as_of], Edge::from_row)?)?;
437 Ok(rows.pop())
438}
439
440fn fetch_episode(conn: &Connection, id: Uuid, group: Option<&str>) -> Result<Option<Episode>> {
441 let mut stmt = conn.prepare_cached(&format!(
442 "SELECT {EPISODE_COLS} FROM episodes
443 WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)"
444 ))?;
445 let mut rows = collect(stmt.query_map(params![id.to_string(), group], Episode::from_row)?)?;
446 Ok(rows.pop())
447}
448
449fn episode_ids_for(conn: &Connection, target: Uuid) -> Result<Vec<Uuid>> {
450 crate::query::episodes_mentioning(conn, &target.to_string())
452}
453
454fn target_chars(target: &SearchTarget) -> usize {
455 match target {
456 SearchTarget::Node(n) => n.name.len() + n.summary.len(),
457 SearchTarget::Edge(e) => e.fact.len(),
458 SearchTarget::Episode(e) => e.content.len(),
459 }
460}