Spaces:
Running
Running
| import json | |
| import re | |
| import faiss | |
| import numpy as np | |
| from pathlib import Path | |
| from typing import List, Dict, Set | |
| from .embedding import EmbeddingEngine | |
| class PoetryMatcher: | |
| def __init__(self, | |
| data_path: str = "data/quotes.json", | |
| index_path: str = "data/index.faiss", | |
| synonym_path: str = "data/synonym_dict.json", | |
| phrase_path: str = "data/phrase_dict.json", | |
| model_name: str = "moka-ai/m3e-base", | |
| semantic_weight: float = 0.5, | |
| keyword_weight: float = 0.25, | |
| synonym_weight: float = 0.25): | |
| self.data_path = Path(data_path) | |
| self.index_path = Path(index_path) | |
| self.synonym_path = Path(synonym_path) | |
| self.phrase_path = Path(phrase_path) | |
| self.semantic_weight = semantic_weight | |
| self.keyword_weight = keyword_weight | |
| self.synonym_weight = synonym_weight | |
| self.embedding_engine = EmbeddingEngine(model_name) | |
| self.quotes = self._load_quotes_from_json() | |
| print(f"Loaded {len(self.quotes)} quotes from JSON") | |
| self.synonym_dict = self._load_synonym_dict() | |
| print(f"Loaded synonym dict with {len(self.synonym_dict)} entries") | |
| self.phrase_dict = self._load_phrase_dict() | |
| print(f"Loaded phrase dict with {len(self.phrase_dict)} entries") | |
| self.index = self._load_or_create_index() | |
| def _load_quotes_from_json(self) -> List[Dict]: | |
| if not self.data_path.exists(): | |
| print(f"Data file not found: {self.data_path}") | |
| return [] | |
| with open(self.data_path, 'r', encoding='utf-8') as f: | |
| quotes = json.load(f) | |
| return quotes | |
| def _load_synonym_dict(self) -> Dict[str, List[str]]: | |
| """加载同义词词典""" | |
| if not self.synonym_path.exists(): | |
| print(f"Synonym dict not found: {self.synonym_path}") | |
| return {} | |
| with open(self.synonym_path, 'r', encoding='utf-8') as f: | |
| raw_dict = json.load(f) | |
| # 将分类词典转换为扁平化的映射 | |
| synonym_map = {} | |
| for category, mappings in raw_dict.items(): | |
| for word, synonyms in mappings.items(): | |
| synonym_map[word] = synonyms | |
| return synonym_map | |
| def _load_phrase_dict(self) -> Dict[str, List[str]]: | |
| """加载短语词典""" | |
| if not self.phrase_path.exists(): | |
| print(f"Phrase dict not found: {self.phrase_path}") | |
| return {} | |
| with open(self.phrase_path, 'r', encoding='utf-8') as f: | |
| raw_dict = json.load(f) | |
| # 将分类词典转换为扁平化的映射 | |
| phrase_map = {} | |
| for category, mappings in raw_dict.items(): | |
| for phrase, synonyms in mappings.items(): | |
| phrase_map[phrase] = synonyms | |
| return phrase_map | |
| def _expand_query(self, query: str) -> List[str]: | |
| """扩展查询词""" | |
| expanded = [query] # 原始查询 | |
| # 1. 优先检查短语匹配(从长到短) | |
| sorted_phrases = sorted(self.phrase_dict.keys(), key=len, reverse=True) | |
| for phrase in sorted_phrases: | |
| if phrase in query: | |
| synonyms = self.phrase_dict[phrase] | |
| expanded.extend(synonyms) | |
| # 2. 提取中文词,检查单字同义词 | |
| words = re.findall(r'[\u4e00-\u9fff]+', query) | |
| for word in words: | |
| # 检查是否有同义词映射 | |
| if word in self.synonym_dict: | |
| synonyms = self.synonym_dict[word] | |
| expanded.extend(synonyms) | |
| # 检查2-gram | |
| if len(word) >= 2: | |
| for i in range(len(word) - 1): | |
| bigram = word[i:i+2] | |
| if bigram in self.synonym_dict: | |
| synonyms = self.synonym_dict[bigram] | |
| expanded.extend(synonyms) | |
| # 去重 | |
| return list(set(expanded)) | |
| def _extract_keywords(self, text: str) -> Set[str]: | |
| """从文本中提取关键词(单字+2-gram)""" | |
| chars = re.findall(r'[\u4e00-\u9fff]', text) | |
| keywords = set() | |
| for char in chars: | |
| keywords.add(char) | |
| for i in range(len(chars) - 1): | |
| bigram = chars[i] + chars[i+1] | |
| keywords.add(bigram) | |
| return keywords | |
| def _calculate_keyword_score(self, query: str, quote_text: str) -> float: | |
| """计算关键词匹配分数""" | |
| query_keywords = self._extract_keywords(query) | |
| quote_keywords = self._extract_keywords(quote_text) | |
| if not query_keywords: | |
| return 0.0 | |
| intersection = query_keywords & quote_keywords | |
| score = 0.0 | |
| for kw in intersection: | |
| if len(kw) >= 2: | |
| score += 2.0 | |
| else: | |
| score += 1.0 | |
| max_score = sum(2.0 if len(kw) >= 2 else 1.0 for kw in query_keywords) | |
| normalized_score = score / max_score if max_score > 0 else 0.0 | |
| return min(1.0, normalized_score) | |
| def _calculate_synonym_score(self, expanded_queries: List[str], quote_text: str) -> float: | |
| """计算同义词匹配分数""" | |
| if len(expanded_queries) <= 1: | |
| return 0.0 | |
| # 提取名句中的字符 | |
| quote_chars = set(re.findall(r'[\u4e00-\u9fff]', quote_text)) | |
| # 计算扩展词在名句中的命中率 | |
| hit_count = 0 | |
| for eq in expanded_queries[1:]: # 跳过原始查询 | |
| # 检查扩展词是否在名句中出现 | |
| if eq in quote_text: | |
| hit_count += 1 | |
| # 检查扩展词的每个字符是否在名句中 | |
| elif any(char in quote_chars for char in eq): | |
| hit_count += 0.5 | |
| # 归一化 | |
| max_hits = len(expanded_queries) - 1 | |
| if max_hits == 0: | |
| return 0.0 | |
| return min(1.0, hit_count / max_hits) | |
| def _load_or_create_index(self) -> faiss.Index: | |
| if self.index_path.exists(): | |
| print(f"Loading existing index from {self.index_path}") | |
| return faiss.read_index(str(self.index_path)) | |
| else: | |
| print("No existing index found, building now...") | |
| self.build_index() | |
| return self.index | |
| def build_index(self): | |
| if len(self.quotes) == 0: | |
| print("No quotes to index") | |
| return | |
| print(f"Building index for {len(self.quotes)} quotes...") | |
| batch_size = 32 | |
| all_embeddings = [] | |
| for i in range(0, len(self.quotes), batch_size): | |
| batch = self.quotes[i:i+batch_size] | |
| texts = [q['text'] for q in batch] | |
| embeddings = self.embedding_engine.encode(texts, use_cache=False) | |
| all_embeddings.append(embeddings) | |
| if (i // batch_size) % 10 == 0: | |
| print(f"Processed {min(i+batch_size, len(self.quotes))}/{len(self.quotes)}") | |
| all_embeddings = np.vstack(all_embeddings).astype('float32') | |
| dimension = all_embeddings.shape[1] | |
| self.index = faiss.IndexFlatIP(dimension) | |
| self.index.add(all_embeddings) | |
| self.index_path.parent.mkdir(parents=True, exist_ok=True) | |
| faiss.write_index(self.index, str(self.index_path)) | |
| print(f"Index saved to {self.index_path}") | |
| print(f"Index contains {self.index.ntotal} vectors") | |
| def match(self, query: str, top_k: int = 5) -> List[Dict]: | |
| if self.index is None or self.index.ntotal == 0: | |
| return [] | |
| # 扩展查询 | |
| expanded_queries = self._expand_query(query) | |
| # 语义搜索 | |
| semantic_top_k = min(top_k * 5, 100) | |
| query_embedding = self.embedding_engine.encode_single(query) | |
| query_array = np.array([query_embedding], dtype=np.float32) | |
| semantic_scores, indices = self.index.search(query_array, semantic_top_k) | |
| results = [] | |
| for sem_score, idx in zip(semantic_scores[0], indices[0]): | |
| if idx >= 0 and idx < len(self.quotes): | |
| quote = self.quotes[idx].copy() | |
| semantic_score = float(sem_score) | |
| keyword_score = self._calculate_keyword_score(query, quote['text']) | |
| synonym_score = self._calculate_synonym_score(expanded_queries, quote['text']) | |
| # 综合分数 | |
| final_score = (self.semantic_weight * semantic_score + | |
| self.keyword_weight * keyword_score + | |
| self.synonym_weight * synonym_score) | |
| # 构建返回数据,确保所有字段都存在 | |
| result = { | |
| 'id': quote.get('id', f'generated_{idx}'), | |
| 'text': quote.get('text', ''), | |
| 'author': quote.get('author'), | |
| 'source': quote.get('source'), | |
| 'dynasty': quote.get('dynasty'), | |
| 'type': quote.get('type'), | |
| 'score': final_score, | |
| 'semantic_score': semantic_score, | |
| 'keyword_score': keyword_score, | |
| 'synonym_score': synonym_score | |
| } | |
| results.append(result) | |
| results.sort(key=lambda x: x['score'], reverse=True) | |
| return results[:top_k] | |
| def get_quotes_count(self) -> int: | |
| return len(self.quotes) | |