@customize-agent/knowledge 4.0.31 → 4.0.33

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.
@@ -175,6 +175,7 @@ export declare class KnowledgeBaseManager {
175
175
  private updateJobsForFile;
176
176
  private ensureVectorStore;
177
177
  private deleteVectorFile;
178
+ private consumePendingVectorRelativePaths;
178
179
  private ensureVectorIndexFresh;
179
180
  uploadSessionIsOpen(operationId: string): boolean;
180
181
  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 {
@@ -474,10 +502,6 @@ export class KnowledgeBaseManager {
474
502
  const targetPath = this.resolveKbRelativePath(relativePath);
475
503
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
476
504
  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
505
  jobs.push(this.store.enqueueIndexJob({ id: `${operationId}-${index}`, relativePath, message: '文件已落盘,等待后台解析' }));
482
506
  }
483
507
  return jobs;
@@ -492,10 +516,6 @@ export class KnowledgeBaseManager {
492
516
  const targetPath = this.resolveKbRelativePath(relativePath);
493
517
  fs.mkdirSync(path.dirname(targetPath), { recursive: true });
494
518
  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
519
  jobs.push(this.store.enqueueIndexJob({ id: `${operationId}-${offset + index}`, relativePath, message: '文件已落盘,等待后台解析' }));
500
520
  }
501
521
  return jobs;
@@ -543,6 +563,11 @@ export class KnowledgeBaseManager {
543
563
  return this.store.listIgnoreRules();
544
564
  }
545
565
  async indexVectors(options = {}) {
566
+ const pendingRelativePaths = !options.rebuild && !options.relativePath && !options.relativePaths?.length
567
+ ? this.consumePendingVectorRelativePaths()
568
+ : [];
569
+ if (pendingRelativePaths.length > 0)
570
+ options = { ...options, relativePaths: pendingRelativePaths };
546
571
  const chunks = options.relativePaths?.length
547
572
  ? options.relativePaths.flatMap(relativePath => this.store.listChunks({ collectionName: options.collectionName, relativePath }))
548
573
  : this.store.listChunks(options);
@@ -555,9 +580,15 @@ export class KnowledgeBaseManager {
555
580
  await this.vectorStores.get(collectionName)?.clearCollection?.();
556
581
  }
557
582
  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 });
583
+ const cleanupRelativePaths = options.relativePaths ?? [options.relativePath].filter(Boolean);
584
+ for (const collectionName of cleanupCollectionNames) {
585
+ const vectorStore = this.vectorStores.get(collectionName);
586
+ if (vectorStore?.deleteByFilePaths)
587
+ await vectorStore.deleteByFilePaths(cleanupRelativePaths, { persist: false });
588
+ else {
589
+ for (const relativePath of cleanupRelativePaths)
590
+ await vectorStore?.deleteByFilePath(relativePath, { persist: false });
591
+ }
561
592
  }
562
593
  if (chunks.length === 0) {
563
594
  for (const collectionName of cleanupCollectionNames)
@@ -906,6 +937,22 @@ export class KnowledgeBaseManager {
906
937
  this.store.setMetadata('vector_index_error', error instanceof Error ? error.message : String(error));
907
938
  }
908
939
  }
940
+ consumePendingVectorRelativePaths() {
941
+ const raw = this.store.getMetadata('vector_pending_relative_paths');
942
+ if (!raw)
943
+ return [];
944
+ try {
945
+ const parsed = JSON.parse(raw);
946
+ if (!Array.isArray(parsed))
947
+ return [];
948
+ this.store.setMetadata('vector_pending_relative_paths', '');
949
+ return [...new Set(parsed.filter((item) => typeof item === 'string' && item.trim().length > 0))];
950
+ }
951
+ catch {
952
+ this.store.setMetadata('vector_pending_relative_paths', '');
953
+ return [];
954
+ }
955
+ }
909
956
  async ensureVectorIndexFresh(chunkCount, options = {}) {
910
957
  if (chunkCount === 0)
911
958
  return;
@@ -925,7 +972,7 @@ export class KnowledgeBaseManager {
925
972
  this.store.setMetadata('last_vector_index_at', String(Date.now()));
926
973
  return;
927
974
  }
928
- if (changedRelativePaths.length > 0 && status === 'ready') {
975
+ if (changedRelativePaths.length > 0) {
929
976
  for (const collectionName of options.changedCollectionNames ?? [])
930
977
  this.ensureVectorStore(collectionName);
931
978
  await this.indexVectors({ relativePaths: changedRelativePaths, cleanupCollectionNames: options.changedCollectionNames });
@@ -933,6 +980,8 @@ export class KnowledgeBaseManager {
933
980
  }
934
981
  if (indexedChunks === chunkCount && status === 'ready')
935
982
  return;
983
+ if (status === 'pending' || status === 'partial')
984
+ return;
936
985
  await this.indexVectors({ rebuild: true });
937
986
  }
938
987
  uploadSessionIsOpen(operationId) {
@@ -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.31",
3
+ "version": "4.0.33",
4
4
  "description": "Local knowledge base infrastructure for customize-agent",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -36,6 +36,9 @@
36
36
  "customize-agent",
37
37
  "knowledge-base"
38
38
  ],
39
+ "optionalDependencies": {
40
+ "hnswlib-node": "^3.0.0"
41
+ },
39
42
  "dependencies": {
40
43
  "@huggingface/transformers": "^3.8.0",
41
44
  "@napi-rs/canvas": "^0.1.82",
@@ -44,7 +47,6 @@
44
47
  "dwgdxf": "^2.0.1",
45
48
  "dxf-parser": "^1.1.2",
46
49
  "fast-glob": "^3.3.3",
47
- "hnswlib-node": "^3.0.0",
48
50
  "jszip": "^3.10.1",
49
51
  "mammoth": "^1.12.0",
50
52
  "node-gyp": "^12.1.0",
@@ -10,7 +10,7 @@ function packageDir(name) {
10
10
 
11
11
  function run(command, args, cwd) {
12
12
  const result = spawnSync(command, args, { cwd, stdio: 'inherit', shell: false });
13
- if (result.status !== 0) process.exit(result.status || 1);
13
+ if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${result.status || 1}`);
14
14
  }
15
15
 
16
16
  function markerPath(hnswDir) {
@@ -61,7 +61,14 @@ function verify(hnswDir) {
61
61
  }
62
62
 
63
63
  try {
64
- const hnswDir = packageDir('hnswlib-node');
64
+ let hnswDir;
65
+ try {
66
+ hnswDir = packageDir('hnswlib-node');
67
+ } catch {
68
+ console.log('[hnsw] 未安装可选依赖 hnswlib-node,跳过 native 初始化。');
69
+ process.exit(0);
70
+ }
71
+
65
72
  if (isMarkerFresh(hnswDir)) {
66
73
  try {
67
74
  verify(hnswDir);
@@ -78,7 +85,11 @@ try {
78
85
  writeMarker(hnswDir);
79
86
  console.log('[hnsw] hnswlib-node 安装和运行验证通过');
80
87
  } catch (error) {
81
- console.error('[hnsw] hnswlib-node 安装或运行验证失败。请确认当前平台已安装 native 编译工具链。');
82
- console.error(error && error.stack ? error.stack : error);
83
- process.exit(1);
88
+ console.warn('[hnsw] hnswlib-node 当前不可用,已跳过可选向量索引 native 初始化,不影响主包安装。');
89
+ console.warn('[hnsw] 如需启用本地向量索引,请安装 native 编译工具链后运行 npm rebuild hnswlib-node 或在源码仓库执行 pnpm doctor:hnsw。');
90
+ if (process.env.CUSTOMIZE_AGENT_HNSW_STRICT === '1') {
91
+ console.warn(error && error.stack ? error.stack : error);
92
+ process.exit(1);
93
+ }
94
+ process.exit(0);
84
95
  }