Skip to main content

converge_knowledge/core/
search.rs

1//! Search types and options.
2
3use super::KnowledgeEntry;
4use serde::{Deserialize, Serialize};
5use uuid::Uuid;
6
7/// A search result from the knowledge base.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct SearchResult {
10    /// The matching entry.
11    pub entry: KnowledgeEntry,
12
13    /// Similarity score (0.0 to 1.0, higher is more similar).
14    pub similarity: f32,
15
16    /// Learned relevance boost applied.
17    pub relevance_boost: f32,
18
19    /// Final combined score.
20    pub score: f32,
21
22    /// Distance in vector space.
23    pub distance: f32,
24}
25
26impl SearchResult {
27    /// Create a new search result.
28    pub fn new(entry: KnowledgeEntry, similarity: f32, distance: f32) -> Self {
29        let relevance_boost = entry.learned_relevance;
30        let score = similarity * relevance_boost;
31
32        Self {
33            entry,
34            similarity,
35            relevance_boost,
36            score,
37            distance,
38        }
39    }
40
41    /// Get the entry ID.
42    pub fn id(&self) -> Uuid {
43        self.entry.id
44    }
45}
46
47/// Options for configuring search behavior.
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct SearchOptions {
50    /// Maximum number of results to return.
51    pub limit: usize,
52
53    /// Minimum similarity threshold (0.0 to 1.0).
54    pub min_similarity: f32,
55
56    /// Filter by category.
57    pub category: Option<String>,
58
59    /// Filter by tags (any match).
60    pub tags: Vec<String>,
61
62    /// Apply learned relevance boosting.
63    pub use_learning: bool,
64
65    /// Include related entries in results.
66    pub include_related: bool,
67
68    /// Diversity factor for MMR (Maximal Marginal Relevance).
69    pub diversity: f32,
70
71    /// Use hybrid search (combine vector + keyword).
72    pub hybrid: bool,
73
74    /// Keyword weight for hybrid search (0.0 to 1.0).
75    pub keyword_weight: f32,
76}
77
78impl Default for SearchOptions {
79    fn default() -> Self {
80        Self {
81            limit: 10,
82            min_similarity: 0.0,
83            category: None,
84            tags: Vec::new(),
85            use_learning: true,
86            include_related: false,
87            diversity: 0.0,
88            hybrid: false,
89            keyword_weight: 0.3,
90        }
91    }
92}
93
94impl SearchOptions {
95    /// Create new search options with a result limit.
96    pub fn new(limit: usize) -> Self {
97        Self {
98            limit,
99            ..Default::default()
100        }
101    }
102
103    /// Set minimum similarity threshold.
104    pub fn with_min_similarity(mut self, threshold: f32) -> Self {
105        self.min_similarity = threshold.clamp(0.0, 1.0);
106        self
107    }
108
109    /// Filter by category.
110    pub fn with_category(mut self, category: impl Into<String>) -> Self {
111        self.category = Some(category.into());
112        self
113    }
114
115    /// Filter by tags.
116    pub fn with_tags(mut self, tags: impl IntoIterator<Item = impl Into<String>>) -> Self {
117        self.tags = tags.into_iter().map(Into::into).collect();
118        self
119    }
120
121    /// Disable learning-based relevance boosting.
122    pub fn without_learning(mut self) -> Self {
123        self.use_learning = false;
124        self
125    }
126
127    /// Include related entries.
128    pub fn with_related(mut self) -> Self {
129        self.include_related = true;
130        self
131    }
132
133    /// Set diversity factor for MMR.
134    pub fn with_diversity(mut self, factor: f32) -> Self {
135        self.diversity = factor.clamp(0.0, 1.0);
136        self
137    }
138
139    /// Enable hybrid search.
140    pub fn hybrid(mut self, keyword_weight: f32) -> Self {
141        self.hybrid = true;
142        self.keyword_weight = keyword_weight.clamp(0.0, 1.0);
143        self
144    }
145}
146
147/// Filter criteria for entries.
148#[derive(Debug, Clone, Default)]
149#[allow(dead_code)]
150pub struct Filter {
151    /// Required categories (any match).
152    pub categories: Vec<String>,
153
154    /// Required tags (any match).
155    pub tags: Vec<String>,
156
157    /// Minimum access count.
158    pub min_access_count: Option<u64>,
159
160    /// Metadata key-value filters.
161    pub metadata: Vec<(String, String)>,
162}
163
164#[allow(dead_code)]
165impl Filter {
166    /// Create an empty filter.
167    pub fn new() -> Self {
168        Self::default()
169    }
170
171    /// Add category filter.
172    pub fn category(mut self, category: impl Into<String>) -> Self {
173        self.categories.push(category.into());
174        self
175    }
176
177    /// Add tag filter.
178    pub fn tag(mut self, tag: impl Into<String>) -> Self {
179        self.tags.push(tag.into());
180        self
181    }
182
183    /// Set minimum access count.
184    pub fn min_access(mut self, count: u64) -> Self {
185        self.min_access_count = Some(count);
186        self
187    }
188
189    /// Add metadata filter.
190    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
191        self.metadata.push((key.into(), value.into()));
192        self
193    }
194
195    /// Check if an entry matches this filter.
196    pub fn matches(&self, entry: &KnowledgeEntry) -> bool {
197        // Check categories
198        if !self.categories.is_empty() {
199            if let Some(cat) = &entry.category {
200                if !self.categories.iter().any(|c| c == cat) {
201                    return false;
202                }
203            } else {
204                return false;
205            }
206        }
207
208        // Check tags
209        if !self.tags.is_empty()
210            && !self
211                .tags
212                .iter()
213                .any(|t| entry.tags.iter().any(|et| et == t))
214        {
215            return false;
216        }
217
218        // Check access count
219        if let Some(min) = self.min_access_count {
220            if entry.access_count < min {
221                return false;
222            }
223        }
224
225        // Check metadata
226        for (key, value) in &self.metadata {
227            if entry.metadata.get(key) != Some(value.as_str()) {
228                return false;
229            }
230        }
231
232        true
233    }
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239
240    #[test]
241    fn test_search_options_builder() {
242        let opts = SearchOptions::new(5)
243            .with_min_similarity(0.5)
244            .with_category("Programming")
245            .with_tags(["rust", "tutorial"])
246            .with_diversity(0.3)
247            .hybrid(0.4);
248
249        assert_eq!(opts.limit, 5);
250        assert!((opts.min_similarity - 0.5).abs() < f32::EPSILON);
251        assert_eq!(opts.category, Some("Programming".to_string()));
252        assert_eq!(opts.tags, vec!["rust", "tutorial"]);
253        assert!((opts.diversity - 0.3).abs() < f32::EPSILON);
254        assert!(opts.hybrid);
255        assert!((opts.keyword_weight - 0.4).abs() < f32::EPSILON);
256    }
257
258    #[test]
259    fn test_filter_matching() {
260        let entry = KnowledgeEntry::new("Test", "Content")
261            .with_category("Programming")
262            .with_tags(["rust", "testing"]);
263
264        let filter = Filter::new().category("Programming").tag("rust");
265
266        assert!(filter.matches(&entry));
267
268        let non_matching_filter = Filter::new().category("Other");
269        assert!(!non_matching_filter.matches(&entry));
270    }
271}