cortico-world-memory 0.1.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/README.md +32 -0
- package/package.json +35 -0
- package/src/ENV_PROMPT.md +15 -0
- package/src/config.ts +77 -0
- package/src/cortico-shim.d.ts +89 -0
- package/src/definition.ts +10 -0
- package/src/embedding.ts +79 -0
- package/src/fact-store.ts +114 -0
- package/src/float16.ts +84 -0
- package/src/index.ts +12 -0
- package/src/knowledge-store.ts +169 -0
- package/src/memory.ts +181 -0
- package/src/observe.ts +60 -0
- package/src/persona-store.ts +100 -0
- package/src/profile-store.ts +102 -0
- package/src/reflection-store.ts +152 -0
- package/src/vector-store.ts +327 -0
- package/src/world.ts +336 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export type ReflectionType = 'preference' | 'style' | 'insight' | 'correction' | 'pattern';
|
|
5
|
+
|
|
6
|
+
export interface Reflection {
|
|
7
|
+
type: ReflectionType;
|
|
8
|
+
userId: string;
|
|
9
|
+
content: string;
|
|
10
|
+
evidence?: string;
|
|
11
|
+
confidence: number;
|
|
12
|
+
actionable: boolean;
|
|
13
|
+
createdAt: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface InteractionRule {
|
|
17
|
+
rule: string;
|
|
18
|
+
type: ReflectionType;
|
|
19
|
+
userId: string;
|
|
20
|
+
confidence: number;
|
|
21
|
+
createdAt: number;
|
|
22
|
+
lastSeen: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ReflectFile {
|
|
26
|
+
reflections: Reflection[];
|
|
27
|
+
interactionRules: InteractionRule[];
|
|
28
|
+
stats: { totalConversations: number; totalReflections: number; lastReflectionTs: number };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const MAX_REFLECTIONS = 100;
|
|
32
|
+
const MAX_RULES = 50;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 反思记忆(对齐 qq_bot reflection_memory,五维记忆第四维):
|
|
36
|
+
* - reflections:自我反思/交互偏好/纠错(去重:同类型+同用户+前50字相似不重复)
|
|
37
|
+
* - interactionRules:从可执行的反思提炼出的可复用交互规则(置信度累加,上限50按置信度淘汰)
|
|
38
|
+
* - 原子落盘(临时文件 + rename)
|
|
39
|
+
*/
|
|
40
|
+
export class ReflectionStore {
|
|
41
|
+
private readonly file: string;
|
|
42
|
+
private data: ReflectFile = { reflections: [], interactionRules: [], stats: { totalConversations: 0, totalReflections: 0, lastReflectionTs: 0 } };
|
|
43
|
+
private dirty = false;
|
|
44
|
+
private readonly flushTimer: ReturnType<typeof setInterval>;
|
|
45
|
+
|
|
46
|
+
constructor(dataDir: string, flushMs = 2000) {
|
|
47
|
+
this.file = join(dataDir, 'reflection-data.json');
|
|
48
|
+
this.load();
|
|
49
|
+
this.flushTimer = setInterval(() => this.flush(), flushMs);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private load(): void {
|
|
53
|
+
try {
|
|
54
|
+
if (existsSync(this.file)) {
|
|
55
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8'));
|
|
56
|
+
if (raw && typeof raw === 'object') {
|
|
57
|
+
this.data.reflections = Array.isArray(raw.reflections) ? raw.reflections : [];
|
|
58
|
+
this.data.interactionRules = Array.isArray(raw.interactionRules) ? raw.interactionRules : [];
|
|
59
|
+
if (raw.stats) this.data.stats = raw.stats;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
/* 损坏则重置 */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private saveNow(): void {
|
|
68
|
+
if (!this.dirty) return;
|
|
69
|
+
try {
|
|
70
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
71
|
+
const tmp = this.file + '.tmp';
|
|
72
|
+
writeFileSync(tmp, JSON.stringify(this.data, null, 2), 'utf8');
|
|
73
|
+
renameSync(tmp, this.file);
|
|
74
|
+
this.dirty = false;
|
|
75
|
+
} catch {
|
|
76
|
+
/* 落盘失败不影响内存态 */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
flush(): void {
|
|
81
|
+
clearInterval(this.flushTimer);
|
|
82
|
+
this.saveNow();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
private addReflection(r: Reflection): boolean {
|
|
86
|
+
for (const e of this.data.reflections) {
|
|
87
|
+
if (e.type === r.type && e.userId === r.userId && (e.content ?? '').slice(0, 50) === (r.content ?? '').slice(0, 50)) return false; // 去重
|
|
88
|
+
}
|
|
89
|
+
this.data.reflections.push(r);
|
|
90
|
+
if (this.data.reflections.length > MAX_REFLECTIONS) this.data.reflections = this.data.reflections.slice(-MAX_REFLECTIONS);
|
|
91
|
+
this.data.stats.totalReflections += 1;
|
|
92
|
+
this.data.stats.lastReflectionTs = r.createdAt;
|
|
93
|
+
this.dirty = true;
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private addRule(rule: InteractionRule): void {
|
|
98
|
+
for (const e of this.data.interactionRules) {
|
|
99
|
+
if ((e.rule ?? '').slice(0, 50) === (rule.rule ?? '').slice(0, 50)) {
|
|
100
|
+
e.confidence = Math.min((e.confidence ?? 0.5) + 0.1, 1.0); // 累加置信度
|
|
101
|
+
e.lastSeen = rule.lastSeen;
|
|
102
|
+
this.dirty = true;
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
this.data.interactionRules.push(rule);
|
|
107
|
+
if (this.data.interactionRules.length > MAX_RULES) {
|
|
108
|
+
this.data.interactionRules.sort((a, b) => (b.confidence ?? 0) - (a.confidence ?? 0));
|
|
109
|
+
this.data.interactionRules = this.data.interactionRules.slice(0, MAX_RULES);
|
|
110
|
+
}
|
|
111
|
+
this.dirty = true;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** 手动沉淀一条反思(对应 mem_reflect 工具)。 */
|
|
115
|
+
reflect(content: string, type: ReflectionType, userId: string, evidence?: string, confidence = 0.6, actionable = true): { added: boolean } {
|
|
116
|
+
const now = Date.now() / 1000;
|
|
117
|
+
const r: Reflection = { type, userId: String(userId), content: (content ?? '').trim(), evidence: evidence?.trim(), confidence, actionable, createdAt: now };
|
|
118
|
+
if (!r.content) return { added: false };
|
|
119
|
+
const added = this.addReflection(r); // 去重命中则返回 false(让工具如实报「重复」)
|
|
120
|
+
if (added && actionable && confidence >= 0.6) {
|
|
121
|
+
this.addRule({ rule: r.content, type, userId: r.userId, confidence, createdAt: now, lastSeen: now });
|
|
122
|
+
}
|
|
123
|
+
return { added };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** 批量写入(供自动反思流程调用)。 */
|
|
127
|
+
ingest(reflections: Reflection[]): number {
|
|
128
|
+
let n = 0;
|
|
129
|
+
for (const r of reflections) {
|
|
130
|
+
const before = this.data.reflections.length;
|
|
131
|
+
this.addReflection(r);
|
|
132
|
+
if (this.data.reflections.length > before) n++;
|
|
133
|
+
if (r.actionable && (r.confidence ?? 0) >= 0.6) {
|
|
134
|
+
this.addRule({ rule: r.content, type: r.type, userId: r.userId, confidence: r.confidence, createdAt: r.createdAt, lastSeen: r.createdAt });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** 取某用户相关的反思 + 交互规则(供召回/注入)。 */
|
|
141
|
+
getFor(userId: string): { reflections: Reflection[]; rules: InteractionRule[] } {
|
|
142
|
+
const uid = String(userId);
|
|
143
|
+
return {
|
|
144
|
+
reflections: this.data.reflections.filter((r) => r.userId === uid),
|
|
145
|
+
rules: this.data.interactionRules.filter((r) => r.userId === uid),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
count(): { reflections: number; rules: number } {
|
|
150
|
+
return { reflections: this.data.reflections.length, rules: this.data.interactionRules.length };
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 向量记忆库 —— 移植自 E:/qq_bot/vector_memory.py(N.E.K.O Hybrid Recall 架构),qq_bot 源码零改动。
|
|
3
|
+
*
|
|
4
|
+
* 语义逐项对齐:
|
|
5
|
+
* - 存储:JSON `{vectors: {scope: [{text, vector(base64 fp16), timestamp}]}}`,同款文件格式,
|
|
6
|
+
* 可直接导入 E:/qq_bot/vector_memory.json(或反向导出)。
|
|
7
|
+
* - 检索:Hybrid Recall = BM25 关键词 + Cosine 向量 + RRF 融合(k=60)。
|
|
8
|
+
* - BM25:仅为 query 词建 DF 表(不为全语料建),CJK 2/3-gram + Latin 整词分词。
|
|
9
|
+
* - Cosine:存储时 L2 归一化,检索时点积;矩阵按 scope 缓存,数据版本号失效。
|
|
10
|
+
* - 降级:无向量记录 / 维度不齐 / embedding 服务不在,都自动退化为纯 BM25——
|
|
11
|
+
* 与 qq_bot memory_client「sidecar 掉线降级进程内、功能永不中断」同一思路。
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { dirname, join } from 'node:path';
|
|
15
|
+
import { decodeVectorFp16, encodeVectorFp16 } from './float16.ts';
|
|
16
|
+
|
|
17
|
+
const BM25_K1 = 1.5;
|
|
18
|
+
const BM25_B = 0.75;
|
|
19
|
+
const RRF_K = 60;
|
|
20
|
+
|
|
21
|
+
export interface VectorItem {
|
|
22
|
+
/** 原文(截断到 200 字,对齐 python 版)。 */
|
|
23
|
+
text: string;
|
|
24
|
+
/** base64(fp16) 编码的 L2 归一化向量;文本-only 条目(BM25-only)为 null。 */
|
|
25
|
+
vector: string | null;
|
|
26
|
+
timestamp: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface VectorData {
|
|
30
|
+
vectors: Record<string, VectorItem[]>;
|
|
31
|
+
model_name?: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface SearchHit {
|
|
35
|
+
text: string;
|
|
36
|
+
/** 融合分数(RRF);单路结果时是归一化后的相对排名分,仅供展示排序。 */
|
|
37
|
+
score: number;
|
|
38
|
+
/** 命中来源:bm25 / vector / both。 */
|
|
39
|
+
via: 'bm25' | 'vector' | 'both';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface MatrixCache {
|
|
43
|
+
matrix: Float32Array; // n × dim 的行主序
|
|
44
|
+
dim: number;
|
|
45
|
+
rows: number[]; // matrix 第 i 行对应 vectors[scope] 的下标
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const MATRIX_CACHE_MAX = 64;
|
|
49
|
+
|
|
50
|
+
function l2Normalize(v: Float32Array): Float32Array {
|
|
51
|
+
let norm = 0;
|
|
52
|
+
for (let i = 0; i < v.length; i++) norm += v[i]! * v[i]!;
|
|
53
|
+
norm = Math.sqrt(norm);
|
|
54
|
+
if (norm === 0 || !Number.isFinite(norm)) return v;
|
|
55
|
+
for (let i = 0; i < v.length; i++) v[i] = v[i]! / norm;
|
|
56
|
+
return v;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** CJK 2/3-gram + Latin 整词分词,对齐 `_tokenize_cjk_ngram`(简繁折叠依赖未装时原文照分)。 */
|
|
60
|
+
export function tokenizeCjkNgram(text: string): string[] {
|
|
61
|
+
const tokens: string[] = [];
|
|
62
|
+
const pattern = /[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]+|[a-zA-Z0-9_]+/g;
|
|
63
|
+
const cjk = /^[\u4e00-\u9fff\u3400-\u4dbf\uf900-\ufaff]/;
|
|
64
|
+
for (const seg of text.toLowerCase().match(pattern) ?? []) {
|
|
65
|
+
if (cjk.test(seg)) {
|
|
66
|
+
for (const n of [2, 3]) {
|
|
67
|
+
for (let i = 0; i + n <= seg.length; i++) tokens.push(seg.slice(i, i + n));
|
|
68
|
+
}
|
|
69
|
+
} else {
|
|
70
|
+
tokens.push(seg);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return tokens;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Okapi BM25:仅为 query 词建 DF 表(对齐 `_bm25_score` 的优化点)。 */
|
|
77
|
+
function bm25Score(queryTokens: string[], docTokens: string[][]): number[] {
|
|
78
|
+
const nDocs = docTokens.length;
|
|
79
|
+
if (nDocs === 0) return [];
|
|
80
|
+
const docLens = docTokens.map((t) => t.length);
|
|
81
|
+
const avgdl = docLens.reduce((a, b) => a + b, 0) / nDocs || 1;
|
|
82
|
+
const querySet = new Set(queryTokens);
|
|
83
|
+
const df = new Map<string, number>();
|
|
84
|
+
const tf: Array<Map<string, number>> = [];
|
|
85
|
+
for (const tokens of docTokens) {
|
|
86
|
+
const docTf = new Map<string, number>();
|
|
87
|
+
for (const t of tokens) {
|
|
88
|
+
if (!querySet.has(t)) continue;
|
|
89
|
+
docTf.set(t, (docTf.get(t) ?? 0) + 1);
|
|
90
|
+
}
|
|
91
|
+
tf.push(docTf);
|
|
92
|
+
for (const t of docTf.keys()) df.set(t, (df.get(t) ?? 0) + 1);
|
|
93
|
+
}
|
|
94
|
+
const scores: number[] = [];
|
|
95
|
+
for (let i = 0; i < nDocs; i++) {
|
|
96
|
+
let score = 0;
|
|
97
|
+
const dl = docLens[i]!;
|
|
98
|
+
for (const token of querySet) {
|
|
99
|
+
const d = df.get(token);
|
|
100
|
+
if (!d) continue;
|
|
101
|
+
const idf = Math.log((nDocs - d + 0.5) / (d + 0.5) + 1);
|
|
102
|
+
const f = tf[i]!.get(token) ?? 0;
|
|
103
|
+
score += idf * ((f * (BM25_K1 + 1)) / (f + BM25_K1 * (1 - BM25_B + BM25_B * (dl / avgdl))));
|
|
104
|
+
}
|
|
105
|
+
scores.push(score);
|
|
106
|
+
}
|
|
107
|
+
return scores;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Reciprocal Rank Fusion,对齐 `_reciprocal_rank_fusion`(k=60)。 */
|
|
111
|
+
function rrfFusion(rankedLists: Array<Array<{ idx: number }>>): Map<number, number> {
|
|
112
|
+
const scores = new Map<number, number>();
|
|
113
|
+
for (const list of rankedLists) {
|
|
114
|
+
list.forEach((item, rank) => {
|
|
115
|
+
scores.set(item.idx, (scores.get(item.idx) ?? 0) + 1 / (RRF_K + rank + 1));
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
return scores;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 一个 scope 的向量库(磁盘持久化 + 内存缓存 + 矩阵缓存 + 防抖写盘)。 */
|
|
122
|
+
export class VectorStore {
|
|
123
|
+
private data: VectorData = { vectors: {} };
|
|
124
|
+
private file: string;
|
|
125
|
+
private matrices = new Map<string, { version: number; m: MatrixCache | null }>();
|
|
126
|
+
private versions = new Map<string, number>;
|
|
127
|
+
private dirty = false;
|
|
128
|
+
private lastSave = 0;
|
|
129
|
+
private flushTimer: ReturnType<typeof setTimeout> | null = null;
|
|
130
|
+
|
|
131
|
+
static readonly SAVE_DEBOUNCE_MS = 2000;
|
|
132
|
+
|
|
133
|
+
constructor(dataDir: string) {
|
|
134
|
+
mkdirSync(dataDir, { recursive: true });
|
|
135
|
+
this.file = join(dataDir, 'vector-memory.json');
|
|
136
|
+
this.load();
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private load(): void {
|
|
140
|
+
if (!existsSync(this.file)) return;
|
|
141
|
+
try {
|
|
142
|
+
const raw = JSON.parse(readFileSync(this.file, 'utf8')) as VectorData;
|
|
143
|
+
if (raw && typeof raw === 'object' && raw.vectors && typeof raw.vectors === 'object') {
|
|
144
|
+
this.data = { vectors: raw.vectors, ...(raw.model_name ? { model_name: raw.model_name } : {}) };
|
|
145
|
+
}
|
|
146
|
+
} catch { /* 损坏文件按空库起步,与 python 版同策略 */ }
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 同步全量写(JSON 体量 = 条数×维度/2 字节,500 条×384 维约 1.5MB,可接受)。 */
|
|
150
|
+
private saveNow(): void {
|
|
151
|
+
try {
|
|
152
|
+
mkdirSync(dirname(this.file), { recursive: true });
|
|
153
|
+
// 原子提交(archive-first 单文件等价):先写临时文件再 rename,同卷 rename 原子,
|
|
154
|
+
// 避免写到一半崩溃损坏整个 vector-memory.json(对齐 qq_bot fact_store crash-safe)。
|
|
155
|
+
const tmp = this.file + '.tmp';
|
|
156
|
+
writeFileSync(tmp, JSON.stringify(this.data), 'utf8');
|
|
157
|
+
renameSync(tmp, this.file);
|
|
158
|
+
this.dirty = false;
|
|
159
|
+
this.lastSave = Date.now();
|
|
160
|
+
} catch { /* 磁盘失败不打断主流程;下次写入再试 */ }
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** 防抖调度写盘:距上次写 < 2s 则延迟合并,否则立即写。与 MemoryBase.scheduleSave 对齐。 */
|
|
164
|
+
private scheduleSave(): void {
|
|
165
|
+
const now = Date.now();
|
|
166
|
+
if (now - this.lastSave < VectorStore.SAVE_DEBOUNCE_MS) {
|
|
167
|
+
this.dirty = true;
|
|
168
|
+
if (!this.flushTimer) {
|
|
169
|
+
this.flushTimer = setTimeout(() => { this.flushTimer = null; this.saveNow(); }, VectorStore.SAVE_DEBOUNCE_MS);
|
|
170
|
+
this.flushTimer.unref?.();
|
|
171
|
+
}
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
this.saveNow();
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** 强制立即落盘(防抖到期 / stop 时调用)。 */
|
|
178
|
+
flush(): void {
|
|
179
|
+
if (this.flushTimer) { clearTimeout(this.flushTimer); this.flushTimer = null; }
|
|
180
|
+
if (this.dirty || !this.lastSave) this.saveNow();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
private invalidate(scope: string): void {
|
|
184
|
+
this.versions.set(scope, (this.versions.get(scope) ?? 0) + 1);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** 追加一条;vector 为 null 时仅进 BM25 索引。超出上限截掉最旧的。 */
|
|
188
|
+
add(scope: string, text: string, vector: readonly number[] | null, maxPerScope: number): void {
|
|
189
|
+
const s = scope || 'main';
|
|
190
|
+
const list = (this.data.vectors[s] ??= []);
|
|
191
|
+
list.push({
|
|
192
|
+
text: text.slice(0, 200),
|
|
193
|
+
vector: vector && vector.length ? encodeVectorFp16(vector) : null,
|
|
194
|
+
timestamp: Date.now() / 1000,
|
|
195
|
+
});
|
|
196
|
+
if (list.length > maxPerScope) this.data.vectors[s] = list.slice(-maxPerScope);
|
|
197
|
+
this.invalidate(s);
|
|
198
|
+
this.scheduleSave(); // 防抖写盘(原为每次 add 都同步 save)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private matrixFor(scope: string): MatrixCache | null {
|
|
202
|
+
const version = this.versions.get(scope) ?? 0;
|
|
203
|
+
const cached = this.matrices.get(scope);
|
|
204
|
+
if (cached && cached.version === version) return cached.m;
|
|
205
|
+
const items = this.data.vectors[scope] ?? [];
|
|
206
|
+
let dim = 0;
|
|
207
|
+
const rows: number[] = [];
|
|
208
|
+
const chunks: Float32Array[] = [];
|
|
209
|
+
for (let i = 0; i < items.length; i++) {
|
|
210
|
+
const enc = items[i]!.vector;
|
|
211
|
+
if (!enc) continue;
|
|
212
|
+
const v = l2Normalize(decodeVectorFp16(enc));
|
|
213
|
+
if (dim === 0) dim = v.length;
|
|
214
|
+
if (v.length !== dim) continue; // 维度不齐的条目(换过模型)跳过,不炸整库
|
|
215
|
+
chunks.push(v);
|
|
216
|
+
rows.push(i);
|
|
217
|
+
}
|
|
218
|
+
let m: MatrixCache | null = null;
|
|
219
|
+
if (rows.length) {
|
|
220
|
+
const matrix = new Float32Array(rows.length * dim);
|
|
221
|
+
chunks.forEach((v, r) => matrix.set(v, r * dim));
|
|
222
|
+
m = { matrix, dim, rows };
|
|
223
|
+
}
|
|
224
|
+
if (this.matrices.size >= MATRIX_CACHE_MAX) {
|
|
225
|
+
const oldest = this.matrices.keys().next().value;
|
|
226
|
+
if (oldest !== undefined) this.matrices.delete(oldest);
|
|
227
|
+
}
|
|
228
|
+
this.matrices.set(scope, { version, m });
|
|
229
|
+
return m;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 点积检索:matrix 已归一化,点积即余弦。返回 [下标, 相似度] 降序。 */
|
|
233
|
+
private cosineSearch(scope: string, queryVector: readonly number[], topK: number): Array<{ idx: number; score: number }> {
|
|
234
|
+
const m = this.matrixFor(scope);
|
|
235
|
+
if (!m || m.rows.length === 0 || queryVector.length !== m.dim) return [];
|
|
236
|
+
const q = l2Normalize(Float32Array.from(queryVector));
|
|
237
|
+
const n = m.rows.length;
|
|
238
|
+
const scores = new Float32Array(n);
|
|
239
|
+
for (let r = 0; r < n; r++) {
|
|
240
|
+
let dot = 0;
|
|
241
|
+
const base = r * m.dim;
|
|
242
|
+
for (let d = 0; d < m.dim; d++) dot += m.matrix[base + d]! * q[d]!;
|
|
243
|
+
scores[r] = dot;
|
|
244
|
+
}
|
|
245
|
+
const order = [...Array(n).keys()]
|
|
246
|
+
.filter((r) => scores[r]! > 0.3) // 对齐 python 版:融合前滤掉低相似度
|
|
247
|
+
.sort((a, b) => scores[b]! - scores[a]!)
|
|
248
|
+
.slice(0, topK);
|
|
249
|
+
return order.map((r) => ({ idx: m.rows[r]!, score: scores[r]! }));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** BM25 检索,返回 [下标, 分数] 降序(>0)。 */
|
|
253
|
+
private bm25Search(scope: string, query: string, topK: number): Array<{ idx: number; score: number }> {
|
|
254
|
+
const items = this.data.vectors[scope] ?? [];
|
|
255
|
+
if (!items.length) return [];
|
|
256
|
+
const queryTokens = tokenizeCjkNgram(query);
|
|
257
|
+
if (!queryTokens.length) return [];
|
|
258
|
+
const docTokens = items.map((it) => tokenizeCjkNgram(it.text));
|
|
259
|
+
const scores = bm25Score(queryTokens, docTokens);
|
|
260
|
+
return scores
|
|
261
|
+
.map((score, idx) => ({ idx, score }))
|
|
262
|
+
.filter((x) => x.score > 0)
|
|
263
|
+
.sort((a, b) => b.score - a.score)
|
|
264
|
+
.slice(0, topK);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Hybrid Recall:BM25 + Cosine 并行打分,RRF 融合取 top_k。
|
|
269
|
+
* 任一路没有结果时融合另一路;两路都空返回 []。
|
|
270
|
+
*/
|
|
271
|
+
hybridSearch(scope: string, query: string, queryVector: readonly number[] | null, topK: number): SearchHit[] {
|
|
272
|
+
const s = scope || 'main';
|
|
273
|
+
const items = this.data.vectors[s] ?? [];
|
|
274
|
+
if (!items.length) return [];
|
|
275
|
+
const bm25 = this.bm25Search(s, query, topK * 2);
|
|
276
|
+
const cosine = queryVector ? this.cosineSearch(s, queryVector, topK * 2) : [];
|
|
277
|
+
if (!bm25.length && !cosine.length) return [];
|
|
278
|
+
const fused = rrfFusion([bm25, cosine]);
|
|
279
|
+
const viaVector = new Set(cosine.map((c) => c.idx));
|
|
280
|
+
const viaBm25 = new Set(bm25.map((c) => c.idx));
|
|
281
|
+
return [...fused.entries()]
|
|
282
|
+
.sort((a, b) => b[1] - a[1])
|
|
283
|
+
.slice(0, topK)
|
|
284
|
+
.map(([idx, score]) => ({
|
|
285
|
+
text: items[idx]!.text,
|
|
286
|
+
score: +score.toFixed(4),
|
|
287
|
+
via: viaBm25.has(idx) && viaVector.has(idx) ? 'both' : viaVector.has(idx) ? 'vector' : 'bm25',
|
|
288
|
+
}));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/** 统计信息,对齐 `get_vector_stats`。 */
|
|
292
|
+
stats(): { scopes: number; total: number; indexed: number; model: string } {
|
|
293
|
+
const vectors = this.data.vectors;
|
|
294
|
+
let total = 0;
|
|
295
|
+
let indexed = 0;
|
|
296
|
+
for (const list of Object.values(vectors)) {
|
|
297
|
+
total += list.length;
|
|
298
|
+
indexed += list.filter((it) => it.vector).length;
|
|
299
|
+
}
|
|
300
|
+
return { scopes: Object.keys(vectors).length, total, indexed, model: this.data.model_name ?? '' };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** 清空:带 scope 清该 scope,不带清全库。对齐 `clear_vectors`。 */
|
|
304
|
+
clear(scope?: string): void {
|
|
305
|
+
if (scope) {
|
|
306
|
+
delete this.data.vectors[scope];
|
|
307
|
+
this.invalidate(scope);
|
|
308
|
+
} else {
|
|
309
|
+
this.data.vectors = {};
|
|
310
|
+
this.matrices.clear();
|
|
311
|
+
this.versions.clear();
|
|
312
|
+
}
|
|
313
|
+
this.scheduleSave(); // 防抖写盘
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** 原始条目(调试/导出用)。 */
|
|
317
|
+
items(scope: string): readonly VectorItem[] {
|
|
318
|
+
return this.data.vectors[scope] ?? [];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
setModelName(name: string): void {
|
|
322
|
+
if (name && name !== this.data.model_name) {
|
|
323
|
+
this.data.model_name = name;
|
|
324
|
+
this.scheduleSave(); // 防抖写盘
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|