// Recherche hybride : FTS5 (BM25) ∥ cosinus vectoriel → fusion RRF → boosts → expansion voisins. import { all, get } from "../db/index.ts"; import { blobToVec, cosine, embedQuery } from "./embeddings.ts"; export type RetrievedChunk = { id: number; document_id: number; course_code: string | null; space: string; ref_type: string; ref_number: number | null; ref_label: string; section_title: string; title: string; content: string; box_types: string; week: number | null; doc_title: string; doc_path: string; filename: string; score: number; }; export type SearchOptions = { query: string; spaces: string[]; // espaces autorisés — TOUJOURS filtrés côté serveur conversationId?: number; // pour inclure les téléversements de cette conversation ownerUserId?: number; k?: number; }; const RRF_K = 60; // Cache mémoire des vecteurs par clé d'espace (invalidé par le nombre de fragments). type VecEntry = { id: number; vec: Float32Array }; const vecCache = new Map(); function spacePlaceholders(spaces: string[]): string { return spaces.map(() => "?").join(","); } function loadVectors(spaces: string[], conversationId?: number, ownerUserId?: number): VecEntry[] { const key = spaces.sort().join("|") + `#${conversationId ?? 0}#${ownerUserId ?? 0}`; const where = buildScopeWhere(spaces, conversationId, ownerUserId); const countRow = get<{ n: number }>(`SELECT COUNT(*) as n FROM chunks WHERE ${where.sql}`, ...where.params); const count = countRow?.n ?? 0; const cached = vecCache.get(key); if (cached && cached.count === count) return cached.entries; const rows = all<{ id: number; embedding: Uint8Array | null }>( `SELECT id, embedding FROM chunks WHERE ${where.sql}`, ...where.params ); const entries: VecEntry[] = []; for (const r of rows) if (r.embedding) entries.push({ id: r.id, vec: blobToVec(r.embedding) }); vecCache.set(key, { count, entries }); if (vecCache.size > 24) vecCache.delete(vecCache.keys().next().value as string); return entries; } function buildScopeWhere(spaces: string[], conversationId?: number, ownerUserId?: number) { // Espaces officiels/privés : filtre simple. Espaces étudiants : restreints au propriétaire/à la conversation. const parts: string[] = []; const params: unknown[] = []; const official = spaces.filter((s) => !s.startsWith("student-")); if (official.length) { parts.push(`(space IN (${spacePlaceholders(official)}))`); params.push(...official); } if (spaces.includes("student-temporary-upload") && conversationId && ownerUserId) { parts.push(`(space = 'student-temporary-upload' AND conversation_id = ? AND owner_user_id = ?)`); params.push(conversationId, ownerUserId); } if (spaces.includes("student-persistent-files") && ownerUserId) { parts.push(`(space = 'student-persistent-files' AND owner_user_id = ?)`); params.push(ownerUserId); } if (!parts.length) return { sql: "0", params: [] as unknown[] }; return { sql: `(${parts.join(" OR ")})`, params }; } function ftsQuery(query: string): string { const tokens = query .toLowerCase() .replace(/[«»"'’()\[\]{}:;,!?<>=+*/\\^~`|-]/g, " ") .split(/\s+/) .filter((t) => t.length > 1) .slice(0, 12); if (!tokens.length) return '""'; return tokens.map((t) => `"${t}"`).join(" OR "); } export async function hybridSearch(opts: SearchOptions): Promise { const k = opts.k ?? 10; const where = buildScopeWhere(opts.spaces, opts.conversationId, opts.ownerUserId); if (where.sql === "0") return []; // 1) FTS5 BM25 const ftsRows = all<{ rowid: number; rank: number }>( `SELECT f.rowid as rowid, bm25(chunks_fts, 3.0, 1.0) as rank FROM chunks_fts f JOIN chunks c ON c.id = f.rowid WHERE chunks_fts MATCH ? AND ${where.sql} ORDER BY rank LIMIT 30`, ftsQuery(opts.query), ...where.params ); // 2) Vectoriel const qvec = await embedQuery(opts.query); const entries = loadVectors(opts.spaces, opts.conversationId, opts.ownerUserId); const vecScored = entries .map((e) => ({ id: e.id, s: cosine(qvec, e.vec) })) .sort((a, b) => b.s - a.s) .slice(0, 30); // 3) Fusion RRF const rrf = new Map(); ftsRows.forEach((r, i) => rrf.set(r.rowid, (rrf.get(r.rowid) ?? 0) + 1 / (RRF_K + i + 1))); vecScored.forEach((r, i) => rrf.set(r.id, (rrf.get(r.id) ?? 0) + 1 / (RRF_K + i + 1))); if (!rrf.size) return []; const ids = [...rrf.keys()]; const rows = all( `SELECT c.id, c.document_id, c.course_code, c.space, c.ref_type, c.ref_number, c.ref_label, c.section_title, c.title, c.content, c.box_types, c.week, d.title as doc_title, d.path as doc_path, d.filename, 0 as score FROM chunks c JOIN documents d ON d.id = c.document_id WHERE c.id IN (${ids.map(() => "?").join(",")})`, ...ids ); const byId = new Map(rows.map((r) => [r.id, r])); // 4) Boosts légers selon le type de question const q = opts.query.toLowerCase(); const wantsDefinition = /\b(défini|definition|qu'est|c'est quoi|signifie)\b/.test(q); const wantsFormula = /\b(formule|calcul|comment calculer|équation)\b/.test(q); const scored = ids .map((id) => { const row = byId.get(id); if (!row) return null; let s = rrf.get(id)!; if (wantsDefinition && row.box_types.includes("defbox")) s *= 1.25; if (wantsFormula && (row.box_types.includes("importbox") || /formule|=/.test(row.content))) s *= 1.15; if (row.ref_type === "glossary") s *= wantsDefinition ? 1.2 : 1.0; return { ...row, score: s }; }) .filter((r): r is RetrievedChunk => !!r) .sort((a, b) => b.score - a.score); // 5) Dédoublonnage (max 3 fragments par document) + équilibre const perDoc = new Map(); const selected: RetrievedChunk[] = []; for (const r of scored) { const n = perDoc.get(r.document_id) ?? 0; if (n >= 3) continue; perDoc.set(r.document_id, n + 1); selected.push(r); if (selected.length >= k) break; } // 6) Expansion : diapositive suite (1/2 → 2/2) et voisines immédiates du meilleur résultat if (selected.length && selected[0].ref_type === "slide" && /\(\d\/\d\)|1\/2|\bsuite\b/i.test(selected[0].title)) { const neighbor = get( `SELECT c.id, c.document_id, c.course_code, c.space, c.ref_type, c.ref_number, c.ref_label, c.section_title, c.title, c.content, c.box_types, c.week, d.title as doc_title, d.path as doc_path, d.filename, 0 as score FROM chunks c JOIN documents d ON d.id = c.document_id WHERE c.document_id = ? AND c.ref_number = ? AND c.id != ?`, selected[0].document_id, (selected[0].ref_number ?? 0) + 1, selected[0].id ); if (neighbor && !selected.some((s) => s.id === neighbor.id)) selected.push({ ...neighbor, score: selected[0].score * 0.9 }); } return selected.slice(0, k + 2); } /** Seuil de pertinence : si le meilleur score RRF est trop faible, le corpus ne couvre pas la question. */ export function isConfidentEnough(results: RetrievedChunk[]): boolean { if (!results.length) return false; return results[0].score >= 1 / (RRF_K + 8); // présent dans le top-8 d'au moins une des deux recherches } export type ContextBlock = { text: string; sources: { tag: string; chunk: RetrievedChunk }[]; }; /** Construit le contexte numéroté [S1..Sn] transmis au modèle. */ export function buildContext(results: RetrievedChunk[], maxChars = 14000): ContextBlock { const sources: { tag: string; chunk: RetrievedChunk }[] = []; const parts: string[] = []; let total = 0; results.forEach((r) => { if (total > maxChars) return; const tag = `S${sources.length + 1}`; const header = `[${tag}] (${r.course_code ?? "téléversement"} — ${r.doc_title}${r.ref_label ? " — " + r.ref_label : ""}${r.title ? " — « " + r.title + " »" : ""})`; const body = r.content.slice(0, 2400); parts.push(`${header}\n${body}`); total += header.length + body.length; sources.push({ tag, chunk: r }); }); return { text: parts.join("\n\n---\n\n"), sources }; }