SPB Git forge

spb/uqo-chat

Public
14commits 1branches 0releases
1.4 MBsize
maindefault branch
17 days agolast push
Python 64.6% TypeScript 33.7% CSS 0.8%
5.4 KB · 149 lines python
Raw Blame History
1"""Hybrid retriever: BM25 (in-memory index over course_chunks) + optional embeddings."""23from __future__ import annotations45import asyncio6import math7from collections import Counter, defaultdict8from dataclasses import dataclass910import numpy as np11from sqlalchemy import select1213from app.core.logging import get_logger14from app.db import SessionLocal15from app.models import CourseChunk16from app.rag.chunking import tokenize1718log = get_logger("rag.retriever")192021@dataclass22class Hit:23    chunk_id: str24    course: str25    module: str26    section: str27    page: str28    url: str29    content: str30    score: float31    visibility: str3233    def source_label(self) -> str:34        sec = f", {self.section}" if self.section and self.section != self.module else ""35        return f"{self.course} – {self.module}{sec}"363738class BM25Index:39    def __init__(self, k1: float = 1.4, b: float = 0.75) -> None:40        self.k1, self.b = k1, b41        self.docs: list[dict] = []42        self.tf: list[Counter[str]] = []43        self.df: Counter[str] = Counter()44        self.doc_len: list[int] = []45        self.avg_len = 1.046        self.embeddings: np.ndarray | None = None47        self.postings: dict[str, list[int]] = defaultdict(list)4849    def build(self, rows: list[CourseChunk]) -> None:50        self.docs, self.tf, self.doc_len = [], [], []51        self.df = Counter()52        self.postings = defaultdict(list)53        embs: list[list[float]] = []54        have_emb = True55        for i, r in enumerate(rows):56            head = f"{r.module} {r.section} "57            toks = tokenize(head * 2 + r.content)58            tf = Counter(toks)59            self.docs.append({"id": r.id, "course": r.course_code, "module": r.module,60                              "section": r.section, "page": r.page, "url": r.url,61                              "content": r.content, "visibility": r.visibility})62            self.tf.append(tf)63            self.doc_len.append(len(toks))64            for t in tf:65                self.df[t] += 166                self.postings[t].append(i)67            if r.embedding:68                embs.append(r.embedding)69            else:70                have_emb = False71        self.avg_len = (sum(self.doc_len) / len(self.doc_len)) if self.doc_len else 1.072        self.embeddings = np.array(embs, dtype=np.float32) if (have_emb and embs) else None73        log.info("bm25_built", docs=len(self.docs), embeddings=self.embeddings is not None)7475    def _idf(self, t: str) -> float:76        n = len(self.docs)77        df = self.df.get(t, 0)78        return math.log(1 + (n - df + 0.5) / (df + 0.5))7980    def search(self, query: str, top_k: int = 8, courses: set[str] | None = None,81               boost_course: str | None = None, include_professor: bool = False,82               query_embedding: list[float] | None = None) -> list[Hit]:83        q = tokenize(query)84        if not q or not self.docs:85            return []86        scores: dict[int, float] = defaultdict(float)87        for t in set(q):88            idf = self._idf(t)89            for i in self.postings.get(t, ()):90                f = self.tf[i][t]91                denom = f + self.k1 * (1 - self.b + self.b * self.doc_len[i] / self.avg_len)92                scores[i] += idf * f * (self.k1 + 1) / denom93        if scores:94            mx = max(scores.values()) or 1.095            for i in scores:96                scores[i] /= mx97        if query_embedding is not None and self.embeddings is not None:98            qv = np.array(query_embedding, dtype=np.float32)99            sims = self.embeddings @ qv / (np.linalg.norm(self.embeddings, axis=1) *100                                           (np.linalg.norm(qv) or 1.0) + 1e-9)101            top = np.argsort(-sims)[: top_k * 4]102            for i in top:103                scores[int(i)] = 0.35 * scores.get(int(i), 0.0) + 0.65 * float(sims[i])104        hits: list[Hit] = []105        for i, s in scores.items():106            d = self.docs[i]107            if courses and d["course"] not in courses:108                continue109            if d["visibility"] == "professor_only" and not include_professor:110                continue111            if boost_course and d["course"] == boost_course:112                s *= 1.15113            hits.append(Hit(d["id"], d["course"], d["module"], d["section"], d["page"],114                            d["url"], d["content"], s, d["visibility"]))115        hits.sort(key=lambda h: h.score, reverse=True)116        # diversify: at most 3 hits per module117        out: list[Hit] = []118        per_module: Counter[str] = Counter()119        for h in hits:120            if per_module[h.module] >= 3:121                continue122            per_module[h.module] += 1123            out.append(h)124            if len(out) >= top_k:125                break126        return out127128129index = BM25Index()130_lock = asyncio.Lock()131132133async def rebuild_index() -> int:134    async with _lock:135        async with SessionLocal() as session:136            rows = list((await session.execute(select(CourseChunk))).scalars())137        index.build(rows)138        return len(rows)139140141def format_for_model(hits: list[Hit]) -> str:142    if not hits:143        return "Aucun passage pertinent trouvé dans le matériel du cours."144    parts = ["Passages du matériel de cours (cite-les avec l'identifiant entre crochets) :"]145    for i, h in enumerate(hits, 1):146        parts.append(f"[S{i}] Source: {h.source_label()}"147                     f"{' — ' + h.url if h.url else ''}\n<document>\n{h.content[:3000]}\n</document>")148    return "\n\n".join(parts)149