dsh-prime-memory 0.11.0 → 0.12.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.en.md +36 -1
- package/CHANGELOG.ja.md +36 -1
- package/CHANGELOG.ko.md +36 -1
- package/CHANGELOG.md +40 -0
- package/README.en.md +28 -0
- package/README.ja.md +25 -0
- package/README.ko.md +25 -0
- package/README.md +22 -0
- package/dist/client.js +54 -30
- package/dist/config.d.ts +39 -1
- package/dist/config.js +16 -0
- package/dist/conflict-service.d.ts +38 -0
- package/dist/conflict-service.js +65 -0
- package/dist/graph/search.d.ts +15 -0
- package/dist/graph/search.js +54 -1
- package/dist/hooks/recall.js +4 -0
- package/dist/index.d.ts +28 -1
- package/dist/index.js +12 -3
- package/dist/pipeline/l1.d.ts +7 -1
- package/dist/pipeline/l1.js +165 -20
- package/dist/pipeline/runner.js +6 -1
- package/dist/prompts/l1-dedup.d.ts +22 -1
- package/dist/prompts/l1-dedup.js +61 -4
- package/dist/stats.d.ts +12 -0
- package/dist/stats.js +222 -33
- package/dist/store/conflicts.d.ts +62 -0
- package/dist/store/conflicts.js +69 -0
- package/dist/store/graph-store.d.ts +72 -1
- package/dist/store/graph-store.js +165 -1
- package/dist/store/l1.d.ts +97 -4
- package/dist/store/l1.js +163 -21
- package/dist/store/receipts.d.ts +157 -0
- package/dist/store/receipts.js +139 -0
- package/dist/store/search-utils.d.ts +15 -0
- package/dist/store/search-utils.js +20 -0
- package/dist/store/session-modes.d.ts +7 -0
- package/dist/store/session-modes.js +9 -0
- package/dist/store/sqlite.d.ts +116 -8
- package/dist/store/sqlite.js +366 -46
- package/dist/store/vec-utils.d.ts +16 -0
- package/dist/store/vec-utils.js +24 -0
- package/dist/tools/index.js +204 -31
- package/dist/types.d.ts +48 -0
- package/dist/types.js +40 -0
- package/dist/workspace.d.ts +46 -0
- package/dist/workspace.js +105 -0
- package/package.json +9 -3
|
@@ -22,6 +22,7 @@ import { applyGraphProjection } from '../graph/apply.js';
|
|
|
22
22
|
import { searchGraphNodes } from '../graph/search.js';
|
|
23
23
|
import { GRAPH_JOB_BACKOFF_BASE_MS, GRAPH_JOB_BATCH, GRAPH_JOB_MAX_ATTEMPTS, GRAPH_PRIORITY_BACKFILL, GRAPH_PROJECTOR_VERSION, } from '../graph/types.js';
|
|
24
24
|
import { normPersistence } from '../types.js';
|
|
25
|
+
import { isZeroVector, vecToBuffer } from './vec-utils.js';
|
|
25
26
|
const TAG = '[memory][graph]';
|
|
26
27
|
/** 坏 JSON 容忍解析:图谱表列损坏只损失该行派生信息,不抛。 */
|
|
27
28
|
function parseJsonSafe(raw, fallback) {
|
|
@@ -134,6 +135,13 @@ export class GraphStore {
|
|
|
134
135
|
logger;
|
|
135
136
|
stmtInsertNode;
|
|
136
137
|
stmtInsertEdge;
|
|
138
|
+
/**
|
|
139
|
+
* §F 图谱节点向量列(vec0)。**结构性可选**:维度未定或 vec0 不可用时这三个
|
|
140
|
+
* 语句就是 `undefined`,该路整体 no-op——既不影响图谱的词法检索,也不向上抛。
|
|
141
|
+
*/
|
|
142
|
+
stmtInsertNodeVec;
|
|
143
|
+
stmtDeleteNodeVec;
|
|
144
|
+
stmtSearchNodeVec;
|
|
137
145
|
/** init 是否就绪(未就绪 = 图谱域整体 no-op,不抛错不传染)。 */
|
|
138
146
|
get ready() {
|
|
139
147
|
return this.db !== null;
|
|
@@ -141,8 +149,11 @@ export class GraphStore {
|
|
|
141
149
|
/**
|
|
142
150
|
* 建表 + 语句缓存(MemoryDb.initSchema 内调用)。任何一步失败都只告警并保持
|
|
143
151
|
* 未就绪——图谱域整体降级 no-op,检索主链路(L0/L1/FTS/向量)不受影响。
|
|
152
|
+
*
|
|
153
|
+
* @param vec §F 节点向量列的维度(来自 MemoryDb 的**既有**能力探测结果;
|
|
154
|
+
* 缺省/0 = 部署未启用向量 → 该路结构性不存在,不建表也不报错)。
|
|
144
155
|
*/
|
|
145
|
-
init(db, logger) {
|
|
156
|
+
init(db, logger, vec) {
|
|
146
157
|
this.logger = logger;
|
|
147
158
|
try {
|
|
148
159
|
db.exec(`
|
|
@@ -225,6 +236,7 @@ export class GraphStore {
|
|
|
225
236
|
source_record_ids_json=excluded.source_record_ids_json, updated_time=excluded.updated_time
|
|
226
237
|
`);
|
|
227
238
|
this.db = db;
|
|
239
|
+
this.prepareNodeVec(db, logger, vec);
|
|
228
240
|
logger?.info(`${TAG} 图谱表就绪(5 表,projectorVersion=${GRAPH_PROJECTOR_VERSION})`);
|
|
229
241
|
}
|
|
230
242
|
catch (err) {
|
|
@@ -233,6 +245,114 @@ export class GraphStore {
|
|
|
233
245
|
logger?.warn(`${TAG} 图谱表初始化失败,图谱功能停用(主存储不受影响): ${err instanceof Error ? err.message : String(err)}`);
|
|
234
246
|
}
|
|
235
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* §F 图谱节点向量列:与 `l1_vec` **同模式**的 vec0 虚拟表——同一个 `vec-utils`
|
|
250
|
+
* 编码、同一种 `float[N] distance_metric=cosine` 声明。
|
|
251
|
+
*
|
|
252
|
+
* **内层 try/catch 是刻意的**(task_34):vec0 扩展缺失时 `CREATE VIRTUAL TABLE`
|
|
253
|
+
* 会抛。若让它冒到外层,图谱会从"向量路不可用"退化成"**整个图谱域不可用**"——
|
|
254
|
+
* 词法检索、投影、裁决全部陪葬。外层那层 catch 是给"图谱表建不起来"用的,
|
|
255
|
+
* 不该被一个**可选**的向量列触发。
|
|
256
|
+
*/
|
|
257
|
+
prepareNodeVec(db, logger, vec) {
|
|
258
|
+
const dimensions = vec?.dimensions ?? 0;
|
|
259
|
+
this.stmtInsertNodeVec = undefined;
|
|
260
|
+
this.stmtDeleteNodeVec = undefined;
|
|
261
|
+
this.stmtSearchNodeVec = undefined;
|
|
262
|
+
// 部署未启用向量(或探测未通过)→ 该路**结构性不存在**:不建表、不告警、不抛。
|
|
263
|
+
// 这与"建了空表却永远查不到东西"是两回事——前者可断言,后者不可。
|
|
264
|
+
if (dimensions <= 0)
|
|
265
|
+
return;
|
|
266
|
+
try {
|
|
267
|
+
db.exec(`
|
|
268
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS graph_node_vec USING vec0(
|
|
269
|
+
node_id TEXT PRIMARY KEY,
|
|
270
|
+
embedding float[${dimensions}] distance_metric=cosine,
|
|
271
|
+
updated_time TEXT DEFAULT ''
|
|
272
|
+
)
|
|
273
|
+
`);
|
|
274
|
+
this.stmtInsertNodeVec = db.prepare('INSERT INTO graph_node_vec (node_id, embedding, updated_time) VALUES (?, ?, ?)');
|
|
275
|
+
this.stmtDeleteNodeVec = db.prepare('DELETE FROM graph_node_vec WHERE node_id = ?');
|
|
276
|
+
this.stmtSearchNodeVec = db.prepare(`
|
|
277
|
+
SELECT node_id, distance
|
|
278
|
+
FROM graph_node_vec
|
|
279
|
+
WHERE embedding MATCH ? AND k = ?
|
|
280
|
+
ORDER BY distance
|
|
281
|
+
`);
|
|
282
|
+
logger?.info(`${TAG} 图谱节点向量表就绪(dim=${dimensions})`);
|
|
283
|
+
}
|
|
284
|
+
catch (err) {
|
|
285
|
+
logger?.warn(`${TAG} 图谱节点向量表不可用,向量路停用(图谱词法检索不受影响): ${err instanceof Error ? err.message : String(err)}`);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
/** 节点向量路是否可用。未就绪时下方两个方法的调用方**无需分支**——它们自身 no-op。 */
|
|
289
|
+
get nodeVecReady() {
|
|
290
|
+
return this.stmtSearchNodeVec !== undefined;
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* 写入/覆盖节点向量(先删后插:vec0 不支持 ON CONFLICT)。
|
|
294
|
+
* 未就绪 / 零向量 / 单条失败 → **静默跳过**,绝不抛。
|
|
295
|
+
* @returns 实际写入条数(供调用方记账,不用于控制流)。
|
|
296
|
+
*/
|
|
297
|
+
upsertNodeVectors(rows) {
|
|
298
|
+
const stmtInsert = this.stmtInsertNodeVec;
|
|
299
|
+
const stmtDelete = this.stmtDeleteNodeVec;
|
|
300
|
+
if (!this.db || !stmtInsert || !stmtDelete)
|
|
301
|
+
return 0;
|
|
302
|
+
let written = 0;
|
|
303
|
+
for (const row of rows) {
|
|
304
|
+
if (isZeroVector(row.embedding))
|
|
305
|
+
continue; // cosine 未定义,不入表
|
|
306
|
+
try {
|
|
307
|
+
stmtDelete.run(row.nodeId);
|
|
308
|
+
stmtInsert.run(row.nodeId, vecToBuffer(row.embedding), row.updatedAt ?? '');
|
|
309
|
+
written++;
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
this.logger?.warn(`${TAG} 节点向量写入失败(跳过该条): ${err instanceof Error ? err.message : String(err)}`);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return written;
|
|
316
|
+
}
|
|
317
|
+
/** 删除节点向量(节点删/合并时用)。未就绪 no-op。 */
|
|
318
|
+
deleteNodeVectors(nodeIds) {
|
|
319
|
+
const stmtDelete = this.stmtDeleteNodeVec;
|
|
320
|
+
if (!this.db || !stmtDelete)
|
|
321
|
+
return;
|
|
322
|
+
try {
|
|
323
|
+
for (const id of nodeIds)
|
|
324
|
+
stmtDelete.run(id);
|
|
325
|
+
}
|
|
326
|
+
catch (err) {
|
|
327
|
+
this.logger?.warn(`${TAG} 节点向量删除失败: ${err instanceof Error ? err.message : String(err)}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
/**
|
|
331
|
+
* 向量检索节点(按 cosine 距离升序,score = 1 - distance,与 L1 向量路同口径)。
|
|
332
|
+
* 未就绪返回**空数组**——调用方无需判断 `nodeVecReady`,降级是内建的。
|
|
333
|
+
*/
|
|
334
|
+
searchNodesByVector(embedding, topK) {
|
|
335
|
+
const stmtSearch = this.stmtSearchNodeVec;
|
|
336
|
+
if (!this.db || !stmtSearch || topK <= 0)
|
|
337
|
+
return [];
|
|
338
|
+
try {
|
|
339
|
+
const rows = stmtSearch.all(vecToBuffer(embedding), topK);
|
|
340
|
+
const out = [];
|
|
341
|
+
for (const { node_id, distance } of rows) {
|
|
342
|
+
if (distance == null || Number.isNaN(distance))
|
|
343
|
+
continue;
|
|
344
|
+
const node = this.getNode(node_id);
|
|
345
|
+
if (!node)
|
|
346
|
+
continue;
|
|
347
|
+
out.push({ node, score: 1 - distance });
|
|
348
|
+
}
|
|
349
|
+
return out;
|
|
350
|
+
}
|
|
351
|
+
catch (err) {
|
|
352
|
+
this.logger?.warn(`${TAG} 节点向量检索失败(返回空): ${err instanceof Error ? err.message : String(err)}`);
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
236
356
|
/** 插件停机时清空连接引用(dispose 序调用,防悬空引用)。 */
|
|
237
357
|
close() {
|
|
238
358
|
this.db = null;
|
|
@@ -623,6 +743,50 @@ export class GraphStore {
|
|
|
623
743
|
this.logger?.warn(`${TAG} 删除传播失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
624
744
|
}
|
|
625
745
|
}
|
|
746
|
+
/**
|
|
747
|
+
* §C 矛盾冻结:把图谱的 `disputed` 状态**同步**到给定冲突集。
|
|
748
|
+
*
|
|
749
|
+
* 为什么是"同步"而不是"标记":裁决会**撤销**争议。只做单向标记的话,
|
|
750
|
+
* 一对已被人工裁决的对,其节点会永远停在 `disputed`——那是**派生投影在说谎**。
|
|
751
|
+
* 图谱是本仓库反复确认的 L1 **派生投影**,派生字段就必须**由当前事实重算**,
|
|
752
|
+
* 而不是靠一串增量事件累积(后者一旦漏一次就永久跑偏)。
|
|
753
|
+
*
|
|
754
|
+
* 判据(与 {@link markSourcesDeleted} 方向相反:那边问"来源是否**全部**消失",
|
|
755
|
+
* 这边问"来源是否**命中**冲突集",命中一条即存疑):
|
|
756
|
+
* - `active` 且来源命中冲突集 → `disputed`
|
|
757
|
+
* - `disputed` 且来源**不**命中冲突集 → 复原为 `active`
|
|
758
|
+
* - `archived` 墓碑两边都不动(墓碑是删除传播的产物,与争议无关)
|
|
759
|
+
*
|
|
760
|
+
* @returns 本次标记 / 复原的节点数。
|
|
761
|
+
*/
|
|
762
|
+
syncDisputed(disputedRecordIds) {
|
|
763
|
+
if (!this.db)
|
|
764
|
+
return { marked: 0, cleared: 0 };
|
|
765
|
+
try {
|
|
766
|
+
const hit = new Set(disputedRecordIds);
|
|
767
|
+
const now = new Date().toISOString();
|
|
768
|
+
const nodes = this.loadGraph().nodes;
|
|
769
|
+
const toMark = nodes.filter((n) => n.status === 'active' && n.sourceRecordIds.some((id) => hit.has(id)));
|
|
770
|
+
// 冲突集为空 = 队列里没有未裁决对 → 所有 disputed 都该复原
|
|
771
|
+
const toClear = nodes.filter((n) => n.status === 'disputed' && !n.sourceRecordIds.some((id) => hit.has(id)));
|
|
772
|
+
if (toMark.length === 0 && toClear.length === 0)
|
|
773
|
+
return { marked: 0, cleared: 0 };
|
|
774
|
+
this.tx(() => {
|
|
775
|
+
for (const n of toMark) {
|
|
776
|
+
this.db.prepare(`UPDATE graph_nodes SET status = 'disputed', updated_time = ? WHERE node_id = ?`).run(now, n.id);
|
|
777
|
+
}
|
|
778
|
+
for (const n of toClear) {
|
|
779
|
+
this.db.prepare(`UPDATE graph_nodes SET status = 'active', updated_time = ? WHERE node_id = ?`).run(now, n.id);
|
|
780
|
+
}
|
|
781
|
+
});
|
|
782
|
+
this.logger?.info(`${TAG} 矛盾冻结同步:${toMark.length} 节点标 disputed,${toClear.length} 节点复原 active`);
|
|
783
|
+
return { marked: toMark.length, cleared: toClear.length };
|
|
784
|
+
}
|
|
785
|
+
catch (err) {
|
|
786
|
+
this.logger?.warn(`${TAG} 冻结状态同步失败(忽略): ${err instanceof Error ? err.message : String(err)}`);
|
|
787
|
+
return { marked: 0, cleared: 0 };
|
|
788
|
+
}
|
|
789
|
+
}
|
|
626
790
|
/** 清空全部图谱数据(L1 重建时调用——图谱是 L1 的投影,记录清空即图谱作废)。 */
|
|
627
791
|
resetAll() {
|
|
628
792
|
if (!this.db)
|
package/dist/store/l1.d.ts
CHANGED
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import type { L1Hit, MemoryFamily, MemoryLogger, MemoryRecord } from '../types.js';
|
|
2
|
+
import type { GraphNodeSearchResult } from '../graph/types.js';
|
|
3
|
+
import type { L1Receipt, ReceiptQuery } from './receipts.js';
|
|
4
|
+
import type { ConflictPair, ConflictResolution } from './conflicts.js';
|
|
2
5
|
import { type EmbeddingService } from './embedding.js';
|
|
3
6
|
import { type MemoryDb } from './sqlite.js';
|
|
4
7
|
export type RecallStrategy = 'keyword' | 'embedding' | 'hybrid';
|
|
8
|
+
/**
|
|
9
|
+
* 图谱路提供者(§D 第 3 路):按查询返回图谱命中(已按 score 降序)。
|
|
10
|
+
* 抽成注入式而非直接读 `db.graphStore`,是为了给 hybrid 融合留一个可替换的测试缝,
|
|
11
|
+
* 并让「未接线 = 恰为 2 路」成为默认行为(既有调用方零行为变化)。
|
|
12
|
+
*/
|
|
13
|
+
export type GraphLaneProvider = (query: string, limit: number, family?: MemoryFamily) => readonly GraphNodeSearchResult[];
|
|
5
14
|
export interface L1SearchOptions {
|
|
6
15
|
/** 按记忆类型精确过滤(后置过滤,官方做法)。 */
|
|
7
16
|
type?: string;
|
|
8
17
|
/** 按族过滤(undefined = 不过滤,即 auto 档与浏览路径;检索唯一缝的族语义)。 */
|
|
9
18
|
family?: MemoryFamily;
|
|
19
|
+
/**
|
|
20
|
+
* §E 当前工作区标识(undefined = **不做可见范围过滤**,与改动前逐字一致)。
|
|
21
|
+
* 与 `family` 落在**同一条 SQL / 同一层回查**里(ADR-0008 组合关系):
|
|
22
|
+
* 若只在检索出口过滤而放任去重候选跨工作区相互污染,会产出「看不见但已影响决策」的记忆。
|
|
23
|
+
*/
|
|
24
|
+
workspaceId?: string;
|
|
10
25
|
/** 分数阈值(仅召回路径传;keyword/embedding 策略生效,FTS 含小语料例外;
|
|
11
26
|
* hybrid 按官方语义在 RRF 融合前不过滤)。 */
|
|
12
27
|
scoreThreshold?: number;
|
|
@@ -23,9 +38,13 @@ export declare class L1Store {
|
|
|
23
38
|
private readonly logger?;
|
|
24
39
|
/** 时效衰减半衰期(天;0=关)。 */
|
|
25
40
|
private readonly decayHalfLifeDays;
|
|
41
|
+
/** §D 第 3 路(图谱回链);缺省 = 不接,恰为 2 路。 */
|
|
42
|
+
private readonly graphLaneProvider?;
|
|
26
43
|
constructor(dataDir: string, db: MemoryDb, embed?: EmbeddingService, strategy?: RecallStrategy, logger?: MemoryLogger,
|
|
27
44
|
/** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
|
|
28
|
-
decayHalfLifeDays?: number
|
|
45
|
+
decayHalfLifeDays?: number,
|
|
46
|
+
/** 图谱路提供者(§D 第 3 路);不传则该路不存在,融合退回双路。 */
|
|
47
|
+
graphLane?: GraphLaneProvider);
|
|
29
48
|
init(): Promise<void>;
|
|
30
49
|
/** 旧版单文件 records.jsonl 一次性导入检索库,成功后改名 .imported。 */
|
|
31
50
|
private importLegacy;
|
|
@@ -34,6 +53,45 @@ export declare class L1Store {
|
|
|
34
53
|
all(): MemoryRecord[];
|
|
35
54
|
/** 按 id 精确取记录(去重决策的版本号查询用,避免全表扫描)。 */
|
|
36
55
|
getByIds(ids: string[]): MemoryRecord[];
|
|
56
|
+
/**
|
|
57
|
+
* §B 决策凭证落盘(L1Store 的薄缝)。
|
|
58
|
+
* 刻意放在 store 上:`runExtraction` 已经持有 L1Store,凭证写入因此无需新增
|
|
59
|
+
* 构造参数或改动签名;同时它也是「写入失败不中断蒸馏」**可注入的测试缝**——
|
|
60
|
+
* 测试只需替换这一个方法就能模拟落盘故障,不必伪造整个 store。
|
|
61
|
+
*/
|
|
62
|
+
recordReceipts(rows: readonly L1Receipt[]): number;
|
|
63
|
+
/**
|
|
64
|
+
* §B 双维回溯的读缝(task_19)。与 `recordReceipts` 同理由:
|
|
65
|
+
* 工具层与 RPC 层只认 L1Store,不直连 `db`——保持"检索库的入口只有一处"
|
|
66
|
+
* 这一既有不变量,也让未来的读缓存/裁剪如需介入仍只有一个落点。
|
|
67
|
+
*/
|
|
68
|
+
listReceipts(opts: ReceiptQuery & {
|
|
69
|
+
limit: number;
|
|
70
|
+
}): L1Receipt[];
|
|
71
|
+
countReceipts(opts: ReceiptQuery): number;
|
|
72
|
+
/**
|
|
73
|
+
* §C 矛盾冻结落盘(thick 缝)。与 `recordReceipts` 同理由:管线已持有 L1Store,
|
|
74
|
+
* 无需新增构造参数;同时它是「冻结写失败不得中断蒸馏」可注入的测试缝。
|
|
75
|
+
*/
|
|
76
|
+
recordConflictPending(rows: readonly ConflictPair[]): number;
|
|
77
|
+
/**
|
|
78
|
+
* §C 冻结的图谱侧同步:把 `disputed` 状态重算到给定冲突集(命中标记 / 不再命中复原)。
|
|
79
|
+
* 经 store 而非直取 `db.graphStore`,与图谱路 provider 的注入式设计同一理由
|
|
80
|
+
* (见本文件头部注释):图谱是**可选**的派生投影,开关关闭时必须是 no-op。
|
|
81
|
+
*/
|
|
82
|
+
syncGraphDisputed(disputedRecordIds: readonly string[]): {
|
|
83
|
+
marked: number;
|
|
84
|
+
cleared: number;
|
|
85
|
+
};
|
|
86
|
+
/** §C 待裁决队列的未裁决条数(task_24 队列上限判据)。 */
|
|
87
|
+
countConflictPendingUnresolved(): number;
|
|
88
|
+
/** §C 取未裁决冲突对(task_24 超时扫描 / task_25 裁决工具)。 */
|
|
89
|
+
listConflictPending(opts?: {
|
|
90
|
+
createdBefore?: string;
|
|
91
|
+
limit?: number;
|
|
92
|
+
}): ConflictPair[];
|
|
93
|
+
/** §C 打上裁决结论(已裁决的不覆盖)。 */
|
|
94
|
+
resolveConflictPending(pairId: string, resolution: ConflictResolution, resolvedAt: string): number;
|
|
37
95
|
/** 新记忆落盘:JSONL 按天追加(事实源)+ 检索库 upsert + 向量。 */
|
|
38
96
|
appendNew(records: MemoryRecord[]): Promise<void>;
|
|
39
97
|
/** 去重 update/merge 产出的记录:只更新检索库(JSONL 事实源不改写,官方语义)。 */
|
|
@@ -48,18 +106,48 @@ export declare class L1Store {
|
|
|
48
106
|
* 融合完整列表(融合分已归一化 0~1,可直接用于展示/过滤)。
|
|
49
107
|
*/
|
|
50
108
|
search(query: string, limit: number, opts?: L1SearchOptions): Promise<L1Hit[]>;
|
|
109
|
+
/**
|
|
110
|
+
* §D 第 4 路(时效路,hybrid 专用):把候选池按 `applyDecayWeight` 加权后的
|
|
111
|
+
* 顺序作为第 4 条**已排序**列表,复用与后处理同源的加权函数(不新增独立逻辑)。
|
|
112
|
+
*
|
|
113
|
+
* **时效是排序信号,不是召回信号**:本路只重排 `ftsList ∪ vecList` 里的既有
|
|
114
|
+
* 候选,**不引入任何新记录**。若让"无关但很新"的记忆靠时效进结果,会直接损害
|
|
115
|
+
* 检索精度——这条性质由 `tests/recency-lane.test.ts` 的 id 集合不变量钉住。
|
|
116
|
+
*
|
|
117
|
+
* **严禁进入 `searchCandidates`**(`search-utils.ts:26-27` 约定):写路径找同语义
|
|
118
|
+
* 旧记录必须**无视新旧**——一旦被时效加权,老的同义记录会被漏检,导致同事实双记录
|
|
119
|
+
* 累积。故本方法只被 `search()` 调用,去重候选路径不得引用。
|
|
120
|
+
*/
|
|
121
|
+
private recencyLane;
|
|
122
|
+
/**
|
|
123
|
+
* §D 第 3 路(图谱路径,hybrid 专用):图谱命中 → `sourceRecordIds` 回链 →
|
|
124
|
+
* L1 记录,作为第 3 条**已排序**列表参与 RRF。
|
|
125
|
+
*
|
|
126
|
+
* 为什么值得:图谱是按实体/关系组织的**可重建派生投影**,能召回词法与向量
|
|
127
|
+
* 都命不中的记录(同义表述、关系可达)——这正是本路相对双路的增量。
|
|
128
|
+
*
|
|
129
|
+
* 三条边界:
|
|
130
|
+
* - **异常降级**:图谱是派生投影,不得因它失败而拖垮主检索 → 记 warn、返回空路,
|
|
131
|
+
* 融合退回双路(路数随之降为 2,分数回到既有量纲);
|
|
132
|
+
* - **族隔离**:图谱节点已按族过滤,但其来源记录可能跨族 → 这里再按 `family`
|
|
133
|
+
* 过滤一次。宁可漏不可串(与档位隔离同源,§A 的 P0 关注点);
|
|
134
|
+
* - **墓碑边界**:图谱行可能回链到已被删除的 L1 记录 → 取不到就跳过,
|
|
135
|
+
* 不补空占位(占位会在 RRF 里凭空加分)。
|
|
136
|
+
*/
|
|
137
|
+
private graphLane;
|
|
51
138
|
/**
|
|
52
139
|
* 时效衰减加权(#29):三路共用的读路径后处理——阈值过滤之后、截断之前
|
|
53
140
|
* (才能轮转名额,而不只是重排已截断的集合)。updated_at 经主表批量点查
|
|
54
141
|
* 回填(FTS 表无该列;候选池 ≤ limit×3 条主键查询,微秒级)。关闭时零开销。
|
|
55
142
|
*/
|
|
56
143
|
private applyDecay;
|
|
57
|
-
/** 浏览列表(UI 用):无关键词时按更新时间倒序分页,支持 Hall
|
|
144
|
+
/** 浏览列表(UI 用):无关键词时按更新时间倒序分页,支持 Hall / 可见范围过滤。 */
|
|
58
145
|
list(opts: {
|
|
59
146
|
type?: string;
|
|
60
147
|
scene?: string;
|
|
61
148
|
family?: string;
|
|
62
149
|
hall?: string;
|
|
150
|
+
workspaceId?: string;
|
|
63
151
|
limit: number;
|
|
64
152
|
offset: number;
|
|
65
153
|
}): {
|
|
@@ -70,9 +158,14 @@ export declare class L1Store {
|
|
|
70
158
|
distinctScenes(): string[];
|
|
71
159
|
/**
|
|
72
160
|
* 去重候选召回(官方 3 级):空库跳过 → 向量优先 → FTS 兜底。
|
|
73
|
-
* 传入 family 时只在同族记录里召回(去重永不跨族)
|
|
161
|
+
* 传入 family 时只在同族记录里召回(去重永不跨族);传入 workspaceId 时
|
|
162
|
+
* 只在**本工作区可见**的记录里召回(§E)——**去重也不跨工作区**。
|
|
163
|
+
*
|
|
164
|
+
* 这一层是 ADR-0008 特意点名的接缝:"scope 过滤必须落在与族隔离同一层"。
|
|
165
|
+
* 理由:候选池决定**新的去重决策**,若此处跨工作区,产出的是「项目 B 里看不见、
|
|
166
|
+
* 但已经决定了项目 A 记忆去向」的记录——比不隔离更糟。
|
|
74
167
|
*/
|
|
75
|
-
searchCandidates(query: string, limit: number, family?: MemoryFamily): Promise<MemoryRecord[]>;
|
|
168
|
+
searchCandidates(query: string, limit: number, family?: MemoryFamily, workspaceId?: string): Promise<MemoryRecord[]>;
|
|
76
169
|
/**
|
|
77
170
|
* 增量重嵌入(embedding 配置变化 / 周期性补齐用):只处理缺失向量的记录,
|
|
78
171
|
* 排除已判定"当前 provider 不可嵌入"的 skip 集。返回写入/失败/跳过数——
|
package/dist/store/l1.js
CHANGED
|
@@ -8,10 +8,11 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { existsSync, promises as fs } from 'node:fs';
|
|
10
10
|
import * as path from 'node:path';
|
|
11
|
-
import { familyForType } from '../types.js';
|
|
11
|
+
import { familyForType, isScopeVisible } from '../types.js';
|
|
12
|
+
import { graphHitRecordIds } from '../graph/search.js';
|
|
12
13
|
import { EmbedHelper, NoopEmbeddingService } from './embedding.js';
|
|
13
14
|
import { appendJsonl, dayKey, ensureDir, readJsonl } from '../util/io.js';
|
|
14
|
-
import { applyDecayWeight,
|
|
15
|
+
import { applyDecayWeight, normalizeRrf, rrfMerge } from './search-utils.js';
|
|
15
16
|
import { isZeroVector } from './sqlite.js';
|
|
16
17
|
/** 官方过度召回倍数:候选池 = limit × 3(官方 tool 路径同款)。 */
|
|
17
18
|
const CANDIDATE_MULTIPLIER = 3;
|
|
@@ -25,9 +26,13 @@ export class L1Store {
|
|
|
25
26
|
logger;
|
|
26
27
|
/** 时效衰减半衰期(天;0=关)。 */
|
|
27
28
|
decayHalfLifeDays;
|
|
29
|
+
/** §D 第 3 路(图谱回链);缺省 = 不接,恰为 2 路。 */
|
|
30
|
+
graphLaneProvider;
|
|
28
31
|
constructor(dataDir, db, embed = new NoopEmbeddingService(), strategy = 'hybrid', logger,
|
|
29
32
|
/** 时效衰减半衰期(天;0=关)。缺省 30 与 config 默认一致。 */
|
|
30
|
-
decayHalfLifeDays
|
|
33
|
+
decayHalfLifeDays,
|
|
34
|
+
/** 图谱路提供者(§D 第 3 路);不传则该路不存在,融合退回双路。 */
|
|
35
|
+
graphLane) {
|
|
31
36
|
this.db = db;
|
|
32
37
|
this.strategy = strategy;
|
|
33
38
|
this.recordsDir = path.join(dataDir, 'records');
|
|
@@ -36,6 +41,7 @@ export class L1Store {
|
|
|
36
41
|
this.helper = new EmbedHelper(embed, logger);
|
|
37
42
|
this.logger = logger;
|
|
38
43
|
this.decayHalfLifeDays = decayHalfLifeDays ?? 30;
|
|
44
|
+
this.graphLaneProvider = graphLane;
|
|
39
45
|
}
|
|
40
46
|
async init() {
|
|
41
47
|
await ensureDir(this.recordsDir);
|
|
@@ -84,6 +90,53 @@ export class L1Store {
|
|
|
84
90
|
getByIds(ids) {
|
|
85
91
|
return this.db.getL1ByIds(ids);
|
|
86
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* §B 决策凭证落盘(L1Store 的薄缝)。
|
|
95
|
+
* 刻意放在 store 上:`runExtraction` 已经持有 L1Store,凭证写入因此无需新增
|
|
96
|
+
* 构造参数或改动签名;同时它也是「写入失败不中断蒸馏」**可注入的测试缝**——
|
|
97
|
+
* 测试只需替换这一个方法就能模拟落盘故障,不必伪造整个 store。
|
|
98
|
+
*/
|
|
99
|
+
recordReceipts(rows) {
|
|
100
|
+
return this.db.recordReceipts(rows);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* §B 双维回溯的读缝(task_19)。与 `recordReceipts` 同理由:
|
|
104
|
+
* 工具层与 RPC 层只认 L1Store,不直连 `db`——保持"检索库的入口只有一处"
|
|
105
|
+
* 这一既有不变量,也让未来的读缓存/裁剪如需介入仍只有一个落点。
|
|
106
|
+
*/
|
|
107
|
+
listReceipts(opts) {
|
|
108
|
+
return this.db.listReceipts(opts);
|
|
109
|
+
}
|
|
110
|
+
countReceipts(opts) {
|
|
111
|
+
return this.db.countReceipts(opts);
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* §C 矛盾冻结落盘(thick 缝)。与 `recordReceipts` 同理由:管线已持有 L1Store,
|
|
115
|
+
* 无需新增构造参数;同时它是「冻结写失败不得中断蒸馏」可注入的测试缝。
|
|
116
|
+
*/
|
|
117
|
+
recordConflictPending(rows) {
|
|
118
|
+
return this.db.recordConflictPending(rows);
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* §C 冻结的图谱侧同步:把 `disputed` 状态重算到给定冲突集(命中标记 / 不再命中复原)。
|
|
122
|
+
* 经 store 而非直取 `db.graphStore`,与图谱路 provider 的注入式设计同一理由
|
|
123
|
+
* (见本文件头部注释):图谱是**可选**的派生投影,开关关闭时必须是 no-op。
|
|
124
|
+
*/
|
|
125
|
+
syncGraphDisputed(disputedRecordIds) {
|
|
126
|
+
return this.db.syncGraphDisputed(disputedRecordIds);
|
|
127
|
+
}
|
|
128
|
+
/** §C 待裁决队列的未裁决条数(task_24 队列上限判据)。 */
|
|
129
|
+
countConflictPendingUnresolved() {
|
|
130
|
+
return this.db.countConflictPendingUnresolved();
|
|
131
|
+
}
|
|
132
|
+
/** §C 取未裁决冲突对(task_24 超时扫描 / task_25 裁决工具)。 */
|
|
133
|
+
listConflictPending(opts = {}) {
|
|
134
|
+
return this.db.listConflictPending(opts);
|
|
135
|
+
}
|
|
136
|
+
/** §C 打上裁决结论(已裁决的不覆盖)。 */
|
|
137
|
+
resolveConflictPending(pairId, resolution, resolvedAt) {
|
|
138
|
+
return this.db.resolveConflictPending(pairId, resolution, resolvedAt);
|
|
139
|
+
}
|
|
87
140
|
/** 新记忆落盘:JSONL 按天追加(事实源)+ 检索库 upsert + 向量。 */
|
|
88
141
|
async appendNew(records) {
|
|
89
142
|
if (records.length === 0)
|
|
@@ -146,28 +199,116 @@ export class L1Store {
|
|
|
146
199
|
if (strategy === 'none')
|
|
147
200
|
return [];
|
|
148
201
|
if (strategy === 'keyword') {
|
|
149
|
-
const fts = this.db.searchL1Fts(query, candidateK, opts?.family);
|
|
202
|
+
const fts = this.db.searchL1Fts(query, candidateK, opts?.family, opts?.workspaceId);
|
|
150
203
|
return this.postProcess(this.applyDecay(applyFtsThreshold(fts, threshold, limit)), opts?.type, limit);
|
|
151
204
|
}
|
|
152
205
|
if (strategy === 'embedding') {
|
|
153
206
|
const vec = await this.helper.query(query, opts?.embeddingTimeoutMs);
|
|
154
207
|
if (!vec) {
|
|
155
208
|
// embedding 调用失败:降级 FTS,不阻断
|
|
156
|
-
const fts = this.db.searchL1Fts(query, candidateK, opts?.family);
|
|
209
|
+
const fts = this.db.searchL1Fts(query, candidateK, opts?.family, opts?.workspaceId);
|
|
157
210
|
return this.postProcess(this.applyDecay(applyFtsThreshold(fts, threshold, limit)), opts?.type, limit);
|
|
158
211
|
}
|
|
159
|
-
const vecHits = this.db.searchL1Vector(vec, candidateK, opts?.family);
|
|
212
|
+
const vecHits = this.db.searchL1Vector(vec, candidateK, opts?.family, opts?.workspaceId);
|
|
160
213
|
return this.postProcess(this.applyDecay(filterScore(vecHits, threshold)), opts?.type, limit);
|
|
161
214
|
}
|
|
162
|
-
// hybrid(官方语义)
|
|
163
|
-
// →
|
|
215
|
+
// hybrid(官方语义):多路并行 → 完整列表 RRF 融合(融合前不过滤阈值)
|
|
216
|
+
// → 融合分按**实际路数**归一化:全路 rank1 命中 = 1.0,双路单列表命中 ≤ 0.5
|
|
164
217
|
const [ftsList, vecRaw] = await Promise.all([
|
|
165
|
-
Promise.resolve(this.db.searchL1Fts(query, candidateK, opts?.family)),
|
|
218
|
+
Promise.resolve(this.db.searchL1Fts(query, candidateK, opts?.family, opts?.workspaceId)),
|
|
166
219
|
this.helper.query(query, opts?.embeddingTimeoutMs),
|
|
167
220
|
]);
|
|
168
|
-
const vecList = vecRaw ? this.db.searchL1Vector(vecRaw, candidateK, opts?.family) : [];
|
|
169
|
-
|
|
170
|
-
|
|
221
|
+
const vecList = vecRaw ? this.db.searchL1Vector(vecRaw, candidateK, opts?.family, opts?.workspaceId) : [];
|
|
222
|
+
// 第 3 路(图谱)仅在接线时**结构性存在**:未接线不占路数名额,故既有调用方
|
|
223
|
+
// 仍走 2 路、得分与改动前逐位一致(见 findings.md §12.1 的路数语义)
|
|
224
|
+
const lanes = [ftsList, vecList];
|
|
225
|
+
if (this.graphLaneProvider)
|
|
226
|
+
lanes.push(this.graphLane(query, candidateK, opts?.family, opts?.workspaceId));
|
|
227
|
+
// 第 4 路(时效)在衰减开启时**结构性存在**;关掉衰减 = 该路不存在
|
|
228
|
+
if (this.decayHalfLifeDays > 0)
|
|
229
|
+
lanes.push(this.recencyLane([...ftsList, ...vecList]));
|
|
230
|
+
const merged = rrfMerge(lanes, (h) => h.id);
|
|
231
|
+
return this.postProcess(this.applyDecay(merged.map(({ rrfScore, ...h }) => ({ ...h, score: normalizeRrf(rrfScore, lanes.length) }))), opts?.type, limit);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* §D 第 4 路(时效路,hybrid 专用):把候选池按 `applyDecayWeight` 加权后的
|
|
235
|
+
* 顺序作为第 4 条**已排序**列表,复用与后处理同源的加权函数(不新增独立逻辑)。
|
|
236
|
+
*
|
|
237
|
+
* **时效是排序信号,不是召回信号**:本路只重排 `ftsList ∪ vecList` 里的既有
|
|
238
|
+
* 候选,**不引入任何新记录**。若让"无关但很新"的记忆靠时效进结果,会直接损害
|
|
239
|
+
* 检索精度——这条性质由 `tests/recency-lane.test.ts` 的 id 集合不变量钉住。
|
|
240
|
+
*
|
|
241
|
+
* **严禁进入 `searchCandidates`**(`search-utils.ts:26-27` 约定):写路径找同语义
|
|
242
|
+
* 旧记录必须**无视新旧**——一旦被时效加权,老的同义记录会被漏检,导致同事实双记录
|
|
243
|
+
* 累积。故本方法只被 `search()` 调用,去重候选路径不得引用。
|
|
244
|
+
*/
|
|
245
|
+
recencyLane(candidates) {
|
|
246
|
+
if (!(this.decayHalfLifeDays > 0) || candidates.length === 0)
|
|
247
|
+
return [];
|
|
248
|
+
const seen = new Set();
|
|
249
|
+
const deduped = [];
|
|
250
|
+
for (const h of candidates) {
|
|
251
|
+
if (seen.has(h.id))
|
|
252
|
+
continue;
|
|
253
|
+
seen.add(h.id);
|
|
254
|
+
deduped.push(h);
|
|
255
|
+
}
|
|
256
|
+
return this.applyDecay(deduped);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* §D 第 3 路(图谱路径,hybrid 专用):图谱命中 → `sourceRecordIds` 回链 →
|
|
260
|
+
* L1 记录,作为第 3 条**已排序**列表参与 RRF。
|
|
261
|
+
*
|
|
262
|
+
* 为什么值得:图谱是按实体/关系组织的**可重建派生投影**,能召回词法与向量
|
|
263
|
+
* 都命不中的记录(同义表述、关系可达)——这正是本路相对双路的增量。
|
|
264
|
+
*
|
|
265
|
+
* 三条边界:
|
|
266
|
+
* - **异常降级**:图谱是派生投影,不得因它失败而拖垮主检索 → 记 warn、返回空路,
|
|
267
|
+
* 融合退回双路(路数随之降为 2,分数回到既有量纲);
|
|
268
|
+
* - **族隔离**:图谱节点已按族过滤,但其来源记录可能跨族 → 这里再按 `family`
|
|
269
|
+
* 过滤一次。宁可漏不可串(与档位隔离同源,§A 的 P0 关注点);
|
|
270
|
+
* - **墓碑边界**:图谱行可能回链到已被删除的 L1 记录 → 取不到就跳过,
|
|
271
|
+
* 不补空占位(占位会在 RRF 里凭空加分)。
|
|
272
|
+
*/
|
|
273
|
+
graphLane(query, limit, family, workspaceId) {
|
|
274
|
+
const provider = this.graphLaneProvider;
|
|
275
|
+
if (!provider)
|
|
276
|
+
return [];
|
|
277
|
+
let hits;
|
|
278
|
+
try {
|
|
279
|
+
hits = provider(query, limit, family);
|
|
280
|
+
}
|
|
281
|
+
catch (err) {
|
|
282
|
+
this.logger?.warn(`[memory] 图谱路检索失败,本轮退回双路: ${err instanceof Error ? err.message : String(err)}`);
|
|
283
|
+
return [];
|
|
284
|
+
}
|
|
285
|
+
const ids = graphHitRecordIds(hits);
|
|
286
|
+
if (ids.length === 0)
|
|
287
|
+
return [];
|
|
288
|
+
const byId = new Map(this.db.getL1ByIds(ids).map((r) => [r.id, r]));
|
|
289
|
+
const out = [];
|
|
290
|
+
for (const id of ids) {
|
|
291
|
+
const r = byId.get(id);
|
|
292
|
+
if (!r)
|
|
293
|
+
continue;
|
|
294
|
+
if (family !== undefined && r.family !== family)
|
|
295
|
+
continue;
|
|
296
|
+
// §E:与族隔离**同一层**再过滤一次。图谱节点自身没有 scope——归属由**来源记录**
|
|
297
|
+
// 决定(节点是 L1 的派生投影,它不该有独立于来源的可见范围)。
|
|
298
|
+
if (workspaceId !== undefined && !isScopeVisible(r.scope, r.workspaceId, workspaceId))
|
|
299
|
+
continue;
|
|
300
|
+
out.push({
|
|
301
|
+
id: r.id,
|
|
302
|
+
content: r.content,
|
|
303
|
+
type: r.type,
|
|
304
|
+
scene_name: r.scene_name,
|
|
305
|
+
priority: r.priority,
|
|
306
|
+
family: r.family,
|
|
307
|
+
// 占位分:RRF 只用 rank,此字段在融合时会被归一化分覆盖
|
|
308
|
+
score: 0,
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
return out;
|
|
171
312
|
}
|
|
172
313
|
/**
|
|
173
314
|
* 时效衰减加权(#29):三路共用的读路径后处理——阈值过滤之后、截断之前
|
|
@@ -184,7 +325,7 @@ export class L1Store {
|
|
|
184
325
|
}
|
|
185
326
|
return applyDecayWeight(hits, this.decayHalfLifeDays, (h) => updatedAtById.get(h.id));
|
|
186
327
|
}
|
|
187
|
-
/** 浏览列表(UI 用):无关键词时按更新时间倒序分页,支持 Hall
|
|
328
|
+
/** 浏览列表(UI 用):无关键词时按更新时间倒序分页,支持 Hall / 可见范围过滤。 */
|
|
188
329
|
list(opts) {
|
|
189
330
|
return this.db.listL1(opts);
|
|
190
331
|
}
|
|
@@ -194,9 +335,14 @@ export class L1Store {
|
|
|
194
335
|
}
|
|
195
336
|
/**
|
|
196
337
|
* 去重候选召回(官方 3 级):空库跳过 → 向量优先 → FTS 兜底。
|
|
197
|
-
* 传入 family 时只在同族记录里召回(去重永不跨族)
|
|
338
|
+
* 传入 family 时只在同族记录里召回(去重永不跨族);传入 workspaceId 时
|
|
339
|
+
* 只在**本工作区可见**的记录里召回(§E)——**去重也不跨工作区**。
|
|
340
|
+
*
|
|
341
|
+
* 这一层是 ADR-0008 特意点名的接缝:"scope 过滤必须落在与族隔离同一层"。
|
|
342
|
+
* 理由:候选池决定**新的去重决策**,若此处跨工作区,产出的是「项目 B 里看不见、
|
|
343
|
+
* 但已经决定了项目 A 记忆去向」的记录——比不隔离更糟。
|
|
198
344
|
*/
|
|
199
|
-
async searchCandidates(query, limit, family) {
|
|
345
|
+
async searchCandidates(query, limit, family, workspaceId) {
|
|
200
346
|
if (this.db.countL1() === 0)
|
|
201
347
|
return [];
|
|
202
348
|
const caps = this.db.getCapabilities();
|
|
@@ -204,7 +350,7 @@ export class L1Store {
|
|
|
204
350
|
try {
|
|
205
351
|
const vec = await this.helper.query(query);
|
|
206
352
|
if (vec) {
|
|
207
|
-
const hits = this.db.searchL1Vector(vec, limit, family);
|
|
353
|
+
const hits = this.db.searchL1Vector(vec, limit, family, workspaceId);
|
|
208
354
|
if (hits.length > 0)
|
|
209
355
|
return this.db.getL1ByIds(hits.map((h) => h.id));
|
|
210
356
|
}
|
|
@@ -213,7 +359,7 @@ export class L1Store {
|
|
|
213
359
|
this.logger?.warn(`[memory] 向量候选召回失败,降级 FTS: ${err instanceof Error ? err.message : String(err)}`);
|
|
214
360
|
}
|
|
215
361
|
}
|
|
216
|
-
const fts = this.db.searchL1Fts(query, limit * 2, family);
|
|
362
|
+
const fts = this.db.searchL1Fts(query, limit * 2, family, workspaceId);
|
|
217
363
|
return this.db.getL1ByIds(fts.map((h) => h.id));
|
|
218
364
|
}
|
|
219
365
|
/**
|
|
@@ -277,10 +423,6 @@ export class L1Store {
|
|
|
277
423
|
return filtered.slice(0, limit);
|
|
278
424
|
}
|
|
279
425
|
}
|
|
280
|
-
/** RRF 原始分归一化到 0~1:双列表 rank1 命中 = 2/(k+1) → 1.0。 */
|
|
281
|
-
function normalizeRrf(rrfScore) {
|
|
282
|
-
return (rrfScore * (RRF_K + 1)) / 2;
|
|
283
|
-
}
|
|
284
426
|
/** FTS 阈值过滤(含官方小语料例外:全部低于阈值但结果数 ≤ maxResults 时保留)。 */
|
|
285
427
|
function applyFtsThreshold(hits, threshold, maxResults) {
|
|
286
428
|
if (threshold <= 0)
|