use crate::ids::NodeId;
pub(crate) const RRF_K: f32 = 60.0;
pub(crate) fn rrf_fuse(lists: &[(f32, Vec<NodeId>)]) -> Vec<(NodeId, f32)> {
use std::collections::HashMap;
let mut scores: HashMap<NodeId, f32> = HashMap::new();
for (weight, ids) in lists {
for (i, id) in ids.iter().enumerate() {
*scores.entry(*id).or_insert(0.0) += weight / (RRF_K + (i as f32 + 1.0));
}
}
let mut out: Vec<(NodeId, f32)> = scores.into_iter().collect();
out.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.cmp(&b.0))
});
out
}
#[cfg(test)]
mod tests {
use super::*;
fn id(n: u128) -> NodeId {
NodeId::from_u128(n)
}
#[test]
fn agreement_across_legs_beats_single_leg_top_rank() {
let fused = rrf_fuse(&[
(1.0, vec![id(1), id(2)]), (1.0, vec![id(3), id(2)]), ]);
assert_eq!(fused[0].0, id(2), "B (agreement) must rank first");
}
#[test]
fn weights_scale_leg_contributions() {
let fused = rrf_fuse(&[(1.0, vec![id(1)]), (0.5, vec![id(2)])]);
assert_eq!(fused[0].0, id(1));
assert!((fused[0].1 - 1.0 / 61.0).abs() < 1e-6);
assert!((fused[1].1 - 0.5 / 61.0).abs() < 1e-6);
}
#[test]
fn ties_break_by_ascending_id() {
let fused = rrf_fuse(&[(1.0, vec![id(9)]), (1.0, vec![id(3)])]);
assert_eq!(fused[0].0, id(3), "equal scores: lower id first");
}
#[test]
fn empty_input_fuses_to_empty() {
assert!(rrf_fuse(&[]).is_empty());
assert!(rrf_fuse(&[(1.0, vec![])]).is_empty());
}
}
use crate::error::TopoError;
use crate::fts::SearchOptions;
use crate::ids::ScopeSet;
use crate::state::NodeRecord;
use crate::Db;
pub(crate) fn leg_depth(k: usize) -> usize {
(3 * k).clamp(30, 50)
}
#[derive(Debug, Clone)]
pub struct RecallQuery {
pub scopes: ScopeSet,
pub query: String,
pub k: usize,
pub vector: Option<(String, Vec<f32>)>,
pub expansions: Vec<(String, Vec<String>)>,
pub graph_boost: bool,
pub options: SearchOptions,
}
pub(crate) const WEIGHT_TEXT: f32 = 1.0;
pub(crate) const WEIGHT_VECTOR: f32 = 1.0;
pub(crate) const WEIGHT_GRAPH: f32 = 0.5;
pub(crate) const GRAPH_SEEDS: usize = 5;
impl Db {
pub fn recall(&self, q: &RecallQuery) -> Result<Vec<(NodeRecord, f32)>, TopoError> {
if q.k == 0 {
return Err(TopoError::Rejected("recall requires k > 0".into()));
}
q.options.validate_recency()?;
if let Some((_, v)) = &q.vector {
if v.is_empty() {
return Err(TopoError::Rejected(
"recall query vector is empty (host must not send an empty embedding)".into(),
));
}
}
let depth = leg_depth(q.k);
let mut leg_options = q.options.clone();
leg_options.recency_weight = 0.0;
let text_hits =
self.search_text_expanded(&q.scopes, &q.query, depth, &leg_options, &q.expansions)?;
let mut records: std::collections::HashMap<crate::NodeId, NodeRecord> =
text_hits.iter().map(|(n, _)| (n.id, n.clone())).collect();
let text_ids: Vec<crate::NodeId> = text_hits.iter().map(|(n, _)| n.id).collect();
let mut lists: Vec<(f32, Vec<crate::NodeId>)> = vec![(WEIGHT_TEXT, text_ids)];
if let Some((model, vector)) = &q.vector {
let vhits = self.search_vector(&crate::VectorQuery {
scopes: q.scopes.clone(),
model: model.clone(),
vector: vector.clone(),
k: depth,
candidates: None,
})?;
let vids: Vec<crate::NodeId> = vhits.iter().map(|(n, _)| n.id).collect();
for (n, _) in vhits {
records.entry(n.id).or_insert(n);
}
lists.push((WEIGHT_VECTOR, vids));
}
if q.graph_boost {
let prelim = rrf_fuse(&lists);
let seeds: Vec<crate::NodeId> =
prelim.iter().take(GRAPH_SEEDS).map(|(id, _)| *id).collect();
let mut graph_ids: Vec<crate::NodeId> = Vec::new();
let mut seen: std::collections::HashSet<crate::NodeId> =
seeds.iter().copied().collect();
for seed in &seeds {
let sg = self.traverse(&crate::TraversalQuery {
scopes: q.scopes.clone(),
seeds: vec![*seed],
max_hops: 1,
edge_types: None,
direction: crate::Direction::Both,
as_of: q.options.now_ms,
})?;
let mut neighbors: Vec<NodeRecord> = sg.nodes;
neighbors.sort_by_key(|n| n.id);
for n in neighbors {
if seen.insert(n.id) {
graph_ids.push(n.id);
records.entry(n.id).or_insert(n);
}
}
}
if !graph_ids.is_empty() {
lists.push((WEIGHT_GRAPH, graph_ids));
}
}
let fused = rrf_fuse(&lists);
let mut out: Vec<(NodeRecord, f32)> = fused
.into_iter()
.filter_map(|(id, score)| records.remove(&id).map(|n| (n, score)))
.collect();
apply_recency(&mut out, &q.options);
out.truncate(q.k);
Ok(out)
}
}
pub(crate) fn apply_recency(out: &mut [(NodeRecord, f32)], options: &SearchOptions) {
let w = options.recency_weight;
if w <= 0.0 {
return;
}
let now = options.now_ms.unwrap_or_else(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock before UNIX epoch")
.as_millis() as i64
});
let half_life = options.recency_half_life_ms as f32;
for (rec, score) in out.iter_mut() {
let age = (now - rec.id.timestamp_ms() as i64).max(0) as f32;
*score *= (1.0 - w) + w * (-(age / half_life)).exp2();
}
out.sort_by(|a, b| {
b.1.partial_cmp(&a.1)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.0.id.cmp(&b.0.id))
});
}