open-memex
Version:
Local-first memory layer and protocol for AI coding agents. Markdown source of truth, SQLite FTS5 index, zero cloud.
185 lines (173 loc) • 7.36 kB
text/typescript
/**
* Index-time synonym/translation expansion (D80).
*
* Stone's rule: solve vocabulary mismatch at write time, so any related
* word finds the answer with a plain round-1 query. At sync/index time
* every memory's keywords are expanded through a curated, checked-in
* equivalence map and the variants are written into the indexed `aliases`
* column (FTS + CJK bigram columns both pick them up via sync.ts). The
* query side stays simple: one FTS round, no trigger, no second chance.
*
* Deliberately NOT an embedding model or an LLM call: deterministic,
* auditable, zero dependencies, and the failure mode (a bad synonym)
* is a visible line in this file, not a silent vector.
*
* Groups are bidirectional — any member expands to the other members.
* Keep groups tight: an over-broad synonym costs precision on every
* query, and at index time a bad entry pollutes every document instead
* of one query. When in doubt, leave it out. A map update only reaches
* already-indexed memories after a reindex.
*
* Design note (D80): the map covers the *precise* slice — EN↔ZH
* translation pairs and dev-domain equivalents where embeddings are
* fuzzy (ship/deploy are neighbors in vector space but "单点登录"/SSO
* is an exact pair no embedding guarantees). Embeddings cover the
* semantic long tail; this map covers what must never be missed.
*/
import { LATIN_STOPWORDS } from "./query.ts";
type Group = string[];
const CJK_RE = /[\u4e00-\u9fff]/;
/**
* Curated equivalence groups. EN dev-domain vocabulary, ZH equivalents,
* and EN↔ZH translation pairs live in one bidirectional group: any member
* expands to all the others, in either direction.
*/
const GROUPS: Group[] = [
["deploy", "ship", "release", "rollout", "publish", "部署", "发布", "上线"],
["test", "testing", "tests", "测试", "单测", "集成测试"],
["regression test", "回归测试"],
["rollback", "revert", "回滚", "撤回", "撤销", "回退"],
["incident", "outage", "postmortem", "故障", "事故", "宕机"],
["auth", "authentication", "login", "sso", "single sign-on", "单点登录", "登录"],
["secret", "secrets", "credential", "credentials", "token", "密钥"],
["database", "db", "数据库"],
["backup", "restore", "备份", "恢复"],
["retention", "保留"],
["production", "生产", "生产环境"],
["staging", "预发布环境", "预发布"],
["wipe", "wipes", "清空", "清除"],
["nightly", "night", "每晚"],
["thursday", "周四", "星期四"],
["mandatory", "强制", "必须"],
["flaky", "flake", "unreliable", "intermittent"],
["bug", "defect", "issue"],
["ci", "pipeline"],
["cache", "caching", "cached"],
["invalidate", "invalidation", "expire", "expiry"],
["oncall", "on-call", "pager"],
["migration", "migrate", "migrating"],
["index", "indexing", "indexed"],
["slow", "latency", "performance", "perf"],
["refactor", "refactoring"],
["merge", "merging"],
["branch", "branches"],
["commit", "commits"],
["review", "code review", "评审", "代码评审"],
["mock", "stub"],
["debug", "debugging", "troubleshoot", "troubleshooting"],
["log", "logs", "logging"],
["monitor", "monitoring", "alert", "alerting"],
["scale", "scaling"],
["timeout", "timeouts"],
["retry", "retries"],
["queue", "job", "worker"],
["api", "endpoint"],
["docker", "container", "image"],
["kubernetes", "k8s"],
["env", "environment"],
["config", "configuration", "setting", "settings"],
["hotfix", "hot fix"],
["estimate", "estimation"],
["乱码", "编码问题"],
["并发", "并行"],
["重构"],
["规范", "约定"],
];
const termLookup = new Map<string, string[]>();
const phraseEntries: Array<{ phrase: string; siblings: string[] }> = [];
const cjkEntries: Array<{ keys: string[]; siblings: string[] }> = [];
for (const g of GROUPS) {
const cjkKeys = g.filter((m) => CJK_RE.test(m));
if (cjkKeys.length > 0) cjkEntries.push({ keys: cjkKeys, siblings: [...g] });
for (const m of g) {
if (CJK_RE.test(m)) continue;
const siblings = g.filter((x) => x !== m);
if (m.includes(" ")) phraseEntries.push({ phrase: m.toLowerCase(), siblings });
else termLookup.set(m.toLowerCase(), siblings);
}
}
/**
* Synonym variants for one lowercased Latin term (excluding the term
* itself). Empty array when the term has no curated synonyms.
*/
export function synonymVariants(term: string): string[] {
return termLookup.get(term.toLowerCase()) ?? [];
}
/** Cap on auto-generated variants per memory — bounds index growth. */
export const MAX_AUTO_VARIANTS = 32;
/**
* Index-time expansion for one memory's text (body + tags). Keyword-
* centric (Stone): extract content terms, expand each through the
* curated map, return variants not already present in the text.
* Pure logic — safe to unit test, runs once per memory at sync time.
*/
export function expansionVariantsForDoc(text: string): string[] {
const lower = text.toLowerCase();
// Content terms only: function words carry no signal (same filter the
// query builder applies, so both sides agree on what a keyword is).
const latinTokens = new Set(
(lower.match(/[a-z0-9_.\-]+/g) ?? []).filter(
(t) => t.length > 1 && !LATIN_STOPWORDS.has(t),
),
);
const out: string[] = [];
const seen = new Set<string>();
const push = (v: string) => {
const key = v.toLowerCase();
if (seen.has(key) || out.length >= MAX_AUTO_VARIANTS) return;
seen.add(key);
out.push(v);
};
// A variant is "already present" when the doc has it as a token (Latin),
// as a substring (Latin phrase), or as a substring (CJK — no tokenizer).
const present = (v: string): boolean =>
CJK_RE.test(v)
? text.includes(v)
: v.includes(" ")
? lower.includes(v.toLowerCase())
: latinTokens.has(v.toLowerCase());
for (const t of latinTokens) {
for (const v of termLookup.get(t) ?? []) if (!present(v)) push(v);
}
for (const { phrase, siblings } of phraseEntries) {
if (!lower.includes(phrase)) continue;
for (const v of siblings) if (!present(v)) push(v);
}
for (const { keys, siblings } of cjkEntries) {
if (!keys.some((k) => text.includes(k))) continue;
for (const v of siblings) if (!present(v)) push(v);
}
return out;
}
/**
* Expand a raw query string with synonym variants (legacy query-time
* helper, kept for bench scripts). The product no longer expands at
* query time — D80 moved expansion to the index.
*/
export function expandQueryWithSynonyms(q: string): string {
const extra = new Set<string>();
const latinTerms = q.toLowerCase().match(/[a-z0-9_.\-]+/g) ?? [];
for (const t of latinTerms) {
for (const s of synonymVariants(t)) extra.add(s);
}
for (const { keys, siblings } of cjkEntries) {
if (keys.some((k) => q.includes(k))) {
for (const s of siblings) extra.add(s);
}
}
// Don't re-add terms already in the query.
const have = new Set(latinTerms);
const additions = [...extra].filter((s) => !have.has(s.toLowerCase()) && !q.includes(s));
if (additions.length === 0) return q;
return `${q} ${additions.join(" ")}`;
}