{"version":3,"file":"bm25.mjs","names":[],"sources":["../../../../../../../../ai/src/rag/hybrid/bm25.ts"],"sourcesContent":["import type { RankedItem } from \"./rrf\";\n\n/** A document to score lexically. */\nexport type LexicalDoc = { id: string; text: string };\n\nconst BM25_K1 = 1.5;\nconst BM25_B = 0.75;\n\n/** Lowercase + split on non-word characters; drop empties. */\nfunction tokenize(text: string): string[] {\n  return text\n    .toLowerCase()\n    .split(/[^a-z0-9]+/i)\n    .filter(Boolean);\n}\n\n/**\n * Rank `docs` against `query` with BM25 (A4) — the lexical half of hybrid\n * retrieval. Scores keyword overlap with TF saturation (`k1`) and length\n * normalization (`b`) over the candidate set, so an exact-term match\n * surfaces even when dense embeddings miss it. Returns docs sorted by\n * score (highest first); zero-score docs are dropped.\n *\n * Operates over the supplied candidate set (typically the dense retriever's\n * over-fetch), so it needs no global corpus index — ideal for fusing with\n * a vector ranking via {@link reciprocalRankFusion}.\n */\nexport function bm25Rank(query: string, docs: ReadonlyArray<LexicalDoc>): RankedItem[] {\n  const queryTerms = [...new Set(tokenize(query))];\n  if (queryTerms.length === 0 || docs.length === 0) return [];\n\n  const tokenized = docs.map(doc => ({ id: doc.id, terms: tokenize(doc.text) }));\n  const avgLen =\n    tokenized.reduce((sum, d) => sum + d.terms.length, 0) / tokenized.length || 1;\n\n  // Document frequency per query term, across the candidate set.\n  const df = new Map<string, number>();\n  for (const term of queryTerms) {\n    df.set(\n      term,\n      tokenized.filter(d => d.terms.includes(term)).length,\n    );\n  }\n\n  const n = tokenized.length;\n\n  const scored = tokenized.map(doc => {\n    const len = doc.terms.length || 1;\n    let score = 0;\n\n    for (const term of queryTerms) {\n      const tf = doc.terms.filter(t => t === term).length;\n      if (tf === 0) continue;\n\n      const docFreq = df.get(term) ?? 0;\n      // BM25 idf (with the +1 to keep it non-negative).\n      const idf = Math.log(1 + (n - docFreq + 0.5) / (docFreq + 0.5));\n      const numerator = tf * (BM25_K1 + 1);\n      const denominator = tf + BM25_K1 * (1 - BM25_B + BM25_B * (len / avgLen));\n      score += idf * (numerator / denominator);\n    }\n\n    return { id: doc.id, score };\n  });\n\n  return scored.filter(item => item.score > 0).sort((a, b) => b.score - a.score);\n}\n"],"mappings":";AAKA,MAAM,UAAU;AAChB,MAAM,SAAS;;AAGf,SAAS,SAAS,MAAwB;CACxC,OAAO,KACJ,YAAY,CAAC,CACb,MAAM,aAAa,CAAC,CACpB,OAAO,OAAO;AACnB;;;;;;;;;;;;AAaA,SAAgB,SAAS,OAAe,MAA+C;CACrF,MAAM,aAAa,CAAC,GAAG,IAAI,IAAI,SAAS,KAAK,CAAC,CAAC;CAC/C,IAAI,WAAW,WAAW,KAAK,KAAK,WAAW,GAAG,OAAO,CAAC;CAE1D,MAAM,YAAY,KAAK,KAAI,SAAQ;EAAE,IAAI,IAAI;EAAI,OAAO,SAAS,IAAI,IAAI;CAAE,EAAE;CAC7E,MAAM,SACJ,UAAU,QAAQ,KAAK,MAAM,MAAM,EAAE,MAAM,QAAQ,CAAC,IAAI,UAAU,UAAU;CAG9E,MAAM,qBAAK,IAAI,IAAoB;CACnC,KAAK,MAAM,QAAQ,YACjB,GAAG,IACD,MACA,UAAU,QAAO,MAAK,EAAE,MAAM,SAAS,IAAI,CAAC,CAAC,CAAC,MAChD;CAGF,MAAM,IAAI,UAAU;CAqBpB,OAnBe,UAAU,KAAI,QAAO;EAClC,MAAM,MAAM,IAAI,MAAM,UAAU;EAChC,IAAI,QAAQ;EAEZ,KAAK,MAAM,QAAQ,YAAY;GAC7B,MAAM,KAAK,IAAI,MAAM,QAAO,MAAK,MAAM,IAAI,CAAC,CAAC;GAC7C,IAAI,OAAO,GAAG;GAEd,MAAM,UAAU,GAAG,IAAI,IAAI,KAAK;GAEhC,MAAM,MAAM,KAAK,IAAI,KAAK,IAAI,UAAU,OAAQ,UAAU,GAAI;GAC9D,MAAM,YAAY,KAAM;GACxB,MAAM,cAAc,KAAK,WAAW,IAAI,SAAS,UAAU,MAAM;GACjE,SAAS,OAAO,YAAY;EAC9B;EAEA,OAAO;GAAE,IAAI,IAAI;GAAI;EAAM;CAC7B,CAEY,CAAC,CAAC,QAAO,SAAQ,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,GAAG,MAAM,EAAE,QAAQ,EAAE,KAAK;AAC/E"}