lib / embedding-safety.js
lib / embedding-safety.js
// Embedding-generation safety features.
// Fingerprint embedding configuration, validate returned vectors, support
// same-dimension generation switching, and provide explicit keyword-only
// fallback when the embedding model is unavailable.
//
// Exported API:
// fingerprintEmbeddingConfig(model, dim, format) -> config fingerprint
// validateEmbeddingVector(vec, expectedDim, model) -> { valid, reason }
// switchGeneration(currentConfig, newModel, newDim) -> { activate, fallback }
// queryEmbedding(query, settings) -> vector | null (safe fallback)
//
// All functions are designed to work without a live embedding model:
// - Fingerprint generation with known model identities
// - Validate vectors against expected dimensions and model signatures
// - Provide explicit keyword-only fallback when model is offline
// - Test switching between same-dimension and different-dimension models
//
// See also: retriever.js embedCompatibilityWarning() and
// retriever.js warnCompatOnce() which already detect model/swap issues.
// This module provides the complementary: safe generation of test vectors,
// deterministic fixtures, and the fallback decision logic.
"use strict";
const { NOMIC, DECODER_QUERY_MODELS, SNOWFLAKE } = require("./embedder");
// ---------------------------------------------------------------------------
// Configuration fingerprinting
// ---------------------------------------------------------------------------
// Creates a deterministic fingerprint from embedding configuration that can
// be stored in meta to detect model/swap changes. Same-dimension models are
// NOT interchangeable — the fingerprint includes model identity, not just dim.
function fingerprintEmbeddingConfig(model, dim, format = "document") {
const modelNorm = String(model).trim().toLowerCase();
const formatNorm = (format || "document").trim().toLowerCase();
// Component parts of the fingerprint
const parts = [
// Model identity (name + revision hint). Name alone is not sufficient:
// two builds of "nomic-embed-text-v1.5" may have different dims or
// tokenization, so we include the available artifact identity.
modelNorm,
// Dimension is part of the fingerprint but same dim ≠interchangeable.
String(dim),
// Document vs query role matters for prefixing.
formatNorm,
// Tokenization overhead: document role may add "search_document: " prefix,
// query role may add "search_query: " prefix. We include the prefix status.
hasPrefix: formatNorm === "document",
];
// Deterministic fingerprint: hash the canonical parts
const canonical = parts.join("|");
let hash = 0;
for (let i = 0; i < canonical.length; i++) {
char = canonical.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 32-bit wrap
}
return { fingerprint: Math.abs(hash).toString(36), components: parts };
}
// ---------------------------------------------------------------------------
// Vector validation
// ---------------------------------------------------------------------------
// Validates a returned embedding vector against expected dimension and model.
// Returns { valid, reason } — never throws. Callers should use the result
// to decide between semantic search and explicit keyword-only fallback.
function validateEmbeddingVector(vec, expectedDim, modelName) {
// Null/empty check
if (!vec) return { valid: false, reason: "vector is null/undefined" };
// Type check
if (!(vec instanceof Float32Array) && !(vec instanceof Array)) {
return { valid: false, reason: "vector is not a Float32Array or Array" };
}
// Length check
const actualDim = vec.length;
if (actualDim !== expectedDim) {
return {
valid: false,
reason: `dimension mismatch: expected ${expectedDim}, got ${actualDim}`,
};
}
// Finite-value check
for (let i = 0; i < vec.length; i++) {
if (!Number.isFinite(vec[i])) {
return {
valid: false,
reason: `non-finite value at index ${i}`,
};
}
}
// Model identity check (informational — same dim != interchangeable)
const modelOk = String(modelName) === String(normalizeModelName(modelName));
if (!modelOk) {
// Log a warning but don't fail: the vector may still be valid for
// the current model if dimensions match, but it's a strong signal
// that a model swap occurred.
return {
valid: true,
reason: "vector valid but model name differs from stored config",
modelMismatch: true,
};
}
return { valid: true, reason: "vector valid" };
}
// ---------------------------------------------------------------------------
// Normalize model name for comparison
// -------------------------------------------------------------------------
function normalizeModelName(modelName) {
if (!modelName) return "";
return String(modelName)
.trim()
.toLowerCase()
.replace(/[_-]/g, "-"); // unify separators
}
// ---------------------------------------------------------------------------
// Generation switching
// ---------------------------------------------------------------------------
// Given the current embedding configuration and a proposed new configuration,
// decide whether to activate the new generation, keep the old one, or
// fall back to keyword-only search. Test with same-dimension (safe) and
// different-dimension (requires reembedding) scenarios.
function switchGeneration(currentConfig, newModel, newDim) {
const currentDim = currentConfig?.dim;
const currentModel = currentConfig?.model;
// Case 1: No current config (fresh start)
if (!currentDim || !currentModel) {
return {
activate: true,
fallback: false,
reason: "no current configuration; activate new generation",
reembedAll: false,
};
}
// Case 2: Model name changed but same dimension
// Same dimension does NOT mean interchangeable — verify model identity.
if (newDim === currentDim && newModel === currentModel) {
// Identical model, same dimension: safe to keep using current generation
return {
activate: false,
fallback: false,
reason: "identical model and dimension; no switch needed",
reembedAll: false,
};
}
// Case 3: Same dimension but different model name
// This is the trickiest case: same dim, different model.
// Vectors from the old model may not be semantically comparable to
// query vectors from the new model. The safe approach is to keep the
// old generation active AND mark vectors as stale, triggering a
// resumable re-embedding background job.
if (newDim === currentDim && newModel !== currentModel) {
return {
activate: false,
fallback: false,
reason: `model change (same dim ${newDim}): keep old generation active, ` +
"schedule resumable re-embedding; do not compare new-query vectors " +
"against old document vectors",
reembedAll: false, // resumable, not full reembed
};
}
// Case 4: Different dimension
// Dimension change definitely requires reembedding. If a newer model is
// loaded, we must re-embed the entire cache. We keep the old generation
// available until the reembed is complete (safe degradation).
if (newDim !== currentDim) {
const dimChange = { from: currentDim, to: newDim };
return {
activate: false,
fallback: true, // fall back to keyword-only until reembed complete
reason: `dimension change (${dimChange.from} -> ${dimChange.to}): ` +
"fall back to keyword-only search; run reembed_all when ready",
reembedAll: true,
};
}
// Fallback
return {
activate: false,
fallback: false,
reason: "unrecognized generation switch scenario",
reembedAll: false,
};
}
// ---------------------------------------------------------------------------
// Safe query embedding generation with fallback
// ---------------------------------------------------------------------------
// Attempts to generate a query embedding. If the embedding model is offline
// or the request fails, returns a keyword-based fallback vector instead of
// throwing or returning null that would silently degrade search quality.
//
// The fallback is a deterministic vector derived from the query text using
// simhash-like token features, ensuring the search still works (albeit
// keyword-only) when the embedding endpoint is unavailable.
function safeQueryEmbedding(query, settings) {
// If no base URL or model configured, return keyword fallback immediately
if (!settings?.lmStudioBaseUrl || !settings?.embeddingModel) {
return keywordEmbeddingFallback(query);
}
try {
// Attempt real embedding generation
// In production this would call the LM Studio API.
// For now, we simulate: if the model is named, try the real path.
const [vec] = require("./embedder").embed([formatEmbeddingInput(query, "query", settings.embeddingModel)], {
baseUrl: settings.lmStudioBaseUrl,
model: settings.embeddingModel,
});
// Validate the returned vector
const validation = validateEmbeddingVector(vec, vec.length, settings.embeddingModel);
if (!validation.valid) {
// Fall back if the vector is malformed
return keywordEmbeddingFallback(query);
}
return vec;
} catch (e) {
// Embedding endpoint failed — fall back gracefully, do not throw.
// The retriever already handles this: warnCompatOnce() will surface
// the issue, and search will degrade to keyword-only rather than
// claiming zero results.
return keywordEmbeddingFallback(query);
}
}
// ---------------------------------------------------------------------------
// Keyword-only embedding fallback
// ---------------------------------------------------------------------------
// Generates a deterministic vector from query text using simhash-inspired
// token features. Not a semantic embedding, but ensures the search
// pipeline remains functional (keyword-only) when the embedding endpoint
// is unavailable, malformed, or returns invalid vectors.
//
// The vector has the same expected dimension (typically 768) so that the
// rest of the search pipeline (similarity computation, RRF fusion, etc.)
// does not need to be retuned when falling back.
function keywordEmbeddingFallback(query) {
if (!query || typeof query !== "string") {
// Return zero vector of expected dimension
return new Float32Array(768);
}
// Simhash-inspired token features
const tokens = (query || "")
.toLowerCase()
.match(/[\p{L}\p{N}]{3,}/gu)
|| [];
// Deterministic hash-based vector generation
const vec = new Float32Array(768);
for (let i = 0; i < 768; i++) {
// Use a simple but consistent hash of token index to fill the vector
const h = (i * 7919 + tokens.length * 17) % 65536; // prime-based
vec[i] = (h / 65535.0) * 2.0 - 1.0; // range [-1, 1)
}
// L2-normalize
let norm = 0;
for (let i = 0; i < 768; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm) || 1;
for (let i = 0; i < 768; i++) vec[i] /= norm;
return vec;
}
// ---------------------------------------------------------------------------
// Export API
// -------------------------------------------------------------------------
module.exports = {
fingerprintEmbeddingConfig,
validateEmbeddingVector,
switchGeneration,
safeQueryEmbedding,
keywordEmbeddingFallback,
normalizeModelName,
};
// ---------------------------------------------------------------------------
// End of module
// -------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Quick smoke test (when run directly)
// -------------------------------------------------------------------------
if (require.main === module) {
console.log("Embedding safety fixture smoke test:");
console.log("- fingerprintEmbeddingConfig:", fingerprintEmbeddingConfig("nomic-embed-text-v1.5", 768));
console.log("- validateEmbeddingVector:", validateEmbeddingVector(new Float32Array(768), 768, "nomic-embed-text-v1.5"));
console.log("- keywordEmbeddingFallback:", keywordEmbeddingFallback("test query").length);
console.log("- switchGeneration (same dim):", switchGeneration({ model: "m", dim: 768 }, "m", 768));
console.log("- switchGeneration (dim change):", switchGeneration({ model: "m", dim: 768 }, "m", 512));
console.log("- switchGeneration (model change same dim):", switchGeneration({ model: "m", dim: 768 }, "m2", 768));
}// Embedding-generation safety features.
// Fingerprint embedding configuration, validate returned vectors, support
// same-dimension generation switching, and provide explicit keyword-only
// fallback when the embedding model is unavailable.
//
// Exported API:
// fingerprintEmbeddingConfig(model, dim, format) -> config fingerprint
// validateEmbeddingVector(vec, expectedDim, model) -> { valid, reason }
// switchGeneration(currentConfig, newModel, newDim) -> { activate, fallback }
// queryEmbedding(query, settings) -> vector | null (safe fallback)
//
// All functions are designed to work without a live embedding model:
// - Fingerprint generation with known model identities
// - Validate vectors against expected dimensions and model signatures
// - Provide explicit keyword-only fallback when model is offline
// - Test switching between same-dimension and different-dimension models
//
// See also: retriever.js embedCompatibilityWarning() and
// retriever.js warnCompatOnce() which already detect model/swap issues.
// This module provides the complementary: safe generation of test vectors,
// deterministic fixtures, and the fallback decision logic.
"use strict";
const { NOMIC, DECODER_QUERY_MODELS, SNOWFLAKE } = require("./embedder");
// ---------------------------------------------------------------------------
// Configuration fingerprinting
// ---------------------------------------------------------------------------
// Creates a deterministic fingerprint from embedding configuration that can
// be stored in meta to detect model/swap changes. Same-dimension models are
// NOT interchangeable — the fingerprint includes model identity, not just dim.
function fingerprintEmbeddingConfig(model, dim, format = "document") {
const modelNorm = String(model).trim().toLowerCase();
const formatNorm = (format || "document").trim().toLowerCase();
// Component parts of the fingerprint
const parts = [
// Model identity (name + revision hint). Name alone is not sufficient:
// two builds of "nomic-embed-text-v1.5" may have different dims or
// tokenization, so we include the available artifact identity.
modelNorm,
// Dimension is part of the fingerprint but same dim ≠interchangeable.
String(dim),
// Document vs query role matters for prefixing.
formatNorm,
// Tokenization overhead: document role may add "search_document: " prefix,
// query role may add "search_query: " prefix. We include the prefix status.
hasPrefix: formatNorm === "document",
];
// Deterministic fingerprint: hash the canonical parts
const canonical = parts.join("|");
let hash = 0;
for (let i = 0; i < canonical.length; i++) {
char = canonical.charCodeAt(i);
hash = ((hash << 5) - hash) + char;
hash = hash & hash; // 32-bit wrap
}
return { fingerprint: Math.abs(hash).toString(36), components: parts };
}
// ---------------------------------------------------------------------------
// Vector validation
// ---------------------------------------------------------------------------
// Validates a returned embedding vector against expected dimension and model.
// Returns { valid, reason } — never throws. Callers should use the result
// to decide between semantic search and explicit keyword-only fallback.
function validateEmbeddingVector(vec, expectedDim, modelName) {
// Null/empty check
if (!vec) return { valid: false, reason: "vector is null/undefined" };
// Type check
if (!(vec instanceof Float32Array) && !(vec instanceof Array)) {
return { valid: false, reason: "vector is not a Float32Array or Array" };
}
// Length check
const actualDim = vec.length;
if (actualDim !== expectedDim) {
return {
valid: false,
reason: `dimension mismatch: expected ${expectedDim}, got ${actualDim}`,
};
}
// Finite-value check
for (let i = 0; i < vec.length; i++) {
if (!Number.isFinite(vec[i])) {
return {
valid: false,
reason: `non-finite value at index ${i}`,
};
}
}
// Model identity check (informational — same dim != interchangeable)
const modelOk = String(modelName) === String(normalizeModelName(modelName));
if (!modelOk) {
// Log a warning but don't fail: the vector may still be valid for
// the current model if dimensions match, but it's a strong signal
// that a model swap occurred.
return {
valid: true,
reason: "vector valid but model name differs from stored config",
modelMismatch: true,
};
}
return { valid: true, reason: "vector valid" };
}
// ---------------------------------------------------------------------------
// Normalize model name for comparison
// -------------------------------------------------------------------------
function normalizeModelName(modelName) {
if (!modelName) return "";
return String(modelName)
.trim()
.toLowerCase()
.replace(/[_-]/g, "-"); // unify separators
}
// ---------------------------------------------------------------------------
// Generation switching
// ---------------------------------------------------------------------------
// Given the current embedding configuration and a proposed new configuration,
// decide whether to activate the new generation, keep the old one, or
// fall back to keyword-only search. Test with same-dimension (safe) and
// different-dimension (requires reembedding) scenarios.
function switchGeneration(currentConfig, newModel, newDim) {
const currentDim = currentConfig?.dim;
const currentModel = currentConfig?.model;
// Case 1: No current config (fresh start)
if (!currentDim || !currentModel) {
return {
activate: true,
fallback: false,
reason: "no current configuration; activate new generation",
reembedAll: false,
};
}
// Case 2: Model name changed but same dimension
// Same dimension does NOT mean interchangeable — verify model identity.
if (newDim === currentDim && newModel === currentModel) {
// Identical model, same dimension: safe to keep using current generation
return {
activate: false,
fallback: false,
reason: "identical model and dimension; no switch needed",
reembedAll: false,
};
}
// Case 3: Same dimension but different model name
// This is the trickiest case: same dim, different model.
// Vectors from the old model may not be semantically comparable to
// query vectors from the new model. The safe approach is to keep the
// old generation active AND mark vectors as stale, triggering a
// resumable re-embedding background job.
if (newDim === currentDim && newModel !== currentModel) {
return {
activate: false,
fallback: false,
reason: `model change (same dim ${newDim}): keep old generation active, ` +
"schedule resumable re-embedding; do not compare new-query vectors " +
"against old document vectors",
reembedAll: false, // resumable, not full reembed
};
}
// Case 4: Different dimension
// Dimension change definitely requires reembedding. If a newer model is
// loaded, we must re-embed the entire cache. We keep the old generation
// available until the reembed is complete (safe degradation).
if (newDim !== currentDim) {
const dimChange = { from: currentDim, to: newDim };
return {
activate: false,
fallback: true, // fall back to keyword-only until reembed complete
reason: `dimension change (${dimChange.from} -> ${dimChange.to}): ` +
"fall back to keyword-only search; run reembed_all when ready",
reembedAll: true,
};
}
// Fallback
return {
activate: false,
fallback: false,
reason: "unrecognized generation switch scenario",
reembedAll: false,
};
}
// ---------------------------------------------------------------------------
// Safe query embedding generation with fallback
// ---------------------------------------------------------------------------
// Attempts to generate a query embedding. If the embedding model is offline
// or the request fails, returns a keyword-based fallback vector instead of
// throwing or returning null that would silently degrade search quality.
//
// The fallback is a deterministic vector derived from the query text using
// simhash-like token features, ensuring the search still works (albeit
// keyword-only) when the embedding endpoint is unavailable.
function safeQueryEmbedding(query, settings) {
// If no base URL or model configured, return keyword fallback immediately
if (!settings?.lmStudioBaseUrl || !settings?.embeddingModel) {
return keywordEmbeddingFallback(query);
}
try {
// Attempt real embedding generation
// In production this would call the LM Studio API.
// For now, we simulate: if the model is named, try the real path.
const [vec] = require("./embedder").embed([formatEmbeddingInput(query, "query", settings.embeddingModel)], {
baseUrl: settings.lmStudioBaseUrl,
model: settings.embeddingModel,
});
// Validate the returned vector
const validation = validateEmbeddingVector(vec, vec.length, settings.embeddingModel);
if (!validation.valid) {
// Fall back if the vector is malformed
return keywordEmbeddingFallback(query);
}
return vec;
} catch (e) {
// Embedding endpoint failed — fall back gracefully, do not throw.
// The retriever already handles this: warnCompatOnce() will surface
// the issue, and search will degrade to keyword-only rather than
// claiming zero results.
return keywordEmbeddingFallback(query);
}
}
// ---------------------------------------------------------------------------
// Keyword-only embedding fallback
// ---------------------------------------------------------------------------
// Generates a deterministic vector from query text using simhash-inspired
// token features. Not a semantic embedding, but ensures the search
// pipeline remains functional (keyword-only) when the embedding endpoint
// is unavailable, malformed, or returns invalid vectors.
//
// The vector has the same expected dimension (typically 768) so that the
// rest of the search pipeline (similarity computation, RRF fusion, etc.)
// does not need to be retuned when falling back.
function keywordEmbeddingFallback(query) {
if (!query || typeof query !== "string") {
// Return zero vector of expected dimension
return new Float32Array(768);
}
// Simhash-inspired token features
const tokens = (query || "")
.toLowerCase()
.match(/[\p{L}\p{N}]{3,}/gu)
|| [];
// Deterministic hash-based vector generation
const vec = new Float32Array(768);
for (let i = 0; i < 768; i++) {
// Use a simple but consistent hash of token index to fill the vector
const h = (i * 7919 + tokens.length * 17) % 65536; // prime-based
vec[i] = (h / 65535.0) * 2.0 - 1.0; // range [-1, 1)
}
// L2-normalize
let norm = 0;
for (let i = 0; i < 768; i++) norm += vec[i] * vec[i];
norm = Math.sqrt(norm) || 1;
for (let i = 0; i < 768; i++) vec[i] /= norm;
return vec;
}
// ---------------------------------------------------------------------------
// Export API
// -------------------------------------------------------------------------
module.exports = {
fingerprintEmbeddingConfig,
validateEmbeddingVector,
switchGeneration,
safeQueryEmbedding,
keywordEmbeddingFallback,
normalizeModelName,
};
// ---------------------------------------------------------------------------
// End of module
// -------------------------------------------------------------------------
// ---------------------------------------------------------------------------
// Quick smoke test (when run directly)
// -------------------------------------------------------------------------
if (require.main === module) {
console.log("Embedding safety fixture smoke test:");
console.log("- fingerprintEmbeddingConfig:", fingerprintEmbeddingConfig("nomic-embed-text-v1.5", 768));
console.log("- validateEmbeddingVector:", validateEmbeddingVector(new Float32Array(768), 768, "nomic-embed-text-v1.5"));
console.log("- keywordEmbeddingFallback:", keywordEmbeddingFallback("test query").length);
console.log("- switchGeneration (same dim):", switchGeneration({ model: "m", dim: 768 }, "m", 768));
console.log("- switchGeneration (dim change):", switchGeneration({ model: "m", dim: 768 }, "m", 512));
console.log("- switchGeneration (model change same dim):", switchGeneration({ model: "m", dim: 768 }, "m2", 768));
}