1/** 2 * @factory/search — weighted hybrid fusion (search-architecture §4.5, 3 * TASK-0099 "Hybrid foundation"). 4 * 5 * The Phase-8 keyword path is unchanged (BM25 only). This module is the 6 * Phase-12 wire point: when a query vector exists (an embedding provider is 7 * configured), the API runs the two-request hybrid path in `client.ts` 8 * (`buildHybridBody` pass 1 + `buildHybridKnnOnlyBody` pass 2) and fuses the 9 * two rank lists **client-side** with RRF. `buildHybridFusion` is the 10 * WEIGHTED wrapper over `rrf.ts`: each list contributes its per-doc RRF term 11 * `1/(k + rank)` (k = 60) multiplied by that list's normalized weight, and 12 * the terms are summed — i.e. `score(d) = kw * rrf_k(d) + vec * rrf_v(d)` 13 * with `kw + vec = 1`. At the default 0.5/0.5 blend the scores are exactly 14 * HALF of `fuseHybrid`'s (unweighted RRF) — the **ordering** is identical 15 * (a global ½ scale never changes ranks), and absolute score values are 16 * irrelevant here (they are display metadata, never compared against 17 * thresholds). 18 * 19 * Pure and deterministic: same lists + weights → same fused order (ties 20 * broken by docId, same rule as `reciprocalRankFusion`). 21 */ 22 23import { RRF_K, reciprocalRankFusion, type FusedResult } from "./rrf.js"; 24 25/** Hybrid blend weights (normalized so `kw + vec === 1`). */ 26export interface HybridWeights { 27 /** Weight of the keyword (BM25) rank list. */ 28 kw: number; 29 /** Weight of the vector (kNN) rank list. */ 30 vec: number; 31} 32 33/** The default blend: an even 0.5/0.5 (== unweighted `fuseHybrid`). */ 34export const DEFAULT_HYBRID_WEIGHTS: HybridWeights = { kw: 0.5, vec: 0.5 }; 35 36/** 37 * Normalize a (partially) given weight pair to `kw + vec === 1`. 38 * 39 * - Missing side → the default (0.5). 40 * - Both zero, negative, or non-finite → throws (a zero blend is a 41 * configuration error, not "all vector" or "all keyword"). 42 */ 43export function normalizeHybridWeights(weights: Partial<HybridWeights> = {}): HybridWeights { 44 const kw = weights.kw ?? DEFAULT_HYBRID_WEIGHTS.kw; 45 const vec = weights.vec ?? DEFAULT_HYBRID_WEIGHTS.vec; 46 if (!Number.isFinite(kw) || !Number.isFinite(vec) || kw < 0 || vec < 0) { 47 throw new Error(`invalid hybrid weights: kw=${kw} vec=${vec} (must be finite, >= 0)`); 48 } 49 const total = kw + vec; 50 if (total === 0) throw new Error("invalid hybrid weights: kw and vec must not both be zero"); 51 return { kw: kw / total, vec: vec / total }; 52} 53 54/** 55 * Weighted RRF fusion of the two hybrid rank lists (the Phase-12 wire point; 56 * NOT wired into `POST /api/v1/search` in Phase 8). 57 * 58 * @param keywordHits doc ids in BM25 rank order (pass 1). 59 * @param knnHits doc ids in kNN rank order (pass 2). 60 * @param weights optional blend; defaults to 0.5/0.5 (== `fuseHybrid`). 61 * @param k RRF constant, default 60. 62 * @param limit max results (0 = all). 63 * 64 * Sources are named `bm25` / `vec` (the same names `fuseHybrid` and 65 * `SearchResult.source` use) so the Phase-12 wiring is a drop-in swap. 66 */ 67export function buildHybridFusion( 68 keywordHits: readonly string[], 69 knnHits: readonly string[], 70 weights: Partial<HybridWeights> = {}, 71 k: number = RRF_K, 72 limit = 0, 73): FusedResult[] { 74 const w = normalizeHybridWeights(weights); 75 const kwFused = reciprocalRankFusion([{ name: "bm25", docIds: [...keywordHits] }], k); 76 const vecFused = reciprocalRankFusion([{ name: "vec", docIds: [...knnHits] }], k); 77 const scores = new Map<string, { score: number; sources: Set<string> }>(); 78 for (const r of kwFused) { 79 const e = scores.get(r.docId) ?? { score: 0, sources: new Set<string>() }; 80 e.score += w.kw * r.score; 81 e.sources.add("bm25"); 82 scores.set(r.docId, e); 83 } 84 for (const r of vecFused) { 85 const e = scores.get(r.docId) ?? { score: 0, sources: new Set<string>() }; 86 e.score += w.vec * r.score; 87 e.sources.add("vec"); 88 scores.set(r.docId, e); 89 } 90 const fused: FusedResult[] = [...scores].map(([docId, { score, sources }]) => ({ 91 docId, 92 score, 93 sources: [...sources].sort(), 94 })); 95 fused.sort((a, b) => (b.score !== a.score ? b.score - a.score : a.docId < b.docId ? -1 : 1)); 96 return limit > 0 ? fused.slice(0, limit) : fused; 97} 98