Chat atelier : reranking LLM d un pool elargi (opt-in RAG_RERANK)

Recupere 3x top_k passages (max 24), fait noter leur pertinence par le LLM
en un appel (temperature 0, extraits tronques a 600 car.), garde les top_k
mieux notes (tri stable : a note egale l ordre cosinus est preserve).
Best-effort : echec LLM ou notes inexploitables -> classement cosinus.
Desactive par defaut (+1 appel avant le premier token) ; recommande avec
un provider cloud rapide via RAG_RERANK=true.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-10 15:52:50 +02:00
parent e26d11a99f
commit 7dec288829
4 changed files with 97 additions and 3 deletions

View File

@@ -136,8 +136,10 @@ def get_notebook_rag_use_case(
def get_notebook_chat_use_case( def get_notebook_chat_use_case(
llm: Annotated[LLMProvider, Depends(get_llm_provider)], llm: Annotated[LLMProvider, Depends(get_llm_provider)],
rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)], rag: Annotated[NotebookRagUseCase, Depends(get_notebook_rag_use_case)],
settings: Annotated[Settings, Depends(get_settings)],
) -> NotebookChatUseCase: ) -> NotebookChatUseCase:
return NotebookChatUseCase(rag=rag, llm=llm) # type: ignore[arg-type] return NotebookChatUseCase(
rag=rag, llm=llm, rerank_enabled=settings.rag_rerank) # type: ignore[arg-type]
def get_notebook_deep_use_case( def get_notebook_deep_use_case(

View File

@@ -10,6 +10,7 @@ from typing import AsyncIterator
from app.application.notebook_rag import NotebookRagUseCase from app.application.notebook_rag import NotebookRagUseCase
from app.application.query_rewrite import standalone_question from app.application.query_rewrite import standalone_question
from app.application.rerank import pool_size, rerank
from app.domain.models import ChatMessage from app.domain.models import ChatMessage
from app.domain.ports import LLMChatProvider from app.domain.ports import LLMChatProvider
@@ -58,9 +59,13 @@ Réponds en français, de façon utile et concise. Mets le texte explicatif AVAN
class NotebookChatUseCase: class NotebookChatUseCase:
def __init__(self, rag: NotebookRagUseCase, llm: LLMChatProvider) -> None: def __init__(
self, rag: NotebookRagUseCase, llm: LLMChatProvider, rerank_enabled: bool = False
) -> None:
self._rag = rag self._rag = rag
self._llm = llm self._llm = llm
# Reranking LLM d'un pool élargi avant injection (voir app.application.rerank).
self._rerank_enabled = rerank_enabled
async def stream( async def stream(
self, self,
@@ -76,6 +81,13 @@ class NotebookChatUseCase:
# le sujet → on le résout depuis l'historique (best-effort, 1 appel léger, # le sujet → on le résout depuis l'historique (best-effort, 1 appel léger,
# uniquement à partir du 2e tour). La réponse, elle, voit tout l'historique. # uniquement à partir du 2e tour). La réponse, elle, voit tout l'historique.
search_query = await standalone_question(self._llm, messages) search_query = await standalone_question(self._llm, messages)
if self._rerank_enabled:
# Pool élargi → notation LLM → top_k final (meilleure précision sur
# les questions ambiguës, au prix d'un appel avant le premier token).
pool = await self._rag.retrieve(
source_ids, search_query, top_k=pool_size(top_k))
passages = await rerank(self._llm, search_query, pool, top_k)
else:
passages = await self._rag.retrieve(source_ids, search_query, top_k=top_k) passages = await self._rag.retrieve(source_ids, search_query, top_k=top_k)
# Évènement 'sources' AVANT le premier token : l'UI peut afficher les # Évènement 'sources' AVANT le premier token : l'UI peut afficher les
# pages utilisées (« 📖 p. 12, 47 ») dès le début de la réponse. # pages utilisées (« 📖 p. 12, 47 ») dès le début de la réponse.

View File

@@ -0,0 +1,73 @@
"""Reranking LLM des passages RAG (chat des ateliers).
Le cosinus classe par similarité de SURFACE ; sur les questions ambiguës, des
passages proches lexicalement mais inutiles passent devant l'extrait qui répond
vraiment. Le reranking récupère un POOL élargi (ex. 3× top_k), fait noter la
pertinence de chaque extrait par le LLM en UN appel, et garde les top_k mieux
notés. Coût : ~1 appel LLM avant le premier token — opt-in via RAG_RERANK.
"""
from __future__ import annotations
import logging
from app.application.llm_json import load_json_object
logger = logging.getLogger(__name__)
# Taille du pool élargi : multiple du top_k demandé, plafonné (le prompt de
# notation doit rester raisonnable même avec rag_top_k élevé).
POOL_FACTOR = 3
POOL_MAX = 24
# Un extrait long n'a pas besoin d'être noté en entier : tronquer borne le
# prompt sans changer le jugement de pertinence.
_EXCERPT_CHARS = 600
_RERANK_PROMPT = """Tu évalues la PERTINENCE d'extraits d'un document pour répondre à une question.
Note chaque extrait de 0 (sans rapport) à 10 (répond directement), indépendamment des autres.
QUESTION : {question}
{passages}
Réponds UNIQUEMENT par un objet JSON : {{"scores": [note_extrait_1, note_extrait_2, ...]}}
Le tableau doit contenir EXACTEMENT {count} notes, dans l'ordre des extraits."""
def pool_size(top_k: int) -> int:
"""Taille du pool à récupérer avant reranking."""
return min(max(top_k * POOL_FACTOR, top_k), POOL_MAX)
async def rerank(llm, question: str, passages: list[dict], top_k: int) -> list[dict]:
"""Renvoie les `top_k` passages les mieux notés par le LLM (tri stable :
à note égale, l'ordre cosinus d'origine est préservé).
BEST-EFFORT : échec LLM, JSON invalide ou nombre de notes incohérent →
on renvoie simplement les `top_k` premiers du classement cosinus.
"""
if len(passages) <= top_k:
return passages
numbered = "\n\n".join(
f"--- EXTRAIT {i + 1} ---\n{(p.get('text') or '')[:_EXCERPT_CHARS]}"
for i, p in enumerate(passages)
)
prompt = _RERANK_PROMPT.format(
question=question, passages=numbered, count=len(passages))
try:
raw = await llm.generate(prompt, temperature=0.0)
except Exception as exc: # noqa: BLE001 — un chat dégradé vaut mieux que pas de chat
logger.warning("Reranking ignoré (échec LLM) : %s", exc)
return passages[:top_k]
parsed, _ = load_json_object(raw)
scores = parsed.get("scores") if isinstance(parsed, dict) else None
if not isinstance(scores, list) or len(scores) != len(passages):
logger.warning("Reranking ignoré (notes inexploitables).")
return passages[:top_k]
try:
scored = [(float(s), i) for i, s in enumerate(scores)]
except (TypeError, ValueError):
logger.warning("Reranking ignoré (notes non numériques).")
return passages[:top_k]
order = sorted(range(len(passages)), key=lambda i: (-scored[i][0], i))
return [passages[i] for i in order[:top_k]]

View File

@@ -89,6 +89,13 @@ class Settings(BaseSettings):
# False = relire TOUT le document à chaque question (exhaustivité maximale). # False = relire TOUT le document à chaque question (exhaustivité maximale).
deep_summary_filter: bool = True deep_summary_filter: bool = True
# Reranking LLM du chat atelier : recupere un pool elargi (3x top_k, max 24)
# puis fait NOTER la pertinence de chaque extrait par le LLM avant d'injecter
# les top_k meilleurs. Meilleure precision sur les questions ambigues, MAIS
# +1 appel LLM avant le premier token (quelques secondes sur un petit modele
# local). Desactive par defaut ; recommande avec un provider cloud rapide.
rag_rerank: bool = False
# Cosinus minimal pour qu'un extrait soit injecté dans le prompt du chat # Cosinus minimal pour qu'un extrait soit injecté dans le prompt du chat
# atelier : en dessous, l'extrait n'a aucun rapport avec la question → mieux # atelier : en dessous, l'extrait n'a aucun rapport avec la question → mieux
# vaut moins d'extraits que du bruit. Défaut conservateur (0.30) : les paires # vaut moins d'extraits que du bruit. Défaut conservateur (0.30) : les paires