Écrire une recherche vectorielle de zéro
Écrire avec numpy une recherche vectorielle de quelques dizaines de lignes – transformer les morceaux de documentation en matrice de vecteurs, calculer la similarité à la requête et garder les premiers. Puis l'évaluer sur 20 questions – seule la moitié trouve le bon passage – et voir d'où vient le problème.
- Environ 45 minutes
- Niveau : Intermédiaire
- Testé : 2026-09-14 bge-small-zh-v1.5, multilingual-e5-small
Le code et les sorties des programmes sont reproduits tels qu’ils ont tourné : commentaires et sorties sont donc en chinois.
La leçon 5 du module 01 a présenté les embeddings : des textes de sens proche ont des vecteurs proches. La leçon précédente a découpé la documentation de httpx en 196 morceaux. En combinant les deux, on obtient un moteur de recherche sémantique : calculer à l'avance le vecteur de chaque morceau, calculer celui de la question de l'utilisateur, et trouver les morceaux les plus proches.
Beaucoup de tutoriels vous font directement installer une base de données vectorielle. Cette leçon s'en passe pour l'instant : on en écrit une avec numpy, quelques dizaines de lignes en tout. Vous verrez que le cœur d'une recherche vectorielle n'est qu'un produit matriciel. Ensuite, on l'évalue sur 20 questions pour voir si elle est vraiment utile.
Construire l'index
import numpy as np
from sentence_transformers import SentenceTransformer
class VectorIndex:
def __init__(self, model_name):
self.model = SentenceTransformer(model_name)
# e5 系列要求给查询和文档分别加上前缀,这是它训练时的约定
self.q_prefix, self.d_prefix = ("query: ", "passage: ") if "e5" in model_name else ("", "")
self.chunks = [] # [(文件名, 文本), ...]
self.matrix = None # 每一行是一个块的向量
def build(self, chunks):
self.chunks = chunks
texts = [self.d_prefix + text for _, text in chunks]
self.matrix = self.model.encode(texts, normalize_embeddings=True, batch_size=32)
build confie tous les morceaux d'un coup au modèle d'embedding et obtient une matrice : 196 morceaux, chacun un vecteur de 512 dimensions, soit une matrice de 196 lignes et 512 colonnes. normalize_embeddings=True ramène la longueur de chaque vecteur à 1, si bien que le calcul de similarité ne demande ensuite qu'un produit scalaire.
Pour chaque morceau, on note aussi de quel fichier il vient : cela servira à juger si la recherche est juste, et la leçon 5 s'en servira pour indiquer à l'utilisateur d'où vient la réponse.
La recherche
def search(self, query, k=5):
q = self.model.encode([self.q_prefix + query], normalize_embeddings=True)[0]
scores = self.matrix @ q # 向量都归一化过了,点积就是余弦相似度
top = np.argsort(-scores)[:k]
return [(float(scores[i]), *self.chunks[i]) for i in top]
self.matrix @ q est un produit matrice-vecteur : 196 lignes, chacune faisant un produit scalaire avec le vecteur de la question, pour calculer d'un coup la similarité entre la question et tous les morceaux. np.argsort(-scores) trie par similarité décroissante, et on garde les k premiers.
Voilà une recherche vectorielle complète. Essayons :
BAAI/bge-small-zh-v1.5:196 个块,向量 (196, 512),建索引用了 15.7 秒
示例:「怎么关闭 SSL 证书校验?」最相似的块来自 advanced/ssl.md,相似度 0.628
### Enabling and disabling verification
By default httpx will verify HTTPS connections, and raise an error for invalid SSL cases...
Une question en chinois a trouvé, dans la documentation anglaise, la section sur la vérification des certificats. La construction de l'index a pris 15,7 secondes, surtout pour le premier chargement du modèle et le calcul des 196 vecteurs. La recherche elle-même ne prend presque aucun temps.
Comment savoir si elle est bonne
Trouver juste sur une question ne prouve rien. Pour évaluer de façon systématique, il faut un ensemble de questions avec réponse type.
J'ai préparé 20 questions en chinois, dans code/04-rag/eval_qa.jsonl. Pour chacune, on a noté dans quel fichier doit se trouver la réponse, et un mot-clé : un morceau récupéré ne compte comme juste que s'il vient de ce fichier et contient ce mot-clé.
{"question": "怎么把超时完全关掉,让请求一直等下去?", "file": "advanced/timeouts.md", "keyword": "timeout=None"}
{"question": "服务器要求 Digest 认证怎么办?", "file": "advanced/authentication.md", "keyword": "DigestAuth"}
{"question": "有些域名不想走代理,环境变量怎么设置?", "file": "environment_variables.md", "keyword": "NO_PROXY"}
……
On juge avec « fichier + mot-clé » plutôt qu'avec le « numéro du morceau », parce que dès que la méthode de découpage change, tous les numéros changent, alors que fichier et mot-clé restent. On peut ainsi évaluer un autre découpage avec le même jeu de questions. J'ai vérifié par script que chaque mot-clé apparaît bien dans le fichier correspondant.
On calcule ensuite quelques indicateurs :
- Taux de réussite au 1er rang : proportion de questions dont le morceau classé premier est le bon.
- Taux de réussite dans les 3 et 5 premiers : proportion de questions dont le bon morceau figure parmi les premiers. Un RAG transmet généralement plusieurs morceaux au modèle, donc cet indicateur compte davantage.
- MRR (rang réciproque moyen) : 1 point si le bon morceau est 1er, 1/2 s'il est 2e, 1/3 s'il est 3e, 0 s'il n'est pas trouvé, moyenne sur toutes les questions. Il reflète à la fois « trouvé ou non » et « classé plus ou moins haut ».
def evaluate(search, questions, k=5):
"""返回第 1 名命中率、前 3 名命中率、前 5 名命中率、MRR,以及没找到的题。"""
ranks, misses = [], []
for qa in questions:
results = search(qa["question"], k)
rank = next((i + 1 for i, r in enumerate(results) if is_hit(r, qa)), None)
ranks.append(rank)
if rank is None:
misses.append((qa, results[0]))
n = len(questions)
hit = lambda top: sum(1 for r in ranks if r and r <= top) / n
mrr = sum(1 / r for r in ranks if r) / n
return hit(1), hit(3), hit(5), mrr, misses
evaluate reçoit une fonction de recherche, et non un index précis. La recherche par mots-clés et la recherche hybride de la leçon suivante peuvent être évaluées de la même façon, et leurs résultats comparés directement.
Résultat : seulement la moitié
20 道题:第 1 名命中 20%,前 3 名命中 40%,前 5 名命中 50%,MRR 0.303
没找到:怎么知道一个响应实际用的是 HTTP/1.1 还是 HTTP/2?(应在 http2.md)→ 第 1 名是 advanced/clients.md:!!! hint
没找到:服务器要求 Digest 认证怎么办?(应在 advanced/authentication.md)→ 第 1 名是 advanced/ssl.md:### Enabling and disabling verification
没找到:怎么让请求走 HTTP 代理?(应在 advanced/proxies.md)→ 第 1 名是 advanced/clients.md:!!! hint
没找到:下载很大的文件时,怎么一块一块地读,而不是一次读进内存?(应在 quickstart.md)→ 第 1 名是 advanced/clients.md:## Multipart file encoding
没找到:响应是 404 或 500 时,怎么让它直接抛异常?(应在 quickstart.md)→ 第 1 名是 advanced/timeouts.md:HTTPX is careful to enforce timeouts eve
没找到:异步发请求应该用哪个类?(应在 async.md)→ 第 1 名是 async.md:# Async Support
没找到:httpx 和 requests 在处理重定向上有什么不一样?(应在 compatibility.md)→ 第 1 名是 advanced/clients.md:!!! hint
没找到:写测试时,怎么不真的发网络请求,而是返回一个假的响应?(应在 advanced/transports.md)→ 第 1 名是 advanced/clients.md:!!! hint
没找到:想在每个请求发出之前和收到响应之后都执行一段代码,比如打日志,怎么做?(应在 advanced/event-hooks.md)→ 第 1 名是 advanced/timeouts.md:HTTPX is careful to enforce timeouts eve
没找到:有些域名不想走代理,环境变量怎么设置?(应在 environment_variables.md)→ 第 1 名是 advanced/clients.md:!!! hint
Sur 20 questions, le bon morceau n'apparaît dans les 5 premiers que pour la moitié. Dans un RAG, cela voudrait dire que pour la moitié des questions, le modèle reçoit des documents sans rapport.
Essayons un autre modèle d'embedding. intfloat/multilingual-e5-small est entraîné spécifiquement pour de nombreuses langues :
python code/04-rag/vector_search.py intfloat/multilingual-e5-small
20 道题:第 1 名命中 30%,前 3 名命中 50%,前 5 名命中 65%,MRR 0.418
Un peu mieux : le taux de réussite dans les 5 premiers passe de 50 % à 65 %, mais ce n'est toujours pas satisfaisant.
D'où vient le problème
Les langues différentes. Les questions sont en chinois, la documentation en anglais. bge-small-zh-v1.5 est surtout entraîné pour le chinois et comprend mal l'anglais. multilingual-e5-small est entraîné pour plusieurs langues, d'où le mieux. Mais placer des questions chinoises et une documentation anglaise dans le même espace vectoriel est intrinsèquement plus difficile qu'au sein d'une seule langue.
Le « morceau passe-partout ». En regardant de près les questions ratées, le même morceau revient sans cesse en 1re place : le !!! hint de advanced/clients.md. Son texte complet :
!!! hint
If you are coming from Requests, `httpx.Client()` is what you can use instead of `requests.Session()`.
Seulement 115 caractères, où figurent à la fois httpx, requests et Client, et qui parle de « comment utiliser httpx » en général. Pour le modèle d'embedding, il ressemble un peu à presque n'importe quelle question « comment faire … avec httpx ». Et comme il est court, aucun autre contenu ne dilue cette « pertinence vague » : il arrive donc premier sur beaucoup de questions. Avec e5, ce rôle est tenu par le début de logging.md, lui aussi un passage général sur httpx.
Ce phénomène est fréquent : les morceaux très courts et généraux ont tendance à monopoliser le haut du classement en recherche vectorielle. On peut fusionner les morceaux trop courts au découpage, ou compenser avec la méthode de la leçon suivante.
Le bon sens, mais pas la précision. Pour « le serveur exige une authentification Digest, que faire ? », le premier résultat est le morceau sur la vérification des certificats SSL. Pour le modèle d'embedding, « authentification » et « vérification de certificat » relèvent toutes deux de « sécurité, vérification d'identité » : des sens assez proches. Mais l'utilisateur demande une méthode d'authentification précise, Digest, et c'est ce mot-là qui compte. La leçon 5 du module 01 a vu le même problème : l'embedding ne distingue pas httpx de requests. Les vecteurs saisissent bien l'idée générale, mais visent mal un nom précis.
La règle d'évaluation a aussi ses limites. Pour « quelle classe utiliser pour envoyer des requêtes asynchrones ? », le premier résultat est bien async.md, mais ce morceau est le début du document, où le mot AsyncClient n'apparaît justement pas ; la règle le juge donc raté. Faut-il le compter comme trouvé ? On peut en discuter. Plus la règle d'évaluation est stricte, plus le score est bas, mais plus il est crédible.
La leçon 6 traite l'évaluation de façon plus systématique ; la leçon suivante s'attaque d'abord à la correspondance exacte et au problème des langues différentes.
Quand faut-il une base de données vectorielle
Nos 196 vecteurs tiennent dans un tableau numpy de moins de 1 Mo, et chaque recherche est un produit matriciel instantané.
Une base de données vectorielle dédiée (par exemple Chroma, Qdrant, Milvus, ou l'extension pgvector de PostgreSQL) ne vaut la peine qu'avec des centaines de milliers ou des millions de vecteurs, ou quand on a besoin de :
- Gros volumes. Comparer un par un des millions de vecteurs est trop lent ; ces bases utilisent des algorithmes de plus proches voisins approximatifs (ANN), qui sacrifient un tout petit peu de précision pour une bien plus grande vitesse.
- Persistance et mises à jour incrémentales. Quand les documents sont sans cesse ajoutés, modifiés ou supprimés, reconstruire toute la matrice à chaque fois n'est pas réaliste.
- Filtrage par conditions. Par exemple « ne chercher que dans la documentation de la version 2.0 », « ne chercher que dans les documents que cet utilisateur a le droit de voir ».
Pour quelques milliers ou dizaines de milliers de morceaux, numpy suffit : enregistrez la matrice dans un fichier avec np.save et rechargez-la directement la fois suivante, sans recalculer les vecteurs. Commencez par le plus simple, et changez quand vous rencontrerez vraiment les problèmes ci-dessus.
Exercices
- Dans
vector_search.py, enregistrez la matrice construite avecnp.save, et au démarrage suivant chargez-la directement si le fichier existe ; comparez les deux temps de démarrage. - Modifiez la fonction de découpage pour jeter ou fusionner les morceaux de moins de 200 caractères, et relancez l'évaluation. Le problème du morceau passe-partout s'atténue-t-il ? De combien change le taux de réussite dans les 5 premiers ?
- Ajoutez à
eval_qa.jsonl5 questions de votre cru, en vérifiant pour chacune dans la documentation l'emplacement de la réponse et le mot-clé.
Auto-test
1. Pourquoi, une fois tous les vecteurs normalisés, un produit scalaire suffit-il à calculer la similarité cosinus ?
La similarité cosinus est le produit scalaire de deux vecteurs divisé par le produit de leurs longueurs. Après normalisation, chaque vecteur a une longueur de 1, le dénominateur vaut 1, et le produit scalaire est directement la similarité cosinus. Toute la recherche ne demande ainsi qu'un produit matriciel.
2. Pour évaluer la recherche, pourquoi juger la réussite avec « fichier + mot-clé » plutôt qu'avec le numéro du morceau ?
Les numéros de morceaux dépendent de la méthode de découpage ; en changeant de découpage, ils changent tous. Le nom de fichier et le mot-clé ne dépendent pas du découpage : le même jeu de questions permet de comparer différentes méthodes de découpage et de recherche.
3. Qu'est-ce qu'un « morceau passe-partout », et pourquoi se classe-t-il si haut en recherche vectorielle ?
Des morceaux très courts et très généraux, comme une astuce du type « utilisez httpx.Client() à la place de requests.Session() ». Ils sont un peu pertinents pour beaucoup de questions, sans autre contenu pour diluer cette pertinence ; ils arrivent donc en tête pour un grand nombre de questions différentes et évincent les morceaux vraiment pertinents.
Questions et discussion
Bloqué sur cette leçon ? Posez votre question ici. Et si vous pouvez répondre à quelqu'un, n'hésitez pas.
Une question rapporte 3 points, une réponse 6. Les messages paraissent après vérification.
Chargement de la discussion…