fix(vector): recover after embedding model changes

This commit is contained in:
youzini
2026-05-19 09:05:55 +00:00
parent a3ff6a739e
commit 681b09a81c
6 changed files with 465 additions and 16 deletions

View File

@@ -1691,6 +1691,9 @@ result = {
getGraphPersistenceState() {
return graphPersistenceState;
},
getPanelRuntimeStatus,
getGraphMutationBlockReason,
ensureGraphMutationReady,
setLocalStoreCapabilitySnapshot(patch = {}) {
bmeLocalStoreCapabilitySnapshot = {
...bmeLocalStoreCapabilitySnapshot,
@@ -4199,6 +4202,70 @@ result = {
assert.equal(unrepairedStatus.persistence.blocked, true);
}
{
const graph = createMeaningfulGraph(
"chat-runtime-fallback-vector-maintenance",
"runtime-fallback-vector-maintenance",
);
graph.historyState.chatId = "chat-runtime-fallback-vector-maintenance";
const harness = await createGraphPersistenceHarness({
chatId: "",
globalChatId: "",
chat: [
{ is_user: true, mes: "已有聊天" },
{ is_user: false, mes: "已有回复" },
],
});
harness.api.setCurrentGraph(graph);
harness.api.setGraphPersistenceState({
loadState: GRAPH_LOAD_STATES.NO_CHAT,
chatId: "chat-runtime-fallback-vector-maintenance",
dbReady: false,
writesBlocked: true,
});
assert.equal(
harness.api.ensureGraphMutationReady("重建向量", {
notify: false,
allowRuntimeGraphFallback: true,
}),
true,
"live chat id 暂空但 runtime graph 已明确绑定聊天时,向量维护不应被误判为未进入聊天",
);
const status = harness.api.getPanelRuntimeStatus();
assert.equal(status.text, "图谱已加载");
assert.match(status.meta, /维护操作会使用图谱身份继续/);
}
{
const graph = createMeaningfulGraph(
"chat-runtime-fallback-denied",
"runtime-fallback-denied",
);
graph.historyState.chatId = "chat-runtime-fallback-denied";
const harness = await createGraphPersistenceHarness({
chatId: "",
globalChatId: "",
chat: [{ is_user: true, mes: "其它上下文" }],
});
harness.api.setCurrentGraph(graph);
harness.api.setGraphPersistenceState({
loadState: GRAPH_LOAD_STATES.NO_CHAT,
chatId: "",
dbReady: false,
writesBlocked: true,
});
assert.equal(
harness.api.ensureGraphMutationReady("重建向量", {
notify: false,
allowRuntimeGraphFallback: true,
}),
false,
"没有 graphPersistenceState.chatId 强绑定时,不应仅凭 runtimeGraph/chat 内容放开写入",
);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-pending-persist-already-accepted",

View File

@@ -69,10 +69,12 @@ import {
} from "../ui/ui-status.js";
import {
onClearGraphController,
onClearVectorCacheController,
onDeleteCurrentIdbController,
onManualCompressController,
onManualEvolveController,
onManualSleepController,
onRebuildVectorIndexController,
} from "../ui/ui-actions-controller.js";
import { createGenerationRecallHarness } from "./helpers/generation-recall-harness.mjs";
@@ -7980,6 +7982,97 @@ async function testManualCompressSkipsWithoutCandidatesAndDoesNotPretendItRan()
assert.match(String(toastMessages[0]?.[1] || ""), /未发起 LLM 压缩/);
}
async function testClearVectorCacheLeavesActionableRebuildState() {
const statuses = [];
const toasts = [];
let savedReason = "";
const graph = {
nodes: [
{ id: "n1", embedding: [1, 2, 3] },
{ id: "n2", embedding: [4, 5, 6] },
],
vectorIndexState: {
hashToNodeId: { h1: "n1", h2: "n2" },
nodeToHash: { n1: "h1", n2: "h2" },
currentVectorSpace: { vectorSpaceId: "old", observedDim: 3 },
manifest: { status: "clean", observedDim: 3, lastError: "" },
dirty: false,
dirtyReason: "",
lastWarning: "",
lastStats: { total: 2, indexed: 2, stale: 0, pending: 0 },
},
};
const result = await onClearVectorCacheController({
confirm: () => true,
getCurrentGraph: () => graph,
saveGraphToChat(payload = {}) {
savedReason = payload.reason;
},
setLastVectorStatus(...args) {
statuses.push(args);
},
refreshPanelLiveState() {},
toastr: {
warning(message) {
toasts.push(message);
},
success(message) {
toasts.push(message);
},
},
});
assert.equal(result?.handledToast, true);
assert.deepEqual(graph.vectorIndexState.hashToNodeId, {});
assert.deepEqual(graph.vectorIndexState.nodeToHash, {});
assert.equal(graph.nodes[0].embedding, null);
assert.equal(graph.nodes[1].embedding, null);
assert.equal(graph.vectorIndexState.currentVectorSpace, null);
assert.equal(graph.vectorIndexState.manifest.status, "stale");
assert.equal(graph.vectorIndexState.manifest.lastError, "manual-clear-vector-cache");
assert.equal(graph.vectorIndexState.dirty, true);
assert.equal(graph.vectorIndexState.dirtyReason, "manual-clear-vector-cache");
assert.equal(graph.vectorIndexState.lastStats.total, 2);
assert.equal(graph.vectorIndexState.lastStats.indexed, 0);
assert.equal(graph.vectorIndexState.lastStats.pending, 2);
assert.equal(statuses[0]?.[0], "向量需要重建");
assert.match(String(statuses[0]?.[1] || ""), /降级为非向量召回/);
assert.equal(savedReason, "manual-clear-vector-cache");
assert.match(String(toasts[0] || ""), /重建向量/);
}
async function testRebuildVectorBlockedExplainsVisibleChatMismatch() {
const toasts = [];
const calls = [];
const result = await onRebuildVectorIndexController({
ensureGraphMutationReady(label, options = {}) {
calls.push(["ensureGraphMutationReady", label, options]);
return false;
},
getGraphMutationBlockReason: () => "重建向量已暂停:当前尚未进入聊天。",
getGraphPersistenceState: () => ({ chatId: "chat-visible" }),
toastr: {
warning(message) {
toasts.push(message);
},
info(message) {
toasts.push(message);
},
},
});
assert.equal(result?.blocked, true);
assert.equal(result?.handledToast, true);
assert.equal(calls[0]?.[0], "ensureGraphMutationReady");
assert.equal(calls[0]?.[1], "重建向量");
assert.equal(calls[0]?.[2]?.notify, false);
assert.equal(calls[0]?.[2]?.allowRuntimeGraphFallback, true);
assert.match(String(toasts[0] || ""), /chat-visible/);
assert.match(String(toasts[0] || ""), /重新探测图谱/);
}
async function testManualCompressUsesForcedCompressionAndPersistsRealMutation() {
const calls = {
forceFlag: null,
@@ -8339,6 +8432,8 @@ await testSynopsisUsesPromptMessagesWithoutFallbackSystemPrompt();
await testRecallUsesSectionedPromptMessagesForContextAndTarget();
await testReflectionUsesPromptMessagesWithoutFallbackSystemPrompt();
await testManualCompressSkipsWithoutCandidatesAndDoesNotPretendItRan();
await testClearVectorCacheLeavesActionableRebuildState();
await testRebuildVectorBlockedExplainsVisibleChatMismatch();
await testManualCompressUsesForcedCompressionAndPersistsRealMutation();
await testManualCompressUpdatesRuntimeStatusForPanelUi();
await testManualEvolveFallsBackToLatestExtractionBatchAfterRefresh();

View File

@@ -17,13 +17,16 @@ installResolveHooks([
]);
let embeddingDim = 3;
let embeddingFailureIndexes = new Set();
globalThis.__stBmeTestOverrides = {
embedding: {
async embedBatch(texts = []) {
return texts.map((text, index) =>
Array.from({ length: embeddingDim }, (_, dimIndex) =>
dimIndex === 0 ? 1 : (index + dimIndex + String(text || "").length) / 100,
),
embeddingFailureIndexes.has(index)
? null
: Array.from({ length: embeddingDim }, (_, dimIndex) =>
dimIndex === 0 ? 1 : (index + dimIndex + String(text || "").length) / 100,
),
);
},
async embedText(text = "") {
@@ -103,6 +106,7 @@ const baseConfig = {
const graph = createVectorGraph();
graph.nodes[0].embedding = [0.1, 0.2, 0.3];
embeddingDim = 3;
embeddingFailureIndexes = new Set();
const changedModelConfig = { ...baseConfig, model: "text-embedding-3-large" };
await syncGraphVectorIndex(graph, changedModelConfig, { chatId: graph.historyState.chatId });
assert.equal(graph.vectorIndexState.manifest.status, "clean");
@@ -112,4 +116,63 @@ const baseConfig = {
assert.notDeepEqual(graph.nodes[0].embedding, [0.1, 0.2, 0.3]);
}
{
const graph = createVectorGraph();
graph.nodes[0].embedding = [0.1, 0.2, 0.3];
graph.vectorIndexState.mode = "direct";
graph.vectorIndexState.modelScope = getVectorModelScope(baseConfig);
graph.vectorIndexState.collectionId = "st-bme-vector-chat-vector-manifest";
graph.vectorIndexState.manifest = {
status: "clean",
vectorSpaceId: "old-space",
observedDim: 3,
model: baseConfig.model,
};
embeddingDim = 4;
embeddingFailureIndexes = new Set([0]);
const changedModelConfig = { ...baseConfig, model: "text-embedding-3-large" };
await syncGraphVectorIndex(graph, changedModelConfig, { chatId: graph.historyState.chatId });
assert.equal(graph.nodes[0].embedding, null);
assert.equal(graph.vectorIndexState.dirty, true);
assert.equal(graph.vectorIndexState.dirtyReason, "partial-embedding-failure");
assert.equal(graph.vectorIndexState.lastStats.indexed, 0);
embeddingFailureIndexes = new Set([0]);
await syncGraphVectorIndex(graph, changedModelConfig, { chatId: graph.historyState.chatId });
assert.equal(
graph.vectorIndexState.lastStats.indexed,
0,
"模型变化后的旧 embedding 不应在后续非 force 同步中被重新登记",
);
assert.equal(graph.nodes[0].embedding, null);
}
{
const graph = createVectorGraph();
graph.nodes[0].embedding = [0.1, 0.2, 0.3];
graph.vectorIndexState.mode = "direct";
graph.vectorIndexState.source = "direct";
graph.vectorIndexState.modelScope = getVectorModelScope(baseConfig);
graph.vectorIndexState.collectionId = "st-bme-vector-chat-vector-manifest";
graph.vectorIndexState.hashToNodeId = { oldHash: "node-a" };
graph.vectorIndexState.nodeToHash = { "node-a": "oldHash" };
graph.vectorIndexState.manifest = {
status: "clean",
vectorSpaceId: "old-space",
observedDim: 3,
model: baseConfig.model,
};
embeddingDim = 4;
embeddingFailureIndexes = new Set([0]);
await syncGraphVectorIndex(graph, baseConfig, {
chatId: graph.historyState.chatId,
force: true,
});
assert.equal(graph.nodes[0].embedding, null);
assert.deepEqual(graph.vectorIndexState.nodeToHash, {});
assert.deepEqual(graph.vectorIndexState.hashToNodeId, {});
assert.equal(graph.vectorIndexState.lastStats.indexed, 0);
assert.equal(graph.vectorIndexState.lastStats.pending, 1);
}
console.log("vector-manifest tests passed");