博客

如何衡量和提升 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,并理解其原因。

什么是召回?

召回率01 的范围来衡量用户真正想要的文档有多少出现在搜索结果中。如果某个查询应显示三个产品,而您的搜索结果仅有两个进入前 10 名,则该查询的得分为 recall@10 = 0.67。这是一个基于集合的指标:它并不关心相关文档在这 k 个结果中的位置。位置 10 的相关文档与位置 1 的相关文档具有同等效力。高召回率意味着您不会丢失相关结果。

维恩图展示了如何计算 Recall@10,通过显示所有相关文档与 BM25 检索出的前 10 个结果的重叠情况,得出 Recall@10 得分为 0.40。

该图表显示了两组文档:所有相关文档(左侧)和 BM25 实际检索到的文档(前 10 个,右侧)。只有交集部分才计入召回率,找到了 prod_1prod_2,而 prod_3prod_4prod_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 可以很好处理的查询(产品标题中的精确标记),而 q2q3q4 是基于意图的查询,用户的意图是以概念而非具体产品关键词来表达的。

测量 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.43

0.43 这意味着在所有四个查询中,BM25 只找到了它应该找到的文档的 43%。这种不足集中体现在基于意图的查询中:“护肤流程”漏掉了面部精华液和身体精油,因为“流程”一词从未出现在产品标题中;而“宠物旅行配件”则检索出了一些不相关的宠物产品,却遗漏了那些以便携性和安全性而非“旅行配件”来描述的宠物笼和宠物箱。

这就是我们的基准。现在我们有了一个要超越的数字。

使用 Jina 嵌入添加向量搜索

Vector search 将文档和查询编码为高维向量,这是一种由数百甚至数千个数值组成的向量,每个数值都对它所代表的数据的特定特征进行编码。意义相似的文档最终会在向量空间中靠近,即使它们没有共同的词汇。“健身器材”和“哑铃套装”会放在一起,因为这两个概念是相关的。我选择 Elasticsearch 作为我的向量数据库,是因为它支持混合搜索,让我既能理解语义,又能精确查找关键字。

EIS 包括通过其推理 API 嵌入模型的开箱即用支持。

步骤 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 会自动生成嵌入。

titledescription 上的 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 词汇搜索和 BM25 与向量相结合的混合搜索的 Recall@10,结果显示混合搜索的召回率明显更高。

按查询细分:

分组条形图比较了四个产品查询中 BM25 词法搜索和混合搜索的 Recall@10,显示混合搜索在每个查询中始终优于词法搜索。

结论

在这篇文章中,我们看到,当用户键入精确的查询时,BM25 词汇搜索是可靠的,但当他们根据意图而非关键词进行搜索时,其召回率就会下降。借助 rank_eval,我们建立了一个可重复的基线,用真实数据来衡量这一差距。在此基础上,我们添加了一个由 Jina 嵌入提供支持的 semantic_text 字段,并再次运行了评估。结果:混合搜索将召回率从 0.43 提高到 0.75,同时保留了精确匹配查询的精确度,但实际幅度取决于您的查询组合。

该模式可扩展至本示例之外:从用户的实际查询中收集判断,以 rank_eval 作为基准运行,添加 semantic_text,然后再次进行测量。您将确切了解改进了哪些方面以及改进了多少。

后续步骤

相关内容

使用 Elasticsearch 解决实体问题,第 4 部分:终极挑战

Jessica Moszkowicz

你懂的,语境--第三部分:混合搜索在语境工程中的威力

Woody Walton

使用 Elasticsearch 与 LLM 进行实体解析,第 2 部分:通过 LLM 判断和语义搜索匹配实体

Jessica Moszkowicz

你懂的,语境--第一部分:混合搜索和语境工程的演变

Woody Walton

混合搜索不头疼:用检索器简化混合搜索

Mike Pellegrini

准备好打造最先进的搜索体验了吗?

足够先进的搜索不是一个人的努力就能实现的。Elasticsearch 由数据科学家、ML 操作员、工程师以及更多和您一样对搜索充满热情的人提供支持。让我们联系起来,共同打造神奇的搜索体验,让您获得想要的结果。

亲自试用