Skip to main content

second_brain_core/
query.rs

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            // Gate membership on similarity alone. Graph and recency only
68            // re-rank, so folding them into the cutoff caps the similarity
69            // term below any useful threshold and would silently drop aged,
70            // unconnected memories that match the query perfectly.
71            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        // The memory is 180 days old with no relations, so graph and recency
277        // contribute nothing; it must still be recalled because a strong
278        // semantic match is what relevance means.
279        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        // A fresh, heavily reinforced memory whose similarity is weak must be
293        // dropped, because graph and recency re-rank matches but must not
294        // promote an irrelevant memory past the similarity floor.
295        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        // Both memories clear the similarity floor with equal similarity, so
319        // recency must break the tie and rank the fresher memory first.
320        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}