sage-mem 0.9.14 → 0.9.16
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.md +4 -3
- package/lib/index.js +72 -6
- package/lib/memory/bm25.js +208 -0
- package/lib/memory/chunks.js +217 -0
- package/lib/memory/search.js +401 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -159,9 +159,10 @@ SAGE_MEM_DIR=/path/to/your/memory
|
|
|
159
159
|
|
|
160
160
|
透明的东西值得说清楚它怎么选料,因为它直接决定注入多少 token:
|
|
161
161
|
|
|
162
|
-
-
|
|
163
|
-
-
|
|
164
|
-
-
|
|
162
|
+
- **两段式检索:先摘要,再正文**。第一段用 `description`(「一句话说清这条是什么」)+ 文件名 + `aliases`(别称)打分,最多取 **10 条**候选;第二段对这些候选做**正文段落检索**(BM25,中文按双字词元),把最相关的那一段注入。旧版拿问题里的双字去全文里蒙,长记忆天生占便宜——实测长记忆在全文匹配下连寒暄问句都能注入上千 token。新版比的是**段落**,每份文件最多贡献一段,长文不再靠段数多占候选位
|
|
163
|
+
- **摘要没写到的,正文能找回来**。摘要是精准的一句话,代价是正文细节天生检索不到;摘要一条都没命中时改由正文检索兜底——此前这种情况直接等于「没有记忆可用」。正文检索只在问题**至少 4 个字符**时才跑:`继续` 这种两个字的问题拿去做正文匹配,命中的全是「继续」这个词本身
|
|
164
|
+
- **换个说法也能命中**:打分是**词面重合**,不是语义的——问「我有什么待办」而文件里写的是「代办清单」,差一个字就完全不命中。给这类记忆加一行 `aliases: [待办, todo]` 就能补上
|
|
165
|
+
- **注入的是命中段落,不是文件开头**。命中段落标成 `### 项目_部署.md:40-52`,agent 据此能直接跳到那几行核对来源;段落前补一句该记忆的 `description`,补回「这段是从哪条记忆切出来的」。`baseline` 与没命中正文的文件仍注入开头。单条正文超 1500 字符截断并标 `…(截断)`
|
|
165
166
|
- **同一个问题不会重复注入**(按会话记签名),问第二遍不会又追加一遍
|
|
166
167
|
- **单场会话累计注入到 60 KB 就停**(`MAX_SESSION_BYTES`)。条数上限管的是「一次注多少」,这条管「一场会话总共注多少」—— 长会话里问题一直在换、签名一直在变,累积本来没有上限。到顶之后新的记忆不再注入(这时该让 agent 直接读文件),**压缩或清空之后账面自动归零**,又能注进来
|
|
167
168
|
- **每条记忆的标题带新鲜度**:`(保存于 N 天前)`,**优先读 frontmatter 的 `updated:`,缺失才回落文件 mtime** —— 手写的时间戳是权威,文件系统时间会被复制 / 备份还原 / checkout 整批改掉。理由是陈旧记忆被当事实引用时,「引用」这个动作本身会让它显得更权威而不是更可疑,所以注入文本开头还带一节**引用纪律**(提到路径先确认存在、提到函数先 grep、要动手先核实)。它**独立成节**而不是一条 bullet:Claude Code 实测同一句话埋成 bullet 命中 0/3、独立成节 3/3
|
package/lib/index.js
CHANGED
|
@@ -23,6 +23,7 @@ import { parseAliases, parseBaseline, parseBaselinePriority, parseFrontmatter, p
|
|
|
23
23
|
import { isReserved, nameKey, safeName } from './memory/naming.js'
|
|
24
24
|
import { ageDays, bigrams, extractText, score, selectBaseline, selectRelevant } from './memory/retrieval.js'
|
|
25
25
|
import { MEMORY_ARCHIVE_DIR, addArchiveMeta, extractTitle, inspectDir, inspectMemoryEntry, readArchivedMeta, scanArchived, scanMemoryFiles, scanStars, shadowsReserved } from './memory/scan.js'
|
|
26
|
+
import { rankChunks } from './memory/search.js'
|
|
26
27
|
import { archiveMemory, restoreMemory } from './memory/archive.js'
|
|
27
28
|
import { flushAccess, pickArchiveCandidates, readAccessLedger, recordAccess } from './memory/access.js'
|
|
28
29
|
import { warnLog, writeFileAtomic } from './memory/util.js'
|
|
@@ -48,6 +49,24 @@ function debugRecall(payload) {
|
|
|
48
49
|
console.log(`[sage-mem] recall ${JSON.stringify(payload)}`)
|
|
49
50
|
}
|
|
50
51
|
|
|
52
|
+
/**
|
|
53
|
+
* 按文件去重,保留每个文件**首次**(也就是得分最高)出现的那条正文命中。
|
|
54
|
+
*
|
|
55
|
+
* 正文检索是段落粒度的,同一份文件可能返回多个段落。注入链要的是「这份文件要不要端上来」,
|
|
56
|
+
* 所以在这里收成文件粒度 —— 而**不是**把同一文件的多个段落一起端上去:那正是
|
|
57
|
+
* `retrieval.js` 当初从「全文匹配」退回「只比摘要」要躲开的病(长文靠段数多占满候选位)。
|
|
58
|
+
*/
|
|
59
|
+
function dedupeByFile(hits) {
|
|
60
|
+
const seen = new Set()
|
|
61
|
+
const out = []
|
|
62
|
+
for (const h of hits) {
|
|
63
|
+
if (seen.has(h.file)) continue
|
|
64
|
+
seen.add(h.file)
|
|
65
|
+
out.push(h)
|
|
66
|
+
}
|
|
67
|
+
return out
|
|
68
|
+
}
|
|
69
|
+
|
|
51
70
|
/**
|
|
52
71
|
* 面板可改的 5 项注入上限 —— 与 `memory/config.js` 里的 `SAGE_MEM_*` 一一对应。
|
|
53
72
|
* 边界值照抄那边的 envInt 调用(lo/hi 不许在这里另立一套)。
|
|
@@ -280,11 +299,46 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
280
299
|
// 因签名变化而被重复注入。现在 baseline 由会话级标记保证只来一次,
|
|
281
300
|
// 检索由这份签名保证同一问题不重复追加,两条线互不干扰。
|
|
282
301
|
const relevant = text && text.length >= 2 ? selectRelevant(text, files) : []
|
|
283
|
-
|
|
302
|
+
|
|
303
|
+
// ── 正文检索 ──
|
|
304
|
+
// 摘要(description + aliases + 文件名)是最省的检索面,代价是**正文里的东西天生检索不到**。
|
|
305
|
+
// 这里用它做两件事:
|
|
306
|
+
// 1. 给候选文件定位「最相关的那一段」,注入时替代「文件开头 1500 字」——
|
|
307
|
+
// 旧写法对任何问题都端同一段开头,等于把「命中在哪」这件事扔掉了;
|
|
308
|
+
// 2. 摘要一条都没命中时,用正文兜一次底。此前这种情况直接等于「没有记忆可用」。
|
|
309
|
+
//
|
|
310
|
+
// query 短于 4 个字符不做正文匹配:`继续` 这种两个字的问题拿去匹配正文,
|
|
311
|
+
// 命中的全是「继续」这个词本身,与用户问什么毫无关系。
|
|
312
|
+
const bodyRanked = typeof text === 'string' && text.length >= 4
|
|
313
|
+
? rankChunks(text, files, { limit: MAX_RESULTS, perFile: 1 })
|
|
314
|
+
: []
|
|
315
|
+
const bestChunk = new Map()
|
|
316
|
+
for (const h of bodyRanked) if (!bestChunk.has(h.file)) bestChunk.set(h.file, h.chunk)
|
|
317
|
+
|
|
318
|
+
// 「摘要命中就按摘要排;一条都没有才让正文接手」这个**保守写法是错的**,
|
|
319
|
+
// 由评测数据推翻(scripts/eval-retrieval.mjs 的对照 + 注入链实测):
|
|
320
|
+
// 摘要只要命中**任何一条**(哪怕很弱、甚至命中错的文件),正文里那份真正相关的
|
|
321
|
+
// 就再也没有机会——应注入的文件级召回从 87.0% 掉到 60.9%。而噪音并没有因此更低
|
|
322
|
+
// (正文命中本来就要过相关性门槛,实测误召回 1/10,与保守写法持平)。
|
|
323
|
+
//
|
|
324
|
+
// 所以改成**并集**:摘要命中的在前(它经过人工写的 description,精确度高),
|
|
325
|
+
// 正文独有的在后(按 BM25 + 证据强度排序)。`take` 按文件去重,
|
|
326
|
+
// 同一份文件不会因为两条检索线都命中而占两个名额。
|
|
327
|
+
const byFile = new Map(files.map(f => [f.file, f]))
|
|
328
|
+
const selected = relevant.slice()
|
|
329
|
+
const taken = new Set(selected.map(f => f.file))
|
|
330
|
+
for (const h of dedupeByFile(bodyRanked)) {
|
|
331
|
+
if (taken.has(h.file)) continue
|
|
332
|
+
taken.add(h.file)
|
|
333
|
+
const f = byFile.get(h.file)
|
|
334
|
+
if (f) selected.push(f)
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const signature = text + '\u0000' + selected.map(f => f.file).join(',')
|
|
284
338
|
if (recallCache.get(session.id) !== signature) {
|
|
285
339
|
recallCache.set(session.id, signature)
|
|
286
340
|
if (recallCache.size > 8) recallCache.delete(recallCache.keys().next().value)
|
|
287
|
-
|
|
341
|
+
selected.forEach(take)
|
|
288
342
|
}
|
|
289
343
|
|
|
290
344
|
if (picked.length === 0) {
|
|
@@ -305,12 +359,24 @@ export class MemoryGateway extends TypertRemoteService {
|
|
|
305
359
|
const injectedFiles = []
|
|
306
360
|
let skippedForBudget = 0
|
|
307
361
|
for (const f of picked) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
362
|
+
// **baseline 不吃段落替换**:它的语义是「这条记忆每场会话都该完整在场」
|
|
363
|
+
// (你是谁、待办清单)。一旦用户的问句碰巧命中它,替换成单个段落就等于
|
|
364
|
+
// 把「完整在场」偷偷降级成「命中在场」—— 那正是 baseline 要防的事。
|
|
365
|
+
const chunk = f.baseline ? null : bestChunk.get(f.file)
|
|
366
|
+
// 命中段落优先;没有命中(baseline、或 query 太短没做正文检索)才回到「文件开头」。
|
|
367
|
+
// 旧写法对**任何**问题都端同一段开头,等于把「命中在哪」这个信息扔掉了。
|
|
368
|
+
const raw = chunk ? chunk.text : f.content
|
|
369
|
+
const body = raw.length > MAX_CHARS_PER_FILE
|
|
370
|
+
? raw.slice(0, MAX_CHARS_PER_FILE) + '\n…(截断)'
|
|
371
|
+
: raw
|
|
311
372
|
const days = ageDays(f, nowMs)
|
|
312
373
|
const stamp = days !== null && days >= STALE_DAYS ? `(保存于 ${days} 天前)` : ''
|
|
313
|
-
|
|
374
|
+
// 段落命中时补两样东西:
|
|
375
|
+
// - 行号:模型据此能直接用文件工具跳过去核对来源,「引用可核对」靠它成立;
|
|
376
|
+
// - 摘要:段落本身不含 frontmatter,补回「这一段是从哪条记忆里切出来的」。
|
|
377
|
+
const loc = chunk ? `:${chunk.startLine}-${chunk.endLine}` : ''
|
|
378
|
+
const brief = chunk && f.description ? ` · ${f.description.slice(0, 80)}` : ''
|
|
379
|
+
const part = `### ${f.file}${loc}${stamp}${brief}\n${body}`
|
|
314
380
|
const size = Buffer.byteLength(part, 'utf8')
|
|
315
381
|
if (usedBytes + size > MAX_SESSION_BYTES) { skippedForBudget++; continue }
|
|
316
382
|
usedBytes += size
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 词元化与 BM25 打分(纯函数,无 I/O)。
|
|
3
|
+
*
|
|
4
|
+
* 为什么不是「双字覆盖率」:`retrieval.js` 那套是**覆盖率**——分母是 query,不惩罚文档长度。
|
|
5
|
+
* 它在摘要(一句话)上工作良好,正是因为它没机会被长文本稀释;一旦拿它扫正文,
|
|
6
|
+
* 长文件什么都能蒙中。BM25 换掉了那个分母:词频饱和(同一词出现 20 次不等于 20 倍相关)
|
|
7
|
+
* 加上长度归一(长文件不加权),再配 IDF 让「到处都有的词」自动失效。
|
|
8
|
+
*
|
|
9
|
+
* 中文没有分词器可用(本插件零外部依赖),所以沿用双字词元。这是有代价的:
|
|
10
|
+
* 「部署超时」会切出「部署」「署超」「超时」,「署超」是跨词边界的假词元。
|
|
11
|
+
* 它极少在文档里命中,所以贡献接近 0 —— 这也是为什么下面额外给了一个**覆盖率小项**:
|
|
12
|
+
* 命中越多的 query 词元越可能是真的要找的那份,用来打破近分平局。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** BM25 词频饱和系数。1.2 是文献里的常用起点。 */
|
|
16
|
+
export const BM25_K1 = 1.2
|
|
17
|
+
|
|
18
|
+
/** BM25 长度归一系数。0.75 同上。两个值都是起始参数,不是实测结论。 */
|
|
19
|
+
export const BM25_B = 0.75
|
|
20
|
+
|
|
21
|
+
/** 覆盖率在最终得分里的权重。刻意很小:它只该打破近分平局,不该压过 IDF。 */
|
|
22
|
+
export const COVERAGE_WEIGHT = 0.2
|
|
23
|
+
|
|
24
|
+
/** 汉字 / 假名 / 谚文的连续段。 */
|
|
25
|
+
const CJK_RUN = /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+/u
|
|
26
|
+
|
|
27
|
+
/** 一段连续的「标识符式」字符:`lib/index.js`、`SAGE_MEM_DEBUG`、`0.9.14`、`FS_STALE_VERSION`。 */
|
|
28
|
+
const ASCII_RUN = /[A-Za-z0-9][A-Za-z0-9_.\-/:\\]*/g
|
|
29
|
+
|
|
30
|
+
/** 标识符内部的分隔符 —— 切开后作为补充词元(`lib/index.js` 也要能被 `index` 搜到)。 */
|
|
31
|
+
const ASCII_SEP = /[_.\-/:\\]+/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* 把文本切成词元数组(**保留重复**,BM25 要的是真实词频)。
|
|
35
|
+
*
|
|
36
|
+
* 两类文本走两条路:
|
|
37
|
+
* - CJK 连续段 → 双字词元;单字段落退化成那一个字。**按段切,不跨标点**,
|
|
38
|
+
* 否则「。我」这种跨句假词元也会进索引。
|
|
39
|
+
* - ASCII 串 → 整个原形 + 按分隔符切出的长度 ≥ 2 的片段。
|
|
40
|
+
* 原形保留是为了精确匹配错误码 / 路径 / 版本号;片段是为了「记得大概是哪个文件」也能搜到。
|
|
41
|
+
*/
|
|
42
|
+
export function tokenize(text) {
|
|
43
|
+
const s = String(text ?? '').toLowerCase()
|
|
44
|
+
if (!s) return []
|
|
45
|
+
const out = []
|
|
46
|
+
// CJK 段优先吃:ASCII 正则会绕过汉字,两者互不重叠。
|
|
47
|
+
const runs = s.match(/[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]+|[A-Za-z0-9][A-Za-z0-9_.\-/:\\]*/gu) ?? []
|
|
48
|
+
for (const run of runs) {
|
|
49
|
+
if (CJK_RUN.test(run)) {
|
|
50
|
+
if (run.length === 1) out.push(run)
|
|
51
|
+
else for (let i = 0; i < run.length - 1; i++) out.push(run.slice(i, i + 2))
|
|
52
|
+
continue
|
|
53
|
+
}
|
|
54
|
+
out.push(run)
|
|
55
|
+
for (const part of run.split(ASCII_SEP)) {
|
|
56
|
+
// 长度 1 的片段是噪音(`1.0.0` 会切出三个 `0`),它们 df 极高、IDF 近 0,纯属白占内存。
|
|
57
|
+
if (part.length >= 2 && part !== run) out.push(part)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return out
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 词元数组 → 词频表。 */
|
|
64
|
+
export function termFreq(tokens) {
|
|
65
|
+
const tf = new Map()
|
|
66
|
+
for (const t of tokens) tf.set(t, (tf.get(t) ?? 0) + 1)
|
|
67
|
+
return tf
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 语料统计:文档数 N、平均长度 avgdl、逐词元的文档频率 df。
|
|
72
|
+
*
|
|
73
|
+
* @param {Array<{tokens:string[]}>} docs
|
|
74
|
+
*/
|
|
75
|
+
export function buildStats(docs) {
|
|
76
|
+
const df = new Map()
|
|
77
|
+
let total = 0
|
|
78
|
+
for (const d of docs) {
|
|
79
|
+
total += d.tokens.length
|
|
80
|
+
// 同一文档里出现多次只算一次 —— df 的定义就是「多少篇文档含这个词元」。
|
|
81
|
+
for (const t of new Set(d.tokens)) df.set(t, (df.get(t) ?? 0) + 1)
|
|
82
|
+
}
|
|
83
|
+
const N = docs.length
|
|
84
|
+
return { N, avgdl: N > 0 ? total / N : 1, df }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** 单篇文档的 BM25 得分。queryTerms 应先去重。 */
|
|
88
|
+
export function scoreDoc(queryTerms, tf, dl, stats) {
|
|
89
|
+
if (!stats || stats.N === 0) return 0
|
|
90
|
+
let score = 0
|
|
91
|
+
for (const t of queryTerms) {
|
|
92
|
+
const f = tf.get(t)
|
|
93
|
+
if (!f) continue
|
|
94
|
+
const idf = idfOf(stats.df.get(t) ?? 0, stats.N)
|
|
95
|
+
score += (idf * (f * (BM25_K1 + 1))) / (f + BM25_K1 * (1 - BM25_B + (BM25_B * dl) / stats.avgdl))
|
|
96
|
+
}
|
|
97
|
+
return score
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 词元的逆文档频率。加 1 的对数形式:df 等于 N 时(到处都是的词元)趋近 0 而不为负。
|
|
102
|
+
*
|
|
103
|
+
* 单独抽出来是因为「证据强度」判据(`evidence`)要用同一个口径 —— 两处各写一遍
|
|
104
|
+
* 迟早会漂。
|
|
105
|
+
*/
|
|
106
|
+
export function idfOf(df, N) {
|
|
107
|
+
if (!N) return 0
|
|
108
|
+
return Math.log(1 + (N - df + 0.5) / (df + 0.5))
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* 证据强度:这份文档到底**凭什么**被认为相关。返回两个数,缺一不可。
|
|
113
|
+
*
|
|
114
|
+
* 为什么需要它:BM25 对**任何**问题都能给出非零分——只要有一个常用双字重合。
|
|
115
|
+
* 「今天天气怎么样」在 900 KB 的中文语料上必然能找到含「怎么」的段落。所以
|
|
116
|
+
* 「分数 > 0」不是可用的相关性判据,必须有下面这条独立门槛。
|
|
117
|
+
*
|
|
118
|
+
* `ratio` = 命中的词元权重 ÷ **在语料里存在过的** query 词元总权重。
|
|
119
|
+
* ⚠️ 分母**必须剔除 df=0 的词元**:语料里根本没有的词(错别字、没记过的术语)
|
|
120
|
+
* 带着最大 IDF 进分母,会把真正相关的题也压到 0.07 这种地板分上。
|
|
121
|
+
* `sum` = 命中词元的权重绝对和。比值高但绝对量小,等于「匹配上的全是到处都有的词」,
|
|
122
|
+
* 仍然不算证据 —— 两个条件同时满足才算过门槛。
|
|
123
|
+
*/
|
|
124
|
+
export function evidence(queryTerms, tf, stats) {
|
|
125
|
+
let have = 0
|
|
126
|
+
let total = 0
|
|
127
|
+
let rarestRatio = 1
|
|
128
|
+
let rarestDf = Number.POSITIVE_INFINITY
|
|
129
|
+
let matched = 0
|
|
130
|
+
for (const t of queryTerms) {
|
|
131
|
+
const df = stats?.df?.get(t) ?? 0
|
|
132
|
+
if (df === 0) continue // 语料里不存在 → 不进分母,也不进分子
|
|
133
|
+
const w = idfOf(df, stats.N)
|
|
134
|
+
total += w
|
|
135
|
+
if (tf.has(t)) {
|
|
136
|
+
have += w
|
|
137
|
+
matched++
|
|
138
|
+
rarestRatio = Math.min(rarestRatio, df / stats.N)
|
|
139
|
+
rarestDf = Math.min(rarestDf, df)
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return {
|
|
143
|
+
ratio: total > 0 ? have / total : 0,
|
|
144
|
+
sum: have,
|
|
145
|
+
/**
|
|
146
|
+
* 命中词元里**最冷门**那个的两个口径:文档频率占比,与绝对文档频率。
|
|
147
|
+
*
|
|
148
|
+
* 为什么两个都要:`df/N` 在小语料上会失真 —— 一个只有 5 份记忆的目录里,
|
|
149
|
+
* 「全库独一份」的词元算出来也是 0.2,于是真命中会被误判成「不够冷门」而整条被滤掉,
|
|
150
|
+
* 小目录的记忆检索直接失效。绝对 df 则在大语料上失真(同一个数字在 100 份与
|
|
151
|
+
* 2000 份语料里含义完全不同)。所以判据是**析取**:
|
|
152
|
+
* `df ≤ 1`(全库独一份,一定是有内容的)**或** `df/N ≤ 门槛`。
|
|
153
|
+
* 两个口径都由调用方拿到,怎么组合是策略层的事。
|
|
154
|
+
*
|
|
155
|
+
* 为什么单有 ratio/sum 不够:`红烧肉怎么做才好吃` 在中文语料上会命中 `怎么`/`么做`
|
|
156
|
+
* 这类到处都有的词元 —— 它们数量够多,`sum` 能堆到 4.8,而 `ratio` 会因为
|
|
157
|
+
* 「红烧/烧肉/好吃」在语料里压根不存在(df=0,被剔出分母)而等于 1.0。
|
|
158
|
+
* 两个数字都像是强证据,实际命中的**全是虚词**。内容词(连接池、温漂、
|
|
159
|
+
* FS_STALE_VERSION)天然冷门,虚词天然热门,区分度就在这里。
|
|
160
|
+
*/
|
|
161
|
+
rarestDfRatio: matched > 0 ? rarestRatio : 1,
|
|
162
|
+
rarestDf: matched > 0 ? rarestDf : Number.POSITIVE_INFINITY,
|
|
163
|
+
matched,
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** query 的独特词元里有多少比例出现在这篇文档里(0~1)。只用来打破近分平局。 */
|
|
168
|
+
export function coverageOf(queryTerms, tf) {
|
|
169
|
+
if (!queryTerms.length) return 0
|
|
170
|
+
let hit = 0
|
|
171
|
+
for (const t of queryTerms) if (tf.has(t)) hit++
|
|
172
|
+
return hit / queryTerms.length
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** query → 去重后的词元数组。 */
|
|
176
|
+
export function queryTermsOf(query) {
|
|
177
|
+
return [...new Set(tokenize(query))]
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 最长「连续命中」的**词元个数**(0 = 一个都没命中)。
|
|
182
|
+
*
|
|
183
|
+
* 为什么必须单独看连续性:中文双字词元会把跨词边界的假词元也造出来。
|
|
184
|
+
* 「明天会不会下雨」里就有两个——
|
|
185
|
+
* - `不会`:真实虚词,df 高;
|
|
186
|
+
* - `天会`:来自「那一天**会**缩短」的跨词边界巧合,df=1,于是**IDF 最大**。
|
|
187
|
+
* 单独看「有没有命中一个冷门词元」,`天会` 这种巧合恰好满足,于是一句完全无关的
|
|
188
|
+
* 天气问题能把潮汐笔记拉进结果。
|
|
189
|
+
*
|
|
190
|
+
* 连续性治的就是它:连续命中 k 个词元 = query 里**至少 k+1 个字连着**出现在文档里。
|
|
191
|
+
* 巧合通常孤立(k=1),真实内容词通常连成片段(`连接池` k=2、`压测时连接池` k=4)。
|
|
192
|
+
*
|
|
193
|
+
* ⚠️ 它也有代价:整句只对上一个两字专名时 k 只有 1,会被判成不连续。
|
|
194
|
+
* 所以门槛里它是**析取**的一部分,不是唯一判据。
|
|
195
|
+
*/
|
|
196
|
+
export function maxMatchedRun(queryTokens, tf) {
|
|
197
|
+
let best = 0
|
|
198
|
+
let cur = 0
|
|
199
|
+
for (const t of queryTokens) {
|
|
200
|
+
if (tf.has(t)) {
|
|
201
|
+
cur++
|
|
202
|
+
if (cur > best) best = cur
|
|
203
|
+
} else {
|
|
204
|
+
cur = 0
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return best
|
|
208
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 正文分段:把一份记忆切成可检索、可定位的段落。
|
|
3
|
+
*
|
|
4
|
+
* 为什么要有这一层:检索原先只比 `description` + `aliases` + 文件名(见 retrieval.js),
|
|
5
|
+
* 换来的是「噪音大幅下降」,代价是**摘要没写到的内容永远检索不到**。要补这个漏,
|
|
6
|
+
* 就得让正文参与匹配 —— 但**不能让整份文件参与匹配**:那会重演旧版的病
|
|
7
|
+
* (长文件什么都能蒙中)。所以改变评分单位:**用段落匹配问题,再把最佳段落归并回文件**,
|
|
8
|
+
* 而不是把一份文件的所有命中次数加起来。
|
|
9
|
+
*
|
|
10
|
+
* 三个必须守住的约束:
|
|
11
|
+
* 1. **行号是 1-based、相对整份文件**(含 frontmatter 那几行)。`read_memory` 的
|
|
12
|
+
* `startLine` 直接吃这个值,中间不许再换算一次 —— 换算是这类功能最常见的错位来源。
|
|
13
|
+
* 2. **revision 是内容 hash,不是路径或标题**。路径会改名、标题会改写,两者都不是身份。
|
|
14
|
+
* 3. **frontmatter 不进段落**。它的键名(`name` / `description` / `node_type`…)在每份
|
|
15
|
+
* 文件里都一样,进了正文索引就等于给每条记忆灌入同一批高频词,正好抬高通用词的 df。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { createHash } from 'node:crypto'
|
|
19
|
+
|
|
20
|
+
/** 目标段落长度(字符)。低于它就与邻居合并,避免碎片段把候选表塞满。 */
|
|
21
|
+
export const CHUNK_TARGET_CHARS = 600
|
|
22
|
+
|
|
23
|
+
/** 硬上限:超过就切。定在目标的 1.5 倍,留出「一行稍长就整段爆掉」的余量。 */
|
|
24
|
+
export const CHUNK_MAX_CHARS = 900
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 独立成段的门槛(字符)。
|
|
28
|
+
*
|
|
29
|
+
* 低于它的段落优先并入相邻段:一条 `- 记完了` 这样的项目符号独立成段,既占一个候选位、
|
|
30
|
+
* 又几乎没有任何可匹配的词。合并是**就近向上**(并进已经攒好的上一段),因为 markdown
|
|
31
|
+
* 里短行通常是上一段同一主题的补充。
|
|
32
|
+
*/
|
|
33
|
+
export const CHUNK_MIN_CHARS = 60
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 内容 revision:`sha256:<前 12 位>`。
|
|
37
|
+
*
|
|
38
|
+
* 12 位十六进制 = 48 bit,对本场景(一份记忆目录内区分版本)绰绰有余,而短到能塞进
|
|
39
|
+
* 注入的状态卡与工具响应里不心疼。**它只表示「内容变了」,不表示「更新了」** ——
|
|
40
|
+
* 加一行注释也会换 revision,这不是错误信息,是「你之前读到的正文已经不是现在这份」。
|
|
41
|
+
*/
|
|
42
|
+
export function revisionOf(content) {
|
|
43
|
+
const hex = createHash('sha256').update(String(content ?? ''), 'utf8').digest('hex')
|
|
44
|
+
return `sha256:${hex.slice(0, 12)}`
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* frontmatter 块的结束行(1-based);没有 frontmatter 返回 0。
|
|
49
|
+
*
|
|
50
|
+
* 判定刻意与 `frontmatter.js` 的正则同形(首行必须是整行 `---`,结束取**第一个**
|
|
51
|
+
* 行首 `---`),否则会出现「frontmatter 解析出来有 description,分段却把它当正文」
|
|
52
|
+
* 这种两边不一致的怪状态。
|
|
53
|
+
*/
|
|
54
|
+
export function frontmatterEndLine(lines) {
|
|
55
|
+
if (String(lines?.[0] ?? '').trim() !== '---') return 0
|
|
56
|
+
for (let i = 1; i < lines.length; i++) {
|
|
57
|
+
if (String(lines[i]).trim() === '---') return i + 1
|
|
58
|
+
}
|
|
59
|
+
return 0
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* 把一份记忆切成段落。
|
|
64
|
+
*
|
|
65
|
+
* @param {{file:string, content:string}} f — `scanMemoryFiles` 的条目
|
|
66
|
+
* @param {string} [knownRevision] — 调用方已经算过 revision 时传进来,省一次对整份正文的 sha256
|
|
67
|
+
* @returns {Array<{id:string,file:string,revision:string,headingPath:string,startLine:number,endLine:number,text:string}>}
|
|
68
|
+
*/
|
|
69
|
+
export function chunkFile(f, knownRevision) {
|
|
70
|
+
const file = String(f?.file ?? '')
|
|
71
|
+
const content = String(f?.content ?? '')
|
|
72
|
+
const revision = typeof knownRevision === 'string' && knownRevision ? knownRevision : revisionOf(content)
|
|
73
|
+
const lines = content.split(/\r?\n/)
|
|
74
|
+
const from = frontmatterEndLine(lines)
|
|
75
|
+
const out = []
|
|
76
|
+
// id 去重表:**必须去重**,因为「单行超长」那条路径会把一行按字符切成多片,
|
|
77
|
+
// 而它们共享同一个行号 → 光用行号当 id 会撞车(实测一份含 5000 字符单行的文件
|
|
78
|
+
// 产出 10 个片段、去重后只剩 2 个)。撞车会让「按 id 取回某一段」取到错的那片。
|
|
79
|
+
// 常见情形(一行以内)保持 `file#起-止` 的可读形式,只有撞车时才追加 `~n`。
|
|
80
|
+
const idSeen = new Map()
|
|
81
|
+
for (const block of mergeBlocks(splitBlocks(lines, from))) {
|
|
82
|
+
for (const piece of splitOne(block)) {
|
|
83
|
+
const base = `${file}#${piece.startLine}-${piece.endLine}`
|
|
84
|
+
const n = idSeen.get(base) ?? 0
|
|
85
|
+
idSeen.set(base, n + 1)
|
|
86
|
+
out.push({
|
|
87
|
+
id: n === 0 ? base : `${base}~${n}`,
|
|
88
|
+
file,
|
|
89
|
+
revision,
|
|
90
|
+
headingPath: piece.headingPath,
|
|
91
|
+
startLine: piece.startLine,
|
|
92
|
+
endLine: piece.endLine,
|
|
93
|
+
text: piece.text,
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
// 空段落(整块只有空白)不产出:它们没有可匹配的词,只会稀释 df。
|
|
98
|
+
return out.filter(c => c.text.length > 0)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 第一批:按标题与空行切出「原生块」。
|
|
103
|
+
*
|
|
104
|
+
* **每行带着自己的行号**(`{no, text}`),不是裸字符串 —— 合并与再切分之后行号仍要准确,
|
|
105
|
+
* 而「用内容反查行号」在有重复行的文件里必然出错(`---`、空行、重复的项目符号)。
|
|
106
|
+
*
|
|
107
|
+
* 围栏代码块里的空行不算分段点:一段代码被空行切成两半,两边都不再是完整的可读内容,
|
|
108
|
+
* 第二半的标题路径也指不对。
|
|
109
|
+
*/
|
|
110
|
+
function splitBlocks(lines, from) {
|
|
111
|
+
const blocks = []
|
|
112
|
+
const stack = []
|
|
113
|
+
let cur = null
|
|
114
|
+
let pendingHead = []
|
|
115
|
+
let inFence = false
|
|
116
|
+
const flush = () => {
|
|
117
|
+
if (cur && cur.raw.length) blocks.push(cur)
|
|
118
|
+
cur = null
|
|
119
|
+
}
|
|
120
|
+
for (let i = from; i < lines.length; i++) {
|
|
121
|
+
const line = lines[i]
|
|
122
|
+
if (/^\s*(```|~~~)/.test(line)) inFence = !inFence
|
|
123
|
+
if (!inFence) {
|
|
124
|
+
const h = line.match(/^(#{1,6})[ \t]+(.+?)[ \t]*$/)
|
|
125
|
+
if (h) {
|
|
126
|
+
flush()
|
|
127
|
+
const level = h[1].length
|
|
128
|
+
while (stack.length && stack[stack.length - 1].level >= level) stack.pop()
|
|
129
|
+
stack.push({ level, text: h[2] })
|
|
130
|
+
// **标题行本身要留在下一段的正文里**(不是只记进 headingPath)。
|
|
131
|
+
// 只记 headingPath 会丢字:一段横跨 `## 解决办法` 与 `## 无关小节` 的合并段落,
|
|
132
|
+
// 只有起始标题进索引,「解决办法」这四个字在检索面上等于不存在。
|
|
133
|
+
// 连续多个标题攒在一起,等下一条正文行来了再一起种进新块(避免产出只有标题的空段)。
|
|
134
|
+
pendingHead.push({ no: i + 1, text: line })
|
|
135
|
+
continue
|
|
136
|
+
}
|
|
137
|
+
if (line.trim() === '') {
|
|
138
|
+
flush()
|
|
139
|
+
continue
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!cur) {
|
|
143
|
+
cur = {
|
|
144
|
+
startLine: pendingHead[0]?.no ?? i + 1,
|
|
145
|
+
endLine: i + 1,
|
|
146
|
+
raw: pendingHead.slice(),
|
|
147
|
+
headingPath: stack.map(s => s.text).join(' › '),
|
|
148
|
+
}
|
|
149
|
+
pendingHead = []
|
|
150
|
+
}
|
|
151
|
+
cur.endLine = i + 1
|
|
152
|
+
cur.raw.push({ no: i + 1, text: line })
|
|
153
|
+
}
|
|
154
|
+
flush()
|
|
155
|
+
return blocks
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 第二批:过短的块往上一块合并,直到接近目标长度。 */
|
|
159
|
+
function mergeBlocks(rawBlocks) {
|
|
160
|
+
const out = []
|
|
161
|
+
for (const b of rawBlocks) {
|
|
162
|
+
const text = b.raw.map(l => l.text).join('\n').trim()
|
|
163
|
+
if (!text) continue
|
|
164
|
+
const prev = out[out.length - 1]
|
|
165
|
+
// 用 `\n\n` 而不是 `\n` 拼接:两块之间**原本就隔着空行**,用单换行拼起来会让
|
|
166
|
+
// 注入文本里两段粘成一整段。这条只在「合并」路径上出现;被切分的单块内部是连续行,
|
|
167
|
+
// 那边的 `\n` 才是对的 —— 两条路径互斥(合并只到 600,切分只对 > 900 的原始块)。
|
|
168
|
+
if (prev && text.length < CHUNK_MIN_CHARS && prev.text.length + text.length + 2 <= CHUNK_TARGET_CHARS) {
|
|
169
|
+
prev.text = `${prev.text}\n\n${text}`
|
|
170
|
+
prev.endLine = b.endLine
|
|
171
|
+
prev.raw = prev.raw.concat(b.raw)
|
|
172
|
+
continue
|
|
173
|
+
}
|
|
174
|
+
out.push({ startLine: b.startLine, endLine: b.endLine, headingPath: b.headingPath, raw: b.raw, text })
|
|
175
|
+
}
|
|
176
|
+
return out
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 第三批:超过硬上限的块按行切成多个片段。
|
|
181
|
+
*
|
|
182
|
+
* **单行超长的情形单独处理**:一份把日志或代码贴成一行的记忆,按行切分永远切不动
|
|
183
|
+
* (一个片段 = 那一整行),于是「长度归一」失效、候选位被一行占满。这里按字符硬切,
|
|
184
|
+
* 该行的行号由所有片段共享 —— 段落级的行号本来就不承诺精确定位到字节。
|
|
185
|
+
*/
|
|
186
|
+
function splitOne(block) {
|
|
187
|
+
if (block.text.length <= CHUNK_MAX_CHARS) return [block]
|
|
188
|
+
const out = []
|
|
189
|
+
let buf = []
|
|
190
|
+
let start = 0
|
|
191
|
+
let end = 0
|
|
192
|
+
const emit = () => {
|
|
193
|
+
if (!buf.length) return
|
|
194
|
+
const text = buf.join('\n').trim()
|
|
195
|
+
if (text) out.push({ startLine: start, endLine: end, headingPath: block.headingPath, text })
|
|
196
|
+
buf = []
|
|
197
|
+
}
|
|
198
|
+
for (const { no, text: line } of block.raw) {
|
|
199
|
+
if (line.length > CHUNK_MAX_CHARS) {
|
|
200
|
+
emit()
|
|
201
|
+
for (let i = 0; i < line.length; i += CHUNK_TARGET_CHARS) {
|
|
202
|
+
out.push({ startLine: no, endLine: no, headingPath: block.headingPath, text: line.slice(i, i + CHUNK_TARGET_CHARS) })
|
|
203
|
+
}
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
const pending = buf.length ? buf.join('\n').length + 1 : 0
|
|
207
|
+
if (pending + line.length > CHUNK_TARGET_CHARS) emit()
|
|
208
|
+
if (!buf.length) {
|
|
209
|
+
start = no
|
|
210
|
+
end = no
|
|
211
|
+
}
|
|
212
|
+
end = no
|
|
213
|
+
buf.push(line)
|
|
214
|
+
}
|
|
215
|
+
emit()
|
|
216
|
+
return out
|
|
217
|
+
}
|
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* sage-mem 记忆层 —— 正文检索:把「段落打分」收口成一个可复用、可评测的检索面。
|
|
3
|
+
*
|
|
4
|
+
* 两层分工(这是本模块最要紧的一条设计):
|
|
5
|
+
* - `rankChunks()` 返回**富对象**(段落 + 逐项分数 + 命中原因),给注入链和评测脚本用;
|
|
6
|
+
* - `retrieve()` 返回**线上形状**(wire shape),给 0.9.16 的 search_memory 工具用。
|
|
7
|
+
*
|
|
8
|
+
* 两者共用同一套排序,只是出口形状不同。**不要反过来让注入链去吃 wire shape** ——
|
|
9
|
+
* 那会把「响应契约」和「内部评分」焊在一起,以后改一个字段名就动到注入行为。
|
|
10
|
+
*
|
|
11
|
+
* 缓存:段落切分与词元化都很贵(中文双字词元在 900 KB 语料上是十万量级),
|
|
12
|
+
* 所以按**文件 revision 缓存**。文件没变就直接复用已切好的段落与词频表;
|
|
13
|
+
* 目录里任何一份文件变了,语料统计(df / avgdl)会跟着重算 —— 语料统计是全局量,
|
|
14
|
+
* 一篇文档的改动会影响所有词的 IDF。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { chunkFile, revisionOf } from './chunks.js'
|
|
18
|
+
import { buildStats, coverageOf, evidence, maxMatchedRun, queryTermsOf, scoreDoc, termFreq, tokenize, COVERAGE_WEIGHT } from './bm25.js'
|
|
19
|
+
import { score as summaryScore, selectRelevant } from './retrieval.js'
|
|
20
|
+
import { extractTitle } from './scan.js'
|
|
21
|
+
|
|
22
|
+
/** 工具响应的默认条数与上限(稿子 §5.2 的起始值)。 */
|
|
23
|
+
export const DEFAULT_LIMIT = 5
|
|
24
|
+
export const MAX_LIMIT = 10
|
|
25
|
+
|
|
26
|
+
/** 每个文件最多贡献几个段落 —— 不累加同一文件的所有段落得分,这是「长文不垄断」的关键。 */
|
|
27
|
+
export const PER_FILE_CHUNKS = 2
|
|
28
|
+
|
|
29
|
+
/** 单条片段的字符上限(稿子 §5.2 的起始值)。 */
|
|
30
|
+
export const EXCERPT_CHARS = 200
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* 相关性门槛 —— **BM25 分数不是可用的判据**。
|
|
34
|
+
*
|
|
35
|
+
* 原因很实在:BM25 对任何问题都能给出非零分,只要有一个常用双字重合。中文语料里
|
|
36
|
+
* 「怎么」「什么」「一个」这类词元的文档频率极高,任何问句都能撞上几个 ——
|
|
37
|
+
* 于是「分数 > 0」等于「几乎总是有结果」,无关问题会从「什么都不注」变成「注一堆噪音」。
|
|
38
|
+
*
|
|
39
|
+
* 三个条件必须同时满足:
|
|
40
|
+
* - `ratio` ≥ 0.35:在「语料里存在过的 query 词元」里,这份文档覆盖了足够大的权重份额;
|
|
41
|
+
* - `sum` ≥ 1.5 :命中的词元绝对信息量够,不是「全是到处都有的词」凑出来的比值;
|
|
42
|
+
* - `rarestDfRatio` ≤ 0.10:至少有一个命中词元是**冷门**的。这条专治一种漏网:
|
|
43
|
+
* 问句里一半的词在语料里压根不存在(df=0,被剔出分母),剩下的通用词恰好都命中,
|
|
44
|
+
* 于是 ratio=1.0、sum 靠数量堆高,看起来证据很强 —— 实际命中的全是虚词。
|
|
45
|
+
* - **连续命中 ≥ 2 个词元**(= query 里至少 3 个字连着出现在文档里)。这条治的是
|
|
46
|
+
* 中文双字词元的**跨词边界假词元**:「明天会不会下雨」里 `天会` 来自
|
|
47
|
+
* 「那一天**会**缩短」,df=1、IDF 最大,单看「有个冷门词元命中」它反而最"强"。
|
|
48
|
+
* 巧合通常孤立,真实内容词通常连成片段。
|
|
49
|
+
*
|
|
50
|
+
* ⚠️ **这三个数是起始参数,不是实测结论**,由 `scripts/eval-retrieval.mjs` 的对照
|
|
51
|
+
* 结果来定(计划里阶段一的用途就是这个)。要放宽就用 `rankChunks(..., { minEvidence: false })`
|
|
52
|
+
* 拿掉门槛,别去改这里的常量。
|
|
53
|
+
*
|
|
54
|
+
* 0.10 这个值来自一次对抗性扫参(16 份合成语料,其中 4 份**故意塞满通用词**,
|
|
55
|
+
* 让无关问句一定能找到词面重合):
|
|
56
|
+
*
|
|
57
|
+
* | 门槛 | 相关题留住 | 无关题误召回 |
|
|
58
|
+
* |---|---|---|
|
|
59
|
+
* | 无门槛 | 12/12 | 4/12 |
|
|
60
|
+
* | 只 ratio | 12/12 | 4/12 |
|
|
61
|
+
* | ratio+sum | 12/12 | 1/12(`红烧肉怎么做才好吃`)|
|
|
62
|
+
* | +rarest ≤ 0.15 | 12/12 | 1/12 |
|
|
63
|
+
* | **+rarest ≤ 0.10** | **12/12** | **0/12** |
|
|
64
|
+
* | +rarest ≤ 0.05 | 0/12 | 0/12 |
|
|
65
|
+
*
|
|
66
|
+
* 相关题命中的最冷门词元都是 0.0625,硬负例是 0.125 —— 0.10 落在分离带里。
|
|
67
|
+
* ⚠️ 该值在**小语料**上定出;真实语料的 df/N 量级不同(段落数越多,同一个词元的 df/N 越小),
|
|
68
|
+
* 阶段一的评测脚本要在真实量级上复核一次再定稿。
|
|
69
|
+
*/
|
|
70
|
+
export const MIN_EVIDENCE_RATIO = 0.35
|
|
71
|
+
export const MIN_EVIDENCE_SUM = 1.5
|
|
72
|
+
export const MAX_EVIDENCE_DF_RATIO = 0.1
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 最少要**连续命中**几个词元(默认 2 = query 里至少 3 个字连着出现在文档里)。
|
|
76
|
+
*
|
|
77
|
+
* 设为 1 就等于关掉这条判据。代价与理由见 `bm25.maxMatchedRun` 的注释。
|
|
78
|
+
*/
|
|
79
|
+
export const MIN_MATCHED_RUN = 2
|
|
80
|
+
|
|
81
|
+
/** 缓存文件数上限。语料只有 109 份时永不触发;它防的是目录被塞进成千上万份文件。 */
|
|
82
|
+
const MAX_CACHE_FILES = 1024
|
|
83
|
+
|
|
84
|
+
/** file → {revision, title, chunks:[{...,tokens,tf,dl}]} */
|
|
85
|
+
const chunkCache = new Map()
|
|
86
|
+
|
|
87
|
+
/** 语料统计缓存:`{sig, stats}`。sig 由参与的文件 revision 拼成,变了就重算。 */
|
|
88
|
+
let statsCache = { sig: '', stats: { N: 0, avgdl: 1, df: new Map() } }
|
|
89
|
+
|
|
90
|
+
/** 只给测试用:清掉进程内缓存,让「改了文件」的场景能从头验。 */
|
|
91
|
+
export function __resetSearchCacheForTest() {
|
|
92
|
+
chunkCache.clear()
|
|
93
|
+
statsCache = { sig: '', stats: { N: 0, avgdl: 1, df: new Map() } }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 取得全部段落与语料统计(带缓存)。
|
|
98
|
+
*
|
|
99
|
+
* @param {Array<{file:string,content:string,mtimeMs?:number}>} files
|
|
100
|
+
* @returns {{chunks:Array, stats:object, skipped:number, complete:boolean}}
|
|
101
|
+
*/
|
|
102
|
+
export function chunksOf(files) {
|
|
103
|
+
const list = Array.isArray(files) ? files : []
|
|
104
|
+
const chunks = []
|
|
105
|
+
const revisions = []
|
|
106
|
+
let skipped = 0
|
|
107
|
+
for (const f of list) {
|
|
108
|
+
const file = String(f?.file ?? '')
|
|
109
|
+
const content = String(f?.content ?? '')
|
|
110
|
+
if (!file) continue
|
|
111
|
+
const revision = revisionOf(content)
|
|
112
|
+
revisions.push(`${file}:${revision}`)
|
|
113
|
+
const hit = chunkCache.get(file)
|
|
114
|
+
if (hit && hit.revision === revision) {
|
|
115
|
+
for (const c of hit.chunks) chunks.push(c)
|
|
116
|
+
continue
|
|
117
|
+
}
|
|
118
|
+
try {
|
|
119
|
+
const title = extractTitle(content)
|
|
120
|
+
const built = chunkFile({ file, content }, revision).map(c => {
|
|
121
|
+
const tokens = tokenize(`${c.headingPath}\n${c.text}`)
|
|
122
|
+
return { ...c, title, tokens, tf: termFreq(tokens), dl: tokens.length }
|
|
123
|
+
})
|
|
124
|
+
chunkCache.set(file, { revision, title, chunks: built })
|
|
125
|
+
for (const c of built) chunks.push(c)
|
|
126
|
+
} catch {
|
|
127
|
+
// 切分失败的文件计入 skipped 并把 complete 置否 —— 检索结果必须能自报「我没查全」,
|
|
128
|
+
// 不能让一个坏文件静默地变成「这篇没命中」。
|
|
129
|
+
skipped++
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// LRU 也不做,只做整体清理:超限说明目录形态异常,重算比留着更安全。
|
|
133
|
+
if (chunkCache.size > MAX_CACHE_FILES) {
|
|
134
|
+
for (const key of [...chunkCache.keys()].slice(0, chunkCache.size - MAX_CACHE_FILES)) chunkCache.delete(key)
|
|
135
|
+
}
|
|
136
|
+
const sig = revisions.join('|')
|
|
137
|
+
if (statsCache.sig !== sig) {
|
|
138
|
+
statsCache = { sig, stats: buildStats(chunks) }
|
|
139
|
+
}
|
|
140
|
+
return { chunks, stats: statsCache.stats, skipped, complete: skipped === 0 }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* 正文排序:段落打分 → 每文件限量 → 全局裁剪。
|
|
145
|
+
*
|
|
146
|
+
* @param {string} query
|
|
147
|
+
* @param {Array} files — `scanMemoryFiles` 的结果
|
|
148
|
+
* @param {{perFile?:number, limit?:number}} [opts]
|
|
149
|
+
* @returns {Array<{chunk:object, file:string, revision:string, bm25:number, coverage:number, score:number, matchedBy:string[]}>}
|
|
150
|
+
*/
|
|
151
|
+
export function rankChunks(query, files, opts = {}) {
|
|
152
|
+
return rankChunksInternal(query, files, opts).hits
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* 与 `rankChunks` 同一套排序,但额外报告「被相关性门槛滤掉多少条」——
|
|
157
|
+
* `retrieve` 的 coverage 要如实说明「有候选但不够格」,而不是把它伪装成「没有候选」。
|
|
158
|
+
*
|
|
159
|
+
* `opts.minEvidence === false` 关掉门槛(评测脚本对照两组时要它)。
|
|
160
|
+
* `opts.limit` 只兜底、**不封顶**(`MAX_LIMIT` 是 `retrieve` 那一层的收敛口径):
|
|
161
|
+
* 内部调用要用 `Infinity` 拿完整排名。两个入口语义不同是有意的,别记混。
|
|
162
|
+
*/
|
|
163
|
+
export function rankChunksInternal(query, files, opts = {}) {
|
|
164
|
+
const perFile = Number.isFinite(opts.perFile) ? Math.max(1, opts.perFile) : PER_FILE_CHUNKS
|
|
165
|
+
// `Infinity` = 不切片,要完整排名(`retrieve` 用它算 `candidates` 与翻页)。
|
|
166
|
+
// 不能写成 `Number.isFinite(Infinity)` 那条分支:Infinity 不是有限数,会被当成「没传」回落默认值。
|
|
167
|
+
const limit = opts.limit === Infinity
|
|
168
|
+
? Infinity
|
|
169
|
+
: (Number.isFinite(opts.limit) ? Math.max(1, opts.limit) : DEFAULT_LIMIT)
|
|
170
|
+
const terms = queryTermsOf(query)
|
|
171
|
+
if (!terms.length) return { hits: [], filtered: 0, chunks: 0 }
|
|
172
|
+
// 有序词元(含重复)——「连续命中」判据要顺序,去重后的集合没有位置信息。
|
|
173
|
+
const queryTokens = tokenize(query)
|
|
174
|
+
const { chunks, stats } = chunksOf(files)
|
|
175
|
+
if (!chunks.length) return { hits: [], filtered: 0, chunks: 0 }
|
|
176
|
+
// `minEvidence` 三态:省略 → 用默认门槛;`false` → 完全不设门槛;
|
|
177
|
+
// 传对象 → 用自定义门槛(评测脚本扫参要用,免得为了换一个数字去改常量)。
|
|
178
|
+
const gate = opts.minEvidence === false
|
|
179
|
+
? null
|
|
180
|
+
: {
|
|
181
|
+
ratio: opts.minEvidence?.ratio ?? MIN_EVIDENCE_RATIO,
|
|
182
|
+
sum: opts.minEvidence?.sum ?? MIN_EVIDENCE_SUM,
|
|
183
|
+
maxDfRatio: opts.minEvidence?.maxDfRatio ?? MAX_EVIDENCE_DF_RATIO,
|
|
184
|
+
minRun: opts.minEvidence?.minRun ?? MIN_MATCHED_RUN,
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let filtered = 0
|
|
188
|
+
const scored = []
|
|
189
|
+
for (const c of chunks) {
|
|
190
|
+
const bm25 = scoreDoc(terms, c.tf, c.dl, stats)
|
|
191
|
+
if (bm25 <= 0) continue
|
|
192
|
+
const ev = evidence(terms, c.tf, stats)
|
|
193
|
+
// 冷门判据是**析取**:`df ≤ 1`(全库独一份)或 `df/N ≤ 门槛`。
|
|
194
|
+
// 只用比值会在小语料上把真命中全滤掉(5 份记忆的目录里,独一份的词元比值也是 0.2)。
|
|
195
|
+
const rare = ev.rarestDf <= 1 || ev.rarestDfRatio <= gate?.maxDfRatio
|
|
196
|
+
const run = maxMatchedRun(queryTokens, c.tf)
|
|
197
|
+
// `sum` 用的是 IDF,而 IDF **随语料规模对数增长** —— 绝对阈值同样不可迁移:
|
|
198
|
+
// 单段语料(N=1)里 df=1 的词元 idf 只有 0.288,一个原样命中的 `FS_STALE_VERSION`
|
|
199
|
+
// 四个词元加起来才 1.15,会被 `sum ≥ 1.5` 整条滤掉。
|
|
200
|
+
// 所以给 `sum` 一个**逃生口**:命中的是「全库独一份」的词元**且**连成片段(run 达标)时,
|
|
201
|
+
// 它就是强证据,不再看绝对量。这与 `rare` 的析取是同一个理由,只是换了个量。
|
|
202
|
+
const uniqueAndContiguous = ev.rarestDf <= 1 && run >= (gate?.minRun ?? 1)
|
|
203
|
+
if (gate && (ev.ratio < gate.ratio || (!uniqueAndContiguous && ev.sum < gate.sum) || !rare || run < gate.minRun)) {
|
|
204
|
+
filtered++
|
|
205
|
+
continue
|
|
206
|
+
}
|
|
207
|
+
const coverage = coverageOf(terms, c.tf)
|
|
208
|
+
scored.push({
|
|
209
|
+
chunk: c,
|
|
210
|
+
file: c.file,
|
|
211
|
+
revision: c.revision,
|
|
212
|
+
bm25,
|
|
213
|
+
coverage,
|
|
214
|
+
evidence: Number(ev.ratio.toFixed(4)),
|
|
215
|
+
evidenceSum: Number(ev.sum.toFixed(4)),
|
|
216
|
+
evidenceRarest: Number(ev.rarestDfRatio.toFixed(4)),
|
|
217
|
+
evidenceRarestDf: Number.isFinite(ev.rarestDf) ? ev.rarestDf : null,
|
|
218
|
+
evidenceRun: run,
|
|
219
|
+
score: bm25 + COVERAGE_WEIGHT * coverage,
|
|
220
|
+
matchedBy: exactTermHit(String(query), c.text) ? ['body', 'exact-term'] : ['body'],
|
|
221
|
+
})
|
|
222
|
+
}
|
|
223
|
+
scored.sort((a, b) => b.score - a.score)
|
|
224
|
+
|
|
225
|
+
// 每文件限量:同一份长文件用五个段落占满候选位,是「长文垄断」的另一种形态。
|
|
226
|
+
const perFileCount = new Map()
|
|
227
|
+
const kept = []
|
|
228
|
+
for (const hit of scored) {
|
|
229
|
+
const n = perFileCount.get(hit.file) ?? 0
|
|
230
|
+
if (n >= perFile) continue
|
|
231
|
+
perFileCount.set(hit.file, n + 1)
|
|
232
|
+
kept.push(hit)
|
|
233
|
+
if (kept.length >= limit) break
|
|
234
|
+
}
|
|
235
|
+
return { hits: kept, filtered, chunks: chunks.length }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* 摘要命中(复用 retrieval.js 的 `selectRelevant`,不另写一套打分)。
|
|
240
|
+
*
|
|
241
|
+
* 只额外做一件事:**说清是哪一栏命中的**。摘要命中与正文命中在展示层的处理不同
|
|
242
|
+
* (前者给指针就够,后者才值得端正文),所以这个理由是行为契约的一部分。
|
|
243
|
+
*/
|
|
244
|
+
export function summaryHits(query, files, limit = DEFAULT_LIMIT) {
|
|
245
|
+
if (!String(query ?? '').trim()) return []
|
|
246
|
+
return selectRelevant(query, files).slice(0, limit).map(f => {
|
|
247
|
+
const fileBase = f.file.replace(/\.md$/, '').replace(/[_-]/g, ' ')
|
|
248
|
+
const matchedBy = []
|
|
249
|
+
if (f.description && summaryScore(query, f.description) > 0) matchedBy.push('description')
|
|
250
|
+
if (Array.isArray(f.aliases) && f.aliases.length && summaryScore(query, f.aliases.join(' ')) > 0) matchedBy.push('alias')
|
|
251
|
+
if (summaryScore(query, fileBase) > 0) matchedBy.push('filename')
|
|
252
|
+
return {
|
|
253
|
+
file: f.file,
|
|
254
|
+
revision: revisionOf(String(f.content ?? '')),
|
|
255
|
+
score: f.score,
|
|
256
|
+
// 回落标签是 `summary` 而不是 `description`:`retrieval.js` 的打分是**三个字段去标点
|
|
257
|
+
// 拼成一串**之后再算双字,所以跨字段边界也能产生命中(`description: '收尾待'` +
|
|
258
|
+
// `aliases: ['办清单']` 会被 query「待办」选中)。逐字段复算时三个字段各自为 0,
|
|
259
|
+
// 回落成 `description` 就是在**谎报命中原因** —— 展示层会据此给出错的解释。
|
|
260
|
+
matchedBy: matchedBy.length ? matchedBy : ['summary'],
|
|
261
|
+
}
|
|
262
|
+
})
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* 检索。返回线上形状(稿子 §5.2)。
|
|
267
|
+
*
|
|
268
|
+
* scope 语义:
|
|
269
|
+
* - `summary` 只比摘要 —— 普通问题先走这条,最省;
|
|
270
|
+
* - `body` 只扫正文段落;
|
|
271
|
+
* - `all` 两者合并,**摘要优先、同文件不重复占位**。
|
|
272
|
+
*
|
|
273
|
+
* 「摘要优先」不是偏好,是成本:摘要命中的那条本来就要被注入,正文再给它一个位置等于
|
|
274
|
+
* 同一份证据占两个候选位。合并时同文件只保留一条,带上它最好的段落。
|
|
275
|
+
*/
|
|
276
|
+
export function retrieve(input = {}) {
|
|
277
|
+
const query = String(input.query ?? '')
|
|
278
|
+
const files = Array.isArray(input.files) ? input.files : []
|
|
279
|
+
const scope = normalizeScope(input.scope)
|
|
280
|
+
const limit = clampLimit(input.limit)
|
|
281
|
+
const perFile = Number.isFinite(input.perFile) ? Math.max(1, input.perFile) : PER_FILE_CHUNKS
|
|
282
|
+
const excerptChars = Number.isFinite(input.excerptChars) ? Math.max(40, input.excerptChars) : EXCERPT_CHARS
|
|
283
|
+
const cursor = Number.isFinite(input.cursor) && input.cursor > 0 ? Math.floor(input.cursor) : 0
|
|
284
|
+
|
|
285
|
+
const corpus = chunksOf(files)
|
|
286
|
+
const { skipped, complete } = corpus
|
|
287
|
+
const terms = queryTermsOf(query)
|
|
288
|
+
|
|
289
|
+
// ⚠️ 两路的候选池都必须**比本页窗口宽**,否则 `candidates` 是本页条数、`nextCursor` 永远是 null
|
|
290
|
+
// (summary 一路曾经就是这样:池宽 = `limit + cursor`,于是翻页根本翻不动)。
|
|
291
|
+
// 取满再切窗口,是本模块唯一正确的做法。
|
|
292
|
+
const bodyRanked = scope === 'summary'
|
|
293
|
+
? { hits: [], filtered: 0, chunks: 0 }
|
|
294
|
+
: rankChunksInternal(query, files, { perFile, limit: Infinity })
|
|
295
|
+
const body = bodyRanked.hits
|
|
296
|
+
const byFile = new Map()
|
|
297
|
+
for (const hit of body) if (!byFile.has(hit.file)) byFile.set(hit.file, hit)
|
|
298
|
+
|
|
299
|
+
// `Infinity` = 取满 `selectRelevant` 给的候选(它自己按 MAX_RESULTS 封顶)。
|
|
300
|
+
// 曾经这里写的是 `limit + cursor` —— 池宽恰好等于本页窗口,于是 total 恒等于 cursor+limit、
|
|
301
|
+
// `nextCursor` 恒为 null,翻页**根本翻不动**(只有第一页)。池宽必须大于窗口。
|
|
302
|
+
const summary = scope === 'body' ? [] : summaryHits(query, files, Infinity)
|
|
303
|
+
const summaryByFile = new Map()
|
|
304
|
+
for (const s of summary) if (!summaryByFile.has(s.file)) summaryByFile.set(s.file, s)
|
|
305
|
+
|
|
306
|
+
const ordered = []
|
|
307
|
+
for (const s of summaryByFile.values()) ordered.push(toHit(s.file, s, byFile.get(s.file), terms, excerptChars))
|
|
308
|
+
for (const [file, b] of byFile) {
|
|
309
|
+
if (summaryByFile.has(file)) continue
|
|
310
|
+
ordered.push(toHit(file, null, b, terms, excerptChars))
|
|
311
|
+
}
|
|
312
|
+
// 两类都命中 → 排前面(同一份证据被两条独立信号指向);其余按分数排。
|
|
313
|
+
ordered.sort((a, b) => Number(b.matchedBy.length > 1) - Number(a.matchedBy.length > 1) || b.score - a.score)
|
|
314
|
+
|
|
315
|
+
const total = ordered.length
|
|
316
|
+
const hits = ordered.slice(cursor, cursor + limit)
|
|
317
|
+
return {
|
|
318
|
+
ok: true,
|
|
319
|
+
scope,
|
|
320
|
+
cursor,
|
|
321
|
+
coverage: {
|
|
322
|
+
searched: files.length,
|
|
323
|
+
skipped,
|
|
324
|
+
complete,
|
|
325
|
+
chunks: corpus.chunks.length,
|
|
326
|
+
// 被相关性门槛滤掉的条数:必须如实报出来,否则「不够格」会被读成「没有候选」——
|
|
327
|
+
// 这两件事对模型下一步该干什么的指示完全不同。
|
|
328
|
+
filteredBelowFloor: bodyRanked.filtered,
|
|
329
|
+
candidates: total,
|
|
330
|
+
},
|
|
331
|
+
hits,
|
|
332
|
+
nextCursor: cursor + limit < total ? cursor + limit : null,
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function toHit(file, summary, body, terms, excerptChars) {
|
|
337
|
+
const score = (summary?.score ?? 0) + (body?.score ?? 0)
|
|
338
|
+
const matchedBy = [...(summary?.matchedBy ?? []), ...(body?.matchedBy ?? [])]
|
|
339
|
+
const text = body?.chunk?.text ?? ''
|
|
340
|
+
return {
|
|
341
|
+
id: body?.chunk?.id ?? `${file}#summary`,
|
|
342
|
+
file,
|
|
343
|
+
revision: body?.revision ?? summary?.revision ?? '',
|
|
344
|
+
title: body?.chunk?.title ?? '',
|
|
345
|
+
headingPath: body?.chunk?.headingPath ?? '',
|
|
346
|
+
lines: body?.chunk ? [body.chunk.startLine, body.chunk.endLine] : null,
|
|
347
|
+
excerpt: text ? excerptAround(text, terms, excerptChars) : '',
|
|
348
|
+
// 证据强度:调用方要能判别「这条凭什么算相关」,而不只看到一个分数。
|
|
349
|
+
evidence: body?.evidence ?? null,
|
|
350
|
+
evidenceSum: body?.evidenceSum ?? null,
|
|
351
|
+
evidenceRarest: body?.evidenceRarest ?? null,
|
|
352
|
+
evidenceRarestDf: body?.evidenceRarestDf ?? null,
|
|
353
|
+
evidenceRun: body?.evidenceRun ?? null,
|
|
354
|
+
score: Number(score.toFixed(4)),
|
|
355
|
+
matchedBy: matchedBy.length ? matchedBy : ['body'],
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** scope 归一:只认三个值,其余一律当 `all`(**不悄悄降级成 summary**,见稿子 §5.2)。 */
|
|
360
|
+
export function normalizeScope(raw) {
|
|
361
|
+
const s = String(raw ?? '').trim().toLowerCase()
|
|
362
|
+
return s === 'summary' || s === 'body' ? s : 'all'
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function clampLimit(raw) {
|
|
366
|
+
const n = Number(raw)
|
|
367
|
+
if (!Number.isFinite(n)) return DEFAULT_LIMIT
|
|
368
|
+
return Math.max(1, Math.min(MAX_LIMIT, Math.round(n)))
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* 片段:以**第一个命中位置**为中心截取,而不是永远取开头。
|
|
373
|
+
*
|
|
374
|
+
* 取开头是错的:一份 900 字的段落里第 600 字才提到那个错误码,给的片段里没有它 ——
|
|
375
|
+
* 模型看不到命中在哪,只能把整段读回来,正好抵消掉「先给片段」省下的钱。
|
|
376
|
+
*/
|
|
377
|
+
export function excerptAround(text, terms, maxChars) {
|
|
378
|
+
const s = String(text ?? '')
|
|
379
|
+
if (s.length <= maxChars) return s
|
|
380
|
+
// 小写化**可能改变字符串长度**(`İ` → `i̇` 这类少数语言环境相关字符)。长度一变,
|
|
381
|
+
// 在 lowercase 串上找到的偏移映射回原串就是错位的。宁可退回「取开头」,
|
|
382
|
+
// 也不要给出一个从错误位置切出来的片段——片段的价值全在「命中词在不在里面」。
|
|
383
|
+
const lower = s.toLowerCase()
|
|
384
|
+
const hay = lower.length === s.length ? lower : s
|
|
385
|
+
let at = -1
|
|
386
|
+
for (const t of terms ?? []) {
|
|
387
|
+
const i = hay.indexOf(t)
|
|
388
|
+
if (i >= 0 && (at < 0 || i < at)) at = i
|
|
389
|
+
}
|
|
390
|
+
if (at < 0) return s.slice(0, maxChars) + '…'
|
|
391
|
+
const lead = Math.floor(maxChars / 3)
|
|
392
|
+
const from = Math.max(0, at - lead)
|
|
393
|
+
return (from > 0 ? '…' : '') + s.slice(from, from + maxChars) + (from + maxChars < s.length ? '…' : '')
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/** 整段 query 原样出现(去掉首尾空白、大小写归一)—— 错误码 / 路径 / 版本号的强信号。 */
|
|
397
|
+
function exactTermHit(query, text) {
|
|
398
|
+
const q = query.trim().toLowerCase().replace(/\s+/g, ' ')
|
|
399
|
+
if (q.length < 2) return false
|
|
400
|
+
return String(text ?? '').toLowerCase().replace(/\s+/g, ' ').includes(q)
|
|
401
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "sage-mem",
|
|
3
3
|
"description": "File-based memory plugin for DeepSeek Harness (DSH) — cross-session memory in plain markdown files, with a memory star-map view, type/tag filtering, retrieval aliases, and an optional autodream pass that consolidates memories on demand or on a schedule — with per-run rollback points, a per-file change declaration (what changed and why), and a separately selectable model for the consolidation pass",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.16",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"exports": {
|