embeddings/

Text → 384-d vectors with optional caching

Two classes (EmbeddingModel, EmbeddingCache) implementing LangChain-shaped embedQuery/embedDocuments over node-llama-cpp, with a SHA-256-keyed LRU cache as a side-channel that never breaks the primary path.

Mature 745 LOC SHA-256 keyed cache Cosine similarity

2
classes
745
LOC
384
default dims
10K
cache maxSize
32
batch size

📚 What is this & why does it exist?

embeddings/ turns text into fixed-length real-valued vectors so that "similar meaning" becomes "similar coordinates". The EmbeddingModel is a thin LangChain-shaped wrapper around node-llama-cpp's embedding context. The EmbeddingCache sits beside it as a transparent side-channel — cache hits return in microseconds, cache misses fall through to the model. Together they make repeat-query workloads (interactive chat, evaluation runs, debugging loops) fast without any complexity in the calling code.

💡 Real-world analogy

Imagine a translator who has a phrase book in their pocket. When you say something, they first check the phrase book; if your phrase is there, they read out the translation in a fraction of a second. If not, they translate from scratch and add the new phrase to the book. The book has a fixed number of pages, so when it fills up they tear out the oldest unused entry to make room. That's EmbeddingModel + EmbeddingCache — and the SHA-256 hash is just the phrase book's index, letting them flip to the right page in one move.

🎯 Why This Component

Embedding generation is the slow path of every RAG system. A single ctx.getEmbeddingFor(text) call routes through llama.cpp, runs a forward pass through bge-small-en-v1.5 (~33 M params), and returns a Float32Array. On a CPU laptop that takes 10–50 ms per call. In an interactive RAG loop where the same documents are re-embedded across runs (debug, eval, demo), that adds up to seconds of wasted compute. EmbeddingCache exists for exactly this scenario: amortise the expensive call across runs by keying on SHA-256(text) and persisting to disk.

The choice to make caching optional and side-channel is deliberate. Dropping the cache should require zero code change in the calling site — the user passes cache:false in the constructor and the embedQuery code path skips the lookup entirely. This pattern (configurable side-channel) is one of the cleanest abstractions in the project.

🏗️ Design Rationale

DecisionChoiceWhy
Cache keySHA-256(text)Fixed-length keys (64 hex chars) regardless of input size; hides text in disk dumps; collision-resistant. Tradeoff: ~5µs per hash, dwarfed by the embedding call.
Cache evictionLRU by linear scan over Map.entries()Eviction is rare on the hot path; doubly-linked-list LRU (O(1)) would add 30+ lines of bookkeeping. Readability wins.
Cache injectionEmbeddingModel takes cache:bool and constructs new EmbeddingCache() internallySimpler API than dependency injection. The user can still call model.cache.saveToDisk() directly.
Batch size 32Default, override per EmbeddingModelBalances Promise.all parallelism vs llama.cpp serialisation cost. Larger batches don't help (llama.cpp is single-threaded per context).
Float32Array → Array conversionArray.from(embedding.vector) on every callJSON-serialisable for cache persistence; standard Array methods work. Tradeoff: 2× memory but cache size is small.
Manual cosine in similarity()Hand-written loop, no SIMDEducational clarity. The reader sees (a·b)/(‖a‖·‖b‖). Real RAG goes through the vector DB anyway.

Architecture

classDiagram
    class EmbeddingModel {
      -modelPath: string
      -dimensions: 384
      -batchSize: 32
      -useCache: bool
      -cache: EmbeddingCache?
      -llama / model / context
      -initialized: bool
      +constructor(config)
      +initialize() Promise
      +embedQuery(text) Promise number[]
      +embedDocuments(texts, onProgress) Promise number[][]
      +embedDocumentsWithMetadata(docs)
      +similarity(text1, text2) Promise number
      -_cosineSimilarity(v1, v2) number
      -_ensureInitialized()
      +saveCache(filepath)
      +loadCache(filepath)
      +cleanup()
    }
    class EmbeddingCache {
      -maxSize: 10000
      -useHash: true
      -hashAlgorithm: 'sha256'
      -cache: Map~string, Entry~
      -stats: Stats
      +get(text) Promise number[]?
      +set(text, vector) Promise
      +has(text) Promise bool
      +size() number
      +clear()
      +keys() string[]
      +getStats() Stats
      +getTopEntries(limit) Entry[]
      +saveToDisk(filepath) Promise
      +loadFromDisk(filepath) Promise
      -_generateKey(text) string
      -_evictLRU()
    }
    class Entry {
      vector: number[]
      text: string?
      timestamp: number
      lastAccessed: number
      hits: number
    }
    class Stats {
      hits: number
      misses: number
      sets: number
      evictions: number
    }
    EmbeddingModel *-- EmbeddingCache : optional
    EmbeddingCache *-- Entry
    EmbeddingCache *-- Stats

Lifecycle & Data Flow

sequenceDiagram
    participant U as User code
    participant EM as EmbeddingModel
    participant EC as EmbeddingCache
    participant Crypto as crypto.createHash
    participant LM as llama.context
    U->>EM: new EmbeddingModel({modelPath, cache:true})
    Note over EM: cache = new EmbeddingCache()
(no model loaded yet) U->>EM: await initialize() EM->>LM: getLlama() + loadModel + createEmbeddingContext LM-->>EM: context Note over EM: ~1.5s cold start U->>EM: embedQuery("hello world") EM->>EC: get("hello world") EC->>Crypto: createHash('sha256').update(text).digest('hex') Crypto-->>EC: 64-char hex alt key in Map EC->>EC: entry.lastAccessed = now()
entry.hits++
stats.hits++ EC-->>EM: vector EM-->>U: number[384] Note over U: ~5µs total else key absent EC->>EC: stats.misses++ EC-->>EM: null EM->>LM: ctx.getEmbeddingFor("hello world") LM-->>EM: Float32Array(384) EM->>EM: Array.from(vector) EM->>EC: set("hello world", vector) EC->>EC: if size >= maxSize: _evictLRU() EC->>EC: cache.set(key, {vector, lastAccessed, hits:0, ...})
stats.sets++ EM-->>U: number[384] Note over U: ~10-50ms end

Interactions

FromToInterfaceWhy this interaction exists
Lesson 04+EmbeddingModelembedQuery / embedDocumentsEvery RAG pipeline starts by turning text into vectors.
EmbeddingModelEmbeddingCacheget / setAvoid re-embedding identical text across runs — the slow path.
EmbeddingModelnode-llama-cppgetLlama / loadModel / createEmbeddingContext / getEmbeddingForThe actual neural-network inference. Wrapped to keep the rest of src/ agnostic of llama.cpp internals.
EmbeddingCachenode:cryptocreateHash('sha256').update(text).digest('hex')Fixed-length, collision-resistant cache key.
EmbeddingCachenode:fs/promiseswriteFile / readFilePersist cache between runs as JSON.
User codeEmbeddingModel.similaritytwo-string convenienceEducational only — real RAG uses the vector DB. Useful for the similarity-basics lesson.
User codeEmbeddingModel.cleanuprelease resourcesllama.cpp holds an mmap'd model and a context; cleanup is good hygiene before exit.

📝 Annotated Code Walkthrough — EmbeddingModel.embedDocuments

The mature batch-embedding path. Every step exists for a reason — remove any one and a real workload breaks.

Guard against use-before-init

async embedDocuments(texts, onProgress = null) {
    this._ensureInitialized();

Throws synchronously if the user forgot await initialize(). Without this, the next line would NPE deep inside llama.cpp with an opaque error.

Reserve output buffer + progress counter

    const vectors = [];
    let processed = 0;

processed lives outside the loop because the inner map closure increments it. Without the closure, the progress callback would fire in batch-aligned bursts (boring) instead of per-document (informative).

Outer loop: walk in batch-sized strides

    for (let i = 0; i < texts.length; i += this.batchSize) {
        const batch = texts.slice(i, Math.min(i + this.batchSize, texts.length));

Why batch? Promise.all over the entire array would create thousands of pending promises — memory pressure plus llama.cpp serialises anyway. Batches of 32 give the GC a chance to breathe.

Parallel cache-or-compute per batch

        const batchVectors = await Promise.all(
            batch.map(async (text) => {
                if (this.cache) {
                    const cached = await this.cache.get(text);
                    if (cached) return cached;
                }
                const embedding = await this.context.getEmbeddingFor(text);
                const vector = Array.from(embedding.vector);
                if (this.cache) await this.cache.set(text, vector);
                processed++;
                if (onProgress) onProgress(processed, texts.length);
                return vector;
            })
        );

The cache check is per-text, not per-batch. A mostly-cached batch returns in microseconds without ever touching the model. A mostly-cold batch parallelises the model calls; llama.cpp serialises internally but Promise.all keeps the JS event loop responsive (progress callbacks fire promptly).

Concatenate and continue

        vectors.push(...batchVectors);
    }
    return vectors;
}

Spread-push because vectors.concat(batchVectors) would allocate a new array each iteration. Spread mutates in place — cheaper.

📝 Annotated Code Walkthrough — EmbeddingCache._evictLRU

Initialise tracking variables

_evictLRU() {
    let oldestKey = null;
    let oldestTime = Infinity;

Infinity as the sentinel ensures the first iteration always wins, regardless of actual timestamp values.

Linear scan

    for (const [key, entry] of this.cache.entries()) {
        if (entry.lastAccessed < oldestTime) {
            oldestTime = entry.lastAccessed;
            oldestKey = key;
        }
    }

O(n) per eviction. Why acceptable? Evictions only happen when cache.size >= maxSize AND the new key is missing — a rare condition once the cache is warm. The hot path is cache hits which are O(1) Map lookup.

Delete and tally

    if (oldestKey) {
        this.cache.delete(oldestKey);
        this.stats.evictions++;
    }
}

The if (oldestKey) guard handles the empty-cache case — though in practice _evictLRU is only called when size >= maxSize, so the cache is never empty here.

Deep-Dive Highlights

SHA-256 keying tradeoffs Mechanism

Why hash the text at all? Three reasons: fixed key length (saves Map memory for long texts), privacy in disk dumps, collision resistance. The cost (~5µs per hash) is invisible against the embedding call.

Promise.all batch parallelism Algorithm

Within a batch, every map callback fires immediately. llama.cpp serialises internally (single context), but the JS event loop stays responsive — progress callbacks fire as each completes, not in batch-aligned bursts.

Stats as a debugging aid Observability

getStats() returns hits, misses, hitRate%, evictions. The user can monitor cache effectiveness during a run; lessons 04/02 prints this at the end as a learning artefact.

⚠ Edge Cases & Boundaries

ScenarioWhat Actually Happens
User calls embedQuery before initialize_ensureInitialized() throws "EmbeddingModel not initialized. Call initialize() first." — loud, fail-fast.
Cache disk file corruptedJSON.parse throws inside loadFromDisk; caller catches, cache stays empty (cold start). Correctness intact, performance degraded.
Cache full at maxSize=10000Each insert evicts the LRU entry. Stats track evictions for tuning. Working-set > cache size means thrashing.
Two texts hash to the same SHA-256Collision — the second set overwrites the first. SHA-256 collisions are computationally infeasible at the scales involved here (10K entries).
Vector dimensions mismatch in similarity()Throws "Vectors must have same dimensions" from _cosineSimilarity. Caller bug; not silently coerced.
Zero-magnitude vector (rare)Cosine returns 0 instead of NaN (div-by-zero guard). Better than propagating NaN through the retrieval pipeline.

Directory Map

embeddings/ files
src/embeddings/
├── EmbeddingModel.js   (320 LOC)   ← The wrapper: initialize, embedQuery, embedDocuments, similarity, cache plumbing
├── EmbeddingCache.js   (425 LOC)   ← LRU cache with stats + JSON persistence
└── index.js                       ← Re-exports both classes

Configuration Reference

ParameterDefaultDescription
EmbeddingModel.modelPath(required)Path to GGUF embedding model.
EmbeddingModel.dimensions384Expected embedding dimensions; must match model.
EmbeddingModel.logLevel'error'Passed to getLlama({logLevel}).
EmbeddingModel.cachetrueToggle the cache side-channel entirely.
EmbeddingModel.batchSize32Per-batch Promise.all width.
EmbeddingCache.maxSize10000Eviction threshold.
EmbeddingCache.useHashtrueSHA-256 the text key.
EmbeddingCache.hashAlgorithm'sha256'Any crypto.createHash algo.
onProgress(processed, total)nullOptional callback fired per text in batch loop.
cleanup()(method)Releases context/model/llama; idempotent.
saveCache(filepath) / loadCache(filepath)(methods)JSON dump + restore including stats and per-entry timestamps.
getCacheStats()(method)Snapshot of hit-rate, evictions, total requests.

Code Examples

Basic embedding with cache stats

import { EmbeddingModel } from './src/embeddings/EmbeddingModel.js';

const em = new EmbeddingModel({
    modelPath: './models/bge-small-en-v1.5.Q8_0.gguf',
    cache: true,
    batchSize: 32
});
await em.initialize();

const vec = await em.embedQuery('What is RAG?');
const docVecs = await em.embedDocuments([
    'RAG combines retrieval with generation.',
    'Embeddings turn text into vectors.',
    'The capital of France is Paris.'
], (n, total) => console.log(`${n}/${total}`));

console.log(em.getCacheStats());
// { enabled:true, size:4, maxSize:10000, hits:0, misses:4,
//   sets:4, evictions:0, hitRate:'0.00%', totalRequests:4 }

await em.cleanup();

Persist cache between runs

// First run
await em.embedDocuments(myDocs);
await em.saveCache('./.cache/embeddings.json');

// Second run (after restart)
await em.loadCache('./.cache/embeddings.json');
await em.embedDocuments(myDocs);  // mostly cache hits, ~10x faster
console.log(em.getCacheStats().hitRate);  // '99.50%'

Compare two texts directly

const score = await em.similarity(
    'machine learning is fun',
    'AI training is enjoyable'
);
console.log(score);  // ~0.78