Project Files
src / embeddings / multimodalEmbeddingStore.ts
/**
* Persistent multimodal embedding store using SQLite (sql.js WebAssembly).
*
* Stores Qwen multimodal vectors by media identity, model, dimension, and
* source fingerprint. This is intentionally separate from the legacy prompt
* embedding store because visual identity is file/media based, not prompt based.
*/
import crypto from 'crypto';
import initSqlJs, { Database } from 'sql.js';
import path from 'path';
import { closeSync, existsSync, mkdirSync, openSync, readFileSync, readSync, statSync, writeFileSync } from 'fs';
import { findImageDataPath } from '../paths.js';
import { resolveGgufModelFiles } from '../llama-server-manager.js';
export interface MultimodalEmbeddingStoreConfig {
/** Path to the SQLite database file */
dbPath?: string;
}
export interface MultimodalEmbeddingEntry {
mediaKey: string;
imageRef: string;
sourceType?: string;
sourceFingerprint: string;
payloadMode: MultimodalEmbeddingPayloadMode;
previewPolicyEnabled: boolean;
embedding: number[];
model: string;
dimension: number;
}
export interface StoredMultimodalEmbedding extends MultimodalEmbeddingEntry {
id: number;
createdAt: Date;
}
export type MultimodalEmbeddingPayloadMode = "original" | "preview";
export interface MultimodalEmbeddingStoreStats {
count: number;
models: string[];
dimensions: number[];
dbSizeBytes: number;
}
export interface StaleCleanupResult {
removed: number;
missingFiles: number;
changedFingerprints: number;
}
export interface UnindexedCleanupResult {
removed: number;
}
// Default DB path used only when a caller constructs MultimodalEmbeddingStore
// without an explicit dbPath. Every production call site passes GGUF_DB_PATH
// explicitly (see multimodalEmbeddingClientGguf.ts); this default is kept for
// standalone/legacy use of the store and is not backend-specific.
export const DEFAULT_MULTIMODAL_DB_PATH = findImageDataPath('multimodal_embeddings.sqlite3');
export function embeddingToFloat32Blob(embedding: number[]): Uint8Array {
const float32 = new Float32Array(embedding);
return new Uint8Array(float32.buffer);
}
export function float32BlobToEmbedding(blob: Uint8Array): number[] {
const float32 = new Float32Array(blob.buffer, blob.byteOffset, blob.byteLength / 4);
return Array.from(float32);
}
export function fileFingerprint(filePath: string): string {
const stat = statSync(filePath);
return `file:${stat.size}:${Math.round(stat.mtimeMs)}`;
}
export function projectThumbnailFingerprint(projectPath: string, thumbnailId: number): string {
const stat = statSync(projectPath);
const walPath = `${projectPath}-wal`;
const walFingerprint = existsSync(walPath)
? `${statSync(walPath).size}:${Math.round(statSync(walPath).mtimeMs)}`
: 'missing';
return `project:${stat.size}:${Math.round(stat.mtimeMs)}:wal:${walFingerprint}:${thumbnailId}`;
}
export function bufferFingerprint(bytes: Uint8Array): string {
return `buffer:${crypto.createHash('sha256').update(bytes).digest('hex')}`;
}
export function embeddingFingerprint(sourceFingerprint: string, metadataText: string): string {
const metadataHash = crypto.createHash('sha256').update(metadataText).digest('hex');
return `${sourceFingerprint}|embedding:${metadataHash}`;
}
function fileContentHash(filePath: string): string {
const descriptor = openSync(filePath, 'r');
const buffer = Buffer.allocUnsafe(8 * 1024 * 1024);
const hash = crypto.createHash('sha256');
try {
while (true) {
const bytesRead = readSync(descriptor, buffer, 0, buffer.length, null);
if (bytesRead === 0) break;
hash.update(buffer.subarray(0, bytesRead));
}
} finally {
closeSync(descriptor);
}
return hash.digest('hex');
}
export function multimodalModelIdentity(modelPath: string, configuredModel: string): string {
if (!path.isAbsolute(modelPath)) return configuredModel;
const { modelFile, mmprojFile } = resolveGgufModelFiles(modelPath);
const formatFileIdentity = (filePath: string): string => {
const hash = fileContentHash(filePath);
return `${path.basename(filePath)}@sha256:${hash}`;
};
return `${formatFileIdentity(modelFile)};${formatFileIdentity(mmprojFile)}`;
}
function sourceFingerprintForFreshness(sourceFingerprint: string): string {
return sourceFingerprint.split('|embedding:', 1)[0];
}
function isProjectRef(imageRef: string): boolean {
return imageRef.startsWith('project://');
}
function isFilesystemRef(imageRef: string): boolean {
return path.isAbsolute(imageRef) && !isProjectRef(imageRef);
}
export class MultimodalEmbeddingStore {
private db: Database | null = null;
private config: Required<MultimodalEmbeddingStoreConfig>;
private SQL: any = null;
private isDirty = false;
private static readonly SCHEMA_VERSION = 4;
constructor(config: MultimodalEmbeddingStoreConfig = {}) {
this.config = {
dbPath: config.dbPath ?? DEFAULT_MULTIMODAL_DB_PATH,
};
}
async init(): Promise<void> {
if (this.db) return;
this.SQL = await initSqlJs();
const dir = path.dirname(this.config.dbPath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
if (existsSync(this.config.dbPath)) {
console.log(`[MultimodalEmbeddingStore] Loading existing DB from: ${this.config.dbPath}`);
const buffer = readFileSync(this.config.dbPath);
this.db = new this.SQL.Database(buffer);
} else {
console.log(`[MultimodalEmbeddingStore] Creating new DB at: ${this.config.dbPath}`);
this.db = new this.SQL.Database();
}
this.initSchema();
const stats = this.getStats();
console.log(`[MultimodalEmbeddingStore] Loaded: ${stats.count} embeddings`);
}
private initSchema(): void {
if (!this.db) throw new Error('Database not initialized');
const tableColumns = this.db.exec(`PRAGMA table_info(multimodal_embeddings)`);
const schemaVersion = Number(this.db.exec(`PRAGMA user_version`)[0]?.values[0]?.[0] ?? 0);
if (tableColumns.length > 0 && schemaVersion < MultimodalEmbeddingStore.SCHEMA_VERSION) {
this.db.run(`BEGIN TRANSACTION`);
try {
this.db.run(`ALTER TABLE multimodal_embeddings RENAME TO multimodal_embeddings_legacy`);
this.createSchema();
if (schemaVersion === 3) {
this.db.run(`
INSERT INTO multimodal_embeddings
(id, media_key, image_ref, source_type, source_fingerprint, embedding, model_identity, dimension, created_at, payload_mode, preview_policy_enabled)
SELECT id, media_key, image_ref, source_type, source_fingerprint, embedding, model_identity, dimension, created_at, 'original', 0
FROM multimodal_embeddings_legacy
`);
}
// Pre-v3 rows identify the model only by a configured directory or
// display name. They cannot safely be attributed to a model file, so
// the indexer regenerates them under the content-addressed identity.
this.db.run(`DROP TABLE multimodal_embeddings_legacy`);
this.db.run(`PRAGMA user_version = ${MultimodalEmbeddingStore.SCHEMA_VERSION}`);
this.db.run(`COMMIT`);
} catch (error) {
this.db.run(`ROLLBACK`);
throw error;
}
} else {
this.createSchema();
this.db.run(`PRAGMA user_version = ${MultimodalEmbeddingStore.SCHEMA_VERSION}`);
}
this.createIndexes();
this.forceSave();
}
private createSchema(): void {
if (!this.db) throw new Error('Database not initialized');
this.db.run(`
CREATE TABLE IF NOT EXISTS multimodal_embeddings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
media_key TEXT NOT NULL,
image_ref TEXT NOT NULL,
source_type TEXT,
source_fingerprint TEXT NOT NULL,
embedding BLOB NOT NULL,
model_identity TEXT NOT NULL,
dimension INTEGER NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
payload_mode TEXT NOT NULL CHECK(payload_mode IN ('original', 'preview')),
preview_policy_enabled INTEGER NOT NULL CHECK(preview_policy_enabled IN (0, 1)),
UNIQUE(media_key, model_identity, payload_mode)
)
`);
}
private createIndexes(): void {
if (!this.db) throw new Error('Database not initialized');
this.db.run(`CREATE INDEX IF NOT EXISTS idx_multimodal_media_key ON multimodal_embeddings(media_key)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_multimodal_model_identity_dimension ON multimodal_embeddings(model_identity, dimension)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_multimodal_image_ref ON multimodal_embeddings(image_ref)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_multimodal_fingerprint ON multimodal_embeddings(source_fingerprint)`);
}
private save(): void {
if (!this.db || !this.isDirty) return;
this.forceSave();
}
private forceSave(): void {
if (!this.db) return;
const data = this.db.export();
writeFileSync(this.config.dbPath, data);
this.isDirty = false;
}
getEmbedding(mediaKey: string, model: string, dimension: number, sourceFingerprint: string, payloadMode: MultimodalEmbeddingPayloadMode): StoredMultimodalEmbedding | null {
if (!this.db) throw new Error('Database not initialized');
const result = this.db.exec(
`SELECT id, media_key, image_ref, source_type, source_fingerprint, embedding, model_identity, dimension, created_at, payload_mode, preview_policy_enabled
FROM multimodal_embeddings
WHERE media_key = ? AND model_identity = ? AND dimension = ? AND source_fingerprint = ? AND payload_mode = ?`,
[mediaKey, model, dimension, sourceFingerprint, payloadMode]
);
if (result.length === 0 || result[0].values.length === 0) return null;
return this.rowToStoredEmbedding(result[0].values[0]);
}
hasFreshEmbedding(mediaKey: string, model: string, dimension: number, sourceFingerprint: string, payloadMode: MultimodalEmbeddingPayloadMode): boolean {
return this.getEmbedding(mediaKey, model, dimension, sourceFingerprint, payloadMode) !== null;
}
hasFreshOriginalEmbedding(mediaKey: string, model: string, dimension: number, sourceFingerprint: string): boolean {
return this.hasFreshEmbedding(mediaKey, model, dimension, sourceFingerprint, "original");
}
promoteLegacyProjectEmbedding(
mediaKey: string,
model: string,
dimension: number,
sourceFingerprint: string,
payloadMode: MultimodalEmbeddingPayloadMode,
): boolean {
if (!this.db || !sourceFingerprint.startsWith("project-generation:")) return false;
const metadataSuffix = sourceFingerprint.match(/\|embedding:[a-f0-9]+$/)?.[0];
if (!metadataSuffix) return false;
const result = this.db.exec(
`SELECT source_fingerprint
FROM multimodal_embeddings
WHERE media_key = ? AND model_identity = ? AND dimension = ? AND payload_mode = ?`,
[mediaKey, model, dimension, payloadMode],
);
const legacyFingerprint = result[0]?.values[0]?.[0];
if (typeof legacyFingerprint !== "string" || !/^project:\d/.test(legacyFingerprint) || !legacyFingerprint.endsWith(metadataSuffix)) {
return false;
}
this.db.run(
`UPDATE multimodal_embeddings
SET source_fingerprint = ?, created_at = CURRENT_TIMESTAMP
WHERE media_key = ? AND model_identity = ? AND dimension = ? AND payload_mode = ?`,
[sourceFingerprint, mediaKey, model, dimension, payloadMode],
);
this.isDirty = true;
this.save();
return true;
}
getEmbeddingForMediaKey(mediaKey: string, model: string, dimension: number): StoredMultimodalEmbedding | null {
if (!this.db) throw new Error('Database not initialized');
const result = this.db.exec(
`SELECT id, media_key, image_ref, source_type, source_fingerprint, embedding, model_identity, dimension, created_at, payload_mode, preview_policy_enabled
FROM multimodal_embeddings
WHERE media_key = ? AND model_identity = ? AND dimension = ?
ORDER BY CASE payload_mode WHEN 'original' THEN 0 ELSE 1 END`,
[mediaKey, model, dimension]
);
if (result.length === 0 || result[0].values.length === 0) return null;
return this.rowToStoredEmbedding(result[0].values[0]);
}
setEmbedding(entry: MultimodalEmbeddingEntry): void {
if (!this.db) throw new Error('Database not initialized');
if (entry.embedding.length !== entry.dimension) {
throw new Error(`Embedding dimension mismatch for ${entry.mediaKey}: expected ${entry.dimension}, got ${entry.embedding.length}`);
}
this.db.run(
`INSERT INTO multimodal_embeddings
(media_key, image_ref, source_type, source_fingerprint, embedding, model_identity, dimension, created_at, payload_mode, preview_policy_enabled)
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)
ON CONFLICT(media_key, model_identity, payload_mode) DO UPDATE SET
image_ref = excluded.image_ref,
source_type = excluded.source_type,
source_fingerprint = excluded.source_fingerprint,
embedding = excluded.embedding,
dimension = excluded.dimension,
created_at = CURRENT_TIMESTAMP,
preview_policy_enabled = excluded.preview_policy_enabled`,
[
entry.mediaKey,
entry.imageRef,
entry.sourceType ?? null,
entry.sourceFingerprint,
embeddingToFloat32Blob(entry.embedding),
entry.model,
entry.dimension,
entry.payloadMode,
entry.previewPolicyEnabled ? 1 : 0,
]
);
this.isDirty = true;
}
setEmbeddings(entries: MultimodalEmbeddingEntry[]): void {
for (const entry of entries) {
this.setEmbedding(entry);
}
this.save();
}
getAllEmbeddings(model: string, dimension: number): StoredMultimodalEmbedding[] {
if (!this.db) throw new Error('Database not initialized');
const result = this.db.exec(
`SELECT id, media_key, image_ref, source_type, source_fingerprint, embedding, model_identity, dimension, created_at, payload_mode, preview_policy_enabled
FROM multimodal_embeddings
WHERE model_identity = ? AND dimension = ?
ORDER BY media_key, CASE payload_mode WHEN 'original' THEN 0 ELSE 1 END`,
[model, dimension]
);
if (result.length === 0) return [];
const entries: StoredMultimodalEmbedding[] = [];
const seenMediaKeys = new Set<string>();
for (const row of result[0].values) {
const entry = this.rowToStoredEmbedding(row);
if (seenMediaKeys.has(entry.mediaKey)) continue;
seenMediaKeys.add(entry.mediaKey);
entries.push(entry);
}
return entries;
}
deleteEmbedding(mediaKey: string, model: string, dimension: number, payloadMode: MultimodalEmbeddingPayloadMode): void {
if (!this.db) throw new Error('Database not initialized');
this.db.run(
`DELETE FROM multimodal_embeddings WHERE media_key = ? AND model_identity = ? AND dimension = ? AND payload_mode = ?`,
[mediaKey, model, dimension, payloadMode]
);
this.isDirty = true;
this.save();
}
deleteEmbeddingsForSourceType(sourceType: string): number {
if (!this.db) throw new Error('Database not initialized');
const countResult = this.db.exec(
`SELECT COUNT(*) FROM multimodal_embeddings WHERE source_type = ?`,
[sourceType],
);
const removed = Number(countResult[0]?.values[0]?.[0] ?? 0);
if (removed === 0) return 0;
this.db.run(
`DELETE FROM multimodal_embeddings WHERE source_type = ?`,
[sourceType],
);
this.isDirty = true;
this.save();
return removed;
}
clearModel(model: string, dimension?: number): void {
if (!this.db) throw new Error('Database not initialized');
if (dimension === undefined) {
this.db.run(`DELETE FROM multimodal_embeddings WHERE model_identity = ?`, [model]);
} else {
this.db.run(`DELETE FROM multimodal_embeddings WHERE model_identity = ? AND dimension = ?`, [model, dimension]);
}
this.isDirty = true;
this.save();
}
clearAll(): void {
if (!this.db) throw new Error('Database not initialized');
this.db.run(`DELETE FROM multimodal_embeddings`);
this.isDirty = true;
this.save();
}
cleanupStaleFilesystemEntries(): StaleCleanupResult {
if (!this.db) throw new Error('Database not initialized');
const result = this.db.exec(
`SELECT id, image_ref, source_fingerprint FROM multimodal_embeddings`
);
let missingFiles = 0;
let changedFingerprints = 0;
const idsToDelete: number[] = [];
for (const row of result[0]?.values ?? []) {
const id = row[0] as number;
const imageRef = row[1] as string;
const sourceFingerprint = row[2] as string;
if (!isFilesystemRef(imageRef)) continue;
if (!existsSync(imageRef)) {
missingFiles += 1;
idsToDelete.push(id);
continue;
}
if (sourceFingerprint.startsWith('file:') && fileFingerprint(imageRef) !== sourceFingerprintForFreshness(sourceFingerprint)) {
changedFingerprints += 1;
idsToDelete.push(id);
}
}
for (const id of idsToDelete) {
this.db.run(`DELETE FROM multimodal_embeddings WHERE id = ?`, [id]);
}
if (idsToDelete.length > 0) {
this.isDirty = true;
this.save();
}
return {
removed: idsToDelete.length,
missingFiles,
changedFingerprints,
};
}
cleanupUnindexedEntries(model: string, dimension: number, validEntries: Array<{ mediaKey: string; sourceFingerprint: string }>): UnindexedCleanupResult {
if (!this.db) throw new Error('Database not initialized');
const valid = new Set(validEntries.map((entry) => `${entry.mediaKey}\0${entry.sourceFingerprint}`));
const result = this.db.exec(
`SELECT id, media_key, source_fingerprint
FROM multimodal_embeddings
WHERE model_identity = ? AND dimension = ?`,
[model, dimension]
);
const idsToDelete: number[] = [];
for (const row of result[0]?.values ?? []) {
const id = row[0] as number;
const mediaKey = row[1] as string;
const sourceFingerprint = row[2] as string;
if (!valid.has(`${mediaKey}\0${sourceFingerprint}`)) {
idsToDelete.push(id);
}
}
for (const id of idsToDelete) {
this.db.run(`DELETE FROM multimodal_embeddings WHERE id = ?`, [id]);
}
if (idsToDelete.length > 0) {
this.isDirty = true;
this.save();
}
return { removed: idsToDelete.length };
}
getStats(): MultimodalEmbeddingStoreStats {
if (!this.db) return { count: 0, models: [], dimensions: [], dbSizeBytes: 0 };
const countResult = this.db.exec(`SELECT COUNT(*) FROM multimodal_embeddings`);
const count = countResult[0]?.values[0]?.[0] as number || 0;
const modelResult = this.db.exec(`SELECT DISTINCT model_identity FROM multimodal_embeddings ORDER BY model_identity`);
const models = (modelResult[0]?.values ?? []).map((row) => row[0] as string);
const dimensionResult = this.db.exec(`SELECT DISTINCT dimension FROM multimodal_embeddings ORDER BY dimension`);
const dimensions = (dimensionResult[0]?.values ?? []).map((row) => row[0] as number);
const dbSizeBytes = existsSync(this.config.dbPath) ? statSync(this.config.dbPath).size : 0;
return { count, models, dimensions, dbSizeBytes };
}
flush(): void {
this.isDirty = true;
this.save();
}
close(): void {
if (this.db) {
this.save();
this.db.close();
this.db = null;
}
}
private rowToStoredEmbedding(row: any[]): StoredMultimodalEmbedding {
return {
id: row[0] as number,
mediaKey: row[1] as string,
imageRef: row[2] as string,
sourceType: (row[3] as string | null) ?? undefined,
sourceFingerprint: row[4] as string,
embedding: float32BlobToEmbedding(row[5] as Uint8Array),
model: row[6] as string,
dimension: row[7] as number,
createdAt: new Date(row[8] as string),
payloadMode: row[9] as MultimodalEmbeddingPayloadMode,
previewPolicyEnabled: Boolean(row[10]),
};
}
}