Lesson 3 — Construire pas à pas

Recherche hybride et liens vérifiés

5 min read

This content is not translated yet. Showing the original version.

Recherche hybride et liens vérifiés

1. Deux recherches complémentaires

Vectorielle (embeddings)BM25 (mots)
trouvele sens : « relances des impayés » → factures_en_retardles mots exacts : PALIERS_RELANCE, FACTURO_DELAI
rateun identifiant rare que le modèle ne « comprend » pasune question formulée sans les mots du code
coût1 appel d'embedding par questionrien (calcul local)

BM25 est l'algorithme classique des moteurs de recherche : il note un morceau selon les mots de la question qu'il contient, en donnant plus de poids aux mots rares. Pour qu'il marche sur du code, il faut découper les identifiants et retirer les accents :

files/code_rag.py
def tokens(text: str) -> list[str]:
    """Mots du code ET de la question : snake_case et CamelCase découpés, minuscules, SANS accents
    (le code écrit « numerotation », la question « numérotation »)."""
    text = unicodedata.normalize("NFKD", text).encode("ascii", "ignore").decode()
    words = re.findall(r"[A-Za-z]+", re.sub(r"([a-z])([A-Z])", r"\1 \2", text))
    return [w.lower() for w in words if len(w) > 2]

tokens("planifier_relances DepotFactures") donne ["planifier", "relances", "depot", "factures"].

2. La fusion de rangs (RRF)

Les scores cosinus (entre -1 et 1) et BM25 (sans borne) ne se comparent pas. La Reciprocal Rank Fusion ignore les scores et n'utilise que les rangs : chaque morceau reçoit ∑1/(60+rang)\sum 1/(60 + \text{rang}) sur les deux classements.

flowchart LR
  Q["« où est gérée la numérotation »"] --> V["classement vectoriel (rangs 1, 2, 3...)"]
  Q --> B["classement BM25 (rangs 1, 2, 3...)"]
  V --> F["RRF : somme des 1/(60+rang)"]
  B --> F
  F --> R["classement fusionné, top-k"]
files/code_rag.py (extrait de search)
    fused = {}
    for order in ranks:                  # un classement par méthode
        for r, i in enumerate(order):
            fused[int(i)] = fused.get(int(i), 0) + 1 / (60 + r + 1)
    best = sorted(fused, key=fused.get, reverse=True)[:k]

La constante 60 vient de l'article original (Cormack et al., 2009) : elle atténue l'écart entre le 1er et le 2e rang, pour qu'un morceau bien classé par les deux méthodes passe devant un morceau premier dans une seule.

Pourquoi fusionner des rangs plutôt qu'additionner les scores cosinus et BM25 ?

3. « Où est géré X ? »

files/code_rag.py
def ou_est_gere(db, sujet, k=5, embed_fn=embed) -> list[str]:
    """« Où est géré X ? » : une liste d'emplacements, sans appel de génération."""
    return [f"{label(h)}  {h['kind']} {h['name']}" for h in search(db, sujet, k, embed_fn=embed_fn)]

4. Répondre avec des liens vérifiés

Le contexte donne à chaque extrait un en-tête --- facturo/tva.py:6-10 (fonction taux_tva). Le prompt demande de citer fichier:ligne. Puis le code vérifie que chaque citation tombe dans un extrait fourni :

files/code_rag.py
CITATION = re.compile(r"`?([\w/]+\.py):(\d+)(?:-(\d+))?`?")


def citations_valides(texte, hits) -> bool:
    cites = CITATION.findall(texte)
    if texte.startswith(REFUS):
        return True
    return bool(cites) and all(any(h["path"] == p and h["start"] <= int(a) <= int(b or a) <= h["end"] for h in hits)
                               for p, a, b in cites)

Si c'est le cas, liens() transforme facturo/relances.py:18-25 en [facturo/relances.py:18-25](projet_demo/facturo/relances.py#L18-L25), l'ancre #L18-L25 étant comprise par GitHub et GitLab. Sinon, la réponse est remplacée par un refus.

Le modèle cite facturo/auth.py:30 alors que l'extrait fourni couvre les lignes 14 à 16. Que fait answer() ?

Exercice : liens vers votre dépôt

Modifiez l'appel à liens() pour pointer vers un dépôt GitHub (https://github.com/<organisation>/<depot>/blob/main/) au lieu du dossier local.

Voir la solution

liens() prend déjà un paramètre base :

files/code_rag.py (dans answer)
BASE_LIENS = os.environ.get("BASE_LIENS", "projet_demo")
...
    return {"answer": liens(text, BASE_LIENS) if ok else ..., ...}

Avec BASE_LIENS=https://github.com/mon-orga/facturo/blob/main, la citation devient https://github.com/mon-orga/facturo/blob/main/facturo/tva.py#L6-L10. Utilisez un identifiant de commit plutôt que main si vous archivez les réponses : les numéros de ligne bougent quand le code change.

Résultat obtenu

Exécutions réelles du 2026-10-15 :

$ python code_rag.py ou "où est gérée la numérotation des factures"
facturo/stockage.py:19  methode DepotFactures.dernier_numero
facturo/numerotation.py:4  constante MOTIF
facturo/numerotation.py:7  fonction prochain_numero
facturo/stockage.py:6  classe DepotFactures
facturo/stockage.py:7  methode DepotFactures.__init__

Avant la suppression des accents dans tokens(), la même commande ne renvoyait aucun emplacement de numerotation.py : BM25 ne reliait pas « numérotation » à numerotation.

$ python eval.py          # extrait : la réponse produite par answer() pour la 1re question
Les relances des factures impayées sont gérées dans le module [facturo/relances.py:7-10](projet_demo/facturo/relances.py#L7-L10)
grâce à la fonction `factures_en_retard` qui identifie les factures dont le délai de paiement est dépassé.
Le niveau de relance est calculé selon les jours de retard définis dans [facturo/relances.py:4](projet_demo/facturo/relances.py#L4)
via la fonction `niveau_relance` située dans [facturo/relances.py:13-15](projet_demo/facturo/relances.py#L13-L15). Enfin, la
fonction `planifier_relances` dans [facturo/relances.py:18-25](projet_demo/facturo/relances.py#L18-L25) établit la liste
des relances à envoyer pour la journée.

Je sais faire

  • Adapter BM25 au code (identifiants découpés, accents retirés)
  • Fusionner deux classements par RRF
  • Vérifier des citations fichier:ligne (plages comprises) et en faire des liens
Next