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)]
149pub struct Filter {
150    /// Required categories (any match).
151    pub categories: Vec<String>,
152
153    /// Required tags (any match).
154    pub tags: Vec<String>,
155
156    /// Minimum access count.
157    pub min_access_count: Option<u64>,
158
159    /// Metadata key-value filters.
160    pub metadata: Vec<(String, String)>,
161}
162
163impl Filter {
164    /// Create an empty filter.
165    pub fn new() -> Self {
166        Self::default()
167    }
168
169    /// Add category filter.
170    pub fn category(mut self, category: impl Into<String>) -> Self {
171        self.categories.push(category.into());
172        self
173    }
174
175    /// Add tag filter.
176    pub fn tag(mut self, tag: impl Into<String>) -> Self {
177        self.tags.push(tag.into());
178        self
179    }
180
181    /// Set minimum access count.
182    pub fn min_access(mut self, count: u64) -> Self {
183        self.min_access_count = Some(count);
184        self
185    }
186
187    /// Add metadata filter.
188    pub fn metadata(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
189        self.metadata.push((key.into(), value.into()));
190        self
191    }
192
193    /// Check if an entry matches this filter.
194    pub fn matches(&self, entry: &KnowledgeEntry) -> bool {
195        // Check categories
196        if !self.categories.is_empty() {
197            if let Some(cat) = &entry.category {
198                if !self.categories.iter().any(|c| c == cat) {
199                    return false;
200                }
201            } else {
202                return false;
203            }
204        }
205
206        // Check tags
207        if !self.tags.is_empty()
208            && !self
209                .tags
210                .iter()
211                .any(|t| entry.tags.iter().any(|et| et == t))
212        {
213            return false;
214        }
215
216        // Check access count
217        if let Some(min) = self.min_access_count {
218            if entry.access_count < min {
219                return false;
220            }
221        }
222
223        // Check metadata
224        for (key, value) in &self.metadata {
225            if entry.metadata.get(key) != Some(value.as_str()) {
226                return false;
227            }
228        }
229
230        true
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237
238    #[test]
239    fn test_search_options_builder() {
240        let opts = SearchOptions::new(5)
241            .with_min_similarity(0.5)
242            .with_category("Programming")
243            .with_tags(["rust", "tutorial"])
244            .with_diversity(0.3)
245            .hybrid(0.4);
246
247        assert_eq!(opts.limit, 5);
248        assert!((opts.min_similarity - 0.5).abs() < f32::EPSILON);
249        assert_eq!(opts.category, Some("Programming".to_string()));
250        assert_eq!(opts.tags, vec!["rust", "tutorial"]);
251        assert!((opts.diversity - 0.3).abs() < f32::EPSILON);
252        assert!(opts.hybrid);
253        assert!((opts.keyword_weight - 0.4).abs() < f32::EPSILON);
254    }
255
256    #[test]
257    fn test_filter_matching() {
258        let entry = KnowledgeEntry::new("Test", "Content")
259            .with_category("Programming")
260            .with_tags(["rust", "testing"]);
261
262        let filter = Filter::new().category("Programming").tag("rust");
263
264        assert!(filter.matches(&entry));
265
266        let non_matching_filter = Filter::new().category("Other");
267        assert!(!non_matching_filter.matches(&entry));
268    }
269}