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/__init__.py +2 -0
- kb/__main__.py +4 -0
- kb/api.py +975 -0
- kb/audit.py +263 -0
- kb/bm25.py +144 -0
- kb/cli.py +788 -0
- kb/config.py +136 -0
- kb/consolidation.py +228 -0
- kb/discovery.py +264 -0
- kb/embedder.py +134 -0
- kb/eval.py +136 -0
- kb/governance.py +188 -0
- kb/i18n.py +257 -0
- kb/ingest.py +122 -0
- kb/llm.py +155 -0
- kb/logging_setup.py +75 -0
- kb/mcp.py +300 -0
- kb/models.py +102 -0
- kb/monitor.py +421 -0
- kb/reranker.py +61 -0
- kb/retriever.py +281 -0
- kb/scopes.py +197 -0
- kb/service.py +947 -0
- kb/sparse.py +238 -0
- kb/storage.py +204 -0
- kb/watcher.py +171 -0
- kb_memory-2.1.0.dist-info/METADATA +415 -0
- kb_memory-2.1.0.dist-info/RECORD +32 -0
- kb_memory-2.1.0.dist-info/WHEEL +5 -0
- kb_memory-2.1.0.dist-info/entry_points.txt +2 -0
- kb_memory-2.1.0.dist-info/licenses/LICENSE +201 -0
- kb_memory-2.1.0.dist-info/top_level.txt +1 -0
kb/audit.py
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
"""记忆治理操作审计日志(N23b/TASK-0073,A3 spec §5)。
|
|
2
|
+
|
|
3
|
+
结构化审计日志闭环:每次去重拦截/衰减降权/新鲜度加权等治理操作写一条
|
|
4
|
+
JSON 行日志到 KB_LOG_DIR/governance-audit.log,按天轮转(TimedRotatingFileHandler)。
|
|
5
|
+
|
|
6
|
+
设计原则:
|
|
7
|
+
- 独立 logger "kb.audit",propagate=False,不混入 kb.log;
|
|
8
|
+
- 纯 JSON 行格式:每行一个对象(timestamp/operation/record_id/namespace/detail);
|
|
9
|
+
- 不阻塞主流程:审计失败记 WARNING 到 "kb" logger,不抛异常;
|
|
10
|
+
- 懒初始化:第一次调用 log_governance_event 时配置 handler,避免导入时创建文件。
|
|
11
|
+
"""
|
|
12
|
+
import json
|
|
13
|
+
import logging
|
|
14
|
+
import re
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from logging.handlers import TimedRotatingFileHandler
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
# 审计 logger 单例(懒初始化)
|
|
20
|
+
_audit_logger: logging.Logger | None = None
|
|
21
|
+
_audit_logger_configured = False
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _get_audit_logger(log_dir: Path | None = None) -> logging.Logger:
|
|
25
|
+
"""获取或初始化审计 logger(懒初始化,按天轮转)。
|
|
26
|
+
|
|
27
|
+
log_dir 缺省时读全局配置单例;目录不存在自动创建。
|
|
28
|
+
"""
|
|
29
|
+
global _audit_logger, _audit_logger_configured
|
|
30
|
+
if _audit_logger_configured and _audit_logger is not None:
|
|
31
|
+
return _audit_logger
|
|
32
|
+
|
|
33
|
+
if log_dir is None:
|
|
34
|
+
from kb.config import get_settings
|
|
35
|
+
log_dir = Path(get_settings().log_dir)
|
|
36
|
+
log_dir = Path(log_dir)
|
|
37
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
|
|
39
|
+
logger = logging.getLogger("kb.audit")
|
|
40
|
+
logger.setLevel(logging.INFO)
|
|
41
|
+
logger.propagate = False # 不向上传播到 "kb",避免混入 kb.log
|
|
42
|
+
|
|
43
|
+
# 关闭并摘除旧 handler(测试/重复装配场景)
|
|
44
|
+
for h in list(logger.handlers):
|
|
45
|
+
logger.removeHandler(h)
|
|
46
|
+
h.close()
|
|
47
|
+
|
|
48
|
+
# 按天轮转:每天午夜切分,保留 30 天备份
|
|
49
|
+
handler = TimedRotatingFileHandler(
|
|
50
|
+
log_dir / "governance-audit.log",
|
|
51
|
+
when="midnight",
|
|
52
|
+
backupCount=30,
|
|
53
|
+
encoding="utf-8-sig", # 带 BOM:Windows 记事本/旧 GBK 工具可直接打开查看中文
|
|
54
|
+
)
|
|
55
|
+
# 纯 JSON 行格式:直接输出 message(已是 JSON 字符串)
|
|
56
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
57
|
+
logger.addHandler(handler)
|
|
58
|
+
|
|
59
|
+
_audit_logger = logger
|
|
60
|
+
_audit_logger_configured = True
|
|
61
|
+
return logger
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def reset_audit_logger() -> None:
|
|
65
|
+
"""重置审计 logger 单例(测试用,清除已配置状态与 handler)。"""
|
|
66
|
+
global _audit_logger, _audit_logger_configured
|
|
67
|
+
if _audit_logger is not None:
|
|
68
|
+
for h in list(_audit_logger.handlers):
|
|
69
|
+
_audit_logger.removeHandler(h)
|
|
70
|
+
h.close()
|
|
71
|
+
_audit_logger = None
|
|
72
|
+
_audit_logger_configured = False
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def log_governance_event(operation: str, record_id: str,
|
|
76
|
+
detail: dict | None = None,
|
|
77
|
+
namespace: str = "default",
|
|
78
|
+
log_dir: Path | None = None) -> None:
|
|
79
|
+
"""记录一条治理操作审计日志(JSON 行)。
|
|
80
|
+
|
|
81
|
+
参数:
|
|
82
|
+
operation: 操作类型(如 dedup_blocked / decay_applied / freshness_applied)
|
|
83
|
+
record_id: 目标记录 ID
|
|
84
|
+
detail: 详情 dict(如 {"similarity": 0.92, "duplicate_of": "xxx"})
|
|
85
|
+
namespace: 命名空间,默认 "default"
|
|
86
|
+
log_dir: 日志目录(测试用,缺省读全局配置)
|
|
87
|
+
|
|
88
|
+
不阻塞主流程:任何异常(磁盘满/权限等)记 WARNING 到 "kb" logger,不抛出。
|
|
89
|
+
"""
|
|
90
|
+
try:
|
|
91
|
+
event = {
|
|
92
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
93
|
+
"operation": operation,
|
|
94
|
+
"record_id": record_id,
|
|
95
|
+
"namespace": namespace,
|
|
96
|
+
"detail": detail or {},
|
|
97
|
+
}
|
|
98
|
+
line = json.dumps(event, ensure_ascii=False)
|
|
99
|
+
logger = _get_audit_logger(log_dir)
|
|
100
|
+
logger.info(line)
|
|
101
|
+
except Exception as exc:
|
|
102
|
+
# 审计失败不阻塞主流程,记 WARNING 到 kb logger
|
|
103
|
+
kb_logger = logging.getLogger("kb")
|
|
104
|
+
kb_logger.warning("审计日志写入失败 operation=%s record_id=%s error=%s",
|
|
105
|
+
operation, record_id, exc)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
# ---- Agent 存取审计(A 节点 spec:2026-08-29)----
|
|
109
|
+
# 按 Agent 分类:在 log_dir/agent-audit/ 下,每个 (client, project, agent_id) 一个独立文件
|
|
110
|
+
# <客户端名>__<项目名>_<任务名>.log(如 TraeWork__kb_agent_TASK-0076.log / Claude Code__worker-1.log),
|
|
111
|
+
# 不再把所有 Agent 写进一个 access-audit.log(人读混乱)。JSON 行,按天轮转 30 天。
|
|
112
|
+
# 身份不重复落行内:client/project/agent 由文件名承载(更新任务名=重命名文件即可),
|
|
113
|
+
# 查询侧从文件名解析补回。敏感红线:content/query 只记前 _ACCESS_SNIPPET 字符摘要,不落全文。
|
|
114
|
+
_ACCESS_SNIPPET = 50
|
|
115
|
+
_ACCESS_DIR = "agent-audit"
|
|
116
|
+
# 文件名非法字符清理白名单:仅保留字母数字、中文、_、-、·、.、空格(其余替换为 _)
|
|
117
|
+
_SAFE_RE = re.compile(r"[^\w\u4e00-\u9fff.\-· ]")
|
|
118
|
+
# 两段分隔符:client__project.log(project 可空→default)
|
|
119
|
+
_SEP = "__"
|
|
120
|
+
_access_loggers: dict[tuple[str, str], logging.Logger] = {}
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _clean_name(s: str | None) -> str:
|
|
124
|
+
"""清理命名段:去掉路径非法字符、折叠连续下划线(保证 `__` 分隔可解析)。"""
|
|
125
|
+
s = _SAFE_RE.sub("_", (s or "").strip())
|
|
126
|
+
while "__" in s:
|
|
127
|
+
s = s.replace("__", "_")
|
|
128
|
+
return s.strip("_") or "unknown"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _agent_file_name(client: str, project: str) -> str:
|
|
132
|
+
"""生成按 (client, project) 分类的审计文件名。
|
|
133
|
+
|
|
134
|
+
v2(2026-08-30):<客户端>__<项目>.log;project 为空 → <客户端>__default.log。
|
|
135
|
+
身份(client/project)由文件名承载,行内不重复记录——项目更名=改连接声明的 project,
|
|
136
|
+
重命名对应文件即可。清理路径非法字符→_、折叠连续下划线,避免跨目录注入。
|
|
137
|
+
English: Build the per-(client, project) audit file name: <client>__<project>.log, or
|
|
138
|
+
<client>__default.log when project is empty. Identity lives in the file name only;
|
|
139
|
+
renaming a project = renaming the file. Sanitizes path-illegal characters to prevent
|
|
140
|
+
directory injection."""
|
|
141
|
+
client_c = _clean_name(client)
|
|
142
|
+
if not client_c or client_c == "unknown":
|
|
143
|
+
client_c = "default"
|
|
144
|
+
project_c = _clean_name(project) if project else "default"
|
|
145
|
+
if not project_c or project_c == "unknown":
|
|
146
|
+
project_c = "default"
|
|
147
|
+
return f"{client_c}{_SEP}{project_c}.log"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def parse_agent_file_name(filename: str) -> dict:
|
|
151
|
+
"""从审计文件名解析 (client, project);轮转后缀(.log.YYYY-MM-DD)自动剥离。
|
|
152
|
+
|
|
153
|
+
v2 文件名规则:<client>__<project>.log(两段式)。project 段 "default" 表示默认桶。
|
|
154
|
+
兼容旧三段式 <client>__<project>__<agent>.log 与两段式 <client>__<agent>.log
|
|
155
|
+
(旧数据零迁移;解析只返回 client/project,agent 保留供旧查询展示)。
|
|
156
|
+
English: Parse (client, project) from an audit file name; rotation suffixes are stripped.
|
|
157
|
+
v2 naming: <client>__<project>.log (two segments); "default" project = default bucket.
|
|
158
|
+
Old three-segment and two-segment agent-based names are still parsed for zero-migration reads."""
|
|
159
|
+
name = Path(filename).name
|
|
160
|
+
for suffix in (".log",):
|
|
161
|
+
if name.endswith(suffix):
|
|
162
|
+
base = name[: -len(suffix)]
|
|
163
|
+
break
|
|
164
|
+
else:
|
|
165
|
+
base = name
|
|
166
|
+
# 去掉可能的按天轮转后缀 .YYYY-MM-DD
|
|
167
|
+
parts = base.split(".")
|
|
168
|
+
stem = parts[0] if parts else base
|
|
169
|
+
segs = stem.split(_SEP)
|
|
170
|
+
client = segs[0] if len(segs) > 0 else "unknown"
|
|
171
|
+
project = segs[1] if len(segs) > 1 else "default"
|
|
172
|
+
agent = segs[-1] if len(segs) > 2 else ""
|
|
173
|
+
return {"client": client, "project": project, "agent": agent}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _get_access_logger(client: str, project: str,
|
|
177
|
+
log_dir: Path | None = None) -> logging.Logger:
|
|
178
|
+
"""按 (client, project) 取独立存取审计 logger(懒建,按天轮转 30 天)。
|
|
179
|
+
|
|
180
|
+
English: Get the per-(client, project) access-audit logger (lazy, daily rotation, 30 backups)."""
|
|
181
|
+
key = (client or "default", project or "")
|
|
182
|
+
if key in _access_loggers:
|
|
183
|
+
return _access_loggers[key]
|
|
184
|
+
if log_dir is None:
|
|
185
|
+
from kb.config import get_settings
|
|
186
|
+
log_dir = Path(get_settings().log_dir)
|
|
187
|
+
log_dir = Path(log_dir)
|
|
188
|
+
agent_dir = log_dir / _ACCESS_DIR
|
|
189
|
+
agent_dir.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
|
|
191
|
+
logger = logging.getLogger(f"kb.audit.access.{key[0]}__{key[1] or 'default'}")
|
|
192
|
+
logger.setLevel(logging.INFO)
|
|
193
|
+
logger.propagate = False # 不向上传播到 "kb",避免混入 kb.log
|
|
194
|
+
for h in list(logger.handlers):
|
|
195
|
+
logger.removeHandler(h)
|
|
196
|
+
h.close()
|
|
197
|
+
handler = TimedRotatingFileHandler(
|
|
198
|
+
agent_dir / _agent_file_name(client, project), when="midnight",
|
|
199
|
+
backupCount=30, encoding="utf-8-sig") # 带 BOM:Windows 记事本/旧 GBK 工具可直接查看中文
|
|
200
|
+
handler.setFormatter(logging.Formatter("%(message)s"))
|
|
201
|
+
logger.addHandler(handler)
|
|
202
|
+
_access_loggers[key] = logger
|
|
203
|
+
return logger
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def reset_access_logger() -> None:
|
|
207
|
+
"""重置存取审计 logger 缓存(测试用,清除全部已建 handler)。"""
|
|
208
|
+
global _access_loggers
|
|
209
|
+
for logger in _access_loggers.values():
|
|
210
|
+
for h in list(logger.handlers):
|
|
211
|
+
logger.removeHandler(h)
|
|
212
|
+
h.close()
|
|
213
|
+
_access_loggers = {}
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _snippet(text: str | None) -> str:
|
|
217
|
+
"""内容/query 摘要:空→"",其余截取前 50 字符(敏感红线,不落全文)。"""
|
|
218
|
+
if not text:
|
|
219
|
+
return ""
|
|
220
|
+
return text[: _ACCESS_SNIPPET]
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def log_access_event(agent_id: str, action: str, record_id: str | None = None,
|
|
224
|
+
type: str | None = None, content: str | None = None,
|
|
225
|
+
query: str | None = None, hits: int | None = None,
|
|
226
|
+
namespace: str = "default", source: str | None = None,
|
|
227
|
+
client: str | None = None, project: str | None = None,
|
|
228
|
+
log_dir: Path | None = None) -> None:
|
|
229
|
+
"""记录一条 Agent 存取审计(JSON 行):谁在何时从哪个客户端做了什么。
|
|
230
|
+
|
|
231
|
+
按 (client, project, agent_id) 分文件落盘到
|
|
232
|
+
log_dir/agent-audit/<客户端>__<项目>_<任务名>.log;
|
|
233
|
+
client/project/agent 由文件名承载(不重复落行内),供查询侧解析补回。
|
|
234
|
+
action: write / search / read / update / delete / ask / ingest。
|
|
235
|
+
content/query 只记前 50 字符摘要(敏感红线)。审计失败不阻塞主流程。
|
|
236
|
+
English: Record one Agent access-audit line, bucketed into a per-agent file
|
|
237
|
+
<client>__<project>_<agent>.log; identity lives in the file name only.
|
|
238
|
+
"""
|
|
239
|
+
if not agent_id:
|
|
240
|
+
agent_id = "default"
|
|
241
|
+
try:
|
|
242
|
+
event = {
|
|
243
|
+
"timestamp": datetime.now(timezone.utc).isoformat(),
|
|
244
|
+
"action": action,
|
|
245
|
+
"type": type,
|
|
246
|
+
"record_id": record_id,
|
|
247
|
+
"namespace": namespace,
|
|
248
|
+
}
|
|
249
|
+
if source is not None:
|
|
250
|
+
event["source"] = source
|
|
251
|
+
if content is not None:
|
|
252
|
+
event["content"] = _snippet(content)
|
|
253
|
+
if query is not None:
|
|
254
|
+
event["query"] = _snippet(query)
|
|
255
|
+
if hits is not None:
|
|
256
|
+
event["hits"] = hits
|
|
257
|
+
line = json.dumps(event, ensure_ascii=False)
|
|
258
|
+
_get_access_logger(client or "default", project or "", log_dir).info(line)
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
# 审计失败不阻塞主流程,记 WARNING 到 kb logger
|
|
261
|
+
kb_logger = logging.getLogger("kb")
|
|
262
|
+
kb_logger.warning("存取审计写入失败 agent=%s action=%s error=%s",
|
|
263
|
+
agent_id, action, exc)
|
kb/bm25.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""BM25 索引:jieba 分词 + rank_bm25,内存维护,变更时同步重建。"""
|
|
2
|
+
import jieba
|
|
3
|
+
from rank_bm25 import BM25Okapi
|
|
4
|
+
|
|
5
|
+
from kb.models import Record
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def tokenize(text: str) -> list[str]:
|
|
9
|
+
"""jieba 细粒度分词(cut_for_search,含子词,更适合关键词检索)
|
|
10
|
+
+ 小写化 + 去空白/单字符标点。"""
|
|
11
|
+
toks = []
|
|
12
|
+
for t in jieba.cut_for_search(text):
|
|
13
|
+
t = t.strip().lower()
|
|
14
|
+
if t and not (len(t) == 1 and (t.isascii() and (not t.isalnum()))):
|
|
15
|
+
toks.append(t)
|
|
16
|
+
return toks
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BM25Index:
|
|
20
|
+
"""内部维护 {record_id: tokens};每次变更同步重建 BM25Okapi(个人级规模毫秒级)。
|
|
21
|
+
|
|
22
|
+
另维护轻量元信息 {record_id: (client, project, type)}(改进项 3:检索隔离
|
|
23
|
+
下推到 BM25 排序前过滤用);启动 load_corpus 成功后由 service.set_meta 回填。
|
|
24
|
+
English: Internally maintains {record_id: tokens}; rebuilds BM25Okapi on every change
|
|
25
|
+
(millisecond-level at personal scale). Also keeps lightweight per-record metadata
|
|
26
|
+
{record_id: (client, project, type)} (improvement #3: isolation pushdown into the
|
|
27
|
+
BM25 pre-ranking filter); after a successful load_corpus the service backfills it
|
|
28
|
+
via set_meta."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._docs: dict[str, list[str]] = {}
|
|
32
|
+
self._meta: dict[str, tuple[str, str, str]] = {}
|
|
33
|
+
self._bm25 = None
|
|
34
|
+
|
|
35
|
+
def _rebuild_bm25(self) -> None:
|
|
36
|
+
"""依据当前语料重建 BM25Okapi;空语料置 None。"""
|
|
37
|
+
if not self._docs:
|
|
38
|
+
self._bm25 = None
|
|
39
|
+
return
|
|
40
|
+
self._bm25 = BM25Okapi(list(self._docs.values()))
|
|
41
|
+
|
|
42
|
+
def rebuild(self, records) -> None:
|
|
43
|
+
"""用一组记录全量重建索引(启动时传 store.iter_all())。"""
|
|
44
|
+
self._docs = {r.id: tokenize(r.content) for r in records}
|
|
45
|
+
self._meta = {r.id: (r.client, r.project, r.type.value) for r in records}
|
|
46
|
+
self._rebuild_bm25()
|
|
47
|
+
|
|
48
|
+
def set_meta(self, records) -> None:
|
|
49
|
+
"""回填轻量元信息(load_corpus 成功路径;不触发重建)。"""
|
|
50
|
+
self._meta = {r.id: (r.client, r.project, r.type.value) for r in records}
|
|
51
|
+
|
|
52
|
+
def meta_of(self, record_id: str) -> tuple[str, str, str] | None:
|
|
53
|
+
"""返回该 id 的 (client, project, type) 元信息;未知 id 返回 None(防御不拦截)。
|
|
54
|
+
English: Return (client, project, type) for an id; None when unknown (defensive)."""
|
|
55
|
+
return self._meta.get(record_id)
|
|
56
|
+
|
|
57
|
+
def add(self, record: Record) -> None:
|
|
58
|
+
"""新增一条记录并重建索引。"""
|
|
59
|
+
self._docs[record.id] = tokenize(record.content)
|
|
60
|
+
self._meta[record.id] = (record.client, record.project, record.type.value)
|
|
61
|
+
self._rebuild_bm25()
|
|
62
|
+
|
|
63
|
+
def add_many(self, records) -> None:
|
|
64
|
+
"""批量新增(改进项 1B):全部并入语料后只重建一次,避免 N 条 N 次全量重建。
|
|
65
|
+
|
|
66
|
+
用于 add_document / add_webpage 的 chunk 批量入库(此前循环 add 对每个
|
|
67
|
+
chunk 重建一次,N 个 chunk = N 次 O(语料) 重建)。
|
|
68
|
+
English: Batch add (improvement #1B): merge all records into the corpus then
|
|
69
|
+
rebuild exactly once, avoiding one full rebuild per record (previously the
|
|
70
|
+
chunk-ingest loop rebuilt per chunk, i.e. N rebuilds for N chunks)."""
|
|
71
|
+
for r in records:
|
|
72
|
+
self._docs[r.id] = tokenize(r.content)
|
|
73
|
+
self._meta[r.id] = (r.client, r.project, r.type.value)
|
|
74
|
+
self._rebuild_bm25()
|
|
75
|
+
|
|
76
|
+
def remove(self, record_id: str) -> None:
|
|
77
|
+
"""删除一条记录并重建索引。"""
|
|
78
|
+
self._docs.pop(record_id, None)
|
|
79
|
+
self._meta.pop(record_id, None)
|
|
80
|
+
self._rebuild_bm25()
|
|
81
|
+
|
|
82
|
+
def remove_many(self, record_ids) -> None:
|
|
83
|
+
"""批量删除(改进项 1B):全部移出后只重建一次(delete_document 用)。"""
|
|
84
|
+
for rid in record_ids:
|
|
85
|
+
self._docs.pop(rid, None)
|
|
86
|
+
self._meta.pop(rid, None)
|
|
87
|
+
self._rebuild_bm25()
|
|
88
|
+
|
|
89
|
+
def search(self, query: str, top_n: int = 10,
|
|
90
|
+
filter_fn=None) -> list[tuple[str, float]]:
|
|
91
|
+
"""返回 (record_id, bm25分数) 降序;空索引返回 []。
|
|
92
|
+
|
|
93
|
+
filter_fn(record_id)->bool(可选)在排序前过滤(改进项 3:检索隔离下推——
|
|
94
|
+
BM25 分数由全语料 DF 决定,过滤只剔除不可见记录,不改变剩余记录分数)。
|
|
95
|
+
English: Return (record_id, bm25 score) descending; an empty index returns [].
|
|
96
|
+
Optional filter_fn(record_id)->bool filters before ranking (improvement #3:
|
|
97
|
+
isolation pushdown — BM25 scores depend on corpus-wide DF, so filtering only
|
|
98
|
+
drops invisible records without altering the scores of the rest)."""
|
|
99
|
+
if self._bm25 is None:
|
|
100
|
+
return []
|
|
101
|
+
query_tokens = tokenize(query)
|
|
102
|
+
if not query_tokens:
|
|
103
|
+
return []
|
|
104
|
+
scores = self._bm25.get_scores(query_tokens)
|
|
105
|
+
if filter_fn is None:
|
|
106
|
+
ranked = sorted(zip(self._docs.keys(), scores),
|
|
107
|
+
key=lambda x: x[1], reverse=True)
|
|
108
|
+
else:
|
|
109
|
+
ranked = sorted(
|
|
110
|
+
((rid, s) for rid, s in zip(self._docs.keys(), scores)
|
|
111
|
+
if filter_fn(rid)),
|
|
112
|
+
key=lambda x: x[1], reverse=True)
|
|
113
|
+
return [(rid, float(score)) for rid, score in ranked[:top_n]]
|
|
114
|
+
|
|
115
|
+
# ---- 语料持久化(N27,A3.5 spec §3.4)----
|
|
116
|
+
|
|
117
|
+
def save_corpus(self, path) -> None:
|
|
118
|
+
"""{record_id: tokens} 序列化 JSON 落盘(启动免全量 jieba 分词)。"""
|
|
119
|
+
import json
|
|
120
|
+
from pathlib import Path
|
|
121
|
+
path = Path(path)
|
|
122
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
123
|
+
path.write_text(json.dumps(self._docs, ensure_ascii=False),
|
|
124
|
+
encoding="utf-8")
|
|
125
|
+
|
|
126
|
+
def load_corpus(self, path, valid_ids) -> bool:
|
|
127
|
+
"""加载持久化语料;id 集合与库一致才生效,否则 False(调用方全量重建)。
|
|
128
|
+
|
|
129
|
+
文件缺失 / JSON 损坏 / id 集合漂移均返回 False(安全降级为重建)。
|
|
130
|
+
"""
|
|
131
|
+
import json
|
|
132
|
+
from pathlib import Path
|
|
133
|
+
path = Path(path)
|
|
134
|
+
if not path.exists():
|
|
135
|
+
return False
|
|
136
|
+
try:
|
|
137
|
+
docs = json.loads(path.read_text(encoding="utf-8"))
|
|
138
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
139
|
+
return False
|
|
140
|
+
if not isinstance(docs, dict) or set(docs.keys()) != set(valid_ids):
|
|
141
|
+
return False
|
|
142
|
+
self._docs = {rid: list(toks) for rid, toks in docs.items()}
|
|
143
|
+
self._rebuild_bm25()
|
|
144
|
+
return True
|