jfox-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
jfox/performance.py ADDED
@@ -0,0 +1,408 @@
1
+ """
2
+ 性能优化模块
3
+
4
+ 提供模型缓存、批量处理等优化功能
5
+ """
6
+
7
+ import time
8
+ import logging
9
+ from functools import wraps
10
+ from typing import Optional, List, Callable, Any
11
+ from pathlib import Path
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ # =============================================================================
17
+ # 性能计时装饰器
18
+ # =============================================================================
19
+
20
+ def timer(func: Callable) -> Callable:
21
+ """计时装饰器"""
22
+ @wraps(func)
23
+ def wrapper(*args, **kwargs):
24
+ start = time.time()
25
+ result = func(*args, **kwargs)
26
+ elapsed = time.time() - start
27
+ logger.info(f"{func.__name__} took {elapsed:.2f}s")
28
+ return result
29
+ return wrapper
30
+
31
+
32
+ # =============================================================================
33
+ # 批量处理优化
34
+ # =============================================================================
35
+
36
+ class BatchProcessor:
37
+ """
38
+ 批量处理器
39
+
40
+ 优化大批量数据的处理性能
41
+ """
42
+
43
+ def __init__(self, batch_size: int = 32):
44
+ self.batch_size = batch_size
45
+
46
+ def process(
47
+ self,
48
+ items: List[Any],
49
+ processor: Callable[[Any], Any],
50
+ show_progress: bool = True
51
+ ) -> List[Any]:
52
+ """
53
+ 批量处理项目
54
+
55
+ Args:
56
+ items: 待处理项目列表
57
+ processor: 处理函数
58
+ show_progress: 是否显示进度
59
+
60
+ Returns:
61
+ 处理结果列表
62
+ """
63
+ results = []
64
+ total = len(items)
65
+
66
+ if show_progress:
67
+ from rich.progress import Progress, SpinnerColumn, TextColumn
68
+ progress = Progress(
69
+ SpinnerColumn(),
70
+ TextColumn("[progress.description]{task.description}"),
71
+ )
72
+ task = progress.add_task(f"Processing {total} items...", total=total)
73
+ else:
74
+ progress = None
75
+ task = None
76
+
77
+ try:
78
+ if progress:
79
+ progress.start()
80
+
81
+ for i, item in enumerate(items):
82
+ try:
83
+ result = processor(item)
84
+ results.append(result)
85
+ except Exception as e:
86
+ logger.warning(f"Failed to process item {i}: {e}")
87
+
88
+ if progress and task is not None:
89
+ progress.update(task, advance=1)
90
+
91
+ if progress:
92
+ progress.stop()
93
+
94
+ except Exception as e:
95
+ if progress:
96
+ progress.stop()
97
+ raise
98
+
99
+ return results
100
+
101
+ def process_embeddings(
102
+ self,
103
+ texts: List[str],
104
+ backend,
105
+ show_progress: bool = True
106
+ ) -> List[List[float]]:
107
+ """
108
+ 批量处理文本嵌入
109
+
110
+ Args:
111
+ texts: 文本列表
112
+ backend: EmbeddingBackend 实例
113
+ show_progress: 是否显示进度
114
+
115
+ Returns:
116
+ 嵌入向量列表
117
+ """
118
+ results = []
119
+ total = len(texts)
120
+
121
+ if show_progress:
122
+ from rich.progress import Progress, SpinnerColumn, TextColumn
123
+ progress = Progress(
124
+ SpinnerColumn(),
125
+ TextColumn("[progress.description]{task.description}"),
126
+ )
127
+ task = progress.add_task(f"Encoding {total} texts...", total=total)
128
+ progress.start()
129
+ else:
130
+ progress = None
131
+ task = None
132
+
133
+ try:
134
+ # 分批处理
135
+ for i in range(0, len(texts), self.batch_size):
136
+ batch = texts[i:i + self.batch_size]
137
+
138
+ try:
139
+ # 批量编码
140
+ embeddings = backend.encode(batch)
141
+ results.extend(embeddings.tolist())
142
+ except Exception as e:
143
+ logger.warning(f"Failed to encode batch {i}: {e}")
144
+ # 失败时返回零向量
145
+ dim = backend.model.get_sentence_embedding_dimension()
146
+ results.extend([[0.0] * dim] * len(batch))
147
+
148
+ if progress and task is not None:
149
+ progress.update(task, advance=len(batch))
150
+
151
+ if progress:
152
+ progress.stop()
153
+
154
+ except Exception as e:
155
+ if progress:
156
+ progress.stop()
157
+ raise
158
+
159
+ return results
160
+
161
+
162
+ # =============================================================================
163
+ # 快速批量导入
164
+ # =============================================================================
165
+
166
+ @timer
167
+ def bulk_import_notes(
168
+ notes_data: List[dict],
169
+ note_type: str = "permanent",
170
+ batch_size: int = 32,
171
+ show_progress: bool = True
172
+ ) -> dict:
173
+ """
174
+ 快速批量导入笔记
175
+
176
+ 优化后的批量导入,使用批量嵌入和批量数据库操作
177
+
178
+ Args:
179
+ notes_data: 笔记数据列表 [{"title": ..., "content": ...}]
180
+ note_type: 笔记类型
181
+ batch_size: 批大小
182
+ show_progress: 是否显示进度
183
+
184
+ Returns:
185
+ 导入统计
186
+ """
187
+ from .models import NoteType
188
+ from . import note as note_module
189
+ from .embedding_backend import get_backend
190
+ from .vector_store import get_vector_store
191
+
192
+ nt = NoteType(note_type.lower())
193
+ backend = get_backend()
194
+ vector_store = get_vector_store()
195
+
196
+ # 确保模型已加载
197
+ if backend.model is None:
198
+ backend.load()
199
+
200
+ imported = 0
201
+ failed = 0
202
+
203
+ if show_progress:
204
+ from rich.progress import Progress, SpinnerColumn, TextColumn
205
+ progress = Progress(
206
+ SpinnerColumn(),
207
+ TextColumn("[progress.description]{task.description}"),
208
+ )
209
+ task = progress.add_task(f"Importing {len(notes_data)} notes...", total=len(notes_data))
210
+ progress.start()
211
+ else:
212
+ progress = None
213
+ task = None
214
+
215
+ try:
216
+ # 分批处理
217
+ for i in range(0, len(notes_data), batch_size):
218
+ batch = notes_data[i:i + batch_size]
219
+
220
+ # 创建笔记对象
221
+ notes = []
222
+ for data in batch:
223
+ try:
224
+ note = note_module.create_note(
225
+ content=data.get("content", ""),
226
+ title=data.get("title"),
227
+ note_type=nt,
228
+ tags=data.get("tags", [])
229
+ )
230
+ notes.append(note)
231
+ except Exception as e:
232
+ logger.warning(f"Failed to create note: {e}")
233
+ failed += 1
234
+
235
+ # 批量保存(不索引)
236
+ for note in notes:
237
+ try:
238
+ note.filepath.parent.mkdir(parents=True, exist_ok=True)
239
+ with open(note.filepath, 'w', encoding='utf-8') as f:
240
+ f.write(note.to_markdown())
241
+ imported += 1
242
+ except Exception as e:
243
+ logger.warning(f"Failed to save note: {e}")
244
+ failed += 1
245
+
246
+ # 批量索引
247
+ try:
248
+ # 准备批量数据
249
+ documents = [f"{n.title}\n{n.content}" for n in notes]
250
+ embeddings = backend.encode(documents).tolist()
251
+
252
+ # 批量添加到 ChromaDB
253
+ ids = [n.id for n in notes]
254
+ metadatas = [{
255
+ "title": n.title,
256
+ "type": n.type.value,
257
+ "filepath": str(n.filepath),
258
+ "tags": ",".join(n.tags),
259
+ } for n in notes]
260
+
261
+ vector_store.collection.add(
262
+ ids=ids,
263
+ documents=documents,
264
+ embeddings=embeddings,
265
+ metadatas=metadatas
266
+ )
267
+ except Exception as e:
268
+ logger.warning(f"Failed to index batch: {e}")
269
+
270
+ if progress and task is not None:
271
+ progress.update(task, advance=len(batch))
272
+
273
+ if progress:
274
+ progress.stop()
275
+
276
+ except Exception as e:
277
+ if progress:
278
+ progress.stop()
279
+ raise
280
+
281
+ return {
282
+ "imported": imported,
283
+ "failed": failed,
284
+ "total": len(notes_data),
285
+ }
286
+
287
+
288
+ # =============================================================================
289
+ # 缓存管理
290
+ # =============================================================================
291
+
292
+ class ModelCache:
293
+ """
294
+ 模型缓存管理
295
+
296
+ 管理 embedding 模型的缓存,避免重复加载
297
+ """
298
+
299
+ _instance: Optional[Any] = None
300
+ _last_used: Optional[float] = None
301
+ _ttl: float = 300 # 5 分钟 TTL
302
+
303
+ @classmethod
304
+ def get_model(cls, model_name: str = "sentence-transformers/all-MiniLM-L6-v2"):
305
+ """
306
+ 获取缓存的模型
307
+
308
+ Args:
309
+ model_name: 模型名称
310
+
311
+ Returns:
312
+ 模型实例
313
+ """
314
+ from sentence_transformers import SentenceTransformer
315
+
316
+ now = time.time()
317
+
318
+ # 检查缓存是否有效
319
+ if cls._instance is not None:
320
+ if cls._last_used and (now - cls._last_used) < cls._ttl:
321
+ cls._last_used = now
322
+ logger.debug("Using cached model")
323
+ return cls._instance
324
+
325
+ # 加载新模型
326
+ logger.info(f"Loading model: {model_name}")
327
+ cls._instance = SentenceTransformer(model_name)
328
+ cls._last_used = now
329
+
330
+ return cls._instance
331
+
332
+ @classmethod
333
+ def clear(cls):
334
+ """清除缓存"""
335
+ cls._instance = None
336
+ cls._last_used = None
337
+ logger.info("Model cache cleared")
338
+
339
+
340
+ # =============================================================================
341
+ # 性能监控
342
+ # =============================================================================
343
+
344
+ class PerformanceMonitor:
345
+ """
346
+ 性能监控器
347
+
348
+ 监控和报告操作性能
349
+ """
350
+
351
+ def __init__(self):
352
+ self.metrics = {}
353
+
354
+ def record(self, operation: str, duration: float):
355
+ """记录操作耗时"""
356
+ if operation not in self.metrics:
357
+ self.metrics[operation] = []
358
+ self.metrics[operation].append(duration)
359
+
360
+ def report(self) -> dict:
361
+ """生成性能报告"""
362
+ report = {}
363
+ for op, times in self.metrics.items():
364
+ report[op] = {
365
+ "count": len(times),
366
+ "total": sum(times),
367
+ "avg": sum(times) / len(times),
368
+ "min": min(times),
369
+ "max": max(times),
370
+ }
371
+ return report
372
+
373
+ def print_report(self):
374
+ """打印性能报告"""
375
+ from rich.table import Table
376
+ from rich.console import Console
377
+
378
+ report = self.report()
379
+ console = Console()
380
+
381
+ table = Table(title="Performance Report")
382
+ table.add_column("Operation", style="cyan")
383
+ table.add_column("Count", justify="right")
384
+ table.add_column("Avg (s)", justify="right")
385
+ table.add_column("Min (s)", justify="right")
386
+ table.add_column("Max (s)", justify="right")
387
+ table.add_column("Total (s)", justify="right")
388
+
389
+ for op, stats in sorted(report.items(), key=lambda x: x[1]["total"], reverse=True):
390
+ table.add_row(
391
+ op,
392
+ str(stats["count"]),
393
+ f"{stats['avg']:.3f}",
394
+ f"{stats['min']:.3f}",
395
+ f"{stats['max']:.3f}",
396
+ f"{stats['total']:.3f}",
397
+ )
398
+
399
+ console.print(table)
400
+
401
+
402
+ # 全局性能监控器
403
+ _perf_monitor = PerformanceMonitor()
404
+
405
+
406
+ def get_perf_monitor() -> PerformanceMonitor:
407
+ """获取性能监控器"""
408
+ return _perf_monitor
jfox/search_engine.py ADDED
@@ -0,0 +1,237 @@
1
+ """
2
+ 混合搜索引擎
3
+
4
+ 结合 BM25 关键词搜索和语义搜索,使用 RRF (Reciprocal Rank Fusion) 融合结果。
5
+ """
6
+
7
+ import logging
8
+ from enum import Enum
9
+ from typing import Dict, List, Optional, Any
10
+
11
+ from .bm25_index import BM25Index, get_bm25_index
12
+ from .vector_store import VectorStore, get_vector_store
13
+ from .config import config
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ class SearchMode(Enum):
19
+ """搜索模式"""
20
+ HYBRID = "hybrid" # 混合搜索(默认)
21
+ SEMANTIC = "semantic" # 纯语义搜索
22
+ KEYWORD = "keyword" # 纯关键词搜索 (BM25)
23
+
24
+
25
+ class HybridSearchEngine:
26
+ """
27
+ 混合搜索引擎
28
+
29
+ 结合 BM25 和语义搜索,使用 RRF 融合算法。
30
+ 支持错误回退机制。
31
+ """
32
+
33
+ def __init__(
34
+ self,
35
+ vector_store: Optional[VectorStore] = None,
36
+ bm25_index: Optional[BM25Index] = None,
37
+ rrf_k: int = 60,
38
+ ):
39
+ """
40
+ 初始化混合搜索引擎
41
+
42
+ Args:
43
+ vector_store: 向量存储实例
44
+ bm25_index: BM25 索引实例
45
+ rrf_k: RRF 融合常数
46
+ """
47
+ self.vector_store = vector_store or get_vector_store()
48
+ self.bm25_index = bm25_index or get_bm25_index()
49
+ self.rrf_k = rrf_k
50
+
51
+ def search(
52
+ self,
53
+ query: str,
54
+ top_k: int = 5,
55
+ mode: SearchMode = SearchMode.HYBRID,
56
+ note_type: Optional[str] = None,
57
+ ) -> List[Dict[str, Any]]:
58
+ """
59
+ 执行搜索
60
+
61
+ Args:
62
+ query: 搜索查询
63
+ top_k: 返回结果数量
64
+ mode: 搜索模式
65
+ note_type: 笔记类型筛选
66
+
67
+ Returns:
68
+ 搜索结果列表
69
+ """
70
+ if mode == SearchMode.SEMANTIC:
71
+ return self._semantic_search(query, top_k, note_type)
72
+ elif mode == SearchMode.KEYWORD:
73
+ return self._keyword_search(query, top_k)
74
+ else: # HYBRID
75
+ return self._hybrid_search(query, top_k, note_type)
76
+
77
+ def _semantic_search(
78
+ self,
79
+ query: str,
80
+ top_k: int,
81
+ note_type: Optional[str] = None,
82
+ ) -> List[Dict[str, Any]]:
83
+ """纯语义搜索"""
84
+ try:
85
+ results = self.vector_store.search(query, top_k=top_k, note_type=note_type)
86
+ # 添加搜索模式标记
87
+ for r in results:
88
+ r['search_mode'] = 'semantic'
89
+ return results
90
+ except Exception as e:
91
+ logger.error(f"Semantic search failed: {e}")
92
+ return []
93
+
94
+ def _keyword_search(self, query: str, top_k: int) -> List[Dict[str, Any]]:
95
+ """纯关键词搜索 (BM25)"""
96
+ try:
97
+ bm25_results = self.bm25_index.search(query, top_k=top_k)
98
+
99
+ # 转换为与语义搜索一致的格式
100
+ results = []
101
+ for r in bm25_results:
102
+ # 获取笔记详情
103
+ from . import note as note_module
104
+ note = note_module.load_note_by_id(r['note_id'])
105
+ if note:
106
+ results.append({
107
+ 'id': r['note_id'],
108
+ 'document': note.content[:300] + "..." if len(note.content) > 300 else note.content,
109
+ 'metadata': {
110
+ 'title': note.title,
111
+ 'type': note.type.value,
112
+ },
113
+ 'score': r['score'],
114
+ 'search_mode': 'keyword',
115
+ })
116
+
117
+ return results
118
+ except Exception as e:
119
+ logger.error(f"Keyword search failed: {e}")
120
+ return []
121
+
122
+ def _hybrid_search(
123
+ self,
124
+ query: str,
125
+ top_k: int,
126
+ note_type: Optional[str] = None,
127
+ ) -> List[Dict[str, Any]]:
128
+ """
129
+ 混合搜索:RRF 融合
130
+
131
+ 公式: score = Σ 1 / (k + rank)
132
+ """
133
+ # 1. 执行两种搜索(获取更多结果用于融合)
134
+ search_k = max(top_k * 2, 10) # 获取足够多的结果
135
+
136
+ semantic_results = []
137
+ bm25_results = []
138
+
139
+ try:
140
+ semantic_results = self.vector_store.search(query, top_k=search_k, note_type=note_type)
141
+ except Exception as e:
142
+ logger.warning(f"Semantic search failed in hybrid mode: {e}")
143
+
144
+ try:
145
+ bm25_results = self.bm25_index.search(query, top_k=search_k)
146
+ except Exception as e:
147
+ logger.warning(f"BM25 search failed in hybrid mode: {e}")
148
+
149
+ # 如果一种搜索失败,回退到另一种
150
+ if not semantic_results and not bm25_results:
151
+ return []
152
+ elif not semantic_results:
153
+ return self._keyword_search(query, top_k)
154
+ elif not bm25_results:
155
+ for r in semantic_results[:top_k]:
156
+ r['search_mode'] = 'semantic'
157
+ return semantic_results[:top_k]
158
+
159
+ # 2. RRF 融合
160
+ fused_scores: Dict[str, float] = {}
161
+ result_data: Dict[str, Dict] = {}
162
+
163
+ # 处理语义搜索结果
164
+ for rank, result in enumerate(semantic_results, start=1):
165
+ note_id = result.get('id')
166
+ if note_id:
167
+ fused_scores[note_id] = fused_scores.get(note_id, 0) + 1 / (self.rrf_k + rank)
168
+ result_data[note_id] = result
169
+
170
+ # 处理 BM25 搜索结果
171
+ for rank, result in enumerate(bm25_results, start=1):
172
+ note_id = result.get('note_id')
173
+ if note_id:
174
+ fused_scores[note_id] = fused_scores.get(note_id, 0) + 1 / (self.rrf_k + rank)
175
+ # 如果没有语义搜索结果,使用 BM25 的数据
176
+ if note_id not in result_data:
177
+ from . import note as note_module
178
+ note = note_module.load_note_by_id(note_id)
179
+ if note:
180
+ result_data[note_id] = {
181
+ 'id': note_id,
182
+ 'document': note.content[:300] + "..." if len(note.content) > 300 else note.content,
183
+ 'metadata': {
184
+ 'title': note.title,
185
+ 'type': note.type.value,
186
+ },
187
+ }
188
+
189
+ # 3. 排序并返回 top_k
190
+ sorted_ids = sorted(fused_scores.keys(), key=lambda x: fused_scores[x], reverse=True)
191
+
192
+ results = []
193
+ for note_id in sorted_ids[:top_k]:
194
+ data = result_data.get(note_id, {})
195
+ data['score'] = fused_scores[note_id]
196
+ data['search_mode'] = 'hybrid'
197
+ results.append(data)
198
+
199
+ return results
200
+
201
+ def rebuild_bm25_index(self) -> bool:
202
+ """
203
+ 重建 BM25 索引
204
+
205
+ Returns:
206
+ 是否成功重建
207
+ """
208
+ try:
209
+ from . import note as note_module
210
+ notes = note_module.list_notes(limit=10000) # 获取所有笔记
211
+ return self.bm25_index.rebuild_from_notes(notes)
212
+ except Exception as e:
213
+ logger.error(f"Failed to rebuild BM25 index: {e}")
214
+ return False
215
+
216
+
217
+ # 全局搜索引擎实例
218
+ _search_engine: Optional[HybridSearchEngine] = None
219
+
220
+
221
+ def get_search_engine() -> HybridSearchEngine:
222
+ """
223
+ 获取搜索引擎实例(单例模式)
224
+
225
+ Returns:
226
+ HybridSearchEngine 实例
227
+ """
228
+ global _search_engine
229
+ if _search_engine is None:
230
+ _search_engine = HybridSearchEngine()
231
+ return _search_engine
232
+
233
+
234
+ def reset_search_engine():
235
+ """重置全局搜索引擎实例(用于切换知识库时)"""
236
+ global _search_engine
237
+ _search_engine = None