如何衡量和提升 Elasticsearch 搜索召回率:通过混合搜索将召回率从 0.43 提升至 0.75
了解如何通过将 BM25 词汇搜索与 Jina AI 向量嵌入相结合来测量和提高 Elasticsearch 中的搜索召回率,并使用 rank_eval API 以实际数据验证改进效果。
Elasticsearch is packed with new features to help you build the best search solutions for your use case. Learn how to put them into action in our hands-on webinar on building a modern Search AI experience. You can also start a free cloud trial or try Elastic on your local machine now.
词汇搜索使用 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
添加重排序功能,以进一步提升前几条结果的精准度




