1use anyhow::Result;
2use chrono::Utc;
3use uuid::Uuid;
4
5use crate::schema::{Memory, RelationType};
6use crate::store::Store;
7
8#[derive(Debug, Clone)]
9pub struct QueryRequest {
10 pub text: String,
11 pub embedding: Vec<f32>,
12 pub limit: usize,
13 pub filters: QueryFilters,
14}
15
16#[derive(Debug, Clone, Default)]
17pub struct QueryFilters {
18 pub source: Option<String>,
19 pub memory_type: Option<crate::schema::MemoryType>,
20 pub min_confidence: Option<f32>,
21 pub entity_names: Vec<String>,
22 pub project_path: Option<String>,
23}
24
25#[derive(Debug, Clone)]
26pub struct QueryResult {
27 pub memory: Memory,
28 pub score: f32,
29 pub path: Vec<Uuid>,
30}
31
32const MIN_SIMILARITY: f32 = 0.59;
33const PROJECT_AFFINITY_BOOST: f32 = 1.15;
34
35pub struct QueryEngine<'a, S: Store> {
36 store: &'a S,
37 vector_weight: f32,
38 graph_weight: f32,
39 recency_weight: f32,
40}
41
42impl<'a, S: Store> QueryEngine<'a, S> {
43 pub fn new(store: &'a S) -> Self {
44 Self {
45 store,
46 vector_weight: 0.5,
47 graph_weight: 0.3,
48 recency_weight: 0.2,
49 }
50 }
51
52 pub fn with_weights(mut self, vector: f32, graph: f32, recency: f32) -> Self {
53 self.vector_weight = vector;
54 self.graph_weight = graph;
55 self.recency_weight = recency;
56 self
57 }
58
59 pub fn recall(&self, request: &QueryRequest) -> Result<Vec<QueryResult>> {
60 let vector_results = self
61 .store
62 .vector_search(&request.embedding, request.limit * 3)?;
63
64 let mut scored: Vec<QueryResult> = Vec::new();
65
66 for (memory, similarity) in vector_results {
67 if similarity < MIN_SIMILARITY {
72 continue;
73 }
74 if let Some(min_conf) = request.filters.min_confidence
75 && memory.confidence < min_conf
76 {
77 continue;
78 }
79 if let Some(ref source) = request.filters.source
80 && &memory.source != source
81 {
82 continue;
83 }
84 if let Some(ref mt) = request.filters.memory_type
85 && &memory.memory_type != mt
86 {
87 continue;
88 }
89
90 let recency_score = self.compute_recency(&memory);
91 let graph_score = self
92 .compute_graph_relevance(&memory, &request.filters.entity_names)
93 .unwrap_or(0.0);
94
95 let mut rank_score = (similarity * self.vector_weight)
96 + (graph_score * self.graph_weight)
97 + (recency_score * self.recency_weight);
98
99 if let Some(ref qp) = request.filters.project_path {
100 if memory.project_path.as_deref() == Some(qp.as_str()) {
101 rank_score *= PROJECT_AFFINITY_BOOST;
102 }
103 }
104
105 scored.push(QueryResult {
106 memory,
107 score: rank_score,
108 path: Vec::new(),
109 });
110 }
111
112 scored.sort_by(|a, b| {
113 b.score
114 .partial_cmp(&a.score)
115 .unwrap_or(std::cmp::Ordering::Equal)
116 });
117 scored.truncate(request.limit);
118
119 Ok(scored)
120 }
121
122 fn compute_recency(&self, memory: &Memory) -> f32 {
123 let hours_since_access = Utc::now()
124 .signed_duration_since(memory.last_accessed)
125 .num_hours() as f32;
126
127 let decay = (-hours_since_access / (24.0 * 30.0)).exp();
128 let access_boost = (memory.access_count as f32).ln_1p() / 10.0;
129
130 (decay + access_boost).min(1.0)
131 }
132
133 fn compute_graph_relevance(&self, memory: &Memory, _entity_names: &[String]) -> Result<f32> {
134 let scored_types = [
135 (RelationType::Reinforces, 0.3_f32),
136 (RelationType::RelatesTo, 0.2),
137 (RelationType::DistilledFrom, 0.15),
138 (RelationType::Mentions, 0.1),
139 (RelationType::DerivedFrom, 0.05),
140 (RelationType::Contradicts, -0.1),
141 (RelationType::Supersedes, -0.2),
142 ];
143
144 let mut relevance = 0.0_f32;
145 for (rt, boost) in &scored_types {
146 if let Ok(rels) = self.store.get_relations(memory.id, Some(*rt)) {
147 for rel in &rels {
148 let b = if *rt == RelationType::RelatesTo {
149 boost * rel.strength
150 } else {
151 *boost
152 };
153 relevance += b;
154 }
155 }
156 }
157
158 Ok(relevance.clamp(0.0, 1.0))
159 }
160
161 pub fn store(&self) -> &S {
162 self.store
163 }
164}
165
166#[cfg(test)]
167mod tests {
168 use super::*;
169 use crate::schema::{Conversation, Entity, MemoryType, Relation};
170 use chrono::Duration;
171
172 struct StubStore {
173 vector_results: Vec<(Memory, f32)>,
174 relations: Vec<Relation>,
175 }
176
177 impl Store for StubStore {
178 fn vector_search(&self, _embedding: &[f32], _limit: usize) -> Result<Vec<(Memory, f32)>> {
179 Ok(self.vector_results.clone())
180 }
181
182 fn get_relations(
183 &self,
184 node_id: Uuid,
185 relation_type: Option<RelationType>,
186 ) -> Result<Vec<Relation>> {
187 Ok(self
188 .relations
189 .iter()
190 .filter(|r| r.from_id == node_id)
191 .filter(|r| relation_type.map(|rt| rt == r.relation_type).unwrap_or(true))
192 .cloned()
193 .collect())
194 }
195
196 fn store_memory(&self, _m: &Memory) -> Result<()> {
197 unimplemented!()
198 }
199 fn get_memory(&self, _id: Uuid) -> Result<Option<Memory>> {
200 unimplemented!()
201 }
202 fn delete_memory(&self, _id: Uuid) -> Result<()> {
203 unimplemented!()
204 }
205 fn store_entity(&self, _e: &Entity) -> Result<()> {
206 unimplemented!()
207 }
208 fn get_entity(&self, _id: Uuid) -> Result<Option<Entity>> {
209 unimplemented!()
210 }
211 fn find_entity_by_name(&self, _name: &str) -> Result<Option<Entity>> {
212 unimplemented!()
213 }
214 fn store_conversation(&self, _c: &Conversation) -> Result<()> {
215 unimplemented!()
216 }
217 fn store_relation(&self, _r: &Relation) -> Result<()> {
218 unimplemented!()
219 }
220 fn traverse(&self, _id: Uuid, _depth: u32) -> Result<Vec<(Memory, Vec<Relation>)>> {
221 unimplemented!()
222 }
223 fn memories_by_source(&self, _s: &str) -> Result<Vec<Memory>> {
224 unimplemented!()
225 }
226 fn memories_by_type(&self, _mt: MemoryType) -> Result<Vec<Memory>> {
227 unimplemented!()
228 }
229 fn memories_needing_decay(&self, _days: u32) -> Result<Vec<Memory>> {
230 unimplemented!()
231 }
232 fn update_memory(&self, _m: &Memory) -> Result<()> {
233 unimplemented!()
234 }
235 fn record_access(&self, _memory: &Memory) -> Result<()> {
236 unimplemented!()
237 }
238 fn text_search(&self, _q: &str, _limit: usize) -> Result<Vec<Memory>> {
239 unimplemented!()
240 }
241 fn memory_count(&self) -> Result<usize> {
242 unimplemented!()
243 }
244 fn all_memory_ids(&self) -> Result<Vec<Uuid>> {
245 unimplemented!()
246 }
247 fn all_relations(&self) -> Result<Vec<Relation>> {
248 unimplemented!()
249 }
250 }
251
252 fn memory_aged(content: &str, days_old: i64) -> Memory {
253 let when = Utc::now() - Duration::days(days_old);
254 let mut m = Memory::new(
255 content.to_string(),
256 MemoryType::Semantic,
257 "test".to_string(),
258 String::new(),
259 );
260 m.created_at = when;
261 m.last_accessed = when;
262 m
263 }
264
265 fn query() -> QueryRequest {
266 QueryRequest {
267 text: "query text".to_string(),
268 embedding: vec![0.0_f32; 384],
269 limit: 10,
270 filters: QueryFilters::default(),
271 }
272 }
273
274 #[test]
275 fn aged_unconnected_memory_survives_on_strong_similarity() {
276 let store = StubStore {
280 vector_results: vec![(
281 memory_aged("kuzu was chosen as the embedded graph store", 180),
282 0.92,
283 )],
284 relations: Vec::new(),
285 };
286 let results = QueryEngine::new(&store).recall(&query()).unwrap();
287 assert_eq!(results.len(), 1);
288 }
289
290 #[test]
291 fn well_connected_low_similarity_memory_is_excluded() {
292 let weak = memory_aged("an entirely unrelated topic", 0);
296 let weak_id = weak.id;
297 let reinforces = |to| Relation {
298 from_id: weak_id,
299 to_id: to,
300 relation_type: RelationType::Reinforces,
301 strength: 1.0,
302 context: None,
303 };
304 let store = StubStore {
305 vector_results: vec![(weak, 0.45)],
306 relations: vec![
307 reinforces(Uuid::new_v4()),
308 reinforces(Uuid::new_v4()),
309 reinforces(Uuid::new_v4()),
310 ],
311 };
312 let results = QueryEngine::new(&store).recall(&query()).unwrap();
313 assert!(results.is_empty());
314 }
315
316 #[test]
317 fn results_are_ranked_by_blended_score() {
318 let store = StubStore {
321 vector_results: vec![
322 (memory_aged("older relevant note", 200), 0.80),
323 (memory_aged("fresher relevant note", 0), 0.80),
324 ],
325 relations: Vec::new(),
326 };
327 let results = QueryEngine::new(&store).recall(&query()).unwrap();
328 assert_eq!(results.len(), 2);
329 assert_eq!(results[0].memory.content, "fresher relevant note");
330 }
331}