Module 04 · Leçon 4

Recherche hybride et reranking

Écrire de zéro une recherche par mots-clés BM25, la fusionner avec la recherche vectorielle par RRF, réécrire d'abord la question chinoise en termes de recherche anglais, et ajouter enfin un modèle de reranking. Chaque étape est mesurée sur le même jeu de 20 questions.

  • Environ 50 minutes
  • Niveau : Intermédiaire
  • Testé : 2026-09-14 deepseek-flash, multilingual-e5-small, bge-reranker-base

Le code et les sorties des programmes sont reproduits tels qu’ils ont tourné : commentaires et sorties sont donc en chinois.

La recherche vectorielle de la leçon précédente n'a trouvé juste qu'un peu plus de la moitié des 20 questions. Les problèmes se ramènent à deux : elle est peu sensible aux noms précis comme « Digest » ou « NO_PROXY » ; et faire correspondre des questions chinoises à une documentation anglaise est intrinsèquement difficile.

Cette leçon ajoute trois choses, l'une après l'autre : la recherche par mots-clés, la réécriture de requête, le reranking. À chaque ajout, on fait passer le même jeu de 20 questions avec l'evaluate de la leçon précédente, pour voir de combien les chiffres changent. La conclusion d'abord : le plus grand gain vient d'un endroit auquel vous ne vous attendez peut-être pas.

La recherche par mots-clés : BM25

Avant que la recherche vectorielle ne se répande, les moteurs de recherche utilisaient la recherche par mots-clés : plus les mots de la question apparaissent souvent et de façon concentrée dans un document, plus son score est élevé. L'algorithme le plus utilisé s'appelle BM25. Il est plus malin que « compter les occurrences » sur deux points :

  • Les mots rares comptent davantage. « the » est presque dans chaque morceau, sa présence ne dit rien ; « DigestAuth » n'apparaît que dans un ou deux morceaux, et une correspondance est un signal fort. Ce poids s'appelle l'IDF (fréquence documentaire inverse).
  • L'effet de la fréquence est plafonné. Un mot qui apparaît 10 fois dans un morceau ne le rend pas 5 fois plus pertinent qu'avec 2 occurrences. BM25 fait croître le score de plus en plus lentement avec la fréquence. Il pénalise aussi les morceaux particulièrement longs, où toutes sortes de mots apparaissent plus facilement.

Écrivons-le de zéro :

import math
import re
from collections import Counter


def tokenize(text):
    # 英文单词和代码标识符按单词切(转成小写),中文按单个汉字切
    return re.findall(r"[a-z0-9_]+|[一-鿿]", text.lower())


class BM25:
    def __init__(self, chunks, k1=1.5, b=0.75):
        self.chunks = chunks
        self.docs = [tokenize(text) for _, text in chunks]
        self.avg_len = sum(len(d) for d in self.docs) / len(self.docs)
        self.tf = [Counter(d) for d in self.docs]
        df = Counter(word for d in self.docs for word in set(d))
        n = len(self.docs)
        # 越少的块里出现的词,越能说明问题,权重越高
        self.idf = {w: math.log(1 + (n - c + 0.5) / (c + 0.5)) for w, c in df.items()}
        self.k1, self.b = k1, b

    def score(self, query_words, i):
        tf, length = self.tf[i], len(self.docs[i])
        s = 0.0
        for w in query_words:
            if w in tf:
                # 词频越高分越高,但增长越来越慢;块越长,同样的词频得分越低
                s += self.idf[w] * tf[w] * (self.k1 + 1) / (tf[w] + self.k1 * (1 - self.b + self.b * length / self.avg_len))
        return s

    def search(self, query, k=5):
        words = tokenize(query)
        scores = [(self.score(words, i), i) for i in range(len(self.docs))]
        scores.sort(reverse=True)
        return [(s, *self.chunks[i]) for s, i in scores[:k]]

k1 règle la vitesse à laquelle l'effet de la fréquence sature, b la force de la pénalité pour les morceaux longs ; 1,5 et 0,75 sont les valeurs par défaut habituelles. Cette implémentation recalcule le score de tous les morceaux à chaque fois, ce qui ne pose aucun problème avec 196 morceaux ; avec des centaines de milliers, il faut un index inversé pour ne calculer que les morceaux contenant les mots de la requête, ce que font les moteurs comme Elasticsearch. Il existe aussi des implémentations Python toutes faites, comme le paquet rank_bm25.

Chercher directement avec la question chinoise : très mauvais

BM25(原问题)      第 1 名  15%  前 3 名  30%  前 5 名  35%  MRR 0.229

Bien pire que la recherche vectorielle (65 % dans les 5 premiers). La raison est simple : la question est en chinois, la documentation en anglais, les mots ne correspondent pas du tout. Dans « 服务器要求 Digest 认证怎么办? » (le serveur exige une authentification Digest, que faire ?), seul le mot anglais « digest » peut correspondre ; aucun des caractères chinois n'existe dans la documentation anglaise.

La réécriture de requête

Puisque les mots ne correspondent pas, faisons-les correspondre : demandons d'abord au grand modèle de réécrire la question chinoise en termes de recherche anglais, puis cherchons avec eux.

def rewrite(question):
    """把中文问题改写成英文检索词。httpx 的文档是英文的,这样关键词才能对上。"""
    if question not in rewrites:
        response = client.chat.completions.create(
            model=MODEL,
            messages=[{"role": "user", "content": (
                "把下面这个关于 Python 库 httpx 的问题,改写成用于搜索 httpx 英文文档的检索词。"
                "输出一行英文,包含问题的英文翻译,以及文档里可能出现的参数名、类名、术语。不要解释。\n\n" + question)}],
            extra_body={"thinking": {"type": "disabled"}},
        )
        rewrites[question] = response.choices[0].message.content.strip()
        CACHE.write_text(json.dumps(rewrites, ensure_ascii=False, indent=2))
    return rewrites[question]

En plus de « traduis en anglais », le prompt demande « les noms de paramètres, noms de classes et termes susceptibles d'apparaître dans la documentation ». Un exemple de réécriture :

服务器要求 Digest 认证怎么办? → httpx Digest authentication server requires digest auth how to use DigestAuth parameter class terms

Le modèle ne s'est pas contenté de traduire : il a deviné le nom de la classe httpx correspondante, DigestAuth. C'est exactement ce qu'il fait bien : il sait à peu près à quoi ressemble httpx et peut traduire la question familière de l'utilisateur dans le vocabulaire technique qu'emploie la documentation.

Les réécritures sont mises en cache dans rewrites.json, pour ne payer qu'une fois par question. Dans une vraie application, chaque question d'utilisateur demande un appel de modèle de plus, soit quelques centaines de millisecondes et moins de 0,0001 dollar.

La fusion : RRF

Recherche vectorielle et recherche par mots-clés ont chacune leurs forces : l'une saisit l'idée générale, l'autre les mots exacts. Peut-on utiliser les deux ensemble ?

La difficulté est que leurs scores ne s'additionnent pas directement : le score vectoriel est une similarité entre 0 et 1, le score BM25 peut valoir une dizaine ou plusieurs dizaines. La méthode courante consiste à ignorer les scores et ne regarder que les rangs ; elle s'appelle RRF (Reciprocal Rank Fusion, fusion par rang réciproque) :

def rrf(result_lists, k=5, c=60):
    """倒数排名融合:一个块在每个列表里排第 r 名,就得 1/(c+r) 分,把各列表的分数加起来。"""
    scores, items = Counter(), {}
    for results in result_lists:
        for rank, (_, file, text) in enumerate(results, 1):
            scores[(file, text)] += 1 / (c + rank)
            items[(file, text)] = (file, text)
    return [(s, *items[key]) for key, s in scores.most_common(k)]

Un morceau bien classé dans les deux listes obtient un score élevé ; présent dans une seule, un peu moins. c=60 est la valeur habituelle de l'article d'origine ; elle évite un écart trop grand entre le 1er et le 2e rang. En pratique, on prend les 20 premiers de chaque méthode, on fusionne, et on garde les 5 premiers.

Résultats

code/04-rag/hybrid.py fait passer toutes ces combinaisons, avec pour la recherche vectorielle le multilingual-e5-small, meilleur à la leçon précédente :

向量(原问题)        第 1 名  30%  前 3 名  50%  前 5 名  65%  MRR 0.418
BM25(原问题)      第 1 名  15%  前 3 名  30%  前 5 名  35%  MRR 0.229
向量(改写后)        第 1 名  55%  前 3 名  90%  前 5 名  95%  MRR 0.718
BM25(改写后)      第 1 名  70%  前 3 名  95%  前 5 名 100%  MRR 0.804
混合 RRF(改写后)    第 1 名  60%  前 3 名  90%  前 5 名 100%  MRR 0.772

La réécriture de requête est la grande gagnante. Pour BM25, le taux de réussite dans les 5 premiers bondit de 35 % à 100 % ; pour la recherche vectorielle, de 65 % à 95 %. Un appel de modèle bon marché fait plus que n'importe quel changement d'algorithme de recherche. Dans notre situation « questions chinoises, documentation anglaise », cette étape est presque indispensable. Même quand question et documentation sont dans la même langue, réécrire une question familière dans le vocabulaire de la documentation aide généralement.

Après réécriture, BM25 fait mieux que la recherche vectorielle. Une documentation technique regorge de noms de paramètres et de classes ; les termes de recherche réécrits les contiennent justement, et la recherche par mots-clés les trouve aussitôt. Cela contredit l'impression répandue que « la recherche vectorielle est plus avancée ».

La recherche hybride ne fait pas mieux sur ce jeu de questions. Après fusion RRF, le taux de réussite dans les 5 premiers est de 100 %, comme BM25, mais le MRR baisse de 0,804 à 0,772. Les résultats mal classés par la recherche vectorielle ont repoussé plus bas certaines bonnes réponses que BM25 classait premières.

Cela ne veut pas dire que la recherche hybride est inutile. Sur d'autres données, par exemple quand les questions des utilisateurs sont très familières et que la documentation ne contient pas les mots-clés correspondants, la recherche vectorielle joue un rôle bien plus grand. Ce que je veux dire : n'utilisez pas une méthode parce qu'elle a l'air plus avancée, décidez avec les données d'évaluation. 20 questions, c'est trop peu, cette différence n'est peut-être qu'une fluctuation ; sur vos propres données, la conclusion peut être tout autre.

Le reranking

La recherche vectorielle transforme séparément la question et le document en vecteurs, puis compare les vecteurs. Pendant le calcul, question et document ne se « voient » pas : c'est rapide, on peut calculer à l'avance tous les vecteurs des documents, mais c'est grossier.

Un modèle de reranking (reranker) procède autrement : il reçoit la question et un morceau de document collés ensemble, les lit tous les deux à la fois et donne directement un score de pertinence. Le jugement est bien plus précis, mais chaque paire doit être calculée, rien ne peut l'être à l'avance, et c'est bien plus lent.

On procède donc en deux temps : d'abord une présélection de 20 morceaux par une méthode rapide (vectorielle, BM25, hybride), puis le modèle de reranking note précisément ces 20 morceaux, les reclasse, et on garde les 5 premiers.

J'utilise bge-reranker-base, publié en open source par la BAAI, qui prend en charge le chinois et l'anglais ; 1,1 Go à télécharger, et il tourne sur CPU :

from sentence_transformers import CrossEncoder

reranker = CrossEncoder("BAAI/bge-reranker-base", max_length=512)


def reranked(question, k=5, use_rewrite=False):
    pool = candidates(question, 20)
    query = rewrite(question) if use_rewrite else question
    scores = reranker.predict([(query, text) for _, _, text in pool])
    order = sorted(range(len(pool)), key=lambda i: -scores[i])
    return [(float(scores[i]), pool[i][1], pool[i][2]) for i in order[:k]]

candidates correspond à « réécriture + hybride RRF » ci-dessus, dont on prend les 20 premiers. Résultat (code/04-rag/rerank.py) :

混合 RRF(改写后)        第 1 名  60%  前 3 名  90%  前 5 名 100%  MRR 0.772  (20 题用时 0.2 秒)
混合 + 重排(用原中文问题)    第 1 名  45%  前 3 名  85%  前 5 名  90%  MRR 0.643  (20 题用时 20.1 秒)
    没找到:怎么让请求走 HTTP 代理?(应在 advanced/proxies.md)→ 第 1 名 advanced/proxies.md
    没找到:有些域名不想走代理,环境变量怎么设置?(应在 environment_variables.md)→ 第 1 名 environment_variables.md
混合 + 重排(用改写后的问题)   第 1 名  85%  前 3 名  95%  前 5 名 100%  MRR 0.912  (20 题用时 20.0 秒)

En reclassant avec la question réécrite en anglais, le taux de réussite au 1er rang passe de 60 % à 85 %, et le MRR de 0,772 à 0,912 : la meilleure de toutes les combinaisons. Pour un RAG, que le 1er soit juste compte beaucoup : le modèle accorde souvent le plus d'importance au document placé en tête, et on peut du coup mettre moins de morceaux, ce qui économise et réduit les distractions.

Reclasser avec la question chinoise d'origine donne au contraire un résultat moins bon. Même si bge-reranker-base prend en charge le chinois et l'anglais, son jugement est moins précis avec une question chinoise et une documentation anglaise. Détail intéressant, pour les deux questions « non trouvées », le 1er résultat se trouve bien dans le bon fichier, mais ne contient pas le mot-clé : il a trouvé l'emplacement à peu près juste sans choisir le morceau le plus exact.

Le prix, c'est le temps : 20 secondes pour 20 questions, soit une seconde par question en moyenne, passée sur le CPU à noter 20 morceaux candidats. Avec un GPU, ce serait bien plus rapide ; on peut aussi réduire le nombre de candidats, ou utiliser un service d'API de reranking. Ajouter ou non le reranking dépend de ce que votre application peut accepter comme seconde supplémentaire.

Récapitulatif de l'effet de chaque étape

Méthode Réussite dans les 5 premiers MRR Coût supplémentaire
Recherche vectorielle 65 % 0,418 Aucun
Plus réécriture de requête 95 % 0,718 Un appel de modèle de plus par question
Réécriture + BM25 100 % 0,804 Aucun
Réécriture + hybride RRF 100 % 0,772 Aucun
Réécriture + hybride + reranking 100 % 0,912 Environ 1 seconde de plus par question (CPU)

Pour votre projet, je conseille d'essayer dans cet ordre : faire d'abord la recherche la plus simple et construire le jeu d'évaluation ; puis essayer la réécriture de requête ; puis l'hybride ; enfin envisager le reranking. À chaque étape, regardez les chiffres ; s'il n'y a pas de gain, n'ajoutez pas.

Exercices

  1. Passez k1 de BM25 à 0,5 et 3, et b à 0 et 1, et voyez comment évolue le résultat « BM25 (après réécriture) ».
  2. Modifiez le prompt de rewrite pour ne demander que la traduction, sans compléter les noms de paramètres et de classes, et relancez (en supprimant d'abord rewrites.json). De combien baisse le taux de réussite dans les 5 premiers ?
  3. Dans rrf, donnez des poids différents aux deux méthodes, par exemple en multipliant le score de BM25 par 2, et voyez si la recherche hybride peut dépasser BM25 seul.
  4. Passez le nombre de candidats du reranking de 20 à 10, et voyez comment évoluent précision et durée.

Auto-test

1. Pourquoi BM25 donne-t-il plus de poids aux mots rares ?

Un mot présent dans presque chaque morceau (comme « the » ou « httpx ») ne permet pas de distinguer quel morceau est plus pertinent ; un mot présent dans peu de morceaux (comme « DigestAuth ») indique fortement, s'il correspond, que ce morceau est lié à la question. L'IDF sert justement à mesurer la rareté d'un mot.

2. Pourquoi ne peut-on pas additionner directement les scores de la recherche vectorielle et de BM25 ? Comment RRF résout-il cela ?

Les deux scores ont des plages complètement différentes : la similarité vectorielle entre 0 et 1, le score BM25 jusqu'à plusieurs dizaines ; en les additionnant, BM25 domine totalement. RRF n'utilise pas les scores bruts, seulement le rang de chaque résultat dans sa liste, noté 1/(c+rang) puis additionné : les classements des deux méthodes se combinent ainsi équitablement.

3. Le modèle de reranking est plus précis que la recherche vectorielle : pourquoi ne pas l'utiliser directement pour chercher parmi tous les morceaux ?

Le modèle de reranking doit calculer la question collée à chaque morceau, sans pouvoir rien calculer à l'avance. Avec beaucoup de morceaux, c'est bien trop lent : 196 morceaux demandent 196 calculs, et des centaines de milliers sont hors de question. On présélectionne donc quelques dizaines de candidats avec une méthode rapide, puis on les reclasse finement avec le modèle de reranking.