@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.
- package/dist/chunking/text-chunker.d.ts +12 -1
- package/dist/chunking/text-chunker.js +219 -41
- package/dist/core/change-tracker.d.ts +1 -0
- package/dist/core/change-tracker.js +21 -0
- package/dist/core/index-state-store.d.ts +37 -1
- package/dist/core/index-state-store.js +300 -28
- package/dist/core/knowledge-base-manager.d.ts +70 -3
- package/dist/core/knowledge-base-manager.js +547 -125
- package/dist/core/multi-project-manager.d.ts +7 -4
- package/dist/core/multi-project-manager.js +35 -23
- package/dist/dedup/dedup-engine.d.ts +3 -0
- package/dist/dedup/dedup-engine.js +12 -2
- package/dist/embedding/embedding-provider.d.ts +1 -0
- package/dist/embedding/embedding-provider.js +16 -1
- package/dist/extraction/content-extractor.d.ts +2 -0
- package/dist/extraction/content-extractor.js +209 -42
- package/dist/extraction/module-resolver.d.ts +17 -0
- package/dist/extraction/module-resolver.js +113 -0
- package/dist/index.d.ts +2 -4
- package/dist/index.js +2 -3
- package/dist/llm/llm-search-provider.d.ts +23 -0
- package/dist/llm/llm-search-provider.js +1 -0
- package/dist/search/federation-search.d.ts +29 -0
- package/dist/search/federation-search.js +8 -1
- package/dist/vector/chroma-store.d.ts +2 -0
- package/dist/vector/chroma-store.js +53 -22
- package/dist/vector/vector-indexer.d.ts +3 -0
- package/dist/vector/vector-indexer.js +23 -0
- package/package.json +11 -3
- package/dist/server/dashboard-client.d.ts +0 -2
- package/dist/server/dashboard-client.js +0 -396
- package/dist/server/dashboard-i18n.d.ts +0 -112
- package/dist/server/dashboard-i18n.js +0 -220
- package/dist/server/dashboard-page.d.ts +0 -6
- package/dist/server/dashboard-page.js +0 -138
- package/dist/server/dashboard-server.d.ts +0 -13
- package/dist/server/dashboard-server.js +0 -225
- package/dist/server/dashboard-styles.d.ts +0 -1
- 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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
379
|
+
const normalized = this.normalizeRelativePath(relativePath);
|
|
380
|
+
const targetPath = this.resolveKbRelativePath(normalized);
|
|
228
381
|
if (fs.existsSync(targetPath))
|
|
229
382
|
fs.unlinkSync(targetPath);
|
|
230
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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'
|
|
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
|
}
|