sync: harden standalone mcp and chunk recovery
This commit is contained in:
@@ -260,33 +260,40 @@ export class ChunkService {
|
||||
const startTime = Date.now();
|
||||
const vectorString = `[${queryEmbedding.join(',')}]`;
|
||||
|
||||
// When searching specific nodes, first get their chunk_ids to scope the vector search
|
||||
if (nodeIds && nodeIds.length > 0) {
|
||||
// Get chunk IDs for the target nodes
|
||||
const chunkIdsQuery = `SELECT id FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`;
|
||||
const chunkIdsResult = sqlite.query<{id: number}>(chunkIdsQuery, nodeIds);
|
||||
const chunkIds = chunkIdsResult.rows.map(r => r.id);
|
||||
const vectorLimit = Math.max(matchCount * 10, 50);
|
||||
|
||||
if (chunkIds.length === 0) {
|
||||
// vec0 requires the knn constraint to live directly on the vec table query.
|
||||
// A previous change pushed node-scoping into that WHERE clause in a way vec0 rejects,
|
||||
// which made every node-scoped vector search throw and silently fall back to text.
|
||||
if (nodeIds && nodeIds.length > 0) {
|
||||
const chunkCountQuery = `SELECT COUNT(*) AS count FROM chunks WHERE node_id IN (${nodeIds.map(() => '?').join(',')})`;
|
||||
const chunkCountResult = sqlite.query<{ count: number }>(chunkCountQuery, nodeIds);
|
||||
const chunkCount = Number(chunkCountResult.rows[0]?.count ?? 0);
|
||||
|
||||
if (chunkCount === 0) {
|
||||
console.log(`🔍 Node-scoped search: no chunks found for nodes ${nodeIds.join(', ')}`);
|
||||
return [];
|
||||
}
|
||||
|
||||
console.log(`🔍 Node-scoped search: ${chunkIds.length} chunks in nodes ${nodeIds.join(', ')}`);
|
||||
console.log(`🔍 Node-scoped search: ${chunkCount} chunks in nodes ${nodeIds.join(', ')}`);
|
||||
|
||||
// Search ONLY within those chunks using rowid filter
|
||||
const query = `
|
||||
let query = `
|
||||
SELECT c.*, (1.0 / (1.0 + v.distance)) AS similarity
|
||||
FROM vec_chunks v
|
||||
FROM (
|
||||
SELECT chunk_id, distance
|
||||
FROM vec_chunks
|
||||
WHERE embedding MATCH ?
|
||||
ORDER BY distance
|
||||
LIMIT ?
|
||||
) v
|
||||
JOIN chunks c ON c.id = v.chunk_id
|
||||
WHERE v.embedding MATCH ?
|
||||
AND v.chunk_id IN (${chunkIds.map(() => '?').join(',')})
|
||||
WHERE c.node_id IN (${nodeIds.map(() => '?').join(',')})
|
||||
AND (1.0 / (1.0 + v.distance)) >= ?
|
||||
ORDER BY v.distance
|
||||
ORDER BY similarity DESC
|
||||
LIMIT ?
|
||||
`;
|
||||
|
||||
const params = [vectorString, ...chunkIds, similarityThreshold, matchCount];
|
||||
const params = [vectorString, vectorLimit, ...nodeIds, similarityThreshold, matchCount];
|
||||
const result = sqlite.query<Chunk & { similarity: number }>(query, params);
|
||||
const searchTime = Date.now() - startTime;
|
||||
|
||||
@@ -299,8 +306,6 @@ export class ChunkService {
|
||||
}
|
||||
|
||||
// Global search (no node filter)
|
||||
const vectorLimit = Math.max(matchCount * 10, 50);
|
||||
|
||||
const query = `
|
||||
WITH vector_results AS (
|
||||
SELECT chunk_id, distance
|
||||
|
||||
@@ -1238,12 +1238,20 @@ class SQLiteClient {
|
||||
createSql: string,
|
||||
triggerSql: string,
|
||||
rebuildSql: string,
|
||||
isStale: (sql: string) => boolean,
|
||||
) => {
|
||||
const existing = this.db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name=?").get(tableName) as { sql?: string } | undefined;
|
||||
const existingSql = (existing?.sql || '').toLowerCase().replace(/\s+/g, ' ').trim();
|
||||
const missing = !existingSql;
|
||||
const stale = !missing && isStale(existingSql);
|
||||
|
||||
if (!existing?.sql) {
|
||||
if (missing) {
|
||||
this.db.exec(createSql);
|
||||
this.db.exec(rebuildSql);
|
||||
} else if (stale) {
|
||||
console.warn(
|
||||
`[SQLiteFTS] ${tableName} schema is stale. Skipping destructive startup rebuild; use the offline repair/rebuild path instead.`
|
||||
);
|
||||
}
|
||||
|
||||
this.db.exec(triggerSql);
|
||||
@@ -1272,6 +1280,12 @@ class SQLiteClient {
|
||||
END;
|
||||
`,
|
||||
"INSERT INTO nodes_fts(nodes_fts) VALUES('rebuild');",
|
||||
(sql) =>
|
||||
!/\bfts5\(\s*title\s*,\s*source\s*,\s*description\b/.test(sql) ||
|
||||
!sql.includes("content='nodes'") ||
|
||||
!sql.includes("content_rowid='id'") ||
|
||||
/\bnotes\b/.test(sql) ||
|
||||
/\btitle\s*,\s*content\b/.test(sql),
|
||||
);
|
||||
|
||||
ensureFts(
|
||||
@@ -1297,6 +1311,10 @@ class SQLiteClient {
|
||||
END;
|
||||
`,
|
||||
"INSERT INTO chunks_fts(chunks_fts) VALUES('rebuild');",
|
||||
(sql) =>
|
||||
!/\bfts5\(\s*text\b/.test(sql) ||
|
||||
!sql.includes("content='chunks'") ||
|
||||
!sql.includes("content_rowid='id'"),
|
||||
);
|
||||
} catch (error) {
|
||||
console.warn('Failed to ensure FTS tables:', error);
|
||||
|
||||
@@ -9,6 +9,7 @@ interface AutoEmbedTask {
|
||||
}
|
||||
|
||||
const DEFAULT_COOLDOWN_MS = 2 * 60 * 1000; // 2 minutes between automatic runs per node
|
||||
const RECOVERY_INTERVAL_MS = 60 * 1000;
|
||||
|
||||
export class AutoEmbedQueue {
|
||||
private readonly queue: number[] = [];
|
||||
@@ -88,10 +89,17 @@ export class AutoEmbedQueue {
|
||||
}
|
||||
|
||||
private async executeTask(task: AutoEmbedTask) {
|
||||
const sqlite = getSQLiteClient();
|
||||
const integrity = sqlite.getIntegrityReport();
|
||||
if (!sqlite.canUseFtsTable('chunks')) {
|
||||
const integrity = getSQLiteClient().getIntegrityReport();
|
||||
if (!integrity.ftsTables.chunks) {
|
||||
console.warn('[AutoEmbedQueue] Skipping chunk write because chunks FTS is degraded:', integrity.summary);
|
||||
try {
|
||||
const node = await nodeService.getNodeById(task.nodeId);
|
||||
if (node && node.chunk_status !== 'chunked' && node.chunk_status !== 'error') {
|
||||
await nodeService.updateNode(task.nodeId, { chunk_status: 'error' });
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[AutoEmbedQueue] Failed to mark node as error while chunks FTS is degraded', task.nodeId, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -121,12 +129,30 @@ export class AutoEmbedQueue {
|
||||
declare global {
|
||||
// eslint-disable-next-line no-var
|
||||
var autoEmbedQueue: AutoEmbedQueue | undefined;
|
||||
// eslint-disable-next-line no-var
|
||||
var autoEmbedRecoveryStarted: boolean | undefined;
|
||||
// eslint-disable-next-line no-var
|
||||
var autoEmbedRecoveryTimer: ReturnType<typeof setInterval> | undefined;
|
||||
}
|
||||
|
||||
export const autoEmbedQueue = globalThis.autoEmbedQueue ?? new AutoEmbedQueue();
|
||||
if (!globalThis.autoEmbedQueue) {
|
||||
globalThis.autoEmbedQueue = autoEmbedQueue;
|
||||
autoEmbedQueue.recoverStuckNodes().catch(error => {
|
||||
console.error('[AutoEmbedQueue] Startup recovery failed', error);
|
||||
});
|
||||
}
|
||||
|
||||
export function startAutoEmbedRecovery(): void {
|
||||
if (globalThis.autoEmbedRecoveryStarted) {
|
||||
return;
|
||||
}
|
||||
globalThis.autoEmbedRecoveryStarted = true;
|
||||
|
||||
const runRecovery = () => {
|
||||
autoEmbedQueue.recoverStuckNodes().catch(error => {
|
||||
console.error('[AutoEmbedQueue] Startup recovery failed', error);
|
||||
});
|
||||
};
|
||||
|
||||
runRecovery();
|
||||
globalThis.autoEmbedRecoveryTimer = setInterval(runRecovery, RECOVERY_INTERVAL_MS);
|
||||
globalThis.autoEmbedRecoveryTimer.unref?.();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user