1use super::graph_store::GraphStore;
9use lc_embeddings::Embeddings;
10use std::collections::HashMap;
11
12pub trait EntityMatcher: Send + Sync {
17 fn find_relevant(&self, query: &str, store: &GraphStore, top_k: usize) -> Vec<String>;
19}
20
21pub struct KeywordMatcher {
31 pub name_weight: usize,
33 pub type_weight: usize,
35 pub desc_weight: usize,
37}
38
39impl Default for KeywordMatcher {
40 fn default() -> Self {
41 Self {
42 name_weight: 3,
43 type_weight: 2,
44 desc_weight: 1,
45 }
46 }
47}
48
49impl KeywordMatcher {
50 pub fn new() -> Self {
52 Self::default()
53 }
54}
55
56impl EntityMatcher for KeywordMatcher {
57 fn find_relevant(&self, query: &str, store: &GraphStore, top_k: usize) -> Vec<String> {
58 let query_lower = query.to_lowercase();
59 let keywords: Vec<&str> = query_lower
60 .split_whitespace()
61 .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()))
62 .filter(|w| !w.is_empty())
63 .collect();
64
65 let mut scored: Vec<(String, usize)> = Vec::new();
66
67 for (id, entity) in store.all_entities() {
68 let name_lower = entity.name.to_lowercase();
69 let desc_lower = entity.description.to_lowercase();
70 let type_lower = entity.entity_type.to_lowercase();
71
72 let mut score = 0usize;
73 for kw in &keywords {
74 if name_lower.contains(kw) {
75 score += self.name_weight;
76 }
77 if type_lower.contains(kw) {
78 score += self.type_weight;
79 }
80 if desc_lower.contains(kw) {
81 score += self.desc_weight;
82 }
83 }
84
85 if score > 0 {
86 scored.push((id.clone(), score));
87 }
88 }
89
90 scored.sort_by(|a, b| b.1.cmp(&a.1));
91 scored.into_iter().take(top_k).map(|(id, _)| id).collect()
92 }
93}
94
95pub struct EmbeddingMatcher<E: Embeddings> {
105 embeddings: E,
106 cache: std::sync::Mutex<HashMap<String, Vec<f32>>>,
108}
109
110impl<E: Embeddings> EmbeddingMatcher<E> {
111 pub fn new(embeddings: E) -> Self {
113 Self {
114 embeddings,
115 cache: std::sync::Mutex::new(HashMap::new()),
116 }
117 }
118
119 async fn get_entity_embedding(&self, entity_id: &str, entity_text: &str) -> Option<Vec<f32>> {
121 {
123 let cache = self.cache.lock().unwrap();
124 if let Some(vec) = cache.get(entity_id) {
125 return Some(vec.clone());
126 }
127 }
128
129 match self.embeddings.embed_query(entity_text).await {
131 Ok(vec) => {
132 self.cache
133 .lock()
134 .unwrap()
135 .insert(entity_id.to_string(), vec.clone());
136 Some(vec)
137 }
138 Err(_) => None,
139 }
140 }
141}
142
143impl<E: Embeddings + 'static> EntityMatcher for EmbeddingMatcher<E> {
144 fn find_relevant(&self, query: &str, store: &GraphStore, top_k: usize) -> Vec<String> {
145 let fallback = KeywordMatcher::new();
149 fallback.find_relevant(query, store, top_k)
150 }
151}
152
153impl<E: Embeddings + 'static> EmbeddingMatcher<E> {
154 pub async fn find_relevant_async(
159 &self,
160 query: &str,
161 store: &GraphStore,
162 top_k: usize,
163 ) -> Vec<String> {
164 let query_vec = match self.embeddings.embed_query(query).await {
165 Ok(v) => v,
166 Err(_) => {
167 let fallback = KeywordMatcher::new();
169 return fallback.find_relevant(query, store, top_k);
170 }
171 };
172
173 let mut scored: Vec<(String, f64)> = Vec::new();
174
175 for (id, entity) in store.all_entities() {
176 let entity_text = format!(
177 "{} {} {}",
178 entity.name, entity.entity_type, entity.description
179 );
180
181 if let Some(entity_vec) = self.get_entity_embedding(id, &entity_text).await {
182 let similarity = cosine_similarity(&query_vec, &entity_vec);
183 if similarity > 0.0 {
184 scored.push((id.clone(), similarity));
185 }
186 }
187 }
188
189 scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
190 scored.into_iter().take(top_k).map(|(id, _)| id).collect()
191 }
192}
193
194fn cosine_similarity(a: &[f32], b: &[f32]) -> f64 {
196 if a.len() != b.len() || a.is_empty() {
197 return 0.0;
198 }
199
200 let dot: f64 = a
201 .iter()
202 .zip(b.iter())
203 .map(|(x, y)| (*x as f64) * (*y as f64))
204 .sum();
205 let norm_a: f64 = a.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
206 let norm_b: f64 = b.iter().map(|x| (*x as f64).powi(2)).sum::<f64>().sqrt();
207
208 if norm_a == 0.0 || norm_b == 0.0 {
209 return 0.0;
210 }
211
212 dot / (norm_a * norm_b)
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::graph_rag::graph_store::{Entity, Relation};
219
220 fn make_test_store() -> GraphStore {
221 let mut store = GraphStore::new();
222 store.add_entity(Entity {
223 id: "e1".into(),
224 name: "Rust".into(),
225 entity_type: "Technology".into(),
226 description: "A systems programming language".into(),
227 });
228 store.add_entity(Entity {
229 id: "e2".into(),
230 name: "Python".into(),
231 entity_type: "Technology".into(),
232 description: "A scripting language".into(),
233 });
234 store.add_entity(Entity {
235 id: "e3".into(),
236 name: "Alice".into(),
237 entity_type: "Person".into(),
238 description: "A developer who uses Rust".into(),
239 });
240 store.add_entity(Entity {
241 id: "e4".into(),
242 name: "Tokio".into(),
243 entity_type: "Library".into(),
244 description: "An async runtime for Rust".into(),
245 });
246 store.add_relation(Relation {
247 source: "e3".into(),
248 target: "e1".into(),
249 relation_type: "uses".into(),
250 description: "Alice uses Rust".into(),
251 doc_id: None,
252 });
253 store
254 }
255
256 #[test]
257 fn test_keyword_matcher_basic() {
258 let store = make_test_store();
259 let matcher = KeywordMatcher::new();
260 let results = matcher.find_relevant("Rust programming", &store, 10);
261 assert!(!results.is_empty());
262 assert_eq!(results[0], "e1");
264 }
265
266 #[test]
267 fn test_keyword_matcher_top_k() {
268 let store = make_test_store();
269 let matcher = KeywordMatcher::new();
270 let results = matcher.find_relevant("Technology", &store, 1);
271 assert_eq!(results.len(), 1);
272 }
273
274 #[test]
275 fn test_keyword_matcher_no_match() {
276 let store = make_test_store();
277 let matcher = KeywordMatcher::new();
278 let results = matcher.find_relevant("cooking recipe", &store, 10);
279 assert!(results.is_empty());
280 }
281
282 #[test]
283 fn test_keyword_matcher_custom_weights() {
284 let store = make_test_store();
285 let matcher = KeywordMatcher {
286 name_weight: 10,
287 type_weight: 1,
288 desc_weight: 0,
289 };
290 let results = matcher.find_relevant("Rust", &store, 10);
291 assert!(!results.is_empty());
292 assert_eq!(results[0], "e1");
293 }
294
295 #[test]
296 fn test_cosine_similarity_identical() {
297 let v = vec![1.0, 0.0, 0.0];
298 let sim = cosine_similarity(&v, &v);
299 assert!((sim - 1.0).abs() < 0.001);
300 }
301
302 #[test]
303 fn test_cosine_similarity_orthogonal() {
304 let a = vec![1.0, 0.0];
305 let b = vec![0.0, 1.0];
306 let sim = cosine_similarity(&a, &b);
307 assert!((sim - 0.0).abs() < 0.001);
308 }
309
310 #[test]
311 fn test_cosine_similarity_opposite() {
312 let a = vec![1.0, 0.0];
313 let b = vec![-1.0, 0.0];
314 let sim = cosine_similarity(&a, &b);
315 assert!((sim - (-1.0)).abs() < 0.001);
316 }
317
318 #[test]
319 fn test_cosine_similarity_empty() {
320 let sim = cosine_similarity(&[], &[]);
321 assert_eq!(sim, 0.0);
322 }
323
324 #[test]
325 fn test_cosine_similarity_different_lengths() {
326 let a = vec![1.0];
327 let b = vec![1.0, 2.0];
328 let sim = cosine_similarity(&a, &b);
329 assert_eq!(sim, 0.0);
330 }
331}