/** * @factory/search — weighted hybrid fusion (search-architecture §4.5, * TASK-0099 "Hybrid foundation"). * * The Phase-8 keyword path is unchanged (BM25 only). This module is the * Phase-12 wire point: when a query vector exists (an embedding provider is * configured), the API runs the two-request hybrid path in `client.ts` * (`buildHybridBody` pass 1 + `buildHybridKnnOnlyBody` pass 2) and fuses the * two rank lists **client-side** with RRF. `buildHybridFusion` is the * WEIGHTED wrapper over `rrf.ts`: each list contributes its per-doc RRF term * `1/(k + rank)` (k = 60) multiplied by that list's normalized weight, and * the terms are summed — i.e. `score(d) = kw * rrf_k(d) + vec * rrf_v(d)` * with `kw + vec = 1`. At the default 0.5/0.5 blend the scores are exactly * HALF of `fuseHybrid`'s (unweighted RRF) — the **ordering** is identical * (a global ½ scale never changes ranks), and absolute score values are * irrelevant here (they are display metadata, never compared against * thresholds). * * Pure and deterministic: same lists + weights → same fused order (ties * broken by docId, same rule as `reciprocalRankFusion`). */ import { RRF_K, reciprocalRankFusion, type FusedResult } from "./rrf.js"; /** Hybrid blend weights (normalized so `kw + vec === 1`). */ export interface HybridWeights { /** Weight of the keyword (BM25) rank list. */ kw: number; /** Weight of the vector (kNN) rank list. */ vec: number; } /** The default blend: an even 0.5/0.5 (== unweighted `fuseHybrid`). */ export const DEFAULT_HYBRID_WEIGHTS: HybridWeights = { kw: 0.5, vec: 0.5 }; /** * Normalize a (partially) given weight pair to `kw + vec === 1`. * * - Missing side → the default (0.5). * - Both zero, negative, or non-finite → throws (a zero blend is a * configuration error, not "all vector" or "all keyword"). */ export function normalizeHybridWeights(weights: Partial = {}): HybridWeights { const kw = weights.kw ?? DEFAULT_HYBRID_WEIGHTS.kw; const vec = weights.vec ?? DEFAULT_HYBRID_WEIGHTS.vec; if (!Number.isFinite(kw) || !Number.isFinite(vec) || kw < 0 || vec < 0) { throw new Error(`invalid hybrid weights: kw=${kw} vec=${vec} (must be finite, >= 0)`); } const total = kw + vec; if (total === 0) throw new Error("invalid hybrid weights: kw and vec must not both be zero"); return { kw: kw / total, vec: vec / total }; } /** * Weighted RRF fusion of the two hybrid rank lists (the Phase-12 wire point; * NOT wired into `POST /api/v1/search` in Phase 8). * * @param keywordHits doc ids in BM25 rank order (pass 1). * @param knnHits doc ids in kNN rank order (pass 2). * @param weights optional blend; defaults to 0.5/0.5 (== `fuseHybrid`). * @param k RRF constant, default 60. * @param limit max results (0 = all). * * Sources are named `bm25` / `vec` (the same names `fuseHybrid` and * `SearchResult.source` use) so the Phase-12 wiring is a drop-in swap. */ export function buildHybridFusion( keywordHits: readonly string[], knnHits: readonly string[], weights: Partial = {}, k: number = RRF_K, limit = 0, ): FusedResult[] { const w = normalizeHybridWeights(weights); const kwFused = reciprocalRankFusion([{ name: "bm25", docIds: [...keywordHits] }], k); const vecFused = reciprocalRankFusion([{ name: "vec", docIds: [...knnHits] }], k); const scores = new Map }>(); for (const r of kwFused) { const e = scores.get(r.docId) ?? { score: 0, sources: new Set() }; e.score += w.kw * r.score; e.sources.add("bm25"); scores.set(r.docId, e); } for (const r of vecFused) { const e = scores.get(r.docId) ?? { score: 0, sources: new Set() }; e.score += w.vec * r.score; e.sources.add("vec"); scores.set(r.docId, e); } const fused: FusedResult[] = [...scores].map(([docId, { score, sources }]) => ({ docId, score, sources: [...sources].sort(), })); fused.sort((a, b) => (b.score !== a.score ? b.score - a.score : a.docId < b.docId ? -1 : 1)); return limit > 0 ? fused.slice(0, limit) : fused; }