@customize-agent/knowledge 4.0.32 → 4.0.34
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/core/index-state-store.d.ts +11 -0
- package/dist/core/index-state-store.js +46 -8
- package/dist/core/knowledge-base-manager.d.ts +7 -1
- package/dist/core/knowledge-base-manager.js +103 -31
- package/dist/core/multi-project-manager.js +5 -3
- package/dist/embedding/embedding-provider.js +1 -1
- package/dist/extraction/content-extractor.d.ts +5 -1
- package/dist/extraction/content-extractor.js +88 -16
- package/dist/vector/hnsw-vector-store.d.ts +4 -0
- package/dist/vector/hnsw-vector-store.js +65 -12
- package/dist/vector/types.d.ts +1 -0
- package/package.json +1 -1
|
@@ -130,11 +130,21 @@ export declare class IndexStateStore {
|
|
|
130
130
|
format: string;
|
|
131
131
|
collectionName: string;
|
|
132
132
|
}): void;
|
|
133
|
+
private chunkQueryParts;
|
|
133
134
|
listChunks(options?: {
|
|
134
135
|
collectionName?: string;
|
|
135
136
|
relativePath?: string;
|
|
136
137
|
limit?: number;
|
|
137
138
|
}): StoredChunk[];
|
|
139
|
+
countChunks(options?: {
|
|
140
|
+
collectionName?: string;
|
|
141
|
+
relativePath?: string;
|
|
142
|
+
}): number;
|
|
143
|
+
listChunksByContentBudget(options?: {
|
|
144
|
+
collectionName?: string;
|
|
145
|
+
relativePath?: string;
|
|
146
|
+
maxContentChars?: number;
|
|
147
|
+
}): StoredChunk[];
|
|
138
148
|
getChunkByRowid(rowid: number): StoredChunk | undefined;
|
|
139
149
|
getChunksByRowids(rowids: number[]): StoredChunk[];
|
|
140
150
|
getContextChunks(relativePath: string, chunkIndex: number, window?: number): StoredChunk[];
|
|
@@ -152,6 +162,7 @@ export declare class IndexStateStore {
|
|
|
152
162
|
filePaths?: string[];
|
|
153
163
|
}): ChunkSearchResult[];
|
|
154
164
|
private filePathFilterClause;
|
|
165
|
+
private resolveChunkSearchLimit;
|
|
155
166
|
private searchChunksFts;
|
|
156
167
|
private searchChunksLike;
|
|
157
168
|
findExactDuplicate(contentHash: string, excludePath?: string): FileHashRecord | undefined;
|
|
@@ -182,7 +182,7 @@ export class IndexStateStore {
|
|
|
182
182
|
});
|
|
183
183
|
transaction();
|
|
184
184
|
}
|
|
185
|
-
|
|
185
|
+
chunkQueryParts(options = {}) {
|
|
186
186
|
const conditions = [];
|
|
187
187
|
const params = [];
|
|
188
188
|
if (options.collectionName) {
|
|
@@ -194,17 +194,47 @@ export class IndexStateStore {
|
|
|
194
194
|
params.push(options.relativePath);
|
|
195
195
|
}
|
|
196
196
|
const where = conditions.length > 0 ? `WHERE ${conditions.join(' AND ')}` : '';
|
|
197
|
+
return { where, params };
|
|
198
|
+
}
|
|
199
|
+
listChunks(options = {}) {
|
|
200
|
+
const { where, params } = this.chunkQueryParts(options);
|
|
197
201
|
const limit = options.limit ? 'LIMIT ?' : '';
|
|
198
|
-
|
|
199
|
-
params.push(options.limit);
|
|
202
|
+
const queryParams = options.limit ? [...params, options.limit] : params;
|
|
200
203
|
const rows = this.db.prepare(`
|
|
201
204
|
SELECT rowid, * FROM kb_chunks
|
|
202
205
|
${where}
|
|
203
206
|
ORDER BY relative_path, chunk_index
|
|
204
207
|
${limit}
|
|
205
|
-
`).all(...
|
|
208
|
+
`).all(...queryParams);
|
|
206
209
|
return rows.map(row => this.rowToChunk(row, 0));
|
|
207
210
|
}
|
|
211
|
+
countChunks(options = {}) {
|
|
212
|
+
const { where, params } = this.chunkQueryParts(options);
|
|
213
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS count FROM kb_chunks ${where}`).get(...params);
|
|
214
|
+
return Number(row?.count || 0);
|
|
215
|
+
}
|
|
216
|
+
listChunksByContentBudget(options = {}) {
|
|
217
|
+
const maxContentChars = Number(options.maxContentChars);
|
|
218
|
+
if (!Number.isFinite(maxContentChars) || maxContentChars <= 0)
|
|
219
|
+
return this.listChunks(options);
|
|
220
|
+
const { where, params } = this.chunkQueryParts(options);
|
|
221
|
+
const rows = this.db.prepare(`
|
|
222
|
+
SELECT rowid, * FROM kb_chunks
|
|
223
|
+
${where}
|
|
224
|
+
ORDER BY relative_path, chunk_index
|
|
225
|
+
`).iterate(...params);
|
|
226
|
+
const chunks = [];
|
|
227
|
+
let usedChars = 0;
|
|
228
|
+
for (const row of rows) {
|
|
229
|
+
const chunk = this.rowToChunk(row, 0);
|
|
230
|
+
const length = chunk.content?.length || 0;
|
|
231
|
+
if (chunks.length > 0 && usedChars + length > maxContentChars)
|
|
232
|
+
break;
|
|
233
|
+
chunks.push(chunk);
|
|
234
|
+
usedChars += length;
|
|
235
|
+
}
|
|
236
|
+
return chunks;
|
|
237
|
+
}
|
|
208
238
|
getChunkByRowid(rowid) {
|
|
209
239
|
const row = this.db.prepare('SELECT rowid, * FROM kb_chunks WHERE rowid = ?').get(rowid);
|
|
210
240
|
return row ? this.rowToChunk(row, 0) : undefined;
|
|
@@ -264,20 +294,28 @@ export class IndexStateStore {
|
|
|
264
294
|
* @param limit 返回结果数量上限
|
|
265
295
|
* @returns 搜索结果列表(按相关性得分排序)
|
|
266
296
|
*/
|
|
267
|
-
searchChunks(query, limit
|
|
297
|
+
searchChunks(query, limit, filters = {}) {
|
|
268
298
|
const terms = this.expandSearchTerms(query);
|
|
269
299
|
if (terms.length === 0)
|
|
270
300
|
return [];
|
|
271
301
|
const filePaths = [...new Set((filters.filePaths ?? []).filter(Boolean))];
|
|
302
|
+
const effectiveLimit = this.resolveChunkSearchLimit(limit, filePaths);
|
|
272
303
|
const results = [
|
|
273
|
-
...(this.ftsEnabled ? this.searchChunksFts(terms,
|
|
274
|
-
...this.searchChunksLike(terms,
|
|
304
|
+
...(this.ftsEnabled ? this.searchChunksFts(terms, effectiveLimit, filePaths) : []),
|
|
305
|
+
...this.searchChunksLike(terms, effectiveLimit, filePaths),
|
|
275
306
|
];
|
|
276
|
-
return this.mergeKeywordResults(results,
|
|
307
|
+
return this.mergeKeywordResults(results, effectiveLimit);
|
|
277
308
|
}
|
|
278
309
|
filePathFilterClause(filePaths, column = 'relative_path') {
|
|
279
310
|
return filePaths.length > 0 ? ` AND ${column} IN (${filePaths.map(() => '?').join(', ')})` : '';
|
|
280
311
|
}
|
|
312
|
+
resolveChunkSearchLimit(limit, filePaths) {
|
|
313
|
+
if (Number.isFinite(limit) && limit > 0)
|
|
314
|
+
return Math.ceil(limit);
|
|
315
|
+
const row = this.db.prepare(`SELECT COUNT(*) as count FROM kb_chunks WHERE 1 = 1${this.filePathFilterClause(filePaths)}`)
|
|
316
|
+
.get(...filePaths);
|
|
317
|
+
return Math.max(1, Math.ceil(Number(row?.count) || 0));
|
|
318
|
+
}
|
|
281
319
|
searchChunksFts(terms, limit, filePaths) {
|
|
282
320
|
try {
|
|
283
321
|
const matchQuery = this.toFtsQuery(terms);
|
|
@@ -66,6 +66,8 @@ export declare class KnowledgeBaseManager {
|
|
|
66
66
|
}): Promise<DiffResult>;
|
|
67
67
|
search(query: string, limit?: number, filters?: SearchFilters): ChunkSearchResult[];
|
|
68
68
|
keywordSearchItems(query: string, limit?: number, filters?: SearchFilters): FederatedSearchItem[];
|
|
69
|
+
private resolveSearchLimit;
|
|
70
|
+
private searchCorpusSize;
|
|
69
71
|
expandContext(item: FederatedSearchItem): FederatedSearchItem;
|
|
70
72
|
hybridSearch(query: string, options?: {
|
|
71
73
|
limit?: number;
|
|
@@ -81,11 +83,14 @@ export declare class KnowledgeBaseManager {
|
|
|
81
83
|
}): Promise<FederatedResult>;
|
|
82
84
|
listRelationships(filePath?: string): FileRelationship[];
|
|
83
85
|
listFiles(): IndexStateRecord[];
|
|
84
|
-
getFileDetail(relativePath: string
|
|
86
|
+
getFileDetail(relativePath: string, options?: {
|
|
87
|
+
maxChunkContentChars?: number;
|
|
88
|
+
}): {
|
|
85
89
|
file: IndexStateRecord;
|
|
86
90
|
absolutePath: string;
|
|
87
91
|
directory: string;
|
|
88
92
|
chunks: import("./index-state-store.js").StoredChunk[];
|
|
93
|
+
totalChunkCount: number;
|
|
89
94
|
parents: import("./index-state-store.js").StoredParentChunk[];
|
|
90
95
|
relationships: FileRelationship[];
|
|
91
96
|
tags: {
|
|
@@ -175,6 +180,7 @@ export declare class KnowledgeBaseManager {
|
|
|
175
180
|
private updateJobsForFile;
|
|
176
181
|
private ensureVectorStore;
|
|
177
182
|
private deleteVectorFile;
|
|
183
|
+
private consumePendingVectorRelativePaths;
|
|
178
184
|
private ensureVectorIndexFresh;
|
|
179
185
|
uploadSessionIsOpen(operationId: string): boolean;
|
|
180
186
|
private emptyDiff;
|
|
@@ -91,7 +91,17 @@ export class KnowledgeBaseManager {
|
|
|
91
91
|
const jobs = this.store.listPendingIndexJobs(options.limit ?? 500);
|
|
92
92
|
if (jobs.length === 0)
|
|
93
93
|
return this.emptyDiff();
|
|
94
|
-
|
|
94
|
+
const lightweightJobs = [];
|
|
95
|
+
const heavyJobs = [];
|
|
96
|
+
for (const job of jobs) {
|
|
97
|
+
const ext = path.extname(job.relativePath).toLowerCase();
|
|
98
|
+
if (/\.(pdf|png|jpe?g|webp|gif|bmp|tiff?|xlsx?|xlsm|docx?|pptx?)$/iu.test(ext))
|
|
99
|
+
heavyJobs.push(job);
|
|
100
|
+
else
|
|
101
|
+
lightweightJobs.push(job);
|
|
102
|
+
}
|
|
103
|
+
const selectedJobs = [...lightweightJobs, ...heavyJobs].slice(0, options.limit ?? 500);
|
|
104
|
+
return this.incrementalIndex({ ...options, onlyRelativePaths: selectedJobs.map(job => job.relativePath) });
|
|
95
105
|
}
|
|
96
106
|
async incrementalIndex(options = {}) {
|
|
97
107
|
this.initialize();
|
|
@@ -111,12 +121,25 @@ export class KnowledgeBaseManager {
|
|
|
111
121
|
diff.newFiles = diff.newFiles.filter(file => onlyRelativePaths.has(file.relativePath));
|
|
112
122
|
diff.modifiedFiles = diff.modifiedFiles.filter(file => onlyRelativePaths.has(file.relativePath));
|
|
113
123
|
diff.deletedFiles = diff.deletedFiles.filter(file => onlyRelativePaths.has(file.relativePath));
|
|
114
|
-
diff.hasChanges = diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0;
|
|
115
124
|
for (const relativePath of onlyRelativePaths) {
|
|
116
125
|
const exists = diff.newFiles.some(file => file.relativePath === relativePath) || diff.modifiedFiles.some(file => file.relativePath === relativePath) || diff.deletedFiles.some(file => file.relativePath === relativePath);
|
|
117
|
-
if (
|
|
118
|
-
|
|
126
|
+
if (exists)
|
|
127
|
+
continue;
|
|
128
|
+
const diskStat = diskFiles.get(relativePath);
|
|
129
|
+
if (!diskStat) {
|
|
130
|
+
this.updateJobsForFile(relativePath, 'ERROR', 100, '待索引文件不存在', '待索引文件不存在');
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const absolutePath = this.resolveKbRelativePath(relativePath);
|
|
134
|
+
const stat = fs.statSync(absolutePath);
|
|
135
|
+
const classified = this.classifier.classify(absolutePath, relativePath, stat);
|
|
136
|
+
const skipReason = this.classifier.shouldSkip(classified);
|
|
137
|
+
if (skipReason)
|
|
138
|
+
this.updateJobsForFile(relativePath, 'ERROR', 100, skipReason, skipReason);
|
|
139
|
+
else
|
|
140
|
+
diff.modifiedFiles.push(classified);
|
|
119
141
|
}
|
|
142
|
+
diff.hasChanges = diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0;
|
|
120
143
|
}
|
|
121
144
|
for (const deleted of diff.deletedFiles) {
|
|
122
145
|
await this.deleteVectorFile(deleted.collectionName, deleted.relativePath);
|
|
@@ -263,8 +286,13 @@ export class KnowledgeBaseManager {
|
|
|
263
286
|
this.store.setMetadata('total_files_indexed', String(stats.fileCount));
|
|
264
287
|
const hasIndexChanges = diff.newFiles.length + diff.modifiedFiles.length + diff.deletedFiles.length > 0;
|
|
265
288
|
if (options.vectorMode === 'defer') {
|
|
266
|
-
if (hasIndexChanges)
|
|
289
|
+
if (hasIndexChanges) {
|
|
267
290
|
this.store.setMetadata('vector_index_status', 'pending');
|
|
291
|
+
const pending = new Set(this.consumePendingVectorRelativePaths());
|
|
292
|
+
for (const relativePath of vectorRelativePaths)
|
|
293
|
+
pending.add(relativePath);
|
|
294
|
+
this.store.setMetadata('vector_pending_relative_paths', JSON.stringify([...pending]));
|
|
295
|
+
}
|
|
268
296
|
this.reportProgress({ stage: 'vectorizing', percent: 85, message: '解析和切片已完成,向量入库转入后台/稍后执行', chunkCount: stats.chunkCount, vectorStatus: this.getVectorStatus() });
|
|
269
297
|
}
|
|
270
298
|
else {
|
|
@@ -298,11 +326,24 @@ export class KnowledgeBaseManager {
|
|
|
298
326
|
this.onProgress = previousOnProgress;
|
|
299
327
|
}
|
|
300
328
|
}
|
|
301
|
-
search(query, limit
|
|
302
|
-
return this.store.searchChunks(query, limit, { filePaths: filters?.filePaths ?? (filters?.filePath ? [filters.filePath] : undefined) });
|
|
329
|
+
search(query, limit, filters) {
|
|
330
|
+
return this.store.searchChunks(query, this.resolveSearchLimit(limit, filters), { filePaths: filters?.filePaths ?? (filters?.filePath ? [filters.filePath] : undefined) });
|
|
331
|
+
}
|
|
332
|
+
keywordSearchItems(query, limit, filters) {
|
|
333
|
+
return this.store.searchChunks(query, this.resolveSearchLimit(limit, filters), { filePaths: filters?.filePaths ?? (filters?.filePath ? [filters.filePath] : undefined) }).map(result => this.toFederatedItem(result, 'keyword'));
|
|
334
|
+
}
|
|
335
|
+
resolveSearchLimit(limit, filters) {
|
|
336
|
+
if (Number.isFinite(limit) && limit > 0)
|
|
337
|
+
return Math.ceil(limit);
|
|
338
|
+
return this.searchCorpusSize(filters);
|
|
303
339
|
}
|
|
304
|
-
|
|
305
|
-
|
|
340
|
+
searchCorpusSize(filters) {
|
|
341
|
+
const paths = filters?.filePaths ?? (filters?.filePath ? [filters.filePath] : undefined);
|
|
342
|
+
const pathSet = new Set(paths?.filter(Boolean));
|
|
343
|
+
const total = this.store.listRecords()
|
|
344
|
+
.filter(record => pathSet.size === 0 || pathSet.has(record.relativePath))
|
|
345
|
+
.reduce((sum, record) => sum + Math.max(0, Math.ceil(Number(record.chunkCount) || 0)), 0);
|
|
346
|
+
return Math.max(1, total);
|
|
306
347
|
}
|
|
307
348
|
expandContext(item) {
|
|
308
349
|
const chunkIndex = item.chunkIndex ?? this.parseChunkIndex(item.id);
|
|
@@ -333,7 +374,9 @@ export class KnowledgeBaseManager {
|
|
|
333
374
|
};
|
|
334
375
|
}
|
|
335
376
|
async hybridSearch(query, options = {}) {
|
|
336
|
-
const
|
|
377
|
+
const requestedLimit = Number.isFinite(options.limit) && options.limit > 0 ? Math.ceil(options.limit) : undefined;
|
|
378
|
+
const corpusSize = this.searchCorpusSize(options.filters);
|
|
379
|
+
const effectiveLimit = requestedLimit ?? corpusSize;
|
|
337
380
|
const start = Date.now();
|
|
338
381
|
const weights = this.retrievalWeights(options.weights);
|
|
339
382
|
const rewrittenQueries = options.generationMode ? [query.trim()].filter(Boolean) : await this.rewriteQueries(query);
|
|
@@ -342,10 +385,12 @@ export class KnowledgeBaseManager {
|
|
|
342
385
|
const vectorMultiplier = options.generationMode ? 3 : 6;
|
|
343
386
|
const vectorQueryLimit = options.generationMode ? 1 : 3;
|
|
344
387
|
for (const [queryIndex, rewritten] of rewrittenQueries.entries()) {
|
|
345
|
-
rankedLists.push({ source: 'keyword', items: this.keywordSearchItems(rewritten,
|
|
388
|
+
rankedLists.push({ source: 'keyword', items: this.keywordSearchItems(rewritten, effectiveLimit * keywordMultiplier, options.filters), queryIndex });
|
|
346
389
|
if (queryIndex < vectorQueryLimit) {
|
|
347
390
|
try {
|
|
348
|
-
|
|
391
|
+
const vectorLimit = effectiveLimit * vectorMultiplier;
|
|
392
|
+
const keywordLimit = effectiveLimit * keywordMultiplier;
|
|
393
|
+
rankedLists.push({ source: 'vector', items: (await this.semanticSearch(rewritten, { ...options, limit: vectorLimit })).results.slice(0, keywordLimit), queryIndex });
|
|
349
394
|
}
|
|
350
395
|
catch { /* 向量搜索在混合搜索中是可选的 */ }
|
|
351
396
|
}
|
|
@@ -353,12 +398,14 @@ export class KnowledgeBaseManager {
|
|
|
353
398
|
const keywordItems = rankedLists.filter(list => list.source === 'keyword').flatMap(list => list.items);
|
|
354
399
|
const vectorItems = rankedLists.filter(list => list.source === 'vector').flatMap(list => list.items);
|
|
355
400
|
// 1. 先进行初筛合并,合并相同的子块并计算混合初始分(不获取大片段,保留子块自身用于精确打分)
|
|
356
|
-
const
|
|
401
|
+
const mergeLimit = effectiveLimit * 4;
|
|
402
|
+
const mergedChildChunks = this.mergeContexts(this.mergeHybridRankedLists(rankedLists, mergeLimit, weights), mergeLimit);
|
|
357
403
|
// 2. 对这些子块进行交叉编码器重排(Cross-Encoder Rerank)
|
|
358
404
|
let reranked = mergedChildChunks;
|
|
359
405
|
let rerankerName = 'local-heuristic-fallback';
|
|
360
406
|
if (mergedChildChunks.length > 0) {
|
|
361
|
-
const
|
|
407
|
+
const rerankLimit = requestedLimit ? Math.min(30, mergeLimit) : mergedChildChunks.length;
|
|
408
|
+
const candidates = mergedChildChunks.slice(0, rerankLimit);
|
|
362
409
|
// 这里使用的是子块自身内容,通常在 500 tokens 左右,不仅相关性判断最准,而且不会超出 Reranker 的 max_length
|
|
363
410
|
const textsToRerank = candidates.map(item => `${item.titlePath ?? item.sectionTitle ?? ''}\n${item.content}`);
|
|
364
411
|
try {
|
|
@@ -381,8 +428,8 @@ export class KnowledgeBaseManager {
|
|
|
381
428
|
reranked = this.heuristicRerank(query, mergedChildChunks);
|
|
382
429
|
}
|
|
383
430
|
}
|
|
384
|
-
// 3.
|
|
385
|
-
const finalExpandedResults = this.mergeExpandedContexts(reranked.map(item => this.expandContext(item)),
|
|
431
|
+
// 3. 拿到精确打分后的结果,此时再进行 expandContext 向上追溯到完整的父块大片段
|
|
432
|
+
const finalExpandedResults = this.mergeExpandedContexts(reranked.map(item => this.expandContext(item)), effectiveLimit);
|
|
386
433
|
return {
|
|
387
434
|
results: finalExpandedResults,
|
|
388
435
|
scopesSearched: this.scope === 'global' ? ['global'] : ['project'],
|
|
@@ -405,7 +452,7 @@ export class KnowledgeBaseManager {
|
|
|
405
452
|
const result = await search.search({
|
|
406
453
|
query,
|
|
407
454
|
queryEmbedding,
|
|
408
|
-
topK: options.limit
|
|
455
|
+
topK: this.resolveSearchLimit(options.limit, options.filters),
|
|
409
456
|
scope: this.scope,
|
|
410
457
|
projectId: this.projectId,
|
|
411
458
|
collections: options.collections,
|
|
@@ -423,17 +470,21 @@ export class KnowledgeBaseManager {
|
|
|
423
470
|
listFiles() {
|
|
424
471
|
return this.store.listRecords();
|
|
425
472
|
}
|
|
426
|
-
getFileDetail(relativePath) {
|
|
473
|
+
getFileDetail(relativePath, options = {}) {
|
|
427
474
|
const normalized = this.normalizeRelativePath(relativePath);
|
|
428
475
|
const file = this.store.listRecords().find(record => record.relativePath === normalized);
|
|
429
476
|
if (!file)
|
|
430
477
|
return undefined;
|
|
431
478
|
const absolutePath = this.resolveKbRelativePath(normalized);
|
|
479
|
+
const chunks = options.maxChunkContentChars
|
|
480
|
+
? this.store.listChunksByContentBudget({ relativePath: normalized, maxContentChars: options.maxChunkContentChars })
|
|
481
|
+
: this.store.listChunks({ relativePath: normalized });
|
|
432
482
|
return {
|
|
433
483
|
file,
|
|
434
484
|
absolutePath,
|
|
435
485
|
directory: path.dirname(absolutePath),
|
|
436
|
-
chunks
|
|
486
|
+
chunks,
|
|
487
|
+
totalChunkCount: this.store.countChunks({ relativePath: normalized }),
|
|
437
488
|
parents: this.store.listParentChunks(normalized),
|
|
438
489
|
relationships: this.store.listRelationships(normalized),
|
|
439
490
|
tags: this.store.listTags(normalized),
|
|
@@ -474,10 +525,6 @@ export class KnowledgeBaseManager {
|
|
|
474
525
|
const targetPath = this.resolveKbRelativePath(relativePath);
|
|
475
526
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
476
527
|
fs.writeFileSync(targetPath, file.content);
|
|
477
|
-
const record = this.store.listRecords().find(item => item.relativePath === relativePath);
|
|
478
|
-
if (record)
|
|
479
|
-
await this.deleteVectorFile(record.collectionName, relativePath);
|
|
480
|
-
this.store.deleteRecord(relativePath);
|
|
481
528
|
jobs.push(this.store.enqueueIndexJob({ id: `${operationId}-${index}`, relativePath, message: '文件已落盘,等待后台解析' }));
|
|
482
529
|
}
|
|
483
530
|
return jobs;
|
|
@@ -492,10 +539,6 @@ export class KnowledgeBaseManager {
|
|
|
492
539
|
const targetPath = this.resolveKbRelativePath(relativePath);
|
|
493
540
|
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
494
541
|
this.moveUploadedFile(file.sourcePath, targetPath);
|
|
495
|
-
const record = this.store.listRecords().find(item => item.relativePath === relativePath);
|
|
496
|
-
if (record)
|
|
497
|
-
await this.deleteVectorFile(record.collectionName, relativePath);
|
|
498
|
-
this.store.deleteRecord(relativePath);
|
|
499
542
|
jobs.push(this.store.enqueueIndexJob({ id: `${operationId}-${offset + index}`, relativePath, message: '文件已落盘,等待后台解析' }));
|
|
500
543
|
}
|
|
501
544
|
return jobs;
|
|
@@ -543,6 +586,11 @@ export class KnowledgeBaseManager {
|
|
|
543
586
|
return this.store.listIgnoreRules();
|
|
544
587
|
}
|
|
545
588
|
async indexVectors(options = {}) {
|
|
589
|
+
const pendingRelativePaths = !options.rebuild && !options.relativePath && !options.relativePaths?.length
|
|
590
|
+
? this.consumePendingVectorRelativePaths()
|
|
591
|
+
: [];
|
|
592
|
+
if (pendingRelativePaths.length > 0)
|
|
593
|
+
options = { ...options, relativePaths: pendingRelativePaths };
|
|
546
594
|
const chunks = options.relativePaths?.length
|
|
547
595
|
? options.relativePaths.flatMap(relativePath => this.store.listChunks({ collectionName: options.collectionName, relativePath }))
|
|
548
596
|
: this.store.listChunks(options);
|
|
@@ -555,9 +603,15 @@ export class KnowledgeBaseManager {
|
|
|
555
603
|
await this.vectorStores.get(collectionName)?.clearCollection?.();
|
|
556
604
|
}
|
|
557
605
|
else {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
606
|
+
const cleanupRelativePaths = options.relativePaths ?? [options.relativePath].filter(Boolean);
|
|
607
|
+
for (const collectionName of cleanupCollectionNames) {
|
|
608
|
+
const vectorStore = this.vectorStores.get(collectionName);
|
|
609
|
+
if (vectorStore?.deleteByFilePaths)
|
|
610
|
+
await vectorStore.deleteByFilePaths(cleanupRelativePaths, { persist: false });
|
|
611
|
+
else {
|
|
612
|
+
for (const relativePath of cleanupRelativePaths)
|
|
613
|
+
await vectorStore?.deleteByFilePath(relativePath, { persist: false });
|
|
614
|
+
}
|
|
561
615
|
}
|
|
562
616
|
if (chunks.length === 0) {
|
|
563
617
|
for (const collectionName of cleanupCollectionNames)
|
|
@@ -906,6 +960,22 @@ export class KnowledgeBaseManager {
|
|
|
906
960
|
this.store.setMetadata('vector_index_error', error instanceof Error ? error.message : String(error));
|
|
907
961
|
}
|
|
908
962
|
}
|
|
963
|
+
consumePendingVectorRelativePaths() {
|
|
964
|
+
const raw = this.store.getMetadata('vector_pending_relative_paths');
|
|
965
|
+
if (!raw)
|
|
966
|
+
return [];
|
|
967
|
+
try {
|
|
968
|
+
const parsed = JSON.parse(raw);
|
|
969
|
+
if (!Array.isArray(parsed))
|
|
970
|
+
return [];
|
|
971
|
+
this.store.setMetadata('vector_pending_relative_paths', '');
|
|
972
|
+
return [...new Set(parsed.filter((item) => typeof item === 'string' && item.trim().length > 0))];
|
|
973
|
+
}
|
|
974
|
+
catch {
|
|
975
|
+
this.store.setMetadata('vector_pending_relative_paths', '');
|
|
976
|
+
return [];
|
|
977
|
+
}
|
|
978
|
+
}
|
|
909
979
|
async ensureVectorIndexFresh(chunkCount, options = {}) {
|
|
910
980
|
if (chunkCount === 0)
|
|
911
981
|
return;
|
|
@@ -925,7 +995,7 @@ export class KnowledgeBaseManager {
|
|
|
925
995
|
this.store.setMetadata('last_vector_index_at', String(Date.now()));
|
|
926
996
|
return;
|
|
927
997
|
}
|
|
928
|
-
if (changedRelativePaths.length > 0
|
|
998
|
+
if (changedRelativePaths.length > 0) {
|
|
929
999
|
for (const collectionName of options.changedCollectionNames ?? [])
|
|
930
1000
|
this.ensureVectorStore(collectionName);
|
|
931
1001
|
await this.indexVectors({ relativePaths: changedRelativePaths, cleanupCollectionNames: options.changedCollectionNames });
|
|
@@ -933,6 +1003,8 @@ export class KnowledgeBaseManager {
|
|
|
933
1003
|
}
|
|
934
1004
|
if (indexedChunks === chunkCount && status === 'ready')
|
|
935
1005
|
return;
|
|
1006
|
+
if (status === 'pending' || status === 'partial')
|
|
1007
|
+
return;
|
|
936
1008
|
await this.indexVectors({ rebuild: true });
|
|
937
1009
|
}
|
|
938
1010
|
uploadSessionIsOpen(operationId) {
|
|
@@ -59,7 +59,7 @@ export class MultiProjectManager {
|
|
|
59
59
|
return this.registry.list();
|
|
60
60
|
}
|
|
61
61
|
async search(projectRoot, query, options = {}) {
|
|
62
|
-
const limit = options.limit
|
|
62
|
+
const limit = Number.isFinite(options.limit) && options.limit > 0 ? Math.ceil(options.limit) : undefined;
|
|
63
63
|
const scope = options.scope ?? 'project';
|
|
64
64
|
const project = await this.getProject(projectRoot);
|
|
65
65
|
if (scope === 'project')
|
|
@@ -73,7 +73,8 @@ export class MultiProjectManager {
|
|
|
73
73
|
}
|
|
74
74
|
const global = await this.getGlobalKB();
|
|
75
75
|
const globalResults = await global.hybridSearch(query, { limit, filters: options.filters, weights: options.weights, generationMode: options.generationMode });
|
|
76
|
-
const
|
|
76
|
+
const mergeLimit = limit ?? (projectResults.results.length + globalResults.results.length);
|
|
77
|
+
const merged = new FederationSearch().merge([...projectResults.results, ...globalResults.results], mergeLimit, 'all');
|
|
77
78
|
return {
|
|
78
79
|
...merged,
|
|
79
80
|
debug: this.mergeDebug(projectResults.debug, globalResults.debug),
|
|
@@ -94,7 +95,8 @@ export class MultiProjectManager {
|
|
|
94
95
|
}
|
|
95
96
|
const global = await this.getGlobalKB();
|
|
96
97
|
const globalResults = await global.semanticSearch(query, options);
|
|
97
|
-
|
|
98
|
+
const mergeLimit = Number.isFinite(options.limit) && options.limit > 0 ? Math.ceil(options.limit) : projectResults.results.length + globalResults.results.length;
|
|
99
|
+
return new FederationSearch().merge([...projectResults.results, ...globalResults.results], mergeLimit, 'all');
|
|
98
100
|
}
|
|
99
101
|
async findCrossProjectDuplicates() {
|
|
100
102
|
const projects = this.registry.list();
|
|
@@ -84,7 +84,7 @@ export class OpenAICompatibleEmbeddingProvider {
|
|
|
84
84
|
}
|
|
85
85
|
function resolveLocalEmbeddingBatchSize(configured) {
|
|
86
86
|
const raw = configured ?? Number(process.env.CUSTOMIZE_EMBEDDING_BATCH_SIZE ?? process.env.KB_EMBEDDING_BATCH_SIZE);
|
|
87
|
-
const fallback = process.platform === 'win32' ? 8 :
|
|
87
|
+
const fallback = process.platform === 'win32' ? 8 : 32;
|
|
88
88
|
if (!Number.isFinite(raw) || raw <= 0)
|
|
89
89
|
return fallback;
|
|
90
90
|
return Math.max(1, Math.min(128, Math.floor(raw)));
|
|
@@ -52,11 +52,15 @@ export declare class ContentExtractor {
|
|
|
52
52
|
private formatBoundingBox;
|
|
53
53
|
private validateRasterImage;
|
|
54
54
|
private extractPdf;
|
|
55
|
+
private hasUsablePdfText;
|
|
55
56
|
private extractPdfHybridPages;
|
|
56
|
-
/** PyMuPDF
|
|
57
|
+
/** PyMuPDF 渲染(默认 200 DPI,低质量页可自适应提高) */
|
|
57
58
|
private tryRenderWithPyMuPDF;
|
|
58
59
|
/** pdfjs-dist + canvas 渲染(降级方案) */
|
|
59
60
|
private tryRenderWithPdfJs;
|
|
61
|
+
private getPdfOcrDpi;
|
|
62
|
+
private getPdfOcrRetryDpi;
|
|
63
|
+
private shouldRetryPdfOcrAtHigherDpi;
|
|
60
64
|
private scoreOcrText;
|
|
61
65
|
private cleanOcrText;
|
|
62
66
|
/** 加载图片像素数据(依赖 sharp) */
|
|
@@ -1029,24 +1029,27 @@ export class ContentExtractor {
|
|
|
1029
1029
|
return undefined;
|
|
1030
1030
|
}
|
|
1031
1031
|
async extractPdf(file) {
|
|
1032
|
-
const
|
|
1033
|
-
|
|
1034
|
-
return hybrid;
|
|
1035
|
-
const metadata = { extractionMode: 'pdf_text', vectorizable: true };
|
|
1036
|
-
const warnings = [...hybrid.warnings];
|
|
1032
|
+
const metadata = { extractionMode: 'pdf_text_first', vectorizable: true };
|
|
1033
|
+
const warnings = [];
|
|
1037
1034
|
try {
|
|
1038
1035
|
const raw = fs.readFileSync(file.absolutePath);
|
|
1039
1036
|
const text = await this.extractPdfText(raw);
|
|
1040
|
-
if (
|
|
1037
|
+
if (this.hasUsablePdfText(text)) {
|
|
1041
1038
|
metadata.contentCoverage = 'pdf_text_streams_layout_markdown';
|
|
1042
1039
|
metadata.pdfExtractor = 'pdfjs-dist';
|
|
1040
|
+
metadata.ocrSkippedReason = 'pdf_text_stream_quality_sufficient';
|
|
1043
1041
|
return { text: [this.metadataOnlyText(file), this.toMarkdownDocument(text)].join('\n\n'), metadata, warnings };
|
|
1044
1042
|
}
|
|
1043
|
+
if (text.trim())
|
|
1044
|
+
warnings.push('PDF 文本层质量不足,已尝试选择性 OCR 增强');
|
|
1045
1045
|
}
|
|
1046
1046
|
catch (error) {
|
|
1047
1047
|
warnings.push(`PDF 文本提取失败: ${error instanceof Error ? error.message : String(error)}`);
|
|
1048
1048
|
metadata.parseError = error instanceof Error ? error.message : String(error);
|
|
1049
1049
|
}
|
|
1050
|
+
const hybrid = await this.extractPdfHybridPages(file);
|
|
1051
|
+
if (hybrid.text.trim())
|
|
1052
|
+
return { text: hybrid.text, metadata: { ...metadata, ...hybrid.metadata }, warnings: [...warnings, ...hybrid.warnings] };
|
|
1050
1053
|
metadata.extractionMode = 'pdf_metadata_only';
|
|
1051
1054
|
metadata.contentCoverage = 'metadata_filename';
|
|
1052
1055
|
metadata.ocrRecommended = true;
|
|
@@ -1055,9 +1058,17 @@ export class ContentExtractor {
|
|
|
1055
1058
|
return {
|
|
1056
1059
|
text: this.metadataOnlyText(file),
|
|
1057
1060
|
metadata,
|
|
1058
|
-
warnings: [...warnings, 'PDF 正文暂未提取到文本,已索引文件名、路径和类型元数据'],
|
|
1061
|
+
warnings: [...warnings, ...hybrid.warnings, 'PDF 正文暂未提取到文本,已索引文件名、路径和类型元数据'],
|
|
1059
1062
|
};
|
|
1060
1063
|
}
|
|
1064
|
+
hasUsablePdfText(text) {
|
|
1065
|
+
const normalized = text.replace(/\s+/gu, ' ').trim();
|
|
1066
|
+
if (normalized.length < Number(process.env.CUSTOMIZE_KB_PDF_TEXT_MIN_CHARS || 80))
|
|
1067
|
+
return false;
|
|
1068
|
+
const replacementRatio = (normalized.match(/[\uFFFD�]/gu)?.length ?? 0) / normalized.length;
|
|
1069
|
+
const visibleRatio = (normalized.match(/[\p{L}\p{N}\p{Script=Han}]/gu)?.length ?? 0) / normalized.length;
|
|
1070
|
+
return replacementRatio < 0.02 && visibleRatio > 0.35;
|
|
1071
|
+
}
|
|
1061
1072
|
async extractPdfHybridPages(file) {
|
|
1062
1073
|
const metadata = {
|
|
1063
1074
|
extractionMode: 'pdf_hybrid_pages',
|
|
@@ -1074,17 +1085,37 @@ export class ContentExtractor {
|
|
|
1074
1085
|
};
|
|
1075
1086
|
const failedPages = [];
|
|
1076
1087
|
const ocrPages = [];
|
|
1088
|
+
const ocrRetryPages = [];
|
|
1077
1089
|
const ocrStrategies = [];
|
|
1078
|
-
// 尝试 PyMuPDF
|
|
1090
|
+
// 尝试 PyMuPDF 渲染(默认 200 DPI),低质量页自动升到 300 DPI 重试
|
|
1079
1091
|
let pageImages;
|
|
1092
|
+
let highDpiImages = null;
|
|
1080
1093
|
let pageCount = 0;
|
|
1081
1094
|
let renderer = 'unknown';
|
|
1095
|
+
const initialDpi = this.getPdfOcrDpi();
|
|
1096
|
+
const retryDpi = this.getPdfOcrRetryDpi(initialDpi);
|
|
1082
1097
|
const tmpDir = fs.mkdtempSync(path.join(this.getTempRoot(), 'kb-pdf-'));
|
|
1098
|
+
metadata.pdfOcrInitialDpi = initialDpi;
|
|
1099
|
+
if (retryDpi > initialDpi)
|
|
1100
|
+
metadata.pdfOcrRetryDpi = retryDpi;
|
|
1101
|
+
const getHighDpiImage = (pageIndex) => {
|
|
1102
|
+
if (retryDpi <= initialDpi)
|
|
1103
|
+
return undefined;
|
|
1104
|
+
if (!highDpiImages) {
|
|
1105
|
+
const retryDir = path.join(tmpDir, `retry-${retryDpi}dpi`);
|
|
1106
|
+
fs.mkdirSync(retryDir, { recursive: true });
|
|
1107
|
+
highDpiImages = this.tryRenderWithPyMuPDF(file.absolutePath, retryDir, retryDpi);
|
|
1108
|
+
if (!highDpiImages?.length)
|
|
1109
|
+
warnings.push(`PDF 高质量 OCR 重试渲染失败(${retryDpi} DPI)`);
|
|
1110
|
+
}
|
|
1111
|
+
const imagePath = highDpiImages?.[pageIndex];
|
|
1112
|
+
return imagePath ? { imagePath, strategy: `PyMuPDF-${retryDpi}dpi` } : undefined;
|
|
1113
|
+
};
|
|
1083
1114
|
try {
|
|
1084
|
-
// ── 方法1: PyMuPDF
|
|
1085
|
-
pageImages = this.tryRenderWithPyMuPDF(file.absolutePath, tmpDir);
|
|
1115
|
+
// ── 方法1: PyMuPDF(默认 200 DPI,低质量页再自适应升到 300 DPI) ──
|
|
1116
|
+
pageImages = this.tryRenderWithPyMuPDF(file.absolutePath, tmpDir, initialDpi);
|
|
1086
1117
|
if (pageImages && pageImages.length > 0) {
|
|
1087
|
-
renderer =
|
|
1118
|
+
renderer = `PyMuPDF-${initialDpi}dpi`;
|
|
1088
1119
|
pageCount = pageImages.length;
|
|
1089
1120
|
}
|
|
1090
1121
|
else {
|
|
@@ -1131,12 +1162,37 @@ export class ContentExtractor {
|
|
|
1131
1162
|
width: dimensions.width, height: dimensions.height, channels: 0,
|
|
1132
1163
|
filePath: imgPath,
|
|
1133
1164
|
});
|
|
1134
|
-
|
|
1165
|
+
let ocrText = this.cleanOcrText(ocrResult.text);
|
|
1166
|
+
let ocrScore = this.scoreOcrText(ocrText);
|
|
1167
|
+
let strategy = renderer;
|
|
1135
1168
|
if (ocrResult.warnings?.length)
|
|
1136
1169
|
warnings.push(...ocrResult.warnings.map(item => `OCR 警告: ${item}`));
|
|
1170
|
+
if (this.shouldRetryPdfOcrAtHigherDpi(ocrText, ocrScore)) {
|
|
1171
|
+
const retry = getHighDpiImage(i);
|
|
1172
|
+
if (retry) {
|
|
1173
|
+
const retryDimensions = await this.readImageDimensions(retry.imagePath);
|
|
1174
|
+
if (retryDimensions && !this.isTooSmallForOcr(retryDimensions.width, retryDimensions.height)) {
|
|
1175
|
+
const retryResult = await provider.recognize({
|
|
1176
|
+
data: new Uint8Array(0),
|
|
1177
|
+
width: retryDimensions.width, height: retryDimensions.height, channels: 0,
|
|
1178
|
+
filePath: retry.imagePath,
|
|
1179
|
+
});
|
|
1180
|
+
const retryText = this.cleanOcrText(retryResult.text);
|
|
1181
|
+
const retryScore = this.scoreOcrText(retryText);
|
|
1182
|
+
if (retryResult.warnings?.length)
|
|
1183
|
+
warnings.push(...retryResult.warnings.map(item => `OCR 重试警告: ${item}`));
|
|
1184
|
+
if (retryScore > ocrScore || (!ocrText && retryText)) {
|
|
1185
|
+
ocrText = retryText;
|
|
1186
|
+
ocrScore = retryScore;
|
|
1187
|
+
strategy = retry.strategy;
|
|
1188
|
+
ocrRetryPages.push(i + 1);
|
|
1189
|
+
}
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
}
|
|
1137
1193
|
if (ocrText) {
|
|
1138
1194
|
ocrPages.push(i + 1);
|
|
1139
|
-
ocrStrategies.push({ page: i + 1, strategy
|
|
1195
|
+
ocrStrategies.push({ page: i + 1, strategy, score: ocrScore });
|
|
1140
1196
|
pageTexts.push(`## PDF 第 ${i + 1} 页(OCR)\n\n${ocrText}`);
|
|
1141
1197
|
}
|
|
1142
1198
|
else {
|
|
@@ -1158,6 +1214,7 @@ export class ContentExtractor {
|
|
|
1158
1214
|
}
|
|
1159
1215
|
metadata.ocrAugmented = ocrPages.length > 0;
|
|
1160
1216
|
metadata.ocrPages = ocrPages;
|
|
1217
|
+
metadata.ocrRetryPages = ocrRetryPages;
|
|
1161
1218
|
metadata.ocrStrategies = ocrStrategies;
|
|
1162
1219
|
metadata.failedPages = failedPages;
|
|
1163
1220
|
metadata.ocrProvider = ocrProvider?.id ?? 'unknown';
|
|
@@ -1171,11 +1228,11 @@ export class ContentExtractor {
|
|
|
1171
1228
|
warnings,
|
|
1172
1229
|
};
|
|
1173
1230
|
}
|
|
1174
|
-
/** PyMuPDF
|
|
1175
|
-
tryRenderWithPyMuPDF(pdfPath, outputDir) {
|
|
1231
|
+
/** PyMuPDF 渲染(默认 200 DPI,低质量页可自适应提高) */
|
|
1232
|
+
tryRenderWithPyMuPDF(pdfPath, outputDir, dpi = this.getPdfOcrDpi()) {
|
|
1176
1233
|
try {
|
|
1177
1234
|
const workerScript = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', '..', 'scripts', 'render_pdf_pages.py');
|
|
1178
|
-
spawnSync('python3', [workerScript, pdfPath, outputDir,
|
|
1235
|
+
spawnSync('python3', [workerScript, pdfPath, outputDir, String(dpi)], {
|
|
1179
1236
|
encoding: 'utf-8', timeout: 60_000, maxBuffer: 1024 * 1024,
|
|
1180
1237
|
});
|
|
1181
1238
|
// 检查输出文件(即使 Python 非零退出码也可能已渲染部分页面)
|
|
@@ -1226,6 +1283,21 @@ export class ContentExtractor {
|
|
|
1226
1283
|
return null;
|
|
1227
1284
|
}
|
|
1228
1285
|
}
|
|
1286
|
+
getPdfOcrDpi() {
|
|
1287
|
+
return Math.max(120, Math.min(300, Number(process.env.CUSTOMIZE_KB_PDF_OCR_DPI || 200)));
|
|
1288
|
+
}
|
|
1289
|
+
getPdfOcrRetryDpi(initialDpi) {
|
|
1290
|
+
const configured = Number(process.env.CUSTOMIZE_KB_PDF_OCR_RETRY_DPI || 300);
|
|
1291
|
+
return Math.max(initialDpi, Math.min(300, Math.max(120, configured)));
|
|
1292
|
+
}
|
|
1293
|
+
shouldRetryPdfOcrAtHigherDpi(text, score) {
|
|
1294
|
+
const normalizedLength = this.normalizedTextLength(text);
|
|
1295
|
+
if (normalizedLength === 0)
|
|
1296
|
+
return true;
|
|
1297
|
+
const threshold = Number(process.env.CUSTOMIZE_KB_PDF_OCR_RETRY_MIN_SCORE || 120);
|
|
1298
|
+
const replacementRatio = (text.match(/[�□]/gu)?.length ?? 0) / Math.max(1, text.length);
|
|
1299
|
+
return score < threshold || replacementRatio > 0.02;
|
|
1300
|
+
}
|
|
1229
1301
|
scoreOcrText(value) {
|
|
1230
1302
|
const text = String(value ?? '').trim();
|
|
1231
1303
|
const normalizedLength = this.normalizedTextLength(text);
|
|
@@ -9,11 +9,13 @@ export declare class HNSWVectorStore implements VectorStoreInterface {
|
|
|
9
9
|
private deletedSinceRebuild;
|
|
10
10
|
private dirty;
|
|
11
11
|
private readonly documents;
|
|
12
|
+
private readonly rowidsByFilePath;
|
|
12
13
|
constructor(collectionName: string, indexPath: string, dimensions?: number, maxElements?: number);
|
|
13
14
|
ensureCollection(): Promise<void>;
|
|
14
15
|
upsert(documents: VectorDocument[], options?: VectorWriteOptions): Promise<void>;
|
|
15
16
|
clearCollection(): Promise<void>;
|
|
16
17
|
deleteByFilePath(filePath: string, options?: VectorWriteOptions): Promise<void>;
|
|
18
|
+
deleteByFilePaths(filePaths: string[], options?: VectorWriteOptions): Promise<void>;
|
|
17
19
|
flush(): Promise<void>;
|
|
18
20
|
needsRebuild(): boolean;
|
|
19
21
|
search(query: VectorSearchQuery): Promise<VectorSearchResult[]>;
|
|
@@ -21,5 +23,7 @@ export declare class HNSWVectorStore implements VectorStoreInterface {
|
|
|
21
23
|
private persist;
|
|
22
24
|
private toStoredDocument;
|
|
23
25
|
private loadDocuments;
|
|
26
|
+
private trackDocumentFilePath;
|
|
27
|
+
private untrackDocumentFilePath;
|
|
24
28
|
private metadataPath;
|
|
25
29
|
}
|
|
@@ -12,6 +12,7 @@ export class HNSWVectorStore {
|
|
|
12
12
|
deletedSinceRebuild = 0;
|
|
13
13
|
dirty = false;
|
|
14
14
|
documents = new Map();
|
|
15
|
+
rowidsByFilePath = new Map();
|
|
15
16
|
constructor(collectionName, indexPath, dimensions = 512, maxElements = 500_000) {
|
|
16
17
|
this.collectionName = collectionName;
|
|
17
18
|
this.indexPath = indexPath;
|
|
@@ -36,8 +37,13 @@ export class HNSWVectorStore {
|
|
|
36
37
|
const rowid = Number(document.metadata.sqlite_rowid);
|
|
37
38
|
if (!Number.isFinite(rowid) || rowid <= 0)
|
|
38
39
|
throw new Error(`HNSW 向量写入缺少有效 sqlite_rowid: ${document.id}`);
|
|
40
|
+
const existing = this.documents.get(rowid);
|
|
41
|
+
if (existing)
|
|
42
|
+
this.untrackDocumentFilePath(rowid, existing);
|
|
39
43
|
this.index.addPoint(document.embedding, rowid, true);
|
|
40
|
-
|
|
44
|
+
const stored = this.toStoredDocument(document);
|
|
45
|
+
this.documents.set(rowid, stored);
|
|
46
|
+
this.trackDocumentFilePath(rowid, stored);
|
|
41
47
|
this.dirty = true;
|
|
42
48
|
}
|
|
43
49
|
if (options.persist !== false)
|
|
@@ -49,24 +55,43 @@ export class HNSWVectorStore {
|
|
|
49
55
|
if (fs.existsSync(this.metadataPath()))
|
|
50
56
|
fs.rmSync(this.metadataPath(), { force: true });
|
|
51
57
|
this.documents.clear();
|
|
58
|
+
this.rowidsByFilePath.clear();
|
|
52
59
|
this.deletedSinceRebuild = 0;
|
|
53
60
|
this.dirty = false;
|
|
54
61
|
this.index = undefined;
|
|
55
62
|
await this.ensureCollection();
|
|
56
63
|
}
|
|
57
64
|
async deleteByFilePath(filePath, options = {}) {
|
|
65
|
+
await this.deleteByFilePaths([filePath], options);
|
|
66
|
+
}
|
|
67
|
+
async deleteByFilePaths(filePaths, options = {}) {
|
|
58
68
|
await this.ensureCollection();
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
catch { /* 忽略缺失的标签 */ }
|
|
66
|
-
this.documents.delete(rowid);
|
|
67
|
-
this.dirty = true;
|
|
69
|
+
const rowids = new Set();
|
|
70
|
+
for (const filePath of filePaths) {
|
|
71
|
+
const tracked = this.rowidsByFilePath.get(filePath);
|
|
72
|
+
if (tracked) {
|
|
73
|
+
for (const rowid of tracked)
|
|
74
|
+
rowids.add(rowid);
|
|
68
75
|
}
|
|
69
76
|
}
|
|
77
|
+
if (rowids.size === 0) {
|
|
78
|
+
if (options.persist !== false && this.dirty)
|
|
79
|
+
this.persist();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
for (const rowid of rowids) {
|
|
83
|
+
const document = this.documents.get(rowid);
|
|
84
|
+
if (!document)
|
|
85
|
+
continue;
|
|
86
|
+
try {
|
|
87
|
+
this.index.markDelete(rowid);
|
|
88
|
+
this.deletedSinceRebuild += 1;
|
|
89
|
+
}
|
|
90
|
+
catch { /* 忽略缺失的标签 */ }
|
|
91
|
+
this.documents.delete(rowid);
|
|
92
|
+
this.untrackDocumentFilePath(rowid, document);
|
|
93
|
+
this.dirty = true;
|
|
94
|
+
}
|
|
70
95
|
if (options.persist !== false)
|
|
71
96
|
this.persist();
|
|
72
97
|
}
|
|
@@ -122,12 +147,40 @@ export class HNSWVectorStore {
|
|
|
122
147
|
const entries = Array.isArray(parsed) ? parsed : parsed.documents ?? [];
|
|
123
148
|
this.deletedSinceRebuild = Array.isArray(parsed) ? 0 : Number(parsed.deletedSinceRebuild ?? 0);
|
|
124
149
|
this.documents.clear();
|
|
125
|
-
|
|
126
|
-
|
|
150
|
+
this.rowidsByFilePath.clear();
|
|
151
|
+
for (const [rowid, document] of entries) {
|
|
152
|
+
const numericRowid = Number(rowid);
|
|
153
|
+
const stored = { id: document.id, content: document.content ?? '', metadata: document.metadata };
|
|
154
|
+
this.documents.set(numericRowid, stored);
|
|
155
|
+
this.trackDocumentFilePath(numericRowid, stored);
|
|
156
|
+
}
|
|
127
157
|
}
|
|
128
158
|
catch {
|
|
129
159
|
this.documents.clear();
|
|
160
|
+
this.rowidsByFilePath.clear();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
trackDocumentFilePath(rowid, document) {
|
|
164
|
+
const filePath = document.metadata.file_path;
|
|
165
|
+
if (typeof filePath !== 'string' || !filePath)
|
|
166
|
+
return;
|
|
167
|
+
let rowids = this.rowidsByFilePath.get(filePath);
|
|
168
|
+
if (!rowids) {
|
|
169
|
+
rowids = new Set();
|
|
170
|
+
this.rowidsByFilePath.set(filePath, rowids);
|
|
130
171
|
}
|
|
172
|
+
rowids.add(rowid);
|
|
173
|
+
}
|
|
174
|
+
untrackDocumentFilePath(rowid, document) {
|
|
175
|
+
const filePath = document.metadata.file_path;
|
|
176
|
+
if (typeof filePath !== 'string' || !filePath)
|
|
177
|
+
return;
|
|
178
|
+
const rowids = this.rowidsByFilePath.get(filePath);
|
|
179
|
+
if (!rowids)
|
|
180
|
+
return;
|
|
181
|
+
rowids.delete(rowid);
|
|
182
|
+
if (rowids.size === 0)
|
|
183
|
+
this.rowidsByFilePath.delete(filePath);
|
|
131
184
|
}
|
|
132
185
|
metadataPath() {
|
|
133
186
|
return `${this.indexPath}.documents.json`;
|
package/dist/vector/types.d.ts
CHANGED
|
@@ -33,6 +33,7 @@ export interface VectorStoreInterface {
|
|
|
33
33
|
ensureCollection(metadata?: Record<string, unknown>): Promise<void>;
|
|
34
34
|
upsert(documents: VectorDocument[], options?: VectorWriteOptions): Promise<void>;
|
|
35
35
|
deleteByFilePath(filePath: string, options?: VectorWriteOptions): Promise<void>;
|
|
36
|
+
deleteByFilePaths?(filePaths: string[], options?: VectorWriteOptions): Promise<void>;
|
|
36
37
|
flush?(): Promise<void>;
|
|
37
38
|
clearCollection?(): Promise<void>;
|
|
38
39
|
needsRebuild?(): boolean;
|