Comment mesurer et améliorer le rappel de recherche Elasticsearch : de 0,43 à 0,75 avec la recherche hybride
Découvrez comment mesurer et améliorer le rappel de recherche dans Elasticsearch en combinant la recherche lexicale BM25 avec les embeddings vectoriels de Jina AI, en utilisant l’API rank_eval pour valider l’amélioration avec des données chiffrées.
La recherche lexicale utilisant l’algorithme de classement BM25 est peu coûteuse, rapide et très efficace pour de nombreuses requêtes. Mais elle présente un inconvénient : les requêtes qui ne partagent pas de jetons avec vos documents. Dans cet article, vous allez mesurer exactement les points faibles de la BM25. Nous utiliserons l’API d’évaluation de classement (rank_eval) d’Elasticsearch et comblerons cet écart en ajoutant des embeddings Jina AI via Elastic Inference Service (EIS). Vous verrez le score de rappel passer de 0.43 à 0.75 et vous comprendrez pourquoi.
Qu'est-ce que le rappel ?
Le rappel mesure, sur une échelle allant de 0 à 1, le nombre de documents réellement souhaités par vos utilisateurs qui apparaissent quelque part dans vos résultats de recherche. Si une requête doit faire apparaître trois produits et que votre recherche ne renvoie que deux d’entre eux dans le top 10, recall@10 = 0.67 pour cette requête. C’est une métrique basée sur des ensembles : la position des documents pertinents dans ces k résultats n’a pas d’importance. Un document pertinent en position 10 compte autant qu’un document en position 1. Un taux de rappel élevé signifie que vous ne perdez pas de résultats pertinents.

Le diagramme montre deux ensembles : tous les documents pertinents (à gauche) et ce que BM25 a réellement récupéré (les 10 premiers, à droite). Seules les intersections comptent pour le rappel, prod_1 et prod_2 ont été trouvés, tandis que prod_3, prod_4 et prod_6 ont été totalement manqués. Résultat : Recall@10 = 2/5 = 0.40.
Produits requis
Entrons dans le vif du sujet pour mieux comprendre le fonctionnement du rappel. Cette démonstration utilise Python. Vous pouvez la suivre dans le cahier d’accompagnement (notebook.ipynb), où chaque bloc de code est une cellule prête à être exécutée.
Le code fourni utilise les éléments suivants :
Elasticsearch 9.3+
Python 3.10+
pip install elasticsearch pandas plotly python-dotenvUn fichier
.envavec vos identifiants Elasticsearch
ELASTICSEARCH_URL=https://your-cluster-url
ELASTICSEARCH_API_KEY=your-api-keyL’ensemble de données
Nous utiliserons un catalogue de produits de 1 000 articles, couvrant des catégories telles que les chaussures, l’électronique, les outils, et bien d’autres.
Chaque document comporte quatre champs :
Champ | Type |
|---|---|
`title` | Texte |
description | Texte |
marque | mot-clé |
Catégorie | mot-clé |
L’ensemble de données est chargé à partir de dataset.csv.
La puissance et les limites de la recherche lexicale
BM25 est l’algorithme de classement par défaut d’Elasticsearch et de la plupart des moteurs de recherche. Il attribue des scores aux documents en fonction de la fréquence d’apparition de vos termes de requête dans ceux-ci, ajustée en fonction de la longueur du document et de la fréquence de ces termes dans l’ensemble de l’index. Vous disposez d’analyseurs en plus : normalisation des minuscules, troncature et suppression des mots vides. Une requête pour « chaussures de course » correspondra à « Chaussures de course » et probablement aussi à « courir ».
Cette méthode fonctionne bien pour une grande catégorie de requêtes :
« chaussures de course » associe immédiatement les produits dont le titre correspond exactement à ces termes.
L’expression « enceinte Bluetooth » fait apparaître des produits audio portables, car les termes apparaissent tels quels.
Les résultats sont déterministes et explicables : un document est bien classé parce que les termes de la requête y apparaissent. La pertinence du débogage est simple.
Les cas d’échec
Essayons maintenant ces requêtes sur le même catalogue :
« Routine de soins de la peau » : Le mot « routine » n’apparaît dans aucun titre de produit. BM25 peut correspondre partiellement à la requête « soins de la peau », mais les sérums pour le visage, les huiles corporelles et les crèmes hydratantes sont décrits à l’aide de termes comme « vitamine C », « rétinol » ou « éclaircissant », dont aucun ne correspond à la requête. Les produits qui forment une routine de soins de la peau complète sont dispersés dans l’index sans aucun élément commun permettant de les regrouper.
ID: B06XX6DS3P, Score: 9.0552, Title: Replenix Retinol Smooth + Tighten Body Lotion - Collagen-Boosting, Regenerating Anti-Aging Body Cream, Reduces Appearance of Stretch Marks, 6.7 oz.
ID: B08XMPKJ1L, Score: 5.2699, Title: Bio-Oil Skincare Body Oil (Natural) Serum for Scars and Stretchmarks, Face and Body Moisturizer Hydrates Skin, with Organic Jojoba Oil and Vitamin E, For All Skin Types, 6.7 oz
ID: B01CY764KQ, Score: 5.0057, Title: Nike Up Or Down Men Deodorant - Pack of 2 | Long-Lasting Fragrance, Body Spray Combo for Men | Deodorant for Active Living | Nike Men's Deo Set | Ultimate Odor Protection | Grooming Essentials | Signature Nike Scent | High-Performance Men's Deodorant« Accessoires de voyage pour animaux de compagnie » : il s’agit d’un regroupement de cas d’utilisation, et non d’une catégorie de produits. Un sac kangourou pour chien, un siège auto pour animal et une caisse de voyage sont tous pertinents, mais leurs descriptions parlent de portabilité, de sécurité et de confort plutôt que d’« accessoires de voyage ». BM25 trouve des correspondances pour le terme « animal de compagnie » au sens large, mais ne comporte aucun signal permettant de distinguer les produits spécifiques aux voyages du reste du catalogue pour animaux de compagnie.
ID: B0BVV7BKTW, Score: 7.4371, Title: Large Foldable Travel Duffel Bag with Shoes Compartment
ID: B07TNPHYNV, Score: 6.6455, Title: 40 Pieces Christmas Bronze Jingle Bells Craft Small Bells
ID: B08R8FRW53, Score: 6.6335, Title: CUBY Dog and Cat Sling Carrier
ID: B08QMCQYGM, Score: 6.5259, Title: YTFGGY Whiteboard Pinstripe Tape 6 Rolls 1/8"
ID: B0CP3LQSWM, Score: 6.2994, Title: Portable Dog Water Bottle 32 OzIl s'agit d'un problème de rappel. Les documents pertinents se trouvent dans votre index. BM25 ne peut tout simplement pas les trouver car les mots de l'utilisateur et les mots du document ne correspondent pas suffisamment étroitement.
L'ajout de synonymes est utile pour les cas connus. Mais vous ne pouvez pas énumérer toutes les façons dont un utilisateur pourrait exprimer une intention. C'est là que les vecteurs entrent en jeu.
Pourquoi il est conseillé de mesurer le rappel
Avant de résoudre un problème, il faut le quantifier.
Recall@k mesure combien de documents vos utilisateurs souhaitent réellement voir apparaître dans vos résultats de recherche. Au sens strict :
Recall@k = (relevant documents found in top k) / (total relevant documents)Precision@k mesure les k premiers résultats et combien sont réellement pertinents :
Precision@k = (relevant documents in top k) / kUne grande précision garantit la qualité des résultats obtenus. Dans le commerce électronique, l’absence d’un produit pertinent (faible taux de rappel) est souvent pire que l’affichage d’un résultat légèrement imparfait (précision moindre), car un produit caché est une vente perdue.
L’API d’Elasticsearch rank_eval vous permet de mesurer les deux de manière systématique. Vous fournissez une liste de requêtes, chacune avec un ensemble de documents évalués, et Elasticsearch calcule les métriques pour vous pour l’ensemble des requêtes.
Configuration de l'évaluation
L’API rank_eval nécessite un ensemble de données d’évaluations : un mappage des requêtes vers les documents pertinents pour chacune d’elles, accompagné d’un grade de pertinence (0 = non pertinent, 1 = pertinent, 2 = très pertinent).
Dans le cahier, il s’agit de la liste des jugements :
judgments = [
# Query 1: "running shoes" BM25 handles well (tokens appear in product titles)
{"query_id": "q1", "doc_id": "B09NQJFRW6", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B08JMD4LMM", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B08VRJ6F2Q", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B07S8NRRWR", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B01HD620I8", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B07DX86321", "grade": 2, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B0968YVLQ8", "grade": 1, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B093QJ39ZS", "grade": 1, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B096FGSC39", "grade": 1, "query": "running shoes"},
{"query_id": "q1", "doc_id": "B01GVQWVV2", "grade": 1, "query": "running shoes"},
# Query 2: "skincare routine" intent-based, "routine" never appears in product titles
{"query_id": "q2", "doc_id": "B08XMPKJ1L", "grade": 2, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B0BN3WQB92", "grade": 2, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B0BT7B7P5T", "grade": 2, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B00NPA2WEY", "grade": 2, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B06XX6DS3P", "grade": 1, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B07PDRD1KT", "grade": 1, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B074J7869B", "grade": 1, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B08JV31QW4", "grade": 1, "query": "skincare routine"},
{"query_id": "q2", "doc_id": "B00K3TVJMQ", "grade": 1, "query": "skincare routine"},
# Query 3: "study desk setup" intent-based, products are desks/stands/organizers
{"query_id": "q3", "doc_id": "B08CS35J2T", "grade": 2, "query": "study desk setup"},
{"query_id": "q3", "doc_id": "B09B3LFDXJ", "grade": 2, "query": "study desk setup"},
{"query_id": "q3", "doc_id": "B07W58LMND", "grade": 1, "query": "study desk setup"},
{"query_id": "q3", "doc_id": "B0CHYDX91L", "grade": 1, "query": "study desk setup"},
# Query 4: "pet travel accessories" use-case grouping, products are carriers/crates/seats
{"query_id": "q4", "doc_id": "B08R8FRW53", "grade": 2, "query": "pet travel accessories"},
{"query_id": "q4", "doc_id": "B01MYUYX33", "grade": 2, "query": "pet travel accessories"},
{"query_id": "q4", "doc_id": "B003C5RKE4", "grade": 2, "query": "pet travel accessories"},
{"query_id": "q4", "doc_id": "B09GF8GBF6", "grade": 1, "query": "pet travel accessories"},
{"query_id": "q4", "doc_id": "B0CP3LQSWM", "grade": 1, "query": "pet travel accessories"},
]Le mélange est intentionnel : q1 est une requête que BM25 gère bien (jetons exacts dans les titres des produits), tandis que q2, q3, et q4 sont des requêtes basées sur l’intention où l’intention de l’utilisateur est exprimée sous forme de concept plutôt que de mots-clés spécifiques sur les produits.
Mesurer le rappel de référence du BM25
Commencez par configurer le client Elasticsearch et indexez les données textuelles brutes :
import os
import json
import pandas as pd
import plotly.graph_objects as go
from elasticsearch import Elasticsearch, helpers
from dotenv import load_dotenv
load_dotenv()
es = Elasticsearch(
os.getenv("ELASTICSEARCH_URL"),
api_key=os.getenv("ELASTICSEARCH_API_KEY")
)
INDEX_NAME = "ecommerce-products"Maintenant, créez la requête rank_eval pour BM25. Chaque requête dans la liste combine une requête avec ses notations :
judgments_df = pd.DataFrame(judgments)
bm25_requests = []
for query_id, query_text in (
judgments_df[["query_id", "query"]].drop_duplicates().values
):
relevant_docs = judgments_df[judgments_df["query_id"] == query_id]
ratings = [
{"_index": INDEX_NAME, "_id": row["doc_id"], "rating": row["grade"]}
for _, row in relevant_docs.iterrows()
]
bm25_requests.append({
"id": query_id,
"request": {
"query": {
"multi_match": {
"query": query_text,
"fields": ["title", "description"]
}
}
},
"ratings": ratings,
})
bm25_eval = {
"requests": bm25_requests,
"metric": {"recall": {"k": 10, "relevant_rating_threshold": 1}},
}
bm25_result = es.rank_eval(index=INDEX_NAME, body=bm25_eval)
print("BM25 Recall@10:", bm25_result.body["metric_score"])Voici le résultat.
BM25 Recall@10: 0.430.43 Signifie que sur l’ensemble des quatre requêtes, BM25 ne trouve que 43 % des documents qu’il devrait trouver. Le problème se situe principalement dans les requêtes basées sur l’intention : « routine de soins de la peau » ne trouve pas les sérums pour le visage et les huiles corporelles car le mot « routine » n’apparaît jamais dans les titres des produits, et « accessoires de voyage pour animaux de compagnie » renvoie des produits pour animaux de compagnie hors sujet tout en ne trouvant pas les cages et les caisses de transport dont la description précise les fonctionnalités de portabilité et de sécurité plutôt que d’« accessoires de voyage ».
Ceci est notre référence. Nous avons maintenant un chiffre à battre.
Ajout de la recherche vectorielle avec les embeddings Jina
Vector search encode les documents et les requêtes sous forme de vecteurs de grande dimension, composés de centaines ou de milliers de valeurs numériques, chacune encodant une fonctionnalité spécifique des données représentées. Les documents ayant une signification similaire se retrouvent proches les uns des autres dans l’espace vectoriel, même s’ils ne partagent aucun mot. « Équipement de gym » et « kit d’haltères » seront proches l’un de l’autre, car les concepts sont liés. J’ai choisi Elasticsearch comme base vectorielle, car il prend en charge la recherche hybride, ce qui me permet de bénéficier d'emblée à la fois d’une compréhension sémantique et d’une précision par mot-clé.
EIS inclut une prise en charge prête à l’emploi pour l’intégration de modèles via son API d’inférence.
Étape 1 : utiliser les embeddings Jina v5 comme point de terminaison d’inférence
INFERENCE_ENDPOINT_ID = ".jina-embeddings-v5-text-small"Si votre cluster dispose de ressources GPU (disponibles dans Elastic Cloud et Elasticsearch 9.3+), les embeddings sont générés sur GPU, ce qui est nettement plus rapide que l’inférence CPU, et supprime le compromis de performance qui rendait auparavant l’utilisation des vecteurs coûteuses à grande échelle.
Pourquoi spécifiquement les embeddings Jina ? jina-embeddings-v5-text est un modèle multilingue (plus de 119 langues) avec une fenêtre de contexte de 32 000 jetons et une prise en charge des adaptateurs Low-Rank Adaptation (LoRA) spécifiques à la tâche. Il fonctionne bien pour les courtes descriptions de produits prêtes à l’emploi. En savoir plus sur le modèle jina-embeddings-v5-text ici.
Étape 2 : créer l’index avec un champ sémantique
index_mappings = {
"mappings": {
"properties": {
"title": {"type": "text", "copy_to": "semantic_field"},
"description": {"type": "text", "copy_to": "semantic_field"},
"brand": {"type": "keyword"},
"category": {"type": "keyword"},
"semantic_field": {
"type": "semantic_text",
"inference_id": INFERENCE_ENDPOINT_ID,
},
}
}
}
if not es.indices.exists(index=INDEX_NAME):
es.indices.create(index=INDEX_NAME, body=index_mappings)
print(f"Created index: {INDEX_NAME}")Le type de champ semantic_text est ici la clé. C’est une abstraction de niveau supérieur par rapport à dense_vector : vous le dirigez vers un point de terminaison d’inférence, et Elasticsearch se charge de générer automatiquement les plongements.
La propriété copy_to sur title et description signifie que le contenu des deux champs est transmis à semantic_field pour intégration, de sorte qu’un seul vecteur capture la représentation complète du produit.
Étape 3 : indexer les produits
def bulk_index(products, index_name):
actions = []
for product in products:
doc_id = product.get("_id")
source = {k: v for k, v in product.items() if k != "_id"}
action = {"_index": index_name, "_source": source}
if doc_id:
action["_id"] = doc_id
actions.append(action)
success, failed = helpers.bulk(es, actions, raise_on_error=False)
if failed:
for error in failed:
print(f"Error: {error}")
else:
print(f"Successfully indexed {success} documents")
bulk_index(products, INDEX_NAME)Au moment de l’indexation, Elasticsearch appelle le point de terminaison d’inférence pour chaque document et stocke le vecteur d’intégration résultant dans semantic_field. Aucun code supplémentaire n’est nécessaire de votre côté.
Recherche hybride : combinaison de BM25 et de vecteurs avec RRF
L'ajout de vecteurs améliore le taux de rappel, mais le recours exclusif aux vecteurs risque d'entraîner une perte de précision pour les requêtes en correspondance exacte. Les résultats pour « chaussures de course » devraient toujours classer en premier les correspondances exactes. La recherche hybride conserve la composante lexicale spécifiquement pour préserver cette précision.
La recherche hybride avec la Fusion des rangs réciproques (RRF) combine le meilleur des deux :
BM25 gère les requêtes exactes et quasi-exactes avec une grande précision.
La recherche sémantique gère les requêtes basées sur l’intention et multilingues avec un rappel élevé.
RRF fusionne les deux listes classées en un seul classement.
La formule RRF attribue à chaque document un score basé sur son classement dans chaque liste de résultats :
score = sum(1 / (rank_constant + rank))Un document bien classé dans les deux listes obtient un score combiné plus élevé. Le paramètre rank_constant détermine le poids attribué aux documents moins bien classés.
hybrid_requests = []
for query_id, query_text in (
judgments_df[["query_id", "query"]].drop_duplicates().values
):
relevant_docs = judgments_df[judgments_df["query_id"] == query_id]
ratings = [
{"_index": INDEX_NAME, "_id": row["doc_id"], "rating": row["grade"]}
for _, row in relevant_docs.iterrows()
]
hybrid_requests.append({
"id": query_id,
"request": {
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"multi_match": {
"query": query_text,
"fields": ["title", "description"],
}
}
}
},
{
"standard": {
"query": {
"match": {
"semantic_field": {"query": query_text}
}
}
}
},
],
"rank_window_size": 50,
"rank_constant": 5,
}
}
},
"ratings": ratings,
})
hybrid_eval = {
"requests": hybrid_requests,
"metric": {"recall": {"k": 10, "relevant_rating_threshold": 1}},
}
hybrid_result = es.rank_eval(index=INDEX_NAME, body=hybrid_eval)
print("Hybrid Recall@10:", hybrid_result.body["metric_score"])Voici le résultat.
Hybrid Recall@10: 0.75Hybrid s’améliore nettement par rapport à BM25 (0.43) et préserve la précision des requêtes de correspondance exacte, comme « chaussures de course ».
Résultats : avant et après
Voici un comparatif complet des trois approches :
methods = {
"BM25 (Lexical)": bm25_requests,
"Hybrid (BM25 + Vectors)": hybrid_requests,
}
recall_metric = {"recall": {"k": 10, "relevant_rating_threshold": 1}}
comparison_data = []
for method_name, requests in methods.items():
result = es.rank_eval(
index=INDEX_NAME,
body={"requests": requests, "metric": recall_metric}
)
comparison_data.append({
"method": method_name,
"recall@10": result.body["metric_score"]
})
comparison_df = pd.DataFrame(comparison_data)
print(comparison_df.to_string(index=False))Voici le résultat.
Méthode | Recall@10 |
|---|---|
BM25 (Lexical) | 0,43 |
Hybride (BM25 + vecteurs) | 0,75 |

Répartition des données par requête :

Conclusion
Tout au long de cet article, nous avons vu que la recherche lexicale BM25 est fiable lorsque les utilisateurs tapent des requêtes exactes, mais qu’elle perd en rappel lorsqu’ils recherchent par intention plutôt que par mots-clés. En utilisant rank_eval, nous avons établi une base reproductible pour mesurer cet écart avec des nombres réels. Ensuite, nous avons ajouté un champ semantic_text alimenté par les embeddings Jina et relancé l’évaluation. Le résultat : la recherche hybride a amélioré le rappel de 0.43 à 0.75, tout en préservant la précision des requêtes à correspondance exacte, bien que la marge réelle dépende de la composition de vos requêtes.
Le modèle s’étend au-delà de cet exemple : collectez les jugements à partir des requêtes réelles de vos utilisateurs, exécutez rank_eval comme référence, ajoutez semantic_text, puis mesurez à nouveau. Vous saurez exactement ce qui s’est amélioré et de combien.
Étapes suivantes
Découvrez la recherche de rappel et de recherche vectorielle : quantification du rappel et de la recherche vectorielle par Jeff Vestal
Ajoutez le reclassement pour une précision encore meilleure sur les meilleurs résultats
Consultez la documentation sur la recherche hybride avec Elasticsearch
Pour aller plus loin

Vous savez, pour le contexte - Partie III : La puissance de la recherche hybride dans l'ingénierie contextuelle

Résolution d’entités avec Elasticsearch et les LLM, partie 2 : mise en correspondance d’entités avec le jugement des LLM et la recherche sémantique


