Como medir e melhorar o recall das buscas no Elasticsearch: de 0,43 a 0,75 com a busca híbrida
Aprenda a medir e melhorar o recall das buscas no Elasticsearch combinando a busca léxica BM25 com embeddings vetoriais do Jina AI, usando a API rank_eval para validar a melhoria com números reais.
A busca lexical usando o algoritmo de classificação BM25 é barata, rápida e muito eficaz para uma ampla gama de consultas. Mas ela tem um ponto cego: consultas que não compartilham tokens com seus documentos. Neste artigo, você vai medir exatamente onde a BM25 falha. Usaremos a API de avaliação de classificação do Elasticsearch (rank_eval) e preencheremos essa lacuna adicionando Jina AI embeddings via Elastic Inference Service (EIS). Você verá a pontuação de recall variar de 0.43 para 0.75 e saberá o porquê.
O que é recall?
O recall mede em uma escala de 0 a 1 quantos dos documentos que seus usuários realmente querem aparecem em algum lugar dos seus resultados de busca. Se uma consulta aparecer em três produtos e sua busca devolver apenas dois deles entre os 10 primeiros, recall@10 = 0.67 nessa consulta. É uma métrica baseada em conjuntos: ela não se importa com a posição dos documentos relevantes dentro desses k resultados. Um documento relevante na posição 10 conta o mesmo que um na posição 1. Ter um recall alto significa que você não está perdendo resultados relevantes.

O diagrama mostra dois conjuntos: todos os documentos relevantes (à esquerda) e o que o BM25 realmente recuperou (top 10, à direita). Apenas a interseção conta para a recuperação, prod_1 e prod_2 foram encontrados, enquanto prod_3, prod_4 e prod_6 foram completamente perdidos. Resultado: Recall@10 = 2/5 = 0.40.
Pré-requisitos
Vamos direto ao ponto para entender melhor como o recall funciona. Esta demonstração usa Python. Você pode acompanhar no notebook complementar (notebook.ipynb), onde cada bloco de código é uma célula pronta para ser executada.
O código fornecido utiliza o seguinte:
Elasticsearch 9.3+
Python 3.10+
pip install elasticsearch pandas plotly python-dotenvUm arquivo
.envcom suas credenciais do Elasticsearch
ELASTICSEARCH_URL=https://your-cluster-url
ELASTICSEARCH_API_KEY=your-api-keyO conjunto de dados
Usaremos um catálogo de produtos com 1.000 produtos, abrangendo categorias como calçados, eletrônicos, ferramentas e outros.
Cada documento tem quatro campos:
Campo | Tipo |
|---|---|
`title` | texto |
`description` | texto |
`marca` | palavra-chave |
`category` | palavra-chave |
O conjunto de dados é carregado a partir de dataset.csv.
O poder e os limites da busca lexical
BM25 é o algoritmo padrão de ranqueamento no Elasticsearch e na maioria dos mecanismos de busca. Ele classifica os documentos de acordo com a frequência com que seus termos de consulta aparecem neles, ajustados ao tamanho do documento e à frequência desses termos em todo o índice. Você tem analisadores na parte superior: normalização de letras minúsculas, stemming e retirada de stopwords. Uma busca por "tênis de corrida" retornará resultados como "Tênis de corrida" e provavelmente também "correr".
Isso funciona bem para uma grande classe de consultas:
"tênis de corrida" faz a correspondência imediata dos produtos com esses tokens exatos no título.
"alto-falante Bluetooth" destaca produtos de áudio portáteis porque os tokens aparecem literalmente.
Os resultados são determinísticos e explicáveis: um documento tem classificação alta porque os termos de consulta aparecem nele. Depurar a relevância é simples.
Onde ocorre a falha
Agora, vamos testar essas consultas no mesmo catálogo:
"rotina de cuidados com a pele": a palavra "rotina" não aparece em nenhum título de produto. O BM25 pode corresponder parcialmente com "cuidados com a pele", mas séruns faciais, óleos corporais e hidratantes são descritos usando termos como "vitamina C", "retinol" ou "iluminador", nenhum dos quais se sobrepõe à consulta. Produtos que formam uma rotina completa de cuidados com a pele ficam espalhados pelo índice, sem nenhum token compartilhado para ancorá-los.
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"acessórios de viagem para pets": é um agrupamento de casos de uso, não uma categoria de produto. Um sling para cães, uma cadeirinha para pets e uma caixa de viagem são todos relevantes, mas as descrições falam sobre portabilidade, segurança e conforto, em vez de "acessórios de viagem". O BM25 corresponde amplamente a palavra "pet", mas não tem sinal para distinguir produtos específicos para viagens do restante do catálogo de pets.
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 OzEsse é um problema de recall. Os documentos relevantes existem no seu índice. O BM25 simplesmente não os encontra porque as palavras do usuário e as do documento não coincidem o suficiente.
Adicionar sinônimos ajuda em casos conhecidos. Mas não dá para enumerar todas as formas como o usuário pode expressar uma intenção. É aí que entram os vetores.
Por que medir o recall
Antes de corrigir um problema, você precisa quantificá-lo.
Recall@k mede quantos documentos que seus usuários realmente desejam aparecem nos resultados de busca. Formalmente:
Recall@k = (relevant documents found in top k) / (total relevant documents)Precision@k mede os k principais resultados e quantos são realmente relevantes:
Precision@k = (relevant documents in top k) / kAlta precisão mostra que os resultados que você retorna são bons. No comércio eletrônico, perder um produto relevante (baixo recall) geralmente é pior do que mostrar um resultado um pouco imperfeito (menor precisão), porque o produto oculto é venda perdida.
A API rank_eval do Elasticsearch permite medir os dois de forma sistemática. Você fornece uma lista de consultas, cada uma com um conjunto de documentos avaliados, e o Elasticsearch calcula as métricas para você em todas elas.
Configuração da avaliação
A API rank_eval precisa de um conjunto de dados de avaliações: um mapeamento das consultas para os documentos relevantes para cada um, junto com uma nota de relevância (0 = não relevante, 1 = relevante, 2 = altamente relevante).
No bloco de notas, esta é a lista de julgamentos:
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"},
]A combinação é intencional: q1 é uma consulta que o BM25 lida bem (tokens exatos nos títulos dos produtos), enquanto q2, q3 e q4 são consultas orientadas à intenção, nas quais a intenção do usuário é expressa como um conceito, não como palavras-chave específicas de produto.
Medindo o recall de base do BM25
Primeiro, configure o cliente do Elasticsearch e indexe os dados de texto bruto:
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"Agora, crie a solicitação rank_eval para o BM25. Cada solicitação na lista combina uma consulta com as classificações correspondentes:
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"])Resultado:
BM25 Recall@10: 0.430.43 significa que, em todas as quatro consultas, o BM25 encontra apenas 43% dos documentos que deveria. A deficiência se concentra nas consultas baseadas na intenção: "rotina de cuidados com a pele" não inclui séruns faciais e óleos corporais, pois "rotina" nunca aparece nos títulos dos produtos. Já "acessórios de viagem para pets" retorna produtos para pets fora do tópico, enquanto não inclui transportadoras e caixas de transporte descritas em termos de portabilidade e segurança, em vez de "acessórios de viagem".
Esta é a nossa linha de base. Agora, temos um número a superar.
Adicionando busca vetorial com embeddings do Jina
Vector search codifica documentos e consultas como vetores de alta dimensão, tipo de vetor composto por centenas ou milhares de valores numéricos, cada um codificando um recurso específico dos dados que representa. Documentos com significado semelhante acabam próximos uns dos outros no espaço vetorial, mesmo que não compartilhem palavras. "Equipamento de ginástica" e "conjunto de halteres" ficam próximos porque os conceitos estão relacionados. Escolhi o Elasticsearch como meu banco de dados vetorial porque ele faz busca híbrida, oferecendo compreensão semântica e precisão de palavras-chave prontas para uso.
EIS inclui suporte pronto para uso de modelos via API de inferência.
Passo 1: usando embeddings Jina v5 como endpoint de inferência
INFERENCE_ENDPOINT_ID = ".jina-embeddings-v5-text-small"Se seu cluster tem recursos de GPU (disponíveis no Elastic Cloud e Elasticsearch 9.3+), as incorporações são geradas na GPU, o que é bem mais rápido do que a inferência da CPU e elimina o tradeoff de desempenho que historicamente encarecia os vetores em larga escala.
Por que as incorporações Jina especificamente? jina-embeddings-v5-text é um modelo multilíngue (mais de 119 idiomas) com uma janela de contexto de 32 mil tokens e suporte para adaptadores de Adaptação de Baixa Ordem (LoRA) específicos para cada tarefa. Ele funciona bem para descrições curtas de produtos, prontamente utilizável. Saiba mais sobre o modelo jina-embeddings-v5-text aqui.
Passo 2: criar o índice com um campo semântico
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}")O tipo de campo semantic_text é essencial aqui. É uma abstração de nível mais alto sobre dense_vector: você aponta para um endpoint de inferência, e o Elasticsearch cuida de gerar automaticamente os embeddings.
A propriedade copy_to em title e description significa que o conteúdo de ambos os campos flui para semantic_field para incorporação, de modo que um único vetor captura a representação completa do produto.
Passo 3: indexar os produtos
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)No momento do índice, o Elasticsearch chama o endpoint de inferência para cada documento e armazena a incorporação resultante em semantic_field. Sem código extra do seu lado.
Busca híbrida: combinando BM25 e vetores com RRF
Adicionar vetores melhora a recuperação, mas usar vetores sozinhos pode prejudicar a precisão em consultas de correspondência exata; "tênis de corrida" ainda deve priorizar correspondências exatas. A busca híbrida mantém o componente léxico especificamente para preservar essa precisão.
A busca híbrida com Fusão de Classificação Recíproca (RRF) mantém o melhor dos dois mundos:
O BM25 lida com consultas exatas e quase exatas com alta precisão.
A busca semântica processa consultas baseadas em intenção e multilíngues com alta precisão.
O RRF combina as duas listas classificadas em uma única classificação.
A fórmula RRF atribui a cada documento uma pontuação baseada em sua classificação em cada lista de resultados:
score = sum(1 / (rank_constant + rank))Um documento bem classificado em ambas as listas recebe uma pontuação combinada maior. O rank_constant controla quanto peso documentos de menor classificação recebem.
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"])Resultado:
Hybrid Recall@10: 0.75O Hybrid melhora substancialmente em relação ao BM25 (0.43) e preserva a precisão para consultas de correspondência exata como "tênis de corrida".
Resultados: Antes e depois
Eis a comparação completa entre as três abordagens:
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))Resultado:
Método | Recall@10 |
|---|---|
BM25 (Léxico) | 0,43 |
Híbrido (BM25 + Vetores) | 0,75 |

Analisando por consulta:

Conclusão
Ao longo deste artigo, vimos que a busca léxica do BM25 é confiável quando os usuários digitam consultas exatas, mas perde a capacidade de recuperação quando buscam por intenção em vez de palavras-chave. Usando rank_eval, estabelecemos uma linha base reprodutível para medir essa lacuna com números reais. A partir daí, adicionamos um campo semantic_text alimentado por embeddings Jina e rodamos a avaliação novamente. O resultado: a buscar híbrida melhorou a capacidade de recuperação de 0.43 para 0.75 enquanto preservava a precisão nas consultas de correspondência exata, embora a margem real dependa da sua mistura de consultas.
O padrão se estende além deste exemplo: colete julgamentos das consultas reais de seus usuários, execute rank_eval como linha de base, adicione semantic_text e meça novamente. Você saberá exatamente o que melhorou e em quanto.
Próximas etapas
Aprofunde-se no recall e na busca vetorial: quantização de busca vetorial e recall, de Jeff Vestal
Adicione o reranking para melhorar ainda mais a precisão nos resultados principais
Conteúdo relacionado




