dsh-engramory-recall 0.11.2 → 0.12.1
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 +41 -0
- package/README.md +115 -7
- package/index.js +25 -2
- package/package.json +6 -2
- package/providers/openai-embeddings.mjs +301 -0
- package/semantic.js +198 -7
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,47 @@
|
|
|
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.1] — 2026-10-15
|
|
7
|
+
|
|
8
|
+
### 新增
|
|
9
|
+
|
|
10
|
+
- **池宽天花板尺子**(`npm run bench:ceiling`):把「候选池放宽到 N 条时,任何提供方最多能拿到几分」
|
|
11
|
+
单独量出来,含按题型拆分与「每查询要发给提供方多少东西」(条数 / 片段字符数 → token 估算,离线可算)。
|
|
12
|
+
|
|
13
|
+
### 说明
|
|
14
|
+
|
|
15
|
+
- 量出一个反直觉的事实并写进 README:**公开数据集上池宽买不到分数** —— LongMemEval-S 每题的 haystack
|
|
16
|
+
只有 30~50 个 session,池内召回 top-5 就已经 98.2%、top-20 99.6%;池宽的价值在「库比窗口大得多」时
|
|
17
|
+
(合成 704 条 70.3% → 97.3%)。所以默认 50 是给大库留的余量,不是公共基准上的分数。
|
|
18
|
+
- `bench/longmemeval.mjs` 的 session→笔记映射抽到 `bench/sessions.mjs`(与尺子共用一份,避免口径漂移),
|
|
19
|
+
20 题输出逐字未变。
|
|
20
|
+
|
|
21
|
+
## [0.12.0] — 2026-10-15
|
|
22
|
+
|
|
23
|
+
### 新增
|
|
24
|
+
|
|
25
|
+
- **语义提供方现在看的是宽池**(`semanticPool`,默认 50、上限 200):池子等于输出窗口时,提供方只能
|
|
26
|
+
在词面已经挑中的那几条里排序,语义再准也捞不动排在第 12 名的正确笔记。合成 704 条上池内召回从
|
|
27
|
+
70.3%(池 5)涨到 97.3%(池 50),真库探针从池 5 起就是 36/36(`npm run bench:seam`)。
|
|
28
|
+
- **默认融合模式改成 `trust`**(提供方的顺序即结果,词面只用来补满窗口),`semanticFusion: rrf` 保留为
|
|
29
|
+
「不确定提供方靠不靠谱」时的安全档。依据:完美的全量排名提供方在等权 RRF 下 hit@1 只有 52.7%
|
|
30
|
+
(纯词面 50.4%,几乎没赚),`trust` 下 86.6%;而倒排提供方反过来是 trust 1.0% / rrf 26.0%。
|
|
31
|
+
- **新增参考提供方 `providers/openai-embeddings.mjs`**:OpenAI 兼容的 `/embeddings` 客户端,零依赖、
|
|
32
|
+
批请求 + 按文本缓存、错误带 HTTP 状态与服务端原话、**密钥不进日志**。配置走环境变量或
|
|
33
|
+
`~/.config/engramory/embed.json`(0600),`npm run embed:check` 一条命令自检。
|
|
34
|
+
- **新增 `provider: 'host-embed'`**:复用 DSH 设置页里已经配好的那条路由(`baseURL` + 模型 + 密钥),
|
|
35
|
+
不用再填一遍环境变量。四步(路由 / baseURL / 模型 / 密钥)都从宿主读,每步失败都给可照做的提示;
|
|
36
|
+
凭据每次检索现解析(不跨操作缓存);MCP 等没有宿主的场景明确报 `no-host`。
|
|
37
|
+
- 新测试:`test/embed-provider.test.mjs`(7 例,含假 embeddings 服务)、`test/host-embed.test.mjs`
|
|
38
|
+
(5 例,含宿主三个服务的最小替身)、`test/semantic.test.mjs` 增加池宽与融合模式 3 例。
|
|
39
|
+
|
|
40
|
+
### 说明
|
|
41
|
+
|
|
42
|
+
- 参考提供方与 `host-embed` 的**召回收益还没有在真 key 上量过**(要花 token,等用户点头)。已量的是
|
|
43
|
+
接缝本身的上限与代价,以及默认值为什么这么定 —— 这两组数字都在 `npm run bench:seam` 里可复算。
|
|
44
|
+
- 默认池 50 的代价:每次检索多算一次词面(50 条),实测平均仍是毫秒级;提供方多收到 45 条候选,
|
|
45
|
+
embeddings 按文本缓存,重复检索不重复付费。
|
|
46
|
+
|
|
6
47
|
## [0.11.2] — 2026-10-15
|
|
7
48
|
|
|
8
49
|
### 变更
|
package/README.md
CHANGED
|
@@ -578,8 +578,11 @@ idempotent / mojibake / xpath / selenium / watchdog / dsn / backoff` 这一类**
|
|
|
578
578
|
接缝(seam)而不是内置向量库:用户自己指一个提供方,指了才生效,指错了也不会把检索弄坏。
|
|
579
579
|
|
|
580
580
|
```yaml
|
|
581
|
-
semanticProvider: '' # 留空 = 纯词面(默认)。可填本地模块路径,或字面量 host-llm
|
|
581
|
+
semanticProvider: '' # 留空 = 纯词面(默认)。可填本地模块路径,或字面量 host-llm / host-embed
|
|
582
582
|
semanticTimeoutMs: 2000 # 单次预算;超时/抛错/返回垃圾一律降级回词面结果
|
|
583
|
+
semanticPool: 50 # 提供方看多宽的候选池(默认 50,上限 200)
|
|
584
|
+
semanticFusion: trust # trust = 提供方说了算(默认);rrf = 词面有一票否决权
|
|
585
|
+
semanticHostRoute: '' # 只有 host-embed 用:复用哪条 DSH 路由(留空 = 当前默认模型的路由)
|
|
583
586
|
```
|
|
584
587
|
|
|
585
588
|
提供方就是一个本地 ESM 模块,导出两个函数里的任意一个:
|
|
@@ -604,9 +607,10 @@ export async function expand({ query, signal }) {
|
|
|
604
607
|
|
|
605
608
|
| 行为 | 为什么 |
|
|
606
609
|
|---|---|
|
|
607
|
-
| `rerank`
|
|
610
|
+
| `rerank` 默认 **trust 融合**(提供方的顺序即结果,词面只用来补满窗口),`expand` **只补空位** | 真提供方返回的是一份**全量排名**,等权 RRF 会让它自己第 2 名以后的名次反过来稀释第 1 名 —— 合成 704 条上这是 86.6% 对 52.7%(下表)。`semanticFusion: rrf` 保留给「不确定提供方靠不靠谱」的场景(那时它是安全档:26.0% 对 1.0%)。`expand` 若也拿去融合,实测 hit@1 掉 7~19pt(原因见下面「让宿主模型当改写提供方」):词面那 1 票没有任何理由比模型那 3 票更该被信任。所以它只把词面没排上的文档**接在后面**,`added` 报补了几条。`expand` 若也拿去融合,实测 hit@1 掉 7~19pt(原因见下面「让宿主模型当改写提供方」):词面那 1 票没有任何理由比模型那 3 票更该被信任。所以它只把词面没排上的文档**接在后面**,`added` 报补了几条。两种提供方返回候选之外的文件名一律丢弃并报数 |
|
|
608
611
|
| 超时**硬兜一层** `Promise.race` | 提供方可以完全忽略 `AbortSignal`(第三方代码什么都干得出来),只靠 abort 会让检索永远等下去 |
|
|
609
612
|
| 失败**不抛错**,降级回词面并如实报 `code` | `off` / `not-found` / `unsupported` / `provider-error` / `timeout` / `provider-empty`,检索工具返回值里的 `semantic` 字段直接给出这次到底用没用上 |
|
|
613
|
+
| **提供方看的是宽池**(`semanticPool`,默认 50) | 池子等于输出窗口时,提供方只能在词面已经挑中的那几条里排序 —— 语义再准也捞不动排在第 12 名的正确笔记。池宽从 5 抬到 50,池内召回从 70.3% 涨到 97.3%(`npm run bench:seam`) |
|
|
610
614
|
| **只管按需检索工具**,不碰每轮自动注入 | 自动召回每个用户轮都跑;挂一个要 200ms 的外部件等于给每一轮加税。热路径永远纯词面 |
|
|
611
615
|
|
|
612
616
|
|
|
@@ -641,7 +645,105 @@ RRF 只能在池内重排,所以 hit@4 一动都不动(67.5%),坏的提
|
|
|
641
645
|
`test/semantic-wiring.test.mjs`(关着时连 import 都不发生、开着时真的融合、自动注入不碰接缝)
|
|
642
646
|
就是钉这四条的。
|
|
643
647
|
|
|
644
|
-
####
|
|
648
|
+
#### 实测:候选池与融合模式(同一个 `npm run bench:seam`)
|
|
649
|
+
|
|
650
|
+
合成语料 704 条、输出窗口 top-4。池宽决定「提供方最多能捞多远」——池内召回就是它的天花板:
|
|
651
|
+
|
|
652
|
+
| 候选池 `semanticPool` | 5 | 20 | **50(默认)** | 100 |
|
|
653
|
+
|---|---|---|---|---|
|
|
654
|
+
| 池内召回(合成 704 条) | 70.3% | 86.6% | **97.3%** | 97.9% |
|
|
655
|
+
| 池内召回(真库探针 36 条) | 36/36 | 36/36 | **36/36** | 36/36 |
|
|
656
|
+
|
|
657
|
+
融合模式决定「提供方的话算不算数」(同一张卷子,只换提供方与融合):
|
|
658
|
+
|
|
659
|
+
| 提供方 | `trust`(默认) | `rrf` |
|
|
660
|
+
|---|---|---|
|
|
661
|
+
| 完美:全量排名(真 embedding 的形态) | **86.6% / 86.6%** | 52.7% / 73.6% |
|
|
662
|
+
| 完美:只投 gold 一票 | 86.6% / 86.6% | 86.6% / 86.6% |
|
|
663
|
+
| 词面倒排(最坏的可信提供方) | 1.0% / 3.8% | 26.0% / 59.7% |
|
|
664
|
+
| 与语义无关的稳定顺序 | 3.1% / 16.8% | 18.8% / 43.9% |
|
|
665
|
+
| 纯词面(不看提供方) | 50.4% / 67.5% | 50.4% / 67.5% |
|
|
666
|
+
|
|
667
|
+
(每格 = hit@1 / hit@4。)三点结论:
|
|
668
|
+
|
|
669
|
+
1. **默认 `trust`**:真提供方一定会排完整个池,等权 RRF 下它自己的低名次条目会把第 1 名挤下去 —— 完美提供方因此只拿到 52.7%(比纯词面 50.4% 几乎没有赚),`trust` 是 86.6%。
|
|
670
|
+
2. **`rrf` 是安全档**:提供方不靠谱时,`trust` 会一路砸到底(倒排 1.0%),`rrf` 至少保住词面的一部分(26.0%)。拿不准就用它。
|
|
671
|
+
3. **「只投一票」两种融合同分**:稀释来自提供方**自己的低名次**,不是来自「它参与了融合」。所以问题不在「要不要融合」,而在「按谁的顺序」。
|
|
672
|
+
|
|
673
|
+
#### 池宽在公开数据集上量不出差别(`npm run bench:ceiling`)
|
|
674
|
+
|
|
675
|
+
LongMemEval-S 500 题,每题自带的 haystack 只有 30~50 个 session(数据 `sha256_12 = d6f21ea9d60a`):
|
|
676
|
+
|
|
677
|
+
| 候选池 | 5 | 20 | 50 | 100 |
|
|
678
|
+
|---|---|---|---|---|
|
|
679
|
+
| 池内召回 | 98.2% | 99.6% | 99.8% | 99.8% |
|
|
680
|
+
|
|
681
|
+
按题型看,唯一在 top-5 有明显缺口的是偏好题(90.0%),它在 top-20 就补满。所以这份公开卷子上
|
|
682
|
+
**top-5 已经够了,池宽放宽买不到分数**——它的意义在库比窗口大得多的时候(合成 704 条 70.3% → 97.3%;
|
|
683
|
+
真库 66 篇 / 200 chunk 从池 5 起就是 36/36)。一句话:**池宽是给大库留的余量,不是公共基准上的分数。**
|
|
684
|
+
|
|
685
|
+
代价(离线估算,按池 100 算,即最坏情况):
|
|
686
|
+
|
|
687
|
+
| 项 | 中位 | p90 | 最大 |
|
|
688
|
+
|---|---|---|---|
|
|
689
|
+
| 候选条数 | 27 | 41 | 48 |
|
|
690
|
+
| 片段字符数 | 1916 | 2921 | 3425 |
|
|
691
|
+
|
|
692
|
+
按英文 ≈4 字符/token 粗估:一条查询约 479 token(中位)、730 token(p90)。提供方按文本缓存,
|
|
693
|
+
同一篇笔记反复出现只付一次;**真延迟要在有端点时量**(`npm run embed:check` 会报一次往返耗时)。
|
|
694
|
+
|
|
695
|
+
#### 内置参考提供方:OpenAI 兼容的 embeddings(`providers/openai-embeddings.mjs`)
|
|
696
|
+
|
|
697
|
+
填两个环境变量就能试;密钥只从环境变量或一个 0600 的小文件进,**不进仓库、不进日志**:
|
|
698
|
+
|
|
699
|
+
```bash
|
|
700
|
+
node providers/openai-embeddings.mjs --check # 先自检:报维度与耗时(不打印密钥)
|
|
701
|
+
```
|
|
702
|
+
|
|
703
|
+
```yaml
|
|
704
|
+
semanticProvider: 'dsh-engramory-recall/providers/openai-embeddings'
|
|
705
|
+
semanticTimeoutMs: 15000 # 远端接口要给足预算(默认 2000ms 是给本机小模型的)
|
|
706
|
+
semanticPool: 50
|
|
707
|
+
semanticFusion: trust
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
```bash
|
|
711
|
+
export ENGRAMORY_EMBED_API_KEY=sk-… # 或写 ~/.config/engramory/embed.json
|
|
712
|
+
export ENGRAMORY_EMBED_BASE_URL=https://api.openai.com/v1 # 换一家就换这两行
|
|
713
|
+
export ENGRAMORY_EMBED_MODEL=text-embedding-3-small
|
|
714
|
+
```
|
|
715
|
+
|
|
716
|
+
**embeddings 只有 OpenAI 这一种接口形状**(`POST {base}/embeddings`):Anthropic 没有 embedding 接口,
|
|
717
|
+
所谓「Claude 协议」在这里用不上。国产替代填 `BASE_URL` + `MODEL` 即可,例如阿里百炼
|
|
718
|
+
`https://dashscope.aliyuncs.com/compatible-mode/v1` + `text-embedding-v4`、智谱
|
|
719
|
+
`https://open.bigmodel.cn/api/paas/v4` + `embedding-3`、硅基流动 `https://api.siliconflow.cn/v1` + `BAAI/bge-m3`。
|
|
720
|
+
|
|
721
|
+
**一句实话**:这条路径的代码、测试、失败降级都有,但**还没有在真 key 上量过召回**(要花token,得你先点头)。
|
|
722
|
+
接缝本身的上限是量过的(上表 86.6%),提供方的质量只能真调一次才知道。
|
|
723
|
+
|
|
724
|
+
#### 复用 DSH 里已经配好的那条路由(`provider: "host-embed"`)
|
|
725
|
+
|
|
726
|
+
不想再填一遍环境变量:DSH 设置页 → 模型里配好的路由(`baseURL` + 模型 + API 密钥)可以**直接借来用**:
|
|
727
|
+
|
|
728
|
+
```yaml
|
|
729
|
+
semanticProvider: 'host-embed'
|
|
730
|
+
semanticHostRoute: '' # 留空 = 用当前默认模型的那条路由;也可指名(如 openai / zhipu)
|
|
731
|
+
semanticTimeoutMs: 15000
|
|
732
|
+
```
|
|
733
|
+
|
|
734
|
+
它按四步读宿主,每步失败都给**可照做**的提示(而不是一句「配置错误」),失败一律降级回词面:
|
|
735
|
+
|
|
736
|
+
| 步骤 | 从哪读 | 读不到时 |
|
|
737
|
+
|---|---|---|
|
|
738
|
+
| 路由 | `semanticHostRoute` → 插件的 `llmProvider` → `agentDefaultModel.currentSelection()` | 提示写 `semanticHostRoute` |
|
|
739
|
+
| `baseURL` | 该路由配置里的 `baseURL`;目录自带路由(openai / mistral / together / 智谱)有内置兜底 | 提示去「自定义模型 API」加一条路由,或写 `semanticHostBaseUrl` |
|
|
740
|
+
| 模型 | 路由 `models[]` 里名字含 embed 的那个;单模型路由直接用它;内置兜底 | 提示写 `semanticHostModel` |
|
|
741
|
+
| 密钥 | 路由的 `apiKeyEnv`(没有就按 `<ROUTE>_API_KEY` 派生)→ `ctx.credentials.resolve()` | 提示去设置页填密钥,并说清 ref 名字 |
|
|
742
|
+
|
|
743
|
+
三条纪律:密钥**只在这四步里过一遍**,不进返回值、不进日志、不进错误信息;凭据**每次检索现解析**
|
|
744
|
+
(宿主规范:不许跨操作缓存);MCP 那种没有宿主的场景会明确报 `no-host` 并提示改用本地模块提供方。
|
|
745
|
+
|
|
746
|
+
|
|
645
747
|
|
|
646
748
|
`host-llm` 能跑通不等于划算,所以这条尺子用**真宿主模型**量了一遍(2026-10-15,逐条调用本机
|
|
647
749
|
harness,提示词直接 `import` 产品的 `hostLlmPrompt()` —— 尺子问的就是产品要问的那句话;
|
|
@@ -707,9 +809,12 @@ harness,提示词直接 `import` 产品的 `hostLlmPrompt()` —— 尺子问
|
|
|
707
809
|
indexMaxLines: 200 # 索引 MEMORY.md 的行数上限
|
|
708
810
|
indexMaxBytes: 25600 # 索引的字节上限
|
|
709
811
|
registerSkill: true # 注册 engramory 技能(写入纪律的正文)
|
|
710
|
-
# 语义检索接缝(默认关闭):本地模块路径,或 host-llm
|
|
812
|
+
# 语义检索接缝(默认关闭):本地模块路径,或 host-llm / host-embed
|
|
711
813
|
semanticProvider: ''
|
|
712
814
|
semanticTimeoutMs: 2000
|
|
815
|
+
semanticPool: 50 # 提供方看多宽的候选池
|
|
816
|
+
semanticFusion: trust # trust = 提供方说了算;rrf = 词面有一票否决权
|
|
817
|
+
semanticHostRoute: '' # 只有 host-embed 用
|
|
713
818
|
```
|
|
714
819
|
|
|
715
820
|
`autoArchive` 打开后归档会在两处自动跑:每轮 pre-step 兜底(10 分钟 TTL,不会每轮扫库),
|
|
@@ -841,13 +946,15 @@ stdout 上只准有 JSON-RPC 帧,混进任何一行非 JSON,这次运行就
|
|
|
841
946
|
## 测试与自检
|
|
842
947
|
|
|
843
948
|
```bash
|
|
844
|
-
npm test # 分词 / BM25 / 切桶 / 门控 / 闸门 / 索引闸门 / 技能注册 / 体检 / 审计 / 沉淀提醒 / 归档 / 打包 / 路由 / 公开基准 harness / MCP 服务器
|
|
949
|
+
npm test # 分词 / BM25 / 切桶 / 门控 / 闸门 / 索引闸门 / 技能注册 / 体检 / 审计 / 沉淀提醒 / 归档 / 打包 / 路由 / 公开基准 harness / MCP 服务器 / 语义接缝(含 host-embed)
|
|
845
950
|
npm run check # 接线自检:配置键认不认、库路径对不对、装的是哪一份、包清单漏没漏文件
|
|
846
951
|
npm run probe # 召回质量探针:3 组 query + 三道对照,跑在真实记忆库上
|
|
847
952
|
npm run probe -- --save # 把这次结果存成基线,之后每次跑自动比对
|
|
848
953
|
npm run bench:check # 合成基准 + 和 bench/expectations.json 对账(CI 跑的就是它)
|
|
849
954
|
npm run bench:bridges # 同义表覆盖率尺子:死值、覆盖、使用面、逐键消融(--ablate / --json / --store)
|
|
850
|
-
npm run bench:seam # 语义接缝的代价与天花板(四种坏提供方 +
|
|
955
|
+
npm run bench:seam # 语义接缝的代价与天花板(四种坏提供方 + 作弊上界 + 融合模式对照)
|
|
956
|
+
npm run bench:ceiling # 池宽天花板:任一提供方在公开数据集上的上界 + 每查询要发多少东西
|
|
957
|
+
npm run embed:check # 参考提供方自检:探一次 embeddings 接口,报维度与耗时(不打印密钥)
|
|
851
958
|
npm run bench:rewrite # 宿主模型当改写提供方:真调用量代价与收益(要本机有 dsh;--offline 只重算)
|
|
852
959
|
npm run bench:mcp # MCP 这一层:冷启动、每步往返、协议帧字节数
|
|
853
960
|
npm run mcp:smoke # 拿脚本当 MCP 客户端,把服务器拉起来走一遍真实对话
|
|
@@ -904,7 +1011,7 @@ CI 跑 `npm test` 和 `npm run bench:check` 两件事。后者是防「数字悄
|
|
|
904
1011
|
| DSH-SEP | 每步 ~1250 字符 guidance + ≤4200 字符快照 | 同上 | 抽取走 `api.deepseek.com` |
|
|
905
1012
|
| **本插件** | **0** | **≤480 字符,且只有指针** | **无** |
|
|
906
1013
|
|
|
907
|
-
##
|
|
1014
|
+
## 十一条被实测证伪的路
|
|
908
1015
|
|
|
909
1016
|
| 尝试 | 结果 |
|
|
910
1017
|
|---|---|
|
|
@@ -917,6 +1024,7 @@ CI 跑 `npm test` 和 `npm run bench:check` 两件事。后者是防「数字悄
|
|
|
917
1024
|
| 时间乘性加权(学 hindsight) | 五档扫描净收益为零,`w ≥ 0.1` 起反而伤对照组 |
|
|
918
1025
|
| 给一个已有同义桥继续加词(`主题` 从 `['theme']` 扩到含 `light-dark / color-mix / data-theme / token`) | 看着全是相关的技术词,实测对照 B 掉 3 分(136→133)。机制:把词加进查询扩展后,同一篇笔记自己的其它 chunk 会把「标题里真的写着这个词」的那个 chunk 挤下去,长笔记里这叫自己跟自己抢。判据不是「这词相不相关」,而是「加了它,谁会往上浮」。这 4 个词已全部退回 |
|
|
919
1026
|
| 拿宿主模型的改写去和词面结果融合(RRF / 归一化求和 / 取最大) | 用真宿主模型(逐条调用本机 harness,提示词取自产品代码)在合成子集 70 条 + 真库探针 36 条上量:改写里**确实有信号**(任意一条单独检索命中 gold@1 达 50.0% / 80.6%,词面是 70.0% / 72.2%),但融合发展一律变差 —— RRF 真库 72.2% → 52.8%、取最大 → 27.8%。机制:投票数变成 4 倍,而词面那 1 票并不比模型那 3 票更该被信任,宿主又没有打分器判断改写好坏。修法是让 `expand` **只补空位**(0.11.1),并且承认「模型能写出更好的查询」≠「检索变好」(`npm run bench:rewrite`) |
|
|
1027
|
+
| 拿等权 RRF 当语义提供方的默认融合 | 合成 704 条、池 20、窗口 4:**完美的全量排名提供方**(真 embedding 的形态)在等权 RRF 下 hit@1 只有 52.7%,比纯词面(50.4%)几乎没赚;同一个提供方在 `trust` 下是 86.6%。机制:提供方返回的是全量排名,它自己第 2 名以后的名次也会拿到票,于是把第 1 名挤下去 —— 稀释来自它**自己的低名次**,不是来自融合本身(「只投一票」的完美提供方两种融合同为 86.6%)。默认已改成 `trust`,`rrf` 保留为「不确定提供方靠不靠谱」时的安全档(倒排提供方:trust 1.0% vs rrf 26.0%) |
|
|
920
1028
|
| 拿偏袒的对照当护栏:用对照 A/B 证明「抬高标题权重是安全的」 | 它们都拿标题当查询,对标题权重结构性偏袒,抬高它必然让两组变好。这不是证据,是同义反复。`TITLE_EXTRA` 第一次定成 3 就是被这个漏洞困住的(「口径在 3 之后不再动,所以后面的增量全来自对照」,而对照根本测不出这件事)。补了中立的对照 C 之后,数据说 3 太保守:C 从 0 到 96 一路不降,在 24 到达平台。已按 C 的拐点改成 24 |
|
|
921
1029
|
|
|
922
1030
|
## 许可
|
package/index.js
CHANGED
|
@@ -175,6 +175,23 @@ const DEFAULTS = {
|
|
|
175
175
|
semanticProvider: '',
|
|
176
176
|
/** 接缝的单次超时预算(毫秒)。超时/抛错/返回垃圾一律降级回词面结果。 */
|
|
177
177
|
semanticTimeoutMs: 2000,
|
|
178
|
+
/**
|
|
179
|
+
* `rerank` 提供方看到的候选池宽度(默认 50,上限 200)。
|
|
180
|
+
*
|
|
181
|
+
* 池宽 = 输出窗口时,提供方只能重排词面已经挑中的那几条,天花板被词面钉死;放宽池子
|
|
182
|
+
* 才给语义信号留出「把排名靠后的正确笔记捞上来」的空间。实测(2026-10-15,合成 704 条):
|
|
183
|
+
* 池内召回 P=5 时 70.3%、P=20 86.6%、P=50 97.3%。代价只是词面多算几条候选 + 提供方多算
|
|
184
|
+
* 几十条(embedding 按篇计费,50 条约几千 token,一次查询级别)。
|
|
185
|
+
*/
|
|
186
|
+
semanticPool: 50,
|
|
187
|
+
/**
|
|
188
|
+
* 融合方式:`rrf`(默认)= 词面与提供方各一票,少赚不赔;`trust` = 提供方的顺序即最终顺序,
|
|
189
|
+
* 天花板最高(合成 @1 86.6% vs 词面 50.4%),但提供方不靠谱时结果会比纯词面更差。
|
|
190
|
+
* 两个数都是量出来的,见 semantic.js 文件头与 README「语义接缝」小节。
|
|
191
|
+
*/
|
|
192
|
+
semanticFusion: 'trust',
|
|
193
|
+
// 只有 `semanticProvider: 'host-embed'` 用得上:指定复用哪条 DSH 路由(留空 = 当前默认模型的路由)。
|
|
194
|
+
semanticHostRoute: '',
|
|
178
195
|
};
|
|
179
196
|
|
|
180
197
|
/** 只在用户句子里出现明确的「长期记住」意图时才追加一行提示(默认开)。 */
|
|
@@ -357,14 +374,20 @@ export function apply(ctx, config = {}) {
|
|
|
357
374
|
}
|
|
358
375
|
const limit = Math.min(Math.max(Number(args?.limit) || 5, 1), 20);
|
|
359
376
|
// 接缝关着(默认)就走原来那条同步路径:不 await、不 import、零额外开销。
|
|
360
|
-
//
|
|
377
|
+
// 开着时提供方看到的是宽于 limit 的候选池(semanticPool),融合方式见 semanticFusion。
|
|
361
378
|
const seam = describeSeam({ provider: settings.semanticProvider });
|
|
362
379
|
const result = seam.enabled
|
|
363
380
|
? await semanticSearch({
|
|
364
381
|
index: indexOf(storePath),
|
|
365
382
|
query,
|
|
366
383
|
limit,
|
|
367
|
-
config: {
|
|
384
|
+
config: {
|
|
385
|
+
provider: settings.semanticProvider,
|
|
386
|
+
timeoutMs: settings.semanticTimeoutMs,
|
|
387
|
+
pool: settings.semanticPool,
|
|
388
|
+
fusion: settings.semanticFusion,
|
|
389
|
+
hostRoute: settings.semanticHostRoute,
|
|
390
|
+
},
|
|
368
391
|
runtime: llmRuntime(),
|
|
369
392
|
})
|
|
370
393
|
: { hits: search(indexOf(storePath), query, limit), used: false, code: 'off', message: '语义接缝未启用(默认)' };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-engramory-recall",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.1",
|
|
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",
|
|
@@ -39,7 +39,8 @@
|
|
|
39
39
|
"./llm": "./llm.js",
|
|
40
40
|
"./index": "./index.js",
|
|
41
41
|
"./mcp": "./mcp.js",
|
|
42
|
-
"./semantic": "./semantic.js"
|
|
42
|
+
"./semantic": "./semantic.js",
|
|
43
|
+
"./providers/openai-embeddings": "./providers/openai-embeddings.mjs"
|
|
43
44
|
},
|
|
44
45
|
"files": [
|
|
45
46
|
"index.js",
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
"llm.js",
|
|
56
57
|
"semantic.js",
|
|
57
58
|
"mcp.js",
|
|
59
|
+
"providers/openai-embeddings.mjs",
|
|
58
60
|
"CHANGELOG.md",
|
|
59
61
|
"cordis.patch.yml",
|
|
60
62
|
"README.md"
|
|
@@ -79,10 +81,12 @@
|
|
|
79
81
|
"bench": "node bench/run.mjs",
|
|
80
82
|
"bench:check": "node bench/run.mjs --check",
|
|
81
83
|
"bench:seam": "node bench/semantic-seam.mjs",
|
|
84
|
+
"bench:ceiling": "node bench/pool-ceiling.mjs",
|
|
82
85
|
"bench:rewrite": "node bench/host-rewrite.mjs",
|
|
83
86
|
"bench:bridges": "node bench/bridge-coverage.mjs",
|
|
84
87
|
"bench:mcp": "node bench/mcp-overhead.mjs",
|
|
85
88
|
"mcp:smoke": "node scripts/mcp-smoke.mjs",
|
|
89
|
+
"embed:check": "node providers/openai-embeddings.mjs --check",
|
|
86
90
|
"release:verify": "node scripts/verify-release.mjs"
|
|
87
91
|
},
|
|
88
92
|
"bin": {
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 参考提供方:OpenAI 兼容的 embeddings 接口(零依赖、可选、密钥只从环境变量进)。
|
|
3
|
+
*
|
|
4
|
+
* 它存在的意义是把「语义接缝」从「你自己写一个」变成「填三个环境变量就能试」:
|
|
5
|
+
*
|
|
6
|
+
* ```jsonc
|
|
7
|
+
* // DSH 插件配置
|
|
8
|
+
* {
|
|
9
|
+
* "semanticProvider": "dsh-engramory-recall/providers/openai-embeddings",
|
|
10
|
+
* "semanticTimeoutMs": 15000,
|
|
11
|
+
* "semanticPool": 50,
|
|
12
|
+
* "semanticFusion": "rrf"
|
|
13
|
+
* }
|
|
14
|
+
* ```
|
|
15
|
+
*
|
|
16
|
+
* 配置来源(优先级:环境变量 > 配置文件 > 内置默认),密钥**不进仓库、不进日志**:
|
|
17
|
+
* · `~/.config/engramory/embed.json`(可用 `ENGRAMORY_EMBED_CONFIG` 换路径):
|
|
18
|
+
* `{"apiKey": "…", "baseUrl": "…", "model": "…"}` —— 推荐这条:macOS 上 GUI 启动的应用
|
|
19
|
+
* 读不到 `~/.zshrc` 里的环境变量,一个 0600 的小文件最省事:
|
|
20
|
+
* `mkdir -p ~/.config/engramory && printf '{"apiKey":"sk-…"}' > ~/.config/engramory/embed.json && chmod 600 ~/.config/engramory/embed.json`
|
|
21
|
+
* · `ENGRAMORY_EMBED_API_KEY` 或 `ENGRAMORY_EMBED_API_KEY_FILE` 指一个只放密钥的文件
|
|
22
|
+
* · `ENGRAMORY_EMBED_BASE_URL` 默认 `https://api.openai.com/v1`(base 里带 `/v1`)
|
|
23
|
+
* · `ENGRAMORY_EMBED_MODEL` 默认 `text-embedding-3-small`
|
|
24
|
+
* · `ENGRAMORY_EMBED_BATCH` 默认 32(一次请求塞多少条)
|
|
25
|
+
* · `ENGRAMORY_EMBED_MAX_CHARS` 每篇笔记送多少字,默认 1200
|
|
26
|
+
* · `ENGRAMORY_EMBED_QUERY_PREFIX` / `ENGRAMORY_EMBED_DOC_PREFIX`
|
|
27
|
+
* 有些模型要指令前缀(如 `query: ` / `passage: `),默认空
|
|
28
|
+
*
|
|
29
|
+
* 换一家提供商就是换 `BASE_URL` + `MODEL`(阿里百炼 `https://dashscope.aliyuncs.com/compatible-mode/v1`
|
|
30
|
+
* + `text-embedding-v4`、智谱 `https://open.bigmodel.cn/api/paas/v4` + `embedding-3`、硅基流动
|
|
31
|
+
* `https://api.siliconflow.cn/v1` + `BAAI/bge-m3` 等)。注意:**embeddings 只有 OpenAI 这一种
|
|
32
|
+
* 接口形状**,Anthropic 没有 embedding 接口,所谓「Claude 协议」在这里用不上。
|
|
33
|
+
*
|
|
34
|
+
* 自检(不打印密钥):
|
|
35
|
+
* ```bash
|
|
36
|
+
* node providers/openai-embeddings.mjs --check # 探一次接口,报维度与耗时
|
|
37
|
+
* node providers/openai-embeddings.mjs --check --json
|
|
38
|
+
* ```
|
|
39
|
+
*
|
|
40
|
+
* 纪律:接口失败一律 **throw**(接缝会降级回词面结果并如实报 `provider-error`),
|
|
41
|
+
* 绝不返回一个「凑合」的顺序;错误信息里绝不含密钥。
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
45
|
+
import { homedir } from 'node:os';
|
|
46
|
+
import { join } from 'node:path';
|
|
47
|
+
import { fileURLToPath } from 'node:url';
|
|
48
|
+
|
|
49
|
+
export const name = 'openai-embeddings';
|
|
50
|
+
|
|
51
|
+
const DEFAULTS = {
|
|
52
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
53
|
+
model: 'text-embedding-3-small',
|
|
54
|
+
batch: 32,
|
|
55
|
+
maxChars: 1200,
|
|
56
|
+
queryPrefix: '',
|
|
57
|
+
docPrefix: '',
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** 默认配置文件位置(可用 ENGRAMORY_EMBED_CONFIG 换)。 */
|
|
61
|
+
export const DEFAULT_CONFIG_PATH = '~/.config/engramory/embed.json';
|
|
62
|
+
|
|
63
|
+
function expandHome(value) {
|
|
64
|
+
const text = String(value ?? '').trim();
|
|
65
|
+
if (text === '~') return homedir();
|
|
66
|
+
return text.startsWith('~/') ? join(homedir(), text.slice(2)) : text;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** 读小配置文件;不存在就当空(这是正常状态,不是错误);坏 JSON 要说清楚。 */
|
|
70
|
+
export function readConfigFile(env = process.env) {
|
|
71
|
+
const full = expandHome(env.ENGRAMORY_EMBED_CONFIG || DEFAULT_CONFIG_PATH);
|
|
72
|
+
if (!full) return { path: '', data: {} };
|
|
73
|
+
try {
|
|
74
|
+
const text = readFileSync(full, 'utf8');
|
|
75
|
+
const data = JSON.parse(text);
|
|
76
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) throw new Error('顶层必须是一个对象');
|
|
77
|
+
return { path: full, data };
|
|
78
|
+
} catch (error) {
|
|
79
|
+
if (error?.code === 'ENOENT') return { path: full, data: {} };
|
|
80
|
+
throw new Error(`配置文件 ${full} 读不了:${error?.message ?? error}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** 进程内缓存:同一篇笔记在不同查询里不必重复付费(键含模型与前缀,换模型自动失效)。 */
|
|
85
|
+
const CACHE_LIMIT = 5000;
|
|
86
|
+
const cache = new Map();
|
|
87
|
+
|
|
88
|
+
function num(value, fallback) {
|
|
89
|
+
const n = Number(value);
|
|
90
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 从环境变量读配置(导出是为了自检与测试能显式传 env,不读进程全局)。 */
|
|
94
|
+
export function readConfig(env = process.env) {
|
|
95
|
+
const { path: configPath, data } = readConfigFile(env);
|
|
96
|
+
// trim=false 是给指令前缀用的:`query: ` 末尾那个空格是模型约定的一部分,不能被吃掉
|
|
97
|
+
const pick = (envName, fileKey, fallback, { trim = true } = {}) => {
|
|
98
|
+
const clean = (value) => (trim ? String(value).trim() : String(value));
|
|
99
|
+
const fromEnv = String(env[envName] ?? '');
|
|
100
|
+
if (fromEnv.trim()) return clean(fromEnv);
|
|
101
|
+
const fromFile = data[fileKey] ?? data[envName];
|
|
102
|
+
if (fromFile !== undefined && fromFile !== null && String(fromFile).trim()) return clean(fromFile);
|
|
103
|
+
return fallback;
|
|
104
|
+
};
|
|
105
|
+
const base = String(pick('ENGRAMORY_EMBED_BASE_URL', 'baseUrl', DEFAULTS.baseUrl)).replace(/\/+$/, '');
|
|
106
|
+
return {
|
|
107
|
+
configPath,
|
|
108
|
+
baseUrl: base || DEFAULTS.baseUrl,
|
|
109
|
+
model: pick('ENGRAMORY_EMBED_MODEL', 'model', DEFAULTS.model) || DEFAULTS.model,
|
|
110
|
+
batch: Math.min(128, Math.max(1, num(pick('ENGRAMORY_EMBED_BATCH', 'batch', DEFAULTS.batch), DEFAULTS.batch))),
|
|
111
|
+
maxChars: Math.max(64, num(pick('ENGRAMORY_EMBED_MAX_CHARS', 'maxChars', DEFAULTS.maxChars), DEFAULTS.maxChars)),
|
|
112
|
+
queryPrefix: pick('ENGRAMORY_EMBED_QUERY_PREFIX', 'queryPrefix', DEFAULTS.queryPrefix, { trim: false }),
|
|
113
|
+
docPrefix: pick('ENGRAMORY_EMBED_DOC_PREFIX', 'docPrefix', DEFAULTS.docPrefix, { trim: false }),
|
|
114
|
+
apiKey: readKey(env, data),
|
|
115
|
+
keySource: keySource(env, data),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function keySource(env, data) {
|
|
120
|
+
if (String(env.ENGRAMORY_EMBED_API_KEY ?? '').trim()) return 'ENGRAMORY_EMBED_API_KEY';
|
|
121
|
+
if (String(env.ENGRAMORY_EMBED_API_KEY_FILE ?? '').trim()) return 'ENGRAMORY_EMBED_API_KEY_FILE';
|
|
122
|
+
if (String(data?.apiKey ?? '').trim()) return `配置文件 ${expandHome(env.ENGRAMORY_EMBED_CONFIG || DEFAULT_CONFIG_PATH)}`;
|
|
123
|
+
return '';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function readKey(env, data = {}) {
|
|
127
|
+
const inline = String(env.ENGRAMORY_EMBED_API_KEY ?? '').trim();
|
|
128
|
+
if (inline) return inline;
|
|
129
|
+
const file = String(env.ENGRAMORY_EMBED_API_KEY_FILE ?? '').trim();
|
|
130
|
+
if (file) {
|
|
131
|
+
try {
|
|
132
|
+
return readFileSync(expandHome(file), 'utf8').trim();
|
|
133
|
+
} catch (error) {
|
|
134
|
+
throw new Error(`读不到密钥文件 ${file}(${error?.code ?? error?.message})`);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return String(data?.apiKey ?? '').trim();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function endpoint(baseUrl) {
|
|
141
|
+
return /\/embeddings$/.test(baseUrl) ? baseUrl : `${baseUrl}/embeddings`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function textOf(doc, cfg) {
|
|
145
|
+
const raw = [doc?.snippet, doc?.title].filter(Boolean).join('\n') || String(doc?.id ?? '');
|
|
146
|
+
return (cfg.docPrefix + raw.replace(/\s+/g, ' ').trim()).slice(0, cfg.maxChars);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function embedBatch(cfg, texts, signal) {
|
|
150
|
+
const response = await fetch(endpoint(cfg.baseUrl), {
|
|
151
|
+
method: 'POST',
|
|
152
|
+
signal,
|
|
153
|
+
headers: {
|
|
154
|
+
'content-type': 'application/json',
|
|
155
|
+
authorization: `Bearer ${cfg.apiKey}`,
|
|
156
|
+
},
|
|
157
|
+
body: JSON.stringify({ model: cfg.model, input: texts }),
|
|
158
|
+
});
|
|
159
|
+
const body = await response.text();
|
|
160
|
+
if (!response.ok) {
|
|
161
|
+
// 状态码 + 服务端原话(截断):密钥绝不进消息。
|
|
162
|
+
throw new Error(`embeddings HTTP ${response.status}:${body.replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
163
|
+
}
|
|
164
|
+
let parsed;
|
|
165
|
+
try {
|
|
166
|
+
parsed = JSON.parse(body);
|
|
167
|
+
} catch {
|
|
168
|
+
throw new Error(`embeddings 返回的不是 JSON:${body.slice(0, 120)}`);
|
|
169
|
+
}
|
|
170
|
+
const rows = parsed?.data;
|
|
171
|
+
if (!Array.isArray(rows) || rows.length !== texts.length) {
|
|
172
|
+
throw new Error(`embeddings 返回的数据条数不对:想 ${texts.length} 条,拿到 ${Array.isArray(rows) ? rows.length : '非数组'}`);
|
|
173
|
+
}
|
|
174
|
+
return rows
|
|
175
|
+
.sort((a, b) => (a?.index ?? 0) - (b?.index ?? 0))
|
|
176
|
+
.map((row) => {
|
|
177
|
+
const vector = row?.embedding;
|
|
178
|
+
if (!Array.isArray(vector) || !vector.length) throw new Error('embeddings 返回了空向量');
|
|
179
|
+
return vector;
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** 批量取向量,带进程内缓存。文本相同(含前缀与模型)就不重复请求。 */
|
|
184
|
+
export async function embedTexts(cfg, texts, signal) {
|
|
185
|
+
const out = new Array(texts.length);
|
|
186
|
+
const todo = [];
|
|
187
|
+
texts.forEach((text, index) => {
|
|
188
|
+
const key = `${cfg.model}\u0000${text}`;
|
|
189
|
+
if (cache.has(key)) out[index] = cache.get(key);
|
|
190
|
+
else todo.push({ index, text, key });
|
|
191
|
+
});
|
|
192
|
+
for (let start = 0; start < todo.length; start += cfg.batch) {
|
|
193
|
+
const slice = todo.slice(start, start + cfg.batch);
|
|
194
|
+
const vectors = await embedBatch(cfg, slice.map((item) => item.text), signal);
|
|
195
|
+
slice.forEach((item, offset) => {
|
|
196
|
+
const vector = vectors[offset];
|
|
197
|
+
out[item.index] = vector;
|
|
198
|
+
if (cache.size >= CACHE_LIMIT) cache.delete(cache.keys().next().value);
|
|
199
|
+
cache.set(item.key, vector);
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
return out;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function cosine(a, b) {
|
|
206
|
+
let dot = 0;
|
|
207
|
+
let na = 0;
|
|
208
|
+
let nb = 0;
|
|
209
|
+
const n = Math.min(a.length, b.length);
|
|
210
|
+
for (let i = 0; i < n; i += 1) {
|
|
211
|
+
dot += a[i] * b[i];
|
|
212
|
+
na += a[i] * a[i];
|
|
213
|
+
nb += b[i] * b[i];
|
|
214
|
+
}
|
|
215
|
+
if (!na || !nb) return 0;
|
|
216
|
+
return dot / Math.sqrt(na * nb);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* 接缝的 `rerank` 接口:给候选池,按余弦相似度排序。
|
|
221
|
+
* 返回**全部**候选(不是只给 limit 条)—— 融合方式(rrf / trust)由接缝决定,不由提供方越权决定。
|
|
222
|
+
*/
|
|
223
|
+
/** 用一份**显式配置**跑重排(密钥来自调用方,而不是环境变量)—— `host-embed` 复用宿主凭据时走这条路。 */
|
|
224
|
+
export async function rerankWith(cfg, { query, docs, signal } = {}) {
|
|
225
|
+
if (!cfg?.apiKey) {
|
|
226
|
+
throw new Error('没配密钥:设置 ENGRAMORY_EMBED_API_KEY(或 ENGRAMORY_EMBED_API_KEY_FILE)后重试');
|
|
227
|
+
}
|
|
228
|
+
const list = Array.isArray(docs) ? docs : [];
|
|
229
|
+
if (!list.length) return [];
|
|
230
|
+
const vectors = await embedTexts(cfg, [cfg.queryPrefix + String(query ?? ''), ...list.map((doc) => textOf(doc, cfg))], signal);
|
|
231
|
+
const queryVector = vectors[0];
|
|
232
|
+
return list
|
|
233
|
+
.map((doc, index) => ({ id: doc.id, score: cosine(queryVector, vectors[index + 1]) }))
|
|
234
|
+
.sort((a, b) => b.score - a.score || String(a.id).localeCompare(String(b.id)));
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** 默认配置(env + 配置文件)下的提供方,供 `provider: '<包名>/providers/openai-embeddings'` 直接使用。 */
|
|
238
|
+
export async function rerank(args) {
|
|
239
|
+
return rerankWith(readConfig(), args);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* 用显式配置造一个提供方对象(`host-embed` 与 `--check` 之外的集成都用它)。
|
|
244
|
+
* overrides 里常见:`baseUrl` / `model` / `apiKey` / `batch` / `maxChars` / `queryPrefix` / `docPrefix`。
|
|
245
|
+
*/
|
|
246
|
+
export function createProvider(overrides = {}) {
|
|
247
|
+
const cfg = { ...readConfig(), ...overrides };
|
|
248
|
+
return {
|
|
249
|
+
name: overrides.providerName ?? `${name}(${cfg.model})`,
|
|
250
|
+
kind: 'rerank',
|
|
251
|
+
config: cfg,
|
|
252
|
+
rerank: (args) => rerankWith(cfg, args),
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/** 自检:探一次接口,报配置(不含密钥)、维度与耗时。 */
|
|
257
|
+
export async function check(env = process.env) {
|
|
258
|
+
const cfg = readConfig(env);
|
|
259
|
+
if (!cfg.apiKey) {
|
|
260
|
+
throw new Error(`没配密钥:设置 ENGRAMORY_EMBED_API_KEY,或写一份 ${cfg.configPath}({"apiKey":"…"},chmod 600)`);
|
|
261
|
+
}
|
|
262
|
+
const started = Date.now();
|
|
263
|
+
const vectors = await embedTexts(cfg, ['engramory 自检:这一段会被送进 embeddings 接口。'], undefined);
|
|
264
|
+
return {
|
|
265
|
+
ok: true,
|
|
266
|
+
baseUrl: cfg.baseUrl,
|
|
267
|
+
model: cfg.model,
|
|
268
|
+
keySource: cfg.keySource,
|
|
269
|
+
configPath: cfg.configPath,
|
|
270
|
+
dimensions: vectors[0].length,
|
|
271
|
+
ms: Date.now() - started,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const isMain = (() => {
|
|
276
|
+
const entry = process.argv[1];
|
|
277
|
+
if (!entry) return false;
|
|
278
|
+
try {
|
|
279
|
+
return realpathSync(entry) === realpathSync(fileURLToPath(import.meta.url));
|
|
280
|
+
} catch {
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
})();
|
|
284
|
+
|
|
285
|
+
if (isMain || process.argv.includes('--check')) {
|
|
286
|
+
if (!process.argv.includes('--check')) {
|
|
287
|
+
console.log('用法:node providers/openai-embeddings.mjs --check [--json]');
|
|
288
|
+
} else {
|
|
289
|
+
try {
|
|
290
|
+
const result = await check();
|
|
291
|
+
if (process.argv.includes('--json')) console.log(JSON.stringify(result, null, 2));
|
|
292
|
+
else {
|
|
293
|
+
console.log(`✓ embeddings 可用:${result.model} @ ${result.baseUrl}`);
|
|
294
|
+
console.log(` 密钥来源:${result.keySource} · 维度:${result.dimensions} · 耗时:${result.ms}ms`);
|
|
295
|
+
}
|
|
296
|
+
} catch (error) {
|
|
297
|
+
console.error(`✗ ${error?.message ?? error}`);
|
|
298
|
+
process.exitCode = 1;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
package/semantic.js
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
* 1. **默认关闭**:`provider` 为空时,这里连 `import()` 都不会执行;
|
|
9
9
|
* 2. **不 import 任何宿主包**:提供方只能是「本地模块路径」(用户在配置里写),
|
|
10
10
|
* 或者内置的 `host-llm` —— 后者也走 `llm.js` 的动态 import,静态依赖图里没有它;
|
|
11
|
+
* (也可以写成裸模块名,如 `dsh-engramory-recall/providers/openai-embeddings`——包里自带)
|
|
11
12
|
* 3. **挂掉的提供方不许影响检索**:超时、抛错、返回垃圾形状、编造不存在的文件名,
|
|
12
13
|
* 一律降级回词面结果,并把原因如实放进返回值(`code` / `message`)。
|
|
13
14
|
*
|
|
@@ -26,8 +27,23 @@
|
|
|
26
27
|
* ```
|
|
27
28
|
*
|
|
28
29
|
* 两种接口的边界**不一样**,这是量出来的、不是拍出来的:
|
|
29
|
-
* · `rerank` 声称自己有打分器 ⇒
|
|
30
|
-
*
|
|
30
|
+
* · `rerank` 声称自己有打分器 ⇒ 它看到的是一个**宽于输出窗口的候选池**(`pool`,默认 50),
|
|
31
|
+
* 融合方式由 `fusion` 决定:`rrf`(默认,词面与提供方各一票,谁也别想翻天)或
|
|
32
|
+
* `trust`(提供方说了算,词面只负责出候选与补空位);
|
|
33
|
+
* · `expand` 只是「换个说法再查一遍」⇒ **只补空位,永不改动词面顺序**(它仍然只按 `limit` 检索)。
|
|
34
|
+
*
|
|
35
|
+
* 「池子宽于窗口」这条是 0.12.0 加的,动机:池宽 = 输出窗口时,提供方只能重排词面已经挑中的
|
|
36
|
+
* 那几条,天花板被词面钉死。实测(`bench/.tmp/pool-oracle.mjs`,2026-10-15,合成 704 条 +
|
|
37
|
+
* 真库探针 36 条,`limit=4`,提供方 = 完美排序 / 乱序排序两个极端):
|
|
38
|
+
* · 池内召回随池宽上升:合成 P=5 495/704 → P=20 610/704 → P=50 685/704 → P=100 689/704;
|
|
39
|
+
* 真库 36 条在 P≥5 就全在池内。池宽就是「完美提供方」的天花板。
|
|
40
|
+
* · `rrf` + 宽池(P≥20)能把完美提供方兑现一半:合成 @1 52.7%(P=5/20 一样,因为 RRF 里
|
|
41
|
+
* 词面第 1 名还压着提供方第 1 名)→ @4 73.6%;真库 @1 72.2% → 80.6%。
|
|
42
|
+
* · `trust` 才拿到全部:合成 @1 86.6%(= 池内召回 610/704)、真库 @1 100%(36/36)。
|
|
43
|
+
* · 代价同样量过:提供方若把顺序**倒过来**,`rrf` 合成 @1 50.4% → 26.0%,`trust` 更惨;
|
|
44
|
+
* 把提供方票数加到 2/3 票能让完美提供方涨到 63.2%/67.5%,但乱序提供方会直接把 @1 打到
|
|
45
|
+
* 1.0%(合成)/ 0.0%(真库)—— 所以**票数永远留 1**,谁想更激进就用 `trust` 明说。
|
|
46
|
+
* 一句话:`rrf` = 少赚不赔,`trust` = 把天花板交给提供方。
|
|
31
47
|
* 实测(2026-10-15,真库探针 36 条 + 合成 70 条,改写由宿主模型逐条产生):
|
|
32
48
|
* RRF 融合让 3 条改写以 1:1 的票数盖过原查询的 1 票,真库 hit@1 26/36 → 19/36、
|
|
33
49
|
* hit@4 36/36 → 30/36;把原查询加权到 3 票 / 5 票、或改成归一化打分,全都仍不如基线;
|
|
@@ -48,6 +64,41 @@ export const DEFAULT_TIMEOUT_MS = 2000;
|
|
|
48
64
|
export const MAX_EXPANSIONS = 8;
|
|
49
65
|
export const MAX_EXPANSION_CHARS = 120;
|
|
50
66
|
export const HOST_LLM = 'host-llm';
|
|
67
|
+
/** 复用 DSH 设置页里已配好的模型路由(baseURL + 模型 + 密钥),不用再填一遍环境变量。 */
|
|
68
|
+
export const HOST_EMBED = 'host-embed';
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 目录自带路由的地址与 embedding 模型(pi-ai catalog 里,宿主设置读不到,只能在这里兜底)。
|
|
72
|
+
* 只收「确定提供 OpenAI 形状 /embeddings 接口」的几家;其余一律要求用户显式写 baseURL。
|
|
73
|
+
*/
|
|
74
|
+
const HOST_EMBED_BASE_URLS = {
|
|
75
|
+
openai: 'https://api.openai.com/v1',
|
|
76
|
+
mistral: 'https://api.mistral.ai/v1',
|
|
77
|
+
together: 'https://api.together.xyz/v1',
|
|
78
|
+
zai: 'https://open.bigmodel.cn/api/paas/v4',
|
|
79
|
+
};
|
|
80
|
+
const HOST_EMBED_MODELS = {
|
|
81
|
+
openai: 'text-embedding-3-small',
|
|
82
|
+
mistral: 'mistral-embed',
|
|
83
|
+
together: 'BAAI/bge-large-en-v1.5',
|
|
84
|
+
zai: 'embedding-3',
|
|
85
|
+
};
|
|
86
|
+
/** `rerank` 提供方看到的候选池宽度(`pool` 配置,默认 50;上限 200)。 */
|
|
87
|
+
export const DEFAULT_POOL = 50;
|
|
88
|
+
export const MAX_POOL = 200;
|
|
89
|
+
/**
|
|
90
|
+
* 融合模式:
|
|
91
|
+
* · `trust`(默认)= 提供方给了顺序就按它的顺序走,词面只用来补满窗口;
|
|
92
|
+
* · `rrf` = 词面与提供方各一票(等权),词面因此有一票否决权。
|
|
93
|
+
*
|
|
94
|
+
* 默认选 `trust` 是量出来的(合成 704 条、池 20、窗口 5;`npm run bench:seam` 可复算):
|
|
95
|
+
* 真提供方返回的是一份**全量排名**,等权 RRF 会让它自己第 2 名以后的名次去稀释第 1 名 ——
|
|
96
|
+
* 完美提供方在 rrf 下只有 52.7%/76.3%(比纯词面 50.4%/70.3% 几乎没赚),trust 下是 86.6%/86.6%;
|
|
97
|
+
* 反过来,坏提供方在 trust 下会一路砸到底(倒排:1.0%/4.7%),这时 rrf 是安全档(26.0%/63.9%)。
|
|
98
|
+
* 结论:可信的提供方配 trust;不确定它靠不靠谱时配 rrf。
|
|
99
|
+
*/
|
|
100
|
+
export const FUSION_MODES = ['rrf', 'trust'];
|
|
101
|
+
export const DEFAULT_FUSION = 'trust';
|
|
51
102
|
|
|
52
103
|
/** 配置里能写的提供方:空 = 关闭;`host-llm` = 走宿主模型(llm.js);其余当本地模块路径。 */
|
|
53
104
|
export function describeSeam(config = {}) {
|
|
@@ -56,20 +107,116 @@ export function describeSeam(config = {}) {
|
|
|
56
107
|
if (spec === HOST_LLM) {
|
|
57
108
|
return { enabled: true, kind: 'host-llm', spec, message: '语义接缝指向宿主模型(llm.js,动态 import)' };
|
|
58
109
|
}
|
|
110
|
+
if (spec === HOST_EMBED) {
|
|
111
|
+
return { enabled: true, kind: 'host-embed', spec, message: '语义接缝复用 DSH 里已配好的模型路由(设置页的 baseURL + 模型 + 密钥)' };
|
|
112
|
+
}
|
|
59
113
|
return { enabled: true, kind: 'module', spec, message: `语义接缝指向本地模块:${spec}` };
|
|
60
114
|
}
|
|
61
115
|
|
|
116
|
+
/** 候选池宽度:至少等于输出窗口,默认 DEFAULT_POOL,上限 MAX_POOL。 */
|
|
117
|
+
export function poolLimit(config = {}, limit = 5) {
|
|
118
|
+
const want = Number(config.pool);
|
|
119
|
+
const pool = Number.isFinite(want) && want > 0 ? want : DEFAULT_POOL;
|
|
120
|
+
return Math.min(MAX_POOL, Math.max(Number(limit) || 1, Math.floor(pool)));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** 融合模式:只认 FUSION_MODES 里的值,其余一律回默认(配置写错不该让检索变怪)。 */
|
|
124
|
+
export function fusionMode(config = {}) {
|
|
125
|
+
const want = String(config.fusion ?? '').trim().toLowerCase();
|
|
126
|
+
return FUSION_MODES.includes(want) ? want : DEFAULT_FUSION;
|
|
127
|
+
}
|
|
128
|
+
|
|
62
129
|
function expandHome(value) {
|
|
63
130
|
if (value === '~') return homedir();
|
|
64
131
|
return value.startsWith('~/') ? join(homedir(), value.slice(2)) : value;
|
|
65
132
|
}
|
|
66
133
|
|
|
134
|
+
/**
|
|
135
|
+
* `provider` 可以写成三种形式,这里判断它是不是「裸模块名」(交给 Node 解析):
|
|
136
|
+
* · `dsh-engramory-recall/providers/openai-embeddings` —— 裸名(包自引用 / 依赖解析);
|
|
137
|
+
* · `./my-provider.mjs`、`~/x/y.mjs`、`/abs/path.mjs` —— 路径。
|
|
138
|
+
*/
|
|
139
|
+
export function isBareSpecifier(spec) {
|
|
140
|
+
const text = String(spec ?? '').trim();
|
|
141
|
+
if (!text) return false;
|
|
142
|
+
if (text.startsWith('.') || text.startsWith('/') || text.startsWith('~')) return false;
|
|
143
|
+
return !/^[A-Za-z]:[\\/]/.test(text);
|
|
144
|
+
}
|
|
145
|
+
|
|
67
146
|
export function resolveProviderPath(spec, baseDir = process.cwd()) {
|
|
68
147
|
const text = expandHome(String(spec ?? '').trim());
|
|
69
148
|
if (!text) return '';
|
|
70
149
|
return isAbsolute(text) ? text : resolve(baseDir, text);
|
|
71
150
|
}
|
|
72
151
|
|
|
152
|
+
/**
|
|
153
|
+
* 从宿主设置里读出「用哪条路由做 embedding」:路由 → baseURL → 模型 → 密钥。
|
|
154
|
+
* 每一步都给出**可照做的失败信息**(而不是一句「配置错误」),并且**绝不把密钥写进任何返回值以外的路径**。
|
|
155
|
+
*/
|
|
156
|
+
async function readHostEmbedProfile(runtime, config = {}) {
|
|
157
|
+
const ctx = runtime?.ctx;
|
|
158
|
+
const settings = ctx && typeof ctx.get === 'function' ? ctx.get('settings') : undefined;
|
|
159
|
+
if (!settings || typeof settings.describe !== 'function') {
|
|
160
|
+
return { ok: false, code: 'no-host', message: 'host-embed 需要宿主设置服务(ctx.settings);MCP 等没有宿主的场景请改用本地模块提供方' };
|
|
161
|
+
}
|
|
162
|
+
let route = String(config.hostRoute ?? runtime?.config?.provider ?? '').trim();
|
|
163
|
+
if (!route) {
|
|
164
|
+
try {
|
|
165
|
+
route = String(ctx.get('agentDefaultModel')?.currentSelection?.()?.provider ?? '').trim();
|
|
166
|
+
} catch {
|
|
167
|
+
route = '';
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!route) return { ok: false, code: 'no-route', message: '拿不到宿主默认模型的路由:请在插件配置里写 semanticHostRoute' };
|
|
171
|
+
|
|
172
|
+
let row;
|
|
173
|
+
try {
|
|
174
|
+
const rows = settings.describe({ redactSecrets: true });
|
|
175
|
+
row = (Array.isArray(rows) ? rows : []).find((item) => item?.ns === 'llm-pi-ai');
|
|
176
|
+
} catch (error) {
|
|
177
|
+
return { ok: false, code: 'load-error', message: `读宿主设置失败:${error?.message ?? error}` };
|
|
178
|
+
}
|
|
179
|
+
const profile = row?.value?.providers?.[route] ?? row?.user?.providers?.[route] ?? {};
|
|
180
|
+
const baseUrl = String(config.hostBaseUrl ?? profile.baseURL ?? HOST_EMBED_BASE_URLS[route] ?? '').trim();
|
|
181
|
+
if (!baseUrl) {
|
|
182
|
+
return {
|
|
183
|
+
ok: false,
|
|
184
|
+
code: 'no-base-url',
|
|
185
|
+
message: `路由 ${route} 没写 baseURL,插件也猜不到它(目录自带路由的地址在 pi-ai catalog 里)。请在 DSH 设置 → 模型 →「自定义模型 API」里加一条 OpenAI 兼容的 embedding 路由,或在本插件配置里写 semanticHostBaseUrl`,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
const declared = Array.isArray(profile.models) ? profile.models : [];
|
|
189
|
+
const model =
|
|
190
|
+
String(config.hostModel ?? '').trim() ||
|
|
191
|
+
String(declared.find((item) => /embed/i.test(String(item?.id ?? '')))?.id ?? '').trim() ||
|
|
192
|
+
(declared.length === 1 ? String(declared[0]?.id ?? '').trim() : '') ||
|
|
193
|
+
HOST_EMBED_MODELS[route] ||
|
|
194
|
+
'';
|
|
195
|
+
if (!model) {
|
|
196
|
+
return { ok: false, code: 'no-model', message: `路由 ${route} 里找不出 embedding 模型:请在插件配置里写 semanticHostModel(例如 text-embedding-3-small)` };
|
|
197
|
+
}
|
|
198
|
+
// 宿主侧凭据的 ref 就是环境变量名本身(custom 路由保存时按 <ROUTE>_API_KEY 派生,catalog 路由由 pi-ai 声明)。
|
|
199
|
+
const keyRef = String(profile.apiKeyEnv ?? '').trim() || `${route.toUpperCase().replace(/[^A-Z0-9]+/g, '_')}_API_KEY`;
|
|
200
|
+
const credentials = typeof ctx.get === 'function' ? ctx.get('credentials') : undefined;
|
|
201
|
+
let apiKey = '';
|
|
202
|
+
if (credentials && typeof credentials.resolve === 'function') {
|
|
203
|
+
try {
|
|
204
|
+
// 宿主规范:凭据**每次调用现解析、不许跨操作缓存**(值可能被用户随时改)。
|
|
205
|
+
apiKey = String((await credentials.resolve(keyRef))?.value ?? '');
|
|
206
|
+
} catch {
|
|
207
|
+
apiKey = '';
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
if (!apiKey) {
|
|
211
|
+
return {
|
|
212
|
+
ok: false,
|
|
213
|
+
code: 'no-key',
|
|
214
|
+
message: `宿主凭据里没有 ${keyRef}:在 DSH 设置 → 模型 →「${route}」里填 API 密钥(落到 ~/.dsh/.credentials.yaml 的 refs.${keyRef}),或给本插件配 ENGRAMORY_EMBED_API_KEY`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
return { ok: true, route, baseUrl, model, apiKey, keyRef };
|
|
218
|
+
}
|
|
219
|
+
|
|
73
220
|
/** 加载提供方。任何失败都返回 `{ ok: false }` —— 调用方负责降级,这里绝不抛。 */
|
|
74
221
|
export async function loadProvider(config = {}, deps = {}) {
|
|
75
222
|
const seam = describeSeam(config);
|
|
@@ -89,6 +236,28 @@ export async function loadProvider(config = {}, deps = {}) {
|
|
|
89
236
|
return { ok: true, provider, kind: 'expand', code: 'host-llm', message: seam.message, seam };
|
|
90
237
|
}
|
|
91
238
|
|
|
239
|
+
if (seam.kind === 'host-embed') {
|
|
240
|
+
const profile = await readHostEmbedProfile(deps.runtime, config);
|
|
241
|
+
if (!profile.ok) return { ok: false, code: profile.code, message: profile.message, seam };
|
|
242
|
+
const module = await import('./providers/openai-embeddings.mjs');
|
|
243
|
+
const provider = module.createProvider({
|
|
244
|
+
baseUrl: profile.baseUrl,
|
|
245
|
+
model: profile.model,
|
|
246
|
+
apiKey: profile.apiKey,
|
|
247
|
+
providerName: `${HOST_EMBED}:${profile.route}/${profile.model}`,
|
|
248
|
+
});
|
|
249
|
+
return { ok: true, provider, kind: 'rerank', code: 'host-embed', message: seam.message, seam, hostRoute: profile.route };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (isBareSpecifier(seam.spec)) {
|
|
253
|
+
const module = await import(seam.spec);
|
|
254
|
+
const provider = normalizeModule(module, seam.spec);
|
|
255
|
+
if (!provider) {
|
|
256
|
+
return { ok: false, code: 'unsupported', message: `提供方模块必须导出 rerank 或 expand 函数:${seam.spec}`, seam, path: seam.spec };
|
|
257
|
+
}
|
|
258
|
+
return { ok: true, provider, kind: provider.kind, code: 'module', message: seam.message, seam, path: seam.spec };
|
|
259
|
+
}
|
|
260
|
+
|
|
92
261
|
const full = resolveProviderPath(seam.spec, deps.baseDir);
|
|
93
262
|
if (!full) return { ok: false, code: 'empty-path', message: '提供方路径是空的', seam };
|
|
94
263
|
try {
|
|
@@ -109,6 +278,14 @@ export async function loadProvider(config = {}, deps = {}) {
|
|
|
109
278
|
}
|
|
110
279
|
return { ok: true, provider, kind: provider.kind, code: 'module', message: seam.message, seam, path: full };
|
|
111
280
|
} catch (error) {
|
|
281
|
+
if (error?.code === 'ERR_MODULE_NOT_FOUND' || error?.code === 'MODULE_NOT_FOUND') {
|
|
282
|
+
return {
|
|
283
|
+
ok: false,
|
|
284
|
+
code: 'not-found',
|
|
285
|
+
message: `找不到提供方模块:${seam.spec}(裸名要装在同一个 node_modules 里,否则写绝对路径)`,
|
|
286
|
+
seam,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
112
289
|
return { ok: false, code: 'load-error', message: `加载提供方失败:${error?.message ?? error}`, seam };
|
|
113
290
|
}
|
|
114
291
|
}
|
|
@@ -274,7 +451,10 @@ export async function semanticSearch(options = {}) {
|
|
|
274
451
|
|
|
275
452
|
try {
|
|
276
453
|
if (provider.kind === 'rerank') {
|
|
277
|
-
|
|
454
|
+
// 提供方看的是宽池:池子 = 输出窗口时它只能重排词面已挑中的那几条(天花板被词面钉死)。
|
|
455
|
+
const poolSize = poolLimit(config, limit);
|
|
456
|
+
const pool = poolSize > limit ? runSearch(options.index, query, poolSize) : lexical;
|
|
457
|
+
const docs = pool.map((hit, index) => ({
|
|
278
458
|
id: hit.id,
|
|
279
459
|
snippet: hit.snippet,
|
|
280
460
|
matched: hit.matched,
|
|
@@ -286,17 +466,28 @@ export async function semanticSearch(options = {}) {
|
|
|
286
466
|
if (!ranking.ids.length) {
|
|
287
467
|
return fail(lexical, started, 'provider-empty', `提供方 ${provider.name} 没给出任何可用排序,已降级`, { provider: provider.name });
|
|
288
468
|
}
|
|
289
|
-
const
|
|
290
|
-
const
|
|
469
|
+
const byId = new Map(pool.map((hit) => [hit.id, hit]));
|
|
470
|
+
const fusion = fusionMode(config);
|
|
471
|
+
let order;
|
|
472
|
+
if (fusion === 'trust') {
|
|
473
|
+
// 提供方说了算:它给过分的按它的顺序走,剩下的坑用词面顺序补满。
|
|
474
|
+
order = [...ranking.ids, ...pool.map((hit) => hit.id).filter((id) => !ranking.ids.includes(id))].slice(0, limit);
|
|
475
|
+
} else {
|
|
476
|
+
order = mergeRRF([pool.map((hit) => hit.id), ranking.ids], limit);
|
|
477
|
+
}
|
|
291
478
|
const hits = order.map((id) => byId.get(id)).filter(Boolean);
|
|
292
|
-
//
|
|
479
|
+
// 提供方编造的、池子里没有的文件名 —— 如实报数,别假装融合了。
|
|
293
480
|
const missing = order.filter((id) => !byId.has(id)).length;
|
|
294
481
|
return {
|
|
295
482
|
hits,
|
|
296
483
|
used: true,
|
|
297
484
|
code: 'reranked',
|
|
298
|
-
message:
|
|
485
|
+
message: fusion === 'trust'
|
|
486
|
+
? `已按 ${provider.name} 的顺序取前 ${hits.length} 条(池 ${pool.length},Trusted 融合)`
|
|
487
|
+
: `已按 ${provider.name} 的顺序融合(RRF,池 ${pool.length})`,
|
|
299
488
|
provider: provider.name,
|
|
489
|
+
fusion,
|
|
490
|
+
pool: pool.length,
|
|
300
491
|
dropped: ranking.dropped,
|
|
301
492
|
missing,
|
|
302
493
|
ms: Date.now() - started,
|