@customize-agent/knowledge 2.0.0 → 2.1.1

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 (37) hide show
  1. package/dist/chunking/text-chunker.d.ts +12 -1
  2. package/dist/chunking/text-chunker.js +219 -41
  3. package/dist/core/change-tracker.d.ts +1 -0
  4. package/dist/core/change-tracker.js +21 -0
  5. package/dist/core/index-state-store.d.ts +35 -1
  6. package/dist/core/index-state-store.js +249 -16
  7. package/dist/core/knowledge-base-manager.d.ts +69 -3
  8. package/dist/core/knowledge-base-manager.js +535 -132
  9. package/dist/core/multi-project-manager.d.ts +7 -4
  10. package/dist/core/multi-project-manager.js +35 -23
  11. package/dist/embedding/embedding-provider.d.ts +1 -0
  12. package/dist/embedding/embedding-provider.js +16 -1
  13. package/dist/extraction/content-extractor.d.ts +2 -0
  14. package/dist/extraction/content-extractor.js +209 -42
  15. package/dist/extraction/module-resolver.d.ts +17 -0
  16. package/dist/extraction/module-resolver.js +113 -0
  17. package/dist/index.d.ts +2 -3
  18. package/dist/index.js +2 -2
  19. package/dist/llm/llm-search-provider.d.ts +23 -0
  20. package/dist/llm/llm-search-provider.js +1 -0
  21. package/dist/search/federation-search.d.ts +29 -0
  22. package/dist/search/federation-search.js +8 -1
  23. package/dist/vector/chroma-store.d.ts +2 -0
  24. package/dist/vector/chroma-store.js +53 -22
  25. package/dist/vector/vector-indexer.d.ts +3 -0
  26. package/dist/vector/vector-indexer.js +23 -0
  27. package/package.json +11 -3
  28. package/dist/server/dashboard-client.d.ts +0 -2
  29. package/dist/server/dashboard-client.js +0 -396
  30. package/dist/server/dashboard-i18n.d.ts +0 -112
  31. package/dist/server/dashboard-i18n.js +0 -220
  32. package/dist/server/dashboard-page.d.ts +0 -6
  33. package/dist/server/dashboard-page.js +0 -138
  34. package/dist/server/dashboard-server.d.ts +0 -13
  35. package/dist/server/dashboard-server.js +0 -225
  36. package/dist/server/dashboard-styles.d.ts +0 -1
  37. package/dist/server/dashboard-styles.js +0 -152
@@ -10,6 +10,7 @@ import { HashEmbeddingProvider } from '../embedding/embedding-provider.js';
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 { ChromaHttpClient, ChromaVectorStore } from '../vector/chroma-store.js';
13
14
  import { VectorIndexer } from '../vector/vector-indexer.js';
14
15
  import { ChangeTracker } from './change-tracker.js';
15
16
  import { KnowledgeFileScanner } from './file-scanner.js';
@@ -21,6 +22,7 @@ export class KnowledgeBaseManager {
21
22
  projectId;
22
23
  kbPath;
23
24
  store;
25
+ chromaClient = new ChromaHttpClient();
24
26
  classifier = new FileClassifier();
25
27
  scanner = new KnowledgeFileScanner();
26
28
  collections = new CollectionManager();
@@ -33,6 +35,8 @@ export class KnowledgeBaseManager {
33
35
  configManager = new ProjectConfigManager();
34
36
  projectConfig;
35
37
  lastSkippedFiles = [];
38
+ llmProvider;
39
+ onProgress;
36
40
  constructor(options) {
37
41
  this.scope = options.scope;
38
42
  this.projectRoot = options.projectRoot;
@@ -40,6 +44,8 @@ export class KnowledgeBaseManager {
40
44
  this.embeddingProvider = options.embeddingProvider ?? new HashEmbeddingProvider();
41
45
  this.vectorStores = options.vectorStores ?? new Map();
42
46
  this.extractor = new ContentExtractor(options.externalExtractors);
47
+ this.llmProvider = options.llmProvider;
48
+ this.onProgress = options.onProgress;
43
49
  const storageRoot = options.storageRoot ?? path.join(os.homedir(), USER_DATA_DIR);
44
50
  if (this.scope === 'global') {
45
51
  this.kbPath = options.kbPath ?? path.join(storageRoot, GLOBAL_KNOWLEDGE_DIR);
@@ -66,28 +72,117 @@ export class KnowledgeBaseManager {
66
72
  fs.mkdirSync(this.kbPath, { recursive: true });
67
73
  }
68
74
  }
69
- async incrementalIndex() {
75
+ async incrementalIndex(options = {}) {
70
76
  this.initialize();
71
- const kbIgnore = this.scanner.loadKbIgnore(this.kbPath);
72
- const configIgnore = this.projectConfig?.kbignore ?? [];
73
- const diskFiles = await this.scanner.scan(this.kbPath, [...kbIgnore, ...configIgnore]);
74
- const tracker = new ChangeTracker(this.store);
75
- const diff = await tracker.computeDiff(diskFiles, this.classifier, this.kbPath);
76
- for (const deleted of diff.deletedFiles) {
77
- this.store.deleteRecord(deleted.relativePath);
78
- }
79
- const now = Date.now();
80
- const indexedBefore = [...this.store.loadActiveRecords().values()];
81
- for (const file of [...diff.newFiles, ...diff.modifiedFiles]) {
82
- const hash = tracker.hashFile(file.absolutePath);
83
- const duplicate = this.store.findExactDuplicate(hash, file.relativePath);
84
- const collectionName = this.scope === 'global'
85
- ? this.collections.getCollectionName('global', file.category)
86
- : this.collections.getCollectionName('project', file.category, this.projectId);
87
- const extraction = await this.extractor.extract(file);
88
- if (!this.hasUsableContent(extraction.text, extraction.metadata)) {
89
- const reason = extraction.warnings[0] ?? '未解析出可用于模型的正文内容,已跳过向量化';
90
- diff.skippedFiles.push({ file, reason });
77
+ const previousOnProgress = this.onProgress;
78
+ if (options.onProgress)
79
+ this.onProgress = options.onProgress;
80
+ try {
81
+ this.reportProgress({ stage: 'scanning', percent: 10, message: '正在扫描知识库文件' });
82
+ const kbIgnore = this.scanner.loadKbIgnore(this.kbPath);
83
+ const configIgnore = this.projectConfig?.kbignore ?? [];
84
+ const diskFiles = await this.scanner.scan(this.kbPath, [...kbIgnore, ...configIgnore]);
85
+ const tracker = new ChangeTracker(this.store);
86
+ const diff = await tracker.computeDiff(diskFiles, this.classifier, this.kbPath);
87
+ for (const deleted of diff.deletedFiles) {
88
+ await this.deleteVectorFile(deleted.collectionName, deleted.relativePath);
89
+ this.store.deleteRecord(deleted.relativePath);
90
+ }
91
+ const now = Date.now();
92
+ const indexedBefore = [...this.store.loadActiveRecords().values()];
93
+ const filesToIndex = [...diff.newFiles, ...diff.modifiedFiles];
94
+ for (const [index, file] of filesToIndex.entries()) {
95
+ const hash = tracker.hashFile(file.absolutePath);
96
+ const duplicate = this.store.findExactDuplicate(hash, file.relativePath);
97
+ const collectionName = this.scope === 'global'
98
+ ? this.collections.getCollectionName('global', file.category)
99
+ : this.collections.getCollectionName('project', file.category, this.projectId);
100
+ const basePercent = filesToIndex.length === 0 ? 40 : 20 + Math.round((index / filesToIndex.length) * 45);
101
+ this.reportProgress({ stage: 'parsing', percent: basePercent, message: `正在解析 ${file.relativePath}`, filePath: file.relativePath });
102
+ const extraction = await this.extractor.extract(file);
103
+ extraction.metadata.textLength = extraction.text.length;
104
+ if (!this.hasUsableContent(extraction.text, extraction.metadata)) {
105
+ const reason = extraction.warnings[0] ?? '未解析出可用于模型的正文内容,已跳过向量化';
106
+ diff.skippedFiles.push({ file, reason });
107
+ this.store.upsertRecord({
108
+ relativePath: file.relativePath,
109
+ category: file.category,
110
+ format: file.format,
111
+ contentHash: hash,
112
+ fileSize: file.fileSize,
113
+ mtime: file.mtime,
114
+ chunkCount: 0,
115
+ collectionName,
116
+ indexedAt: now,
117
+ lastVerifiedAt: now,
118
+ status: 'error',
119
+ errorMessage: reason,
120
+ metadataJson: JSON.stringify({ mimeType: file.mimeType, warnings: extraction.warnings }),
121
+ });
122
+ continue;
123
+ }
124
+ const normalizedHash = this.dedup.normalizedHash(extraction.text);
125
+ const normalizedDuplicate = !duplicate && normalizedHash
126
+ ? this.store.findNormalizedDuplicate(normalizedHash, file.relativePath)
127
+ : undefined;
128
+ this.reportProgress({ stage: 'chunking', percent: Math.min(80, basePercent + 10), message: `正在切片 ${file.relativePath}`, filePath: file.relativePath });
129
+ const chunks = this.chunker.chunk(extraction.text, file, extraction.metadata);
130
+ this.reportProgress({ stage: 'chunking', percent: Math.min(84, basePercent + 14), message: `切片完成:${chunks.length} 块`, filePath: file.relativePath, chunkCount: chunks.length });
131
+ this.store.upsertFileHash({
132
+ contentHash: hash,
133
+ filePath: file.relativePath,
134
+ fileSize: file.fileSize,
135
+ category: file.category,
136
+ normalizedHash,
137
+ });
138
+ if (duplicate) {
139
+ this.store.addRelationship({
140
+ sourceFile: file.relativePath,
141
+ targetFile: duplicate.filePath,
142
+ relationshipType: 'exact_duplicate',
143
+ confidence: 1,
144
+ detail: `SHA-256 完全相同: ${hash}`,
145
+ userConfirmed: 0,
146
+ });
147
+ }
148
+ else if (normalizedDuplicate && normalizedHash) {
149
+ this.store.addRelationship({
150
+ sourceFile: file.relativePath,
151
+ targetFile: normalizedDuplicate.filePath,
152
+ relationshipType: this.dedup.relationshipForFormats(file.format, normalizedDuplicate.category),
153
+ confidence: 0.95,
154
+ detail: `归一化内容哈希相同: ${normalizedHash}`,
155
+ userConfirmed: 0,
156
+ });
157
+ }
158
+ if (!duplicate && extraction.text.length > 1000) {
159
+ const minHash = this.dedup.computeMinHash(extraction.text);
160
+ if (minHash) {
161
+ for (const existing of this.store.listMinHashesByBuckets(minHash.buckets, file.relativePath)) {
162
+ const similarity = this.dedup.estimateSimilarity(minHash.signature, existing.signature);
163
+ const relationshipType = this.dedup.relationshipForSimilarity(similarity);
164
+ if (relationshipType) {
165
+ this.store.addRelationship({
166
+ sourceFile: file.relativePath,
167
+ targetFile: existing.filePath,
168
+ relationshipType,
169
+ confidence: similarity,
170
+ detail: `MinHash 相似度: ${similarity.toFixed(3)}`,
171
+ userConfirmed: 0,
172
+ });
173
+ }
174
+ }
175
+ this.store.upsertMinHash({
176
+ filePath: file.relativePath,
177
+ signature: minHash.signature,
178
+ shingleCount: minHash.shingleCount,
179
+ buckets: minHash.buckets,
180
+ });
181
+ }
182
+ }
183
+ for (const relationship of this.relationshipDetector.detect(file, indexedBefore)) {
184
+ this.store.addRelationship(relationship);
185
+ }
91
186
  this.store.upsertRecord({
92
187
  relativePath: file.relativePath,
93
188
  category: file.category,
@@ -95,123 +190,134 @@ export class KnowledgeBaseManager {
95
190
  contentHash: hash,
96
191
  fileSize: file.fileSize,
97
192
  mtime: file.mtime,
98
- chunkCount: 0,
193
+ chunkCount: chunks.length,
99
194
  collectionName,
100
195
  indexedAt: now,
101
196
  lastVerifiedAt: now,
102
- status: 'error',
103
- errorMessage: reason,
104
- metadataJson: JSON.stringify({ mimeType: file.mimeType, warnings: extraction.warnings }),
105
- });
106
- continue;
107
- }
108
- const normalizedHash = this.dedup.normalizedHash(extraction.text);
109
- const normalizedDuplicate = !duplicate && normalizedHash
110
- ? this.store.findNormalizedDuplicate(normalizedHash, file.relativePath)
111
- : undefined;
112
- const chunks = duplicate ? [] : this.chunker.chunk(extraction.text, file, extraction.metadata);
113
- this.store.upsertFileHash({
114
- contentHash: hash,
115
- filePath: file.relativePath,
116
- fileSize: file.fileSize,
117
- category: file.category,
118
- normalizedHash,
119
- });
120
- if (duplicate) {
121
- this.store.addRelationship({
122
- sourceFile: file.relativePath,
123
- targetFile: duplicate.filePath,
124
- relationshipType: 'exact_duplicate',
125
- confidence: 1,
126
- detail: `SHA-256 完全相同: ${hash}`,
127
- userConfirmed: 0,
197
+ status: 'active',
198
+ metadataJson: JSON.stringify({
199
+ mimeType: file.mimeType,
200
+ extraction: extraction.metadata,
201
+ warnings: extraction.warnings,
202
+ extractionTimeMs: extraction.extractionTimeMs,
203
+ }),
128
204
  });
129
- }
130
- else if (normalizedDuplicate && normalizedHash) {
131
- this.store.addRelationship({
132
- sourceFile: file.relativePath,
133
- targetFile: normalizedDuplicate.filePath,
134
- relationshipType: this.dedup.relationshipForFormats(file.format, normalizedDuplicate.category),
135
- confidence: 0.95,
136
- detail: `归一化内容哈希相同: ${normalizedHash}`,
137
- userConfirmed: 0,
205
+ this.store.replaceChunks(file.relativePath, chunks, {
206
+ category: file.category,
207
+ format: file.format,
208
+ collectionName,
138
209
  });
139
210
  }
140
- if (!duplicate && extraction.text.length > 1000) {
141
- const minHash = this.dedup.computeMinHash(extraction.text);
142
- if (minHash) {
143
- for (const existing of this.store.listMinHashesByBuckets(minHash.buckets, file.relativePath)) {
144
- const similarity = this.dedup.estimateSimilarity(minHash.signature, existing.signature);
145
- const relationshipType = this.dedup.relationshipForSimilarity(similarity);
146
- if (relationshipType) {
147
- this.store.addRelationship({
148
- sourceFile: file.relativePath,
149
- targetFile: existing.filePath,
150
- relationshipType,
151
- confidence: similarity,
152
- detail: `MinHash 相似度: ${similarity.toFixed(3)}`,
153
- userConfirmed: 0,
154
- });
155
- }
156
- }
157
- this.store.upsertMinHash({
158
- filePath: file.relativePath,
159
- signature: minHash.signature,
160
- shingleCount: minHash.shingleCount,
161
- buckets: minHash.buckets,
162
- });
163
- }
211
+ const stats = this.getStats();
212
+ this.store.setMetadata('last_incremental_index_at', String(now));
213
+ this.store.setMetadata('total_chunks', String(stats.chunkCount));
214
+ this.store.setMetadata('total_files_indexed', String(stats.fileCount));
215
+ if (options.vectorMode === 'defer') {
216
+ this.reportProgress({ stage: 'vectorizing', percent: 85, message: '解析和切片已完成,向量入库转入后台/稍后执行', chunkCount: stats.chunkCount, vectorStatus: this.getVectorStatus() });
217
+ void this.ensureVectorIndexFresh(stats.chunkCount, diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0).catch(() => undefined);
164
218
  }
165
- for (const relationship of this.relationshipDetector.detect(file, indexedBefore)) {
166
- this.store.addRelationship(relationship);
219
+ else {
220
+ await this.ensureVectorIndexFresh(stats.chunkCount, diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0);
167
221
  }
168
- this.store.upsertRecord({
169
- relativePath: file.relativePath,
170
- category: file.category,
171
- format: file.format,
172
- contentHash: hash,
173
- fileSize: file.fileSize,
174
- mtime: file.mtime,
175
- chunkCount: chunks.length,
176
- collectionName,
177
- indexedAt: now,
178
- lastVerifiedAt: now,
179
- status: 'active',
180
- metadataJson: JSON.stringify({
181
- mimeType: file.mimeType,
182
- extraction: extraction.metadata,
183
- warnings: extraction.warnings,
184
- extractionTimeMs: extraction.extractionTimeMs,
185
- }),
186
- });
187
- this.store.replaceChunks(file.relativePath, chunks, {
188
- category: file.category,
189
- format: file.format,
190
- collectionName,
222
+ this.lastSkippedFiles = diff.skippedFiles;
223
+ const vectorStatus = this.getVectorStatus();
224
+ const vectorDeferred = options.vectorMode === 'defer';
225
+ this.reportProgress({
226
+ stage: vectorDeferred || vectorStatus.status !== 'error' ? 'done' : 'error',
227
+ percent: vectorDeferred || vectorStatus.status !== 'error' ? 100 : 85,
228
+ message: vectorDeferred
229
+ ? '解析、切片和 SQLite 入库已完成,向量入库后台执行'
230
+ : vectorStatus.status === 'error'
231
+ ? '解析和切片已完成,ChromaDB 未连接,向量待入库'
232
+ : '知识库索引完成',
233
+ chunkCount: stats.chunkCount,
234
+ vectorStatus,
191
235
  });
236
+ return diff;
237
+ }
238
+ finally {
239
+ this.onProgress = previousOnProgress;
192
240
  }
193
- const stats = this.getStats();
194
- this.store.setMetadata('last_incremental_index_at', String(now));
195
- this.store.setMetadata('total_chunks', String(stats.chunkCount));
196
- this.store.setMetadata('total_files_indexed', String(stats.fileCount));
197
- this.lastSkippedFiles = diff.skippedFiles;
198
- return diff;
199
241
  }
200
242
  search(query, limit = 10) {
201
243
  return this.store.searchChunks(query, limit);
202
244
  }
245
+ keywordSearchItems(query, limit = 10) {
246
+ return this.store.searchChunks(query, limit).map(result => this.toFederatedItem(result, 'keyword'));
247
+ }
248
+ expandContext(item) {
249
+ const chunkIndex = item.chunkIndex ?? this.parseChunkIndex(item.id);
250
+ const parent = item.parentId ? this.store.getParentChunk(item.filePath, item.parentId) : undefined;
251
+ if (parent) {
252
+ return {
253
+ ...item,
254
+ content: parent.content,
255
+ chunkIndex,
256
+ parentId: parent.parentId,
257
+ sectionTitle: parent.sectionTitle ?? item.sectionTitle,
258
+ };
259
+ }
260
+ const parentChunks = item.parentId ? this.store.getChunksByParent(item.filePath, item.parentId, 6) : [];
261
+ const chunks = parentChunks.length > 0 ? parentChunks : this.store.getContextChunks(item.filePath, chunkIndex, 1);
262
+ if (chunks.length === 0)
263
+ return item;
264
+ return {
265
+ ...item,
266
+ content: chunks.map(chunk => chunk.content).join('\n\n---\n\n'),
267
+ chunkIndex,
268
+ parentId: item.parentId ?? this.parseMetadataString(chunks[0]?.metadataJson, 'parentId'),
269
+ sectionTitle: item.sectionTitle ?? chunks.find(chunk => chunk.sectionTitle)?.sectionTitle,
270
+ };
271
+ }
272
+ async hybridSearch(query, options = {}) {
273
+ const limit = options.limit ?? 10;
274
+ const start = Date.now();
275
+ const weights = this.retrievalWeights(options.weights);
276
+ const rewrittenQueries = await this.rewriteQueries(query);
277
+ const keywordItems = rewrittenQueries.flatMap((rewritten, index) => this.keywordSearchItems(rewritten, limit * 2).map(item => ({ ...item, score: item.score * (index === 0 ? 1 : (weights.rewrite ?? 0.72)) })));
278
+ const vectorItems = [];
279
+ for (const rewritten of rewrittenQueries.slice(0, 3)) {
280
+ try {
281
+ vectorItems.push(...(await this.semanticSearch(rewritten, { ...options, limit: limit * 2 })).results);
282
+ }
283
+ catch { /* vector search is optional in hybrid search */ }
284
+ }
285
+ const merged = this.mergeHybridItems([...keywordItems, ...vectorItems], limit * 2, weights).map(item => this.expandContext(item));
286
+ const useLLMRerank = !!this.llmProvider;
287
+ const preReranked = useLLMRerank ? merged : this.heuristicRerank(query, merged);
288
+ const reranked = useLLMRerank ? (await this.llmRerank(query, preReranked)) : preReranked;
289
+ return {
290
+ results: reranked.slice(0, limit),
291
+ scopesSearched: this.scope === 'global' ? ['global'] : ['project'],
292
+ queryTimeMs: Date.now() - start,
293
+ debug: {
294
+ originalQuery: query,
295
+ rewrittenQueries,
296
+ weights,
297
+ recallCounts: { keyword: keywordItems.length, vector: vectorItems.length, merged: merged.length },
298
+ reranker: useLLMRerank ? 'llm-semantic-reranker-v1' : 'local-statistical-reranker-v1',
299
+ },
300
+ };
301
+ }
203
302
  async semanticSearch(query, options = {}) {
303
+ for (const record of this.store.listRecords())
304
+ this.ensureVectorStore(record.collectionName);
204
305
  const queryEmbedding = await this.embeddingProvider.embedQuery(query);
205
306
  const search = new FederationSearch(this.vectorStores);
206
- return search.search({
207
- query,
208
- queryEmbedding,
209
- topK: options.limit ?? 10,
210
- scope: this.scope,
211
- projectId: this.projectId,
212
- collections: options.collections,
213
- filters: options.filters,
214
- });
307
+ try {
308
+ return await search.search({
309
+ query,
310
+ queryEmbedding,
311
+ topK: options.limit ?? 10,
312
+ scope: this.scope,
313
+ projectId: this.projectId,
314
+ collections: options.collections,
315
+ filters: options.filters,
316
+ });
317
+ }
318
+ catch {
319
+ return { results: [], scopesSearched: this.scope === 'global' ? ['global'] : ['project'], queryTimeMs: 0 };
320
+ }
215
321
  }
216
322
  listRelationships(filePath) {
217
323
  return this.store.listRelationships(filePath);
@@ -219,6 +325,33 @@ export class KnowledgeBaseManager {
219
325
  listFiles() {
220
326
  return this.store.listRecords();
221
327
  }
328
+ getFileDetail(relativePath) {
329
+ const normalized = this.normalizeRelativePath(relativePath);
330
+ const file = this.store.listRecords().find(record => record.relativePath === normalized);
331
+ if (!file)
332
+ return undefined;
333
+ const absolutePath = this.resolveKbRelativePath(normalized);
334
+ return {
335
+ file,
336
+ absolutePath,
337
+ directory: path.dirname(absolutePath),
338
+ chunks: this.store.listChunks({ relativePath: normalized, limit: 500 }),
339
+ parents: this.store.listParentChunks(normalized),
340
+ relationships: this.store.listRelationships(normalized),
341
+ tags: this.store.listTags(normalized),
342
+ };
343
+ }
344
+ async reindexFile(relativePath) {
345
+ const normalized = this.normalizeRelativePath(relativePath);
346
+ const record = this.store.listRecords().find(item => item.relativePath === normalized);
347
+ const targetPath = this.resolveKbRelativePath(normalized);
348
+ if (!fs.existsSync(targetPath))
349
+ throw new Error('file not found');
350
+ if (record)
351
+ await this.deleteVectorFile(record.collectionName, normalized);
352
+ this.store.deleteRecord(normalized);
353
+ return this.incrementalIndex();
354
+ }
222
355
  async addFile(sourcePath, targetRelativePath) {
223
356
  this.initialize();
224
357
  const resolvedSource = path.resolve(sourcePath);
@@ -231,22 +364,28 @@ export class KnowledgeBaseManager {
231
364
  getUploadRelativePath(fileName, targetRelativePath) {
232
365
  return targetRelativePath ?? this.defaultUploadRelativePath(fileName);
233
366
  }
234
- async uploadFile(fileName, content, targetRelativePath) {
367
+ async uploadFile(fileName, content, targetRelativePath, onProgress, options = {}) {
235
368
  this.initialize();
236
369
  const relativePath = this.getUploadRelativePath(fileName, targetRelativePath);
237
370
  const targetPath = this.resolveKbRelativePath(relativePath);
238
371
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
239
372
  fs.writeFileSync(targetPath, content);
240
- return this.incrementalIndex();
373
+ return this.incrementalIndex({ onProgress, vectorMode: options.vectorMode });
241
374
  }
242
375
  listFailedFiles() {
243
376
  return this.lastSkippedFiles;
244
377
  }
245
378
  async removeFile(relativePath) {
246
- const targetPath = this.resolveKbRelativePath(relativePath);
379
+ const normalized = this.normalizeRelativePath(relativePath);
380
+ const targetPath = this.resolveKbRelativePath(normalized);
247
381
  if (fs.existsSync(targetPath))
248
382
  fs.unlinkSync(targetPath);
249
- this.store.deleteRecord(this.normalizeRelativePath(relativePath));
383
+ // 同步删除 ChromaDB 向量数据,避免孤儿向量污染搜索结果
384
+ const record = this.store.listRecords().find(r => r.relativePath === normalized);
385
+ if (record) {
386
+ await this.deleteVectorFile(record.collectionName, normalized);
387
+ }
388
+ this.store.deleteRecord(normalized);
250
389
  }
251
390
  tagFile(relativePath, tags) {
252
391
  this.store.setTags(this.normalizeRelativePath(relativePath), tags);
@@ -268,12 +407,28 @@ export class KnowledgeBaseManager {
268
407
  }
269
408
  async indexVectors(options = {}) {
270
409
  const chunks = this.store.listChunks(options);
410
+ for (const collectionName of new Set(chunks.map(chunk => chunk.collectionName)))
411
+ this.ensureVectorStore(collectionName);
412
+ this.reportProgress({ stage: 'vectorizing', percent: 85, message: `正在写入 ChromaDB 向量库,共 ${chunks.length} 个切片`, chunkCount: chunks.length });
271
413
  const indexer = new VectorIndexer(this.embeddingProvider, this.vectorStores);
272
- const results = await indexer.indexChunks(chunks);
273
- this.store.setMetadata('embedding_model', this.embeddingProvider.model);
274
- this.store.setMetadata('embedding_dimension', String(this.embeddingProvider.dimensions));
275
- this.store.setMetadata('last_vector_index_at', String(Date.now()));
276
- return results;
414
+ try {
415
+ const results = await indexer.indexChunks(chunks);
416
+ this.store.setMetadata('embedding_model', this.embeddingProvider.model);
417
+ this.store.setMetadata('embedding_dimension', String(this.embeddingProvider.dimensions));
418
+ this.store.setMetadata('vector_indexed_chunks', String(chunks.length));
419
+ this.store.setMetadata('vector_index_status', 'ready');
420
+ this.store.setMetadata('vector_index_error', '');
421
+ this.store.setMetadata('last_vector_index_at', String(Date.now()));
422
+ return results;
423
+ }
424
+ catch (error) {
425
+ const message = error instanceof Error ? error.message : String(error);
426
+ this.store.setMetadata('vector_index_status', 'error');
427
+ this.store.setMetadata('vector_index_error', message);
428
+ this.store.setMetadata('last_vector_index_at', String(Date.now()));
429
+ this.reportProgress({ stage: 'error', percent: 85, message: 'ChromaDB 向量入库失败', chunkCount: chunks.length, vectorStatus: this.getVectorStatus() });
430
+ return [];
431
+ }
277
432
  }
278
433
  getProjectConfig() {
279
434
  return this.projectConfig;
@@ -289,12 +444,260 @@ export class KnowledgeBaseManager {
289
444
  lastIndexedAt: stats.lastIndexedAt,
290
445
  };
291
446
  }
447
+ getVectorStatus() {
448
+ return {
449
+ status: this.store.getMetadata('vector_index_status') ?? 'pending',
450
+ error: this.store.getMetadata('vector_index_error') || undefined,
451
+ indexedChunks: Number(this.store.getMetadata('vector_indexed_chunks') ?? 0),
452
+ lastIndexedAt: Number(this.store.getMetadata('last_vector_index_at') ?? 0),
453
+ backend: `ChromaDB (${this.chromaClient.baseUrl})`,
454
+ };
455
+ }
456
+ async rewriteQueries(query) {
457
+ const normalized = query.trim();
458
+ const variants = new Set([normalized]);
459
+ // LLM 查询扩展(如果可用)
460
+ if (this.llmProvider) {
461
+ try {
462
+ const llmQueries = await this.llmExpandQueries(normalized);
463
+ for (const q of llmQueries)
464
+ variants.add(q);
465
+ }
466
+ catch {
467
+ // LLM 失败不影响原始查询
468
+ }
469
+ }
470
+ return [...variants].filter(Boolean).slice(0, 6);
471
+ }
472
+ async llmExpandQueries(query) {
473
+ if (!this.llmProvider)
474
+ return [];
475
+ const prompt = `你是一个搜索查询优化器。用户输入了一个搜索查询,请生成 3-5 个不同的查询变体,用不同的措辞和同义词来表达相同的信息需求,以便在知识库中检索到更全面的结果。
476
+
477
+ 如果查询是中文,请同时生成英文变体;如果查询是英文,请同时生成中文变体。
478
+
479
+ 直接输出查询列表,每行一个,不要编号或其他文字。
480
+
481
+ 原始查询:${query}`;
482
+ const response = await this.llmProvider.chat([
483
+ { role: 'system', content: '你是一个精确的搜索查询扩展引擎。只输出查询列表。' },
484
+ { role: 'user', content: prompt },
485
+ ], { temperature: 0.3, maxTokens: 500 });
486
+ return response.content
487
+ .split('\n')
488
+ .map(line => line.replace(/^[-*\d.]+\s*/, '').trim())
489
+ .filter(line => line.length > 0 && line !== query)
490
+ .slice(0, 5);
491
+ }
492
+ retrievalWeights(overrides = {}) {
493
+ return {
494
+ keyword: overrides.keyword ?? Number(process.env.KB_RETRIEVAL_KEYWORD_WEIGHT ?? 1),
495
+ vector: overrides.vector ?? Number(process.env.KB_RETRIEVAL_VECTOR_WEIGHT ?? 0.9),
496
+ rewrite: overrides.rewrite ?? Number(process.env.KB_RETRIEVAL_REWRITE_WEIGHT ?? 0.72),
497
+ hybridBonus: overrides.hybridBonus ?? Number(process.env.KB_RETRIEVAL_HYBRID_BONUS ?? 0.35),
498
+ rerankPhrase: 120,
499
+ rerankTerm: 8,
500
+ };
501
+ }
502
+ heuristicRerank(query, items) {
503
+ const terms = query.toLowerCase().split(/[\s,,。;;::、]+/u).filter(Boolean);
504
+ const phrase = query.toLowerCase().trim();
505
+ return items.map(item => {
506
+ const content = `${item.filePath}\n${item.sectionTitle ?? ''}\n${item.content}`.toLowerCase();
507
+ let rerankBoost = 0;
508
+ if (phrase && content.includes(phrase))
509
+ rerankBoost += 120;
510
+ for (const term of terms)
511
+ if (term && content.includes(term))
512
+ rerankBoost += 8;
513
+ if (item.chunkKind === 'table' && /表|行|列|金额|数量|报价|评分/u.test(query))
514
+ rerankBoost += 30;
515
+ if (item.chunkKind === 'metadata' && /图纸|图层|轴网|标注|块|实体|cad|dxf|step|iges|模型/u.test(query))
516
+ rerankBoost += 60;
517
+ if (item.chunkKind === 'data' && /json|xml|yaml|字段|配置|数据|路径|price|id|name/u.test(query))
518
+ rerankBoost += 30;
519
+ const score = item.score + rerankBoost;
520
+ return {
521
+ ...item,
522
+ score,
523
+ scoreDetails: {
524
+ ...item.scoreDetails,
525
+ rerankBoost,
526
+ hybridScore: score,
527
+ },
528
+ };
529
+ }).sort((a, b) => b.score - a.score);
530
+ }
531
+ async llmRerank(query, items) {
532
+ if (!this.llmProvider || items.length === 0)
533
+ return this.heuristicRerank(query, items);
534
+ const candidates = items.slice(0, 20);
535
+ const resultsText = candidates.map((item, index) => {
536
+ const contentPreview = item.content.slice(0, 300).replace(/[\n\r]+/g, ' ');
537
+ return `[DOC_${index}] 路径: ${item.filePath} | 类型: ${item.chunkKind ?? 'text'}\n 内容: ${contentPreview}`;
538
+ }).join('\n\n');
539
+ const prompt = `你是一个文档相关性评估器。根据用户查询,为以下文档片段打分(1-10)。
540
+ 1=完全不相关,10=高度相关。
541
+ 输出格式:每行 "DOC_ID:分数",如 "DOC_0:8"
542
+
543
+ 查询:${query}
544
+
545
+ ${resultsText}
546
+
547
+ 相关性评分:`;
548
+ try {
549
+ const response = await this.llmProvider.chat([
550
+ { role: 'system', content: '你是一个精确的文档相关性评估器。只输出 DOC_ID:分数的列表。' },
551
+ { role: 'user', content: prompt },
552
+ ], { temperature: 0.1, maxTokens: 600 });
553
+ const scoreMap = new Map();
554
+ for (const line of response.content.split('\n')) {
555
+ const match = line.match(/DOC[_\s]*(\d+)[^\d]*(\d+)/i);
556
+ if (match)
557
+ scoreMap.set(Number(match[1]), Math.min(10, Math.max(1, Number(match[2]))));
558
+ }
559
+ if (scoreMap.size === 0)
560
+ return this.heuristicRerank(query, items);
561
+ return items.map((item, index) => {
562
+ const llmScore = scoreMap.get(index);
563
+ if (llmScore == null || llmScore === undefined)
564
+ return item;
565
+ // LLM 分数 (1-10) 映射为权重因子:10→2.0x, 5→1.0x, 1→0.2x
566
+ const llmFactor = 0.2 + (llmScore / 10) * 1.8;
567
+ const newScore = item.score * llmFactor;
568
+ return {
569
+ ...item,
570
+ score: newScore,
571
+ scoreDetails: {
572
+ ...item.scoreDetails,
573
+ rerankBoost: newScore - (item.scoreDetails?.hybridScore ?? item.score),
574
+ llmRelevanceScore: llmScore,
575
+ hybridScore: newScore,
576
+ },
577
+ };
578
+ }).sort((a, b) => b.score - a.score);
579
+ }
580
+ catch {
581
+ return this.heuristicRerank(query, items);
582
+ }
583
+ }
584
+ toFederatedItem(result, source) {
585
+ const metadata = this.parseMetadata(result.metadataJson);
586
+ return {
587
+ id: result.id,
588
+ content: result.content,
589
+ filePath: result.relativePath,
590
+ scope: this.scope === 'global' ? 'global' : 'project',
591
+ collection: result.collectionName,
592
+ score: result.score,
593
+ chunkIndex: result.chunkIndex,
594
+ parentId: this.metadataString(metadata.parentId),
595
+ source,
596
+ sectionTitle: result.sectionTitle,
597
+ rowRange: this.metadataString(metadata.rowRange),
598
+ chunkKind: this.metadataString(metadata.chunkKind),
599
+ scoreDetails: result.scoreDetails,
600
+ facets: this.metadataFacets(metadata),
601
+ };
602
+ }
603
+ mergeHybridItems(items, limit, weights = this.retrievalWeights()) {
604
+ const byKey = new Map();
605
+ for (const item of items) {
606
+ const key = `${item.scope}:${item.filePath}:${item.parentId ?? item.chunkIndex ?? item.id}`;
607
+ const sourceWeight = item.source === 'vector' ? (weights.vector ?? 0.9) : item.source === 'keyword' ? (weights.keyword ?? 1) : 1.1;
608
+ const weighted = { ...item, score: item.score * sourceWeight, scoreDetails: { ...item.scoreDetails, hybridScore: item.score * sourceWeight } };
609
+ const existing = byKey.get(key);
610
+ if (!existing) {
611
+ byKey.set(key, weighted);
612
+ }
613
+ else {
614
+ const score = Math.max(existing.score, weighted.score) + Math.min(existing.score, weighted.score) * (weights.hybridBonus ?? 0.35);
615
+ byKey.set(key, {
616
+ ...existing,
617
+ score,
618
+ source: existing.source === weighted.source ? existing.source : 'hybrid',
619
+ scoreDetails: {
620
+ ...existing.scoreDetails,
621
+ ...weighted.scoreDetails,
622
+ hybridScore: score,
623
+ },
624
+ });
625
+ }
626
+ }
627
+ return [...byKey.values()].sort((a, b) => b.score - a.score).slice(0, limit);
628
+ }
629
+ parseChunkIndex(id) {
630
+ const match = /#(\d+)$/u.exec(id);
631
+ return match ? Number(match[1]) : 0;
632
+ }
633
+ parseMetadataString(metadataJson, key) {
634
+ return this.metadataString(this.parseMetadata(metadataJson)[key]);
635
+ }
636
+ parseMetadata(metadataJson) {
637
+ if (!metadataJson)
638
+ return {};
639
+ try {
640
+ return JSON.parse(metadataJson);
641
+ }
642
+ catch {
643
+ return {};
644
+ }
645
+ }
646
+ metadataString(value) {
647
+ return typeof value === 'string' ? value : undefined;
648
+ }
649
+ metadataFacets(metadata) {
650
+ const keys = ['sheetNames', 'columnNames', 'rowCount', 'columnCount', 'dataPaths', 'layerNames', 'blockNames', 'entityTypes', 'productNames', 'materialNames', 'ocrRecommended', 'ocrReason'];
651
+ const facets = {};
652
+ for (const key of keys) {
653
+ const value = metadata[key];
654
+ if (typeof value === 'string' || typeof value === 'number')
655
+ facets[key] = value;
656
+ if (typeof value === 'boolean')
657
+ facets[key] = String(value);
658
+ if (Array.isArray(value))
659
+ facets[key] = value.filter(item => typeof item === 'string').slice(0, 12);
660
+ }
661
+ return facets;
662
+ }
292
663
  close() {
293
664
  this.store.close();
665
+ for (const store of this.vectorStores.values()) {
666
+ if ('close' in store && typeof store.close === 'function')
667
+ store.close();
668
+ }
669
+ }
670
+ reportProgress(progress) {
671
+ this.onProgress?.(progress);
672
+ }
673
+ ensureVectorStore(collectionName) {
674
+ if (this.vectorStores.has(collectionName))
675
+ return;
676
+ this.vectorStores.set(collectionName, new ChromaVectorStore(this.chromaClient, collectionName));
677
+ }
678
+ async deleteVectorFile(collectionName, relativePath) {
679
+ this.ensureVectorStore(collectionName);
680
+ try {
681
+ await this.vectorStores.get(collectionName)?.deleteByFilePath(relativePath);
682
+ }
683
+ catch (error) {
684
+ this.store.setMetadata('vector_index_status', 'error');
685
+ this.store.setMetadata('vector_index_error', error instanceof Error ? error.message : String(error));
686
+ }
687
+ }
688
+ async ensureVectorIndexFresh(chunkCount, force = false) {
689
+ if (chunkCount === 0)
690
+ return;
691
+ const indexedChunks = Number(this.store.getMetadata('vector_indexed_chunks') ?? 0);
692
+ const model = this.store.getMetadata('embedding_model');
693
+ const dimension = this.store.getMetadata('embedding_dimension');
694
+ if (!force && indexedChunks === chunkCount && model === this.embeddingProvider.model && dimension === String(this.embeddingProvider.dimensions))
695
+ return;
696
+ await this.indexVectors();
294
697
  }
295
698
  hasUsableContent(text, metadata) {
296
699
  const coverage = String(metadata.contentCoverage ?? '');
297
- if (coverage === 'metadata' || coverage === 'metadata_filename')
700
+ if (coverage === 'metadata')
298
701
  return false;
299
702
  return text.trim().length > 0;
300
703
  }
@@ -314,6 +717,6 @@ export class KnowledgeBaseManager {
314
717
  return targetPath;
315
718
  }
316
719
  normalizeRelativePath(relativePath) {
317
- return relativePath.split(path.sep).join('/').replace(/^\/+/, '');
720
+ return relativePath.replace(/\\/gu, '/').split(path.sep).join('/').replace(/^\/+/, '');
318
721
  }
319
722
  }