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