@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.
- 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 +35 -1
- package/dist/core/index-state-store.js +249 -16
- package/dist/core/knowledge-base-manager.d.ts +69 -3
- package/dist/core/knowledge-base-manager.js +535 -132
- package/dist/core/multi-project-manager.d.ts +7 -4
- package/dist/core/multi-project-manager.js +35 -23
- 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 -3
- package/dist/index.js +2 -2
- 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,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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
this.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
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:
|
|
193
|
+
chunkCount: chunks.length,
|
|
99
194
|
collectionName,
|
|
100
195
|
indexedAt: now,
|
|
101
196
|
lastVerifiedAt: now,
|
|
102
|
-
status: '
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
166
|
-
this.
|
|
219
|
+
else {
|
|
220
|
+
await this.ensureVectorIndexFresh(stats.chunkCount, diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0);
|
|
167
221
|
}
|
|
168
|
-
this.
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
|
379
|
+
const normalized = this.normalizeRelativePath(relativePath);
|
|
380
|
+
const targetPath = this.resolveKbRelativePath(normalized);
|
|
247
381
|
if (fs.existsSync(targetPath))
|
|
248
382
|
fs.unlinkSync(targetPath);
|
|
249
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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'
|
|
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
|
}
|