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 crate::graph_rag::GraphRAGError;
10use crate::hybrid::filter_by_score;
11use lc_core::math::cosine_similarity;
12use lc_embeddings::Embeddings;
13use std::collections::{HashMap, HashSet};
14
15/// Trait for finding relevant entities in a graph store given a query.
16///
17/// Implementations can use different matching strategies (keyword, embedding,
18/// hybrid, etc.). The default is [`KeywordMatcher`].
19pub trait EntityMatcher: Send + Sync {
20    /// Find entity IDs relevant to the query, returning at most `top_k` results.
21    fn find_relevant(&self, query: &str, store: &GraphStore, top_k: usize) -> Vec<String>;
22}
23
24// ---------------------------------------------------------------------------
25// KeywordMatcher
26// ---------------------------------------------------------------------------
27
28/// 查询词来源类型:P2-4 用于给不同来源的命中施加不同衰减权重。
29#[derive(Debug, Clone, Copy)]
30enum TermKind {
31    /// 查询词直接命中(权重 1.0)。
32    Direct,
33    /// 同义词扩展命中(衰减 `synonym_weight`)。
34    Synonym,
35    /// CJK 二元组命中(衰减 `cjk_bigram_weight`)。
36    Bigram,
37}
38
39/// 一个待匹配查询词:文本 + 来源类型。
40struct Term {
41    text: String,
42    kind: TermKind,
43}
44
45/// 中英混合归一化(P2-4):全角字符转半角(全角 ASCII 与半角差 0xFEE0),
46/// 使 "Rust" 归一化为 "rust",与实体名的小写形式一致。
47fn normalize_text(s: &str) -> String {
48    s.chars()
49        .map(|c| {
50            let u = c as u32;
51            if (0xFF01..=0xFF5E).contains(&u) {
52                char::from_u32(u - 0xFEE0).unwrap_or(c)
53            } else {
54                c
55            }
56        })
57        .collect()
58}
59
60/// 是否为 CJK 表意字符(中文/日文汉字等)。
61fn is_cjk(c: char) -> bool {
62    matches!(
63        c,
64        '\u{3400}'..='\u{4DBF}' | '\u{4E00}'..='\u{9FFF}' | '\u{F900}'..='\u{FAFF}'
65    )
66}
67
68/// 中文无空格,取 CJK 字符的相邻二元组补召回(如 "机器学习" → 机器/器学/学习),
69/// 使长中文查询能命中短实体名。
70fn cjk_bigrams(s: &str) -> Vec<String> {
71    let chars: Vec<char> = s.chars().filter(|c| is_cjk(*c)).collect();
72    if chars.len() < 2 {
73        return Vec::new();
74    }
75    chars.windows(2).map(|w| w.iter().collect()).collect()
76}
77
78/// Matches entities by keyword substring search.
79///
80/// This is the default matcher used by GraphRAG. It splits the query into
81/// keywords and scores each entity based on how many keywords match the
82/// entity's name, type, and description. Name matches are weighted highest.
83///
84/// P2-4: 在固定 name+3/type+2/desc+1 权重之上加入三项改进,修复拍脑袋权重
85/// 与子串匹配对同义词/多义词/中英混杂的漏召回:
86/// - **同义词表扩展** `synonyms`:查询词命中同义词键时按等价词追加匹配
87///   (命中衰减 `synonym_weight`,默认 0.7)。
88/// - **中英混合归一化**:全角→半角 + 中文长查询拆 CJK 二元组,解决无空格
89///   中文单 token 无法命中短实体名的问题。
90/// - **TF-IDF 加权**:每个查询词按其在实体语料中的逆文档频率加权,常见词
91///   (如 "Technology") 区分度小、贡献小,稀有词贡献大;`use_tfidf` 可关闭。
92pub struct KeywordMatcher {
93    /// Weight for name matches (default: 3).
94    pub name_weight: usize,
95    /// Weight for type matches (default: 2).
96    pub type_weight: usize,
97    /// Weight for description matches (default: 1).
98    pub desc_weight: usize,
99    /// 同义词表:查询词(归一化后的小写/半角形式)→ 等价词列表,等价词同样
100    /// 填归一化形式。命中等价词时按等价词再匹配一次,贡献乘 `synonym_weight`。
101    pub synonyms: HashMap<String, Vec<String>>,
102    /// 是否启用 TF-IDF 加权(默认 true)。关闭后回落到固定权重。
103    pub use_tfidf: bool,
104    /// 同义词命中衰减系数(默认 0.7)。
105    pub synonym_weight: f64,
106    /// CJK 二元组命中衰减系数(默认 0.5)。
107    pub cjk_bigram_weight: f64,
108}
109
110impl Default for KeywordMatcher {
111    fn default() -> Self {
112        Self {
113            name_weight: 3,
114            type_weight: 2,
115            desc_weight: 1,
116            synonyms: HashMap::new(),
117            use_tfidf: true,
118            synonym_weight: 0.7,
119            cjk_bigram_weight: 0.5,
120        }
121    }
122}
123
124impl KeywordMatcher {
125    /// Creates a new keyword matcher with default weights.
126    pub fn new() -> Self {
127        Self::default()
128    }
129
130    /// 配置同义词表(查询词 → 等价词列表)。
131    pub fn with_synonyms(mut self, synonyms: HashMap<String, Vec<String>>) -> Self {
132        self.synonyms = synonyms;
133        self
134    }
135
136    /// 开关 TF-IDF 加权(默认开启)。
137    pub fn with_tfidf(mut self, enabled: bool) -> Self {
138        self.use_tfidf = enabled;
139        self
140    }
141
142    /// 把查询拆成待匹配词序列(P2-4):直接词 + 同义词扩展 + CJK 二元组,按文本去重。
143    fn build_terms(&self, query: &str) -> Vec<Term> {
144        let normalized = normalize_text(query).to_lowercase();
145        let tokens: Vec<String> = normalized
146            .split_whitespace()
147            .map(|w| w.trim_matches(|c: char| !c.is_alphanumeric()).to_string())
148            .filter(|w| !w.is_empty())
149            .collect();
150
151        let mut terms: Vec<Term> = Vec::new();
152        let mut seen: HashSet<String> = HashSet::new();
153        for tok in tokens {
154            // 直接词优先,避免同义词与直接词文本相同时被误判成同义词。
155            Self::push_term(&mut terms, &mut seen, tok.clone(), TermKind::Direct);
156
157            // 同义词扩展:键也做归一化,容忍用户键带全角/大小写。
158            let syns = self
159                .synonyms
160                .iter()
161                .find(|(k, _)| normalize_text(k).to_lowercase() == tok)
162                .map(|(_, v)| v);
163            if let Some(syns) = syns {
164                for syn in syns {
165                    Self::push_term(&mut terms, &mut seen, syn.clone(), TermKind::Synonym);
166                }
167            }
168
169            // 中文无空格,长查询拆 CJK 二元组补召回。
170            if tok.chars().any(is_cjk) {
171                for bg in cjk_bigrams(&tok) {
172                    Self::push_term(&mut terms, &mut seen, bg, TermKind::Bigram);
173                }
174            }
175        }
176        terms
177    }
178
179    fn push_term(terms: &mut Vec<Term>, seen: &mut HashSet<String>, text: String, kind: TermKind) {
180        if seen.insert(text.clone()) {
181            terms.push(Term { text, kind });
182        }
183    }
184
185    /// 计算每个查询词在实体语料中的平滑 IDF(P2-4)。
186    ///
187    /// `idf = ln((N+1)/(df+1)) + 1`,df = 命中该词的实体数。常见词 df 大、IDF 小,
188    /// 稀有词 IDF 大。平滑项保证 df == N 时不归零。
189    fn compute_idf(&self, terms: &[Term], store: &GraphStore) -> HashMap<String, f64> {
190        let n = store.all_entities().len() as f64;
191        let mut df: HashMap<String, usize> = HashMap::new();
192        for term in terms {
193            df.entry(term.text.clone()).or_insert(0);
194        }
195        for entity in store.all_entities().values() {
196            let name = normalize_text(&entity.name).to_lowercase();
197            let desc = normalize_text(&entity.description).to_lowercase();
198            let typ = normalize_text(&entity.entity_type).to_lowercase();
199            for term in terms {
200                if name.contains(&term.text)
201                    || desc.contains(&term.text)
202                    || typ.contains(&term.text)
203                {
204                    if let Some(c) = df.get_mut(&term.text) {
205                        *c += 1;
206                    }
207                }
208            }
209        }
210        let mut idf = HashMap::with_capacity(terms.len());
211        for (text, count) in &df {
212            let w = ((n + 1.0) / (*count as f64 + 1.0)).ln() + 1.0;
213            idf.insert(text.clone(), w);
214        }
215        idf
216    }
217
218    /// 单个查询词对单个实体的得分:字段权重 × TF-IDF 权重 × 来源衰减。
219    fn match_score(
220        &self,
221        term: &Term,
222        name: &str,
223        type_name: &str,
224        desc: &str,
225        idf_weight: f64,
226    ) -> f64 {
227        let mut score = 0.0f64;
228        if name.contains(&term.text) {
229            score += self.name_weight as f64 * idf_weight;
230        }
231        if type_name.contains(&term.text) {
232            score += self.type_weight as f64 * idf_weight;
233        }
234        if desc.contains(&term.text) {
235            score += self.desc_weight as f64 * idf_weight;
236        }
237        match term.kind {
238            TermKind::Direct => score,
239            TermKind::Synonym => score * self.synonym_weight,
240            TermKind::Bigram => score * self.cjk_bigram_weight,
241        }
242    }
243}
244
245impl EntityMatcher for KeywordMatcher {
246    fn find_relevant(&self, query: &str, store: &GraphStore, top_k: usize) -> Vec<String> {
247        let terms = self.build_terms(query);
248        if terms.is_empty() {
249            return Vec::new();
250        }
251
252        // P2-4: 预计算 TF-IDF,每个查询词按语料逆文档频率加权。
253        let idf = if self.use_tfidf {
254            Some(self.compute_idf(&terms, store))
255        } else {
256            None
257        };
258
259        let mut scored: Vec<(String, f64)> = Vec::new();
260
261        for (id, entity) in store.all_entities() {
262            let name_lower = normalize_text(&entity.name).to_lowercase();
263            let desc_lower = normalize_text(&entity.description).to_lowercase();
264            let type_lower = normalize_text(&entity.entity_type).to_lowercase();
265
266            let mut score = 0.0f64;
267            for term in &terms {
268                let idf_weight = idf
269                    .as_ref()
270                    .and_then(|m| m.get(&term.text))
271                    .copied()
272                    .unwrap_or(1.0);
273                score += self.match_score(term, &name_lower, &type_lower, &desc_lower, idf_weight);
274            }
275
276            if score > 0.0 {
277                scored.push((id.clone(), score));
278            }
279        }
280
281        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
282        scored.into_iter().take(top_k).map(|(id, _)| id).collect()
283    }
284}
285
286// ---------------------------------------------------------------------------
287// EmbeddingMatcher
288// ---------------------------------------------------------------------------
289
290/// Matches entities by computing embedding similarity between the query and
291/// entity representations (name + type + description).
292///
293/// Requires an [`Embeddings`] implementation to compute vectors. Embeddings
294/// are cached internally to avoid recomputation across calls.
295pub struct EmbeddingMatcher<E: Embeddings> {
296    embeddings: E,
297    /// Cached entity vectors: entity_id → embedding.
298    cache: std::sync::Mutex<HashMap<String, Vec<f32>>>,
299    /// 嵌入相似度最小阈值(P1-2),默认 0.0 保持旧行为。
300    min_score: f64,
301}
302
303impl<E: Embeddings> EmbeddingMatcher<E> {
304    /// Creates a new embedding matcher with the given embeddings backend.
305    pub fn new(embeddings: E) -> Self {
306        Self {
307            embeddings,
308            cache: std::sync::Mutex::new(HashMap::new()),
309            min_score: 0.0,
310        }
311    }
312
313    /// 设置嵌入相似度最小阈值(P1-2),默认 0.0 保持旧行为。
314    pub fn with_min_score(mut self, min_score: f64) -> Self {
315        self.min_score = min_score;
316        self
317    }
318
319    /// Returns the embedding for an entity, computing and caching it if needed.
320    async fn get_entity_embedding(&self, entity_id: &str, entity_text: &str) -> Option<Vec<f32>> {
321        // Check cache first
322        {
323            let cache = self.cache.lock().unwrap_or_else(|e| e.into_inner());
324            if let Some(vec) = cache.get(entity_id) {
325                return Some(vec.clone());
326            }
327        }
328
329        // Compute and cache
330        match self.embeddings.embed_query(entity_text).await {
331            Ok(vec) => {
332                self.cache
333                    .lock()
334                    .unwrap_or_else(|e| e.into_inner())
335                    .insert(entity_id.to_string(), vec.clone());
336                Some(vec)
337            }
338            Err(e) => {
339                // 实体嵌入失败:该实体从图匹配中排除,记日志暴露降级
340                log::warn!(
341                    "entity `{}` embedding failed; excluded from graph matching: {}",
342                    entity_id,
343                    e
344                );
345                None
346            }
347        }
348    }
349}
350
351impl<E: Embeddings + 'static> EntityMatcher for EmbeddingMatcher<E> {
352    fn find_relevant(&self, query: &str, _store: &GraphStore, _top_k: usize) -> Vec<String> {
353        // P1-4: 不再静默降级到 KeywordMatcher。
354        //
355        // 同步 trait 方法调不了 async `embed_query`,旧实现悄悄回落关键词匹配,
356        // 用户以为在用向量匹配、实际是关键词,零提示——比报错更危险。
357        // 这里拒绝静默降级:返回空结果并 `log::warn`,让失败可见。
358        // 需要嵌入匹配请调用 `find_relevant_async`(GraphRAG 的 query 路径),
359        // 或显式配置 `KeywordMatcher`。
360        log::warn!(
361            "EmbeddingMatcher::find_relevant (sync) cannot run embedding matching and no longer \
362             silently falls back to keyword matching; returning empty results for query '{}'. \
363             Use find_relevant_async instead, or configure KeywordMatcher for sync matching.",
364            query
365        );
366        Vec::new()
367    }
368}
369
370impl<E: Embeddings + 'static> EmbeddingMatcher<E> {
371    /// Async version of entity matching using embeddings.
372    ///
373    /// This is the preferred method when using embedding-based matching,
374    /// since embedding computation is inherently async.
375    ///
376    /// P0-2: 不再静默降级/静默 0 分——embedding 失败或向量维度错乱会显式报错,
377    /// 让调用方知道语义匹配不可用或数据有缺陷,而不是悄悄回落 keyword
378    /// 或把"维度错乱"当成"不相似"。
379    pub async fn find_relevant_async(
380        &self,
381        query: &str,
382        store: &GraphStore,
383        top_k: usize,
384    ) -> Result<Vec<String>, GraphRAGError> {
385        let query_vec = self.embeddings.embed_query(query).await.map_err(|e| {
386            GraphRAGError::QueryError(format!("EmbeddingMatcher: query embedding failed: {}", e))
387        })?;
388
389        let mut scored: Vec<(String, f64)> = Vec::new();
390
391        for (id, entity) in store.all_entities() {
392            let entity_text = format!(
393                "{} {} {}",
394                entity.name, entity.entity_type, entity.description
395            );
396
397            if let Some(entity_vec) = self.get_entity_embedding(id, &entity_text).await {
398                match cosine_similarity(&query_vec, &entity_vec) {
399                    Ok(score) => {
400                        scored.push((id.clone(), score as f64));
401                    }
402                    // 向量维度不等是数据缺陷(如中途换 embedding 模型),
403                    // 报错而非当成"不相似"静默放行。
404                    Err(lc_core::math::MathError::LengthMismatch(a, b)) => {
405                        return Err(GraphRAGError::QueryError(format!(
406                            "EmbeddingMatcher: vector dimension mismatch {} vs {} (embedding model changed?)",
407                            a, b
408                        )));
409                    }
410                    // `MathError` is `#[non_exhaustive]`; treat any other
411                    // similarity failure as a query error.
412                    Err(other) => {
413                        return Err(GraphRAGError::QueryError(format!(
414                            "EmbeddingMatcher: similarity computation failed: {}",
415                            other
416                        )));
417                    }
418                }
419            }
420        }
421
422        let mut scored = filter_by_score(scored, self.min_score);
423        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
424        Ok(scored.into_iter().take(top_k).map(|(id, _)| id).collect())
425    }
426}
427
428#[cfg(test)]
429mod tests {
430    use super::*;
431    use crate::graph_rag::graph_store::{Entity, Relation};
432    use lc_embeddings::MockEmbeddings;
433
434    fn make_test_store() -> GraphStore {
435        let mut store = GraphStore::new();
436        store.add_entity(Entity {
437            id: "e1".into(),
438            name: "Rust".into(),
439            entity_type: "Technology".into(),
440            description: "A systems programming language".into(),
441        });
442        store.add_entity(Entity {
443            id: "e2".into(),
444            name: "Python".into(),
445            entity_type: "Technology".into(),
446            description: "A scripting language".into(),
447        });
448        store.add_entity(Entity {
449            id: "e3".into(),
450            name: "Alice".into(),
451            entity_type: "Person".into(),
452            description: "A developer who uses Rust".into(),
453        });
454        store.add_entity(Entity {
455            id: "e4".into(),
456            name: "Tokio".into(),
457            entity_type: "Library".into(),
458            description: "An async runtime for Rust".into(),
459        });
460        store.add_relation(Relation {
461            source: "e3".into(),
462            target: "e1".into(),
463            relation_type: "uses".into(),
464            description: "Alice uses Rust".into(),
465            doc_id: None,
466        });
467        store
468    }
469
470    #[test]
471    fn test_keyword_matcher_basic() {
472        let store = make_test_store();
473        let matcher = KeywordMatcher::new();
474        let results = matcher.find_relevant("Rust programming", &store, 10);
475        assert!(!results.is_empty());
476        // "Rust" entity should rank first (name match + description match)
477        assert_eq!(results[0], "e1");
478    }
479
480    #[test]
481    fn test_keyword_matcher_top_k() {
482        let store = make_test_store();
483        let matcher = KeywordMatcher::new();
484        let results = matcher.find_relevant("Technology", &store, 1);
485        assert_eq!(results.len(), 1);
486    }
487
488    #[test]
489    fn test_keyword_matcher_no_match() {
490        let store = make_test_store();
491        let matcher = KeywordMatcher::new();
492        let results = matcher.find_relevant("cooking recipe", &store, 10);
493        assert!(results.is_empty());
494    }
495
496    /// P1-4: 同步 `find_relevant` 不再静默降级——"Rust" 在 store 中
497    /// 明明命中 KeywordMatcher(e1),但 EmbeddingMatcher 同步路径必须返回空,
498    /// 拒绝悄悄回落关键词匹配,让"嵌入匹配不可用"可见。
499    #[test]
500    fn test_embedding_matcher_sync_returns_empty_not_keyword_fallback() {
501        let store = make_test_store();
502        let matcher = EmbeddingMatcher::new(MockEmbeddings::new(8));
503        let results = matcher.find_relevant("Rust", &store, 10);
504        assert!(
505            results.is_empty(),
506            "sync find_relevant must NOT silently fall back to keyword matching"
507        );
508    }
509
510    /// P1-4: 嵌入匹配请走 async 路径——它仍返回真正的相似实体。
511    ///
512    /// MockEmbeddings 对相同文本产出相同向量,因此 query 与实体文本完全一致时
513    /// 余弦 = 1.0 > min_score(0.0),必被召回,断言确定。
514    #[tokio::test]
515    async fn test_embedding_matcher_async_still_works() {
516        let mut store = GraphStore::new();
517        store.add_entity(Entity {
518            id: "e1".into(),
519            name: "Rust".into(),
520            entity_type: "Technology".into(),
521            description: "A systems programming language".into(),
522        });
523        let matcher = EmbeddingMatcher::new(MockEmbeddings::new(8));
524        let query = "Rust Technology A systems programming language";
525        let results = matcher
526            .find_relevant_async(query, &store, 10)
527            .await
528            .unwrap();
529        assert_eq!(results, vec!["e1".to_string()]);
530    }
531
532    #[test]
533    fn test_keyword_matcher_custom_weights() {
534        let store = make_test_store();
535        let matcher = KeywordMatcher {
536            name_weight: 10,
537            type_weight: 1,
538            desc_weight: 0,
539            ..Default::default()
540        };
541        let results = matcher.find_relevant("Rust", &store, 10);
542        assert!(!results.is_empty());
543        assert_eq!(results[0], "e1");
544    }
545
546    /// P2-4: 同义词表扩展——查询词命中同义词键时按等价词匹配,补漏召回。
547    #[test]
548    fn test_keyword_matcher_synonym_expansion() {
549        let mut store = GraphStore::new();
550        store.add_entity(Entity {
551            id: "e1".into(),
552            name: "PostgreSQL".into(),
553            entity_type: "Database".into(),
554            description: "relational database".into(),
555        });
556        store.add_entity(Entity {
557            id: "e2".into(),
558            name: "机器学习".into(),
559            entity_type: "Technology".into(),
560            description: "AI 领域".into(),
561        });
562
563        let synonyms: HashMap<String, Vec<String>> =
564            HashMap::from([("数据库".to_string(), vec!["database".to_string()])]);
565        let matcher = KeywordMatcher::new().with_synonyms(synonyms);
566
567        // "数据库" 直接子串命中不了 PostgreSQL,靠同义词 "database" 命中 type/desc。
568        let results = matcher.find_relevant("数据库", &store, 10);
569        assert!(
570            results.contains(&"e1".to_string()),
571            "同义词 'database' 应能召回 PostgreSQL(e1)"
572        );
573    }
574
575    /// P2-4: 中英混合归一化——全角字符转半角后能匹配实体名。
576    #[test]
577    fn test_keyword_matcher_fullwidth_normalization() {
578        let mut store = GraphStore::new();
579        store.add_entity(Entity {
580            id: "e1".into(),
581            name: "Rust".into(),
582            entity_type: "Technology".into(),
583            description: "systems language".into(),
584        });
585
586        let matcher = KeywordMatcher::new();
587        // "Rust" 全角 → 归一化 "rust"。
588        let results = matcher.find_relevant("Rust", &store, 10);
589        assert_eq!(results, vec!["e1".to_string()]);
590    }
591
592    /// P2-4: 中文无空格,长查询拆 CJK 二元组,能命中短实体名。
593    #[test]
594    fn test_keyword_matcher_cjk_bigram_recall() {
595        let mut store = GraphStore::new();
596        store.add_entity(Entity {
597            id: "e1".into(),
598            name: "机器学习".into(),
599            entity_type: "Technology".into(),
600            description: "AI 领域".into(),
601        });
602
603        let matcher = KeywordMatcher::new();
604        // 直接子串 "机器学习算法" ⊄ "机器学习",靠二元组 "机器"/"器学"/"学习" 召回。
605        let results = matcher.find_relevant("机器学习算法", &store, 10);
606        assert!(
607            results.contains(&"e1".to_string()),
608            "CJK 二元组应能召回 '机器学习' 实体"
609        );
610    }
611
612    /// P2-4: TF-IDF 属性——常见词的 IDF 低于稀有词。
613    #[test]
614    fn test_keyword_matcher_tfidf_common_lower_than_rare() {
615        let mut store = GraphStore::new();
616        for name in ["Rust", "Python", "Ruby"] {
617            store.add_entity(Entity {
618                id: name.to_lowercase(),
619                name: name.to_string(),
620                entity_type: "Technology".to_string(),
621                description: String::new(),
622            });
623        }
624
625        let matcher = KeywordMatcher::new();
626        let terms = matcher.build_terms("rust technology");
627        let idf = matcher.compute_idf(&terms, &store);
628
629        // "technology" 出现在 3 个实体的 type → IDF 低;"rust" 只在 e1 → IDF 高。
630        let tech_idf = idf["technology"];
631        let rust_idf = idf["rust"];
632        assert!(
633            rust_idf > tech_idf,
634            "稀有词 'rust' 的 IDF ({:.3}) 应高于常见词 'technology' ({:.3})",
635            rust_idf,
636            tech_idf
637        );
638    }
639
640    /// P2-4: TF-IDF 改变排序——固定权重并列时,稀有词命中方优先。
641    #[test]
642    fn test_keyword_matcher_tfidf_breaks_fixed_weight_tie() {
643        let mut store = GraphStore::new();
644        store.add_entity(Entity {
645            id: "e1".into(),
646            name: "data".into(),
647            entity_type: "Technology".into(),
648            description: "machine learning".into(),
649        });
650        store.add_entity(Entity {
651            id: "e2".into(),
652            name: "learning".into(),
653            entity_type: "Technology".into(),
654            description: "data".into(),
655        });
656        store.add_entity(Entity {
657            id: "e3".into(),
658            name: "extra".into(),
659            entity_type: "Technology".into(),
660            description: "data warehouse".into(),
661        });
662
663        let matcher = KeywordMatcher::new();
664        // 固定权重下 e1/e2 并列(4 分);TF-IDF 下 "learning" 更稀有(只 2 实体),
665        // e2 的 name 命中稀有词 → 应优先。
666        let results = matcher.find_relevant("data learning", &store, 10);
667        assert_eq!(results[0], "e2");
668    }
669
670    /// P0-2: 收敛到 lc-core 单一实现(Result<f32, MathError> 契约)。
671    #[test]
672    fn test_cosine_similarity_identical() {
673        let v = vec![1.0, 0.0, 0.0];
674        let sim = cosine_similarity(&v, &v).unwrap();
675        assert!((sim - 1.0).abs() < 0.001);
676    }
677
678    #[test]
679    fn test_cosine_similarity_orthogonal() {
680        let a = vec![1.0, 0.0];
681        let b = vec![0.0, 1.0];
682        let sim = cosine_similarity(&a, &b).unwrap();
683        assert!((sim - 0.0).abs() < 0.001);
684    }
685
686    #[test]
687    fn test_cosine_similarity_opposite() {
688        let a = vec![1.0, 0.0];
689        let b = vec![-1.0, 0.0];
690        let sim = cosine_similarity(&a, &b).unwrap();
691        assert!((sim - (-1.0)).abs() < 0.001);
692    }
693
694    #[test]
695    fn test_cosine_similarity_zero_norm() {
696        // 零向量不是维度错误——lc-core 返回 Ok(0.0)。
697        let sim = cosine_similarity(&[], &[]).unwrap();
698        assert_eq!(sim, 0.0);
699    }
700
701    /// P0-2: 维度不等必须报错,不再是静默 0.0(否则"维度错乱"被当成"不相似")。
702    #[test]
703    fn test_cosine_similarity_different_lengths_errors() {
704        let a = vec![1.0];
705        let b = vec![1.0, 2.0];
706        assert!(cosine_similarity(&a, &b).is_err());
707    }
708}