@customize-agent/knowledge 1.0.1 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) 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 +37 -1
  6. package/dist/core/index-state-store.js +300 -28
  7. package/dist/core/knowledge-base-manager.d.ts +70 -3
  8. package/dist/core/knowledge-base-manager.js +547 -125
  9. package/dist/core/multi-project-manager.d.ts +7 -4
  10. package/dist/core/multi-project-manager.js +35 -23
  11. package/dist/dedup/dedup-engine.d.ts +3 -0
  12. package/dist/dedup/dedup-engine.js +12 -2
  13. package/dist/embedding/embedding-provider.d.ts +1 -0
  14. package/dist/embedding/embedding-provider.js +16 -1
  15. package/dist/extraction/content-extractor.d.ts +2 -0
  16. package/dist/extraction/content-extractor.js +209 -42
  17. package/dist/extraction/module-resolver.d.ts +17 -0
  18. package/dist/extraction/module-resolver.js +113 -0
  19. package/dist/index.d.ts +2 -4
  20. package/dist/index.js +2 -3
  21. package/dist/llm/llm-search-provider.d.ts +23 -0
  22. package/dist/llm/llm-search-provider.js +1 -0
  23. package/dist/search/federation-search.d.ts +29 -0
  24. package/dist/search/federation-search.js +8 -1
  25. package/dist/vector/chroma-store.d.ts +2 -0
  26. package/dist/vector/chroma-store.js +53 -22
  27. package/dist/vector/vector-indexer.d.ts +3 -0
  28. package/dist/vector/vector-indexer.js +23 -0
  29. package/package.json +11 -3
  30. package/dist/server/dashboard-client.d.ts +0 -2
  31. package/dist/server/dashboard-client.js +0 -396
  32. package/dist/server/dashboard-i18n.d.ts +0 -112
  33. package/dist/server/dashboard-i18n.js +0 -220
  34. package/dist/server/dashboard-page.d.ts +0 -6
  35. package/dist/server/dashboard-page.js +0 -138
  36. package/dist/server/dashboard-server.d.ts +0 -13
  37. package/dist/server/dashboard-server.js +0 -225
  38. package/dist/server/dashboard-styles.d.ts +0 -1
  39. 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,136 +72,252 @@ 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 extraction = await this.extractor.extract(file);
85
- if (!this.hasUsableContent(extraction.text, extraction.metadata)) {
86
- diff.skippedFiles.push({ file, reason: extraction.warnings[0] ?? '未解析出可用于模型的正文内容,已跳过向量化' });
87
- this.store.deleteRecord(file.relativePath);
88
- continue;
89
- }
90
- const normalizedHash = this.dedup.normalizedHash(extraction.text);
91
- const normalizedDuplicate = !duplicate && normalizedHash
92
- ? this.store.findNormalizedDuplicate(normalizedHash, file.relativePath)
93
- : undefined;
94
- const chunks = duplicate ? [] : this.chunker.chunk(extraction.text, file, extraction.metadata);
95
- const collectionName = this.scope === 'global'
96
- ? this.collections.getCollectionName('global', file.category)
97
- : this.collections.getCollectionName('project', file.category, this.projectId);
98
- this.store.upsertFileHash({
99
- contentHash: hash,
100
- filePath: file.relativePath,
101
- fileSize: file.fileSize,
102
- category: file.category,
103
- normalizedHash,
104
- });
105
- if (duplicate) {
106
- this.store.addRelationship({
107
- sourceFile: file.relativePath,
108
- targetFile: duplicate.filePath,
109
- relationshipType: 'exact_duplicate',
110
- confidence: 1,
111
- detail: `SHA-256 完全相同: ${hash}`,
112
- userConfirmed: 0,
113
- });
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);
114
90
  }
115
- else if (normalizedDuplicate && normalizedHash) {
116
- this.store.addRelationship({
117
- sourceFile: file.relativePath,
118
- targetFile: normalizedDuplicate.filePath,
119
- relationshipType: this.dedup.relationshipForFormats(file.format, normalizedDuplicate.category),
120
- confidence: 0.95,
121
- detail: `归一化内容哈希相同: ${normalizedHash}`,
122
- userConfirmed: 0,
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,
123
137
  });
124
- }
125
- if (!duplicate && extraction.text.length > 1000) {
126
- const minHash = this.dedup.computeMinHash(extraction.text);
127
- if (minHash) {
128
- for (const existing of this.store.listMinHashes(file.relativePath)) {
129
- const similarity = this.dedup.estimateSimilarity(minHash.signature, existing.signature);
130
- const relationshipType = this.dedup.relationshipForSimilarity(similarity);
131
- if (relationshipType) {
132
- this.store.addRelationship({
133
- sourceFile: file.relativePath,
134
- targetFile: existing.filePath,
135
- relationshipType,
136
- confidence: similarity,
137
- detail: `MinHash 相似度: ${similarity.toFixed(3)}`,
138
- userConfirmed: 0,
139
- });
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
+ }
140
174
  }
175
+ this.store.upsertMinHash({
176
+ filePath: file.relativePath,
177
+ signature: minHash.signature,
178
+ shingleCount: minHash.shingleCount,
179
+ buckets: minHash.buckets,
180
+ });
141
181
  }
142
- this.store.upsertMinHash({
143
- filePath: file.relativePath,
144
- signature: minHash.signature,
145
- shingleCount: minHash.shingleCount,
146
- });
147
182
  }
183
+ for (const relationship of this.relationshipDetector.detect(file, indexedBefore)) {
184
+ this.store.addRelationship(relationship);
185
+ }
186
+ this.store.upsertRecord({
187
+ relativePath: file.relativePath,
188
+ category: file.category,
189
+ format: file.format,
190
+ contentHash: hash,
191
+ fileSize: file.fileSize,
192
+ mtime: file.mtime,
193
+ chunkCount: chunks.length,
194
+ collectionName,
195
+ indexedAt: now,
196
+ lastVerifiedAt: now,
197
+ status: 'active',
198
+ metadataJson: JSON.stringify({
199
+ mimeType: file.mimeType,
200
+ extraction: extraction.metadata,
201
+ warnings: extraction.warnings,
202
+ extractionTimeMs: extraction.extractionTimeMs,
203
+ }),
204
+ });
205
+ this.store.replaceChunks(file.relativePath, chunks, {
206
+ category: file.category,
207
+ format: file.format,
208
+ collectionName,
209
+ });
148
210
  }
149
- for (const relationship of this.relationshipDetector.detect(file, indexedBefore)) {
150
- this.store.addRelationship(relationship);
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);
151
218
  }
152
- this.store.upsertRecord({
153
- relativePath: file.relativePath,
154
- category: file.category,
155
- format: file.format,
156
- contentHash: hash,
157
- fileSize: file.fileSize,
158
- mtime: file.mtime,
159
- chunkCount: chunks.length,
160
- collectionName,
161
- indexedAt: now,
162
- lastVerifiedAt: now,
163
- status: 'active',
164
- metadataJson: JSON.stringify({
165
- mimeType: file.mimeType,
166
- extraction: extraction.metadata,
167
- warnings: extraction.warnings,
168
- extractionTimeMs: extraction.extractionTimeMs,
169
- }),
170
- });
171
- this.store.replaceChunks(file.relativePath, chunks, {
172
- category: file.category,
173
- format: file.format,
174
- collectionName,
219
+ else {
220
+ await this.ensureVectorIndexFresh(stats.chunkCount, diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0);
221
+ }
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,
175
235
  });
236
+ return diff;
237
+ }
238
+ finally {
239
+ this.onProgress = previousOnProgress;
176
240
  }
177
- const stats = this.getStats();
178
- this.store.setMetadata('last_incremental_index_at', String(now));
179
- this.store.setMetadata('total_chunks', String(stats.chunkCount));
180
- this.store.setMetadata('total_files_indexed', String(stats.fileCount));
181
- this.lastSkippedFiles = diff.skippedFiles;
182
- return diff;
183
241
  }
184
242
  search(query, limit = 10) {
185
243
  return this.store.searchChunks(query, limit);
186
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
+ }
187
302
  async semanticSearch(query, options = {}) {
303
+ for (const record of this.store.listRecords())
304
+ this.ensureVectorStore(record.collectionName);
188
305
  const queryEmbedding = await this.embeddingProvider.embedQuery(query);
189
306
  const search = new FederationSearch(this.vectorStores);
190
- return search.search({
191
- query,
192
- queryEmbedding,
193
- topK: options.limit ?? 10,
194
- scope: this.scope,
195
- projectId: this.projectId,
196
- collections: options.collections,
197
- filters: options.filters,
198
- });
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
+ }
199
321
  }
200
322
  listRelationships(filePath) {
201
323
  return this.store.listRelationships(filePath);
@@ -203,6 +325,33 @@ export class KnowledgeBaseManager {
203
325
  listFiles() {
204
326
  return this.store.listRecords();
205
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
+ }
206
355
  async addFile(sourcePath, targetRelativePath) {
207
356
  this.initialize();
208
357
  const resolvedSource = path.resolve(sourcePath);
@@ -212,22 +361,31 @@ export class KnowledgeBaseManager {
212
361
  fs.copyFileSync(resolvedSource, targetPath);
213
362
  return this.incrementalIndex();
214
363
  }
215
- async uploadFile(fileName, content, targetRelativePath) {
364
+ getUploadRelativePath(fileName, targetRelativePath) {
365
+ return targetRelativePath ?? this.defaultUploadRelativePath(fileName);
366
+ }
367
+ async uploadFile(fileName, content, targetRelativePath, onProgress, options = {}) {
216
368
  this.initialize();
217
- const relativePath = targetRelativePath ?? this.defaultUploadRelativePath(fileName);
369
+ const relativePath = this.getUploadRelativePath(fileName, targetRelativePath);
218
370
  const targetPath = this.resolveKbRelativePath(relativePath);
219
371
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
220
372
  fs.writeFileSync(targetPath, content);
221
- return this.incrementalIndex();
373
+ return this.incrementalIndex({ onProgress, vectorMode: options.vectorMode });
222
374
  }
223
375
  listFailedFiles() {
224
376
  return this.lastSkippedFiles;
225
377
  }
226
378
  async removeFile(relativePath) {
227
- const targetPath = this.resolveKbRelativePath(relativePath);
379
+ const normalized = this.normalizeRelativePath(relativePath);
380
+ const targetPath = this.resolveKbRelativePath(normalized);
228
381
  if (fs.existsSync(targetPath))
229
382
  fs.unlinkSync(targetPath);
230
- 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);
231
389
  }
232
390
  tagFile(relativePath, tags) {
233
391
  this.store.setTags(this.normalizeRelativePath(relativePath), tags);
@@ -249,12 +407,28 @@ export class KnowledgeBaseManager {
249
407
  }
250
408
  async indexVectors(options = {}) {
251
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 });
252
413
  const indexer = new VectorIndexer(this.embeddingProvider, this.vectorStores);
253
- const results = await indexer.indexChunks(chunks);
254
- this.store.setMetadata('embedding_model', this.embeddingProvider.model);
255
- this.store.setMetadata('embedding_dimension', String(this.embeddingProvider.dimensions));
256
- this.store.setMetadata('last_vector_index_at', String(Date.now()));
257
- 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
+ }
258
432
  }
259
433
  getProjectConfig() {
260
434
  return this.projectConfig;
@@ -270,12 +444,260 @@ export class KnowledgeBaseManager {
270
444
  lastIndexedAt: stats.lastIndexedAt,
271
445
  };
272
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
+ }
273
663
  close() {
274
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();
275
697
  }
276
698
  hasUsableContent(text, metadata) {
277
699
  const coverage = String(metadata.contentCoverage ?? '');
278
- if (coverage === 'metadata' || coverage === 'metadata_filename')
700
+ if (coverage === 'metadata')
279
701
  return false;
280
702
  return text.trim().length > 0;
281
703
  }
@@ -295,6 +717,6 @@ export class KnowledgeBaseManager {
295
717
  return targetPath;
296
718
  }
297
719
  normalizeRelativePath(relativePath) {
298
- return relativePath.split(path.sep).join('/').replace(/^\/+/, '');
720
+ return relativePath.replace(/\\/gu, '/').split(path.sep).join('/').replace(/^\/+/, '');
299
721
  }
300
722
  }