UNPKG

@claude-flow/embeddings

Version:

V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking

126 lines • 4.93 kB
/** * ADR-121 Phase 12 — Embedding-level fusion (HyDE). * * HyDE (Hypothetical Document Embeddings — Gao, Ma, Lin, Callan 2022, * "Precise Zero-Shot Dense Retrieval without Relevance Labels") fixes * a structural problem with question-based dense retrieval: question * embeddings live in "question space" while documents embed into * "answer space", so cosine similarity systematically underweights * relevant docs whose surface form differs from the question. * * The HyDE recipe: * 1. Have an LLM generate N hypothetical answers to the question. * 2. Embed each hypothetical answer. * 3. Average those embeddings into a single query vector. * 4. Search with the averaged vector. * * Empirically: this single transform produces SOTA zero-shot * retrieval on BEIR — beating supervised in-domain dense retrievers * on many tasks. The trick works because hypothetical answers live * in the same answer-space as the corpus, so the averaged vector * lands near the true relevant docs. * * This module ships the **averaging** step as a pure function — the * LLM-generation step is the caller's job (the orchestrator agent * already has an LLM). Composes with every existing retrieval shape: * * HyDE + plain search = embeddings_search_text_hyde * HyDE + MMR rerank = caller-composable (search_text_hyde * results piped through mmrRerank) * HyDE + RRF ensemble = caller-composable (search_text_hyde * across multiple LLM samples, fused) * * Relationship to RRF (Phase 11): * - RRF fuses at the **rank** level: N searches, merge ranks. More * expensive but preserves intent boundaries between variants. * - HyDE/avg fuses at the **embedding** level: 1 search after vector * average. Cheaper but interpolates between variants — the * averaged vector represents the "centroid" intent. * * The two are complementary, not redundant. Production systems * often use HyDE inside one ranked list, and RRF across multiple * ranked lists (one HyDE-search, one BM25-search, etc.). */ /** * Average N embedding vectors into a single embedding vector. * * Time complexity: O(N · dim). Memory: O(dim). * * Returns a brand-new Float32Array — does not mutate inputs. */ export function averageEmbeddings(vectors, options = {}) { if (!Array.isArray(vectors) || vectors.length === 0) { throw new Error('averageEmbeddings: vectors must be a non-empty array'); } const normalizeInputs = options.normalizeInputs ?? true; const normalizeOutput = options.normalizeOutput ?? true; const weights = options.weights ?? vectors.map(() => 1); if (weights.length !== vectors.length) { throw new Error(`averageEmbeddings: weights.length (${weights.length}) must match vectors.length (${vectors.length})`); } for (let i = 0; i < weights.length; i++) { if (weights[i] < 0) { throw new Error(`averageEmbeddings: weights[${i}] is negative (${weights[i]})`); } } // Find dim from the first vector + assert all match. const first = vectors[0]; const dim = first.length; for (let i = 1; i < vectors.length; i++) { if (vectors[i].length !== dim) { throw new Error(`averageEmbeddings: vector ${i} has dim ${vectors[i].length}, expected ${dim}`); } } const out = new Float32Array(dim); let totalWeight = 0; for (let i = 0; i < vectors.length; i++) { const v = vectors[i]; const w = weights[i]; if (w === 0) continue; totalWeight += w; let norm = 1; if (normalizeInputs) { let sq = 0; for (let j = 0; j < dim; j++) sq += v[j] * v[j]; norm = sq > 0 ? Math.sqrt(sq) : 1; } const scale = w / norm; for (let j = 0; j < dim; j++) { out[j] += v[j] * scale; } } if (totalWeight === 0) { // All-zero-weight case — produce a deterministic zero vector // rather than NaN. return out; } // Mean — divide by total weight. for (let j = 0; j < dim; j++) { out[j] /= totalWeight; } if (normalizeOutput) { let sq = 0; for (let j = 0; j < dim; j++) sq += out[j] * out[j]; if (sq > 0) { const inv = 1 / Math.sqrt(sq); for (let j = 0; j < dim; j++) out[j] *= inv; } } return out; } /** * Convenience: confirm a Float32Array is L2-unit-normalized within * `tolerance`. Useful for tests + the smoke driver to verify the * normalizeOutput contract. */ export function isUnitNorm(v, tolerance = 1e-5) { let sq = 0; for (let i = 0; i < v.length; i++) sq += v[i] * v[i]; return Math.abs(Math.sqrt(sq) - 1) < tolerance; } //# sourceMappingURL=embedding-fusion.js.map