quote-finder / core /matcher.py
askljie
Fix synonym score calculation
3f49919
Raw
History Blame Contribute Delete
9.89 kB
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)