Fix lint warnings and update retrieval modules
This commit is contained in:
@@ -14,7 +14,7 @@ import { getContext } from "../../../../../../extensions.js";
|
|||||||
import { xbLog } from "../../../core/debug-core.js";
|
import { xbLog } from "../../../core/debug-core.js";
|
||||||
import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js";
|
import { getSummaryStore, getFacts, isRelationFact } from "../data/store.js";
|
||||||
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
|
import { getVectorConfig, getSummaryPanelConfig, getSettings } from "../data/config.js";
|
||||||
import { recallMemory, buildQueryText } from "../vector/retrieval/recall.js";
|
import { recallMemory } from "../vector/retrieval/recall.js";
|
||||||
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/storage/chunk-store.js";
|
import { getChunksByFloors, getAllChunkVectors, getAllEventVectors, getMeta } from "../vector/storage/chunk-store.js";
|
||||||
|
|
||||||
// Metrics
|
// Metrics
|
||||||
@@ -1032,6 +1032,12 @@ async function buildVectorPrompt(store, recallResult, causalById, focusEntities
|
|||||||
? Math.round(attached / totalSelected * 100)
|
? Math.round(attached / totalSelected * 100)
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
|
const selectedReal = metrics.evidence.selectedByType?.chunkReal || 0;
|
||||||
|
const selectedTotal = metrics.evidence.selected || 0;
|
||||||
|
metrics.quality.chunkRealRatio = selectedTotal > 0
|
||||||
|
? Math.round(selectedReal / selectedTotal * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
metrics.quality.potentialIssues = detectIssues(metrics);
|
metrics.quality.potentialIssues = detectIssues(metrics);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1136,8 +1142,7 @@ export async function buildVectorPromptText(excludeLastAi = false, hooks = {}) {
|
|||||||
let causalById = new Map();
|
let causalById = new Map();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const queryText = buildQueryText(chat, 2, excludeLastAi);
|
recallResult = await recallMemory(allEvents, vectorCfg, {
|
||||||
recallResult = await recallMemory(queryText, allEvents, vectorCfg, {
|
|
||||||
excludeLastAi,
|
excludeLastAi,
|
||||||
pendingUserMessage,
|
pendingUserMessage,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -82,6 +82,8 @@ import {
|
|||||||
// vector io
|
// vector io
|
||||||
import { exportVectors, importVectors } from "./vector/storage/vector-io.js";
|
import { exportVectors, importVectors } from "./vector/storage/vector-io.js";
|
||||||
|
|
||||||
|
import { invalidateLexicalIndex } from "./vector/retrieval/lexical-index.js";
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// 常量
|
// 常量
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
@@ -298,6 +300,8 @@ async function handleAnchorGenerate() {
|
|||||||
postToFrame({ type: "ANCHOR_GEN_PROGRESS", current: 0, total: 1, message: "向量化 L1..." });
|
postToFrame({ type: "ANCHOR_GEN_PROGRESS", current: 0, total: 1, message: "向量化 L1..." });
|
||||||
await buildIncrementalChunks({ vectorConfig: vectorCfg });
|
await buildIncrementalChunks({ vectorConfig: vectorCfg });
|
||||||
|
|
||||||
|
invalidateLexicalIndex();
|
||||||
|
|
||||||
await sendAnchorStatsToFrame();
|
await sendAnchorStatsToFrame();
|
||||||
await sendVectorStatsToFrame();
|
await sendVectorStatsToFrame();
|
||||||
|
|
||||||
@@ -899,6 +903,8 @@ async function autoRunSummaryWithRetry(targetMesId, configForRun) {
|
|||||||
const store = getSummaryStore();
|
const store = getSummaryStore();
|
||||||
postToFrame({ type: "SUMMARY_FULL_DATA", payload: buildFramePayload(store) });
|
postToFrame({ type: "SUMMARY_FULL_DATA", payload: buildFramePayload(store) });
|
||||||
|
|
||||||
|
invalidateLexicalIndex();
|
||||||
|
|
||||||
applyHideStateDebounced();
|
applyHideStateDebounced();
|
||||||
updateFrameStatsAfterSummary(endMesId, store.json || {});
|
updateFrameStatsAfterSummary(endMesId, store.json || {});
|
||||||
|
|
||||||
@@ -1185,6 +1191,8 @@ async function handleManualGenerate(mesId, config) {
|
|||||||
const store = getSummaryStore();
|
const store = getSummaryStore();
|
||||||
postToFrame({ type: "SUMMARY_FULL_DATA", payload: buildFramePayload(store) });
|
postToFrame({ type: "SUMMARY_FULL_DATA", payload: buildFramePayload(store) });
|
||||||
|
|
||||||
|
invalidateLexicalIndex();
|
||||||
|
|
||||||
applyHideStateDebounced();
|
applyHideStateDebounced();
|
||||||
updateFrameStatsAfterSummary(endMesId, store.json || {});
|
updateFrameStatsAfterSummary(endMesId, store.json || {});
|
||||||
|
|
||||||
@@ -1205,6 +1213,7 @@ async function handleChatChanged() {
|
|||||||
const newLength = Array.isArray(chat) ? chat.length : 0;
|
const newLength = Array.isArray(chat) ? chat.length : 0;
|
||||||
|
|
||||||
await rollbackSummaryIfNeeded();
|
await rollbackSummaryIfNeeded();
|
||||||
|
invalidateLexicalIndex();
|
||||||
initButtonsForAll();
|
initButtonsForAll();
|
||||||
|
|
||||||
const store = getSummaryStore();
|
const store = getSummaryStore();
|
||||||
|
|||||||
@@ -1,333 +0,0 @@
|
|||||||
// ============================================================================
|
|
||||||
// query-expansion.js - 检索查询生成器(三层 themes 版)
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
import { callLLM, parseJson } from './llm-service.js';
|
|
||||||
import { xbLog } from '../../../../core/debug-core.js';
|
|
||||||
import { filterText } from '../utils/text-filter.js';
|
|
||||||
import { getContext } from '../../../../../../../extensions.js';
|
|
||||||
import { getSummaryStore } from '../../data/store.js';
|
|
||||||
|
|
||||||
const MODULE_ID = 'query-expansion';
|
|
||||||
const SESSION_ID = 'xb6';
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 系统提示词
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const SYSTEM_PROMPT = `你是检索查询生成器。根据当前对话上下文,生成用于检索历史剧情的查询语句。
|
|
||||||
|
|
||||||
## 输出格式(严格JSON)
|
|
||||||
{
|
|
||||||
"focus": ["焦点人物"],
|
|
||||||
"fn": ["叙事功能"],
|
|
||||||
"pt": ["互动模式"],
|
|
||||||
"kw": ["关键词"],
|
|
||||||
"queries": ["DSL查询语句"]
|
|
||||||
}
|
|
||||||
|
|
||||||
## fn(叙事功能)枚举
|
|
||||||
establish=建立设定 | escalate=升级加剧 | reveal=揭示发现 | challenge=挑战试探
|
|
||||||
commit=承诺锁定 | conflict=冲突对抗 | resolve=解决收束 | transform=转变逆转
|
|
||||||
bond=连接羁绊 | break=断裂破坏
|
|
||||||
|
|
||||||
## pt(互动模式)枚举
|
|
||||||
power_down=上对下 | power_up=下对上 | power_equal=对等 | power_contest=争夺
|
|
||||||
asymmetric=信息不对称 | witnessed=有观众 | secluded=隔绝私密
|
|
||||||
ritual=仪式正式 | routine=日常惯例 | triangular=三方介入
|
|
||||||
|
|
||||||
## DSL 查询格式
|
|
||||||
- <act> 主体 -> 动作 (-> 客体)? (在地点)?
|
|
||||||
- <emo> 主体 -> 情绪 (对客体)?
|
|
||||||
- <dec> 主体 -> 决定/承诺 (对客体)?
|
|
||||||
- <rev> 揭示: 内容 (关于客体)?
|
|
||||||
- <ten> 主体A <-> 主体B: 冲突内容
|
|
||||||
- <loc> 场景: 地点/状态
|
|
||||||
|
|
||||||
## 规则
|
|
||||||
- focus: 核心人物,1-4个
|
|
||||||
- fn: 当前对话涉及的叙事功能,1-3个
|
|
||||||
- pt: 当前对话涉及的互动模式,1-3个
|
|
||||||
- kw: 具体关键词,1-4个
|
|
||||||
- queries: 2-4条 DSL 查询
|
|
||||||
|
|
||||||
## 示例
|
|
||||||
|
|
||||||
输入:艾拉说"那把剑...我记得它的重量,在火山口的时候"
|
|
||||||
输出:
|
|
||||||
{
|
|
||||||
"focus": ["艾拉", "古龙"],
|
|
||||||
"fn": ["commit", "bond"],
|
|
||||||
"pt": ["power_down", "ritual"],
|
|
||||||
"kw": ["圣剑", "战斗", "火山口"],
|
|
||||||
"queries": [
|
|
||||||
"<act> 艾拉 -> 战斗/使用圣剑 -> 古龙 [commit/power_down]",
|
|
||||||
"<loc> 场景: 火山口 [ritual]",
|
|
||||||
"<emo> 艾拉 -> 牺牲/决绝 [commit]"
|
|
||||||
]
|
|
||||||
}`;
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 上下文构建
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
function getCharacterContext() {
|
|
||||||
const context = getContext();
|
|
||||||
const char = context.characters?.[context.characterId];
|
|
||||||
|
|
||||||
if (!char) {
|
|
||||||
return { name: '', description: '', personality: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: char.name || '',
|
|
||||||
description: (char.description || '').slice(0, 500),
|
|
||||||
personality: (char.personality || '').slice(0, 300),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPersonaContext() {
|
|
||||||
const context = getContext();
|
|
||||||
|
|
||||||
if (typeof window !== 'undefined' && window.power_user?.persona_description) {
|
|
||||||
return String(window.power_user.persona_description).slice(0, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (context.persona_description) {
|
|
||||||
return String(context.persona_description).slice(0, 500);
|
|
||||||
}
|
|
||||||
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRecentEvents(count = 8) {
|
|
||||||
const store = getSummaryStore();
|
|
||||||
const events = store?.json?.events || [];
|
|
||||||
|
|
||||||
return events
|
|
||||||
.slice(-count)
|
|
||||||
.map(e => {
|
|
||||||
const time = e.timeLabel || '';
|
|
||||||
const title = e.title || '';
|
|
||||||
const participants = (e.participants || []).join('/');
|
|
||||||
const summary = (e.summary || '').replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, '').slice(0, 80);
|
|
||||||
|
|
||||||
return time
|
|
||||||
? `[${time}] ${title || participants}: ${summary}`
|
|
||||||
: `${title || participants}: ${summary}`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRelevantArcs(focusHint = []) {
|
|
||||||
const store = getSummaryStore();
|
|
||||||
const arcs = store?.json?.arcs || [];
|
|
||||||
|
|
||||||
if (!arcs.length) return [];
|
|
||||||
|
|
||||||
const hintSet = new Set(focusHint.map(s => String(s).toLowerCase()));
|
|
||||||
|
|
||||||
const sorted = [...arcs].sort((a, b) => {
|
|
||||||
const aHit = hintSet.has(String(a.name || '').toLowerCase()) ? 1 : 0;
|
|
||||||
const bHit = hintSet.has(String(b.name || '').toLowerCase()) ? 1 : 0;
|
|
||||||
return bHit - aHit;
|
|
||||||
});
|
|
||||||
|
|
||||||
return sorted.slice(0, 4).map(a => {
|
|
||||||
const progress = Math.round((a.progress || 0) * 100);
|
|
||||||
return `${a.name}: ${a.trajectory || '未知状态'} (${progress}%)`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractNamesFromMessages(messages) {
|
|
||||||
const names = new Set();
|
|
||||||
|
|
||||||
for (const m of messages) {
|
|
||||||
if (m.name) names.add(m.name);
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = messages.map(m => m.mes || '').join(' ');
|
|
||||||
const namePattern = /[\u4e00-\u9fff]{2,4}/g;
|
|
||||||
const matches = text.match(namePattern) || [];
|
|
||||||
|
|
||||||
const freq = {};
|
|
||||||
for (const name of matches) {
|
|
||||||
freq[name] = (freq[name] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.entries(freq)
|
|
||||||
.filter(([, count]) => count >= 2)
|
|
||||||
.forEach(([name]) => names.add(name));
|
|
||||||
|
|
||||||
return Array.from(names).slice(0, 6);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 主函数
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
export async function expandQuery(messages, options = {}) {
|
|
||||||
const { pendingUserMessage = null, timeout = 6000 } = options;
|
|
||||||
|
|
||||||
if (!messages?.length && !pendingUserMessage) {
|
|
||||||
return { focus: [], fn: [], pt: [], kw: [], queries: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const T0 = performance.now();
|
|
||||||
|
|
||||||
const character = getCharacterContext();
|
|
||||||
const persona = getPersonaContext();
|
|
||||||
const nameHints = extractNamesFromMessages(messages || []);
|
|
||||||
const recentEvents = getRecentEvents(8);
|
|
||||||
const arcs = getRelevantArcs(nameHints);
|
|
||||||
|
|
||||||
const dialogueParts = [];
|
|
||||||
|
|
||||||
for (const m of (messages || [])) {
|
|
||||||
const speaker = m.is_user ? '用户' : (m.name || '角色');
|
|
||||||
const text = filterText(m.mes || '').trim();
|
|
||||||
if (text) {
|
|
||||||
dialogueParts.push(`【${speaker}】\n${text.slice(0, 400)}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pendingUserMessage) {
|
|
||||||
dialogueParts.push(`【用户(刚输入)】\n${filterText(pendingUserMessage).slice(0, 400)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inputParts = [];
|
|
||||||
|
|
||||||
if (character.name) {
|
|
||||||
inputParts.push(`## 当前角色\n${character.name}: ${character.description || character.personality || '无描述'}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (persona) {
|
|
||||||
inputParts.push(`## 用户人设\n${persona}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (recentEvents.length) {
|
|
||||||
inputParts.push(`## 近期剧情\n${recentEvents.map((e, i) => `${i + 1}. ${e}`).join('\n')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (arcs.length) {
|
|
||||||
inputParts.push(`## 角色状态\n${arcs.join('\n')}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
inputParts.push(`## 最近对话\n${dialogueParts.join('\n\n')}`);
|
|
||||||
|
|
||||||
const input = inputParts.join('\n\n');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const response = await callLLM([
|
|
||||||
{ role: 'system', content: SYSTEM_PROMPT },
|
|
||||||
{ role: 'user', content: input },
|
|
||||||
], {
|
|
||||||
temperature: 0.15,
|
|
||||||
max_tokens: 500,
|
|
||||||
timeout,
|
|
||||||
sessionId: SESSION_ID,
|
|
||||||
});
|
|
||||||
|
|
||||||
const parsed = parseJson(response);
|
|
||||||
if (!parsed) {
|
|
||||||
xbLog.warn(MODULE_ID, 'JSON解析失败', response?.slice(0, 200));
|
|
||||||
return { focus: [], fn: [], pt: [], kw: [], queries: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = {
|
|
||||||
focus: Array.isArray(parsed.focus) ? parsed.focus.slice(0, 5) : [],
|
|
||||||
fn: Array.isArray(parsed.fn) ? parsed.fn.slice(0, 4) : [],
|
|
||||||
pt: Array.isArray(parsed.pt) ? parsed.pt.slice(0, 4) : [],
|
|
||||||
kw: Array.isArray(parsed.kw) ? parsed.kw.slice(0, 5) : [],
|
|
||||||
queries: Array.isArray(parsed.queries) ? parsed.queries.slice(0, 5) : [],
|
|
||||||
};
|
|
||||||
|
|
||||||
xbLog.info(MODULE_ID, `完成 (${Math.round(performance.now() - T0)}ms) focus=[${result.focus.join(',')}] fn=[${result.fn.join(',')}]`);
|
|
||||||
return result;
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
xbLog.error(MODULE_ID, '调用失败', e);
|
|
||||||
return { focus: [], fn: [], pt: [], kw: [], queries: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 缓存
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const cache = new Map();
|
|
||||||
const CACHE_TTL = 300000;
|
|
||||||
|
|
||||||
function hashMessages(messages, pending = '') {
|
|
||||||
const text = (messages || [])
|
|
||||||
.slice(-3)
|
|
||||||
.map(m => (m.mes || '').slice(0, 100))
|
|
||||||
.join('|') + '|' + (pending || '').slice(0, 100);
|
|
||||||
|
|
||||||
let h = 0;
|
|
||||||
for (let i = 0; i < text.length; i++) {
|
|
||||||
h = ((h << 5) - h + text.charCodeAt(i)) | 0;
|
|
||||||
}
|
|
||||||
return h.toString(36);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function expandQueryCached(messages, options = {}) {
|
|
||||||
const key = hashMessages(messages, options.pendingUserMessage);
|
|
||||||
const cached = cache.get(key);
|
|
||||||
|
|
||||||
if (cached && Date.now() - cached.time < CACHE_TTL) {
|
|
||||||
return cached.result;
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await expandQuery(messages, options);
|
|
||||||
|
|
||||||
if (result.focus.length || result.queries.length) {
|
|
||||||
if (cache.size > 50) {
|
|
||||||
cache.delete(cache.keys().next().value);
|
|
||||||
}
|
|
||||||
cache.set(key, { result, time: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 辅助函数:构建检索文本
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 将 expansion 结果转换为检索文本
|
|
||||||
* 三层 themes 自然拼入,让向量自动编码
|
|
||||||
*/
|
|
||||||
export function buildSearchText(expansion) {
|
|
||||||
const parts = [];
|
|
||||||
|
|
||||||
// focus 人物
|
|
||||||
if (expansion.focus?.length) {
|
|
||||||
parts.push(expansion.focus.join(' '));
|
|
||||||
}
|
|
||||||
|
|
||||||
// fn + pt + kw 合并为标签
|
|
||||||
const tags = [
|
|
||||||
...(expansion.fn || []),
|
|
||||||
...(expansion.pt || []),
|
|
||||||
...(expansion.kw || []),
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
if (tags.length) {
|
|
||||||
parts.push(`[${tags.join('/')}]`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// queries
|
|
||||||
if (expansion.queries?.length) {
|
|
||||||
parts.push(...expansion.queries);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.filter(Boolean).join(' ').slice(0, 1500);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 提取实体列表(兼容旧接口)
|
|
||||||
*/
|
|
||||||
export function getEntitiesFromExpansion(expansion) {
|
|
||||||
return expansion?.focus || [];
|
|
||||||
}
|
|
||||||
@@ -159,21 +159,6 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress) {
|
|||||||
let peakActive = 0;
|
let peakActive = 0;
|
||||||
const tStart = performance.now();
|
const tStart = performance.now();
|
||||||
|
|
||||||
const atomBuffer = [];
|
|
||||||
const FLOOR_FLUSH_THRESHOLD = 10;
|
|
||||||
let bufferedFloors = 0;
|
|
||||||
const flushAtomBuffer = () => {
|
|
||||||
if (!atomBuffer.length) return;
|
|
||||||
saveStateAtoms(atomBuffer.splice(0, atomBuffer.length));
|
|
||||||
bufferedFloors = 0;
|
|
||||||
};
|
|
||||||
const markFloorBuffered = () => {
|
|
||||||
bufferedFloors++;
|
|
||||||
if (bufferedFloors >= FLOOR_FLUSH_THRESHOLD) {
|
|
||||||
flushAtomBuffer();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// ★ Phase 1: 收集所有新提取的 atoms(不向量化)
|
// ★ Phase 1: 收集所有新提取的 atoms(不向量化)
|
||||||
const allNewAtoms = [];
|
const allNewAtoms = [];
|
||||||
|
|
||||||
@@ -218,8 +203,7 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress) {
|
|||||||
setL0FloorStatus(floor, { status: 'empty', reason: 'llm_empty', atoms: 0 });
|
setL0FloorStatus(floor, { status: 'empty', reason: 'llm_empty', atoms: 0 });
|
||||||
} else {
|
} else {
|
||||||
atoms.forEach(a => a.chatId = chatId);
|
atoms.forEach(a => a.chatId = chatId);
|
||||||
atomBuffer.push(...atoms);
|
saveStateAtoms(atoms);
|
||||||
markFloorBuffered();
|
|
||||||
// Phase 1: 只收集,不向量化
|
// Phase 1: 只收集,不向量化
|
||||||
allNewAtoms.push(...atoms);
|
allNewAtoms.push(...atoms);
|
||||||
|
|
||||||
@@ -250,7 +234,6 @@ export async function incrementalExtractAtoms(chatId, chat, onProgress) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
await Promise.all(Array.from({ length: poolSize }, (_, i) => runWorker(i)));
|
await Promise.all(Array.from({ length: poolSize }, (_, i) => runWorker(i)));
|
||||||
flushAtomBuffer();
|
|
||||||
if (DEBUG_CONCURRENCY) {
|
if (DEBUG_CONCURRENCY) {
|
||||||
const elapsed = Math.max(1, Math.round(performance.now() - tStart));
|
const elapsed = Math.max(1, Math.round(performance.now() - tStart));
|
||||||
xbLog.info(MODULE_ID, `L0 pool done completed=${completed}/${total} failed=${failed} peakActive=${peakActive} elapsedMs=${elapsed}`);
|
xbLog.info(MODULE_ID, `L0 pool done completed=${completed}/${total} failed=${failed} peakActive=${peakActive} elapsedMs=${elapsed}`);
|
||||||
|
|||||||
189
modules/story-summary/vector/pipeline/state-recall.js
Normal file
189
modules/story-summary/vector/pipeline/state-recall.js
Normal file
@@ -0,0 +1,189 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Story Summary - State Recall (L0)
|
||||||
|
// L0 语义锚点召回 + floor bonus + 虚拟 chunk 转换
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { getContext } from '../../../../../../../extensions.js';
|
||||||
|
import { getAllStateVectors, getStateAtoms } from '../storage/state-store.js';
|
||||||
|
import { getMeta } from '../storage/chunk-store.js';
|
||||||
|
import { getEngineFingerprint } from '../utils/embedder.js';
|
||||||
|
import { xbLog } from '../../../../core/debug-core.js';
|
||||||
|
|
||||||
|
const MODULE_ID = 'state-recall';
|
||||||
|
|
||||||
|
const CONFIG = {
|
||||||
|
MAX_RESULTS: 20,
|
||||||
|
MIN_SIMILARITY: 0.55,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 工具函数
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// L0 向量检索
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检索与 query 相似的 StateAtoms
|
||||||
|
* @returns {Array<{atom, similarity}>}
|
||||||
|
*/
|
||||||
|
export async function searchStateAtoms(queryVector, vectorConfig) {
|
||||||
|
const { chatId } = getContext();
|
||||||
|
if (!chatId || !queryVector?.length) return [];
|
||||||
|
|
||||||
|
// 检查 fingerprint
|
||||||
|
const meta = await getMeta(chatId);
|
||||||
|
const fp = getEngineFingerprint(vectorConfig);
|
||||||
|
if (meta.fingerprint && meta.fingerprint !== fp) {
|
||||||
|
xbLog.warn(MODULE_ID, 'fingerprint 不匹配,跳过 L0 召回');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取向量
|
||||||
|
const stateVectors = await getAllStateVectors(chatId);
|
||||||
|
if (!stateVectors.length) return [];
|
||||||
|
|
||||||
|
// 获取 atoms(用于关联 semantic 等字段)
|
||||||
|
const atoms = getStateAtoms();
|
||||||
|
const atomMap = new Map(atoms.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.MIN_SIMILARITY)
|
||||||
|
.sort((a, b) => b.similarity - a.similarity)
|
||||||
|
.slice(0, CONFIG.MAX_RESULTS);
|
||||||
|
|
||||||
|
return scored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Floor Bonus 构建
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 L0 相关楼层的加权映射
|
||||||
|
* @returns {Map<number, number>}
|
||||||
|
*/
|
||||||
|
export function buildL0FloorBonus(l0Results, bonusFactor = 0.10) {
|
||||||
|
const floorBonus = new Map();
|
||||||
|
|
||||||
|
for (const r of l0Results || []) {
|
||||||
|
// 每个楼层只加一次,取最高相似度对应的 bonus
|
||||||
|
// 简化处理:统一加 bonusFactor,不区分相似度高低
|
||||||
|
if (!floorBonus.has(r.floor)) {
|
||||||
|
floorBonus.set(r.floor, bonusFactor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return floorBonus;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 虚拟 Chunk 转换
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 将 L0 结果转换为虚拟 chunk 格式
|
||||||
|
* 用于和 L1 chunks 统一处理
|
||||||
|
*/
|
||||||
|
export function stateToVirtualChunks(l0Results) {
|
||||||
|
return (l0Results || []).map(r => ({
|
||||||
|
chunkId: `state-${r.atomId}`,
|
||||||
|
floor: r.floor,
|
||||||
|
chunkIdx: -1, // 负值,排序时排在 L1 前面
|
||||||
|
speaker: '📌', // 固定标记
|
||||||
|
isUser: false,
|
||||||
|
text: r.atom.semantic,
|
||||||
|
textHash: null,
|
||||||
|
similarity: r.similarity,
|
||||||
|
isL0: true, // 标记字段
|
||||||
|
// 保留原始 atom 信息
|
||||||
|
_atom: r.atom,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// 每楼层稀疏去重
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 合并 L0 和 L1 chunks
|
||||||
|
* @param {Array} l0Chunks - L0 虚拟 chunks(带 similarity)
|
||||||
|
* @param {Array} l1Chunks - L1 真实 chunks(无 similarity)
|
||||||
|
* @param {number} limit - 每楼层上限
|
||||||
|
* @returns {Array} 合并后的 chunks
|
||||||
|
*/
|
||||||
|
export function mergeAndSparsify(l0Chunks, l1Chunks, limit = 2) {
|
||||||
|
// 构建 L0 楼层 → 最高 similarity 映射
|
||||||
|
const floorSimilarity = new Map();
|
||||||
|
for (const c of (l0Chunks || [])) {
|
||||||
|
const existing = floorSimilarity.get(c.floor) || 0;
|
||||||
|
if ((c.similarity || 0) > existing) {
|
||||||
|
floorSimilarity.set(c.floor, c.similarity || 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// L1 继承所属楼层的 L0 similarity
|
||||||
|
const l1WithScore = (l1Chunks || []).map(c => ({
|
||||||
|
...c,
|
||||||
|
similarity: floorSimilarity.get(c.floor) || 0.5,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 合并并按相似度排序
|
||||||
|
const all = [...(l0Chunks || []), ...l1WithScore]
|
||||||
|
.sort((a, b) => {
|
||||||
|
// 相似度优先
|
||||||
|
const simDiff = (b.similarity || 0) - (a.similarity || 0);
|
||||||
|
if (Math.abs(simDiff) > 0.01) return simDiff;
|
||||||
|
|
||||||
|
// 同楼层:L0 优先于 L1
|
||||||
|
if (a.floor === b.floor) {
|
||||||
|
if (a.isL0 && !b.isL0) return -1;
|
||||||
|
if (!a.isL0 && b.isL0) return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按楼层升序
|
||||||
|
return a.floor - b.floor;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 每楼层稀疏去重
|
||||||
|
const byFloor = new Map();
|
||||||
|
|
||||||
|
for (const c of all) {
|
||||||
|
const arr = byFloor.get(c.floor) || [];
|
||||||
|
if (arr.length < limit) {
|
||||||
|
arr.push(c);
|
||||||
|
byFloor.set(c.floor, arr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 扁平化并保持排序
|
||||||
|
return Array.from(byFloor.values())
|
||||||
|
.flat()
|
||||||
|
.sort((a, b) => (b.similarity || 0) - (a.similarity || 0));
|
||||||
|
}
|
||||||
|
|
||||||
151
modules/story-summary/vector/retrieval/entity-lexicon.js
Normal file
151
modules/story-summary/vector/retrieval/entity-lexicon.js
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// entity-lexicon.js - 实体词典(确定性,无 LLM)
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// 1. 从已有结构化存储构建可信实体词典
|
||||||
|
// 2. 从文本中提取命中的实体
|
||||||
|
//
|
||||||
|
// 硬约束:name1 永不进入词典
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标准化字符串(用于实体匹配)
|
||||||
|
* @param {string} s
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function normalize(s) {
|
||||||
|
return String(s || '')
|
||||||
|
.normalize('NFKC')
|
||||||
|
.replace(/[\u200B-\u200D\uFEFF]/g, '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建实体词典
|
||||||
|
*
|
||||||
|
* 来源(按可信度):
|
||||||
|
* 1. store.json.characters.main — 已确认主要角色
|
||||||
|
* 2. store.json.arcs[].name — 弧光对象
|
||||||
|
* 3. context.name2 — 当前角色
|
||||||
|
* 4. store.json.facts[].s — L3 事实主语
|
||||||
|
*
|
||||||
|
* 硬约束:永远排除 normalize(context.name1)
|
||||||
|
*
|
||||||
|
* @param {object} store - getSummaryStore() 返回值
|
||||||
|
* @param {object} context - { name1: string, name2: string }
|
||||||
|
* @returns {Set<string>} 标准化后的实体集合
|
||||||
|
*/
|
||||||
|
export function buildEntityLexicon(store, context) {
|
||||||
|
const lexicon = new Set();
|
||||||
|
|
||||||
|
// 内部辅助:添加非空实体
|
||||||
|
const add = (raw) => {
|
||||||
|
const n = normalize(raw);
|
||||||
|
if (n && n.length >= 2) lexicon.add(n);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1. 主要角色
|
||||||
|
const main = store?.json?.characters?.main || [];
|
||||||
|
for (const m of main) {
|
||||||
|
add(typeof m === 'string' ? m : m.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 弧光角色
|
||||||
|
const arcs = store?.json?.arcs || [];
|
||||||
|
for (const a of arcs) {
|
||||||
|
add(a.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 当前角色 name2
|
||||||
|
if (context?.name2) {
|
||||||
|
add(context.name2);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. L3 facts 主语
|
||||||
|
const facts = store?.json?.facts || [];
|
||||||
|
for (const f of facts) {
|
||||||
|
if (f.retracted) continue;
|
||||||
|
add(f.s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 硬约束:删除 name1
|
||||||
|
if (context?.name1) {
|
||||||
|
lexicon.delete(normalize(context.name1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return lexicon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建"原词形 → 标准化"映射表
|
||||||
|
* 用于从 lexicon 反查原始显示名
|
||||||
|
*
|
||||||
|
* @param {object} store
|
||||||
|
* @param {object} context
|
||||||
|
* @returns {Map<string, string>} normalize(name) → 原词形
|
||||||
|
*/
|
||||||
|
export function buildDisplayNameMap(store, context) {
|
||||||
|
const map = new Map();
|
||||||
|
|
||||||
|
const register = (raw) => {
|
||||||
|
const n = normalize(raw);
|
||||||
|
if (n && n.length >= 2 && !map.has(n)) {
|
||||||
|
map.set(n, String(raw).trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const main = store?.json?.characters?.main || [];
|
||||||
|
for (const m of main) {
|
||||||
|
register(typeof m === 'string' ? m : m.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
const arcs = store?.json?.arcs || [];
|
||||||
|
for (const a of arcs) {
|
||||||
|
register(a.name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context?.name2) register(context.name2);
|
||||||
|
|
||||||
|
const facts = store?.json?.facts || [];
|
||||||
|
for (const f of facts) {
|
||||||
|
if (!f.retracted) register(f.s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ★ 硬约束:删除 name1
|
||||||
|
if (context?.name1) {
|
||||||
|
map.delete(normalize(context.name1));
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本中提取命中的实体
|
||||||
|
*
|
||||||
|
* 逻辑:遍历词典,检查文本中是否包含(不区分大小写)
|
||||||
|
* 返回命中的实体原词形(去重)
|
||||||
|
*
|
||||||
|
* @param {string} text - 清洗后的文本
|
||||||
|
* @param {Set<string>} lexicon - 标准化后的实体集合
|
||||||
|
* @param {Map<string, string>} displayMap - normalize → 原词形
|
||||||
|
* @returns {string[]} 命中的实体(原词形)
|
||||||
|
*/
|
||||||
|
export function extractEntitiesFromText(text, lexicon, displayMap) {
|
||||||
|
if (!text || !lexicon?.size) return [];
|
||||||
|
|
||||||
|
const textNorm = normalize(text);
|
||||||
|
const hits = [];
|
||||||
|
const seen = new Set();
|
||||||
|
|
||||||
|
for (const entity of lexicon) {
|
||||||
|
if (textNorm.includes(entity) && !seen.has(entity)) {
|
||||||
|
seen.add(entity);
|
||||||
|
// 优先返回原词形
|
||||||
|
const display = displayMap?.get(entity) || entity;
|
||||||
|
hits.push(display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return hits;
|
||||||
|
}
|
||||||
369
modules/story-summary/vector/retrieval/lexical-index.js
Normal file
369
modules/story-summary/vector/retrieval/lexical-index.js
Normal file
@@ -0,0 +1,369 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// lexical-index.js - MiniSearch 词法检索索引
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// 1. 对 L0 atoms + L1 chunks + L2 events 建立词法索引
|
||||||
|
// 2. 提供词法检索接口(专名精确匹配兜底)
|
||||||
|
// 3. 惰性构建 + 缓存失效机制
|
||||||
|
//
|
||||||
|
// 索引存储:纯内存(不持久化)
|
||||||
|
// 重建时机:CHAT_CHANGED / L0提取完成 / L2总结完成
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import MiniSearch from '../../../../libs/minisearch.mjs';
|
||||||
|
import { getContext } from '../../../../../../../extensions.js';
|
||||||
|
import { getSummaryStore } from '../../data/store.js';
|
||||||
|
import { getStateAtoms } from '../storage/state-store.js';
|
||||||
|
import { getAllChunks } from '../storage/chunk-store.js';
|
||||||
|
import { xbLog } from '../../../../core/debug-core.js';
|
||||||
|
|
||||||
|
const MODULE_ID = 'lexical-index';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 缓存
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
let cachedIndex = null;
|
||||||
|
let cachedChatId = null;
|
||||||
|
let cachedFingerprint = null; // atoms.length + chunks.length + events.length 的简单指纹
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 工具函数
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理事件摘要(移除楼层标记)
|
||||||
|
* @param {string} summary
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function cleanSummary(summary) {
|
||||||
|
return String(summary || '')
|
||||||
|
.replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 计算缓存指纹(用于判断是否需要重建)
|
||||||
|
* @param {number} atomCount
|
||||||
|
* @param {number} chunkCount
|
||||||
|
* @param {number} eventCount
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function computeFingerprint(atomCount, chunkCount, eventCount) {
|
||||||
|
return `${atomCount}:${chunkCount}:${eventCount}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 索引构建
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建 MiniSearch 索引
|
||||||
|
*
|
||||||
|
* 索引三类文档:
|
||||||
|
* - L0 atoms: { id: atomId, type: 'atom', floor, text: semantic }
|
||||||
|
* - L1 chunks: { id: chunkId, type: 'chunk', floor, text: chunk.text }
|
||||||
|
* - L2 events: { id: eventId, type: 'event', floor: null, text: title + participants + summary }
|
||||||
|
*
|
||||||
|
* @param {object[]} atoms - getStateAtoms() 返回值
|
||||||
|
* @param {object[]} chunks - getAllChunks(chatId) 返回值
|
||||||
|
* @param {object[]} events - store.json.events
|
||||||
|
* @returns {MiniSearch}
|
||||||
|
*/
|
||||||
|
export function buildLexicalIndex(atoms, chunks, events) {
|
||||||
|
const T0 = performance.now();
|
||||||
|
|
||||||
|
const index = new MiniSearch({
|
||||||
|
fields: ['text'],
|
||||||
|
storeFields: ['type', 'floor'],
|
||||||
|
idField: 'id',
|
||||||
|
searchOptions: {
|
||||||
|
boost: { text: 1 },
|
||||||
|
fuzzy: 0.2,
|
||||||
|
prefix: true,
|
||||||
|
},
|
||||||
|
// 中文友好的 tokenizer:按字符 bigram + 空格/标点分词
|
||||||
|
tokenize: chineseTokenize,
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = [];
|
||||||
|
|
||||||
|
// L0 atoms
|
||||||
|
for (const atom of (atoms || [])) {
|
||||||
|
if (!atom?.atomId || !atom.semantic) continue;
|
||||||
|
docs.push({
|
||||||
|
id: atom.atomId,
|
||||||
|
type: 'atom',
|
||||||
|
floor: atom.floor ?? -1,
|
||||||
|
text: atom.semantic,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// L1 chunks
|
||||||
|
for (const chunk of (chunks || [])) {
|
||||||
|
if (!chunk?.chunkId || !chunk.text) continue;
|
||||||
|
docs.push({
|
||||||
|
id: chunk.chunkId,
|
||||||
|
type: 'chunk',
|
||||||
|
floor: chunk.floor ?? -1,
|
||||||
|
text: chunk.text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// L2 events
|
||||||
|
for (const ev of (events || [])) {
|
||||||
|
if (!ev?.id) continue;
|
||||||
|
const parts = [];
|
||||||
|
if (ev.title) parts.push(ev.title);
|
||||||
|
if (ev.participants?.length) parts.push(ev.participants.join(' '));
|
||||||
|
const summary = cleanSummary(ev.summary);
|
||||||
|
if (summary) parts.push(summary);
|
||||||
|
const text = parts.join(' ').trim();
|
||||||
|
if (!text) continue;
|
||||||
|
|
||||||
|
docs.push({
|
||||||
|
id: ev.id,
|
||||||
|
type: 'event',
|
||||||
|
floor: null,
|
||||||
|
text,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (docs.length > 0) {
|
||||||
|
index.addAll(docs);
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsed = Math.round(performance.now() - T0);
|
||||||
|
xbLog.info(MODULE_ID, `索引构建完成: ${docs.length} 文档 (atoms=${atoms?.length || 0}, chunks=${chunks?.length || 0}, events=${events?.length || 0}) ${elapsed}ms`);
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 中文 Tokenizer
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中文友好的分词器
|
||||||
|
*
|
||||||
|
* 策略:
|
||||||
|
* 1. 连续中文字符 → 滑动 bigram("黄英梅" → "黄英", "英梅")
|
||||||
|
* 2. 连续非中文字符 → 按空格/标点分割
|
||||||
|
* 3. 保留完整中文词(2-4字)作为额外 token
|
||||||
|
*
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function chineseTokenize(text) {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
const tokens = [];
|
||||||
|
const s = String(text).toLowerCase();
|
||||||
|
|
||||||
|
// 分离中文段和非中文段
|
||||||
|
const segments = s.split(/([\u4e00-\u9fff]+)/g);
|
||||||
|
|
||||||
|
for (const seg of segments) {
|
||||||
|
if (!seg) continue;
|
||||||
|
|
||||||
|
// 中文段:bigram + 完整段(如果 2-6 字)
|
||||||
|
if (/^[\u4e00-\u9fff]+$/.test(seg)) {
|
||||||
|
// 完整段作为一个 token(如果长度合适)
|
||||||
|
if (seg.length >= 2 && seg.length <= 6) {
|
||||||
|
tokens.push(seg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// bigram
|
||||||
|
for (let i = 0; i < seg.length - 1; i++) {
|
||||||
|
tokens.push(seg.slice(i, i + 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// trigram(对 3+ 字的段)
|
||||||
|
for (let i = 0; i < seg.length - 2; i++) {
|
||||||
|
tokens.push(seg.slice(i, i + 3));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 非中文段:按空格/标点分割
|
||||||
|
const words = seg.split(/[\s\-_.,;:!?'"()[\]{}<>/\\|@#$%^&*+=~`]+/);
|
||||||
|
for (const w of words) {
|
||||||
|
const trimmed = w.trim();
|
||||||
|
if (trimmed.length >= 2) {
|
||||||
|
tokens.push(trimmed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokens;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 检索
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} LexicalSearchResult
|
||||||
|
* @property {string[]} atomIds - 命中的 L0 atom IDs
|
||||||
|
* @property {Set<number>} atomFloors - 命中的 L0 楼层集合
|
||||||
|
* @property {string[]} chunkIds - 命中的 L1 chunk IDs
|
||||||
|
* @property {Set<number>} chunkFloors - 命中的 L1 楼层集合
|
||||||
|
* @property {string[]} eventIds - 命中的 L2 event IDs
|
||||||
|
* @property {object[]} chunkScores - chunk 命中详情 [{ chunkId, score }]
|
||||||
|
* @property {number} searchTime - 检索耗时 ms
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在词法索引中检索
|
||||||
|
*
|
||||||
|
* @param {MiniSearch} index - 索引实例
|
||||||
|
* @param {string[]} terms - 查询词列表
|
||||||
|
* @returns {LexicalSearchResult}
|
||||||
|
*/
|
||||||
|
export function searchLexicalIndex(index, terms) {
|
||||||
|
const T0 = performance.now();
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
atomIds: [],
|
||||||
|
atomFloors: new Set(),
|
||||||
|
chunkIds: [],
|
||||||
|
chunkFloors: new Set(),
|
||||||
|
eventIds: [],
|
||||||
|
chunkScores: [],
|
||||||
|
searchTime: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!index || !terms?.length) {
|
||||||
|
result.searchTime = Math.round(performance.now() - T0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用所有 terms 联合查询
|
||||||
|
const queryString = terms.join(' ');
|
||||||
|
|
||||||
|
let hits;
|
||||||
|
try {
|
||||||
|
hits = index.search(queryString, {
|
||||||
|
boost: { text: 1 },
|
||||||
|
fuzzy: 0.2,
|
||||||
|
prefix: true,
|
||||||
|
combineWith: 'OR',
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.warn(MODULE_ID, '检索失败', e);
|
||||||
|
result.searchTime = Math.round(performance.now() - T0);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分类结果
|
||||||
|
const atomIdSet = new Set();
|
||||||
|
const chunkIdSet = new Set();
|
||||||
|
const eventIdSet = new Set();
|
||||||
|
|
||||||
|
for (const hit of hits) {
|
||||||
|
const type = hit.type;
|
||||||
|
const id = hit.id;
|
||||||
|
const floor = hit.floor;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'atom':
|
||||||
|
if (!atomIdSet.has(id)) {
|
||||||
|
atomIdSet.add(id);
|
||||||
|
result.atomIds.push(id);
|
||||||
|
if (typeof floor === 'number' && floor >= 0) {
|
||||||
|
result.atomFloors.add(floor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'chunk':
|
||||||
|
if (!chunkIdSet.has(id)) {
|
||||||
|
chunkIdSet.add(id);
|
||||||
|
result.chunkIds.push(id);
|
||||||
|
result.chunkScores.push({ chunkId: id, score: hit.score });
|
||||||
|
if (typeof floor === 'number' && floor >= 0) {
|
||||||
|
result.chunkFloors.add(floor);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'event':
|
||||||
|
if (!eventIdSet.has(id)) {
|
||||||
|
eventIdSet.add(id);
|
||||||
|
result.eventIds.push(id);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result.searchTime = Math.round(performance.now() - T0);
|
||||||
|
|
||||||
|
xbLog.info(MODULE_ID,
|
||||||
|
`检索完成: terms=[${terms.slice(0, 5).join(',')}] → atoms=${result.atomIds.length} chunks=${result.chunkIds.length} events=${result.eventIds.length} (${result.searchTime}ms)`
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 惰性缓存管理
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取词法索引(惰性构建 + 缓存)
|
||||||
|
*
|
||||||
|
* 如果缓存有效则直接返回;否则自动构建。
|
||||||
|
* 缓存失效条件:chatId 变化 / 数据指纹变化 / 手动 invalidate
|
||||||
|
*
|
||||||
|
* @returns {Promise<MiniSearch>}
|
||||||
|
*/
|
||||||
|
export async function getLexicalIndex() {
|
||||||
|
const { chatId } = getContext();
|
||||||
|
if (!chatId) return null;
|
||||||
|
|
||||||
|
// 收集当前数据
|
||||||
|
const atoms = getStateAtoms() || [];
|
||||||
|
const store = getSummaryStore();
|
||||||
|
const events = store?.json?.events || [];
|
||||||
|
|
||||||
|
let chunks = [];
|
||||||
|
try {
|
||||||
|
chunks = await getAllChunks(chatId);
|
||||||
|
} catch (e) {
|
||||||
|
xbLog.warn(MODULE_ID, '获取 chunks 失败', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fp = computeFingerprint(atoms.length, chunks.length, events.length);
|
||||||
|
|
||||||
|
// 缓存命中
|
||||||
|
if (cachedIndex && cachedChatId === chatId && cachedFingerprint === fp) {
|
||||||
|
return cachedIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重建
|
||||||
|
xbLog.info(MODULE_ID, `缓存失效,重建索引 (chatId=${chatId.slice(0, 8)}, fp=${fp})`);
|
||||||
|
|
||||||
|
const index = buildLexicalIndex(atoms, chunks, events);
|
||||||
|
|
||||||
|
cachedIndex = index;
|
||||||
|
cachedChatId = chatId;
|
||||||
|
cachedFingerprint = fp;
|
||||||
|
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 使缓存失效(下次 getLexicalIndex 时自动重建)
|
||||||
|
*
|
||||||
|
* 调用时机:
|
||||||
|
* - CHAT_CHANGED
|
||||||
|
* - L0 提取完成(handleAnchorGenerate 完成后)
|
||||||
|
* - L2 总结完成(onComplete 回调中)
|
||||||
|
*/
|
||||||
|
export function invalidateLexicalIndex() {
|
||||||
|
if (cachedIndex) {
|
||||||
|
xbLog.info(MODULE_ID, '索引缓存已失效');
|
||||||
|
}
|
||||||
|
cachedIndex = null;
|
||||||
|
cachedChatId = null;
|
||||||
|
cachedFingerprint = null;
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
// Story Summary - Metrics Collector (v2 - 统一命名)
|
// Story Summary - Metrics Collector (v3 - Deterministic Query + Hybrid + W-RRF)
|
||||||
//
|
//
|
||||||
// 命名规范:
|
// 命名规范:
|
||||||
// - 存储层用 L0/L1/L2/L3(StateAtom/Chunk/Event/Fact)
|
// - 存储层用 L0/L1/L2/L3(StateAtom/Chunk/Event/Fact)
|
||||||
@@ -8,21 +8,44 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建空的指标对象
|
* 创建空的指标对象
|
||||||
* @returns {object} 指标对象
|
* @returns {object}
|
||||||
*/
|
*/
|
||||||
export function createMetrics() {
|
export function createMetrics() {
|
||||||
return {
|
return {
|
||||||
|
// Query Build - 查询构建
|
||||||
|
query: {
|
||||||
|
buildTime: 0,
|
||||||
|
refineTime: 0,
|
||||||
|
},
|
||||||
|
|
||||||
// Anchor (L0 StateAtoms) - 语义锚点
|
// Anchor (L0 StateAtoms) - 语义锚点
|
||||||
anchor: {
|
anchor: {
|
||||||
needRecall: false,
|
needRecall: false,
|
||||||
focusEntities: [],
|
focusEntities: [],
|
||||||
queries: [],
|
|
||||||
queryExpansionTime: 0,
|
|
||||||
matched: 0,
|
matched: 0,
|
||||||
floorsHit: 0,
|
floorsHit: 0,
|
||||||
topHits: [],
|
topHits: [],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Lexical (MiniSearch) - 词法检索
|
||||||
|
lexical: {
|
||||||
|
terms: [],
|
||||||
|
atomHits: 0,
|
||||||
|
chunkHits: 0,
|
||||||
|
eventHits: 0,
|
||||||
|
searchTime: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Fusion (W-RRF) - 多路融合
|
||||||
|
fusion: {
|
||||||
|
denseCount: 0,
|
||||||
|
lexCount: 0,
|
||||||
|
anchorCount: 0,
|
||||||
|
totalUnique: 0,
|
||||||
|
afterCap: 0,
|
||||||
|
time: 0,
|
||||||
|
},
|
||||||
|
|
||||||
// Constraint (L3 Facts) - 世界约束
|
// Constraint (L3 Facts) - 世界约束
|
||||||
constraint: {
|
constraint: {
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -37,7 +60,7 @@ export function createMetrics() {
|
|||||||
inStore: 0,
|
inStore: 0,
|
||||||
considered: 0,
|
considered: 0,
|
||||||
selected: 0,
|
selected: 0,
|
||||||
byRecallType: { direct: 0, related: 0, causal: 0 },
|
byRecallType: { direct: 0, related: 0, causal: 0, lexical: 0 },
|
||||||
similarityDistribution: { min: 0, max: 0, mean: 0, median: 0 },
|
similarityDistribution: { min: 0, max: 0, mean: 0, median: 0 },
|
||||||
entityFilter: null,
|
entityFilter: null,
|
||||||
causalChainDepth: 0,
|
causalChainDepth: 0,
|
||||||
@@ -50,7 +73,7 @@ export function createMetrics() {
|
|||||||
evidence: {
|
evidence: {
|
||||||
floorsFromAnchors: 0,
|
floorsFromAnchors: 0,
|
||||||
chunkTotal: 0,
|
chunkTotal: 0,
|
||||||
chunkAfterCoarse: 0,
|
denseCoarse: 0,
|
||||||
merged: 0,
|
merged: 0,
|
||||||
mergedByType: { anchorVirtual: 0, chunkReal: 0 },
|
mergedByType: { anchorVirtual: 0, chunkReal: 0 },
|
||||||
selected: 0,
|
selected: 0,
|
||||||
@@ -93,8 +116,11 @@ export function createMetrics() {
|
|||||||
|
|
||||||
// Timing - 计时
|
// Timing - 计时
|
||||||
timing: {
|
timing: {
|
||||||
queryExpansion: 0,
|
queryBuild: 0,
|
||||||
|
queryRefine: 0,
|
||||||
anchorSearch: 0,
|
anchorSearch: 0,
|
||||||
|
lexicalSearch: 0,
|
||||||
|
fusion: 0,
|
||||||
constraintFilter: 0,
|
constraintFilter: 0,
|
||||||
eventRetrieval: 0,
|
eventRetrieval: 0,
|
||||||
evidenceRetrieval: 0,
|
evidenceRetrieval: 0,
|
||||||
@@ -109,6 +135,7 @@ export function createMetrics() {
|
|||||||
constraintCoverage: 100,
|
constraintCoverage: 100,
|
||||||
eventPrecisionProxy: 0,
|
eventPrecisionProxy: 0,
|
||||||
evidenceDensity: 0,
|
evidenceDensity: 0,
|
||||||
|
chunkRealRatio: 0,
|
||||||
potentialIssues: [],
|
potentialIssues: [],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -116,7 +143,7 @@ export function createMetrics() {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 计算相似度分布统计
|
* 计算相似度分布统计
|
||||||
* @param {number[]} similarities - 相似度数组
|
* @param {number[]} similarities
|
||||||
* @returns {{min: number, max: number, mean: number, median: number}}
|
* @returns {{min: number, max: number, mean: number, median: number}}
|
||||||
*/
|
*/
|
||||||
export function calcSimilarityStats(similarities) {
|
export function calcSimilarityStats(similarities) {
|
||||||
@@ -137,8 +164,8 @@ export function calcSimilarityStats(similarities) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 格式化指标为可读日志
|
* 格式化指标为可读日志
|
||||||
* @param {object} metrics - 指标对象
|
* @param {object} metrics
|
||||||
* @returns {string} 格式化后的日志
|
* @returns {string}
|
||||||
*/
|
*/
|
||||||
export function formatMetricsLog(metrics) {
|
export function formatMetricsLog(metrics) {
|
||||||
const m = metrics;
|
const m = metrics;
|
||||||
@@ -150,18 +177,41 @@ export function formatMetricsLog(metrics) {
|
|||||||
lines.push('════════════════════════════════════════');
|
lines.push('════════════════════════════════════════');
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
|
// Query Build
|
||||||
|
lines.push('[Query] 查询构建');
|
||||||
|
lines.push(`├─ build_time: ${m.query.buildTime}ms`);
|
||||||
|
lines.push(`└─ refine_time: ${m.query.refineTime}ms`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
// Anchor (L0 StateAtoms)
|
// Anchor (L0 StateAtoms)
|
||||||
lines.push('[Anchor] L0 StateAtoms - 语义锚点');
|
lines.push('[Anchor] L0 StateAtoms - 语义锚点');
|
||||||
lines.push(`├─ need_recall: ${m.anchor.needRecall}`);
|
lines.push(`├─ need_recall: ${m.anchor.needRecall}`);
|
||||||
if (m.anchor.needRecall) {
|
if (m.anchor.needRecall) {
|
||||||
lines.push(`├─ focus_entities: [${(m.anchor.focusEntities || []).join(', ')}]`);
|
lines.push(`├─ focus_entities: [${(m.anchor.focusEntities || []).join(', ')}]`);
|
||||||
lines.push(`├─ queries: [${(m.anchor.queries || []).slice(0, 3).join(', ')}]`);
|
|
||||||
lines.push(`├─ query_expansion_time: ${m.anchor.queryExpansionTime}ms`);
|
|
||||||
lines.push(`├─ matched: ${m.anchor.matched || 0}`);
|
lines.push(`├─ matched: ${m.anchor.matched || 0}`);
|
||||||
lines.push(`└─ floors_hit: ${m.anchor.floorsHit || 0}`);
|
lines.push(`└─ floors_hit: ${m.anchor.floorsHit || 0}`);
|
||||||
}
|
}
|
||||||
lines.push('');
|
lines.push('');
|
||||||
|
|
||||||
|
// Lexical (MiniSearch)
|
||||||
|
lines.push('[Lexical] MiniSearch - 词法检索');
|
||||||
|
lines.push(`├─ terms: [${(m.lexical.terms || []).slice(0, 8).join(', ')}]`);
|
||||||
|
lines.push(`├─ atom_hits: ${m.lexical.atomHits}`);
|
||||||
|
lines.push(`├─ chunk_hits: ${m.lexical.chunkHits}`);
|
||||||
|
lines.push(`├─ event_hits: ${m.lexical.eventHits}`);
|
||||||
|
lines.push(`└─ search_time: ${m.lexical.searchTime}ms`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
// Fusion (W-RRF)
|
||||||
|
lines.push('[Fusion] W-RRF - 多路融合');
|
||||||
|
lines.push(`├─ dense_count: ${m.fusion.denseCount}`);
|
||||||
|
lines.push(`├─ lex_count: ${m.fusion.lexCount}`);
|
||||||
|
lines.push(`├─ anchor_count: ${m.fusion.anchorCount}`);
|
||||||
|
lines.push(`├─ total_unique: ${m.fusion.totalUnique}`);
|
||||||
|
lines.push(`├─ after_cap: ${m.fusion.afterCap}`);
|
||||||
|
lines.push(`└─ time: ${m.fusion.time}ms`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
// Constraint (L3 Facts)
|
// Constraint (L3 Facts)
|
||||||
lines.push('[Constraint] L3 Facts - 世界约束');
|
lines.push('[Constraint] L3 Facts - 世界约束');
|
||||||
lines.push(`├─ total: ${m.constraint.total}`);
|
lines.push(`├─ total: ${m.constraint.total}`);
|
||||||
@@ -191,7 +241,8 @@ export function formatMetricsLog(metrics) {
|
|||||||
lines.push(`├─ by_recall_type:`);
|
lines.push(`├─ by_recall_type:`);
|
||||||
lines.push(`│ ├─ direct: ${m.event.byRecallType.direct}`);
|
lines.push(`│ ├─ direct: ${m.event.byRecallType.direct}`);
|
||||||
lines.push(`│ ├─ related: ${m.event.byRecallType.related}`);
|
lines.push(`│ ├─ related: ${m.event.byRecallType.related}`);
|
||||||
lines.push(`│ └─ causal: ${m.event.byRecallType.causal}`);
|
lines.push(`│ ├─ causal: ${m.event.byRecallType.causal}`);
|
||||||
|
lines.push(`│ └─ lexical: ${m.event.byRecallType.lexical}`);
|
||||||
|
|
||||||
const sim = m.event.similarityDistribution;
|
const sim = m.event.similarityDistribution;
|
||||||
if (sim && sim.max > 0) {
|
if (sim && sim.max > 0) {
|
||||||
@@ -210,12 +261,9 @@ export function formatMetricsLog(metrics) {
|
|||||||
lines.push('[Evidence] L1 Chunks - 原文证据');
|
lines.push('[Evidence] L1 Chunks - 原文证据');
|
||||||
lines.push(`├─ floors_from_anchors: ${m.evidence.floorsFromAnchors}`);
|
lines.push(`├─ floors_from_anchors: ${m.evidence.floorsFromAnchors}`);
|
||||||
|
|
||||||
// 粗筛信息
|
|
||||||
if (m.evidence.chunkTotal > 0) {
|
if (m.evidence.chunkTotal > 0) {
|
||||||
lines.push(`├─ coarse_filter:`);
|
lines.push(`├─ chunk_total: ${m.evidence.chunkTotal}`);
|
||||||
lines.push(`│ ├─ total: ${m.evidence.chunkTotal}`);
|
lines.push(`├─ dense_coarse: ${m.evidence.denseCoarse}`);
|
||||||
lines.push(`│ ├─ after: ${m.evidence.chunkAfterCoarse}`);
|
|
||||||
lines.push(`│ └─ filtered: ${m.evidence.chunkTotal - m.evidence.chunkAfterCoarse}`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lines.push(`├─ merged: ${m.evidence.merged}`);
|
lines.push(`├─ merged: ${m.evidence.merged}`);
|
||||||
@@ -225,7 +273,6 @@ export function formatMetricsLog(metrics) {
|
|||||||
lines.push(`│ └─ chunk_real: ${mt.chunkReal || 0}`);
|
lines.push(`│ └─ chunk_real: ${mt.chunkReal || 0}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Rerank 信息
|
|
||||||
if (m.evidence.rerankApplied) {
|
if (m.evidence.rerankApplied) {
|
||||||
lines.push(`├─ rerank_applied: true`);
|
lines.push(`├─ rerank_applied: true`);
|
||||||
lines.push(`│ ├─ before: ${m.evidence.beforeRerank}`);
|
lines.push(`│ ├─ before: ${m.evidence.beforeRerank}`);
|
||||||
@@ -281,8 +328,11 @@ export function formatMetricsLog(metrics) {
|
|||||||
|
|
||||||
// Timing
|
// Timing
|
||||||
lines.push('[Timing] 计时');
|
lines.push('[Timing] 计时');
|
||||||
lines.push(`├─ query_expansion: ${m.timing.queryExpansion}ms`);
|
lines.push(`├─ query_build: ${m.query.buildTime}ms`);
|
||||||
|
lines.push(`├─ query_refine: ${m.query.refineTime}ms`);
|
||||||
lines.push(`├─ anchor_search: ${m.timing.anchorSearch}ms`);
|
lines.push(`├─ anchor_search: ${m.timing.anchorSearch}ms`);
|
||||||
|
lines.push(`├─ lexical_search: ${m.lexical.searchTime}ms`);
|
||||||
|
lines.push(`├─ fusion: ${m.fusion.time}ms`);
|
||||||
lines.push(`├─ constraint_filter: ${m.timing.constraintFilter}ms`);
|
lines.push(`├─ constraint_filter: ${m.timing.constraintFilter}ms`);
|
||||||
lines.push(`├─ event_retrieval: ${m.timing.eventRetrieval}ms`);
|
lines.push(`├─ event_retrieval: ${m.timing.eventRetrieval}ms`);
|
||||||
lines.push(`├─ evidence_retrieval: ${m.timing.evidenceRetrieval}ms`);
|
lines.push(`├─ evidence_retrieval: ${m.timing.evidenceRetrieval}ms`);
|
||||||
@@ -299,6 +349,7 @@ export function formatMetricsLog(metrics) {
|
|||||||
lines.push(`├─ constraint_coverage: ${m.quality.constraintCoverage}%`);
|
lines.push(`├─ constraint_coverage: ${m.quality.constraintCoverage}%`);
|
||||||
lines.push(`├─ event_precision_proxy: ${m.quality.eventPrecisionProxy}`);
|
lines.push(`├─ event_precision_proxy: ${m.quality.eventPrecisionProxy}`);
|
||||||
lines.push(`├─ evidence_density: ${m.quality.evidenceDensity}%`);
|
lines.push(`├─ evidence_density: ${m.quality.evidenceDensity}%`);
|
||||||
|
lines.push(`├─ chunk_real_ratio: ${m.quality.chunkRealRatio}%`);
|
||||||
|
|
||||||
if (m.quality.potentialIssues && m.quality.potentialIssues.length > 0) {
|
if (m.quality.potentialIssues && m.quality.potentialIssues.length > 0) {
|
||||||
lines.push(`└─ potential_issues:`);
|
lines.push(`└─ potential_issues:`);
|
||||||
@@ -319,14 +370,53 @@ export function formatMetricsLog(metrics) {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 检测潜在问题
|
* 检测潜在问题
|
||||||
* @param {object} metrics - 指标对象
|
* @param {object} metrics
|
||||||
* @returns {string[]} 问题列表
|
* @returns {string[]}
|
||||||
*/
|
*/
|
||||||
export function detectIssues(metrics) {
|
export function detectIssues(metrics) {
|
||||||
const issues = [];
|
const issues = [];
|
||||||
const m = metrics;
|
const m = metrics;
|
||||||
|
|
||||||
// 事件召回比例问题
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// 查询构建问题
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ((m.anchor.focusEntities || []).length === 0) {
|
||||||
|
issues.push('No focus entities extracted - entity lexicon may be empty or messages too short');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// 锚点匹配问题
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ((m.anchor.matched || 0) === 0 && m.anchor.needRecall) {
|
||||||
|
issues.push('No anchors matched - may need to generate anchors');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// 词法检索问题
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if ((m.lexical.terms || []).length > 0 && m.lexical.atomHits === 0 && m.lexical.chunkHits === 0 && m.lexical.eventHits === 0) {
|
||||||
|
issues.push('Lexical search returned zero hits - terms may not match any indexed content');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// 融合问题
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (m.fusion.lexCount === 0 && m.fusion.denseCount > 0) {
|
||||||
|
issues.push('No lexical candidates in fusion - hybrid retrieval not contributing');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (m.fusion.afterCap === 0) {
|
||||||
|
issues.push('Fusion produced zero candidates - all retrieval paths may have failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
// 事件召回问题
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (m.event.considered > 0) {
|
if (m.event.considered > 0) {
|
||||||
const selectRatio = m.event.selected / m.event.considered;
|
const selectRatio = m.event.selected / m.event.considered;
|
||||||
if (selectRatio < 0.1) {
|
if (selectRatio < 0.1) {
|
||||||
@@ -341,7 +431,7 @@ export function detectIssues(metrics) {
|
|||||||
if (m.event.entityFilter) {
|
if (m.event.entityFilter) {
|
||||||
const ef = m.event.entityFilter;
|
const ef = m.event.entityFilter;
|
||||||
if (ef.filtered === 0 && ef.before > 10) {
|
if (ef.filtered === 0 && ef.before > 10) {
|
||||||
issues.push(`No events filtered by entity - focus entities may be too broad or missing`);
|
issues.push('No events filtered by entity - focus entities may be too broad or missing');
|
||||||
}
|
}
|
||||||
if (ef.before > 0 && ef.filtered > ef.before * 0.8) {
|
if (ef.before > 0 && ef.filtered > ef.before * 0.8) {
|
||||||
issues.push(`Too many events filtered (${ef.filtered}/${ef.before}) - focus may be too narrow`);
|
issues.push(`Too many events filtered (${ef.filtered}/${ef.before}) - focus may be too narrow`);
|
||||||
@@ -355,19 +445,18 @@ export function detectIssues(metrics) {
|
|||||||
|
|
||||||
// 因果链问题
|
// 因果链问题
|
||||||
if (m.event.selected > 0 && m.event.causalCount === 0 && m.event.byRecallType.direct === 0) {
|
if (m.event.selected > 0 && m.event.causalCount === 0 && m.event.byRecallType.direct === 0) {
|
||||||
issues.push('No direct or causal events - query expansion may be inaccurate');
|
issues.push('No direct or causal events - query may not align with stored events');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 锚点匹配问题
|
// ─────────────────────────────────────────────────────────────────
|
||||||
if ((m.anchor.matched || 0) === 0) {
|
// 证据问题
|
||||||
issues.push('No anchors matched - may need to generate anchors');
|
// ─────────────────────────────────────────────────────────────────
|
||||||
}
|
|
||||||
|
|
||||||
// 证据粗筛问题
|
// Dense 粗筛比例
|
||||||
if (m.evidence.chunkTotal > 0 && m.evidence.chunkAfterCoarse > 0) {
|
if (m.evidence.chunkTotal > 0 && m.evidence.denseCoarse > 0) {
|
||||||
const coarseFilterRatio = 1 - (m.evidence.chunkAfterCoarse / m.evidence.chunkTotal);
|
const coarseFilterRatio = 1 - (m.evidence.denseCoarse / m.evidence.chunkTotal);
|
||||||
if (coarseFilterRatio > 0.9) {
|
if (coarseFilterRatio > 0.95) {
|
||||||
issues.push(`Very high evidence coarse filter ratio (${(coarseFilterRatio * 100).toFixed(0)}%) - query may be too specific`);
|
issues.push(`Very high dense coarse filter ratio (${(coarseFilterRatio * 100).toFixed(0)}%) - query vector may be poorly aligned`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +465,7 @@ export function detectIssues(metrics) {
|
|||||||
if (m.evidence.beforeRerank > 0 && m.evidence.afterRerank > 0) {
|
if (m.evidence.beforeRerank > 0 && m.evidence.afterRerank > 0) {
|
||||||
const filterRatio = 1 - (m.evidence.afterRerank / m.evidence.beforeRerank);
|
const filterRatio = 1 - (m.evidence.afterRerank / m.evidence.beforeRerank);
|
||||||
if (filterRatio > 0.7) {
|
if (filterRatio > 0.7) {
|
||||||
issues.push(`High rerank filter ratio (${(filterRatio * 100).toFixed(0)}%) - many irrelevant chunks removed`);
|
issues.push(`High rerank filter ratio (${(filterRatio * 100).toFixed(0)}%) - many irrelevant chunks in fusion output`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -395,24 +484,36 @@ export function detectIssues(metrics) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 证据密度问题
|
// chunk_real 比例(核心质量指标)
|
||||||
if (m.evidence.selected > 0 && m.evidence.selectedByType) {
|
if (m.evidence.selected > 0 && m.evidence.selectedByType) {
|
||||||
const chunkReal = m.evidence.selectedByType.chunkReal || 0;
|
const chunkReal = m.evidence.selectedByType.chunkReal || 0;
|
||||||
const density = chunkReal / m.evidence.selected;
|
const ratio = chunkReal / m.evidence.selected;
|
||||||
if (density < 0.3 && m.evidence.selected > 10) {
|
if (ratio === 0 && m.evidence.selected > 5) {
|
||||||
issues.push(`Low real chunk ratio in selected (${(density * 100).toFixed(0)}%) - may lack concrete evidence`);
|
issues.push('Zero real chunks in selected evidence - only anchor virtual chunks present');
|
||||||
|
} else if (ratio < 0.2 && m.evidence.selected > 10) {
|
||||||
|
issues.push(`Low real chunk ratio (${(ratio * 100).toFixed(0)}%) - may lack concrete dialogue evidence`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
// 预算问题
|
// 预算问题
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
if (m.budget.utilization > 90) {
|
if (m.budget.utilization > 90) {
|
||||||
issues.push(`High budget utilization (${m.budget.utilization}%) - may be truncating content`);
|
issues.push(`High budget utilization (${m.budget.utilization}%) - may be truncating content`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────
|
||||||
// 性能问题
|
// 性能问题
|
||||||
if (m.timing.total > 5000) {
|
// ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (m.timing.total > 8000) {
|
||||||
issues.push(`Slow recall (${m.timing.total}ms) - consider optimization`);
|
issues.push(`Slow recall (${m.timing.total}ms) - consider optimization`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (m.query.buildTime > 100) {
|
||||||
|
issues.push(`Slow query build (${m.query.buildTime}ms) - entity lexicon may be too large`);
|
||||||
|
}
|
||||||
|
|
||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|||||||
341
modules/story-summary/vector/retrieval/query-builder.js
Normal file
341
modules/story-summary/vector/retrieval/query-builder.js
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// query-builder.js - 确定性查询构建器(无 LLM)
|
||||||
|
//
|
||||||
|
// 职责:
|
||||||
|
// 1. 从最近消息 + 实体词典构建 QueryBundle_v0
|
||||||
|
// 2. 用第一轮召回结果增强为 QueryBundle_v1
|
||||||
|
//
|
||||||
|
// 不负责:向量化、检索、rerank
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
import { getContext } from '../../../../../../../extensions.js';
|
||||||
|
import { buildEntityLexicon, buildDisplayNameMap, extractEntitiesFromText } from './entity-lexicon.js';
|
||||||
|
import { getSummaryStore } from '../../data/store.js';
|
||||||
|
import { filterText } from '../utils/text-filter.js';
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 常量
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const DIALOGUE_MAX_CHARS = 400;
|
||||||
|
const PENDING_MAX_CHARS = 400;
|
||||||
|
const MEMORY_HINT_MAX_CHARS = 100;
|
||||||
|
const MEMORY_HINT_ATOMS_MAX = 5;
|
||||||
|
const MEMORY_HINT_EVENTS_MAX = 3;
|
||||||
|
const RERANK_QUERY_MAX_CHARS = 500;
|
||||||
|
const RERANK_SNIPPET_CHARS = 150;
|
||||||
|
const LEXICAL_TERMS_MAX = 10;
|
||||||
|
const LEXICAL_TERM_MIN_LEN = 2;
|
||||||
|
const LEXICAL_TERM_MAX_LEN = 6;
|
||||||
|
|
||||||
|
// 中文停用词(高频无意义词)
|
||||||
|
const STOP_WORDS = new Set([
|
||||||
|
'的', '了', '在', '是', '我', '有', '和', '就', '不', '人',
|
||||||
|
'都', '一', '一个', '上', '也', '很', '到', '说', '要', '去',
|
||||||
|
'你', '会', '着', '没有', '看', '好', '自己', '这', '他', '她',
|
||||||
|
'它', '吗', '什么', '那', '里', '来', '吧', '呢', '啊', '哦',
|
||||||
|
'嗯', '呀', '哈', '嘿', '喂', '哎', '唉', '哇', '呃', '嘛',
|
||||||
|
'把', '被', '让', '给', '从', '向', '对', '跟', '比', '但',
|
||||||
|
'而', '或', '如果', '因为', '所以', '虽然', '但是', '然后',
|
||||||
|
'可以', '这样', '那样', '怎么', '为什么', '什么样', '哪里',
|
||||||
|
'时候', '现在', '已经', '还是', '只是', '可能', '应该', '知道',
|
||||||
|
'觉得', '开始', '一下', '一些', '这个', '那个', '他们', '我们',
|
||||||
|
'你们', '自己', '起来', '出来', '进去', '回来', '过来', '下去',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 工具函数
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清洗消息文本(与 chunk-builder / recall 保持一致)
|
||||||
|
* @param {string} text
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function cleanMessageText(text) {
|
||||||
|
return filterText(text)
|
||||||
|
.replace(/\[tts:[^\]]*\]/gi, '')
|
||||||
|
.replace(/<state>[\s\S]*?<\/state>/gi, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 截断文本到指定长度
|
||||||
|
* @param {string} text
|
||||||
|
* @param {number} maxLen
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function truncate(text, maxLen) {
|
||||||
|
if (!text || text.length <= maxLen) return text || '';
|
||||||
|
return text.slice(0, maxLen) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理事件摘要(移除楼层标记)
|
||||||
|
* @param {string} summary
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function cleanSummary(summary) {
|
||||||
|
return String(summary || '')
|
||||||
|
.replace(/\s*\(#\d+(?:-\d+)?\)\s*$/, '')
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从文本中提取高频实词(用于词法检索)
|
||||||
|
*
|
||||||
|
* 策略:按中文字符边界 + 空格/标点分词,取长度 2-6 的片段
|
||||||
|
* 过滤停用词,按频率排序
|
||||||
|
*
|
||||||
|
* @param {string} text - 清洗后的文本
|
||||||
|
* @param {number} maxTerms - 最大词数
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
function extractKeyTerms(text, maxTerms = LEXICAL_TERMS_MAX) {
|
||||||
|
if (!text) return [];
|
||||||
|
|
||||||
|
// 提取连续中文片段 + 英文单词
|
||||||
|
const segments = text.match(/[\u4e00-\u9fff]{2,6}|[a-zA-Z]{3,}/g) || [];
|
||||||
|
|
||||||
|
const freq = new Map();
|
||||||
|
for (const seg of segments) {
|
||||||
|
const s = seg.toLowerCase();
|
||||||
|
if (s.length < LEXICAL_TERM_MIN_LEN || s.length > LEXICAL_TERM_MAX_LEN) continue;
|
||||||
|
if (STOP_WORDS.has(s)) continue;
|
||||||
|
freq.set(s, (freq.get(s) || 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(freq.entries())
|
||||||
|
.sort((a, b) => b[1] - a[1])
|
||||||
|
.slice(0, maxTerms)
|
||||||
|
.map(([term]) => term);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// QueryBundle 类型定义(JSDoc)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {object} QueryBundle
|
||||||
|
* @property {string[]} focusEntities - 焦点实体(原词形,已排除 name1)
|
||||||
|
* @property {string} queryText_v0 - 第一轮查询文本
|
||||||
|
* @property {string|null} queryText_v1 - 第二轮查询文本(refinement 后填充)
|
||||||
|
* @property {string} rerankQuery - rerank 用的短查询
|
||||||
|
* @property {string[]} lexicalTerms - MiniSearch 查询词
|
||||||
|
* @property {Set<string>} _lexicon - 实体词典(内部使用)
|
||||||
|
* @property {Map<string, string>} _displayMap - 标准化→原词形映射(内部使用)
|
||||||
|
*/
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 阶段 1:构建 QueryBundle_v0
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建初始查询包
|
||||||
|
*
|
||||||
|
* @param {object[]} lastMessages - 最近 K=2 条消息
|
||||||
|
* @param {string|null} pendingUserMessage - 用户刚输入但未进 chat 的消息
|
||||||
|
* @param {object|null} store - getSummaryStore() 返回值(可选,内部会自动获取)
|
||||||
|
* @param {object|null} context - { name1, name2 }(可选,内部会自动获取)
|
||||||
|
* @returns {QueryBundle}
|
||||||
|
*/
|
||||||
|
export function buildQueryBundle(lastMessages, pendingUserMessage, store = null, context = null) {
|
||||||
|
// 自动获取 store 和 context
|
||||||
|
if (!store) store = getSummaryStore();
|
||||||
|
if (!context) {
|
||||||
|
const ctx = getContext();
|
||||||
|
context = { name1: ctx.name1, name2: ctx.name2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 构建实体词典
|
||||||
|
const lexicon = buildEntityLexicon(store, context);
|
||||||
|
const displayMap = buildDisplayNameMap(store, context);
|
||||||
|
|
||||||
|
// 2. 清洗消息文本
|
||||||
|
const dialogueLines = [];
|
||||||
|
const allCleanText = [];
|
||||||
|
|
||||||
|
for (const m of (lastMessages || [])) {
|
||||||
|
const speaker = m.is_user ? (context.name1 || '用户') : (m.name || context.name2 || '角色');
|
||||||
|
const clean = cleanMessageText(m.mes || '');
|
||||||
|
|
||||||
|
if (clean) {
|
||||||
|
// ★ 修复 A:不使用楼层号,embedding 模型不需要
|
||||||
|
dialogueLines.push(`${speaker}: ${truncate(clean, DIALOGUE_MAX_CHARS)}`);
|
||||||
|
allCleanText.push(clean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 处理 pendingUserMessage
|
||||||
|
let pendingClean = '';
|
||||||
|
if (pendingUserMessage) {
|
||||||
|
pendingClean = cleanMessageText(pendingUserMessage);
|
||||||
|
if (pendingClean) {
|
||||||
|
allCleanText.push(pendingClean);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 提取焦点实体
|
||||||
|
const combinedText = allCleanText.join(' ');
|
||||||
|
const focusEntities = extractEntitiesFromText(combinedText, lexicon, displayMap);
|
||||||
|
|
||||||
|
// 5. 构建 queryText_v0
|
||||||
|
const queryParts = [];
|
||||||
|
|
||||||
|
if (focusEntities.length > 0) {
|
||||||
|
queryParts.push(`[ENTITIES]\n${focusEntities.join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dialogueLines.length > 0) {
|
||||||
|
queryParts.push(`[DIALOGUE]\n${dialogueLines.join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingClean) {
|
||||||
|
queryParts.push(`[PENDING_USER]\n${truncate(pendingClean, PENDING_MAX_CHARS)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryText_v0 = queryParts.join('\n\n');
|
||||||
|
|
||||||
|
// 6. 构建 rerankQuery(短版)
|
||||||
|
const rerankParts = [];
|
||||||
|
|
||||||
|
if (focusEntities.length > 0) {
|
||||||
|
rerankParts.push(focusEntities.join(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const m of (lastMessages || [])) {
|
||||||
|
const clean = cleanMessageText(m.mes || '');
|
||||||
|
if (clean) {
|
||||||
|
rerankParts.push(truncate(clean, RERANK_SNIPPET_CHARS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingClean) {
|
||||||
|
rerankParts.push(truncate(pendingClean, RERANK_SNIPPET_CHARS));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rerankQuery = truncate(rerankParts.join('\n'), RERANK_QUERY_MAX_CHARS);
|
||||||
|
|
||||||
|
// 7. 构建 lexicalTerms
|
||||||
|
const entityTerms = focusEntities.map(e => e.toLowerCase());
|
||||||
|
const textTerms = extractKeyTerms(combinedText);
|
||||||
|
|
||||||
|
// 合并去重:实体优先
|
||||||
|
const termSet = new Set(entityTerms);
|
||||||
|
for (const t of textTerms) {
|
||||||
|
if (termSet.size >= LEXICAL_TERMS_MAX) break;
|
||||||
|
termSet.add(t);
|
||||||
|
}
|
||||||
|
|
||||||
|
const lexicalTerms = Array.from(termSet);
|
||||||
|
|
||||||
|
return {
|
||||||
|
focusEntities,
|
||||||
|
queryText_v0,
|
||||||
|
queryText_v1: null,
|
||||||
|
rerankQuery,
|
||||||
|
lexicalTerms,
|
||||||
|
_lexicon: lexicon,
|
||||||
|
_displayMap: displayMap,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
// 阶段 3:Query Refinement(用第一轮召回结果增强)
|
||||||
|
// ─────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用第一轮召回结果增强 QueryBundle
|
||||||
|
*
|
||||||
|
* 原地修改 bundle:
|
||||||
|
* - queryText_v1 = queryText_v0 + [MEMORY_HINTS]
|
||||||
|
* - focusEntities 可能扩展(从 anchorHits 的 subject/object 中补充)
|
||||||
|
* - rerankQuery 追加 memory hints 关键词
|
||||||
|
* - lexicalTerms 追加 memory hints 关键词
|
||||||
|
*
|
||||||
|
* @param {QueryBundle} bundle - 原始查询包
|
||||||
|
* @param {object[]} anchorHits - 第一轮 L0 命中(按相似度降序)
|
||||||
|
* @param {object[]} eventHits - 第一轮 L2 命中(按相似度降序)
|
||||||
|
*/
|
||||||
|
export function refineQueryBundle(bundle, anchorHits, eventHits) {
|
||||||
|
const hints = [];
|
||||||
|
|
||||||
|
// 1. 从 top anchorHits 提取 memory hints
|
||||||
|
const topAnchors = (anchorHits || []).slice(0, MEMORY_HINT_ATOMS_MAX);
|
||||||
|
for (const hit of topAnchors) {
|
||||||
|
const semantic = hit.atom?.semantic || '';
|
||||||
|
if (semantic) {
|
||||||
|
hints.push(truncate(semantic, MEMORY_HINT_MAX_CHARS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 从 top eventHits 提取 memory hints
|
||||||
|
const topEvents = (eventHits || []).slice(0, MEMORY_HINT_EVENTS_MAX);
|
||||||
|
for (const hit of topEvents) {
|
||||||
|
const ev = hit.event || {};
|
||||||
|
const title = String(ev.title || '').trim();
|
||||||
|
const summary = cleanSummary(ev.summary);
|
||||||
|
const line = title && summary
|
||||||
|
? `${title}: ${summary}`
|
||||||
|
: title || summary;
|
||||||
|
if (line) {
|
||||||
|
hints.push(truncate(line, MEMORY_HINT_MAX_CHARS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 构建 queryText_v1
|
||||||
|
if (hints.length > 0) {
|
||||||
|
bundle.queryText_v1 = bundle.queryText_v0 + `\n\n[MEMORY_HINTS]\n${hints.join('\n')}`;
|
||||||
|
} else {
|
||||||
|
bundle.queryText_v1 = bundle.queryText_v0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 从 anchorHits 补充 focusEntities
|
||||||
|
const lexicon = bundle._lexicon;
|
||||||
|
const displayMap = bundle._displayMap;
|
||||||
|
|
||||||
|
if (lexicon && topAnchors.length > 0) {
|
||||||
|
const existingSet = new Set(bundle.focusEntities.map(e => e.toLowerCase()));
|
||||||
|
|
||||||
|
for (const hit of topAnchors) {
|
||||||
|
const atom = hit.atom;
|
||||||
|
if (!atom) continue;
|
||||||
|
|
||||||
|
// 检查 subject 和 object
|
||||||
|
for (const field of [atom.subject, atom.object]) {
|
||||||
|
if (!field) continue;
|
||||||
|
const norm = String(field).normalize('NFKC').replace(/[\u200B-\u200D\uFEFF]/g, '').trim().toLowerCase();
|
||||||
|
if (norm.length >= 2 && lexicon.has(norm) && !existingSet.has(norm)) {
|
||||||
|
existingSet.add(norm);
|
||||||
|
const display = displayMap?.get(norm) || field;
|
||||||
|
bundle.focusEntities.push(display);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 增强 rerankQuery
|
||||||
|
if (hints.length > 0) {
|
||||||
|
const hintKeywords = extractKeyTerms(hints.join(' '), 5);
|
||||||
|
if (hintKeywords.length > 0) {
|
||||||
|
const addition = hintKeywords.join(' ');
|
||||||
|
bundle.rerankQuery = truncate(
|
||||||
|
bundle.rerankQuery + '\n' + addition,
|
||||||
|
RERANK_QUERY_MAX_CHARS
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 增强 lexicalTerms
|
||||||
|
if (hints.length > 0) {
|
||||||
|
const hintTerms = extractKeyTerms(hints.join(' '), 5);
|
||||||
|
const termSet = new Set(bundle.lexicalTerms);
|
||||||
|
for (const t of hintTerms) {
|
||||||
|
if (termSet.size >= LEXICAL_TERMS_MAX) break;
|
||||||
|
if (!termSet.has(t)) {
|
||||||
|
termSet.add(t);
|
||||||
|
bundle.lexicalTerms.push(t);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user