@jerryjiao/knowflow 0.3.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 +89 -0
- package/CONTRIBUTING.md +41 -0
- package/LICENSE +21 -0
- package/README.md +177 -0
- package/README.zh-CN.md +176 -0
- package/SECURITY.md +18 -0
- package/bin/knowflow.js +367 -0
- package/docs/README-CN.md +10 -0
- package/docs/architecture/system-architecture.md +150 -0
- package/docs/assets/knowflow-graph-demo.png +0 -0
- package/docs/assets/knowflow-social-preview.png +0 -0
- package/docs/assets/logo.png +0 -0
- package/docs/contributing.md +131 -0
- package/docs/methodology/llm-wiki-methodology.md +110 -0
- package/docs/reference/data-model.md +200 -0
- package/examples/quickstart.md +60 -0
- package/package.json +57 -0
- package/scripts/batch-ingest.cjs +455 -0
- package/scripts/bookmark_sync.sh +150 -0
- package/scripts/enrich-wiki.js +683 -0
- package/scripts/graph_builder.py +612 -0
- package/scripts/graph_relation_labeler.py +191 -0
- package/scripts/ingest.sh +162 -0
- package/scripts/pipeline.sh +73 -0
- package/scripts/tags-builder.mjs +75 -0
- package/scripts/vector-store.mjs +717 -0
- package/scripts/vector_store.py +225 -0
- package/scripts/wechat_sync.sh +199 -0
- package/scripts/wiki-auto-fix.sh +238 -0
- package/scripts/wiki-health.py +285 -0
- package/scripts/wiki-health.sh +335 -0
- package/templates/comparison.md +31 -0
- package/templates/concept.md +37 -0
- package/templates/entity.md +32 -0
- package/templates/source.md +36 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* KnowFlow Vector Store v2.1
|
|
4
|
+
* 智谱 embedding-3 向量检索引擎
|
|
5
|
+
*
|
|
6
|
+
* 用法:
|
|
7
|
+
* node vector-store.mjs build 全量构建索引
|
|
8
|
+
* node vector-store.mjs build --incremental 增量构建(只处理新增/修改)
|
|
9
|
+
* node vector-store.mjs build --stats 构建后显示统计
|
|
10
|
+
* node vector-store.mjs query "关键词" 语义搜索 Top10(混合排序)
|
|
11
|
+
* node vector-store.mjs query "关键词" --stats 查询后显示统计
|
|
12
|
+
* node vector-store.mjs search "query" 语义搜索 Top5(精简版)
|
|
13
|
+
* node vector-store.mjs ask "你的问题" LLM 问答(基于检索上下文)
|
|
14
|
+
* node vector-store.mjs stats 统计
|
|
15
|
+
* node vector-store.mjs stats --verbose 详细统计(含 per-type 明细行)
|
|
16
|
+
* node vector-store.mjs stats --json JSON 格式输出统计
|
|
17
|
+
* node vector-store.mjs build --stats --json 构建后 JSON 统计
|
|
18
|
+
* node vector-store.mjs query "关键词" --stats --json 查询后 JSON 统计
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync, writeFileSync, readdirSync, existsSync, statSync } from 'fs';
|
|
22
|
+
import { dirname, join, relative, extname, resolve } from 'path';
|
|
23
|
+
import { fileURLToPath } from 'url';
|
|
24
|
+
|
|
25
|
+
const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const PROJECT_ROOT = resolve(process.env.KNOWFLOW_ROOT || PACKAGE_ROOT);
|
|
27
|
+
const WIKI_DIR = resolve(process.env.KNOWFLOW_WIKI_DIR || join(PROJECT_ROOT, 'wiki'));
|
|
28
|
+
const INDEX_FILE = join(WIKI_DIR, '.vector-index.json');
|
|
29
|
+
const CACHE_FILE = join(WIKI_DIR, '.embed-cache.json');
|
|
30
|
+
const MANIFEST_FILE = join(WIKI_DIR, '.vector-manifest.json'); // 增量用:记录文件 mtime
|
|
31
|
+
const API_URL = 'https://open.bigmodel.cn/api/paas/v4/embeddings';
|
|
32
|
+
const MODEL = 'embedding-3';
|
|
33
|
+
const CHAT_API_URL = 'https://open.bigmodel.cn/api/paas/v4/chat/completions';
|
|
34
|
+
const CHAT_MODEL = 'glm-4-flash';
|
|
35
|
+
const DIMS = 1024;
|
|
36
|
+
const BATCH_SIZE = 20;
|
|
37
|
+
const MIN_SIZE = 300; // 最小文件大小阈值
|
|
38
|
+
const QUERY_CACHE_TTL = 60_000; // 查询缓存 TTL: 60 秒
|
|
39
|
+
const QUERY_CACHE_MAX = 100; // 最大缓存条目数
|
|
40
|
+
|
|
41
|
+
// ─── 查询缓存(模块级) ───
|
|
42
|
+
const queryCache = new Map();
|
|
43
|
+
function getQueryCacheKey(text) { return text.trim().toLowerCase(); }
|
|
44
|
+
|
|
45
|
+
// ─── 类型权重(混合排序) ───
|
|
46
|
+
const TYPE_WEIGHTS = {
|
|
47
|
+
concepts: 1.15, // 概念页略高
|
|
48
|
+
entities: 1.10, // 实体页略高
|
|
49
|
+
topics: 1.00, // 专题页基准
|
|
50
|
+
sources: 0.90, // 来源页略低
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
// 加载 .env
|
|
54
|
+
const envPath = join(PROJECT_ROOT, '.env');
|
|
55
|
+
if (existsSync(envPath)) {
|
|
56
|
+
for (const line of readFileSync(envPath, 'utf8').split('\n')) {
|
|
57
|
+
const [k, ...v] = line.split('=');
|
|
58
|
+
if (k?.trim() && !process.env[k.trim()]) process.env[k.trim()] = v.join('=').trim();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
const API_KEY = process.env.ZHIPUAI_API_KEY || '';
|
|
62
|
+
|
|
63
|
+
if (!API_KEY) {
|
|
64
|
+
console.error('❌ Error: ZHIPUAI_API_KEY environment variable is required');
|
|
65
|
+
console.error(' export ZHIPUAI_API_KEY="your-key-here"');
|
|
66
|
+
// Don't exit for stats/help commands, only block LLM calls
|
|
67
|
+
const isLlmCommand = process.argv[2] === 'ask' || process.argv[2] === 'query';
|
|
68
|
+
if (isLlmCommand) process.exit(1);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ─── 工具函数 ───
|
|
72
|
+
|
|
73
|
+
function log(msg) { console.log(`[${new Date().toLocaleTimeString()}] ${msg}`); }
|
|
74
|
+
|
|
75
|
+
function getAllMdFiles(dir) {
|
|
76
|
+
const files = [];
|
|
77
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
78
|
+
if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
|
|
79
|
+
const full = join(dir, entry.name);
|
|
80
|
+
if (entry.isDirectory()) files.push(...getAllMdFiles(full));
|
|
81
|
+
else if (extname(entry.name) === '.md') files.push(full);
|
|
82
|
+
}
|
|
83
|
+
return files;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function cosineSimilarity(a, b) {
|
|
87
|
+
let dot = 0, normA = 0, normB = 0;
|
|
88
|
+
for (let i = 0; i < a.length; i++) {
|
|
89
|
+
dot += a[i] * b[i];
|
|
90
|
+
normA += a[i] * a[i];
|
|
91
|
+
normB += b[i] * b[i];
|
|
92
|
+
}
|
|
93
|
+
return dot / (Math.sqrt(normA) * Math.sqrt(normB) + 1e-10);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function getTypeWeight(path) {
|
|
97
|
+
const type = path.split('/')[0];
|
|
98
|
+
return TYPE_WEIGHTS[type] || 1.0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function extractTitle(content) {
|
|
102
|
+
const m = content.match(/^#\s+(.+)$/m);
|
|
103
|
+
return m ? m[1].trim() : '(untitled)';
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }
|
|
107
|
+
|
|
108
|
+
function estimateTokens(text) {
|
|
109
|
+
return Math.ceil(text.length / 4);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function formatDuration(ms) {
|
|
113
|
+
if (ms < 60000) return `${Math.round(ms / 1000)}s`;
|
|
114
|
+
if (ms < 3600000) return `${Math.round(ms / 60000)}m ${Math.round((ms % 60000) / 1000)}s`;
|
|
115
|
+
const h = Math.floor(ms / 3600000);
|
|
116
|
+
const m = Math.round((ms % 3600000) / 60000);
|
|
117
|
+
return `${h}h ${m}m`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── Embedding API ───
|
|
121
|
+
|
|
122
|
+
async function getEmbedding(texts) {
|
|
123
|
+
const res = await fetch(API_URL, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
headers: {
|
|
126
|
+
'Content-Type': 'application/json',
|
|
127
|
+
'Authorization': `Bearer ${API_KEY}`
|
|
128
|
+
},
|
|
129
|
+
body: JSON.stringify({ model: MODEL, input: texts, dimensions: DIMS })
|
|
130
|
+
});
|
|
131
|
+
if (!res.ok) {
|
|
132
|
+
const err = await res.text();
|
|
133
|
+
throw new Error(`Embedding API error ${res.status}: ${err}`);
|
|
134
|
+
}
|
|
135
|
+
const data = await res.json();
|
|
136
|
+
return data.data.sort((a, b) => a.index - b.index).map(item => item.embedding);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Manifest(增量更新用) ───
|
|
140
|
+
|
|
141
|
+
function loadManifest() {
|
|
142
|
+
if (existsSync(MANIFEST_FILE)) return JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'));
|
|
143
|
+
return {};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function saveManifest(manifest) {
|
|
147
|
+
writeFileSync(MANIFEST_FILE, JSON.stringify(manifest, null, 2), 'utf8');
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ─── Build 索引 ───
|
|
151
|
+
|
|
152
|
+
async function buildIndex(incremental = false) {
|
|
153
|
+
if (!API_KEY) throw new Error('需要 ZHIPUAI_API_KEY 环境变量');
|
|
154
|
+
|
|
155
|
+
log('📂 扫描 wiki 目录...');
|
|
156
|
+
const files = getAllMdFiles(WIKI_DIR);
|
|
157
|
+
log(`找到 ${files.length} 个 md 文件${incremental ? ' (增量模式)' : ''}`);
|
|
158
|
+
|
|
159
|
+
// 加载缓存和 manifest
|
|
160
|
+
let cache = {};
|
|
161
|
+
if (existsSync(CACHE_FILE)) cache = JSON.parse(readFileSync(CACHE_FILE, 'utf8'));
|
|
162
|
+
const manifest = incremental ? loadManifest() : {};
|
|
163
|
+
if (incremental) log(`Manifest: 已记录 ${Object.keys(manifest).length} 个文件`);
|
|
164
|
+
|
|
165
|
+
const index = [];
|
|
166
|
+
let cached = 0, newReq = 0, skipped = 0, failed = 0, unchanged = 0;
|
|
167
|
+
const toEmbed = [];
|
|
168
|
+
|
|
169
|
+
for (const filePath of files) {
|
|
170
|
+
const relPath = relative(WIKI_DIR, filePath);
|
|
171
|
+
const content = readFileSync(filePath, 'utf8').trim();
|
|
172
|
+
|
|
173
|
+
// 跳过小文件
|
|
174
|
+
if (content.length < MIN_SIZE) { skipped++; continue; }
|
|
175
|
+
|
|
176
|
+
const fingerprint = relPath + '::' + content.slice(0, 500);
|
|
177
|
+
const mtime = statSync(filePath).mtimeMs;
|
|
178
|
+
|
|
179
|
+
// 增量模式:检查文件是否变化
|
|
180
|
+
if (incremental && manifest[relPath] === mtime && cache[fingerprint]) {
|
|
181
|
+
index.push({ path: relPath, title: extractTitle(content), size: content.length, embedding: cache[fingerprint] });
|
|
182
|
+
cached++;
|
|
183
|
+
unchanged++;
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (cache[fingerprint]) {
|
|
188
|
+
index.push({ path: relPath, title: extractTitle(content), size: content.length, embedding: cache[fingerprint] });
|
|
189
|
+
cached++;
|
|
190
|
+
} else {
|
|
191
|
+
toEmbed.push({ path: relPath, title: extractTitle(content), text: content.slice(0, 1500), fingerprint, mtime });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
log(`统计: 缓存命中=${cached}, 新增/变更=${toEmbed.length}, 跳过小文件=${skipped}${incremental ? ', 未变化=' + unchanged : ''}`);
|
|
196
|
+
|
|
197
|
+
// 分批请求 embedding
|
|
198
|
+
for (let i = 0; i < toEmbed.length; i += BATCH_SIZE) {
|
|
199
|
+
const batch = toEmbed.slice(i, i + BATCH_SIZE);
|
|
200
|
+
const texts = batch.map(b => `${b.title}\n${b.text}`);
|
|
201
|
+
process.stdout.write(`\r🔄 Embedding ${Math.min(i + BATCH_SIZE, toEmbed.length)}/${toEmbed.length}...`);
|
|
202
|
+
|
|
203
|
+
try {
|
|
204
|
+
const embeddings = await getEmbedding(texts);
|
|
205
|
+
for (let j = 0; j < batch.length; j++) {
|
|
206
|
+
const b = batch[j];
|
|
207
|
+
index.push({ path: b.path, title: b.title, size: b.text.length, embedding: embeddings[j] });
|
|
208
|
+
cache[b.fingerprint] = embeddings[j];
|
|
209
|
+
newReq++;
|
|
210
|
+
// 更新 manifest
|
|
211
|
+
if (b.mtime) manifest[b.path] = b.mtime;
|
|
212
|
+
}
|
|
213
|
+
} catch (err) {
|
|
214
|
+
console.log(`\n⚠️ 批次 ${i} 失败: ${err.message},将在下次重试`);
|
|
215
|
+
failed += batch.length;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (i + BATCH_SIZE < toEmbed.length) await sleep(300);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
console.log();
|
|
222
|
+
|
|
223
|
+
// 保存
|
|
224
|
+
const indexData = {
|
|
225
|
+
builtAt: new Date().toISOString(),
|
|
226
|
+
pages: index,
|
|
227
|
+
};
|
|
228
|
+
writeFileSync(INDEX_FILE, JSON.stringify(indexData, null, 2), 'utf8');
|
|
229
|
+
writeFileSync(CACHE_FILE, JSON.stringify(cache), 'utf8');
|
|
230
|
+
if (incremental) saveManifest(manifest);
|
|
231
|
+
|
|
232
|
+
const hasEmbed = index.filter(x => x.embedding).length;
|
|
233
|
+
log(`✅ 完成! ${index.length} 页 (${hasEmbed} 有向量, ${skipped} 跳过, ${failed} 重试中)`);
|
|
234
|
+
log(`缓存: ${Object.keys(cache).length} 条 | ${incremental ? 'Manifest: ' + Object.keys(manifest).length + ' 文件' : ''}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// ─── 关键词匹配(BM25 简化版) ───
|
|
238
|
+
function keywordMatch(queryTerms, content, title) {
|
|
239
|
+
const text = `${title} ${content}`.toLowerCase();
|
|
240
|
+
let score = 0;
|
|
241
|
+
for (const term of queryTerms) {
|
|
242
|
+
if (!term) continue;
|
|
243
|
+
// 标题命中权重更高
|
|
244
|
+
const inTitle = title.toLowerCase().includes(term) ? 2 : 0;
|
|
245
|
+
// 内容中出现的次数
|
|
246
|
+
const regex = new RegExp(term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi');
|
|
247
|
+
const matches = text.match(regex);
|
|
248
|
+
const count = matches ? matches.length : 0;
|
|
249
|
+
score += inTitle + Math.min(count, 5) * 0.5; // 单个词最多贡献 2.5 分
|
|
250
|
+
}
|
|
251
|
+
return score / queryTerms.length; // 归一化
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// ─── Query 查询(混合检索:向量 + 关键词) ───
|
|
255
|
+
|
|
256
|
+
async function queryIndex(queryText, topK = 10) {
|
|
257
|
+
if (!API_KEY) throw new Error('需要 ZHIPUAI_API_KEY 环境变量,请先运行 knowflow init 配置');
|
|
258
|
+
if (!existsSync(INDEX_FILE)) { log('❌ 向量索引不存在,请先运行 knowflow ingest 添加内容后自动构建索引'); return []; }
|
|
259
|
+
|
|
260
|
+
const raw = JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
|
|
261
|
+
const index = Array.isArray(raw) ? raw : raw.pages || [];
|
|
262
|
+
const validIndex = index.filter(x => x.embedding);
|
|
263
|
+
|
|
264
|
+
if (validIndex.length === 0) {
|
|
265
|
+
log('⚠️ 索引中没有可用的向量数据,可能需要重新构建索引 (knowflow ingest + pipeline)');
|
|
266
|
+
return [];
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
log(`🔍 查询: "${queryText}" (${validIndex.length}/${index.length} 有向量)`);
|
|
270
|
+
|
|
271
|
+
// ── 检查查询缓存 ──
|
|
272
|
+
const cacheKey = getQueryCacheKey(queryText);
|
|
273
|
+
const cached = queryCache.get(cacheKey);
|
|
274
|
+
if (cached && Date.now() - cached.ts < QUERY_CACHE_TTL) {
|
|
275
|
+
log('♻️ 使用查询缓存');
|
|
276
|
+
return cached.results.slice(0, topK);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── 向量搜索 ──
|
|
280
|
+
const [queryEmb] = await getEmbedding([queryText]);
|
|
281
|
+
|
|
282
|
+
const vectorResults = validIndex.map(item => ({
|
|
283
|
+
...item,
|
|
284
|
+
vectorScore: cosineSimilarity(queryEmb, item.embedding),
|
|
285
|
+
typeWeight: getTypeWeight(item.path),
|
|
286
|
+
})).map(item => ({
|
|
287
|
+
...item,
|
|
288
|
+
vectorFinal: item.vectorScore * item.typeWeight,
|
|
289
|
+
}));
|
|
290
|
+
|
|
291
|
+
// ── 关键词匹配 ──
|
|
292
|
+
const queryTerms = queryText.toLowerCase().split(/\s+/).filter(t => t.length > 1);
|
|
293
|
+
const keywordResults = vectorResults.map(item => {
|
|
294
|
+
// 读取文件内容用于关键词匹配(使用缓存的标题+前缀)
|
|
295
|
+
const filePath = join(WIKI_DIR, item.path);
|
|
296
|
+
let content = '';
|
|
297
|
+
try { content = readFileSync(filePath, 'utf8').slice(0, 2000); } catch {}
|
|
298
|
+
return {
|
|
299
|
+
...item,
|
|
300
|
+
kwScore: keywordMatch(queryTerms, content, item.title || ''),
|
|
301
|
+
};
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// ── 混合评分: 向量 70% + 关键词 30%(归一化后) ──
|
|
305
|
+
const maxVector = Math.max(...keywordResults.map(r => r.vectorFinal), 0.01);
|
|
306
|
+
const maxKW = Math.max(...keywordResults.map(r => r.kwScore), 0.01);
|
|
307
|
+
|
|
308
|
+
const results = keywordResults
|
|
309
|
+
.map(item => ({
|
|
310
|
+
...item,
|
|
311
|
+
score: (item.vectorFinal / maxVector) * 0.7 + (item.kwScore / maxKW) * 0.3,
|
|
312
|
+
}))
|
|
313
|
+
.filter(item => item.vectorScore > 0.05 || item.kwScore > 0) // 至少一个维度有信号
|
|
314
|
+
.sort((a, b) => b.score - a.score)
|
|
315
|
+
.slice(0, topK);
|
|
316
|
+
|
|
317
|
+
// ── 写入缓存 ──
|
|
318
|
+
if (queryCache.size >= QUERY_CACHE_MAX) {
|
|
319
|
+
// 删除最旧的条目
|
|
320
|
+
const oldest = queryCache.keys().next().value;
|
|
321
|
+
queryCache.delete(oldest);
|
|
322
|
+
}
|
|
323
|
+
queryCache.set(cacheKey, { ts: Date.now(), results: [...results] });
|
|
324
|
+
|
|
325
|
+
log(`\n📋 Top ${results.length} 结果 (混合检索: 向量70% + 关键词30%):\n`);
|
|
326
|
+
for (const r of results) {
|
|
327
|
+
const type = r.path.split('/')[0];
|
|
328
|
+
const wikiUrl = `wiki/${r.path}`;
|
|
329
|
+
console.log(` 📄 [${(r.score * 100).toFixed(1)}%] ${r.title}`);
|
|
330
|
+
console.log(` 📍 ${r.path} (${type})`);
|
|
331
|
+
console.log(` 🔗 → ${wikiUrl}`);
|
|
332
|
+
console.log(` 📊 向量:${(r.vectorScore * 100).toFixed(1)}% | 关键词:${r.kwScore.toFixed(2)} | 权重:${r.typeWeight}x`);
|
|
333
|
+
console.log();
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return results;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
// ─── Stats ───
|
|
340
|
+
|
|
341
|
+
function showStats(verbose = false, json = false) {
|
|
342
|
+
if (!existsSync(INDEX_FILE)) { if (json) { console.log(JSON.stringify({ error: '索引不存在' }, null, 2)); return null; } log('索引不存在'); return; }
|
|
343
|
+
const raw = JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
|
|
344
|
+
const index = Array.isArray(raw) ? raw : raw.pages || [];
|
|
345
|
+
const hasEmbed = index.filter(x => x.embedding).length;
|
|
346
|
+
|
|
347
|
+
// Index freshness
|
|
348
|
+
const lastBuilt = statSync(INDEX_FILE).mtime;
|
|
349
|
+
const builtAtISO = raw.builtAt;
|
|
350
|
+
const freshnessMs = builtAtISO ? Date.now() - new Date(builtAtISO).getTime() : null;
|
|
351
|
+
|
|
352
|
+
// ── Summary metrics ──
|
|
353
|
+
const total = index.length;
|
|
354
|
+
const embedCoverage = total > 0 ? parseFloat((hasEmbed / total * 100).toFixed(1)) : 0;
|
|
355
|
+
|
|
356
|
+
// Cache hit ratio
|
|
357
|
+
let cacheEntries = 0;
|
|
358
|
+
if (existsSync(CACHE_FILE)) {
|
|
359
|
+
cacheEntries = Object.keys(JSON.parse(readFileSync(CACHE_FILE, 'utf8'))).length;
|
|
360
|
+
}
|
|
361
|
+
const cacheHitRatio = total > 0 ? parseFloat((cacheEntries / total * 100).toFixed(1)) : 0;
|
|
362
|
+
|
|
363
|
+
// Estimated total API tokens (title + first 1500 chars of content per entry)
|
|
364
|
+
const estimatedApiTokens = index.reduce((sum, item) => {
|
|
365
|
+
const apiInputLen = (item.title || '').length + 1 + Math.min(item.size || 0, 1500);
|
|
366
|
+
return sum + estimateTokens('x'.repeat(apiInputLen));
|
|
367
|
+
}, 0);
|
|
368
|
+
|
|
369
|
+
// Per-type breakdown
|
|
370
|
+
const typeStats = {};
|
|
371
|
+
for (const item of index) {
|
|
372
|
+
const type = item.path.split('/')[0];
|
|
373
|
+
if (!typeStats[type]) typeStats[type] = { count: 0, embedded: 0, tokens: 0, size: 0 };
|
|
374
|
+
typeStats[type].count++;
|
|
375
|
+
if (item.embedding) typeStats[type].embedded++;
|
|
376
|
+
typeStats[type].tokens += estimateTokens((item.title || '') + 'x'.repeat(Math.min(item.size || 0, 1500)));
|
|
377
|
+
typeStats[type].size += item.size || 0;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// ── JSON mode: return structured object ──
|
|
381
|
+
if (json) {
|
|
382
|
+
let manifestEntries = 0;
|
|
383
|
+
if (existsSync(MANIFEST_FILE)) {
|
|
384
|
+
manifestEntries = Object.keys(JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'))).length;
|
|
385
|
+
}
|
|
386
|
+
const typeBreakdown = {};
|
|
387
|
+
for (const [type, s] of Object.entries(typeStats)) {
|
|
388
|
+
typeBreakdown[type] = {
|
|
389
|
+
pages: s.count,
|
|
390
|
+
embedded: s.embedded,
|
|
391
|
+
coverage: s.count > 0 ? parseFloat((s.embedded / s.count * 100).toFixed(1)) : 0,
|
|
392
|
+
tokens: s.tokens,
|
|
393
|
+
size: s.size,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
const result = {
|
|
397
|
+
builtAt: builtAtISO || null,
|
|
398
|
+
freshnessMs,
|
|
399
|
+
pages: total,
|
|
400
|
+
embedded: hasEmbed,
|
|
401
|
+
embedCoverage,
|
|
402
|
+
cacheEntries,
|
|
403
|
+
cacheHitRatio,
|
|
404
|
+
estimatedApiTokens,
|
|
405
|
+
manifestEntries,
|
|
406
|
+
typeBreakdown,
|
|
407
|
+
};
|
|
408
|
+
if (verbose) {
|
|
409
|
+
result.files = index.map(item => ({
|
|
410
|
+
path: item.path,
|
|
411
|
+
title: item.title || '(untitled)',
|
|
412
|
+
size: item.size || 0,
|
|
413
|
+
tokens: Math.ceil((item.size || 0) / 4),
|
|
414
|
+
hasEmbedding: !!item.embedding,
|
|
415
|
+
}));
|
|
416
|
+
}
|
|
417
|
+
return result;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// ── Human-readable mode (original behavior) ──
|
|
421
|
+
log(`📊 向量索引统计 Last Built: ${lastBuilt.toLocaleString()}`);
|
|
422
|
+
|
|
423
|
+
log('');
|
|
424
|
+
log(` Pages: ${total}`);
|
|
425
|
+
log(` Embedding: ${hasEmbed}/${total} (${embedCoverage}% coverage)`);
|
|
426
|
+
log(` Cache: ${cacheEntries} entries (${cacheHitRatio}% hit ratio)`);
|
|
427
|
+
log(` API Tokens: ~${estimatedApiTokens.toLocaleString()} (estimated total)`);
|
|
428
|
+
log(` Freshness: ${freshnessMs !== null ? formatDuration(freshnessMs) + ' ago' : 'unknown'}`);
|
|
429
|
+
log(` 类型权重: concepts×1.15 | entities×1.10 | topics×1.0 | sources×0.90`);
|
|
430
|
+
if (existsSync(MANIFEST_FILE)) {
|
|
431
|
+
log(` Manifest: ${Object.keys(JSON.parse(readFileSync(MANIFEST_FILE, 'utf8'))).length} 文件`);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ── Per-type breakdown table (always shown) ──
|
|
435
|
+
log('');
|
|
436
|
+
log('── Per-Type Breakdown ──');
|
|
437
|
+
|
|
438
|
+
const typeHeader = { type: 'Type', pages: 'Pages', embedded: 'Embedded', coverage: 'Coverage', tokens: 'Tokens' };
|
|
439
|
+
const typeColWidths = {};
|
|
440
|
+
for (const key of Object.keys(typeHeader)) {
|
|
441
|
+
const maxData = Object.keys(typeStats).reduce((m, t) => {
|
|
442
|
+
const s = typeStats[t];
|
|
443
|
+
const vals = { type: t, pages: String(s.count), embedded: String(s.embedded), coverage: (s.count > 0 ? (s.embedded / s.count * 100).toFixed(1) + '%' : '0.0%'), tokens: s.tokens.toLocaleString() };
|
|
444
|
+
return Math.max(m, String(vals[key]).length);
|
|
445
|
+
}, 0);
|
|
446
|
+
typeColWidths[key] = Math.max(typeHeader[key].length, maxData);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
const pad = (s, w, align = 'left') => {
|
|
450
|
+
s = String(s);
|
|
451
|
+
return align === 'right' ? s.padStart(w) : s.padEnd(w);
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
const typeSep = Object.keys(typeHeader).map(k => '─'.repeat(typeColWidths[k] + 2)).join('┼');
|
|
455
|
+
const typeHeaderLine = Object.entries(typeHeader).map(([k, v]) => ` ${pad(v, typeColWidths[k])} `).join('│');
|
|
456
|
+
|
|
457
|
+
log(typeHeaderLine);
|
|
458
|
+
log(typeSep);
|
|
459
|
+
|
|
460
|
+
const sortedTypes = Object.keys(typeStats).sort((a, b) => {
|
|
461
|
+
const order = ['concepts', 'entities', 'topics', 'sources'];
|
|
462
|
+
return order.indexOf(a) - order.indexOf(b);
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
let grandPages = 0, grandEmbedded = 0, grandTokens = 0;
|
|
466
|
+
for (const type of sortedTypes) {
|
|
467
|
+
const s = typeStats[type];
|
|
468
|
+
grandPages += s.count;
|
|
469
|
+
grandEmbedded += s.embedded;
|
|
470
|
+
grandTokens += s.tokens;
|
|
471
|
+
const coverage = s.count > 0 ? (s.embedded / s.count * 100).toFixed(1) + '%' : '0.0%';
|
|
472
|
+
log([
|
|
473
|
+
` ${pad(type, typeColWidths.type)} `,
|
|
474
|
+
` ${pad(s.count, typeColWidths.pages, 'right')} `,
|
|
475
|
+
` ${pad(s.embedded, typeColWidths.embedded, 'right')} `,
|
|
476
|
+
` ${pad(coverage, typeColWidths.coverage, 'right')} `,
|
|
477
|
+
` ${pad(s.tokens.toLocaleString(), typeColWidths.tokens, 'right')} `,
|
|
478
|
+
].join('│'));
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
log(typeSep);
|
|
482
|
+
const grandCoverage = grandPages > 0 ? (grandEmbedded / grandPages * 100).toFixed(1) + '%' : '0.0%';
|
|
483
|
+
log([
|
|
484
|
+
` ${pad(`TOTAL`, typeColWidths.type)} `,
|
|
485
|
+
` ${pad(grandPages, typeColWidths.pages, 'right')} `,
|
|
486
|
+
` ${pad(grandEmbedded, typeColWidths.embedded, 'right')} `,
|
|
487
|
+
` ${pad(grandCoverage, typeColWidths.coverage, 'right')} `,
|
|
488
|
+
` ${pad(grandTokens.toLocaleString(), typeColWidths.tokens, 'right')} `,
|
|
489
|
+
].join('│'));
|
|
490
|
+
|
|
491
|
+
// ── Per-file table (only in verbose mode) ──
|
|
492
|
+
if (verbose) {
|
|
493
|
+
log('');
|
|
494
|
+
log('── Per-File Detail ──');
|
|
495
|
+
|
|
496
|
+
const rows = index.map(item => ({
|
|
497
|
+
path: item.path,
|
|
498
|
+
chunks: 1,
|
|
499
|
+
tokens: Math.ceil((item.size || 0) / 4),
|
|
500
|
+
size: item.size || 0,
|
|
501
|
+
hasVec: !!item.embedding,
|
|
502
|
+
}));
|
|
503
|
+
|
|
504
|
+
const header = { path: 'Path', chunks: 'Chunks', tokens: 'Tokens', size: 'Size', hasVec: 'Vector' };
|
|
505
|
+
const colWidths = {};
|
|
506
|
+
for (const key of Object.keys(header)) {
|
|
507
|
+
const maxData = rows.reduce((m, r) => Math.max(m, String(r[key]).length), 0);
|
|
508
|
+
colWidths[key] = Math.max(header[key].length, maxData);
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const sep = Object.values(header).map((h, i) => {
|
|
512
|
+
const key = Object.keys(header)[i];
|
|
513
|
+
return '─'.repeat(colWidths[key] + 2);
|
|
514
|
+
}).join('┼');
|
|
515
|
+
|
|
516
|
+
const headerLine = Object.entries(header).map(([k, v]) => ` ${pad(v, colWidths[k])} `).join('│');
|
|
517
|
+
log(headerLine);
|
|
518
|
+
log(sep);
|
|
519
|
+
|
|
520
|
+
let totalTokens = 0, totalSize = 0;
|
|
521
|
+
for (const r of rows) {
|
|
522
|
+
const tokens = r.tokens;
|
|
523
|
+
totalTokens += tokens;
|
|
524
|
+
totalSize += r.size;
|
|
525
|
+
const line = [
|
|
526
|
+
` ${pad(r.path, colWidths.path)} `,
|
|
527
|
+
` ${pad(r.chunks, colWidths.chunks, 'right')} `,
|
|
528
|
+
` ${pad(tokens.toLocaleString(), colWidths.tokens, 'right')} `,
|
|
529
|
+
` ${pad(r.size.toLocaleString(), colWidths.size, 'right')} `,
|
|
530
|
+
` ${pad(r.hasVec ? '✓' : '✗', colWidths.hasVec)} `,
|
|
531
|
+
].join('│');
|
|
532
|
+
log(line);
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
log(sep);
|
|
536
|
+
const summaryLine = [
|
|
537
|
+
` ${pad(`TOTAL (${rows.length} entries)`, colWidths.path)} `,
|
|
538
|
+
` ${pad(rows.length, colWidths.chunks, 'right')} `,
|
|
539
|
+
` ${pad(totalTokens.toLocaleString(), colWidths.tokens, 'right')} `,
|
|
540
|
+
` ${pad(totalSize.toLocaleString(), colWidths.size, 'right')} `,
|
|
541
|
+
` ${pad(`${hasEmbed}/${rows.length}`, colWidths.hasVec)} `,
|
|
542
|
+
].join('│');
|
|
543
|
+
log(summaryLine);
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
log('');
|
|
547
|
+
log(` Store: ${INDEX_FILE}`);
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
// ─── Ask (LLM Q&A) ───
|
|
551
|
+
|
|
552
|
+
async function askQuestion(queryText) {
|
|
553
|
+
if (!API_KEY) throw new Error('需要 ZHIPUAI_API_KEY 环境变量');
|
|
554
|
+
if (!existsSync(INDEX_FILE)) { log('❌ 索引不存在,先运行 build'); return; }
|
|
555
|
+
|
|
556
|
+
// Step 1 & 2: Find top 5 relevant passages using existing queryIndex logic
|
|
557
|
+
const results = await queryIndex(queryText, 5);
|
|
558
|
+
if (!results || results.length === 0) {
|
|
559
|
+
log('未找到相关内容');
|
|
560
|
+
return;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Read file content for context passages
|
|
564
|
+
const contexts = [];
|
|
565
|
+
for (const r of results) {
|
|
566
|
+
const filePath = join(WIKI_DIR, r.path);
|
|
567
|
+
if (existsSync(filePath)) {
|
|
568
|
+
const content = readFileSync(filePath, 'utf8');
|
|
569
|
+
contexts.push({ path: r.path, title: r.title, content: content.slice(0, 2000) });
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if (contexts.length === 0) {
|
|
574
|
+
log('未找到可用的上下文文件');
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// Step 3: Call ZhipuAI chat completion API with context
|
|
579
|
+
const contextText = contexts.map((c, i) => `[${i + 1}] ${c.title} (${c.path})\n${c.content}`).join('\n\n---\n\n');
|
|
580
|
+
|
|
581
|
+
const systemPrompt = `你是一个知识助手。请根据以下提供的上下文内容回答用户的问题。要求:
|
|
582
|
+
1. 基于提供的上下文内容回答,不要编造信息
|
|
583
|
+
2. 在回答中引用来源,使用 [1], [2] 等标记
|
|
584
|
+
3. 如果上下文中没有足够的信息来回答问题,请如实说明
|
|
585
|
+
4. 回答要简洁准确
|
|
586
|
+
|
|
587
|
+
上下文:
|
|
588
|
+
${contextText}`;
|
|
589
|
+
|
|
590
|
+
log('🤖 生成回答...');
|
|
591
|
+
|
|
592
|
+
const res = await fetch(CHAT_API_URL, {
|
|
593
|
+
method: 'POST',
|
|
594
|
+
headers: {
|
|
595
|
+
'Content-Type': 'application/json',
|
|
596
|
+
'Authorization': `Bearer ${API_KEY}`
|
|
597
|
+
},
|
|
598
|
+
body: JSON.stringify({
|
|
599
|
+
model: CHAT_MODEL,
|
|
600
|
+
messages: [
|
|
601
|
+
{ role: 'system', content: systemPrompt },
|
|
602
|
+
{ role: 'user', content: queryText }
|
|
603
|
+
]
|
|
604
|
+
})
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
if (!res.ok) {
|
|
608
|
+
const err = await res.text();
|
|
609
|
+
throw new Error(`Chat API error ${res.status}: ${err}`);
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const data = await res.json();
|
|
613
|
+
const answer = data.choices?.[0]?.message?.content || '(无回答)';
|
|
614
|
+
|
|
615
|
+
// Step 4: Output answer + citation list
|
|
616
|
+
console.log('\n' + '─'.repeat(60));
|
|
617
|
+
console.log(answer);
|
|
618
|
+
console.log('\n' + '─'.repeat(60));
|
|
619
|
+
console.log('📎 引用来源:');
|
|
620
|
+
for (let i = 0; i < contexts.length; i++) {
|
|
621
|
+
console.log(` [${i + 1}] ${contexts[i].path} — ${contexts[i].title}`);
|
|
622
|
+
}
|
|
623
|
+
console.log();
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// ─── 模块化导出 ───
|
|
627
|
+
export function init() { return buildIndex(false); }
|
|
628
|
+
export function incrementalBuild() { return buildIndex(true); }
|
|
629
|
+
export { queryIndex as search };
|
|
630
|
+
export { queryIndex as query };
|
|
631
|
+
export function add(filePath) {
|
|
632
|
+
// 单文件添加到索引(未来扩展)
|
|
633
|
+
log(`📎 add: ${filePath} — 即将支持单文件增量索引`);
|
|
634
|
+
}
|
|
635
|
+
export function getStats(verbose = false, json = false) { return showStats(verbose, json); }
|
|
636
|
+
export function getIndexInfo() {
|
|
637
|
+
if (!existsSync(INDEX_FILE)) return null;
|
|
638
|
+
const raw = JSON.parse(readFileSync(INDEX_FILE, 'utf8'));
|
|
639
|
+
const index = Array.isArray(raw) ? raw : raw.pages || [];
|
|
640
|
+
return {
|
|
641
|
+
exists: true,
|
|
642
|
+
builtAt: raw.builtAt || null,
|
|
643
|
+
pageCount: index.length,
|
|
644
|
+
embeddedCount: index.filter(x => x.embedding).length,
|
|
645
|
+
mtime: statSync(INDEX_FILE).mtime.toISOString(),
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// ─── CLI ───
|
|
650
|
+
|
|
651
|
+
const cmd = process.argv[2];
|
|
652
|
+
|
|
653
|
+
const wantStats = process.argv.includes('--stats');
|
|
654
|
+
const verbose = process.argv.includes('--verbose') || process.argv.includes('-v');
|
|
655
|
+
const jsonMode = process.argv.includes('--json');
|
|
656
|
+
|
|
657
|
+
if (cmd === 'build') {
|
|
658
|
+
const incremental = process.argv.includes('--incremental') || process.argv.includes('-i');
|
|
659
|
+
buildIndex(incremental)
|
|
660
|
+
.then(() => { if (wantStats) { if (jsonMode) { const s = showStats(verbose, true); if (s) console.log(JSON.stringify(s, null, 2)); } else { showStats(verbose, false); } } })
|
|
661
|
+
.catch(e => { console.error(e.message); process.exit(1); });
|
|
662
|
+
} else if (cmd === 'query') {
|
|
663
|
+
const topIndex = process.argv.indexOf('--top');
|
|
664
|
+
const top = topIndex >= 0 ? Number.parseInt(process.argv[topIndex + 1], 10) : 10;
|
|
665
|
+
const args = process.argv.slice(3).filter((a, index, all) =>
|
|
666
|
+
a !== '--stats' && a !== '--verbose' && a !== '-v' && a !== '--json' &&
|
|
667
|
+
a !== '--top' && all[index - 1] !== '--top');
|
|
668
|
+
const q = args.join(' ');
|
|
669
|
+
if (!q) { log('用法: query "查询内容"'); process.exit(1); }
|
|
670
|
+
queryIndex(q, Number.isInteger(top) && top > 0 ? top : 10)
|
|
671
|
+
.then(() => { if (wantStats) { if (jsonMode) { const s = showStats(verbose, true); if (s) console.log(JSON.stringify(s, null, 2)); } else { showStats(verbose, false); } } })
|
|
672
|
+
.catch(e => { console.error(e.message); process.exit(1); });
|
|
673
|
+
} else if (cmd === 'search') {
|
|
674
|
+
const args = process.argv.slice(3).filter(a => a !== '--stats' && a !== '--verbose' && a !== '-v' && a !== '--json');
|
|
675
|
+
const q = args.join(' ');
|
|
676
|
+
if (!q) { log('用法: search "查询内容"'); process.exit(1); }
|
|
677
|
+
queryIndex(q, 5)
|
|
678
|
+
.then(() => { if (wantStats) { if (jsonMode) { const s = showStats(verbose, true); if (s) console.log(JSON.stringify(s, null, 2)); } else { showStats(verbose, false); } } })
|
|
679
|
+
.catch(e => { console.error(e.message); process.exit(1); });
|
|
680
|
+
} else if (cmd === 'ask') {
|
|
681
|
+
const args = process.argv.slice(3).filter(a => a !== '--stats' && a !== '--verbose' && a !== '-v' && a !== '--json');
|
|
682
|
+
const q = args.join(' ');
|
|
683
|
+
if (!q) { log('用法: ask "你的问题"'); process.exit(1); }
|
|
684
|
+
askQuestion(q)
|
|
685
|
+
.then(() => { if (wantStats) { if (jsonMode) { const s = showStats(verbose, true); if (s) console.log(JSON.stringify(s, null, 2)); } else { showStats(verbose, false); } } })
|
|
686
|
+
.catch(e => { console.error(e.message); process.exit(1); });
|
|
687
|
+
} else if (cmd === 'stats') {
|
|
688
|
+
if (jsonMode) { const s = showStats(verbose, true); if (s) console.log(JSON.stringify(s, null, 2)); }
|
|
689
|
+
else { showStats(verbose, false); }
|
|
690
|
+
} else {
|
|
691
|
+
console.log(`
|
|
692
|
+
KnowFlow Vector Store v2.1
|
|
693
|
+
用法:
|
|
694
|
+
node vector-store.mjs build 全量构建索引
|
|
695
|
+
node vector-store.mjs build --incremental 增量构建(只处理新增/修改文件)
|
|
696
|
+
node vector-store.mjs build --stats 构建后显示统计
|
|
697
|
+
node vector-store.mjs query "关键词" 语义搜索 Top10(混合排序)
|
|
698
|
+
node vector-store.mjs query "关键词" --stats 查询后显示统计
|
|
699
|
+
node vector-store.mjs search "query" 语义搜索 Top5(精简版)
|
|
700
|
+
node vector-store.mjs ask "你的问题" LLM 问答(基于检索上下文回答+引用来源)
|
|
701
|
+
node vector-store.mjs stats 查看统计
|
|
702
|
+
node vector-store.mjs stats --verbose 详细统计(含 per-file 明细表)
|
|
703
|
+
node vector-store.mjs stats --json JSON 格式输出统计
|
|
704
|
+
node vector-store.mjs build --stats --json 构建后 JSON 统计
|
|
705
|
+
node vector-store.mjs query "关键词" --stats --json 查询后 JSON 统计
|
|
706
|
+
|
|
707
|
+
特性:
|
|
708
|
+
✅ 增量更新 (--incremental): 基于 mtime,跳过未变化的文件
|
|
709
|
+
✅ 混合排序: 相似度 × 类型权重 (concepts×1.15 > entities×1.10 > topics×1 > sources×0.90)
|
|
710
|
+
✅ 失败重试: API 失败的页面不缓存,下次自动重试
|
|
711
|
+
✅ 小文件过滤: 跳过 <${MIN_SIZE}B 的空壳页
|
|
712
|
+
✅ 统计 (--stats): build/query 后追加显示索引统计(含构建时间)
|
|
713
|
+
✅ 详细统计 (--verbose): 显示 per-file 明细表 + per-type 分组统计
|
|
714
|
+
✅ JSON 输出 (--json): 配合 stats 或 --stats 输出结构化 JSON(含 --verbose 时追加 files 数组)
|
|
715
|
+
✅ builtAt: 索引文件记录 ISO 构建时间戳
|
|
716
|
+
`);
|
|
717
|
+
}
|