{"version":3,"file":"keyword-reranker.mjs","names":[],"sources":["../../../../../../../../ai/src/rag/rerank/keyword-reranker.ts"],"sourcesContent":["import type { RetrievedChunk } from \"../contracts/citation.type\";\nimport type { RagReranker } from \"./reranker.contract\";\n\n/** Options for the {@link keywordReranker}. */\nexport type KeywordRerankerOptions = {\n  /**\n   * Weight of the lexical-overlap signal blended with the original cosine\n   * score, in `[0, 1]`. `1` ranks purely by keyword overlap; `0` keeps the\n   * cosine order. Default `0.5`.\n   */\n  weight?: number;\n};\n\n/** Splits text into lowercase alphanumeric terms. */\nfunction tokenize(text: string): string[] {\n  return text\n    .toLowerCase()\n    .split(/[^a-z0-9]+/)\n    .filter((term) => term.length > 0);\n}\n\n/**\n * Zero-dependency lexical reranker (a BM25-lite, IDF-free keyword overlap).\n *\n * For each candidate it computes the fraction of distinct query terms that\n * appear in the chunk, blends that with the candidate's original cosine\n * score by `weight`, and sorts descending. A pure-lexical pass costs\n * nothing beyond string splits — no peer, no model — so it is the\n * recommended opt-in reranker when an embedding-only ranking surfaces a\n * keyword-rich chunk too low.\n *\n * Ties (equal blended score) preserve the incoming order, so the cosine\n * ranking breaks ties deterministically.\n *\n * @example\n * const kb = ai.rag({ embedder, store, reranker: ai.rag.keywordReranker() });\n */\nexport function keywordReranker(options: KeywordRerankerOptions = {}): RagReranker {\n  const weight = options.weight ?? 0.5;\n\n  return {\n    name: \"keyword\",\n    async rerank(query: string, candidates: RetrievedChunk[]): Promise<RetrievedChunk[]> {\n      if (candidates.length === 0) {\n        return [];\n      }\n\n      const queryTerms = new Set(tokenize(query));\n\n      if (queryTerms.size === 0) {\n        return [...candidates];\n      }\n\n      const scored = candidates.map((candidate, position) => {\n        const chunkTerms = new Set(tokenize(candidate.text));\n\n        let overlap = 0;\n        for (const term of queryTerms) {\n          if (chunkTerms.has(term)) {\n            overlap += 1;\n          }\n        }\n\n        const lexical = overlap / queryTerms.size;\n        const blended = weight * lexical + (1 - weight) * candidate.score;\n\n        return { candidate, blended, position };\n      });\n\n      scored.sort((first, second) => {\n        if (second.blended !== first.blended) {\n          return second.blended - first.blended;\n        }\n\n        // Stable on ties: keep the incoming (cosine) order.\n        return first.position - second.position;\n      });\n\n      return scored.map((entry) => ({\n        ...entry.candidate,\n        score: entry.blended,\n        citation: { ...entry.candidate.citation, score: entry.blended },\n      }));\n    },\n  };\n}\n"],"mappings":";;AAcA,SAAS,SAAS,MAAwB;CACxC,OAAO,KACJ,YAAY,CAAC,CACb,MAAM,YAAY,CAAC,CACnB,QAAQ,SAAS,KAAK,SAAS,CAAC;AACrC;;;;;;;;;;;;;;;;;AAkBA,SAAgB,gBAAgB,UAAkC,CAAC,GAAgB;CACjF,MAAM,SAAS,QAAQ,UAAU;CAEjC,OAAO;EACL,MAAM;EACN,MAAM,OAAO,OAAe,YAAyD;GACnF,IAAI,WAAW,WAAW,GACxB,OAAO,CAAC;GAGV,MAAM,aAAa,IAAI,IAAI,SAAS,KAAK,CAAC;GAE1C,IAAI,WAAW,SAAS,GACtB,OAAO,CAAC,GAAG,UAAU;GAGvB,MAAM,SAAS,WAAW,KAAK,WAAW,aAAa;IACrD,MAAM,aAAa,IAAI,IAAI,SAAS,UAAU,IAAI,CAAC;IAEnD,IAAI,UAAU;IACd,KAAK,MAAM,QAAQ,YACjB,IAAI,WAAW,IAAI,IAAI,GACrB,WAAW;IAOf,OAAO;KAAE;KAAW,SAFJ,UADA,UAAU,WAAW,SACD,IAAI,UAAU,UAAU;KAE/B;IAAS;GACxC,CAAC;GAED,OAAO,MAAM,OAAO,WAAW;IAC7B,IAAI,OAAO,YAAY,MAAM,SAC3B,OAAO,OAAO,UAAU,MAAM;IAIhC,OAAO,MAAM,WAAW,OAAO;GACjC,CAAC;GAED,OAAO,OAAO,KAAK,WAAW;IAC5B,GAAG,MAAM;IACT,OAAO,MAAM;IACb,UAAU;KAAE,GAAG,MAAM,UAAU;KAAU,OAAO,MAAM;IAAQ;GAChE,EAAE;EACJ;CACF;AACF"}