open-memex 0.7.3 → 0.8.0-alpha.2
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/AGENTS.md +2 -1
- package/CHANGELOG.md +12 -0
- package/README.md +35 -0
- package/README.zh-CN.md +9 -0
- package/dist/.build-stamp +1 -1
- package/dist/retrieve/query.js +20 -9
- package/dist/retrieve/search.js +19 -12
- package/dist/retrieve/synonyms.js +183 -0
- package/dist/store/sync.js +9 -1
- package/docs/V2-DESIGN.md +7 -2
- package/docs/release-runbook.md +8 -2
- package/docs/retrieval-ablation-study.md +191 -0
- package/package.json +5 -3
- package/scripts/bench-longmemeval.ts +342 -0
- package/scripts/bench-semantic.ts +198 -0
- package/scripts/retrieval-eval/fixture.ts +167 -0
- package/scripts/retrieval-eval.ts +81 -0
- package/scripts/smoke-pure.ts +27 -0
- package/src/retrieve/query.ts +23 -9
- package/src/retrieve/search.ts +30 -13
- package/src/retrieve/synonyms.ts +184 -0
- package/src/store/sync.ts +11 -1
|
@@ -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"));
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Synthetic retrieval-eval fixture (D77).
|
|
3
|
+
*
|
|
4
|
+
* 48 personal-scope memories + 36 queries with gold ids. Written BEFORE
|
|
5
|
+
* running the eval — the fixture is the test, not tuned to the results.
|
|
6
|
+
* All memories share one scope/tier so the measured ranking is pure BM25.
|
|
7
|
+
* Mixed EN/ZH; includes paraphrase and synonym queries (the hard cases
|
|
8
|
+
* from the retrieval-robustness question) and near-duplicate distractors.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export interface FixtureMemory {
|
|
12
|
+
id: string;
|
|
13
|
+
type: string;
|
|
14
|
+
tags: string[];
|
|
15
|
+
aliases?: string[];
|
|
16
|
+
body: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface FixtureQuery {
|
|
20
|
+
query: string;
|
|
21
|
+
gold: string[];
|
|
22
|
+
note: string;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MEMORIES: FixtureMemory[] = [
|
|
26
|
+
// ---- deploy & CI ----
|
|
27
|
+
{ id: "mem-001", type: "decision", tags: ["deploy", "ci"], body: "Deploy to production only from the release branch. Merging to main triggers staging; the release branch is cut from main every Thursday and production deploys only from release/*." },
|
|
28
|
+
{ id: "mem-002", type: "gotcha", tags: ["ci", "github-actions"], body: "GitHub Actions macOS runners are 10x the per-minute cost of linux. The e2e job was moved to ubuntu-latest and macOS is kept only for the notarization step." },
|
|
29
|
+
{ id: "mem-003", type: "howto", tags: ["deploy", "rollback"], body: "To roll back a production deploy: revert the merge commit on the release branch, push, and the pipeline redeploys the previous artifact. Do not redeploy old artifacts manually from the Actions UI." },
|
|
30
|
+
{ id: "mem-004", type: "fact", tags: ["ci"], body: "CI runs lint, typecheck, unit tests, and the smoke suite on every PR. The full e2e suite runs only on the release branch to save minutes." },
|
|
31
|
+
// distractor: shares deploy vocabulary, different topic
|
|
32
|
+
{ id: "mem-005", type: "fact", tags: ["deploy"], body: "The deploy dashboard is at deploys.internal.example.com. It shows who deployed what and when, but rollbacks must still go through the release branch revert flow." },
|
|
33
|
+
|
|
34
|
+
// ---- testing ----
|
|
35
|
+
{ id: "mem-006", type: "preference", tags: ["testing"], body: "Prefer table-driven tests over copy-pasted cases. One test function, a slice of inputs and expected outputs — much easier to extend than five near-identical test blocks." },
|
|
36
|
+
{ id: "mem-007", type: "gotcha", tags: ["testing", "flaky"], body: "The checkout flow test is flaky on CI because it depends on wall-clock timing. It was quarantined with t.Parallel disabled and a retry wrapper; do not unquarantine without fixing the clock dependency." },
|
|
37
|
+
{ id: "mem-008", type: "decision", tags: ["testing", "coverage"], body: "Coverage gate is 80% on new code, measured by the CI coverage job. Legacy modules are exempt until they are touched — then the touched lines must be covered." },
|
|
38
|
+
|
|
39
|
+
// ---- editor & tools ----
|
|
40
|
+
{ id: "mem-009", type: "preference", tags: ["editor", "vscode"], body: "VS Code: format on save with the workspace prettier config, never the global one. The workspace config lives in .vscode/settings.json and wins over user settings." },
|
|
41
|
+
{ id: "mem-010", type: "howto", tags: ["git"], aliases: ["how to undo last commit", "git revert commit"], body: "To undo the last local commit but keep the changes: git reset --soft HEAD~1. To discard everything including working tree: git reset --hard HEAD~1 (destructive, no undo)." },
|
|
42
|
+
{ id: "mem-011", type: "fact", tags: ["node"], body: "The repo pins Node 22.14.0 in .nvmrc. CI and the Dockerfile both use it; running 20.x locally causes the better-sqlite3 native binding to fail to load." },
|
|
43
|
+
|
|
44
|
+
// ---- database ----
|
|
45
|
+
{ id: "mem-012", type: "gotcha", tags: ["postgres", "migrations"], body: "Postgres migrations must be backward compatible: never drop a column in the same release that stops writing to it. Expand first, migrate, then contract in the next release." },
|
|
46
|
+
{ id: "mem-013", type: "decision", tags: ["postgres", "index"], body: "Added a partial index on orders(status) WHERE status = 'pending' after the pending-orders query started sequential-scanning 40M rows. Query time went from 9s to 40ms." },
|
|
47
|
+
{ id: "mem-014", type: "howto", tags: ["sqlite"], aliases: ["sqlite busy timeout", "database is locked fix"], body: "SQLite 'database is locked' under concurrent writers: enable WAL mode and set a busy timeout (PRAGMA busy_timeout=5000). Readers never block writers in WAL." },
|
|
48
|
+
|
|
49
|
+
// ---- API design ----
|
|
50
|
+
{ id: "mem-015", type: "decision", tags: ["api", "rest"], body: "Public API uses cursor pagination (opaque cursor param), never offset/limit. Offset breaks when rows are inserted concurrently; the cursor is the last seen id." },
|
|
51
|
+
{ id: "mem-016", type: "lesson", tags: ["api", "versioning"], body: "Lesson from the v2 migration: versioning the whole API at once was a mistake. Additive changes ship unversioned; only breaking changes get a new version prefix." },
|
|
52
|
+
|
|
53
|
+
// ---- security ----
|
|
54
|
+
{ id: "mem-017", type: "constraint", tags: ["security", "secrets"], body: "Never commit secrets. The pre-commit hook runs gitleaks; if a secret lands in history it must be rotated immediately — rewriting history does not unexpose it." },
|
|
55
|
+
{ id: "mem-018", type: "howto", tags: ["security", "jwt"], aliases: ["refresh token rotation"], body: "JWT access tokens live 15 minutes; refresh tokens rotate on every use and are bound to the device fingerprint. A reused refresh token invalidates the whole token family." },
|
|
56
|
+
|
|
57
|
+
// ---- performance ----
|
|
58
|
+
{ id: "mem-019", type: "gotcha", tags: ["performance", "n+1"], body: "The N+1 in the invoice endpoint came from lazy-loading line items per invoice in a loop. Fixed with a single JOIN fetch; p95 dropped from 2.1s to 180ms." },
|
|
59
|
+
{ id: "mem-020", type: "decision", tags: ["performance", "caching"], aliases: ["cache invalidation strategy"], body: "Cache invalidation uses write-through for the product catalog: writes update both DB and cache synchronously. Stale reads were worse than the write latency cost." },
|
|
60
|
+
|
|
61
|
+
// ---- Chinese memories ----
|
|
62
|
+
{ id: "mem-021", type: "decision", tags: ["部署", "规范"], body: "生产环境只允许周四发布,紧急 hotfix 需要 TL 在发布群里 @所有人 说明原因。周五不发布,避免周末 oncall。" },
|
|
63
|
+
{ id: "mem-022", type: "gotcha", tags: ["微信", "小程序"], body: "微信小程序的 request 并发上限是 10,超过会直接失败不排队。批量上传图片必须自己做并发控制,分批 8 个一组发。" },
|
|
64
|
+
{ id: "mem-023", type: "preference", tags: ["代码风格"], body: "中文注释写在代码上方,不要写在行尾。行尾注释在窄屏和 diff 里经常被截断,上方注释可读性更好。" },
|
|
65
|
+
{ id: "mem-024", type: "howto", tags: ["git", "中文"], aliases: ["git 中文乱码", "commit message 乱码"], body: "Windows 上 git log 中文乱码:设置 git config --global core.quotepath false,同时把终端编码调成 UTF-8。" },
|
|
66
|
+
{ id: "mem-025", type: "fact", tags: ["考勤"], body: "每月 5 号之前提交上月考勤,逾期系统自动锁定找 HR 解锁。弹性工作制但 10 点前要到岗。" },
|
|
67
|
+
// distractor sharing vocab
|
|
68
|
+
{ id: "mem-026", type: "fact", tags: ["发布"], body: "发布群是企业微信里的「发版通知」群,CI 成功后机器人会自动推送版本号和 changelog 链接。" },
|
|
69
|
+
|
|
70
|
+
// ---- incidents ----
|
|
71
|
+
{ id: "mem-027", type: "lesson", tags: ["incident", "postgres"], body: "Incident 2026-08: the primary ran out of disk because WAL archiving stalled silently. Lesson: alert on archive_command failures, not just disk usage — disk was the symptom." },
|
|
72
|
+
{ id: "mem-028", type: "lesson", tags: ["incident", "dns"], body: "Incident 2026-09: DNS TTL of 24h turned a 5-minute failover into a day-long outage. Critical records now use 300s TTL; the long TTL only ever saved pennies." },
|
|
73
|
+
|
|
74
|
+
// ---- architecture decisions ----
|
|
75
|
+
{ id: "mem-029", type: "decision", tags: ["architecture", "monorepo"], body: "Monorepo with pnpm workspaces won over polyrepo: cross-package refactors were the dominant pain and CI can already scope builds per changed package." },
|
|
76
|
+
{ id: "mem-030", type: "decision", tags: ["architecture", "queue"], body: "Chose SQS over RabbitMQ for the notification pipeline: at-least-once delivery is fine there, and we did not want to operate another stateful service." },
|
|
77
|
+
{ id: "mem-031", type: "fact", tags: ["architecture"], body: "The system has three services: api-gateway (edge), core (business logic), worker (async jobs). The worker scales independently on queue depth." },
|
|
78
|
+
|
|
79
|
+
// ---- frontend ----
|
|
80
|
+
{ id: "mem-032", type: "gotcha", tags: ["react", "hooks"], body: "React useEffect with an empty dep array still re-runs in StrictMode dev double-invoke. Effects that POST must be idempotent or guarded by a ref — production is fine, dev double-fires." },
|
|
81
|
+
{ id: "mem-033", type: "preference", tags: ["css"], body: "Use CSS modules for component styles, global stylesheet only for resets and tokens. The one time we put component CSS global it leaked into the admin panel." },
|
|
82
|
+
|
|
83
|
+
// ---- personal productivity ----
|
|
84
|
+
{ id: "mem-034", type: "preference", tags: ["productivity"], body: "Deep work block is 9–11am, no meetings. The calendar auto-declines invites in that window with a note pointing to the team agreement." },
|
|
85
|
+
{ id: "mem-035", type: "fact", tags: ["productivity", "standup"], body: "Standup is async in Slack #standup by 10am. Format: yesterday / today / blockers, one line each. No video call unless someone flags a blocker." },
|
|
86
|
+
|
|
87
|
+
// ---- more distractors & variety ----
|
|
88
|
+
{ id: "mem-036", type: "fact", tags: ["ci"], body: "The CI badge in the README reflects the main branch only. PR builds show as checks on the PR itself, not on the badge." },
|
|
89
|
+
{ id: "mem-037", type: "howto", tags: ["docker"], body: "Multi-stage Docker build: first stage compiles with dev dependencies, final stage copies only dist/ and production node_modules. Image went from 1.2GB to 180MB." },
|
|
90
|
+
{ id: "mem-038", type: "gotcha", tags: ["docker", "arm"], body: "Docker builds on Apple Silicon default to arm64; the prod cluster is amd64. Always build with --platform linux/amd64 or the container crashes on deploy with exec format error." },
|
|
91
|
+
{ id: "mem-039", type: "decision", tags: ["logging"], body: "Structured JSON logs everywhere, no free-text log lines. The log aggregator parses fields; free text broke the error-rate dashboard twice." },
|
|
92
|
+
{ id: "mem-040", type: "preference", tags: ["code-review"], body: "Code review SLA: first review within 4 business hours. If you are blocked on review, ping in #dev — do not merge your own PR except for docs." },
|
|
93
|
+
|
|
94
|
+
// ---- synonyms/paraphrase targets ----
|
|
95
|
+
{ id: "mem-041", type: "howto", tags: ["k8s"], aliases: ["kubernetes pod crashloop", "pod 一直重启"], body: "Kubernetes CrashLoopBackOff: check kubectl describe pod for the exit code first, then logs --previous. Nine times out of ten it is a missing env var, not the image." },
|
|
96
|
+
{ id: "mem-042", type: "fact", tags: ["oncall"], body: "Oncall rotation is weekly, Monday to Monday. The handoff note must list: active incidents, risky deploys this week, and anything with a TODO owner on vacation." },
|
|
97
|
+
{ id: "mem-043", type: "decision", tags: ["auth"], body: "SSO via OIDC is mandatory for all internal tools. Password-only auth was removed after the credential-stuffing scare in March; service accounts use mTLS instead." },
|
|
98
|
+
{ id: "mem-044", type: "gotcha", tags: ["timezone"], body: "Store all timestamps in UTC, convert at the display edge. The reporting bug that mixed server-local time with UTC took a week to untangle because both looked plausible." },
|
|
99
|
+
{ id: "mem-045", type: "howto", tags: ["ssh"], body: "SSH into prod goes through the bastion with agent forwarding disabled. Copy the key to the bastion instead — forwarding once let a compromised dev box pivot further." },
|
|
100
|
+
{ id: "mem-046", type: "preference", tags: ["meetings"], body: "No-meeting Wednesdays for the eng team. Recurring meetings that land on Wednesday get moved, not exempted." },
|
|
101
|
+
{ id: "mem-047", type: "fact", tags: ["vpn"], body: "The company VPN auto-disconnects after 12 hours. Long-running remote jobs should run on the dev server inside the network, not over a laptop VPN session." },
|
|
102
|
+
{ id: "mem-048", type: "lesson", tags: ["estimation"], body: "Estimates are given as ranges (3–5 days), never single numbers. The single-number estimates were wrong 80% of the time; ranges forced the uncertainty conversation upfront." },
|
|
103
|
+
{ id: "mem-049", type: "fact", tags: ["备份", "数据库"], body: "数据库备份每天凌晨两点执行,全量保留三十天,恢复演练每个季度做一次。" },
|
|
104
|
+
{ id: "mem-050", type: "decision", tags: ["发布", "测试"], body: "上线前必须跑完全量回归测试,测试报告要贴到发布群里,TL 确认后才能发。" },
|
|
105
|
+
{ id: "mem-051", type: "gotcha", tags: ["staging"], body: "The staging environment wipes itself every night at midnight. Never leave test data there overnight — it will be gone by morning." },
|
|
106
|
+
];
|
|
107
|
+
|
|
108
|
+
export const QUERIES: FixtureQuery[] = [
|
|
109
|
+
// ---- exact-term queries ----
|
|
110
|
+
{ query: "production deploy release branch", gold: ["mem-001"], note: "exact terms" },
|
|
111
|
+
{ query: "GitHub Actions macOS runner cost", gold: ["mem-002"], note: "exact terms" },
|
|
112
|
+
{ query: "rollback production deploy revert", gold: ["mem-003"], note: "exact terms" },
|
|
113
|
+
{ query: "table driven tests", gold: ["mem-006"], note: "exact terms" },
|
|
114
|
+
{ query: "partial index pending orders", gold: ["mem-013"], note: "exact terms" },
|
|
115
|
+
{ query: "cursor pagination offset", gold: ["mem-015"], note: "exact terms" },
|
|
116
|
+
{ query: "WAL archiving disk full", gold: ["mem-027"], note: "exact terms" },
|
|
117
|
+
{ query: "JWT refresh token rotation", gold: ["mem-018"], note: "exact terms" },
|
|
118
|
+
|
|
119
|
+
// ---- paraphrase queries (same meaning, different words) ----
|
|
120
|
+
{ query: "how do I undo my last commit", gold: ["mem-010"], note: "paraphrase; alias covers it" },
|
|
121
|
+
{ query: "sqlite locked concurrent writers", gold: ["mem-014"], note: "paraphrase; alias covers it" },
|
|
122
|
+
{ query: "k8s pod keeps restarting", gold: ["mem-041"], note: "paraphrase; alias covers it" },
|
|
123
|
+
{ query: "when can we ship to prod", gold: ["mem-001"], note: "paraphrase of deploy rule" },
|
|
124
|
+
{ query: "tests that fail randomly on CI", gold: ["mem-007"], note: "paraphrase: flaky" },
|
|
125
|
+
{ query: "why did the API versioning go wrong", gold: ["mem-016"], note: "paraphrase of lesson" },
|
|
126
|
+
{ query: "slow invoice endpoint fix", gold: ["mem-019"], note: "paraphrase: N+1" },
|
|
127
|
+
|
|
128
|
+
// ---- synonym queries (no shared content words) ----
|
|
129
|
+
{ query: "shipping code to production safely", gold: ["mem-001"], note: "synonym: shipping/deploy" },
|
|
130
|
+
{ query: "unreliable test quarantine", gold: ["mem-007"], note: "synonym: unreliable/flaky" },
|
|
131
|
+
{ query: "database locking issue", gold: ["mem-014"], note: "synonym" },
|
|
132
|
+
{ query: "stale data problem cache", gold: ["mem-020"], note: "synonym: stale reads" },
|
|
133
|
+
|
|
134
|
+
// ---- Chinese queries ----
|
|
135
|
+
{ query: "周四发布 hotfix 流程", gold: ["mem-021"], note: "CJK exact" },
|
|
136
|
+
{ query: "小程序并发限制", gold: ["mem-022"], note: "CJK exact" },
|
|
137
|
+
{ query: "git 中文显示乱码", gold: ["mem-024"], note: "CJK; alias covers it" },
|
|
138
|
+
{ query: "注释写在哪里比较好", gold: ["mem-023"], note: "CJK paraphrase" },
|
|
139
|
+
{ query: "考勤什么时候交", gold: ["mem-025"], note: "CJK paraphrase" },
|
|
140
|
+
|
|
141
|
+
// ---- cross-language (expected hard) ----
|
|
142
|
+
{ query: "production release thursday rule", gold: ["mem-021"], note: "EN query, ZH memory — hard" },
|
|
143
|
+
{ query: "wechat miniprogram request limit", gold: ["mem-022"], note: "EN query, ZH memory — hard" },
|
|
144
|
+
|
|
145
|
+
// ---- vocabulary-mismatch (translation pairs; the real-world slice LongMemEval-S lacks) ----
|
|
146
|
+
// Written 2026-10-07 BEFORE any index-time expansion or embedding layer exists:
|
|
147
|
+
// each query shares ~zero content words with its gold memory.
|
|
148
|
+
{ query: "database backup retention policy", gold: ["mem-049"], note: "EN query, ZH memory — 备份/保留" },
|
|
149
|
+
{ query: "full regression test before production release", gold: ["mem-050"], note: "EN query, ZH memory — 回归/上线" },
|
|
150
|
+
{ query: "预发布环境数据为什么会丢", gold: ["mem-051"], note: "ZH query, EN memory — staging/wipe" },
|
|
151
|
+
{ query: "单点登录强制要求", gold: ["mem-043"], note: "ZH query, EN memory — SSO/mandatory" },
|
|
152
|
+
|
|
153
|
+
// ---- distractor resistance ----
|
|
154
|
+
{ query: "where is the deploy dashboard", gold: ["mem-005"], note: "must prefer mem-005 over mem-001/003" },
|
|
155
|
+
{ query: "CI badge meaning", gold: ["mem-036"], note: "must prefer mem-036 over mem-004" },
|
|
156
|
+
{ query: "docker image too big", gold: ["mem-037"], note: "must prefer mem-037 over mem-038" },
|
|
157
|
+
{ query: "container crashes on deploy", gold: ["mem-038"], note: "must prefer mem-038 over mem-037" },
|
|
158
|
+
{ query: "standup format", gold: ["mem-035"], note: "exact" },
|
|
159
|
+
{ query: "oncall handoff", gold: ["mem-042"], note: "exact" },
|
|
160
|
+
{ query: "monorepo or polyrepo", gold: ["mem-029"], note: "exact" },
|
|
161
|
+
{ query: "timestamps UTC or local", gold: ["mem-044"], note: "paraphrase" },
|
|
162
|
+
|
|
163
|
+
// ---- synonym-only queries (round 1 sees none of these words; round 2 must catch them) ----
|
|
164
|
+
{ query: "how do we ship", gold: ["mem-001"], note: "synonym-only: ship→deploy/release" },
|
|
165
|
+
{ query: "pager schedule", gold: ["mem-042"], note: "synonym-only: pager→oncall" },
|
|
166
|
+
{ query: "my token got exposed", gold: ["mem-017"], note: "synonym-only: token→secret" },
|
|
167
|
+
];
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retrieval eval (D77, layer 1): offline FTS5 baseline on a synthetic fixture.
|
|
3
|
+
*
|
|
4
|
+
* Usage: node --experimental-strip-types scripts/retrieval-eval.ts
|
|
5
|
+
*
|
|
6
|
+
* Seeds a scratch OPEN_MEMEX_HOME with the fixture memories (all personal
|
|
7
|
+
* scope, so every memory sits in the same review tier and the measured
|
|
8
|
+
* ranking is pure BM25), runs each fixture query through search(), and
|
|
9
|
+
* reports recall@1 / recall@5 / MRR plus a per-query miss table.
|
|
10
|
+
*
|
|
11
|
+
* The fixture (scripts/retrieval-eval/fixture.ts) was written BEFORE the
|
|
12
|
+
* first run. Do not tune the fixture to the results — a miss is a finding,
|
|
13
|
+
* not a bug in the fixture.
|
|
14
|
+
*/
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import os from "node:os";
|
|
17
|
+
import path from "node:path";
|
|
18
|
+
|
|
19
|
+
const HOME = fs.mkdtempSync(path.join(os.tmpdir(), "retrieval-eval-"));
|
|
20
|
+
process.env.OPEN_MEMEX_HOME = HOME;
|
|
21
|
+
|
|
22
|
+
import { MEMORIES, QUERIES } from "./retrieval-eval/fixture.ts";
|
|
23
|
+
import { serialize, normalizeFrontmatter } from "../src/store/markdown.ts";
|
|
24
|
+
import { syncScope } from "../src/store/sync.ts";
|
|
25
|
+
import { search } from "../src/retrieve/search.ts";
|
|
26
|
+
import { memoriesDirPath } from "../src/paths.ts";
|
|
27
|
+
|
|
28
|
+
const K = 5;
|
|
29
|
+
|
|
30
|
+
// --- seed ---
|
|
31
|
+
const dir = memoriesDirPath("personal");
|
|
32
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
33
|
+
for (const m of MEMORIES) {
|
|
34
|
+
const fm = normalizeFrontmatter({
|
|
35
|
+
id: m.id,
|
|
36
|
+
scope: "personal",
|
|
37
|
+
type: m.type,
|
|
38
|
+
tags: m.tags,
|
|
39
|
+
aliases: m.aliases,
|
|
40
|
+
source: "retrieval-eval fixture",
|
|
41
|
+
});
|
|
42
|
+
fs.writeFileSync(path.join(dir, `${m.id}.md`), serialize(fm, m.body), "utf8");
|
|
43
|
+
}
|
|
44
|
+
const stats = syncScope("personal");
|
|
45
|
+
console.log(`seeded ${MEMORIES.length} memories (indexed: ${stats.added})\n`);
|
|
46
|
+
|
|
47
|
+
// --- run ---
|
|
48
|
+
let r1 = 0;
|
|
49
|
+
let r5 = 0;
|
|
50
|
+
let mrr = 0;
|
|
51
|
+
const misses: string[] = [];
|
|
52
|
+
|
|
53
|
+
for (const q of QUERIES) {
|
|
54
|
+
const stats = { ftsQuery: "", candidates: 0, hiddenSuperseded: 0, hiddenExcluded: 0 };
|
|
55
|
+
const hits = search(q.query, { scopeKeys: ["personal"], limit: 50, stats });
|
|
56
|
+
const ids = hits.map((h) => h.id);
|
|
57
|
+
let rank = -1;
|
|
58
|
+
for (const g of q.gold) {
|
|
59
|
+
const i = ids.indexOf(g);
|
|
60
|
+
if (i !== -1 && (rank === -1 || i < rank)) rank = i;
|
|
61
|
+
}
|
|
62
|
+
if (rank === 0) r1++;
|
|
63
|
+
if (rank !== -1 && rank < K) r5++;
|
|
64
|
+
if (rank !== -1) mrr += 1 / (rank + 1);
|
|
65
|
+
else misses.push(` MISS rank=- query="${q.query}" gold=${q.gold.join(",")} [${q.note}]`);
|
|
66
|
+
if (rank > 0) {
|
|
67
|
+
console.log(` rank=${rank + 1} query="${q.query}" gold=${q.gold.join(",")} [${q.note}]`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const n = QUERIES.length;
|
|
72
|
+
console.log(`\n==== retrieval-eval: FTS5 baseline ====`);
|
|
73
|
+
console.log(`corpus : ${MEMORIES.length} memories (personal scope, single tier)`);
|
|
74
|
+
console.log(`queries: ${n}`);
|
|
75
|
+
console.log(`recall@1: ${(r1 / n).toFixed(3)} (${r1}/${n})`);
|
|
76
|
+
console.log(`recall@5: ${(r5 / n).toFixed(3)} (${r5}/${n})`);
|
|
77
|
+
console.log(`MRR : ${(mrr / n).toFixed(3)}`);
|
|
78
|
+
if (misses.length > 0) {
|
|
79
|
+
console.log(`\nmisses (${misses.length}):`);
|
|
80
|
+
for (const m of misses) console.log(m);
|
|
81
|
+
}
|
package/scripts/smoke-pure.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { DEFAULT_CONFIG } from "../src/config.ts";
|
|
|
8
8
|
import { resolveProjectScope, resolveCwdScope, pickScopeRoot, PERSONAL_SCOPE } from "../src/scope.ts";
|
|
9
9
|
import { cjkIndexText, cjkQueryExpr, hasCjk } from "../src/retrieve/cjk.ts";
|
|
10
10
|
import { toFtsQuery } from "../src/retrieve/query.ts";
|
|
11
|
+
import { expansionVariantsForDoc, MAX_AUTO_VARIANTS } from "../src/retrieve/synonyms.ts";
|
|
11
12
|
import { contentHash, similarity, NEAR_DUP_THRESHOLD } from "../src/store/lifecycle.ts";
|
|
12
13
|
import {
|
|
13
14
|
MIN_NODE_VERSION,
|
|
@@ -867,5 +868,31 @@ ok("cursor keeps skill", shouldInstallSkill("cursor", false) === true);
|
|
|
867
868
|
ok("truncation note speaks only when cut", truncationNote(20, 54)?.includes("34 more") === true && truncationNote(20, 20) === null);
|
|
868
869
|
}
|
|
869
870
|
|
|
871
|
+
console.log("== D80 index-time expansion ==");
|
|
872
|
+
{
|
|
873
|
+
// EN term expands to its group siblings, minus what's already in the text.
|
|
874
|
+
const v1 = expansionVariantsForDoc("Deploy to production only from the release branch.");
|
|
875
|
+
ok("ship expands from deploy", v1.includes("ship"), v1.join(","));
|
|
876
|
+
ok("no dupes of present terms", !v1.includes("deploy") && !v1.includes("release"), v1.join(","));
|
|
877
|
+
ok("function words are not keywords", !v1.includes("to") && !v1.includes("the"), v1.join(","));
|
|
878
|
+
// ZH substring expands both directions: doc has 数据库 → database indexed.
|
|
879
|
+
const v2 = expansionVariantsForDoc("数据库备份每天凌晨两点执行,全量保留三十天。");
|
|
880
|
+
ok("database indexed from 数据库", v2.includes("database"), v2.join(","));
|
|
881
|
+
ok("backup indexed from 备份", v2.includes("backup"), v2.join(","));
|
|
882
|
+
ok("retention indexed from 保留", v2.includes("retention"), v2.join(","));
|
|
883
|
+
// EN doc gets ZH variants: staging → 预发布环境.
|
|
884
|
+
const v3 = expansionVariantsForDoc("The staging environment wipes itself every night at midnight.");
|
|
885
|
+
ok("预发布环境 indexed from staging", v3.includes("预发布环境"), v3.join(","));
|
|
886
|
+
ok("wipes inflects to wipe group", v3.includes("清空"), v3.join(","));
|
|
887
|
+
// Multi-word Latin phrase: regression test.
|
|
888
|
+
const v4 = expansionVariantsForDoc("上线前必须跑完全量回归测试。");
|
|
889
|
+
ok("regression test indexed from 回归测试", v4.includes("regression test"), v4.join(","));
|
|
890
|
+
// Cap bounds index growth.
|
|
891
|
+
const v5 = expansionVariantsForDoc("test deploy bug cache queue api docker config log mock review commit merge branch");
|
|
892
|
+
ok("variant cap respected", v5.length <= MAX_AUTO_VARIANTS, String(v5.length));
|
|
893
|
+
// Empty / no-match input.
|
|
894
|
+
ok("no curated terms → no variants", expansionVariantsForDoc("hello world").length === 0);
|
|
895
|
+
}
|
|
896
|
+
|
|
870
897
|
console.log(fails === 0 ? "\nALL PASS" : `\n${fails} FAILURES`);
|
|
871
898
|
process.exit(fails === 0 ? 0 : 1);
|
package/src/retrieve/query.ts
CHANGED
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { cjkQueryExpr, hasCjk } from "./cjk.ts";
|
|
15
15
|
|
|
16
16
|
/** English function words that carry no retrieval signal in questions. */
|
|
17
|
-
const LATIN_STOPWORDS = new Set([
|
|
17
|
+
export const LATIN_STOPWORDS = new Set([
|
|
18
18
|
"the", "a", "an", "and", "or", "of", "to", "in", "on", "for", "with",
|
|
19
19
|
"without", "is", "are", "was", "were", "be", "been", "do", "does", "did",
|
|
20
20
|
"how", "what", "why", "when", "where", "which", "who", "whom", "we", "i",
|
|
@@ -32,9 +32,11 @@ const CJK_QUESTION_BIGRAMS = new Set([
|
|
|
32
32
|
|
|
33
33
|
const MAX_TERMS = 24;
|
|
34
34
|
|
|
35
|
-
function latinTerms(q: string): string[] {
|
|
35
|
+
function latinTerms(q: string, useStopwords = true): string[] {
|
|
36
36
|
const raw = q.toLowerCase().match(/[a-z0-9_.\-]+/g) ?? [];
|
|
37
|
-
const kept =
|
|
37
|
+
const kept = useStopwords
|
|
38
|
+
? raw.filter((t) => t.length > 1 && !LATIN_STOPWORDS.has(t))
|
|
39
|
+
: raw.filter((t) => t.length > 1);
|
|
38
40
|
const chosen = kept.length > 0 ? kept : raw;
|
|
39
41
|
return [...new Set(chosen)].slice(0, MAX_TERMS);
|
|
40
42
|
}
|
|
@@ -44,15 +46,27 @@ function latinTerms(q: string): string[] {
|
|
|
44
46
|
* Latin tokens keep the old behavior (prefix match on content/tags/type).
|
|
45
47
|
* CJK runs become an OR of bigrams against the `cjk` column (see cjk.ts).
|
|
46
48
|
* Mixed queries OR the two parts together.
|
|
49
|
+
*
|
|
50
|
+
* Flags are ablation toggles (all default to the shipped behavior):
|
|
51
|
+
* - stopwords: false → skip function-word filtering
|
|
52
|
+
* - orJoin: false → AND the terms instead of OR
|
|
53
|
+
* - prefix: false → exact term match instead of `term*`
|
|
47
54
|
*/
|
|
48
|
-
export function toFtsQuery(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
55
|
+
export function toFtsQuery(
|
|
56
|
+
q: string,
|
|
57
|
+
flags: { stopwords?: boolean; orJoin?: boolean; prefix?: boolean } = {},
|
|
58
|
+
): string {
|
|
59
|
+
const useStopwords = flags.stopwords !== false;
|
|
60
|
+
const useOr = flags.orJoin !== false;
|
|
61
|
+
const usePrefix = flags.prefix !== false;
|
|
62
|
+
const joiner = useOr ? " OR " : " AND ";
|
|
63
|
+
const latin = latinTerms(q, useStopwords)
|
|
64
|
+
.map((t) => (usePrefix ? `"${t.replace(/"/g, '""')}"*` : `"${t.replace(/"/g, '""')}"`))
|
|
65
|
+
.join(joiner);
|
|
52
66
|
const cjk = hasCjk(q)
|
|
53
|
-
? cjkQueryExpr(q, { dropBigrams: CJK_QUESTION_BIGRAMS, maxTerms: MAX_TERMS })
|
|
67
|
+
? cjkQueryExpr(q, { dropBigrams: useStopwords ? CJK_QUESTION_BIGRAMS : undefined, maxTerms: MAX_TERMS })
|
|
54
68
|
: "";
|
|
55
|
-
if (latin && cjk) return `(${latin}) OR {cjk}:(${cjk})`;
|
|
69
|
+
if (latin && cjk) return `(${latin}) ${useOr ? "OR" : "AND"} {cjk}:(${cjk})`;
|
|
56
70
|
if (cjk) return `{cjk}:(${cjk})`;
|
|
57
71
|
return latin;
|
|
58
72
|
}
|
package/src/retrieve/search.ts
CHANGED
|
@@ -158,9 +158,19 @@ function resolveVisible(rows: RawRow[], limit: number, stats?: SearchStats): Sea
|
|
|
158
158
|
*/
|
|
159
159
|
export function search(
|
|
160
160
|
query: string,
|
|
161
|
-
opts: {
|
|
161
|
+
opts: {
|
|
162
|
+
scopeKeys?: string[];
|
|
163
|
+
limit?: number;
|
|
164
|
+
type?: string;
|
|
165
|
+
stats?: SearchStats;
|
|
166
|
+
/** Ablation toggles for query construction (default = shipped behavior). */
|
|
167
|
+
stopwords?: boolean;
|
|
168
|
+
orJoin?: boolean;
|
|
169
|
+
prefix?: boolean;
|
|
170
|
+
} = {},
|
|
162
171
|
): SearchHit[] {
|
|
163
|
-
const
|
|
172
|
+
const qflags = { stopwords: opts.stopwords, orJoin: opts.orJoin, prefix: opts.prefix };
|
|
173
|
+
const q = toFtsQuery(query, qflags);
|
|
164
174
|
if (opts.stats) {
|
|
165
175
|
opts.stats.ftsQuery = q;
|
|
166
176
|
opts.stats.candidates = 0;
|
|
@@ -190,14 +200,7 @@ export function search(
|
|
|
190
200
|
ORDER BY score ASC
|
|
191
201
|
LIMIT ?
|
|
192
202
|
`;
|
|
193
|
-
|
|
194
|
-
if (opts.scopeKeys && opts.scopeKeys.length > 0) params.push(...opts.scopeKeys);
|
|
195
|
-
if (opts.type) params.push(opts.type);
|
|
196
|
-
params.push(fetchLimit);
|
|
197
|
-
|
|
198
|
-
const rows = db()
|
|
199
|
-
.prepare(sql)
|
|
200
|
-
.all(...(params as any[])) as Array<{
|
|
203
|
+
type Row = {
|
|
201
204
|
id: string;
|
|
202
205
|
scope_key: string;
|
|
203
206
|
project_name: string;
|
|
@@ -211,10 +214,24 @@ export function search(
|
|
|
211
214
|
created_at: number;
|
|
212
215
|
snippet: string;
|
|
213
216
|
score: number;
|
|
214
|
-
}
|
|
217
|
+
};
|
|
218
|
+
const fetchFts = (matchExpr: string): RawRow[] => {
|
|
219
|
+
const params: unknown[] = [matchExpr];
|
|
220
|
+
if (opts.scopeKeys && opts.scopeKeys.length > 0) params.push(...opts.scopeKeys);
|
|
221
|
+
if (opts.type) params.push(opts.type);
|
|
222
|
+
params.push(fetchLimit);
|
|
223
|
+
const rows = db()
|
|
224
|
+
.prepare(sql)
|
|
225
|
+
.all(...(params as any[])) as Row[];
|
|
226
|
+
// FTS5 bm25: lower = better; invert for intuition.
|
|
227
|
+
return rows.map((r) => ({ ...r, score: -r.score }));
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const raw: RawRow[] = fetchFts(q);
|
|
215
231
|
|
|
216
|
-
//
|
|
217
|
-
|
|
232
|
+
// D80: vocabulary mismatch is solved at index time (synonyms.ts expands
|
|
233
|
+
// curated variants into the aliases column at sync time), so the query
|
|
234
|
+
// side is a single FTS round — no trigger, no second chance.
|
|
218
235
|
if (opts.stats) opts.stats.candidates = raw.length;
|
|
219
236
|
return resolveVisible(raw, limit, opts.stats);
|
|
220
237
|
}
|