@kira905/dsh-memory-toolkit 0.1.3 → 0.2.0
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.
- package/CHANGELOG.md +92 -0
- package/README.en.md +225 -2
- package/README.md +180 -2
- package/lib/deploy-guard.mjs +463 -275
- package/lib/guard-http.mjs +59 -62
- package/lib/pitfall-form-map.mjs +94 -84
- package/lib/pitfall-score.mjs +262 -185
- package/lib/session-file-name.mjs +116 -100
- package/lib/zstd-frames.mjs +88 -67
- package/memory-db-tools/README.md +29 -1
- package/memory-db-tools/audit-analyze.mjs +1 -1
- package/memory-db-tools/audit-export.mjs +60 -16
- package/memory-db-tools/audit-struct-probe.mjs +29 -8
- package/memory-db-tools/lib/known-devices.mjs +137 -0
- package/memory-engine/README.md +58 -4
- package/memory-engine/bench/bench.mjs +1 -1
- package/memory-engine/cli/band-sentinel.mjs +164 -0
- package/memory-engine/cli/distill-rollback.mjs +112 -44
- package/memory-engine/cli/distill-sessions.mjs +237 -0
- package/memory-engine/cli/distill.mjs +196 -95
- package/memory-engine/cli/doc-index-rebuild.mjs +142 -108
- package/memory-engine/cli/doc-index.mjs +47 -38
- package/memory-engine/cli/domain-tags.mjs +246 -162
- package/memory-engine/cli/layer-migrate.mjs +176 -0
- package/memory-engine/cli/layer-move.mjs +189 -0
- package/memory-engine/cli/pitfall-hook.mjs +136 -70
- package/memory-engine/cli/recall.mjs +243 -110
- package/memory-engine/cli/selfcheck.mjs +93 -44
- package/memory-engine/cli/serve-start.mjs +221 -140
- package/memory-engine/cli/sweep.mjs +83 -44
- package/memory-engine/cli/sync.mjs +93 -57
- package/memory-engine/config/audit-devices.json +6 -0
- package/memory-engine/config/bands.json +7 -0
- package/memory-engine/config/layers.json +7 -0
- package/memory-engine/dict/LICENSE-/350/257/215/345/205/270.md +53 -0
- package/memory-engine/dict/lexicon-config.json +11 -0
- package/memory-engine/dict//350/257/215/345/205/270.txt +109750 -0
- package/memory-engine/dict//350/257/215/351/242/221.txt +20000 -0
- package/memory-engine/lib/band-sentinel.mjs +417 -0
- package/memory-engine/lib/cli-strict.mjs +128 -0
- package/memory-engine/lib/cli-util.mjs +147 -125
- package/memory-engine/lib/config.mjs +83 -96
- package/memory-engine/lib/dirty-ledger.mjs +127 -94
- package/memory-engine/lib/distill-rollback.mjs +173 -133
- package/memory-engine/lib/distill.mjs +878 -585
- package/memory-engine/lib/doc-index-store.mjs +208 -235
- package/memory-engine/lib/doc-index.mjs +351 -350
- package/memory-engine/lib/doc-scan.mjs +178 -156
- package/memory-engine/lib/doc-summarize.mjs +220 -205
- package/memory-engine/lib/domain-tags.mjs +358 -277
- package/memory-engine/lib/embedder.mjs +175 -194
- package/memory-engine/lib/hashes.mjs +22 -7
- package/memory-engine/lib/layer-migrate.mjs +330 -0
- package/memory-engine/lib/layer-move.mjs +307 -0
- package/memory-engine/lib/layers.mjs +191 -0
- package/memory-engine/lib/lexicon-recall.mjs +473 -0
- package/memory-engine/lib/memory-source.mjs +78 -80
- package/memory-engine/lib/pitfall-source.mjs +115 -114
- package/memory-engine/lib/policy-source.mjs +279 -261
- package/memory-engine/lib/recall-log.mjs +285 -0
- package/memory-engine/lib/recall.mjs +565 -240
- package/memory-engine/lib/selfcheck.mjs +142 -106
- package/memory-engine/lib/session-distill.mjs +603 -0
- package/memory-engine/lib/session-source.mjs +421 -0
- package/memory-engine/lib/sweep.mjs +107 -130
- package/memory-engine/lib/sync.mjs +110 -140
- package/memory-engine/lib/tick-hook.mjs +123 -148
- package/memory-engine/lib/vectors-store.mjs +213 -188
- package/memory-engine/test/12-layering.test.mjs +539 -0
- package/memory-engine/test/13-session-distill.test.mjs +616 -0
- package/memory-engine/tools/bridge-smoke.mjs +94 -55
- package/memory-engine/tools/fetch-model.mjs +167 -87
- package/memory-engine/tools/smoke-recall.mjs +94 -38
- package/memory-engine/tools/verify-recall-chain.mjs +413 -0
- package/package.json +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// 分层口径的唯一出处:层名集合 / 缺省层 / 列名 / 行指纹 / 配置文件位置。
|
|
2
|
+
//
|
|
3
|
+
// 三条硬口径(本文件就是它们的实现):
|
|
4
|
+
// ① 层名**不写死**:缺省的 core / recall / archival 只是随仓发布的配置里的值,
|
|
5
|
+
// 换一套层名只改 config/layers.json(或环境变量),代码里没有任何一处 switch 层名;
|
|
6
|
+
// ② 缺省层只决定「存量行回填成什么」,**绝不等于删除或隐藏**:回填不改正文、不改行数;
|
|
7
|
+
// ③ 行指纹是跨命令的唯一比对口径(迁移前后 / 搬出搬回 / 快照对拍都用它),
|
|
8
|
+
// 算法 = 定序数组的 JSON + sha256,字段顺序与分隔方式写死,换版本不许改。
|
|
9
|
+
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import crypto from 'node:crypto';
|
|
13
|
+
import { fileURLToPath } from 'node:url';
|
|
14
|
+
import { resolveConfig } from './config.mjs';
|
|
15
|
+
|
|
16
|
+
/** 层名白名单:只允许字母数字下划线连字符,1~32 字符。给 SQL 与表名留安全边界。 */
|
|
17
|
+
export const LAYER_NAME = /^[A-Za-z0-9][A-Za-z0-9_-]{0,31}$/;
|
|
18
|
+
|
|
19
|
+
/** 内置兜底(配置缺失时用它,保证「装完就能跑」)。 */
|
|
20
|
+
export const BUILTIN_LAYERS = Object.freeze({
|
|
21
|
+
column: 'layer',
|
|
22
|
+
layers: Object.freeze(['core', 'recall', 'archival']),
|
|
23
|
+
defaultLayer: 'recall',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const ENV = Object.freeze({
|
|
27
|
+
layers: 'MEMORY_ENGINE_LAYERS',
|
|
28
|
+
defaultLayer: 'MEMORY_ENGINE_DEFAULT_LAYER',
|
|
29
|
+
column: 'MEMORY_ENGINE_LAYER_COLUMN',
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
/** `memory-engine/config/` 的绝对位置(随包发布)。 */
|
|
33
|
+
export function configDir() {
|
|
34
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'config');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function defaultLayerConfigPath() {
|
|
38
|
+
return path.join(configDir(), 'layers.json');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function defaultBandsConfigPath() {
|
|
42
|
+
return path.join(configDir(), 'bands.json');
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 读一份 JSON 配置文件:不存在就返回 ok:false(不是异常),解不开才算错。 */
|
|
46
|
+
export function readJsonFile(file) {
|
|
47
|
+
try {
|
|
48
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
49
|
+
return { ok: true, value: JSON.parse(text), file };
|
|
50
|
+
} catch (e) {
|
|
51
|
+
if (e && e.code === 'ENOENT') return { ok: false, reason: 'missing', file };
|
|
52
|
+
return { ok: false, reason: `解不开:${e && e.message}`, file };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 解析逗号/空白分隔的层名串(环境变量与命令行共用)。 */
|
|
57
|
+
export function splitNames(text) {
|
|
58
|
+
return String(text ?? '')
|
|
59
|
+
.split(/[\s,,]+/)
|
|
60
|
+
.map((s) => s.trim())
|
|
61
|
+
.filter(Boolean);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertNames(layers, where) {
|
|
65
|
+
if (!Array.isArray(layers) || layers.length === 0) {
|
|
66
|
+
throw new Error(`${where} 的层名集合为空 —— 至少要有一个层名`);
|
|
67
|
+
}
|
|
68
|
+
const seen = new Set();
|
|
69
|
+
for (const name of layers) {
|
|
70
|
+
if (!LAYER_NAME.test(String(name))) {
|
|
71
|
+
throw new Error(`${where} 的层名不合法:${JSON.stringify(name)}(只允许字母数字下划线连字符,1~32 字符)`);
|
|
72
|
+
}
|
|
73
|
+
const key = String(name).toLowerCase();
|
|
74
|
+
if (seen.has(key)) throw new Error(`${where} 的层名重复:${name}(大小写不敏感地重了)`);
|
|
75
|
+
seen.add(key);
|
|
76
|
+
}
|
|
77
|
+
return layers.map(String);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* 折出一份层配置。优先级:显式入参 > 环境变量 > 配置文件 > 内置兜底。
|
|
82
|
+
* @returns {{column:string, layers:string[], defaultLayer:string, source:object, warnings:string[]}}
|
|
83
|
+
*/
|
|
84
|
+
export function loadLayerConfig({ file = null, layers = null, defaultLayer = null, column = null, env = process.env } = {}) {
|
|
85
|
+
const warnings = [];
|
|
86
|
+
const cfgPath = file ? path.resolve(file) : defaultLayerConfigPath();
|
|
87
|
+
const disk = readJsonFile(cfgPath);
|
|
88
|
+
if (!disk.ok && disk.reason !== 'missing') throw new Error(`层配置读不了(${cfgPath}):${disk.reason}`);
|
|
89
|
+
if (!disk.ok) warnings.push(`层配置不在(${cfgPath})⇒ 用内置层名 ${BUILTIN_LAYERS.layers.join('/')}`);
|
|
90
|
+
|
|
91
|
+
const fromDisk = disk.ok && disk.value && typeof disk.value === 'object' ? disk.value : {};
|
|
92
|
+
|
|
93
|
+
const envLayers = splitNames(env[ENV.layers]);
|
|
94
|
+
const explicitLayers = Array.isArray(layers) ? layers : splitNames(layers);
|
|
95
|
+
|
|
96
|
+
const picked = explicitLayers.length ? explicitLayers : envLayers.length ? envLayers : fromDisk.layers;
|
|
97
|
+
const names = assertNames(picked && picked.length ? picked : [...BUILTIN_LAYERS.layers], '层配置');
|
|
98
|
+
|
|
99
|
+
const col =
|
|
100
|
+
String(column || env[ENV.column] || fromDisk.column || BUILTIN_LAYERS.column).trim();
|
|
101
|
+
if (!LAYER_NAME.test(col)) throw new Error(`层列名不合法:${col}`);
|
|
102
|
+
|
|
103
|
+
const dflt = String(defaultLayer || env[ENV.defaultLayer] || fromDisk.default || BUILTIN_LAYERS.defaultLayer).trim();
|
|
104
|
+
if (!names.some((n) => n.toLowerCase() === dflt.toLowerCase())) {
|
|
105
|
+
throw new Error(`缺省层「${dflt}」不在层名集合 [${names.join(', ')}] 里 —— 回填会写进一个不存在的层`);
|
|
106
|
+
}
|
|
107
|
+
const defaultResolved = names.find((n) => n.toLowerCase() === dflt.toLowerCase());
|
|
108
|
+
|
|
109
|
+
const source = {
|
|
110
|
+
file: cfgPath,
|
|
111
|
+
fileUsed: disk.ok,
|
|
112
|
+
layers: explicitLayers.length ? 'cli' : envLayers.length ? 'env' : disk.ok ? 'file' : 'builtin',
|
|
113
|
+
defaultLayer: defaultLayer ? 'cli' : env[ENV.defaultLayer] ? 'env' : disk.ok && fromDisk.default ? 'file' : 'builtin',
|
|
114
|
+
column: column ? 'cli' : env[ENV.column] ? 'env' : disk.ok && fromDisk.column ? 'file' : 'builtin',
|
|
115
|
+
};
|
|
116
|
+
return { column: col, layers: names, defaultLayer: defaultResolved, source, warnings };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** 层在集合里的位次(越靠后越"深");不在集合里回 -1。 */
|
|
120
|
+
export function layerRank(cfg, name) {
|
|
121
|
+
const key = String(name ?? '').toLowerCase();
|
|
122
|
+
return cfg.layers.findIndex((n) => n.toLowerCase() === key);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** 该名字是否属于配置里的层(大小写不敏感),返回规范写法或 null。 */
|
|
126
|
+
export function canonicalLayerName(cfg, name) {
|
|
127
|
+
const rank = layerRank(cfg, name);
|
|
128
|
+
return rank < 0 ? null : cfg.layers[rank];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** 行指纹的定序字段:顺序改动即破坏跨版本可比性。 */
|
|
132
|
+
export function digestFields(cfg) {
|
|
133
|
+
return ['id', 'text', 'tags', 'pinned', 'created_at', 'updated_at', cfg.column];
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 一行的规范化部分(数组形态,便于逐字段比对差异)。 */
|
|
137
|
+
export function canonicalParts(row, cfg) {
|
|
138
|
+
return [
|
|
139
|
+
Number(row?.id ?? 0),
|
|
140
|
+
String(row?.text ?? ''),
|
|
141
|
+
String(row?.tags ?? ''),
|
|
142
|
+
Number(row?.pinned ?? 0),
|
|
143
|
+
Number(row?.created_at ?? 0),
|
|
144
|
+
Number(row?.updated_at ?? 0),
|
|
145
|
+
String(row?.[cfg.column] ?? ''),
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 行指纹 = sha256(JSON(定序数组))。 */
|
|
150
|
+
export function rowDigest(row, cfg) {
|
|
151
|
+
return crypto.createHash('sha256').update(JSON.stringify(canonicalParts(row, cfg)), 'utf8').digest('hex');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 一批行 → Map<id, digest>。 */
|
|
155
|
+
export function digestRows(rows, cfg) {
|
|
156
|
+
const out = new Map();
|
|
157
|
+
for (const row of rows) out.set(Number(row.id), rowDigest(row, cfg));
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 两批指纹比对:返回缺失 / 多出 / 内容不符三类,空数组即一致。 */
|
|
162
|
+
export function compareDigests(before, after) {
|
|
163
|
+
const missing = [];
|
|
164
|
+
const extra = [];
|
|
165
|
+
const changed = [];
|
|
166
|
+
for (const [id, d] of before) {
|
|
167
|
+
if (!after.has(id)) missing.push(id);
|
|
168
|
+
else if (after.get(id) !== d) changed.push(id);
|
|
169
|
+
}
|
|
170
|
+
for (const id of after.keys()) if (!before.has(id)) extra.push(id);
|
|
171
|
+
const sortNum = (a, b) => a - b;
|
|
172
|
+
return { missing: missing.sort(sortNum), extra: extra.sort(sortNum), changed: changed.sort(sortNum) };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** 记忆库路径的缺省来源(与引擎其余部分共用一份配置解析)。 */
|
|
176
|
+
export function defaultMemoryDbPath(env = process.env) {
|
|
177
|
+
return resolveConfig({ env }).memoryDb;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** 人话描述这层配置从哪来(报告与 --help 里用)。 */
|
|
181
|
+
export function describeConfig(cfg) {
|
|
182
|
+
const bits = [`列名 ${cfg.column}`, `层名 ${cfg.layers.join(' / ')}`, `缺省层 ${cfg.defaultLayer}`];
|
|
183
|
+
const src = cfg.source;
|
|
184
|
+
const where = {
|
|
185
|
+
cli: '命令行',
|
|
186
|
+
env: '环境变量',
|
|
187
|
+
file: path.basename(src.file),
|
|
188
|
+
builtin: '内置兜底',
|
|
189
|
+
};
|
|
190
|
+
return `${bits.join(' · ')}(来源:层名=${where[src.layers] ?? src.layers}、缺省层=${where[src.defaultLayer] ?? src.defaultLayer})`;
|
|
191
|
+
}
|
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
// 词表第二路召回:把「字面撞没撞上」这一路补回来。
|
|
2
|
+
//
|
|
3
|
+
// 为什么需要它:纯向量召回对**精确词**(专有名词、命令名、错别字极少的术语)不敏感,
|
|
4
|
+
// 一句话里那个唯一的线索词常常被语义相近的段落挤掉。SQLite 的 FTS5 在本仓只认英文/数字
|
|
5
|
+
// (unicode61 不切中文),中文提问那一路等于不存在 ⇒ 这里自带一份词典做最长匹配切词。
|
|
6
|
+
//
|
|
7
|
+
// 打分用 BM25(tf 饱和 + 文档长度归一 + 语料内 idf),再乘一个「通用词常见度」折扣:
|
|
8
|
+
// 越常见的中文词越不像检索线索(词频表来自 jieba,值越大越具体),低于门限的词直接不参与。
|
|
9
|
+
//
|
|
10
|
+
// 三条硬边界:
|
|
11
|
+
// ① 对数据源**只读**:只调 `allTexts()` / `allTags()` / `count()` / `maxUpdatedAt()`,
|
|
12
|
+
// 不自己开库、不建表、不写。
|
|
13
|
+
// ② **不会拖慢主路**:索引进程内缓存 + 后台预热;首次调用拿不到就先只走向量路,
|
|
14
|
+
// 预热完下一轮自然带上(`allowBuild: true` 时当场建,给命令行与夹具用)。
|
|
15
|
+
// ③ **缺素材不算错**:词典/词频/开关文件缺失或坏掉 ⇒ 这一路返回空,绝不抛错。
|
|
16
|
+
//
|
|
17
|
+
// 许可:`dict/词典.txt` 与 `dict/词频.txt` 来自 jieba 的 extra_dict/dict.txt.small(MIT),
|
|
18
|
+
// 来源与署名见 `dict/LICENSE-词典.md`(随包分发必须保留)。
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import path from 'node:path';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
const LIB_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
24
|
+
/** 素材目录的出厂位置:`memory-engine/dict/`(与 lib 同级,随包发布)。 */
|
|
25
|
+
const DEFAULT_ASSET_DIR = path.join(LIB_DIR, '..', 'dict');
|
|
26
|
+
|
|
27
|
+
export const ASSET_FILES = Object.freeze({
|
|
28
|
+
words: '词典.txt',
|
|
29
|
+
freq: '词频.txt',
|
|
30
|
+
switch: 'lexicon-config.json',
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 当前素材目录:环境变量 `MEMORY_ENGINE_DICT_DIR` 可覆盖(打包方换位置、夹具演"词典缺失"都用它)。
|
|
35
|
+
* 每次现读环境变量 —— 不许在模块加载时定死。
|
|
36
|
+
*/
|
|
37
|
+
export function assetDir() {
|
|
38
|
+
const fromEnv = process.env.MEMORY_ENGINE_DICT_DIR;
|
|
39
|
+
return fromEnv ? path.resolve(String(fromEnv)) : DEFAULT_ASSET_DIR;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 出厂的默认值;开关文件缺失/坏掉时用这一套(口径:词表默认**关**,见 README「设计依据」)。 */
|
|
43
|
+
export const LEXICON_DEFAULTS = Object.freeze({
|
|
44
|
+
enabled: false,
|
|
45
|
+
topK: 20,
|
|
46
|
+
floor: 0.3,
|
|
47
|
+
ttlMs: 600000,
|
|
48
|
+
k1: 1.2,
|
|
49
|
+
b: 0.75,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/** 切词窗口上限:词典里最长的词条是 15 个字,留一格余量。 */
|
|
53
|
+
const WINDOW_LIMIT = 16;
|
|
54
|
+
const ENV_SWITCH = 'MEMORY_ENGINE_LEXICON';
|
|
55
|
+
const ENV_TOPK = 'MEMORY_ENGINE_LEXICON_TOPK';
|
|
56
|
+
const ENV_FLOOR = 'MEMORY_ENGINE_LEXICON_FLOOR';
|
|
57
|
+
|
|
58
|
+
/** 多字虚词/口头语:切出来也不当线索(单字一律不当线索,见 tokenizeLexiconQuery)。 */
|
|
59
|
+
const STOP_TERMS = new Set([
|
|
60
|
+
'什么', '怎么', '怎么样', '为什么', '哪里', '哪儿', '现在', '目前', '当前', '刚才', '然后',
|
|
61
|
+
'记得', '记住', '告诉', '请问', '一下', '来着', '我们', '你们', '他们', '她们', '这个',
|
|
62
|
+
'那个', '哪些', '是否', '有没有', '是不是', '可以', '还是', '其实', '因为', '所以', '如果',
|
|
63
|
+
]);
|
|
64
|
+
|
|
65
|
+
/* ─────────────────────────────── 素材读取 ─────────────────────────────── */
|
|
66
|
+
|
|
67
|
+
// 素材读不到就回空串:调用方只需要知道「有没有」,不需要异常。
|
|
68
|
+
function readAsset(dir, name) {
|
|
69
|
+
try {
|
|
70
|
+
return fs.readFileSync(path.join(dir, name), 'utf8');
|
|
71
|
+
} catch {
|
|
72
|
+
return '';
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
let TABLE = null;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 词典 + 词频(同一目录只读一次,约 1.2 MB 常驻)。
|
|
80
|
+
* 缺文件不抛错:`available: false` ⇒ 这一路整体空手(正确退化到纯向量)。
|
|
81
|
+
* @param {{dir?:string, fresh?:boolean}} [options]
|
|
82
|
+
* @returns {{dir:string, words:Set<string>, freq:Map<string,number>, longest:number, available:boolean, loadedAt:number}}
|
|
83
|
+
*/
|
|
84
|
+
export function loadWordTable(options = {}) {
|
|
85
|
+
const dir = options.dir ? path.resolve(String(options.dir)) : assetDir();
|
|
86
|
+
if (TABLE && TABLE.dir === dir && !options.fresh) return TABLE;
|
|
87
|
+
const words = new Set();
|
|
88
|
+
let longest = 2;
|
|
89
|
+
for (const line of readAsset(dir, ASSET_FILES.words).split(/\r?\n/)) {
|
|
90
|
+
const word = line.trim();
|
|
91
|
+
if (!word) continue;
|
|
92
|
+
words.add(word);
|
|
93
|
+
if (word.length > longest) longest = word.length;
|
|
94
|
+
}
|
|
95
|
+
if (longest > WINDOW_LIMIT) longest = WINDOW_LIMIT;
|
|
96
|
+
|
|
97
|
+
const freq = new Map();
|
|
98
|
+
for (const line of readAsset(dir, ASSET_FILES.freq).split(/\r?\n/)) {
|
|
99
|
+
const parts = line.trim().split(/\s+/);
|
|
100
|
+
if (parts.length < 2) continue;
|
|
101
|
+
const value = Number(parts[1]);
|
|
102
|
+
if (parts[0] && Number.isFinite(value)) freq.set(parts[0], value);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
TABLE = { dir, words, freq, longest, available: words.size > 0, loadedAt: Date.now() };
|
|
106
|
+
return TABLE;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** 素材清单(诊断用;也给「词典在不在」这类判据一个现成的出处)。 */
|
|
110
|
+
export function lexiconAssets() {
|
|
111
|
+
const dir = assetDir();
|
|
112
|
+
const bytes = {};
|
|
113
|
+
for (const [key, name] of Object.entries(ASSET_FILES)) {
|
|
114
|
+
try {
|
|
115
|
+
bytes[key] = fs.statSync(path.join(dir, name)).size;
|
|
116
|
+
} catch {
|
|
117
|
+
bytes[key] = null;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { dir, files: { ...ASSET_FILES }, bytes };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* ─────────────────────────────── 开关 ─────────────────────────────────── */
|
|
124
|
+
|
|
125
|
+
let SWITCH_CACHE = null;
|
|
126
|
+
|
|
127
|
+
const asBool = (value, fallback) => {
|
|
128
|
+
if (typeof value === 'boolean') return value;
|
|
129
|
+
if (typeof value === 'number') return value !== 0;
|
|
130
|
+
if (typeof value === 'string') {
|
|
131
|
+
const text = value.trim().toLowerCase();
|
|
132
|
+
if (['1', 'on', 'true', 'yes', 'enable', 'enabled'].includes(text)) return true;
|
|
133
|
+
if (['0', 'off', 'false', 'no', 'disable', 'disabled'].includes(text)) return false;
|
|
134
|
+
}
|
|
135
|
+
return fallback;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const asNumber = (value, fallback, lo, hi) => {
|
|
139
|
+
const parsed = Number(value);
|
|
140
|
+
if (!Number.isFinite(parsed)) return fallback;
|
|
141
|
+
if (parsed < lo) return lo;
|
|
142
|
+
if (parsed > hi) return hi;
|
|
143
|
+
return parsed;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 解析开关:显式入参 > 环境变量 > `dict/lexicon-config.json` > 默认值。
|
|
148
|
+
* 配置文件按 mtime + 字节数缓存 ⇒ 改文件立刻生效,不必重启;代码改了仍要重启(模块在 boot 读)。
|
|
149
|
+
* @param {{enabled?:boolean|null, topK?:number|null, floor?:number|null, k1?:number|null, b?:number|null,
|
|
150
|
+
* file?:string, env?:object, fresh?:boolean}} [overrides]
|
|
151
|
+
*/
|
|
152
|
+
export function resolveLexiconSwitch(overrides = {}) {
|
|
153
|
+
const env = overrides.env || process.env;
|
|
154
|
+
const file = overrides.file || path.join(assetDir(), ASSET_FILES.switch);
|
|
155
|
+
let fromFile = null;
|
|
156
|
+
try {
|
|
157
|
+
const stat = fs.statSync(file);
|
|
158
|
+
const stamp = `${stat.mtimeMs}:${stat.size}`;
|
|
159
|
+
if (overrides.fresh || !SWITCH_CACHE || SWITCH_CACHE.stamp !== stamp || SWITCH_CACHE.file !== file) {
|
|
160
|
+
let parsed = null;
|
|
161
|
+
try {
|
|
162
|
+
parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
163
|
+
} catch {
|
|
164
|
+
parsed = null;
|
|
165
|
+
}
|
|
166
|
+
SWITCH_CACHE = { file, stamp, value: parsed && typeof parsed === 'object' ? parsed : null };
|
|
167
|
+
}
|
|
168
|
+
fromFile = SWITCH_CACHE.value;
|
|
169
|
+
} catch {
|
|
170
|
+
fromFile = null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const pick = (explicit, envName, field, fallback) => {
|
|
174
|
+
if (explicit !== undefined && explicit !== null) return explicit;
|
|
175
|
+
if (envName && env && env[envName] !== undefined && env[envName] !== '') return env[envName];
|
|
176
|
+
if (fromFile && fromFile[field] !== undefined && fromFile[field] !== null) return fromFile[field];
|
|
177
|
+
return fallback;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const sourceTag = (() => {
|
|
181
|
+
if (overrides.enabled !== undefined && overrides.enabled !== null) return 'argument';
|
|
182
|
+
if (env && env[ENV_SWITCH] !== undefined && env[ENV_SWITCH] !== '') return 'env';
|
|
183
|
+
return fromFile ? 'file' : 'default';
|
|
184
|
+
})();
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
enabled: asBool(pick(overrides.enabled, ENV_SWITCH, 'enabled', LEXICON_DEFAULTS.enabled), LEXICON_DEFAULTS.enabled),
|
|
188
|
+
topK: Math.trunc(asNumber(pick(overrides.topK, ENV_TOPK, 'topK', LEXICON_DEFAULTS.topK), LEXICON_DEFAULTS.topK, 1, 500)),
|
|
189
|
+
floor: asNumber(pick(overrides.floor, ENV_FLOOR, 'floor', LEXICON_DEFAULTS.floor), LEXICON_DEFAULTS.floor, 0, 1),
|
|
190
|
+
ttlMs: asNumber(pick(overrides.ttlMs, null, 'ttlMs', LEXICON_DEFAULTS.ttlMs), LEXICON_DEFAULTS.ttlMs, 0, 86400000),
|
|
191
|
+
k1: asNumber(pick(overrides.k1, null, 'k1', LEXICON_DEFAULTS.k1), LEXICON_DEFAULTS.k1, 0.01, 10),
|
|
192
|
+
b: asNumber(pick(overrides.b, null, 'b', LEXICON_DEFAULTS.b), LEXICON_DEFAULTS.b, 0, 1),
|
|
193
|
+
source: sourceTag,
|
|
194
|
+
file,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/* ─────────────────────────────── 切词 ─────────────────────────────────── */
|
|
199
|
+
|
|
200
|
+
/** 「这个词在通用中文里有多具体」:0 = 最常见(不当线索)/ 1 = 很具体(查不到也算 1)。 */
|
|
201
|
+
function specificityOf(term, table) {
|
|
202
|
+
const raw = table.freq.get(term);
|
|
203
|
+
if (raw === undefined) return 1;
|
|
204
|
+
const parsed = Number(raw);
|
|
205
|
+
if (!Number.isFinite(parsed)) return 1;
|
|
206
|
+
return Math.min(1, Math.max(0, parsed / 99));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** 连成一串的汉字按词典做最长匹配;切不出词就退化成单字(后面还要过停用字表)。 */
|
|
210
|
+
function segmentHanRun(run, table) {
|
|
211
|
+
const pieces = [];
|
|
212
|
+
let at = 0;
|
|
213
|
+
while (at < run.length) {
|
|
214
|
+
const cap = Math.min(table.longest, run.length - at);
|
|
215
|
+
let width = 0;
|
|
216
|
+
for (let size = cap; size >= 2; size -= 1) {
|
|
217
|
+
if (table.words.has(run.slice(at, at + size))) {
|
|
218
|
+
width = size;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
if (width === 0) {
|
|
223
|
+
pieces.push(run.slice(at, at + 1));
|
|
224
|
+
at += 1;
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
pieces.push(run.slice(at, at + width));
|
|
228
|
+
at += width;
|
|
229
|
+
}
|
|
230
|
+
return pieces;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 切词:英文/数字串(≥2 字符)+ 汉字按词典最长匹配。
|
|
235
|
+
* 单字与虚词一律丢掉 —— 单字几乎不是检索线索,留着只会把噪声拉进融合。
|
|
236
|
+
* 命中集去重(BM25 不重复计同一个词)。
|
|
237
|
+
* @param {string} text
|
|
238
|
+
* @param {object} [table] 事先取好的词表(省一次表查找)
|
|
239
|
+
* @returns {string[]}
|
|
240
|
+
*/
|
|
241
|
+
export function tokenizeLexiconQuery(text, table = loadWordTable()) {
|
|
242
|
+
const out = new Set();
|
|
243
|
+
const body = String(text ?? '').toLowerCase();
|
|
244
|
+
for (const match of body.matchAll(/[a-z0-9][a-z0-9_.+-]*/g)) {
|
|
245
|
+
if (match[0].length >= 2) out.add(match[0]);
|
|
246
|
+
}
|
|
247
|
+
for (const run of body.match(/[\u3400-\u4dbf\u4e00-\u9fff]+/g) || []) {
|
|
248
|
+
for (const piece of segmentHanRun(run, table)) {
|
|
249
|
+
if (piece.length < 2) continue;
|
|
250
|
+
if (STOP_TERMS.has(piece)) continue;
|
|
251
|
+
out.add(piece);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return [...out];
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/* ─────────────────────────────── 索引 ─────────────────────────────────── */
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* 建词表索引:读全量正文与标签 → 切词 → 倒排(词 → [文档序号, tf, ...])+ 文档长度。
|
|
261
|
+
* **一次性几百毫秒,别放在每轮对话的热路径上**(用 getLexiconIndex 的缓存/预热)。
|
|
262
|
+
* @param {object} source 数据源(memory / pitfall / policy 任一)
|
|
263
|
+
* @param {{dir?:string}} [options] 词典目录(缺省 = assetDir())
|
|
264
|
+
* @returns {object|null}
|
|
265
|
+
*/
|
|
266
|
+
export function buildLexiconIndex(source, options = {}) {
|
|
267
|
+
const started = Date.now();
|
|
268
|
+
const table = loadWordTable({ dir: options.dir });
|
|
269
|
+
if (!table.available) return null;
|
|
270
|
+
if (!source || typeof source.allTexts !== 'function') return null;
|
|
271
|
+
|
|
272
|
+
const tagsById = new Map();
|
|
273
|
+
try {
|
|
274
|
+
if (typeof source.allTags === 'function') {
|
|
275
|
+
for (const row of source.allTags()) tagsById.set(Number(row.id), row.tags);
|
|
276
|
+
}
|
|
277
|
+
} catch {
|
|
278
|
+
tagsById.clear();
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const ids = [];
|
|
282
|
+
const lens = [];
|
|
283
|
+
const df = new Map();
|
|
284
|
+
const postings = new Map();
|
|
285
|
+
let maxId = 0;
|
|
286
|
+
let totalLen = 0;
|
|
287
|
+
|
|
288
|
+
for (const row of source.allTexts()) {
|
|
289
|
+
const id = Number(row.id);
|
|
290
|
+
const tagText = String(tagsById.get(id) ?? '').replace(/[,,]/g, ' ');
|
|
291
|
+
const tokens = tokenizeLexiconQuery(`${row.text ?? ''} ${tagText}`, table);
|
|
292
|
+
const doc = ids.length;
|
|
293
|
+
ids.push(id);
|
|
294
|
+
if (id > maxId) maxId = id;
|
|
295
|
+
|
|
296
|
+
let length = 0;
|
|
297
|
+
const seen = new Map();
|
|
298
|
+
for (const token of tokens) {
|
|
299
|
+
seen.set(token, (seen.get(token) || 0) + 1);
|
|
300
|
+
length += 1;
|
|
301
|
+
}
|
|
302
|
+
lens.push(length);
|
|
303
|
+
totalLen += length;
|
|
304
|
+
|
|
305
|
+
for (const [term, count] of seen) {
|
|
306
|
+
const row2 = postings.get(term);
|
|
307
|
+
if (row2) row2.push(doc, count);
|
|
308
|
+
else postings.set(term, [doc, count]);
|
|
309
|
+
df.set(term, (df.get(term) || 0) + 1);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
const docCount = ids.length;
|
|
314
|
+
return {
|
|
315
|
+
ids,
|
|
316
|
+
lens: Uint32Array.from(lens),
|
|
317
|
+
avgLen: docCount ? totalLen / docCount : 0,
|
|
318
|
+
df,
|
|
319
|
+
postings,
|
|
320
|
+
docCount,
|
|
321
|
+
maxId,
|
|
322
|
+
dictDir: table.dir,
|
|
323
|
+
sourceName: source.sourceName || null,
|
|
324
|
+
available: true,
|
|
325
|
+
builtAt: Date.now(),
|
|
326
|
+
buildMs: Date.now() - started,
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* BM25 打分:score(d) = Σ_t w(t) · tf·(k1+1) / (tf + k1·(1-b+b·|d|/avgdl)),
|
|
332
|
+
* 其中 w(t) = idf(t) · 通用具体度(t),且只保留具体度 ≥ floor 的词。
|
|
333
|
+
* @param {string} query
|
|
334
|
+
* @param {object} index buildLexiconIndex 的产物
|
|
335
|
+
* @param {{topK?:number, floor?:number, k1?:number, b?:number}} [options]
|
|
336
|
+
* @returns {Array<{id:number, score:number, rank:number}>} 已降序、已截断
|
|
337
|
+
*/
|
|
338
|
+
export function rankByLexicon(query, index, options = {}) {
|
|
339
|
+
if (!index || !index.docCount || !index.available) return [];
|
|
340
|
+
const table = loadWordTable({ dir: index.dictDir });
|
|
341
|
+
const topK = Math.trunc(asNumber(options.topK, LEXICON_DEFAULTS.topK, 1, 500));
|
|
342
|
+
const floor = asNumber(options.floor, LEXICON_DEFAULTS.floor, 0, 1);
|
|
343
|
+
const k1 = asNumber(options.k1, LEXICON_DEFAULTS.k1, 0.01, 10);
|
|
344
|
+
const b = asNumber(options.b, LEXICON_DEFAULTS.b, 0, 1);
|
|
345
|
+
|
|
346
|
+
const terms = tokenizeLexiconQuery(query, table).filter(
|
|
347
|
+
(term) => specificityOf(term, table) >= floor && index.postings.has(term),
|
|
348
|
+
);
|
|
349
|
+
if (!terms.length) return [];
|
|
350
|
+
|
|
351
|
+
const scores = new Float64Array(index.docCount);
|
|
352
|
+
const visited = new Uint8Array(index.docCount);
|
|
353
|
+
const touched = [];
|
|
354
|
+
const avgLen = index.avgLen || 1;
|
|
355
|
+
|
|
356
|
+
for (const term of terms) {
|
|
357
|
+
const df = index.df.get(term) || 0;
|
|
358
|
+
const idf = Math.log(1 + (index.docCount - df + 0.5) / (df + 0.5));
|
|
359
|
+
const weight = idf * specificityOf(term, table);
|
|
360
|
+
if (!(weight > 0)) continue;
|
|
361
|
+
const row = index.postings.get(term);
|
|
362
|
+
for (let at = 0; at < row.length; at += 2) {
|
|
363
|
+
const doc = row[at];
|
|
364
|
+
const tf = row[at + 1];
|
|
365
|
+
const lengthNorm = 1 - b + (b * index.lens[doc]) / avgLen;
|
|
366
|
+
const gain = (tf * (k1 + 1)) / (tf + k1 * lengthNorm);
|
|
367
|
+
if (!visited[doc]) {
|
|
368
|
+
visited[doc] = 1;
|
|
369
|
+
touched.push(doc);
|
|
370
|
+
}
|
|
371
|
+
scores[doc] += weight * gain;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const ranked = [];
|
|
376
|
+
for (const doc of touched) {
|
|
377
|
+
if (scores[doc] > 0) ranked.push({ doc, id: index.ids[doc], score: scores[doc] });
|
|
378
|
+
}
|
|
379
|
+
ranked.sort((left, right) => right.score - left.score || left.doc - right.doc);
|
|
380
|
+
return ranked.slice(0, topK).map((row, at) => ({ id: row.id, score: row.score, rank: at + 1 }));
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/* ─────────────────────────── 进程内缓存 / 预热 ─────────────────────────── */
|
|
384
|
+
|
|
385
|
+
let CACHE = null; // { key, index }
|
|
386
|
+
let WARMING = false;
|
|
387
|
+
|
|
388
|
+
const sourceTagOf = (source) => String(source?.dbPath || source?.sourceName || '(anonymous)');
|
|
389
|
+
|
|
390
|
+
function stampOf(source) {
|
|
391
|
+
const count = typeof source?.count === 'function' ? Number(source.count()) : 0;
|
|
392
|
+
const updated = typeof source?.maxUpdatedAt === 'function' ? Number(source.maxUpdatedAt()) : 0;
|
|
393
|
+
return {
|
|
394
|
+
count: Number.isFinite(count) ? count : 0,
|
|
395
|
+
updated: Number.isFinite(updated) ? updated : 0,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function warmInBackground(source, key) {
|
|
400
|
+
if (WARMING) return;
|
|
401
|
+
WARMING = true;
|
|
402
|
+
const timer = setImmediate(() => {
|
|
403
|
+
try {
|
|
404
|
+
// 期间可能已被别人建好(同 key 就直接复用)
|
|
405
|
+
if (!CACHE || CACHE.key !== key) {
|
|
406
|
+
const index = buildLexiconIndex(source);
|
|
407
|
+
if (index) CACHE = { key, index };
|
|
408
|
+
}
|
|
409
|
+
} catch {
|
|
410
|
+
// fail-open:建不起来就一直只走向量路,不打扰主路
|
|
411
|
+
} finally {
|
|
412
|
+
WARMING = false;
|
|
413
|
+
}
|
|
414
|
+
});
|
|
415
|
+
if (typeof timer.unref === 'function') timer.unref();
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* 取(可能陈旧一轮的)词表索引。**永不抛错**。
|
|
420
|
+
* - 命中缓存且未过期 ⇒ 直接给;
|
|
421
|
+
* - `allowBuild: true` ⇒ 当场建(命令行 / 夹具用,几百毫秒);
|
|
422
|
+
* - 否则后台预热并返回同源的旧索引或 null(null ⇒ 调用方这一轮只走向量路)。
|
|
423
|
+
* @param {object} source
|
|
424
|
+
* @param {{allowBuild?:boolean, force?:boolean}} [options]
|
|
425
|
+
* @returns {object|null}
|
|
426
|
+
*/
|
|
427
|
+
export function getLexiconIndex(source, options = {}) {
|
|
428
|
+
const wantBuild = Boolean(options.allowBuild);
|
|
429
|
+
const force = Boolean(options.force);
|
|
430
|
+
try {
|
|
431
|
+
if (!source || typeof source.allTexts !== 'function') return null;
|
|
432
|
+
const switchValue = resolveLexiconSwitch();
|
|
433
|
+
const stamp = stampOf(source);
|
|
434
|
+
const key = `${sourceTagOf(source)}|${stamp.count}|${stamp.updated}`;
|
|
435
|
+
const cached = CACHE && CACHE.key === key ? CACHE.index : null;
|
|
436
|
+
const fresh = cached && !force && Date.now() - cached.builtAt < switchValue.ttlMs;
|
|
437
|
+
if (fresh) return cached;
|
|
438
|
+
if (!wantBuild) {
|
|
439
|
+
warmInBackground(source, key);
|
|
440
|
+
return cached; // 同源旧索引可以先用(TTL 与条数/max(updated_at) 兜底)
|
|
441
|
+
}
|
|
442
|
+
const index = buildLexiconIndex(source);
|
|
443
|
+
if (index) CACHE = { key, index };
|
|
444
|
+
return index;
|
|
445
|
+
} catch {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** 显式丢弃缓存(写库后、或测试里要干净起点时用)。 */
|
|
451
|
+
export function resetLexiconIndex() {
|
|
452
|
+
CACHE = null;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** 只读诊断面:不建索引、不预热,给自检/探针用。 */
|
|
456
|
+
export function lexiconStatus() {
|
|
457
|
+
const table = loadWordTable();
|
|
458
|
+
return {
|
|
459
|
+
assets: lexiconAssets(),
|
|
460
|
+
config: resolveLexiconSwitch(),
|
|
461
|
+
table: { available: table.available, words: table.words.size, freq: table.freq.size, longest: table.longest },
|
|
462
|
+
cache: CACHE
|
|
463
|
+
? {
|
|
464
|
+
key: CACHE.key,
|
|
465
|
+
docCount: CACHE.index.docCount,
|
|
466
|
+
buildMs: CACHE.index.buildMs,
|
|
467
|
+
builtAt: CACHE.index.builtAt,
|
|
468
|
+
ageMs: Date.now() - CACHE.index.builtAt,
|
|
469
|
+
}
|
|
470
|
+
: null,
|
|
471
|
+
warming: WARMING,
|
|
472
|
+
};
|
|
473
|
+
}
|