@customize-agent/knowledge 4.0.1 → 4.0.2

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 (33) hide show
  1. package/dist/chunking/bge-tokenizer.d.ts +10 -0
  2. package/dist/chunking/bge-tokenizer.js +68 -0
  3. package/dist/chunking/text-chunker.d.ts +10 -0
  4. package/dist/chunking/text-chunker.js +158 -53
  5. package/dist/classification/classifier.js +0 -1
  6. package/dist/core/index-state-store.d.ts +47 -0
  7. package/dist/core/index-state-store.js +184 -50
  8. package/dist/core/knowledge-base-manager.d.ts +24 -2
  9. package/dist/core/knowledge-base-manager.js +195 -51
  10. package/dist/core/multi-project-manager.d.ts +2 -0
  11. package/dist/core/multi-project-manager.js +13 -2
  12. package/dist/embedding/embedding-provider.d.ts +22 -0
  13. package/dist/embedding/embedding-provider.js +116 -2
  14. package/dist/extraction/content-extractor.d.ts +30 -2
  15. package/dist/extraction/content-extractor.js +514 -115
  16. package/dist/index.d.ts +2 -2
  17. package/dist/index.js +2 -2
  18. package/dist/search/federation-search.d.ts +1 -0
  19. package/dist/search/federation-search.js +1 -0
  20. package/dist/vector/hnsw-vector-store.d.ts +20 -0
  21. package/dist/vector/hnsw-vector-store.js +107 -0
  22. package/dist/vector/types.d.ts +2 -0
  23. package/dist/vector/vector-indexer.d.ts +2 -0
  24. package/dist/vector/vector-indexer.js +13 -1
  25. package/models/bge-small-zh-v1.5/config.json +31 -0
  26. package/models/bge-small-zh-v1.5/onnx/model_quantized.onnx +0 -0
  27. package/models/bge-small-zh-v1.5/special_tokens_map.json +7 -0
  28. package/models/bge-small-zh-v1.5/tokenizer.json +21278 -0
  29. package/models/bge-small-zh-v1.5/tokenizer_config.json +15 -0
  30. package/package.json +11 -4
  31. package/scripts/install-hnsw.cjs +47 -0
  32. package/dist/vector/sqlite-vec-store.d.ts +0 -38
  33. 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 { /* vector search is optional in hybrid search */ }
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,31 @@ 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
500
  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));
501
+ const actualModel = results[0]?.embeddingModel ?? this.embeddingProvider.model;
502
+ const actualDimension = results[0]?.embeddingDimension ?? this.embeddingProvider.dimensions;
503
+ this.store.setMetadata('embedding_model', actualModel);
504
+ this.store.setMetadata('embedding_dimension', String(actualDimension));
436
505
  this.store.setMetadata('vector_indexed_chunks', String(chunks.length));
437
506
  this.store.setMetadata('vector_index_status', 'ready');
438
507
  this.store.setMetadata('vector_index_error', '');
439
508
  this.store.setMetadata('last_vector_index_at', String(Date.now()));
509
+ if (options.relativePath)
510
+ this.updateJobsForFile(options.relativePath, 'SUCCESS', 100, '解析、切片和向量索引完成');
440
511
  return results;
441
512
  }
442
513
  catch (error) {
@@ -444,7 +515,9 @@ export class KnowledgeBaseManager {
444
515
  this.store.setMetadata('vector_index_status', 'error');
445
516
  this.store.setMetadata('vector_index_error', message);
446
517
  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() });
518
+ this.reportProgress({ stage: 'error', percent: 85, message: 'HNSWLib 向量入库失败', chunkCount: chunks.length, vectorStatus: this.getVectorStatus() });
519
+ if (options.relativePath)
520
+ this.updateJobsForFile(options.relativePath, 'ERROR', 100, 'HNSWLib 向量入库失败', message);
448
521
  return [];
449
522
  }
450
523
  }
@@ -462,13 +535,23 @@ export class KnowledgeBaseManager {
462
535
  lastIndexedAt: stats.lastIndexedAt,
463
536
  };
464
537
  }
538
+ listIndexJobsByPrefix(prefix) {
539
+ return this.store.listIndexJobsByPrefix(prefix);
540
+ }
541
+ countPendingIndexJobs() {
542
+ return this.store.countPendingIndexJobs();
543
+ }
544
+ failPendingIndexJobs(message) {
545
+ for (const job of this.store.listPendingIndexJobs(1_000))
546
+ this.store.updateIndexJob(job.id, { status: 'ERROR', percent: 100, message, errorMessage: message });
547
+ }
465
548
  getVectorStatus() {
466
549
  return {
467
550
  status: this.store.getMetadata('vector_index_status') ?? 'pending',
468
551
  error: this.store.getMetadata('vector_index_error') || undefined,
469
552
  indexedChunks: Number(this.store.getMetadata('vector_indexed_chunks') ?? 0),
470
553
  lastIndexedAt: Number(this.store.getMetadata('last_vector_index_at') ?? 0),
471
- backend: `SQLite + sqlite-vec (${this.sqliteVecClient.dbPath})`,
554
+ backend: `SQLite + HNSWLib (${this.vectorRoot})`,
472
555
  };
473
556
  }
474
557
  async rewriteQueries(query) {
@@ -599,10 +682,22 @@ ${resultsText}
599
682
  return this.heuristicRerank(query, items);
600
683
  }
601
684
  }
685
+ hydrateVectorResultsFromSqlite(items) {
686
+ return items.map(item => {
687
+ if (!item.rowid)
688
+ return item;
689
+ const chunk = this.store.getChunkByRowid(item.rowid);
690
+ if (!chunk)
691
+ return item;
692
+ const hydrated = this.toFederatedItem({ ...chunk, score: item.score, scoreDetails: item.scoreDetails }, 'vector');
693
+ return { ...hydrated, score: item.score, scoreDetails: item.scoreDetails };
694
+ });
695
+ }
602
696
  toFederatedItem(result, source) {
603
697
  const metadata = this.parseMetadata(result.metadataJson);
604
698
  return {
605
699
  id: result.id,
700
+ rowid: result.rowid,
606
701
  content: result.content,
607
702
  filePath: result.relativePath,
608
703
  scope: this.scope === 'global' ? 'global' : 'project',
@@ -618,32 +713,70 @@ ${resultsText}
618
713
  facets: this.metadataFacets(metadata),
619
714
  };
620
715
  }
621
- mergeHybridItems(items, limit, weights = this.retrievalWeights()) {
716
+ mergeHybridRankedLists(lists, limit, weights = this.retrievalWeights()) {
717
+ const k = Number(process.env.KB_RETRIEVAL_RRF_K ?? 60);
718
+ const byKey = new Map();
719
+ for (const list of lists) {
720
+ const sourceWeight = list.source === 'vector' ? (weights.vector ?? 0.9) : (weights.keyword ?? 1);
721
+ const rewriteWeight = list.queryIndex === 0 ? 1 : (weights.rewrite ?? 0.72);
722
+ list.items.forEach((item, index) => {
723
+ const key = this.contextKey(item);
724
+ const rankScore = sourceWeight * rewriteWeight / (k + index + 1);
725
+ const existing = byKey.get(key);
726
+ if (!existing) {
727
+ byKey.set(key, {
728
+ ...item,
729
+ score: rankScore,
730
+ source: list.source,
731
+ scoreDetails: { ...item.scoreDetails, hybridScore: rankScore },
732
+ seenSources: new Set([list.source]),
733
+ });
734
+ return;
735
+ }
736
+ existing.score += rankScore;
737
+ existing.seenSources?.add(list.source);
738
+ existing.source = existing.seenSources && existing.seenSources.size > 1 ? 'hybrid' : existing.source;
739
+ existing.scoreDetails = { ...existing.scoreDetails, ...item.scoreDetails, hybridScore: existing.score };
740
+ if (item.score > (existing.scoreDetails?.keywordScore ?? existing.scoreDetails?.vectorScore ?? 0)) {
741
+ existing.content = item.content;
742
+ existing.sectionTitle = item.sectionTitle ?? existing.sectionTitle;
743
+ existing.chunkIndex = item.chunkIndex ?? existing.chunkIndex;
744
+ existing.parentId = item.parentId ?? existing.parentId;
745
+ }
746
+ });
747
+ }
748
+ const hybridBonus = weights.hybridBonus ?? 0.35;
749
+ return [...byKey.values()].map(item => {
750
+ const bonus = item.seenSources && item.seenSources.size > 1 ? item.score * hybridBonus : 0;
751
+ const { seenSources: _seenSources, ...result } = item;
752
+ return { ...result, score: result.score + bonus, scoreDetails: { ...result.scoreDetails, hybridScore: result.score + bonus } };
753
+ }).sort((a, b) => b.score - a.score).slice(0, limit);
754
+ }
755
+ mergeContexts(items, limit) {
622
756
  const byKey = new Map();
623
757
  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 } };
758
+ const key = this.contextKey(item);
627
759
  const existing = byKey.get(key);
628
760
  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
- });
761
+ byKey.set(key, item);
762
+ continue;
643
763
  }
764
+ const score = Math.max(existing.score, item.score);
765
+ byKey.set(key, {
766
+ ...existing,
767
+ score,
768
+ source: existing.source === item.source ? existing.source : 'hybrid',
769
+ content: existing.content.length >= item.content.length ? existing.content : item.content,
770
+ scoreDetails: { ...existing.scoreDetails, ...item.scoreDetails, hybridScore: score },
771
+ });
644
772
  }
645
773
  return [...byKey.values()].sort((a, b) => b.score - a.score).slice(0, limit);
646
774
  }
775
+ contextKey(item) {
776
+ if (item.rowid)
777
+ return `${item.scope}:rowid:${item.rowid}`;
778
+ return `${item.scope}:${item.filePath}:${item.parentId ?? item.chunkIndex ?? item.id}`;
779
+ }
647
780
  parseChunkIndex(id) {
648
781
  const match = /#(\d+)$/u.exec(id);
649
782
  return match ? Number(match[1]) : 0;
@@ -688,10 +821,16 @@ ${resultsText}
688
821
  reportProgress(progress) {
689
822
  this.onProgress?.(progress);
690
823
  }
824
+ updateJobsForFile(relativePath, status, percent, message, errorMessage) {
825
+ for (const job of this.store.listActiveIndexJobsByPath(relativePath)) {
826
+ this.store.updateIndexJob(job.id, { status, percent, message, errorMessage });
827
+ }
828
+ }
691
829
  ensureVectorStore(collectionName) {
692
830
  if (this.vectorStores.has(collectionName))
693
831
  return;
694
- this.vectorStores.set(collectionName, new SQLiteVecVectorStore(this.sqliteVecClient, collectionName));
832
+ const safeName = collectionName.replace(/[^a-zA-Z0-9_.-]/gu, '_');
833
+ this.vectorStores.set(collectionName, new HNSWVectorStore(collectionName, path.join(this.vectorRoot, `${safeName}.hnsw`), this.embeddingProvider.dimensions));
695
834
  }
696
835
  async deleteVectorFile(collectionName, relativePath) {
697
836
  this.ensureVectorStore(collectionName);
@@ -706,10 +845,15 @@ ${resultsText}
706
845
  async ensureVectorIndexFresh(chunkCount, force = false) {
707
846
  if (chunkCount === 0)
708
847
  return;
848
+ for (const record of this.store.listRecords())
849
+ this.ensureVectorStore(record.collectionName);
850
+ if ([...this.vectorStores.values()].some(store => store.needsRebuild?.())) {
851
+ await this.indexVectors();
852
+ return;
853
+ }
709
854
  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))
855
+ const status = this.store.getMetadata('vector_index_status');
856
+ if (!force && indexedChunks === chunkCount && status === 'ready')
713
857
  return;
714
858
  await this.indexVectors();
715
859
  }
@@ -8,6 +8,7 @@ export declare class MultiProjectManager {
8
8
  private readonly registry;
9
9
  private readonly configManager;
10
10
  private readonly projects;
11
+ private readonly lastSearchIndexCheck;
11
12
  private globalKB?;
12
13
  constructor(storageRoot?: string, llmProvider?: LLMSearchProvider);
13
14
  getProject(projectRoot: string): Promise<KnowledgeBaseManager>;
@@ -28,6 +29,7 @@ export declare class MultiProjectManager {
28
29
  forgetProject(projectId: string): Promise<void>;
29
30
  closeProject(projectId: string): Promise<void>;
30
31
  shutdown(): Promise<void>;
32
+ private ensureFreshForSearch;
31
33
  private mergeDebug;
32
34
  private updateRegistry;
33
35
  }
@@ -12,6 +12,7 @@ export class MultiProjectManager {
12
12
  registry;
13
13
  configManager;
14
14
  projects = new Map();
15
+ lastSearchIndexCheck = new Map();
15
16
  globalKB;
16
17
  constructor(storageRoot = path.join(os.homedir(), USER_DATA_DIR), llmProvider) {
17
18
  this.storageRoot = storageRoot;
@@ -55,7 +56,7 @@ export class MultiProjectManager {
55
56
  const limit = options.limit ?? 10;
56
57
  const scope = options.scope ?? 'all';
57
58
  const project = await this.getProject(projectRoot);
58
- await project.incrementalIndex();
59
+ await this.ensureFreshForSearch(projectRoot, project);
59
60
  if (scope === 'project')
60
61
  return project.hybridSearch(query, { limit, weights: options.weights });
61
62
  const projectResults = scope === 'all'
@@ -76,7 +77,7 @@ export class MultiProjectManager {
76
77
  async semanticSearch(projectRoot, query, options = {}) {
77
78
  const scope = options.scope ?? 'all';
78
79
  const project = await this.getProject(projectRoot);
79
- await project.incrementalIndex();
80
+ await this.ensureFreshForSearch(projectRoot, project);
80
81
  if (scope === 'project') {
81
82
  return project.semanticSearch(query, options);
82
83
  }
@@ -141,6 +142,16 @@ export class MultiProjectManager {
141
142
  this.globalKB = undefined;
142
143
  this.registry.close();
143
144
  }
145
+ async ensureFreshForSearch(projectRoot, project) {
146
+ const now = Date.now();
147
+ const key = path.resolve(projectRoot);
148
+ const ttlMs = Number(process.env.KB_SEARCH_INDEX_TTL_MS ?? 30000);
149
+ const last = this.lastSearchIndexCheck.get(key) ?? 0;
150
+ if (now - last < ttlMs)
151
+ return;
152
+ this.lastSearchIndexCheck.set(key, now);
153
+ await project.incrementalIndex({ vectorMode: 'defer' });
154
+ }
144
155
  mergeDebug(projectDebug, globalDebug) {
145
156
  if (!projectDebug && !globalDebug)
146
157
  return undefined;
@@ -30,4 +30,26 @@ export declare class OpenAICompatibleEmbeddingProvider implements EmbeddingProvi
30
30
  embedQuery(text: string): Promise<number[]>;
31
31
  private embed;
32
32
  }
33
+ export interface LocalTransformersEmbeddingOptions {
34
+ model?: string;
35
+ dimensions?: number;
36
+ modelPath?: string;
37
+ }
38
+ export declare class LocalTransformersEmbeddingProvider implements EmbeddingProvider {
39
+ readonly model: string;
40
+ readonly dimensions: number;
41
+ private readonly modelPath?;
42
+ private static pipelines;
43
+ constructor(options?: LocalTransformersEmbeddingOptions);
44
+ embedDocuments(texts: string[]): Promise<number[][]>;
45
+ embedQuery(text: string): Promise<number[]>;
46
+ private embed;
47
+ private getPipeline;
48
+ private createPipeline;
49
+ private parseVectors;
50
+ private isTensorLike;
51
+ private splitFlatVectors;
52
+ private resizeVector;
53
+ private normalize;
54
+ }
33
55
  export declare function createEmbeddingProviderFromEnvironment(): EmbeddingProvider;