dsh-engramory-recall 0.12.1 → 0.12.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/CHANGELOG.md +33 -0
- package/README.md +54 -2
- package/package.json +2 -1
- package/providers/openai-embeddings.mjs +53 -12
- package/semantic.js +8 -7
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,39 @@
|
|
|
3
3
|
本项目的所有重要变更都记在这里。格式参照 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.1.0/),
|
|
4
4
|
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。
|
|
5
5
|
|
|
6
|
+
## [0.12.3] — 2026-10-15
|
|
7
|
+
|
|
8
|
+
### 新增
|
|
9
|
+
|
|
10
|
+
- **参考提供方会自动重试限流与临时故障**:`429 / 408 / 409 / 425 / 500 / 502 / 503 / 504` 重试 2 次,
|
|
11
|
+
尊重服务端 `Retry-After`(秒数或 HTTP 日期,上限 5 秒),等待可被接缝的超时预算打断;
|
|
12
|
+
`400 / 401 / 403` 不重试。重试到上限的错误写成 `embeddings HTTP 503(已重试 2 次):<服务端原话>`。
|
|
13
|
+
新增 3 条测试(重试后成功 / 重试到上限 / 4xx 不重试),都打本地假服务。
|
|
14
|
+
|
|
15
|
+
### 文档
|
|
16
|
+
|
|
17
|
+
- README 补「哪些服务能用」:常见可用名单(OpenAI / 智谱 / 阿里百炼 / 硅基流动 / Mistral / Together /
|
|
18
|
+
本机 Ollama / 本机 LM Studio 的 `BASE_URL` + `MODEL`),以及**不能用的两家及其原因** ——
|
|
19
|
+
Anthropic 没有 embeddings 接口;**DeepSeek 官方 API 只有对话模型**(已对照官方模型表),
|
|
20
|
+
所以现有的 DeepSeek key 走不了这条路,零成本试法是把 Ollama / LM Studio 起在本机。
|
|
21
|
+
|
|
22
|
+
## [0.12.2] — 2026-10-15
|
|
23
|
+
|
|
24
|
+
### 新增
|
|
25
|
+
|
|
26
|
+
- **embeddings 接缝的实测尺子**(`npm run bench:embed`):`--fake` 起本机假服务把管线与代价量清楚
|
|
27
|
+
(池子实际大小、每次检索耗时、HTTP 调用数、发出文本条数、缓存省下多少),`--real` 换成真端点量质量。
|
|
28
|
+
真库那半边要显式 `--store`,因为**它会把笔记片段发给端点**。
|
|
29
|
+
- 提供方模块现在也接受**默认导出对象**(`export default { rerank }`)——第三方提供方最自然的写法,
|
|
30
|
+
不必改成命名导出。命名导出照旧。
|
|
31
|
+
|
|
32
|
+
### 说明
|
|
33
|
+
|
|
34
|
+
- 假向量(哈希向量 = 最坏提供方)把融合表的结论变成实测:真库探针 hit@1 从 72.2%(词面)掉到
|
|
35
|
+
5.6%(`trust`)与 19.4%(`rrf`)—— `trust` 对垃圾提供方是灾难,**第一次接第三方接口建议先 `rrf`**。
|
|
36
|
+
- 缓存实测:26925 条候选只发出 1994 条(省 92.6%),因为同一篇笔记在不同查询里反复出现。
|
|
37
|
+
- 池子实际大小中位 31~39 条(不是 50):词面只回有分的文档,所以 `semanticPool` 是上限不是常态。
|
|
38
|
+
|
|
6
39
|
## [0.12.1] — 2026-10-15
|
|
7
40
|
|
|
8
41
|
### 新增
|
package/README.md
CHANGED
|
@@ -718,8 +718,59 @@ export ENGRAMORY_EMBED_MODEL=text-embedding-3-small
|
|
|
718
718
|
`https://dashscope.aliyuncs.com/compatible-mode/v1` + `text-embedding-v4`、智谱
|
|
719
719
|
`https://open.bigmodel.cn/api/paas/v4` + `embedding-3`、硅基流动 `https://api.siliconflow.cn/v1` + `BAAI/bge-m3`。
|
|
720
720
|
|
|
721
|
-
|
|
722
|
-
|
|
721
|
+
#### 哪些服务能用(以及 DeepSeek 为什么不行)
|
|
722
|
+
|
|
723
|
+
要求只有一条:提供 `POST {base}/embeddings`。常见可用名单(`BASE_URL` + `MODEL` 照填):
|
|
724
|
+
|
|
725
|
+
| 供应商 | BASE_URL | 模型示例 |
|
|
726
|
+
|---|---|---|
|
|
727
|
+
| OpenAI | `https://api.openai.com/v1` | `text-embedding-3-small` / `-large` |
|
|
728
|
+
| 智谱 | `https://open.bigmodel.cn/api/paas/v4` | `embedding-3` |
|
|
729
|
+
| 阿里百炼 | `https://dashscope.aliyuncs.com/compatible-mode/v1` | `text-embedding-v4` |
|
|
730
|
+
| 硅基流动 | `https://api.siliconflow.cn/v1` | `BAAI/bge-m3` |
|
|
731
|
+
| Mistral | `https://api.mistral.ai/v1` | `mistral-embed` |
|
|
732
|
+
| Together | `https://api.together.xyz/v1` | `BAAI/bge-large-en-v1.5` |
|
|
733
|
+
| 本机 Ollama | `http://localhost:11434/v1` | `nomic-embed-text` / `bge-m3` |
|
|
734
|
+
| 本机 LM Studio | `http://localhost:1234/v1` | 你加载的那个 embedding 模型 |
|
|
735
|
+
|
|
736
|
+
**不能用的两家**,原因不同:
|
|
737
|
+
|
|
738
|
+
- **Anthropic(Claude)没有 embeddings 接口** —— 「Claude 协议」跟向量无关,填了也只会 404。
|
|
739
|
+
官方推荐的做法是另找一家 embeddings 服务。
|
|
740
|
+
- **DeepSeek 官方 API 只有对话模型**([官方模型表](https://api-docs.deepseek.com/quick_start/pricing)
|
|
741
|
+
里只有 `deepseek-flash` / `deepseek-v4-pro`,没有 `/embeddings`)⇒ **你现在这把 DeepSeek key 用不上这条路**。
|
|
742
|
+
想零成本试就把 Ollama / LM Studio 在本机起一个(完全不出网),想真量就挑上面任一家。
|
|
743
|
+
|
|
744
|
+
一探便知:`node providers/openai-embeddings.mjs --check` —— **404 = 这家没这个接口;401 = 密钥不对**;
|
|
745
|
+
成功了会报维度与一次往返耗时。
|
|
746
|
+
|
|
747
|
+
**限流与临时故障会自动扛**:`429 / 500 / 502 / 503 / 504`(以及 408/409/425)重试 2 次,尊重服务端
|
|
748
|
+
`Retry-After`(上限 5 秒,而且等待可被超时预算打断);`400 / 401 / 403` 这类不重试 —— 重试改不了
|
|
749
|
+
鉴权与请求体的问题。重试到上限的错误会写成 `embeddings HTTP 503(已重试 2 次):<服务端原话>`。
|
|
750
|
+
|
|
751
|
+
**一句实话**:这条路径的代码、测试、失败降级、重试都有,但**还没有在真 key 上量过召回**(要花 token,
|
|
752
|
+
得你先点头)。接缝本身的上限是量过的(上表 86.6%),提供方的质量只能真调一次才知道。
|
|
753
|
+
|
|
754
|
+
#### 真端点怎么量:`npm run bench:embed`
|
|
755
|
+
|
|
756
|
+
一条命令,两种模式:`--fake`(默认)起一个**本机假 embeddings 服务**,只读「管线与代价」;
|
|
757
|
+
`--real` 用 `providers/openai-embeddings.mjs` 的配置读法找真端点,那时质量列才是真的。
|
|
758
|
+
真库那半边要显式加 `--store ~/memory/engramory` —— 加了就意味着**你的笔记片段会发给那个端点**,
|
|
759
|
+
所以默认不开。
|
|
760
|
+
|
|
761
|
+
假向量(哈希向量 = 最坏提供方)跑出来的两组数字,正好把上面那张融合表变成实测:
|
|
762
|
+
|
|
763
|
+
| 卷子 | 词面 hit@1 / hit@4 | 假向量 + `trust` | 假向量 + `rrf` |
|
|
764
|
+
|---|---|---|---|
|
|
765
|
+
| 合成基准 704 条 | 50.4% / 67.5% | 18.3% / 25.4% | 26.8% / 43.6% |
|
|
766
|
+
| 真库探针 36 条 | 72.2% / 97.2% | 5.6% / 16.7% | 19.4% / 52.8% |
|
|
767
|
+
|
|
768
|
+
`trust` 对垃圾提供方是**灾难**(真库上 72.2% → 5.6%),`rrf` 也一样亏但守住一半。
|
|
769
|
+
所以:**不确定提供方靠不靠谱就别开 `trust`**(这也是第一次接第三方接口时的默认建议)。
|
|
770
|
+
|
|
771
|
+
代价侧(同一次运行数的):池子实际大小中位 31~39 条(不是 50,因为词面只回有分的文档)、
|
|
772
|
+
每次检索 p50 19~20 ms(假服务在本机,真延迟看端点);提供方按文本缓存让
|
|
773
|
+
**26925 条候选只发了 1994 条(省 92.6%)** —— 同一篇笔记在不同查询里反复出现时只付一次钱。
|
|
723
774
|
|
|
724
775
|
#### 复用 DSH 里已经配好的那条路由(`provider: "host-embed"`)
|
|
725
776
|
|
|
@@ -954,6 +1005,7 @@ npm run bench:check # 合成基准 + 和 bench/expectations.json 对账(
|
|
|
954
1005
|
npm run bench:bridges # 同义表覆盖率尺子:死值、覆盖、使用面、逐键消融(--ablate / --json / --store)
|
|
955
1006
|
npm run bench:seam # 语义接缝的代价与天花板(四种坏提供方 + 作弊上界 + 融合模式对照)
|
|
956
1007
|
npm run bench:ceiling # 池宽天花板:任一提供方在公开数据集上的上界 + 每查询要发多少东西
|
|
1008
|
+
npm run bench:embed # embeddings 接缝:--fake 量管线与代价 / --real 用真端点量质量
|
|
957
1009
|
npm run embed:check # 参考提供方自检:探一次 embeddings 接口,报维度与耗时(不打印密钥)
|
|
958
1010
|
npm run bench:rewrite # 宿主模型当改写提供方:真调用量代价与收益(要本机有 dsh;--offline 只重算)
|
|
959
1011
|
npm run bench:mcp # MCP 这一层:冷启动、每步往返、协议帧字节数
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-engramory-recall",
|
|
3
|
-
"version": "0.12.
|
|
3
|
+
"version": "0.12.3",
|
|
4
4
|
"description": "Local BM25 auto-recall, fail-closed write guard, static doctor and a memory panel for the Engramory markdown store — no network, no model, no daemon.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"dsh",
|
|
@@ -82,6 +82,7 @@
|
|
|
82
82
|
"bench:check": "node bench/run.mjs --check",
|
|
83
83
|
"bench:seam": "node bench/semantic-seam.mjs",
|
|
84
84
|
"bench:ceiling": "node bench/pool-ceiling.mjs",
|
|
85
|
+
"bench:embed": "node bench/embed-live.mjs",
|
|
85
86
|
"bench:rewrite": "node bench/host-rewrite.mjs",
|
|
86
87
|
"bench:bridges": "node bench/bridge-coverage.mjs",
|
|
87
88
|
"bench:mcp": "node bench/mcp-overhead.mjs",
|
|
@@ -60,6 +60,11 @@ const DEFAULTS = {
|
|
|
60
60
|
/** 默认配置文件位置(可用 ENGRAMORY_EMBED_CONFIG 换)。 */
|
|
61
61
|
export const DEFAULT_CONFIG_PATH = '~/.config/engramory/embed.json';
|
|
62
62
|
|
|
63
|
+
/** 可重试的状态码:限流与网关/服务端临时故障。真端点第一次限流就失败的话,接缝等于不能用。 */
|
|
64
|
+
const RETRY_STATUS = new Set([408, 409, 425, 429, 500, 502, 503, 504]);
|
|
65
|
+
const RETRY_LIMIT = 2; // 首次 + 2 次重试
|
|
66
|
+
const RETRY_BASE_MS = 250;
|
|
67
|
+
|
|
63
68
|
function expandHome(value) {
|
|
64
69
|
const text = String(value ?? '').trim();
|
|
65
70
|
if (text === '~') return homedir();
|
|
@@ -147,19 +152,26 @@ function textOf(doc, cfg) {
|
|
|
147
152
|
}
|
|
148
153
|
|
|
149
154
|
async function embedBatch(cfg, texts, signal) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
155
|
+
let response;
|
|
156
|
+
let body = '';
|
|
157
|
+
for (let attempt = 0; ; attempt += 1) {
|
|
158
|
+
response = await fetch(endpoint(cfg.baseUrl), {
|
|
159
|
+
method: 'POST',
|
|
160
|
+
signal,
|
|
161
|
+
headers: {
|
|
162
|
+
'content-type': 'application/json',
|
|
163
|
+
authorization: `Bearer ${cfg.apiKey}`,
|
|
164
|
+
},
|
|
165
|
+
body: JSON.stringify({ model: cfg.model, input: texts }),
|
|
166
|
+
});
|
|
167
|
+
body = await response.text();
|
|
168
|
+
if (response.ok || !RETRY_STATUS.has(response.status) || attempt >= RETRY_LIMIT) break;
|
|
169
|
+
await sleep(retryDelayMs(response, attempt), signal);
|
|
170
|
+
}
|
|
160
171
|
if (!response.ok) {
|
|
161
|
-
// 状态码 +
|
|
162
|
-
|
|
172
|
+
// 状态码 + 服务端原话(截断):密钥绝不进消息。重试过的话说明这是重试后的终态。
|
|
173
|
+
const tried = RETRY_STATUS.has(response.status) ? `(已重试 ${RETRY_LIMIT} 次)` : '';
|
|
174
|
+
throw new Error(`embeddings HTTP ${response.status}${tried}:${body.replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
163
175
|
}
|
|
164
176
|
let parsed;
|
|
165
177
|
try {
|
|
@@ -202,6 +214,35 @@ export async function embedTexts(cfg, texts, signal) {
|
|
|
202
214
|
return out;
|
|
203
215
|
}
|
|
204
216
|
|
|
217
|
+
/** 等服务端让等的时间:优先 `Retry-After`(秒数或 HTTP 日期),否则指数退避。上限 5 秒。 */
|
|
218
|
+
function retryDelayMs(response, attempt) {
|
|
219
|
+
const header = response?.headers?.get?.('retry-after');
|
|
220
|
+
if (header) {
|
|
221
|
+
const seconds = Number(header);
|
|
222
|
+
if (Number.isFinite(seconds) && seconds >= 0) return Math.min(5000, seconds * 1000);
|
|
223
|
+
const when = Date.parse(header);
|
|
224
|
+
if (Number.isFinite(when)) return Math.min(5000, Math.max(0, when - Date.now()));
|
|
225
|
+
}
|
|
226
|
+
return Math.min(5000, RETRY_BASE_MS * 2 ** attempt);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 可被 AbortSignal 打断的等待:接缝有超时预算,重试不许把预算吃光。 */
|
|
230
|
+
function sleep(ms, signal) {
|
|
231
|
+
if (!ms) return Promise.resolve();
|
|
232
|
+
return new Promise((resolve, reject) => {
|
|
233
|
+
const timer = setTimeout(() => {
|
|
234
|
+
signal?.removeEventListener?.('abort', onAbort);
|
|
235
|
+
resolve();
|
|
236
|
+
}, ms);
|
|
237
|
+
const onAbort = () => {
|
|
238
|
+
clearTimeout(timer);
|
|
239
|
+
reject(signal?.reason ?? new Error('aborted'));
|
|
240
|
+
};
|
|
241
|
+
if (signal?.aborted) onAbort();
|
|
242
|
+
else signal?.addEventListener?.('abort', onAbort, { once: true });
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
|
|
205
246
|
function cosine(a, b) {
|
|
206
247
|
let dot = 0;
|
|
207
248
|
let na = 0;
|
package/semantic.js
CHANGED
|
@@ -291,13 +291,14 @@ export async function loadProvider(config = {}, deps = {}) {
|
|
|
291
291
|
}
|
|
292
292
|
|
|
293
293
|
function normalizeModule(module, source) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
294
|
+
// 两种写法都收:命名导出(`export function rerank`)与默认导出对象(`export default { rerank }`)。
|
|
295
|
+
// 后者是第三方提供方最自然的写法(工厂函数 + 默认导出),不必逼人家改风格。
|
|
296
|
+
const fallback = String(source).split('/').pop();
|
|
297
|
+
for (const target of [module, module?.default]) {
|
|
298
|
+
if (!target || typeof target !== 'object') continue;
|
|
299
|
+
const name = String(target.name ?? module?.name ?? fallback);
|
|
300
|
+
if (typeof target.rerank === 'function') return { name, kind: 'rerank', rerank: target.rerank, source };
|
|
301
|
+
if (typeof target.expand === 'function') return { name, kind: 'expand', expand: target.expand, source };
|
|
301
302
|
}
|
|
302
303
|
return null;
|
|
303
304
|
}
|