ai-helper-agent 2.2.0 → 2.3.1
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 +94 -1
- package/README.zh-CN.md +94 -1
- package/dist/assets/{agent-store-DOyMdB_7.js → agent-store-DcAs6YyA.js} +2 -2
- package/dist/assets/{agent-store-DOyMdB_7.js.map → agent-store-DcAs6YyA.js.map} +1 -1
- package/dist/assets/background.js +83 -89
- package/dist/assets/background.js.map +1 -1
- package/dist/assets/content.js +81 -65
- package/dist/assets/{i18n-MmYITlVj.js → i18n-C4jSRoCH.js} +5 -5
- package/dist/assets/i18n-C4jSRoCH.js.map +1 -0
- package/dist/assets/{model-profiles-CV_sUuPf.js → model-profiles-Dl_MsXWa.js} +5 -5
- package/dist/assets/model-profiles-Dl_MsXWa.js.map +1 -0
- package/dist/assets/options-8-nu3P5K.css +1 -0
- package/dist/assets/options.js +264 -197
- package/dist/assets/options.js.map +1 -1
- package/dist/assets/{page-snapshot-renderer-CE53ibi-.js → page-snapshot-renderer-CVL6r6Kf.js} +2 -2
- package/dist/assets/{page-snapshot-renderer-CE53ibi-.js.map → page-snapshot-renderer-CVL6r6Kf.js.map} +1 -1
- package/dist/assets/{session-store-B0ZPGm7d.js → session-store-CzVQCgQ_.js} +4 -4
- package/dist/assets/session-store-CzVQCgQ_.js.map +1 -0
- package/dist/assets/side_panel.css +1 -1
- package/dist/assets/side_panel.js +327 -293
- package/dist/assets/side_panel.js.map +1 -1
- package/dist/assets/version-CNG2KGnJ.js +2 -0
- package/dist/assets/version-CNG2KGnJ.js.map +1 -0
- package/dist/libs/jspdf.min.js +1 -1
- package/dist/manifest.json +2 -2
- package/dist/offscreen/offscreen.js +4 -4
- package/dist/options.html +140 -3
- package/dist/side_panel.html +27 -4
- package/package.json +1 -1
- package/src/config.js +72 -24
- package/src/crypto-lite.js +31 -0
- package/src/locales/en.json +55 -0
- package/src/locales/zh.json +55 -0
- package/src/memory/embedding-config.js +95 -0
- package/src/memory/fusion.js +60 -0
- package/src/memory/injection.js +118 -0
- package/src/memory/routes.js +193 -0
- package/src/memory/service.js +957 -0
- package/src/memory/tags.js +62 -0
- package/src/memory/tier.js +37 -0
- package/src/memory/tokens.js +158 -0
- package/src/memory/vectors.js +128 -0
- package/src/rag/embedding/index.js +7 -0
- package/src/rag/embedding/local.js +25 -0
- package/src/rag/keyword-match.js +112 -0
- package/src/rag/manager.js +15 -0
- package/src/rag/searcher.js +15 -87
- package/src/server.js +6 -0
- package/dist/assets/i18n-MmYITlVj.js.map +0 -1
- package/dist/assets/model-profiles-CV_sUuPf.js.map +0 -1
- package/dist/assets/options-Df_1Dtn8.css +0 -1
- package/dist/assets/session-store-B0ZPGm7d.js.map +0 -1
- /package/dist/_locales/{zh → zh_CN}/messages.json +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// agent/src/memory/tags.js - 标签在记忆检索里的角色(单一来源)
|
|
2
|
+
//
|
|
3
|
+
// 2026-10-14 口径变更(用户定的检索取向):tags 从「打分前的硬过滤」降为「名次加成」。
|
|
4
|
+
// 原缺陷:标签过滤发生在任何打分之前,于是它与「词面命中门槛」「语义地板」构成三个硬门槛的
|
|
5
|
+
// AND 叠加——一条内容正确、语义也正确的记忆,只因为没打上请求里的标签就被整条抹掉。
|
|
6
|
+
// 现在的分工:
|
|
7
|
+
// 1) 有 query 时标签不再剔除候选,只按重叠比例把条目往上抬有限几名(见 applyTagRankLift);
|
|
8
|
+
// 2) 无 query 时标签是唯一选择器,属枚举语义,保持硬过滤(见 filterByTags 的调用方);
|
|
9
|
+
// 3) 本模块不得参与词面通道的 df 统计池:df 的判据是 `d > 0 && d <= limit`,一个只出现在
|
|
10
|
+
// 非标签条目里的词在标签子集上 d 为 undefined 会被直接拒——那等于新方案想捞回的条目
|
|
11
|
+
// 只能靠与标签子集共享的词才能命中,跟硬过滤差不了多少。df 继续走全候选池(已标定口径)。
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 全量标签命中最多上抬的名次数。
|
|
15
|
+
* 1 位太弱(几乎抬不动),3 位以上接近「标签命中一律排前」——那正是本次要避免的形态。
|
|
16
|
+
*/
|
|
17
|
+
export const TAG_RANK_LIFT = 2;
|
|
18
|
+
|
|
19
|
+
/** 标签大小写/空白不敏感,与迁移前的比较口径保持一致 */
|
|
20
|
+
function normalizeTag(value) {
|
|
21
|
+
return String(value ?? '').trim().toLowerCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** 调用方是否提出了标签请求(非数组、空数组都视为没有) */
|
|
25
|
+
export function hasTagRequest(requestedTags) {
|
|
26
|
+
return Array.isArray(requestedTags) && requestedTags.length > 0;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 重叠比例:命中的请求标签数 / 请求标签总数,值域 [0,1]。
|
|
31
|
+
* 分母取请求数而不是命中数,这样「请求三个只中一个」弱于「请求三个中三个」。
|
|
32
|
+
*/
|
|
33
|
+
export function tagOverlapRatio(memoryTags, requestedTags) {
|
|
34
|
+
const requested = (Array.isArray(requestedTags) ? requestedTags : []).map(normalizeTag).filter(Boolean);
|
|
35
|
+
const owned = new Set((Array.isArray(memoryTags) ? memoryTags : []).map(normalizeTag).filter(Boolean));
|
|
36
|
+
if (requested.length === 0 || owned.size === 0) return 0;
|
|
37
|
+
let hits = 0;
|
|
38
|
+
for (const tag of requested) if (owned.has(tag)) hits += 1;
|
|
39
|
+
return hits / requested.length;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 标签至少沾一个才算命中(与迁移前的 some() 语义逐字等价) */
|
|
43
|
+
export function filterByTags(candidates = [], requestedTags) {
|
|
44
|
+
// 无标签请求时返回全量而不是空集:调用方一旦漏了前置判断,也不能把整个结果集抹掉
|
|
45
|
+
if (!hasTagRequest(requestedTags)) return candidates.slice();
|
|
46
|
+
return candidates.filter(m => tagOverlapRatio(m?.tags, requestedTags) > 0);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* 按标签重叠比例上抬名次。输入必须是已经排好名的列表(词面单通道序或 RRF 融合序)。
|
|
51
|
+
* 不修改入参:调用方还要用原名次做别的判定。
|
|
52
|
+
*/
|
|
53
|
+
export function applyTagRankLift(ranked = [], requestedTags) {
|
|
54
|
+
if (!hasTagRequest(requestedTags)) return ranked.slice();
|
|
55
|
+
const moved = ranked.map((memory, index) => {
|
|
56
|
+
const lift = Math.round(TAG_RANK_LIFT * tagOverlapRatio(memory?.tags, requestedTags));
|
|
57
|
+
return { memory, index, lift, key: index - lift };
|
|
58
|
+
});
|
|
59
|
+
// 同 key 时优先被抬上来的那条:否则抬 1 位会与它让开的那条并列,按稳定序纹丝不动,加成等于没生效
|
|
60
|
+
moved.sort((a, b) => (a.key - b.key) || (b.lift - a.lift) || (a.index - b.index));
|
|
61
|
+
return moved.map(m => m.memory);
|
|
62
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// agent/src/memory/tier.js - 记忆召回通道分级决策(纯函数,无 IO)
|
|
2
|
+
// none = 只走关键词通道(与下推完成后的历史行为逐字一致,默认档);
|
|
3
|
+
// remote/local = 走双通道排名融合(关键词名次 + 语义名次,见 fusion.js)。
|
|
4
|
+
// 判定只看配置 + RAG 依赖是否就位。本模块不管采信条件:逐条候选的绝对余弦阈值已被实测否定,
|
|
5
|
+
// 采信规则单点在 fusion.js,不在这里留任何常量。
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* 决定当前配置能用到哪一档召回通道。
|
|
9
|
+
* @param {object} cfg memoryEmbedding 配置(getMemoryEmbeddingConfig() 的形态)
|
|
10
|
+
* @param {boolean} ragAvailable 本地模型依赖(transformers/vectra)是否已安装
|
|
11
|
+
* @returns {'none'|'remote'|'local'}
|
|
12
|
+
*/
|
|
13
|
+
export function resolveMemoryTier(cfg = null, ragAvailable = false) {
|
|
14
|
+
const c = cfg || {};
|
|
15
|
+
// enabled 只认真 true:saveMemoryEmbeddingConfig 也按同一口径落盘
|
|
16
|
+
if (c.enabled !== true) return 'none';
|
|
17
|
+
|
|
18
|
+
if (c.mode === 'openai-compat') {
|
|
19
|
+
const hasKey = String(c.apiKey || '').trim().length > 0;
|
|
20
|
+
const hasModel = String(c.modelName || '').trim().length > 0;
|
|
21
|
+
// 远端模式不依赖 RAG 包,只要有端点配置就能开向量通道
|
|
22
|
+
return hasKey && hasModel ? 'remote' : 'none';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (c.mode === 'local') {
|
|
26
|
+
// 本地模型需要 RAG 依赖;依赖缺失时静默降级,不报错、不提示(UI 侧自行展示当前档位)
|
|
27
|
+
// 取“真值”而非 === true:调用方是 isRagAvailable()(布尔),但允许包装层传回其它真值而不被误降级
|
|
28
|
+
return ragAvailable ? 'local' : 'none';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
return 'none';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 是否需要计算/使用向量 */
|
|
35
|
+
export function isVectorTier(tier) {
|
|
36
|
+
return tier === 'remote' || tier === 'local';
|
|
37
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
// agent/src/memory/tokens.js - 记忆关键词通道的分词与计分(单一来源)
|
|
2
|
+
//
|
|
3
|
+
// 2026-10-13 口径变更:从「把 query 炸成最多 30 个碎 bigram、每个命中 +3」改为
|
|
4
|
+
// 「最长命中片段的单元制」,并与知识库共用同一份匹配实现(rag/keyword-match.js)。
|
|
5
|
+
// 变更动机(实测于真实记忆库,见 2026-10-13-memory-keyword-parity.md):
|
|
6
|
+
// 碎 bigram 下「写网」「个国」「我查」这类随机 2 字块几乎必然命中某些长条目,
|
|
7
|
+
// 于是任何 query 都能捞回一堆无关记忆;且命中 4 个随机碎块(+12)高于命中一个
|
|
8
|
+
// 4 字整词(+3),排序方向是反的。知识库侧 2026-09 已修掉这两点。
|
|
9
|
+
// 停用词表随之删除:单元数按命中长度分层后,虚词无法单独构成足够长的片段,
|
|
10
|
+
// 而两侧共用一份切段口径比记忆侧再叠一层词表更不容易漂移。
|
|
11
|
+
|
|
12
|
+
import { extractQueryTerms, matchLatin, matchCjk, KEYWORD_MAX_HITS } from '../rag/keyword-match.js';
|
|
13
|
+
|
|
14
|
+
/** 字段权重:正文 > 标题 = 标签(整数刻度,与向量分融合时同为「分」这一单位) */
|
|
15
|
+
export const FIELD_WEIGHT = { content: 3, title: 2, tags: 2 };
|
|
16
|
+
|
|
17
|
+
/** 整句原样命中的额外加分:正文 +5、标题 +3(沿用迁移前的权重) */
|
|
18
|
+
export const ORIGINAL_QUERY_CONTENT_BONUS = 5;
|
|
19
|
+
export const ORIGINAL_QUERY_TITLE_BONUS = 3;
|
|
20
|
+
export const ORIGINAL_QUERY_MIN_LENGTH = 2;
|
|
21
|
+
|
|
22
|
+
/** 高频单元剔除:与知识库同参数——命中过半条目的单元无区分度,df ≤ 2 恒保留 */
|
|
23
|
+
export const KEYWORD_MAX_DF_RATIO = 0.5;
|
|
24
|
+
export const KEYWORD_DF_EXEMPT = 2;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 命中门槛:单元数 ≥ 2,或存在一个「完整单元」命中。
|
|
28
|
+
* 单元身份是**查询单元**(拉丁词 / 中文连续段),其单元数取它在任一字段里达到的最强值;
|
|
29
|
+
* 不得按字段累加——同一个 2 字片段在正文和标签各命中一次就凑出 2,会把这个门槛旁路掉(实测缺陷)。
|
|
30
|
+
* 实测于 Task 2 评测集(计划 Task 3 Step 3):只看 score > 0 时 3 条零重叠查询仍会
|
|
31
|
+
* 捣回仅命中 2 字碎块的长条目;纯要求 units ≥ 2 能抹平噪声,但会把「单个英文词」
|
|
32
|
+
* 「单个 2 字中文词」这类短查询误杀。加 full 分支后两者兼得,10/10 全绿。
|
|
33
|
+
*/
|
|
34
|
+
export const KEYWORD_MIN_UNITS = 2;
|
|
35
|
+
|
|
36
|
+
/** 参与词面匹配的字段面(也是参与 embedding 的字段面,两侧口径必须一致) */
|
|
37
|
+
export function memorySearchText(m) {
|
|
38
|
+
return [m?.content, m?.title, (m?.tags || []).join(' ')].filter(Boolean).join('\n');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 逐字段文本(小写):字段权重按此分别计算 */
|
|
42
|
+
function fieldTexts(m) {
|
|
43
|
+
return {
|
|
44
|
+
content: String(m?.content || '').toLowerCase(),
|
|
45
|
+
title: String(m?.title || '').toLowerCase(),
|
|
46
|
+
tags: (m?.tags || []).join(' ').toLowerCase()
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 查询拆段:拉丁整词 + 中文连续段(与知识库同一实现) */
|
|
51
|
+
export function extractMemoryTerms(query) {
|
|
52
|
+
return extractQueryTerms(query);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* 单字段内的逐查询单元命中明细:词边界拉丁命中 + 中文最长片段。
|
|
57
|
+
* unit 是稳定的单元身份(拉丁按词、中文按连续段),前缀 l:/c: 防止两种形态互相撞车;
|
|
58
|
+
* 它是**查询单元**而不是命中片段——按片段计会把「检索」「方案」两个随机 2 字块当成两个单元,
|
|
59
|
+
* 正是 2026-10-13 碎块噪声要消除的东西,所以这里不能改。
|
|
60
|
+
*/
|
|
61
|
+
function fieldUnitHits(text, terms, dfFilter) {
|
|
62
|
+
if (!text) return [];
|
|
63
|
+
const out = [];
|
|
64
|
+
for (const term of matchLatin(text, terms.latin)) {
|
|
65
|
+
if (!dfFilter(term)) continue;
|
|
66
|
+
// 拉丁命中本身就是整词命中(词边界已保证),不是碎块
|
|
67
|
+
out.push({ unit: 'l:' + term, units: 1, full: true });
|
|
68
|
+
}
|
|
69
|
+
for (const seg of terms.cjk) {
|
|
70
|
+
const hit = matchCjk(text, seg);
|
|
71
|
+
if (!hit || !hit.keys.some(k => dfFilter(k))) continue;
|
|
72
|
+
out.push({ unit: 'c:' + seg, units: hit.units, full: seg.length === hit.keys[0].length });
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* 构建文档频率过滤器:统计每个命中单元出现在多少条候选里,过半者判为无区分度。
|
|
79
|
+
* 必须在计分前对全候选扫一遍——单条记忆自身无法判断某词是否常见。
|
|
80
|
+
* @param {Array} candidates 本次召回的候选记忆
|
|
81
|
+
* @param {{latin: string[], cjk: string[]}} terms
|
|
82
|
+
* @returns {{keep: (key: string) => boolean, hasDiscriminative: boolean}}
|
|
83
|
+
* keep 该单元是否具备区分度;hasDiscriminative 是否**至少有一个**单元具备区分度
|
|
84
|
+
* (全塌信号:整个查询的词形区分度都不成立,调用方据此决定是否忽略 df)
|
|
85
|
+
*/
|
|
86
|
+
export function buildDocFreqFilter(candidates = [], terms = { latin: [], cjk: [] }) {
|
|
87
|
+
const df = new Map();
|
|
88
|
+
for (const m of candidates) {
|
|
89
|
+
const hay = memorySearchText(m).toLowerCase();
|
|
90
|
+
if (!hay) continue;
|
|
91
|
+
for (const term of matchLatin(hay, terms.latin)) df.set(term, (df.get(term) || 0) + 1);
|
|
92
|
+
for (const seg of terms.cjk) {
|
|
93
|
+
const hit = matchCjk(hay, seg);
|
|
94
|
+
if (hit) for (const k of hit.keys) df.set(k, (df.get(k) || 0) + 1);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const limit = Math.max(KEYWORD_DF_EXEMPT, candidates.length * KEYWORD_MAX_DF_RATIO);
|
|
98
|
+
// d > 0:未被任何候选碰过的单元不参与判定;d ≤ limit:过半者剔除
|
|
99
|
+
const keep = (key) => {
|
|
100
|
+
const d = df.get(key);
|
|
101
|
+
return d > 0 && d <= limit;
|
|
102
|
+
};
|
|
103
|
+
// 全塌判定:df 表里一个幸存单元都没有。df 的键只会在被某条候选碰到时才写入,所以 d > 0 恒成立,
|
|
104
|
+
// 这里等价于「有没有任何一个键没过 limit」。注意「查询词全库无人碰」也算全塌——那个词本来就不具备区分度。
|
|
105
|
+
let hasDiscriminative = false;
|
|
106
|
+
for (const d of df.values()) {
|
|
107
|
+
if (d <= limit) { hasDiscriminative = true; break; }
|
|
108
|
+
}
|
|
109
|
+
return { keep, hasDiscriminative };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 记忆关键词分:字段内命中单元和(封顶 KEYWORD_MAX_HITS)× 字段权重,跨字段累加;
|
|
114
|
+
* 另加整句原样命中分。
|
|
115
|
+
* 与旧实现的差别即本次变更的目的——碎块不再与整词等价,长命中不再被拆成多次加分。
|
|
116
|
+
* full 标识「命中了某个完整单元」(整个拉丁词或整个中文连续段),供调用方区分
|
|
117
|
+
* 「整词命中」与「只碰了 2 字碎块」——后者的分数必须过 KEYWORD_MIN_UNITS 门槛才能算命中。
|
|
118
|
+
* @param {object} m 记忆条目
|
|
119
|
+
* @param {{latin: string[], cjk: string[]}} terms extractMemoryTerms 的结果
|
|
120
|
+
* @param {string} originalQuery 原始查询(小写 trim 后传入)
|
|
121
|
+
* @param {(key: string) => boolean} [dfFilter] buildDocFreqFilter 的结果,默认全通过
|
|
122
|
+
* @returns {{score: number, units: number, full: boolean, best: number}}
|
|
123
|
+
* units 命中强度(跨单元累加,同单元跨字段取最强),0 表示词面不沾;
|
|
124
|
+
* best 单个查询单元自己达到的最强单元数——区分「一次深命中」与「多次浅碎块」,
|
|
125
|
+
* score 只反映出现在几个字段,判名次时必须有这根深度轴。
|
|
126
|
+
*/
|
|
127
|
+
export function keywordScore(m, terms, originalQuery, dfFilter = () => true) {
|
|
128
|
+
const texts = fieldTexts(m);
|
|
129
|
+
const perUnit = new Map(); // 单元身份 -> 各字段里的最强单元数
|
|
130
|
+
let score = 0;
|
|
131
|
+
let full = false;
|
|
132
|
+
for (const field of Object.keys(FIELD_WEIGHT)) {
|
|
133
|
+
// 字段级封顶(与原实现一致):同一字段内的命中单元和先封顶再乘权重——不封顶的话,
|
|
134
|
+
// 长文档碰多个碎块会被按单元线性放大,正是本轮要压制的噪声形态。
|
|
135
|
+
// 跨字段仍各自累加:一个词既在正文又在标签出现,本来就是更强的排序信号。
|
|
136
|
+
let fieldUnits = 0;
|
|
137
|
+
for (const h of fieldUnitHits(texts[field], terms, dfFilter)) {
|
|
138
|
+
fieldUnits += h.units;
|
|
139
|
+
const prev = perUnit.get(h.unit);
|
|
140
|
+
if (prev === undefined || h.units > prev) perUnit.set(h.unit, h.units);
|
|
141
|
+
if (h.full) full = true;
|
|
142
|
+
}
|
|
143
|
+
score += FIELD_WEIGHT[field] * Math.min(fieldUnits, KEYWORD_MAX_HITS);
|
|
144
|
+
}
|
|
145
|
+
let units = 0;
|
|
146
|
+
let best = 0;
|
|
147
|
+
for (const u of perUnit.values()) {
|
|
148
|
+
units += u;
|
|
149
|
+
if (u > best) best = u;
|
|
150
|
+
}
|
|
151
|
+
units = Math.min(units, KEYWORD_MAX_HITS); // 门槛轴同样封顶:去重后的单元和也不得超过 KEYWORD_MAX_HITS
|
|
152
|
+
const query = String(originalQuery || '');
|
|
153
|
+
if (query.length >= ORIGINAL_QUERY_MIN_LENGTH) {
|
|
154
|
+
if (texts.content.includes(query)) score += ORIGINAL_QUERY_CONTENT_BONUS;
|
|
155
|
+
if (texts.title.includes(query)) score += ORIGINAL_QUERY_TITLE_BONUS;
|
|
156
|
+
}
|
|
157
|
+
return { score, units, full, best };
|
|
158
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// agent/src/memory/vectors.js - 记忆向量的派生缓存
|
|
2
|
+
// 原则:global-memory.json 是唯一真源,本文件只缓存派生向量。文件可丢、可坏、可整目录删除,
|
|
3
|
+
// 任何异常都必须被吞掉当成空缓存(召回随之回落关键词通道),绝不能把 recall 带崩或带抛。
|
|
4
|
+
// 跨模型向量不可比:modelKey(mode|modelName|dimensions)任一变化即整体作废旧向量。
|
|
5
|
+
|
|
6
|
+
import { readFile, writeFile, mkdir } from 'fs/promises';
|
|
7
|
+
import { dirname, join } from 'path';
|
|
8
|
+
import { MEMORY_DIR } from '../config.js';
|
|
9
|
+
|
|
10
|
+
export const VECTORS_FILE = join(MEMORY_DIR, 'memory-vectors.json');
|
|
11
|
+
|
|
12
|
+
/** 模型标识:mode|model|dimensions。缺字段也拼成稳定串,不留 undefined 参与比较。 */
|
|
13
|
+
export function memoryModelKey(cfg = {}) {
|
|
14
|
+
const c = cfg || {};
|
|
15
|
+
return `${c.mode || ''}|${c.modelName || ''}|${Number(c.dimensions) > 0 ? Math.floor(Number(c.dimensions)) : 0}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 余弦相似度。零向量、长度不一致、非有限数一律返回 0(不可比 ≠ 相似)。
|
|
20
|
+
*/
|
|
21
|
+
export function cosine(a = [], b = []) {
|
|
22
|
+
if (!Array.isArray(a) || !Array.isArray(b) || !a.length || a.length !== b.length) return 0;
|
|
23
|
+
let dot = 0;
|
|
24
|
+
let na = 0;
|
|
25
|
+
let nb = 0;
|
|
26
|
+
for (let i = 0; i < a.length; i++) {
|
|
27
|
+
const x = a[i];
|
|
28
|
+
const y = b[i];
|
|
29
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) return 0;
|
|
30
|
+
dot += x * y;
|
|
31
|
+
na += x * x;
|
|
32
|
+
nb += y * y;
|
|
33
|
+
}
|
|
34
|
+
if (!na || !nb) return 0;
|
|
35
|
+
// 合并开方:分开写 sqrt(na)*sqrt(nb) 会带出浮点误差(sqrt(2)*sqrt(2)=2.0000000000000004),
|
|
36
|
+
// 自相似度算出 0.9999999999999998,阀值比较与展示都会飘。
|
|
37
|
+
const denom = Math.sqrt(na * nb);
|
|
38
|
+
return denom ? dot / denom : 0;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 只接受「非空 + 全有限数」的向量入库,NaN/Infinity 一旦落盘会污染整个相似度排序 */
|
|
42
|
+
function isUsableVector(vec) {
|
|
43
|
+
return Array.isArray(vec) && vec.length > 0 && vec.every((n) => Number.isFinite(n));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 缓存门面(filePath 可注入,便于单测与临时 HOME 冒烟;进程内可创建多个实例互不干扰)
|
|
48
|
+
*/
|
|
49
|
+
export function createVectorStoreFacade({ filePath = VECTORS_FILE } = {}) {
|
|
50
|
+
// 串行链:读-改-写不能交叉,否则并发 upsert 会互相覆盖(与 service 的写锁同一思路)
|
|
51
|
+
let chain = Promise.resolve();
|
|
52
|
+
const serialized = (task) => {
|
|
53
|
+
const run = chain.then(task);
|
|
54
|
+
chain = run.catch(() => {});
|
|
55
|
+
return run;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
async function readRaw() {
|
|
59
|
+
try {
|
|
60
|
+
const raw = await readFile(filePath, 'utf-8');
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
if (!parsed || typeof parsed !== 'object' || !parsed.vectors || typeof parsed.vectors !== 'object') {
|
|
63
|
+
return { modelKey: '', vectors: {} };
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
modelKey: typeof parsed.modelKey === 'string' ? parsed.modelKey : '',
|
|
67
|
+
vectors: parsed.vectors
|
|
68
|
+
};
|
|
69
|
+
} catch {
|
|
70
|
+
// 文件缺失 / JSON 损坏 / 权限问题:派生缓存视为空,下次使用时重建
|
|
71
|
+
return { modelKey: '', vectors: {} };
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function writeRaw(payload) {
|
|
76
|
+
try {
|
|
77
|
+
await mkdir(dirname(filePath), { recursive: true });
|
|
78
|
+
await writeFile(filePath, JSON.stringify(payload), 'utf-8');
|
|
79
|
+
return { success: true };
|
|
80
|
+
} catch (err) {
|
|
81
|
+
console.warn('[Memory] vectors write failed:', err?.message);
|
|
82
|
+
return { success: false, error: err?.message || 'vectors_write_failed' };
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
filePath,
|
|
88
|
+
read: () => serialized(readRaw),
|
|
89
|
+
write: (payload) => serialized(() => writeRaw(payload)),
|
|
90
|
+
|
|
91
|
+
/** 批量写入向量;modelKey 与已存不同时整体作废 */
|
|
92
|
+
upsertVectors: (entries, modelKey) => serialized(async () => {
|
|
93
|
+
const cur = await readRaw();
|
|
94
|
+
const vectors = cur.modelKey === modelKey ? cur.vectors : {};
|
|
95
|
+
for (const [id, vec] of entries || []) {
|
|
96
|
+
if (isUsableVector(vec)) vectors[id] = vec;
|
|
97
|
+
}
|
|
98
|
+
return writeRaw({ modelKey, vectors });
|
|
99
|
+
}),
|
|
100
|
+
|
|
101
|
+
/** 摘除指定 id(记忆删除/淘汰时同步调用,避免留下孤儿向量) */
|
|
102
|
+
dropVectors: (ids) => serialized(async () => {
|
|
103
|
+
const cur = await readRaw();
|
|
104
|
+
let touched = false;
|
|
105
|
+
for (const id of ids || []) {
|
|
106
|
+
if (id in cur.vectors) {
|
|
107
|
+
delete cur.vectors[id];
|
|
108
|
+
touched = true;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
if (!touched) return { success: true, skipped: true };
|
|
112
|
+
return writeRaw(cur);
|
|
113
|
+
}),
|
|
114
|
+
|
|
115
|
+
/** 整体作废(reindex 端点用) */
|
|
116
|
+
clear: () => serialized(() => writeRaw({ modelKey: '', vectors: {} })),
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ==================== 默认路径便捷出口(进程级单例) ====================
|
|
121
|
+
|
|
122
|
+
const defaultStore = createVectorStoreFacade();
|
|
123
|
+
|
|
124
|
+
export const readVectors = () => defaultStore.read();
|
|
125
|
+
export const writeVectors = (payload) => defaultStore.write(payload);
|
|
126
|
+
export const upsertVectors = (entries, modelKey) => defaultStore.upsertVectors(entries, modelKey);
|
|
127
|
+
export const dropVectors = (ids) => defaultStore.dropVectors(ids);
|
|
128
|
+
export const clearVectors = () => defaultStore.clear();
|
|
@@ -27,6 +27,13 @@ export class EmbeddingProvider {
|
|
|
27
27
|
? new OpenAICompatEmbedding(resolved)
|
|
28
28
|
: new LocalEmbedding(resolved);
|
|
29
29
|
|
|
30
|
+
// 长度可观测能力:只有本地档能拿到自己的 tokenizer。
|
|
31
|
+
// 远端档置 null(而非“猜一个窗口”):厂商何时截断、截在哪,返回的向量里看不出来,
|
|
32
|
+
// 所以下游必须能区分「没有超窗」与「无从判断」。
|
|
33
|
+
this.tokenCounter = typeof this.backend.countTokens === 'function'
|
|
34
|
+
? (texts) => this.backend.countTokens(texts)
|
|
35
|
+
: null;
|
|
36
|
+
|
|
30
37
|
console.log(`[RAG] embedding mode: ${this.mode}, model: ${this.modelName}, queryPrefix: ${this.queryPrefix ? 'on' : 'off'}`);
|
|
31
38
|
}
|
|
32
39
|
|
|
@@ -42,6 +42,31 @@ export class LocalEmbedding {
|
|
|
42
42
|
return output.tolist();
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
+
/**
|
|
46
|
+
* 统计文本的真实 token 长度与模型上下文窗口(供调用方发现「会被截多」)。
|
|
47
|
+
*
|
|
48
|
+
* 必须 `truncation: false`:feature-extraction pipeline 内部用的是 `truncation: true`,
|
|
49
|
+
* 实测在 `model_max_length`(本模型 512)处静默砍尾——跟着它的口径数只会永远数到窗口值,
|
|
50
|
+
* 看不见损失了多少。也不走 `embed()` 的反推:tolist 只给向量,给不出长度。
|
|
51
|
+
* 纯 tokenization 不跑推理,成本远低于嵌入本身。
|
|
52
|
+
* @param {string[]} texts
|
|
53
|
+
* @returns {Promise<{counts: number[], window: number}>} counts 与入参同序
|
|
54
|
+
*/
|
|
55
|
+
async countTokens(texts) {
|
|
56
|
+
if (!Array.isArray(texts) || texts.length === 0) return { counts: [], window: 0 };
|
|
57
|
+
|
|
58
|
+
const extractor = await this._getPipeline();
|
|
59
|
+
const tokenizer = extractor.tokenizer;
|
|
60
|
+
// 窗口值以 tokenizer 为准(实测 = tokenizer_config.model_max_length = 512),不自己写常量
|
|
61
|
+
const window = Number(tokenizer.model_max_length) || 0;
|
|
62
|
+
const counts = texts.map((t) => {
|
|
63
|
+
const encoded = tokenizer(String(t ?? ''), { padding: false, truncation: false });
|
|
64
|
+
const dims = encoded?.input_ids?.dims;
|
|
65
|
+
return Array.isArray(dims) && dims.length ? Number(dims[dims.length - 1]) : 0;
|
|
66
|
+
});
|
|
67
|
+
return { counts, window };
|
|
68
|
+
}
|
|
69
|
+
|
|
45
70
|
/**
|
|
46
71
|
* 获取(或加载)pipeline 单例
|
|
47
72
|
*/
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// agent/src/rag/keyword-match.js - 关键词片段化匹配的纯函数(知识库与长期记忆共用)
|
|
2
|
+
// 拆出本模块的原因:两侧原本各写一套匹配。知识库在 2026-09 就修掉了「英文子串误伤」
|
|
3
|
+
// (rag 命中 storage)与「中文整段失配」两类缺陷,而记忆侧一直沿用更早的碎 bigram 方案,
|
|
4
|
+
// 于是同一条 query 在两个子系统里的命中口径完全不同。共用一份实现,杜绝再次漂移。
|
|
5
|
+
//
|
|
6
|
+
// 语义约束(改动前必读):
|
|
7
|
+
// - 英文/数字按词边界判定,CJK 相邻视为边界;
|
|
8
|
+
// - 中文取「最长命中片段」,且 2 字片段命中必蕴含更长片段的命中,故先做 2 字预检;
|
|
9
|
+
// - 单元数只按长度分层(L=2 → 1,L=3 → 2,L≥4 → 3 顶格),不做出现次数累加;
|
|
10
|
+
// - 本模块只负责「匹配」,df 统计与抬分属各调用方自己的刻度(知识库 [0,1]、记忆整数),不在此处。
|
|
11
|
+
|
|
12
|
+
export const KEYWORD_MIN_LEN = 2; // 命中单元最小长度(单字符匹配噪声过大)
|
|
13
|
+
export const KEYWORD_MAX_FRAGMENT_LEN = 4; // 中文最长匹配窗口(≥4 字命中已顶格 3 单元,无需更长窗口)
|
|
14
|
+
export const KEYWORD_MAX_HITS = 3; // 计分单元总数上限(防止长查询过度加分)
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 从查询中提取关键词(英文/数字按分隔符成段;中文取连续段)
|
|
18
|
+
* 说明:不引入分词依赖——中文长段由 matchCjk 按最长命中片段降级匹配,
|
|
19
|
+
* 本函数只负责切段与小写去重
|
|
20
|
+
* @param {string} query
|
|
21
|
+
* @returns {string[]} 小写去重后的关键词
|
|
22
|
+
*/
|
|
23
|
+
export function extractKeywords(query) {
|
|
24
|
+
const parts = String(query || '').toLowerCase().split(/[^a-z0-9\u4e00-\u9fff]+/);
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
const keywords = [];
|
|
27
|
+
for (const p of parts) {
|
|
28
|
+
if (p.length >= KEYWORD_MIN_LEN && !seen.has(p)) {
|
|
29
|
+
seen.add(p);
|
|
30
|
+
keywords.push(p);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return keywords;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 将关键词段拆分为带类型的命中单元:英文/数字段与中文段分开处理
|
|
38
|
+
* 段内非 [a-z0-9] 的字符必为汉字(切分阶段已剔除其他字符),无需引入 Unicode 转义
|
|
39
|
+
* @param {string} query
|
|
40
|
+
* @returns {{latin: string[], cjk: string[]}} 小写去重后的单元列表
|
|
41
|
+
*/
|
|
42
|
+
export function extractQueryTerms(query) {
|
|
43
|
+
const latin = [];
|
|
44
|
+
const cjk = [];
|
|
45
|
+
const seenLatin = new Set();
|
|
46
|
+
const seenCjk = new Set();
|
|
47
|
+
for (const seg of extractKeywords(query)) {
|
|
48
|
+
for (const run of seg.match(/[a-z0-9]+|[^a-z0-9]+/g) || []) {
|
|
49
|
+
if (run.length < KEYWORD_MIN_LEN) continue;
|
|
50
|
+
if (/^[a-z0-9]+$/.test(run)) {
|
|
51
|
+
if (!seenLatin.has(run)) {
|
|
52
|
+
seenLatin.add(run);
|
|
53
|
+
latin.push(run);
|
|
54
|
+
}
|
|
55
|
+
} else if (!seenCjk.has(run)) {
|
|
56
|
+
seenCjk.add(run);
|
|
57
|
+
cjk.push(run);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return { latin, cjk };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 英文/数字:词边界匹配(前后须为非字母数字;CJK 相邻视为边界)
|
|
66
|
+
* @param {string} text 已小写的待匹配文本
|
|
67
|
+
* @param {string[]} latin 拉丁/数字单元
|
|
68
|
+
* @returns {string[]} 命中的单元
|
|
69
|
+
*/
|
|
70
|
+
export function matchLatin(text, latin) {
|
|
71
|
+
if (!latin || latin.length === 0) return [];
|
|
72
|
+
const bounded = ' ' + String(text || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ') + ' ';
|
|
73
|
+
return latin.filter(term => bounded.includes(' ' + term + ' '));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* 中文:文本内此段的最长命中片段
|
|
78
|
+
* @param {string} text 已小写的待匹配文本
|
|
79
|
+
* @param {string} seg 一个连续中文段
|
|
80
|
+
* @returns {{keys: string[], units: number} | null} 无 2 字片段命中时为 null;
|
|
81
|
+
* units = min(bestLen - 1, KEYWORD_MAX_HITS)
|
|
82
|
+
*/
|
|
83
|
+
export function matchCjk(text, seg) {
|
|
84
|
+
const hay = String(text || '').toLowerCase();
|
|
85
|
+
if (!hay || !seg || seg.length < KEYWORD_MIN_LEN) return null;
|
|
86
|
+
|
|
87
|
+
// 2 字片段预检:更长片段的命中必蕴含其 2 字子串命中,全未命中即可跳过
|
|
88
|
+
const keys2 = new Set();
|
|
89
|
+
for (let i = 0; i + 2 <= seg.length; i++) {
|
|
90
|
+
const frag = seg.slice(i, i + 2);
|
|
91
|
+
if (hay.includes(frag)) keys2.add(frag);
|
|
92
|
+
}
|
|
93
|
+
if (keys2.size === 0) return null;
|
|
94
|
+
|
|
95
|
+
const maxLen = Math.min(seg.length, KEYWORD_MAX_FRAGMENT_LEN);
|
|
96
|
+
let bestKeys = keys2;
|
|
97
|
+
let bestLen = 2;
|
|
98
|
+
for (let len = maxLen; len >= 3; len--) {
|
|
99
|
+
const keys = new Set();
|
|
100
|
+
for (let i = 0; i + len <= seg.length; i++) {
|
|
101
|
+
const frag = seg.slice(i, i + len);
|
|
102
|
+
if (hay.includes(frag)) keys.add(frag);
|
|
103
|
+
}
|
|
104
|
+
if (keys.size > 0) {
|
|
105
|
+
bestKeys = keys;
|
|
106
|
+
bestLen = len;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
// 命中越长计分单元越多(L=2 → 1 单元;L≥4 顶格 3 单元)
|
|
111
|
+
return { keys: [...bestKeys], units: Math.min(bestLen - 1, KEYWORD_MAX_HITS) };
|
|
112
|
+
}
|
package/src/rag/manager.js
CHANGED
|
@@ -25,6 +25,7 @@ import { RagError } from './errors.js';
|
|
|
25
25
|
import { checkPath } from '../security.js';
|
|
26
26
|
import { fetchUrlGuarded, decodeBytes } from './url-guard.js';
|
|
27
27
|
import { loadConfig } from '../config.js';
|
|
28
|
+
import { encodeSecret, decodeSecret } from '../crypto-lite.js';
|
|
28
29
|
|
|
29
30
|
// 知识库 ID 格式校验(防路径穿越)
|
|
30
31
|
const COLLECTION_ID_RE = /^kb_[a-z0-9]+$/;
|
|
@@ -189,6 +190,12 @@ export class RagManager {
|
|
|
189
190
|
const raw = await readFile(COLLECTIONS_REGISTRY, 'utf-8');
|
|
190
191
|
const reg = JSON.parse(raw);
|
|
191
192
|
if (!Array.isArray(reg.collections)) reg.collections = [];
|
|
193
|
+
// 磁盘密文 → 内存明文(兼容旧版无 enc: 前缀的明文)
|
|
194
|
+
for (const col of reg.collections) {
|
|
195
|
+
if (col.embeddingConfig?.apiKey) {
|
|
196
|
+
col.embeddingConfig = { ...col.embeddingConfig, apiKey: decodeSecret(col.embeddingConfig.apiKey) };
|
|
197
|
+
}
|
|
198
|
+
}
|
|
192
199
|
return reg;
|
|
193
200
|
} catch {
|
|
194
201
|
return { version: 1, collections: [] };
|
|
@@ -204,6 +211,14 @@ export class RagManager {
|
|
|
204
211
|
const reg = await this._loadRegistry();
|
|
205
212
|
const out = mutator(reg) || reg;
|
|
206
213
|
await mkdir(RAG_ROOT, { recursive: true });
|
|
214
|
+
// 落盘前混淆各库的 apiKey(内存保持明文)
|
|
215
|
+
if (Array.isArray(out.collections)) {
|
|
216
|
+
for (const col of out.collections) {
|
|
217
|
+
if (col.embeddingConfig?.apiKey) {
|
|
218
|
+
col.embeddingConfig = { ...col.embeddingConfig, apiKey: encodeSecret(col.embeddingConfig.apiKey) };
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
207
222
|
// 注册表含远端向量服务密钥:创建即 0600,并对历史遗留的宽松权限文件显式收紧
|
|
208
223
|
await writeFile(COLLECTIONS_REGISTRY, JSON.stringify(out, null, 2), { encoding: 'utf-8', mode: 0o600 });
|
|
209
224
|
await chmod(COLLECTIONS_REGISTRY, 0o600).catch(() => {}); // Windows 无 POSIX 权限位,忽略
|