如何衡量和提升 Elasticsearch 搜索召回率:通过混合搜索将召回率从 0.43 提升至 0.75
了解如何通过将 BM25 词汇搜索与 Jina AI 向量嵌入相结合来测量和提高 Elasticsearch 中的搜索召回率,并使用 rank_eval API 以实际数据验证改进效果。
词汇搜索使用 BM25 排序算法,对于各种查询来说成本低、速度快且非常有效。但它有一个盲点:无法处理与文档没有共同标记的查询。在本文中,您将准确衡量 BM25 的不足之处。我们将使用 Elasticsearch 的排名评估 API (rank_eval),并通过添加 Jina AI 嵌入,通过 Elastic 推理服务 (EIS) 来缩小这一差距。您会看到召回分数从 0.43 提升到 0.75,并理解其原因。
什么是召回?
召回率 以 0 到 1 的范围来衡量用户真正想要的文档有多少出现在搜索结果中。如果某个查询应显示三个产品,而您的搜索结果仅有两个进入前 10 名,则该查询的得分为 recall@10 = 0.67。这是一个基于集合的指标:它并不关心相关文档在这 k 个结果中的位置。位置 10 的相关文档与位置 1 的相关文档具有同等效力。高召回率意味着您不会丢失相关结果。

该图表显示了两组文档:所有相关文档(左侧)和 BM25 实际检索到的文档(前 10 个,右侧)。只有交集部分才计入召回率,找到了 prod_1 和 prod_2,而 prod_3、prod_4 和 prod_6 则完全遗漏。结果:Recall@10 = 2/5 = 0.40。
准备工作
让我们言归正传,更好地了解召回的工作原理。本演示使用 Python。您可以在配套笔记本 (notebook.ipynb) 中跟着操作,其中每个代码块都是一个可直接运行的单元。
提供的代码使用以下内容:
Elasticsearch 9.3+
Python 3.10+
pip install elasticsearch pandas plotly python-dotenv包含 Elasticsearch 凭据的
.env文件
ELASTICSEARCH_URL=https://your-cluster-url
ELASTICSEARCH_API_KEY=your-api-key该数据集
我们将使用包含 1,000 种产品的产品目录,涵盖鞋类、电子产品、工具等多个类别。
每份文档有四个字段:
字段 | 类型 |
|---|---|
“标题” | 文本 |
“描述” | 文本 |
“品牌” | 关键字 |
`类别` | 关键字 |
该数据集加载自 dataset.csv。
词汇搜索的支持和局限性
BM25 是 Elasticsearch 和大多数搜索引擎的默认排名算法。它根据查询词在文档中的出现频率对其进行评分,并根据文档长度和这些词在整个索引中的出现频率进行调整。在此基础上,您还可以获得分析器:小写规范化、词干提取和停用词消除。查询“跑步鞋”将匹配“跑步鞋”,也可能匹配“跑步”。
这对很多查询都很有效:
“跑鞋”会立即匹配标题中包含这些确切标记的产品。
“蓝牙扬声器”会显示便携式音频产品,因为这些词语是逐字匹配的。
搜索结果具有确定性和可解释性:文档排名靠前,是因为查询词出现在其中。调试相关性很简单。
出现问题的地方
现在,让我们针对同一目录尝试这些查询:
“护肤流程”:在任何产品标题中都没有出现“流程”这个词。BM25 能够部分匹配“护肤”这一词,但面部精华液、身体精油和保湿霜等产品是用“维生素 C”、“视黄醇”或“提亮”等术语来描述的,这些术语与查询词都没有重叠。构成完整护肤流程的产品分散在索引中,没有任何共同的令牌将其关联起来。
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“宠物旅行配件”:这是一个用例分组,而非产品类别。宠物狗背带、宠物汽车座椅和旅行笼都与此相关,但它们的描述侧重于便携性、安全性和舒适性,而非“旅行配件”。BM25 与“宠物”大致匹配,但无法区分旅行专用产品与宠物目录中的其他产品。
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 Oz这是一个召回问题。相关文档已存在于您的索引中。BM25 无法找到它们,因为用户的用词和文档中的词语匹配度不够高。
添加同义词有助于处理已知情况。但您无法枚举用户表达某种意图的所有方式。这就是向量发挥作用的地方。
为何要测量召回率
在解决问题之前,需要先对问题进行量化。
Recall@k 衡量有多少用户真正想要的文档出现在搜索结果中。正式来说:
Recall@k = (relevant documents found in top k) / (total relevant documents)Precision@k 衡量前 k 个结果,以及其中有多少是实际相关的:
Precision@k = (relevant documents in top k) / k高精度意味着您返回的结果质量较高。在电子商务领域,缺少相关产品(召回率低)通常比显示稍有瑕疵的结果(精度较低)更糟糕,因为隐藏的产品意味着销售损失。
Elasticsearch 的 rank_eval API 允许您系统地测量两者。您提供一系列查询,每个查询都有一组已评分的文档,Elasticsearch 会为您计算所有查询的指标。
设置评估
rank_eval API 需要一个评级数据集:查询与每个查询相关的文档之间的映射,以及相关性等级(0=不相关,1=相关,2=高度相关)。
在笔记本中,这是判断列表:
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"},
]这种混合是有意为之:q1 是 BM25 可以很好处理的查询(产品标题中的精确标记),而 q2、q3 和 q4 是基于意图的查询,用户的意图是以概念而非具体产品关键词来表达的。
测量 BM25 基线召回率
首先,设置 Elasticsearch 客户端,并对原始文本数据建立索引:
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"现在为 BM25 构建 rank_eval 请求。列表中的每个请求都将会查询及其评分结合起来:
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"])结果:
BM25 Recall@10: 0.430.43 这意味着在所有四个查询中,BM25 只找到了它应该找到的文档的 43%。这种不足集中体现在基于意图的查询中:“护肤流程”漏掉了面部精华液和身体精油,因为“流程”一词从未出现在产品标题中;而“宠物旅行配件”则检索出了一些不相关的宠物产品,却遗漏了那些以便携性和安全性而非“旅行配件”来描述的宠物笼和宠物箱。
这就是我们的基准。现在我们有了一个要超越的数字。
使用 Jina 嵌入添加向量搜索
Vector search 将文档和查询编码为高维向量,这是一种由数百甚至数千个数值组成的向量,每个数值都对它所代表的数据的特定特征进行编码。意义相似的文档最终会在向量空间中靠近,即使它们没有共同的词汇。“健身器材”和“哑铃套装”会放在一起,因为这两个概念是相关的。我选择 Elasticsearch 作为我的向量数据库,是因为它支持混合搜索,让我既能理解语义,又能精确查找关键字。
步骤 1:使用 Jina 嵌入 v5 作为推理终端
INFERENCE_ENDPOINT_ID = ".jina-embeddings-v5-text-small"如果您的集群具有 GPU 资源(在 Elastic Cloud 和 Elasticsearch 9.3+ 中可用),嵌入将在 GPU 上生成,这比 CPU 推理快得多,并消除了历史上使向量在扩展时变得昂贵的性能权衡。
为什么要特别选用 Jina 嵌入?jina-embeddings-v5-text 是一种多语言模型(支持 119 种以上语言),具有 32,000 个标记的上下文窗口,并支持特定任务的低秩自适应 (LoRA) 适配器。它适用于开箱即用的简短产品描述。点击此处了解有关 jina-embeddings-v5-text 模型的更多信息。
步骤 2:创建具有语义字段的索引
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}")这里的关键在于 semantic_text 字段类型。这是对 dense_vector 的更高级别的抽象:您将其指向一个推理终端,Elasticsearch 会自动生成嵌入。
title 和description 上的 copy_to 属性意味着这两个字段的内容都会流入 semantic_field 进行嵌入,因此单个向量就能捕获完整的产品表示。
步骤 3:为产品编制索引
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)索引时,Elasticsearch 会调用每个文档的推理端点,并将生成的嵌入存储在 semantic_field 中。您无需编写任何额外代码。
混合搜索:将 BM25 与向量结合并采用 RRF
添加向量可以提高召回率,但仅使用向量可能会在精确匹配查询中失去精度;“跑鞋”仍应将逐字匹配的结果排在首位。混合搜索则保留词汇成分,以保持这种精确性。
使用倒数排序融合 (RRF) 的混合搜索可以保持两者的优点:
BM25 可以高精度处理精确和近似精确的查询。
语义搜索能以高召回率处理基于意图和多语言的查询。
RRF 将两份排名表合并为一份排名表。
RRF 公式根据每个文档在每个结果列表中的排名,为每个文档分配分数:
score = sum(1 / (rank_constant + rank))在两个列表中均排名靠前的文档将获得更高的综合得分。rank_constant用于控制排名较低的文档获得的权重大小。
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"])结果:
Hybrid Recall@10: 0.75混合搜索在 BM25 (0.43) 的基础上有了显著提升,并为“跑鞋”等精确匹配查询保留了精确度。
结果:前后结果对比
以下是所有三种方法的完整对比:
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))结果:
方法 | Recall@10 |
|---|---|
BM25(词法) | 0.43 |
混合型(BM25 + 向量) | 0.75 |

按查询细分:

结论
在这篇文章中,我们看到,当用户键入精确的查询时,BM25 词汇搜索是可靠的,但当他们根据意图而非关键词进行搜索时,其召回率就会下降。借助 rank_eval,我们建立了一个可重复的基线,用真实数据来衡量这一差距。在此基础上,我们添加了一个由 Jina 嵌入提供支持的 semantic_text 字段,并再次运行了评估。结果:混合搜索将召回率从 0.43 提高到 0.75,同时保留了精确匹配查询的精确度,但实际幅度取决于您的查询组合。
该模式可扩展至本示例之外:从用户的实际查询中收集判断,以 rank_eval 作为基准运行,添加 semantic_text,然后再次进行测量。您将确切了解改进了哪些方面以及改进了多少。
后续步骤
深入了解召回与向量搜索:《召回与向量搜索量化》,作者:Jeff Vestal
添加重排序功能,以进一步提升前几条结果的精准度




