tasq/node_modules/agentic-flow/dist/intelligence/EmbeddingCache.js

624 lines
19 KiB
JavaScript

/**
* EmbeddingCache - Persistent cache for embeddings
*
* Makes ONNX embeddings practical by caching across sessions:
* - First embed: ~400ms (ONNX inference)
* - Cached embed: ~0.1ms (SQLite lookup) or ~0.01ms (in-memory fallback)
*
* Storage: ~/.agentic-flow/embedding-cache.db (if SQLite available)
*
* Windows Compatibility:
* - Falls back to in-memory cache if better-sqlite3 compilation fails
* - No native module compilation required for basic functionality
*/
import { existsSync, mkdirSync, statSync, readFileSync, writeFileSync } from 'fs';
import { join } from 'path';
import { homedir } from 'os';
import { createHash } from 'crypto';
// Default config
const DEFAULT_CONFIG = {
maxEntries: 10000,
maxAgeDays: 30,
dbPath: join(homedir(), '.agentic-flow', 'embedding-cache.db'),
dimension: 384,
forceMemory: false,
};
// Check if better-sqlite3 is available (native, fastest)
let BetterSqlite3 = null;
let nativeSqliteAvailable = false;
// Check if sql.js is available (WASM, cross-platform)
let SqlJs = null;
let wasmSqliteAvailable = false;
try {
// Try native SQLite first (fastest)
BetterSqlite3 = require('better-sqlite3');
nativeSqliteAvailable = true;
}
catch {
// Native not available, try WASM fallback
try {
SqlJs = require('sql.js');
wasmSqliteAvailable = true;
}
catch {
// Neither available, will use memory cache
}
}
const sqliteAvailable = nativeSqliteAvailable || wasmSqliteAvailable;
/**
* In-memory cache fallback for Windows compatibility
*/
class MemoryCache {
cache = new Map();
maxEntries;
hits = 0;
misses = 0;
constructor(maxEntries = 10000) {
this.maxEntries = maxEntries;
}
get(hash) {
const entry = this.cache.get(hash);
if (entry) {
entry.hits++;
entry.accessed = Date.now();
this.hits++;
return { embedding: entry.embedding, dimension: entry.embedding.length };
}
this.misses++;
return null;
}
set(hash, text, embedding, model) {
const now = Date.now();
this.cache.set(hash, {
embedding,
model,
hits: 1,
created: now,
accessed: now,
});
// Evict if over limit
if (this.cache.size > this.maxEntries) {
this.evictLRU(Math.ceil(this.maxEntries * 0.1));
}
}
has(hash) {
return this.cache.has(hash);
}
evictLRU(count) {
const entries = Array.from(this.cache.entries())
.sort((a, b) => a[1].accessed - b[1].accessed)
.slice(0, count);
for (const [key] of entries) {
this.cache.delete(key);
}
}
clear() {
this.cache.clear();
this.hits = 0;
this.misses = 0;
}
getStats() {
const entries = Array.from(this.cache.values());
const oldest = entries.length > 0 ? Math.min(...entries.map(e => e.created)) : 0;
const newest = entries.length > 0 ? Math.max(...entries.map(e => e.created)) : 0;
return {
totalEntries: this.cache.size,
hits: this.hits,
misses: this.misses,
hitRate: this.hits + this.misses > 0 ? this.hits / (this.hits + this.misses) : 0,
dbSizeBytes: this.cache.size * 384 * 4, // Approximate
oldestEntry: oldest,
newestEntry: newest,
backend: 'memory',
};
}
}
/**
* WASM SQLite cache (sql.js) - Cross-platform with persistence
* Works on Windows without native compilation
*/
class WasmSqliteCache {
db = null;
config;
hits = 0;
misses = 0;
dirty = false;
saveTimeout = null;
constructor(config) {
this.config = config;
}
async init() {
if (this.db)
return;
// Ensure directory exists
const dir = join(homedir(), '.agentic-flow');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Initialize sql.js
const SQL = await SqlJs();
// Load existing database or create new
const dbPath = this.config.dbPath.replace('.db', '-wasm.db');
try {
if (existsSync(dbPath)) {
const buffer = readFileSync(dbPath);
this.db = new SQL.Database(buffer);
}
else {
this.db = new SQL.Database();
}
}
catch {
this.db = new SQL.Database();
}
this.initSchema();
this.cleanupOldEntries();
}
initSchema() {
this.db.run(`
CREATE TABLE IF NOT EXISTS embeddings (
hash TEXT PRIMARY KEY,
text TEXT NOT NULL,
embedding BLOB NOT NULL,
dimension INTEGER NOT NULL,
model TEXT NOT NULL,
hits INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
last_accessed INTEGER NOT NULL
)
`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_last_accessed ON embeddings(last_accessed)`);
this.db.run(`CREATE INDEX IF NOT EXISTS idx_created_at ON embeddings(created_at)`);
}
save() {
// Debounce saves
if (this.saveTimeout)
return;
this.saveTimeout = setTimeout(() => {
try {
const data = this.db.export();
const buffer = Buffer.from(data);
const dbPath = this.config.dbPath.replace('.db', '-wasm.db');
writeFileSync(dbPath, buffer);
this.dirty = false;
}
catch (err) {
console.warn('[WasmSqliteCache] Save failed:', err);
}
this.saveTimeout = null;
}, 1000);
}
get(hash) {
if (!this.db)
return null;
const stmt = this.db.prepare(`SELECT embedding, dimension FROM embeddings WHERE hash = ?`);
stmt.bind([hash]);
if (stmt.step()) {
const row = stmt.getAsObject();
stmt.free();
this.hits++;
this.db.run(`UPDATE embeddings SET hits = hits + 1, last_accessed = ? WHERE hash = ?`, [Date.now(), hash]);
this.dirty = true;
this.save();
// Convert Uint8Array to Float32Array
const uint8 = row.embedding;
const float32 = new Float32Array(uint8.buffer, uint8.byteOffset, row.dimension);
return { embedding: float32, dimension: row.dimension };
}
stmt.free();
this.misses++;
return null;
}
set(hash, text, embedding, model) {
if (!this.db)
return;
const now = Date.now();
const buffer = new Uint8Array(embedding.buffer, embedding.byteOffset, embedding.byteLength);
this.db.run(`INSERT OR REPLACE INTO embeddings (hash, text, embedding, dimension, model, hits, created_at, last_accessed)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)`, [hash, text, buffer, embedding.length, model, now, now]);
this.dirty = true;
this.maybeEvict();
this.save();
}
has(hash) {
if (!this.db)
return false;
const stmt = this.db.prepare(`SELECT 1 FROM embeddings WHERE hash = ? LIMIT 1`);
stmt.bind([hash]);
const found = stmt.step();
stmt.free();
return found;
}
maybeEvict() {
const countStmt = this.db.prepare(`SELECT COUNT(*) as count FROM embeddings`);
countStmt.step();
const count = countStmt.getAsObject().count;
countStmt.free();
if (count > this.config.maxEntries) {
const toEvict = Math.ceil(this.config.maxEntries * 0.1);
this.db.run(`DELETE FROM embeddings WHERE hash IN (
SELECT hash FROM embeddings ORDER BY last_accessed ASC LIMIT ?
)`, [toEvict]);
}
}
cleanupOldEntries() {
const cutoff = Date.now() - (this.config.maxAgeDays * 24 * 60 * 60 * 1000);
this.db.run(`DELETE FROM embeddings WHERE created_at < ?`, [cutoff]);
}
clear() {
if (this.db) {
this.db.run('DELETE FROM embeddings');
this.hits = 0;
this.misses = 0;
this.save();
}
}
close() {
if (this.saveTimeout) {
clearTimeout(this.saveTimeout);
// Force save
try {
const data = this.db.export();
const buffer = Buffer.from(data);
const dbPath = this.config.dbPath.replace('.db', '-wasm.db');
writeFileSync(dbPath, buffer);
}
catch { }
}
if (this.db) {
this.db.close();
this.db = null;
}
}
getStats() {
if (!this.db) {
return {
totalEntries: 0,
hits: this.hits,
misses: this.misses,
hitRate: 0,
dbSizeBytes: 0,
oldestEntry: 0,
newestEntry: 0,
backend: 'memory',
};
}
const countStmt = this.db.prepare(`SELECT COUNT(*) as count FROM embeddings`);
countStmt.step();
const count = countStmt.getAsObject().count;
countStmt.free();
const oldestStmt = this.db.prepare(`SELECT MIN(created_at) as oldest FROM embeddings`);
oldestStmt.step();
const oldest = oldestStmt.getAsObject().oldest || 0;
oldestStmt.free();
const newestStmt = this.db.prepare(`SELECT MAX(created_at) as newest FROM embeddings`);
newestStmt.step();
const newest = newestStmt.getAsObject().newest || 0;
newestStmt.free();
let dbSizeBytes = 0;
try {
const dbPath = this.config.dbPath.replace('.db', '-wasm.db');
const stats = statSync(dbPath);
dbSizeBytes = stats.size;
}
catch { }
return {
totalEntries: count,
hits: this.hits,
misses: this.misses,
hitRate: this.hits + this.misses > 0 ? this.hits / (this.hits + this.misses) : 0,
dbSizeBytes,
oldestEntry: oldest,
newestEntry: newest,
backend: 'file',
};
}
}
/**
* Native SQLite cache (better-sqlite3) - Fastest option
*/
class SqliteCache {
db;
config;
hits = 0;
misses = 0;
// Prepared statements for performance
stmtGet;
stmtInsert;
stmtUpdateHits;
stmtCount;
stmtEvictOld;
stmtEvictLRU;
stmtHas;
constructor(config) {
this.config = config;
// Ensure directory exists
const dir = join(homedir(), '.agentic-flow');
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Open database with WAL mode for better concurrency
this.db = new BetterSqlite3(this.config.dbPath);
this.db.pragma('journal_mode = WAL');
this.db.pragma('synchronous = NORMAL');
this.db.pragma('cache_size = 10000');
this.initSchema();
this.prepareStatements();
this.cleanupOldEntries();
}
initSchema() {
this.db.exec(`
CREATE TABLE IF NOT EXISTS embeddings (
hash TEXT PRIMARY KEY,
text TEXT NOT NULL,
embedding BLOB NOT NULL,
dimension INTEGER NOT NULL,
model TEXT NOT NULL,
hits INTEGER DEFAULT 1,
created_at INTEGER NOT NULL,
last_accessed INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_last_accessed ON embeddings(last_accessed);
CREATE INDEX IF NOT EXISTS idx_created_at ON embeddings(created_at);
CREATE INDEX IF NOT EXISTS idx_model ON embeddings(model);
`);
}
prepareStatements() {
this.stmtGet = this.db.prepare(`
SELECT embedding, dimension FROM embeddings WHERE hash = ?
`);
this.stmtInsert = this.db.prepare(`
INSERT OR REPLACE INTO embeddings (hash, text, embedding, dimension, model, hits, created_at, last_accessed)
VALUES (?, ?, ?, ?, ?, 1, ?, ?)
`);
this.stmtUpdateHits = this.db.prepare(`
UPDATE embeddings SET hits = hits + 1, last_accessed = ? WHERE hash = ?
`);
this.stmtCount = this.db.prepare(`SELECT COUNT(*) as count FROM embeddings`);
this.stmtEvictOld = this.db.prepare(`
DELETE FROM embeddings WHERE created_at < ?
`);
this.stmtEvictLRU = this.db.prepare(`
DELETE FROM embeddings WHERE hash IN (
SELECT hash FROM embeddings ORDER BY last_accessed ASC LIMIT ?
)
`);
this.stmtHas = this.db.prepare(`SELECT 1 FROM embeddings WHERE hash = ? LIMIT 1`);
}
get(hash) {
const row = this.stmtGet.get(hash);
if (row) {
this.hits++;
this.stmtUpdateHits.run(Date.now(), hash);
return {
embedding: new Float32Array(row.embedding.buffer, row.embedding.byteOffset, row.dimension),
dimension: row.dimension,
};
}
this.misses++;
return null;
}
set(hash, text, embedding, model) {
const now = Date.now();
const buffer = Buffer.from(embedding.buffer, embedding.byteOffset, embedding.byteLength);
this.stmtInsert.run(hash, text, buffer, embedding.length, model, now, now);
this.maybeEvict();
}
has(hash) {
return this.stmtHas.get(hash) !== undefined;
}
maybeEvict() {
const count = this.stmtCount.get().count;
if (count > this.config.maxEntries) {
const toEvict = Math.ceil(this.config.maxEntries * 0.1);
this.stmtEvictLRU.run(toEvict);
}
}
cleanupOldEntries() {
const cutoff = Date.now() - (this.config.maxAgeDays * 24 * 60 * 60 * 1000);
this.stmtEvictOld.run(cutoff);
}
clear() {
this.db.exec('DELETE FROM embeddings');
this.hits = 0;
this.misses = 0;
}
vacuum() {
this.db.exec('VACUUM');
}
close() {
this.db.close();
}
getStats() {
const count = this.stmtCount.get().count;
const oldest = this.db.prepare(`SELECT MIN(created_at) as oldest FROM embeddings`).get();
const newest = this.db.prepare(`SELECT MAX(created_at) as newest FROM embeddings`).get();
let dbSizeBytes = 0;
try {
const stats = statSync(this.config.dbPath);
dbSizeBytes = stats.size;
}
catch { }
return {
totalEntries: count,
hits: this.hits,
misses: this.misses,
hitRate: this.hits + this.misses > 0 ? this.hits / (this.hits + this.misses) : 0,
dbSizeBytes,
oldestEntry: oldest.oldest || 0,
newestEntry: newest.newest || 0,
backend: 'sqlite',
};
}
}
/**
* EmbeddingCache - Auto-selects best available backend
*
* Backend priority:
* 1. Native SQLite (better-sqlite3) - Fastest, 9000x speedup
* 2. WASM SQLite (sql.js) - Cross-platform with persistence
* 3. Memory cache - Fallback, no persistence
*/
export class EmbeddingCache {
backend;
config;
wasmInitPromise = null;
constructor(config = {}) {
this.config = { ...DEFAULT_CONFIG, ...config };
// Try native SQLite first (fastest)
if (nativeSqliteAvailable && !this.config.forceMemory) {
try {
this.backend = new SqliteCache(this.config);
return;
}
catch (err) {
console.warn('[EmbeddingCache] Native SQLite failed, trying WASM fallback');
}
}
// Try WASM SQLite second (cross-platform with persistence)
if (wasmSqliteAvailable && !this.config.forceMemory) {
this.backend = new WasmSqliteCache(this.config);
this.wasmInitPromise = this.backend.init().catch(err => {
console.warn('[EmbeddingCache] WASM SQLite init failed, using memory cache');
this.backend = new MemoryCache(this.config.maxEntries);
});
return;
}
// Fallback to memory cache
this.backend = new MemoryCache(this.config.maxEntries);
}
/**
* Ensure WASM backend is initialized (if using)
*/
async ensureInit() {
if (this.wasmInitPromise) {
await this.wasmInitPromise;
}
}
/**
* Generate hash key for text + model combination
*/
hashKey(text, model = 'default') {
return createHash('sha256').update(`${model}:${text}`).digest('hex').slice(0, 32);
}
/**
* Get embedding from cache
*/
get(text, model = 'default') {
const hash = this.hashKey(text, model);
const result = this.backend.get(hash);
return result ? result.embedding : null;
}
/**
* Store embedding in cache
*/
set(text, embedding, model = 'default') {
const hash = this.hashKey(text, model);
if (this.backend instanceof SqliteCache) {
this.backend.set(hash, text, embedding, model);
}
else {
this.backend.set(hash, text, embedding, model);
}
}
/**
* Check if text is cached
*/
has(text, model = 'default') {
const hash = this.hashKey(text, model);
return this.backend.has(hash);
}
/**
* Get multiple embeddings at once
*/
getMany(texts, model = 'default') {
const result = new Map();
for (const text of texts) {
const embedding = this.get(text, model);
if (embedding) {
result.set(text, embedding);
}
}
return result;
}
/**
* Store multiple embeddings at once
*/
setMany(entries, model = 'default') {
for (const { text, embedding } of entries) {
this.set(text, embedding, model);
}
}
/**
* Get cache statistics
*/
getStats() {
return this.backend.getStats();
}
/**
* Clear all cached embeddings
*/
clear() {
this.backend.clear();
}
/**
* Vacuum database (SQLite only)
*/
vacuum() {
if (this.backend instanceof SqliteCache) {
this.backend.vacuum();
}
}
/**
* Close database connection
*/
close() {
if (this.backend instanceof SqliteCache || this.backend instanceof WasmSqliteCache) {
this.backend.close();
}
}
/**
* Check if using SQLite backend (native or WASM)
*/
isSqliteBackend() {
return this.backend instanceof SqliteCache || this.backend instanceof WasmSqliteCache;
}
/**
* Get backend type
*/
getBackendType() {
if (this.backend instanceof SqliteCache)
return 'native';
if (this.backend instanceof WasmSqliteCache)
return 'wasm';
return 'memory';
}
}
// Singleton instance
let cacheInstance = null;
/**
* Get the singleton embedding cache
*/
export function getEmbeddingCache(config) {
if (!cacheInstance) {
cacheInstance = new EmbeddingCache(config);
}
return cacheInstance;
}
/**
* Reset the cache singleton (for testing)
*/
export function resetEmbeddingCache() {
if (cacheInstance) {
cacheInstance.close();
cacheInstance = null;
}
}
/**
* Check if SQLite is available
*/
export function isSqliteAvailable() {
return sqliteAvailable;
}
//# sourceMappingURL=EmbeddingCache.js.map