Add local AI and Qdrant vector backends
This commit is contained in:
@@ -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>(`
|
||||
|
||||
@@ -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}"`);
|
||||
|
||||
|
||||
@@ -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 = (() => {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user