Detect embedding model changes and distribution shifts before they silently degrade your retrieval quality.
When an embedding model changes -- OpenAI's text-embedding-ada-002 to text-embedding-3-small, a Cohere version bump, or any silent provider update -- the vectors already stored in your database become incompatible with newly produced vectors. Queries return wrong results. No error is thrown, no status code changes, no log line appears. The system looks healthy. The results are wrong.
embed-drift detects this failure before it reaches your users. It monitors embedding distributions over time through two complementary mechanisms:
Canary-based detection embeds a fixed set of reference texts, stores the resulting vectors, and later re-embeds the same texts to check whether the model has changed. This is cheap (embeds only 25 canary texts, not the entire corpus) and catches model changes on the very next check.
Statistical snapshot comparison captures the distribution of a sample of embedding vectors at time T -- centroid, per-dimension variance, pairwise similarity distribution, and more -- and compares that snapshot against a future sample. When the distributions have drifted beyond configurable thresholds, embed-drift computes a composite drift score, classifies severity, and fires alert callbacks.
Zero runtime dependencies. Pure TypeScript. All statistical computations are self-contained.
npm install embed-driftRequires Node.js 18 or later.
import{createMonitor}from'embed-drift';// Create a drift monitor for your embedding modelconstmonitor=createMonitor({modelId: 'text-embedding-3-small',alertSeverity: 'high',onDrift: (report)=>{console.warn('Embedding drift detected:',report.summary);},});// Take a baseline snapshot from your current embeddingsconstbaseline=monitor.snapshot(baselineEmbeddings);monitor.setBaseline(baseline);// Later, check new embeddings against the baselineconstreport=monitor.check(newEmbeddings);console.log(report.composite.severity);// 'none' | 'low' | 'medium' | 'high' | 'critical'// Detect silent model changes using canary textsconstcanaryReport=awaitmonitor.checkCanaries(embedFn);if(canaryReport.modelChanged){console.error('Embedding model has changed!');}// Save a snapshot to diskmonitor.saveSnapshot(baseline,'./snapshots/baseline.json');// Load it back laterconstloaded=monitor.loadSnapshot('./snapshots/baseline.json');monitor.setBaseline(loaded);import{createMonitor}from'embed-drift';constmonitor=createMonitor({modelId: 'text-embedding-3-small'});constbaseline=monitor.loadSnapshot('./snapshots/production-baseline.json');monitor.setBaseline(baseline);constreport=monitor.check(newEmbeddings);if(report.composite.severity==='high'||report.composite.severity==='critical'){console.error('Drift too high -- block deployment until re-indexing is complete.');process.exit(1);}Canary-based model change detection -- Embeds a fixed corpus of 25 diverse reference texts and compares their embeddings over time. Detects silent model swaps, version bumps, and provider changes within a single check cycle.
Five complementary drift detection methods -- Centroid shift, pairwise cosine similarity distribution, dimension-wise statistics (Cohen's d + KS-like statistic), Maximum Mean Discrepancy (MMD) approximation with random Fourier features, and canary comparison. Each method produces a normalized score in [0, 1].
Composite drift scoring -- Weighted average of all method scores with configurable per-method weights. Automatic weight renormalization when methods are disabled or data is unavailable.
Severity classification -- Composite scores are classified into five actionable bands:
none,low,medium,high,critical. Model changes always producecriticalseverity.Configurable alerting -- Set severity thresholds and per-method score thresholds. Register an
onDriftcallback to integrate with any monitoring system. Supports both synchronous and asynchronous callbacks.Snapshot persistence -- Save and load statistical snapshots as portable JSON files. Snapshots are compact (typically 10-900 KB depending on dimensionality and sample size) and work across processes, machines, and time.
Zero runtime dependencies -- All statistical computations are self-contained TypeScript. No native modules, no WASM, no Python bridge.
Full TypeScript support -- Complete type definitions for all exports. Strict mode compatible.
import{createMonitor,DriftError,DEFAULT_CANARY_TEXTS,}from'embed-drift';importtype{EmbedFn,DriftSeverity,MethodResult,MethodThresholds,MethodWeights,SnapshotOptions,CheckOptions,Snapshot,DriftReport,CanaryReport,DriftMonitorOptions,DriftMonitor,DriftErrorCode,}from'embed-drift';Creates a drift monitor instance. All drift detection state and configuration is encapsulated in the returned object.
Options (DriftMonitorOptions):
| Option | Type | Default | Description |
|---|---|---|---|
modelId | string | -- | Required. The embedding model identifier. |
canaryTexts | string[] | [] | Additional canary texts to append to the built-in corpus. |
replaceDefaultCanaries | boolean | false | If true, use only canaryTexts instead of built-in corpus + custom. |
canaryThreshold | number | 0.95 | Mean cosine similarity below which modelChanged is declared. |
alertSeverity | DriftSeverity | 'high' | Minimum severity to fire the onDrift callback. |
thresholds | Partial<MethodThresholds> | {} | Per-method score overrides that trigger an alert. |
onDrift | (report) => void | Promise<void> | undefined | Callback invoked when an alert fires. Async errors are swallowed. |
methodWeights | Partial<MethodWeights> | see below | Weights for the composite drift score. |
enabledMethods | { centroid?, pairwise?, dimensionWise?, mmd? } | all true | Disable specific drift methods. |
mmdRandomFeatures | number | 100 | Number of random Fourier features for MMD approximation. |
pairwiseSamplePairs | number | 500 | Number of random pairs sampled for pairwise similarity estimation. |
Default composite weights:
{canary: 0.35,centroid: 0.15,pairwise: 0.20,dimensionWise: 0.15,mmd: 0.15}Computes a statistical snapshot of the provided embedding vectors.
Parameters:
| Parameter | Type | Description |
|---|---|---|
embeddings | number[][] | At least 2 vectors of consistent dimensionality. |
options.sampleSize | number | Number of vectors to store for KS/MMD computation. Default: 50. |
options.metadata | Record<string, unknown> | Caller-provided key-value metadata attached to the snapshot. |
Returns: A Snapshot object containing the model ID, centroid, per-dimension variance, pairwise similarity statistics, a 20-bin similarity histogram, and a random sample of vectors.
Throws:
DriftError('EMPTY_INPUT')if fewer than 2 vectors are given.DriftError('INCONSISTENT_DIMENSIONS')if vectors have different lengths.
Compares two snapshots and returns a DriftReport with per-method drift scores, a composite score, and severity classification.
Parameters:
| Parameter | Type | Description |
|---|---|---|
snapshotA | Snapshot | The reference (baseline) snapshot. |
snapshotB | Snapshot | The new snapshot to compare against the baseline. |
Returns: A DriftReport with all per-method results, composite score, severity, alert status, and a human-readable summary.
Throws:
DriftError('INCOMPATIBLE_DIMENSIONS')if the two snapshots have different dimensionalities.
When snapshotA.modelId !== snapshotB.modelId, the report sets modelChanged: true and severity to critical.
Stores a snapshot as the baseline for subsequent check() calls.
Returns the currently stored baseline snapshot, or undefined if none is set.
Creates a new snapshot from embeddings and compares it against the stored baseline.
Parameters:
| Parameter | Type | Description |
|---|---|---|
embeddings | number[][] | New embedding vectors to compare against the baseline. |
options.snapshotOptions | SnapshotOptions | Options forwarded to snapshot creation. |
Returns: A DriftReport.
Throws:
DriftError('NO_BASELINE')if no baseline has been set viasetBaseline().
Embeds the configured canary texts using embedFn and compares against stored reference embeddings.
Parameters:
| Parameter | Type | Description |
|---|---|---|
embedFn | (texts: string[]) => Promise<number[][]> | A function that embeds an array of texts and returns vectors. |
Behavior:
- On the first call, establishes the reference baseline. Returns a
CanaryReportwithisInitialBaseline: trueanddriftScore: 0. - On subsequent calls, computes per-canary cosine similarities and returns
modelChanged: truewhen the mean similarity falls belowcanaryThreshold.
Returns: A CanaryReport.
Throws:
DriftError('EMBED_FN_FAILED')ifembedFnthrows.
Explicitly sets the canary reference embeddings without calling checkCanaries. Useful for loading a persisted canary baseline.
Parameters:
| Parameter | Type | Description |
|---|---|---|
canaryEmbeddings | number[][] | Pre-computed canary embeddings (one per canary text). |
Throws:
DriftError('EMPTY_INPUT')if the array is empty.
Returns the resolved canary text array (built-in + custom, or custom-only if replaceDefaultCanaries: true).
Evaluates a DriftReport or CanaryReport against configured thresholds and returns true if an alert should fire. Does not invoke the onDrift callback.
An alert fires if:
- The report severity meets or exceeds
alertSeverity, OR - Any per-method score exceeds its configured threshold in
thresholds.
Writes a snapshot as pretty-printed JSON to the given file path.
Reads and validates a snapshot from a JSON file. Validates all required fields and dimensional consistency.
Throws:
DriftError('INVALID_SNAPSHOT')if the file is missing, not valid JSON, or fails schema validation.
Custom error class extending Error with a code property for programmatic error handling.
import{DriftError}from'embed-drift';try{monitor.check(embeddings);}catch(err){if(errinstanceofDriftError){console.error(`Drift error [${err.code}]: ${err.message}`);}}A frozen array of 25 diverse English reference texts spanning technical documentation, scientific language, legal text, casual conversation, news, medical, creative writing, mathematical, instructional, and philosophical domains. Used as the default canary corpus for model fingerprinting.
import{DEFAULT_CANARY_TEXTS}from'embed-drift';console.log(DEFAULT_CANARY_TEXTS.length);// 25The composite drift score is a weighted average of per-method scores. Weights are renormalized when methods are disabled or their data is unavailable.
constmonitor=createMonitor({modelId: 'text-embedding-3-small',methodWeights: {canary: 0.40,// Increase canary influencecentroid: 0.10,pairwise: 0.20,dimensionWise: 0.15,mmd: 0.15,},});Disable individual drift detection methods when they are not needed:
constmonitor=createMonitor({modelId: 'text-embedding-3-small',enabledMethods: {mmd: false,// Skip MMD computationdimensionWise: false,// Skip dimension-wise analysis},});Disabled methods report computed: false and score 0. Their weights are redistributed to the remaining active methods.
Alerts fire when severity meets or exceeds alertSeverity, or when any per-method score exceeds its configured threshold:
constmonitor=createMonitor({modelId: 'text-embedding-3-small',alertSeverity: 'high',thresholds: {composite: 0.40,canary: 0.05,centroid: 0.30,},onDrift: (report)=>{// Send to your monitoring systemwebhook.post('/alerts/embedding-drift',report);},});Add domain-specific canary texts for increased sensitivity:
constmonitor=createMonitor({modelId: 'text-embedding-3-small',canaryTexts: ['The plaintiff alleges breach of fiduciary duty under Section 14(a).','Amortization of goodwill is calculated on a straight-line basis.',],});// Uses all 25 default canaries + 2 custom = 27 totalconstmonitorCustomOnly=createMonitor({modelId: 'text-embedding-3-small',canaryTexts: ['My custom canary text.'],replaceDefaultCanaries: true,});// Uses only the 1 custom canary textembed-drift implements five complementary methods. Each produces a normalized score in [0, 1].
Measures the cosine distance between the mean embedding vectors (centroids) of two snapshots. Detects global shifts in the embedding space. Computational cost: O(n * d) where n is the sample size and d is the dimensionality.
Compares the distribution of pairwise cosine similarities between two snapshots. Captures changes in how embeddings are spread relative to each other -- how compact or diffuse the distribution is -- even when the centroid stays the same.
Analyzes per-dimension statistics using Cohen's d effect size and KS-like statistics across sample vectors. The Cohen's d score identifies which specific dimensions have shifted, while the KS statistic captures distributional shape changes (bimodality, heavy tails) that mean and variance alone do not surface. The two scores are blended equally.
Uses Maximum Mean Discrepancy with random Fourier features (random kitchen sinks approximation) to measure the distance between two embedding distributions in a kernel-induced feature space. The RBF kernel bandwidth is set via the median heuristic. Sensitive to all moments of the distribution difference. Configurable via mmdRandomFeatures (default: 100).
Embeds a fixed corpus of diverse reference texts and compares their embeddings over time. Detects silent model changes (provider swaps, version updates) by monitoring whether the same inputs produce the same outputs. The primary and cheapest signal for model change detection.
| Composite Score | Severity | Recommended Action |
|---|---|---|
| 0.00 -- 0.05 | none | No action needed. Distribution is stable. |
| 0.05 -- 0.20 | low | Monitor. Normal content variation. |
| 0.20 -- 0.40 | medium | Investigate. Consider partial re-indexing. |
| 0.40 -- 0.70 | high | Re-embed recommended. Significant drift detected. |
| 0.70 -- 1.00 | critical | Re-embed immediately. |
A confirmed model change (different model IDs or canary mean similarity below threshold) always produces critical severity regardless of the composite score.
All errors thrown by embed-drift are instances of DriftError with a code property for programmatic handling:
| Code | When It Is Thrown |
|---|---|
EMPTY_INPUT | Embedding array has fewer than 2 vectors, or setCanaryBaseline receives an empty array. |
INCONSISTENT_DIMENSIONS | Vectors in the input array have different dimensionalities. |
INCOMPATIBLE_DIMENSIONS | Two snapshots being compared have different dimensionalities. |
NO_BASELINE | check() called before setBaseline(). |
INVALID_SNAPSHOT | Loaded snapshot file is missing, not valid JSON, or fails schema validation. |
NO_CANARY_BASELINE | Canary comparison attempted without a reference baseline. |
EMBED_FN_FAILED | The embedding function passed to checkCanaries() threw an error. |
import{DriftError}from'embed-drift';try{constreport=monitor.check(newEmbeddings);}catch(err){if(errinstanceofDriftError){switch(err.code){case'NO_BASELINE':
console.error('Set a baseline before calling check().');break;case'INCOMPATIBLE_DIMENSIONS':
console.error('Snapshot dimensions do not match.');break;default:
console.error(`Unexpected drift error: ${err.code}`);}}}Run periodic drift checks as part of a cron job or background worker:
import{createMonitor}from'embed-drift';constmonitor=createMonitor({modelId: 'text-embedding-3-small',alertSeverity: 'medium',onDrift: async(report)=>{awaitsendSlackAlert(`Embedding drift detected: ${report.summary}`);},});// Load the production baselineconstbaseline=monitor.loadSnapshot('./snapshots/production-baseline.json');monitor.setBaseline(baseline);// Sample current embeddings from your vector databaseconstcurrentSample=awaitsampleFromVectorDB(1000);// Check for driftconstreport=monitor.check(currentSample);console.log(`Severity: ${report.composite.severity}, Score: ${report.composite.score}`);Detect model changes with minimal API cost:
import{createMonitor}from'embed-drift';constmonitor=createMonitor({modelId: 'text-embedding-3-small',canaryThreshold: 0.95,onDrift: (report)=>{if('modelChanged'inreport&&report.modelChanged){triggerReindexingPipeline();}},});// On first run, establishes the canary baselineconstembedFn=async(texts: string[])=>{returnopenai.embeddings.create({model: 'text-embedding-3-small',input: texts}).then(res=>res.data.map(d=>d.embedding));};constreport=awaitmonitor.checkCanaries(embedFn);if(report.isInitialBaseline){console.log('Canary baseline established.');}elseif(report.modelChanged){console.error('Model changed! Drift score:',report.driftScore);}else{console.log('Model unchanged. Mean similarity:',report.meanSimilarity);}Compare snapshots without managing baseline state:
constmonitor=createMonitor({modelId: 'text-embedding-3-small'});constsnapshotA=monitor.loadSnapshot('./snapshots/2025-01-baseline.json');constsnapshotB=monitor.loadSnapshot('./snapshots/2025-03-current.json');constreport=monitor.compare(snapshotA,snapshotB);console.log('Composite score:',report.composite.score);console.log('Severity:',report.composite.severity);console.log('Centroid drift:',report.methods.centroid.score);console.log('Pairwise drift:',report.methods.pairwise.score);console.log('MMD drift:',report.methods.mmd.score);console.log('Summary:',report.summary);Load a previously saved canary baseline to avoid re-establishing on every restart:
constmonitor=createMonitor({modelId: 'text-embedding-3-small',replaceDefaultCanaries: true,canaryTexts: ['My domain-specific canary text.'],});// Load saved canary embeddingsconstsavedCanaries=JSON.parse(readFileSync('./canary-baseline.json','utf-8'));monitor.setCanaryBaseline(savedCanaries);// Now checkCanaries compares against the loaded baselineconstreport=awaitmonitor.checkCanaries(embedFn);All types are exported for use in TypeScript projects:
interfaceSnapshot{id: string;// UUID v4createdAt: string;// ISO 8601 timestampmodelId: string;// Embedding model identifierdimensionality: number;// Vector dimensionssampleCount: number;// Number of input vectorscentroid: number[];// Element-wise mean vectorvariance: number[];// Per-dimension variancemeanPairwiseSimilarity: number;// Mean cosine similarity across sampled pairsstdPairwiseSimilarity: number;// Std dev of pairwise cosine similaritiessimilarityHistogram: number[];// 20-bin histogram from -1.0 to 1.0sampleVectors: number[][];// Random sample of vectors for KS/MMDcanaryEmbeddings?: number[][];// Canary text embeddings (optional)metadata?: Record<string,unknown>;// Caller-provided metadata (optional)}interfaceDriftReport{id: string;createdAt: string;snapshotIds: [string,string];modelIds: [string,string];modelChanged: boolean;methods: {canary: MethodResult;centroid: MethodResult;pairwise: MethodResult;dimensionWise: MethodResult;mmd: MethodResult;};composite: {score: number;// Weighted average in [0, 1]severity: DriftSeverity;// 'none' | 'low' | 'medium' | 'high' | 'critical'weights: MethodWeights;// Effective weights used};alerted: boolean;summary: string;durationMs: number;}interfaceCanaryReport{id: string;createdAt: string;canaryCount: number;meanSimilarity: number;minSimilarity: number;perCanarySimilarities: number[];driftScore: number;// 1 - meanSimilaritymodelChanged: boolean;isInitialBaseline: boolean;alerted: boolean;modelId: string;durationMs: number;}interfaceMethodResult{score: number;// Drift score in [0, 1]computed: boolean;// Whether this method was runinterpretation: string;// Human-readable interpretationdetails?: Record<string,unknown>;// Method-specific details}typeEmbedFn=(texts: string[])=>Promise<number[][]>;typeDriftSeverity='none'|'low'|'medium'|'high'|'critical';typeDriftErrorCode=|'EMPTY_INPUT'|'INCONSISTENT_DIMENSIONS'|'INCOMPATIBLE_DIMENSIONS'|'NO_BASELINE'|'INVALID_SNAPSHOT'|'NO_CANARY_BASELINE'|'EMBED_FN_FAILED';MIT