@customize-agent/knowledge 4.0.9 → 4.0.11

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.
@@ -37,6 +37,7 @@ export declare class TextChunker {
37
37
  private collectBraceBalancedBlocks;
38
38
  private splitCodeByStructuralFallback;
39
39
  private splitIntoSections;
40
+ private mergeSmallSections;
40
41
  private mergeLeadingHeader;
41
42
  private recursiveSplit;
42
43
  private mergeParts;
@@ -46,6 +47,7 @@ export declare class TextChunker {
46
47
  private extractMarkdownTableRowRange;
47
48
  private splitMarkdownTable;
48
49
  private enforceCandidateLimit;
50
+ private buildTitlePaths;
49
51
  private withHeader;
50
52
  private createChunk;
51
53
  private kindForCategory;
@@ -15,8 +15,8 @@ const RECURSIVE_SEPARATORS = [
15
15
  /\n(?=#{1,6}\s)/u,
16
16
  /\n{2,}/u,
17
17
  /\n(?=(?:第[一二三四五六七八九十百千万\d]+[章节条]|[一二三四五六七八九十]+、|\d+[.)、]))/u,
18
- /(?<=[。!?;])\s*/u,
19
- /(?<=[,、])\s*/u,
18
+ /(?<=[。!?;])\s+/u,
19
+ /(?<=[,、])\s+/u,
20
20
  /\s+/u,
21
21
  ];
22
22
  const LANGUAGE_ROUTER = {
@@ -45,7 +45,8 @@ export class TextChunker {
45
45
  return [];
46
46
  const config = DEFAULT_CONFIGS[file.category];
47
47
  const normalized = this.withHeader(source, file, config);
48
- const candidates = this.enforceCandidateLimit(this.createCandidates(normalized, file, config), config);
48
+ const rawCandidates = this.createCandidates(normalized, file, config);
49
+ const candidates = this.enforceCandidateLimit(rawCandidates, config);
49
50
  return candidates.map((candidate, index) => this.createChunk(index, candidate, file, metadata));
50
51
  }
51
52
  createCandidates(text, file, config) {
@@ -58,8 +59,9 @@ export class TextChunker {
58
59
  return this.createTextCandidates(text, file.category, config);
59
60
  }
60
61
  createTextCandidates(text, category, config) {
61
- const sections = this.splitIntoSections(text, category);
62
+ const sections = this.mergeSmallSections(this.splitIntoSections(text, category), config);
62
63
  const candidates = [];
64
+ const titlePaths = this.buildTitlePaths(sections);
63
65
  sections.forEach((section, parentIndex) => {
64
66
  const parentId = `p${parentIndex}`;
65
67
  const parts = this.mergeLeadingHeader(this.recursiveSplit(section.text, config.maxChunkSize));
@@ -72,6 +74,7 @@ export class TextChunker {
72
74
  startChar,
73
75
  endChar: startChar + part.length,
74
76
  sectionTitle: section.title,
77
+ titlePath: titlePaths[parentIndex],
75
78
  kind: this.kindForCategory(category),
76
79
  parentId,
77
80
  parentIndex,
@@ -90,6 +93,7 @@ export class TextChunker {
90
93
  startChar,
91
94
  endChar: startChar + part.length,
92
95
  sectionTitle: this.extractSectionTitle(part) ?? '表格数据',
96
+ titlePath: this.extractSectionTitle(part) ?? '表格数据',
93
97
  kind: 'table',
94
98
  parentId: `table-${index}`,
95
99
  parentIndex: index,
@@ -108,6 +112,7 @@ export class TextChunker {
108
112
  startChar: Math.max(0, text.indexOf(part.slice(0, 40))),
109
113
  endChar: Math.max(0, text.indexOf(part.slice(0, 40))) + part.length,
110
114
  sectionTitle: this.extractSectionTitle(part),
115
+ titlePath: this.extractSectionTitle(part),
111
116
  kind: 'data',
112
117
  parentId: `data-${index}`,
113
118
  parentIndex: index,
@@ -128,6 +133,7 @@ export class TextChunker {
128
133
  startChar,
129
134
  endChar: startChar + part.length,
130
135
  sectionTitle: this.extractSectionTitle(part),
136
+ titlePath: this.extractSectionTitle(part),
131
137
  kind: 'code',
132
138
  parentId: `code-${language}-${index}`,
133
139
  parentIndex: index,
@@ -210,6 +216,29 @@ export class TextChunker {
210
216
  return { text: section, startChar, title: this.extractSectionTitle(section) };
211
217
  });
212
218
  }
219
+ mergeSmallSections(sections, config) {
220
+ const merged = [];
221
+ let current;
222
+ const targetTokens = Math.max(80, Math.floor(config.maxChunkSize * 0.75));
223
+ for (const section of sections) {
224
+ if (!current) {
225
+ current = { ...section };
226
+ continue;
227
+ }
228
+ const candidateText = `${current.text}\n\n${section.text}`;
229
+ const currentTokens = this.estimateTokens(current.text);
230
+ if (currentTokens < targetTokens && this.estimateTokens(candidateText) <= config.maxChunkSize) {
231
+ current = { ...current, text: candidateText, title: current.title ?? section.title };
232
+ }
233
+ else {
234
+ merged.push(current);
235
+ current = { ...section };
236
+ }
237
+ }
238
+ if (current)
239
+ merged.push(current);
240
+ return merged;
241
+ }
213
242
  mergeLeadingHeader(sections) {
214
243
  if (sections.length < 2)
215
244
  return sections;
@@ -232,7 +261,7 @@ export class TextChunker {
232
261
  if (!separator)
233
262
  return this.splitBySentenceBoundary(text, maxTokens);
234
263
  const parts = text.split(separator).map(part => part.trim()).filter(Boolean);
235
- if (parts.length <= 1)
264
+ if (parts.length <= 1 || parts.some(part => part === text))
236
265
  return this.recursiveSplit(text, maxTokens, separatorIndex + 1);
237
266
  return parts.flatMap(part => this.recursiveSplit(part, maxTokens, separatorIndex + 1));
238
267
  }
@@ -321,6 +350,24 @@ export class TextChunker {
321
350
  }));
322
351
  });
323
352
  }
353
+ buildTitlePaths(sections) {
354
+ const stack = [];
355
+ return sections.map(section => {
356
+ const firstLine = section.text.trim().split(/\r?\n/u)[0] ?? '';
357
+ const heading = firstLine.match(/^(#{1,6})\s+(.+)$/u);
358
+ if (heading?.[1] && heading[2]) {
359
+ const level = heading[1].length;
360
+ const title = heading[2].trim();
361
+ while (stack.length > 0 && stack[stack.length - 1].level >= level)
362
+ stack.pop();
363
+ stack.push({ level, title });
364
+ }
365
+ else if (section.title && stack.length === 0) {
366
+ stack.push({ level: 1, title: section.title });
367
+ }
368
+ return stack.map(item => item.title).join(' > ') || section.title;
369
+ });
370
+ }
324
371
  withHeader(text, file, config) {
325
372
  if (!config.headerInjection)
326
373
  return text;
@@ -344,9 +391,10 @@ export class TextChunker {
344
391
  childIndex: candidate.childIndex,
345
392
  rowRange: candidate.rowRange,
346
393
  sectionTitle: candidate.sectionTitle ?? this.extractSectionTitle(text),
394
+ titlePath: candidate.titlePath ?? candidate.sectionTitle ?? this.extractSectionTitle(text),
347
395
  startChar: candidate.startChar,
348
396
  endChar: candidate.endChar,
349
- splitStrategy: 'recursive_parent_child_v1',
397
+ splitStrategy: 'recursive_parent_child_v2',
350
398
  },
351
399
  };
352
400
  }
@@ -17,11 +17,18 @@ export interface StoredChunk {
17
17
  relativePath: string;
18
18
  chunkIndex: number;
19
19
  content: string;
20
+ searchContent?: string;
20
21
  category: FileCategory;
21
22
  format: string;
22
23
  collectionName: string;
23
24
  tokenCount: number;
24
25
  sectionTitle?: string;
26
+ titlePath?: string;
27
+ parentId?: string;
28
+ chunkKind?: string;
29
+ rowRange?: string;
30
+ startChar?: number;
31
+ endChar?: number;
25
32
  metadataJson?: string;
26
33
  createdAt: number;
27
34
  }
@@ -183,6 +190,7 @@ export declare class IndexStateStore {
183
190
  relativePath: string;
184
191
  }>;
185
192
  close(): void;
193
+ private resetLegacyChunkSchemaIfNeeded;
186
194
  private initTables;
187
195
  private initFts;
188
196
  private rebuildFtsIfNeeded;
@@ -194,6 +202,8 @@ export declare class IndexStateStore {
194
202
  private splitParentGroups;
195
203
  private rowToJob;
196
204
  private metadataString;
205
+ private buildChunkSearchContent;
206
+ private searchableRowText;
197
207
  private rowToChunk;
198
208
  private expandSearchTerms;
199
209
  private toFtsQuery;
@@ -128,13 +128,14 @@ export class IndexStateStore {
128
128
  this.db.prepare('DELETE FROM kb_chunks_fts WHERE relative_path = ?').run(relativePath);
129
129
  const insert = this.db.prepare(`
130
130
  INSERT INTO kb_chunks (
131
- id, relative_path, chunk_index, content, category, format,
132
- collection_name, token_count, section_title, metadata_json, created_at
133
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
131
+ id, relative_path, chunk_index, content, search_content, category, format,
132
+ collection_name, token_count, section_title, title_path, parent_id, chunk_kind,
133
+ row_range, start_char, end_char, metadata_json, created_at
134
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
134
135
  `);
135
136
  const insertFts = this.ftsEnabled ? this.db.prepare(`
136
- INSERT INTO kb_chunks_fts (id, relative_path, category, format, section_title, content)
137
- VALUES (?, ?, ?, ?, ?, ?)
137
+ INSERT INTO kb_chunks_fts (id, relative_path, category, format, section_title, title_path, chunk_kind, content)
138
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
138
139
  `) : undefined;
139
140
  const insertParent = this.db.prepare(`
140
141
  INSERT INTO kb_parent_chunks (
@@ -160,13 +161,18 @@ export class IndexStateStore {
160
161
  for (const [parentId, group] of parentGroups.entries()) {
161
162
  const parentContent = group.map(chunk => chunk.text).join('\n\n---\n\n');
162
163
  parentContents.push(parentContent);
163
- insertParent.run(parentId, relativePath, parentId, parentContent, file.category, file.format, file.collectionName, group.find(chunk => chunk.sectionTitle)?.sectionTitle ?? null, group.length, JSON.stringify({ parentId, splitStrategy: this.metadataString(group[0]?.metadata.splitStrategy), chunkKind: this.metadataString(group[0]?.metadata.chunkKind) }), now);
164
+ insertParent.run(parentId, relativePath, parentId, parentContent, file.category, file.format, file.collectionName, group.find(chunk => chunk.sectionTitle)?.sectionTitle ?? null, group.length, JSON.stringify({ parentId, splitStrategy: this.metadataString(group[0]?.metadata.splitStrategy), chunkKind: this.metadataString(group[0]?.metadata.chunkKind), titlePath: this.metadataString(group[0]?.metadata.titlePath) }), now);
164
165
  }
165
166
  insertDocument.run(`${relativePath}#document`, relativePath, parentContents.join('\n\n=== SECTION ===\n\n'), file.category, file.format, file.collectionName, parentGroups.size, groupedChunks.length, JSON.stringify({ parentType: 'document', splitStrategy: 'document_section_child_v1' }), now);
166
167
  for (const chunk of groupedChunks) {
167
168
  const chunkId = `${relativePath}#${chunk.index}`;
168
- insert.run(chunkId, relativePath, chunk.index, chunk.text, file.category, file.format, file.collectionName, chunk.tokenCount, chunk.sectionTitle ?? null, JSON.stringify(chunk.metadata), now);
169
- insertFts?.run(chunkId, relativePath, file.category, file.format, chunk.sectionTitle ?? '', chunk.text);
169
+ const titlePath = this.metadataString(chunk.metadata.titlePath) ?? chunk.sectionTitle ?? '';
170
+ const parentId = this.metadataString(chunk.metadata.parentId) ?? null;
171
+ const chunkKind = this.metadataString(chunk.metadata.chunkKind) ?? null;
172
+ const rowRange = this.metadataString(chunk.metadata.rowRange) ?? null;
173
+ const searchContent = this.buildChunkSearchContent(relativePath, file, chunk, titlePath);
174
+ insert.run(chunkId, relativePath, chunk.index, chunk.text, searchContent, file.category, file.format, file.collectionName, chunk.tokenCount, chunk.sectionTitle ?? null, titlePath || null, parentId, chunkKind, rowRange, Number(chunk.metadata.startChar ?? chunk.startChar), Number(chunk.metadata.endChar ?? chunk.endChar), JSON.stringify(chunk.metadata), now);
175
+ insertFts?.run(chunkId, relativePath, file.category, file.format, chunk.sectionTitle ?? '', titlePath, chunkKind ?? '', searchContent);
170
176
  }
171
177
  });
172
178
  transaction();
@@ -278,7 +284,7 @@ export class IndexStateStore {
278
284
  `).all(matchQuery, limit * 8);
279
285
  return rows
280
286
  .map(row => {
281
- const keyword = this.scoreChunkDetailed(`${String(row.section_title ?? '')}\n${String(row.category)}\n${String(row.format)}\n${String(row.content)}`, terms);
287
+ const keyword = this.scoreChunkDetailed(this.searchableRowText(row), terms);
282
288
  const bm25Score = this.bm25ToPositiveScore(Number(row.bm25_score));
283
289
  return this.rowToChunk(row, keyword.keywordScore + bm25Score, { ...keyword, bm25Score });
284
290
  })
@@ -293,13 +299,13 @@ export class IndexStateStore {
293
299
  searchChunksLike(terms, limit) {
294
300
  const rows = this.db.prepare(`
295
301
  SELECT rowid, * FROM kb_chunks
296
- WHERE ${terms.map(() => '(LOWER(content) LIKE ? OR LOWER(relative_path) LIKE ? OR LOWER(category) LIKE ? OR LOWER(format) LIKE ?)').join(' OR ')}
302
+ WHERE ${terms.map(() => '(LOWER(search_content) LIKE ? OR LOWER(content) LIKE ? OR LOWER(relative_path) LIKE ? OR LOWER(category) LIKE ? OR LOWER(format) LIKE ? OR LOWER(COALESCE(title_path, \'\')) LIKE ? OR LOWER(COALESCE(chunk_kind, \'\')) LIKE ?)').join(' OR ')}
297
303
  ORDER BY created_at DESC
298
304
  LIMIT ?
299
- `).all(...terms.flatMap(term => [`%${term}%`, `%${term}%`, `%${term}%`, `%${term}%`]), limit * 6);
305
+ `).all(...terms.flatMap(term => [`%${term}%`, `%${term}%`, `%${term}%`, `%${term}%`, `%${term}%`, `%${term}%`, `%${term}%`]), limit * 6);
300
306
  return rows
301
307
  .map(row => {
302
- const keyword = this.scoreChunkDetailed(`${String(row.section_title ?? '')}\n${String(row.category)}\n${String(row.format)}\n${String(row.content)}`, terms);
308
+ const keyword = this.scoreChunkDetailed(this.searchableRowText(row), terms);
303
309
  return this.rowToChunk(row, keyword.keywordScore, keyword);
304
310
  })
305
311
  .filter(row => row.score > 0)
@@ -479,7 +485,23 @@ export class IndexStateStore {
479
485
  close() {
480
486
  this.db.close();
481
487
  }
488
+ resetLegacyChunkSchemaIfNeeded() {
489
+ const table = this.db.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'kb_chunks'").get();
490
+ if (!table)
491
+ return;
492
+ const columns = this.db.prepare('PRAGMA table_info(kb_chunks)').all();
493
+ const names = new Set(columns.map(column => column.name));
494
+ if (names.has('search_content') && names.has('title_path') && names.has('chunk_kind'))
495
+ return;
496
+ this.db.exec(`
497
+ DROP TABLE IF EXISTS kb_chunks_fts;
498
+ DROP TABLE IF EXISTS kb_chunks;
499
+ DROP TABLE IF EXISTS kb_parent_chunks;
500
+ DROP TABLE IF EXISTS kb_document_chunks;
501
+ `);
502
+ }
482
503
  initTables() {
504
+ this.resetLegacyChunkSchemaIfNeeded();
483
505
  this.db.exec(`
484
506
  CREATE TABLE IF NOT EXISTS kb_index_state (
485
507
  relative_path TEXT PRIMARY KEY,
@@ -506,17 +528,27 @@ export class IndexStateStore {
506
528
  relative_path TEXT NOT NULL,
507
529
  chunk_index INTEGER NOT NULL,
508
530
  content TEXT NOT NULL,
531
+ search_content TEXT NOT NULL,
509
532
  category TEXT NOT NULL,
510
533
  format TEXT NOT NULL,
511
534
  collection_name TEXT NOT NULL,
512
535
  token_count INTEGER NOT NULL,
513
536
  section_title TEXT,
537
+ title_path TEXT,
538
+ parent_id TEXT,
539
+ chunk_kind TEXT,
540
+ row_range TEXT,
541
+ start_char INTEGER,
542
+ end_char INTEGER,
514
543
  metadata_json TEXT,
515
544
  created_at INTEGER NOT NULL
516
545
  );
517
546
  CREATE INDEX IF NOT EXISTS idx_kb_chunks_path ON kb_chunks(relative_path);
518
547
  CREATE INDEX IF NOT EXISTS idx_kb_chunks_category ON kb_chunks(category);
519
548
  CREATE INDEX IF NOT EXISTS idx_kb_chunks_collection ON kb_chunks(collection_name);
549
+ CREATE INDEX IF NOT EXISTS idx_kb_chunks_parent ON kb_chunks(relative_path, parent_id);
550
+ CREATE INDEX IF NOT EXISTS idx_kb_chunks_kind ON kb_chunks(chunk_kind);
551
+ CREATE INDEX IF NOT EXISTS idx_kb_chunks_title_path ON kb_chunks(title_path);
520
552
 
521
553
  CREATE TABLE IF NOT EXISTS kb_parent_chunks (
522
554
  id TEXT PRIMARY KEY,
@@ -626,8 +658,8 @@ export class IndexStateStore {
626
658
  `);
627
659
  this.initFts();
628
660
  try {
629
- if (this.getMetadata('schema_version') !== '2')
630
- this.setMetadata('schema_version', '2');
661
+ if (this.getMetadata('schema_version') !== '3')
662
+ this.setMetadata('schema_version', '3');
631
663
  }
632
664
  catch {
633
665
  // 受限环境中已有索引库可能以只读方式挂载;运行时元数据不是必需项。
@@ -642,6 +674,8 @@ export class IndexStateStore {
642
674
  category,
643
675
  format,
644
676
  section_title,
677
+ title_path,
678
+ chunk_kind,
645
679
  content,
646
680
  tokenize = 'unicode61 remove_diacritics 2'
647
681
  );
@@ -660,8 +694,8 @@ export class IndexStateStore {
660
694
  if (Number(row.count ?? 0) > 0)
661
695
  return;
662
696
  this.db.prepare(`
663
- INSERT INTO kb_chunks_fts (id, relative_path, category, format, section_title, content)
664
- SELECT id, relative_path, category, format, COALESCE(section_title, ''), content FROM kb_chunks
697
+ INSERT INTO kb_chunks_fts (id, relative_path, category, format, section_title, title_path, chunk_kind, content)
698
+ SELECT id, relative_path, category, format, COALESCE(section_title, ''), COALESCE(title_path, ''), COALESCE(chunk_kind, ''), COALESCE(search_content, content) FROM kb_chunks
665
699
  `).run();
666
700
  }
667
701
  rowToMinHash(row) {
@@ -747,6 +781,32 @@ export class IndexStateStore {
747
781
  metadataString(value) {
748
782
  return typeof value === 'string' ? value : undefined;
749
783
  }
784
+ buildChunkSearchContent(relativePath, file, chunk, titlePath) {
785
+ const metadata = chunk.metadata;
786
+ const fields = [
787
+ `文件路径: ${relativePath}`,
788
+ `资料类型: ${file.category}/${file.format}`,
789
+ chunk.sectionTitle ? `章节标题: ${chunk.sectionTitle}` : '',
790
+ titlePath ? `标题路径: ${titlePath}` : '',
791
+ this.metadataString(metadata.chunkKind) ? `切片类型: ${this.metadataString(metadata.chunkKind)}` : '',
792
+ this.metadataString(metadata.rowRange) ? `表格行范围: ${this.metadataString(metadata.rowRange)}` : '',
793
+ chunk.text,
794
+ ];
795
+ return fields.filter(Boolean).join('\n');
796
+ }
797
+ searchableRowText(row) {
798
+ return [
799
+ row.relative_path,
800
+ row.category,
801
+ row.format,
802
+ row.section_title,
803
+ row.title_path,
804
+ row.chunk_kind,
805
+ row.row_range,
806
+ row.search_content,
807
+ row.content,
808
+ ].map(value => value == null ? '' : String(value)).join('\n');
809
+ }
750
810
  rowToChunk(row, score, scoreDetails) {
751
811
  return {
752
812
  rowid: Number(row.rowid ?? 0),
@@ -754,11 +814,18 @@ export class IndexStateStore {
754
814
  relativePath: String(row.relative_path),
755
815
  chunkIndex: Number(row.chunk_index),
756
816
  content: String(row.content),
817
+ searchContent: row.search_content == null ? undefined : String(row.search_content),
757
818
  category: String(row.category),
758
819
  format: String(row.format),
759
820
  collectionName: String(row.collection_name),
760
821
  tokenCount: Number(row.token_count),
761
822
  sectionTitle: row.section_title == null ? undefined : String(row.section_title),
823
+ titlePath: row.title_path == null ? undefined : String(row.title_path),
824
+ parentId: row.parent_id == null ? undefined : String(row.parent_id),
825
+ chunkKind: row.chunk_kind == null ? undefined : String(row.chunk_kind),
826
+ rowRange: row.row_range == null ? undefined : String(row.row_range),
827
+ startChar: row.start_char == null ? undefined : Number(row.start_char),
828
+ endChar: row.end_char == null ? undefined : Number(row.end_char),
762
829
  metadataJson: row.metadata_json == null ? undefined : String(row.metadata_json),
763
830
  createdAt: Number(row.created_at),
764
831
  score,
@@ -315,6 +315,7 @@ export class KnowledgeBaseManager {
315
315
  chunkIndex,
316
316
  parentId: parent.parentId,
317
317
  sectionTitle: parent.sectionTitle ?? item.sectionTitle,
318
+ titlePath: item.titlePath ?? this.parseMetadataString(parent.metadataJson, 'titlePath'),
318
319
  };
319
320
  }
320
321
  const parentChunks = item.parentId ? this.store.getChunksByParent(item.filePath, item.parentId, 6) : [];
@@ -328,6 +329,7 @@ export class KnowledgeBaseManager {
328
329
  content: chunks.map(chunk => chunk.content).join('\n\n---\n\n'),
329
330
  chunkIndex,
330
331
  parentId: item.parentId ?? this.parseMetadataString(chunks[0]?.metadataJson, 'parentId'),
332
+ titlePath: item.titlePath ?? chunks.map(chunk => this.parseMetadataString(chunk.metadataJson, 'titlePath')).find(Boolean),
331
333
  sectionTitle: item.sectionTitle ?? chunks.find(chunk => chunk.sectionTitle)?.sectionTitle,
332
334
  };
333
335
  }
@@ -341,7 +343,7 @@ export class KnowledgeBaseManager {
341
343
  rankedLists.push({ source: 'keyword', items: this.keywordSearchItems(rewritten, limit * 3), queryIndex });
342
344
  if (queryIndex < 3) {
343
345
  try {
344
- rankedLists.push({ source: 'vector', items: (await this.semanticSearch(rewritten, { ...options, limit: limit * 3 })).results, queryIndex });
346
+ rankedLists.push({ source: 'vector', items: (await this.semanticSearch(rewritten, { ...options, limit: limit * 6 })).results.slice(0, limit * 3), queryIndex });
345
347
  }
346
348
  catch { /* 向量搜索在混合搜索中是可选的 */ }
347
349
  }
@@ -664,15 +666,21 @@ export class KnowledgeBaseManager {
664
666
  const terms = query.toLowerCase().split(/[\s,,。;;::、]+/u).filter(Boolean);
665
667
  const phrase = query.toLowerCase().trim();
666
668
  return items.map(item => {
667
- const content = `${item.filePath}\n${item.sectionTitle ?? ''}\n${item.content}`.toLowerCase();
669
+ const content = `${item.filePath}\n${item.titlePath ?? ''}\n${item.sectionTitle ?? ''}\n${item.chunkKind ?? ''}\n${item.content}`.toLowerCase();
668
670
  let rerankBoost = 0;
669
671
  if (phrase && content.includes(phrase))
670
672
  rerankBoost += 120;
671
- for (const term of terms)
672
- if (term && content.includes(term))
673
+ const titleText = `${item.titlePath ?? ''}\n${item.sectionTitle ?? ''}`.toLowerCase();
674
+ for (const term of terms) {
675
+ if (!term)
676
+ continue;
677
+ if (content.includes(term))
673
678
  rerankBoost += 8;
674
- if (item.chunkKind === 'table' && /表|行|列|金额|数量|报价|评分/u.test(query))
675
- rerankBoost += 30;
679
+ if (titleText.includes(term))
680
+ rerankBoost += 18;
681
+ }
682
+ if (item.chunkKind === 'table' && /表|行|列|金额|数量|报价|评分|清单|明细|统计|数据/u.test(query))
683
+ rerankBoost += 40;
676
684
  if (item.chunkKind === 'metadata' && /图纸|图层|轴网|标注|块|实体|cad|dxf|step|iges|模型/u.test(query))
677
685
  rerankBoost += 60;
678
686
  if (item.chunkKind === 'data' && /json|xml|yaml|字段|配置|数据|路径|price|id|name/u.test(query))
@@ -695,7 +703,7 @@ export class KnowledgeBaseManager {
695
703
  const candidates = items.slice(0, 20);
696
704
  const resultsText = candidates.map((item, index) => {
697
705
  const contentPreview = item.content.slice(0, 300).replace(/[\n\r]+/g, ' ');
698
- return `[DOC_${index}] 路径: ${item.filePath} | 类型: ${item.chunkKind ?? 'text'}\n 内容: ${contentPreview}`;
706
+ return `[DOC_${index}] 路径: ${item.filePath} | 标题路径: ${item.titlePath ?? item.sectionTitle ?? ''} | 类型: ${item.chunkKind ?? 'text'}\n 内容: ${contentPreview}`;
699
707
  }).join('\n\n');
700
708
  const prompt = `你是一个文档相关性评估器。根据用户查询,为以下文档片段打分(1-10)。
701
709
  1=完全不相关,10=高度相关。
@@ -767,8 +775,11 @@ ${resultsText}
767
775
  parentId: this.metadataString(metadata.parentId),
768
776
  source,
769
777
  sectionTitle: result.sectionTitle,
770
- rowRange: this.metadataString(metadata.rowRange),
771
- chunkKind: this.metadataString(metadata.chunkKind),
778
+ titlePath: result.titlePath ?? this.metadataString(metadata.titlePath),
779
+ rowRange: result.rowRange ?? this.metadataString(metadata.rowRange),
780
+ chunkKind: result.chunkKind ?? this.metadataString(metadata.chunkKind),
781
+ startChar: result.startChar,
782
+ endChar: result.endChar,
772
783
  scoreDetails: result.scoreDetails,
773
784
  facets: this.metadataFacets(metadata),
774
785
  };
@@ -800,8 +811,11 @@ ${resultsText}
800
811
  if (item.score > (existing.scoreDetails?.keywordScore ?? existing.scoreDetails?.vectorScore ?? 0)) {
801
812
  existing.content = item.content;
802
813
  existing.sectionTitle = item.sectionTitle ?? existing.sectionTitle;
814
+ existing.titlePath = item.titlePath ?? existing.titlePath;
803
815
  existing.chunkIndex = item.chunkIndex ?? existing.chunkIndex;
804
816
  existing.parentId = item.parentId ?? existing.parentId;
817
+ existing.chunkKind = item.chunkKind ?? existing.chunkKind;
818
+ existing.rowRange = item.rowRange ?? existing.rowRange;
805
819
  }
806
820
  });
807
821
  }
@@ -858,7 +872,7 @@ ${resultsText}
858
872
  return typeof value === 'string' ? value : undefined;
859
873
  }
860
874
  metadataFacets(metadata) {
861
- const keys = ['sheetNames', 'columnNames', 'rowCount', 'columnCount', 'dataPaths', 'layerNames', 'blockNames', 'entityTypes', 'productNames', 'materialNames', 'ocrRecommended', 'ocrReason'];
875
+ const keys = ['titlePath', 'sectionTitle', 'chunkKind', 'rowRange', 'sheetNames', 'columnNames', 'rowCount', 'columnCount', 'dataPaths', 'layerNames', 'blockNames', 'entityTypes', 'productNames', 'materialNames', 'ocrRecommended', 'ocrReason'];
862
876
  const facets = {};
863
877
  for (const key of keys) {
864
878
  const value = metadata[key];
@@ -58,10 +58,13 @@ export declare class ContentExtractor {
58
58
  private formatBoundingBox;
59
59
  private validateRasterImage;
60
60
  private extractPdf;
61
+ private shouldAugmentPdfWithOcr;
61
62
  private extractScannedPdfOcr;
62
63
  private extractPdfText;
64
+ private normalizedTextLength;
63
65
  private toPdfTextItem;
64
66
  private layoutPdfTextItems;
67
+ private joinPdfRowText;
65
68
  private groupPdfItemsIntoRows;
66
69
  private detectPdfColumnSplit;
67
70
  private rowsToPdfMarkdownWithTables;
@@ -1063,7 +1063,16 @@ try {
1063
1063
  if (text.trim()) {
1064
1064
  metadata.contentCoverage = 'pdf_text_streams_layout_markdown';
1065
1065
  metadata.pdfExtractor = 'pdfjs-dist';
1066
- return { text: [this.metadataOnlyText(file), this.toMarkdownDocument(text)].join('\n\n'), metadata, warnings };
1066
+ const markdownText = this.toMarkdownDocument(text);
1067
+ if (!this.shouldAugmentPdfWithOcr(markdownText))
1068
+ return { text: [this.metadataOnlyText(file), markdownText].join('\n\n'), metadata, warnings };
1069
+ const ocr = await this.extractScannedPdfOcr(file);
1070
+ if (ocr.text.trim()) {
1071
+ metadata.contentCoverage = 'pdf_text_streams_plus_ocr';
1072
+ metadata.ocrAugmented = true;
1073
+ return { text: [this.metadataOnlyText(file), markdownText, '## PDF OCR 备用识别文本', ocr.text].join('\n\n'), metadata: { ...metadata, ocr: ocr.metadata }, warnings: [...warnings, ...ocr.warnings] };
1074
+ }
1075
+ return { text: [this.metadataOnlyText(file), markdownText].join('\n\n'), metadata: { ...metadata, ocrRecommended: true, ocrReason: ocr.metadata.ocrReason ?? 'pdf_text_low_quality' }, warnings: [...warnings, ...ocr.warnings] };
1067
1076
  }
1068
1077
  }
1069
1078
  catch (error) {
@@ -1086,6 +1095,17 @@ try {
1086
1095
  warnings: [...warnings, 'PDF 正文暂未提取到文本,已索引文件名、路径和类型元数据', ...ocr.warnings],
1087
1096
  };
1088
1097
  }
1098
+ shouldAugmentPdfWithOcr(text) {
1099
+ const normalizedLength = this.normalizedTextLength(text);
1100
+ if (normalizedLength < 1200)
1101
+ return true;
1102
+ const lines = text.split(/\r?\n/u).map(line => line.trim()).filter(Boolean);
1103
+ if (lines.length === 0)
1104
+ return true;
1105
+ const shortLineRatio = lines.filter(line => line.length <= 12).length / lines.length;
1106
+ const cjkCount = (text.match(/[\p{Script=Han}]/gu) ?? []).length;
1107
+ return shortLineRatio > 0.65 && cjkCount < 1200;
1108
+ }
1089
1109
  async extractScannedPdfOcr(file) {
1090
1110
  const metadata = {
1091
1111
  extractionMode: 'pdf_page_ocr_embedded',
@@ -1173,6 +1193,7 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
1173
1193
  }
1174
1194
  }
1175
1195
  async extractPdfText(buffer) {
1196
+ let pdfjsText = '';
1176
1197
  // 第一层:pdfjs-dist 文本提取(处理压缩内容流、CJK 字体、现代 PDF)
1177
1198
  try {
1178
1199
  const mod = await resolveAndImport('pdfjs-dist/legacy/build/pdf.mjs');
@@ -1182,7 +1203,7 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
1182
1203
  const pageLimit = doc.numPages;
1183
1204
  for (let i = 1; i <= pageLimit; i++) {
1184
1205
  const page = await doc.getPage(i);
1185
- const content = await page.getTextContent();
1206
+ const content = await page.getTextContent({ normalizeWhitespace: false, disableCombineTextItems: true });
1186
1207
  const items = content.items
1187
1208
  .map((item) => this.toPdfTextItem(item))
1188
1209
  .filter((item) => !!item && item.str.trim().length > 0);
@@ -1191,29 +1212,32 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
1191
1212
  pages.push(pageText.trim());
1192
1213
  }
1193
1214
  await doc.destroy();
1194
- if (pages.length > 0) {
1195
- const combined = pages.join('\n\n');
1196
- if (combined.trim())
1197
- return combined;
1198
- }
1215
+ pdfjsText = pages.join('\n\n').trim();
1199
1216
  }
1200
1217
  catch (e) {
1201
1218
  if (process.env.KB_DEBUG === '1')
1202
1219
  console.warn('[kb] pdfjs-dist extraction failed:', e.message);
1203
1220
  }
1204
- // 第二层:pdf-parse(兼容旧版 PDF
1221
+ // 第二层:pdf-parse(兼容旧版 PDF),与 pdfjs 结果互补,避免单一解析器漏字
1205
1222
  try {
1206
1223
  const mod = await resolveAndImport('pdf-parse');
1207
- const pdfParse = mod.default;
1224
+ const pdfParse = typeof mod === 'function'
1225
+ ? mod
1226
+ : mod.default;
1208
1227
  if (pdfParse) {
1209
1228
  const result = await pdfParse(buffer);
1210
- if (result.text.trim())
1211
- return result.text;
1229
+ const parseText = result.text.trim();
1230
+ if (pdfjsText && parseText && this.normalizedTextLength(parseText) > this.normalizedTextLength(pdfjsText) * 1.08)
1231
+ return [pdfjsText, '## PDF 备用解析文本', parseText].join('\n\n');
1232
+ if (parseText && !pdfjsText)
1233
+ return parseText;
1212
1234
  }
1213
1235
  }
1214
1236
  catch {
1215
- // 降级到下方纯正则提取
1237
+ // pdfjs 结果已可用时忽略备用解析器失败
1216
1238
  }
1239
+ if (pdfjsText)
1240
+ return pdfjsText;
1217
1241
  // 第三层:raw regex 回退(未压缩的古老 PDF)
1218
1242
  const raw = buffer.toString('latin1');
1219
1243
  const matches = Array.from(raw.matchAll(/\(([^()]{2,500})\)\s*T[jJ]/gu), match => match[1] ?? '')
@@ -1229,6 +1253,9 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
1229
1253
  .join('')
1230
1254
  .trim();
1231
1255
  }
1256
+ normalizedTextLength(value) {
1257
+ return value.replace(/\s+/gu, '').length;
1258
+ }
1232
1259
  toPdfTextItem(item) {
1233
1260
  if (!item || typeof item !== 'object' || !('str' in item))
1234
1261
  return undefined;
@@ -1257,6 +1284,25 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
1257
1284
  const markdown = this.rowsToPdfMarkdownWithTables(orderedRows);
1258
1285
  return [`## PDF 第 ${pageNumber} 页`, markdown].join('\n\n');
1259
1286
  }
1287
+ joinPdfRowText(items) {
1288
+ let output = '';
1289
+ let previous;
1290
+ for (const item of items) {
1291
+ const text = item.str.trim();
1292
+ if (!text)
1293
+ continue;
1294
+ if (!previous) {
1295
+ output += text;
1296
+ }
1297
+ else {
1298
+ const gap = item.x - (previous.x + previous.width);
1299
+ const cjkJoin = /[\p{Script=Han}((《“‘]$/u.test(output) || /^[\p{Script=Han}))》”’、,。;:!?]/u.test(text);
1300
+ output += gap > Math.max(3, previous.height * 0.35) && !cjkJoin ? ` ${text}` : text;
1301
+ }
1302
+ previous = item;
1303
+ }
1304
+ return output.replace(/\s+/gu, ' ').trim();
1305
+ }
1260
1306
  groupPdfItemsIntoRows(items) {
1261
1307
  const sorted = [...items].sort((a, b) => b.y - a.y || a.x - b.x);
1262
1308
  const rows = [];
@@ -1270,7 +1316,7 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
1270
1316
  return rows.map(row => {
1271
1317
  const rowItems = row.items.sort((a, b) => a.x - b.x);
1272
1318
  return {
1273
- text: rowItems.map(item => item.str.trim()).filter(Boolean).join(' ').replace(/\s+/gu, ' '),
1319
+ text: this.joinPdfRowText(rowItems),
1274
1320
  x: Math.min(...rowItems.map(item => item.x)),
1275
1321
  y: row.y,
1276
1322
  height: Math.max(...rowItems.map(item => item.height || 0)),
@@ -15,8 +15,11 @@ export interface FederatedSearchItem {
15
15
  parentId?: string;
16
16
  source?: 'keyword' | 'vector' | 'hybrid';
17
17
  sectionTitle?: string;
18
+ titlePath?: string;
18
19
  rowRange?: string;
19
20
  chunkKind?: string;
21
+ startChar?: number;
22
+ endChar?: number;
20
23
  scoreDetails?: {
21
24
  keywordScore?: number;
22
25
  bm25Score?: number;
@@ -85,8 +85,11 @@ export class FederationSearch {
85
85
  parentId: typeof result.document.metadata.parent_id === 'string' ? result.document.metadata.parent_id : undefined,
86
86
  source: 'vector',
87
87
  sectionTitle: typeof result.document.metadata.section_title === 'string' ? result.document.metadata.section_title : undefined,
88
+ titlePath: typeof result.document.metadata.title_path === 'string' ? result.document.metadata.title_path : undefined,
88
89
  rowRange: typeof result.document.metadata.row_range === 'string' ? result.document.metadata.row_range : undefined,
89
90
  chunkKind: typeof result.document.metadata.chunk_kind === 'string' ? result.document.metadata.chunk_kind : undefined,
91
+ startChar: typeof result.document.metadata.start_char === 'number' ? result.document.metadata.start_char : undefined,
92
+ endChar: typeof result.document.metadata.end_char === 'number' ? result.document.metadata.end_char : undefined,
90
93
  scoreDetails: { vectorScore: result.score },
91
94
  };
92
95
  }
@@ -17,6 +17,7 @@ export declare class HNSWVectorStore implements VectorStoreInterface {
17
17
  flush(): Promise<void>;
18
18
  needsRebuild(): boolean;
19
19
  search(query: VectorSearchQuery): Promise<VectorSearchResult[]>;
20
+ private matchesWhere;
20
21
  private persist;
21
22
  private toStoredDocument;
22
23
  private loadDocuments;
@@ -81,17 +81,22 @@ export class HNSWVectorStore {
81
81
  }
82
82
  async search(query) {
83
83
  await this.ensureCollection();
84
- const result = this.index.searchKnn(query.queryEmbedding, query.topK);
84
+ const hasFilter = !!query.where && Object.keys(query.where).length > 0;
85
+ const candidateK = hasFilter ? Math.min(this.documents.size, Math.max(query.topK * 10, query.topK + 50)) : query.topK;
86
+ const result = this.index.searchKnn(query.queryEmbedding, candidateK);
85
87
  return result.neighbors.flatMap((rowid, index) => {
86
88
  const document = this.documents.get(rowid);
87
- if (!document)
88
- return [];
89
- if (typeof query.where?.file_path === 'string' && document.metadata.file_path !== query.where.file_path)
89
+ if (!document || !this.matchesWhere(document, query.where))
90
90
  return [];
91
91
  const { embedding: _embedding, ...stored } = document;
92
92
  const distance = result.distances[index] ?? 0;
93
93
  return [{ collection: this.collectionName, document: stored, score: 1 / (1 + distance) }];
94
- });
94
+ }).slice(0, query.topK);
95
+ }
96
+ matchesWhere(document, where) {
97
+ if (!where)
98
+ return true;
99
+ return Object.entries(where).every(([key, value]) => document.metadata[key] === value);
95
100
  }
96
101
  persist() {
97
102
  this.index.writeIndexSync(this.indexPath);
@@ -29,6 +29,7 @@ export declare class VectorIndexer {
29
29
  private embedDocuments;
30
30
  private isValidEmbeddings;
31
31
  private groupByCollection;
32
+ private embeddingText;
32
33
  private toVectorDocument;
33
34
  private parseMetadata;
34
35
  private metadataString;
@@ -29,7 +29,7 @@ export class VectorIndexer {
29
29
  let processedChunks = 0;
30
30
  for (let offset = 0; offset < collectionChunks.length; offset += batchSize) {
31
31
  const batchChunks = collectionChunks.slice(offset, offset + batchSize);
32
- const texts = batchChunks.map(chunk => chunk.content);
32
+ const texts = batchChunks.map(chunk => this.embeddingText(chunk));
33
33
  const embeddings = await this.embedDocuments(texts);
34
34
  const documents = batchChunks.map((chunk, index) => this.toVectorDocument(chunk, embeddings[index] ?? []));
35
35
  await store.upsert(documents, { persist: options.persistEachBatch === true });
@@ -72,6 +72,17 @@ export class VectorIndexer {
72
72
  }
73
73
  return grouped;
74
74
  }
75
+ embeddingText(chunk) {
76
+ return chunk.searchContent ?? [
77
+ `文件路径: ${chunk.relativePath}`,
78
+ `资料类型: ${chunk.category}/${chunk.format}`,
79
+ chunk.titlePath ? `标题路径: ${chunk.titlePath}` : '',
80
+ chunk.sectionTitle ? `章节标题: ${chunk.sectionTitle}` : '',
81
+ chunk.chunkKind ? `切片类型: ${chunk.chunkKind}` : '',
82
+ chunk.rowRange ? `表格行范围: ${chunk.rowRange}` : '',
83
+ chunk.content,
84
+ ].filter(Boolean).join('\n');
85
+ }
75
86
  toVectorDocument(chunk, embedding) {
76
87
  const chunkMetadata = this.parseMetadata(chunk.metadataJson);
77
88
  return {
@@ -86,11 +97,14 @@ export class VectorIndexer {
86
97
  format: chunk.format,
87
98
  token_count: chunk.tokenCount,
88
99
  section_title: chunk.sectionTitle ?? null,
89
- parent_id: this.metadataString(chunkMetadata.parentId),
100
+ title_path: chunk.titlePath ?? this.metadataString(chunkMetadata.titlePath),
101
+ parent_id: chunk.parentId ?? this.metadataString(chunkMetadata.parentId),
90
102
  parent_index: this.metadataNumber(chunkMetadata.parentIndex),
91
103
  child_index: this.metadataNumber(chunkMetadata.childIndex),
92
- chunk_kind: this.metadataString(chunkMetadata.chunkKind),
93
- row_range: this.metadataString(chunkMetadata.rowRange),
104
+ chunk_kind: chunk.chunkKind ?? this.metadataString(chunkMetadata.chunkKind),
105
+ row_range: chunk.rowRange ?? this.metadataString(chunkMetadata.rowRange),
106
+ start_char: chunk.startChar ?? this.metadataNumber(chunkMetadata.startChar),
107
+ end_char: chunk.endChar ?? this.metadataNumber(chunkMetadata.endChar),
94
108
  split_strategy: this.metadataString(chunkMetadata.splitStrategy),
95
109
  },
96
110
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@customize-agent/knowledge",
3
- "version": "4.0.9",
3
+ "version": "4.0.11",
4
4
  "description": "Local knowledge base infrastructure for customize-agent",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",