open-memex 0.7.3 → 0.8.0-alpha.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "open-memex",
3
- "version": "0.7.3",
3
+ "version": "0.8.0-alpha.3",
4
4
  "mcpName": "io.github.stoneskin/open-memex",
5
5
  "description": "Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.",
6
6
  "keywords": [
@@ -21,7 +21,8 @@
21
21
  "claude-code",
22
22
  "vscode",
23
23
  "knowledge-management",
24
- "developer-tools"
24
+ "developer-tools",
25
+ "synonyms"
25
26
  ],
26
27
  "type": "module",
27
28
  "license": "Apache-2.0",
@@ -40,7 +41,8 @@
40
41
  "mcp": "node --experimental-strip-types src/mcp.ts",
41
42
  "build": "tsc -p tsconfig.build.json && node scripts/build-stamp.mjs",
42
43
  "prepublishOnly": "npm run build",
43
- "postinstall": "node scripts/postinstall.js"
44
+ "postinstall": "node scripts/postinstall.js",
45
+ "test-full": "node --experimental-strip-types scripts/test-full.ts"
44
46
  },
45
47
  "dependencies": {
46
48
  "@modelcontextprotocol/sdk": "^1.30.1",
@@ -0,0 +1,342 @@
1
+ /**
2
+ * LongMemEval-S retrieval bench (D77 external validation).
3
+ *
4
+ * Retrieval-stage only: for each question, every haystack session becomes
5
+ * one memory (full turns, role-labeled), the corpus is isolated per
6
+ * question, and we check whether a gold session lands in the top-k.
7
+ * Metric: recall_any@k + MRR — the same protocol agentmemory publishes
8
+ * (their BM25+vector: R@5 95.2%, R@10 98.6%, MRR 88.2%).
9
+ *
10
+ * This is NOT the official LongMemEval score (that needs answer
11
+ * generation + a GPT-4o judge). Do not call the output a "LongMemEval
12
+ * score" — it is retrieval recall on LongMemEval-S.
13
+ *
14
+ * The 30 abstention questions (*_abs) are excluded, per LongMemEval's own
15
+ * retrieval-eval protocol (nothing in the history answers them).
16
+ *
17
+ * Historical OFAT variants: no-stopwords / and / no-prefix. The no-synonyms
18
+ * variant was retired by D80 (expansion moved to index time; a search-time
19
+ * toggle is meaningless) — its 2026-10-07 measurement (effect 0.000) stands
20
+ * in docs/retrieval-ablation-study.md.
21
+ *
22
+ * Usage:
23
+ * node --experimental-strip-types scripts/bench-longmemeval.ts \
24
+ * --data ~/workspace/bench-data/longmemeval_s_cleaned.json [--limit 20]
25
+ */
26
+ import fs from "node:fs";
27
+ import os from "node:os";
28
+ import path from "node:path";
29
+
30
+ const args = process.argv.slice(2);
31
+ const dataPath = args[args.indexOf("--data") + 1] ?? "";
32
+ const limitIdx = args.indexOf("--limit");
33
+ const LIMIT = limitIdx === -1 ? Infinity : Number(args[limitIdx + 1]);
34
+ const POOLED = args.includes("--pooled");
35
+ const variantIdx = args.indexOf("--variant");
36
+ const VARIANT = variantIdx === -1 ? "baseline" : args[variantIdx + 1];
37
+ if (!dataPath) {
38
+ console.error("missing --data <longmemeval_s_cleaned.json>");
39
+ process.exit(1);
40
+ }
41
+ if (!["baseline", "no-stopwords", "and", "no-prefix"].includes(VARIANT)) {
42
+ console.error(`unknown --variant ${VARIANT}`);
43
+ process.exit(1);
44
+ }
45
+
46
+ interface LMEItem {
47
+ question_id: string;
48
+ question_type: string;
49
+ question: string;
50
+ answer_session_ids: string[];
51
+ haystack_session_ids: string[];
52
+ haystack_sessions: Array<Array<{ role: string; content: string }>>;
53
+ haystack_dates: string[];
54
+ }
55
+
56
+ const items: LMEItem[] = JSON.parse(fs.readFileSync(dataPath, "utf8"));
57
+ const questions = items
58
+ .filter((q) => !q.question_id.endsWith("_abs"))
59
+ .slice(0, LIMIT);
60
+ console.log(`questions: ${questions.length} (abstention excluded)`);
61
+
62
+ const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "lme-bench-"));
63
+ process.env.OPEN_MEMEX_HOME = HOME;
64
+
65
+ import { serialize, normalizeFrontmatter } from "../src/store/markdown.ts";
66
+ import { syncScope } from "../src/store/sync.ts";
67
+ import { search } from "../src/retrieve/search.ts";
68
+ import { expandQueryWithSynonyms } from "../src/retrieve/synonyms.ts";
69
+ import { memoriesDirPath } from "../src/paths.ts";
70
+ import { db } from "../src/store/db.ts";
71
+
72
+ const K = (() => {
73
+ const li = args.indexOf("--search-limit");
74
+ return li === -1 ? 10 : Number(args[li + 1]);
75
+ })();
76
+
77
+ const SEARCH_OPTS = {
78
+ stopwords: VARIANT !== "no-stopwords",
79
+ orJoin: VARIANT !== "and",
80
+ prefix: VARIANT !== "no-prefix",
81
+ };
82
+
83
+ interface Agg {
84
+ n: number;
85
+ r1: number;
86
+ r3: number;
87
+ r5: number;
88
+ r10: number;
89
+ mrr: number;
90
+ }
91
+ const fresh = (): Agg => ({ n: 0, r1: 0, r3: 0, r5: 0, r10: 0, mrr: 0 });
92
+ const byType = new Map<string, Agg>();
93
+ const total = fresh();
94
+
95
+ function record(agg: Agg, rank: number) {
96
+ agg.n++;
97
+ if (rank === 0) agg.r1++;
98
+ if (rank !== -1 && rank < 3) agg.r3++;
99
+ if (rank !== -1 && rank < 5) agg.r5++;
100
+ if (rank !== -1 && rank < 10) agg.r10++;
101
+ if (rank !== -1) agg.mrr += 1 / (rank + 1);
102
+ }
103
+
104
+ function writeSession(
105
+ dir: string,
106
+ id: string,
107
+ turns: Array<{ role: string; content: string }>,
108
+ label: string,
109
+ ) {
110
+ const body =
111
+ `[${label}]\n\n` + turns.map((t) => `${t.role}: ${t.content}`).join("\n\n");
112
+ const fm = normalizeFrontmatter({
113
+ id,
114
+ scope: "personal",
115
+ type: "fact",
116
+ tags: ["longmemeval"],
117
+ source: "longmemeval-s",
118
+ });
119
+ fs.writeFileSync(path.join(dir, `${id}.md`), serialize(fm, body), "utf8");
120
+ }
121
+
122
+ function wipeScope() {
123
+ db()
124
+ .prepare("DELETE FROM memories WHERE scope_key = 'personal'")
125
+ .run();
126
+ db()
127
+ .prepare("DELETE FROM memories_fts WHERE rowid NOT IN (SELECT rowid FROM memories)")
128
+ .run();
129
+ const dir = memoriesDirPath("personal");
130
+ fs.mkdirSync(dir, { recursive: true });
131
+ for (const f of fs.readdirSync(dir)) fs.unlinkSync(path.join(dir, f));
132
+ return dir;
133
+ }
134
+
135
+ // question-local gold: session indices within this question's haystack
136
+ function goldIdxFor(q: LMEItem): Set<number> {
137
+ const sidToIdx = new Map(q.haystack_session_ids.map((sid, i) => [sid, i]));
138
+ return new Set(
139
+ q.answer_session_ids
140
+ .map((sid) => sidToIdx.get(sid))
141
+ .filter((i): i is number => i !== undefined),
142
+ );
143
+ }
144
+
145
+
146
+ function rankOf(
147
+ q: LMEItem,
148
+ isGold: (id: string) => boolean,
149
+ ): number {
150
+ const stats = { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0, aliasExpansions: [] as string[] };
151
+ const hits = search(q.question, {
152
+ scopeKeys: ["personal"],
153
+ limit: K,
154
+ stats,
155
+ ...SEARCH_OPTS,
156
+ });
157
+ for (let r = 0; r < hits.length; r++) {
158
+ if (isGold(hits[r].id)) return r;
159
+ }
160
+ return -1;
161
+ }
162
+
163
+ if (POOLED) {
164
+ // One big corpus: every session of every question (~23.5k memories).
165
+ const dir = wipeScope();
166
+ let qi = 0;
167
+ for (const q of questions) {
168
+ q.haystack_sessions.forEach((turns, i) => {
169
+ writeSession(dir, `q${qi}-s${i}`, turns, `question ${qi}, session ${i} — ${q.haystack_dates[i] ?? "unknown date"}`);
170
+ });
171
+ qi++;
172
+ }
173
+ syncScope("personal");
174
+ console.log(`pooled corpus: ${(db().prepare("SELECT COUNT(*) c FROM memories").get() as { c: number }).c} memories`);
175
+ }
176
+
177
+ // ---- rescue experiment: where do synonyms actually help? ----
178
+ // Arms: A = round 1 only; B = fallback append (shipped design, trigger forced
179
+ // to fire); C = always-expand + RRF fusion. Metric is rescue-focused, not MRR:
180
+ // a hit counts when the gold lands in the arm's top-K. Stratification by
181
+ // query-gold lexical overlap happens in analysis (see --out-rows TSV).
182
+ const RESCUE = args.includes("--rescue");
183
+ const ROWS = (() => {
184
+ const i = args.indexOf("--out-rows");
185
+ return i === -1 ? "" : args[i + 1];
186
+ })();
187
+
188
+ const RESCUE_STOP = new Set(
189
+ "the a an and or of to in on for with is are was were be been do does did how what why when where which who whom we i you he she it they them us me my our your his her its their this that these those there here can could should would will shall may might please if then than so as at by from into about over under again once just".split(" "),
190
+ );
191
+ function contentTerms(t: string): Set<string> {
192
+ const out = new Set<string>();
193
+ for (const w of t.toLowerCase().match(/[a-z0-9]+/g) ?? []) {
194
+ if (w.length > 2 && !RESCUE_STOP.has(w)) out.add(w);
195
+ }
196
+ return out;
197
+ }
198
+ function rrfFuse(
199
+ lists: Array<Array<{ id: string }>>,
200
+ k: number,
201
+ depth: number,
202
+ ): string[] {
203
+ const scores = new Map<string, number>();
204
+ for (const list of lists) {
205
+ list.slice(0, depth).forEach((h, rank) => {
206
+ scores.set(h.id, (scores.get(h.id) ?? 0) + 1 / (k + rank + 1));
207
+ });
208
+ }
209
+ return [...scores.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
210
+ }
211
+
212
+ if (RESCUE) {
213
+ const RD = 50; // retrieval depth per arm
214
+ const rows: string[] = ["qi\toverlap\ta\tb\tc"];
215
+ let qi = 0;
216
+ // pooled seeding happens once below via the shared POOLED block
217
+ if (POOLED) {
218
+ const dir = wipeScope();
219
+ let qj = 0;
220
+ for (const q of questions) {
221
+ q.haystack_sessions.forEach((turns, i) => {
222
+ writeSession(dir, `q${qj}-s${i}`, turns, `question ${qj}, session ${i}`);
223
+ });
224
+ qj++;
225
+ }
226
+ syncScope("personal");
227
+ }
228
+ let nA = 0, nB = 0, nC = 0, n = 0;
229
+ for (const q of questions) {
230
+ const gold = goldIdxFor(q);
231
+ if (gold.size === 0) { qi++; continue; }
232
+ let isGold: (id: string) => boolean;
233
+ let goldText = "";
234
+ if (POOLED) {
235
+ const myQi = qi;
236
+ isGold = (id) => {
237
+ const m = /^q(\d+)-s(\d+)$/.exec(id);
238
+ return !!m && Number(m[1]) === myQi && gold.has(Number(m[2]));
239
+ };
240
+ goldText = [...gold].map((i) => q.haystack_sessions[i].map((t) => t.content).join(" ")).join(" ");
241
+ } else {
242
+ const dir = wipeScope();
243
+ q.haystack_sessions.forEach((turns, i) => {
244
+ writeSession(dir, `sess-${i}`, turns, `session ${i}`);
245
+ });
246
+ syncScope("personal");
247
+ isGold = (id) => {
248
+ const m = /^sess-(\d+)$/.exec(id);
249
+ return !!m && gold.has(Number(m[1]));
250
+ };
251
+ goldText = [...gold].map((i) => q.haystack_sessions[i].map((t) => t.content).join(" ")).join(" ");
252
+ }
253
+ const r1 = search(q.question, { scopeKeys: ["personal"], limit: RD });
254
+ const qx = expandQueryWithSynonyms(q.question);
255
+ const r2 = qx === q.question ? r1 : search(qx, { scopeKeys: ["personal"], limit: RD });
256
+ const ids1 = r1.map((h) => h.id);
257
+ const in1 = new Set(ids1);
258
+ const aTop = ids1.slice(0, K);
259
+ const bTop = [...aTop, ...r2.map((h) => h.id).filter((id) => !in1.has(id))].slice(0, K);
260
+ const cTop = rrfFuse([r1, r2], 60, RD).slice(0, K);
261
+ const hit = (top: string[]) => (top.some(isGold) ? 1 : 0);
262
+ const ha = hit(aTop), hb = hit(bTop), hc = hit(cTop);
263
+ nA += ha; nB += hb; nC += hc; n++;
264
+ const qt = contentTerms(q.question);
265
+ const gt = contentTerms(goldText);
266
+ let overlap = 0;
267
+ for (const t of qt) if (gt.has(t)) overlap++;
268
+ rows.push(`${qi}\t${overlap}\t${ha}\t${hb}\t${hc}`);
269
+ qi++;
270
+ if (qi % 50 === 0) console.log(` ... ${qi}/${questions.length}`);
271
+ }
272
+ if (ROWS) fs.writeFileSync(ROWS, rows.join("\n") + "\n", "utf8");
273
+ console.log(`\n==== rescue experiment (${POOLED ? "pooled" : "per-question"}, K=${K}) ====`);
274
+ console.log(`arm A (round1 only) recall@${K}: ${(nA / n * 100).toFixed(1)}%`);
275
+ console.log(`arm B (fallback append) recall@${K}: ${(nB / n * 100).toFixed(1)}% rescues: ${nB - nA}`);
276
+ console.log(`arm C (RRF fusion) recall@${K}: ${(nC / n * 100).toFixed(1)}% rescues: ${nC - nA}`);
277
+ console.log(`rows written to ${ROWS || "(none)"}`);
278
+ process.exit(0);
279
+ }
280
+
281
+ let done = 0;
282
+ let qi = 0;
283
+ for (const q of questions) {
284
+ const gold = goldIdxFor(q);
285
+ let isGold: (id: string) => boolean;
286
+ if (POOLED) {
287
+ const myQi = qi;
288
+ isGold = (id) => {
289
+ const m = /^q(\d+)-s(\d+)$/.exec(id);
290
+ return !!m && Number(m[1]) === myQi && gold.has(Number(m[2]));
291
+ };
292
+ } else {
293
+ // Isolated corpus: wipe the personal scope and reseed per question.
294
+ const dir = wipeScope();
295
+ if (gold.size === 0) {
296
+ qi++;
297
+ continue;
298
+ }
299
+ q.haystack_sessions.forEach((turns, i) => {
300
+ writeSession(dir, `sess-${i}`, turns, `session ${i} — ${q.haystack_dates[i] ?? "unknown date"}`);
301
+ });
302
+ syncScope("personal");
303
+ isGold = (id) => {
304
+ const m = /^sess-(\d+)$/.exec(id);
305
+ return !!m && gold.has(Number(m[1]));
306
+ };
307
+ }
308
+ if (gold.size === 0) {
309
+ qi++;
310
+ continue;
311
+ }
312
+
313
+ const r = rankOf(q, isGold);
314
+ let t = byType.get(q.question_type);
315
+ if (!t) {
316
+ t = fresh();
317
+ byType.set(q.question_type, t);
318
+ }
319
+ record(total, r);
320
+ record(t, r);
321
+
322
+ done++;
323
+ qi++;
324
+ if (done % 50 === 0) console.log(` ... ${done}/${questions.length}`);
325
+ }
326
+
327
+ function pct(a: Agg, f: (x: Agg) => number): string {
328
+ return ((f(a) / a.n) * 100).toFixed(1) + "%";
329
+ }
330
+ function show(name: string, a: Agg) {
331
+ const cells = [`n=${a.n}`, `R@1 ${pct(a, (x) => x.r1)}`];
332
+ if (K >= 3) cells.push(`R@3 ${pct(a, (x) => x.r3)}`);
333
+ if (K >= 5) cells.push(`R@5 ${pct(a, (x) => x.r5)}`);
334
+ if (K >= 10) cells.push(`R@10 ${pct(a, (x) => x.r10)}`);
335
+ cells.push(`MRR@${K} ${(a.mrr / a.n).toFixed(3)}`);
336
+ console.log(` ${name.padEnd(28)} ${cells.join(" ")}`);
337
+ }
338
+
339
+ console.log(`\n==== LongMemEval-S retrieval (${POOLED ? "pooled 23k corpus" : "per-question isolated corpus"}) ====`);
340
+ console.log(`variant=${VARIANT} search-limit=${K}`);
341
+ show("overall", total);
342
+ for (const [t, a] of [...byType.entries()].sort()) show(t, a);
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Semantic-ranking experiment (the D77 embedding gate, Stone 2026-10-07).
3
+ *
4
+ * Question: does a local multilingual embedding + RRF hybrid beat the
5
+ * lexical ceiling? Target: pooled LongMemEval-S R@5 40.6% / MRR 0.284.
6
+ * Also measured on the synthetic fixture, esp. the 5 cross-lingual
7
+ * vocabulary-mismatch queries (the slice Stone cares about).
8
+ *
9
+ * Model: paraphrase-multilingual-MiniLM-L12-v2 via the `transformers` npm
10
+ * package (local, no API). Chosen multilingual because the mismatch slice
11
+ * is EN<->ZH. Embeddings are cached to disk; cosine is brute-forced in JS
12
+ * (22k x 384 is milliseconds).
13
+ *
14
+ * Modes:
15
+ * --pooled --data <longmemeval_s_cleaned.json> 22k sessions, 470 questions
16
+ * --fixture 51 synthetic memories, 41 queries
17
+ *
18
+ * Usage:
19
+ * HF_HUB_CACHE=~/workspace/bench-data/hf-cache node --experimental-strip-types \
20
+ * scripts/bench-semantic.ts --pooled --data ~/workspace/bench-data/longmemeval_s_cleaned.json
21
+ */
22
+ import fs from "node:fs";
23
+ import os from "node:os";
24
+ import path from "node:path";
25
+
26
+ const args = process.argv.slice(2);
27
+ const POOLED = args.includes("--pooled");
28
+ const dataIdx = args.indexOf("--data");
29
+ const dataPath = dataIdx === -1 ? "" : args[dataIdx + 1];
30
+ const limitIdx = args.indexOf("--limit");
31
+ const LIMIT = limitIdx === -1 ? Infinity : Number(args[limitIdx + 1]);
32
+ const MODEL = (() => {
33
+ const i = args.indexOf("--model");
34
+ return i === -1 ? "Xenova/paraphrase-multilingual-MiniLM-L12-v2" : args[i + 1];
35
+ })();
36
+ const K = 10;
37
+ const DEPTH = 50;
38
+
39
+ const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "sem-bench-"));
40
+ process.env.OPEN_MEMEX_HOME = HOME;
41
+
42
+ import { pipeline } from "@huggingface/transformers";
43
+ import { serialize, normalizeFrontmatter } from "../src/store/markdown.ts";
44
+ import { syncScope } from "../src/store/sync.ts";
45
+ import { search } from "../src/retrieve/search.ts";
46
+ import { memoriesDirPath } from "../src/paths.ts";
47
+
48
+ interface Doc {
49
+ id: string;
50
+ text: string;
51
+ }
52
+ interface Q {
53
+ text: string;
54
+ gold: Set<string>;
55
+ note: string;
56
+ }
57
+
58
+ const docs: Doc[] = [];
59
+ const questions: Q[] = [];
60
+
61
+ if (POOLED) {
62
+ if (!dataPath) throw new Error("missing --data");
63
+ const items = JSON.parse(fs.readFileSync(dataPath, "utf8"));
64
+ let qi = 0;
65
+ for (const item of items) {
66
+ if (item.question_id.endsWith("_abs")) continue;
67
+ if (qi >= LIMIT) break;
68
+ const sids: string[] = item.haystack_session_ids;
69
+ const gold = new Set<string>();
70
+ for (const g of item.answer_session_ids) {
71
+ const i = sids.indexOf(g);
72
+ if (i !== -1) gold.add(`q${qi}-s${i}`);
73
+ }
74
+ if (gold.size === 0) continue;
75
+ item.haystack_sessions.forEach((turns: Array<{ role: string; content: string }>, i: number) => {
76
+ docs.push({
77
+ id: `q${qi}-s${i}`,
78
+ text: turns.map((t) => `${t.role}: ${t.content}`).join("\n").slice(0, 2000),
79
+ });
80
+ });
81
+ questions.push({ text: item.question, gold, note: item.question_type });
82
+ qi++;
83
+ }
84
+ } else {
85
+ const { MEMORIES, QUERIES } = await import("./retrieval-eval/fixture.ts");
86
+ for (const m of MEMORIES) {
87
+ docs.push({
88
+ id: m.id,
89
+ text: [m.body, ...(m.aliases ?? []), ...(m.tags ?? [])].join("\n").slice(0, 2000),
90
+ });
91
+ }
92
+ for (const q of QUERIES.slice(0, LIMIT)) {
93
+ questions.push({ text: q.query, gold: new Set(q.gold), note: q.note });
94
+ }
95
+ }
96
+
97
+ console.log(`docs: ${docs.length}, questions: ${questions.length}, model: ${MODEL}`);
98
+
99
+ // Seed the FTS index (both modes) so the BM25 arm runs on the same corpus.
100
+ {
101
+ const dir = memoriesDirPath("personal");
102
+ fs.mkdirSync(dir, { recursive: true });
103
+ const B = 2000;
104
+ for (let i = 0; i < docs.length; i += B) {
105
+ for (const d of docs.slice(i, i + B)) {
106
+ const fm = normalizeFrontmatter({ id: d.id, scope: "personal", type: "fact", tags: [] });
107
+ fs.writeFileSync(path.join(dir, `${d.id}.md`), serialize(fm, d.text), "utf8");
108
+ }
109
+ console.log(` seeded ${Math.min(i + B, docs.length)}/${docs.length}`);
110
+ }
111
+ syncScope("personal");
112
+ }
113
+
114
+ // ---- embeddings (cached) ----
115
+ const cachePath = POOLED
116
+ ? `${os.homedir()}/workspace/bench-data/emb-pooled.json`
117
+ : `${os.homedir()}/workspace/bench-data/emb-fixture.json`;
118
+ let emb: number[][];
119
+ if (fs.existsSync(cachePath)) {
120
+ console.log("loading cached embeddings…");
121
+ emb = JSON.parse(fs.readFileSync(cachePath, "utf8"));
122
+ } else {
123
+ console.log("loading model… (first run downloads ~470MB)");
124
+ const extractor: any = await pipeline("feature-extraction", MODEL);
125
+ emb = [];
126
+ const B = 32;
127
+ for (let i = 0; i < docs.length; i += B) {
128
+ const batch = docs.map((d) => d.text).slice(i, i + B);
129
+ const out: any = await extractor(batch, { pooling: "mean", normalize: true });
130
+ const arr = out.tolist();
131
+ for (const v of arr) emb.push(v);
132
+ if (i % 320 === 0) console.log(` embedded ${i}/${docs.length}`);
133
+ }
134
+ fs.writeFileSync(cachePath, JSON.stringify(emb));
135
+ console.log("embeddings cached");
136
+ }
137
+ const DIM = emb[0].length;
138
+ console.log(`embedding dim: ${DIM}`);
139
+
140
+ const extractor: any = await pipeline("feature-extraction", MODEL);
141
+
142
+ function topVector(queryVec: number[], depth: number): string[] {
143
+ const scores: Array<{ id: string; s: number }> = [];
144
+ for (let i = 0; i < emb.length; i++) {
145
+ const v = emb[i];
146
+ let s = 0;
147
+ for (let d = 0; d < DIM; d++) s += queryVec[d] * v[d];
148
+ scores.push({ id: docs[i].id, s });
149
+ }
150
+ scores.sort((a, b) => b.s - a.s);
151
+ return scores.slice(0, depth).map((x) => x.id);
152
+ }
153
+
154
+ function rrf(lists: string[][], k = 60): string[] {
155
+ const scores = new Map<string, number>();
156
+ for (const list of lists) {
157
+ list.forEach((id, rank) => {
158
+ scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1));
159
+ });
160
+ }
161
+ return [...scores.entries()].sort((a, b) => b[1] - a[1]).map(([id]) => id);
162
+ }
163
+
164
+ let bR5 = 0, bMRR = 0, vR5 = 0, vMRR = 0, hR5 = 0, hMRR = 0;
165
+ const missDetail: string[] = [];
166
+ let qi = 0;
167
+ for (const q of questions) {
168
+ const bm25 = search(q.text, { scopeKeys: ["personal"], limit: DEPTH, synonyms: false }).map(
169
+ (h) => h.id,
170
+ );
171
+ const qv: number[] = (await extractor(q.text, { pooling: "mean", normalize: true })).tolist()[0];
172
+ const vec = topVector(qv, DEPTH);
173
+ const hyb = rrf([bm25, vec]).slice(0, K);
174
+
175
+ const rankIn = (top: string[]): number => {
176
+ for (let r = 0; r < top.slice(0, K).length; r++) if (q.gold.has(top[r])) return r;
177
+ return -1;
178
+ };
179
+ const rb = rankIn(bm25), rv = rankIn(vec), rh = rankIn(hyb);
180
+ if (rb !== -1 && rb < 5) bR5++;
181
+ if (rb !== -1) bMRR += 1 / (rb + 1);
182
+ if (rv !== -1 && rv < 5) vR5++;
183
+ if (rv !== -1) vMRR += 1 / (rv + 1);
184
+ if (rh !== -1 && rh < 5) hR5++;
185
+ if (rh !== -1) hMRR += 1 / (rh + 1);
186
+ if (!POOLED && rh === -1) {
187
+ missDetail.push(` MISS(hybrid) query="${q.text}" gold=${[...q.gold].join(",")} [${q.note}] bm25rank=${rb} vecrank=${rv}`);
188
+ }
189
+ qi++;
190
+ if (qi % 100 === 0) console.log(` ... ${qi}/${questions.length}`);
191
+ }
192
+
193
+ const n = questions.length;
194
+ console.log(`\n==== semantic experiment (${POOLED ? "pooled 22k" : "fixture"}) ====`);
195
+ console.log(`BM25-only : R@5 ${(bR5 / n * 100).toFixed(1)}% MRR ${(bMRR / n).toFixed(3)}`);
196
+ console.log(`vec-only : R@5 ${(vR5 / n * 100).toFixed(1)}% MRR ${(vMRR / n).toFixed(3)}`);
197
+ console.log(`hybrid RRF: R@5 ${(hR5 / n * 100).toFixed(1)}% MRR ${(hMRR / n).toFixed(3)}`);
198
+ if (missDetail.length) console.log("hybrid misses:\n" + missDetail.join("\n"));