use std::collections::HashMap;
use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::Grit;
use crate::clock::TimestampMs;
use crate::error::Result;
use crate::model::{EDGE_COLS, EPISODE_COLS, Edge, Episode, NODE_COLS, Node, collect};
use crate::query::ids_json;
use crate::vecext::f32s_as_bytes;
const RRF_K: f64 = 60.0;
const LEG_LIMIT: i64 = 50;
const EXPANSION_SEEDS: usize = 5;
const CHARS_PER_TOKEN: usize = 4;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Budget {
Items(usize),
ApproxTokens(usize),
}
impl Budget {
pub fn items(n: usize) -> Self {
Budget::Items(n)
}
pub fn approx_tokens(n: usize) -> Self {
Budget::ApproxTokens(n)
}
fn max_items(self) -> usize {
match self {
Budget::Items(n) => n,
Budget::ApproxTokens(n) => n.max(1),
}
}
}
#[derive(Debug, Clone)]
pub struct Query {
text: String,
vector: Option<Vec<f32>>,
group_id: Option<String>,
as_of: Option<TimestampMs>,
as_at: Option<TimestampMs>,
budget: Budget,
targets: Option<Vec<SearchKind>>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchKind {
Node,
Edge,
Episode,
}
impl Query {
pub fn text(text: impl Into<String>) -> Self {
Self {
text: text.into(),
vector: None,
group_id: None,
as_of: None,
as_at: None,
budget: Budget::Items(20),
targets: None,
}
}
pub fn targets(mut self, kinds: &[SearchKind]) -> Self {
self.targets = Some(kinds.to_vec());
self
}
pub fn vector(mut self, embedding: Vec<f32>) -> Self {
self.vector = Some(embedding);
self
}
pub fn group(mut self, group_id: impl Into<String>) -> Self {
self.group_id = Some(group_id.into());
self
}
pub fn as_of(mut self, t: TimestampMs) -> Self {
self.as_of = Some(t);
self
}
pub fn as_at(mut self, t: TimestampMs) -> Self {
self.as_at = Some(t);
self
}
pub fn budget(mut self, budget: Budget) -> Self {
self.budget = budget;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum SearchTarget {
Node(Node),
Edge(Edge),
Episode(Episode),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchHit {
pub target: SearchTarget,
pub score: f64,
pub episodes: Vec<Uuid>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
enum CandidateKind {
Node,
Edge,
Episode,
}
impl Grit {
pub fn search(&self, query: Query) -> Result<Vec<SearchHit>> {
let now = self.now_ms();
let as_of = query.as_of.unwrap_or(now);
let as_at = query.as_at.unwrap_or(now);
let group = query.group_id.as_deref();
let match_expr = fts_match_expr(&query.text);
let trigram_expr = trigram_match_expr(&query.text);
let conn = self.read();
let tx = conn.unchecked_transaction()?;
let mut legs: Vec<Vec<(CandidateKind, Uuid)>> = Vec::new();
if let Some(expr) = &match_expr {
legs.push(tag(
CandidateKind::Node,
fts_nodes(&tx, "nodes_fts", expr, group, as_at)?,
));
legs.push(tag(
CandidateKind::Edge,
fts_edges(&tx, "edges_fts", expr, group, as_at, as_of)?,
));
legs.push(tag(
CandidateKind::Episode,
fts_episodes(&tx, "episodes_fts", expr, group)?,
));
}
if let Some(expr) = &trigram_expr {
legs.push(tag(
CandidateKind::Node,
fts_nodes(&tx, "nodes_fts_tri", expr, group, as_at)?,
));
legs.push(tag(
CandidateKind::Edge,
fts_edges(&tx, "edges_fts_tri", expr, group, as_at, as_of)?,
));
legs.push(tag(
CandidateKind::Episode,
fts_episodes(&tx, "episodes_fts_tri", expr, group)?,
));
}
if let Some(vector) = &query.vector
&& vec_tables_exist(&tx)?
{
legs.push(tag(
CandidateKind::Node,
vec_leg(&tx, "vec_nodes", vector, group)?,
));
legs.push(tag(
CandidateKind::Edge,
vec_leg(&tx, "vec_edges", vector, group)?,
));
}
let mut fused = rrf(&legs);
let seed_nodes: Vec<Uuid> = fused
.iter()
.filter(|((kind, _), _)| *kind == CandidateKind::Node)
.take(EXPANSION_SEEDS)
.map(|((_, id), _)| *id)
.collect();
if !seed_nodes.is_empty() {
legs.push(tag(
CandidateKind::Node,
expansion_leg(&tx, &seed_nodes, group, as_at, as_of)?,
));
fused = rrf(&legs);
}
let mut hits = Vec::new();
let mut spent_chars = 0usize;
for ((kind, id), score) in fused {
if let Some(targets) = &query.targets {
let kind = match kind {
CandidateKind::Node => SearchKind::Node,
CandidateKind::Edge => SearchKind::Edge,
CandidateKind::Episode => SearchKind::Episode,
};
if !targets.contains(&kind) {
continue;
}
}
if hits.len() >= query.budget.max_items() {
break;
}
if let Budget::ApproxTokens(tokens) = query.budget
&& spent_chars / CHARS_PER_TOKEN >= tokens
{
break;
}
let target = match kind {
CandidateKind::Node => fetch_node(&tx, id, group, as_at)?.map(SearchTarget::Node),
CandidateKind::Edge => {
fetch_edge(&tx, id, group, as_at, as_of)?.map(SearchTarget::Edge)
}
CandidateKind::Episode => fetch_episode(&tx, id, group)?.map(SearchTarget::Episode),
};
let Some(target) = target else { continue };
spent_chars += target_chars(&target);
let episodes = match kind {
CandidateKind::Episode => Vec::new(),
_ => episode_ids_for(&tx, id)?,
};
hits.push(SearchHit {
target,
score,
episodes,
});
}
Ok(hits)
}
}
fn fts_match_expr(text: &str) -> Option<String> {
let tokens: Vec<String> = text
.split_whitespace()
.map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
.collect();
if tokens.is_empty() {
None
} else {
Some(tokens.join(" "))
}
}
fn trigram_match_expr(text: &str) -> Option<String> {
let tokens: Vec<String> = text
.split_whitespace()
.filter(|t| t.chars().count() >= 3)
.map(|t| format!("\"{}\"", t.replace('"', "\"\"")))
.collect();
if tokens.is_empty() {
None
} else {
Some(tokens.join(" "))
}
}
fn tag(kind: CandidateKind, ids: Vec<Uuid>) -> Vec<(CandidateKind, Uuid)> {
ids.into_iter().map(|id| (kind, id)).collect()
}
fn rrf(legs: &[Vec<(CandidateKind, Uuid)>]) -> Vec<((CandidateKind, Uuid), f64)> {
let mut scores: HashMap<(CandidateKind, Uuid), f64> = HashMap::new();
for leg in legs {
for (rank, key) in leg.iter().enumerate() {
*scores.entry(*key).or_insert(0.0) += 1.0 / (RRF_K + rank as f64 + 1.0);
}
}
let mut fused: Vec<_> = scores.into_iter().collect();
fused.sort_by(|a, b| b.1.total_cmp(&a.1).then_with(|| a.0.1.cmp(&b.0.1)));
fused
}
fn parse_ids(rows: Vec<String>) -> Result<Vec<Uuid>> {
rows.iter()
.map(|s| {
Uuid::parse_str(s).map_err(|_| crate::Error::Corrupt(format!("bad uuid in index: {s}")))
})
.collect()
}
fn fts_nodes(
conn: &Connection,
table: &str,
expr: &str,
group: Option<&str>,
as_at: TimestampMs,
) -> Result<Vec<Uuid>> {
let mut stmt = conn.prepare_cached(&format!(
"SELECT n.id FROM {table} AS f
JOIN nodes AS n ON n.rowid = f.rowid
WHERE {table} MATCH ?1
AND (?2 IS NULL OR n.group_id = ?2)
AND n.created_at <= ?3 AND (n.expired_at IS NULL OR n.expired_at > ?3)
ORDER BY f.rank LIMIT ?4"
))?;
let rows = stmt.query_map(params![expr, group, as_at, LEG_LIMIT], |r| r.get(0))?;
parse_ids(collect(rows)?)
}
fn fts_edges(
conn: &Connection,
table: &str,
expr: &str,
group: Option<&str>,
as_at: TimestampMs,
as_of: TimestampMs,
) -> Result<Vec<Uuid>> {
let mut stmt = conn.prepare_cached(&format!(
"SELECT e.id FROM {table} AS f
JOIN edges AS e ON e.rowid = f.rowid
WHERE {table} MATCH ?1
AND (?2 IS NULL OR e.group_id = ?2)
AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
AND (e.valid_at IS NULL OR e.valid_at <= ?4)
AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
WHERE iv.edge_id = e.id
AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
ORDER BY f.rank LIMIT ?5"
))?;
let rows = stmt.query_map(params![expr, group, as_at, as_of, LEG_LIMIT], |r| r.get(0))?;
parse_ids(collect(rows)?)
}
fn fts_episodes(
conn: &Connection,
table: &str,
expr: &str,
group: Option<&str>,
) -> Result<Vec<Uuid>> {
let mut stmt = conn.prepare_cached(&format!(
"SELECT e.id FROM {table} AS f
JOIN episodes AS e ON e.rowid = f.rowid
WHERE {table} MATCH ?1
AND (?2 IS NULL OR e.group_id = ?2)
ORDER BY f.rank LIMIT ?3"
))?;
let rows = stmt.query_map(params![expr, group, LEG_LIMIT], |r| r.get(0))?;
parse_ids(collect(rows)?)
}
fn vec_tables_exist(conn: &Connection) -> Result<bool> {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name IN ('vec_nodes', 'vec_edges')",
[],
|r| r.get(0),
)?;
Ok(n == 2)
}
fn vec_leg(
conn: &Connection,
table: &str,
vector: &[f32],
group: Option<&str>,
) -> Result<Vec<Uuid>> {
let registered_dim: Option<i64> = conn
.query_row("SELECT dim FROM embedding_meta WHERE id = 1", [], |r| {
r.get(0)
})
.map(Some)
.or_else(|e| match e {
rusqlite::Error::QueryReturnedNoRows => Ok(None),
other => Err(other),
})?;
if registered_dim != Some(vector.len() as i64) {
return Ok(Vec::new());
}
let rows = if let Some(group) = group {
let mut stmt = conn.prepare_cached(&format!(
"SELECT id FROM {table}
WHERE embedding MATCH ?1 AND k = ?2 AND group_id = ?3
ORDER BY distance"
))?;
let rows = stmt.query_map(params![f32s_as_bytes(vector), LEG_LIMIT, group], |r| {
r.get::<_, String>(0)
})?;
collect(rows)?
} else {
let mut stmt = conn.prepare_cached(&format!(
"SELECT id FROM {table} WHERE embedding MATCH ?1 AND k = ?2 ORDER BY distance"
))?;
let rows = stmt.query_map(params![f32s_as_bytes(vector), LEG_LIMIT], |r| {
r.get::<_, String>(0)
})?;
collect(rows)?
};
parse_ids(rows)
}
fn expansion_leg(
conn: &Connection,
seeds: &[Uuid],
group: Option<&str>,
as_at: TimestampMs,
as_of: TimestampMs,
) -> Result<Vec<Uuid>> {
let mut stmt = conn.prepare_cached(
"SELECT DISTINCT CASE WHEN e.src = s.value THEN e.dst ELSE e.src END
FROM json_each(?1) AS s
JOIN edges AS e ON e.src = s.value OR e.dst = s.value
WHERE (?2 IS NULL OR e.group_id = ?2)
AND e.created_at <= ?3 AND (e.expired_at IS NULL OR e.expired_at > ?3)
AND (e.valid_at IS NULL OR e.valid_at <= ?4)
AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
WHERE iv.edge_id = e.id
AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
LIMIT ?5",
)?;
let rows = stmt.query_map(
params![ids_json(seeds.iter()), group, as_at, as_of, LEG_LIMIT],
|r| r.get(0),
)?;
parse_ids(collect(rows)?)
}
fn fetch_node(
conn: &Connection,
id: Uuid,
group: Option<&str>,
as_at: TimestampMs,
) -> Result<Option<Node>> {
let mut stmt = conn.prepare_cached(&format!(
"SELECT {NODE_COLS} FROM nodes
WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)
AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3)"
))?;
let mut rows = collect(stmt.query_map(params![id.to_string(), group, as_at], Node::from_row)?)?;
Ok(rows.pop())
}
fn fetch_edge(
conn: &Connection,
id: Uuid,
group: Option<&str>,
as_at: TimestampMs,
as_of: TimestampMs,
) -> Result<Option<Edge>> {
let mut stmt = conn.prepare_cached(&format!(
"SELECT {EDGE_COLS} FROM edges
WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)
AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3)
AND (valid_at IS NULL OR valid_at <= ?4)
AND NOT EXISTS (SELECT 1 FROM edge_invalidations AS iv
WHERE iv.edge_id = edges.id
AND iv.recorded_at <= ?3 AND iv.invalid_at <= ?4)
AND EXISTS (SELECT 1 FROM nodes WHERE id = edges.src
AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3))
AND EXISTS (SELECT 1 FROM nodes WHERE id = edges.dst
AND created_at <= ?3 AND (expired_at IS NULL OR expired_at > ?3))"
))?;
let mut rows =
collect(stmt.query_map(params![id.to_string(), group, as_at, as_of], Edge::from_row)?)?;
Ok(rows.pop())
}
fn fetch_episode(conn: &Connection, id: Uuid, group: Option<&str>) -> Result<Option<Episode>> {
let mut stmt = conn.prepare_cached(&format!(
"SELECT {EPISODE_COLS} FROM episodes
WHERE id = ?1 AND (?2 IS NULL OR group_id = ?2)"
))?;
let mut rows = collect(stmt.query_map(params![id.to_string(), group], Episode::from_row)?)?;
Ok(rows.pop())
}
fn episode_ids_for(conn: &Connection, target: Uuid) -> Result<Vec<Uuid>> {
crate::query::episodes_mentioning(conn, &target.to_string())
}
fn target_chars(target: &SearchTarget) -> usize {
match target {
SearchTarget::Node(n) => n.name.len() + n.summary.len(),
SearchTarget::Edge(e) => e.fact.len(),
SearchTarget::Episode(e) => e.content.len(),
}
}