@claude-flow/embeddings
Version:
V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking
458 lines • 17.1 kB
JavaScript
/**
* RVF Embedding Cache - Pure TypeScript Binary File Cache
*
* Replaces the sql.js-based PersistentEmbeddingCache with a lightweight
* pure-TS binary file format. No native dependencies required.
*
* Features:
* - Map-based in-memory cache with periodic flush to binary file
* - LRU eviction tracked via access timestamps
* - TTL support for cache entries
* - Deterministic FNV-1a text hashing for keys
* - Binary format: RVEC magic + entry records
*
* Binary entry format:
* [4-byte key-hash][4-byte dims][dims*4 bytes float32][8-byte timestamp][8-byte access-count]
*
* @module @claude-flow/embeddings
*/
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync } from 'fs';
import { dirname } from 'path';
/** Validate a file path is safe */
function validatePath(p) {
if (p.includes('\0'))
throw new Error('Cache path contains null bytes');
}
// ============================================================================
// Constants
// ============================================================================
/** Binary file magic bytes: "RVEC" */
const MAGIC = new Uint8Array([0x52, 0x56, 0x45, 0x43]); // R V E C
/** Default TTL: 7 days in milliseconds */
const DEFAULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
/** Default max entries */
const DEFAULT_MAX_SIZE = 10000;
/** Auto-flush interval: 30 seconds */
const AUTO_FLUSH_INTERVAL_MS = 30000;
/** FNV-1a offset basis (32-bit) */
const FNV_OFFSET_BASIS = 0x811c9dc5;
/** FNV-1a prime (32-bit) */
const FNV_PRIME = 0x01000193;
// ============================================================================
// RVF Embedding Cache
// ============================================================================
/**
* Pure-TS binary file embedding cache with LRU eviction and TTL support.
*
* Stores embeddings as raw Float32Array bytes keyed by FNV-1a text hashes.
* Uses an in-memory Map with periodic flush to a compact binary file.
*/
export class RvfEmbeddingCache {
cachePath;
maxSize;
ttlMs;
dimensions;
entries = new Map();
textToHash = new Map();
dirty = false;
flushTimer = null;
initialized = false;
constructor(config) {
this.cachePath = config.cachePath;
this.maxSize = config.maxSize ?? DEFAULT_MAX_SIZE;
this.ttlMs = config.ttlMs ?? DEFAULT_TTL_MS;
this.dimensions = config.dimensions;
validatePath(this.cachePath);
}
// --------------------------------------------------------------------------
// Initialization
// --------------------------------------------------------------------------
/**
* Lazily initialize the cache: load from disk if file exists, start auto-flush.
*/
async ensureInitialized() {
if (this.initialized)
return;
// Ensure parent directory exists
const dir = dirname(this.cachePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Load existing cache file
if (existsSync(this.cachePath)) {
this.loadFromFile();
}
// Clean expired entries on startup
this.cleanExpired();
// Start auto-flush timer
this.startAutoFlush();
this.initialized = true;
}
// --------------------------------------------------------------------------
// Public API (matches PersistentEmbeddingCache)
// --------------------------------------------------------------------------
/**
* Get an embedding from the cache by text.
* Returns null if not found or expired.
*/
async get(text) {
await this.ensureInitialized();
const hash = this.hashText(text);
const entry = this.entries.get(hash);
if (!entry) {
return null;
}
// Check TTL
const now = Date.now();
if (now - entry.createdAt > this.ttlMs) {
this.entries.delete(hash);
this.textToHash.delete(text);
this.dirty = true;
return null;
}
// Update LRU tracking
entry.accessedAt = now;
entry.accessCount++;
this.dirty = true;
return entry.embedding;
}
/**
* Store an embedding in the cache.
* Triggers LRU eviction if the cache exceeds maxSize.
*/
async set(text, embedding) {
await this.ensureInitialized();
// Validate dimensions if configured
if (this.dimensions !== undefined && embedding.length !== this.dimensions) {
throw new Error(`Dimension mismatch: expected ${this.dimensions}, got ${embedding.length}`);
}
const hash = this.hashText(text);
const now = Date.now();
// Copy the embedding to avoid external mutation
const copy = new Float32Array(embedding.length);
copy.set(embedding);
const existing = this.entries.get(hash);
if (existing) {
existing.embedding = copy;
existing.accessedAt = now;
existing.accessCount++;
}
else {
this.entries.set(hash, {
embedding: copy,
createdAt: now,
accessedAt: now,
accessCount: 1,
});
this.textToHash.set(text, hash);
}
this.dirty = true;
// Evict if over capacity
this.evictIfNeeded();
}
/**
* Check whether the cache contains an embedding for the given text.
*/
async has(text) {
await this.ensureInitialized();
const hash = this.hashText(text);
const entry = this.entries.get(hash);
if (!entry)
return false;
// Check TTL
if (Date.now() - entry.createdAt > this.ttlMs) {
this.entries.delete(hash);
this.textToHash.delete(text);
this.dirty = true;
return false;
}
return true;
}
/**
* Delete a specific entry from the cache.
* Returns true if the entry existed and was deleted.
*/
async delete(text) {
await this.ensureInitialized();
const hash = this.hashText(text);
const existed = this.entries.delete(hash);
this.textToHash.delete(text);
if (existed) {
this.dirty = true;
}
return existed;
}
/**
* Clear all entries from the cache and persist the empty state.
*/
async clear() {
await this.ensureInitialized();
this.entries.clear();
this.textToHash.clear();
this.dirty = true;
this.flushToFile();
}
/**
* Return the number of entries currently in the cache.
*/
async size() {
await this.ensureInitialized();
return this.entries.size;
}
/**
* Flush pending changes to disk and stop the auto-flush timer.
*/
async close() {
this.stopAutoFlush();
if (this.dirty) {
this.flushToFile();
}
this.entries.clear();
this.textToHash.clear();
this.initialized = false;
}
// --------------------------------------------------------------------------
// Hashing
// --------------------------------------------------------------------------
/**
* FNV-1a 32-bit hash of the input text.
* Deterministic: same input always produces the same hash.
*/
hashText(text) {
let hash = FNV_OFFSET_BASIS;
for (let i = 0; i < text.length; i++) {
hash ^= text.charCodeAt(i);
hash = Math.imul(hash, FNV_PRIME) >>> 0;
}
return hash;
}
// --------------------------------------------------------------------------
// LRU Eviction
// --------------------------------------------------------------------------
/**
* If the cache exceeds maxSize, evict the least-recently-accessed entries
* until we are back at 90% capacity.
*/
evictIfNeeded() {
if (this.entries.size <= this.maxSize)
return;
const targetSize = Math.floor(this.maxSize * 0.9);
const toEvict = this.entries.size - targetSize;
// Sort entries by accessedAt ascending (oldest first)
const sorted = [...this.entries.entries()].sort((a, b) => a[1].accessedAt - b[1].accessedAt);
// Build reverse map for O(1) lookup (hash → text)
const hashToText = new Map();
for (const [text, hash] of this.textToHash) {
hashToText.set(hash, text);
}
for (let i = 0; i < toEvict && i < sorted.length; i++) {
const [hash] = sorted[i];
this.entries.delete(hash);
const text = hashToText.get(hash);
if (text !== undefined)
this.textToHash.delete(text);
}
this.dirty = true;
}
// --------------------------------------------------------------------------
// TTL Cleanup
// --------------------------------------------------------------------------
/**
* Remove all entries whose createdAt timestamp is older than TTL.
*/
cleanExpired() {
const cutoff = Date.now() - this.ttlMs;
const toDelete = [];
for (const [hash, entry] of this.entries) {
if (entry.createdAt < cutoff) {
toDelete.push(hash);
}
}
// Build reverse map for O(1) lookup
const hashToText = new Map();
for (const [text, h] of this.textToHash) {
hashToText.set(h, text);
}
for (const hash of toDelete) {
this.entries.delete(hash);
const text = hashToText.get(hash);
if (text !== undefined)
this.textToHash.delete(text);
}
if (toDelete.length > 0) {
this.dirty = true;
}
}
// --------------------------------------------------------------------------
// Auto-Flush Timer
// --------------------------------------------------------------------------
startAutoFlush() {
if (this.flushTimer)
return;
this.flushTimer = setInterval(() => {
if (this.dirty) {
this.flushToFile();
}
}, AUTO_FLUSH_INTERVAL_MS);
if (this.flushTimer.unref)
this.flushTimer.unref();
}
stopAutoFlush() {
if (this.flushTimer) {
clearInterval(this.flushTimer);
this.flushTimer = null;
}
}
// --------------------------------------------------------------------------
// Binary Serialization
// --------------------------------------------------------------------------
/**
* Write all entries to the binary cache file.
*
* Format:
* [4-byte magic "RVEC"]
* For each entry:
* [4-byte key-hash (uint32)]
* [4-byte dims (uint32)]
* [dims * 4 bytes float32 data]
* [8-byte createdAt (float64, used as timestamp)]
* [8-byte accessCount (float64)]
*/
flushToFile() {
try {
// Version 2 format: magic(4) + version(4) + entries...
// Entry: hash(4) + dims(4) + embedding(dims*4) + createdAt(8) + accessedAt(8) + accessCount(8)
let totalSize = MAGIC.length + 4; // magic + version uint32
for (const [, entry] of this.entries) {
totalSize += 4 + 4 + entry.embedding.length * 4 + 8 + 8 + 8;
}
const buffer = new ArrayBuffer(totalSize);
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
let offset = 0;
// Write magic
bytes.set(MAGIC, 0);
offset += MAGIC.length;
// Write format version
view.setUint32(offset, 2, true);
offset += 4;
// Write entries
for (const [hash, entry] of this.entries) {
// Key hash (uint32, little-endian)
view.setUint32(offset, hash, true);
offset += 4;
// Dimensions (uint32, little-endian)
view.setUint32(offset, entry.embedding.length, true);
offset += 4;
// Embedding data (float32 array, little-endian)
for (let i = 0; i < entry.embedding.length; i++) {
view.setFloat32(offset, entry.embedding[i], true);
offset += 4;
}
// createdAt as float64 (little-endian) - v2: separate from accessedAt
view.setFloat64(offset, entry.createdAt, true);
offset += 8;
// accessedAt as float64 (little-endian)
view.setFloat64(offset, entry.accessedAt, true);
offset += 8;
// Access count as float64 (little-endian)
view.setFloat64(offset, entry.accessCount, true);
offset += 8;
}
// Ensure parent directory exists
const dir = dirname(this.cachePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
const tmpSuffix = Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
const tmpPath = this.cachePath + '.tmp.' + tmpSuffix;
writeFileSync(tmpPath, Buffer.from(buffer));
renameSync(tmpPath, this.cachePath);
this.dirty = false;
}
catch (error) {
console.error('[rvf-embedding-cache] Flush error:', error instanceof Error ? error.message : error);
}
}
/**
* Load entries from the binary cache file.
*/
loadFromFile() {
try {
const fileBuffer = readFileSync(this.cachePath);
if (fileBuffer.length < MAGIC.length)
return;
const buffer = fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength);
const view = new DataView(buffer);
const bytes = new Uint8Array(buffer);
let offset = 0;
// Verify magic
for (let i = 0; i < MAGIC.length; i++) {
if (bytes[offset + i] !== MAGIC[i]) {
console.warn('[rvf-embedding-cache] Invalid magic bytes, skipping load');
return;
}
}
offset += MAGIC.length;
// Check for version header (v2+)
let formatVersion = 1;
if (offset + 4 <= buffer.byteLength) {
const possibleVersion = view.getUint32(offset, true);
if (possibleVersion === 2) {
formatVersion = possibleVersion;
offset += 4;
}
}
// Read entries
while (offset + 8 <= buffer.byteLength) {
// Need at least 4 (hash) + 4 (dims) = 8 bytes for the header
const hash = view.getUint32(offset, true);
offset += 4;
const dims = view.getUint32(offset, true);
offset += 4;
const entryDataSize = formatVersion === 2
? dims * 4 + 8 + 8 + 8 // v2: embedding + createdAt + accessedAt + accessCount
: dims * 4 + 8 + 8; // v1: embedding + accessedAt + accessCount
if (offset + entryDataSize > buffer.byteLength) {
console.warn('[rvf-embedding-cache] Truncated entry, stopping load');
break;
}
// Read embedding
const embedding = new Float32Array(dims);
for (let i = 0; i < dims; i++) {
embedding[i] = view.getFloat32(offset, true);
offset += 4;
}
let createdAt;
let accessedAt;
let accessCount;
if (formatVersion >= 2) {
createdAt = view.getFloat64(offset, true);
offset += 8;
accessedAt = view.getFloat64(offset, true);
offset += 8;
accessCount = view.getFloat64(offset, true);
offset += 8;
}
else {
// v1: only accessedAt was stored, use it as createdAt too
accessedAt = view.getFloat64(offset, true);
offset += 8;
accessCount = view.getFloat64(offset, true);
offset += 8;
createdAt = accessedAt;
}
this.entries.set(hash, {
embedding,
createdAt,
accessedAt,
accessCount,
});
}
}
catch (error) {
console.warn('[rvf-embedding-cache] Load error:', error instanceof Error ? error.message : error);
}
}
}
//# sourceMappingURL=rvf-embedding-cache.js.map