kb-memory 2.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.
kb/embedder.py ADDED
@@ -0,0 +1,134 @@
1
+ """嵌入器:嵌入模型延迟加载 + 设备支持 + 归一化向量输出(实例化不加载模型)。
2
+
3
+ English: Embedder: lazy-loaded embedding model, device support, normalized vectors
4
+ (construction never loads the model).
5
+ """
6
+ import gc
7
+ import threading
8
+ import time
9
+
10
+
11
+ class EmbeddingUnavailableError(RuntimeError):
12
+ """嵌入模型不可用(下载/加载/推理失败);API 层据此返回 503 结构化错误。
13
+ English: Embedding model unavailable (download/load/encode failure); the API layer
14
+ maps this to a structured 503."""
15
+
16
+ def __init__(self, model_name: str, cause: str):
17
+ self.model_name = model_name
18
+ self.cause = cause
19
+ super().__init__(f"嵌入模型不可用:{model_name}({cause})")
20
+
21
+
22
+ class Embedder:
23
+ """SentenceTransformer 封装;构造只存参数,首次 embed 才加载模型。
24
+
25
+ English: SentenceTransformer wrapper; the constructor only stores parameters,
26
+ and the model is loaded on the first embed call.
27
+ """
28
+
29
+ def __init__(self, model_name: str, device: str = "cpu",
30
+ idle_unload_seconds: int = 0):
31
+ """只存参数不加载模型;_model 延迟到首次使用。
32
+ English: Store parameters only; defer loading until first use."""
33
+ self.model_name = model_name
34
+ self.device = device
35
+ self.idle_unload_seconds = max(0, int(idle_unload_seconds or 0))
36
+ self._model = None
37
+ self._state = "unloaded" # unloaded | loading | ok | error
38
+ self._error = ""
39
+ self._last_used = time.monotonic()
40
+ self._lock = threading.RLock()
41
+
42
+ def status(self) -> dict:
43
+ """返回可放进 healthz 的轻量状态(不触发加载)。
44
+ English: Return a lightweight status suitable for healthz (never triggers loading)."""
45
+ return {"state": self._state, "model": self.model_name,
46
+ "error": self._error,
47
+ "idle_unload_seconds": self.idle_unload_seconds}
48
+
49
+ def _mark_error(self, exc: Exception) -> EmbeddingUnavailableError:
50
+ """记录失败状态并包装成统一异常。
51
+ English: Record the failure state and wrap it in the unified exception."""
52
+ self._state = "error"
53
+ self._error = str(exc)[:300]
54
+ return EmbeddingUnavailableError(self.model_name, self._error)
55
+
56
+ def _ensure_loaded(self):
57
+ """首次使用时加载模型;优先离线(缓存命中,不联网),失败再在线下载;cuda 下 fp16。
58
+
59
+ 加载失败时状态置 error,并抛 EmbeddingUnavailableError(不把底层
60
+ OSError/HTTPError 裸抛给调用方)。
61
+ English: Load the model on first use — offline first (cache hit, no network),
62
+ then online download; fp16 under cuda. On failure the state becomes error and
63
+ EmbeddingUnavailableError is raised instead of leaking OSError/HTTPError.
64
+ """
65
+ with self._lock:
66
+ if self._model is not None:
67
+ self._last_used = time.monotonic()
68
+ return
69
+ self._state = "loading"
70
+ try:
71
+ from sentence_transformers import SentenceTransformer
72
+ try:
73
+ self._model = SentenceTransformer(
74
+ self.model_name, device=self.device, local_files_only=True)
75
+ except Exception:
76
+ # 缓存未命中(首次部署新模型),走在线下载;HF_ENDPOINT 镜像由环境变量提供
77
+ self._model = SentenceTransformer(self.model_name, device=self.device)
78
+ if self.device == "cuda":
79
+ self._model.half()
80
+ self._state = "ok"
81
+ self._error = ""
82
+ self._last_used = time.monotonic()
83
+ except Exception as exc: # noqa: BLE001 - 统一转换为可读错误
84
+ raise self._mark_error(exc) from exc
85
+
86
+ def unload(self) -> bool:
87
+ """卸载模型释放内存;未加载时返回 False。
88
+ English: Unload the model to free memory; returns False when not loaded."""
89
+ with self._lock:
90
+ if self._model is None:
91
+ return False
92
+ self._model = None
93
+ self._state = "unloaded"
94
+ self._error = ""
95
+ gc.collect()
96
+ if self.device == "cuda":
97
+ try:
98
+ import torch
99
+ if torch.cuda.is_available():
100
+ torch.cuda.empty_cache()
101
+ except Exception: # noqa: BLE001 - 释放显存失败不影响卸载
102
+ pass
103
+ return True
104
+
105
+ def unload_if_idle(self, now: float | None = None) -> bool:
106
+ """空闲超过阈值时卸载模型;返回是否真正卸载。
107
+ English: Unload when idle beyond the threshold; returns whether it actually unloaded."""
108
+ if self.idle_unload_seconds <= 0 or self._model is None:
109
+ return False
110
+ now = time.monotonic() if now is None else now
111
+ if now - self._last_used < self.idle_unload_seconds:
112
+ return False
113
+ return self.unload()
114
+
115
+ def embed_texts(self, texts: list[str]) -> list[list[float]]:
116
+ """批量编码,normalize_embeddings=True(单位向量)。
117
+ English: Batch encode with normalize_embeddings=True (unit vectors)."""
118
+ try:
119
+ with self._lock:
120
+ self._ensure_loaded()
121
+ vecs = self._model.encode(
122
+ texts, normalize_embeddings=True, convert_to_numpy=True
123
+ )
124
+ self._last_used = time.monotonic()
125
+ except EmbeddingUnavailableError:
126
+ raise
127
+ except Exception as exc: # noqa: BLE001 - 推理失败同样视为模型不可用
128
+ raise self._mark_error(exc) from exc
129
+ return [v.tolist() for v in vecs]
130
+
131
+ def embed_query(self, query: str) -> list[float]:
132
+ """单条查询编码,同上归一化。
133
+ English: Encode a single query with the same normalization."""
134
+ return self.embed_texts([query])[0]
kb/eval.py ADDED
@@ -0,0 +1,136 @@
1
+ """最小评测基准:中文 QA 数据集 → Recall@1/@5 + MRR(N26,A3.5 spec §3.5)。
2
+
3
+ 评测流程:
4
+ 独立 KB_DATA_DIR(CLI 层创建临时目录,不碰生产库)
5
+ → 导入全部 corpus(每条独立成块,互为干扰项)
6
+ → 逐条 question 检索(指定 mode/top_k,治理默认关)
7
+ → 命中判定:corpus 记录 id ∈ top-k 结果
8
+ → 指标:Recall@1 / Recall@5 / MRR = mean(1/rank)
9
+ → 报告:总体 + 分难度 + 未命中清单 + 平均延迟
10
+
11
+ run_eval 与 service 解耦(鸭子类型:add_memory / search),可注入替身单测。
12
+
13
+ English: Minimal evaluation benchmark: Chinese QA dataset → Recall@1/@5 + MRR (N26, A3.5 spec §3.5).
14
+ Evaluation flow: isolated KB_DATA_DIR → import all corpus → retrieve per question → hit judgment
15
+ → metrics (Recall@1 / Recall@5 / MRR) → report. run_eval is decoupled from service
16
+ (duck-typed add_memory / search), so a test stub can be injected for unit tests.
17
+ """
18
+ import json
19
+ import time
20
+ from pathlib import Path
21
+
22
+ DIFFICULTY_TAGS = ("keyword", "semantic", "distractor")
23
+
24
+
25
+ class EvalDatasetError(Exception):
26
+ """数据集加载失败(文件缺失 / JSONL 损坏 / 必填字段缺失)。
27
+ English: Dataset load failure (missing file / corrupt JSONL / missing required fields)."""
28
+
29
+
30
+ def load_dataset(path) -> list[dict]:
31
+ """加载 JSONL 数据集;每行 {"qid", "question", "corpus", "tags"}。
32
+
33
+ 字段缺失 / JSON 损坏 / 文件不存在抛 EvalDatasetError。
34
+ English: Load a JSONL dataset; each line is {"qid", "question", "corpus", "tags"}.
35
+ Missing fields / corrupt JSON / a nonexistent file raise EvalDatasetError."""
36
+ path = Path(path)
37
+ if not path.exists():
38
+ raise EvalDatasetError(f"数据集不存在: {path}")
39
+ items = []
40
+ try:
41
+ for line_no, line in enumerate(
42
+ path.read_text(encoding="utf-8").splitlines(), start=1):
43
+ line = line.strip()
44
+ if not line:
45
+ continue
46
+ item = json.loads(line)
47
+ for field in ("qid", "question", "corpus"):
48
+ if field not in item:
49
+ raise EvalDatasetError(
50
+ f"第 {line_no} 行缺字段 {field}: {item}")
51
+ items.append(item)
52
+ except json.JSONDecodeError as e:
53
+ raise EvalDatasetError(f"JSONL 解析失败({path}): {e}")
54
+ return items
55
+
56
+
57
+ def compute_metrics(ranks: list[int | None]) -> dict:
58
+ """由 1-based 排名列表计算 Recall@1 / Recall@5 / MRR。
59
+
60
+ ranks: 每条问题的目标记录排名(1 起);None=未进 top-k。
61
+ 空列表安全返回全 0。
62
+ English: Compute Recall@1 / Recall@5 / MRR from a 1-based rank list.
63
+ ranks: the target-record rank per question (starting at 1); None = not in top-k.
64
+ An empty list safely returns all zeros."""
65
+ n = len(ranks)
66
+ if n == 0:
67
+ return {"recall_at_1": 0.0, "recall_at_5": 0.0, "mrr": 0.0}
68
+ hit1 = sum(1 for r in ranks if r == 1)
69
+ hit5 = sum(1 for r in ranks if r is not None and r <= 5)
70
+ mrr = sum(1 / r for r in ranks if r is not None) / n
71
+ return {"recall_at_1": hit1 / n, "recall_at_5": hit5 / n, "mrr": mrr}
72
+
73
+
74
+ def _difficulty_of(item: dict) -> str | None:
75
+ """提取难度标签(keyword/semantic/distractor);无则 None。
76
+ English: Extract the difficulty tag (keyword/semantic/distractor); None if absent."""
77
+ for t in item.get("tags") or []:
78
+ if t in DIFFICULTY_TAGS:
79
+ return t
80
+ return None
81
+
82
+
83
+ def run_eval(service, dataset: list[dict], top_k: int = 5,
84
+ mode: str = "hybrid") -> dict:
85
+ """端到端评测:写入 corpus → 逐条检索 → 指标汇总。
86
+
87
+ service: KBService 或替身(需 add_memory(content, tags=...) / search(...))。
88
+ 返回报告 dict:{count, top_k, mode, recall_at_1/5, mrr, latency_ms_avg,
89
+ by_difficulty, misses}。
90
+ English: End-to-end evaluation: write corpus → retrieve per question → aggregate metrics.
91
+ service: a KBService or stub (needs add_memory(content, tags=...) / search(...)).
92
+ Returns a report dict: {count, top_k, mode, recall_at_1/5, mrr, latency_ms_avg,
93
+ by_difficulty, misses}."""
94
+ corpus_ids: dict[int, str] = {} # qid → record_id
95
+ for item in dataset:
96
+ rec = service.add_memory(item["corpus"],
97
+ tags=list(item.get("tags") or ["eval"]))
98
+ corpus_ids[item["qid"]] = rec.id
99
+
100
+ ranks: list[int | None] = []
101
+ latency_total = 0.0
102
+ misses = []
103
+ for item in dataset:
104
+ t0 = time.perf_counter()
105
+ results = service.search(item["question"], top_k=top_k, mode=mode)
106
+ latency_total += (time.perf_counter() - t0) * 1000.0
107
+ target_id = corpus_ids[item["qid"]]
108
+ rank = None
109
+ for i, r in enumerate(results, start=1):
110
+ if r["id"] == target_id:
111
+ rank = i
112
+ break
113
+ ranks.append(rank)
114
+ if rank is None:
115
+ misses.append({"qid": item["qid"], "question": item["question"]})
116
+
117
+ overall = compute_metrics(ranks)
118
+ by_difficulty: dict[str, dict] = {}
119
+ for tag in DIFFICULTY_TAGS:
120
+ sub_ranks = [r for item, r in zip(dataset, ranks)
121
+ if _difficulty_of(item) == tag]
122
+ if sub_ranks:
123
+ by_difficulty[tag] = compute_metrics(sub_ranks)
124
+ by_difficulty[tag]["count"] = len(sub_ranks)
125
+
126
+ return {
127
+ "count": len(dataset),
128
+ "top_k": top_k,
129
+ "mode": mode,
130
+ "recall_at_1": overall["recall_at_1"],
131
+ "recall_at_5": overall["recall_at_5"],
132
+ "mrr": overall["mrr"],
133
+ "latency_ms_avg": latency_total / max(1, len(dataset)),
134
+ "by_difficulty": by_difficulty,
135
+ "misses": misses,
136
+ }
kb/governance.py ADDED
@@ -0,0 +1,188 @@
1
+ """记忆治理 A3:访问频率衰减纯函数模块(N21b,TASK-0068)+ 语义去重(N22a,TASK-0069)。
2
+
3
+ 纯函数优先:输入标量输出分数,不依赖 Record/Store/配置,可直接单测。
4
+ 衰减公式(A3 spec §3.1):
5
+ decay_factor = exp(-λ * days_since_last_accessed) * (1 + γ * log₂(1 + access_count))
6
+ final_score = rrf_score * decay_factor
7
+
8
+ 语义去重(A3 spec §3.2,卡片简化为 409 拦截):
9
+ check_duplicate(content, store, embedder, threshold) → (existing_id|None, similarity)
10
+ 写入前向量检索 top1,相似度 ≥ 阈值视为重复,service 层抛 DuplicateError,api 层返回 409。
11
+
12
+ 默认 KB_DECAY_ENABLED=false / KB_DEDUP_ENABLED=false,零行为变化。
13
+ BM25 路径不受衰减影响(仅向量/RRF 排序受影响)。
14
+ """
15
+ import math
16
+ from datetime import datetime
17
+
18
+
19
+ class DuplicateError(Exception):
20
+ """语义去重命中异常(N22a/TASK-0069)。
21
+
22
+ service.add_memory 在 dedup_enabled 且 check_duplicate 命中时抛出;
23
+ api.create_memory 捕获后返回 409 + duplicate_of 字段。
24
+ """
25
+
26
+ def __init__(self, existing_id: str, similarity: float):
27
+ self.existing_id = existing_id
28
+ self.similarity = similarity
29
+ super().__init__(f"语义重复(相似度 {similarity:.3f}),已有记录 {existing_id}")
30
+
31
+
32
+ def compute_decay_factor(days_since_last_accessed: float,
33
+ access_count: int,
34
+ lambda_: float = 0.02,
35
+ gamma: float = 0.3) -> float:
36
+ """计算衰减因子(A3 spec §3.1)。
37
+
38
+ decay_factor = exp(-λ * days) * (1 + γ * log₂(1 + access_count))
39
+
40
+ 参数:
41
+ days_since_last_accessed: 距上次访问的天数(>=0,last_accessed 为空时用 created_at)
42
+ access_count: 累计访问次数(>=0,新记录=0)
43
+ lambda_: 衰减速率 λ(/天),默认 0.02(半衰期≈35天)
44
+ gamma: 高频访问加权系数 γ,默认 0.3(access_count=10→约2.0倍)
45
+
46
+ 返回:
47
+ 衰减因子(>0);高频访问记忆获得加权,长期未访问记忆降权。
48
+ """
49
+ if days_since_last_accessed < 0:
50
+ days_since_last_accessed = 0.0
51
+ if access_count < 0:
52
+ access_count = 0
53
+ recency = math.exp(-lambda_ * days_since_last_accessed)
54
+ frequency = 1.0 + gamma * math.log2(1.0 + access_count)
55
+ return recency * frequency
56
+
57
+
58
+ def apply_decay(rrf_score: float, decay_factor: float) -> float:
59
+ """对 RRF 融合分应用衰减因子(A3 spec §3.1)。
60
+
61
+ final_score = rrf_score * decay_factor
62
+
63
+ 参数:
64
+ rrf_score: RRF 融合分(>=0)
65
+ decay_factor: 衰减因子(compute_decay_factor 的返回值)
66
+
67
+ 返回:
68
+ 衰减后的最终排序分。
69
+ """
70
+ return rrf_score * decay_factor
71
+
72
+
73
+ def days_since(last_accessed: str, created_at: str,
74
+ now: datetime | None = None) -> float:
75
+ """计算距上次访问的天数;last_accessed 为空时用 created_at(A3 spec §3.1)。
76
+
77
+ 参数:
78
+ last_accessed: 上次访问时间(ISO 格式字符串,空串=""表示从未访问)
79
+ created_at: 记录创建时间(ISO 格式字符串,从未访问时用此替代)
80
+ now: 当前时间(None=datetime.now())
81
+
82
+ 返回:
83
+ 天数差(>=0,未来时间视为0)。解析失败时返回0.0(安全降级)。
84
+ """
85
+ if now is None:
86
+ now = datetime.now()
87
+ ref_str = last_accessed if last_accessed else created_at
88
+ if not ref_str:
89
+ return 0.0
90
+ try:
91
+ ref = datetime.fromisoformat(ref_str)
92
+ except (ValueError, TypeError):
93
+ return 0.0
94
+ delta = (now - ref).total_seconds() / 86400.0
95
+ return max(0.0, delta)
96
+
97
+
98
+ def freshness_boost(days_since_updated: float,
99
+ beta: float = 0.05,
100
+ alpha: float = 0.3) -> float:
101
+ """计算新鲜度加权因子(A3 spec §3.3)。
102
+
103
+ recency = exp(-β * days_since_updated)
104
+ boost = 1 + α * recency
105
+
106
+ 与衰减(§3.1)正交:衰减看 last_accessed(访问冷热),新鲜度看 updated_at
107
+ (内容新旧),两者独立可叠加(正交相乘)。
108
+
109
+ 参数:
110
+ days_since_updated: 距上次更新的天数(>=0,用 updated_at)
111
+ beta: 新鲜度衰减速率 β(/天),默认 0.05(半衰期≈14天)
112
+ alpha: 新鲜度加权上限系数 α,默认 0.3(boost 范围 [1, 1.3])
113
+
114
+ 返回:
115
+ 新鲜度加权因子(范围 [1, 1+α],新记忆接近 1+α,旧记忆接近 1)。
116
+ """
117
+ if days_since_updated < 0:
118
+ days_since_updated = 0.0
119
+ recency = math.exp(-beta * days_since_updated)
120
+ return 1.0 + alpha * recency
121
+
122
+
123
+ def compute_stats(records, now: datetime | None = None) -> dict:
124
+ """计算治理统计(A3 spec §4.2,TASK-0070):纯函数,输入记录迭代器输出统计 dict。
125
+
126
+ 返回:
127
+ total_count: 总记录数
128
+ avg_access_count: 平均 access_count(保留2位小数)
129
+ stale_90d_count: 超 90 天未命中数(last_accessed 为空时用 created_at)
130
+
131
+ access_count/last_accessed 用 getattr 兼容 TASK-0067 未合入(未合入时均为 0/"")。
132
+ """
133
+ if now is None:
134
+ now = datetime.now()
135
+ total = 0
136
+ access_sum = 0
137
+ stale_90d = 0
138
+ for rec in records:
139
+ total += 1
140
+ ac = getattr(rec, "access_count", 0) or 0
141
+ access_sum += ac
142
+ last_accessed = getattr(rec, "last_accessed", "") or ""
143
+ created_at = getattr(rec, "created_at", "") or ""
144
+ days = days_since(last_accessed, str(created_at), now)
145
+ if days > 90:
146
+ stale_90d += 1
147
+ avg_access = (access_sum / total) if total > 0 else 0.0
148
+ return {
149
+ "total_count": total,
150
+ "avg_access_count": round(avg_access, 2),
151
+ "stale_90d_count": stale_90d,
152
+ }
153
+
154
+
155
+ # ---- 语义去重(N22a/TASK-0069)----
156
+
157
+ def check_duplicate(content: str, store, embedder,
158
+ threshold: float = 0.92) -> tuple[str | None, float]:
159
+ """语义去重检查(A3 spec §3.2,top1 拦截)。
160
+
161
+ 对新内容做向量检索 top1,若最高余弦相似度 ≥ threshold 则视为重复,
162
+ 返回 (existing_id, similarity);否则返回 (None, 0.0)。
163
+
164
+ 参数:
165
+ content: 待写入的新内容文本
166
+ store: ChromaStore 实例(有 query(embedding, top_k) 方法)
167
+ embedder: Embedder 实例(有 embed_query(text) 方法)
168
+ threshold: 余弦相似度阈值,默认 0.92(≥此值视为重复)
169
+
170
+ 返回:
171
+ (existing_id, similarity) 命中重复;(None, 0.0) 无重复。
172
+
173
+ 异常降级:嵌入或检索失败时返回 (None, 0.0)(不阻塞写入,记 WARNING 由调用方处理)。
174
+ """
175
+ import logging
176
+ logger = logging.getLogger("kb.governance")
177
+ try:
178
+ vec = embedder.embed_query(content)
179
+ hits = store.query(vec, top_k=1)
180
+ if not hits:
181
+ return (None, 0.0)
182
+ _rec, score = hits[0]
183
+ if score >= threshold:
184
+ return (_rec.id, float(score))
185
+ return (None, 0.0)
186
+ except Exception as e:
187
+ logger.warning("语义去重检查失败,降级为不拦截: %s", e)
188
+ return (None, 0.0)