Skip to main content

lc_rag/graph_rag/
matcher.rs

1// src/retrieval/graph_rag/matcher.rs
2//! Entity matching strategies for GraphRAG local queries.
3//!
4//! Provides the [`EntityMatcher`] trait and two implementations:
5//! - [`KeywordMatcher`]: matches entities by keyword substring (default, zero-cost)
6//! - [`EmbeddingMatcher`]: matches entities by embedding cosine similarity
7
8use super::graph_store::GraphStore;
9use lc_embeddings::Embeddings;
10use std::collections::HashMap;
11
12/// Trait for finding relevant entities in a graph store given a query.
13///
14/// Implementations can use different matching strategies (keyword, embedding,
15/// hybrid, etc.). The default is [`KeywordMatcher`].
16pub trait EntityMatcher: Send + Sync {
17    /// Find entity IDs relevant to the query, returning at most `top_k` results.
18    fn find_relevant(&self, query: &str, store: &GraphStore, top_k: usize) -> Vec<String>;
19}
20
21// ---------------------------------------------------------------------------
22// KeywordMatcher
23// ---------------------------------------------------------------------------
24
25/// Matches entities by keyword substring search.
26///
27/// This is the default matcher used by GraphRAG. It splits the query into
28/// keywords and scores each entity based on how many keywords match the
29/// entity's name, type, and description. Name matches are weighted highest.
30pub struct KeywordMatcher {
31    /// Weight for name matches (default: 3).
32    pub name_weight: usize,
33    /// Weight for type matches (default: 2).
34    pub type_weight: usize,
35    /// Weight for description matches (default: 1).
36    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    /// Creates a new keyword matcher with default weights.
51    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
95// ---------------------------------------------------------------------------
96// EmbeddingMatcher
97// ---------------------------------------------------------------------------
98
99/// Matches entities by computing embedding similarity between the query and
100/// entity representations (name + type + description).
101///
102/// Requires an [`Embeddings`] implementation to compute vectors. Embeddings
103/// are cached internally to avoid recomputation across calls.
104pub struct EmbeddingMatcher<E: Embeddings> {
105    embeddings: E,
106    /// Cached entity vectors: entity_id → embedding.
107    cache: std::sync::Mutex<HashMap<String, Vec<f32>>>,
108}
109
110impl<E: Embeddings> EmbeddingMatcher<E> {
111    /// Creates a new embedding matcher with the given embeddings backend.
112    pub fn new(embeddings: E) -> Self {
113        Self {
114            embeddings,
115            cache: std::sync::Mutex::new(HashMap::new()),
116        }
117    }
118
119    /// Returns the embedding for an entity, computing and caching it if needed.
120    async fn get_entity_embedding(&self, entity_id: &str, entity_text: &str) -> Option<Vec<f32>> {
121        // Check cache first
122        {
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        // Compute and cache
130        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        // Synchronous wrapper: we can't call async embed_query in a sync trait method.
146        // Fallback to keyword matching for the sync interface.
147        // The async version is available via `find_relevant_async`.
148        let fallback = KeywordMatcher::new();
149        fallback.find_relevant(query, store, top_k)
150    }
151}
152
153impl<E: Embeddings + 'static> EmbeddingMatcher<E> {
154    /// Async version of entity matching using embeddings.
155    ///
156    /// This is the preferred method when using embedding-based matching,
157    /// since embedding computation is inherently async.
158    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                // Fallback to keyword matching if embedding fails
168                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
194/// Computes cosine similarity between two vectors.
195fn 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        // "Rust" entity should rank first (name match + description match)
263        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}