@claude-flow/embeddings
Version:
V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking
78 lines • 3.25 kB
JavaScript
/**
* ADR-121 Phase 17 — Hybrid sparse+dense retrieval.
*
* Composes BM25 (sparse lexical) + dense vector search via RRF
* (Phase 11) into a single fused ranking. The standard production
* pattern for catching documents with rare/technical terms that
* dense embeddings underweight while preserving the semantic
* matching that BM25 misses.
*
* The two signals are not score-comparable directly (BM25 is
* unbounded positive, cosine ∈ [-1, 1]) — RRF operates on ranks,
* which makes it the right fusion primitive here.
*
* Pipeline:
* 1. denseHits = denseTopK(corpus, queryVec, fetchK)
* 2. sparseHits = bm25TopK(queryText, bm25Index, fetchK)
* 3. fused = RRF([denseHits, sparseHits], { k, kRrf, listWeights })
*
* Caller provides:
* - the dense search function (any backing: AnnRouter, in-memory,
* DiskANN snapshot)
* - the BM25 index (built once per corpus via buildBm25Index)
*
* Composable with all earlier primitives: HyDE produces the query
* vector, MMR-rerank the fused output, etc.
*/
import { reciprocalRankFusion } from './rrf.js';
import { bm25TopK } from './bm25.js';
/**
* Run hybrid sparse+dense retrieval. Returns RRF-fused hits plus
* diagnostics for observability.
*
* The dense search is async (matches real backings); the BM25 search
* is sync (pure in-memory). Caller passes the dense search function
* so this module stays decoupled from any specific ANN backing.
*/
export async function hybridRetrieval(queryText, queryVector, bm25Index, denseSearch, options) {
const k = options.k;
if (!Number.isInteger(k) || k < 1) {
throw new Error('hybridRetrieval: k must be a positive integer');
}
const fetchK = options.fetchK ?? Math.max(k * 3, k);
const kRrf = options.kRrf ?? 60;
const listWeights = options.listWeights ?? [1, 1];
if (listWeights.length !== 2) {
throw new Error('hybridRetrieval: listWeights must be [denseWeight, sparseWeight]');
}
// Run both signals in parallel.
const [denseHits, sparseHits] = await Promise.all([
Promise.resolve(denseSearch(queryVector, fetchK)),
Promise.resolve(bm25TopK(queryText, bm25Index, fetchK)),
]);
// Compute overlap diagnostic before fusion.
const denseIds = new Set(denseHits.map(h => h.id));
const overlapIds = sparseHits.filter(h => denseIds.has(h.id)).map(h => h.id);
const meanSparseScore = sparseHits.length === 0
? 0
: sparseHits.reduce((s, h) => s + h.score, 0) / sparseHits.length;
// RRF-fuse. Annotate payloads with the source list so the caller
// can see which signal carried each hit.
const denseRrfList = denseHits.map(h => ({ id: h.id, payload: { source: 'dense', score: h.score } }));
const sparseRrfList = sparseHits.map(h => ({ id: h.id, payload: { source: 'sparse', score: h.score } }));
const fused = reciprocalRankFusion([denseRrfList, sparseRrfList], {
k,
kRrf,
listWeights,
});
return {
hits: fused,
diagnostics: {
denseHitCount: denseHits.length,
sparseHitCount: sparseHits.length,
overlapIds,
meanSparseScore,
},
};
}
//# sourceMappingURL=hybrid-retrieval.js.map