@customize-agent/knowledge 4.0.8 → 4.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunking/text-chunker.d.ts +1 -0
- package/dist/chunking/text-chunker.js +26 -2
- package/dist/core/index-state-store.d.ts +10 -0
- package/dist/core/index-state-store.js +83 -16
- package/dist/core/knowledge-base-manager.js +24 -10
- package/dist/extraction/content-extractor.d.ts +5 -0
- package/dist/extraction/content-extractor.js +82 -35
- package/dist/search/federation-search.d.ts +3 -0
- package/dist/search/federation-search.js +3 -0
- package/dist/vector/hnsw-vector-store.d.ts +1 -0
- package/dist/vector/hnsw-vector-store.js +10 -5
- package/dist/vector/vector-indexer.d.ts +1 -0
- package/dist/vector/vector-indexer.js +18 -4
- package/package.json +1 -1
|
@@ -60,6 +60,7 @@ export class TextChunker {
|
|
|
60
60
|
createTextCandidates(text, category, config) {
|
|
61
61
|
const sections = this.splitIntoSections(text, category);
|
|
62
62
|
const candidates = [];
|
|
63
|
+
const titlePaths = this.buildTitlePaths(sections);
|
|
63
64
|
sections.forEach((section, parentIndex) => {
|
|
64
65
|
const parentId = `p${parentIndex}`;
|
|
65
66
|
const parts = this.mergeLeadingHeader(this.recursiveSplit(section.text, config.maxChunkSize));
|
|
@@ -72,6 +73,7 @@ export class TextChunker {
|
|
|
72
73
|
startChar,
|
|
73
74
|
endChar: startChar + part.length,
|
|
74
75
|
sectionTitle: section.title,
|
|
76
|
+
titlePath: titlePaths[parentIndex],
|
|
75
77
|
kind: this.kindForCategory(category),
|
|
76
78
|
parentId,
|
|
77
79
|
parentIndex,
|
|
@@ -90,6 +92,7 @@ export class TextChunker {
|
|
|
90
92
|
startChar,
|
|
91
93
|
endChar: startChar + part.length,
|
|
92
94
|
sectionTitle: this.extractSectionTitle(part) ?? '表格数据',
|
|
95
|
+
titlePath: this.extractSectionTitle(part) ?? '表格数据',
|
|
93
96
|
kind: 'table',
|
|
94
97
|
parentId: `table-${index}`,
|
|
95
98
|
parentIndex: index,
|
|
@@ -108,6 +111,7 @@ export class TextChunker {
|
|
|
108
111
|
startChar: Math.max(0, text.indexOf(part.slice(0, 40))),
|
|
109
112
|
endChar: Math.max(0, text.indexOf(part.slice(0, 40))) + part.length,
|
|
110
113
|
sectionTitle: this.extractSectionTitle(part),
|
|
114
|
+
titlePath: this.extractSectionTitle(part),
|
|
111
115
|
kind: 'data',
|
|
112
116
|
parentId: `data-${index}`,
|
|
113
117
|
parentIndex: index,
|
|
@@ -128,6 +132,7 @@ export class TextChunker {
|
|
|
128
132
|
startChar,
|
|
129
133
|
endChar: startChar + part.length,
|
|
130
134
|
sectionTitle: this.extractSectionTitle(part),
|
|
135
|
+
titlePath: this.extractSectionTitle(part),
|
|
131
136
|
kind: 'code',
|
|
132
137
|
parentId: `code-${language}-${index}`,
|
|
133
138
|
parentIndex: index,
|
|
@@ -321,10 +326,28 @@ export class TextChunker {
|
|
|
321
326
|
}));
|
|
322
327
|
});
|
|
323
328
|
}
|
|
329
|
+
buildTitlePaths(sections) {
|
|
330
|
+
const stack = [];
|
|
331
|
+
return sections.map(section => {
|
|
332
|
+
const firstLine = section.text.trim().split(/\r?\n/u)[0] ?? '';
|
|
333
|
+
const heading = firstLine.match(/^(#{1,6})\s+(.+)$/u);
|
|
334
|
+
if (heading?.[1] && heading[2]) {
|
|
335
|
+
const level = heading[1].length;
|
|
336
|
+
const title = heading[2].trim();
|
|
337
|
+
while (stack.length > 0 && stack[stack.length - 1].level >= level)
|
|
338
|
+
stack.pop();
|
|
339
|
+
stack.push({ level, title });
|
|
340
|
+
}
|
|
341
|
+
else if (section.title && stack.length === 0) {
|
|
342
|
+
stack.push({ level: 1, title: section.title });
|
|
343
|
+
}
|
|
344
|
+
return stack.map(item => item.title).join(' > ') || section.title;
|
|
345
|
+
});
|
|
346
|
+
}
|
|
324
347
|
withHeader(text, file, config) {
|
|
325
348
|
if (!config.headerInjection)
|
|
326
349
|
return text;
|
|
327
|
-
return
|
|
350
|
+
return `资料类型: ${file.category}/${file.format}\n\n${text}`;
|
|
328
351
|
}
|
|
329
352
|
createChunk(index, candidate, file, metadata) {
|
|
330
353
|
const text = candidate.text.trim();
|
|
@@ -344,9 +367,10 @@ export class TextChunker {
|
|
|
344
367
|
childIndex: candidate.childIndex,
|
|
345
368
|
rowRange: candidate.rowRange,
|
|
346
369
|
sectionTitle: candidate.sectionTitle ?? this.extractSectionTitle(text),
|
|
370
|
+
titlePath: candidate.titlePath ?? candidate.sectionTitle ?? this.extractSectionTitle(text),
|
|
347
371
|
startChar: candidate.startChar,
|
|
348
372
|
endChar: candidate.endChar,
|
|
349
|
-
splitStrategy: '
|
|
373
|
+
splitStrategy: 'recursive_parent_child_v2',
|
|
350
374
|
},
|
|
351
375
|
};
|
|
352
376
|
}
|
|
@@ -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,
|
|
133
|
-
|
|
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
|
-
|
|
169
|
-
|
|
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(
|
|
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(
|
|
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') !== '
|
|
630
|
-
this.setMetadata('schema_version', '
|
|
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 *
|
|
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
|
-
|
|
672
|
-
|
|
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
|
-
|
|
675
|
-
|
|
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
|
-
|
|
771
|
-
|
|
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];
|
|
@@ -16,6 +16,9 @@ export declare class ContentExtractor {
|
|
|
16
16
|
private extractTextClipping;
|
|
17
17
|
private swapUtf16Bytes;
|
|
18
18
|
private extractReadableFragments;
|
|
19
|
+
private cleanCadReadableText;
|
|
20
|
+
private isReadableCadValue;
|
|
21
|
+
private cleanExtractedText;
|
|
19
22
|
private textScore;
|
|
20
23
|
private extractCad;
|
|
21
24
|
private extractDxf;
|
|
@@ -57,8 +60,10 @@ export declare class ContentExtractor {
|
|
|
57
60
|
private extractPdf;
|
|
58
61
|
private extractScannedPdfOcr;
|
|
59
62
|
private extractPdfText;
|
|
63
|
+
private normalizedTextLength;
|
|
60
64
|
private toPdfTextItem;
|
|
61
65
|
private layoutPdfTextItems;
|
|
66
|
+
private joinPdfRowText;
|
|
62
67
|
private groupPdfItemsIntoRows;
|
|
63
68
|
private detectPdfColumnSplit;
|
|
64
69
|
private rowsToPdfMarkdownWithTables;
|
|
@@ -5,6 +5,8 @@ import { tmpdir } from 'node:os';
|
|
|
5
5
|
import { pathToFileURL } from 'node:url';
|
|
6
6
|
import { ExternalExtractorRegistry } from './external-extractor.js';
|
|
7
7
|
import { resolveAndImport, resolvePackage, getNodeModulesRoot } from './module-resolver.js';
|
|
8
|
+
const CAD_INTERNAL_TOKEN_RE = /\b(?:TDbPipe|TDbPipeValve|TDbPipeFitting|TDbWellh|AcDb\w+|Dwg\w+|ObjectId|Handle|ByLayer|Continuous|Model|Layout\d*)\b/giu;
|
|
9
|
+
const CAD_INTERNAL_LINE_RE = /^(?:TDb\w+|AcDb\w+|[A-F0-9]{8,}|\d+|Model|Layout\d*|ByLayer|Continuous)$/iu;
|
|
8
10
|
/** 文件内容提取器,支持文档、表格、图片、CAD 等多种文件格式的内容抽取 */
|
|
9
11
|
export class ContentExtractor {
|
|
10
12
|
externalExtractors;
|
|
@@ -102,7 +104,7 @@ export class ContentExtractor {
|
|
|
102
104
|
}
|
|
103
105
|
}
|
|
104
106
|
return {
|
|
105
|
-
text: text.trim(),
|
|
107
|
+
text: this.cleanExtractedText(text, file).trim(),
|
|
106
108
|
metadata,
|
|
107
109
|
warnings,
|
|
108
110
|
extractionTimeMs: Date.now() - start,
|
|
@@ -177,9 +179,36 @@ export class ContentExtractor {
|
|
|
177
179
|
return value
|
|
178
180
|
.replace(/[^\p{L}\p{N}\p{P}\p{S}\s]/gu, '\n')
|
|
179
181
|
.split(/[\r\n]+/u)
|
|
180
|
-
.map(line =>
|
|
182
|
+
.map(line => this.cleanCadReadableText(line))
|
|
181
183
|
.filter(line => line.length >= 2 && /[\p{L}\p{N}]/u.test(line));
|
|
182
184
|
}
|
|
185
|
+
cleanCadReadableText(value) {
|
|
186
|
+
return value
|
|
187
|
+
.replace(CAD_INTERNAL_TOKEN_RE, '')
|
|
188
|
+
.replace(/\b(?:LINE|LWPOLYLINE|POLYLINE|INSERT|HATCH|CIRCLE|ARC|DIMENSION|TEXT|MTEXT)\b/giu, '')
|
|
189
|
+
.replace(/\s+/gu, ' ')
|
|
190
|
+
.trim();
|
|
191
|
+
}
|
|
192
|
+
isReadableCadValue(value) {
|
|
193
|
+
const cleaned = this.cleanCadReadableText(value);
|
|
194
|
+
return cleaned.length >= 2 && !CAD_INTERNAL_LINE_RE.test(cleaned) && /[\p{Script=Han}\p{Letter}\d]/u.test(cleaned);
|
|
195
|
+
}
|
|
196
|
+
cleanExtractedText(value, file) {
|
|
197
|
+
const normalized = [...value]
|
|
198
|
+
.filter(char => {
|
|
199
|
+
const code = char.charCodeAt(0);
|
|
200
|
+
return code === 9 || code === 10 || code === 13 || code >= 32;
|
|
201
|
+
})
|
|
202
|
+
.join('');
|
|
203
|
+
if (file.category !== 'cad')
|
|
204
|
+
return normalized.replace(/\n{3,}/gu, '\n\n');
|
|
205
|
+
return normalized
|
|
206
|
+
.split(/\r?\n/u)
|
|
207
|
+
.map(line => this.cleanCadReadableText(line))
|
|
208
|
+
.filter(line => line && !CAD_INTERNAL_LINE_RE.test(line))
|
|
209
|
+
.join('\n')
|
|
210
|
+
.replace(/\n{3,}/gu, '\n\n');
|
|
211
|
+
}
|
|
183
212
|
textScore(value) {
|
|
184
213
|
const cjk = (value.match(/[\p{Script=Han}]/gu) ?? []).length;
|
|
185
214
|
const alnum = (value.match(/[\p{L}\p{N}]/gu) ?? []).length;
|
|
@@ -202,9 +231,9 @@ export class ContentExtractor {
|
|
|
202
231
|
}
|
|
203
232
|
if (file.format === 'autocad' && ext === '.dxf') {
|
|
204
233
|
const raw = fs.readFileSync(file.absolutePath, 'utf8');
|
|
205
|
-
const layers = this.matchAll(raw, /\n\s*8\s*\n([^\n]+)/gu).slice(0, 300);
|
|
234
|
+
const layers = this.matchAll(raw, /\n\s*8\s*\n([^\n]+)/gu).filter(value => this.isReadableCadValue(value)).slice(0, 300);
|
|
206
235
|
const textEntities = this.extractDxfTextAnnotations(raw).slice(0, 500);
|
|
207
|
-
const blocks = this.matchAll(raw, /\n\s*2\s*\n([^\n]+)/gu).slice(0, 300);
|
|
236
|
+
const blocks = this.matchAll(raw, /\n\s*2\s*\n([^\n]+)/gu).filter(value => this.isReadableCadValue(value)).slice(0, 300);
|
|
208
237
|
const entityTypes = this.matchAll(raw, /\n\s*0\s*\n([A-Z][A-Z0-9_]+)/gu).slice(0, 1000);
|
|
209
238
|
const uniqueLayers = Array.from(new Set(layers));
|
|
210
239
|
const uniqueBlocks = Array.from(new Set(blocks));
|
|
@@ -278,7 +307,7 @@ export class ContentExtractor {
|
|
|
278
307
|
if (result.text.trim())
|
|
279
308
|
return result;
|
|
280
309
|
}
|
|
281
|
-
const readable = this.extractBinaryReadableFragments(file.absolutePath).slice(0,
|
|
310
|
+
const readable = this.extractBinaryReadableFragments(file.absolutePath).filter(value => this.isReadableCadValue(value)).slice(0, 5000);
|
|
282
311
|
metadata.extractionMode = 'builtin_cad_readable_fragments';
|
|
283
312
|
metadata.contentCoverage = readable.length > 0 ? 'cad_readable_text_fragments' : 'metadata';
|
|
284
313
|
metadata.stringCount = readable.length;
|
|
@@ -303,10 +332,10 @@ export class ContentExtractor {
|
|
|
303
332
|
catch {
|
|
304
333
|
warnings.push('dxf-parser 解析失败,已使用 DXF 文本结构抽取回退');
|
|
305
334
|
}
|
|
306
|
-
const layers = this.matchAll(raw,
|
|
307
|
-
const textEntities = this.extractDxfTextAnnotations(raw).slice(0,
|
|
308
|
-
const blocks = this.matchAll(raw,
|
|
309
|
-
const entityTypes = this.matchAll(raw,
|
|
335
|
+
const layers = this.matchAll(raw, /(?:^|\r?\n)\s*8\s*\r?\n([^\r\n]+)/gu).filter(value => this.isReadableCadValue(value)).slice(0, 300);
|
|
336
|
+
const textEntities = this.extractDxfTextAnnotations(raw).slice(0, 5000);
|
|
337
|
+
const blocks = this.matchAll(raw, /(?:^|\r?\n)\s*2\s*\r?\n([^\r\n]+)/gu).filter(value => this.isReadableCadValue(value)).slice(0, 300);
|
|
338
|
+
const entityTypes = this.matchAll(raw, /(?:^|\r?\n)\s*0\s*\r?\n([A-Z][A-Z0-9_]+)/gu).slice(0, 1200);
|
|
310
339
|
const uniqueLayers = Array.from(new Set(layers));
|
|
311
340
|
const uniqueBlocks = Array.from(new Set(blocks));
|
|
312
341
|
const uniqueEntityTypes = Array.from(new Set(entityTypes));
|
|
@@ -350,18 +379,18 @@ export class ContentExtractor {
|
|
|
350
379
|
});
|
|
351
380
|
}
|
|
352
381
|
extractDxfTextAnnotations(raw) {
|
|
353
|
-
const entities = raw.split(
|
|
382
|
+
const entities = raw.split(/(?:^|\r?\n)\s*0\s*\r?\n/u).filter(section => /^(?:TEXT|MTEXT|DIMENSION|LEADER)/u.test(section.trim()));
|
|
354
383
|
return entities.flatMap(section => {
|
|
355
|
-
const text =
|
|
356
|
-
if (!text)
|
|
384
|
+
const text = this.cleanCadReadableText(/(?:^|\r?\n)\s*(?:1|3)\s*\r?\n([^\r\n]+)/u.exec(section)?.[1] ?? '');
|
|
385
|
+
if (!text || !this.isReadableCadValue(text))
|
|
357
386
|
return [];
|
|
358
387
|
return [{
|
|
359
388
|
text,
|
|
360
|
-
layer:
|
|
361
|
-
block:
|
|
389
|
+
layer: /(?:^|\r?\n)\s*8\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]?.trim(),
|
|
390
|
+
block: /(?:^|\r?\n)\s*2\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]?.trim(),
|
|
362
391
|
entityType: section.trim().split(/\s+/u)[0],
|
|
363
|
-
x: Number(
|
|
364
|
-
y: Number(
|
|
392
|
+
x: Number(/(?:^|\r?\n)\s*10\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]),
|
|
393
|
+
y: Number(/(?:^|\r?\n)\s*20\s*\r?\n([^\r\n]+)/u.exec(section)?.[1]),
|
|
365
394
|
}].map(item => ({ ...item, x: Number.isFinite(item.x) ? item.x : undefined, y: Number.isFinite(item.y) ? item.y : undefined }));
|
|
366
395
|
});
|
|
367
396
|
}
|
|
@@ -1144,6 +1173,7 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
|
|
|
1144
1173
|
}
|
|
1145
1174
|
}
|
|
1146
1175
|
async extractPdfText(buffer) {
|
|
1176
|
+
let pdfjsText = '';
|
|
1147
1177
|
// 第一层:pdfjs-dist 文本提取(处理压缩内容流、CJK 字体、现代 PDF)
|
|
1148
1178
|
try {
|
|
1149
1179
|
const mod = await resolveAndImport('pdfjs-dist/legacy/build/pdf.mjs');
|
|
@@ -1153,7 +1183,7 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
|
|
|
1153
1183
|
const pageLimit = doc.numPages;
|
|
1154
1184
|
for (let i = 1; i <= pageLimit; i++) {
|
|
1155
1185
|
const page = await doc.getPage(i);
|
|
1156
|
-
const content = await page.getTextContent();
|
|
1186
|
+
const content = await page.getTextContent({ normalizeWhitespace: false, disableCombineTextItems: true });
|
|
1157
1187
|
const items = content.items
|
|
1158
1188
|
.map((item) => this.toPdfTextItem(item))
|
|
1159
1189
|
.filter((item) => !!item && item.str.trim().length > 0);
|
|
@@ -1162,28 +1192,30 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
|
|
|
1162
1192
|
pages.push(pageText.trim());
|
|
1163
1193
|
}
|
|
1164
1194
|
await doc.destroy();
|
|
1165
|
-
|
|
1166
|
-
const combined = pages.join('\n\n');
|
|
1167
|
-
if (combined.trim())
|
|
1168
|
-
return combined.slice(0, 250_000);
|
|
1169
|
-
}
|
|
1195
|
+
pdfjsText = pages.join('\n\n').trim();
|
|
1170
1196
|
}
|
|
1171
1197
|
catch (e) {
|
|
1172
1198
|
if (process.env.KB_DEBUG === '1')
|
|
1173
1199
|
console.warn('[kb] pdfjs-dist extraction failed:', e.message);
|
|
1174
1200
|
}
|
|
1175
|
-
// 第二层:pdf-parse(兼容旧版 PDF
|
|
1201
|
+
// 第二层:pdf-parse(兼容旧版 PDF),与 pdfjs 结果互补,避免单一解析器漏字
|
|
1176
1202
|
try {
|
|
1177
1203
|
const mod = await resolveAndImport('pdf-parse');
|
|
1178
1204
|
const pdfParse = mod.default;
|
|
1179
1205
|
if (pdfParse) {
|
|
1180
1206
|
const result = await pdfParse(buffer);
|
|
1181
|
-
|
|
1182
|
-
|
|
1207
|
+
const parseText = result.text.trim();
|
|
1208
|
+
if (pdfjsText && parseText && this.normalizedTextLength(parseText) > this.normalizedTextLength(pdfjsText) * 1.08)
|
|
1209
|
+
return [pdfjsText, '## PDF 备用解析文本', parseText].join('\n\n');
|
|
1210
|
+
if (pdfjsText)
|
|
1211
|
+
return pdfjsText;
|
|
1212
|
+
if (parseText)
|
|
1213
|
+
return parseText;
|
|
1183
1214
|
}
|
|
1184
1215
|
}
|
|
1185
1216
|
catch {
|
|
1186
|
-
|
|
1217
|
+
if (pdfjsText)
|
|
1218
|
+
return pdfjsText;
|
|
1187
1219
|
}
|
|
1188
1220
|
// 第三层:raw regex 回退(未压缩的古老 PDF)
|
|
1189
1221
|
const raw = buffer.toString('latin1');
|
|
@@ -1200,6 +1232,9 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
|
|
|
1200
1232
|
.join('')
|
|
1201
1233
|
.trim();
|
|
1202
1234
|
}
|
|
1235
|
+
normalizedTextLength(value) {
|
|
1236
|
+
return value.replace(/\s+/gu, '').length;
|
|
1237
|
+
}
|
|
1203
1238
|
toPdfTextItem(item) {
|
|
1204
1239
|
if (!item || typeof item !== 'object' || !('str' in item))
|
|
1205
1240
|
return undefined;
|
|
@@ -1228,6 +1263,25 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
|
|
|
1228
1263
|
const markdown = this.rowsToPdfMarkdownWithTables(orderedRows);
|
|
1229
1264
|
return [`## PDF 第 ${pageNumber} 页`, markdown].join('\n\n');
|
|
1230
1265
|
}
|
|
1266
|
+
joinPdfRowText(items) {
|
|
1267
|
+
let output = '';
|
|
1268
|
+
let previous;
|
|
1269
|
+
for (const item of items) {
|
|
1270
|
+
const text = item.str.trim();
|
|
1271
|
+
if (!text)
|
|
1272
|
+
continue;
|
|
1273
|
+
if (!previous) {
|
|
1274
|
+
output += text;
|
|
1275
|
+
}
|
|
1276
|
+
else {
|
|
1277
|
+
const gap = item.x - (previous.x + previous.width);
|
|
1278
|
+
const cjkJoin = /[\p{Script=Han}((《“‘]$/u.test(output) || /^[\p{Script=Han}))》”’、,。;:!?]/u.test(text);
|
|
1279
|
+
output += gap > Math.max(3, previous.height * 0.35) && !cjkJoin ? ` ${text}` : text;
|
|
1280
|
+
}
|
|
1281
|
+
previous = item;
|
|
1282
|
+
}
|
|
1283
|
+
return output.replace(/\s+/gu, ' ').trim();
|
|
1284
|
+
}
|
|
1231
1285
|
groupPdfItemsIntoRows(items) {
|
|
1232
1286
|
const sorted = [...items].sort((a, b) => b.y - a.y || a.x - b.x);
|
|
1233
1287
|
const rows = [];
|
|
@@ -1241,7 +1295,7 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
|
|
|
1241
1295
|
return rows.map(row => {
|
|
1242
1296
|
const rowItems = row.items.sort((a, b) => a.x - b.x);
|
|
1243
1297
|
return {
|
|
1244
|
-
text:
|
|
1298
|
+
text: this.joinPdfRowText(rowItems),
|
|
1245
1299
|
x: Math.min(...rowItems.map(item => item.x)),
|
|
1246
1300
|
y: row.y,
|
|
1247
1301
|
height: Math.max(...rowItems.map(item => item.height || 0)),
|
|
@@ -1450,15 +1504,8 @@ process.stdout.write(JSON.stringify({ pageCount: doc.numPages, pageLimit, text:
|
|
|
1450
1504
|
return lines.filter(line => !line.endsWith(':'));
|
|
1451
1505
|
}
|
|
1452
1506
|
metadataOnlyText(file) {
|
|
1453
|
-
const fileName = path.basename(file.relativePath);
|
|
1454
|
-
const directory = path.dirname(file.relativePath);
|
|
1455
|
-
const searchableName = fileName.replace(/[_\-.]+/gu, ' ');
|
|
1456
1507
|
return [
|
|
1457
|
-
|
|
1458
|
-
`文件路径: ${file.relativePath}`,
|
|
1459
|
-
`所在目录: ${directory === '.' ? 'knowledgeBase' : directory}`,
|
|
1460
|
-
`可搜索名称: ${searchableName}`,
|
|
1461
|
-
`文件类型: ${file.category}/${file.format}`,
|
|
1508
|
+
`资料类型: ${file.category}/${file.format}`,
|
|
1462
1509
|
`MIME: ${file.mimeType}`,
|
|
1463
1510
|
`文件大小: ${file.fileSize} bytes`,
|
|
1464
1511
|
].join('\n');
|
|
@@ -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
|
|
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,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
|
|
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
|
-
|
|
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
|
};
|