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,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Index-time synonym/translation expansion (D80).
|
|
3
|
+
*
|
|
4
|
+
* Stone's rule: solve vocabulary mismatch at write time, so any related
|
|
5
|
+
* word finds the answer with a plain round-1 query. At sync/index time
|
|
6
|
+
* every memory's keywords are expanded through a curated, checked-in
|
|
7
|
+
* equivalence map and the variants are written into the indexed `aliases`
|
|
8
|
+
* column (FTS + CJK bigram columns both pick them up via sync.ts). The
|
|
9
|
+
* query side stays simple: one FTS round, no trigger, no second chance.
|
|
10
|
+
*
|
|
11
|
+
* Deliberately NOT an embedding model or an LLM call: deterministic,
|
|
12
|
+
* auditable, zero dependencies, and the failure mode (a bad synonym)
|
|
13
|
+
* is a visible line in this file, not a silent vector.
|
|
14
|
+
*
|
|
15
|
+
* Groups are bidirectional — any member expands to the other members.
|
|
16
|
+
* Keep groups tight: an over-broad synonym costs precision on every
|
|
17
|
+
* query, and at index time a bad entry pollutes every document instead
|
|
18
|
+
* of one query. When in doubt, leave it out. A map update only reaches
|
|
19
|
+
* already-indexed memories after a reindex.
|
|
20
|
+
*
|
|
21
|
+
* Design note (D80): the map covers the *precise* slice — EN↔ZH
|
|
22
|
+
* translation pairs and dev-domain equivalents where embeddings are
|
|
23
|
+
* fuzzy (ship/deploy are neighbors in vector space but "单点登录"/SSO
|
|
24
|
+
* is an exact pair no embedding guarantees). Embeddings cover the
|
|
25
|
+
* semantic long tail; this map covers what must never be missed.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { LATIN_STOPWORDS } from "./query.ts";
|
|
29
|
+
|
|
30
|
+
type Group = string[];
|
|
31
|
+
|
|
32
|
+
const CJK_RE = /[\u4e00-\u9fff]/;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Curated equivalence groups. EN dev-domain vocabulary, ZH equivalents,
|
|
36
|
+
* and EN↔ZH translation pairs live in one bidirectional group: any member
|
|
37
|
+
* expands to all the others, in either direction.
|
|
38
|
+
*/
|
|
39
|
+
const GROUPS: Group[] = [
|
|
40
|
+
["deploy", "ship", "release", "rollout", "publish", "部署", "发布", "上线"],
|
|
41
|
+
["test", "testing", "tests", "测试", "单测", "集成测试"],
|
|
42
|
+
["regression test", "回归测试"],
|
|
43
|
+
["rollback", "revert", "回滚", "撤回", "撤销", "回退"],
|
|
44
|
+
["incident", "outage", "postmortem", "故障", "事故", "宕机"],
|
|
45
|
+
["auth", "authentication", "login", "sso", "single sign-on", "单点登录", "登录"],
|
|
46
|
+
["secret", "secrets", "credential", "credentials", "token", "密钥"],
|
|
47
|
+
["database", "db", "数据库"],
|
|
48
|
+
["backup", "restore", "备份", "恢复"],
|
|
49
|
+
["retention", "保留"],
|
|
50
|
+
["production", "生产", "生产环境"],
|
|
51
|
+
["staging", "预发布环境", "预发布"],
|
|
52
|
+
["wipe", "wipes", "清空", "清除"],
|
|
53
|
+
["nightly", "night", "每晚"],
|
|
54
|
+
["thursday", "周四", "星期四"],
|
|
55
|
+
["mandatory", "强制", "必须"],
|
|
56
|
+
["flaky", "flake", "unreliable", "intermittent"],
|
|
57
|
+
["bug", "defect", "issue"],
|
|
58
|
+
["ci", "pipeline"],
|
|
59
|
+
["cache", "caching", "cached"],
|
|
60
|
+
["invalidate", "invalidation", "expire", "expiry"],
|
|
61
|
+
["oncall", "on-call", "pager"],
|
|
62
|
+
["migration", "migrate", "migrating"],
|
|
63
|
+
["index", "indexing", "indexed"],
|
|
64
|
+
["slow", "latency", "performance", "perf"],
|
|
65
|
+
["refactor", "refactoring"],
|
|
66
|
+
["merge", "merging"],
|
|
67
|
+
["branch", "branches"],
|
|
68
|
+
["commit", "commits"],
|
|
69
|
+
["review", "code review", "评审", "代码评审"],
|
|
70
|
+
["mock", "stub"],
|
|
71
|
+
["debug", "debugging", "troubleshoot", "troubleshooting"],
|
|
72
|
+
["log", "logs", "logging"],
|
|
73
|
+
["monitor", "monitoring", "alert", "alerting"],
|
|
74
|
+
["scale", "scaling"],
|
|
75
|
+
["timeout", "timeouts"],
|
|
76
|
+
["retry", "retries"],
|
|
77
|
+
["queue", "job", "worker"],
|
|
78
|
+
["api", "endpoint"],
|
|
79
|
+
["docker", "container", "image"],
|
|
80
|
+
["kubernetes", "k8s"],
|
|
81
|
+
["env", "environment"],
|
|
82
|
+
["config", "configuration", "setting", "settings"],
|
|
83
|
+
["hotfix", "hot fix"],
|
|
84
|
+
["estimate", "estimation"],
|
|
85
|
+
["乱码", "编码问题"],
|
|
86
|
+
["并发", "并行"],
|
|
87
|
+
["重构"],
|
|
88
|
+
["规范", "约定"],
|
|
89
|
+
];
|
|
90
|
+
|
|
91
|
+
const termLookup = new Map<string, string[]>();
|
|
92
|
+
const phraseEntries: Array<{ phrase: string; siblings: string[] }> = [];
|
|
93
|
+
const cjkEntries: Array<{ keys: string[]; siblings: string[] }> = [];
|
|
94
|
+
|
|
95
|
+
for (const g of GROUPS) {
|
|
96
|
+
const cjkKeys = g.filter((m) => CJK_RE.test(m));
|
|
97
|
+
if (cjkKeys.length > 0) cjkEntries.push({ keys: cjkKeys, siblings: [...g] });
|
|
98
|
+
for (const m of g) {
|
|
99
|
+
if (CJK_RE.test(m)) continue;
|
|
100
|
+
const siblings = g.filter((x) => x !== m);
|
|
101
|
+
if (m.includes(" ")) phraseEntries.push({ phrase: m.toLowerCase(), siblings });
|
|
102
|
+
else termLookup.set(m.toLowerCase(), siblings);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Synonym variants for one lowercased Latin term (excluding the term
|
|
108
|
+
* itself). Empty array when the term has no curated synonyms.
|
|
109
|
+
*/
|
|
110
|
+
export function synonymVariants(term: string): string[] {
|
|
111
|
+
return termLookup.get(term.toLowerCase()) ?? [];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Cap on auto-generated variants per memory — bounds index growth. */
|
|
115
|
+
export const MAX_AUTO_VARIANTS = 32;
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Index-time expansion for one memory's text (body + tags). Keyword-
|
|
119
|
+
* centric (Stone): extract content terms, expand each through the
|
|
120
|
+
* curated map, return variants not already present in the text.
|
|
121
|
+
* Pure logic — safe to unit test, runs once per memory at sync time.
|
|
122
|
+
*/
|
|
123
|
+
export function expansionVariantsForDoc(text: string): string[] {
|
|
124
|
+
const lower = text.toLowerCase();
|
|
125
|
+
// Content terms only: function words carry no signal (same filter the
|
|
126
|
+
// query builder applies, so both sides agree on what a keyword is).
|
|
127
|
+
const latinTokens = new Set(
|
|
128
|
+
(lower.match(/[a-z0-9_.\-]+/g) ?? []).filter(
|
|
129
|
+
(t) => t.length > 1 && !LATIN_STOPWORDS.has(t),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
const out: string[] = [];
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
const push = (v: string) => {
|
|
135
|
+
const key = v.toLowerCase();
|
|
136
|
+
if (seen.has(key) || out.length >= MAX_AUTO_VARIANTS) return;
|
|
137
|
+
seen.add(key);
|
|
138
|
+
out.push(v);
|
|
139
|
+
};
|
|
140
|
+
// A variant is "already present" when the doc has it as a token (Latin),
|
|
141
|
+
// as a substring (Latin phrase), or as a substring (CJK — no tokenizer).
|
|
142
|
+
const present = (v: string): boolean =>
|
|
143
|
+
CJK_RE.test(v)
|
|
144
|
+
? text.includes(v)
|
|
145
|
+
: v.includes(" ")
|
|
146
|
+
? lower.includes(v.toLowerCase())
|
|
147
|
+
: latinTokens.has(v.toLowerCase());
|
|
148
|
+
|
|
149
|
+
for (const t of latinTokens) {
|
|
150
|
+
for (const v of termLookup.get(t) ?? []) if (!present(v)) push(v);
|
|
151
|
+
}
|
|
152
|
+
for (const { phrase, siblings } of phraseEntries) {
|
|
153
|
+
if (!lower.includes(phrase)) continue;
|
|
154
|
+
for (const v of siblings) if (!present(v)) push(v);
|
|
155
|
+
}
|
|
156
|
+
for (const { keys, siblings } of cjkEntries) {
|
|
157
|
+
if (!keys.some((k) => text.includes(k))) continue;
|
|
158
|
+
for (const v of siblings) if (!present(v)) push(v);
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Expand a raw query string with synonym variants (legacy query-time
|
|
165
|
+
* helper, kept for bench scripts). The product no longer expands at
|
|
166
|
+
* query time — D80 moved expansion to the index.
|
|
167
|
+
*/
|
|
168
|
+
export function expandQueryWithSynonyms(q: string): string {
|
|
169
|
+
const extra = new Set<string>();
|
|
170
|
+
const latinTerms = q.toLowerCase().match(/[a-z0-9_.\-]+/g) ?? [];
|
|
171
|
+
for (const t of latinTerms) {
|
|
172
|
+
for (const s of synonymVariants(t)) extra.add(s);
|
|
173
|
+
}
|
|
174
|
+
for (const { keys, siblings } of cjkEntries) {
|
|
175
|
+
if (keys.some((k) => q.includes(k))) {
|
|
176
|
+
for (const s of siblings) extra.add(s);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// Don't re-add terms already in the query.
|
|
180
|
+
const have = new Set(latinTerms);
|
|
181
|
+
const additions = [...extra].filter((s) => !have.has(s.toLowerCase()) && !q.includes(s));
|
|
182
|
+
if (additions.length === 0) return q;
|
|
183
|
+
return `${q} ${additions.join(" ")}`;
|
|
184
|
+
}
|
package/src/store/sync.ts
CHANGED
|
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { db } from "./db.ts";
|
|
4
4
|
import { cjkIndexText } from "../retrieve/cjk.ts";
|
|
5
|
+
import { expansionVariantsForDoc } from "../retrieve/synonyms.ts";
|
|
5
6
|
import { contentHash, repairChain } from "./lifecycle.ts";
|
|
6
7
|
import {
|
|
7
8
|
iterMemoryFiles,
|
|
@@ -63,7 +64,16 @@ export function upsertFromFile(mf: MemoryFile): void {
|
|
|
63
64
|
function writeRow(mf: MemoryFile, mtimeMs: number): void {
|
|
64
65
|
const { fm, body, filePath } = mf;
|
|
65
66
|
const tags = (fm.tags ?? []).join(",");
|
|
66
|
-
|
|
67
|
+
// D80 index-time expansion: curated synonym/translation variants for the
|
|
68
|
+
// memory's keywords are indexed in the aliases column (and therefore the
|
|
69
|
+
// CJK bigram column below), so any related word matches in round one.
|
|
70
|
+
// Derived at sync time — the Markdown file stays the source of truth.
|
|
71
|
+
const supplied = fm.aliases ?? [];
|
|
72
|
+
const have = new Set(supplied.map((a) => a.toLowerCase()));
|
|
73
|
+
const auto = expansionVariantsForDoc(body + "\n" + tags).filter(
|
|
74
|
+
(a) => !have.has(a.toLowerCase()),
|
|
75
|
+
);
|
|
76
|
+
const aliases = [...supplied, ...auto].join(",");
|
|
67
77
|
db().prepare(UPSERT_SQL).run(
|
|
68
78
|
fm.id,
|
|
69
79
|
fm.scope_key,
|