mindforge-memory 6.4.1__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.
Files changed (64) hide show
  1. MindForge.py +20 -0
  2. mindforge/__init__.py +111 -0
  3. mindforge/adapters/__init__.py +18 -0
  4. mindforge/adapters/claude_adapter.py +84 -0
  5. mindforge/adapters/crewai_memory.py +76 -0
  6. mindforge/adapters/generic_api.py +183 -0
  7. mindforge/adapters/langgraph_store.py +463 -0
  8. mindforge/adapters/openclaw_adapter.py +91 -0
  9. mindforge/api/__init__.py +1 -0
  10. mindforge/api/server.py +1567 -0
  11. mindforge/cli/__init__.py +1 -0
  12. mindforge/cli/main.py +13607 -0
  13. mindforge/core/__init__.py +37 -0
  14. mindforge/core/embedding.py +847 -0
  15. mindforge/core/encryption.py +597 -0
  16. mindforge/core/indexer.py +363 -0
  17. mindforge/core/mindforge.py +6298 -0
  18. mindforge/core/paths.py +43 -0
  19. mindforge/core/query.py +501 -0
  20. mindforge/core/storage.py +18656 -0
  21. mindforge/core/types.py +314 -0
  22. mindforge/core/version.py +56 -0
  23. mindforge/daemon/__init__.py +0 -0
  24. mindforge/daemon/server.py +277 -0
  25. mindforge/mcp/__init__.py +4 -0
  26. mindforge/mcp/server.py +2559 -0
  27. mindforge/modules/__init__.py +138 -0
  28. mindforge/modules/categorizer.py +151 -0
  29. mindforge/modules/conflict_detector.py +356 -0
  30. mindforge/modules/connectors.py +331 -0
  31. mindforge/modules/context_window.py +337 -0
  32. mindforge/modules/event_bus.py +572 -0
  33. mindforge/modules/evolution.py +210 -0
  34. mindforge/modules/experience.py +402 -0
  35. mindforge/modules/federated.py +722 -0
  36. mindforge/modules/federated_acl.py +393 -0
  37. mindforge/modules/follow_up.py +315 -0
  38. mindforge/modules/hybrid_search.py +565 -0
  39. mindforge/modules/importer.py +374 -0
  40. mindforge/modules/integrator.py +196 -0
  41. mindforge/modules/intent_router.py +359 -0
  42. mindforge/modules/knowledge_graph.py +395 -0
  43. mindforge/modules/memory_compressor.py +592 -0
  44. mindforge/modules/memory_decay.py +474 -0
  45. mindforge/modules/memory_passport.py +250 -0
  46. mindforge/modules/multi_agent.py +534 -0
  47. mindforge/modules/multimodal.py +304 -0
  48. mindforge/modules/outcome_learning.py +186 -0
  49. mindforge/modules/personality.py +275 -0
  50. mindforge/modules/principled_forgetting.py +258 -0
  51. mindforge/modules/privacy.py +736 -0
  52. mindforge/modules/recall.py +263 -0
  53. mindforge/modules/session_focus.py +305 -0
  54. mindforge/modules/share_conflict.py +521 -0
  55. mindforge/modules/skill_extractor.py +340 -0
  56. mindforge/modules/skill_pack.py +205 -0
  57. mindforge/modules/timeline_viz.py +206 -0
  58. mindforge/modules/v6.py +931 -0
  59. mindforge_memory-6.4.1.dist-info/METADATA +281 -0
  60. mindforge_memory-6.4.1.dist-info/RECORD +64 -0
  61. mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
  62. mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
  63. mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
  64. mindforge_memory-6.4.1.dist-info/top_level.txt +2 -0
@@ -0,0 +1,847 @@
1
+ """
2
+ MindForge 嵌入引擎
3
+
4
+ 多后端适配器架构(v5.4.6 新增):
5
+ - sentence-transformers(本地 CPU 推理,默认)
6
+ - OpenAI Embedding API(需 API key,无 GPU 友好)
7
+ - Ollama(本地推理服务)
8
+ - 自定义 HTTP 端点
9
+
10
+ 通过环境变量或构造参数配置后端:
11
+ MINDFORGE_EMBEDDING_BACKEND=sentence_transformers|openai|ollama|http
12
+ OPENAI_API_KEY=sk-...
13
+ MINDFORGE_EMBEDDING_MODEL=text-embedding-3-small
14
+ MINDFORGE_EMBEDDING_API_URL=https://custom.endpoint/embed
15
+ OLLAMA_HOST=http://localhost:11434
16
+ MINDFORGE_EMBEDDING_BACKEND=fake # v5.7.8:确定性伪向量(测试/评测基线,无需模型)
17
+
18
+
19
+ 默认模型: paraphrase-multilingual-MiniLM-L12-v2 (384 维, ~120MB, 支持50+语言)
20
+ """
21
+
22
+ import struct
23
+ import logging
24
+ import os
25
+ import threading
26
+ import json
27
+ from typing import List, Optional, Tuple
28
+ from abc import ABC, abstractmethod
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # 默认模型名
33
+ DEFAULT_MODEL = "paraphrase-multilingual-MiniLM-L12-v2"
34
+
35
+ # 默认维度(all-MiniLM-L6-v2 输出 384 维)
36
+ DEFAULT_DIMENSION = 384
37
+
38
+
39
+ # ===== 后端适配器抽象基类 =====
40
+
41
+ class EmbeddingBackend(ABC):
42
+ """嵌入后端抽象基类
43
+
44
+ 所有后端适配器需实现 encode / encode_batch / is_available / dimension / model_name。
45
+ """
46
+
47
+ @property
48
+ @abstractmethod
49
+ def is_available(self) -> bool:
50
+ """后端是否可用"""
51
+ ...
52
+
53
+ @property
54
+ @abstractmethod
55
+ def dimension(self) -> int:
56
+ """嵌入向量维度"""
57
+ ...
58
+
59
+ @property
60
+ @abstractmethod
61
+ def model_name(self) -> str:
62
+ """模型名称"""
63
+ ...
64
+
65
+ @abstractmethod
66
+ def encode(self, text: str) -> Optional[List[float]]:
67
+ """编码单条文本"""
68
+ ...
69
+
70
+ @abstractmethod
71
+ def encode_batch(self, texts: List[str]) -> Optional[List[List[float]]]:
72
+ """批量编码文本"""
73
+ ...
74
+
75
+
76
+ # ===== sentence-transformers 后端(默认) =====
77
+
78
+ class SentenceTransformerBackend(EmbeddingBackend):
79
+ """sentence-transformers 本地推理后端
80
+
81
+ 可选依赖,未安装时 is_available 返回 False。
82
+ """
83
+
84
+ def __init__(self, model_name: str = DEFAULT_MODEL):
85
+ self._model_name = model_name
86
+ self._model = None
87
+ self._dimension = DEFAULT_DIMENSION
88
+ self._load_attempted = False
89
+ self._available = False
90
+
91
+ @property
92
+ def is_available(self) -> bool:
93
+ if not self._load_attempted:
94
+ self._try_load()
95
+ return self._available
96
+
97
+ @property
98
+ def dimension(self) -> int:
99
+ return self._dimension
100
+
101
+ @property
102
+ def model_name(self) -> str:
103
+ return self._model_name
104
+
105
+ def _try_load(self):
106
+ self._load_attempted = True
107
+ try:
108
+ from sentence_transformers import SentenceTransformer
109
+ self._model = SentenceTransformer(self._model_name)
110
+ test_vec = self._model.encode("test", show_progress_bar=False)
111
+ self._dimension = len(test_vec)
112
+ self._available = True
113
+ logger.info("SentenceTransformerBackend 加载成功: %s (dim=%d)",
114
+ self._model_name, self._dimension)
115
+ except ImportError:
116
+ logger.warning(
117
+ "sentence-transformers 未安装,向量检索不可用。"
118
+ "安装: pip install sentence-transformers")
119
+ self._available = False
120
+ except Exception as e:
121
+ logger.warning("SentenceTransformerBackend 加载失败: %s", e)
122
+ self._available = False
123
+
124
+ def encode(self, text: str) -> Optional[List[float]]:
125
+ if not self.is_available or not text:
126
+ return None
127
+ try:
128
+ vec = self._model.encode(text, show_progress_bar=False,
129
+ normalize_embeddings=True)
130
+ return vec.tolist()
131
+ except Exception as e:
132
+ logger.warning("encode 失败: %s", e)
133
+ return None
134
+
135
+ def encode_batch(self, texts: List[str]) -> Optional[List[List[float]]]:
136
+ if not self.is_available or not texts:
137
+ return None
138
+ try:
139
+ vecs = self._model.encode(
140
+ texts, show_progress_bar=False,
141
+ normalize_embeddings=True, batch_size=32)
142
+ return [v.tolist() for v in vecs]
143
+ except Exception as e:
144
+ logger.warning("encode_batch 失败: %s", e)
145
+ return None
146
+
147
+
148
+ # ===== OpenAI Embedding 后端 =====
149
+
150
+ class OpenAIBackend(EmbeddingBackend):
151
+ """OpenAI Embedding API 后端
152
+
153
+ 需要设置 OPENAI_API_KEY 环境变量。
154
+ 可通过 MINDFORGE_EMBEDDING_MODEL 指定模型(默认 text-embedding-3-small)。
155
+ """
156
+
157
+ def __init__(self, model_name: str = "text-embedding-3-small",
158
+ api_key: str = "", api_base: str = ""):
159
+ self._model_name = model_name
160
+ self._api_key = api_key or os.environ.get("OPENAI_API_KEY", "")
161
+ self._api_base = api_base or os.environ.get("OPENAI_API_BASE",
162
+ "https://api.openai.com/v1")
163
+ self._dimension = 1536 # text-embedding-3-small 默认 1536 维
164
+ self._available = False
165
+ self._load_attempted = False
166
+
167
+ @property
168
+ def is_available(self) -> bool:
169
+ if not self._load_attempted:
170
+ self._try_load()
171
+ return self._available
172
+
173
+ @property
174
+ def dimension(self) -> int:
175
+ return self._dimension
176
+
177
+ @property
178
+ def model_name(self) -> str:
179
+ return self._model_name
180
+
181
+ def _try_load(self):
182
+ self._load_attempted = True
183
+ if not self._api_key:
184
+ logger.warning("OpenAIBackend: OPENAI_API_KEY 未设置")
185
+ self._available = False
186
+ return
187
+ try:
188
+ pass
189
+ # 探测维度:发送一条测试请求
190
+ result = self._call_api(["test"])
191
+ if result and len(result) > 0:
192
+ self._dimension = len(result[0])
193
+ self._available = True
194
+ logger.info("OpenAIBackend 加载成功: %s (dim=%d)",
195
+ self._model_name, self._dimension)
196
+ else:
197
+ self._available = False
198
+ except Exception as e:
199
+ logger.warning("OpenAIBackend 加载失败: %s", e)
200
+ self._available = False
201
+
202
+ def _call_api(self, texts: List[str]) -> Optional[List[List[float]]]:
203
+ """调用 OpenAI Embeddings API"""
204
+ if not self._api_key:
205
+ return None
206
+ try:
207
+ import urllib.request
208
+ import urllib.error
209
+
210
+ url = f"{self._api_base}/embeddings"
211
+ payload = json.dumps({
212
+ "model": self._model_name,
213
+ "input": texts,
214
+ }).encode("utf-8")
215
+
216
+ req = urllib.request.Request(
217
+ url,
218
+ data=payload,
219
+ headers={
220
+ "Content-Type": "application/json",
221
+ "Authorization": f"Bearer {self._api_key}",
222
+ },
223
+ method="POST",
224
+ )
225
+ with urllib.request.urlopen(req, timeout=30) as resp:
226
+ data = json.loads(resp.read().decode("utf-8"))
227
+ embeddings = [item["embedding"] for item in data["data"]]
228
+ return embeddings
229
+ except urllib.error.HTTPError as e:
230
+ logger.warning("OpenAI API HTTP 错误: %d %s", e.code, e.reason)
231
+ return None
232
+ except Exception as e:
233
+ logger.warning("OpenAI API 调用失败: %s", e)
234
+ return None
235
+
236
+ def encode(self, text: str) -> Optional[List[float]]:
237
+ if not self.is_available or not text:
238
+ return None
239
+ result = self._call_api([text])
240
+ if result and len(result) > 0:
241
+ return result[0]
242
+ return None
243
+
244
+ def encode_batch(self, texts: List[str]) -> Optional[List[List[float]]]:
245
+ if not self.is_available or not texts:
246
+ return None
247
+ # OpenAI API 单次最多 2048 条
248
+ all_results = []
249
+ batch_size = 100
250
+ for i in range(0, len(texts), batch_size):
251
+ batch = texts[i:i + batch_size]
252
+ result = self._call_api(batch)
253
+ if result:
254
+ all_results.extend(result)
255
+ else:
256
+ return None
257
+ return all_results
258
+
259
+
260
+ # ===== Ollama 后端 =====
261
+
262
+ class OllamaBackend(EmbeddingBackend):
263
+ """Ollama 本地嵌入后端
264
+
265
+ 需要本地运行 Ollama 服务。通过 OLLAMA_HOST 配置地址(默认 http://localhost:11434)。
266
+ 通过 MINDFORGE_EMBEDDING_MODEL 指定模型(默认 nomic-embed-text)。
267
+ """
268
+
269
+ def __init__(self, model_name: str = "nomic-embed-text",
270
+ host: str = ""):
271
+ self._model_name = model_name
272
+ self._host = host or os.environ.get("OLLAMA_HOST",
273
+ "http://localhost:11434")
274
+ self._dimension = 768 # nomic-embed-text 默认 768 维
275
+ self._available = False
276
+ self._load_attempted = False
277
+
278
+ @property
279
+ def is_available(self) -> bool:
280
+ if not self._load_attempted:
281
+ self._try_load()
282
+ return self._available
283
+
284
+ @property
285
+ def dimension(self) -> int:
286
+ return self._dimension
287
+
288
+ @property
289
+ def model_name(self) -> str:
290
+ return self._model_name
291
+
292
+ def _try_load(self):
293
+ self._load_attempted = True
294
+ try:
295
+ result = self._call_api("test")
296
+ if result:
297
+ self._dimension = len(result)
298
+ self._available = True
299
+ logger.info("OllamaBackend 加载成功: %s (dim=%d)",
300
+ self._model_name, self._dimension)
301
+ else:
302
+ self._available = False
303
+ except Exception as e:
304
+ logger.warning("OllamaBackend 加载失败: %s", e)
305
+ self._available = False
306
+
307
+ def _call_api(self, text: str) -> Optional[List[float]]:
308
+ """调用 Ollama Embeddings API"""
309
+ try:
310
+ import urllib.request
311
+ import urllib.error
312
+
313
+ url = f"{self._host}/api/embeddings"
314
+ payload = json.dumps({
315
+ "model": self._model_name,
316
+ "prompt": text,
317
+ }).encode("utf-8")
318
+
319
+ req = urllib.request.Request(
320
+ url,
321
+ data=payload,
322
+ headers={"Content-Type": "application/json"},
323
+ method="POST",
324
+ )
325
+ with urllib.request.urlopen(req, timeout=30) as resp:
326
+ data = json.loads(resp.read().decode("utf-8"))
327
+ return data.get("embedding")
328
+ except urllib.error.URLError as e:
329
+ logger.warning("Ollama 连接失败(服务未启动?): %s", e)
330
+ return None
331
+ except Exception as e:
332
+ logger.warning("Ollama API 调用失败: %s", e)
333
+ return None
334
+
335
+ def encode(self, text: str) -> Optional[List[float]]:
336
+ if not self.is_available or not text:
337
+ return None
338
+ return self._call_api(text)
339
+
340
+ def encode_batch(self, texts: List[str]) -> Optional[List[List[float]]]:
341
+ """批量编码文本为向量。
342
+
343
+ v5.4.8 P2-001 修复:
344
+ - 小批量(<4)使用串行,避免线程池开销
345
+ - 部分失败时返回部分结果而不是整体失败
346
+ """
347
+ if not self.is_available or not texts:
348
+ return None
349
+
350
+ n = len(texts)
351
+
352
+ # 小批量:串行处理,避免线程池开销
353
+ if n < 4:
354
+ results = []
355
+ for text in texts:
356
+ vec = self._call_api(text)
357
+ if vec is not None:
358
+ results.append(vec)
359
+ return results if results else None
360
+
361
+ # 大批量:并发处理
362
+ from concurrent.futures import ThreadPoolExecutor, as_completed
363
+ results = [None] * n
364
+ failed_count = 0
365
+
366
+ with ThreadPoolExecutor(max_workers=min(8, n)) as executor:
367
+ future_to_idx = {
368
+ executor.submit(self._call_api, text): i
369
+ for i, text in enumerate(texts)
370
+ }
371
+ for future in as_completed(future_to_idx):
372
+ idx = future_to_idx[future]
373
+ try:
374
+ vec = future.result()
375
+ if vec is not None:
376
+ results[idx] = vec
377
+ else:
378
+ failed_count += 1
379
+ except Exception:
380
+ failed_count += 1
381
+
382
+ # 全部失败
383
+ if failed_count == n:
384
+ return None
385
+
386
+ # 部分失败:记录警告但返回成功部分
387
+ if failed_count > 0:
388
+ logger.warning(
389
+ "Ollama batch: %d/%d succeeded, %d failed",
390
+ n - failed_count, n, failed_count
391
+ )
392
+
393
+ # 过滤掉 None
394
+ return [r for r in results if r is not None]
395
+
396
+
397
+ # ===== 自定义 HTTP 后端 =====
398
+
399
+ class HTTPBackend(EmbeddingBackend):
400
+ """自定义 HTTP 嵌入后端
401
+
402
+ 通过 MINDFORGE_EMBEDDING_API_URL 指定端点 URL。
403
+ 端点需接受 POST {"texts": ["..."]} 并返回 {"embeddings": [[...]]}。
404
+ 可选设置 MINDFORGE_EMBEDDING_API_KEY 用于 Bearer 认证。
405
+ """
406
+
407
+ def __init__(self, api_url: str = "", api_key: str = "",
408
+ model_name: str = "custom", dimension: int = 0):
409
+ self._model_name = model_name
410
+ self._api_url = api_url or os.environ.get("MINDFORGE_EMBEDDING_API_URL", "")
411
+ self._api_key = api_key or os.environ.get("MINDFORGE_EMBEDDING_API_KEY", "")
412
+ self._dimension = dimension or int(os.environ.get("MINDFORGE_EMBEDDING_DIM", "0"))
413
+ self._available = False
414
+ self._load_attempted = False
415
+
416
+ @property
417
+ def is_available(self) -> bool:
418
+ if not self._load_attempted:
419
+ self._try_load()
420
+ return self._available
421
+
422
+ @property
423
+ def dimension(self) -> int:
424
+ return self._dimension
425
+
426
+ @property
427
+ def model_name(self) -> str:
428
+ return self._model_name
429
+
430
+ def _try_load(self):
431
+ self._load_attempted = True
432
+ if not self._api_url:
433
+ logger.warning("HTTPBackend: MINDFORGE_EMBEDDING_API_URL 未设置")
434
+ self._available = False
435
+ return
436
+ try:
437
+ result = self._call_api(["test"])
438
+ if result and len(result) > 0:
439
+ if self._dimension == 0:
440
+ self._dimension = len(result[0])
441
+ self._available = True
442
+ logger.info("HTTPBackend 加载成功: %s (dim=%d)",
443
+ self._api_url, self._dimension)
444
+ else:
445
+ self._available = False
446
+ except Exception as e:
447
+ logger.warning("HTTPBackend 加载失败: %s", e)
448
+ self._available = False
449
+
450
+ def _call_api(self, texts: List[str]) -> Optional[List[List[float]]]:
451
+ try:
452
+ import urllib.request
453
+ import urllib.error
454
+
455
+ payload = json.dumps({"texts": texts}).encode("utf-8")
456
+ headers = {"Content-Type": "application/json"}
457
+ if self._api_key:
458
+ headers["Authorization"] = f"Bearer {self._api_key}"
459
+
460
+ req = urllib.request.Request(
461
+ self._api_url,
462
+ data=payload,
463
+ headers=headers,
464
+ method="POST",
465
+ )
466
+ with urllib.request.urlopen(req, timeout=30) as resp:
467
+ data = json.loads(resp.read().decode("utf-8"))
468
+ if isinstance(data, list):
469
+ return data
470
+ return data.get("embeddings") or data.get("data")
471
+ except Exception as e:
472
+ logger.warning("HTTP Backend 调用失败: %s", e)
473
+ return None
474
+
475
+ def encode(self, text: str) -> Optional[List[float]]:
476
+ if not self.is_available or not text:
477
+ return None
478
+ result = self._call_api([text])
479
+ if result and len(result) > 0:
480
+ return result[0]
481
+ return None
482
+
483
+ def encode_batch(self, texts: List[str], batch_size: int = 100) -> Optional[List[List[float]]]:
484
+ """批量编码文本为向量。
485
+
486
+ v5.4.8 P1-002 修复:添加 batch_size 限制,分批发送请求,
487
+ 防止大量文本导致 413 错误或超时。
488
+ """
489
+ if not self.is_available or not texts:
490
+ return None
491
+
492
+ # 分批处理
493
+ all_results = []
494
+ for i in range(0, len(texts), batch_size):
495
+ chunk = texts[i:i + batch_size]
496
+ result = self._call_api(chunk)
497
+ if result is None:
498
+ logger.warning("HTTPBackend batch %d-%d failed", i, i + len(chunk))
499
+ return None
500
+ all_results.extend(result)
501
+ return all_results
502
+
503
+
504
+ # ===== 确定性伪向量后端(v5.7.8) =====
505
+
506
+ class FakeBackend(EmbeddingBackend):
507
+ """确定性伪向量后端(v5.7.8 新增)
508
+
509
+ 不依赖任何模型 / 网络,为测试与评测基线提供确定性向量:
510
+ - 相同文本 → 相同向量(可复现)
511
+ - 共享字符 bigram 的文本 → 余弦相似度更高(近似词面重合)
512
+ - 输出 L2 归一化
513
+
514
+ 用途:
515
+ - CI / 无模型环境跑通向量检索完整链路
516
+ - benchmarks/embedding_eval.py 提供可复现基线
517
+ (MINDFORGE_EMBEDDING_BACKEND=fake 或 create_backend("fake"))
518
+ - 开发者本地快速冒烟
519
+
520
+ 注意:伪向量不代表真实语义,仅在无真实模型时作链路与基线用途。
521
+ """
522
+
523
+ def __init__(self, model_name: str = "fake-bigram", seed: int = 42,
524
+ dimension: int = 384):
525
+ self._model_name = model_name
526
+ self._seed = int(seed)
527
+ self._dimension = int(dimension)
528
+ self._available = True
529
+
530
+ @property
531
+ def is_available(self) -> bool:
532
+ return True
533
+
534
+ @property
535
+ def dimension(self) -> int:
536
+ return self._dimension
537
+
538
+ @property
539
+ def model_name(self) -> str:
540
+ return self._model_name
541
+
542
+ def _vec(self, text: str) -> List[float]:
543
+ import hashlib
544
+ n = self._dimension
545
+ vec = [0.0] * n
546
+ t = text.lower()
547
+ seed_b = self._seed.to_bytes(4, "big")
548
+ # 字符 bigram 哈希桶(位置无关 → 词面重合度高者相似)
549
+ for i in range(len(t) - 1):
550
+ g = t[i:i + 2]
551
+ if g == " " or g[0] == " " or g[1] == " ":
552
+ continue
553
+ h = hashlib.blake2b(
554
+ g.encode("utf-8", "ignore") + seed_b, digest_size=8).digest()
555
+ v = int.from_bytes(h, "big") / 2 ** 64
556
+ idx = int.from_bytes(
557
+ hashlib.blake2b(g.encode("utf-8", "ignore") + b"\x01" + seed_b,
558
+ digest_size=4).digest(), "big") % n
559
+ vec[idx] += v
560
+ # 单字符(权重减半)
561
+ for ch in set(t):
562
+ if ch.isspace():
563
+ continue
564
+ h = hashlib.blake2b(
565
+ ch.encode("utf-8", "ignore") + seed_b, digest_size=8).digest()
566
+ v = int.from_bytes(h, "big") / 2 ** 64
567
+ idx = int.from_bytes(
568
+ hashlib.blake2b(ch.encode("utf-8", "ignore") + b"\x02" + seed_b,
569
+ digest_size=4).digest(), "big") % n
570
+ vec[idx] += v * 0.5
571
+ norm = sum(x * x for x in vec) ** 0.5
572
+ if norm > 0:
573
+ vec = [x / norm for x in vec]
574
+ return vec
575
+
576
+ def encode(self, text: str) -> Optional[List[float]]:
577
+ if not text:
578
+ return None
579
+ return self._vec(text)
580
+
581
+ def encode_batch(self, texts: List[str]) -> Optional[List[List[float]]]:
582
+ if not texts:
583
+ return None
584
+ return [self._vec(t) for t in texts]
585
+
586
+
587
+ # ===== 后端工厂 =====
588
+
589
+ _BACKEND_REGISTRY = {
590
+ "sentence_transformers": SentenceTransformerBackend,
591
+ "openai": OpenAIBackend,
592
+ "ollama": OllamaBackend,
593
+ "http": HTTPBackend,
594
+ "fake": FakeBackend, # v5.7.8:确定性测试/评测后端
595
+ }
596
+
597
+
598
+ def create_backend(backend_name: str = "", model_name: str = "",
599
+ **kwargs) -> EmbeddingBackend:
600
+ """创建嵌入后端实例
601
+
602
+ Args:
603
+ backend_name: 后端名称,空则从环境变量 MINDFORGE_EMBEDDING_BACKEND 读取,
604
+ 默认 sentence_transformers
605
+ model_name: 模型名称,空则从环境变量读取
606
+ **kwargs: 传递给后端构造函数的额外参数
607
+
608
+ Returns:
609
+ EmbeddingBackend 实例
610
+ """
611
+ backend_name = backend_name or os.environ.get(
612
+ "MINDFORGE_EMBEDDING_BACKEND", "sentence_transformers")
613
+ model_name = model_name or os.environ.get("MINDFORGE_EMBEDDING_MODEL", "")
614
+
615
+ cls = _BACKEND_REGISTRY.get(backend_name)
616
+ if cls is None:
617
+ raise ValueError(
618
+ f"未知嵌入后端: {backend_name!r}。"
619
+ f"支持: {', '.join(sorted(_BACKEND_REGISTRY))}"
620
+ )
621
+
622
+ if backend_name == "openai":
623
+ model = model_name or "text-embedding-3-small"
624
+ return cls(model_name=model, **kwargs)
625
+ elif backend_name == "ollama":
626
+ model = model_name or "nomic-embed-text"
627
+ return cls(model_name=model, **kwargs)
628
+ elif backend_name == "http":
629
+ return cls(model_name=model_name or "custom", **kwargs)
630
+ elif backend_name == "fake":
631
+ seed = int(kwargs.pop("seed", 42))
632
+ dim = int(kwargs.pop("dimension", DEFAULT_DIMENSION))
633
+ # fake 无真实模型名:忽略 DEFAULT_MODEL,统一为可辨识的确定性标识
634
+ return cls(model_name="fake-bigram-" + str(seed), seed=seed, dimension=dim, **kwargs)
635
+ else:
636
+ model = model_name or DEFAULT_MODEL
637
+ return cls(model_name=model, **kwargs)
638
+
639
+
640
+ # ===== 嵌入引擎(统一接口,兼容旧 API) =====
641
+
642
+ class EmbeddingEngine:
643
+ """语义嵌入引擎(多后端适配器,v5.4.6 增强)
644
+
645
+ 封装多种嵌入后端,提供:
646
+ - 文本 → 向量编码(encode / encode_batch)
647
+ - 向量序列化(serialize / deserialize,用于 SQLite BLOB 存储)
648
+ - 余弦相似度计算
649
+ - 懒加载:首次 encode 时才加载模型/连接后端
650
+ - 多后端支持:sentence-transformers / OpenAI / Ollama / 自定义 HTTP
651
+
652
+ 后端选择优先级:
653
+ 1. 构造参数 backend=
654
+ 2. 环境变量 MINDFORGE_EMBEDDING_BACKEND
655
+ 3. 默认 sentence_transformers
656
+
657
+ 使用方式:
658
+ engine = EmbeddingEngine() # 自动选择后端
659
+ if engine.is_available:
660
+ vec = engine.encode("hello world")
661
+
662
+ # 指定后端
663
+ engine = EmbeddingEngine(backend="openai")
664
+ engine = EmbeddingEngine(backend="ollama", model_name="nomic-embed-text")
665
+ """
666
+
667
+ _instance = None
668
+ _lock = threading.Lock()
669
+
670
+ def __new__(cls, *args, **kwargs):
671
+ with cls._lock:
672
+ if cls._instance is None:
673
+ cls._instance = super().__new__(cls)
674
+ return cls._instance
675
+
676
+ def __init__(self, model_name: str = "", backend: str = ""):
677
+ if hasattr(self, "_initialized"):
678
+ # v5.5.3 fix: 检查参数是否不同,如不同则记录警告
679
+ requested_backend = backend or os.environ.get("MINDFORGE_EMBEDDING_BACKEND", "sentence_transformers")
680
+ requested_model = model_name or os.environ.get("MINDFORGE_EMBEDDING_MODEL", "")
681
+ if requested_backend != self._backend_name or (requested_model and requested_model != self._model_name):
682
+ import logging
683
+ logger = logging.getLogger(__name__)
684
+ logger.warning(
685
+ "EmbeddingEngine is a singleton: requested backend=%s model=%s, "
686
+ "but using existing instance backend=%s model=%s",
687
+ requested_backend, requested_model, self._backend_name, self._model_name
688
+ )
689
+ return
690
+ try:
691
+ self._initialized = True
692
+ self._backend_name = backend or os.environ.get(
693
+ "MINDFORGE_EMBEDDING_BACKEND", "sentence_transformers")
694
+ self._backend: Optional[EmbeddingBackend] = None
695
+ self._model_name = model_name or os.environ.get(
696
+ "MINDFORGE_EMBEDDING_MODEL", "")
697
+ if not self._model_name:
698
+ if self._backend_name == "openai":
699
+ self._model_name = "text-embedding-3-small"
700
+ elif self._backend_name == "ollama":
701
+ self._model_name = "nomic-embed-text"
702
+ else:
703
+ self._model_name = DEFAULT_MODEL
704
+ self._dimension = DEFAULT_DIMENSION
705
+ self._load_attempted = False
706
+ self._available = False
707
+ except Exception:
708
+ # P1 修复:初始化失败时重置单例,允许后续调用重试
709
+ # (此前失败后 _instance 已创建但半初始化,后续调用直接拿到坏实例)
710
+ with EmbeddingEngine._lock:
711
+ EmbeddingEngine._instance = None
712
+ raise
713
+
714
+ @property
715
+ def is_available(self) -> bool:
716
+ """嵌入后端是否可用"""
717
+ if not self._load_attempted:
718
+ self._try_load()
719
+ return self._available
720
+
721
+ @property
722
+ def dimension(self) -> int:
723
+ """嵌入向量维度"""
724
+ return self._dimension
725
+
726
+ @property
727
+ def model_name(self) -> str:
728
+ return self._model_name
729
+
730
+ @property
731
+ def backend_name(self) -> str:
732
+ """当前后端名称"""
733
+ return self._backend_name
734
+
735
+ def _try_load(self):
736
+ """懒加载嵌入后端"""
737
+ self._load_attempted = True
738
+ try:
739
+ self._backend = create_backend(
740
+ backend_name=self._backend_name,
741
+ model_name=self._model_name,
742
+ )
743
+ if self._backend.is_available:
744
+ self._dimension = self._backend.dimension
745
+ # v5.7.8:真实模型名以实际后端为准(fake 等后端不使用默认模型)
746
+ self._model_name = self._backend.model_name
747
+ self._available = True
748
+ logger.info("EmbeddingEngine 加载成功 [%s]: %s (dim=%d)",
749
+ self._backend_name, self._model_name, self._dimension)
750
+ else:
751
+ self._available = False
752
+ except Exception as e:
753
+ logger.warning("EmbeddingEngine 加载失败: %s", e)
754
+ self._available = False
755
+
756
+ def encode(self, text: str) -> Optional[List[float]]:
757
+ """编码单条文本为向量
758
+
759
+ Args:
760
+ text: 输入文本
761
+
762
+ Returns:
763
+ 嵌入向量 (List[float]),或 None(模型不可用时)
764
+ """
765
+ if not self.is_available or not text:
766
+ return None
767
+ return self._backend.encode(text)
768
+
769
+ def encode_batch(self, texts: List[str]) -> Optional[List[List[float]]]:
770
+ """批量编码文本为向量
771
+
772
+ Args:
773
+ texts: 输入文本列表
774
+
775
+ Returns:
776
+ 嵌入向量列表,或 None(模型不可用时)
777
+ """
778
+ if not self.is_available or not texts:
779
+ return None
780
+ return self._backend.encode_batch(texts)
781
+
782
+ @staticmethod
783
+ def serialize(vec: List[float]) -> bytes:
784
+ """将向量序列化为 bytes(用于 SQLite BLOB 存储)
785
+
786
+ 格式: 4 字节 float32 数组(小端序)
787
+ """
788
+ return struct.pack(f'<{len(vec)}f', *vec)
789
+
790
+ @staticmethod
791
+ def deserialize(blob: bytes) -> List[float]:
792
+ """从 bytes 反序列化向量"""
793
+ if not blob:
794
+ return []
795
+ count = len(blob) // 4
796
+ return list(struct.unpack(f'<{count}f', blob))
797
+
798
+ @staticmethod
799
+ def cosine_similarity(v1: List[float], v2: List[float]) -> float:
800
+ """计算两个向量的余弦相似度
801
+
802
+ v5.4.6 修复:不再假设向量已归一化,改为完整余弦相似度计算。
803
+ 降级模式或外部 API 返回的向量可能未归一化,
804
+ 之前直接点积导致相同向量返回非 1.0 的错误结果。
805
+
806
+ Args:
807
+ v1: 向量 1
808
+ v2: 向量 2
809
+
810
+ Returns:
811
+ 余弦相似度 [-1.0, 1.0]
812
+ """
813
+ if not v1 or not v2 or len(v1) != len(v2):
814
+ return 0.0
815
+ dot = sum(a * b for a, b in zip(v1, v2))
816
+ norm1 = sum(a * a for a in v1) ** 0.5
817
+ norm2 = sum(b * b for b in v2) ** 0.5
818
+ if norm1 == 0.0 or norm2 == 0.0:
819
+ return 0.0
820
+ return max(-1.0, min(1.0, dot / (norm1 * norm2)))
821
+
822
+ @staticmethod
823
+ def cosine_similarity_batch(
824
+ query_vec: List[float],
825
+ candidates: List[Tuple[str, List[float]]],
826
+ top_k: int = 20
827
+ ) -> List[Tuple[str, float]]:
828
+ """批量计算余弦相似度并返回 top_k 结果
829
+
830
+ Args:
831
+ query_vec: 查询向量
832
+ candidates: [(memory_id, embedding_vec), ...]
833
+ top_k: 返回前 k 个结果
834
+
835
+ Returns:
836
+ [(memory_id, score), ...] 按分数降序
837
+ """
838
+ if not query_vec or not candidates:
839
+ return []
840
+ scored = []
841
+ for mem_id, vec in candidates:
842
+ if not vec or len(vec) != len(query_vec):
843
+ continue
844
+ score = EmbeddingEngine.cosine_similarity(query_vec, vec)
845
+ scored.append((mem_id, score))
846
+ scored.sort(key=lambda x: x[1], reverse=True)
847
+ return scored[:top_k]