1use crate::error::Result;
2use crate::models::{
3 Memory, SearchParams, SearchResult, SearchResultWithMetadata, SearchStrategy, StorageStats,
4};
5use async_trait::async_trait;
6use sqlx::{PgPool, Row};
7use uuid::Uuid;
8
9#[async_trait]
11pub trait StorageInterface: Send + Sync {
12 async fn store(
14 &self,
15 content: &str,
16 context: String,
17 summary: String,
18 tags: Option<Vec<String>>,
19 ) -> Result<Uuid>;
20
21 async fn store_chunk(
23 &self,
24 content: &str,
25 context: String,
26 summary: String,
27 tags: Option<Vec<String>>,
28 chunk_index: i32,
29 total_chunks: i32,
30 parent_id: Uuid,
31 ) -> Result<Uuid>;
32
33 async fn get(&self, id: Uuid) -> Result<Option<Memory>>;
35
36 async fn delete(&self, id: Uuid) -> Result<bool>;
38
39 async fn search(&self, params: SearchParams) -> Result<Vec<SearchResult>>;
41
42 async fn stats(&self) -> Result<StorageStats>;
44
45 async fn list_recent(&self, limit: i64) -> Result<Vec<Memory>>;
47
48 async fn get_chunks(&self, parent_id: Uuid) -> Result<Vec<Memory>>;
50}
51
52pub struct Storage {
54 pool: PgPool,
55}
56
57impl Storage {
58 pub fn new(pool: PgPool) -> Self {
60 Self { pool }
61 }
62
63 pub async fn store(
65 &self,
66 content: &str,
67 context: String,
68 summary: String,
69 tags: Option<Vec<String>>,
70 ) -> Result<Uuid> {
71 let memory = Memory::new(content.to_string(), context, summary, tags);
72
73 let result: Uuid = sqlx::query_scalar(
75 r#"
76 INSERT INTO memories (id, content, content_hash, tags, context, summary, chunk_index, total_chunks, parent_id, created_at, updated_at)
77 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
78 ON CONFLICT (content_hash) DO UPDATE SET
79 context = EXCLUDED.context,
80 summary = EXCLUDED.summary,
81 tags = EXCLUDED.tags,
82 updated_at = EXCLUDED.updated_at
83 RETURNING id
84 "#
85 )
86 .bind(memory.id)
87 .bind(memory.content)
88 .bind(memory.content_hash)
89 .bind(&memory.tags)
90 .bind(&memory.context)
91 .bind(&memory.summary)
92 .bind(memory.chunk_index)
93 .bind(memory.total_chunks)
94 .bind(memory.parent_id)
95 .bind(memory.created_at)
96 .bind(memory.updated_at)
97 .fetch_one(&self.pool)
98 .await?;
99
100 Ok(result)
101 }
102
103 pub async fn store_chunk(
105 &self,
106 content: &str,
107 context: String,
108 summary: String,
109 tags: Option<Vec<String>>,
110 chunk_index: i32,
111 total_chunks: i32,
112 parent_id: Uuid,
113 ) -> Result<Uuid> {
114 let memory = Memory::new_chunk(
115 content.to_string(),
116 context,
117 summary,
118 tags,
119 chunk_index,
120 total_chunks,
121 parent_id,
122 );
123
124 let result: Uuid = sqlx::query_scalar(
126 r#"
127 INSERT INTO memories (id, content, content_hash, tags, context, summary, chunk_index, total_chunks, parent_id, created_at, updated_at)
128 VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
129 RETURNING id
130 "#
131 )
132 .bind(memory.id)
133 .bind(memory.content)
134 .bind(memory.content_hash)
135 .bind(&memory.tags)
136 .bind(&memory.context)
137 .bind(&memory.summary)
138 .bind(memory.chunk_index)
139 .bind(memory.total_chunks)
140 .bind(memory.parent_id)
141 .bind(memory.created_at)
142 .bind(memory.updated_at)
143 .fetch_one(&self.pool)
144 .await?;
145
146 Ok(result)
147 }
148
149 pub async fn get(&self, id: Uuid) -> Result<Option<Memory>> {
151 let memory = sqlx::query_as::<_, Memory>(
152 r#"
153 SELECT
154 id,
155 content,
156 content_hash,
157 tags,
158 context,
159 summary,
160 chunk_index,
161 total_chunks,
162 parent_id,
163 created_at,
164 updated_at
165 FROM memories
166 WHERE id = $1
167 "#,
168 )
169 .bind(id)
170 .fetch_optional(&self.pool)
171 .await?;
172
173 Ok(memory)
174 }
175
176 pub async fn get_chunks(&self, parent_id: Uuid) -> Result<Vec<Memory>> {
178 let memories = sqlx::query_as::<_, Memory>(
179 r#"
180 SELECT
181 id,
182 content,
183 content_hash,
184 tags,
185 context,
186 summary,
187 chunk_index,
188 total_chunks,
189 parent_id,
190 created_at,
191 updated_at
192 FROM memories
193 WHERE parent_id = $1
194 ORDER BY chunk_index ASC
195 "#,
196 )
197 .bind(parent_id)
198 .fetch_all(&self.pool)
199 .await?;
200
201 Ok(memories)
202 }
203
204 pub async fn delete(&self, id: Uuid) -> Result<bool> {
206 let result = sqlx::query("DELETE FROM memories WHERE id = $1")
207 .bind(id)
208 .execute(&self.pool)
209 .await?;
210
211 Ok(result.rows_affected() > 0)
212 }
213
214 pub async fn stats(&self) -> Result<StorageStats> {
216 let row = sqlx::query(
217 r#"
218 SELECT
219 COUNT(*) as total_memories,
220 pg_size_pretty(pg_total_relation_size('memories')) as table_size,
221 MAX(created_at) as last_memory_created
222 FROM memories
223 "#,
224 )
225 .fetch_one(&self.pool)
226 .await?;
227
228 let stats = StorageStats {
229 total_memories: row.get("total_memories"),
230 table_size: row.get("table_size"),
231 last_memory_created: row.get("last_memory_created"),
232 };
233
234 Ok(stats)
235 }
236
237 pub async fn list_recent(&self, limit: i64) -> Result<Vec<Memory>> {
239 let memories = sqlx::query_as::<_, Memory>(
240 r#"
241 SELECT
242 id,
243 content,
244 content_hash,
245 tags,
246 context,
247 summary,
248 chunk_index,
249 total_chunks,
250 parent_id,
251 created_at,
252 updated_at
253 FROM memories
254 ORDER BY created_at DESC
255 LIMIT $1
256 "#,
257 )
258 .bind(limit)
259 .fetch_all(&self.pool)
260 .await?;
261
262 Ok(memories)
263 }
264
265 pub async fn find_similar_content(
268 &self,
269 content_hash: &str,
270 limit: i64,
271 ) -> Result<Vec<Memory>> {
272 let memories = sqlx::query_as::<_, Memory>(
273 r#"
274 SELECT
275 id,
276 content,
277 content_hash,
278 tags,
279 context,
280 summary,
281 chunk_index,
282 total_chunks,
283 parent_id,
284 created_at,
285 updated_at
286 FROM memories
287 WHERE content_hash = $1
288 ORDER BY created_at DESC
289 LIMIT $2
290 "#,
291 )
292 .bind(content_hash)
293 .bind(limit)
294 .fetch_all(&self.pool)
295 .await?;
296
297 Ok(memories)
298 }
299
300 pub async fn exists_with_content(&self, content_hash: &str) -> Result<bool> {
303 let count: i64 =
304 sqlx::query_scalar("SELECT COUNT(*) FROM memories WHERE content_hash = $1")
305 .bind(content_hash)
306 .fetch_one(&self.pool)
307 .await?;
308
309 Ok(count > 0)
310 }
311
312 pub async fn get_content_stats(&self) -> Result<Vec<(String, i64)>> {
315 let rows = sqlx::query(
316 r#"
317 SELECT
318 content_hash,
319 COUNT(*) as total_count
320 FROM memories
321 GROUP BY content_hash
322 HAVING COUNT(*) > 1
323 ORDER BY total_count DESC
324 LIMIT 50
325 "#,
326 )
327 .fetch_all(&self.pool)
328 .await?;
329
330 let stats = rows
331 .into_iter()
332 .map(|row| {
333 (
334 row.get::<String, _>("content_hash"),
335 row.get::<i64, _>("total_count"),
336 )
337 })
338 .collect();
339
340 Ok(stats)
341 }
342
343 pub async fn search_memories(&self, params: SearchParams) -> Result<Vec<SearchResult>> {
346 self.search_memories_progressive(params).await
348 }
349
350 async fn search_memories_progressive(&self, params: SearchParams) -> Result<Vec<SearchResult>> {
355 let result_with_metadata = self
356 .search_memories_progressive_with_metadata(params)
357 .await?;
358 Ok(result_with_metadata.results)
359 }
360
361 pub async fn search_memories_progressive_with_metadata(
363 &self,
364 params: SearchParams,
365 ) -> Result<SearchResultWithMetadata> {
366 use crate::models::SearchMetadata;
367
368 let stage1_results = self.search_memories_internal(params.clone()).await?;
370 if !stage1_results.is_empty() {
371 let metadata = SearchMetadata {
372 stage_used: 1,
373 stage_description: "Original parameters".to_string(),
374 threshold_used: params.similarity_threshold,
375 total_results: stage1_results.len(),
376 };
377 return Ok(SearchResultWithMetadata {
378 results: stage1_results,
379 metadata,
380 });
381 }
382
383 let mut relaxed_params = params.clone();
385 relaxed_params.similarity_threshold = (params.similarity_threshold - 0.25).max(0.1);
386
387 let stage2_results = self
388 .search_memories_internal(relaxed_params.clone())
389 .await?;
390 if !stage2_results.is_empty() {
391 let metadata = SearchMetadata {
392 stage_used: 2,
393 stage_description: "Relaxed threshold".to_string(),
394 threshold_used: relaxed_params.similarity_threshold,
395 total_results: stage2_results.len(),
396 };
397 return Ok(SearchResultWithMetadata {
398 results: stage2_results,
399 metadata,
400 });
401 }
402
403 let mut content_params = params.clone();
405 content_params.similarity_threshold = 0.1;
406 content_params.use_tag_embedding = false;
407 content_params.search_strategy = SearchStrategy::ContentFirst;
408
409 let stage3_results = self.search_memories_internal(content_params).await?;
410 let metadata = SearchMetadata {
411 stage_used: 3,
412 stage_description: "Content-only similarity".to_string(),
413 threshold_used: 0.1,
414 total_results: stage3_results.len(),
415 };
416
417 Ok(SearchResultWithMetadata {
418 results: stage3_results,
419 metadata,
420 })
421 }
422
423 async fn search_memories_internal(&self, params: SearchParams) -> Result<Vec<SearchResult>> {
425 let has_embeddings = self.check_embedding_columns_exist().await?;
427
428 if !has_embeddings || (!params.use_tag_embedding && !params.use_content_embedding) {
429 return self.search_memories_fallback(params).await;
431 }
432
433 let query_memory_ids = if params.use_tag_embedding || params.use_content_embedding {
436 let similar_text_rows = sqlx::query(
438 r#"
439 SELECT id, summary, content
440 FROM memories
441 WHERE to_tsvector('english', summary || ' ' || content) @@ plainto_tsquery('english', $1)
442 AND embedding_vector IS NOT NULL
443 LIMIT 5
444 "#
445 )
446 .bind(¶ms.query)
447 .fetch_all(&self.pool)
448 .await;
449
450 match similar_text_rows {
451 Ok(rows) => {
452 if rows.is_empty() {
453 return self.search_memories_fallback(params).await;
455 }
456 rows.into_iter()
457 .map(|row| row.get::<Uuid, _>("id"))
458 .collect::<Vec<_>>()
459 }
460 Err(_) => {
461 return self.search_memories_fallback(params).await;
463 }
464 }
465 } else {
466 vec![]
467 };
468
469 let mut results = match params.search_strategy {
471 SearchStrategy::TagsFirst => self.search_tags_first(¶ms, &query_memory_ids).await?,
472 SearchStrategy::ContentFirst => {
473 self.search_content_first(¶ms, &query_memory_ids)
474 .await?
475 }
476 SearchStrategy::Hybrid => self.search_hybrid(¶ms, &query_memory_ids).await?,
477 };
478
479 if params.boost_recent {
481 self.apply_recency_boost(&mut results);
482 }
483
484 results.sort_by(|a, b| b.combined_score.partial_cmp(&a.combined_score).unwrap());
486 results.truncate(params.max_results);
487
488 Ok(results)
489 }
490
491 async fn check_embedding_columns_exist(&self) -> Result<bool> {
493 let result = sqlx::query(
494 r#"
495 SELECT COUNT(*) as count
496 FROM information_schema.columns
497 WHERE table_name = 'memories'
498 AND column_name IN ('embedding_vector', 'tag_embedding')
499 "#,
500 )
501 .fetch_one(&self.pool)
502 .await;
503
504 match result {
505 Ok(row) => {
506 let count: i64 = row.get("count");
507 Ok(count >= 2) }
509 Err(_) => Ok(false),
510 }
511 }
512
513 async fn search_tags_first(
515 &self,
516 params: &SearchParams,
517 query_ids: &[Uuid],
518 ) -> Result<Vec<SearchResult>> {
519 if query_ids.is_empty() {
520 return Ok(vec![]);
521 }
522
523 let tag_results = sqlx::query(
525 r#"
526 WITH query_embedding AS (
527 SELECT tag_embedding as query_vector
528 FROM memories
529 WHERE id = $1 AND tag_embedding IS NOT NULL
530 LIMIT 1
531 )
532 SELECT m.*,
533 (m.tag_embedding <=> q.query_vector) as tag_similarity,
534 m.semantic_cluster
535 FROM memories m, query_embedding q
536 WHERE m.tag_embedding IS NOT NULL
537 AND ($2::text[] IS NULL OR m.tags && $2::text[])
538 AND (m.tag_embedding <=> q.query_vector) <= $3
539 ORDER BY m.tag_embedding <=> q.query_vector
540 LIMIT $4
541 "#,
542 )
543 .bind(query_ids[0])
544 .bind(¶ms.tag_filter)
545 .bind(1.0 - params.similarity_threshold) .bind((params.max_results * 3) as i64) .fetch_all(&self.pool)
548 .await?;
549
550 self.enhance_with_content_similarity(tag_results, query_ids, params)
551 .await
552 }
553
554 async fn search_content_first(
556 &self,
557 params: &SearchParams,
558 query_ids: &[Uuid],
559 ) -> Result<Vec<SearchResult>> {
560 if query_ids.is_empty() {
561 return Ok(vec![]);
562 }
563
564 let content_results = sqlx::query(
565 r#"
566 WITH query_embedding AS (
567 SELECT embedding_vector as query_vector
568 FROM memories
569 WHERE id = $1 AND embedding_vector IS NOT NULL
570 LIMIT 1
571 )
572 SELECT m.*,
573 (m.embedding_vector <=> q.query_vector) as content_similarity,
574 m.semantic_cluster
575 FROM memories m, query_embedding q
576 WHERE m.embedding_vector IS NOT NULL
577 AND ($2::text[] IS NULL OR m.tags && $2::text[])
578 AND (m.embedding_vector <=> q.query_vector) <= $3
579 ORDER BY m.embedding_vector <=> q.query_vector
580 LIMIT $4
581 "#,
582 )
583 .bind(query_ids[0])
584 .bind(¶ms.tag_filter)
585 .bind(1.0 - params.similarity_threshold)
586 .bind((params.max_results * 2) as i64)
587 .fetch_all(&self.pool)
588 .await?;
589
590 self.enhance_with_tag_similarity(content_results, query_ids, params)
591 .await
592 }
593
594 async fn search_hybrid(
596 &self,
597 params: &SearchParams,
598 query_ids: &[Uuid],
599 ) -> Result<Vec<SearchResult>> {
600 if query_ids.is_empty() {
601 return Ok(vec![]);
602 }
603
604 let results = sqlx::query(
605 r#"
606 WITH query_embeddings AS (
607 SELECT
608 embedding_vector as content_query_vector,
609 tag_embedding as tag_query_vector
610 FROM memories
611 WHERE id = $1
612 AND embedding_vector IS NOT NULL
613 AND tag_embedding IS NOT NULL
614 LIMIT 1
615 )
616 SELECT m.*,
617 (m.embedding_vector <=> q.content_query_vector) as content_similarity,
618 (m.tag_embedding <=> q.tag_query_vector) as tag_similarity,
619 m.semantic_cluster
620 FROM memories m, query_embeddings q
621 WHERE m.embedding_vector IS NOT NULL
622 AND m.tag_embedding IS NOT NULL
623 AND ($2::text[] IS NULL OR m.tags && $2::text[])
624 AND (
625 (m.embedding_vector <=> q.content_query_vector) <= $3 OR
626 (m.tag_embedding <=> q.tag_query_vector) <= $3
627 )
628 ORDER BY LEAST(
629 m.embedding_vector <=> q.content_query_vector,
630 m.tag_embedding <=> q.tag_query_vector
631 )
632 LIMIT $4
633 "#,
634 )
635 .bind(query_ids[0])
636 .bind(¶ms.tag_filter)
637 .bind(1.0 - params.similarity_threshold)
638 .bind((params.max_results * 2) as i64)
639 .fetch_all(&self.pool)
640 .await?;
641
642 Ok(self.rows_to_search_results(results, params))
643 }
644
645 async fn enhance_with_content_similarity(
647 &self,
648 tag_results: Vec<sqlx::postgres::PgRow>,
649 query_ids: &[Uuid],
650 params: &SearchParams,
651 ) -> Result<Vec<SearchResult>> {
652 if query_ids.is_empty() || tag_results.is_empty() {
653 return Ok(vec![]);
654 }
655
656 let memory_ids: Vec<Uuid> = tag_results.iter().map(|r| r.get("id")).collect();
658
659 let content_similarities = if params.use_content_embedding {
660 sqlx::query(
661 r#"
662 WITH query_embedding AS (
663 SELECT embedding_vector as query_vector
664 FROM memories
665 WHERE id = $1 AND embedding_vector IS NOT NULL
666 LIMIT 1
667 )
668 SELECT m.id, (m.embedding_vector <=> q.query_vector) as content_similarity
669 FROM memories m, query_embedding q
670 WHERE m.id = ANY($2) AND m.embedding_vector IS NOT NULL
671 "#,
672 )
673 .bind(query_ids[0])
674 .bind(&memory_ids)
675 .fetch_all(&self.pool)
676 .await?
677 } else {
678 vec![]
679 };
680
681 let content_sim_map: std::collections::HashMap<Uuid, f64> = content_similarities
683 .into_iter()
684 .map(|row| (row.get("id"), 1.0 - row.get::<f64, _>("content_similarity")))
685 .collect();
686
687 let mut results = vec![];
689 for row in tag_results {
690 let memory_id: Uuid = row.get("id");
691 let tag_similarity = Some(1.0 - row.get::<f64, _>("tag_similarity"));
692 let content_similarity = content_sim_map.get(&memory_id).copied();
693 let semantic_cluster = row.get("semantic_cluster");
694
695 let memory = self.row_to_memory(&row);
696 let result = SearchResult::new(
697 memory,
698 tag_similarity,
699 content_similarity,
700 semantic_cluster,
701 params.tag_weight,
702 params.content_weight,
703 );
704
705 if result.combined_score >= params.similarity_threshold {
706 results.push(result);
707 }
708 }
709
710 Ok(results)
711 }
712
713 async fn enhance_with_tag_similarity(
715 &self,
716 content_results: Vec<sqlx::postgres::PgRow>,
717 query_ids: &[Uuid],
718 params: &SearchParams,
719 ) -> Result<Vec<SearchResult>> {
720 if query_ids.is_empty() || content_results.is_empty() {
721 return Ok(vec![]);
722 }
723
724 let memory_ids: Vec<Uuid> = content_results.iter().map(|r| r.get("id")).collect();
725
726 let tag_similarities = if params.use_tag_embedding {
727 sqlx::query(
728 r#"
729 WITH query_embedding AS (
730 SELECT tag_embedding as query_vector
731 FROM memories
732 WHERE id = $1 AND tag_embedding IS NOT NULL
733 LIMIT 1
734 )
735 SELECT m.id, (m.tag_embedding <=> q.query_vector) as tag_similarity
736 FROM memories m, query_embedding q
737 WHERE m.id = ANY($2) AND m.tag_embedding IS NOT NULL
738 "#,
739 )
740 .bind(query_ids[0])
741 .bind(&memory_ids)
742 .fetch_all(&self.pool)
743 .await?
744 } else {
745 vec![]
746 };
747
748 let tag_sim_map: std::collections::HashMap<Uuid, f64> = tag_similarities
749 .into_iter()
750 .map(|row| (row.get("id"), 1.0 - row.get::<f64, _>("tag_similarity")))
751 .collect();
752
753 let mut results = vec![];
754 for row in content_results {
755 let memory_id: Uuid = row.get("id");
756 let content_similarity = Some(1.0 - row.get::<f64, _>("content_similarity"));
757 let tag_similarity = tag_sim_map.get(&memory_id).copied();
758 let semantic_cluster = row.get("semantic_cluster");
759
760 let memory = self.row_to_memory(&row);
761 let result = SearchResult::new(
762 memory,
763 tag_similarity,
764 content_similarity,
765 semantic_cluster,
766 params.tag_weight,
767 params.content_weight,
768 );
769
770 if result.combined_score >= params.similarity_threshold {
771 results.push(result);
772 }
773 }
774
775 Ok(results)
776 }
777
778 fn rows_to_search_results(
780 &self,
781 rows: Vec<sqlx::postgres::PgRow>,
782 params: &SearchParams,
783 ) -> Vec<SearchResult> {
784 rows.into_iter()
785 .filter_map(|row| {
786 let tag_similarity = row
787 .try_get::<f64, _>("tag_similarity")
788 .ok()
789 .map(|v| 1.0 - v);
790 let content_similarity = row
791 .try_get::<f64, _>("content_similarity")
792 .ok()
793 .map(|v| 1.0 - v);
794 let semantic_cluster = row.get("semantic_cluster");
795
796 let memory = self.row_to_memory(&row);
797 let result = SearchResult::new(
798 memory,
799 tag_similarity,
800 content_similarity,
801 semantic_cluster,
802 params.tag_weight,
803 params.content_weight,
804 );
805
806 if result.combined_score >= params.similarity_threshold {
807 Some(result)
808 } else {
809 None
810 }
811 })
812 .collect()
813 }
814
815 fn row_to_memory(&self, row: &sqlx::postgres::PgRow) -> Memory {
818 Memory {
819 id: row.get("id"),
820 content: row.get("content"),
821 content_hash: row.get("content_hash"),
822 tags: row.get("tags"),
823 context: row.get("context"),
824 summary: row.get("summary"),
825 chunk_index: row.get("chunk_index"),
826 total_chunks: row.get("total_chunks"),
827 parent_id: row.get("parent_id"),
828 created_at: row.get("created_at"),
829 updated_at: row.get("updated_at"),
830 }
831 }
832
833 fn apply_recency_boost(&self, results: &mut [SearchResult]) {
835 let now = chrono::Utc::now();
836 for result in results.iter_mut() {
837 let age_days = (now - result.memory.created_at).num_days() as f64;
838 let recency_factor = (1.0 / (1.0 + age_days / 30.0)).max(0.1); result.combined_score *= recency_factor;
840 }
841 }
842
843 async fn search_memories_fallback(&self, params: SearchParams) -> Result<Vec<SearchResult>> {
845 let search_pattern = format!("%{}%", params.query);
847
848 let query_sql = if let Some(ref _tag_filter) = params.tag_filter {
849 r#"
850 SELECT *,
851 CAST(CASE
852 WHEN content ILIKE $1 AND summary ILIKE $1 THEN 1.0
853 WHEN content ILIKE $1 OR summary ILIKE $1 THEN 0.8
854 WHEN context ILIKE $1 THEN 0.6
855 WHEN EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE $1) THEN 0.5
856 ELSE 0.4
857 END AS FLOAT8) as rank
858 FROM memories
859 WHERE (content ILIKE $1 OR summary ILIKE $1 OR context ILIKE $1
860 OR EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE $1))
861 AND tags && $2::text[]
862 ORDER BY rank DESC, created_at DESC
863 LIMIT $3
864 "#
865 } else {
866 r#"
867 SELECT *,
868 CAST(CASE
869 WHEN content ILIKE $1 AND summary ILIKE $1 THEN 1.0
870 WHEN content ILIKE $1 OR summary ILIKE $1 THEN 0.8
871 WHEN context ILIKE $1 THEN 0.6
872 WHEN EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE $1) THEN 0.5
873 ELSE 0.4
874 END AS FLOAT8) as rank
875 FROM memories
876 WHERE content ILIKE $1 OR summary ILIKE $1 OR context ILIKE $1
877 OR EXISTS (SELECT 1 FROM unnest(tags) AS tag WHERE tag ILIKE $1)
878 ORDER BY rank DESC, created_at DESC
879 LIMIT $2
880 "#
881 };
882
883 let rows = if let Some(ref tag_filter) = params.tag_filter {
884 sqlx::query(query_sql)
885 .bind(&search_pattern)
886 .bind(tag_filter)
887 .bind(params.max_results as i64)
888 .fetch_all(&self.pool)
889 .await?
890 } else {
891 sqlx::query(query_sql)
892 .bind(&search_pattern)
893 .bind(params.max_results as i64)
894 .fetch_all(&self.pool)
895 .await?
896 };
897
898 let results = rows
899 .into_iter()
900 .map(|row| {
901 let rank: f64 = row.get("rank");
902 let memory = self.row_to_memory(&row);
903 SearchResult {
905 memory,
906 tag_similarity: None,
907 content_similarity: Some(rank),
908 combined_score: rank, semantic_cluster: None,
910 }
911 })
912 .filter(|result| result.combined_score >= params.similarity_threshold)
913 .collect();
914
915 Ok(results)
916 }
917}
918
919#[async_trait]
920impl StorageInterface for Storage {
921 async fn store(
922 &self,
923 content: &str,
924 context: String,
925 summary: String,
926 tags: Option<Vec<String>>,
927 ) -> Result<Uuid> {
928 self.store(content, context, summary, tags).await
929 }
930
931 async fn store_chunk(
932 &self,
933 content: &str,
934 context: String,
935 summary: String,
936 tags: Option<Vec<String>>,
937 chunk_index: i32,
938 total_chunks: i32,
939 parent_id: Uuid,
940 ) -> Result<Uuid> {
941 self.store_chunk(content, context, summary, tags, chunk_index, total_chunks, parent_id)
942 .await
943 }
944
945 async fn get(&self, id: Uuid) -> Result<Option<Memory>> {
946 self.get(id).await
947 }
948
949 async fn delete(&self, id: Uuid) -> Result<bool> {
950 self.delete(id).await
951 }
952
953 async fn search(&self, params: SearchParams) -> Result<Vec<SearchResult>> {
954 self.search_memories(params).await
955 }
956
957 async fn stats(&self) -> Result<StorageStats> {
958 self.stats().await
959 }
960
961 async fn list_recent(&self, limit: i64) -> Result<Vec<Memory>> {
962 self.list_recent(limit).await
963 }
964
965 async fn get_chunks(&self, parent_id: Uuid) -> Result<Vec<Memory>> {
966 self.get_chunks(parent_id).await
967 }
968}