Add local AI and Qdrant vector backends

This commit is contained in:
“BeeRad”
2026-05-02 09:57:57 +10:00
parent 00f0afb8f4
commit 782ace9a34
37 changed files with 1440 additions and 321 deletions
+18 -53
View File
@@ -1,5 +1,7 @@
import { getSQLiteClient } from './sqlite-client';
import { Chunk, ChunkData } from '@/types/database';
import { getVectorBackendType } from '@/services/vectorBackend';
import { getVectorBackend } from '@/services/vectorBackend/factory';
type RankedChunk = Chunk & { similarity: number };
@@ -256,16 +258,11 @@ export class ChunkService {
matchCount = 5,
nodeIds?: number[]
): Promise<Array<Chunk & { similarity: number }>> {
const sqlite = getSQLiteClient();
const startTime = Date.now();
const vectorString = `[${queryEmbedding.join(',')}]`;
const vectorBackend = await getVectorBackend();
const vectorLimit = Math.max(matchCount * 10, 50);
// 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 sqlite = getSQLiteClient();
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);
@@ -276,62 +273,26 @@ export class ChunkService {
}
console.log(`🔍 Node-scoped search: ${chunkCount} chunks in nodes ${nodeIds.join(', ')}`);
let query = `
SELECT c.*, (1.0 / (1.0 + v.distance)) AS similarity
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 c.node_id IN (${nodeIds.map(() => '?').join(',')})
AND (1.0 / (1.0 + v.distance)) >= ?
ORDER BY similarity DESC
LIMIT ?
`;
const params = [vectorString, vectorLimit, ...nodeIds, similarityThreshold, matchCount];
const result = sqlite.query<Chunk & { similarity: number }>(query, params);
const rows = await vectorBackend.searchChunks(queryEmbedding, similarityThreshold, matchCount, nodeIds);
const searchTime = Date.now() - startTime;
console.log(`📊 Vector search (node-scoped): ${result.rows.length} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (result.rows.length > 0) {
console.log(`🎯 Top result: chunk ${result.rows[0].id} (similarity: ${result.rows[0].similarity.toFixed(3)})`);
console.log(`📊 Vector search (${getVectorBackendType()}, node-scoped): ${rows.length} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (rows.length > 0) {
console.log(`🎯 Top result: chunk ${rows[0].id} (similarity: ${rows[0].similarity.toFixed(3)})`);
}
return result.rows;
return rows;
}
// Global search (no node filter)
const query = `
WITH vector_results AS (
SELECT chunk_id, distance
FROM vec_chunks
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
)
SELECT c.*, (1.0 / (1.0 + vr.distance)) AS similarity
FROM vector_results vr
JOIN chunks c ON c.id = vr.chunk_id
WHERE (1.0 / (1.0 + vr.distance)) >= ?
ORDER BY similarity DESC
LIMIT ?
`;
const params = [vectorString, vectorLimit, similarityThreshold, matchCount];
const result = sqlite.query<Chunk & { similarity: number }>(query, params);
const rows = await vectorBackend.searchChunks(queryEmbedding, similarityThreshold, matchCount);
const searchTime = Date.now() - startTime;
console.log(`📊 Vector search (global): ${result.rows.length}/${vectorLimit} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (result.rows.length > 0) {
console.log(`🎯 Top result: chunk ${result.rows[0].id} (similarity: ${result.rows[0].similarity.toFixed(3)})`);
console.log(`📊 Vector search (${getVectorBackendType()}, global): ${rows.length} chunks, threshold=${similarityThreshold}, time=${searchTime}ms`);
if (rows.length > 0) {
console.log(`🎯 Top result: chunk ${rows[0].id} (similarity: ${rows[0].similarity.toFixed(3)})`);
}
return result.rows;
return rows;
}
async textSearchFallback(
@@ -495,6 +456,10 @@ export class ChunkService {
}
async getChunksWithoutEmbeddings(): Promise<Chunk[]> {
if (getVectorBackendType() === 'qdrant') {
return [];
}
// In SQLite, chunk vectors live in vec_chunks; report chunks without corresponding vector rows
const sqlite = getSQLiteClient();
const result = sqlite.query<Chunk>(`
+4 -12
View File
@@ -1,6 +1,4 @@
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { hasPreferredOpenAiKey, getPreferredOpenAiKey } from '../storage/openaiKeyServer';
import { generateUtilityText } from '@/services/llm/provider';
import type { CanonicalNodeMetadata } from '@/types/database';
export interface DescriptionInput {
@@ -23,25 +21,19 @@ export interface DescriptionInput {
* The result must cover what the artifact is, why it is in the graph, and workflow status.
*/
export async function generateDescription(input: DescriptionInput): Promise<string> {
if (!hasPreferredOpenAiKey()) {
console.log(`[DescriptionService] No valid OpenAI key, using fallback for: "${input.title}"`);
return `${input.title}. Added via Quick Add with no further context yet, so the reason it belongs in the graph is not fully inferred. It has not been reviewed yet.`.slice(0, 500);
}
try {
const prompt = buildDescriptionPrompt(input);
console.log(`[DescriptionService] Generating description for: "${input.title}"`);
const provider = createOpenAI({ apiKey: getPreferredOpenAiKey() });
const response = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
maxOutputTokens: 100,
temperature: 0.3,
task: 'description',
});
const description = sanitizeDescription(response.text, input);
const description = sanitizeDescription(text, input);
console.log(`[DescriptionService] Generated: "${description}"`);
+7 -22
View File
@@ -2,11 +2,9 @@ import { getSQLiteClient } from './sqlite-client';
import { Edge, EdgeContext, EdgeData, EdgeCreatedVia, NodeConnection, Node } from '@/types/database';
import { eventBroadcaster } from '../events';
import { nodeService } from './nodes';
import { generateText } from 'ai';
import { createOpenAI } from '@ai-sdk/openai';
import { z } from 'zod';
import { validateEdgeExplanation } from './quality';
import { getPreferredOpenAiKey, hasPreferredOpenAiKey } from '../storage/openaiKeyServer';
import { generateUtilityText } from '@/services/llm/provider';
const inferredEdgeContextSchema = z.object({
type: z.enum(['created_by', 'part_of', 'source_of', 'related_to']),
@@ -75,16 +73,12 @@ async function inferEdgeContext(params: {
].join('\n');
try {
if (!hasPreferredOpenAiKey()) {
return { type: 'related_to', confidence: 0.2, swap_direction: false };
}
const provider = createOpenAI({ apiKey: getPreferredOpenAiKey() });
const { text } = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
temperature: 0.0,
maxOutputTokens: 120,
responseFormat: 'json',
task: 'edge_inference',
});
const parsedJson = (() => {
@@ -142,21 +136,12 @@ async function autoInferEdge(params: {
].join('\n');
try {
if (!hasPreferredOpenAiKey()) {
return {
explanation: `Connection to ${toNode.title}; exact relationship uncertain.`,
type: 'related_to',
confidence: 0.2,
swap_direction: false,
};
}
const provider = createOpenAI({ apiKey: getPreferredOpenAiKey() });
const { text } = await generateText({
model: provider('gpt-4o-mini'),
const text = await generateUtilityText({
prompt,
temperature: 0.0,
maxOutputTokens: 150,
responseFormat: 'json',
task: 'edge_inference',
});
const parsedJson = (() => {
+22 -26
View File
@@ -4,6 +4,7 @@ import { eventBroadcaster } from '../events';
import { EmbeddingService } from '@/services/embeddings';
import { getHighSignalSearchTerms, scoreNodeSearchMatch } from './searchRanking';
import { buildCanonicalNodeMetadata, mergeNodeMetadata } from '@/services/nodes/metadata';
import { getVectorBackend } from '@/services/vectorBackend/factory';
type NodeRow = Node;
type NodeSearchRow = NodeRow & { rank?: number; similarity?: number };
@@ -345,6 +346,13 @@ export class NodeService {
private async deleteNodeSQLite(id: number): Promise<void> {
const sqlite = getSQLiteClient();
try {
const vectorBackend = await getVectorBackend();
await vectorBackend.deleteNode(id);
} catch (error) {
console.warn(`[NodeService] Could not delete vectors for node ${id}:`, error);
}
const result = sqlite.query('DELETE FROM nodes WHERE id = ?', [id]);
@@ -627,35 +635,27 @@ export class NodeService {
return [];
}
const vecExists = sqlite.prepare(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name='vec_nodes'"
).get();
if (!vecExists) return [];
const vectorString = `[${embedding.join(',')}]`;
const { clauses, params } = this.buildNodeFilterClauses(filters);
const whereClauses = clauses.length > 0 ? `WHERE ${clauses.join(' AND ')}` : '';
const extraClauses = clauses.length > 0 ? `AND ${clauses.join(' AND ')}` : '';
const vectorBackend = await getVectorBackend();
const matches = await vectorBackend.searchNodes(embedding, limit);
if (matches.length === 0) return [];
const matchIds = matches.map((match) => match.nodeId);
const scoreById = new Map(matches.map((match) => [match.nodeId, match.score]));
const result = sqlite.query<NodeSearchRow>(`
WITH vector_matches AS (
SELECT node_id, distance
FROM vec_nodes
WHERE embedding MATCH ?
ORDER BY distance
LIMIT ?
)
SELECT n.id, n.title, n.description, n.source, n.link, n.event_date, n.metadata,
n.chunk_status, n.embedding_updated_at, n.embedding_text,
n.created_at, n.updated_at,
(1.0 / (1.0 + vm.distance)) AS similarity
FROM vector_matches vm
JOIN nodes n ON n.id = vm.node_id
${whereClauses}
ORDER BY vm.distance
n.created_at, n.updated_at
FROM nodes n
WHERE n.id IN (${matchIds.map(() => '?').join(',')})
${extraClauses}
LIMIT ?
`, [vectorString, Math.max(limit * 2, 50), ...params, limit]);
`, [...matchIds, ...params, limit]);
return result.rows;
return result.rows
.map((row) => ({ ...row, similarity: scoreById.get(row.id) || 0 }))
.sort((a, b) => Number(b.similarity || 0) - Number(a.similarity || 0));
} catch (error) {
console.warn('[NodeSearch] Vector search unavailable, continuing without it:', error);
return [];
@@ -679,10 +679,6 @@ export class NodeService {
// PostgreSQL path removed in SQLite-only consolidation
private async bulkUpdateNodesSQLite(ids: number[], updates: Partial<Node>): Promise<Node[]> {
// For SQLite, use IN (SELECT value FROM json_each(?)) for safety
const sqlite = getSQLiteClient();
const idsJson = JSON.stringify(ids);
// For now, just update one by one - could optimize later
const updatedNodes: Node[] = [];
for (const id of ids) {
+119 -4
View File
@@ -3,6 +3,8 @@ import fs from 'fs';
import path from 'path';
import { DatabaseError } from '@/types/database';
import { getDatabasePath, getVecExtensionPath } from '@/services/database/sqlite-runtime';
import { getEmbeddingProviderInfo } from '@/services/embedding/provider';
import { getVectorBackendType } from '@/services/vectorBackend';
export interface SQLiteConfig {
dbPath: string;
@@ -43,6 +45,28 @@ export interface DatabaseIntegrityReport {
error?: string;
}
export interface EmbeddingProfileStatus {
active: {
profile: string;
model: string;
dimensions: number;
vector_backend: string;
};
stored: {
profile: string;
model: string;
dimensions: number;
vector_backend: string;
updated_at?: string;
} | null;
vectors: {
nodes: number;
chunks: number;
};
rebuild_required: boolean;
reason?: string;
}
class SQLiteClient {
private static instance: SQLiteClient;
private db: Database.Database;
@@ -258,13 +282,14 @@ class SQLiteClient {
public ensureVectorExtensions(): void {
try {
const dimensions = getEmbeddingProviderInfo().dimensions;
// Test for vec_nodes and vec_chunks; create them if missing
const hasVecNodes = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name=?").get('vec_nodes');
if (!hasVecNodes) {
this.db.exec(`
CREATE VIRTUAL TABLE vec_nodes USING vec0(
node_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
embedding FLOAT[${dimensions}]
);
`);
console.log('Created vec_nodes virtual table');
@@ -275,7 +300,7 @@ class SQLiteClient {
this.db.exec(`
CREATE VIRTUAL TABLE vec_chunks USING vec0(
chunk_id INTEGER PRIMARY KEY,
embedding FLOAT[1536]
embedding FLOAT[${dimensions}]
);
`);
console.log('Created vec_chunks virtual table');
@@ -353,6 +378,15 @@ class SQLiteClient {
FOREIGN KEY (node_id) REFERENCES nodes(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS embedding_profile_state (
id INTEGER PRIMARY KEY CHECK (id = 1),
profile TEXT NOT NULL,
model TEXT NOT NULL,
dimensions INTEGER NOT NULL,
vector_backend TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS chats (
id INTEGER PRIMARY KEY,
chat_type TEXT,
@@ -953,9 +987,10 @@ class SQLiteClient {
try {
this.db.exec(`DROP TABLE IF EXISTS ${table};`);
} catch {}
const dimensions = getEmbeddingProviderInfo().dimensions;
const ddl = table === 'vec_nodes'
? `CREATE VIRTUAL TABLE vec_nodes USING vec0(node_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);`
: `CREATE VIRTUAL TABLE vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[1536]);`;
? `CREATE VIRTUAL TABLE vec_nodes USING vec0(node_id INTEGER PRIMARY KEY, embedding FLOAT[${dimensions}]);`
: `CREATE VIRTUAL TABLE vec_chunks USING vec0(chunk_id INTEGER PRIMARY KEY, embedding FLOAT[${dimensions}]);`;
try {
this.db.exec(ddl);
console.log(`Recreated ${table} virtual table`);
@@ -1229,6 +1264,86 @@ class SQLiteClient {
};
}
public getEmbeddingProfileStatus(): EmbeddingProfileStatus {
const embedding = getEmbeddingProviderInfo();
const active = {
profile: embedding.profile,
model: embedding.model,
dimensions: embedding.dimensions,
vector_backend: getVectorBackendType(),
};
const stored = this.db.prepare(`
SELECT profile, model, dimensions, vector_backend, updated_at
FROM embedding_profile_state
WHERE id = 1
`).get() as EmbeddingProfileStatus['stored'] | undefined;
const nodes = this.countVectorRows('vec_nodes');
const chunks = this.countVectorRows('vec_chunks');
const hasVectors = nodes > 0 || chunks > 0;
let rebuildRequired = false;
let reason: string | undefined;
if (!stored && hasVectors) {
rebuildRequired = true;
reason = 'Existing vectors do not have recorded provider/model/dimension metadata.';
} else if (stored) {
const mismatches = [
stored.profile !== active.profile ? `profile ${stored.profile} -> ${active.profile}` : '',
stored.model !== active.model ? `model ${stored.model} -> ${active.model}` : '',
Number(stored.dimensions) !== active.dimensions ? `dimensions ${stored.dimensions} -> ${active.dimensions}` : '',
stored.vector_backend !== active.vector_backend ? `backend ${stored.vector_backend} -> ${active.vector_backend}` : '',
].filter(Boolean);
if (mismatches.length > 0) {
rebuildRequired = hasVectors;
reason = `Embedding/vector profile changed (${mismatches.join(', ')}). Rebuild embeddings before semantic search.`;
}
}
return {
active,
stored: stored || null,
vectors: { nodes, chunks },
rebuild_required: rebuildRequired,
reason,
};
}
public markEmbeddingProfileCurrent(): void {
if (this.readOnly) return;
const embedding = getEmbeddingProviderInfo();
this.db.prepare(`
INSERT INTO embedding_profile_state (id, profile, model, dimensions, vector_backend, updated_at)
VALUES (1, ?, ?, ?, ?, ?)
ON CONFLICT(id) DO UPDATE SET
profile = excluded.profile,
model = excluded.model,
dimensions = excluded.dimensions,
vector_backend = excluded.vector_backend,
updated_at = excluded.updated_at
`).run(
embedding.profile,
embedding.model,
embedding.dimensions,
getVectorBackendType(),
new Date().toISOString()
);
}
private countVectorRows(tableName: 'vec_nodes' | 'vec_chunks'): number {
try {
const exists = this.db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name=?").get(tableName);
if (!exists) return 0;
const row = this.db.prepare(`SELECT COUNT(*) AS count FROM ${tableName}`).get() as { count?: number } | undefined;
return Number(row?.count ?? 0);
} catch {
return 0;
}
}
public getIntegrityReport(forceRefresh = false): DatabaseIntegrityReport {
if (!this.integrityReport || forceRefresh) {
this.integrityReport = this.inspectIntegrity();