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/__init__.py +5 -0
- jfox/__main__.py +6 -0
- jfox/bm25_index.py +388 -0
- jfox/cli.py +1895 -0
- jfox/config.py +180 -0
- jfox/embedding_backend.py +65 -0
- jfox/formatters.py +305 -0
- jfox/global_config.py +281 -0
- jfox/graph.py +331 -0
- jfox/indexer.py +366 -0
- jfox/kb_manager.py +316 -0
- jfox/models.py +144 -0
- jfox/note.py +464 -0
- jfox/performance.py +408 -0
- jfox/search_engine.py +237 -0
- jfox/template.py +301 -0
- jfox/template_cli.py +327 -0
- jfox/vector_store.py +200 -0
- jfox_cli-0.1.0.dist-info/METADATA +637 -0
- jfox_cli-0.1.0.dist-info/RECORD +22 -0
- jfox_cli-0.1.0.dist-info/WHEEL +4 -0
- jfox_cli-0.1.0.dist-info/entry_points.txt +2 -0
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
|