scream-code 0.8.2 → 0.8.4
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/main.mjs
CHANGED
|
@@ -6,7 +6,7 @@ const __dirname = __cjsShimDirname(__filename);
|
|
|
6
6
|
import "./suppress-sqlite-warning-C2VB0doZ.mjs";
|
|
7
7
|
//#region src/main.ts
|
|
8
8
|
try {
|
|
9
|
-
(await import("./app-
|
|
9
|
+
(await import("./app-DYDC5U75.mjs")).main();
|
|
10
10
|
} catch (error) {
|
|
11
11
|
process.stderr.write(`${error instanceof Error ? error.stack ?? error.message : String(error)}\n`);
|
|
12
12
|
process.exit(1);
|
|
@@ -243,6 +243,22 @@ var KnowledgeStore = class {
|
|
|
243
243
|
return this.embeddingEngine;
|
|
244
244
|
}
|
|
245
245
|
/**
|
|
246
|
+
* Proactively load the embedding model (downloads on first call).
|
|
247
|
+
* Call before ingestion so the user gets a clear download prompt instead
|
|
248
|
+
* of a mid-ingest failure.
|
|
249
|
+
*/
|
|
250
|
+
async ensureEmbeddingReady() {
|
|
251
|
+
if (this.embeddingEngine === void 0) return {
|
|
252
|
+
ok: false,
|
|
253
|
+
reason: "engine not set"
|
|
254
|
+
};
|
|
255
|
+
const ok = await this.embeddingEngine.ensureReady();
|
|
256
|
+
return {
|
|
257
|
+
ok,
|
|
258
|
+
reason: ok ? void 0 : "fastembed init failed"
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
246
262
|
* Begin a write transaction. Use commitTransaction / rollbackTransaction
|
|
247
263
|
* to close it. Used by ingest to keep chunks/events/entities/edges atomic —
|
|
248
264
|
* a mid-ingest failure leaves no partial rows.
|
|
@@ -637,6 +653,42 @@ var KnowledgeStore = class {
|
|
|
637
653
|
JOIN knowledge_event_entities ee ON ee.entity_id = en.id
|
|
638
654
|
WHERE ee.event_id = ?`).all(eventId).map(rowToEntity);
|
|
639
655
|
}
|
|
656
|
+
async listEntities(sourceId) {
|
|
657
|
+
await this.init();
|
|
658
|
+
if (this.db === void 0) return [];
|
|
659
|
+
const sql = sourceId ? `SELECT e.*, COUNT(ee.id) AS event_count
|
|
660
|
+
FROM knowledge_entities e
|
|
661
|
+
LEFT JOIN knowledge_event_entities ee ON ee.entity_id = e.id
|
|
662
|
+
WHERE e.source_id = ?
|
|
663
|
+
GROUP BY e.id
|
|
664
|
+
ORDER BY event_count DESC` : `SELECT e.*, COUNT(ee.id) AS event_count
|
|
665
|
+
FROM knowledge_entities e
|
|
666
|
+
LEFT JOIN knowledge_event_entities ee ON ee.entity_id = e.id
|
|
667
|
+
GROUP BY e.id
|
|
668
|
+
ORDER BY event_count DESC`;
|
|
669
|
+
return (sourceId ? this.db.prepare(sql).all(sourceId) : this.db.prepare(sql).all()).map((row) => ({
|
|
670
|
+
...rowToEntity(row),
|
|
671
|
+
eventCount: Number(row["event_count"] ?? 0)
|
|
672
|
+
}));
|
|
673
|
+
}
|
|
674
|
+
async listEvents(sourceId) {
|
|
675
|
+
await this.init();
|
|
676
|
+
if (this.db === void 0) return [];
|
|
677
|
+
const sql = sourceId ? "SELECT * FROM knowledge_events WHERE source_id = ? ORDER BY rank ASC" : "SELECT * FROM knowledge_events ORDER BY created_at DESC";
|
|
678
|
+
return (sourceId ? this.db.prepare(sql).all(sourceId) : this.db.prepare(sql).all()).map(rowToEvent);
|
|
679
|
+
}
|
|
680
|
+
async listEventEntities(sourceId) {
|
|
681
|
+
await this.init();
|
|
682
|
+
if (this.db === void 0) return [];
|
|
683
|
+
const sql = sourceId ? `SELECT ee.event_id, ee.entity_id
|
|
684
|
+
FROM knowledge_event_entities ee
|
|
685
|
+
JOIN knowledge_events e ON e.id = ee.event_id
|
|
686
|
+
WHERE e.source_id = ?` : "SELECT event_id, entity_id FROM knowledge_event_entities";
|
|
687
|
+
return (sourceId ? this.db.prepare(sql).all(sourceId) : this.db.prepare(sql).all()).map((row) => ({
|
|
688
|
+
eventId: asString(row["event_id"]),
|
|
689
|
+
entityId: asString(row["entity_id"])
|
|
690
|
+
}));
|
|
691
|
+
}
|
|
640
692
|
async ftsSearchChunks(query, limit = 50) {
|
|
641
693
|
await this.init();
|
|
642
694
|
if (this.db === void 0) return [];
|
|
@@ -819,6 +871,13 @@ function buildFtsQuery(search) {
|
|
|
819
871
|
const HEADING_RE = /^(#{1,6})\s+(.+?)\s*$/;
|
|
820
872
|
const CODE_FENCE_RE = /^(\s*)(`{3,}|~{3,})/;
|
|
821
873
|
/**
|
|
874
|
+
* Soft cap on chunk token count. BGE-small-zh-v1.5 (the fastembed model used
|
|
875
|
+
* here) has a 512-token sequence limit; chunks longer than that get truncated
|
|
876
|
+
* at the embedding layer, hurting retrieval quality. We split at ~480 to leave
|
|
877
|
+
* headroom for the heading line that gets prepended at embed time.
|
|
878
|
+
*/
|
|
879
|
+
const MAX_CHUNK_TOKENS = 480;
|
|
880
|
+
/**
|
|
822
881
|
* Split a markdown document into sections using heading_strict strategy:
|
|
823
882
|
* each section begins at a heading and contains all body content until the
|
|
824
883
|
* next heading. Consecutive headings collapse to the lowest-level one as the
|
|
@@ -837,18 +896,25 @@ function chunkMarkdown(content) {
|
|
|
837
896
|
const flush = () => {
|
|
838
897
|
const raw = buffer.join("\n").trim();
|
|
839
898
|
const heading = currentHeading;
|
|
899
|
+
const level = currentLevel;
|
|
840
900
|
buffer = [];
|
|
841
901
|
if (raw.length === 0) return;
|
|
842
902
|
const body = stripMarkdown(raw);
|
|
843
903
|
if (body.length === 0 && heading === null) return;
|
|
844
|
-
|
|
904
|
+
const subSections = splitLargeSection({
|
|
845
905
|
heading,
|
|
846
|
-
headingLevel:
|
|
906
|
+
headingLevel: level,
|
|
847
907
|
content: body,
|
|
848
908
|
rawContent: raw,
|
|
849
909
|
rank
|
|
850
910
|
});
|
|
851
|
-
|
|
911
|
+
for (const section of subSections) {
|
|
912
|
+
sections.push({
|
|
913
|
+
...section,
|
|
914
|
+
rank
|
|
915
|
+
});
|
|
916
|
+
rank += 1;
|
|
917
|
+
}
|
|
852
918
|
};
|
|
853
919
|
for (const line of lines) {
|
|
854
920
|
if (CODE_FENCE_RE.exec(line)) {
|
|
@@ -882,10 +948,11 @@ function stripMarkdown(text) {
|
|
|
882
948
|
/**
|
|
883
949
|
* Split a plain text file into chunks by blank-line paragraphs.
|
|
884
950
|
* Each non-empty paragraph becomes one chunk with no heading.
|
|
885
|
-
* Long paragraphs are split at sentence boundaries to
|
|
951
|
+
* Long paragraphs are split at sentence boundaries to stay under the
|
|
952
|
+
* embedding model's token cap (same MAX_CHUNK_TOKENS as markdown).
|
|
886
953
|
*/
|
|
887
954
|
function chunkText(content) {
|
|
888
|
-
const MAX_PARA_TOKENS =
|
|
955
|
+
const MAX_PARA_TOKENS = MAX_CHUNK_TOKENS;
|
|
889
956
|
const MAX_PARA_CHARS = MAX_PARA_TOKENS * 4;
|
|
890
957
|
const paragraphs = content.split(/\n\s*\n/).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
891
958
|
const sections = [];
|
|
@@ -928,8 +995,144 @@ function chunkText(content) {
|
|
|
928
995
|
function estimateTokens(text) {
|
|
929
996
|
return Math.ceil(text.length / 4);
|
|
930
997
|
}
|
|
998
|
+
/**
|
|
999
|
+
* Split a section that exceeds MAX_CHUNK_TOKENS into smaller sub-sections.
|
|
1000
|
+
* Strategy (mirrors SAG's `splitLargeSection`):
|
|
1001
|
+
* 1. Split into paragraphs by blank lines.
|
|
1002
|
+
* 2. Greedily pack paragraphs into chunks under the token cap.
|
|
1003
|
+
* 3. A paragraph that alone exceeds the cap is split at sentence boundaries;
|
|
1004
|
+
* a sentence that itself exceeds the cap is split by character limit.
|
|
1005
|
+
*
|
|
1006
|
+
* Sections under the cap are returned unchanged (single-element array).
|
|
1007
|
+
* The heading is preserved across all sub-sections; rank is reassigned by the
|
|
1008
|
+
* caller. rawContent is rebuilt from the sub-chunk's content paragraphs.
|
|
1009
|
+
*/
|
|
1010
|
+
function splitLargeSection(section) {
|
|
1011
|
+
if (estimateTokens(section.content) <= MAX_CHUNK_TOKENS) return [section];
|
|
1012
|
+
const paragraphs = section.rawContent.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
|
|
1013
|
+
if (paragraphs.length <= 1) return splitBySentences(section, section.rawContent);
|
|
1014
|
+
const result = [];
|
|
1015
|
+
let buffer = [];
|
|
1016
|
+
let bufferTokens = 0;
|
|
1017
|
+
const flushBuffer = () => {
|
|
1018
|
+
if (buffer.length === 0) return;
|
|
1019
|
+
const raw = buffer.join("\n\n").trim();
|
|
1020
|
+
if (raw.length === 0) {
|
|
1021
|
+
buffer = [];
|
|
1022
|
+
bufferTokens = 0;
|
|
1023
|
+
return;
|
|
1024
|
+
}
|
|
1025
|
+
result.push({
|
|
1026
|
+
heading: section.heading,
|
|
1027
|
+
headingLevel: section.headingLevel,
|
|
1028
|
+
content: stripMarkdown(raw),
|
|
1029
|
+
rawContent: raw,
|
|
1030
|
+
rank: 0
|
|
1031
|
+
});
|
|
1032
|
+
buffer = [];
|
|
1033
|
+
bufferTokens = 0;
|
|
1034
|
+
};
|
|
1035
|
+
for (const para of paragraphs) {
|
|
1036
|
+
const paraTokens = estimateTokens(para);
|
|
1037
|
+
if (paraTokens > MAX_CHUNK_TOKENS) {
|
|
1038
|
+
flushBuffer();
|
|
1039
|
+
for (const frag of splitBySentences(section, para)) result.push(frag);
|
|
1040
|
+
continue;
|
|
1041
|
+
}
|
|
1042
|
+
if (buffer.length > 0 && bufferTokens + paraTokens > MAX_CHUNK_TOKENS) flushBuffer();
|
|
1043
|
+
buffer.push(para);
|
|
1044
|
+
bufferTokens += paraTokens;
|
|
1045
|
+
}
|
|
1046
|
+
flushBuffer();
|
|
1047
|
+
return result.length > 0 ? result : [section];
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Split a long text into sub-sections at sentence boundaries.
|
|
1051
|
+
* Falls back to a hard character cut when a single sentence exceeds the cap.
|
|
1052
|
+
*/
|
|
1053
|
+
function splitBySentences(section, text) {
|
|
1054
|
+
const sentences = splitSentences(text);
|
|
1055
|
+
const result = [];
|
|
1056
|
+
let buffer = "";
|
|
1057
|
+
let bufferTokens = 0;
|
|
1058
|
+
const flush = () => {
|
|
1059
|
+
const trimmed = buffer.trim();
|
|
1060
|
+
if (trimmed.length === 0) {
|
|
1061
|
+
buffer = "";
|
|
1062
|
+
bufferTokens = 0;
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
result.push({
|
|
1066
|
+
heading: section.heading,
|
|
1067
|
+
headingLevel: section.headingLevel,
|
|
1068
|
+
content: stripMarkdown(trimmed),
|
|
1069
|
+
rawContent: trimmed,
|
|
1070
|
+
rank: 0
|
|
1071
|
+
});
|
|
1072
|
+
buffer = "";
|
|
1073
|
+
bufferTokens = 0;
|
|
1074
|
+
};
|
|
1075
|
+
for (const sentence of sentences) {
|
|
1076
|
+
const sentTokens = estimateTokens(sentence);
|
|
1077
|
+
if (sentTokens > MAX_CHUNK_TOKENS) {
|
|
1078
|
+
flush();
|
|
1079
|
+
for (const frag of splitByCharLimit(sentence)) result.push({
|
|
1080
|
+
heading: section.heading,
|
|
1081
|
+
headingLevel: section.headingLevel,
|
|
1082
|
+
content: stripMarkdown(frag),
|
|
1083
|
+
rawContent: frag,
|
|
1084
|
+
rank: 0
|
|
1085
|
+
});
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (buffer.length > 0 && bufferTokens + sentTokens > MAX_CHUNK_TOKENS) flush();
|
|
1089
|
+
buffer = buffer.length === 0 ? sentence : `${buffer} ${sentence}`;
|
|
1090
|
+
bufferTokens += sentTokens;
|
|
1091
|
+
}
|
|
1092
|
+
flush();
|
|
1093
|
+
return result.length > 0 ? result : [{
|
|
1094
|
+
heading: section.heading,
|
|
1095
|
+
headingLevel: section.headingLevel,
|
|
1096
|
+
content: section.content,
|
|
1097
|
+
rawContent: section.rawContent,
|
|
1098
|
+
rank: 0
|
|
1099
|
+
}];
|
|
1100
|
+
}
|
|
1101
|
+
/** Split text into sentences using CJK + ASCII punctuation. */
|
|
1102
|
+
function splitSentences(text) {
|
|
1103
|
+
return text.match(/[^。!?!?.\n]+[。!?!?.\n]+|[^。!?!?.\n]+$/g) ?? [text];
|
|
1104
|
+
}
|
|
1105
|
+
/** Hard character cut for pathological single sentences longer than the cap. */
|
|
1106
|
+
function splitByCharLimit(text) {
|
|
1107
|
+
const maxChars = MAX_CHUNK_TOKENS * 4;
|
|
1108
|
+
const chunks = [];
|
|
1109
|
+
let remaining = text.trim();
|
|
1110
|
+
while (remaining.length > maxChars) {
|
|
1111
|
+
chunks.push(remaining.slice(0, maxChars));
|
|
1112
|
+
remaining = remaining.slice(maxChars).trimStart();
|
|
1113
|
+
}
|
|
1114
|
+
if (remaining.length > 0) chunks.push(remaining);
|
|
1115
|
+
return chunks;
|
|
1116
|
+
}
|
|
931
1117
|
//#endregion
|
|
932
1118
|
//#region ../../packages/knowledge/src/extractor.ts
|
|
1119
|
+
/** Max LLM call attempts for extraction — fails fall back to a heading-derived event. */
|
|
1120
|
+
const EXTRACTION_MAX_ATTEMPTS = 3;
|
|
1121
|
+
/** Base delay (ms) for exponential backoff: 100, 200, 400. */
|
|
1122
|
+
const EXTRACTION_BACKOFF_BASE_MS = 100;
|
|
1123
|
+
function backoffDelay(attempt) {
|
|
1124
|
+
return EXTRACTION_BACKOFF_BASE_MS * 2 ** Math.max(0, attempt - 1);
|
|
1125
|
+
}
|
|
1126
|
+
async function callLlmWithBackoff(llm, systemPrompt, userPrompt) {
|
|
1127
|
+
let lastError;
|
|
1128
|
+
for (let attempt = 1; attempt <= EXTRACTION_MAX_ATTEMPTS; attempt += 1) try {
|
|
1129
|
+
return await llm.generate(systemPrompt, userPrompt);
|
|
1130
|
+
} catch (error) {
|
|
1131
|
+
lastError = error;
|
|
1132
|
+
if (attempt < EXTRACTION_MAX_ATTEMPTS) await new Promise((resolve) => setTimeout(resolve, backoffDelay(attempt)));
|
|
1133
|
+
}
|
|
1134
|
+
throw lastError;
|
|
1135
|
+
}
|
|
933
1136
|
const ENTITY_TYPES = [
|
|
934
1137
|
"person",
|
|
935
1138
|
"organization",
|
|
@@ -1104,11 +1307,12 @@ function fallbackEvent(chunk) {
|
|
|
1104
1307
|
}
|
|
1105
1308
|
/**
|
|
1106
1309
|
* Run extraction on a single chunk via the LLM caller.
|
|
1107
|
-
*
|
|
1310
|
+
* Retries with exponential backoff on transient failures; falls back to a
|
|
1311
|
+
* heading-derived event only after all attempts fail.
|
|
1108
1312
|
*/
|
|
1109
1313
|
async function extractEventFromChunk(llm, chunk) {
|
|
1110
1314
|
try {
|
|
1111
|
-
return parseExtractionResponse(await llm
|
|
1315
|
+
return parseExtractionResponse(await callLlmWithBackoff(llm, EXTRACTION_SYSTEM_PROMPT, buildExtractionUserPrompt(chunk)), chunk);
|
|
1112
1316
|
} catch {
|
|
1113
1317
|
return fallbackEvent(chunk);
|
|
1114
1318
|
}
|
|
@@ -1188,7 +1392,8 @@ Only include entities that explicitly appear in the query. If no clear entities,
|
|
|
1188
1392
|
}
|
|
1189
1393
|
//#endregion
|
|
1190
1394
|
//#region ../../packages/knowledge/src/ingest.ts
|
|
1191
|
-
|
|
1395
|
+
/** LLM concurrency for extraction — kept low to avoid rate-limit bursts. */
|
|
1396
|
+
const LLM_CONCURRENCY = 3;
|
|
1192
1397
|
const SUPPORTED_EXTENSIONS = new Set([
|
|
1193
1398
|
".md",
|
|
1194
1399
|
".markdown",
|
|
@@ -1252,9 +1457,9 @@ async function ingestFile(store, llm, filePath, onProgress) {
|
|
|
1252
1457
|
if (engine === void 0 || !engine.available) {
|
|
1253
1458
|
onProgress?.({
|
|
1254
1459
|
stage: "error",
|
|
1255
|
-
message: "
|
|
1460
|
+
message: "向量模型未就绪"
|
|
1256
1461
|
});
|
|
1257
|
-
throw new Error("
|
|
1462
|
+
throw new Error("向量模型未就绪,请检查网络后重试");
|
|
1258
1463
|
}
|
|
1259
1464
|
const existing = await store.findSourceByFilePath(filePath);
|
|
1260
1465
|
if (existing !== void 0) {
|
|
@@ -1262,7 +1467,7 @@ async function ingestFile(store, llm, filePath, onProgress) {
|
|
|
1262
1467
|
stage: "error",
|
|
1263
1468
|
message: `文件已摄入:${existing.name}(source id=${existing.id})`
|
|
1264
1469
|
});
|
|
1265
|
-
throw new Error(
|
|
1470
|
+
throw new Error(`文件已摄入过: ${filePath} (source ${existing.id})`);
|
|
1266
1471
|
}
|
|
1267
1472
|
onProgress?.({
|
|
1268
1473
|
stage: "chunking",
|
|
@@ -1275,7 +1480,7 @@ async function ingestFile(store, llm, filePath, onProgress) {
|
|
|
1275
1480
|
stage: "error",
|
|
1276
1481
|
message: "文件无有效内容"
|
|
1277
1482
|
});
|
|
1278
|
-
throw new Error("
|
|
1483
|
+
throw new Error("文件无有效内容可摄入");
|
|
1279
1484
|
}
|
|
1280
1485
|
store.beginTransaction();
|
|
1281
1486
|
try {
|
|
@@ -1300,9 +1505,9 @@ async function ingestFile(store, llm, filePath, onProgress) {
|
|
|
1300
1505
|
if (chunkEmbeddings === null) {
|
|
1301
1506
|
onProgress?.({
|
|
1302
1507
|
stage: "error",
|
|
1303
|
-
message: "chunk
|
|
1508
|
+
message: "chunk 向量嵌入失败"
|
|
1304
1509
|
});
|
|
1305
|
-
throw new Error("chunk
|
|
1510
|
+
throw new Error("chunk 向量嵌入失败");
|
|
1306
1511
|
}
|
|
1307
1512
|
const chunks = [];
|
|
1308
1513
|
for (let i = 0; i < sections.length; i++) {
|
|
@@ -1351,9 +1556,9 @@ async function ingestFile(store, llm, filePath, onProgress) {
|
|
|
1351
1556
|
if (titleEmbeddings === null || contentEmbeddings === null) {
|
|
1352
1557
|
onProgress?.({
|
|
1353
1558
|
stage: "error",
|
|
1354
|
-
message: "event
|
|
1559
|
+
message: "event 向量嵌入失败"
|
|
1355
1560
|
});
|
|
1356
|
-
throw new Error("event
|
|
1561
|
+
throw new Error("event 向量嵌入失败");
|
|
1357
1562
|
}
|
|
1358
1563
|
const events = [];
|
|
1359
1564
|
for (let i = 0; i < extractedEvents.length; i++) {
|
|
@@ -1394,9 +1599,9 @@ async function ingestFile(store, llm, filePath, onProgress) {
|
|
|
1394
1599
|
if (uniqueEntities.length > 0 && entityEmbeddings === null) {
|
|
1395
1600
|
onProgress?.({
|
|
1396
1601
|
stage: "error",
|
|
1397
|
-
message: "entity
|
|
1602
|
+
message: "entity 向量嵌入失败"
|
|
1398
1603
|
});
|
|
1399
|
-
throw new Error("entity
|
|
1604
|
+
throw new Error("entity 向量嵌入失败");
|
|
1400
1605
|
}
|
|
1401
1606
|
const entityIdByEntityKey = /* @__PURE__ */ new Map();
|
|
1402
1607
|
for (let i = 0; i < uniqueEntities.length; i++) {
|
|
@@ -1431,9 +1636,9 @@ async function ingestFile(store, llm, filePath, onProgress) {
|
|
|
1431
1636
|
if (relationPairs.length > 0 && relationEmbeddings === null) {
|
|
1432
1637
|
onProgress?.({
|
|
1433
1638
|
stage: "error",
|
|
1434
|
-
message: "relation
|
|
1639
|
+
message: "relation 向量嵌入失败"
|
|
1435
1640
|
});
|
|
1436
|
-
throw new Error("relation
|
|
1641
|
+
throw new Error("relation 向量嵌入失败");
|
|
1437
1642
|
}
|
|
1438
1643
|
for (let i = 0; i < relationPairs.length; i++) {
|
|
1439
1644
|
const pair = relationPairs[i];
|
|
@@ -1479,7 +1684,7 @@ async function collectSupportedFiles(dirPath) {
|
|
|
1479
1684
|
}
|
|
1480
1685
|
async function ingestDirectory(store, llm, dirPath, onProgress) {
|
|
1481
1686
|
const files = await collectSupportedFiles(dirPath);
|
|
1482
|
-
if (files.length === 0) throw new Error(
|
|
1687
|
+
if (files.length === 0) throw new Error(`目录下没有支持的文件 (.md, .markdown, .txt): ${dirPath}`);
|
|
1483
1688
|
const errors = [];
|
|
1484
1689
|
let succeeded = 0;
|
|
1485
1690
|
let totalChunks = 0;
|
|
@@ -1534,6 +1739,28 @@ const EXPANDED_EVENT_LIMIT = 100;
|
|
|
1534
1739
|
const COARSE_RANK_LIMIT = 50;
|
|
1535
1740
|
const ENTITY_VECTOR_THRESHOLD = .7;
|
|
1536
1741
|
const TITLE_VECTOR_THRESHOLD = .4;
|
|
1742
|
+
async function timed(onStep, step, detail, fn, payload) {
|
|
1743
|
+
const start = performance.now();
|
|
1744
|
+
try {
|
|
1745
|
+
const result = await fn();
|
|
1746
|
+
const traceStep = {
|
|
1747
|
+
step,
|
|
1748
|
+
detail,
|
|
1749
|
+
durationMs: Math.round((performance.now() - start) * 100) / 100,
|
|
1750
|
+
...payload !== void 0 ? { payload: payload(result) } : {}
|
|
1751
|
+
};
|
|
1752
|
+
onStep?.(traceStep);
|
|
1753
|
+
return result;
|
|
1754
|
+
} catch (error) {
|
|
1755
|
+
const durationMs = Math.round((performance.now() - start) * 100) / 100;
|
|
1756
|
+
onStep?.({
|
|
1757
|
+
step,
|
|
1758
|
+
detail: `${detail} 失败:${error instanceof Error ? error.message : String(error)}`,
|
|
1759
|
+
durationMs
|
|
1760
|
+
});
|
|
1761
|
+
throw error;
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1537
1764
|
/**
|
|
1538
1765
|
* Multi-hop retrieval:
|
|
1539
1766
|
* 1. Vectorize query.
|
|
@@ -1545,62 +1772,155 @@ const TITLE_VECTOR_THRESHOLD = .4;
|
|
|
1545
1772
|
* 6. LLM rerank — pick top-K most relevant.
|
|
1546
1773
|
* 7. Return corresponding chunks (deduped by chunk_id) with scores and provenance.
|
|
1547
1774
|
* If rerank yields fewer than topK chunks, backfill by direct chunk vector search.
|
|
1775
|
+
*
|
|
1776
|
+
* Optional `onStep` callback receives a trace step at each phase — used by the
|
|
1777
|
+
* KnowledgeLookup tool to expose retrieval diagnostics to the agent.
|
|
1548
1778
|
*/
|
|
1549
1779
|
async function multiSearch(store, llm, query, options = {}) {
|
|
1780
|
+
return (await multiSearchWithTrace(store, llm, query, options)).results;
|
|
1781
|
+
}
|
|
1782
|
+
/** Like multiSearch but also returns the retrieval trace for diagnostics. */
|
|
1783
|
+
async function multiSearchWithTrace(store, llm, query, options = {}) {
|
|
1550
1784
|
const topK = Math.min(options.topK ?? DEFAULT_TOP_K, MAX_TOP_K);
|
|
1785
|
+
const steps = [];
|
|
1786
|
+
const onStep = (step) => steps.push(step);
|
|
1787
|
+
const rerankedEventTitles = [];
|
|
1788
|
+
let fallbackReason = null;
|
|
1551
1789
|
const engine = store.getEmbeddingEngine();
|
|
1552
|
-
if (engine === void 0 || !engine.available)
|
|
1553
|
-
|
|
1554
|
-
|
|
1790
|
+
if (engine === void 0 || !engine.available) {
|
|
1791
|
+
fallbackReason = "embedding engine unavailable; used FTS5 keyword fallback";
|
|
1792
|
+
return {
|
|
1793
|
+
results: await ftsFallback(store, query, topK),
|
|
1794
|
+
trace: {
|
|
1795
|
+
steps,
|
|
1796
|
+
rerankedEventTitles,
|
|
1797
|
+
fallbackReason
|
|
1798
|
+
}
|
|
1799
|
+
};
|
|
1800
|
+
}
|
|
1801
|
+
const queryEmbeddings = await timed(onStep, "queryEmbedding", "把用户问题转成向量,用于召回相关事件和切片。", async () => engine.embedBatch([query]));
|
|
1802
|
+
if (queryEmbeddings === null || queryEmbeddings.length === 0) {
|
|
1803
|
+
fallbackReason = "query embedding returned null; used FTS5 fallback";
|
|
1804
|
+
return {
|
|
1805
|
+
results: await ftsFallback(store, query, topK),
|
|
1806
|
+
trace: {
|
|
1807
|
+
steps,
|
|
1808
|
+
rerankedEventTitles,
|
|
1809
|
+
fallbackReason
|
|
1810
|
+
}
|
|
1811
|
+
};
|
|
1812
|
+
}
|
|
1555
1813
|
const queryVec = queryEmbeddings[0];
|
|
1556
|
-
const recalledEntities = await recallEntities(store, llm, query, queryVec);
|
|
1814
|
+
const recalledEntities = await timed(onStep, "entityRecall", "LLM 抽 query 实体 + 名字精确匹配 + 向量召回。", () => recallEntities(store, llm, query, queryVec), (entities) => ({ count: entities.length }));
|
|
1557
1815
|
const seedEventIds = /* @__PURE__ */ new Set();
|
|
1558
1816
|
for (const entity of recalledEntities) {
|
|
1559
1817
|
const events = await store.findEventsByEntity(entity.id);
|
|
1560
1818
|
for (const event of events) seedEventIds.add(event.id);
|
|
1561
1819
|
}
|
|
1562
|
-
const titleMatches = await store.findEventsByTitleVector(queryVec, {
|
|
1820
|
+
const titleMatches = await timed(onStep, "seedEventsByTitle", "按查询向量在事件标题向量上召回 seed events。", () => store.findEventsByTitleVector(queryVec, {
|
|
1563
1821
|
limit: SEED_EVENT_LIMIT,
|
|
1564
1822
|
threshold: TITLE_VECTOR_THRESHOLD
|
|
1565
|
-
});
|
|
1823
|
+
}), (matches) => ({ count: matches.length }));
|
|
1566
1824
|
for (const { event } of titleMatches) seedEventIds.add(event.id);
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1825
|
+
if (seedEventIds.size === 0) {
|
|
1826
|
+
fallbackReason = "no seed events; used direct chunk vector search";
|
|
1827
|
+
return {
|
|
1828
|
+
results: await chunkVectorFallback(store, queryVec, topK),
|
|
1829
|
+
trace: {
|
|
1830
|
+
steps,
|
|
1831
|
+
rerankedEventTitles,
|
|
1832
|
+
fallbackReason
|
|
1833
|
+
}
|
|
1834
|
+
};
|
|
1835
|
+
}
|
|
1836
|
+
const expandedEventIds = await timed(onStep, "bfsExpand", "从 seed events 沿实体关系 1 跳扩展候选事件。", async () => {
|
|
1837
|
+
const ids = new Set(seedEventIds);
|
|
1838
|
+
let capped = false;
|
|
1839
|
+
for (const eventId of seedEventIds) {
|
|
1840
|
+
const entities = await store.findEntitiesByEvent(eventId);
|
|
1841
|
+
for (const entity of entities) {
|
|
1842
|
+
const neighborEvents = await store.findEventsByEntity(entity.id);
|
|
1843
|
+
for (const neighbor of neighborEvents) {
|
|
1844
|
+
ids.add(neighbor.id);
|
|
1845
|
+
if (ids.size >= EXPANDED_EVENT_LIMIT) {
|
|
1846
|
+
capped = true;
|
|
1847
|
+
break;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
if (ids.size >= EXPANDED_EVENT_LIMIT) break;
|
|
1575
1851
|
}
|
|
1576
|
-
if (
|
|
1852
|
+
if (ids.size >= EXPANDED_EVENT_LIMIT) break;
|
|
1577
1853
|
}
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1854
|
+
return {
|
|
1855
|
+
ids,
|
|
1856
|
+
capped
|
|
1857
|
+
};
|
|
1858
|
+
}, (result) => ({
|
|
1859
|
+
expandedCount: result.ids.size,
|
|
1860
|
+
capped: result.capped
|
|
1861
|
+
}));
|
|
1862
|
+
const { candidates, topCandidates } = await timed(onStep, "coarseRank", "按 content 向量相似度对候选事件排序,取前 COARSE_RANK_LIMIT 个。", async () => {
|
|
1863
|
+
const cands = [];
|
|
1864
|
+
for (const eventId of expandedEventIds.ids) {
|
|
1865
|
+
const event = await store.getEvent(eventId);
|
|
1866
|
+
if (event === void 0) continue;
|
|
1867
|
+
const vec = event.contentEmbedding;
|
|
1868
|
+
const score = vec === null ? 0 : engine.cosineSimilarity(queryVec, vec);
|
|
1869
|
+
cands.push({
|
|
1870
|
+
id: event.id,
|
|
1871
|
+
title: event.title,
|
|
1872
|
+
summary: event.summary ?? "",
|
|
1873
|
+
score,
|
|
1874
|
+
chunkId: event.chunkId
|
|
1875
|
+
});
|
|
1876
|
+
}
|
|
1877
|
+
cands.sort((a, b) => b.score - a.score);
|
|
1878
|
+
return {
|
|
1879
|
+
candidates: cands,
|
|
1880
|
+
topCandidates: cands.slice(0, COARSE_RANK_LIMIT)
|
|
1881
|
+
};
|
|
1882
|
+
}, (result) => ({
|
|
1883
|
+
totalCandidates: result.candidates.length,
|
|
1884
|
+
kept: result.topCandidates.length,
|
|
1885
|
+
topScore: result.topCandidates[0]?.score ?? 0
|
|
1886
|
+
}));
|
|
1887
|
+
if (candidates.length === 0) {
|
|
1888
|
+
fallbackReason = "no graph-reachable events with content; used chunk vector fallback";
|
|
1889
|
+
return {
|
|
1890
|
+
results: await chunkVectorFallback(store, queryVec, topK),
|
|
1891
|
+
trace: {
|
|
1892
|
+
steps,
|
|
1893
|
+
rerankedEventTitles,
|
|
1894
|
+
fallbackReason
|
|
1895
|
+
}
|
|
1896
|
+
};
|
|
1593
1897
|
}
|
|
1594
|
-
if (candidates.length === 0) return chunkVectorFallback(store, queryVec, topK);
|
|
1595
|
-
candidates.sort((a, b) => b.score - a.score);
|
|
1596
|
-
const topCandidates = candidates.slice(0, COARSE_RANK_LIMIT);
|
|
1597
1898
|
let rankedIds;
|
|
1598
|
-
if (options.skipRerank === true
|
|
1599
|
-
|
|
1899
|
+
if (options.skipRerank === true) {
|
|
1900
|
+
rankedIds = topCandidates.map((c) => c.id);
|
|
1901
|
+
onStep({
|
|
1902
|
+
step: "rerank",
|
|
1903
|
+
detail: "跳过 LLM rerank(skipRerank=true),直接用 coarse rank 顺序。",
|
|
1904
|
+
durationMs: 0,
|
|
1905
|
+
payload: { count: rankedIds.length }
|
|
1906
|
+
});
|
|
1907
|
+
} else if (topCandidates.length <= topK) {
|
|
1908
|
+
rankedIds = topCandidates.map((c) => c.id);
|
|
1909
|
+
onStep({
|
|
1910
|
+
step: "rerank",
|
|
1911
|
+
detail: `候选数 ${topCandidates.length} ≤ topK ${topK},无需 LLM rerank。`,
|
|
1912
|
+
durationMs: 0,
|
|
1913
|
+
payload: { count: rankedIds.length }
|
|
1914
|
+
});
|
|
1915
|
+
} else rankedIds = await timed(onStep, "rerank", `LLM 从 ${topCandidates.length} 个候选中选最相关的 ${topK} 个。`, () => rerankEventsWithLlm(llm, query, topCandidates.map((c) => ({
|
|
1600
1916
|
id: c.id,
|
|
1601
1917
|
title: c.title,
|
|
1602
1918
|
summary: c.summary
|
|
1603
|
-
})), topK);
|
|
1919
|
+
})), topK), (ids) => ({ count: ids.length }));
|
|
1920
|
+
for (const id of rankedIds) {
|
|
1921
|
+
const c = topCandidates.find((x) => x.id === id);
|
|
1922
|
+
if (c !== void 0) rerankedEventTitles.push(c.title);
|
|
1923
|
+
}
|
|
1604
1924
|
const seen = /* @__PURE__ */ new Set();
|
|
1605
1925
|
const results = [];
|
|
1606
1926
|
for (const eventId of rankedIds) {
|
|
@@ -1612,17 +1932,29 @@ async function multiSearch(store, llm, query, options = {}) {
|
|
|
1612
1932
|
if (result !== void 0) results.push(result);
|
|
1613
1933
|
if (results.length >= topK) break;
|
|
1614
1934
|
}
|
|
1615
|
-
if (results.length < topK)
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1935
|
+
if (results.length < topK) {
|
|
1936
|
+
let supplemented = 0;
|
|
1937
|
+
for (const candidate of topCandidates) {
|
|
1938
|
+
if (results.length >= topK) break;
|
|
1939
|
+
if (rankedIds.includes(candidate.id)) continue;
|
|
1940
|
+
if (seen.has(candidate.chunkId)) continue;
|
|
1941
|
+
seen.add(candidate.chunkId);
|
|
1942
|
+
const result = await store.buildSearchResult(candidate.chunkId, candidate.score, candidate.id);
|
|
1943
|
+
if (result !== void 0) {
|
|
1944
|
+
results.push(result);
|
|
1945
|
+
supplemented += 1;
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
if (supplemented > 0) onStep({
|
|
1949
|
+
step: "supplementFromCoarse",
|
|
1950
|
+
detail: `rerank 结果不足,从 coarse rank 残余补 ${supplemented} 个。`,
|
|
1951
|
+
durationMs: 0,
|
|
1952
|
+
payload: { supplemented }
|
|
1953
|
+
});
|
|
1622
1954
|
}
|
|
1623
1955
|
if (results.length < topK) {
|
|
1624
1956
|
const remaining = topK - results.length;
|
|
1625
|
-
const backfill = await store.searchChunksByVector(queryVec, { limit: remaining * 2 });
|
|
1957
|
+
const backfill = await timed(onStep, "backfill", `rerank 结果不足,回退到 chunk 向量搜索补 ${remaining} 个。`, () => store.searchChunksByVector(queryVec, { limit: remaining * 2 }), (matches) => ({ backfillCandidates: matches.length }));
|
|
1626
1958
|
for (const { chunk, score } of backfill) {
|
|
1627
1959
|
if (results.length >= topK) break;
|
|
1628
1960
|
if (seen.has(chunk.id)) continue;
|
|
@@ -1631,7 +1963,14 @@ async function multiSearch(store, llm, query, options = {}) {
|
|
|
1631
1963
|
if (result !== void 0) results.push(result);
|
|
1632
1964
|
}
|
|
1633
1965
|
}
|
|
1634
|
-
return
|
|
1966
|
+
return {
|
|
1967
|
+
results,
|
|
1968
|
+
trace: {
|
|
1969
|
+
steps,
|
|
1970
|
+
rerankedEventTitles,
|
|
1971
|
+
fallbackReason
|
|
1972
|
+
}
|
|
1973
|
+
};
|
|
1635
1974
|
}
|
|
1636
1975
|
/** Recall entities by name (LLM-extracted) and by vector similarity. */
|
|
1637
1976
|
async function recallEntities(store, llm, query, queryVec) {
|
|
@@ -1671,4 +2010,4 @@ async function chunkVectorFallback(store, queryVec, topK) {
|
|
|
1671
2010
|
return results;
|
|
1672
2011
|
}
|
|
1673
2012
|
//#endregion
|
|
1674
|
-
export {
|
|
2013
|
+
export { join as C, resolve as D, relative as E, isAbsolute as S, parse as T, splitLargeSection as _, isSupportedFile as a, basename as b, buildExtractionUserPrompt as c, extractQueryEntities as d, parseExtractionResponse as f, estimateTokens as g, chunkText as h, ingestFile as i, extractEventFromChunk as l, chunkMarkdown as m, multiSearchWithTrace as n, ENTITY_TYPES as o, rerankEventsWithLlm as p, ingestDirectory as r, EXTRACTION_SYSTEM_PROMPT as s, multiSearch as t, extractJsonFromText as u, stripMarkdown as v, normalize as w, dirname as x, KnowledgeStore as y };
|
|
@@ -3,5 +3,5 @@ import { fileURLToPath as __cjsShimFileURLToPath } from 'node:url';
|
|
|
3
3
|
import { dirname as __cjsShimDirname } from 'node:path';
|
|
4
4
|
const __filename = __cjsShimFileURLToPath(import.meta.url);
|
|
5
5
|
const __dirname = __cjsShimDirname(__filename);
|
|
6
|
-
import {
|
|
7
|
-
export {
|
|
6
|
+
import { n as multiSearchWithTrace } from "./src-CptHxtUY.mjs";
|
|
7
|
+
export { multiSearchWithTrace };
|