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,565 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MindForge 混合检索增强管线
|
|
3
|
+
================================
|
|
4
|
+
补上原检索缺的两块:
|
|
5
|
+
A. 查询扩展 Query Expansion:
|
|
6
|
+
- 同义词扩展(内置中文常见技术/生活同义词 + 用户自定义词典)
|
|
7
|
+
- 上位词泛化(如「MySQL 报错」→「数据库报错」)
|
|
8
|
+
- 缩写还原(如「k8s」→「Kubernetes」)
|
|
9
|
+
- 纠错回退(编辑距离 1 的常见错拼)
|
|
10
|
+
|
|
11
|
+
B. Cross-Encoder 风格重排(Reranking):
|
|
12
|
+
- 纯 CPU 的多特征打分(query-token overlap / 关键短语命中 / n-gram overlap
|
|
13
|
+
/ 词距惩罚 / 类别与重要性先验),模拟 cross-encoder 的相关性判断
|
|
14
|
+
- 与原 relevance_score 做加权融合,最终输出新排序
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
import math
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from typing import Any, Dict, Iterable, List, Optional, Sequence, Set, Tuple
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
# 内置查询扩展词典(可通过 add_synonym / add_abbr 扩充)
|
|
26
|
+
# ---------------------------------------------------------------------------
|
|
27
|
+
|
|
28
|
+
DEFAULT_SYNONYMS: Dict[str, List[str]] = {
|
|
29
|
+
"部署": ["上线", "发布", "发版"],
|
|
30
|
+
"报错": ["错误", "异常", "失败", "崩溃"],
|
|
31
|
+
"数据库": ["mysql", "postgresql", "pgsql", "db", "sqlite", "redis", "mongo", "mongodb"],
|
|
32
|
+
"mysql": ["数据库", "mysql", "mysqldb"],
|
|
33
|
+
"k8s": ["kubernetes", "kubectl", "容器", "容器化"],
|
|
34
|
+
"容器": ["docker", "k8s", "kubernetes", "镜像"],
|
|
35
|
+
"配置": ["conf", "config", "配置文件", "settings", "参数"],
|
|
36
|
+
"启动": ["运行", "拉起", "start", "开机", "执行"],
|
|
37
|
+
"停止": ["shutdown", "stop", "停掉", "终止", "结束"],
|
|
38
|
+
"搜索": ["查找", "查询", "检索", "search", "find"],
|
|
39
|
+
"缓存": ["cache", "redis", "memcached"],
|
|
40
|
+
"接口": ["api", "接口调用", "端点", "endpoint"],
|
|
41
|
+
"用户": ["user", "客户", "账号"],
|
|
42
|
+
"密码": ["password", "口令", "pwd", "秘钥"],
|
|
43
|
+
"日志": ["log", "日志文件", "访问日志"],
|
|
44
|
+
"性能": ["速度", "慢", "耗时", "吞吐", "qps"],
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
DEFAULT_ABBR: Dict[str, str] = {
|
|
48
|
+
"k8s": "Kubernetes",
|
|
49
|
+
"db": "数据库",
|
|
50
|
+
"api": "接口",
|
|
51
|
+
"ai": "人工智能",
|
|
52
|
+
"llm": "大语言模型",
|
|
53
|
+
"kg": "知识图谱",
|
|
54
|
+
"qa": "问答",
|
|
55
|
+
"ui": "界面",
|
|
56
|
+
"ux": "用户体验",
|
|
57
|
+
"ci": "持续集成",
|
|
58
|
+
"cd": "持续部署",
|
|
59
|
+
"devops": "开发运维",
|
|
60
|
+
"sdk": "开发工具包",
|
|
61
|
+
"sql": "结构化查询语言",
|
|
62
|
+
"cpu": "中央处理器",
|
|
63
|
+
"gpu": "图形处理器",
|
|
64
|
+
"nlp": "自然语言处理",
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
DEFAULT_HYPONYMS: Dict[str, List[str]] = { # 具体 → 泛化
|
|
68
|
+
"mysql": ["数据库", "关系型数据库"],
|
|
69
|
+
"postgresql": ["数据库"],
|
|
70
|
+
"redis": ["数据库", "缓存"],
|
|
71
|
+
"mongodb": ["数据库", "文档数据库"],
|
|
72
|
+
"k8s": ["容器编排", "云原生"],
|
|
73
|
+
"docker": ["容器", "虚拟化"],
|
|
74
|
+
"kubernetes": ["容器编排", "云原生"],
|
|
75
|
+
"nginx": ["反向代理", "web服务器"],
|
|
76
|
+
"fastapi": ["api框架", "web框架"],
|
|
77
|
+
"flask": ["api框架", "web框架"],
|
|
78
|
+
"django": ["api框架", "web框架"],
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
# ---------------------------------------------------------------------------
|
|
83
|
+
# 数据类
|
|
84
|
+
# ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
@dataclass
|
|
87
|
+
class ExpansionResult:
|
|
88
|
+
original: str
|
|
89
|
+
expanded_terms: List[str] = field(default_factory=list) # 扩展出的并列词
|
|
90
|
+
rewrites: List[str] = field(default_factory=list) # 重写后的查询(可直接拿去多路检索)
|
|
91
|
+
note: str = ""
|
|
92
|
+
|
|
93
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
94
|
+
return {"original": self.original,
|
|
95
|
+
"expanded_terms": self.expanded_terms[:40],
|
|
96
|
+
"rewrites": self.rewrites[:10],
|
|
97
|
+
"note": self.note}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class RerankResult:
|
|
102
|
+
memory_id: str
|
|
103
|
+
original_score: float
|
|
104
|
+
rerank_score: float
|
|
105
|
+
fused_score: float
|
|
106
|
+
rank_before: int
|
|
107
|
+
rank_after: int
|
|
108
|
+
features: Dict[str, float] = field(default_factory=dict)
|
|
109
|
+
content: str = ""
|
|
110
|
+
# v5.5.6 fix(P2-2): 保留候选元数据,供下游过滤/去重/展示
|
|
111
|
+
importance: str = ""
|
|
112
|
+
category: str = ""
|
|
113
|
+
layer: str = ""
|
|
114
|
+
tags: List[str] = field(default_factory=list)
|
|
115
|
+
|
|
116
|
+
def to_dict(self) -> Dict[str, Any]:
|
|
117
|
+
return {
|
|
118
|
+
"memory_id": self.memory_id,
|
|
119
|
+
"original_score": round(self.original_score, 4),
|
|
120
|
+
"rerank_score": round(self.rerank_score, 4),
|
|
121
|
+
"fused_score": round(self.fused_score, 4),
|
|
122
|
+
"rank_before": self.rank_before,
|
|
123
|
+
"rank_after": self.rank_after,
|
|
124
|
+
"delta_rank": self.rank_before - self.rank_after,
|
|
125
|
+
"features": {k: round(v, 4) for k, v in self.features.items()},
|
|
126
|
+
"content": self.content[:120],
|
|
127
|
+
"importance": self.importance,
|
|
128
|
+
"category": self.category,
|
|
129
|
+
"layer": self.layer,
|
|
130
|
+
"tags": self.tags,
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ---------------------------------------------------------------------------
|
|
135
|
+
# Query Expansion
|
|
136
|
+
# ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
class QueryExpander:
|
|
139
|
+
"""同义词 + 缩写 + 上位词 + 可选编辑距离纠错"""
|
|
140
|
+
|
|
141
|
+
def __init__(self,
|
|
142
|
+
synonyms: Optional[Dict[str, List[str]]] = None,
|
|
143
|
+
abbr: Optional[Dict[str, str]] = None,
|
|
144
|
+
hyponyms: Optional[Dict[str, List[str]]] = None,
|
|
145
|
+
max_rewrites: int = 6,
|
|
146
|
+
enable_typo: bool = True):
|
|
147
|
+
self.synonyms: Dict[str, List[str]] = {}
|
|
148
|
+
for k, vs in (synonyms or DEFAULT_SYNONYMS).items():
|
|
149
|
+
self._add_multi(self.synonyms, k, vs)
|
|
150
|
+
self.abbr = dict(abbr or DEFAULT_ABBR)
|
|
151
|
+
self.hyponyms = dict(hyponyms or DEFAULT_HYPONYMS)
|
|
152
|
+
self.max_rewrites = max_rewrites
|
|
153
|
+
self.enable_typo = enable_typo
|
|
154
|
+
|
|
155
|
+
@staticmethod
|
|
156
|
+
def _add_multi(d: Dict[str, List[str]], k: str, vs: Iterable[str]) -> None:
|
|
157
|
+
lc = k.lower()
|
|
158
|
+
bucket = d.setdefault(lc, [])
|
|
159
|
+
for v in vs:
|
|
160
|
+
v_lc = v.lower()
|
|
161
|
+
if v_lc not in bucket and v_lc != lc:
|
|
162
|
+
bucket.append(v_lc)
|
|
163
|
+
# 双向:把每个同义词也作为 key,反向扩展回原词
|
|
164
|
+
for v in vs:
|
|
165
|
+
v_lc = v.lower()
|
|
166
|
+
reverse = d.setdefault(v_lc, [])
|
|
167
|
+
if lc not in reverse and lc != v_lc:
|
|
168
|
+
reverse.append(lc)
|
|
169
|
+
|
|
170
|
+
def add_synonym(self, term: str, synonyms: Sequence[str]) -> None:
|
|
171
|
+
self._add_multi(self.synonyms, term, synonyms)
|
|
172
|
+
|
|
173
|
+
def add_abbr(self, abbr: str, full: str) -> None:
|
|
174
|
+
self.abbr[abbr.lower()] = full
|
|
175
|
+
|
|
176
|
+
@staticmethod
|
|
177
|
+
def _tokenize(text: str) -> List[str]:
|
|
178
|
+
raw = re.sub(r"[\W_]+", " ", text.lower()).split()
|
|
179
|
+
# 中文逐字不做,这里仅按空白+符号分词;中文再切 2-gram
|
|
180
|
+
bag: List[str] = []
|
|
181
|
+
for token in raw:
|
|
182
|
+
if not token:
|
|
183
|
+
continue
|
|
184
|
+
bag.append(token)
|
|
185
|
+
if re.search(r"[\u4e00-\u9fa5]", token) and len(token) >= 2:
|
|
186
|
+
for n in (2, 3):
|
|
187
|
+
for i in range(len(token) - n + 1):
|
|
188
|
+
bag.append(token[i:i + n])
|
|
189
|
+
return bag
|
|
190
|
+
|
|
191
|
+
# -- typo tolerance (edit distance 1) ----------------------------------
|
|
192
|
+
|
|
193
|
+
@staticmethod
|
|
194
|
+
def _edit_dist1(a: str, b: str) -> bool:
|
|
195
|
+
"""Check if two strings have edit distance <= 1. O(min(len(a), len(b)))"""
|
|
196
|
+
la, lb = len(a), len(b)
|
|
197
|
+
if abs(la - lb) > 1:
|
|
198
|
+
return False
|
|
199
|
+
# Ensure a is the shorter one
|
|
200
|
+
if la > lb:
|
|
201
|
+
a, b = b, a
|
|
202
|
+
la, lb = lb, la
|
|
203
|
+
# Same length: check for 0 or 1 substitution
|
|
204
|
+
if la == lb:
|
|
205
|
+
diff = 0
|
|
206
|
+
for ca, cb in zip(a, b):
|
|
207
|
+
if ca != cb:
|
|
208
|
+
diff += 1
|
|
209
|
+
if diff > 1:
|
|
210
|
+
return False
|
|
211
|
+
return True
|
|
212
|
+
# Length diff 1: check for 1 insertion/deletion
|
|
213
|
+
i = j = 0
|
|
214
|
+
diff = 0
|
|
215
|
+
while i < la and j < lb:
|
|
216
|
+
if a[i] == b[j]:
|
|
217
|
+
i += 1
|
|
218
|
+
j += 1
|
|
219
|
+
else:
|
|
220
|
+
diff += 1
|
|
221
|
+
if diff > 1:
|
|
222
|
+
return False
|
|
223
|
+
j += 1 # skip one char in longer string
|
|
224
|
+
return True
|
|
225
|
+
|
|
226
|
+
def _typo_candidates(self, tokens: Sequence[str], max_tokens: int = 5) -> Dict[str, List[str]]:
|
|
227
|
+
"""把已知同义词/缩写词典作为 vocabulary,寻找编辑距离 1 的替换。
|
|
228
|
+
|
|
229
|
+
v5.4.8 P1-001 修复:改用反向查找(遍历 vocab 检查编辑距离),
|
|
230
|
+
而不是生成所有 edits(O(L*A) per word)。
|
|
231
|
+
添加 max_tokens 限制防止长查询导致性能崩溃。
|
|
232
|
+
"""
|
|
233
|
+
vocab: Set[str] = set(self.synonyms) | set(self.abbr) | set(self.hyponyms)
|
|
234
|
+
if not vocab:
|
|
235
|
+
return {}
|
|
236
|
+
|
|
237
|
+
out: Dict[str, List[str]] = {}
|
|
238
|
+
checked = 0
|
|
239
|
+
for t in tokens:
|
|
240
|
+
if checked >= max_tokens:
|
|
241
|
+
break
|
|
242
|
+
if len(t) < 3 or not re.fullmatch(r"[a-z0-9_]+", t):
|
|
243
|
+
continue
|
|
244
|
+
if t in vocab:
|
|
245
|
+
continue
|
|
246
|
+
# 反向查找:遍历 vocab 检查编辑距离
|
|
247
|
+
candidates = [v for v in vocab if self._edit_dist1(t, v)]
|
|
248
|
+
if candidates:
|
|
249
|
+
out[t] = candidates[:3]
|
|
250
|
+
checked += 1
|
|
251
|
+
return out
|
|
252
|
+
|
|
253
|
+
# -- main expand -------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
def expand(self, query: str, hyponym: bool = True,
|
|
256
|
+
typo: Optional[bool] = None) -> ExpansionResult:
|
|
257
|
+
q = (query or "").strip()
|
|
258
|
+
tokens = self._tokenize(q)
|
|
259
|
+
seen_terms: Set[str] = set(tokens)
|
|
260
|
+
expanded: List[str] = []
|
|
261
|
+
|
|
262
|
+
def _add(t: str) -> None:
|
|
263
|
+
if not t:
|
|
264
|
+
return
|
|
265
|
+
if t not in seen_terms:
|
|
266
|
+
seen_terms.add(t)
|
|
267
|
+
expanded.append(t)
|
|
268
|
+
|
|
269
|
+
# 1) 缩写还原
|
|
270
|
+
for t in tokens:
|
|
271
|
+
if t in self.abbr:
|
|
272
|
+
_add(self.abbr[t].lower())
|
|
273
|
+
|
|
274
|
+
# 2) 同义词
|
|
275
|
+
for t in tokens:
|
|
276
|
+
if t in self.synonyms:
|
|
277
|
+
for s in self.synonyms[t]:
|
|
278
|
+
_add(s)
|
|
279
|
+
|
|
280
|
+
# 3) 上位词泛化
|
|
281
|
+
if hyponym:
|
|
282
|
+
for t in tokens:
|
|
283
|
+
if t in self.hyponyms:
|
|
284
|
+
for g in self.hyponyms[t]:
|
|
285
|
+
_add(g.lower())
|
|
286
|
+
|
|
287
|
+
# 4) 纠错(把 query 中可能的 typo token 指向候选词)
|
|
288
|
+
typo_note = ""
|
|
289
|
+
if (typo if typo is not None else self.enable_typo):
|
|
290
|
+
suggestions = self._typo_candidates(tokens)
|
|
291
|
+
if suggestions:
|
|
292
|
+
for bad, goods in suggestions.items():
|
|
293
|
+
for g in goods:
|
|
294
|
+
_add(g)
|
|
295
|
+
typo_parts = [f"{b}→{'/'.join(g)}" for b, g in suggestions.items()]
|
|
296
|
+
typo_note = "typo 候选: " + ", ".join(typo_parts)
|
|
297
|
+
|
|
298
|
+
# 生成 rewrite:原句 + 同义词替换版本(逐词替换一次)
|
|
299
|
+
rewrites: List[str] = [q]
|
|
300
|
+
lowered_tokens = re.split(r"(\s+)", q)
|
|
301
|
+
for idx, chunk in enumerate(lowered_tokens):
|
|
302
|
+
lc = chunk.lower()
|
|
303
|
+
if lc in self.synonyms:
|
|
304
|
+
for s in self.synonyms[lc][:2]:
|
|
305
|
+
new_parts = lowered_tokens.copy()
|
|
306
|
+
# v5.6.8 修复:原写法 `s if chunk.isupper() else s` 两个分支相同,
|
|
307
|
+
# 大小写保留逻辑形同虚设(全大写原词会被替换成小写同义词)。
|
|
308
|
+
new_parts[idx] = s.upper() if chunk.isupper() else s
|
|
309
|
+
rewrites.append("".join(new_parts))
|
|
310
|
+
if lc in self.abbr:
|
|
311
|
+
new_parts = lowered_tokens.copy()
|
|
312
|
+
new_parts[idx] = self.abbr[lc]
|
|
313
|
+
rewrites.append("".join(new_parts))
|
|
314
|
+
# 去重保序 + 截断
|
|
315
|
+
dedup: List[str] = []
|
|
316
|
+
for r in rewrites:
|
|
317
|
+
if r not in dedup:
|
|
318
|
+
dedup.append(r)
|
|
319
|
+
if len(dedup) >= self.max_rewrites:
|
|
320
|
+
break
|
|
321
|
+
return ExpansionResult(
|
|
322
|
+
original=q,
|
|
323
|
+
expanded_terms=expanded,
|
|
324
|
+
rewrites=dedup,
|
|
325
|
+
note=typo_note,
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# ---------------------------------------------------------------------------
|
|
330
|
+
# Cross-Encoder Style Reranker (纯 CPU, 多特征融合)
|
|
331
|
+
# ---------------------------------------------------------------------------
|
|
332
|
+
|
|
333
|
+
@dataclass
|
|
334
|
+
class RerankWeights:
|
|
335
|
+
original: float = 0.35
|
|
336
|
+
token_overlap: float = 0.22
|
|
337
|
+
phrase_hit: float = 0.20
|
|
338
|
+
ngram_overlap: float = 0.13
|
|
339
|
+
proximity: float = 0.05
|
|
340
|
+
importance_bonus: float = 0.03
|
|
341
|
+
category_bonus: float = 0.02
|
|
342
|
+
layer_bonus: float = 0.01
|
|
343
|
+
length_norm: float = 0.10
|
|
344
|
+
|
|
345
|
+
def total(self) -> float:
|
|
346
|
+
return (self.original + self.token_overlap + self.phrase_hit +
|
|
347
|
+
self.ngram_overlap + self.proximity +
|
|
348
|
+
self.importance_bonus + self.category_bonus +
|
|
349
|
+
self.layer_bonus)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class CrossEncoderReranker:
|
|
353
|
+
"""无外部依赖的相关性重排:融合 query 感知的多特征打分"""
|
|
354
|
+
|
|
355
|
+
def __init__(self, weights: Optional[RerankWeights] = None):
|
|
356
|
+
self.weights = weights or RerankWeights()
|
|
357
|
+
|
|
358
|
+
@staticmethod
|
|
359
|
+
def _norm(text: str) -> str:
|
|
360
|
+
return re.sub(r"[\W_]+", " ", (text or "").lower())
|
|
361
|
+
|
|
362
|
+
@staticmethod
|
|
363
|
+
def _tokens(text: str) -> List[str]:
|
|
364
|
+
s = re.sub(r"[\W_]+", " ", text.lower())
|
|
365
|
+
return [t for t in s.split() if t]
|
|
366
|
+
|
|
367
|
+
@staticmethod
|
|
368
|
+
def _char_ngrams(text: str, n: int = 2) -> List[str]:
|
|
369
|
+
s = re.sub(r"[\s]+", "", text.lower())
|
|
370
|
+
if len(s) < n:
|
|
371
|
+
return [s] if s else []
|
|
372
|
+
return [s[i:i + n] for i in range(len(s) - n + 1)]
|
|
373
|
+
|
|
374
|
+
def _score_token_overlap(self, q_tokens: Sequence[str], d_tokens: Sequence[str]) -> float:
|
|
375
|
+
if not q_tokens or not d_tokens:
|
|
376
|
+
return 0.0
|
|
377
|
+
qs, ds = set(q_tokens), set(d_tokens)
|
|
378
|
+
inter = len(qs & ds)
|
|
379
|
+
union = len(qs | ds)
|
|
380
|
+
jaccard = inter / max(1, union)
|
|
381
|
+
# recall 部分:query 词命中比例
|
|
382
|
+
recall = sum(1 for t in q_tokens if t in ds) / max(1, len(q_tokens))
|
|
383
|
+
return 0.6 * recall + 0.4 * jaccard
|
|
384
|
+
|
|
385
|
+
def _score_phrase_hit(self, query: str, doc: str) -> float:
|
|
386
|
+
if not query or not doc:
|
|
387
|
+
return 0.0
|
|
388
|
+
q = self._norm(query)
|
|
389
|
+
d = self._norm(doc)
|
|
390
|
+
if not q:
|
|
391
|
+
return 0.0
|
|
392
|
+
# 3+ 长度的 token:若在文档里出现算一个强命中
|
|
393
|
+
long_tokens = [t for t in self._tokens(q) if len(t) >= 3]
|
|
394
|
+
hits = 0
|
|
395
|
+
for t in long_tokens:
|
|
396
|
+
if t in d:
|
|
397
|
+
hits += 1
|
|
398
|
+
# 完整子串命中(2-gram以上)
|
|
399
|
+
substring_hits = 0
|
|
400
|
+
for n in (3, 4):
|
|
401
|
+
for i in range(len(q) - n + 1):
|
|
402
|
+
chunk = q[i:i + n]
|
|
403
|
+
if chunk.isspace():
|
|
404
|
+
continue
|
|
405
|
+
if chunk in d:
|
|
406
|
+
substring_hits += 1
|
|
407
|
+
ratio1 = hits / max(1, len(long_tokens))
|
|
408
|
+
ratio2 = min(1.0, substring_hits / max(1, len(q) / 3.0))
|
|
409
|
+
return 0.5 * ratio1 + 0.5 * ratio2
|
|
410
|
+
|
|
411
|
+
def _score_ngram_overlap(self, query: str, doc: str) -> float:
|
|
412
|
+
q = set(self._char_ngrams(query, 2)) | set(self._char_ngrams(query, 3))
|
|
413
|
+
d = set(self._char_ngrams(doc, 2)) | set(self._char_ngrams(doc, 3))
|
|
414
|
+
if not q or not d:
|
|
415
|
+
return 0.0
|
|
416
|
+
return len(q & d) / max(1, len(q | d))
|
|
417
|
+
|
|
418
|
+
def _score_proximity(self, q_tokens: Sequence[str], doc: str) -> float:
|
|
419
|
+
"""query 词在文档中首次出现位置的跨度越小分越高。"""
|
|
420
|
+
if not q_tokens or not doc:
|
|
421
|
+
return 0.0
|
|
422
|
+
d_lower = doc.lower()
|
|
423
|
+
positions: List[int] = []
|
|
424
|
+
for t in q_tokens:
|
|
425
|
+
idx = d_lower.find(t)
|
|
426
|
+
if idx >= 0:
|
|
427
|
+
positions.append(idx)
|
|
428
|
+
if len(positions) < 2:
|
|
429
|
+
return 0.0 if len(positions) == 0 else 0.5
|
|
430
|
+
span = max(positions) - min(positions)
|
|
431
|
+
# span 越小越好;按 doc 长度归一
|
|
432
|
+
norm = 1.0 - min(1.0, span / max(1.0, len(doc)))
|
|
433
|
+
return norm
|
|
434
|
+
|
|
435
|
+
def _score_metadata(self, doc_meta: Dict[str, Any], query_meta: Dict[str, Any]) -> Tuple[float, float, float]:
|
|
436
|
+
"""(importance_bonus, category_bonus, layer_bonus)"""
|
|
437
|
+
importance_score = 0.0
|
|
438
|
+
imp = str(doc_meta.get("importance", "")).upper()
|
|
439
|
+
if imp == "CRITICAL":
|
|
440
|
+
importance_score = 1.0
|
|
441
|
+
elif imp == "HIGH":
|
|
442
|
+
importance_score = 0.75
|
|
443
|
+
elif imp == "MEDIUM":
|
|
444
|
+
importance_score = 0.45
|
|
445
|
+
elif imp == "LOW":
|
|
446
|
+
importance_score = 0.15
|
|
447
|
+
category_score = 0.0
|
|
448
|
+
cat = str(doc_meta.get("category", "")).lower()
|
|
449
|
+
if cat and cat == str(query_meta.get("category", "")).lower():
|
|
450
|
+
category_score = 1.0
|
|
451
|
+
layer_score = 0.0
|
|
452
|
+
layer = str(doc_meta.get("layer", "")).lower()
|
|
453
|
+
if layer == "permanent":
|
|
454
|
+
layer_score = 1.0
|
|
455
|
+
elif layer == "long_term":
|
|
456
|
+
layer_score = 0.8
|
|
457
|
+
elif layer == "short_term":
|
|
458
|
+
layer_score = 0.4
|
|
459
|
+
return importance_score, category_score, layer_score
|
|
460
|
+
|
|
461
|
+
# -- main rerank -------------------------------------------------------
|
|
462
|
+
|
|
463
|
+
def rerank(self,
|
|
464
|
+
query: str,
|
|
465
|
+
candidates: Sequence, # v5.4.7 修复 H-5:接受 dict 或 dataclass
|
|
466
|
+
query_category: Optional[str] = None,
|
|
467
|
+
top_k: int = 20) -> List[RerankResult]:
|
|
468
|
+
"""candidates 每项需含: memory_id, content, original_score;
|
|
469
|
+
可含 importance/category/layer 元数据。
|
|
470
|
+
v5.4.7 修复 H-5:支持 dict 和 dataclass (MemoryChunk) 两种输入。"""
|
|
471
|
+
q_norm = self._norm(query)
|
|
472
|
+
q_tokens = self._tokens(query)
|
|
473
|
+
|
|
474
|
+
def _get_field(c, field, default=""):
|
|
475
|
+
"""v5.4.7 修复 H-5:兼容 dict 和 dataclass 访问"""
|
|
476
|
+
if isinstance(c, dict):
|
|
477
|
+
return c.get(field, default)
|
|
478
|
+
return getattr(c, field, default)
|
|
479
|
+
|
|
480
|
+
scored: List[RerankResult] = []
|
|
481
|
+
for rank_before, c in enumerate(candidates, 1):
|
|
482
|
+
content = str(_get_field(c, "content", ""))
|
|
483
|
+
d_tokens = self._tokens(content)
|
|
484
|
+
meta = {
|
|
485
|
+
"importance": _get_field(c, "importance"),
|
|
486
|
+
"category": _get_field(c, "category"),
|
|
487
|
+
"layer": _get_field(c, "layer"),
|
|
488
|
+
}
|
|
489
|
+
q_meta = {"category": query_category or ""}
|
|
490
|
+
|
|
491
|
+
f_token = self._score_token_overlap(q_tokens, d_tokens)
|
|
492
|
+
f_phrase = self._score_phrase_hit(q_norm, self._norm(content))
|
|
493
|
+
f_ngram = self._score_ngram_overlap(q_norm, content)
|
|
494
|
+
f_prox = self._score_proximity(q_tokens, content)
|
|
495
|
+
f_imp, f_cat, f_layer = self._score_metadata(meta, q_meta)
|
|
496
|
+
|
|
497
|
+
original_score = float(_get_field(c, "original_score", _get_field(c, "score", 0.0)))
|
|
498
|
+
# 归一化 original_score 到 0-1(若原分 >1 先压缩)
|
|
499
|
+
orig_norm = 1.0 - math.exp(-max(0.0, original_score))
|
|
500
|
+
rerank_raw = (
|
|
501
|
+
self.weights.token_overlap * f_token +
|
|
502
|
+
self.weights.phrase_hit * f_phrase +
|
|
503
|
+
self.weights.ngram_overlap * f_ngram +
|
|
504
|
+
self.weights.proximity * f_prox +
|
|
505
|
+
self.weights.importance_bonus * f_imp +
|
|
506
|
+
self.weights.category_bonus * f_cat +
|
|
507
|
+
self.weights.layer_bonus * f_layer
|
|
508
|
+
)
|
|
509
|
+
rerank_denom = (
|
|
510
|
+
self.weights.token_overlap + self.weights.phrase_hit +
|
|
511
|
+
self.weights.ngram_overlap + self.weights.proximity +
|
|
512
|
+
self.weights.importance_bonus + self.weights.category_bonus +
|
|
513
|
+
self.weights.layer_bonus
|
|
514
|
+
) or 1.0
|
|
515
|
+
rerank_score = rerank_raw / rerank_denom
|
|
516
|
+
# 长度惩罚:内容过短(<10 字)给 0.9 折扣
|
|
517
|
+
length_penalty = 1.0
|
|
518
|
+
if len(content.strip()) < 10:
|
|
519
|
+
length_penalty = 0.9
|
|
520
|
+
fused_score = (
|
|
521
|
+
self.weights.original * orig_norm +
|
|
522
|
+
(1.0 - self.weights.original) * rerank_score
|
|
523
|
+
) * length_penalty
|
|
524
|
+
|
|
525
|
+
scored.append(RerankResult(
|
|
526
|
+
# v6.1.2 修复:本函数其余字段一律经 `_get_field` 读取(v5.4.7 H-5
|
|
527
|
+
# 声明「支持 dict 和 dataclass」),只有这一行直接 `c.get(...)`。
|
|
528
|
+
# 传 MemoryChunk(query.py 的 dataclass,没有 .get)即在首个候选上
|
|
529
|
+
# 抛 AttributeError——对外承诺的 dataclass 支持一直是失效的,内部
|
|
530
|
+
# 调用方 core/mindforge.py 早先把 chunks 转成 dict 才绕开。
|
|
531
|
+
memory_id=str(_get_field(c, "memory_id", "")
|
|
532
|
+
or _get_field(c, "id", "")),
|
|
533
|
+
original_score=original_score,
|
|
534
|
+
rerank_score=rerank_score,
|
|
535
|
+
fused_score=fused_score,
|
|
536
|
+
rank_before=rank_before,
|
|
537
|
+
rank_after=0,
|
|
538
|
+
features={
|
|
539
|
+
"token_overlap": f_token,
|
|
540
|
+
"phrase_hit": f_phrase,
|
|
541
|
+
"ngram_overlap": f_ngram,
|
|
542
|
+
"proximity": f_prox,
|
|
543
|
+
"importance": f_imp,
|
|
544
|
+
"category_match": f_cat,
|
|
545
|
+
"layer_bonus": f_layer,
|
|
546
|
+
"length_penalty": length_penalty,
|
|
547
|
+
},
|
|
548
|
+
content=content,
|
|
549
|
+
# v5.5.6 fix(P2-2): 透传候选元数据
|
|
550
|
+
importance=str(_get_field(c, "importance", "") or ""),
|
|
551
|
+
category=str(_get_field(c, "category", "") or ""),
|
|
552
|
+
layer=str(_get_field(c, "layer", "") or ""),
|
|
553
|
+
tags=list(_get_field(c, "tags", []) or []),
|
|
554
|
+
))
|
|
555
|
+
# 按 fused_score 排序
|
|
556
|
+
scored.sort(key=lambda x: -x.fused_score)
|
|
557
|
+
for rank_after, r in enumerate(scored, 1):
|
|
558
|
+
r.rank_after = rank_after
|
|
559
|
+
return scored[: max(1, int(top_k))]
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
__all__ = [
|
|
563
|
+
"QueryExpander", "ExpansionResult",
|
|
564
|
+
"CrossEncoderReranker", "RerankResult", "RerankWeights",
|
|
565
|
+
]
|