feat: 改进召回精排反馈与上下文配置

This commit is contained in:
Youzini-afk
2026-03-24 22:15:47 +08:00
parent 1f03b0df4a
commit d4d527237e
6 changed files with 227 additions and 18 deletions

105
index.js
View File

@@ -84,6 +84,7 @@ const defaultSettings = {
recallEnableGraphDiffusion: true, // 是否启用图扩散
recallDiffusionTopK: 100, // 图扩散阶段保留的候选上限
recallLlmCandidatePool: 30, // 传给 LLM 精排的候选池大小
recallLlmContextMessages: 4, // 传给 LLM 精排的最近非系统消息数
// 注入设置
injectPosition: "atDepth", // 注入位置
@@ -166,10 +167,16 @@ let isRecalling = false;
let lastInjectionContent = "";
let lastExtractedItems = []; // 最近提取的节点(面板展示用)
let lastRecalledItems = []; // 最近召回的节点(面板展示用)
let lastRecallStatus = {
text: "待命",
meta: "尚未执行召回",
level: "idle",
};
let extractionCount = 0; // v2: 提取次数计数器(定期触发概要/遗忘/反思)
let serverSettingsSaveTimer = null;
let isRecoveringHistory = false;
let lastHistoryWarningAt = 0;
let lastRecallFallbackNoticeAt = 0;
function getNodeDisplayName(node) {
return (
@@ -279,6 +286,11 @@ function ensureCurrentGraphRuntimeState() {
function clearInjectionState() {
lastInjectionContent = "";
lastRecalledItems = [];
lastRecallStatus = {
text: "待命",
meta: "当前无有效注入内容",
level: "idle",
};
try {
const context = getContext();
@@ -291,6 +303,21 @@ function clearInjectionState() {
} catch (error) {
console.warn("[ST-BME] 清理旧注入失败:", error);
}
refreshPanelLiveState();
}
function refreshPanelLiveState() {
_panelModule?.refreshLiveState?.();
}
function setLastRecallStatus(text, meta, level = "info") {
lastRecallStatus = {
text: String(text || "待命"),
meta: String(meta || ""),
level,
};
refreshPanelLiveState();
}
async function recordGraphMutation({
@@ -560,6 +587,12 @@ function updateModuleSettings(patch = {}) {
);
lastInjectionContent = "";
lastRecalledItems = [];
lastRecallStatus = {
text: "已停用",
meta: "插件已关闭,注入内容已清空",
level: "idle",
};
refreshPanelLiveState();
} catch (error) {
console.warn("[ST-BME] 关闭插件时清理注入失败:", error);
}
@@ -583,6 +616,11 @@ function loadGraphFromChat() {
lastExtractedItems = [];
lastRecalledItems = [];
lastInjectionContent = "";
lastRecallStatus = {
text: "待命",
meta: "当前聊天尚未建立记忆图谱",
level: "idle",
};
return;
}
@@ -598,6 +636,11 @@ function loadGraphFromChat() {
lastExtractedItems = [];
updateLastRecalledItems(currentGraph.lastRecallResult || []);
lastInjectionContent = "";
lastRecallStatus = {
text: "待命",
meta: "已加载聊天图谱,等待下一次召回",
level: "idle",
};
}
function saveGraphToChat() {
@@ -1186,22 +1229,39 @@ async function runRecall() {
// 获取最新用户消息
let userMessage = "";
const recentMessages = [];
const recentContextMessageLimit = clampInt(
settings.recallLlmContextMessages,
4,
0,
20,
);
for (let i = chat.length - 1; i >= 0 && recentMessages.length < 4; i--) {
for (
let i = chat.length - 1;
i >= 0 && (!userMessage || recentMessages.length < recentContextMessageLimit);
i--
) {
const msg = chat[i];
if (msg.is_system) continue;
if (msg.is_user && !userMessage) {
userMessage = msg.mes || "";
}
recentMessages.unshift(
`[${msg.is_user ? "user" : "assistant"}]: ${msg.mes || ""}`,
);
if (recentMessages.length < recentContextMessageLimit) {
recentMessages.unshift(
`[${msg.is_user ? "user" : "assistant"}]: ${msg.mes || ""}`,
);
}
}
if (!userMessage) return;
console.log("[ST-BME] 开始召回");
setLastRecallStatus(
"召回中",
`上下文 ${recentMessages.length} 条 · 当前用户消息长度 ${userMessage.length}`,
"running",
);
const result = await retrieve({
graph: currentGraph,
@@ -1235,6 +1295,12 @@ async function runRecall() {
// 格式化注入文本
const injectionText = formatInjection(result, getSchema()).trim();
lastInjectionContent = injectionText;
const retrievalMeta = result?.meta?.retrieval || {};
const llmMeta = retrievalMeta.llm || {
status: settings.recallEnableLLM ? "unknown" : "disabled",
reason: settings.recallEnableLLM ? "未提供 LLM 状态" : "LLM 精排已关闭",
candidatePool: 0,
};
if (injectionText) {
const tokens = estimateTokens(injectionText);
@@ -1255,10 +1321,40 @@ async function runRecall() {
currentGraph.lastRecallResult = result.selectedNodeIds;
updateLastRecalledItems(result.selectedNodeIds || []);
saveGraphToChat();
const llmLabel =
llmMeta.status === "llm"
? "LLM 精排完成"
: llmMeta.status === "fallback"
? "LLM 回退评分"
: llmMeta.status === "disabled"
? "仅评分排序"
: "召回完成";
setLastRecallStatus(
llmLabel,
`ctx ${recentMessages.length} · vector ${retrievalMeta.vectorHits ?? 0} · diffusion ${retrievalMeta.diffusionHits ?? 0} · llm pool ${llmMeta.candidatePool ?? 0} · recall ${result.stats.recallCount}`,
llmMeta.status === "fallback" ? "warning" : "success",
);
if (llmMeta.status === "fallback") {
const now = Date.now();
if (now - lastRecallFallbackNoticeAt > 15000) {
lastRecallFallbackNoticeAt = now;
toastr.warning(
llmMeta.reason || "LLM 精排未返回有效结果,已回退到评分排序",
"ST-BME 召回提示",
{ timeOut: 4500 },
);
}
}
} catch (e) {
console.error("[ST-BME] 召回失败:", e);
const message = e?.message || String(e);
setLastRecallStatus("召回失败", message, "error");
toastr.error(`召回失败: ${message}`);
} finally {
isRecalling = false;
refreshPanelLiveState();
}
}
@@ -1669,6 +1765,7 @@ async function onReembedDirect() {
getSettings: () => getSettings(),
getLastExtract: () => lastExtractedItems,
getLastRecall: () => lastRecalledItems,
getLastRecallStatus: () => lastRecallStatus,
getLastInjection: () => lastInjectionContent,
updateSettings: (patch) => {
const settings = updateModuleSettings(patch);

View File

@@ -126,6 +126,10 @@
<label>最近恢复</label>
<div class="bme-recent-meta" id="bme-status-recovery"></div>
</div>
<div class="bme-config-row">
<label>最近召回</label>
<div class="bme-recent-meta" id="bme-status-last-recall"></div>
</div>
</div>
<div class="bme-mobile-graph-preview" id="bme-mobile-graph-area">
@@ -748,7 +752,7 @@
<div class="bme-config-card-head">
<div>
<div class="bme-config-card-title">LLM 精确召回</div>
<div class="bme-config-card-subtitle">控制是否启用 LLM 精排,以及传给 LLM 的候选池大小与最终保留上限。</div>
<div class="bme-config-card-subtitle">控制是否启用 LLM 精排,以及传给 LLM 的上下文消息数、候选池大小与最终保留上限。</div>
</div>
<div class="bme-config-guard-note">在“功能开关”中启用后生效。</div>
</div>
@@ -756,6 +760,10 @@
<input id="bme-setting-recall-llm" type="checkbox" />
<span>启用 LLM 精排</span>
</label>
<div class="bme-config-row bme-stage-param">
<label for="bme-setting-recall-llm-context-messages">LLM 精排上下文消息数</label>
<input id="bme-setting-recall-llm-context-messages" class="bme-config-input" type="number" min="0" max="20" />
</div>
<div class="bme-config-row bme-stage-param">
<label for="bme-setting-recall-llm-candidate-pool">LLM 精排候选池</label>
<input id="bme-setting-recall-llm-candidate-pool" class="bme-config-input" type="number" min="1" max="100" />

View File

@@ -120,6 +120,7 @@ let _getGraph = null;
let _getSettings = null;
let _getLastExtract = null;
let _getLastRecall = null;
let _getLastRecallStatus = null;
let _getLastInjection = null;
let _updateSettings = null;
let _actionHandlers = {};
@@ -141,6 +142,7 @@ export async function initPanel({
getSettings,
getLastExtract,
getLastRecall,
getLastRecallStatus,
getLastInjection,
updateSettings,
actions,
@@ -149,6 +151,7 @@ export async function initPanel({
_getSettings = getSettings;
_getLastExtract = getLastExtract;
_getLastRecall = getLastRecall;
_getLastRecallStatus = getLastRecallStatus;
_getLastInjection = getLastInjection;
_updateSettings = updateSettings;
_actionHandlers = actions || {};
@@ -176,6 +179,7 @@ export async function initPanel({
panelEl?.querySelector(".bme-tab-btn.active")?.dataset.tab || "dashboard";
_applyWorkspaceMode();
_syncConfigSectionState();
_refreshRuntimeStatus();
}
/**
@@ -204,6 +208,7 @@ export function openPanel() {
const activeTabId =
panelEl?.querySelector(".bme-tab-btn.active")?.dataset.tab || currentTabId;
_switchTab(activeTabId);
_refreshRuntimeStatus();
_refreshGraph();
_buildLegend();
}
@@ -226,6 +231,27 @@ export function updatePanelTheme(themeName) {
_highlightThemeChoice(themeName);
}
export function refreshLiveState() {
if (!overlayEl?.classList.contains("active")) return;
_refreshRuntimeStatus();
switch (currentTabId) {
case "dashboard":
_refreshDashboard();
break;
case "memory":
_refreshMemoryBrowser();
break;
case "injection":
void _refreshInjectionPreview();
break;
default:
break;
}
_refreshGraph();
}
// ==================== Tab 切换 ====================
function _bindTabs() {
@@ -309,10 +335,6 @@ function _refreshDashboard() {
_setText("bme-stat-edges", graph.edges.length);
_setText("bme-stat-archived", archivedCount);
_setText("bme-stat-frag", `${fragRate}%`);
_setText(
"bme-status-meta",
`NODES: ${activeNodes.length} | EDGES: ${graph.edges.length}`,
);
const chatId = graph?.historyState?.chatId || "—";
const lastProcessed = graph?.historyState?.lastProcessedAssistantFloor ?? -1;
@@ -321,6 +343,7 @@ function _refreshDashboard() {
const vectorMode = graph?.vectorIndexState?.mode || "—";
const vectorSource = graph?.vectorIndexState?.source || "—";
const recovery = graph?.historyState?.lastRecoveryResult;
const recallStatus = _getLastRecallStatus?.() || {};
_setText("bme-status-chat-id", chatId);
_setText(
@@ -339,6 +362,10 @@ function _refreshDashboard() {
? `${recovery.status} · from ${recovery.fromFloor ?? "—"} · ${recovery.reason || "—"}`
: "暂无恢复记录",
);
_setText(
"bme-status-last-recall",
recallStatus.meta || "尚未执行召回",
);
_renderRecentList("bme-recent-extract", _getLastExtract?.() || []);
_renderRecentList("bme-recent-recall", _getLastRecall?.() || []);
@@ -720,6 +747,10 @@ function _refreshConfigTab() {
"bme-setting-recall-llm-candidate-pool",
settings.recallLlmCandidatePool ?? 30,
);
_setInputValue(
"bme-setting-recall-llm-context-messages",
settings.recallLlmContextMessages ?? 4,
);
_setInputValue("bme-setting-inject-depth", settings.injectDepth ?? 9999);
_setInputValue("bme-setting-graph-weight", settings.graphWeight ?? 0.6);
_setInputValue("bme-setting-vector-weight", settings.vectorWeight ?? 0.3);
@@ -892,6 +923,9 @@ function _bindConfigControls() {
bindNumber("bme-setting-recall-llm-candidate-pool", 30, 1, 100, (value) =>
_patchSettings({ recallLlmCandidatePool: value }),
);
bindNumber("bme-setting-recall-llm-context-messages", 4, 0, 20, (value) =>
_patchSettings({ recallLlmContextMessages: value }),
);
bindNumber("bme-setting-inject-depth", 9999, 0, 9999, (value) =>
_patchSettings({ injectDepth: value }),
);
@@ -1186,6 +1220,15 @@ function _setText(id, text) {
if (el) el.textContent = String(text);
}
function _refreshRuntimeStatus() {
const recallStatus = _getLastRecallStatus?.() || {};
const text = recallStatus.text || "待命";
const meta = recallStatus.meta || "尚未执行召回";
_setText("bme-status-text", text);
_setText("bme-status-meta", meta);
_setText("bme-panel-status", text);
}
function _patchSettings(patch = {}, options = {}) {
const settings = _updateSettings?.(patch) || _getSettings?.() || {};
if (options.refreshGuards) _refreshGuardedConfigStates(settings);

View File

@@ -76,9 +76,27 @@ export async function retrieve({
let vectorResults = [];
let diffusionResults = [];
let useLLM = false;
let llmMeta = {
enabled: enableLLMRecall,
status: enableLLMRecall ? "pending" : "disabled",
reason: enableLLMRecall ? "" : "LLM 精排已关闭",
candidatePool: 0,
selectedSeedCount: 0,
};
if (nodeCount === 0) {
return buildResult(graph, [], schema);
return buildResult(graph, [], schema, {
retrieval: {
vectorHits: 0,
diffusionHits: 0,
scoredCandidates: 0,
llm: {
...llmMeta,
status: enableLLMRecall ? "skipped" : "disabled",
reason: "当前没有可参与召回的活跃节点",
},
},
});
}
// ========== 第 1 层:向量预筛 ==========
@@ -208,7 +226,7 @@ export async function retrieve({
0,
Math.min(normalizedLlmCandidatePool, scoredNodes.length),
);
selectedNodeIds = await llmRecall(
const llmResult = await llmRecall(
userMessage,
recentMessages,
candidateNodes,
@@ -217,10 +235,25 @@ export async function retrieve({
normalizedMaxRecallNodes,
options.recallPrompt,
);
selectedNodeIds = llmResult.selectedNodeIds;
llmMeta = {
enabled: true,
status: llmResult.status,
reason: llmResult.reason,
candidatePool: candidateNodes.length,
selectedSeedCount: llmResult.selectedNodeIds.length,
};
} else {
selectedNodeIds = scoredNodes
.slice(0, Math.min(normalizedTopK, scoredNodes.length))
.map((s) => s.nodeId);
llmMeta = {
enabled: false,
status: "disabled",
reason: "LLM 精排已关闭,直接采用评分排序",
candidatePool: 0,
selectedSeedCount: selectedNodeIds.length,
};
}
selectedNodeIds = reconstructSceneNodeIds(
@@ -265,7 +298,14 @@ export async function retrieve({
selectedNodeIds = uniqueNodeIds(selectedNodeIds);
return buildResult(graph, selectedNodeIds, schema);
return buildResult(graph, selectedNodeIds, schema, {
retrieval: {
vectorHits: vectorResults.length,
diffusionHits: diffusionResults.length,
scoredCandidates: scoredNodes.length,
llm: llmMeta,
},
});
}
/**
@@ -374,14 +414,30 @@ async function llmRecall(
if (result?.selected_ids && Array.isArray(result.selected_ids)) {
// 校验 ID 有效性
const validIds = result.selected_ids.filter((id) =>
candidates.some((c) => c.nodeId === id),
);
return validIds;
const validIds = uniqueNodeIds(
result.selected_ids.filter((id) =>
candidates.some((c) => c.nodeId === id),
),
).slice(0, maxNodes);
if (validIds.length > 0 || result.selected_ids.length === 0) {
return {
selectedNodeIds: validIds,
status: "llm",
reason:
validIds.length < result.selected_ids.length
? "LLM 返回了部分无效或超限 ID已自动裁剪"
: "LLM 精排完成",
};
}
}
// LLM 失败时回退到纯评分排序
return candidates.slice(0, maxNodes).map((c) => c.nodeId);
return {
selectedNodeIds: candidates.slice(0, maxNodes).map((c) => c.nodeId),
status: "fallback",
reason: "LLM 未返回有效 JSON 或有效候选,已回退到评分排序",
};
}
// ==================== v2 辅助函数 ====================
@@ -418,7 +474,7 @@ function filterByVisibility(nodes, characterName) {
* 构建最终检索结果
* 分离常驻注入Core和召回注入Recall
*/
function buildResult(graph, selectedNodeIds, schema) {
function buildResult(graph, selectedNodeIds, schema, meta = {}) {
const coreNodes = [];
const recallNodes = [];
const selectedSet = new Set(uniqueNodeIds(selectedNodeIds));
@@ -453,6 +509,7 @@ function buildResult(graph, selectedNodeIds, schema) {
recallNodes,
groupedRecallNodes,
selectedNodeIds: [...selectedSet],
meta,
stats: {
totalActive: activeNodes.length,
coreCount: coreNodes.length,

View File

@@ -32,6 +32,7 @@ assert.equal(defaultSettings.recallEnableVectorPrefilter, true);
assert.equal(defaultSettings.recallEnableGraphDiffusion, true);
assert.equal(defaultSettings.recallDiffusionTopK, 100);
assert.equal(defaultSettings.recallLlmCandidatePool, 30);
assert.equal(defaultSettings.recallLlmContextMessages, 4);
assert.equal(defaultSettings.injectDepth, 9999);
console.log("default-settings tests passed");

View File

@@ -165,6 +165,8 @@ assert.deepEqual(state.vectorCalls, [4]);
assert.equal(state.diffusionCalls.length, 0);
assert.equal(state.llmCandidateCount, 2);
assert.deepEqual(Array.from(llmPoolResult.selectedNodeIds), ["rule-2", "rule-1"]);
assert.equal(llmPoolResult.meta.retrieval.llm.status, "llm");
assert.equal(llmPoolResult.meta.retrieval.llm.candidatePool, 2);
state.vectorCalls.length = 0;
state.diffusionCalls.length = 0;
@@ -187,5 +189,6 @@ await retrieve({
assert.deepEqual(state.vectorCalls, [3]);
assert.equal(state.diffusionCalls.length, 1);
assert.equal(state.diffusionCalls[0].options.topK, 7);
assert.equal(noStageResult.meta.retrieval.llm.status, "disabled");
console.log("retrieval-config tests passed");