Aller au contenu
English

Recherche lexicale, dense et hybride

  • Leçon 8
  • 55 min
  • Module 3 : Créer et comparer la récupération

Ce que vous saurez faire

Choisissez une méthode de recherche pour l’assistant de Larkfield avec des preuves, pas avec un slogan. Vous comparez la recherche par mots-clés, vectorielle et hybride sur de vraies questions. Ensuite, vous vérifiez que l’assistant voit seulement la version en vigueur de la politique. Il doit aussi voir seulement les documents que ses utilisateurs peuvent lire.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 55 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

  • Le projet de Les plongements pour la recherche, avec .venv actif : dense.py et embed.py existent, et python -m policy_assistant ingest a affiché embeddings intfloat/multilingual-e5-small.
  • Si votre projet ne fonctionne plus, copiez l’instantané end-of-m02 et refaites les étapes de la section Construisez-le de cette leçon.

Aucun compte ni aucune clé. Testé avec Python 3.12.13 sur macOS (Apple silicon). Les commandes Windows ne sont pas testées.

Une bonne bibliothèque offre deux façons de trouver un livre. Le catalogue trouve une cote exacte, comme QA76.73, en une seconde. La bibliothécaire trouve un livre à partir d’une description vague, mais elle ne se souvient pas de toutes les cotes. Vous utilisez le catalogue quand vous avez le numéro, et la bibliothécaire quand vous avez seulement une idée. Un lecteur avisé consulte les deux et compare les listes.

Tomás a vu la dernière leçon, et il est convaincu : « La recherche vectorielle comprend le sens. Les mots-clés, c’est dépassé. Utilisons les vecteurs pour tout. » Grace essaie une de ses questions, un code que les agents tapent tel quel : « Qu’est-ce que LB-1? » La réponse se trouve dans le guide du taille-haie : « mettez quelques gouttes d’huile pour lame LB-1 le long de la lame ». Voici la recherche vectorielle, avec le code du projet de cette leçon :

Fenêtre de terminal
python -m policy_assistant search "What is LB-1?" --method dense
1. 0.7836 [8021b57fe6d3] privacy-policy v3 | Who we share it with | public
Who we share it with We share your information with couriers, our payment processor and our help-de…
2. 0.7773 [13f00fce8eaa] privacy-policy v3 | Our help-desk assistant | public
Our help-desk assistant Our help-desk staff use an assistant that drafts answers from Larkfield's p…
3. 0.7738 [6e0e1d1c7e4f] privacy-policy v3 | Contact | public
Contact Write to our privacy office at privacy@larkfield.example.
4. 0.7616 [f78646d93435] policy-governance v2 | Access labels | public
Access labels - public: The document may be shown or quoted to customers. - staff: Only for help-de…
5. 0.7609 [a31b42d3e40d] damaged-or-wrong-items v2 | Wrong item | public
Wrong item We send a free return label. We ship the right item as soon as the courier scans the ret…
dense, top 5, as of 2026-10-09, audience staff

Trois segments sur la confidentialité, et rien sur l’huile pour lame. Le segment du taille-haie n’est même pas parmi les 20 premiers : il arrive au 34e rang. « LB-1 » ne signifie rien pour e5. La question devient donc « Qu’est-ce que…? », une question vague. Les questions vagues mènent à des segments proches de tout, comme ces courtes sections sur la confidentialité. Avec la même question, la recherche par mots-clés place le segment du taille-haie en premier. « LB-1 » est un mot rare que seul ce segment contient.

Les mots-clés échouent donc avec « sprinkler » (arroseur) et le français, et les vecteurs échouent avec « LB-1 ». Aucune méthode n’est « toujours meilleure ». La seule façon de choisir est d’examiner de vraies questions de chaque type.

L’explorateur montre les 5 premiers résultats de trois méthodes pour quatre questions de Grace. La recherche par mots-clés utilise BM25, comme dans le module 1. La recherche vectorielle utilise e5, comme dans la dernière leçon. La recherche hybride combine les deux; la prochaine section explique comment. Un passage portant l’étiquette « Relevant » (pertinent) est un passage que Grace a marqué comme contenant la réponse.

Explorer

Recherche par mots-clés, vectorielle et hybride sur quatre vraies questions

Mots-clés : premier pertinent
rang 1
Vectorielle : premier pertinent
rang 1
Hybride : premier pertinent
rang 1

Q38 : Un code de produit exact dans une phrase. Les trois méthodes ont un passage pertinent dans le top 5.

Question Which pump kit fits the PW-2200?

Passages pertinents : product-care-pw2200 › Parts and part numbers; warranty-claims-procedure › Pressure washer pumps. Filtre de date : 2026-10-09.

Recherche par mots-clés (BM25)

  1. product-care-pw2200Parts and part numbersPart | Part number | Notes Pump kit | PK-2200-07 | fits the PW-2200 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…Pertinent
  2. product-care-pw2400Parts and part numbersPart | Part number | Notes Pump kit | PK-2400-03 | fits the PW-2400 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…
  3. warranty-claims-procedure staffPressure washer pumps- If the pump of a PW-2200 fails within 3 years, send the pump kit PK-2200-07 instead of a new pressure washer. - For the PW-2400, send the pump kit…Pertinent
  4. product-care-pw2200Winter storageDrain all water from the pump and hoses before temperatures fall below 0 °C. Store it indoors, above 0 °C. Frost damage is not covered by the warrant…
  5. product-care-pw2200Using it safelyNever run the pressure washer for more than 2 minutes with the trigger released: the pump overheats. Never point the spray at people, animals or elec…

Recherche vectorielle (e5)

  1. product-care-pw2200Parts and part numbersPart | Part number | Notes Pump kit | PK-2200-07 | fits the PW-2200 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…Pertinent
  2. warranty-claims-procedure staffPressure washer pumps- If the pump of a PW-2200 fails within 3 years, send the pump kit PK-2200-07 instead of a new pressure washer. - For the PW-2400, send the pump kit…Pertinent
  3. product-care-pw2400Parts and part numbersPart | Part number | Notes Pump kit | PK-2400-03 | fits the PW-2400 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…
  4. product-care-pw2200Specifications- Maximum pressure: 2,200 PSI. - Water flow: 6.4 litres per minute. - Electric motor: 1,800 W. Plug into an outdoor outlet with a GFCI (ground-fault)…
  5. product-care-pw2200Troubleshooting- Pressure pulses up and down: clean the inlet filter and check that the water tap is fully open. - Motor hums but does not start: release the trigge…

Recherche hybride (RRF)

  1. product-care-pw2200Parts and part numbersPart | Part number | Notes Pump kit | PK-2200-07 | fits the PW-2200 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…Pertinent
  2. product-care-pw2400Parts and part numbersPart | Part number | Notes Pump kit | PK-2400-03 | fits the PW-2400 only Nozzle set (0°, 15°, 25°, 40° and soap) | NZ-415 | fits the PW-2200 and PW-2…
  3. warranty-claims-procedure staffPressure washer pumps- If the pump of a PW-2200 fails within 3 years, send the pump kit PK-2200-07 instead of a new pressure washer. - For the PW-2400, send the pump kit…Pertinent
  4. product-care-pw2200Winter storageDrain all water from the pump and hoses before temperatures fall below 0 °C. Store it indoors, above 0 °C. Frost damage is not covered by the warrant…
  5. product-care-pw2200Using it safelyNever run the pressure washer for more than 2 minutes with the trigger released: the pump overheats. Never point the spray at people, animals or elec…
Les 5 premiers résultats de chaque méthode pour quatre questions du jeu de questions de Larkfield. Le code de recherche du projet les calcule avec les segments structurés, e5, la date de la question et un public de membres du personnel.
Description textuelle de l’explorateur

L’explorateur a une seule commande : la question. En dessous, un affichage donne le rang du premier passage pertinent pour chaque méthode. Trois colonnes montrent les 5 premiers résultats de chaque méthode, avec les passages pertinents indiqués. Voici les quatre questions et leurs résultats :

Code de produit (Q38, « Quel ensemble de pompe convient au PW-2200? ») : le passage pertinent arrive en premier pour les recherches par mots-clés, vectorielle et hybride. Un code dans une phrase complète est facile à trouver pour les trois méthodes.

Reformulation (Q34, « Quelle est la durée de la garantie d’un arroseur de jardin? ») : la recherche par mots-clés place le tableau des garanties au rang 16, hors des 5 premiers résultats. Son premier résultat est la garantie des meubles de jardin. La recherche vectorielle place le tableau au rang 2, et la recherche hybride au rang 3.

Français (Q63, « Puis-je retourner une carte-cadeau? ») : la recherche par mots-clés ne trouve aucun passage pertinent parmi ses 20 premiers résultats. Elle trouve seulement des segments en français sur les retours. La recherche vectorielle place le passage sur les cartes-cadeaux « Lost cards and returns » (cartes perdues et retours) au rang 2, et la recherche hybride au rang 5.

Identifiant seul (Q64, « Qu’est-ce que LB-1? ») : la recherche par mots-clés place le passage du taille-haie au rang 1. La recherche vectorielle ne le trouve pas parmi ses 20 premiers résultats. Ses 5 premiers résultats sont trois sections sur la confidentialité, les étiquettes d’accès et « Wrong item » (mauvais article). La recherche hybride le place au rang 6, juste hors des 5 premiers résultats.

Regardez encore la question contenant seulement un identifiant. La recherche par mots-clés place la réponse en premier, et la recherche vectorielle ne la trouve pas du tout. La recherche hybride la place au rang 6, hors des 5 premiers résultats. Combiner les deux listes n’a pas conservé le meilleur de chacune. La section qui suit la prochaine explique pourquoi.

Où les mots-clés gagnent, et où les vecteurs gagnent

Section intitulée « Où les mots-clés gagnent, et où les vecteurs gagnent »

Une question par type reste un petit échantillon. Voici hit@5 sur tout le jeu de questions de Grace, par type de question :

Questions Nombre Mots-clés (BM25) Vectorielle (e5) Hybride
Tableaux (la réponse est dans une ligne de tableau) 8 0.625 1.000 0.875
Français 8 0.625 1.000 0.750
Codes exacts 11 1.000 0.909 0.909
Versions (dates dans la question) 8 0.875 0.750 0.625
Toutes 59 0.831 0.915 0.864

La recherche lexicale, qui utilise les mots exacts, gagne quand les mots sont rares et exacts. C’est le cas des codes de produits, des numéros de pièces, des codes du personnel comme RMA-, et des dates. Quatre questions du jeu contiennent seulement un code, par exemple « Qu’est-ce que LB-1? » et « À quoi sert CV-310? ». BM25 classe le bon segment en premier pour les quatre. La recherche vectorielle manque LB-1 et classe les trois autres au 3e ou au 4e rang. Parfois, un code se trouve dans une phrase complète, comme dans « Quel ensemble de pompe convient au PW-2200? ». Les deux méthodes le trouvent alors, parce que le reste de la phrase porte le sens.

La recherche dense, qui utilise le sens, gagne quand les mots diffèrent. C’est le cas des reformulations (« sprinkler » et « sprinklers », arroseur et arroseurs). C’est aussi le cas des lignes de tableau utilisant d’autres mots que la question, et des autres langues. Avec BM25, les questions en français atteignent 0,625; avec e5, elles trouvent toutes un passage pertinent.

Voilà la réponse à Tomás. La recherche vectorielle est meilleure en moyenne ici : 0,915 contre 0,831. Mais « en moyenne » cache les questions où elle échoue complètement. Ce sont les codes que les agents copient d’une boîte ou d’un reçu. Avec 59 questions, un écart d’une ou deux questions (0,017 à 0,034) ne prouve rien. Un écart de 5 questions, comme ici, est un véritable signal sur ce jeu. Il pourrait quand même ne pas se maintenir sur une autre collection.

Si chaque méthode trouve ce que l’autre manque, pourquoi ne pas utiliser les deux? La recherche hybride lance la recherche par mots-clés et la recherche vectorielle, puis combine les deux listes classées en une seule.

On ne peut pas additionner les scores. Les scores BM25 vont de 0 à environ 30. Les similarités cosinus d’e5 se situent entre 0,7 et 0,9. Une somme suivrait presque uniquement BM25. La méthode habituelle utilise donc seulement les rangs. Chaque liste donne à chaque segment un vote de 1 ÷ (60 + rang). Le code additionne ensuite les votes. C’est la fusion par rang réciproque (RRF) : « réciproque » parce que le vote est 1 divisé par le rang. La constante 60 vient de l’article qui a proposé RRF, par Cormack, Clarke et Büttcher en 2009. Elle empêche la première place d’une liste de tout décider.

Un petit exemple, avec des listes inventées :

Segment Rang par mots-clés Rang vectoriel Score RRF
A 1 3 1/61 + 1/63 = 0,0323
D non trouvé 1 1/61 = 0,0164
B non trouvé 2 1/62 = 0,0161
C 2 non trouvé 1/62 = 0,0161

A est dans les deux listes, alors il reçoit deux votes et gagne. Pourtant, aucune liste ne le plaçait seul en tête. C’est l’idée de la recherche hybride : un segment que les deux méthodes favorisent est probablement pertinent.

Prenons maintenant la vraie question « Qu’est-ce que LB-1? ». BM25 classe le segment du taille-haie en premier : 1/61 = 0,0164. La recherche vectorielle ne le trouve pas parmi ses 20 premiers résultats, alors il ne reçoit pas de deuxième vote. Quatre autres segments figurent dans les deux listes, à des rangs peu élevés. Leurs deux petits votes totalisent plus qu’un seul gros vote :

Quatre lignes, chacune présentant un segment avec ses deux rangs et son score fusionné. Le tableau des garanties, rang BM25 7 et rang vectoriel 11 : 1/67 + 1/71 = 0,0290, rang hybride 1. Les gros articles, rang BM25 4 et rang vectoriel 20 : 1/64 + 1/80 = 0,0281, rang hybride 2. La section sur la confidentialité Who we share it with (avec qui nous les partageons), rang vectoriel 1 et absente des 20 premiers résultats BM25 : 1/61 = 0,0164, rang hybride 5. Le passage du taille-haie avec LB-1, rang BM25 1 et absent des 20 premiers résultats vectoriels : 1/61 = 0,0164, rang hybride 6, hors des 5 premiers résultats.
Fusion par rang réciproque sur la vraie question « Qu'est-ce que LB-1? ». Les segments avec deux votes faibles battent le segment avec un vote fort.
Description textuelle du diagramme

Quatre lignes. Chaque ligne commence par un segment et son rang dans les deux listes. Elle se termine par son score fusionné et son rang dans la liste hybride. Le tableau des garanties est 7e pour BM25 et 11e pour la recherche vectorielle : 1/67 + 1/71 = 0,0290, rang hybride 1. La section sur la livraison des gros articles est 4e pour BM25 et 20e pour la recherche vectorielle : 1/64 + 1/80 = 0,0281, rang hybride 2. Deux autres segments avec deux votes occupent les rangs 3 et 4. La section sur la confidentialité « Who we share it with » est 1re pour la recherche vectorielle et absente des 20 premiers résultats BM25 : 1/61 = 0,0164, rang hybride 5. Le passage du taille-haie avec LB-1 est 1er pour BM25 et absent des 20 premiers résultats vectoriels : lui aussi obtient 1/61 = 0,0164. L’identifiant du segment tranche l’égalité. Il arrive donc 6e et sort des 5 premiers résultats.

Le résultat honnête : la recherche hybride n’est pas meilleure ici

Section intitulée « Le résultat honnête : la recherche hybride n’est pas meilleure ici »

Sur tout le jeu, la recherche hybride obtient un score inférieur à la recherche vectorielle seule : hit@5 0.864 contre 0.915. La raison est celle que montre l’image. Quand une liste est faible pour une question, ses résultats aléatoires de rang peu élevé votent quand même. Ils peuvent faire descendre un bon résultat. Pour les questions en français, la liste de BM25 est faible, et la recherche hybride passe de 1,000 à 0,750.

La recherche hybride aide toutefois dans un cas. Pour les quatre questions contenant seulement un identifiant, elle fait monter le bon segment. TG-24, HR-50 et CV-310 passent du rang 3 ou 4 au rang 1. LB-1 passe du rang 34 au rang 6. Si les agents de Larkfield tapaient beaucoup de codes seuls, cela pourrait compter. Avec les vecteurs du modèle hébergé embed-small, la recherche hybride est légèrement meilleure que les vecteurs seuls (0,898 contre 0,881). La même méthode aide avec un modèle de plongement et nuit avec un autre.

La leçon n’est donc pas « la recherche hybride est mauvaise ». C’est plutôt : mesurez sur vos propres questions. Les articles de blogue et les bancs d’essai disent souvent que la recherche hybride gagne. Sur leurs données, c’est peut-être vrai. Sur les 59 questions de Larkfield, avec e5, ce n’est pas le cas. Aucune méthode n’est la meilleure partout. Le cours garde la recherche hybride pour une tâche : recueillir les 20 candidats que la prochaine leçon reclassera. Cette combinaison, appelée rerank, est le choix par défaut du cours, parce qu’elle offre le meilleur classement.

Il faut régler deux questions avant tout classement : quelle version du document s’applique, et qui peut le voir. Les segments portent les réponses, vues dans Conserver les métadonnées : une plage de dates de validité et une étiquette d’accès.

  • Le filtre de date garde seulement les versions en vigueur à une date : effective_from <= date <= effective_to. Quelle date? Le document policy-governance de Larkfield précise : pour les retours, la date de livraison. La version 3 de la politique de retour (30 jours) est en vigueur jusqu’au 31 octobre 2026. La version 4 (45 jours) entre en vigueur le 1er novembre.
  • Le filtre d’accès suit le public visé. L’assistant du service de soutien est destiné au personnel, alors il peut voir tous les segments. Un assistant public, destiné aux clients, peut seulement voir les segments portant l’étiquette public (public). Un segment sans étiquette compte comme staff (personnel) : quand l’étiquette manque, le choix sûr est de le cacher.

Les filtres s’appliquent avant de choisir les k premiers résultats, dans la recherche. S’ils s’appliquaient après, des segments réservés au personnel pourraient occuper 3 des 5 places. Le filtre les retirerait ensuite. Un utilisateur public recevrait seulement 2 résultats. Pire encore, une étape ultérieure pourrait oublier complètement le filtrage.

Une rangée d'étapes de gauche à droite : 185 segments dans l'index; filtres d'abord, version en vigueur à la date et étiquette d'accès pour le public visé; candidats, les 20 premiers résultats de BM25 et les 20 premiers de la recherche vectorielle; hybride, fusionner les deux listes avec RRF et garder 20 résultats; reclassement, un encodeur croisé lit la question avec le segment 20 fois; k vaut 5, les preuves choisies; vers le modèle du module 4. Une flèche pointillée part des filtres vers les autres versions et les segments réservés au personnel pour un assistant public : retirés.
L'entonnoir de récupération de l'assistant de Larkfield. Les filtres passent en premier. Aucun contenu périmé ou interdit ne peut donc occuper une place parmi les 5 premiers résultats.
Description textuelle du diagramme

Sept étapes, de gauche à droite. 1 : les 185 segments dans l’index. 2 : les filtres, d’abord : seulement la version en vigueur à la date de la question et les étiquettes d’accès autorisées pour le public visé. Une flèche pointillée s’éloigne de cette étape. Les autres versions et les segments réservés au personnel, pour un assistant public, sont retirés ici. 3 : les candidats : les 20 premiers résultats de BM25 et les 20 premiers de la recherche vectorielle, parmi les segments filtrés. 4 : la recherche hybride fusionne les deux listes avec RRF et garde 20 résultats. 5 : le reclassement, sujet de la prochaine leçon : un encodeur croisé lit la question avec chacun des 20 segments. 6 : les k premiers résultats, ici 5 segments : les preuves choisies. 7 : les preuves vont au modèle, dans le module 4.

Les k premiers résultats correspondent au nombre de segments que la recherche renvoie. Larkfield utilise k = 5. Un k plus grand trouve plus de passages pertinents, mais envoie aussi plus de texte au modèle. Cela coûte des jetons (tokens) et peut noyer la réponse. Voici recall@k pour la recherche vectorielle : la part de tous les passages pertinents qui figurent parmi les k premiers résultats.

k 1 3 5 10
recall@k, recherche vectorielle (e5) 0.531 0.785 0.862 0.895

De 3 à 5, le rappel gagne 0,077. De 5 à 10, il gagne seulement 0,033, pour deux fois plus de texte. C’est pourquoi k = 5 est un choix raisonnable ici. Le module 4 fixe un budget en jetons pour le contexte, et Mesurer la récupération explique recall@k en détail.

Tomás a une autre idée : « Les vrais systèmes RAG utilisent une base de données vectorielle. » Un index vectoriel est une structure de données qui trouve des vecteurs proches sans les comparer tous. Il devient utile avec des millions de segments. Larkfield en a 185. Comparer la question aux 185 vecteurs dans NumPy prend environ 0,09 ms sur l’ordinateur de test. Calculer le plongement de la question prend 5,2 ms. La recherche elle-même n’est donc pas la partie lente.

Une base de données vectorielle ajouterait un service à installer, à mettre à jour et à sauvegarder, sans aucun gain. Mesurez d’abord. Quand la collection atteindra plusieurs milliers de segments, essayez un index comme sqlite-vec et mesurez de nouveau.

Le projet reçoit trois nouveaux fichiers, hybrid.py, filters.py et search.py, et un nouveau fichier de ligne de commande. Travaillez dans ~/projects/policy-assistant, avec .venv activé.

  1. Créez le fichier policy_assistant/hybrid.py :

    policy_assistant/hybrid.py
    """Hybrid search: combine keyword and vector rankings with reciprocal rank fusion (RRF).
    The scores of BM25 and cosine similarity are on different scales and cannot be added. RRF uses only
    the ranks: an item gets 1 / (K + rank) from every list it appears in, and the sums are sorted.
    K = 60 (the value of the original paper, Cormack, Clarke and Buettcher, 2009) keeps one list's top
    item from dominating. An item that both lists rank well comes first.
    Worked example (K = 60): chunk A is 1st by keywords and 3rd by vectors: 1/61 + 1/63 = 0.0323.
    Chunk B is 2nd by vectors only: 1/62 = 0.0161. A comes first.
    """
    from .lexical import Hit
    K = 60
    def rrf(rankings: list[list[Hit]], k: int = 5, constant: int = K) -> list[Hit]:
    scores: dict[str, float] = {}
    for ranking in rankings:
    for hit in ranking:
    scores[hit.id] = scores.get(hit.id, 0.0) + 1.0 / (constant + hit.rank)
    order = sorted(scores.items(), key=lambda item: (-item[1], item[0]))
    return [Hit(i, round(s, 6), rank) for rank, (i, s) in enumerate(order[:k], 1)]
  2. Créez le fichier policy_assistant/filters.py :

    policy_assistant/filters.py
    """Metadata filters: which chunks may be retrieved for this question at all.
    Applied BEFORE the top k is chosen (and so before anything reaches the model):
    - as_of: only versions in force on that date (effective_from <= as_of, and as_of <= effective_to
    when the document has an end date). Which date? The policy governance document says: for returns,
    the delivery date; otherwise the day of the question.
    - audience: "staff" (the help-desk assistant) may see every chunk; "public" (a customer-facing
    assistant) only chunks labelled public. A chunk without an access label counts as staff: when the
    label is missing, the safe choice is to hide it.
    """
    from dataclasses import dataclass
    AUDIENCES = ("staff", "public")
    @dataclass(frozen=True)
    class Filters:
    as_of: str | None = None # YYYY-MM-DD, or None for no date filter
    audience: str = "staff"
    def __post_init__(self):
    if self.audience not in AUDIENCES:
    raise ValueError(f"audience must be one of {AUDIENCES}, not {self.audience!r}")
    def allows(self, chunk) -> bool:
    if self.audience == "public" and (getattr(chunk, "access", "") or "staff") != "public":
    return False
    if self.as_of:
    if chunk.effective_from and chunk.effective_from > self.as_of:
    return False
    if chunk.effective_to and self.as_of > chunk.effective_to:
    return False
    return True

    Les dates sont des chaînes au format YYYY-MM-DD. Les comparer comme du texte donne donc le bon ordre.

  3. Créez le fichier policy_assistant/search.py. C’est le point d’entrée unique pour toutes les méthodes :

    policy_assistant/search.py
    """One entry point for every search method, with the metadata filters applied first.
    bm25 keyword search (lexical.py)
    dense vector search (dense.py) with the embedding model that built the index
    hybrid bm25 + dense, combined with reciprocal rank fusion (hybrid.py)
    rerank hybrid's top CANDIDATES, reread by the cross-encoder (rerank.py)
    """
    from dataclasses import dataclass
    from .dense import search as dense_search
    from .filters import Filters
    from .hybrid import rrf
    from .lexical import Hit
    from .metadata import Chunk
    from .store import IndexMismatch, Store
    METHODS = ("bm25", "dense", "hybrid", "rerank")
    CANDIDATES = 20 # how many results each first-stage method passes on to fusion and reranking
    @dataclass(frozen=True)
    class Result:
    chunk: Chunk
    score: float
    rank: int
    class Retriever:
    def __init__(self, store: Store, embedder=None, reranker=None):
    self.store, self.embedder, self.reranker = store, embedder, reranker
    self._chunks = {c.chunk_id: c for c in store.chunks()}
    def _allowed(self, filters: Filters):
    return lambda chunk_id: filters.allows(self._chunks[chunk_id])
    def _check_model(self) -> None:
    if self.embedder is None:
    raise ValueError("Dense search needs an embedding model.")
    meta = self.store.meta()
    if (meta.get("embedding_model"), meta.get("embedding_revision")) != (self.embedder.name, self.embedder.revision):
    raise IndexMismatch(
    f"The index was built with {meta.get('embedding_model') or 'no embedding model'} "
    f"({meta.get('embedding_revision', '')}), and the question would be embedded with "
    f"{self.embedder.name} ({self.embedder.revision}). Vectors of two models are not comparable.")
    def bm25(self, query: str, k: int, filters: Filters) -> list[Hit]:
    return self.store.keywords.search(query, k, self._allowed(filters))
    def dense(self, query: str, k: int, filters: Filters) -> list[Hit]:
    self._check_model()
    ids, matrix = self.store.vectors()
    return dense_search(self.embedder.embed_queries([query])[0], matrix, ids, k, self._allowed(filters))
    def hybrid(self, query: str, k: int, filters: Filters) -> list[Hit]:
    return rrf([self.bm25(query, CANDIDATES, filters), self.dense(query, CANDIDATES, filters)], k)
    def rerank(self, query: str, k: int, filters: Filters) -> list[Hit]:
    if self.reranker is None:
    raise ValueError("Reranking needs a cross-encoder (rerank.Reranker).")
    candidates = [self._chunks[h.id] for h in self.hybrid(query, CANDIDATES, filters)]
    return self.reranker.rerank(query, candidates, k)
    def search(self, query: str, method: str = "hybrid", k: int = 5, filters: Filters = Filters()) -> list[Result]:
    if method not in METHODS:
    raise ValueError(f"Unknown method {method!r}: use one of {', '.join(METHODS)}.")
    hits = getattr(self, method)(query, k, filters)
    return [Result(self._chunks[h.id], h.score, h.rank) for h in hits]
    Partie Signification
    _allowed(filters) Transforme les filtres en fonction allowed, utilisée par dense.search et l’index par mots-clés. Chaque méthode filtre avant de réduire la liste à k résultats.
    _check_model() Compare le modèle dans les métadonnées meta de l’index avec celui de la question. Un nom ou une révision différente arrête la recherche.
    CANDIDATES = 20 La recherche hybride prend les 20 premiers résultats de chaque méthode, pas les 5 premiers. Un segment classé 6e par une méthode peut donc encore recevoir son vote.
    rerank La méthode de la prochaine leçon. Son fichier arrive à la prochaine étape.
  4. Copiez le nouveau fichier de ligne de commande et le modèle de reclassement depuis l’instantané. Le fichier de ligne de commande comporte plusieurs petits changements. La prochaine leçon explique le modèle de reclassement :

    Fenêtre de terminal
    cp ../nextia-learning-labs/C09/snapshots/end-of-m03/policy_assistant/__main__.py policy_assistant/
    cp ../nextia-learning-labs/C09/snapshots/end-of-m03/policy_assistant/rerank.py policy_assistant/

    search et eval acceptent maintenant --method (bm25, dense, hybrid ou rerank), --as-of et --audience. Sans --as-of, la date du cours, le 9 octobre 2026, est utilisée pour reproduire les résultats de cette leçon. Utilisez --as-of today en usage réel, et --as-of none pour désactiver le filtre de date. eval utilise la date propre à chaque question.

  5. Copiez les deux nouveaux fichiers de test :

    Fenêtre de terminal
    cp ../nextia-learning-labs/C09/snapshots/end-of-m03/tests/test_retrieval_parts.py tests/
    cp ../nextia-learning-labs/C09/snapshots/end-of-m03/tests/test_search.py tests/

    test_retrieval_parts.py vérifie les petits exemples : le cosinus de (3, 4) et (4, 3) vaut 0,96, et le tableau RRF ci-dessus. test_search.py vérifie les vrais contre-exemples de la collection : « Qu’est-ce que LB-1? », la question française sur les cartes-cadeaux et celle sur l’arroseur.

  6. Exécutez les tests :

    Fenêtre de terminal
    python -m pytest

    La dernière ligne est 44 passed (44 tests réussis). La première exécution prend quelques secondes de plus, car les tests chargent e5.

  1. Cherchez LB-1 avec chaque méthode. Changez seulement --method :

    Fenêtre de terminal
    python -m policy_assistant search "What is LB-1?" --method bm25
    python -m policy_assistant search "What is LB-1?" --method dense
    python -m policy_assistant search "What is LB-1?" --method hybrid

    Vérification : avec bm25, le premier résultat est product-care-hedge-trimmer-ht550 v1 | After each use (après chaque utilisation). Avec dense et hybrid, il ne figure pas parmi les 5 premiers. La liste hybrid commence par warranty-policy v5 | Warranty periods by product c… (durées de garantie par catégorie de produits…), à 0.0290.

  2. Mesurez chaque méthode sur tout le jeu de questions :

    Fenêtre de terminal
    python -m policy_assistant eval --method bm25
    python -m policy_assistant eval --method dense
    python -m policy_assistant eval --method hybrid

    Les premières lignes des sorties :

    bm25, top 5, audience staff: hit@5 0.831 (59 questions with relevant passages)
    dense, top 5, audience staff: hit@5 0.915 (59 questions with relevant passages)
    hybrid, top 5, audience staff: hit@5 0.864 (59 questions with relevant passages)

    BM25 obtient maintenant 0,831, plutôt que les 0,797 du module 2, car eval utilise la date de chaque question. La mauvaise version d’un document n’occupe plus une place parmi les 5 premiers résultats. La dernière ligne de chaque sortie indique les questions manquées. Pour dense, c’est missed: Q13, Q26, Q28, Q64, Q56 (questions manquées).

  3. Observez le filtre de date choisir une version. La question n’a pas de date, alors vous fournissez la date de livraison :

    Fenêtre de terminal
    python -m policy_assistant search "Can a customer return an unused item?" --method bm25 --as-of 2026-11-05

    Le premier résultat est la version 4, avec 45 jours :

    1. 11.6078 [963dae4229eb] returns-policy v4 | Return window | public
    Return window You can return an unused item within 45 days of delivery for a full refund of the ite…

    Relancez la commande avec --as-of none. Les versions 3 et 4 sont maintenant toutes deux retournées, avec le même score, 11,6078. La recherche par mots-clés ne peut pas les distinguer : seule la date le peut.

  4. Observez le filtre d’accès. Demandez le rabais du personnel en tant qu’assistant public :

    Fenêtre de terminal
    python -m policy_assistant search "What is the staff discount?" --method bm25 --audience public

    Aucun résultat ne provient de staff-discount-and-perks. Le premier résultat est promotions-and-discount-codes v2 | Using a discount code | public (utiliser un code de rabais; public). Sans --audience public, les deux premiers résultats sont les sections Rules (règles) et Staff discount (rabais du personnel) du document du personnel, étiquetées staff (personnel).

  5. Essayez de chercher dans l’index e5 avec un autre modèle :

    Fenêtre de terminal
    python -m policy_assistant search "What is LB-1?" --method dense --embedder sentence-transformers/all-MiniLM-L6-v2

    La recherche s’arrête avant de comparer le moindre vecteur :

    The index was built with intfloat/multilingual-e5-small (614241f622f53c4eeff9890bdc4f31cfecc418b3), and the question would be embedded with sentence-transformers/all-MiniLM-L6-v2 (main). Vectors of two models are not comparable.

    Cette commande télécharge all-MiniLM-L6-v2 (environ 90 Mo) la première fois, avant la vérification. Vous pouvez la sauter : test_search.py vérifie le même refus.

Faites d’abord une prédiction, puis exécutez search avec --method bm25 et --method dense pour chaque question. Pour chacune, indiquez quelle méthode trouve un passage pertinent parmi ses 5 premiers résultats, et pourquoi.

  1. « À quoi sert CV-310? » (passage pertinent : product-care-bbq-gr310, section Parts (pièces))
  2. « Un client n’a rien acheté chez nous depuis deux ans. Qu’est-il arrivé à ses points Rewards? » (passage pertinent : loyalty-program, section Expiry (expiration))
  3. « Qu’est-ce que HR-50? » (passage pertinent : supplier-aquaflow-hose-reels, section The AquaFlow range (la gamme AquaFlow))
Afficher la réponse
  1. CV-310 : BM25 classe product-care-bbq-gr310 | Parts premier, et la recherche dense le classe 3e. Les deux le trouvent parmi les 5 premiers, mais cette question limitée à un code convient mieux aux mots-clés. La recherche dense place privacy-policy | Who we share it with (avec qui nous les partageons) en premier, comme pour LB-1. C’est un segment court et général, proche de nombreuses questions vagues.
  2. Deux ans : BM25 classe loyalty-program | Expiry 8e et manque donc le passage. La recherche dense le classe 2e. La question dit « n’a rien acheté depuis deux ans ». La politique dit « Les points expirent après 18 mois sans aucun achat ». C’est la même idée, formulée autrement.
  3. HR-50 : BM25 classe la gamme AquaFlow première, et la recherche dense la classe 3e, encore après le segment sur la confidentialité.

La tendance observée dans l’explorateur se confirme : les codes favorisent les mots-clés, et les reformulations favorisent les vecteurs. Pour les questions contenant un code, la recherche dense trouve généralement le segment parmi les 5 premiers; pas pour LB-1. Voilà pourquoi un seul échec sur un jeu de 59 questions compte quand même. Un agent qui tape « LB-1 » n’obtient rien d’utile.

Erreur courante

Un assistant public sans filtre d'accès

Symptôme : Omar veut essayer l’assistant sur le site de Larkfield, pour les clients. Tomás lance la même recherche sans filtre d’accès : « Ce sont les mêmes documents, les réponses seront bonnes. » La question « Quel est le rabais du personnel? » retourne d’abord deux segments de la procédure du personnel. L’un indique le rabais de 20 %, l’autre les règles (« Ne jamais le communiquer aux clients »). Sur les 67 questions, BM25 sans filtre d’accès place 59 segments réservés au personnel parmi les 5 premiers résultats. La recherche hybride en place 53, et le reclassement, 70.

Cause : chaque segment porte une étiquette d’accès, mais rien ne l’utilisait. La recherche trouve le meilleur texte pour la question. Pour de nombreuses questions, ce texte est une procédure du personnel.

Correction : transmettez le public visé à la recherche pour que le filtre s’applique avant la sélection des k premiers résultats : --audience public. Avec le filtre, le nombre est 0 pour chaque méthode. Testez-le aussi dans le code : test_search.py vérifie qu’une recherche publique ne retourne jamais de segment du personnel. Autorisations et isolation suit les étiquettes dans tout l’assistant, y compris les réponses et leurs citations.

Pour un vrai service d’IA : déterminez le public de chaque recherche dans le code, selon la personne qui pose la question, pas selon la question. Un filtre après la recherche ne suffit pas, ni une règle dans la requête (prompt). Dans les deux cas, le texte a déjà atteint le modèle.

Vérifiez votre compréhension

Expliquez à Tomás, avec un exemple réel, pourquoi « la recherche vectorielle est toujours meilleure que les mots-clés » est faux.

Afficher la réponse

Pour « Qu’est-ce que LB-1? », la recherche par mots-clés (BM25) classe premier le passage du taille-haie contenant « huile pour lame LB-1 ». La recherche vectorielle avec e5 ne le trouve pas parmi ses 20 premiers résultats. Ses 5 premiers sont des sections sur la confidentialité. « LB-1 » est un code rare et exact auquel le modèle de plongement ne donne aucun sens utile. La question devient donc vague. BM25 le trouve parce qu’un mot rare présent dans un seul segment reçoit un score élevé. La recherche vectorielle est meilleure en moyenne ici, avec un hit@5 de 0,915 contre 0,831. Elle gagne sur les reformulations, les tableaux et le français. Mais « en moyenne » ne veut pas dire « toujours ».

Vérifiez votre compréhension

Faites une prédiction : avec RRF et la constante 60, le segment X est 2e dans les deux listes, par mots-clés et vectorielle. Le segment Y est 1er dans la liste par mots-clés seulement. Lequel arrive en premier, et avec quels scores?

Afficher la réponse

X obtient 1/62 + 1/62 = 0,0323. Y obtient 1/61 = 0,0164. X arrive en premier, avec environ le double du score de Y. Un segment bien classé par les deux listes bat un segment classé premier par une seule liste. C’est l’idée de la recherche hybride, mais aussi son risque. Si Y est la bonne réponse et que la liste vectorielle est faible, la fusion fait descendre Y, comme pour LB-1.

Vérifiez votre compréhension

Pourquoi faut-il appliquer les filtres de date et d'accès avant de choisir les 5 premiers résultats, et non après?

Afficher la réponse

Si les filtres s’appliquent après, les places occupées par de mauvaises versions ou des segments interdits sont supprimées. L’utilisateur reçoit alors moins de 5 résultats, et un segment pertinent classé 6e n’est jamais retourné. Si une étape ultérieure oublie de filtrer, le texte interdit atteint même le modèle. Filtrer d’abord garantit que chacune des 5 places revient à un segment en vigueur et autorisé.

Récapitulatif

  • La recherche lexicale (BM25) gagne sur les mots rares et exacts : codes, numéros de pièces, dates. La recherche dense gagne sur les reformulations, les tableaux et les autres langues. Sur le jeu de Larkfield : BM25 0,831, recherche vectorielle 0,915.
  • La recherche hybride avec fusion des rangs réciproques additionne 1 ÷ (60 + rang) de chaque liste. Ici, elle obtient 0,864, moins que la recherche vectorielle seule. Une liste faible peut faire descendre un bon résultat, comme pour « Qu’est-ce que LB-1? ». Mesurez avec vos propres questions.
  • Le filtre de date conserve la version en vigueur, et le filtre d’accès conserve ce que le public peut voir. Les deux s’appliquent avant de choisir les k premiers résultats (ici 5).
  • Avec 185 segments, comparer tous les vecteurs prend 0,09 ms. Une base de données vectorielle n’est pas encore nécessaire.

Ensuite, relisez les candidats avec un second modèle, plus lent, et laissez un modèle de langage reformuler la question. Les deux aident, et les deux ont un coût.

Leçon suivante : Reclassement et reformulation des requêtes