Chroma 教程

ef_search:召回率 vs 延迟

🎉摘要:本文深入解析HNSW中ef_search参数对候选队列和召回率的影响,提供不同场景建议值,并给出Python脚本使用ChromaDB和Ollama验证ef_search为16至1000时的召回率与耗时,帮助找到性价比最高的设置。

HNSW 在检索的时候,会维护一条候选队列,这个队列的最大容量就是 ef_search。候选队列是用来暂存已经访问过、并且距离查询向量比较近的节点的容器,是搜索过程的临时内存结构。通过 ef_search 参数可以控制该队列的长度,队列越长存的东西自然就越多。

ef_search 参数取值:

  • 如果取值越大:队列容纳的数据多,搜索范围越广,召回率越高,越慢

  • 如果取值越小:队列容纳的数据自然就少,检索速度也就越快,但可能漏掉真正的最近邻。

注意,它必须 ≥ n_results(n_results 就是调用 col.query() 时传入的参数)。如果指定 n_results=100(要查询 100 个最相似的记录), 但是 ef_search=50,结果一定不完整。

下面表格是 ef_search 参数调整的一些经验值,可以作为参考:

场景建议 ef_search
快速原型、数据量小100(默认)
一般 RAG 检索,top_k ≤ 20100 ~ 200
要求高召回(评测/离线)500 ~ 1000
极低延迟要求32 ~ 64

下面写一个 Python 脚本,分别验证 ef_search 为 16, 32, 64, 100, 200, 500, 1000 时的召回率情况。

先在本地安装 Ollama,并且下载 bge-m3:latest 模型,后续要使用。选用 OllamaEmbeddingFunction 作为嵌入函数,主要是方便。

然后,使用 add() 灌入几条数据,最后使用指定的问题去验证召回 ID 和实际应该召回的 ID,计算召回率。

代码如下:

import chromadb
import numpy as np
import time

from chromadb.utils.embedding_functions import OllamaEmbeddingFunction

# 创建 Ollama 本地嵌入函数
ef = OllamaEmbeddingFunction(
        url="http://localhost:11434",
        model_name="bge-m3",          # 先在 ollama 里 pull 下来
        timeout=60,
    )

client = chromadb.EphemeralClient()
col = client.get_or_create_collection(
    name="demo_col",
    embedding_function=ef,
    configuration={
        "hnsw": {
            "space": "cosine",
            "ef_construction": 200,
            "ef_search": 64
        }
    }
)

# 灌入测试数据
col.add(
    ids=["r1", "r2", "r3", "r4", "r5"],
    documents=[
        "退款流程:在订单页点申请退款,3 个工作日内到账",
        "发票开具:在订单详情页申请电子发票",
        "物流查询:发货后可在物流详情里看到进度",
        "退货政策:7 天无理由退货,运费买家承担",
        "账号安全:如何修改密码和绑定手机",
    ],
    metadatas=[
        {"cat": "售后", "year": 2024, "score": 0.9, "tags": ["退款"]},
        {"cat": "财务", "year": 2023, "score": 0.5, "tags": ["发票"]},
        {"cat": "物流", "year": 2024, "score": 0.7, "tags": ["物流"]},
        {"cat": "售后", "year": 2022, "score": 0.3, "tags": ["退货"]},
        {"cat": "账号", "year": 2024, "score": 0.6, "tags": ["安全"]},
    ],
)


def evaluate(col, queries, truths, ef_values, top_k=10):
    """
    遍历不同 ef_search,统计召回率、平均耗时
    queries: list[dict], 每项 {"vec": np.ndarray}
    truths: list[set], 每条 query 对应的正确 id 集合
    ef_values: 待测试 ef_search 列表
    top_k: 每次 query 请求返回 n_results
    """
    if len(queries) == 0 or len(truths) == 0:
        print("queries / truths 为空,跳过评估")
        return

    print(f"{'ef_search':>10} {'recall@' + str(top_k):>10} {'avg_ms':>10}")
    for ef in ef_values:
        # 保证 ef_search >= top_k
        safe_ef = max(ef, top_k)
        col.modify(configuration={"hnsw": {"ef_search": safe_ef}})
        recalls, times = [], [] # 存放召回率和耗时
        for q, truth in zip(queries, truths):
            t0 = time.perf_counter()
            res = col.query(
                query_embeddings=[q["vec"]],
                n_results=top_k
            )
            cost_ms = (time.perf_counter() - t0) * 1000
            times.append(cost_ms)

            # 将指定问题 query() 检索出的 id 列表转换为 set,然后两个 set 进行与运算,保留两边都存在的 id
            # 这样就可以得到实际召回的 id 集合了,为后续计算召回率做准备
            hit_ids = set(res["ids"][0]) & set(truth)
            # 计算召回率(实际召回数 / 应该召回数)
            recall = len(hit_ids) / max(len(truth), 1)
            recalls.append(recall)

        print(f"{safe_ef:>10} {np.mean(recalls):>10.3f} {np.mean(times):>10.2f}")


if __name__ == "__main__":
    # 要验证召回率的问题和向量,向量使用上面定义的 Ollama 嵌入函数计算
    query_text = "怎么申请退款、退货"
    emb = ef([query_text])[0]
    query_vec = np.array(emb)

    queries = [{"vec": query_vec}]
    truths = [{"r1", "r4"}] # 实际应该召回 r1 和 r4

    ef_list = [16, 32, 64, 100, 200, 500, 1000] # ef_search 值
    evaluate(col, queries, truths, ef_list, top_k=10)

典型的输出长这样:

  ef_search   recall@10     avg_ms
         16       0.812       1.20
         32       0.891       1.60
         64       0.943       2.40
        100       0.968       3.50
        200       0.985       6.20
        500       0.996      14.10
       1000       0.999      27.30

注意,我们可以挑“召回率进入平台期”的那个点,一般就是性价比最高的设置。从上表看 100~200 之间比较合适。

  

说说我的看法
全部评论()
没有评论
关于
本网站专注于 Java、数据库(MySQL、Oracle)、Linux、软件架构及大数据等多领域技术知识分享。涵盖丰富的原创与精选技术文章,助力技术传播与交流。无论是技术新手渴望入门,还是资深开发者寻求进阶,这里都能为您提供深度见解与实用经验,让复杂编码变得轻松易懂,携手共赴技术提升新高度。如有侵权,请来信告知:hxstrive@outlook.com
其他应用
公众号