@jerryjiao/knowflow 0.3.0 → 0.4.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/CHANGELOG.md +26 -2
- package/README.md +28 -5
- package/README.zh-CN.md +28 -5
- package/bin/knowflow.js +108 -9
- package/docs/architecture/system-architecture.md +4 -4
- package/package.json +2 -2
- package/scripts/embedding-config.mjs +62 -0
- package/scripts/vector-store.mjs +42 -22
- package/scripts/vector_store.py +0 -225
- package/scripts/wiki-health.py +0 -285
package/CHANGELOG.md
CHANGED
|
@@ -5,12 +5,37 @@ All notable changes to KnowFlow are documented here. This project follows
|
|
|
5
5
|
|
|
6
6
|
## [Unreleased]
|
|
7
7
|
|
|
8
|
+
## [0.4.0] - 2026-08-27
|
|
9
|
+
|
|
8
10
|
### Added
|
|
9
11
|
|
|
12
|
+
- `knowflow index build [--incremental] [--stats]` and `knowflow index stats [--verbose] [--json]`
|
|
13
|
+
expose the vector index as a first-class CLI surface — no more running
|
|
14
|
+
`scripts/vector-store.mjs` by hand to build or inspect the semantic index.
|
|
15
|
+
- `knowflow ask <question>` answers questions from retrieved Wiki context with
|
|
16
|
+
a citation list, promoted from the vector-store script to the CLI.
|
|
17
|
+
- Pluggable embedding providers via the optional `embedding` section of
|
|
18
|
+
`.knowflowrc`. Built-in presets: `zhipu` (default, unchanged behavior) and
|
|
19
|
+
`openai`; `custom` points at any OpenAI-compatible endpoint
|
|
20
|
+
(`baseUrl`, `model`, `apiKeyEnv`, `dims`, `chatModel` all overridable).
|
|
21
|
+
`knowflow status` now shows the resolved provider and required key env;
|
|
22
|
+
malformed `embedding` sections fail fast naming the offending field.
|
|
10
23
|
- Bilingual documentation website (Astro + Starlight) under `site/`, deployed to
|
|
11
24
|
GitHub Pages via GitHub Actions. English landing at `/`, Chinese at `/zh/`,
|
|
12
25
|
docs under `/en/` and `/zh/` with browser-language auto-switch.
|
|
13
26
|
|
|
27
|
+
### Changed
|
|
28
|
+
|
|
29
|
+
- API-key checks for `query` (and the new index/ask commands) honor the
|
|
30
|
+
configured provider's key env instead of assuming `ZHIPUAI_API_KEY`.
|
|
31
|
+
- Version assertions in the CLI test suite read `package.json` instead of a
|
|
32
|
+
hardcoded string.
|
|
33
|
+
|
|
34
|
+
### Removed
|
|
35
|
+
|
|
36
|
+
- Dead `scripts/wiki-health.py` and `scripts/vector_store.py` superseded by the
|
|
37
|
+
shell/Node implementations the CLI already invokes.
|
|
38
|
+
|
|
14
39
|
## [0.3.0] - 2026-08-18
|
|
15
40
|
|
|
16
41
|
### Added
|
|
@@ -36,8 +61,7 @@ All notable changes to KnowFlow are documented here. This project follows
|
|
|
36
61
|
|
|
37
62
|
### Changed
|
|
38
63
|
|
|
39
|
-
- Both READMEs
|
|
40
|
-
npm package is published, instead of an `npx` command that 404s.
|
|
64
|
+
- Both READMEs use `npm install -g @jerryjiao/knowflow` as the primary install path, with the source checkout as an alternative.
|
|
41
65
|
|
|
42
66
|
## [0.2.1] - 2026-08-10
|
|
43
67
|
|
package/README.md
CHANGED
|
@@ -79,7 +79,10 @@ KnowFlow is inspired by [Andrej Karpathy's LLM Wiki](https://karpathy.github.io/
|
|
|
79
79
|
| `knowflow health` | Check broken links, small files, and isolated pages |
|
|
80
80
|
| `knowflow tags` | Build `tag/<name>.md` hub pages from `[[tag/<name>]]` links |
|
|
81
81
|
| `knowflow status` | Show raw, Wiki, graph, vector-index, and API-key status |
|
|
82
|
-
| `knowflow
|
|
82
|
+
| `knowflow index build [--incremental]` | Build the vector index for semantic search (incremental skips unchanged pages) |
|
|
83
|
+
| `knowflow index stats [--verbose] [--json]` | Report index coverage, cache, and token estimates |
|
|
84
|
+
| `knowflow query <text>` | Query an existing vector index (hybrid vector + keyword ranking) |
|
|
85
|
+
| `knowflow ask <question>` | Answer a question from retrieved Wiki context, with citations |
|
|
83
86
|
|
|
84
87
|
KnowFlow searches upward from the current directory for the nearest `.knowflowrc`, so commands also work inside project subdirectories.
|
|
85
88
|
|
|
@@ -133,19 +136,39 @@ my-wiki/
|
|
|
133
136
|
|
|
134
137
|
`health.excludeOrphanDirs` lists directories whose pages are expected to be unreferenced (daily-sync feeds, inboxes) and should not count as isolated pages. `knowflow tags` regenerates every hub page under `wiki/tag/`, so re-running it after new tagged pages arrive is safe and idempotent.
|
|
135
138
|
|
|
136
|
-
Graph generation, health checks, capture, and status do not require an API key. Semantic search
|
|
139
|
+
Graph generation, health checks, capture, and status do not require an API key. Semantic search (`index build`, `query`, `ask`) needs an embedding-provider key in the project environment or a project-root `.env` file. The default provider is Zhipu:
|
|
137
140
|
|
|
138
141
|
```bash
|
|
139
142
|
ZHIPUAI_API_KEY=your-key-here
|
|
140
143
|
```
|
|
141
144
|
|
|
142
|
-
The
|
|
145
|
+
The embedding provider is pluggable via the optional `embedding` section of `.knowflowrc`. Switch to OpenAI, or point at any OpenAI-compatible endpoint:
|
|
146
|
+
|
|
147
|
+
```json
|
|
148
|
+
{
|
|
149
|
+
"embedding": { "provider": "openai" }
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
```json
|
|
154
|
+
{
|
|
155
|
+
"embedding": {
|
|
156
|
+
"provider": "custom",
|
|
157
|
+
"baseUrl": "https://your-relay.example.com/v1",
|
|
158
|
+
"model": "your-embedding-model",
|
|
159
|
+
"apiKeyEnv": "RELAY_API_KEY",
|
|
160
|
+
"chatModel": "your-chat-model"
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
Presets (`zhipu`, `openai`) fill in endpoint, model, and key-env defaults; every field can be overridden individually. `chatModel` backs `knowflow ask`. Malformed `embedding` sections fail fast with the offending field named.
|
|
143
166
|
|
|
144
167
|
## Current limitations
|
|
145
168
|
|
|
146
169
|
- `ingest` captures raw material; structured Wiki synthesis is a separate human or agent step.
|
|
147
170
|
- URL capture uses Jina Reader. YouTube and some logged-in platforms may also require `yt-dlp` or an authenticated browser workflow.
|
|
148
|
-
- `query`
|
|
171
|
+
- `query` / `ask` need a vector index built with `knowflow index build` and a [Zhipu AI](https://open.bigmodel.cn/) (default) or other embedding-provider API key.
|
|
149
172
|
- `bookmark_sync.sh` depends on the optional third-party `ft` command.
|
|
150
173
|
- Generated graph HTML loads vis-network from a CDN when opened.
|
|
151
174
|
|
|
@@ -154,9 +177,9 @@ The current CLI can query an existing vector index but does not yet expose a sta
|
|
|
154
177
|
- [x] Standalone project initialization and portable paths
|
|
155
178
|
- [x] Raw URL/text capture, Wiki health checks, and interactive graphs
|
|
156
179
|
- [x] CLI tests and CI across supported Node.js versions
|
|
180
|
+
- [x] `knowflow index` and pluggable embedding providers
|
|
157
181
|
- [ ] A first-class agent workflow from raw capture to reviewed Wiki pages
|
|
158
182
|
- [ ] Incremental ingestion and duplicate-source detection
|
|
159
|
-
- [ ] `knowflow index` and pluggable embedding providers
|
|
160
183
|
- [ ] Extractor/plugin system and a local Web UI
|
|
161
184
|
|
|
162
185
|
Ideas and focused pull requests are welcome. Start with the [contribution guide](CONTRIBUTING.md), run the [text-to-graph example](examples/quickstart.md), or propose a use case in [GitHub Issues](https://github.com/jerryjiao/knowflow/issues).
|
package/README.zh-CN.md
CHANGED
|
@@ -78,7 +78,10 @@ KnowFlow 的灵感来自 [Andrej Karpathy 的 LLM Wiki](https://karpathy.github.
|
|
|
78
78
|
| `knowflow health` | 检查断链、小文件和孤立页面 |
|
|
79
79
|
| `knowflow tags` | 根据 `[[tag/<名称>]]` 链接生成 `tag/<名称>.md` 聚合页 |
|
|
80
80
|
| `knowflow status` | 显示原始素材、Wiki、图谱、向量索引和 API Key 状态 |
|
|
81
|
-
| `knowflow
|
|
81
|
+
| `knowflow index build [--incremental]` | 构建语义检索的向量索引(`--incremental` 跳过未变化页面) |
|
|
82
|
+
| `knowflow index stats [--verbose] [--json]` | 查看索引覆盖率、缓存与 token 估算 |
|
|
83
|
+
| `knowflow query <text>` | 查询已有向量索引(向量 + 关键词混合排序) |
|
|
84
|
+
| `knowflow ask <question>` | 基于检索到的 Wiki 上下文回答问题,附引用来源 |
|
|
82
85
|
|
|
83
86
|
KnowFlow 会从当前目录向上查找最近的 `.knowflowrc`,因此也可以在项目子目录中运行命令。
|
|
84
87
|
|
|
@@ -132,19 +135,39 @@ my-wiki/
|
|
|
132
135
|
|
|
133
136
|
`health.excludeOrphanDirs` 列出预期不会被引用的目录(每日同步流水页、收件箱等),其中的页面不计入孤立页面。`knowflow tags` 每次都会全量重建 `wiki/tag/` 下的聚合页,新增带标签页面后重跑即可,幂等安全。
|
|
134
137
|
|
|
135
|
-
图谱生成、健康检查、内容采集和状态查看均不需要 API Key
|
|
138
|
+
图谱生成、健康检查、内容采集和状态查看均不需要 API Key。语义检索(`index build`、`query`、`ask`)需要在项目环境变量或项目根目录 `.env` 中配置 embedding 提供商的 Key,默认提供商为智谱:
|
|
136
139
|
|
|
137
140
|
```bash
|
|
138
141
|
ZHIPUAI_API_KEY=your-key-here
|
|
139
142
|
```
|
|
140
143
|
|
|
141
|
-
|
|
144
|
+
Embedding 提供商可通过 `.knowflowrc` 的可选 `embedding` 节插拔。切换到 OpenAI,或指向任意 OpenAI 兼容端点:
|
|
145
|
+
|
|
146
|
+
```json
|
|
147
|
+
{
|
|
148
|
+
"embedding": { "provider": "openai" }
|
|
149
|
+
}
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"embedding": {
|
|
155
|
+
"provider": "custom",
|
|
156
|
+
"baseUrl": "https://your-relay.example.com/v1",
|
|
157
|
+
"model": "your-embedding-model",
|
|
158
|
+
"apiKeyEnv": "RELAY_API_KEY",
|
|
159
|
+
"chatModel": "your-chat-model"
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
内置预设(`zhipu`、`openai`)会填好端点、模型与 Key 环境变量名,所有字段均可单独覆盖;`chatModel` 供 `knowflow ask` 使用。`embedding` 节写错时会立刻报错并指出问题字段。
|
|
142
165
|
|
|
143
166
|
## 当前限制
|
|
144
167
|
|
|
145
168
|
- `ingest` 只采集原始素材;将其提炼为结构化 Wiki 是独立的人工或 Agent 步骤。
|
|
146
169
|
- URL 采集依赖 Jina Reader;YouTube 和部分需要登录的平台可能还需要 `yt-dlp` 或带登录态的浏览器工作流。
|
|
147
|
-
- `query`
|
|
170
|
+
- `query` / `ask` 需要先运行 `knowflow index build` 构建向量索引,并配置[智谱 AI](https://open.bigmodel.cn/)(默认)或其他 embedding 提供商的 API Key。
|
|
148
171
|
- `bookmark_sync.sh` 依赖可选的第三方 `ft` 命令。
|
|
149
172
|
- 生成的图谱 HTML 打开时会从 CDN 加载 vis-network。
|
|
150
173
|
|
|
@@ -153,9 +176,9 @@ ZHIPUAI_API_KEY=your-key-here
|
|
|
153
176
|
- [x] 独立项目初始化和可移植路径
|
|
154
177
|
- [x] URL / 文本原始采集、Wiki 健康检查和交互式图谱
|
|
155
178
|
- [x] CLI 自动化测试与多 Node.js 版本 CI
|
|
179
|
+
- [x] `knowflow index` 与可插拔嵌入提供商
|
|
156
180
|
- [ ] 从原始素材到已审核 Wiki 页面的一等 Agent 工作流
|
|
157
181
|
- [ ] 增量采集和重复来源检测
|
|
158
|
-
- [ ] `knowflow index` 与可插拔嵌入提供商
|
|
159
182
|
- [ ] 提取器 / 插件系统和本地 Web UI
|
|
160
183
|
|
|
161
184
|
欢迎提交想法和范围清晰的 PR。你可以先阅读[贡献指南](CONTRIBUTING.md)、运行[从文本到图谱的示例](examples/quickstart.md),或在 [GitHub Issues](https://github.com/jerryjiao/knowflow/issues) 中提出使用场景。
|
package/bin/knowflow.js
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
} from 'node:fs';
|
|
15
15
|
import { dirname, join, parse, resolve } from 'node:path';
|
|
16
16
|
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { resolveEmbeddingConfig } from '../scripts/embedding-config.mjs';
|
|
17
18
|
|
|
18
19
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
19
20
|
const SCRIPTS = join(PACKAGE_ROOT, 'scripts');
|
|
@@ -51,6 +52,7 @@ function loadProject(start = process.cwd()) {
|
|
|
51
52
|
wiki: { ...DEFAULT_CONFIG.wiki, ...userConfig.wiki },
|
|
52
53
|
graph: { ...DEFAULT_CONFIG.graph, ...userConfig.graph },
|
|
53
54
|
health: { ...DEFAULT_CONFIG.health, ...userConfig.health },
|
|
55
|
+
embedding: userConfig.embedding ?? {},
|
|
54
56
|
};
|
|
55
57
|
const pathValue = (value, label) => {
|
|
56
58
|
if (typeof value !== 'string' || value.trim() === '') throw new Error(`${label} 必须是非空路径`);
|
|
@@ -120,17 +122,38 @@ function countLines(dir) {
|
|
|
120
122
|
return lines;
|
|
121
123
|
}
|
|
122
124
|
|
|
123
|
-
function hasApiKey(project) {
|
|
124
|
-
if (process.env
|
|
125
|
+
function hasApiKey(project, apiKeyEnv = 'ZHIPUAI_API_KEY') {
|
|
126
|
+
if (process.env[apiKeyEnv]) return true;
|
|
125
127
|
try {
|
|
126
128
|
return readFileSync(join(project.root, '.env'), 'utf8')
|
|
127
129
|
.split(/\r?\n/)
|
|
128
|
-
.some(line => /^\s
|
|
130
|
+
.some(line => line.startsWith(apiKeyEnv) && /^\s*=\s*[^$\s]/.test(line.slice(apiKeyEnv.length)));
|
|
129
131
|
} catch {
|
|
130
132
|
return false;
|
|
131
133
|
}
|
|
132
134
|
}
|
|
133
135
|
|
|
136
|
+
// Resolves the embedding provider from project config; returns null (and sets
|
|
137
|
+
// the exit code) when the user's `embedding` section is malformed.
|
|
138
|
+
function embeddingFor(project) {
|
|
139
|
+
try {
|
|
140
|
+
return resolveEmbeddingConfig(project.config.embedding);
|
|
141
|
+
} catch (error) {
|
|
142
|
+
console.error(chalk.red('❌ embedding 配置错误:'), error.message);
|
|
143
|
+
process.exitCode = 1;
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function requireApiKey(project, embedding, action) {
|
|
149
|
+
if (!hasApiKey(project, embedding.apiKeyEnv)) {
|
|
150
|
+
console.error(chalk.yellow(`⚠️ 未检测到 ${embedding.apiKeyEnv}(embedding.provider=${embedding.provider}),请在项目 .env 或环境变量中配置后再${action}。`));
|
|
151
|
+
process.exitCode = 1;
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
134
157
|
function initialise(directory) {
|
|
135
158
|
const root = resolve(directory);
|
|
136
159
|
mkdirSync(root, { recursive: true });
|
|
@@ -221,11 +244,9 @@ program
|
|
|
221
244
|
}, 5)
|
|
222
245
|
.action((text, opts) => {
|
|
223
246
|
const project = loadProject();
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
return;
|
|
228
|
-
}
|
|
247
|
+
const embedding = embeddingFor(project);
|
|
248
|
+
if (!embedding) return;
|
|
249
|
+
if (!requireApiKey(project, embedding, '检索')) return;
|
|
229
250
|
try {
|
|
230
251
|
run(process.execPath, [join(SCRIPTS, 'vector-store.mjs'), 'query', text, '--top', String(opts.top)], {
|
|
231
252
|
cwd: project.root,
|
|
@@ -306,6 +327,79 @@ program
|
|
|
306
327
|
}
|
|
307
328
|
});
|
|
308
329
|
|
|
330
|
+
const index = program
|
|
331
|
+
.command('index')
|
|
332
|
+
.description('向量索引管理(语义检索的数据底座,provider 可在 .knowflowrc 的 embedding 节配置)')
|
|
333
|
+
.action(() => index.help());
|
|
334
|
+
|
|
335
|
+
index
|
|
336
|
+
.command('build')
|
|
337
|
+
.description('构建向量索引(默认全量;--incremental 只处理新增/修改文件)')
|
|
338
|
+
.option('-i, --incremental', '增量构建,跳过未变化文件')
|
|
339
|
+
.option('--stats', '构建完成后显示索引统计')
|
|
340
|
+
.action(opts => {
|
|
341
|
+
const project = loadProject();
|
|
342
|
+
const embedding = embeddingFor(project);
|
|
343
|
+
if (!embedding) return;
|
|
344
|
+
if (!requireApiKey(project, embedding, '构建索引')) return;
|
|
345
|
+
console.log(chalk.blue('🧱 构建向量索引...'));
|
|
346
|
+
try {
|
|
347
|
+
run(process.execPath, [
|
|
348
|
+
join(SCRIPTS, 'vector-store.mjs'), 'build',
|
|
349
|
+
...(opts.incremental ? ['--incremental'] : []),
|
|
350
|
+
...(opts.stats ? ['--stats'] : []),
|
|
351
|
+
], {
|
|
352
|
+
cwd: project.root,
|
|
353
|
+
env: projectEnv(project),
|
|
354
|
+
});
|
|
355
|
+
console.log(chalk.green('✅ 索引构建完成!'));
|
|
356
|
+
} catch (error) {
|
|
357
|
+
console.error(chalk.red('❌ 索引构建失败:'), error.message);
|
|
358
|
+
process.exitCode = 1;
|
|
359
|
+
}
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
index
|
|
363
|
+
.command('stats')
|
|
364
|
+
.description('查看索引统计(页数 / 向量覆盖率 / 缓存 / token 估算)')
|
|
365
|
+
.option('-v, --verbose', '显示 per-file 明细表')
|
|
366
|
+
.option('--json', 'JSON 结构化输出')
|
|
367
|
+
.action(opts => {
|
|
368
|
+
const project = loadProject();
|
|
369
|
+
try {
|
|
370
|
+
run(process.execPath, [
|
|
371
|
+
join(SCRIPTS, 'vector-store.mjs'), 'stats',
|
|
372
|
+
...(opts.verbose ? ['--verbose'] : []),
|
|
373
|
+
...(opts.json ? ['--json'] : []),
|
|
374
|
+
], {
|
|
375
|
+
cwd: project.root,
|
|
376
|
+
env: projectEnv(project),
|
|
377
|
+
});
|
|
378
|
+
} catch (error) {
|
|
379
|
+
console.error(chalk.red('❌ 统计失败:'), error.message);
|
|
380
|
+
process.exitCode = 1;
|
|
381
|
+
}
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
program
|
|
385
|
+
.command('ask <question>')
|
|
386
|
+
.description('基于知识库检索上下文回答问题(附引用来源,需已构建索引)')
|
|
387
|
+
.action(question => {
|
|
388
|
+
const project = loadProject();
|
|
389
|
+
const embedding = embeddingFor(project);
|
|
390
|
+
if (!embedding) return;
|
|
391
|
+
if (!requireApiKey(project, embedding, '提问')) return;
|
|
392
|
+
try {
|
|
393
|
+
run(process.execPath, [join(SCRIPTS, 'vector-store.mjs'), 'ask', question], {
|
|
394
|
+
cwd: project.root,
|
|
395
|
+
env: projectEnv(project),
|
|
396
|
+
});
|
|
397
|
+
} catch (error) {
|
|
398
|
+
console.error(chalk.red('❌ 问答失败:'), error.message);
|
|
399
|
+
process.exitCode = 1;
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
309
403
|
program
|
|
310
404
|
.command('health')
|
|
311
405
|
.description('Wiki 健康检查(断链、空文件、孤立页面)')
|
|
@@ -350,13 +444,18 @@ program
|
|
|
350
444
|
vectorStatus = chalk.green(`✅ ${pages.length} 页,${embedded} 页已向量化`);
|
|
351
445
|
} catch { vectorStatus = chalk.yellow('⚠️ 索引损坏'); }
|
|
352
446
|
}
|
|
447
|
+
const embedding = embeddingFor(project);
|
|
448
|
+
const keyStatus = embedding
|
|
449
|
+
? (hasApiKey(project, embedding.apiKeyEnv) ? `✅ 已配置 (${embedding.apiKeyEnv})` : `❌ 未配置(需要 ${embedding.apiKeyEnv})`)
|
|
450
|
+
: '❌ embedding 配置错误';
|
|
353
451
|
console.log(`\n${chalk.bold.cyan(' 📊 KnowFlow 状态概览')}`);
|
|
354
452
|
console.log(` Wiki 文章数: ${articleCount} 篇`);
|
|
355
453
|
console.log(` 总行数: ${lineCount} 行`);
|
|
356
454
|
console.log(` 原始素材: ${rawCount} 个`);
|
|
357
455
|
console.log(` 向量索引: ${vectorStatus}`);
|
|
456
|
+
console.log(` Embedding: ${embedding ? `${embedding.provider} · ${embedding.model}` : '-'}`);
|
|
358
457
|
console.log(` 图谱: ${nodes} 个节点 / ${edges} 条关系`);
|
|
359
|
-
console.log(` API Key: ${
|
|
458
|
+
console.log(` API Key: ${keyStatus}`);
|
|
360
459
|
console.log(chalk.dim(` 项目目录: ${project.root}`));
|
|
361
460
|
console.log(chalk.dim(` Wiki 目录: ${project.wikiDir}\n`));
|
|
362
461
|
});
|
|
@@ -32,8 +32,8 @@
|
|
|
32
32
|
│ Step 4: Graph │ │ Step 5: Vector │
|
|
33
33
|
│ 知识图谱构建 │ │ 向量索引构建 │
|
|
34
34
|
│ scripts/graph_ │ │ scripts/vector-store.mjs │
|
|
35
|
-
│ builder.py │ │
|
|
36
|
-
│ → graph.json │ │ → vector-
|
|
35
|
+
│ builder.py │ │ (provider 可插拔,默认智谱) │
|
|
36
|
+
│ → graph.json │ │ → .vector-index.json │
|
|
37
37
|
│ → graph.html │ │ │
|
|
38
38
|
└─────────────────────┘ └─────────────────────────────────┘
|
|
39
39
|
```
|
|
@@ -50,8 +50,8 @@ knowflow/
|
|
|
50
50
|
│ ├── enrich-wiki.js # Wiki 后处理(链接补全、摘要生成)
|
|
51
51
|
│ ├── graph_builder.py # 从 Wiki 页面构建知识图谱
|
|
52
52
|
│ ├── graph_relation_labeler.py # LLM 分析关系类型
|
|
53
|
-
│ ├── vector-store.mjs # 向量索引(Embedding +
|
|
54
|
-
│ ├──
|
|
53
|
+
│ ├── vector-store.mjs # 向量索引(Embedding + 存储,provider 可插拔)
|
|
54
|
+
│ ├── embedding-config.mjs # embedding 提供商解析(zhipu/openai/custom)
|
|
55
55
|
│ ├── pipeline.sh # 5 步全自动化管线
|
|
56
56
|
│ ├── bookmark_sync.sh # X/Twitter 书签同步
|
|
57
57
|
│ ├── wechat_sync.sh # 微信公众号文章同步
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jerryjiao/knowflow",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Agent-native Markdown workspace for raw capture, linked wikis, knowledge graphs, and semantic search",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"scripts": {
|
|
24
24
|
"knowflow": "node bin/knowflow.js",
|
|
25
25
|
"test": "node --test",
|
|
26
|
-
"check": "node --check bin/knowflow.js && node --check scripts/batch-ingest.cjs && node --check scripts/enrich-wiki.js && node --check scripts/vector-store.mjs"
|
|
26
|
+
"check": "node --check bin/knowflow.js && node --check scripts/batch-ingest.cjs && node --check scripts/enrich-wiki.js && node --check scripts/vector-store.mjs && node --check scripts/embedding-config.mjs"
|
|
27
27
|
},
|
|
28
28
|
"repository": {
|
|
29
29
|
"type": "git",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pluggable embedding provider configuration for KnowFlow.
|
|
3
|
+
*
|
|
4
|
+
* Reads the optional `embedding` section of .knowflowrc and resolves it into
|
|
5
|
+
* a concrete endpoint description. Zero side effects — safe to import from
|
|
6
|
+
* tests and the CLI.
|
|
7
|
+
*
|
|
8
|
+
* Resolved shape: { provider, baseUrl, model, dims, apiKeyEnv, chatModel }
|
|
9
|
+
* - dims: number sent as `dimensions`, or null to use the model default
|
|
10
|
+
* - chatModel: model used by `knowflow ask`, or null when not applicable
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const EMBEDDING_PRESETS = {
|
|
14
|
+
zhipu: {
|
|
15
|
+
baseUrl: 'https://open.bigmodel.cn/api/paas/v4',
|
|
16
|
+
model: 'embedding-3',
|
|
17
|
+
dims: 1024,
|
|
18
|
+
apiKeyEnv: 'ZHIPUAI_API_KEY',
|
|
19
|
+
chatModel: 'glm-4-flash',
|
|
20
|
+
},
|
|
21
|
+
openai: {
|
|
22
|
+
baseUrl: 'https://api.openai.com/v1',
|
|
23
|
+
model: 'text-embedding-3-small',
|
|
24
|
+
dims: null,
|
|
25
|
+
apiKeyEnv: 'OPENAI_API_KEY',
|
|
26
|
+
chatModel: 'gpt-4o-mini',
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const DEFAULT_PROVIDER = 'zhipu';
|
|
31
|
+
const CUSTOM_API_KEY_ENV = 'EMBEDDING_API_KEY';
|
|
32
|
+
|
|
33
|
+
export function resolveEmbeddingConfig(userConfig = {}) {
|
|
34
|
+
const provider = userConfig.provider ?? DEFAULT_PROVIDER;
|
|
35
|
+
|
|
36
|
+
if (provider === 'custom') {
|
|
37
|
+
if (!userConfig.baseUrl) throw new Error("embedding.provider 'custom' requires embedding.baseUrl");
|
|
38
|
+
if (!userConfig.model) throw new Error("embedding.provider 'custom' requires embedding.model");
|
|
39
|
+
} else if (!EMBEDDING_PRESETS[provider]) {
|
|
40
|
+
const known = [...Object.keys(EMBEDDING_PRESETS), 'custom'].join(', ');
|
|
41
|
+
throw new Error(`Unknown embedding provider "${provider}". Available: ${known}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const preset = EMBEDDING_PRESETS[provider] ?? {};
|
|
45
|
+
const dims = userConfig.dims !== undefined ? userConfig.dims : preset.dims ?? null;
|
|
46
|
+
if (dims !== null && (!Number.isInteger(dims) || dims <= 0)) {
|
|
47
|
+
throw new Error('embedding.dims must be a positive integer or null');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
provider,
|
|
52
|
+
baseUrl: stripTrailingSlash(userConfig.baseUrl ?? preset.baseUrl),
|
|
53
|
+
model: userConfig.model ?? preset.model,
|
|
54
|
+
dims,
|
|
55
|
+
apiKeyEnv: userConfig.apiKeyEnv ?? preset.apiKeyEnv ?? CUSTOM_API_KEY_ENV,
|
|
56
|
+
chatModel: userConfig.chatModel ?? preset.chatModel ?? null,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function stripTrailingSlash(url) {
|
|
61
|
+
return typeof url === 'string' ? url.replace(/\/+$/, '') : url;
|
|
62
|
+
}
|
package/scripts/vector-store.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* KnowFlow Vector Store v2.
|
|
4
|
-
*
|
|
3
|
+
* KnowFlow Vector Store v2.2
|
|
4
|
+
* 向量检索引擎(embedding provider 可插拔,默认智谱 embedding-3;.knowflowrc 的 embedding 节可切换 openai / 自定义 OpenAI 兼容端点)
|
|
5
5
|
*
|
|
6
6
|
* 用法:
|
|
7
7
|
* node vector-store.mjs build 全量构建索引
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'fs';
|
|
22
22
|
import { dirname, join, relative, extname, resolve } from 'path';
|
|
23
23
|
import { fileURLToPath } from 'url';
|
|
24
|
+
import { resolveEmbeddingConfig } from './embedding-config.mjs';
|
|
24
25
|
|
|
25
26
|
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
27
|
const PROJECT_ROOT = resolve(process.env.KNOWFLOW_ROOT || PACKAGE_ROOT);
|
|
@@ -28,11 +29,27 @@ const WIKI_DIR = resolve(process.env.KNOWFLOW_WIKI_DIR || join(PROJECT_ROOT, 'wi
|
|
|
28
29
|
const INDEX_FILE = join(WIKI_DIR, '.vector-index.json');
|
|
29
30
|
const CACHE_FILE = join(WIKI_DIR, '.embed-cache.json');
|
|
30
31
|
const MANIFEST_FILE = join(WIKI_DIR, '.vector-manifest.json'); // 增量用:记录文件 mtime
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
32
|
+
|
|
33
|
+
// Pluggable embedding provider: `embedding` section of .knowflowrc, resolved
|
|
34
|
+
// against built-in presets (zhipu default, openai) or a custom OpenAI-compatible endpoint.
|
|
35
|
+
function loadUserEmbeddingConfig() {
|
|
36
|
+
const rcPath = join(PROJECT_ROOT, '.knowflowrc');
|
|
37
|
+
if (!existsSync(rcPath)) return {};
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(readFileSync(rcPath, 'utf8')).embedding ?? {};
|
|
40
|
+
} catch (error) {
|
|
41
|
+
console.error(`⚠️ 无法解析 ${rcPath} 的 embedding 配置(${error.message}),使用默认 provider zhipu`);
|
|
42
|
+
return {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
let EMBEDDING;
|
|
47
|
+
try {
|
|
48
|
+
EMBEDDING = resolveEmbeddingConfig(loadUserEmbeddingConfig());
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.error(`❌ ${error.message}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
}
|
|
36
53
|
const BATCH_SIZE = 20;
|
|
37
54
|
const MIN_SIZE = 300; // 最小文件大小阈值
|
|
38
55
|
const QUERY_CACHE_TTL = 60_000; // 查询缓存 TTL: 60 秒
|
|
@@ -58,14 +75,14 @@ if (existsSync(envPath)) {
|
|
|
58
75
|
if (k?.trim() && !process.env[k.trim()]) process.env[k.trim()] = v.join('=').trim();
|
|
59
76
|
}
|
|
60
77
|
}
|
|
61
|
-
const API_KEY = process.env.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
const API_KEY = process.env[EMBEDDING.apiKeyEnv] || '';
|
|
79
|
+
|
|
80
|
+
// LLM 命令(build/query/ask)缺 key 时立即报错退出;纯本地命令(stats)不受影响。
|
|
81
|
+
// 通过 knowflow CLI 跑时,门面层已按 provider 前置拦截,这里是直跑脚本的兜底。
|
|
82
|
+
if (!API_KEY && ['build', 'query', 'search', 'ask'].includes(process.argv[2])) {
|
|
83
|
+
console.error(`❌ Error: ${EMBEDDING.apiKeyEnv} environment variable is required (embedding.provider=${EMBEDDING.provider})`);
|
|
84
|
+
console.error(` export ${EMBEDDING.apiKeyEnv}="your-key-here"`);
|
|
85
|
+
process.exit(1);
|
|
69
86
|
}
|
|
70
87
|
|
|
71
88
|
// ─── 工具函数 ───
|
|
@@ -120,13 +137,15 @@ function formatDuration(ms) {
|
|
|
120
137
|
// ─── Embedding API ───
|
|
121
138
|
|
|
122
139
|
async function getEmbedding(texts) {
|
|
123
|
-
const
|
|
140
|
+
const body = { model: EMBEDDING.model, input: texts };
|
|
141
|
+
if (EMBEDDING.dims) body.dimensions = EMBEDDING.dims;
|
|
142
|
+
const res = await fetch(`${EMBEDDING.baseUrl}/embeddings`, {
|
|
124
143
|
method: 'POST',
|
|
125
144
|
headers: {
|
|
126
145
|
'Content-Type': 'application/json',
|
|
127
146
|
'Authorization': `Bearer ${API_KEY}`
|
|
128
147
|
},
|
|
129
|
-
body: JSON.stringify(
|
|
148
|
+
body: JSON.stringify(body)
|
|
130
149
|
});
|
|
131
150
|
if (!res.ok) {
|
|
132
151
|
const err = await res.text();
|
|
@@ -150,7 +169,7 @@ function saveManifest(manifest) {
|
|
|
150
169
|
// ─── Build 索引 ───
|
|
151
170
|
|
|
152
171
|
async function buildIndex(incremental = false) {
|
|
153
|
-
if (!API_KEY) throw new Error(
|
|
172
|
+
if (!API_KEY) throw new Error(`需要 ${EMBEDDING.apiKeyEnv} 环境变量`);
|
|
154
173
|
|
|
155
174
|
log('📂 扫描 wiki 目录...');
|
|
156
175
|
const files = getAllMdFiles(WIKI_DIR);
|
|
@@ -254,7 +273,7 @@ function keywordMatch(queryTerms, content, title) {
|
|
|
254
273
|
// ─── Query 查询(混合检索:向量 + 关键词) ───
|
|
255
274
|
|
|
256
275
|
async function queryIndex(queryText, topK = 10) {
|
|
257
|
-
if (!API_KEY) throw new Error(
|
|
276
|
+
if (!API_KEY) throw new Error(`需要 ${EMBEDDING.apiKeyEnv} 环境变量,请先在项目 .env 或环境变量中配置`);
|
|
258
277
|
if (!existsSync(INDEX_FILE)) { log('❌ 向量索引不存在,请先运行 knowflow ingest 添加内容后自动构建索引'); return []; }
|
|
259
278
|
|
|
260
279
|
const raw = JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
|
|
@@ -550,7 +569,7 @@ function showStats(verbose = false, json = false) {
|
|
|
550
569
|
// ─── Ask (LLM Q&A) ───
|
|
551
570
|
|
|
552
571
|
async function askQuestion(queryText) {
|
|
553
|
-
if (!API_KEY) throw new Error(
|
|
572
|
+
if (!API_KEY) throw new Error(`需要 ${EMBEDDING.apiKeyEnv} 环境变量`);
|
|
554
573
|
if (!existsSync(INDEX_FILE)) { log('❌ 索引不存在,先运行 build'); return; }
|
|
555
574
|
|
|
556
575
|
// Step 1 & 2: Find top 5 relevant passages using existing queryIndex logic
|
|
@@ -589,14 +608,15 @@ ${contextText}`;
|
|
|
589
608
|
|
|
590
609
|
log('🤖 生成回答...');
|
|
591
610
|
|
|
592
|
-
|
|
611
|
+
if (!EMBEDDING.chatModel) throw new Error(`当前 embedding provider (${EMBEDDING.provider}) 未配置 chatModel,knowflow ask 不可用`);
|
|
612
|
+
const res = await fetch(`${EMBEDDING.baseUrl}/chat/completions`, {
|
|
593
613
|
method: 'POST',
|
|
594
614
|
headers: {
|
|
595
615
|
'Content-Type': 'application/json',
|
|
596
616
|
'Authorization': `Bearer ${API_KEY}`
|
|
597
617
|
},
|
|
598
618
|
body: JSON.stringify({
|
|
599
|
-
model:
|
|
619
|
+
model: EMBEDDING.chatModel,
|
|
600
620
|
messages: [
|
|
601
621
|
{ role: 'system', content: systemPrompt },
|
|
602
622
|
{ role: 'user', content: queryText }
|
package/scripts/vector_store.py
DELETED
|
@@ -1,225 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
KnowFlow Vector Store — 基于智谱 embedding-3 的语义检索
|
|
4
|
-
用法:
|
|
5
|
-
python3 vector_store.py build # 对所有 wiki 页面建索引
|
|
6
|
-
python3 vector_store.py query "AI Agent" # 语义查询
|
|
7
|
-
python3 vector_store.py stats # 查看索引状态
|
|
8
|
-
"""
|
|
9
|
-
import os, sys, json, glob, hashlib
|
|
10
|
-
from pathlib import Path
|
|
11
|
-
|
|
12
|
-
PROJECT_ROOT = Path(os.environ.get("KNOWFLOW_ROOT", Path(__file__).resolve().parent.parent))
|
|
13
|
-
WIKI_DIR = Path(os.environ.get("KNOWFLOW_WIKI_DIR", PROJECT_ROOT / "wiki")).resolve()
|
|
14
|
-
INDEX_FILE = WIKI_DIR / ".vector-index.json"
|
|
15
|
-
EMBED_CACHE = WIKI_DIR / ".embed-cache.json"
|
|
16
|
-
|
|
17
|
-
# ====== Embedding ======
|
|
18
|
-
def get_embedding(text: str, api_key: str = None) -> list:
|
|
19
|
-
"""调用智谱 embedding-3 API 获取向量"""
|
|
20
|
-
import urllib.request, urllib.error
|
|
21
|
-
|
|
22
|
-
key = api_key or os.environ.get("ZHIPUAI_API_KEY", "")
|
|
23
|
-
if not key:
|
|
24
|
-
# 尝试从 openclaw 配置读取
|
|
25
|
-
cfg_path = Path.home() / ".openclaw" / "config.yaml"
|
|
26
|
-
if cfg_path.exists():
|
|
27
|
-
import yaml
|
|
28
|
-
try:
|
|
29
|
-
cfg = yaml.safe_load(cfg_path.read_text())
|
|
30
|
-
key = cfg.get("zhipu", {}).get("apiKey", "") or cfg.get("providers", {}).get("zhipu", {}).get("apiKey", "")
|
|
31
|
-
except: pass
|
|
32
|
-
|
|
33
|
-
if not key:
|
|
34
|
-
print("⚠️ 未找到 ZHIPUAI_API_KEY,请设置环境变量或在 .env 中配置")
|
|
35
|
-
return None
|
|
36
|
-
|
|
37
|
-
url = "https://open.bigmodel.cn/api/paas/v4/embeddings"
|
|
38
|
-
payload = json.dumps({
|
|
39
|
-
"model": "embedding-3",
|
|
40
|
-
"input": text[:8000], # 截断过长文本
|
|
41
|
-
"dimensions": 1024
|
|
42
|
-
}).encode()
|
|
43
|
-
|
|
44
|
-
req = urllib.request.Request(url, data=payload, headers={
|
|
45
|
-
"Content-Type": "application/json",
|
|
46
|
-
"Authorization": f"Bearer {key}"
|
|
47
|
-
})
|
|
48
|
-
|
|
49
|
-
try:
|
|
50
|
-
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
51
|
-
data = json.loads(resp.read())
|
|
52
|
-
return data["data"][0]["embedding"]
|
|
53
|
-
except Exception as e:
|
|
54
|
-
print(f"⚠️ Embedding API 错误: {e}")
|
|
55
|
-
return None
|
|
56
|
-
|
|
57
|
-
# ====== 文件扫描 ======
|
|
58
|
-
def scan_wiki_files() -> list:
|
|
59
|
-
"""扫描所有 wiki markdown 文件,返回 (path, content, meta) 列表"""
|
|
60
|
-
files = []
|
|
61
|
-
for md_file in sorted(WIKI_DIR.rglob("*.md")):
|
|
62
|
-
# 跳过隐藏文件和特殊文件
|
|
63
|
-
if any(p.startswith(".") for p in md_file.parts):
|
|
64
|
-
continue
|
|
65
|
-
|
|
66
|
-
text = md_file.read_text(encoding="utf-8", errors="ignore")
|
|
67
|
-
if len(text.strip()) < 50: # 跳过太短的文件
|
|
68
|
-
continue
|
|
69
|
-
|
|
70
|
-
# 提取 frontmatter 之后的正文用于 embedding
|
|
71
|
-
body = text
|
|
72
|
-
if text.startswith("---"):
|
|
73
|
-
parts = text.split("---", 2)
|
|
74
|
-
if len(parts) >= 3:
|
|
75
|
-
body = parts[2].strip()
|
|
76
|
-
|
|
77
|
-
# 截取前 2000 字符作为 embedding 内容(标题+摘要+关键内容)
|
|
78
|
-
embed_text = body[:2000]
|
|
79
|
-
|
|
80
|
-
rel_path = str(md_file.relative_to(WIKI_DIR))
|
|
81
|
-
files.append({
|
|
82
|
-
"path": rel_path,
|
|
83
|
-
"full_path": str(md_file),
|
|
84
|
-
"title": md_file.stem,
|
|
85
|
-
"body": body,
|
|
86
|
-
"embed_text": embed_text,
|
|
87
|
-
"size": len(text),
|
|
88
|
-
"category": rel_path.split("/")[0] if "/" in rel_path else "root"
|
|
89
|
-
})
|
|
90
|
-
|
|
91
|
-
return files
|
|
92
|
-
|
|
93
|
-
# ====== Build Index ======
|
|
94
|
-
def build_index(force=False):
|
|
95
|
-
"""构建/更新向量索引"""
|
|
96
|
-
print(f"📚 扫描 Wiki 目录: {WIKI_DIR}")
|
|
97
|
-
files = scan_wiki_files()
|
|
98
|
-
print(f"📊 找到 {len(files)} 个页面")
|
|
99
|
-
|
|
100
|
-
# 加载已有缓存
|
|
101
|
-
cache = {}
|
|
102
|
-
if EMBED_CACHE.exists() and not force:
|
|
103
|
-
cache = json.loads(EMBED_CACHE.read_text())
|
|
104
|
-
|
|
105
|
-
index = []
|
|
106
|
-
new_count = 0
|
|
107
|
-
cache_count = 0
|
|
108
|
-
|
|
109
|
-
for i, f in enumerate(files):
|
|
110
|
-
# 用文件路径+大小+修改时间做 hash 判断是否需要重新 embedding
|
|
111
|
-
file_hash = hashlib.md5(f"{f['path']}:{f['size']}".encode()).hexdigest()
|
|
112
|
-
|
|
113
|
-
if file_hash in cache and not force:
|
|
114
|
-
index.append({**f, "embedding": cache[file_hash], "_hash": file_hash})
|
|
115
|
-
cache_count += 1
|
|
116
|
-
else:
|
|
117
|
-
print(f" [{i+1}/{len(files)}] Embedding: {f['path']} ...", end=" ", flush=True)
|
|
118
|
-
emb = get_embedding(f["embed_text"])
|
|
119
|
-
if emb:
|
|
120
|
-
f["embedding"] = emb
|
|
121
|
-
f["_hash"] = file_hash
|
|
122
|
-
index.append(f)
|
|
123
|
-
cache[file_hash] = emb
|
|
124
|
-
new_count += 1
|
|
125
|
-
print("✅")
|
|
126
|
-
else:
|
|
127
|
-
print("❌ 跳过")
|
|
128
|
-
|
|
129
|
-
# 每 10 个保存一次缓存
|
|
130
|
-
if (i + 1) % 20 == 0:
|
|
131
|
-
EMBED_CACHE.write_text(json.dumps(cache))
|
|
132
|
-
|
|
133
|
-
# 保存最终结果
|
|
134
|
-
EMBED_CACHE.write_text(json.dumps(cache))
|
|
135
|
-
INDEX_FILE.write_text(json.dumps(index, ensure_ascii=False, indent=2))
|
|
136
|
-
|
|
137
|
-
print(f"\n✅ 索引构建完成!")
|
|
138
|
-
print(f" 新增: {new_count} | 缓存: {cache_count} | 总计: {len(index)}")
|
|
139
|
-
print(f" 索引文件: {INDEX_FILE} ({INDEX_FILE.stat().st_size / 1024:.1f} KB)")
|
|
140
|
-
print(f" 缓存文件: {EMBED_CACHE} ({EMBED_CACHE.stat().st_size / 1024:.1f} KB)")
|
|
141
|
-
|
|
142
|
-
# ====== Query ======
|
|
143
|
-
def query(text: str, top_k: int = 5, category_filter: str = None) -> list:
|
|
144
|
-
"""语义查询,返回最相关的页面"""
|
|
145
|
-
if not INDEX_FILE.exists():
|
|
146
|
-
print("❌ 索引不存在,请先运行: python3 vector_store.py build")
|
|
147
|
-
return []
|
|
148
|
-
|
|
149
|
-
index = json.loads(INDEX_FILE.read_text())
|
|
150
|
-
if not index:
|
|
151
|
-
print("❌ 索引为空")
|
|
152
|
-
return []
|
|
153
|
-
|
|
154
|
-
print(f"🔍 查询: \"{text}\"")
|
|
155
|
-
query_emb = get_embedding(text)
|
|
156
|
-
if not query_emb:
|
|
157
|
-
return []
|
|
158
|
-
|
|
159
|
-
# 余弦相似度
|
|
160
|
-
def cosine_similarity(a, b):
|
|
161
|
-
dot = sum(x * y for x, y in zip(a, b))
|
|
162
|
-
norm_a = sum(x * x for x in a) ** 0.5
|
|
163
|
-
norm_b = sum(x * x for x in b) ** 0.5
|
|
164
|
-
if norm_a == 0 or norm_b == 0: return 0
|
|
165
|
-
return dot / (norm_a * norm_b)
|
|
166
|
-
|
|
167
|
-
results = []
|
|
168
|
-
for item in index:
|
|
169
|
-
if category_filter and item.get("category") != category_filter:
|
|
170
|
-
continue
|
|
171
|
-
score = cosine_similarity(query_emb, item["embedding"])
|
|
172
|
-
results.append({**item, "score": round(score, 4)})
|
|
173
|
-
|
|
174
|
-
results.sort(key=lambda x: x["score"], reverse=True)
|
|
175
|
-
top = results[:top_k]
|
|
176
|
-
|
|
177
|
-
print(f"\n📋 Top {len(top)} 结果:\n")
|
|
178
|
-
for r in top:
|
|
179
|
-
cat_emoji = {"sources":"📄","entities":"🏷️","concepts":"💡","topics":"📑","root":"📁"}.get(r.get("category"), "📄")
|
|
180
|
-
print(f" {cat_emoji} [{r['score']:.3f}] {r['path']}")
|
|
181
|
-
print(f" ({r['size']} chars | {r['category']})")
|
|
182
|
-
# 显示匹配到的关键词上下文
|
|
183
|
-
body_preview = r.get("body", "")[:200].replace("\n", " ")
|
|
184
|
-
print(f" 预览: {body_preview}...")
|
|
185
|
-
print()
|
|
186
|
-
|
|
187
|
-
return top
|
|
188
|
-
|
|
189
|
-
# ====== Stats ======
|
|
190
|
-
def show_stats():
|
|
191
|
-
"""显示索引统计"""
|
|
192
|
-
if not INDEX_FILE.exists():
|
|
193
|
-
print("❌ 索引不存在"); return
|
|
194
|
-
|
|
195
|
-
index = json.loads(INDEX_FILE.read_text())
|
|
196
|
-
categories = {}
|
|
197
|
-
for item in index:
|
|
198
|
-
c = item.get("category", "root")
|
|
199
|
-
categories[c] = categories.get(c, 0) + 1
|
|
200
|
-
|
|
201
|
-
print(f"📊 Vector Store 统计:")
|
|
202
|
-
print(f" 总页面数: {len(index)}")
|
|
203
|
-
print(f" 索引大小: {INDEX_FILE.stat().st_size / 1024:.1f} KB")
|
|
204
|
-
print(f" 缓存大小: {EMBED_CACHE.stat().st_size / 1024:.1f} KB" if EMBED_CACHE.exists() else "")
|
|
205
|
-
print(f"\n 按分类:")
|
|
206
|
-
for c, cnt in sorted(categories.items(), key=lambda x: -x[1]):
|
|
207
|
-
emoji = {"sources":"📄","entities":"🏷️","concepts":"💡","topics":"📑","root":"📁"}.get(c, "📁")
|
|
208
|
-
print(f" {emoji} {c}: {cnt}")
|
|
209
|
-
|
|
210
|
-
# ====== Main ======
|
|
211
|
-
if __name__ == "__main__":
|
|
212
|
-
cmd = sys.argv[1] if len(sys.argv) > 1 else "stats"
|
|
213
|
-
|
|
214
|
-
if cmd == "build":
|
|
215
|
-
build_index("--force" in sys.argv)
|
|
216
|
-
elif cmd == "query":
|
|
217
|
-
q = " ".join(sys.argv[2:])
|
|
218
|
-
if not q:
|
|
219
|
-
print("用法: python3 vector_store.py query \"搜索内容\"")
|
|
220
|
-
else:
|
|
221
|
-
query(q)
|
|
222
|
-
elif cmd == "stats":
|
|
223
|
-
show_stats()
|
|
224
|
-
else:
|
|
225
|
-
print(__doc__)
|
package/scripts/wiki-health.py
DELETED
|
@@ -1,285 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Wiki Health Check — validates links, file sizes, and orphan pages.
|
|
3
|
-
|
|
4
|
-
Usage:
|
|
5
|
-
python scripts/wiki-health.py [WIKI_DIR]
|
|
6
|
-
|
|
7
|
-
Checks:
|
|
8
|
-
1. Broken links: [[wiki-links]] and [markdown](links) pointing to missing files
|
|
9
|
-
2. Tiny files: .md files under 100 bytes
|
|
10
|
-
3. Orphan pages: .md files not linked from any other page (excludes index.md, topics.md, overview.md, log.md)
|
|
11
|
-
|
|
12
|
-
Exit codes:
|
|
13
|
-
0 — all checks pass (or only warnings)
|
|
14
|
-
1 — broken links found
|
|
15
|
-
2 — errors during execution
|
|
16
|
-
"""
|
|
17
|
-
|
|
18
|
-
import os
|
|
19
|
-
import re
|
|
20
|
-
import sys
|
|
21
|
-
import urllib.request
|
|
22
|
-
import urllib.parse
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
from collections import defaultdict
|
|
25
|
-
|
|
26
|
-
# --- Configuration ---
|
|
27
|
-
WIKI_ROOT = Path(sys.argv[1]) if len(sys.argv) > 1 else Path("wiki")
|
|
28
|
-
TINY_THRESHOLD = 100 # bytes
|
|
29
|
-
INDEX_FILES = {"index.md", "topics.md", "overview.md", "log.md"}
|
|
30
|
-
|
|
31
|
-
# --- Helpers ---
|
|
32
|
-
|
|
33
|
-
def resolve_wiki_link(link_target: str, source_file: Path, wiki_root: Path) -> Path:
|
|
34
|
-
"""Resolve a [[wiki-link]] target to an actual file path.
|
|
35
|
-
|
|
36
|
-
Wiki-links are relative to wiki_root, not to the source file.
|
|
37
|
-
Supports optional display text: [[target|Display Text]] -> target
|
|
38
|
-
"""
|
|
39
|
-
# Strip optional display text: [[target|display]] -> target
|
|
40
|
-
target = link_target.split("|")[0].strip()
|
|
41
|
-
if not target:
|
|
42
|
-
return None
|
|
43
|
-
|
|
44
|
-
# Wiki-links are relative to wiki root
|
|
45
|
-
resolved = wiki_root / target
|
|
46
|
-
|
|
47
|
-
# Try exact path first
|
|
48
|
-
if resolved.is_file():
|
|
49
|
-
return resolved
|
|
50
|
-
|
|
51
|
-
# Try with .md extension
|
|
52
|
-
md_path = resolved.with_suffix(".md")
|
|
53
|
-
if md_path.is_file():
|
|
54
|
-
return md_path
|
|
55
|
-
|
|
56
|
-
return None
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def resolve_md_link(link_target: str, source_file: Path, wiki_root: Path) -> Path:
|
|
60
|
-
"""Resolve a [markdown](link) target to an actual file path.
|
|
61
|
-
|
|
62
|
-
Markdown links are relative to the source file's directory.
|
|
63
|
-
"""
|
|
64
|
-
if not link_target or link_target.startswith(("http://", "https://", "#", "mailto:")):
|
|
65
|
-
return None # External or anchor links — skip
|
|
66
|
-
|
|
67
|
-
# URL-decode for Chinese filenames
|
|
68
|
-
decoded = urllib.parse.unquote(link_target)
|
|
69
|
-
|
|
70
|
-
# Markdown links are relative to the source file directory
|
|
71
|
-
source_dir = source_file.parent
|
|
72
|
-
resolved = (source_dir / decoded).resolve()
|
|
73
|
-
|
|
74
|
-
if resolved.is_file():
|
|
75
|
-
return resolved
|
|
76
|
-
|
|
77
|
-
return None
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
def strip_code_blocks(content: str) -> str:
|
|
81
|
-
"""Remove fenced code blocks and inline code to avoid false positive links."""
|
|
82
|
-
# Remove fenced code blocks (```...```)
|
|
83
|
-
content = re.sub(r'```.*?```', '', content, flags=re.DOTALL)
|
|
84
|
-
# Remove inline code (`...`)
|
|
85
|
-
content = re.sub(r'`[^`]+`', '', content)
|
|
86
|
-
return content
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def extract_links(content: str, source_file: Path, wiki_root: Path):
|
|
90
|
-
"""Extract all link targets from markdown content.
|
|
91
|
-
|
|
92
|
-
Returns:
|
|
93
|
-
wiki_links: set of (raw_target, resolved_path_or_None) for [[]] links
|
|
94
|
-
md_links: set of (raw_target, resolved_path_or_None) for []() links
|
|
95
|
-
"""
|
|
96
|
-
wiki_links = set()
|
|
97
|
-
md_links = set()
|
|
98
|
-
|
|
99
|
-
# Strip code blocks to avoid false positives
|
|
100
|
-
clean = strip_code_blocks(content)
|
|
101
|
-
|
|
102
|
-
# [[wiki-links]] — may contain | for display text
|
|
103
|
-
for match in re.finditer(r'\[\[([^\]]+)\]\]', clean):
|
|
104
|
-
raw = match.group(1)
|
|
105
|
-
target = raw.split("|")[0].strip()
|
|
106
|
-
resolved = resolve_wiki_link(target, source_file, wiki_root)
|
|
107
|
-
wiki_links.add((target, resolved))
|
|
108
|
-
|
|
109
|
-
# [markdown](links) — standard markdown
|
|
110
|
-
for match in re.finditer(r'\[([^\]]*)\]\(([^)]+)\)', clean):
|
|
111
|
-
raw_target = match.group(2).strip()
|
|
112
|
-
resolved = resolve_md_link(raw_target, source_file, wiki_root)
|
|
113
|
-
if resolved is not None or not raw_target.startswith(("http://", "https://", "#", "mailto:")):
|
|
114
|
-
md_links.add((raw_target, resolved))
|
|
115
|
-
|
|
116
|
-
return wiki_links, md_links
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
# --- Checks ---
|
|
120
|
-
|
|
121
|
-
def check_broken_links(wiki_root: Path) -> list[dict]:
|
|
122
|
-
"""Find all broken links across the wiki."""
|
|
123
|
-
broken = []
|
|
124
|
-
|
|
125
|
-
for md_file in sorted(wiki_root.rglob("*.md")):
|
|
126
|
-
try:
|
|
127
|
-
content = md_file.read_text(encoding="utf-8")
|
|
128
|
-
except Exception as e:
|
|
129
|
-
broken.append({
|
|
130
|
-
"file": str(md_file.relative_to(wiki_root)),
|
|
131
|
-
"error": f"Cannot read file: {e}"
|
|
132
|
-
})
|
|
133
|
-
continue
|
|
134
|
-
|
|
135
|
-
wiki_links, md_links = extract_links(content, md_file, wiki_root)
|
|
136
|
-
|
|
137
|
-
rel_path = str(md_file.relative_to(wiki_root))
|
|
138
|
-
|
|
139
|
-
for raw_target, resolved in wiki_links:
|
|
140
|
-
if resolved is None:
|
|
141
|
-
broken.append({
|
|
142
|
-
"type": "wiki-link",
|
|
143
|
-
"file": rel_path,
|
|
144
|
-
"target": raw_target,
|
|
145
|
-
"detail": f"[[{raw_target}]] -> file not found"
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
for raw_target, resolved in md_links:
|
|
149
|
-
# Skip external links
|
|
150
|
-
if raw_target.startswith(("http://", "https://", "#", "mailto:")):
|
|
151
|
-
continue
|
|
152
|
-
if resolved is None:
|
|
153
|
-
broken.append({
|
|
154
|
-
"type": "md-link",
|
|
155
|
-
"file": rel_path,
|
|
156
|
-
"target": raw_target,
|
|
157
|
-
"detail": f"[]({raw_target}) -> file not found"
|
|
158
|
-
})
|
|
159
|
-
|
|
160
|
-
return broken
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
def check_tiny_files(wiki_root: Path, threshold: int = TINY_THRESHOLD) -> list[dict]:
|
|
164
|
-
"""Find .md files smaller than threshold bytes."""
|
|
165
|
-
tiny = []
|
|
166
|
-
|
|
167
|
-
for md_file in sorted(wiki_root.rglob("*.md")):
|
|
168
|
-
try:
|
|
169
|
-
size = md_file.stat().st_size
|
|
170
|
-
except OSError:
|
|
171
|
-
continue
|
|
172
|
-
|
|
173
|
-
if size < threshold:
|
|
174
|
-
tiny.append({
|
|
175
|
-
"file": str(md_file.relative_to(wiki_root)),
|
|
176
|
-
"size": size,
|
|
177
|
-
"detail": f"{size}B < {threshold}B threshold"
|
|
178
|
-
})
|
|
179
|
-
|
|
180
|
-
return tiny
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
def check_orphan_pages(wiki_root: Path) -> list[dict]:
|
|
184
|
-
"""Find .md pages not referenced by any other page."""
|
|
185
|
-
# Collect all existing pages
|
|
186
|
-
all_pages = set()
|
|
187
|
-
for md_file in wiki_root.rglob("*.md"):
|
|
188
|
-
all_pages.add(md_file)
|
|
189
|
-
|
|
190
|
-
# Collect all link targets across all files
|
|
191
|
-
referenced = set()
|
|
192
|
-
for md_file in wiki_root.rglob("*.md"):
|
|
193
|
-
try:
|
|
194
|
-
content = md_file.read_text(encoding="utf-8")
|
|
195
|
-
except Exception:
|
|
196
|
-
continue
|
|
197
|
-
|
|
198
|
-
wiki_links, md_links = extract_links(content, md_file, wiki_root)
|
|
199
|
-
|
|
200
|
-
for _, resolved in wiki_links:
|
|
201
|
-
if resolved is not None:
|
|
202
|
-
referenced.add(resolved)
|
|
203
|
-
|
|
204
|
-
for _, resolved in md_links:
|
|
205
|
-
if resolved is not None:
|
|
206
|
-
referenced.add(resolved)
|
|
207
|
-
|
|
208
|
-
orphans = []
|
|
209
|
-
for page in sorted(all_pages):
|
|
210
|
-
rel = str(page.relative_to(wiki_root))
|
|
211
|
-
# Index files are never considered orphans
|
|
212
|
-
if page.name in INDEX_FILES:
|
|
213
|
-
continue
|
|
214
|
-
if page not in referenced:
|
|
215
|
-
orphans.append({
|
|
216
|
-
"file": rel,
|
|
217
|
-
"detail": "not linked from any other page"
|
|
218
|
-
})
|
|
219
|
-
|
|
220
|
-
return orphans
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
# --- Main ---
|
|
224
|
-
|
|
225
|
-
def main():
|
|
226
|
-
if not WIKI_ROOT.is_dir():
|
|
227
|
-
print(f"ERROR: wiki directory not found: {WIKI_ROOT}")
|
|
228
|
-
sys.exit(2)
|
|
229
|
-
|
|
230
|
-
print(f"Wiki Health Check — {WIKI_ROOT.resolve()}")
|
|
231
|
-
print(f"{'=' * 60}")
|
|
232
|
-
|
|
233
|
-
# Count files
|
|
234
|
-
md_files = list(WIKI_ROOT.rglob("*.md"))
|
|
235
|
-
print(f"Total .md files: {len(md_files)}")
|
|
236
|
-
|
|
237
|
-
exit_code = 0
|
|
238
|
-
|
|
239
|
-
# 1. Broken links
|
|
240
|
-
print(f"\n--- Broken Links ---")
|
|
241
|
-
broken = check_broken_links(WIKI_ROOT)
|
|
242
|
-
if broken:
|
|
243
|
-
errors = [b for b in broken if "error" in b]
|
|
244
|
-
link_issues = [b for b in broken if "error" not in b]
|
|
245
|
-
if link_issues:
|
|
246
|
-
print(f" BROKEN LINKS: {len(link_issues)}")
|
|
247
|
-
for item in link_issues:
|
|
248
|
-
print(f" [{item['type']}] {item['file']} -> {item['target']}")
|
|
249
|
-
exit_code = 1
|
|
250
|
-
if errors:
|
|
251
|
-
print(f" READ ERRORS: {len(errors)}")
|
|
252
|
-
for item in errors:
|
|
253
|
-
print(f" {item['file']}: {item['error']}")
|
|
254
|
-
else:
|
|
255
|
-
print(" OK — no broken links found")
|
|
256
|
-
|
|
257
|
-
# 2. Tiny files
|
|
258
|
-
print(f"\n--- Tiny Files (< {TINY_THRESHOLD}B) ---")
|
|
259
|
-
tiny = check_tiny_files(WIKI_ROOT)
|
|
260
|
-
if tiny:
|
|
261
|
-
print(f" TINY FILES: {len(tiny)}")
|
|
262
|
-
for item in tiny:
|
|
263
|
-
print(f" {item['file']} ({item['detail']})")
|
|
264
|
-
else:
|
|
265
|
-
print(" OK — no tiny files found")
|
|
266
|
-
|
|
267
|
-
# 3. Orphan pages
|
|
268
|
-
print(f"\n--- Orphan Pages ---")
|
|
269
|
-
orphans = check_orphan_pages(WIKI_ROOT)
|
|
270
|
-
if orphans:
|
|
271
|
-
print(f" ORPHAN PAGES: {len(orphans)}")
|
|
272
|
-
for item in orphans:
|
|
273
|
-
print(f" {item['file']}")
|
|
274
|
-
else:
|
|
275
|
-
print(" OK — no orphan pages found")
|
|
276
|
-
|
|
277
|
-
print(f"\n{'=' * 60}")
|
|
278
|
-
total_issues = len(broken) + len(tiny) + len(orphans)
|
|
279
|
-
print(f"Total issues: {total_issues}")
|
|
280
|
-
|
|
281
|
-
sys.exit(exit_code)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
if __name__ == "__main__":
|
|
285
|
-
main()
|