Files
LittleWhiteBox/modules/story-summary/vector/retrieval/recall.js

825 lines
31 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ═══════════════════════════════════════════════════════════════════════════
// Story Summary - Recall Engine (v5 - 统一命名)
//
// 命名规范:
// - 存储层用 L0/L1/L2/L3StateAtom/Chunk/Event/Fact
// - 召回层用语义名称anchor/evidence/event/constraint
// ═══════════════════════════════════════════════════════════════════════════
import { getAllEventVectors, getChunksByFloors, getMeta, getChunkVectorsByIds } from '../storage/chunk-store.js';
import { getAllStateVectors, getStateAtoms } from '../storage/state-store.js';
import { getEngineFingerprint, embed } from '../utils/embedder.js';
import { xbLog } from '../../../../core/debug-core.js';
import { getContext } from '../../../../../../../extensions.js';
import { filterText } from '../utils/text-filter.js';
import { expandQueryCached, buildSearchText } from '../llm/query-expansion.js';
import { rerankChunks } from '../llm/reranker.js';
import { createMetrics, calcSimilarityStats } from './metrics.js';
const MODULE_ID = 'recall';
// ═══════════════════════════════════════════════════════════════════════════
// 配置
// ═══════════════════════════════════════════════════════════════════════════
const CONFIG = {
// Query Expansion
QUERY_EXPANSION_TIMEOUT: 6000,
// Anchor (L0 StateAtoms) 配置
ANCHOR_MIN_SIMILARITY: 0.58,
// Evidence (L1 Chunks) 粗筛配置
EVIDENCE_COARSE_MAX: 100,
// Event (L2 Events) 配置
EVENT_CANDIDATE_MAX: 100,
EVENT_SELECT_MAX: 50,
EVENT_MIN_SIMILARITY: 0.55,
EVENT_MMR_LAMBDA: 0.72,
// Rerank 配置
RERANK_THRESHOLD: 80,
RERANK_TOP_N: 50,
RERANK_MIN_SCORE: 0.15,
// 因果链
CAUSAL_CHAIN_MAX_DEPTH: 10,
CAUSAL_INJECT_MAX: 30,
};
// ═══════════════════════════════════════════════════════════════════════════
// 工具函数
// ═══════════════════════════════════════════════════════════════════════════
/**
* 计算余弦相似度
* @param {number[]} a - 向量A
* @param {number[]} b - 向量B
* @returns {number} 相似度 [0, 1]
*/
function cosineSimilarity(a, b) {
if (!a?.length || !b?.length || a.length !== b.length) return 0;
let dot = 0, nA = 0, nB = 0;
for (let i = 0; i < a.length; i++) {
dot += a[i] * b[i];
nA += a[i] * a[i];
nB += b[i] * b[i];
}
return nA && nB ? dot / (Math.sqrt(nA) * Math.sqrt(nB)) : 0;
}
/**
* 标准化字符串(用于实体匹配)
* @param {string} s - 输入字符串
* @returns {string} 标准化后的字符串
*/
function normalize(s) {
return String(s || '')
.normalize('NFKC')
.replace(/[\u200B-\u200D\uFEFF]/g, '')
.trim()
.toLowerCase();
}
/**
* 清理文本用于召回
* @param {string} text - 原始文本
* @returns {string} 清理后的文本
*/
function cleanForRecall(text) {
return filterText(text).replace(/\[tts:[^\]]*\]/gi, '').trim();
}
/**
* 从 focus entities 中移除用户名
* @param {string[]} focusEntities - 焦点实体列表
* @param {string} userName - 用户名
* @returns {string[]} 过滤后的实体列表
*/
function removeUserNameFromFocus(focusEntities, userName) {
const u = normalize(userName);
if (!u) return Array.isArray(focusEntities) ? focusEntities : [];
return (focusEntities || [])
.map(e => String(e || '').trim())
.filter(Boolean)
.filter(e => normalize(e) !== u);
}
/**
* 构建 rerank 查询文本
* @param {object} expansion - query expansion 结果
* @param {object[]} lastMessages - 最近消息
* @param {string} pendingUserMessage - 待发送的用户消息
* @returns {string} 查询文本
*/
function buildRerankQuery(expansion, lastMessages, pendingUserMessage) {
const parts = [];
if (expansion?.focus?.length) {
parts.push(expansion.focus.join(' '));
}
if (expansion?.queries?.length) {
parts.push(...expansion.queries.slice(0, 3));
}
const recentTexts = (lastMessages || [])
.slice(-2)
.map(m => cleanForRecall(m.mes || '').slice(0, 150))
.filter(Boolean);
if (recentTexts.length) {
parts.push(...recentTexts);
}
if (pendingUserMessage) {
parts.push(cleanForRecall(pendingUserMessage).slice(0, 200));
}
return parts.filter(Boolean).join('\n').slice(0, 1500);
}
// ═══════════════════════════════════════════════════════════════════════════
// MMR 选择算法
// ═══════════════════════════════════════════════════════════════════════════
/**
* Maximal Marginal Relevance 选择
* @param {object[]} candidates - 候选项
* @param {number} k - 选择数量
* @param {number} lambda - 相关性/多样性权衡参数
* @param {Function} getVector - 获取向量的函数
* @param {Function} getScore - 获取分数的函数
* @returns {object[]} 选中的候选项
*/
function mmrSelect(candidates, k, lambda, getVector, getScore) {
const selected = [];
const ids = new Set();
while (selected.length < k && candidates.length) {
let best = null;
let bestScore = -Infinity;
for (const c of candidates) {
if (ids.has(c._id)) continue;
const rel = getScore(c);
let div = 0;
if (selected.length) {
const vC = getVector(c);
if (vC?.length) {
for (const s of selected) {
const sim = cosineSimilarity(vC, getVector(s));
if (sim > div) div = sim;
}
}
}
const score = lambda * rel - (1 - lambda) * div;
if (score > bestScore) {
bestScore = score;
best = c;
}
}
if (!best) break;
selected.push(best);
ids.add(best._id);
}
return selected;
}
// ═══════════════════════════════════════════════════════════════════════════
// [Anchors] L0 StateAtoms 检索
// ═══════════════════════════════════════════════════════════════════════════
/**
* 检索语义锚点L0 StateAtoms
* @param {number[]} queryVector - 查询向量
* @param {object} vectorConfig - 向量配置
* @param {object} metrics - 指标对象
* @returns {Promise<{hits: object[], floors: Set<number>}>}
*/
async function recallAnchors(queryVector, vectorConfig, metrics) {
const { chatId } = getContext();
if (!chatId || !queryVector?.length) {
return { hits: [], floors: new Set() };
}
const meta = await getMeta(chatId);
const fp = getEngineFingerprint(vectorConfig);
if (meta.fingerprint && meta.fingerprint !== fp) {
xbLog.warn(MODULE_ID, 'Anchor fingerprint 不匹配');
return { hits: [], floors: new Set() };
}
const stateVectors = await getAllStateVectors(chatId);
if (!stateVectors.length) {
return { hits: [], floors: new Set() };
}
const atomsList = getStateAtoms();
const atomMap = new Map(atomsList.map(a => [a.atomId, a]));
// 按阈值过滤,不设硬上限
const scored = stateVectors
.map(sv => {
const atom = atomMap.get(sv.atomId);
if (!atom) return null;
return {
atomId: sv.atomId,
floor: sv.floor,
similarity: cosineSimilarity(queryVector, sv.vector),
atom,
};
})
.filter(Boolean)
.filter(s => s.similarity >= CONFIG.ANCHOR_MIN_SIMILARITY)
.sort((a, b) => b.similarity - a.similarity);
const floors = new Set(scored.map(s => s.floor));
if (metrics) {
metrics.anchor.matched = scored.length;
metrics.anchor.floorsHit = floors.size;
metrics.anchor.topHits = scored.slice(0, 5).map(s => ({
floor: s.floor,
semantic: s.atom?.semantic?.slice(0, 50),
similarity: Math.round(s.similarity * 1000) / 1000,
}));
}
return { hits: scored, floors };
}
// ═══════════════════════════════════════════════════════════════════════════
// [Evidence] L1 Chunks 拉取 + 粗筛 + Rerank
// ═══════════════════════════════════════════════════════════════════════════
/**
* 统计 evidence 类型构成
* @param {object[]} chunks - chunk 列表
* @returns {{anchorVirtual: number, chunkReal: number}}
*/
function countEvidenceByType(chunks) {
let anchorVirtual = 0;
let chunkReal = 0;
for (const c of chunks || []) {
if (c.isAnchorVirtual) {
anchorVirtual++;
} else {
chunkReal++;
}
}
return { anchorVirtual, chunkReal };
}
/**
* 根据锚点命中楼层拉取证据L1 Chunks
* @param {Set<number>} anchorFloors - 锚点命中的楼层
* @param {object[]} anchorHits - 锚点命中结果
* @param {number[]} queryVector - 查询向量
* @param {string} queryText - rerank 查询文本
* @param {object} metrics - 指标对象
* @returns {Promise<object[]>} 证据 chunks
*/
async function pullEvidenceByFloors(anchorFloors, anchorHits, queryVector, queryText, metrics) {
const { chatId } = getContext();
if (!chatId || !anchorFloors.size) {
return [];
}
const floorArray = Array.from(anchorFloors);
// 1. 构建锚点虚拟 chunks来自 L0 StateAtoms
const anchorVirtualChunks = (anchorHits || []).map(a => ({
chunkId: `anchor-${a.atomId}`,
floor: a.floor,
chunkIdx: -1,
speaker: '📌',
isUser: false,
text: a.atom?.semantic || '',
similarity: a.similarity,
isAnchorVirtual: true,
_atom: a.atom,
}));
// 2. 拉取真实 chunks来自 L1
let dbChunks = [];
try {
dbChunks = await getChunksByFloors(chatId, floorArray);
} catch (e) {
xbLog.warn(MODULE_ID, '从 DB 拉取 chunks 失败', e);
}
// 3. L1 向量粗筛
let coarseFiltered = [];
if (dbChunks.length > 0 && queryVector?.length) {
const chunkIds = dbChunks.map(c => c.chunkId);
let chunkVectors = [];
try {
chunkVectors = await getChunkVectorsByIds(chatId, chunkIds);
} catch (e) {
xbLog.warn(MODULE_ID, 'L1 向量获取失败', e);
}
const vectorMap = new Map(chunkVectors.map(v => [v.chunkId, v.vector]));
coarseFiltered = dbChunks
.map(c => {
const vec = vectorMap.get(c.chunkId);
if (!vec?.length) return null;
return {
...c,
isAnchorVirtual: false,
similarity: cosineSimilarity(queryVector, vec),
};
})
.filter(Boolean)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, CONFIG.EVIDENCE_COARSE_MAX);
}
// 4. 合并
const allEvidence = [...anchorVirtualChunks, ...coarseFiltered];
// 更新 metrics
if (metrics) {
metrics.evidence.floorsFromAnchors = floorArray.length;
metrics.evidence.chunkTotal = dbChunks.length;
metrics.evidence.chunkAfterCoarse = coarseFiltered.length;
metrics.evidence.merged = allEvidence.length;
metrics.evidence.mergedByType = countEvidenceByType(allEvidence);
}
// 5. 是否需要 Rerank
if (allEvidence.length <= CONFIG.RERANK_THRESHOLD) {
if (metrics) {
metrics.evidence.rerankApplied = false;
metrics.evidence.selected = allEvidence.length;
metrics.evidence.selectedByType = countEvidenceByType(allEvidence);
}
return allEvidence;
}
// 6. Rerank 精排
const T_Rerank_Start = performance.now();
const reranked = await rerankChunks(queryText, allEvidence, {
topN: CONFIG.RERANK_TOP_N,
minScore: CONFIG.RERANK_MIN_SCORE,
});
const rerankTime = Math.round(performance.now() - T_Rerank_Start);
if (metrics) {
metrics.evidence.rerankApplied = true;
metrics.evidence.beforeRerank = allEvidence.length;
metrics.evidence.afterRerank = reranked.length;
metrics.evidence.selected = reranked.length;
metrics.evidence.selectedByType = countEvidenceByType(reranked);
metrics.evidence.rerankTime = rerankTime;
metrics.timing.evidenceRerank = rerankTime;
const scores = reranked.map(c => c._rerankScore || 0).filter(s => s > 0);
if (scores.length > 0) {
scores.sort((a, b) => a - b);
metrics.evidence.rerankScores = {
min: Number(scores[0].toFixed(3)),
max: Number(scores[scores.length - 1].toFixed(3)),
mean: Number((scores.reduce((a, b) => a + b, 0) / scores.length).toFixed(3)),
};
}
}
xbLog.info(MODULE_ID, `Evidence: ${dbChunks.length} L1 → ${coarseFiltered.length} coarse → ${reranked.length} rerank (${rerankTime}ms)`);
return reranked;
}
// ═══════════════════════════════════════════════════════════════════════════
// [Events] L2 Events 检索
// ═══════════════════════════════════════════════════════════════════════════
/**
* 检索事件L2 Events
* @param {number[]} queryVector - 查询向量
* @param {object[]} allEvents - 所有事件
* @param {object} vectorConfig - 向量配置
* @param {string[]} focusEntities - 焦点实体
* @param {object} metrics - 指标对象
* @returns {Promise<object[]>} 事件命中结果
*/
async function recallEvents(queryVector, allEvents, vectorConfig, focusEntities, metrics) {
const { chatId } = getContext();
if (!chatId || !queryVector?.length || !allEvents?.length) {
return [];
}
const meta = await getMeta(chatId);
const fp = getEngineFingerprint(vectorConfig);
if (meta.fingerprint && meta.fingerprint !== fp) {
xbLog.warn(MODULE_ID, 'Event fingerprint 不匹配');
return [];
}
const eventVectors = await getAllEventVectors(chatId);
const vectorMap = new Map(eventVectors.map(v => [v.eventId, v.vector]));
if (!vectorMap.size) {
return [];
}
const focusSet = new Set((focusEntities || []).map(normalize));
const scored = allEvents.map(event => {
const v = vectorMap.get(event.id);
const baseSim = v ? cosineSimilarity(queryVector, v) : 0;
const participants = (event.participants || []).map(p => normalize(p));
const hasEntityMatch = participants.some(p => focusSet.has(p));
const bonus = hasEntityMatch ? 0.05 : 0;
return {
_id: event.id,
event,
similarity: baseSim + bonus,
_baseSim: baseSim,
_hasEntityMatch: hasEntityMatch,
vector: v,
};
});
if (metrics) {
metrics.event.inStore = allEvents.length;
}
let candidates = scored
.filter(s => s.similarity >= CONFIG.EVENT_MIN_SIMILARITY)
.sort((a, b) => b.similarity - a.similarity)
.slice(0, CONFIG.EVENT_CANDIDATE_MAX);
if (metrics) {
metrics.event.considered = candidates.length;
}
// 实体过滤
if (focusSet.size > 0) {
const beforeFilter = candidates.length;
candidates = candidates.filter(c => {
if (c.similarity >= 0.85) return true;
return c._hasEntityMatch;
});
if (metrics) {
metrics.event.entityFilter = {
focusEntities: focusEntities || [],
before: beforeFilter,
after: candidates.length,
filtered: beforeFilter - candidates.length,
};
}
}
// MMR 选择
const selected = mmrSelect(
candidates,
CONFIG.EVENT_SELECT_MAX,
CONFIG.EVENT_MMR_LAMBDA,
c => c.vector,
c => c.similarity
);
let directCount = 0;
let relatedCount = 0;
const results = selected.map(s => {
const recallType = s._hasEntityMatch ? 'DIRECT' : 'RELATED';
if (recallType === 'DIRECT') directCount++;
else relatedCount++;
return {
event: s.event,
similarity: s.similarity,
_recallType: recallType,
_baseSim: s._baseSim,
};
});
if (metrics) {
metrics.event.selected = results.length;
metrics.event.byRecallType = { direct: directCount, related: relatedCount, causal: 0 };
metrics.event.similarityDistribution = calcSimilarityStats(results.map(r => r.similarity));
}
return results;
}
// ═══════════════════════════════════════════════════════════════════════════
// [Causation] 因果链追溯
// ═══════════════════════════════════════════════════════════════════════════
/**
* 构建事件索引
* @param {object[]} allEvents - 所有事件
* @returns {Map<string, object>} 事件索引
*/
function buildEventIndex(allEvents) {
const map = new Map();
for (const e of allEvents || []) {
if (e?.id) map.set(e.id, e);
}
return map;
}
/**
* 追溯因果链
* @param {object[]} eventHits - 事件命中结果
* @param {Map<string, object>} eventIndex - 事件索引
* @param {number} maxDepth - 最大深度
* @returns {{results: object[], maxDepth: number}}
*/
function traceCausation(eventHits, eventIndex, maxDepth = CONFIG.CAUSAL_CHAIN_MAX_DEPTH) {
const out = new Map();
const idRe = /^evt-\d+$/;
let maxActualDepth = 0;
function visit(parentId, depth, chainFrom) {
if (depth > maxDepth) return;
if (!idRe.test(parentId)) return;
const ev = eventIndex.get(parentId);
if (!ev) return;
if (depth > maxActualDepth) maxActualDepth = depth;
const existed = out.get(parentId);
if (!existed) {
out.set(parentId, { event: ev, depth, chainFrom: [chainFrom] });
} else {
if (depth < existed.depth) existed.depth = depth;
if (!existed.chainFrom.includes(chainFrom)) existed.chainFrom.push(chainFrom);
}
for (const next of (ev.causedBy || [])) {
visit(String(next || '').trim(), depth + 1, chainFrom);
}
}
for (const r of eventHits || []) {
const rid = r?.event?.id;
if (!rid) continue;
for (const cid of (r.event?.causedBy || [])) {
visit(String(cid || '').trim(), 1, rid);
}
}
const results = Array.from(out.values())
.sort((a, b) => {
const refDiff = b.chainFrom.length - a.chainFrom.length;
if (refDiff !== 0) return refDiff;
return a.depth - b.depth;
})
.slice(0, CONFIG.CAUSAL_INJECT_MAX);
return { results, maxDepth: maxActualDepth };
}
// ═══════════════════════════════════════════════════════════════════════════
// 辅助函数
// ═══════════════════════════════════════════════════════════════════════════
/**
* 获取最近消息
* @param {object[]} chat - 聊天记录
* @param {number} count - 消息数量
* @param {boolean} excludeLastAi - 是否排除最后的 AI 消息
* @returns {object[]} 最近消息
*/
function getLastMessages(chat, count = 4, excludeLastAi = false) {
if (!chat?.length) return [];
let messages = [...chat];
if (excludeLastAi && messages.length > 0 && !messages[messages.length - 1]?.is_user) {
messages = messages.slice(0, -1);
}
return messages.slice(-count);
}
/**
* 构建查询文本
* @param {object[]} chat - 聊天记录
* @param {number} count - 消息数量
* @param {boolean} excludeLastAi - 是否排除最后的 AI 消息
* @returns {string} 查询文本
*/
export function buildQueryText(chat, count = 2, excludeLastAi = false) {
if (!chat?.length) return '';
let messages = chat;
if (excludeLastAi && messages.length > 0 && !messages[messages.length - 1]?.is_user) {
messages = messages.slice(0, -1);
}
return messages.slice(-count).map(m => {
const text = cleanForRecall(m.mes);
const speaker = m.name || (m.is_user ? '用户' : '角色');
return `${speaker}: ${text.slice(0, 500)}`;
}).filter(Boolean).join('\n');
}
// ═══════════════════════════════════════════════════════════════════════════
// 主函数
// ═══════════════════════════════════════════════════════════════════════════
/**
* 执行记忆召回
* @param {string} queryText - 查询文本
* @param {object[]} allEvents - 所有事件L2
* @param {object} vectorConfig - 向量配置
* @param {object} options - 选项
* @returns {Promise<object>} 召回结果
*/
export async function recallMemory(queryText, allEvents, vectorConfig, options = {}) {
const T0 = performance.now();
const { chat, name1 } = getContext();
const { pendingUserMessage = null, excludeLastAi = false } = options;
const metrics = createMetrics();
if (!allEvents?.length) {
metrics.anchor.needRecall = false;
return {
events: [],
evidenceChunks: [],
causalChain: [],
focusEntities: [],
elapsed: 0,
logText: 'No events.',
metrics,
};
}
// ═══════════════════════════════════════════════════════════════════════
// Step 1: Query Expansion
// ═══════════════════════════════════════════════════════════════════════
const T_QE_Start = performance.now();
const lastMessages = getLastMessages(chat, 4, excludeLastAi);
let expansion = { focus: [], queries: [] };
try {
expansion = await expandQueryCached(lastMessages, {
pendingUserMessage,
timeout: CONFIG.QUERY_EXPANSION_TIMEOUT,
});
xbLog.info(MODULE_ID, `Query Expansion: focus=[${expansion.focus.join(',')}] queries=${expansion.queries.length}`);
} catch (e) {
xbLog.warn(MODULE_ID, 'Query Expansion 失败,降级使用原始文本', e);
}
const searchText = buildSearchText(expansion);
const finalSearchText = searchText || queryText || lastMessages.map(m => cleanForRecall(m.mes || '').slice(0, 200)).join(' ');
const focusEntities = removeUserNameFromFocus(expansion.focus, name1);
metrics.anchor.needRecall = true;
metrics.anchor.focusEntities = focusEntities;
metrics.anchor.queries = expansion.queries || [];
metrics.anchor.queryExpansionTime = Math.round(performance.now() - T_QE_Start);
metrics.timing.queryExpansion = metrics.anchor.queryExpansionTime;
// ═══════════════════════════════════════════════════════════════════════
// Step 2: 向量化查询
// ═══════════════════════════════════════════════════════════════════════
let queryVector;
try {
const [vec] = await embed([finalSearchText], vectorConfig, { timeout: 10000 });
queryVector = vec;
} catch (e) {
xbLog.error(MODULE_ID, '向量化失败', e);
metrics.timing.total = Math.round(performance.now() - T0);
return {
events: [],
evidenceChunks: [],
causalChain: [],
focusEntities,
elapsed: metrics.timing.total,
logText: 'Embedding failed.',
metrics,
};
}
if (!queryVector?.length) {
metrics.timing.total = Math.round(performance.now() - T0);
return {
events: [],
evidenceChunks: [],
causalChain: [],
focusEntities,
elapsed: metrics.timing.total,
logText: 'Empty query vector.',
metrics,
};
}
// ═══════════════════════════════════════════════════════════════════════
// Step 3: Anchor (L0) 检索
// ═══════════════════════════════════════════════════════════════════════
const T_Anchor_Start = performance.now();
const { hits: anchorHits, floors: anchorFloors } = await recallAnchors(queryVector, vectorConfig, metrics);
metrics.timing.anchorSearch = Math.round(performance.now() - T_Anchor_Start);
// ═══════════════════════════════════════════════════════════════════════
// Step 4: Evidence (L1) 拉取 + 粗筛 + Rerank
// ═══════════════════════════════════════════════════════════════════════
const T_Evidence_Start = performance.now();
const rerankQuery = buildRerankQuery(expansion, lastMessages, pendingUserMessage);
const evidenceChunks = await pullEvidenceByFloors(anchorFloors, anchorHits, queryVector, rerankQuery, metrics);
metrics.timing.evidenceRetrieval = Math.round(performance.now() - T_Evidence_Start);
// ═══════════════════════════════════════════════════════════════════════
// Step 5: Event (L2) 独立检索
// ═══════════════════════════════════════════════════════════════════════
const T_Event_Start = performance.now();
const eventHits = await recallEvents(queryVector, allEvents, vectorConfig, focusEntities, metrics);
metrics.timing.eventRetrieval = Math.round(performance.now() - T_Event_Start);
// ═══════════════════════════════════════════════════════════════════════
// Step 6: 因果链追溯
// ═══════════════════════════════════════════════════════════════════════
const eventIndex = buildEventIndex(allEvents);
const { results: causalMap, maxDepth: causalMaxDepth } = traceCausation(eventHits, eventIndex);
const recalledIdSet = new Set(eventHits.map(x => x?.event?.id).filter(Boolean));
const causalChain = causalMap
.filter(x => x?.event?.id && !recalledIdSet.has(x.event.id))
.map(x => ({
event: x.event,
similarity: 0,
_recallType: 'CAUSAL',
_causalDepth: x.depth,
chainFrom: x.chainFrom,
}));
if (metrics.event.byRecallType) {
metrics.event.byRecallType.causal = causalChain.length;
}
metrics.event.causalChainDepth = causalMaxDepth;
metrics.event.causalCount = causalChain.length;
// ═══════════════════════════════════════════════════════════════════════
// 完成
// ═══════════════════════════════════════════════════════════════════════
metrics.timing.total = Math.round(performance.now() - T0);
metrics.event.entityNames = focusEntities;
metrics.event.entitiesUsed = focusEntities.length;
console.group('%c[Recall v5]', 'color: #7c3aed; font-weight: bold');
console.log(`Elapsed: ${metrics.timing.total}ms`);
console.log(`Query Expansion: focus=[${expansion.focus.join(', ')}]`);
console.log(`Anchors: ${anchorHits.length} hits → ${anchorFloors.size} floors`);
console.log(`Evidence: ${metrics.evidence.chunkTotal || 0} L1 → ${metrics.evidence.chunkAfterCoarse || 0} coarse → ${evidenceChunks.length} final`);
if (metrics.evidence.rerankApplied) {
console.log(`Evidence Rerank: ${metrics.evidence.beforeRerank}${metrics.evidence.afterRerank} (${metrics.evidence.rerankTime}ms)`);
}
console.log(`Events: ${eventHits.length} hits, ${causalChain.length} causal`);
console.groupEnd();
return {
events: eventHits,
causalChain,
evidenceChunks,
expansion,
focusEntities,
elapsed: metrics.timing.total,
metrics,
};
}