Aller au contenu
English

Une référence de base par mots-clés

  • Leçon 3
  • 50 min
  • Module 1 : Définir le problème de connaissances

Ce que vous saurez faire

Créez la recherche la plus simple qui puisse fonctionner, une recherche par mots-clés, et mesurez son score sur les questions de Grace. Chaque modification suivante de l’assistant doit dépasser ce score, sinon elle ne vaut pas son coût.

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

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

  • Le projet ~/projects/policy-assistant de la dernière leçon, avec son .venv actif. python -m pytest donne 4 passed (4 tests réussis).
  • Si vous ne l’avez pas, copiez l’instantané C09/snapshots/start des exercices, comme dans Préparer le projet.
  • Rien de nouveau à installer : la recherche plein texte de SQLite fait partie de Python.

Aucun compte et aucun paiement. Rien dans cette leçon n’appelle un modèle.

Testé avec Python 3.12.13 (qui contient SQLite 3.53.4) sur macOS. Les étapes pour Windows et Linux ne sont pas testées.

Vous voulez savoir combien de temps une bouilloire est couverte. Vous ne lisez pas tout le manuel du magasin. Vous consultez l’index à la fin, trouvez « kettle » (bouilloire), et il indique les pages 12 et 48. Si « kettle » indiquait les pages 3, 5, 6, 8 et 11 à 90, l’index aiderait peu. Le mot est partout. Un mot qui apparaît sur deux pages vous renseigne beaucoup. Un mot qui apparaît sur chaque page ne vous renseigne pas.

Une recherche par mots-clés fonctionne de la même façon. Elle cherche les mots de la question, trouve les passages qui les contiennent et place les plus utiles en premier.

Dans ce module, l’unité de recherche est une section d’un document Markdown : le texte sous un titre ##. Les 32 documents Markdown comptent 154 sections. La recherche ne couvre pas encore les 2 documents HTML et les 2 documents PDF. Leur lecture est au programme du module 2.

Prenez la question de Grace « Which pump kit fits the PW-2200? » (Quel ensemble de pompe convient au PW-2200?). Comptez les sections qui contiennent chacun de ses mots :

Mot which pump kit fits the pw 2200
Sections (sur 154) 3 4 3 1 106 8 2

Essayez les deux recherches évidentes. « Sections contenant tous les mots » ne trouve rien : aucune section ne contient les sept mots. « Sections contenant au moins un des mots » trouve 108 sections, car « the » figure dans 106 d’entre elles. Aucune n’est utile. La recherche doit classer les sections : placer quelques bonnes sections en premier, puis les autres. Et une correspondance avec « fits » ou « 2200 » doit compter beaucoup plus qu’une correspondance avec « the ».

L’idée : compter les occurrences, donner plus de poids aux mots rares

Section intitulée « L’idée : compter les occurrences, donner plus de poids aux mots rares »

Deux idées simples donnent un bon classement.

  • Fréquence du terme. Une section qui utilise plus souvent un mot de la question a plus de chances de traiter du sujet. Toutefois, les points supplémentaires diminuent : un mot répété cinq fois ne vaut pas cinq fois une seule occurrence.
  • Rareté. Un mot présent dans peu de sections vaut plus. « Fits » (1 section) pointe vers un seul endroit; « the » (106 sections) pointe partout. Ce poids s’appelle la fréquence inverse des documents : moins de documents contiennent le mot, plus elle est élevée.

Une troisième idée, moins importante : une section courte contenant le mot est plus ciblée qu’une section longue. Elle reçoit donc un peu plus de points.

BM25 combine les trois. « BM » signifie « best matching » (meilleure correspondance). 25 est le numéro de la formule dans une série d’expériences de Stephen Robertson et ses collègues, dans les années 1990. C’est encore la méthode de classement standard pour la recherche par mots-clés. Chercher les mots exacts de la question s’appelle la recherche par mots-clés, ou recherche lexicale (« lexicale » signifie « qui concerne les mots »).

Voici huit sections courtes, inventées pour cet exemple. La question est « kettle refund » (remboursement de bouilloire).

Section Texte Mots
A Kettle notice: stop using the kettle. 6
B Kettle care: descale it every month. 6
C Refund timing: we send it in 2 days. 8
D Gift cards: a gift card has no refund. 8
E Price drops: ask for a refund of the difference. 9
F Delivery: parcels arrive in 3 to 5 days. 8
G Returns: return unused items within 30 days. 7
H Rewards: points expire after 18 months. 6

Étape 1, la rareté. « kettle » figure dans 2 des 8 sections, et « refund » dans 3. « kettle » est donc plus rare, et son poids est environ deux fois plus élevé : 0.96 contre 0.45. F, G et H ne contiennent aucun des deux mots, donc la recherche ne leur donne jamais de score.

Étape 2, la fréquence du terme. A contient « kettle » deux fois, et B une fois. Chaque section reçoit des points selon le nombre d’occurrences de chaque mot : une occurrence donne environ 1, deux donnent environ 1,4, pas 2.

Étape 3, multiplier et additionner. Pour chaque mot de la question, le score additionne rareté × points :

Section Occurrences de « kettle » Occurrences de « refund » Score
A 2 0 0.96 × 1.45 = 1.38
B 1 0 0.96 × 1.08 = 1.03
C 0 1 0.45 × 0.96 = 0.43
D 0 1 0.45 × 0.96 = 0.43
E 0 1 0.45 × 0.91 = 0.41
  • contient le mot rare kettle
  • contient seulement refund
Scores BM25 des cinq sections contenant un mot de la requête, calculés par SQLite. F, G et H ne contiennent aucun mot de la requête et ne reçoivent aucun score.
Description textuelle du graphique

Un diagramme à cinq colonnes.

  • A contient kettle deux fois et obtient 1,38.
  • B contient kettle une fois et obtient 1,03.
  • C contient refund une fois et obtient 0,43.
  • D contient refund une fois et obtient 0,43.
  • E contient refund une fois dans une section plus longue et obtient 0,41.

Lisez le diagramme de gauche à droite. A dépasse B parce qu’elle contient « kettle » deux fois, mais de 1,34 fois, pas de 2 fois. B dépasse C, D et E, car un mot rare vaut plus qu’un mot courant. Et E se trouve juste sous C et D : même nombre d’occurrences, mais E compte un mot de plus. Ce sont les trois idées de BM25, avec de vrais nombres.

La formule, pour les curieux

Pour une question contenant les mots ww, le score d’une section est :

score=∑wrarity(w)×f×(k1+1)f+k1×(1−b+b×lengthaverage length)\text{score} = \sum_{w} \text{rarity}(w) \times \frac{f \times (k_1 + 1)}{f + k_1 \times \left(1 - b + b \times \frac{\text{length}}{\text{average length}}\right)}
rarity(w)=ln⁡N−n+0.5n+0.5\text{rarity}(w) = \ln \frac{N - n + 0.5}{n + 0.5}

Ici, ff est le nombre d’occurrences du mot dans la section, et NN est le nombre de sections (8). nn est le nombre de sections contenant le mot. SQLite utilise k1=1.2k_1 = 1.2 et b=0.75b = 0.75. Pour « kettle », la rareté est ln(6,5 ÷ 2,5) = 0,96.

La section A compte 6 mots, et la moyenne est de 7,25. Ses points :

2×2.22+1.2×(0.25+0.75×6÷7.25)=1.45\frac{2 \times 2.2}{2 + 1.2 \times (0.25 + 0.75 \times 6 \div 7.25)} = 1.45

A obtient donc 0,96 × 1,45 = 1,38.

Un mot présent dans plus de la moitié des sections reçoit une rareté négative. SQLite la remplace par une valeur presque nulle. C’est pourquoi « the » ne compte presque pas.

Où la recherche s’exécute : la recherche plein texte SQLite

Section intitulée « Où la recherche s’exécute : la recherche plein texte SQLite »

Dans SQL et préparation des données pour l’IA, Mei conservait les billets de soutien de Larkfield dans SQLite. Cette base de données tient dans un seul fichier et s’utilise avec le module sqlite3 de Python. SQLite possède aussi FTS5, une table de recherche plein texte, incluse dans sqlite3 de Python. Il n’y a donc rien à installer.

Une table FTS5 conserve un index inversé : pour chaque mot, la liste des lignes qui le contiennent. Il fonctionne comme l’index à la fin d’un livre. Une recherche lit seulement les listes des mots de la question, puis classe ces lignes avec bm25().

À gauche, la requête kettle refund se divise en deux mots. kettle, présent dans 2 sections sur 8, avec une rareté de 0,96, pointe vers la section A (2 occurrences) et la section B (1 occurrence). refund, présent dans 3 sections sur 8, avec une rareté de 0,45, pointe vers les sections C, D et E (1 occurrence chacune). Une note en pointillés indique que les sections F, G et H ne contiennent aucun mot de la requête et ne reçoivent jamais de score.
L'index inversé de l'exemple détaillé. La recherche lit deux listes courtes, pas huit sections.
Description textuelle du diagramme

La requête « kettle refund » se divise en deux mots. Le mot « kettle » figure dans 2 sections sur 8, avec une rareté de 0,96. Il apparaît 2 fois dans la section A (avis sur les bouilloires) et 1 fois dans la section B (entretien des bouilloires). Le mot « refund » figure dans 3 sections sur 8, avec une rareté de 0,45. Il apparaît 1 fois dans C (délai de remboursement), D (cartes-cadeaux) et E (baisses de prix). Les sections F, G et H ne contiennent aucun mot de la requête, donc la recherche ne leur donne jamais de score.

FTS5 découpe d’abord le texte en mots avec un segmenteur en jetons. Celui du projet, unicode61 remove_diacritics 2, met chaque mot en minuscules et retire les accents. Ainsi, « prolongée » correspond à « prolongee ». Il découpe aussi « PW-2200 » en « pw » et « 2200 ». La question est découpée de la même façon, donc un code de produit trouve encore une correspondance. Il ne réduit pas les mots à une racine : « refunds » et « refund » sont deux mots différents.

Vous ajoutez quatre éléments au projet : une fonction qui découpe chaque document en sections, la recherche, un module de notation et deux commandes. À la fin, votre projet sera identique à l’instantané end-of-m01.

Ouvrez policy_assistant/documents.py. Ajoutez import re après import csv. Ajoutez ensuite ceci à la fin du fichier :

policy_assistant/documents.py (add at the end)
@dataclass(frozen=True)
class Section:
"""One section of a Markdown document: its heading and its text (the Module 1 search unit)."""
passage_id: str # doc_id@vVERSION#heading-slug, for example returns-policy@v3#return-window
doc_id: str
version: str
section: str
text: str
@property
def sections(self) -> tuple: # the same attribute as a chunk's, so evaluate.py can score both
return (self.section,)
def slug(text: str) -> str:
return re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
def sections(info: DocInfo) -> list[Section]:
"""Split a Markdown document at its '## ' headings. Each section starts with the document title."""
meta, body = read_markdown(info.path)
out, heading, lines = [], None, []
def close():
if heading is not None:
text = f"{info.title}\n## {heading}\n" + "\n".join(lines).strip()
out.append(Section(f"{info.doc_id}@v{info.version}#{slug(heading)}", info.doc_id, info.version, heading, text))
for line in body.splitlines():
if line.startswith("## "):
close()
heading, lines = line[3:].strip(), []
elif heading is not None:
lines.append(line)
close()
return out

Chaque section conserve l’identifiant de son document, sa version et son titre. Son identifiant réunit les trois, par exemple returns-policy@v3#return-window, pour qu’un résultat indique toujours sa provenance. Le titre du document vient en premier dans le texte : « Returns policy » (politique de retour) aide ainsi la recherche. Vérifiez-le :

Fenêtre de terminal
python -c "from policy_assistant.documents import load_inventory, sections; print(sum(len(sections(d)) for d in load_inventory() if d.format == 'md'))"
Sortie
154

Créez le fichier policy_assistant/lexical.py :

policy_assistant/lexical.py
"""Keyword search with SQLite full-text search (FTS5) and BM25.
FTS5 keeps an inverted index: for every word, the items that contain it. BM25 scores an item higher
when it contains the query's words often (term frequency, with diminishing returns), when those words
are rare in the collection (inverse document frequency), and when the item is short.
SQLite's bm25() returns lower numbers for better matches; `search` turns them round, so that a
higher score is better, as with every other search method in this project.
The tokenizer `unicode61 remove_diacritics 2` lowercases words and removes accents (é -> e), so a
French question matches French text written with or without accents. It splits "PW-2200" into "pw" and
"2200": the query is split the same way, so exact product codes still match.
"""
import re
import sqlite3
from dataclasses import dataclass
from typing import Callable, Iterable
@dataclass(frozen=True)
class Hit:
id: str
score: float
rank: int # 1 = best
def words(text: str) -> list[str]:
"""The query's words, lowercased, without duplicates, in order."""
return list(dict.fromkeys(w.lower() for w in re.findall(r"\w+", text)))
def fts_query(text: str) -> str:
"""'Which pump kit fits the PW-2200?' -> '"which" OR "pump" OR ... OR "pw" OR "2200"'.
Every word is quoted, so FTS5 never reads a word as an operator (AND, OR, NOT, NEAR, -).
OR means that an item needs only some of the words; BM25 ranks the items with more of them,
and with rarer ones, higher.
"""
return " OR ".join(f'"{w}"' for w in words(text))
class KeywordIndex:
"""An FTS5 table of (item ID, text) inside a SQLite database."""
TABLE = "keyword_index"
def __init__(self, conn: sqlite3.Connection):
self.conn = conn
conn.execute(f"CREATE VIRTUAL TABLE IF NOT EXISTS {self.TABLE} USING fts5("
"item_id UNINDEXED, text, tokenize = 'unicode61 remove_diacritics 2')")
def add(self, items: Iterable[tuple[str, str]]) -> None:
self.conn.executemany(f"INSERT INTO {self.TABLE} (item_id, text) VALUES (?, ?)", list(items))
def remove(self, ids: Iterable[str]) -> None:
self.conn.executemany(f"DELETE FROM {self.TABLE} WHERE item_id = ?", [(i,) for i in ids])
def search(self, query: str, k: int = 5, allowed: Callable[[str], bool] | None = None) -> list[Hit]:
"""The k best items for the query. `allowed(item_id)` removes items before the top k is taken."""
q = fts_query(query)
if not q:
return []
rows = self.conn.execute(
f"SELECT item_id, bm25({self.TABLE}) FROM {self.TABLE} WHERE {self.TABLE} MATCH ? "
f"ORDER BY bm25({self.TABLE}), item_id", (q,)).fetchall()
hits = [(item_id, -score) for item_id, score in rows if allowed is None or allowed(item_id)]
return [Hit(item_id, round(score, 4), rank) for rank, (item_id, score) in enumerate(hits[:k], 1)]
Élément Son rôle
CREATE VIRTUAL TABLE … USING fts5(…) Crée la table plein texte. item_id UNINDEXED conserve l’identifiant sans y faire de recherche.
fts_query() Entoure chaque mot de guillemets et les relie avec OR (ou), pour qu’un mot comme « not » ne soit jamais lu comme un opérateur
MATCH ? Trouve les lignes contenant au moins un des mots, grâce à l’index inversé
ORDER BY bm25(…) Les classe. Le bm25() de SQLite donne des nombres plus petits aux meilleures lignes, donc le code change le signe.
item_id dans ORDER BY Départage les égalités de la même façon chaque fois, pour que les résultats restent identiques entre les exécutions
allowed Retire des lignes avant de sélectionner les k premiers résultats. Le module 3 l’utilise pour les étiquettes d’accès.

Vérifiez avec l’exemple détaillé. Lancez Python avec python, puis saisissez :

Python
import sqlite3
from policy_assistant.lexical import KeywordIndex, fts_query
index = KeywordIndex(sqlite3.connect(":memory:"))
index.add([
("A", "Kettle notice: stop using the kettle."),
("B", "Kettle care: descale it every month."),
("C", "Refund timing: we send it in 2 days."),
("D", "Gift cards: a gift card has no refund."),
("E", "Price drops: ask for a refund of the difference."),
("F", "Delivery: parcels arrive in 3 to 5 days."),
("G", "Returns: return unused items within 30 days."),
("H", "Rewards: points expire after 18 months."),
])
fts_query("kettle refund")
for hit in index.search("kettle refund", k=8):
print(hit)
Sortie
'"kettle" OR "refund"'
Hit(id='A', score=1.3808, rank=1)
Hit(id='B', score=1.028, rank=2)
Hit(id='C', score=0.4336, rank=3)
Hit(id='D', score=0.4336, rank=4)
Hit(id='E', score=0.4114, rank=5)

Ce sont les scores de l’exemple détaillé. ":memory:" crée une base de données qui reste uniquement en mémoire, pour ce test. Saisissez exit() pour quitter Python.

La recherche a besoin de son propre score. L’idée est la plus simple possible. Pour chaque question à laquelle on peut répondre, la recherche a-t-elle placé un passage pertinent près du début?

La recherche renvoie ses meilleurs résultats en premier. Les k premiers résultats sont les k résultats en tête de liste. Larkfield utilise k = 5, car cinq passages courts tiennent facilement dans une requête (prompt). hit@k vaut 1 pour une question si au moins un de ses passages pertinents figure parmi les k premiers résultats. Il vaut 0 si aucun n’y figure. Sa moyenne sur toutes les questions auxquelles on peut répondre donne la proportion de questions dont les preuves ont atteint le modèle.

Un exemple détaillé avec quatre vraies questions et la recherche de cette leçon :

Question Rang de la première section pertinente hit@5
Q01, double facturation 1 1
Q22, retour le 1er décembre 2 1
Q05, points Rewards après deux ans 8 0
Q31, brûleurs de barbecue introuvable (la réponse est dans le PDF) 0
Q14, rabais étudiant aucun passage pertinent : non évaluée –

hit@5 = (1 + 1 + 0 + 0) ÷ 4 = 0,5. Q14 n’a aucun passage pertinent, donc elle ne peut pas compter. hit@k est la forme la plus simple du rappel de récupération : la part des preuves nécessaires que la recherche a trouvées. Le module 5 ajoute des mesures plus fines, fondées sur Le compromis entre précision et rappel du cours d’apprentissage automatique.

Créez le fichier policy_assistant/evaluate.py :

policy_assistant/evaluate.py
"""Measure retrieval against Grace's question set: the first answerability check.
A question lists its relevant passages (document, version, section). A search result is relevant when
it comes from one of them (same document and version, and it covers that section).
hit@k: 1 if at least one of the top k results is relevant, else 0. Its mean over the questions is the
share of answerable questions for which the evidence reached the top k: the number to beat.
Unanswerable questions have no relevant passage and are left out.
"""
import json
import statistics
from dataclasses import dataclass
from pathlib import Path
PROJECT = Path(__file__).resolve().parent.parent
QUESTIONS = PROJECT / "questions" / "questions.jsonl"
@dataclass(frozen=True)
class Question:
id: str
question: str
language: str
kind: str
as_of: str
public_ok: bool
abstain: bool
reference_answer: str
keys: list
must_not: list
relevant: list # [{"doc_id", "version", "section", "access"}]
relevant_chunks: list
wrong_version: list
tags: list
notes: str
def load_questions(path: Path = QUESTIONS) -> dict[str, Question]:
qs = [Question(**json.loads(line)) for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
return {q.id: q for q in qs}
def covers(result, passage: dict) -> bool:
return result.doc_id == passage["doc_id"] and result.version == passage["version"] and passage["section"] in result.sections
def retrieval_scores(q: Question, ranked: list, k: int) -> dict:
if not q.relevant:
return {"id": q.id, "kind": q.kind, "scored": False}
first = next((i for i, r in enumerate(ranked) if any(covers(r, p) for p in q.relevant)), None)
return {"id": q.id, "kind": q.kind, "scored": True, "hit": 1.0 if first is not None and first < k else 0.0,
"first_rank": None if first is None else first + 1}
def summarise(rows: list[dict]) -> dict:
scored = [r for r in rows if r["scored"]]
return {"questions": len(scored), "hit": round(statistics.fmean(r["hit"] for r in scored), 3) if scored else None}
def by_kind(rows: list[dict]) -> dict:
return {kind: summarise([r for r in rows if r["kind"] == kind]) for kind in sorted({r["kind"] for r in rows})}

covers() est la règle de correspondance : un résultat est pertinent si son document, sa version et sa section correspondent à un passage pertinent. La version compte : la version 4 de la politique de retour n’est pas pertinente pour une question sur une livraison d’octobre.

Remplacez tout le contenu de policy_assistant/__main__.py par cette version. Elle conserve inventory et questions, et ajoute search et eval :

policy_assistant/__main__.py
"""The command line of the policy assistant.
python -m policy_assistant inventory the documents, their versions, owners and access labels
python -m policy_assistant questions [--kind KIND] Grace's question set
python -m policy_assistant search "QUESTION" [--k 5] keyword search (BM25) over the sections of the Markdown documents
python -m policy_assistant eval [--k 5] hit@k on the question set: the baseline to beat
Module 1 searches the 32 Markdown documents, one section at a time. The 4 HTML and PDF documents are
not searched yet: parsing them is Module 2.
"""
import argparse
import sqlite3
import sys
from .documents import load_inventory, sections
from .lexical import KeywordIndex
def short(text: str, n: int = 90) -> str:
text = " ".join(text.split())
return text if len(text) <= n else text[: n - 1] + "…"
def cmd_inventory(args) -> None:
docs = load_inventory()
print(f"{'document':34} {'v':>2} {'from':10} {'to':10} {'access':6} {'lang':4} {'type':9} {'format':6} owner")
for d in docs:
print(f"{d.doc_id:34} {d.version:>2} {d.effective_from:10} {d.effective_to or '-':10} {d.access:6} "
f"{d.language:4} {d.doc_type:9} {d.format:6} {d.owner}")
print(f"{len(docs)} documents: {sum(d.access == 'staff' for d in docs)} staff-only, "
f"{sum(d.language == 'fr' for d in docs)} in French, {sum(d.format != 'md' for d in docs)} not Markdown")
def cmd_questions(args) -> None:
from .evaluate import load_questions
qs = [q for q in load_questions().values() if not args.kind or q.kind == args.kind]
for q in qs:
print(f"{q.id} {q.kind:15} {q.language} {q.as_of} {short(q.question, 80)}")
print(f"{len(qs)} questions")
def section_index():
"""An in-memory keyword index of every section of the Markdown documents."""
all_sections = [s for d in load_inventory() if d.format == "md" for s in sections(d)]
index = KeywordIndex(sqlite3.connect(":memory:"))
index.add((s.passage_id, s.text) for s in all_sections)
return index, {s.passage_id: s for s in all_sections}
def cmd_search(args) -> None:
index, by_id = section_index()
for hit in index.search(args.query, args.k):
s = by_id[hit.id]
print(f"{hit.rank}. {hit.score:>8.4f} {hit.id}")
print(f" {short(s.text.split(chr(10), 2)[-1], 100)}")
print(f"BM25 over {len(by_id)} sections of the Markdown documents, top {args.k}")
def cmd_eval(args) -> None:
from .evaluate import by_kind, load_questions, retrieval_scores, summarise
index, by_id = section_index()
rows = []
for q in load_questions().values():
ranked = [by_id[h.id] for h in index.search(q.question, args.k)]
rows.append(retrieval_scores(q, ranked, args.k))
s = summarise(rows)
print(f"BM25 over sections, top {args.k}: hit@{args.k} {s['hit']:.3f} ({s['questions']} questions with relevant passages)")
for kind, k in by_kind(rows).items():
if k["questions"]:
print(f" {kind:15} {k['questions']:>3} questions, hit@{args.k} {k['hit']:.3f}")
print(f"missed: {', '.join(r['id'] for r in rows if r['scored'] and not r['hit'])}")
def main(argv=None) -> None:
p = argparse.ArgumentParser(prog="python -m policy_assistant", description="Larkfield's policy assistant")
sub = p.add_subparsers(dest="command", required=True)
sub.add_parser("inventory")
s = sub.add_parser("questions"); s.add_argument("--kind")
s = sub.add_parser("search"); s.add_argument("query"); s.add_argument("--k", type=int, default=5)
s = sub.add_parser("eval"); s.add_argument("--k", type=int, default=5)
args = p.parse_args(argv)
{"inventory": cmd_inventory, "questions": cmd_questions, "search": cmd_search, "eval": cmd_eval}[args.command](args)
if __name__ == "__main__":
sys.exit(main())

L’index reste en mémoire et se reconstruit à chaque commande. Avec 154 sections courtes, cela prend peu de temps. Le module 2 conserve l’index dans un fichier.

Deux fichiers de test protègent ce que vous avez créé. Créez tests/test_lexical.py :

tests/test_lexical.py
import sqlite3
from policy_assistant.lexical import KeywordIndex, fts_query, words
def make(items):
index = KeywordIndex(sqlite3.connect(":memory:"))
index.add(items)
return index
def test_query_quotes_every_word_and_splits_codes():
assert words("Which pump kit fits the PW-2200?") == ["which", "pump", "kit", "fits", "the", "pw", "2200"]
assert fts_query("NOT a code") == '"not" OR "a" OR "code"' # NOT is a word here, not an operator
def test_bm25_prefers_the_rare_word():
index = make([("a", "the kettle and the toaster"), ("b", "the kettle"), ("c", "the toaster and the lamp")])
hits = index.search("kettle lamp", k=3)
# "lamp" occurs once in the collection, "kettle" twice: the item with the rare word wins.
assert hits[0].id == "c"
assert [h.rank for h in hits] == [1, 2, 3]
assert hits[0].score > hits[1].score
def test_exact_code_and_accents():
index = make([("pw", "Pump kit | PK-2200-07 | fits the PW-2200 only"), ("fr", "Larkfield ne vend pas de garantie prolongée.")])
assert index.search("PK-2200-07", k=1)[0].id == "pw"
assert index.search("garantie prolongee", k=1)[0].id == "fr" # accents removed by the tokenizer
def test_allowed_is_applied_before_the_top_k():
index = make([("staff", "goodwill credit 25 dollars"), ("public", "store credit never expires")])
hits = index.search("credit", k=1, allowed=lambda i: i == "public")
assert [h.id for h in hits] == ["public"]
def test_remove():
index = make([("x", "deleted text"), ("y", "kept text")])
index.remove(["x"])
assert [h.id for h in index.search("text", k=5)] == ["y"]

Et tests/test_baseline.py, qui fixe le score de cette leçon :

tests/test_baseline.py
"""Module 1's keyword baseline: BM25 over the sections of the Markdown documents. The number to beat."""
import sqlite3
from policy_assistant.documents import load_inventory, sections
from policy_assistant.evaluate import load_questions, retrieval_scores, summarise
from policy_assistant.lexical import KeywordIndex
def test_sections_of_a_document():
info = next(d for d in load_inventory() if d.doc_id == "returns-policy" and d.version == "3")
secs = sections(info)
assert [s.section for s in secs][:2] == ["Who this policy is for", "Return window"]
assert secs[1].passage_id == "returns-policy@v3#return-window"
assert secs[1].text.startswith("Returns policy\n## Return window\n")
def test_baseline_hit_at_5():
all_sections = [s for d in load_inventory() if d.format == "md" for s in sections(d)]
index = KeywordIndex(sqlite3.connect(":memory:"))
index.add((s.passage_id, s.text) for s in all_sections)
by_id = {s.passage_id: s for s in all_sections}
rows = [retrieval_scores(q, [by_id[h.id] for h in index.search(q.question, 5)], 5) for q in load_questions().values()]
assert len(all_sections) == 154
assert summarise(rows)["hit"] == 0.695 # 41 of 59 questions with relevant passages

Exécutez tous les tests :

Fenêtre de terminal
python -m pytest
Sortie (fin)
tests/test_baseline.py .. [ 18%]
tests/test_first_run.py .... [ 54%]
tests/test_lexical.py ..... [100%]
============================== 11 passed in 0.06s ==============================

Si vous voyez 11 passed (11 tests réussis), votre projet est identique à l’instantané end-of-m01.

Posez la question de Grace qui utilise un code exact :

Fenêtre de terminal
python -m policy_assistant search "Which pump kit fits the PW-2200?"
Sortie
1. 20.3759 product-care-pw2400@v1#parts-and-part-numbers
| Part | Part number | Notes | | --- | --- | --- | | Pump kit | PK-2400-03 | fits the PW-2400 only …
2. 18.9867 warranty-claims-procedure@v3#pressure-washer-pumps
- If the pump of a PW-2200 fails within 3 years, send the pump kit PK-2200-07 instead of a new pres…
3. 6.2832 product-care-pw2400@v1#winter-storage
Drain all water from the pump and hoses before temperatures fall below 0 °C. Store it indoors, abov…
4. 5.8360 product-care-pw2400@v1#using-it-safely
Never run the pressure washer for more than 2 minutes with the trigger released: the pump overheats…
5. 3.9398 refund-approval-procedure@v2#when-this-procedure-applies
Refunds for normal returns are approved automatically after inspection. Use this procedure for manu…
BM25 over 154 sections of the Markdown documents, top 5

Le premier résultat est le tableau des pièces du mauvais nettoyeur à pression : le PW-2400. Son tableau contient « pump », « kit », « fits », « pw » et aussi « 2200 », car un ensemble de buses convient aux deux modèles. Le guide du PW-2200 est un fichier PDF, donc cette recherche n’y a pas accès. Le deuxième résultat nomme bien PK-2200-07, mais c’est une procédure réservée au personnel qu’un client ne doit jamais voir. La recherche a fait son travail avec les mots disponibles; le bon document manquait. C’est une source manquante, et aucune formule de classement ne peut régler ce problème.

Mesurez maintenant le score de la recherche sur toutes les questions de Grace :

Fenêtre de terminal
python -m policy_assistant eval
Sortie
BM25 over sections, top 5: hit@5 0.695 (59 questions with relevant passages)
answerable 18 questions, hit@5 0.722
contradiction 4 questions, hit@5 1.000
exact-code 11 questions, hit@5 1.000
injection-bait 3 questions, hit@5 0.333
staff-only 7 questions, hit@5 1.000
table 8 questions, hit@5 0.125
version 8 questions, hit@5 0.500
missed: Q05, Q11, Q12, Q13, Q23, Q26, Q27, Q29, Q31, Q32, Q33, Q34, Q35, Q36, Q37, Q56, Q57, Q63

Pour 41 des 59 questions auxquelles on peut répondre, une section pertinente figurait parmi les 5 premiers résultats : hit@5 = 0.695. C’est la référence de base : le score de la méthode raisonnable la plus simple, que chaque modification suivante doit dépasser. Une nouvelle méthode qui ne dépasse pas 0,695 sur ces questions ne vaut pas son coût.

La référence de base par mots-clés selon le type de question : BM25 sur les 154 sections des 32 documents Markdown, 5 premiers résultats, sans filtres. La ligne pointillée indique le score sur l'ensemble des 59 questions.
Description textuelle du graphique

Sept lignes, sur une échelle de 0 à 1. Une ligne pointillée marque 0,695, le score sur l’ensemble des 59 questions.

  • Avec réponse : 13 sur 18, 0,722.
  • Contradiction : 4 sur 4, 1,000.
  • Code exact : 11 sur 11, 1,000.
  • Piège d’injection : 1 sur 3, 0,333.
  • Personnel seulement : 7 sur 7, 1,000.
  • Tableau : 1 sur 8, 0,125.
  • Version : 4 sur 8, 0,500.

La recherche par mots-clés est parfaite pour les codes exacts et les questions du personnel : leurs mots sont distinctifs. Elle est faible pour les tableaux et les versions. Pourquoi? Examinez les 18 échecs un par un, et la raison est presque toujours la même :

  • 14 échecs nécessitent un fichier HTML ou PDF : le tableau des garanties (PDF), le tableau des livraisons (HTML) ou la version 4 de la politique de retour (HTML). Ce sont des sources manquantes, comme pour le PW-2200.
  • 2 échecs utilisent d’autres mots : Q05 parle d’un client qui « has not bought anything » (n’a rien acheté) depuis deux ans. La politique parle de 18 mois sans achat. Q12 dit « died » (a cessé de fonctionner) et « delivered » (livré), et la politique dit « dies » (cesse de fonctionner) et « delivery » (livraison). Pour cette recherche, ce sont des mots différents. Les bonnes sections se trouvent aux rangs 8 et 6.
  • 2 échecs passent d’une langue à l’autre : Q13 est en anglais, et la seule réponse se trouve dans un document français. Q63 est en français, et la réponse est en anglais. La recherche par mots-clés associe les mots, pas les sens.

Examinez aussi un résultat réussi qui n’est pas tout à fait correct. Pour Q22, le premier résultat est l’avis des fêtes expiré, et la bonne section vient en deuxième. La recherche ne tient pas encore compte des dates; le module 3 ajoute un filtre de date.

Utilisez-la comme première recherche de tout assistant documentaire, et conservez-la. Elle est exacte pour les codes de produits, les numéros de pièces et les noms. Elle est aussi facile à expliquer. Sur cet ordinateur, une requête dans l’index final du cours prend environ 0,26 ms. Elle ne nécessite ni modèle, ni téléchargement, ni clé.

Ne vous fiez pas à elle seule quand les gens emploient d’autres mots que les documents, ou une autre langue. « Garden sprinkler » (arroseur de jardin) ne trouvera pas une ligne de tableau qui dit « Watering » (arrosage). Le module 3 ajoute la recherche par le sens pour ce problème et compare les deux sur les mêmes questions.

Son coût : presque rien en temps ou en argent. Le vrai coût, ce sont les échecs que vous ne voyez pas. Une recherche renvoie toujours cinq résultats, même si aucun n’est pertinent.

Erreur courante

Une base de données vectorielle avant une référence de base

Symptôme : Tomás dit : « La recherche par mots-clés date des années 1990. Commençons avec une base de données vectorielle, elle comprend le sens. » Deux semaines plus tard, l’assistant utilise un nouveau service de base de données. Omar demande : « Est-ce mieux qu’avant? » Personne ne peut le dire, parce qu’il n’y a pas d’« avant ».

Cause : l’équipe a créé une nouvelle méthode avant de mesurer la méthode la plus simple. Sans référence de base, il n’y a rien à comparer. L’équipe ne sait pas non plus d’où viennent les échecs. Ici, 14 des 18 échecs concernent des documents qu’aucune recherche ne lit encore. Une base de données vectorielle les manquerait aussi.

Solution :

  1. Créez d’abord la recherche la plus simple et mesurez-la sur des questions fixes : hit@5 de 0,695.
  2. Examinez les échecs et trouvez leur cause avant de choisir une solution.
  3. Changez une seule chose à la fois et mesurez-la de la même façon.

Ce que cela signifie pour un vrai service d’IA : une référence de base permet de vérifier chaque décision suivante à faible coût. Le module 3 mesure la recherche par le sens par rapport à cette référence de base. La recherche par mots-clés y gagne encore pour certaines questions.

Faites d’abord une prédiction : si l’assistant prenait les 10 premières sections plutôt que les 5 premières, lesquels des 18 échecs deviendraient des réussites? Exécutez ensuite :

Fenêtre de terminal
python -m policy_assistant eval --k 10
Afficher la réponse
Sortie
BM25 over sections, top 10: hit@10 0.729 (59 questions with relevant passages)
answerable 18 questions, hit@10 0.833
contradiction 4 questions, hit@10 1.000
exact-code 11 questions, hit@10 1.000
injection-bait 3 questions, hit@10 0.333
staff-only 7 questions, hit@10 1.000
table 8 questions, hit@10 0.125
version 8 questions, hit@10 0.500
missed: Q11, Q13, Q23, Q26, Q27, Q29, Q31, Q32, Q33, Q34, Q35, Q36, Q37, Q56, Q57, Q63

Seules Q05 et Q12 deviennent des réussites : leurs sections étaient aux rangs 8 et 6. Les 14 sources manquantes et les 2 échecs liés à la langue restent. Un k plus grand ne peut pas trouver un document que la recherche n’a pas. Deux fois plus de passages allongent chaque requête (prompt). Pour corriger la plupart des échecs, il faut lire les fichiers HTML et PDF, au module 2.

Exemple détaillé : l’inventaire et les questions de Grace

Section intitulée « Exemple détaillé : l’inventaire et les questions de Grace »

Les deux fichiers de Grace servent de modèle pour votre exercice. Chaque document occupe une ligne de corpus/inventory.csv :

corpus/inventory.csv (the header and one row)
file,doc_id,title,version,effective_from,effective_to,owner,access,language,doc_type,format
documents/returns-policy.v3.md,returns-policy,Returns policy,3,2025-03-01,2026-10-31,Support (Grace),public,en,policy,md

Chaque question indique son type, sa date, sa réponse et l’endroit où se trouve la réponse. Pour trois de ses questions, Grace a écrit ceci, puis a vérifié chacune avec search (recherche) :

Question Type Passage pertinent Réponse Trouvé par search?
« Un client a été facturé deux fois pour la même commande. Dans quel délai remboursons-nous le montant en trop? » avec réponse refunds-and-payments v3, Double charges (doubles facturations) Dans les 3 jours ouvrables après que Larkfield confirme la double facturation. Oui, rang 1
« Quel ensemble de pompe convient au PW-2200? » code exact product-care-pw2200 v2 (PDF), Parts and part numbers (pièces et numéros de pièces) PK-2200-07 Seulement la procédure du personnel; le guide est un PDF
« Offrons-nous un rabais étudiant? » sans réponse aucun Les documents ne le précisent pas. Cinq résultats, aucun pertinent

La dernière ligne est importante. La recherche renvoie cinq sections pour toute question, donc une liste de résultats ne prouve rien. Seule une personne qui connaît les documents peut dire qu’aucune section ne répond à la question.

Votre tâche : un inventaire du corpus et quinze questions

Section intitulée « Votre tâche : un inventaire du corpus et quinze questions »

Choisissez une petite collection que vous connaissez : 5 à 10 documents de votre travail, d’un club, d’un centre d’aide public ou de Larkfield.

  1. Créez un inventaire, my_inventory.csv, avec les colonnes du fichier de Grace. Attribuez à chaque document un responsable, une version, une date d’entrée en vigueur et une étiquette d’accès. Si vous ne connaissez pas une valeur, écrivez unknown (inconnu), sans deviner.
  2. Écrivez quinze questions représentatives dans my_questions.md. Utilisez les mots des vrais utilisateurs, pas ceux des documents.
  3. Variez les types. Écrivez au moins :
    • 3 questions auxquelles les documents ne peuvent pas répondre,
    • 2 qui dépendent d’une date ou d’une version,
    • 2 avec un code ou un nom,
    • 1 dont la réponse est dans un tableau.
  4. Pour chaque question, écrivez la réponse, ainsi que le document et la section qui la contiennent. Pour une question sans réponse, écrivez « none » (aucun).
  5. Si vous avez utilisé la collection de Larkfield, exécutez python -m policy_assistant search "…" pour chaque question. Notez si la section pertinente figure parmi les 5 premières.
Afficher un exemple de réponse (Larkfield, cinq questions sur quinze)
Question Type Passage pertinent Réponse Parmi les 5 premiers?
« Combien de temps conservons-nous l’historique des commandes d’un client? » avec réponse privacy-policy v3, How long we keep it (durée de conservation) Commandes et factures : 7 ans, comme l’exige la loi fiscale. Oui, rang 1
« Un client peut-il retourner un sac de terre ouvert? » avec réponse returns-policy v3, Items that cannot be returned (articles non retournables) Non : les sacs ouverts de terre, d’engrais ou de produits chimiques de jardin ne peuvent pas être retournés. Oui, rang 1
« Une commande est arrivée le 2 novembre 2026. Un membre Rewards paie-t-il l’étiquette de retour? » version returns-policy v4 (HTML), Return shipping costs (frais d’expédition de retour) Non : depuis la version 4, les membres Rewards reçoivent une étiquette de retour gratuite. Non : la version 4 est un fichier HTML
« Égalons-nous le prix d’un autre magasin? » avec réponse refunds-and-payments v3, Price adjustments (ajustements de prix); help-centre-faq v7, Do you match other shops’ prices? (égalez-vous les prix des autres magasins?) Non. Si Larkfield réduit son propre prix dans les 14 jours après la livraison, le client peut demander la différence. Oui, rangs 1 et 2
« Offrons-nous l’emballage cadeau? » sans réponse aucun Les documents ne le précisent pas. Cinq résultats (l’offre de bouilloire, les cartes-cadeaux, les paiements, Rewards), aucun pertinent

Un bon ensemble contient des questions où la recherche échoue pour une raison connue, et des questions sans aucune réponse. Un ensemble où la recherche réussit à 100 % est trop facile pour vous apprendre quoi que ce soit.

Vérifiez votre compréhension

Récupération : BM25 classe les sections selon la fréquence et la rareté des termes. Que favorise chaque mesure, et pourquoi le mot « the » compte-t-il si peu?

Afficher la réponse

La fréquence des termes favorise une section qui utilise plus souvent un mot de la question. Le gain diminue : deux occurrences valent environ 1,4 fois une occurrence, pas 2 fois. La rareté (fréquence documentaire inverse) favorise les mots présents dans peu de sections, car ils désignent un petit nombre d’endroits. « The » (le, la ou les) figure dans 106 des 154 sections, soit plus de la moitié. Sa rareté est donc d’environ 0 et il n’ajoute presque rien aux scores. Les sections plus courtes reçoivent aussi un léger avantage.

Vérifiez votre compréhension

Prédiction : vous ajoutez le guide PW-2200 (actuellement un PDF) à la recherche sous forme de fichier Markdown. Que deviennent les résultats pour « Which pump kit fits the PW-2200? » (quel ensemble de pompe convient au PW-2200?) et hit@5 pour les questions à code exact?

Afficher la réponse

Sa section Parts and part numbers contient les mots « pump » (pompe), « kit » (ensemble), « fits » (convient), « pw » et « 2200 ». Dans un test, elle s’est classée première (19,24), devant le tableau PW-2400 (17,21). Une réponse destinée aux clients pourrait alors venir d’un passage public. hit@5 pour les questions à code exact ne peut pas augmenter : il vaut déjà 1,000, car la procédure du personnel comptait comme pertinente. Cela montre que hit@5 ne voit pas tout : une réussite peut quand même être le mauvais passage pour le public visé. Le module 2 lit le PDF, et le module 3 ajoute le filtre d’accès.

Vérifiez votre compréhension

Vérification finale : auxquelles de ces questions les documents de Larkfield ne peuvent-ils pas répondre? Pour chacune avec réponse, nommez le document. (1) Les clients peuvent-ils ramasser une commande en ligne dans un magasin Larkfield? (2) Larkfield vend-elle des garanties prolongées? (3) Quelle est la limite de poids du chariot AquaFlow HR-50? (4) Combien de temps la pompe d'un PW-2200 est-elle couverte? (5) Un client peut-il retourner une carte-cadeau? (6) Offrons-nous un rabais étudiant?

Afficher la réponse

Les documents ne peuvent pas répondre à (1) le ramassage en magasin, (3) la limite de poids du HR-50 et (6) un rabais étudiant : aucun document ne les précise. La recherche renvoie quand même cinq sections pour chacune. Pour (1), elles parlent de crédit en magasin et d’installation. Pour (3), elles viennent de la fiche du fournisseur AquaFlow. Pour (6), elles parlent du rabais du personnel et des codes de rabais. Aucune ne répond à la question. Les autres questions ont une réponse. (2) Non : la seule mention figure dans le document français garantie-legale-quebec, section Garantie prolongée (« Nous ne vendons pas de garantie prolongée »). Une recherche par mots-clés en anglais la manque donc. (4) 3 ans : le tableau des garanties dans warranty-policy (PDF) et la procédure du personnel warranty-claims-procedure. (5) Non : la politique de retour, version 3, inclut les cartes-cadeaux parmi les articles non retournables. La politique des cartes-cadeaux dit la même chose.

Récapitulatif

Dans ce module, vous avez défini le problème de connaissances avant de le résoudre :

  • La RAG récupère des passages, les place dans la requête et demande des réponses avec citations, ou « les documents ne le précisent pas ». Seuls, les modèles refusaient surtout de répondre et donnaient parfois des réponses erronées. Avec cinq passages, chat-small a répondu correctement à 51 questions sur 59.
  • Le corpus et son inventaire précisent quels documents comptent, avec responsables, versions, dates d’entrée en vigueur et étiquettes d’accès. Les données de test de Grace comprennent 67 questions, dont 8 sans réponse.
  • La recherche par mots-clés (recherche lexicale) avec BM25 classe les sections selon la fréquence et la rareté des termes. FTS5 de SQLite conserve un index inversé, dans Python.
  • hit@5 vaut 1 lorsqu’un passage pertinent figure dans le top k = 5. La référence de base : hit@5 de 0,695 (41 sur 59), sur les 154 sections des 32 documents Markdown.
  • 14 des 18 échecs viennent de sources manquantes : les fichiers HTML et PDF. Un k plus grand ou une nouvelle méthode de recherche ne peut pas corriger cela.

Ensuite, vous lirez les documents HTML et PDF et verrez ce que l’extraction de texte perd en chemin.

Leçon suivante : Analyser et normaliser