dsh-vectordb 0.0.1 → 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 he fangsheng (https://github.com/dongsheng123132)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,19 +1,139 @@
1
- # dsh-vectordb · 本地向量库
1
+ # dsh-vectordb · 本地纯向量存储 + 两步检索
2
2
 
3
- > **定义**:本地零依赖向量库,支持元数据过滤与混合检索(BM25 + 余弦),不起服务、不接 SaaS。
4
- >
5
- > **English**: A local, dependency-free vector store for embeddings with metadata filters and hybrid (BM25 + cosine) search — no server, no SaaS.
3
+ dsh-vectordb 是一个 DSH(DeepSeek Harness)插件,用于在本地存向量、按元数据过滤做近似检索:多集合、`vectors.jsonl` 追加日志 + 墓碑 + `compact` 重写、幂等 `opId`;检索走**两步**——先对 k-means 质心粗筛,再对候选算真距离,返回里永远带 `scanned` / `candidates` / `returned` 与 `exact` 标记。安装:`dsh plugin --profile web add dsh-vectordb`。
6
4
 
7
- 状态:**0.0.1 名称占位**(name reservation)。实现中;能力说明即本包目标范围,完成前请勿依赖。
5
+ 包 `dsh-vectordb@0.1.0` · 已实测宿主 DSH 0.2.2-rc.1、Node 23.11.0 · 许可 MIT · 作者主页 https://github.com/dongsheng123132 · 仓库 https://github.com/dongsheng123132/dsh-vectordb(发布时创建)
8
6
 
9
- ## 它补上 DSH 的哪一块
7
+ **零依赖、不联网、不接 ANN 库**:不引 hnswlib/faiss,不用任何外部服务;测试逐条锁死「没有网络模块、没有子进程、零运行时依赖」。
10
8
 
11
- 本地零依赖向量库,支持元数据过滤与混合检索(BM25 + 余弦),不起服务、不接 SaaS。
9
+ ## 它解决什么?
12
10
 
13
- ## 安装(可用版本发布后)
11
+ 想在 agent 会话里做 RAG,常见两条路:接一个 SaaS 向量库(要联网、要 Key、数据出门),或者装 ANN 库(要编译、要依赖)。这个插件走第三条:**本地文件 + 手写聚类**,把「真向量检索的存储层」做小做对:
12
+
13
+ - 多集合,每个集合自己声明 `dim` 与 `metric`;
14
+ - 写入是追加日志,删了只是墓碑,`compact` 才真正重写;
15
+ - 检索分两步,**近似就是近似**——`exact:false` 时明说「只扫了 1/15 个簇」;
16
+ - 同一 `opId` 重复提交不会重复写(网络重试/agent 重放安全);
17
+ - 坏行跳过并计数,一行坏 JSON 不会毁掉整个集合。
18
+
19
+ ## 它不是什么?(边界写死)
20
+
21
+ | 想做的事 | 本包的态度 |
22
+ |---|---|
23
+ | 本地存向量、按元数据过滤检索、删、压缩 | ✅ |
24
+ | 接 hnswlib / faiss / 任何 ANN 库 | ❌ 不接:零依赖,粗筛用内存 k-means |
25
+ | 联网、调嵌入 API | ❌ 不做:向量由你(或别的插件)生成后传进来 |
26
+ | BM25 / 关键词混合检索 | ❌ 不是本包:那是 `dsh-vector-kit` 的活 |
27
+ | 把近似结果说成精确结果 | ❌ 绝不:`exact` 只有候选覆盖全量时才为 true |
28
+ | 维度不一致时截断/补零 | ❌ 绝不:直接 `dimension_mismatch` 拒绝 |
29
+
30
+ ## 怎么装、怎么用?
31
+
32
+ ```sh
33
+ dsh plugin --profile web add dsh-vectordb
34
+ ```
35
+
36
+ 装好后在 DSH 会话里直接说,模型会调 `dsh_vectordb`:
37
+
38
+ ```text
39
+ 建一个 768 维的向量集合 docs,用 cosine
40
+ 把这批 chunk 写进 docs(带 lang 元数据)
41
+ 在 docs 里找最像这段的 5 条,只要 lang=zh
42
+ 这条为什么是近似结果?扫了多少条、候选多少
43
+ 把删掉的向量压掉,看看文件小了多少
44
+ ```
45
+
46
+ 不装插件也能用 CLI(零依赖,只要 Node ≥22):
47
+
48
+ ```sh
49
+ node lib/cli.js create --collection docs --dim 768 --metric cosine --base-dir ./data
50
+ node lib/cli.js upsert --collection docs --items-file chunks.json --op-id ingest-1 --base-dir ./data
51
+ node lib/cli.js search --collection docs --query '[0.1,0.2,...]' --top-k 5 --filter '{"lang":"zh"}' --base-dir ./data
52
+ node lib/cli.js delete --collection docs --ids a,b --base-dir ./data
53
+ node lib/cli.js compact --collection docs --base-dir ./data
54
+ node lib/cli.js stats --collection docs --base-dir ./data
55
+ ```
56
+
57
+ 退出码:`0` 成功|`1` 参数错误|`2` 集合不存在或 IO 失败|`3` 输入被拒(维度/度量/向量/元数据)。
58
+
59
+ ## 六个动作
60
+
61
+ | 动作 | 做什么 | 关键返回 |
62
+ |---|---|---|
63
+ | `create` | 建集合,声明 `dim` + `metric` | `created` / `dim` / `metric` |
64
+ | `upsert` | 写 chunk:`id` + 向量 + 元数据 + 可选摘要 | `applied` / `seqStart..seqEnd` / `idempotent` |
65
+ | `search` | 两步检索(top-k + 元数据过滤) | `results` + **`scanned` / `candidates` / `returned` / `exact`** |
66
+ | `delete` | 墓碑删除(日志里留 delete 记录) | `tombstoned` / `missing` / `live` |
67
+ | `compact` | 物理清理并重写日志 | `kept` / `removedOps` / `beforeBytes` → `afterBytes` |
68
+ | `stats` | 概览(含坏行数、opId 历史长度) | `live` / `tombstones` / `ops` / `badLines` |
69
+
70
+ ## 两步检索口径(exact 判据写死)
71
+
72
+ ```
73
+ search(query):
74
+ 1) 过滤:先按元数据把候选集缩到 filtered
75
+ 2) 如果 filtered ≤ exactThreshold(默认 64)→ 暴力扫全量,mode=brute,exact=true
76
+ 3) 否则:k = √filtered 个 k-means 质心(确定性初始化,同数据同结果)
77
+ 粗筛 = 扫离 query 最近的 nprobe 个质心(默认 1)
78
+ 精确重排 = 对候选算真距离,取 top-k
79
+ exact = (candidates == filtered) ← 只有覆盖全量才为 true
80
+ ```
81
+
82
+ 返回里永远有:
83
+
84
+ - `scanned`:集合里存活多少条(过滤前);
85
+ - `filtered`:过滤后多少条;
86
+ - `candidates`:粗筛后真正算过距离的多少条;
87
+ - `returned`:实际返回几条;
88
+ - `exact`:**候选是否覆盖了 filtered 的全部**;
89
+ - `note`:人话解释这次为什么是(或不是)精确的。
90
+
91
+ `nprobe = k` 时覆盖全量 → `exact:true`;小集合默认暴力扫 → `exact:true`;其余一律 `exact:false`。近似的 `note` 会直接写「**这是近似结果**:真近邻可能在没扫的簇里」。
92
+
93
+ ## 持久化、墓碑与幂等
94
+
95
+ - 目录:`<baseDir>/collections/<名字>/{vectors.jsonl, meta.json, .lock}`;`baseDir` 默认 `$DSH_VECTORDB_DIR` 或 `<DSH_HOME>/vectordb`。
96
+ - `vectors.jsonl` 只追加:`upsert` / `delete` 各一行;读时从头重放(重启即可重建)。
97
+ - 删除是**墓碑**:`live` 减一、`tombstones` 加一,日志里仍在;`compact` 才物理重写。
98
+ - `meta.json` 原子写(临时文件 → fsync → rename),记录 `dim` / `metric` / `nextSeq` / 已应用的 `opId`。
99
+ - 写路径加锁(`open(..., 'wx')` 独占创建 `.lock`,陈旧 15s 可接管);**`search` / `stats` 不加锁、不写盘**。
100
+ - 坏行(坏 JSON / 形状不对)跳过并计数,`stats.badLines` 能看到。
101
+ - 同一 `opId` 重复提交 → `idempotent:true`、`applied:0`,不再写第二遍。
102
+
103
+ ## 与 dsh-vector-kit 怎么分工?
104
+
105
+ | | `dsh-vector-kit` | `dsh-vectordb`(本包) |
106
+ |---|---|---|
107
+ | 检索 | BM25 + 余弦混合 | **纯向量 + 两步检索**(粗筛 + 精确重排) |
108
+ | 索引 | 倒排索引 + 可选向量 | k-means 质心(内存,确定性) |
109
+ | 返回诚实度 | 混合分 | `scanned/candidates/returned/exact` |
110
+ | 适合 | 关键词 + 语义混合召回 | 「我要一个真向量库,并且知道它近不近似」 |
111
+
112
+ ## 事实
113
+
114
+ - **6 个动作**:create / upsert / search / delete / compact / stats;**2 个度量**:cosine、l2(其它 `unsupported_metric`)
115
+ - **exact 判据 3 条**:过滤后 ≤64 条 → brute/exact;`nprobe=k` → 覆盖全量/exact;其余 → two-step/近似
116
+ - **测试 53 条 / 8 个测试文件**:建集合、维度拒绝、度量拒绝、幂等 opId、两度量排序、元数据过滤、近似 `exact:false`(300 条)、小集合 `exact:true`、墓碑不返回、compact 变小且结果一致、重启重建、坏行跳过、只读快照、命名同源、CLI 四档退出码、插件契约
117
+ - **源码 1890 行**(`plugin.mjs` + `lib/` 共 10 个文件),**零运行时依赖**
118
+ - `npm pack --dry-run`:17 文件 / 79 KB / tarball 26 KB
119
+
120
+ ## 已知局限
121
+
122
+ - **不做 ANN 索引**:粗筛是内存 k-means,每次查询现算;数据大时比 hnswlib 慢。
123
+ - **粗筛会漏真近邻**:`exact:false` 时结果只是「候选里的最优」;要精确就调大 `nprobe`/`k` 或用小集合模式。
124
+ - **写路径串行**:同机文件锁,不是分布式锁;多机写同一目录没有保证。
125
+ - **向量以 JSON 数字存**:比二进制大 2-4 倍,没有量化/压缩。
126
+ - **过滤能力有限**:元数据子集相等 / 数组包含;不支持范围、表达式或全文检索。
127
+ - **删除不立即回收**:墓碑要 `compact` 才真正消失。
128
+ - **不生成嵌入**:向量由调用方提供,本包不联网调模型。
129
+
130
+ ## 开发与自检
14
131
 
15
132
  ```sh
16
- dsh plugin --profile desktop add dsh-vectordb
133
+ node --test test/ # 或 node --test test/*.test.mjs
134
+ npm run check # 包结构 + 语法 + 命名四处同源 + 宿主 schema + 临时目录冒烟
135
+ npm run verify:install # 走宿主安装代码路径装进临时 profile(DSH_HOME 指向 mkdtemp)
136
+ node ../dsh-geo-audit/lib/cli.js --file README.md # 用 GEO 插件审这份 README
17
137
  ```
18
138
 
19
139
  ## 许可
@@ -0,0 +1,16 @@
1
+ # dsh-vectordb:往宿主挂一个 host 侧插件,注册模型可调用工具 dsh_vectordb。
2
+ # 行 id 与包名同名(三个名字同源),避免「行 id ≠ 包名」两个命名空间带来的重复注册。
3
+ - insert:
4
+ - id: dsh-vectordb
5
+ name: dsh-vectordb
6
+ config:
7
+ # 数据根;留空 = $DSH_VECTORDIR 或 <DSH_HOME>/vectordb
8
+ baseDir: ''
9
+ # 新建集合的默认度量(cosine | l2)
10
+ metric: cosine
11
+ # search 默认返回条数
12
+ topK: 10
13
+ # 粗筛默认扫几个质心
14
+ nprobe: 1
15
+ # 小集合暴力扫全量的阈值
16
+ exactThreshold: 64
@@ -0,0 +1,28 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "policy": "dsh-plugin-naming/v1",
4
+ "plugin": {
5
+ "namespace": "hfshfg",
6
+ "name": "vectordb",
7
+ "coordinate": "hfshfg/vectordb",
8
+ "packageName": "dsh-vectordb"
9
+ },
10
+ "names": {
11
+ "pluginNames": [
12
+ "dsh-vectordb"
13
+ ],
14
+ "loaderIds": [
15
+ "dsh-vectordb"
16
+ ],
17
+ "services": [],
18
+ "tools": [
19
+ "dsh_vectordb"
20
+ ],
21
+ "commands": [],
22
+ "skills": [],
23
+ "skillProviders": [],
24
+ "events": [],
25
+ "settingsNamespaces": [],
26
+ "routes": []
27
+ }
28
+ }
package/lib/cli.js ADDED
@@ -0,0 +1,276 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `dsh-vectordb` 命令行:建集合 / 写向量 / 两步检索 / 墓碑删除 / 压缩 / 概览。
4
+ *
5
+ * 约定:
6
+ * - 正常结果写 stdout,错误写 stderr;`--json` 时 stdout 只有一个对象
7
+ * - 退出码:0 成功|1 参数错误|2 集合不存在或 IO 失败|3 输入被拒(维度/度量/向量/元数据)
8
+ */
9
+
10
+ import { readFileSync, realpathSync } from 'node:fs'
11
+ import { resolve } from 'node:path'
12
+ import { fileURLToPath } from 'node:url'
13
+
14
+ import { ACTIONS, runAction } from './engine.mjs'
15
+ import { describeError } from './errors.mjs'
16
+ import { renderJson, renderText } from './format.mjs'
17
+ import { VERSION } from './version.mjs'
18
+
19
+ const USAGE = `dsh-vectordb ${VERSION} —— 本地纯向量存储与两步检索(零依赖、不联网)
20
+
21
+ 用法:
22
+ node lib/cli.js create --collection <名> --dim <n> [--metric cosine|l2] [--base-dir <目录>]
23
+ node lib/cli.js upsert --collection <名> --items-file <json 文件> [--op-id <幂等键>]
24
+ node lib/cli.js search --collection <名> --query <向量> [--top-k 10] [--filter <json>] [--nprobe 1] [--k <簇数>]
25
+ node lib/cli.js delete --collection <名> --ids <id,id...> [--op-id <幂等键>]
26
+ node lib/cli.js compact --collection <名>
27
+ node lib/cli.js stats --collection <名>
28
+
29
+ 选项:
30
+ --collection <名> 集合名(字母/数字/_/-,≤63)
31
+ --base-dir <目录> 数据根(默认 $DSH_VECTORDIR 或 <DSH_HOME>/vectordb)
32
+ --dim <n> create 用:向量维度(1..4096)
33
+ --metric <m> create 用:cosine(默认)| l2;其它一律 unsupported_metric
34
+ --items-file <路径> upsert 用:JSON 数组,每项 {id, vector, metadata?, text?}
35
+ --query <向量> search 用:JSON 数组,或 1,2,3 逗号分隔
36
+ --top-k <n> search 用:返回几条(默认 10)
37
+ --filter <json> search 用:元数据子集匹配,如 {"lang":"zh"}
38
+ --nprobe <n> search 用:粗筛扫几个质心(默认 1;越大越准越慢)
39
+ --k <n> search 用:强制质心数(默认 √n)
40
+ --exact-threshold <n> search 用:≤ 这个数就暴力扫全量(默认 64)
41
+ --ids <id,...> delete 用:要墓碑删除的 id
42
+ --op-id <字符串> 写操作幂等键:同一个 opId 重复提交不会重复写
43
+ --json 输出 JSON(stdout 只有一个对象)
44
+ --brief 文本模式少说两句
45
+ --help, -h 显示本帮助
46
+ --version, -v 只打印版本
47
+
48
+ 退出码:0 成功|1 参数错误|2 集合不存在/IO|3 输入被拒(维度/度量/向量/元数据)`
49
+
50
+ /** 参数错误(退出码 1)。 */
51
+ class UsageError extends Error {}
52
+
53
+ /**
54
+ * 解析 argv。
55
+ * @param {string[]} argv 参数
56
+ * @returns {{action:string|null,flags:object}} 结果
57
+ */
58
+ function parseArgs(argv) {
59
+ const flags = { json: false, brief: false }
60
+ let action = null
61
+ for (let index = 0; index < argv.length; index++) {
62
+ const arg = argv[index]
63
+ const take = (name) => {
64
+ const next = argv[++index]
65
+ if (next === undefined) throw new UsageError(`${name} 需要一个值`)
66
+ return next
67
+ }
68
+ const int = (name) => {
69
+ const value = Number(take(name))
70
+ if (!Number.isInteger(value)) throw new UsageError(`${name} 需要整数`)
71
+ return value
72
+ }
73
+ switch (arg) {
74
+ case '--help': case '-h': flags.help = true; break
75
+ case '--version': case '-v': flags.version = true; break
76
+ case '--json': flags.json = true; break
77
+ case '--brief': flags.brief = true; break
78
+ case '--collection': case '-c': flags.collection = take('--collection'); break
79
+ case '--base-dir': flags.baseDir = take('--base-dir'); break
80
+ case '--dim': flags.dim = int('--dim'); break
81
+ case '--metric': flags.metric = take('--metric'); break
82
+ case '--items-file': flags.itemsFile = take('--items-file'); break
83
+ case '--query': flags.query = take('--query'); break
84
+ case '--top-k': flags.topK = int('--top-k'); break
85
+ case '--filter': flags.filter = take('--filter'); break
86
+ case '--nprobe': flags.nprobe = int('--nprobe'); break
87
+ case '--k': flags.k = int('--k'); break
88
+ case '--exact-threshold': flags.exactThreshold = int('--exact-threshold'); break
89
+ case '--ids': flags.ids = take('--ids'); break
90
+ case '--op-id': flags.opId = take('--op-id'); break
91
+ default:
92
+ if (arg.startsWith('--')) throw new UsageError(`未知参数 ${arg}`)
93
+ if (action !== null) throw new UsageError(`一次只能跑一个动作,多了 ${arg}`)
94
+ if (!ACTIONS.includes(arg)) throw new UsageError(`不认识的动作 ${arg}(可选:${ACTIONS.join(' / ')})`)
95
+ action = arg
96
+ }
97
+ }
98
+ return { action, flags }
99
+ }
100
+
101
+ /**
102
+ * 解析向量参数:JSON 数组或逗号分隔。
103
+ * @param {string} raw 原值
104
+ * @returns {number[]} 向量
105
+ */
106
+ function parseVector(raw) {
107
+ const text = String(raw).trim()
108
+ try {
109
+ const parsed = JSON.parse(text)
110
+ if (Array.isArray(parsed)) return parsed.map((value) => Number(value))
111
+ } catch {
112
+ // 落到逗号分隔
113
+ }
114
+ return text.split(',').map((part) => Number(part.trim()))
115
+ }
116
+
117
+ /**
118
+ * 解析 JSON 参数。
119
+ * @param {string} raw 原值
120
+ * @param {string} name 名称
121
+ * @returns {object} 对象
122
+ */
123
+ function parseJsonObject(raw, name) {
124
+ try {
125
+ const parsed = JSON.parse(raw)
126
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw new Error('不是对象')
127
+ return parsed
128
+ } catch (error) {
129
+ throw new UsageError(`${name} 需要 JSON 对象:${error instanceof Error ? error.message : String(error)}`)
130
+ }
131
+ }
132
+
133
+ /**
134
+ * 读 items 文件。
135
+ * @param {string} file 路径
136
+ * @returns {Array<object>} 条目
137
+ */
138
+ function readItems(file) {
139
+ let text
140
+ try {
141
+ text = readFileSync(file, 'utf8')
142
+ } catch (error) {
143
+ throw new UsageError(`读不了 --items-file:${error instanceof Error ? error.message : String(error)}`)
144
+ }
145
+ try {
146
+ const parsed = JSON.parse(text)
147
+ if (!Array.isArray(parsed)) throw new Error('顶层必须是数组')
148
+ return parsed
149
+ } catch (error) {
150
+ throw new UsageError(`--items-file 不是合法 JSON 数组:${error instanceof Error ? error.message : String(error)}`)
151
+ }
152
+ }
153
+
154
+ /**
155
+ * 错误码 → 退出码。
156
+ * @param {string} code 错误码
157
+ * @returns {number} 退出码
158
+ */
159
+ function exitCodeOf(code) {
160
+ if (code === 'collection_not_found' || code === 'read_failed' || code === 'lock_failed' || code === 'lock_timeout') return 2
161
+ if (['dimension_mismatch', 'unsupported_metric', 'invalid_vector', 'invalid_metadata', 'invalid_text', 'invalid_dim', 'invalid_id', 'invalid_item', 'empty_batch', 'batch_too_large', 'collection_exists'].includes(code)) return 3
162
+ return 1
163
+ }
164
+
165
+ /**
166
+ * 组装动作参数。
167
+ * @param {string} action 动作
168
+ * @param {object} flags 参数
169
+ * @returns {object} 动作参数
170
+ */
171
+ function argsOf(action, flags) {
172
+ const base = {
173
+ action,
174
+ collection: flags.collection,
175
+ base_dir: flags.baseDir,
176
+ op_id: flags.opId,
177
+ }
178
+ switch (action) {
179
+ case 'create': return { ...base, dim: flags.dim, metric: flags.metric }
180
+ case 'upsert': {
181
+ if (!flags.itemsFile) throw new UsageError('upsert 需要 --items-file <json>')
182
+ return { ...base, items: readItems(flags.itemsFile) }
183
+ }
184
+ case 'search': {
185
+ if (!flags.query) throw new UsageError('search 需要 --query <向量>')
186
+ return {
187
+ ...base,
188
+ query: parseVector(flags.query),
189
+ top_k: flags.topK,
190
+ filter: flags.filter === undefined ? undefined : parseJsonObject(flags.filter, '--filter'),
191
+ nprobe: flags.nprobe,
192
+ k: flags.k,
193
+ exact_threshold: flags.exactThreshold,
194
+ }
195
+ }
196
+ case 'delete': {
197
+ if (!flags.ids) throw new UsageError('delete 需要 --ids <id,...>')
198
+ return { ...base, ids: flags.ids.split(',').map((id) => id.trim()).filter(Boolean) }
199
+ }
200
+ default: return base
201
+ }
202
+ }
203
+
204
+ /**
205
+ * CLI 主流程。
206
+ * @param {string[]} argv 参数
207
+ * @returns {Promise<number>} 退出码
208
+ */
209
+ async function main(argv) {
210
+ let parsed
211
+ try {
212
+ parsed = parseArgs(argv)
213
+ } catch (error) {
214
+ process.stderr.write(`${error.message}\n\n${USAGE}\n`)
215
+ return 1
216
+ }
217
+ const { action, flags } = parsed
218
+ if (flags.help) { process.stdout.write(`${USAGE}\n`); return 0 }
219
+ if (flags.version) { process.stdout.write(`${VERSION}\n`); return 0 }
220
+ if (!action) {
221
+ process.stderr.write(`需要一个动作(${ACTIONS.join(' / ')})\n\n${USAGE}\n`)
222
+ return 1
223
+ }
224
+ if (!flags.collection) {
225
+ process.stderr.write('缺少 --collection <集合名>\n')
226
+ return 1
227
+ }
228
+
229
+ try {
230
+ const args = argsOf(action, flags)
231
+ const report = await runAction({}, args)
232
+ process.stdout.write(flags.json
233
+ ? `${renderJson(report)}\n`
234
+ : `${renderText(report, { detail: flags.brief ? 'brief' : 'full' })}\n`)
235
+ return 0
236
+ } catch (error) {
237
+ const info = describeError(error)
238
+ if (info.code === 'invalid_dim' || info.code === 'invalid_name' || info.code === 'empty_batch') {
239
+ // 这些是动作参数错,按参数错误处理
240
+ process.stderr.write(`✗ ${info.message}\n`)
241
+ return 1
242
+ }
243
+ if (flags.json) process.stderr.write(`${JSON.stringify({ ok: false, error: info }, null, 2)}\n`)
244
+ else {
245
+ process.stderr.write(`✗ ${info.message}\n`)
246
+ if (info.hint) process.stderr.write(` → ${info.hint}\n`)
247
+ }
248
+ return exitCodeOf(info.code)
249
+ }
250
+ }
251
+
252
+ /**
253
+ * 是否被直接执行(被 import 时不跑)。
254
+ * @returns {boolean} 是否直接执行
255
+ */
256
+ function isInvokedDirectly() {
257
+ const entry = process.argv[1]
258
+ if (!entry) return false
259
+ const self = fileURLToPath(import.meta.url)
260
+ try {
261
+ return realpathSync(entry) === realpathSync(self)
262
+ } catch {
263
+ return resolve(entry) === self
264
+ }
265
+ }
266
+
267
+ if (isInvokedDirectly()) {
268
+ main(process.argv.slice(2))
269
+ .then((code) => { process.exitCode = code })
270
+ .catch((error) => {
271
+ process.stderr.write(`✗ 未捕获异常:${error instanceof Error ? error.stack ?? error.message : String(error)}\n`)
272
+ process.exitCode = 1
273
+ })
274
+ }
275
+
276
+ export { main, parseArgs }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * 粗筛用的**确定性**聚类(不引 ANN 库:hnswlib/faiss 都要装依赖)。
3
+ *
4
+ * 做法:k-means,k 默认 √n;初始化不用随机数(按 id 排序后等距取点),保证同一份数据
5
+ * 每次算出的质心一致——否则「同一查询两次结果不同」会让人无法复现。
6
+ *
7
+ * 检索时先挑离查询最近的 nprobe 个质心(粗筛),只对它们的成员做真距离(精确重排)。
8
+ * 这就是「两步检索」的全部:**粗筛可能漏掉真近邻**,所以返回值必须带 exact 标记。
9
+ */
10
+
11
+ import { METRICS, scoreOf, unit } from './vector.mjs'
12
+
13
+ /** 默认质心数:√n(上取整)。 */
14
+ export function defaultK(n) {
15
+ if (!Number.isFinite(n) || n <= 0) return 1
16
+ return Math.max(1, Math.ceil(Math.sqrt(n)))
17
+ }
18
+
19
+ /**
20
+ * 建索引(确定性 k-means)。
21
+ * @param {Array<{id:string,vector:number[]}>} records 记录(按 id 排序后传入)
22
+ * @param {{k?:number,metric?:string,iterations?:number}} [options] 选项
23
+ * @returns {{k:number,metric:string,dim:number,centroids:number[][],assignments:Map<string,number>,sizes:number[],iterations:number}} 索引
24
+ */
25
+ export function buildIndex(records, options = {}) {
26
+ const metric = METRICS.includes(options.metric) ? options.metric : 'cosine'
27
+ const n = records.length
28
+ const dim = records[0]?.vector.length ?? 0
29
+ const k = Math.min(Math.max(1, Number.isInteger(options.k) ? options.k : defaultK(n)), Math.max(1, n))
30
+ const iterations = Math.min(Math.max(1, Number.isInteger(options.iterations) ? options.iterations : 8), 32)
31
+
32
+ if (n === 0) {
33
+ return { k: 0, metric, dim, centroids: [], assignments: new Map(), sizes: [], iterations: 0 }
34
+ }
35
+
36
+ // 确定性初始化:按 id 排序后等距取点
37
+ let centroids = []
38
+ for (let index = 0; index < k; index++) {
39
+ const position = Math.min(n - 1, Math.floor(((index + 0.5) * n) / k))
40
+ centroids.push(records[position].vector.slice())
41
+ }
42
+
43
+ const assignments = new Map()
44
+ let currentIterations = 0
45
+ for (let iteration = 0; iteration < iterations; iteration++) {
46
+ currentIterations = iteration + 1
47
+ let changed = false
48
+ for (const record of records) {
49
+ const best = nearestCentroid(record.vector, centroids, metric)
50
+ const previous = assignments.get(record.id)
51
+ if (previous !== best) {
52
+ assignments.set(record.id, best)
53
+ changed = true
54
+ }
55
+ }
56
+ const next = centroids.map(() => new Array(dim).fill(0))
57
+ const counts = new Array(centroids.length).fill(0)
58
+ for (const record of records) {
59
+ const cluster = assignments.get(record.id)
60
+ if (cluster === undefined) continue
61
+ counts[cluster] += 1
62
+ const target = next[cluster]
63
+ for (let index = 0; index < dim; index++) target[index] += record.vector[index]
64
+ }
65
+ for (let cluster = 0; cluster < next.length; cluster++) {
66
+ if (counts[cluster] === 0) continue
67
+ const centroid = next[cluster].map((value) => value / counts[cluster])
68
+ next[cluster] = metric === 'cosine' ? unit(centroid) : centroid
69
+ }
70
+ centroids = next
71
+ if (!changed) break
72
+ }
73
+
74
+ const sizes = new Array(centroids.length).fill(0)
75
+ for (const record of records) {
76
+ const cluster = assignments.get(record.id)
77
+ if (cluster !== undefined) sizes[cluster] += 1
78
+ }
79
+ return { k, metric, dim, centroids, assignments, sizes, iterations: currentIterations }
80
+ }
81
+
82
+ /**
83
+ * 找最近的质心。
84
+ * @param {number[]} vector 向量
85
+ * @param {number[][]} centroids 质心
86
+ * @param {string} metric 度量
87
+ * @returns {number} 质心下标
88
+ */
89
+ function nearestCentroid(vector, centroids, metric) {
90
+ let bestIndex = 0
91
+ let bestScore = -Infinity
92
+ for (let index = 0; index < centroids.length; index++) {
93
+ const score = scoreOf(metric, vector, centroids[index]).better
94
+ if (score > bestScore) {
95
+ bestScore = score
96
+ bestIndex = index
97
+ }
98
+ }
99
+ return bestIndex
100
+ }
101
+
102
+ /**
103
+ * 粗筛:挑 nprobe 个离查询最近的质心。
104
+ * @param {number[]} query 查询向量
105
+ * @param {{centroids:number[][],metric:string}} index 索引
106
+ * @param {{nprobe?:number}} [options] 选项
107
+ * @returns {{probed:number[],nprobe:number,probeAll:boolean}} 粗筛结果
108
+ */
109
+ export function probeClusters(query, index, options = {}) {
110
+ const total = index.centroids.length
111
+ if (total === 0) return { probed: [], nprobe: 0, probeAll: true }
112
+ const requested = Number.isInteger(options.nprobe) && options.nprobe > 0 ? options.nprobe : 1
113
+ const nprobe = Math.min(requested, total)
114
+ const ranked = index.centroids
115
+ .map((centroid, position) => ({ position, score: scoreOf(index.metric, query, centroid).better }))
116
+ .sort((a, b) => b.score - a.score || a.position - b.position)
117
+ .slice(0, nprobe)
118
+ .map((item) => item.position)
119
+ return { probed: ranked, nprobe, probeAll: nprobe >= total }
120
+ }