@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.
@@ -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
- listChunks(options = {}) {
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
- if (options.limit)
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(...params);
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 = 10, filters = {}) {
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, limit, filePaths) : []),
274
- ...this.searchChunksLike(terms, limit, filePaths),
304
+ ...(this.ftsEnabled ? this.searchChunksFts(terms, effectiveLimit, filePaths) : []),
305
+ ...this.searchChunksLike(terms, effectiveLimit, filePaths),
275
306
  ];
276
- return this.mergeKeywordResults(results, limit);
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
- return this.incrementalIndex({ ...options, onlyRelativePaths: jobs.map(job => job.relativePath) });
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 (!exists)
118
- this.updateJobsForFile(relativePath, 'ERROR', 100, '待索引文件不存在或未发生变化', '待索引文件不存在或未发生变化');
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 = 10, filters) {
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
- keywordSearchItems(query, limit = 10, filters) {
305
- return this.store.searchChunks(query, limit, { filePaths: filters?.filePaths ?? (filters?.filePath ? [filters.filePath] : undefined) }).map(result => this.toFederatedItem(result, 'keyword'));
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 limit = options.limit ?? 10;
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, limit * keywordMultiplier, options.filters), queryIndex });
388
+ rankedLists.push({ source: 'keyword', items: this.keywordSearchItems(rewritten, effectiveLimit * keywordMultiplier, options.filters), queryIndex });
346
389
  if (queryIndex < vectorQueryLimit) {
347
390
  try {
348
- rankedLists.push({ source: 'vector', items: (await this.semanticSearch(rewritten, { ...options, limit: limit * vectorMultiplier })).results.slice(0, limit * keywordMultiplier), queryIndex });
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 mergedChildChunks = this.mergeContexts(this.mergeHybridRankedLists(rankedLists, limit * 4, weights), limit * 4);
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 candidates = mergedChildChunks.slice(0, Math.min(30, limit * 4));
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. 拿到精确打分后的 Top 结果,此时再进行 expandContext 向上追溯到完整的父块大片段
385
- const finalExpandedResults = this.mergeExpandedContexts(reranked.map(item => this.expandContext(item)), limit);
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 ?? 10,
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: this.store.listChunks({ relativePath: normalized, limit: 500 }),
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
- for (const relativePath of options.relativePaths ?? [options.relativePath].filter(Boolean)) {
559
- for (const collectionName of cleanupCollectionNames)
560
- await this.vectorStores.get(collectionName)?.deleteByFilePath(relativePath, { persist: false });
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 && status === 'ready') {
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 ?? 10;
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 merged = new FederationSearch().merge([...projectResults.results, ...globalResults.results], limit, 'all');
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
- return new FederationSearch().merge([...projectResults.results, ...globalResults.results], options.limit ?? 10, 'all');
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 : 16;
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 渲染(300 DPI 原生提取,质量远高于 pdfjs-dist) */
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 hybrid = await this.extractPdfHybridPages(file);
1033
- if (hybrid.text.trim())
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 (text.trim()) {
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 渲染(高质量)或降级到 pdfjs-dist
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: PyMuPDF300 DPI 原生渲染,质量最高) ──
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 = 'PyMuPDF';
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
- const ocrText = this.cleanOcrText(ocrResult.text);
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: renderer, score: this.scoreOcrText(ocrText) });
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 渲染(300 DPI 原生提取,质量远高于 pdfjs-dist) */
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, '300'], {
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
- this.documents.set(rowid, this.toStoredDocument(document));
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
- for (const [rowid, document] of this.documents.entries()) {
60
- if (document.metadata.file_path === filePath) {
61
- try {
62
- this.index.markDelete(rowid);
63
- this.deletedSinceRebuild += 1;
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
- for (const [rowid, document] of entries)
126
- this.documents.set(Number(rowid), { id: document.id, content: document.content ?? '', metadata: document.metadata });
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`;
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@customize-agent/knowledge",
3
- "version": "4.0.32",
3
+ "version": "4.0.34",
4
4
  "description": "Local knowledge base infrastructure for customize-agent",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",