feat(recall): add diffusion stage and improve retrieval metrics

This commit is contained in:
2026-02-12 15:36:07 +08:00
parent 111cd081f6
commit a646a70224
6 changed files with 1084 additions and 61 deletions

View File

@@ -43,18 +43,22 @@ function canNotifyRecallFail() {
// 预算常量
// ─────────────────────────────────────────────────────────────────────────────
const MAIN_BUDGET_MAX = 10000;
const DISTANT_EVIDENCE_MAX = 2500;
const RECENT_EVIDENCE_MAX = 5000;
const TOTAL_BUDGET_MAX = 15000;
const SHARED_POOL_MAX = 10000;
const CONSTRAINT_MAX = 2000;
const ARCS_MAX = 1500;
const EVENT_BUDGET_MAX = 5000;
const RELATED_EVENT_MAX = 1000;
const SUMMARIZED_EVIDENCE_MAX = 1500;
const UNSUMMARIZED_EVIDENCE_MAX = 5000;
const TOP_N_STAR = 5;
// 邻近补挂:未被事件消费的 L0如果距最近事件 ≤ 此值则补挂
const NEARBY_FLOOR_TOLERANCE = 2;
// L0 显示文本:分号拼接 vs 多行模式的阈值
const L0_JOINED_MAX_LENGTH = 120;
// 背景证据实体过滤旁通阈值(与事件过滤策略一致)
const EVIDENCE_ENTITY_BYPASS_SIM = 0.80;
// 背景证据:无实体匹配时保留的最低相似度(与 recall.js CONFIG.EVENT_ENTITY_BYPASS_SIM 保持一致)
const EVIDENCE_ENTITY_BYPASS_SIM = 0.70;
// ─────────────────────────────────────────────────────────────────────────────
// 工具函数
@@ -157,7 +161,7 @@ function collectL0Entities(l0) {
* 背景证据是否保留(按焦点实体过滤)
* 规则:
* 1) 无焦点实体:保留
* 2) similarity >= 0.80保留旁通
* 2) similarity >= 0.70保留旁通
* 3) who/edges 命中焦点实体:保留
* 4) 兼容旧数据semantic 文本包含焦点实体:保留
* 否则过滤。
@@ -361,7 +365,7 @@ function formatArcLine(arc) {
*/
function buildL0DisplayText(l0) {
const atom = l0.atom || {};
return String(atom.scene || atom.semantic || l0.text || '').trim() || '(未知锚点)';
return String(atom.semantic || l0.text || '').trim() || '(未知锚点)';
}
/**
@@ -474,15 +478,15 @@ function buildEvidenceGroup(floor, l0AtomsForFloor, l1ByFloor) {
* 格式化一个证据组为文本行数组
*
* 短行模式(拼接后 ≤ 120 字):
* #500 [📌] 薇薇保持跪趴姿势;薇薇展示细节;薇薇与蓝袖之间:被审视
* ┌ #499 [蓝袖] ...
* #500 [📌] 小林整理会议记录;小周补充行动项;两人确认下周安排
* ┌ #499 [小周] ...
* #500 [角色] ...
*
* 长行模式(拼接后 > 120 字):
* #500 [📌] 薇薇保持跪趴姿势 在书房
* │ 薇薇展示肛周细节 在书房
* │ 薇薇与蓝袖之间:身体被审视 在书房
* ┌ #499 [蓝袖] ...
* #500 [📌] 小林在图书馆归档旧资料
* │ 小周核对目录并修正编号
* │ 两人讨论借阅规则并更新说明
* ┌ #499 [小周] ...
* #500 [角色] ...
*
* @param {EvidenceGroup} group - 证据组
@@ -705,7 +709,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
const T_Start = performance.now();
const data = store.json || {};
const total = { used: 0, max: MAIN_BUDGET_MAX };
const total = { used: 0, max: SHARED_POOL_MAX };
// 从 recallResult 解构
const l0Selected = recallResult?.l0Selected || [];
@@ -723,7 +727,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
// 注入统计
const injectionStats = {
budget: { max: TOTAL_BUDGET_MAX, used: 0 },
budget: { max: SHARED_POOL_MAX + UNSUMMARIZED_EVIDENCE_MAX, used: 0 },
constraint: { count: 0, tokens: 0, filtered: 0 },
arc: { count: 0, tokens: 0 },
event: { selected: 0, tokens: 0 },
@@ -817,6 +821,8 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
const eventHits = (recallResult?.events || []).filter(e => e?.event?.summary);
const candidates = [...eventHits].sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
const eventBudget = { used: 0, max: Math.min(EVENT_BUDGET_MAX, total.max - total.used) };
const relatedBudget = { used: 0, max: RELATED_EVENT_MAX };
const selectedDirect = [];
const selectedRelated = [];
@@ -825,8 +831,10 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
const e = candidates[candidateRank];
if (total.used >= total.max) break;
if (eventBudget.used >= eventBudget.max) break;
const isDirect = e._recallType === "DIRECT";
if (!isDirect && relatedBudget.used >= relatedBudget.max) continue;
// 收集该事件范围内的 EvidenceGroupper-floor
const evidenceGroups = collectEvidenceGroupsForEvent(e.event, l0Selected, l1ByFloor, usedL0Ids);
@@ -873,6 +881,8 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
injectionStats.event.selected++;
injectionStats.event.tokens += costNoEvidence;
total.used += costNoEvidence;
eventBudget.used += costNoEvidence;
if (!isDirect) relatedBudget.used += costNoEvidence;
eventDetails.list.push({
title: e.event?.title || e.event?.id,
@@ -912,6 +922,8 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
injectionStats.evidence.l0InEvents += l0Count;
injectionStats.evidence.l1InEvents += l1FloorCount;
total.used += cost;
eventBudget.used += cost;
if (!isDirect) relatedBudget.used += cost;
eventDetails.list.push({
title: e.event?.title || e.event?.id,
@@ -928,6 +940,73 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
selectedDirect.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event));
selectedRelated.sort((a, b) => getEventSortKey(a.event) - getEventSortKey(b.event));
// ═══════════════════════════════════════════════════════════════════
// 邻近补挂:未被事件消费的 L0距最近已选事件 ≤ 2 楼则补挂
// 每个 L0 只挂最近的一个事件,不扩展事件范围,不产生重叠
// ═══════════════════════════════════════════════════════════════════
const allSelectedItems = [...selectedDirect, ...selectedRelated];
const nearbyByItem = new Map();
for (const l0 of l0Selected) {
if (usedL0Ids.has(l0.id)) continue;
let bestItem = null;
let bestDistance = Infinity;
for (const item of allSelectedItems) {
const range = parseFloorRange(item.event?.summary);
if (!range) continue;
let distance;
if (l0.floor < range.start) distance = range.start - l0.floor;
else if (l0.floor > range.end) distance = l0.floor - range.end;
else continue;
if (distance <= NEARBY_FLOOR_TOLERANCE && distance <= bestDistance) {
bestDistance = distance;
bestItem = item;
}
}
if (bestItem) {
if (!nearbyByItem.has(bestItem)) nearbyByItem.set(bestItem, []);
nearbyByItem.get(bestItem).push(l0);
usedL0Ids.add(l0.id);
}
}
for (const [item, nearbyL0s] of nearbyByItem) {
const floorMap = groupL0ByFloor(nearbyL0s);
for (const [floor, l0s] of floorMap) {
const group = buildEvidenceGroup(floor, l0s, l1ByFloor);
item.evidenceGroups.push(group);
}
item.evidenceGroups.sort((a, b) => a.floor - b.floor);
const newText = formatEventWithEvidence(item.event, 0, item.evidenceGroups, causalById);
const newTokens = estimateTokens(newText);
const delta = newTokens - item.tokens;
if (total.used + delta > total.max) {
for (const l0 of nearbyL0s) usedL0Ids.delete(l0.id);
continue;
}
total.used += delta;
eventBudget.used += delta;
const isDirect = selectedDirect.includes(item);
if (!isDirect) relatedBudget.used += delta;
injectionStats.evidence.l0InEvents += nearbyL0s.length;
item.text = newText;
item.tokens = newTokens;
injectionStats.event.tokens += delta;
}
// 重新编号 + 星标
const directEventTexts = selectedDirect.map((it, i) => {
const numbered = renumberEventText(it.text, i + 1);
@@ -964,7 +1043,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
const distantL0 = remainingL0.filter(l0 => l0.floor <= lastSummarized);
if (distantL0.length && total.used < total.max) {
const distantBudget = { used: 0, max: Math.min(DISTANT_EVIDENCE_MAX, total.max - total.used) };
const distantBudget = { used: 0, max: Math.min(SUMMARIZED_EVIDENCE_MAX, total.max - total.used) };
// 按楼层排序(时间顺序)后分组
distantL0.sort((a, b) => a.floor - b.floor);
@@ -1006,7 +1085,7 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
.filter(l0 => l0.floor >= recentStart && l0.floor <= recentEnd);
if (recentL0.length) {
const recentBudget = { used: 0, max: RECENT_EVIDENCE_MAX };
const recentBudget = { used: 0, max: UNSUMMARIZED_EVIDENCE_MAX };
// 按楼层排序后分组
recentL0.sort((a, b) => a.floor - b.floor);
@@ -1051,10 +1130,10 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
sections.push(`[好像有关的事] 听说过或有点模糊\n\n${assembled.relatedEvents.lines.join("\n\n")}`);
}
if (assembled.distantEvidence.lines.length) {
sections.push(`[更早以前] 记忆里残留的老画面\n${assembled.distantEvidence.lines.join("\n")}`);
sections.push(`[零散记忆] 没归入事件的片段\n${assembled.distantEvidence.lines.join("\n")}`);
}
if (assembled.recentEvidence.lines.length) {
sections.push(`[近期] 清晰但还没整理\n${assembled.recentEvidence.lines.join("\n")}`);
sections.push(`[新鲜记忆] 还没总结的部分\n${assembled.recentEvidence.lines.join("\n")}`);
}
if (assembled.arcs.lines.length) {
sections.push(`[这些人] 他们的弧光\n${assembled.arcs.lines.join("\n")}`);
@@ -1085,9 +1164,11 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities,
metrics.formatting.time = Math.round(performance.now() - T_Format_Start);
metrics.timing.formatting = metrics.formatting.time;
metrics.budget.total = total.used + (assembled.recentEvidence.tokens || 0);
metrics.budget.limit = TOTAL_BUDGET_MAX;
metrics.budget.utilization = Math.round(metrics.budget.total / TOTAL_BUDGET_MAX * 100);
const effectiveTotal = total.used + (assembled.recentEvidence.tokens || 0);
const effectiveLimit = SHARED_POOL_MAX + UNSUMMARIZED_EVIDENCE_MAX;
metrics.budget.total = effectiveTotal;
metrics.budget.limit = effectiveLimit;
metrics.budget.utilization = Math.round(effectiveTotal / effectiveLimit * 100);
metrics.budget.breakdown = {
constraints: assembled.constraints.tokens,
events: injectionStats.event.tokens,