dsh-layered-memory 0.8.5 → 0.8.7
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/README.en.md +25 -2
- package/README.md +12 -2
- package/dist/client.js +317 -0
- package/dist/config.d.ts +21 -0
- package/dist/config.js +7 -0
- package/dist/embedding-worker.cjs +176 -0
- package/dist/hooks/recall.d.ts +10 -6
- package/dist/hooks/recall.js +40 -8
- package/dist/index.d.ts +14 -0
- package/dist/index.js +5 -2
- package/dist/llm.js +5 -0
- package/dist/pipeline/runner.d.ts +5 -0
- package/dist/pipeline/runner.js +23 -4
- package/dist/pipeline/trigger.d.ts +2 -0
- package/dist/pipeline/trigger.js +11 -0
- package/dist/stats.js +11 -0
- package/dist/store/embedding-source.d.ts +2 -1
- package/dist/store/embedding-source.js +7 -2
- package/dist/store/embedding.d.ts +2 -1
- package/dist/store/l0.js +10 -4
- package/dist/store/l1.d.ts +11 -1
- package/dist/store/l1.js +26 -6
- package/dist/store/local-embedding.d.ts +69 -46
- package/dist/store/local-embedding.js +179 -75
- package/dist/store/recall-dedupe.d.ts +26 -0
- package/dist/store/recall-dedupe.js +138 -0
- package/dist/store/runtime-installer.d.ts +0 -2
- package/dist/store/runtime-installer.js +0 -6
- package/dist/store/search-utils.d.ts +17 -0
- package/dist/store/search-utils.js +29 -0
- package/dist/store/sqlite.d.ts +68 -0
- package/dist/store/sqlite.js +185 -9
- package/dist/token-cost.d.ts +90 -0
- package/dist/token-cost.js +178 -0
- package/dist/util/recall-budget.d.ts +2 -2
- package/dist/util/recall-budget.js +2 -2
- package/package.json +1 -1
package/dist/store/l1.d.ts
CHANGED
|
@@ -21,7 +21,11 @@ export declare class L1Store {
|
|
|
21
21
|
private readonly helper;
|
|
22
22
|
private embedSvc;
|
|
23
23
|
private readonly logger?;
|
|
24
|
-
|
|
24
|
+
/** 时效衰减半衰期(天;0=关)。 */
|
|
25
|
+
private readonly decayHalfLifeDays;
|
|
26
|
+
constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, strategy?: RecallStrategy, logger?: MemoryLogger,
|
|
27
|
+
/** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
|
|
28
|
+
decayHalfLifeDays?: number);
|
|
25
29
|
init(): Promise<void>;
|
|
26
30
|
/** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
|
|
27
31
|
private importLegacy;
|
|
@@ -44,6 +48,12 @@ export declare class L1Store {
|
|
|
44
48
|
* 融合完整列表(融合分已归一化 0~1,可直接用于展示/过滤)。
|
|
45
49
|
*/
|
|
46
50
|
search(query: string, limit: number, opts?: L1SearchOptions): Promise<L1Hit[]>;
|
|
51
|
+
/**
|
|
52
|
+
* 时效衰减加权(#29):三路共用的读路径后处理——阈值过滤之后、截断之前
|
|
53
|
+
* (才能轮转名额,而不只是重排已截断的集合)。updated_at 经主表批量点查
|
|
54
|
+
* 回填(FTS 表无该列;候选池 ≤ limit×3 条主键查询,微秒级)。关闭时零开销。
|
|
55
|
+
*/
|
|
56
|
+
private applyDecay;
|
|
47
57
|
/** 浏览列表(UI 用):无关键词时按更新时间倒序分页。 */
|
|
48
58
|
list(opts: {
|
|
49
59
|
type?: string;
|
package/dist/store/l1.js
CHANGED
|
@@ -11,7 +11,7 @@ import * as path from 'node:path';
|
|
|
11
11
|
import { familyForType } from '../types.js';
|
|
12
12
|
import { EmbedHelper, NoopEmbeddingService } from './embedding.js';
|
|
13
13
|
import { appendJsonl, dayKey, ensureDir, readJsonl } from './io.js';
|
|
14
|
-
import { RRF_K, rrfMerge } from './search-utils.js';
|
|
14
|
+
import { applyDecayWeight, RRF_K, rrfMerge } from './search-utils.js';
|
|
15
15
|
import { isZeroVector } from './sqlite.js';
|
|
16
16
|
/** 官方过度召回倍数:候选池 = limit × 3(官方 tool 路径同款)。 */
|
|
17
17
|
const CANDIDATE_MULTIPLIER = 3;
|
|
@@ -23,7 +23,11 @@ export class L1Store {
|
|
|
23
23
|
helper;
|
|
24
24
|
embedSvc;
|
|
25
25
|
logger;
|
|
26
|
-
|
|
26
|
+
/** 时效衰减半衰期(天;0=关)。 */
|
|
27
|
+
decayHalfLifeDays;
|
|
28
|
+
constructor(dataDir, db, embed = new NoopEmbeddingService(), strategy = 'hybrid', logger,
|
|
29
|
+
/** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
|
|
30
|
+
decayHalfLifeDays) {
|
|
27
31
|
this.db = db;
|
|
28
32
|
this.strategy = strategy;
|
|
29
33
|
this.recordsDir = path.join(dataDir, 'records');
|
|
@@ -31,6 +35,7 @@ export class L1Store {
|
|
|
31
35
|
this.embedSvc = embed;
|
|
32
36
|
this.helper = new EmbedHelper(embed, logger);
|
|
33
37
|
this.logger = logger;
|
|
38
|
+
this.decayHalfLifeDays = decayHalfLifeDays ?? 30;
|
|
34
39
|
}
|
|
35
40
|
async init() {
|
|
36
41
|
await ensureDir(this.recordsDir);
|
|
@@ -142,17 +147,17 @@ export class L1Store {
|
|
|
142
147
|
return [];
|
|
143
148
|
if (strategy === 'keyword') {
|
|
144
149
|
const fts = this.db.searchL1Fts(query, candidateK, opts?.family);
|
|
145
|
-
return this.postProcess(applyFtsThreshold(fts, threshold, limit), opts?.type, limit);
|
|
150
|
+
return this.postProcess(this.applyDecay(applyFtsThreshold(fts, threshold, limit)), opts?.type, limit);
|
|
146
151
|
}
|
|
147
152
|
if (strategy === 'embedding') {
|
|
148
153
|
const vec = await this.helper.query(query, opts?.embeddingTimeoutMs);
|
|
149
154
|
if (!vec) {
|
|
150
155
|
// embedding 调用失败:降级 FTS,不阻断
|
|
151
156
|
const fts = this.db.searchL1Fts(query, candidateK, opts?.family);
|
|
152
|
-
return this.postProcess(applyFtsThreshold(fts, threshold, limit), opts?.type, limit);
|
|
157
|
+
return this.postProcess(this.applyDecay(applyFtsThreshold(fts, threshold, limit)), opts?.type, limit);
|
|
153
158
|
}
|
|
154
159
|
const vecHits = this.db.searchL1Vector(vec, candidateK, opts?.family);
|
|
155
|
-
return this.postProcess(filterScore(vecHits, threshold), opts?.type, limit);
|
|
160
|
+
return this.postProcess(this.applyDecay(filterScore(vecHits, threshold)), opts?.type, limit);
|
|
156
161
|
}
|
|
157
162
|
// hybrid(官方语义):双路并行 → 完整列表 RRF 融合(融合前不过滤阈值)
|
|
158
163
|
// → 融合分归一化:rank1 双列表命中 = 1.0,单列表命中 ≤ 0.5,保持 0~1 语义
|
|
@@ -162,7 +167,22 @@ export class L1Store {
|
|
|
162
167
|
]);
|
|
163
168
|
const vecList = vecRaw ? this.db.searchL1Vector(vecRaw, candidateK, opts?.family) : [];
|
|
164
169
|
const merged = rrfMerge([ftsList, vecList], (h) => h.id);
|
|
165
|
-
return this.postProcess(merged.map(({ rrfScore, ...h }) => ({ ...h, score: normalizeRrf(rrfScore) })), opts?.type, limit);
|
|
170
|
+
return this.postProcess(this.applyDecay(merged.map(({ rrfScore, ...h }) => ({ ...h, score: normalizeRrf(rrfScore) }))), opts?.type, limit);
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* 时效衰减加权(#29):三路共用的读路径后处理——阈值过滤之后、截断之前
|
|
174
|
+
* (才能轮转名额,而不只是重排已截断的集合)。updated_at 经主表批量点查
|
|
175
|
+
* 回填(FTS 表无该列;候选池 ≤ limit×3 条主键查询,微秒级)。关闭时零开销。
|
|
176
|
+
*/
|
|
177
|
+
applyDecay(hits) {
|
|
178
|
+
if (!(this.decayHalfLifeDays > 0) || hits.length === 0)
|
|
179
|
+
return hits;
|
|
180
|
+
const updatedAtById = new Map();
|
|
181
|
+
for (const r of this.db.getL1ByIds(hits.map((h) => h.id))) {
|
|
182
|
+
if (Number.isFinite(r.updatedAt))
|
|
183
|
+
updatedAtById.set(r.id, r.updatedAt);
|
|
184
|
+
}
|
|
185
|
+
return applyDecayWeight(hits, this.decayHalfLifeDays, (h) => updatedAtById.get(h.id));
|
|
166
186
|
}
|
|
167
187
|
/** 浏览列表(UI 用):无关键词时按更新时间倒序分页。 */
|
|
168
188
|
list(opts) {
|
|
@@ -1,66 +1,89 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 本地嵌入服务(D1/D6 决策):transformers.js + ONNX,进程内 CPU 推理。
|
|
3
|
-
*
|
|
4
|
-
* - 懒加载(D6):首次嵌入调用才加载模型(下载完成后的预热也走这里),
|
|
5
|
-
* 加载后常驻,close() 释放(嵌入源切走/关闭时调用);
|
|
6
|
-
* - 模型从数据目录 models/<id>/ 本地加载(env.allowRemoteModels=false 杜绝联网);
|
|
7
|
-
* - loader 可注入(测试缝):不依赖真实 transformers.js/模型文件即可测状态机;
|
|
8
|
-
* - 池化方式来自模型目录(BGE 系 CLS / Gemma 系 MEAN),normalize 交给 pipeline
|
|
9
|
-
* 内建 L2 归一(与远程路径的 sanitizeAndNormalize 语义一致)。
|
|
10
|
-
*
|
|
11
|
-
* 注意:真实模型路径的池化正确性(尤其 embeddinggemma 的 mean)属于实现期
|
|
12
|
-
* 待验证项——下载预热后应做一次相似度 sanity 检查(见 PR 验证指引)。
|
|
13
|
-
*/
|
|
14
1
|
import type { MemoryLogger } from '../types.js';
|
|
15
2
|
import type { CatalogEntry } from './model-catalog.js';
|
|
16
|
-
import type { EmbeddingProviderInfo, EmbeddingService } from './embedding.js';
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
dispose?: () => Promise<void> | void;
|
|
3
|
+
import type { EmbedCallOptions, EmbeddingProviderInfo, EmbeddingService } from './embedding.js';
|
|
4
|
+
type LocalState = 'idle' | 'loading' | 'ready' | 'failed' | 'terminated';
|
|
5
|
+
/** worker 启动参数(workerData;构造后不可变)。 */
|
|
6
|
+
export interface EmbedWorkerData {
|
|
7
|
+
runtimeDir: string;
|
|
8
|
+
modelDir: string;
|
|
9
|
+
pooling: 'cls' | 'mean';
|
|
10
|
+
dtype: string;
|
|
11
|
+
maxInputChars: number;
|
|
26
12
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
13
|
+
/** 主线程 → worker 的调用(id 由通道分配)。 */
|
|
14
|
+
export type EmbedWorkerCall = {
|
|
15
|
+
type: 'ping';
|
|
16
|
+
} | {
|
|
17
|
+
type: 'warmup';
|
|
18
|
+
} | {
|
|
19
|
+
type: 'embed';
|
|
20
|
+
texts: string[];
|
|
21
|
+
priority: boolean;
|
|
22
|
+
};
|
|
23
|
+
/** worker → 主线程的应答。 */
|
|
24
|
+
export type EmbedWorkerReply = {
|
|
25
|
+
id: number;
|
|
26
|
+
ok: true;
|
|
27
|
+
type: 'pong';
|
|
28
|
+
} | {
|
|
29
|
+
id: number;
|
|
30
|
+
ok: true;
|
|
31
|
+
type: 'ready';
|
|
32
|
+
} | {
|
|
33
|
+
id: number;
|
|
34
|
+
ok: true;
|
|
35
|
+
type: 'embedded';
|
|
36
|
+
vectors: Float32Array[];
|
|
37
|
+
} | {
|
|
38
|
+
id: number;
|
|
39
|
+
ok: false;
|
|
40
|
+
stage: 'load' | 'infer';
|
|
41
|
+
error: string;
|
|
42
|
+
};
|
|
43
|
+
/** worker 通道抽象(测试缝:smoke 注入假实现验证协议与状态机)。 */
|
|
44
|
+
export interface EmbedWorkerChannel {
|
|
45
|
+
request(call: EmbedWorkerCall): Promise<EmbedWorkerReply>;
|
|
46
|
+
/** 立即终止 worker 并拒绝全部未决请求(close 语义);幂等。 */
|
|
47
|
+
terminate(): void;
|
|
48
|
+
/** worker 意外崩溃通知(此后所有未决请求已被通道拒绝)。 */
|
|
49
|
+
setOnCrash(cb: (error: string) => void): void;
|
|
50
|
+
}
|
|
51
|
+
export interface LocalEmbeddingOptions {
|
|
52
|
+
/** 数据目录 runtime/(worker 据此 createRequire 加载 transformers)。 */
|
|
53
|
+
runtimeDir: string;
|
|
54
|
+
/** worker 资产路径(默认 dist/embedding-worker.cjs;测试可显式指定)。 */
|
|
55
|
+
workerPath?: string;
|
|
56
|
+
/** 通道注入缝(测试用;缺省 spawn 真实 worker)。 */
|
|
57
|
+
channel?: EmbedWorkerChannel;
|
|
58
|
+
logger?: MemoryLogger;
|
|
59
|
+
maxInputChars?: number;
|
|
33
60
|
}
|
|
34
|
-
/** 模块加载器(默认走 RuntimeInstaller.resolveModule;测试注入假模块)。 */
|
|
35
|
-
export type ModuleLoader = () => Promise<TransformersModuleLike>;
|
|
36
|
-
type LocalState = 'idle' | 'loading' | 'ready' | 'failed' | 'terminated';
|
|
37
61
|
export declare class LocalEmbeddingService implements EmbeddingService {
|
|
38
62
|
private state;
|
|
39
|
-
private extractor;
|
|
40
|
-
private loadPromise;
|
|
41
63
|
private loadError;
|
|
42
|
-
private readonly
|
|
64
|
+
private readonly channel;
|
|
43
65
|
private readonly entry;
|
|
44
|
-
private readonly loader;
|
|
45
66
|
private readonly logger?;
|
|
46
|
-
|
|
47
|
-
private readonly maxInputChars;
|
|
48
|
-
constructor(entry: CatalogEntry, modelDir: string, loader: ModuleLoader, logger?: MemoryLogger, maxInputChars?: number);
|
|
67
|
+
constructor(entry: CatalogEntry, modelDir: string, opts: LocalEmbeddingOptions);
|
|
49
68
|
getDimensions(): number;
|
|
50
69
|
getProviderInfo(): EmbeddingProviderInfo;
|
|
51
70
|
isReady(): boolean;
|
|
52
71
|
/** 状态(进度展示用)。 */
|
|
53
72
|
getState(): LocalState;
|
|
54
73
|
getLoadError(): string | null;
|
|
55
|
-
/**
|
|
74
|
+
/** 后台预热:启动后让 worker 立即加载模型(幂等;失败态可重试)。 */
|
|
56
75
|
startWarmup(): void;
|
|
57
|
-
/**
|
|
76
|
+
/** 等待模型就绪(warmup 协议;applyChain 的 warming 阶段与测试用)。 */
|
|
58
77
|
waitForReady(): Promise<void>;
|
|
59
|
-
embed(text: string): Promise<Float32Array>;
|
|
60
|
-
embedBatch(texts: string[]): Promise<Float32Array[]>;
|
|
61
|
-
/**
|
|
62
|
-
*
|
|
78
|
+
embed(text: string, callOpts?: EmbedCallOptions): Promise<Float32Array>;
|
|
79
|
+
embedBatch(texts: string[], callOpts?: EmbedCallOptions): Promise<Float32Array[]>;
|
|
80
|
+
/** 释放 worker 线程与模型(嵌入源切走/关闭时调用;幂等)。terminated 后不可
|
|
81
|
+
* 再复用——防止插件卸载/切走后残留的重嵌循环把模型重新加载常驻(内存泄漏)。 */
|
|
63
82
|
close(): void;
|
|
64
|
-
|
|
83
|
+
/** 内层钳制(仅缩短):超时放弃等待(迟到回复由通道按 id 丢弃),调用方降级。 */
|
|
84
|
+
private requestWithTimeout;
|
|
85
|
+
/** loading → ready 一次性日志(memory.log 时序可读性:启动到模型就绪的间隔)。 */
|
|
86
|
+
private markReady;
|
|
87
|
+
private applyLoadFailure;
|
|
65
88
|
}
|
|
66
89
|
export {};
|
|
@@ -1,20 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 本地嵌入服务(D1/D6 决策,worker 线程化版):transformers.js 的模型加载与
|
|
3
|
+
* ONNX 推理全部在 worker_threads 子线程执行(resources/embedding-worker.cjs),
|
|
4
|
+
* 本类只是主线程侧的协议代理——onnxruntime-node 的 run/loadModel 是主线程
|
|
5
|
+
* 同步调用,留在宿主事件循环会冻结整个 dsh 页面(0.8.6 修复的真实事故)。
|
|
6
|
+
*
|
|
7
|
+
* - 懒加载(D6):首次嵌入/warmup 才让 worker 加载模型,close()=terminate
|
|
8
|
+
* 释放线程与模型(嵌入源切走/关闭时调用),terminated 后不可复用;
|
|
9
|
+
* - 模型从数据目录 models/<id>/ 本地加载(worker 侧 env.allowRemoteModels=false);
|
|
10
|
+
* - channel 可注入(测试缝):smoke 用假通道验证协议与状态机,不触真模型;
|
|
11
|
+
* - callOpts.timeoutMs 经 Promise.race 钳制(迟到回复按 id 丢弃——推理在
|
|
12
|
+
* worker 线程无法真正取消,但主线程可以停止等待并降级 FTS);
|
|
13
|
+
* - 池化方式来自模型目录(BGE 系 CLS / Gemma 系 MEAN),normalize 交给
|
|
14
|
+
* pipeline 内建 L2 归一(与远程路径的 sanitizeAndNormalize 语义一致)。
|
|
15
|
+
*/
|
|
16
|
+
import * as path from 'node:path';
|
|
17
|
+
import { fileURLToPath } from 'node:url';
|
|
18
|
+
import { Worker } from 'node:worker_threads';
|
|
19
|
+
/** 默认 worker 资产路径:dist/store/ → dist/embedding-worker.cjs(构建期拷入)。 */
|
|
20
|
+
function defaultWorkerPath() {
|
|
21
|
+
return path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', 'embedding-worker.cjs');
|
|
22
|
+
}
|
|
23
|
+
/** 真实通道:spawn worker_threads + 自增 id 配对 + 崩溃兜底拒绝。 */
|
|
24
|
+
class RealWorkerChannel {
|
|
25
|
+
worker;
|
|
26
|
+
pending = new Map();
|
|
27
|
+
nextId = 1;
|
|
28
|
+
terminated = false;
|
|
29
|
+
crashed;
|
|
30
|
+
crashCb;
|
|
31
|
+
constructor(workerPath, workerData) {
|
|
32
|
+
this.worker = new Worker(workerPath, { workerData });
|
|
33
|
+
this.worker.on('message', (msg) => {
|
|
34
|
+
if (msg && msg.type === 'fatal') {
|
|
35
|
+
this.failAll(`本地嵌入 worker 致命错误: ${msg.error ?? '未知'}`);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const id = msg.id;
|
|
39
|
+
if (typeof id !== 'number')
|
|
40
|
+
return;
|
|
41
|
+
const entry = this.pending.get(id);
|
|
42
|
+
if (!entry)
|
|
43
|
+
return; // 迟到回复(调用方已超时放弃)
|
|
44
|
+
this.pending.delete(id);
|
|
45
|
+
entry.resolve(msg);
|
|
46
|
+
});
|
|
47
|
+
// error(未捕获异常且 worker 未自处理)与 exit(含 fatal 后的退出)都兜底拒绝
|
|
48
|
+
this.worker.on('error', (err) => this.failAll(`本地嵌入 worker 线程错误: ${err.message}`));
|
|
49
|
+
this.worker.on('exit', (code) => {
|
|
50
|
+
if (!this.terminated)
|
|
51
|
+
this.failAll(`本地嵌入 worker 线程退出(code=${code})`);
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
request(call) {
|
|
55
|
+
// 已释放/已崩溃的通道快速拒绝——postMessage 到死线程是静默无回应,调用方会挂到超时
|
|
56
|
+
if (this.terminated)
|
|
57
|
+
return Promise.reject(new Error('嵌入 worker 已释放'));
|
|
58
|
+
if (this.crashed)
|
|
59
|
+
return Promise.reject(new Error(this.crashed));
|
|
60
|
+
const id = this.nextId++;
|
|
61
|
+
return new Promise((resolve, reject) => {
|
|
62
|
+
this.pending.set(id, { resolve, reject });
|
|
63
|
+
this.worker.postMessage({ ...call, id });
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
terminate() {
|
|
67
|
+
if (this.terminated)
|
|
68
|
+
return;
|
|
69
|
+
this.terminated = true;
|
|
70
|
+
this.failAll('嵌入 worker 已释放');
|
|
71
|
+
void this.worker.terminate();
|
|
72
|
+
}
|
|
73
|
+
setOnCrash(cb) {
|
|
74
|
+
this.crashCb = cb;
|
|
75
|
+
// 构造与回调注册之间发生的崩溃(spawn 即失败等)不丢通知
|
|
76
|
+
if (this.crashed)
|
|
77
|
+
cb(this.crashed);
|
|
78
|
+
}
|
|
79
|
+
failAll(error) {
|
|
80
|
+
for (const [, entry] of this.pending)
|
|
81
|
+
entry.reject(new Error(error));
|
|
82
|
+
this.pending.clear();
|
|
83
|
+
if (!this.terminated && !this.crashed) {
|
|
84
|
+
this.crashed = error;
|
|
85
|
+
this.crashCb?.(error);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
1
89
|
export class LocalEmbeddingService {
|
|
2
90
|
state = 'idle';
|
|
3
|
-
extractor = null;
|
|
4
|
-
loadPromise = null;
|
|
5
91
|
loadError = null;
|
|
6
|
-
|
|
92
|
+
channel;
|
|
7
93
|
entry;
|
|
8
|
-
loader;
|
|
9
94
|
logger;
|
|
10
|
-
|
|
11
|
-
maxInputChars;
|
|
12
|
-
constructor(entry, modelDir, loader, logger, maxInputChars) {
|
|
95
|
+
constructor(entry, modelDir, opts) {
|
|
13
96
|
this.entry = entry;
|
|
14
|
-
this.
|
|
15
|
-
|
|
16
|
-
this.
|
|
17
|
-
|
|
97
|
+
this.logger = opts.logger;
|
|
98
|
+
const maxInputChars = opts.maxInputChars && opts.maxInputChars > 0 ? opts.maxInputChars : 5000;
|
|
99
|
+
this.channel =
|
|
100
|
+
opts.channel ??
|
|
101
|
+
new RealWorkerChannel(opts.workerPath ?? defaultWorkerPath(), {
|
|
102
|
+
runtimeDir: opts.runtimeDir,
|
|
103
|
+
modelDir,
|
|
104
|
+
pooling: entry.pooling,
|
|
105
|
+
dtype: 'q8',
|
|
106
|
+
maxInputChars,
|
|
107
|
+
});
|
|
108
|
+
// 崩溃不自愈(换源/重启恢复):拒绝语义沿 EmbedHelper 降级链走 FTS
|
|
109
|
+
this.channel.setOnCrash((error) => {
|
|
110
|
+
if (this.state === 'terminated')
|
|
111
|
+
return;
|
|
112
|
+
this.state = 'failed';
|
|
113
|
+
this.loadError = error;
|
|
114
|
+
this.logger?.warn(`[memory] ${error}(本地嵌入转入 failed 态,换源或重启可恢复)`);
|
|
115
|
+
});
|
|
18
116
|
}
|
|
19
117
|
getDimensions() {
|
|
20
118
|
return this.entry.dims;
|
|
@@ -24,7 +122,7 @@ export class LocalEmbeddingService {
|
|
|
24
122
|
return { provider: 'local', model: this.entry.id, dimensions: this.entry.dims };
|
|
25
123
|
}
|
|
26
124
|
isReady() {
|
|
27
|
-
return this.state === 'ready'
|
|
125
|
+
return this.state === 'ready';
|
|
28
126
|
}
|
|
29
127
|
/** 状态(进度展示用)。 */
|
|
30
128
|
getState() {
|
|
@@ -33,91 +131,97 @@ export class LocalEmbeddingService {
|
|
|
33
131
|
getLoadError() {
|
|
34
132
|
return this.loadError;
|
|
35
133
|
}
|
|
36
|
-
/**
|
|
134
|
+
/** 后台预热:启动后让 worker 立即加载模型(幂等;失败态可重试)。 */
|
|
37
135
|
startWarmup() {
|
|
38
|
-
void this.
|
|
136
|
+
void this.waitForReady().catch(() => { });
|
|
39
137
|
}
|
|
40
|
-
/**
|
|
138
|
+
/** 等待模型就绪(warmup 协议;applyChain 的 warming 阶段与测试用)。 */
|
|
41
139
|
async waitForReady() {
|
|
42
|
-
|
|
140
|
+
if (this.state === 'ready')
|
|
141
|
+
return;
|
|
142
|
+
if (this.state === 'terminated') {
|
|
143
|
+
throw new Error('本地嵌入服务已释放(嵌入源已切换);本实例不可复用');
|
|
144
|
+
}
|
|
145
|
+
if (this.state !== 'failed')
|
|
146
|
+
this.state = 'loading';
|
|
147
|
+
const reply = await this.channel.request({ type: 'warmup' });
|
|
148
|
+
if (!reply.ok) {
|
|
149
|
+
this.applyLoadFailure(reply.error);
|
|
150
|
+
throw new Error(reply.error);
|
|
151
|
+
}
|
|
152
|
+
this.markReady();
|
|
43
153
|
}
|
|
44
|
-
async embed(text) {
|
|
45
|
-
const [vec] = await this.embedBatch([text]);
|
|
154
|
+
async embed(text, callOpts) {
|
|
155
|
+
const [vec] = await this.embedBatch([text], callOpts);
|
|
46
156
|
return vec;
|
|
47
157
|
}
|
|
48
|
-
async embedBatch(texts) {
|
|
158
|
+
async embedBatch(texts, callOpts) {
|
|
49
159
|
if (texts.length === 0)
|
|
50
160
|
return [];
|
|
51
161
|
if (this.state === 'terminated') {
|
|
52
162
|
throw new Error('本地嵌入服务已释放(嵌入源已切换);本实例不可复用');
|
|
53
163
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
164
|
+
if (this.state === 'failed') {
|
|
165
|
+
// 只有明确失败过的服务才抛错(EmbedHelper 捕获后降级 FTS);warmup 可重试
|
|
166
|
+
throw new Error(`本地嵌入模型加载失败: ${this.loadError ?? '未知原因'}(重启插件或重新下载模型可重试)`);
|
|
167
|
+
}
|
|
168
|
+
if (this.state !== 'ready')
|
|
169
|
+
this.state = 'loading';
|
|
170
|
+
// 单条请求(召回 query)带优先标记:worker 侧插队,不被 reindex 批次堵队尾
|
|
171
|
+
const reply = await this.requestWithTimeout({ type: 'embed', texts, priority: texts.length === 1 }, callOpts?.timeoutMs);
|
|
172
|
+
if (!reply.ok) {
|
|
173
|
+
if (reply.stage === 'load')
|
|
174
|
+
this.applyLoadFailure(reply.error);
|
|
175
|
+
else
|
|
176
|
+
this.markReady(); // 推理失败说明模型已加载成功(loading → ready),失败只属于这一次调用
|
|
177
|
+
throw new Error(reply.error);
|
|
61
178
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
179
|
+
if (reply.type !== 'embedded')
|
|
180
|
+
throw new Error(`嵌入 worker 返回异常消息类型: ${reply.type}`);
|
|
181
|
+
this.markReady();
|
|
182
|
+
for (const v of reply.vectors) {
|
|
183
|
+
if (v.length !== this.entry.dims) {
|
|
184
|
+
throw new Error(`本地嵌入维度不匹配:期望 ${this.entry.dims},得到 ${v.length}`);
|
|
185
|
+
}
|
|
67
186
|
}
|
|
68
|
-
return
|
|
187
|
+
return reply.vectors;
|
|
69
188
|
}
|
|
70
|
-
/**
|
|
71
|
-
*
|
|
189
|
+
/** 释放 worker 线程与模型(嵌入源切走/关闭时调用;幂等)。terminated 后不可
|
|
190
|
+
* 再复用——防止插件卸载/切走后残留的重嵌循环把模型重新加载常驻(内存泄漏)。 */
|
|
72
191
|
close() {
|
|
73
|
-
const ext = this.extractor;
|
|
74
|
-
this.extractor = null;
|
|
75
|
-
this.loadPromise = null;
|
|
76
192
|
this.state = 'terminated';
|
|
77
193
|
this.loadError = null;
|
|
194
|
+
this.channel.terminate();
|
|
195
|
+
}
|
|
196
|
+
/** 内层钳制(仅缩短):超时放弃等待(迟到回复由通道按 id 丢弃),调用方降级。 */
|
|
197
|
+
async requestWithTimeout(call, timeoutMs) {
|
|
198
|
+
if (!(timeoutMs && timeoutMs > 0))
|
|
199
|
+
return this.channel.request(call);
|
|
200
|
+
let timer;
|
|
78
201
|
try {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
202
|
+
return await Promise.race([
|
|
203
|
+
this.channel.request(call),
|
|
204
|
+
new Promise((_, reject) => {
|
|
205
|
+
timer = setTimeout(() => reject(new Error(`本地嵌入调用超时(${timeoutMs}ms),已放弃等待`)), timeoutMs);
|
|
206
|
+
}),
|
|
207
|
+
]);
|
|
82
208
|
}
|
|
83
|
-
|
|
84
|
-
|
|
209
|
+
finally {
|
|
210
|
+
// 先到者胜出后清掉另一个定时器(不清理会挂住引用至自然到期,raceRecallTimeout 同款)
|
|
211
|
+
if (timer)
|
|
212
|
+
clearTimeout(timer);
|
|
85
213
|
}
|
|
86
214
|
}
|
|
87
|
-
|
|
215
|
+
/** loading → ready 一次性日志(memory.log 时序可读性:启动到模型就绪的间隔)。 */
|
|
216
|
+
markReady() {
|
|
88
217
|
if (this.state === 'ready')
|
|
89
218
|
return;
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
this.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (mod.env) {
|
|
98
|
-
mod.env.allowRemoteModels = false;
|
|
99
|
-
if (mod.env.allowLocalModels !== undefined)
|
|
100
|
-
mod.env.allowLocalModels = true;
|
|
101
|
-
}
|
|
102
|
-
const extractor = await mod.pipeline('feature-extraction', this.modelDir, { dtype: 'q8' });
|
|
103
|
-
// loading 中被 close()(terminated):不得覆写状态复活(review C)——直接释放丢弃
|
|
104
|
-
if (this.state === 'terminated') {
|
|
105
|
-
const dispose = extractor.dispose;
|
|
106
|
-
if (dispose)
|
|
107
|
-
void Promise.resolve(dispose.call(extractor)).catch(() => { });
|
|
108
|
-
throw new Error('加载期间服务已释放');
|
|
109
|
-
}
|
|
110
|
-
this.extractor = extractor;
|
|
111
|
-
this.state = 'ready';
|
|
112
|
-
this.logger?.info(`[memory] 本地嵌入模型就绪: ${this.entry.id}(dims=${this.entry.dims},pooling=${this.entry.pooling})`);
|
|
113
|
-
}
|
|
114
|
-
catch (err) {
|
|
115
|
-
this.state = 'failed';
|
|
116
|
-
this.loadError = err instanceof Error ? err.message : String(err);
|
|
117
|
-
this.logger?.warn(`[memory] 本地嵌入模型加载失败(${this.entry.id}): ${this.loadError}`);
|
|
118
|
-
throw err;
|
|
119
|
-
}
|
|
120
|
-
})();
|
|
121
|
-
return this.loadPromise;
|
|
219
|
+
this.state = 'ready';
|
|
220
|
+
this.logger?.info(`[memory] 本地嵌入模型就绪: ${this.entry.id}(dims=${this.entry.dims},pooling=${this.entry.pooling})`);
|
|
221
|
+
}
|
|
222
|
+
applyLoadFailure(error) {
|
|
223
|
+
this.state = 'failed';
|
|
224
|
+
this.loadError = error;
|
|
225
|
+
this.logger?.warn(`[memory] 本地嵌入模型加载失败(${this.entry.id}): ${error}`);
|
|
122
226
|
}
|
|
123
227
|
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { MemoryLogger } from '../types.js';
|
|
2
|
+
/** 会话条目上限(按 updatedAt 淘汰最旧;防文件无限增长)。 */
|
|
3
|
+
export declare const RECALL_DEDUPE_SESSION_CAP = 200;
|
|
4
|
+
/** 单会话记录 id 上限(按插入序淘汰最旧;Set 迭代序即插入序)。 */
|
|
5
|
+
export declare const RECALL_DEDUPE_IDS_CAP = 512;
|
|
6
|
+
export declare class RecallDedupeStore {
|
|
7
|
+
private readonly logger?;
|
|
8
|
+
private readonly file;
|
|
9
|
+
private readonly entries;
|
|
10
|
+
private persistFailed;
|
|
11
|
+
/** 串行化持久化写(避免并发原子写撞临时文件名);init 链最前(先载入再落盘,防丢更新)。 */
|
|
12
|
+
private writeChain;
|
|
13
|
+
constructor(dataDir: string, logger?: MemoryLogger | undefined);
|
|
14
|
+
/** 载入持久化映射(合并进内存——构造与载入之间发生的 mark 不丢);失败降级内存态。 */
|
|
15
|
+
private init;
|
|
16
|
+
/** 该会话的已注入集合(热路径同步读;未出现过的会话返回空集合,惰性建条)。 */
|
|
17
|
+
seen(sessionId: string): Set<string>;
|
|
18
|
+
/** 标记本轮实际注入的记录 id(写穿;调用方保证只传模型真实看到的条目)。 */
|
|
19
|
+
mark(sessionId: string, recordIds: string[]): void;
|
|
20
|
+
/** 清空该会话的记录(compact/clear 后上下文已丢失,记忆需可重新注入)。 */
|
|
21
|
+
reset(sessionId: string): void;
|
|
22
|
+
/** 等待在途持久化写完成(测试/停机用)。 */
|
|
23
|
+
flush(): Promise<void>;
|
|
24
|
+
private persist;
|
|
25
|
+
private serialize;
|
|
26
|
+
}
|