@customize-agent/knowledge 4.0.1 → 4.0.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/dist/chunking/bge-tokenizer.d.ts +12 -0
  2. package/dist/chunking/bge-tokenizer.js +70 -0
  3. package/dist/chunking/text-chunker.d.ts +20 -0
  4. package/dist/chunking/text-chunker.js +166 -53
  5. package/dist/classification/classifier.d.ts +1 -0
  6. package/dist/classification/classifier.js +1 -1
  7. package/dist/constants.d.ts +7 -0
  8. package/dist/constants.js +7 -0
  9. package/dist/core/change-tracker.d.ts +13 -0
  10. package/dist/core/change-tracker.js +13 -0
  11. package/dist/core/file-scanner.d.ts +13 -0
  12. package/dist/core/file-scanner.js +12 -0
  13. package/dist/core/index-state-store.d.ts +67 -0
  14. package/dist/core/index-state-store.js +204 -50
  15. package/dist/core/knowledge-base-manager.d.ts +24 -2
  16. package/dist/core/knowledge-base-manager.js +204 -52
  17. package/dist/core/multi-project-manager.d.ts +10 -0
  18. package/dist/core/multi-project-manager.js +21 -2
  19. package/dist/core/project-config.d.ts +4 -0
  20. package/dist/core/project-config.js +4 -0
  21. package/dist/core/project-id.d.ts +5 -0
  22. package/dist/core/project-id.js +5 -0
  23. package/dist/core/project-registry.d.ts +1 -0
  24. package/dist/core/project-registry.js +1 -0
  25. package/dist/dedup/dedup-engine.d.ts +3 -0
  26. package/dist/dedup/dedup-engine.js +1 -0
  27. package/dist/dedup/relationship-detector.d.ts +7 -0
  28. package/dist/dedup/relationship-detector.js +7 -0
  29. package/dist/embedding/embedding-provider.d.ts +32 -0
  30. package/dist/embedding/embedding-provider.js +136 -2
  31. package/dist/extraction/content-extractor.d.ts +32 -2
  32. package/dist/extraction/content-extractor.js +524 -124
  33. package/dist/extraction/external-extractor.d.ts +10 -0
  34. package/dist/extraction/external-extractor.js +6 -0
  35. package/dist/extraction/module-resolver.js +2 -2
  36. package/dist/index.d.ts +2 -2
  37. package/dist/index.js +4 -4
  38. package/dist/search/federation-search.d.ts +8 -0
  39. package/dist/search/federation-search.js +2 -0
  40. package/dist/types.d.ts +10 -0
  41. package/dist/vector/collection-manager.d.ts +3 -0
  42. package/dist/vector/collection-manager.js +3 -0
  43. package/dist/vector/hnsw-vector-store.d.ts +21 -0
  44. package/dist/vector/hnsw-vector-store.js +108 -0
  45. package/dist/vector/types.d.ts +8 -0
  46. package/dist/vector/vector-indexer.d.ts +15 -1
  47. package/dist/vector/vector-indexer.js +34 -5
  48. package/models/bge-small-zh-v1.5/config.json +31 -0
  49. package/models/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
  50. package/models/bge-small-zh-v1.5/special_tokens_map.json +7 -0
  51. package/models/bge-small-zh-v1.5/tokenizer.json +21278 -0
  52. package/models/bge-small-zh-v1.5/tokenizer_config.json +15 -0
  53. package/package.json +16 -9
  54. package/scripts/install-hnsw.cjs +47 -0
  55. package/dist/vector/sqlite-vec-store.d.ts +0 -38
  56. package/dist/vector/sqlite-vec-store.js +0 -203
@@ -10,7 +10,7 @@ import { createEmbeddingProviderFromEnvironment } from '../embedding/embedding-p
10
10
  import { ContentExtractor } from '../extraction/content-extractor.js';
11
11
  import { FederationSearch } from '../search/federation-search.js';
12
12
  import { CollectionManager } from '../vector/collection-manager.js';
13
- import { SQLiteVecClient, SQLiteVecVectorStore } from '../vector/sqlite-vec-store.js';
13
+ import { HNSWVectorStore } from '../vector/hnsw-vector-store.js';
14
14
  import { VectorIndexer } from '../vector/vector-indexer.js';
15
15
  import { ChangeTracker } from './change-tracker.js';
16
16
  import { KnowledgeFileScanner } from './file-scanner.js';
@@ -22,7 +22,7 @@ export class KnowledgeBaseManager {
22
22
  projectId;
23
23
  kbPath;
24
24
  store;
25
- sqliteVecClient;
25
+ vectorRoot;
26
26
  classifier = new FileClassifier();
27
27
  scanner = new KnowledgeFileScanner();
28
28
  collections = new CollectionManager();
@@ -61,7 +61,7 @@ export class KnowledgeBaseManager {
61
61
  dbPath = path.join(storageRoot, 'projects', options.projectId, 'kb.db');
62
62
  }
63
63
  this.store = new IndexStateStore(dbPath);
64
- this.sqliteVecClient = new SQLiteVecClient({ dbPath });
64
+ this.vectorRoot = path.join(path.dirname(dbPath), 'hnsw');
65
65
  }
66
66
  initialize() {
67
67
  if (this.scope === 'project' && this.projectRoot) {
@@ -76,6 +76,22 @@ export class KnowledgeBaseManager {
76
76
  fs.mkdirSync(this.kbPath, { recursive: true });
77
77
  }
78
78
  }
79
+ async forceReindexAll(options = {}) {
80
+ this.initialize();
81
+ const records = this.store.listRecords();
82
+ for (const record of records) {
83
+ await this.deleteVectorFile(record.collectionName, record.relativePath);
84
+ this.store.deleteRecord(record.relativePath);
85
+ }
86
+ return this.incrementalIndex(options);
87
+ }
88
+ async consumePendingIndexJobs(options = {}) {
89
+ this.initialize();
90
+ const jobs = this.store.listPendingIndexJobs(options.limit ?? 50);
91
+ if (jobs.length === 0)
92
+ return this.incrementalIndex(options);
93
+ return this.incrementalIndex({ ...options, onlyRelativePaths: jobs.map(job => job.relativePath) });
94
+ }
79
95
  async incrementalIndex(options = {}) {
80
96
  this.initialize();
81
97
  const previousOnProgress = this.onProgress;
@@ -88,9 +104,22 @@ export class KnowledgeBaseManager {
88
104
  const diskFiles = await this.scanner.scan(this.kbPath, [...kbIgnore, ...configIgnore]);
89
105
  const tracker = new ChangeTracker(this.store);
90
106
  const diff = await tracker.computeDiff(diskFiles, this.classifier, this.kbPath);
107
+ const onlyRelativePaths = options.onlyRelativePaths ? new Set(options.onlyRelativePaths) : undefined;
108
+ if (onlyRelativePaths) {
109
+ diff.newFiles = diff.newFiles.filter(file => onlyRelativePaths.has(file.relativePath));
110
+ diff.modifiedFiles = diff.modifiedFiles.filter(file => onlyRelativePaths.has(file.relativePath));
111
+ diff.deletedFiles = diff.deletedFiles.filter(file => onlyRelativePaths.has(file.relativePath));
112
+ diff.hasChanges = diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0;
113
+ for (const relativePath of onlyRelativePaths) {
114
+ const exists = diff.newFiles.some(file => file.relativePath === relativePath) || diff.modifiedFiles.some(file => file.relativePath === relativePath) || diff.deletedFiles.some(file => file.relativePath === relativePath);
115
+ if (!exists)
116
+ this.updateJobsForFile(relativePath, 'ERROR', 100, '待索引文件不存在或未发生变化', '待索引文件不存在或未发生变化');
117
+ }
118
+ }
91
119
  for (const deleted of diff.deletedFiles) {
92
120
  await this.deleteVectorFile(deleted.collectionName, deleted.relativePath);
93
121
  this.store.deleteRecord(deleted.relativePath);
122
+ this.updateJobsForFile(deleted.relativePath, 'SUCCESS', 100, '文件已删除,索引记录和向量已清理');
94
123
  }
95
124
  const now = Date.now();
96
125
  const indexedBefore = [...this.store.loadActiveRecords().values()];
@@ -102,12 +131,14 @@ export class KnowledgeBaseManager {
102
131
  ? this.collections.getCollectionName('global', file.category)
103
132
  : this.collections.getCollectionName('project', file.category, this.projectId);
104
133
  const basePercent = filesToIndex.length === 0 ? 40 : 20 + Math.round((index / filesToIndex.length) * 45);
134
+ this.updateJobsForFile(file.relativePath, 'PARSING', basePercent, `正在解析 ${file.relativePath}`);
105
135
  this.reportProgress({ stage: 'parsing', percent: basePercent, message: `正在解析 ${file.relativePath}`, filePath: file.relativePath });
106
136
  const extraction = await this.extractor.extract(file);
107
137
  extraction.metadata.textLength = extraction.text.length;
108
138
  if (!this.hasUsableContent(extraction.text, extraction.metadata)) {
109
139
  const reason = extraction.warnings[0] ?? '未解析出可用于模型的正文内容,已跳过向量化';
110
140
  diff.skippedFiles.push({ file, reason });
141
+ this.updateJobsForFile(file.relativePath, 'ERROR', 100, reason, reason);
111
142
  this.store.upsertRecord({
112
143
  relativePath: file.relativePath,
113
144
  category: file.category,
@@ -129,6 +160,7 @@ export class KnowledgeBaseManager {
129
160
  const normalizedDuplicate = !duplicate && normalizedHash
130
161
  ? this.store.findNormalizedDuplicate(normalizedHash, file.relativePath)
131
162
  : undefined;
163
+ this.updateJobsForFile(file.relativePath, 'CHUNKING', Math.min(80, basePercent + 10), `正在切片 ${file.relativePath}`);
132
164
  this.reportProgress({ stage: 'chunking', percent: Math.min(80, basePercent + 10), message: `正在切片 ${file.relativePath}`, filePath: file.relativePath });
133
165
  const chunks = this.chunker.chunk(extraction.text, file, extraction.metadata);
134
166
  this.reportProgress({ stage: 'chunking', percent: Math.min(84, basePercent + 14), message: `切片完成:${chunks.length} 块`, filePath: file.relativePath, chunkCount: chunks.length });
@@ -212,20 +244,31 @@ export class KnowledgeBaseManager {
212
244
  format: file.format,
213
245
  collectionName,
214
246
  });
247
+ this.updateJobsForFile(file.relativePath, options.vectorMode === 'defer' ? 'SUCCESS' : 'INDEXING', options.vectorMode === 'defer' ? 100 : 85, options.vectorMode === 'defer' ? '解析和切片已完成' : '等待向量入库');
215
248
  }
216
249
  const stats = this.getStats();
217
250
  this.store.setMetadata('last_incremental_index_at', String(now));
218
251
  this.store.setMetadata('total_chunks', String(stats.chunkCount));
219
252
  this.store.setMetadata('total_files_indexed', String(stats.fileCount));
253
+ const hasIndexChanges = diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0;
220
254
  if (options.vectorMode === 'defer') {
255
+ if (hasIndexChanges)
256
+ this.store.setMetadata('vector_index_status', 'pending');
221
257
  this.reportProgress({ stage: 'vectorizing', percent: 85, message: '解析和切片已完成,向量入库转入后台/稍后执行', chunkCount: stats.chunkCount, vectorStatus: this.getVectorStatus() });
222
- void this.ensureVectorIndexFresh(stats.chunkCount, diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0).catch(() => undefined);
223
258
  }
224
259
  else {
225
- await this.ensureVectorIndexFresh(stats.chunkCount, diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0);
260
+ await this.ensureVectorIndexFresh(stats.chunkCount, hasIndexChanges);
226
261
  }
227
262
  this.lastSkippedFiles = diff.skippedFiles;
228
263
  const vectorStatus = this.getVectorStatus();
264
+ if (options.vectorMode !== 'defer') {
265
+ for (const file of filesToIndex) {
266
+ if (vectorStatus.status === 'error')
267
+ this.updateJobsForFile(file.relativePath, 'ERROR', 100, 'HNSWLib 向量入库失败', vectorStatus.error);
268
+ else
269
+ this.updateJobsForFile(file.relativePath, 'SUCCESS', 100, '解析、切片和向量索引完成');
270
+ }
271
+ }
229
272
  const vectorDeferred = options.vectorMode === 'defer';
230
273
  this.reportProgress({
231
274
  stage: vectorDeferred || vectorStatus.status !== 'error' ? 'done' : 'error',
@@ -233,7 +276,7 @@ export class KnowledgeBaseManager {
233
276
  message: vectorDeferred
234
277
  ? '解析、切片和 SQLite 入库已完成,向量入库后台执行'
235
278
  : vectorStatus.status === 'error'
236
- ? '解析和切片已完成,sqlite-vec 向量待入库'
279
+ ? '解析和切片已完成,HNSWLib 向量待入库'
237
280
  : '知识库索引完成',
238
281
  chunkCount: stats.chunkCount,
239
282
  vectorStatus,
@@ -264,8 +307,10 @@ export class KnowledgeBaseManager {
264
307
  }
265
308
  const parentChunks = item.parentId ? this.store.getChunksByParent(item.filePath, item.parentId, 6) : [];
266
309
  const chunks = parentChunks.length > 0 ? parentChunks : this.store.getContextChunks(item.filePath, chunkIndex, 1);
267
- if (chunks.length === 0)
268
- return item;
310
+ if (chunks.length === 0) {
311
+ const document = this.store.getDocumentChunk(item.filePath);
312
+ return document ? { ...item, content: document.content, sectionTitle: item.sectionTitle ?? 'Document Parent' } : item;
313
+ }
269
314
  return {
270
315
  ...item,
271
316
  content: chunks.map(chunk => chunk.content).join('\n\n---\n\n'),
@@ -279,15 +324,19 @@ export class KnowledgeBaseManager {
279
324
  const start = Date.now();
280
325
  const weights = this.retrievalWeights(options.weights);
281
326
  const rewrittenQueries = await this.rewriteQueries(query);
282
- const keywordItems = rewrittenQueries.flatMap((rewritten, index) => this.keywordSearchItems(rewritten, limit * 2).map(item => ({ ...item, score: item.score * (index === 0 ? 1 : (weights.rewrite ?? 0.72)) })));
283
- const vectorItems = [];
284
- for (const rewritten of rewrittenQueries.slice(0, 3)) {
285
- try {
286
- vectorItems.push(...(await this.semanticSearch(rewritten, { ...options, limit: limit * 2 })).results);
327
+ const rankedLists = [];
328
+ for (const [queryIndex, rewritten] of rewrittenQueries.entries()) {
329
+ rankedLists.push({ source: 'keyword', items: this.keywordSearchItems(rewritten, limit * 3), queryIndex });
330
+ if (queryIndex < 3) {
331
+ try {
332
+ rankedLists.push({ source: 'vector', items: (await this.semanticSearch(rewritten, { ...options, limit: limit * 3 })).results, queryIndex });
333
+ }
334
+ catch { /* 向量搜索在混合搜索中是可选的 */ }
287
335
  }
288
- catch { /* vector search is optional in hybrid search */ }
289
336
  }
290
- const merged = this.mergeHybridItems([...keywordItems, ...vectorItems], limit * 2, weights).map(item => this.expandContext(item));
337
+ const keywordItems = rankedLists.filter(list => list.source === 'keyword').flatMap(list => list.items);
338
+ const vectorItems = rankedLists.filter(list => list.source === 'vector').flatMap(list => list.items);
339
+ const merged = this.mergeContexts(this.mergeHybridRankedLists(rankedLists, limit * 4, weights).map(item => this.expandContext(item)), limit * 2);
291
340
  const useLLMRerank = !!this.llmProvider;
292
341
  const preReranked = useLLMRerank ? merged : this.heuristicRerank(query, merged);
293
342
  const reranked = useLLMRerank ? (await this.llmRerank(query, preReranked)) : preReranked;
@@ -310,7 +359,7 @@ export class KnowledgeBaseManager {
310
359
  const queryEmbedding = await this.embeddingProvider.embedQuery(query);
311
360
  const search = new FederationSearch(this.vectorStores);
312
361
  try {
313
- return await search.search({
362
+ const result = await search.search({
314
363
  query,
315
364
  queryEmbedding,
316
365
  topK: options.limit ?? 10,
@@ -319,6 +368,7 @@ export class KnowledgeBaseManager {
319
368
  collections: options.collections,
320
369
  filters: options.filters,
321
370
  });
371
+ return { ...result, results: this.hydrateVectorResultsFromSqlite(result.results) };
322
372
  }
323
373
  catch {
324
374
  return { results: [], scopesSearched: this.scope === 'global' ? ['global'] : ['project'], queryTimeMs: 0 };
@@ -372,22 +422,25 @@ export class KnowledgeBaseManager {
372
422
  async uploadFile(fileName, content, targetRelativePath, onProgress, options = {}) {
373
423
  return this.uploadFiles([{ fileName, content, targetRelativePath }], onProgress, options);
374
424
  }
375
- async uploadFiles(files, onProgress, options = {}) {
425
+ async stageUploadedFiles(files, operationId = `upload-${Date.now()}`) {
376
426
  this.initialize();
377
- const uploadedPaths = [];
378
- for (const file of files) {
427
+ const jobs = [];
428
+ for (let index = 0; index < files.length; index++) {
429
+ const file = files[index];
379
430
  const relativePath = this.getUploadRelativePath(file.fileName, file.targetRelativePath);
380
431
  const targetPath = this.resolveKbRelativePath(relativePath);
381
432
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
382
433
  fs.writeFileSync(targetPath, file.content);
383
- uploadedPaths.push(relativePath);
384
- }
385
- for (const relativePath of uploadedPaths) {
386
434
  const record = this.store.listRecords().find(item => item.relativePath === relativePath);
387
435
  if (record)
388
436
  await this.deleteVectorFile(record.collectionName, relativePath);
389
437
  this.store.deleteRecord(relativePath);
438
+ jobs.push(this.store.enqueueIndexJob({ id: `${operationId}-${index}`, relativePath, message: '文件已落盘,等待后台解析' }));
390
439
  }
440
+ return jobs;
441
+ }
442
+ async uploadFiles(files, onProgress, options = {}) {
443
+ await this.stageUploadedFiles(files);
391
444
  return this.incrementalIndex({ onProgress, vectorMode: options.vectorMode });
392
445
  }
393
446
  listFailedFiles() {
@@ -398,12 +451,17 @@ export class KnowledgeBaseManager {
398
451
  const targetPath = this.resolveKbRelativePath(normalized);
399
452
  if (fs.existsSync(targetPath))
400
453
  fs.unlinkSync(targetPath);
401
- // 同步删除 sqlite-vec 向量数据,避免孤儿向量污染搜索结果
454
+ // 同步删除 HNSWLib 向量数据,避免孤儿向量污染搜索结果
402
455
  const record = this.store.listRecords().find(r => r.relativePath === normalized);
403
456
  if (record) {
404
457
  await this.deleteVectorFile(record.collectionName, normalized);
405
458
  }
406
459
  this.store.deleteRecord(normalized);
460
+ const stats = this.getStats();
461
+ this.store.setMetadata('total_chunks', String(stats.chunkCount));
462
+ this.store.setMetadata('total_files_indexed', String(stats.fileCount));
463
+ this.store.setMetadata('vector_indexed_chunks', String(stats.chunkCount));
464
+ this.store.setMetadata('last_vector_index_at', String(Date.now()));
407
465
  }
408
466
  tagFile(relativePath, tags) {
409
467
  this.store.setTags(this.normalizeRelativePath(relativePath), tags);
@@ -425,18 +483,39 @@ export class KnowledgeBaseManager {
425
483
  }
426
484
  async indexVectors(options = {}) {
427
485
  const chunks = this.store.listChunks(options);
428
- for (const collectionName of new Set(chunks.map(chunk => chunk.collectionName)))
486
+ const collectionNames = new Set(chunks.map(chunk => chunk.collectionName));
487
+ for (const collectionName of collectionNames)
429
488
  this.ensureVectorStore(collectionName);
430
- this.reportProgress({ stage: 'vectorizing', percent: 85, message: `正在写入 sqlite-vec 向量库,共 ${chunks.length} 个切片`, chunkCount: chunks.length });
489
+ if (!options.relativePath) {
490
+ for (const collectionName of collectionNames)
491
+ await this.vectorStores.get(collectionName)?.clearCollection?.();
492
+ }
493
+ else {
494
+ for (const collectionName of collectionNames)
495
+ await this.vectorStores.get(collectionName)?.deleteByFilePath(options.relativePath);
496
+ }
497
+ this.reportProgress({ stage: 'vectorizing', percent: 85, message: `正在写入 HNSWLib 向量库,共 ${chunks.length} 个切片`, chunkCount: chunks.length });
431
498
  const indexer = new VectorIndexer(this.embeddingProvider, this.vectorStores);
432
499
  try {
433
- const results = await indexer.indexChunks(chunks);
434
- this.store.setMetadata('embedding_model', this.embeddingProvider.model);
435
- this.store.setMetadata('embedding_dimension', String(this.embeddingProvider.dimensions));
500
+ const results = await indexer.indexChunks(chunks, {
501
+ onProgress: progress => {
502
+ const percent = 85 + Math.round((progress.processedChunks / Math.max(1, progress.totalChunks)) * 14);
503
+ const message = `正在分批向量化并写入:${progress.processedChunks}/${progress.totalChunks} 个切片`;
504
+ this.reportProgress({ stage: 'vectorizing', percent, message, chunkCount: progress.totalChunks });
505
+ if (options.relativePath)
506
+ this.updateJobsForFile(options.relativePath, 'INDEXING', percent, message);
507
+ },
508
+ });
509
+ const actualModel = results[0]?.embeddingModel ?? this.embeddingProvider.model;
510
+ const actualDimension = results[0]?.embeddingDimension ?? this.embeddingProvider.dimensions;
511
+ this.store.setMetadata('embedding_model', actualModel);
512
+ this.store.setMetadata('embedding_dimension', String(actualDimension));
436
513
  this.store.setMetadata('vector_indexed_chunks', String(chunks.length));
437
514
  this.store.setMetadata('vector_index_status', 'ready');
438
515
  this.store.setMetadata('vector_index_error', '');
439
516
  this.store.setMetadata('last_vector_index_at', String(Date.now()));
517
+ if (options.relativePath)
518
+ this.updateJobsForFile(options.relativePath, 'SUCCESS', 100, '解析、切片和向量索引完成');
440
519
  return results;
441
520
  }
442
521
  catch (error) {
@@ -444,7 +523,9 @@ export class KnowledgeBaseManager {
444
523
  this.store.setMetadata('vector_index_status', 'error');
445
524
  this.store.setMetadata('vector_index_error', message);
446
525
  this.store.setMetadata('last_vector_index_at', String(Date.now()));
447
- this.reportProgress({ stage: 'error', percent: 85, message: 'sqlite-vec 向量入库失败', chunkCount: chunks.length, vectorStatus: this.getVectorStatus() });
526
+ this.reportProgress({ stage: 'error', percent: 85, message: 'HNSWLib 向量入库失败', chunkCount: chunks.length, vectorStatus: this.getVectorStatus() });
527
+ if (options.relativePath)
528
+ this.updateJobsForFile(options.relativePath, 'ERROR', 100, 'HNSWLib 向量入库失败', message);
448
529
  return [];
449
530
  }
450
531
  }
@@ -462,13 +543,23 @@ export class KnowledgeBaseManager {
462
543
  lastIndexedAt: stats.lastIndexedAt,
463
544
  };
464
545
  }
546
+ listIndexJobsByPrefix(prefix) {
547
+ return this.store.listIndexJobsByPrefix(prefix);
548
+ }
549
+ countPendingIndexJobs() {
550
+ return this.store.countPendingIndexJobs();
551
+ }
552
+ failPendingIndexJobs(message) {
553
+ for (const job of this.store.listPendingIndexJobs(1_000))
554
+ this.store.updateIndexJob(job.id, { status: 'ERROR', percent: 100, message, errorMessage: message });
555
+ }
465
556
  getVectorStatus() {
466
557
  return {
467
558
  status: this.store.getMetadata('vector_index_status') ?? 'pending',
468
559
  error: this.store.getMetadata('vector_index_error') || undefined,
469
560
  indexedChunks: Number(this.store.getMetadata('vector_indexed_chunks') ?? 0),
470
561
  lastIndexedAt: Number(this.store.getMetadata('last_vector_index_at') ?? 0),
471
- backend: `SQLite + sqlite-vec (${this.sqliteVecClient.dbPath})`,
562
+ backend: `SQLite + HNSWLib (${this.vectorRoot})`,
472
563
  };
473
564
  }
474
565
  async rewriteQueries(query) {
@@ -599,10 +690,22 @@ ${resultsText}
599
690
  return this.heuristicRerank(query, items);
600
691
  }
601
692
  }
693
+ hydrateVectorResultsFromSqlite(items) {
694
+ return items.map(item => {
695
+ if (!item.rowid)
696
+ return item;
697
+ const chunk = this.store.getChunkByRowid(item.rowid);
698
+ if (!chunk)
699
+ return item;
700
+ const hydrated = this.toFederatedItem({ ...chunk, score: item.score, scoreDetails: item.scoreDetails }, 'vector');
701
+ return { ...hydrated, score: item.score, scoreDetails: item.scoreDetails };
702
+ });
703
+ }
602
704
  toFederatedItem(result, source) {
603
705
  const metadata = this.parseMetadata(result.metadataJson);
604
706
  return {
605
707
  id: result.id,
708
+ rowid: result.rowid,
606
709
  content: result.content,
607
710
  filePath: result.relativePath,
608
711
  scope: this.scope === 'global' ? 'global' : 'project',
@@ -618,32 +721,70 @@ ${resultsText}
618
721
  facets: this.metadataFacets(metadata),
619
722
  };
620
723
  }
621
- mergeHybridItems(items, limit, weights = this.retrievalWeights()) {
724
+ mergeHybridRankedLists(lists, limit, weights = this.retrievalWeights()) {
725
+ const k = Number(process.env.KB_RETRIEVAL_RRF_K ?? 60);
726
+ const byKey = new Map();
727
+ for (const list of lists) {
728
+ const sourceWeight = list.source === 'vector' ? (weights.vector ?? 0.9) : (weights.keyword ?? 1);
729
+ const rewriteWeight = list.queryIndex === 0 ? 1 : (weights.rewrite ?? 0.72);
730
+ list.items.forEach((item, index) => {
731
+ const key = this.contextKey(item);
732
+ const rankScore = sourceWeight * rewriteWeight / (k + index + 1);
733
+ const existing = byKey.get(key);
734
+ if (!existing) {
735
+ byKey.set(key, {
736
+ ...item,
737
+ score: rankScore,
738
+ source: list.source,
739
+ scoreDetails: { ...item.scoreDetails, hybridScore: rankScore },
740
+ seenSources: new Set([list.source]),
741
+ });
742
+ return;
743
+ }
744
+ existing.score += rankScore;
745
+ existing.seenSources?.add(list.source);
746
+ existing.source = existing.seenSources && existing.seenSources.size > 1 ? 'hybrid' : existing.source;
747
+ existing.scoreDetails = { ...existing.scoreDetails, ...item.scoreDetails, hybridScore: existing.score };
748
+ if (item.score > (existing.scoreDetails?.keywordScore ?? existing.scoreDetails?.vectorScore ?? 0)) {
749
+ existing.content = item.content;
750
+ existing.sectionTitle = item.sectionTitle ?? existing.sectionTitle;
751
+ existing.chunkIndex = item.chunkIndex ?? existing.chunkIndex;
752
+ existing.parentId = item.parentId ?? existing.parentId;
753
+ }
754
+ });
755
+ }
756
+ const hybridBonus = weights.hybridBonus ?? 0.35;
757
+ return [...byKey.values()].map(item => {
758
+ const bonus = item.seenSources && item.seenSources.size > 1 ? item.score * hybridBonus : 0;
759
+ const { seenSources: _seenSources, ...result } = item;
760
+ return { ...result, score: result.score + bonus, scoreDetails: { ...result.scoreDetails, hybridScore: result.score + bonus } };
761
+ }).sort((a, b) => b.score - a.score).slice(0, limit);
762
+ }
763
+ mergeContexts(items, limit) {
622
764
  const byKey = new Map();
623
765
  for (const item of items) {
624
- const key = `${item.scope}:${item.filePath}:${item.parentId ?? item.chunkIndex ?? item.id}`;
625
- const sourceWeight = item.source === 'vector' ? (weights.vector ?? 0.9) : item.source === 'keyword' ? (weights.keyword ?? 1) : 1.1;
626
- const weighted = { ...item, score: item.score * sourceWeight, scoreDetails: { ...item.scoreDetails, hybridScore: item.score * sourceWeight } };
766
+ const key = this.contextKey(item);
627
767
  const existing = byKey.get(key);
628
768
  if (!existing) {
629
- byKey.set(key, weighted);
630
- }
631
- else {
632
- const score = Math.max(existing.score, weighted.score) + Math.min(existing.score, weighted.score) * (weights.hybridBonus ?? 0.35);
633
- byKey.set(key, {
634
- ...existing,
635
- score,
636
- source: existing.source === weighted.source ? existing.source : 'hybrid',
637
- scoreDetails: {
638
- ...existing.scoreDetails,
639
- ...weighted.scoreDetails,
640
- hybridScore: score,
641
- },
642
- });
769
+ byKey.set(key, item);
770
+ continue;
643
771
  }
772
+ const score = Math.max(existing.score, item.score);
773
+ byKey.set(key, {
774
+ ...existing,
775
+ score,
776
+ source: existing.source === item.source ? existing.source : 'hybrid',
777
+ content: existing.content.length >= item.content.length ? existing.content : item.content,
778
+ scoreDetails: { ...existing.scoreDetails, ...item.scoreDetails, hybridScore: score },
779
+ });
644
780
  }
645
781
  return [...byKey.values()].sort((a, b) => b.score - a.score).slice(0, limit);
646
782
  }
783
+ contextKey(item) {
784
+ if (item.rowid)
785
+ return `${item.scope}:rowid:${item.rowid}`;
786
+ return `${item.scope}:${item.filePath}:${item.parentId ?? item.chunkIndex ?? item.id}`;
787
+ }
647
788
  parseChunkIndex(id) {
648
789
  const match = /#(\d+)$/u.exec(id);
649
790
  return match ? Number(match[1]) : 0;
@@ -688,10 +829,16 @@ ${resultsText}
688
829
  reportProgress(progress) {
689
830
  this.onProgress?.(progress);
690
831
  }
832
+ updateJobsForFile(relativePath, status, percent, message, errorMessage) {
833
+ for (const job of this.store.listActiveIndexJobsByPath(relativePath)) {
834
+ this.store.updateIndexJob(job.id, { status, percent, message, errorMessage });
835
+ }
836
+ }
691
837
  ensureVectorStore(collectionName) {
692
838
  if (this.vectorStores.has(collectionName))
693
839
  return;
694
- this.vectorStores.set(collectionName, new SQLiteVecVectorStore(this.sqliteVecClient, collectionName));
840
+ const safeName = collectionName.replace(/[^a-zA-Z0-9_.-]/gu, '_');
841
+ this.vectorStores.set(collectionName, new HNSWVectorStore(collectionName, path.join(this.vectorRoot, `${safeName}.hnsw`), this.embeddingProvider.dimensions));
695
842
  }
696
843
  async deleteVectorFile(collectionName, relativePath) {
697
844
  this.ensureVectorStore(collectionName);
@@ -706,10 +853,15 @@ ${resultsText}
706
853
  async ensureVectorIndexFresh(chunkCount, force = false) {
707
854
  if (chunkCount === 0)
708
855
  return;
856
+ for (const record of this.store.listRecords())
857
+ this.ensureVectorStore(record.collectionName);
858
+ if ([...this.vectorStores.values()].some(store => store.needsRebuild?.())) {
859
+ await this.indexVectors();
860
+ return;
861
+ }
709
862
  const indexedChunks = Number(this.store.getMetadata('vector_indexed_chunks') ?? 0);
710
- const model = this.store.getMetadata('embedding_model');
711
- const dimension = this.store.getMetadata('embedding_dimension');
712
- if (!force && indexedChunks === chunkCount && model === this.embeddingProvider.model && dimension === String(this.embeddingProvider.dimensions))
863
+ const status = this.store.getMetadata('vector_index_status');
864
+ if (!force && indexedChunks === chunkCount && status === 'ready')
713
865
  return;
714
866
  await this.indexVectors();
715
867
  }
@@ -2,15 +2,24 @@ import type { FederatedResult, RetrievalWeights, SearchFilters, SearchScope } fr
2
2
  import type { CrossProjectDuplicate, ProjectInfo } from '../types.js';
3
3
  import { KnowledgeBaseManager } from './knowledge-base-manager.js';
4
4
  import type { LLMSearchProvider } from '../llm/llm-search-provider.js';
5
+ /** 多项目管理器,管理多个项目的知识库并支持跨项目搜索 */
5
6
  export declare class MultiProjectManager {
6
7
  private readonly storageRoot;
7
8
  private readonly llmProvider?;
8
9
  private readonly registry;
9
10
  private readonly configManager;
10
11
  private readonly projects;
12
+ private readonly lastSearchIndexCheck;
11
13
  private globalKB?;
12
14
  constructor(storageRoot?: string, llmProvider?: LLMSearchProvider);
15
+ /**
16
+ * 获取或创建指定项目的知识库管理器
17
+ * @param projectRoot 项目根目录
18
+ */
13
19
  getProject(projectRoot: string): Promise<KnowledgeBaseManager>;
20
+ /**
21
+ * 获取或初始化全局知识库
22
+ */
14
23
  getGlobalKB(): Promise<KnowledgeBaseManager>;
15
24
  listProjects(): Promise<ProjectInfo[]>;
16
25
  search(projectRoot: string, query: string, options?: {
@@ -28,6 +37,7 @@ export declare class MultiProjectManager {
28
37
  forgetProject(projectId: string): Promise<void>;
29
38
  closeProject(projectId: string): Promise<void>;
30
39
  shutdown(): Promise<void>;
40
+ private ensureFreshForSearch;
31
41
  private mergeDebug;
32
42
  private updateRegistry;
33
43
  }
@@ -6,12 +6,14 @@ import { KnowledgeBaseManager } from './knowledge-base-manager.js';
6
6
  import { computeProjectId } from './project-id.js';
7
7
  import { getProjectKbPath, ProjectConfigManager } from './project-config.js';
8
8
  import { ProjectRegistry } from './project-registry.js';
9
+ /** 多项目管理器,管理多个项目的知识库并支持跨项目搜索 */
9
10
  export class MultiProjectManager {
10
11
  storageRoot;
11
12
  llmProvider;
12
13
  registry;
13
14
  configManager;
14
15
  projects = new Map();
16
+ lastSearchIndexCheck = new Map();
15
17
  globalKB;
16
18
  constructor(storageRoot = path.join(os.homedir(), USER_DATA_DIR), llmProvider) {
17
19
  this.storageRoot = storageRoot;
@@ -19,6 +21,10 @@ export class MultiProjectManager {
19
21
  this.registry = new ProjectRegistry(path.join(storageRoot, 'projects', 'registry.db'));
20
22
  this.configManager = new ProjectConfigManager(storageRoot);
21
23
  }
24
+ /**
25
+ * 获取或创建指定项目的知识库管理器
26
+ * @param projectRoot 项目根目录
27
+ */
22
28
  async getProject(projectRoot) {
23
29
  const resolvedRoot = path.resolve(projectRoot);
24
30
  const projectId = computeProjectId(resolvedRoot);
@@ -39,6 +45,9 @@ export class MultiProjectManager {
39
45
  this.updateRegistry(manager, resolvedRoot, config.projectName, config.lastOpenedAt);
40
46
  return manager;
41
47
  }
48
+ /**
49
+ * 获取或初始化全局知识库
50
+ */
42
51
  async getGlobalKB() {
43
52
  if (this.globalKB)
44
53
  return this.globalKB;
@@ -55,7 +64,7 @@ export class MultiProjectManager {
55
64
  const limit = options.limit ?? 10;
56
65
  const scope = options.scope ?? 'all';
57
66
  const project = await this.getProject(projectRoot);
58
- await project.incrementalIndex();
67
+ await this.ensureFreshForSearch(projectRoot, project);
59
68
  if (scope === 'project')
60
69
  return project.hybridSearch(query, { limit, weights: options.weights });
61
70
  const projectResults = scope === 'all'
@@ -76,7 +85,7 @@ export class MultiProjectManager {
76
85
  async semanticSearch(projectRoot, query, options = {}) {
77
86
  const scope = options.scope ?? 'all';
78
87
  const project = await this.getProject(projectRoot);
79
- await project.incrementalIndex();
88
+ await this.ensureFreshForSearch(projectRoot, project);
80
89
  if (scope === 'project') {
81
90
  return project.semanticSearch(query, options);
82
91
  }
@@ -141,6 +150,16 @@ export class MultiProjectManager {
141
150
  this.globalKB = undefined;
142
151
  this.registry.close();
143
152
  }
153
+ async ensureFreshForSearch(projectRoot, project) {
154
+ const now = Date.now();
155
+ const key = path.resolve(projectRoot);
156
+ const ttlMs = Number(process.env.KB_SEARCH_INDEX_TTL_MS ?? 30000);
157
+ const last = this.lastSearchIndexCheck.get(key) ?? 0;
158
+ if (now - last < ttlMs)
159
+ return;
160
+ this.lastSearchIndexCheck.set(key, now);
161
+ await project.incrementalIndex({ vectorMode: 'defer' });
162
+ }
144
163
  mergeDebug(projectDebug, globalDebug) {
145
164
  if (!projectDebug && !globalDebug)
146
165
  return undefined;
@@ -1,7 +1,11 @@
1
1
  import type { ProjectConfig } from '../types.js';
2
+ /** 获取项目配置文件路径 */
2
3
  export declare function getProjectConfigPath(projectRoot: string, storageRoot?: string): string;
4
+ /** 获取项目知识库目录路径 */
3
5
  export declare function getProjectKbPath(projectRoot: string): string;
6
+ /** 确保项目存在 CUSTOMIZE.md 文件(如不存在则创建默认模板) */
4
7
  export declare function ensureProjectCustomizeFile(projectRoot: string): void;
8
+ /** 项目配置管理器,负责加载和保存项目配置 */
5
9
  export declare class ProjectConfigManager {
6
10
  private readonly storageRoot;
7
11
  constructor(storageRoot?: string);
@@ -3,10 +3,12 @@ import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
4
  import { DEFAULT_CATEGORY_DIRS, KNOWLEDGE_BASE_DIR, USER_DATA_DIR } from '../constants.js';
5
5
  import { computeProjectId } from './project-id.js';
6
+ /** 获取项目配置文件路径 */
6
7
  export function getProjectConfigPath(projectRoot, storageRoot = path.join(os.homedir(), USER_DATA_DIR)) {
7
8
  const projectId = computeProjectId(projectRoot);
8
9
  return path.join(storageRoot, 'projects', projectId, 'project.json');
9
10
  }
11
+ /** 获取项目知识库目录路径 */
10
12
  export function getProjectKbPath(projectRoot) {
11
13
  return path.join(projectRoot, KNOWLEDGE_BASE_DIR);
12
14
  }
@@ -27,6 +29,7 @@ const DEFAULT_CUSTOMIZE_MD = `# Customize Agent 配置示例
27
29
  - 使用中文回复。
28
30
  - 重要改动完成后运行必要的类型检查或构建检查。
29
31
  `;
32
+ /** 确保项目存在 CUSTOMIZE.md 文件(如不存在则创建默认模板) */
30
33
  export function ensureProjectCustomizeFile(projectRoot) {
31
34
  const filePath = path.join(projectRoot, 'CUSTOMIZE.md');
32
35
  if (!fs.existsSync(filePath)) {
@@ -34,6 +37,7 @@ export function ensureProjectCustomizeFile(projectRoot) {
34
37
  fs.writeFileSync(filePath, DEFAULT_CUSTOMIZE_MD, 'utf8');
35
38
  }
36
39
  }
40
+ /** 项目配置管理器,负责加载和保存项目配置 */
37
41
  export class ProjectConfigManager {
38
42
  storageRoot;
39
43
  constructor(storageRoot = path.join(os.homedir(), USER_DATA_DIR)) {
@@ -1 +1,6 @@
1
+ /**
2
+ * 使用项目根目录计算项目唯一标识
3
+ * @param projectRoot 项目根目录路径
4
+ * @returns 项目 ID(SHA-256 前 12 位)
5
+ */
1
6
  export declare function computeProjectId(projectRoot: string): string;
@@ -1,5 +1,10 @@
1
1
  import * as crypto from 'node:crypto';
2
2
  import * as path from 'node:path';
3
+ /**
4
+ * 使用项目根目录计算项目唯一标识
5
+ * @param projectRoot 项目根目录路径
6
+ * @returns 项目 ID(SHA-256 前 12 位)
7
+ */
3
8
  export function computeProjectId(projectRoot) {
4
9
  return crypto.createHash('sha256')
5
10
  .update(path.resolve(projectRoot))
@@ -1,4 +1,5 @@
1
1
  import type { ProjectInfo } from '../types.js';
2
+ /** 项目注册表,使用 SQLite 管理已注册项目的元数据 */
2
3
  export declare class ProjectRegistry {
3
4
  private readonly db;
4
5
  constructor(dbPath: string);
@@ -1,6 +1,7 @@
1
1
  import Database from 'better-sqlite3';
2
2
  import * as fs from 'node:fs';
3
3
  import * as path from 'node:path';
4
+ /** 项目注册表,使用 SQLite 管理已注册项目的元数据 */
4
5
  export class ProjectRegistry {
5
6
  db;
6
7
  constructor(dbPath) {