{"version":3,"file":"llm-reranker.mjs","names":[],"sources":["../../../../../../../../ai/src/rag/rerank/llm-reranker.ts"],"sourcesContent":["import type { Message } from \"../../contracts/conversation-message.type\";\nimport type { ModelContract } from \"../../contracts/model.contract\";\nimport type { RetrievedChunk } from \"../contracts/citation.type\";\nimport type { RagReranker } from \"./reranker.contract\";\n\n/** Options for the {@link llmReranker}. */\nexport type LlmRerankerOptions = {\n  /** The model used to score candidate relevance. Required. */\n  model: ModelContract;\n  /**\n   * How many candidates to score per model call. Larger batches mean\n   * fewer round-trips but a longer prompt. Default `10`.\n   */\n  batchSize?: number;\n};\n\n/**\n * A single relevance score the model returns for a candidate, in `[0, 1]`,\n * keyed by the candidate's position in the batch.\n */\ntype ScoreLine = {\n  index: number;\n  score: number;\n};\n\n/**\n * Build the scoring prompt — the model rates each candidate's relevance to\n * the query on a `0..1` scale and replies with one `index: score` line per\n * candidate. Kept terse and JSON-light so any chat model can answer.\n */\nfunction buildPrompt(query: string, candidates: RetrievedChunk[]): Message[] {\n  const lines = candidates\n    .map((candidate, index) => `[${index}] ${candidate.text}`)\n    .join(\"\\n\\n\");\n\n  return [\n    {\n      role: \"system\",\n      content:\n        \"You are a relevance grader. For each numbered passage, rate how well it answers the query on a scale from 0 (irrelevant) to 1 (fully relevant). Reply with ONLY a JSON array of objects like [{\\\"index\\\":0,\\\"score\\\":0.9}], one entry per passage, no prose.\",\n    },\n    {\n      role: \"user\",\n      content: `Query: ${query}\\n\\nPassages:\\n${lines}`,\n    },\n  ];\n}\n\n/**\n * Parse the model's reply into a score map. Tolerant of surrounding prose:\n * extracts the first JSON array and reads `{ index, score }` entries.\n * Returns an empty map when nothing parseable is found, so the caller can\n * fall back to the original order.\n */\nfunction parseScores(reply: string): Map<number, number> {\n  const scores = new Map<number, number>();\n  const start = reply.indexOf(\"[\");\n  const end = reply.lastIndexOf(\"]\");\n\n  if (start === -1 || end === -1 || end <= start) {\n    return scores;\n  }\n\n  let parsed: unknown;\n  try {\n    parsed = JSON.parse(reply.slice(start, end + 1));\n  } catch {\n    return scores;\n  }\n\n  if (!Array.isArray(parsed)) {\n    return scores;\n  }\n\n  for (const entry of parsed as ScoreLine[]) {\n    if (\n      entry &&\n      typeof entry.index === \"number\" &&\n      typeof entry.score === \"number\" &&\n      Number.isFinite(entry.score)\n    ) {\n      scores.set(entry.index, Math.max(0, Math.min(1, entry.score)));\n    }\n  }\n\n  return scores;\n}\n\n/**\n * Optional model-backed reranker.\n *\n * Asks an LLM to grade each over-fetched candidate's relevance to the\n * query on a `0..1` scale, then sorts descending by the model's score.\n * Candidates the model does not score keep their original cosine score, so\n * a partial/garbled reply degrades gracefully rather than dropping hits.\n * Scoring is batched (`batchSize`) to bound prompt length.\n *\n * Unlike {@link keywordReranker}, this costs one or more model calls per\n * retrieval — opt in only when precision matters more than latency/cost.\n *\n * @example\n * const kb = ai.rag({\n *   embedder,\n *   store,\n *   reranker: ai.rag.llmReranker({ model: openai.model({ name: \"gpt-4o-mini\" }) }),\n * });\n */\nexport function llmReranker(options: LlmRerankerOptions): RagReranker {\n  const batchSize = options.batchSize ?? 10;\n\n  return {\n    name: \"llm\",\n    async rerank(query: string, candidates: RetrievedChunk[]): Promise<RetrievedChunk[]> {\n      if (candidates.length === 0) {\n        return [];\n      }\n\n      const rescored: RetrievedChunk[] = [];\n\n      for (let offset = 0; offset < candidates.length; offset += batchSize) {\n        const batch = candidates.slice(offset, offset + batchSize);\n        const response = await options.model.complete(buildPrompt(query, batch));\n        const scores = parseScores(response.content);\n\n        batch.forEach((candidate, index) => {\n          const score = scores.has(index) ? (scores.get(index) as number) : candidate.score;\n\n          rescored.push({\n            ...candidate,\n            score,\n            citation: { ...candidate.citation, score },\n          });\n        });\n      }\n\n      return rescored.sort((first, second) => second.score - first.score);\n    },\n  };\n}\n"],"mappings":";;;;;;AA8BA,SAAS,YAAY,OAAe,YAAyC;CAK3E,OAAO,CACL;EACE,MAAM;EACN,SACE;CACJ,GACA;EACE,MAAM;EACN,SAAS,UAAU,MAAM,iBAZf,WACX,KAAK,WAAW,UAAU,IAAI,MAAM,IAAI,UAAU,MAAM,CAAC,CACzD,KAAK,MAU0C;CAChD,CACF;AACF;;;;;;;AAQA,SAAS,YAAY,OAAoC;CACvD,MAAM,yBAAS,IAAI,IAAoB;CACvC,MAAM,QAAQ,MAAM,QAAQ,GAAG;CAC/B,MAAM,MAAM,MAAM,YAAY,GAAG;CAEjC,IAAI,UAAU,MAAM,QAAQ,MAAM,OAAO,OACvC,OAAO;CAGT,IAAI;CACJ,IAAI;EACF,SAAS,KAAK,MAAM,MAAM,MAAM,OAAO,MAAM,CAAC,CAAC;CACjD,QAAQ;EACN,OAAO;CACT;CAEA,IAAI,CAAC,MAAM,QAAQ,MAAM,GACvB,OAAO;CAGT,KAAK,MAAM,SAAS,QAClB,IACE,SACA,OAAO,MAAM,UAAU,YACvB,OAAO,MAAM,UAAU,YACvB,OAAO,SAAS,MAAM,KAAK,GAE3B,OAAO,IAAI,MAAM,OAAO,KAAK,IAAI,GAAG,KAAK,IAAI,GAAG,MAAM,KAAK,CAAC,CAAC;CAIjE,OAAO;AACT;;;;;;;;;;;;;;;;;;;;AAqBA,SAAgB,YAAY,SAA0C;CACpE,MAAM,YAAY,QAAQ,aAAa;CAEvC,OAAO;EACL,MAAM;EACN,MAAM,OAAO,OAAe,YAAyD;GACnF,IAAI,WAAW,WAAW,GACxB,OAAO,CAAC;GAGV,MAAM,WAA6B,CAAC;GAEpC,KAAK,IAAI,SAAS,GAAG,SAAS,WAAW,QAAQ,UAAU,WAAW;IACpE,MAAM,QAAQ,WAAW,MAAM,QAAQ,SAAS,SAAS;IAEzD,MAAM,SAAS,aAAY,MADJ,QAAQ,MAAM,SAAS,YAAY,OAAO,KAAK,CAAC,EACpC,CAAC,OAAO;IAE3C,MAAM,SAAS,WAAW,UAAU;KAClC,MAAM,QAAQ,OAAO,IAAI,KAAK,IAAK,OAAO,IAAI,KAAK,IAAe,UAAU;KAE5E,SAAS,KAAK;MACZ,GAAG;MACH;MACA,UAAU;OAAE,GAAG,UAAU;OAAU;MAAM;KAC3C,CAAC;IACH,CAAC;GACH;GAEA,OAAO,SAAS,MAAM,OAAO,WAAW,OAAO,QAAQ,MAAM,KAAK;EACpE;CACF;AACF"}