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.
- MindForge.py +20 -0
- mindforge/__init__.py +111 -0
- mindforge/adapters/__init__.py +18 -0
- mindforge/adapters/claude_adapter.py +84 -0
- mindforge/adapters/crewai_memory.py +76 -0
- mindforge/adapters/generic_api.py +183 -0
- mindforge/adapters/langgraph_store.py +463 -0
- mindforge/adapters/openclaw_adapter.py +91 -0
- mindforge/api/__init__.py +1 -0
- mindforge/api/server.py +1567 -0
- mindforge/cli/__init__.py +1 -0
- mindforge/cli/main.py +13607 -0
- mindforge/core/__init__.py +37 -0
- mindforge/core/embedding.py +847 -0
- mindforge/core/encryption.py +597 -0
- mindforge/core/indexer.py +363 -0
- mindforge/core/mindforge.py +6298 -0
- mindforge/core/paths.py +43 -0
- mindforge/core/query.py +501 -0
- mindforge/core/storage.py +18656 -0
- mindforge/core/types.py +314 -0
- mindforge/core/version.py +56 -0
- mindforge/daemon/__init__.py +0 -0
- mindforge/daemon/server.py +277 -0
- mindforge/mcp/__init__.py +4 -0
- mindforge/mcp/server.py +2559 -0
- mindforge/modules/__init__.py +138 -0
- mindforge/modules/categorizer.py +151 -0
- mindforge/modules/conflict_detector.py +356 -0
- mindforge/modules/connectors.py +331 -0
- mindforge/modules/context_window.py +337 -0
- mindforge/modules/event_bus.py +572 -0
- mindforge/modules/evolution.py +210 -0
- mindforge/modules/experience.py +402 -0
- mindforge/modules/federated.py +722 -0
- mindforge/modules/federated_acl.py +393 -0
- mindforge/modules/follow_up.py +315 -0
- mindforge/modules/hybrid_search.py +565 -0
- mindforge/modules/importer.py +374 -0
- mindforge/modules/integrator.py +196 -0
- mindforge/modules/intent_router.py +359 -0
- mindforge/modules/knowledge_graph.py +395 -0
- mindforge/modules/memory_compressor.py +592 -0
- mindforge/modules/memory_decay.py +474 -0
- mindforge/modules/memory_passport.py +250 -0
- mindforge/modules/multi_agent.py +534 -0
- mindforge/modules/multimodal.py +304 -0
- mindforge/modules/outcome_learning.py +186 -0
- mindforge/modules/personality.py +275 -0
- mindforge/modules/principled_forgetting.py +258 -0
- mindforge/modules/privacy.py +736 -0
- mindforge/modules/recall.py +263 -0
- mindforge/modules/session_focus.py +305 -0
- mindforge/modules/share_conflict.py +521 -0
- mindforge/modules/skill_extractor.py +340 -0
- mindforge/modules/skill_pack.py +205 -0
- mindforge/modules/timeline_viz.py +206 -0
- mindforge/modules/v6.py +931 -0
- mindforge_memory-6.4.1.dist-info/METADATA +281 -0
- mindforge_memory-6.4.1.dist-info/RECORD +64 -0
- mindforge_memory-6.4.1.dist-info/WHEEL +5 -0
- mindforge_memory-6.4.1.dist-info/entry_points.txt +3 -0
- mindforge_memory-6.4.1.dist-info/licenses/LICENSE +21 -0
- 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]
|