@claude-flow/embeddings
Version:
V3 Embedding Service - OpenAI, Transformers.js, Agentic-Flow (ONNX), Mock providers with hyperbolic embeddings, normalization, and chunking
213 lines (198 loc) • 6.94 kB
text/typescript
/**
* ADR-121 Phase 17 — Okapi BM25 (sparse lexical retrieval).
*
* Dense vector retrieval (cosine over learned embeddings) is what
* Phases 9-16 ship. It's powerful but has a known failure mode:
* rare or technical terms that the embedding model never saw at
* training time get averaged out in the dense vector, so docs that
* literally contain "CVE-2024-12345" or "sha256:abc…" rank poorly
* even when they're the exact match.
*
* The standard production fix is **hybrid retrieval**: combine a
* sparse lexical signal (BM25 — what classical search engines use)
* with the dense signal (cosine), then RRF-fuse the two ranked
* lists. The sparse signal catches the rare-term docs the dense
* signal underweights; the dense signal catches the semantic-only
* matches the sparse signal misses.
*
* This module ships pure-function BM25 (Okapi variant — the modern
* production default since Robertson-Walker 1994) with no external
* deps. Composes with the Phase 11 RRF primitive for hybrid retrieval.
*
* score(q, d) = Σ_{t in q} IDF(t) · (tf(t,d) · (k1 + 1)) / (tf(t,d) + k1·(1 - b + b·|d|/avgdl))
*
* IDF(t) = ln((N - df(t) + 0.5) / (df(t) + 0.5) + 1)
*
* Parameters:
* k1 ∈ [1.2, 2.0] — term frequency saturation (default 1.5)
* b ∈ [0.0, 1.0] — document-length normalization (default 0.75)
*/
export interface Bm25Options {
/** Term frequency saturation. Default 1.5 (Okapi standard). */
readonly k1?: number;
/** Length normalization. Default 0.75. */
readonly b?: number;
}
export interface Bm25Document {
readonly id: string;
readonly text: string;
}
export interface Bm25Index {
readonly docs: ReadonlyArray<{ id: string; tokens: string[]; length: number }>;
/** Document frequency: token → number of docs containing it. */
readonly df: ReadonlyMap<string, number>;
/** Number of documents. */
readonly N: number;
/** Average document length. */
readonly avgdl: number;
readonly k1: number;
readonly b: number;
}
export interface Bm25Hit {
readonly id: string;
readonly score: number;
}
/**
* Default tokenizer: lowercase, split on non-alphanumeric, drop
* empty strings, drop tokens of length < 2 (stopword-ish noise).
*
* Production callers can supply their own tokenizer (e.g. stemmer,
* stop-word filter, n-grams) by tokenizing themselves and passing
* the pre-tokenized form via `buildBm25IndexFromTokens`.
*/
export function tokenize(text: string): string[] {
if (typeof text !== 'string' || text.length === 0) return [];
return text
.toLowerCase()
.split(/[^a-z0-9]+/i)
.filter(t => t.length >= 2);
}
/**
* Build a BM25 index over a corpus. O(N · avg-doc-length).
*
* The index is immutable — to add docs, build a new index. For
* streaming corpora the right move is to maintain the (df, N, avgdl,
* docs) structures explicitly and rebuild incrementally; this
* module keeps it simple.
*/
export function buildBm25Index(corpus: ReadonlyArray<Bm25Document>, options: Bm25Options = {}): Bm25Index {
const k1 = options.k1 ?? 1.5;
const b = options.b ?? 0.75;
if (k1 <= 0) throw new Error('k1 must be > 0');
if (b < 0 || b > 1) throw new Error('b must be in [0, 1]');
const docs: Array<{ id: string; tokens: string[]; length: number }> = [];
const df = new Map<string, number>();
let totalLen = 0;
for (const d of corpus) {
const tokens = tokenize(d.text);
docs.push({ id: d.id, tokens, length: tokens.length });
totalLen += tokens.length;
// Count distinct tokens per doc for df.
const seen = new Set<string>();
for (const t of tokens) {
if (!seen.has(t)) {
seen.add(t);
df.set(t, (df.get(t) ?? 0) + 1);
}
}
}
return {
docs,
df,
N: docs.length,
avgdl: docs.length > 0 ? totalLen / docs.length : 0,
k1,
b,
};
}
/**
* Score a single document against a query under the index. Used
* by bm25TopK; exported for callers who want to score a specific
* (query, doc) pair.
*/
export function bm25Score(
queryTokens: ReadonlyArray<string>,
doc: { tokens: string[]; length: number },
index: Bm25Index,
): number {
const { df, N, avgdl, k1, b } = index;
// Count term frequencies in the doc once.
const tfMap = new Map<string, number>();
for (const t of doc.tokens) {
tfMap.set(t, (tfMap.get(t) ?? 0) + 1);
}
let score = 0;
for (const qt of queryTokens) {
const tf = tfMap.get(qt) ?? 0;
if (tf === 0) continue;
const dfT = df.get(qt) ?? 0;
const idf = Math.log(((N - dfT + 0.5) / (dfT + 0.5)) + 1);
const numerator = tf * (k1 + 1);
const denominator = tf + k1 * (1 - b + b * (doc.length / Math.max(avgdl, 1e-9)));
score += idf * (numerator / denominator);
}
return score;
}
/**
* Top-k BM25 search. Returns docs with score > 0 (docs that share
* at least one token with the query), sorted descending. Ties
* resolve by id ascending for determinism.
*
* Time complexity: O(N · |q|) where N = doc count, |q| = unique
* query tokens.
*/
export function bm25TopK(
query: string,
index: Bm25Index,
k: number,
): Bm25Hit[] {
if (!Number.isInteger(k) || k <= 0) return [];
const queryTokens = Array.from(new Set(tokenize(query)));
if (queryTokens.length === 0) return [];
const scored: Bm25Hit[] = [];
for (const d of index.docs) {
const score = bm25Score(queryTokens, d, index);
if (score > 0) scored.push({ id: d.id, score });
}
scored.sort((a, b) => {
if (b.score !== a.score) return b.score - a.score;
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
});
return scored.slice(0, k);
}
/**
* Compute the inverse document frequency for a single token. Returns
* 0 for tokens not in the corpus. Useful for the adaptive router's
* rare-token-density signal.
*/
export function idfOf(token: string, index: Bm25Index): number {
const dfT = index.df.get(token) ?? 0;
if (dfT === 0) return 0;
return Math.log(((index.N - dfT + 0.5) / (dfT + 0.5)) + 1);
}
/**
* Rare-token density of a query relative to a BM25 index. Returns
* the **mean IDF** of unique tokens in the query that appear in the
* corpus at least once (tokens not in the corpus would have IDF 0
* and would dilute the signal — they don't represent "rare
* technical terms our retrieval can act on", they represent
* "tokens our corpus doesn't have").
*
* Used by the adaptive router as the hybrid-retrieval signal: high
* mean IDF means the query has rare/technical terms that dense
* cosine retrieval tends to underweight.
*/
export function queryMeanIdf(query: string, index: Bm25Index): number {
const queryTokens = Array.from(new Set(tokenize(query)));
if (queryTokens.length === 0) return 0;
let total = 0;
let counted = 0;
for (const t of queryTokens) {
const idf = idfOf(t, index);
if (idf > 0) {
total += idf;
counted++;
}
}
return counted === 0 ? 0 : total / counted;
}