• 文章
  • What is a Sparse Vector? How to Achieve Vector-based Hybrid Search
返回到向量搜索手册

什么是稀疏向量?如何实现基于向量的混合搜索

Nirant Kasliwal

·

2023年12月09日

What is a Sparse Vector? How to Achieve Vector-based Hybrid Search

想象一个拥有庞大索引卡系统的图书馆。每张索引卡只标记出每本书(文档)的大量可能集合中的几个关键词(稀疏向量)。这就是稀疏向量在文本中所能实现的。

稀疏向量和密集向量是什么?

稀疏向量就像数据中的Marie Kondo——仅保留能激发快乐(或者在这种情况下,相关性)的部分。

考虑一个简化的例子,有两个文档,每个文档有200个单词。稠密向量会有几百个非零值,而稀疏向量可能会有更少,比如只有20个非零值。

在这个例子中:我们假设它只从每个文档中选择2个单词或标记。其余的值为零。这就是为什么它被称为稀疏向量。

dense = [0.2, 0.3, 0.5, 0.7, ...]  # several hundred floats
sparse = [{331: 0.5}, {14136: 0.7}]  # 20 key value pairs

数字331和14136映射到词汇表中的特定标记,例如['chocolate', 'icecream']。其余的值为零。这就是为什么它被称为稀疏向量。

这些标记不总是单词,有时它们可以是子词: ['ch', 'ocolate'] 也是。

它们在信息检索中至关重要,特别是在排名和搜索系统中。BM25是搜索引擎如Elasticsearch使用的标准排名函数,举例说明。BM25计算文档与给定搜索查询的相关性。

BM25的能力是众所周知的,但它也有其局限性。

BM25仅依赖于文档中单词的频率,而不试图理解单词的含义或上下文的重要性。此外,它需要提前计算整个语料库的统计信息,这对大型数据集来说是一个挑战。

稀疏向量利用神经网络的力量克服这些限制,同时保留查询确切单词和短语的能力。它们在处理大文本数据方面表现出色,使其在现代数据处理中至关重要,并在传统方法如BM25上标志着一种进步。

理解稀疏向量

稀疏向量是一种表示方式,其中每个维度对应一个词或子词,极大地帮助了解文档排名。这种清晰度是稀疏向量在现代搜索和推荐系统中不可或缺的原因,补充了富含意义的嵌入或密集向量。

来自像OpenAI Ada-002或句子变换器模型的稠密向量对每个元素都包含非零值。相比之下,稀疏向量专注于每个文档的相对词权重,大多数值为零。这导致了一个更高效和可解释的系统,特别是在像搜索这样的文本密集型应用中。

稀疏向量在许多稀有关键词或专业术语存在的领域和场景中表现出色。比如,在医学领域,许多稀有术语在一般词汇中并不存在,因此通用的密集向量无法捕捉到该领域的细微差别。

特性稀疏向量密集向量
数据表示元素多数为零所有元素均为非零
计算效率通常较高,特别是在涉及零元素的操作中较低,因为操作是对所有元素进行的
信息密度较低密度,专注于关键特征高度密度,捕捉细微关系
示例应用文本搜索, 混合搜索RAG, 许多通用机器学习任务

稀疏向量在哪些方面失效呢?它们在捕捉单词之间细微关系方面表现不佳。例如,它们无法像密集向量那样捕捉“king”和“queen”之间的关系。

SPLADE

让我们来看看 SPLADE,一种制作稀疏向量的优秀方式。我们先看一些数字。越高越好:

模型MRR@10 (MS MARCO 开发)类型
BM250.184稀疏
TCT-ColBERT0.359稠密
文档到查询-T5 link0.277稀疏
SPLADE0.322稀疏
SPLADE-max0.340稀疏
SPLADE-doc0.322稀疏
DistilSPLADE-max0.368稀疏

所有数字来自 SPLADEv2。MRR是平均倒数排名,这是一个用于排序的标准指标。MS MARCO 是一个用于评估段落的排序和检索的数据集。

SPLADE是一种相当灵活的方法,具有可调节的正则化参数,可以获得不同的模型

SPLADE 更像是一类模型,而非单一的模型:根据正则化的大小,我们可以获得不同的模型(从非常稀疏到进行强烈查询/文档扩展的模型),具有不同的特性和性能。

首先,让我们看看如何创建一个稀疏向量。然后,我们将研究SPLADE背后的概念。

创建稀疏向量

我们将探讨两种不同的方法来创建稀疏向量。更高性能的方法是从专用文档和查询编码器创建稀疏向量。我们将看一个更简单的方法 - 在这里我们将对文档和查询使用相同的模型。我们将获得一个包含标记ID及其对应权重的字典,代表一个示例文本 - 代表一个文档。

如果你想一起学习,这里有一个 Colab 笔记本备用链接,里面包含所有代码。

设置

from transformers import AutoModelForMaskedLM, AutoTokenizer

model_id = "naver/splade-cocondenser-ensembledistil"

tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForMaskedLM.from_pretrained(model_id)

text = """Arthur Robert Ashe Jr. (July 10, 1943 – February 6, 1993) was an American professional tennis player. He won three Grand Slam titles in singles and two in doubles."""

计算稀疏向量

import torch


def compute_vector(text):
    """
    Computes a vector from logits and attention mask using ReLU, log, and max operations.
    """
    tokens = tokenizer(text, return_tensors="pt")
    output = model(**tokens)
    logits, attention_mask = output.logits, tokens.attention_mask
    relu_log = torch.log(1 + torch.relu(logits))
    weighted_log = relu_log * attention_mask.unsqueeze(-1)
    max_val, _ = torch.max(weighted_log, dim=1)
    vec = max_val.squeeze()

    return vec, tokens


vec, tokens = compute_vector(text)
print(vec.shape)

您会注意到,根据该分词器,文本中有38个标记。这将与向量中的标记数量不同。在TF-IDF中,我们只会将权重分配给这些标记或单词。在SPLADE中,我们使用学习得到的模型对词汇表中的所有标记分配权重。

术语扩展和权重

def extract_and_map_sparse_vector(vector, tokenizer):
    """
    Extracts non-zero elements from a given vector and maps these elements to their human-readable tokens using a tokenizer. The function creates and returns a sorted dictionary where keys are the tokens corresponding to non-zero elements in the vector, and values are the weights of these elements, sorted in descending order of weights.

    This function is useful in NLP tasks where you need to understand the significance of different tokens based on a model's output vector. It first identifies non-zero values in the vector, maps them to tokens, and sorts them by weight for better interpretability.

    Args:
    vector (torch.Tensor): A PyTorch tensor from which to extract non-zero elements.
    tokenizer: The tokenizer used for tokenization in the model, providing the mapping from tokens to indices.

    Returns:
    dict: A sorted dictionary mapping human-readable tokens to their corresponding non-zero weights.
    """

    # Extract indices and values of non-zero elements in the vector
    cols = vector.nonzero().squeeze().cpu().tolist()
    weights = vector[cols].cpu().tolist()

    # Map indices to tokens and create a dictionary
    idx2token = {idx: token for token, idx in tokenizer.get_vocab().items()}
    token_weight_dict = {
        idx2token[idx]: round(weight, 2) for idx, weight in zip(cols, weights)
    }

    # Sort the dictionary by weights in descending order
    sorted_token_weight_dict = {
        k: v
        for k, v in sorted(
            token_weight_dict.items(), key=lambda item: item[1], reverse=True
        )
    }

    return sorted_token_weight_dict


# Usage example
sorted_tokens = extract_and_map_sparse_vector(vec, tokenizer)
sorted_tokens

总共有102个排序过的标记。这已经扩展到包括原始文本中没有的标记。这是我们接下来要讨论的术语扩展。

这里添加了一些术语:“柏林”和“创始人” - 尽管没有提到亚瑟的种族(这导致了欧文在柏林的胜利)以及他作为亚瑟·阿什城市健康研究所创始人的工作。以下是几个权重超过1的 sorted_tokens

{
    "ashe": 2.95,
    "arthur": 2.61,
    "tennis": 2.22,
    "robert": 1.74,
    "jr": 1.55,
    "he": 1.39,
    "founder": 1.36,
    "doubles": 1.24,
    "won": 1.22,
    "slam": 1.22,
    "died": 1.19,
    "singles": 1.1,
    "was": 1.07,
    "player": 1.06,
    "titles": 0.99, 
    ...
}

如果您有兴趣使用更高性能的方法,请查看以下模型:

  1. naver/efficient-splade-VI-BT-large-doc
  2. naver/efficient-splade-VI-BT-large-query

为什么SPLADE有效:术语扩展

考虑一个查询“太阳能优势”。SPLADE 可能会扩展这包括“可再生”、“可持续”和“光伏”等术语,这些术语在语境上是相关的,但并未被明确提及。这个过程被称为术语扩展,它是 SPLADE 的一个关键组成部分。

SPLADE学习查询/文档扩展以包括其他相关术语。这是相较于其他稀疏方法的一个关键优势,后者仅包含确切的单词,但完全忽略了上下文相关的词。

这种扩展与我们在制作SPLADE模型时可以控制的因素直接相关:通过正则化来实现稀疏性。我们用来表示每个文档的词元数量(BERT字词)。如果我们使用更多的词元,我们可以表示更多的术语,但向量会变得更密集。这个数字通常在每个文档20到200之间。作为参考,稠密BERT向量是768维,OpenAI嵌入是1536维,而稀疏向量是30维。

例如,假设有一个1M的文档语料库。假设我们每个文档使用100个稀疏令牌ID + 权重。相应地,密集的BERT向量将是768M浮点数,OpenAI Embedding将是1.536B浮点数,而稀疏向量最多将是100M整数 + 100M浮点数。这可能意味着内存使用量减少10倍,这对大型系统来说是一个巨大的胜利:

向量类型内存 (GB)
稠密BERT向量6.144
OpenAI 嵌入12.288
稀疏向量1.12

SPLADE是如何工作的:利用BERT

SPLADE 利用变换器架构生成文档和查询的稀疏表示,实现高效检索。让我们深入了解这个过程。

变换器主干输出的logits是SPLADE构建的输入。变换器架构可以是像BERT这样的熟悉结构。SPLADE利用这些logits构建稀疏向量,而不是产生密集的概率分布——可以将它们视为令牌的精华,每个维度对应于词汇表中的一个术语及其在给定文档或查询上下文中的相关权重。

这种稀疏性是至关重要的;它反映了典型的 掩码语言建模 任务中的概率分布,但针对检索效果进行了调整,强调了同时具有以下特征的术语:

  1. 上下文相关:能够很好地代表文档的术语应给予更多的权重。
  2. 在文档之间具有区分性:一个文档具有而其他文档没有的术语应该被赋予更大的权重。

在标准的变压器模型中,你所期望的词元级分布现在被转换为SPLADE中的词元级重要性分数。这些分数反映了每个术语在文档或查询上下文中的重要性,指导模型将更多的权重分配给那些在检索过程中可能更有意义的术语。

生成的稀疏向量不仅内存高效,而且针对像 Qdrant 这样的搜索引擎在高维空间中的精确匹配进行了优化。

解释SPLADE

密集向量的一个缺点是它们不可解释,这使得理解为什么一个文档与查询相关变得困难。

SPLADE 重要性估计可以提供有关文档与查询相关性的“为什么”的洞察。通过阐明哪些标记对检索分数贡献最大,SPLADE 在性能的同时提供了一定程度的可解释性,这是神经信息检索系统领域中一种罕见的成就。对于从事搜索的工程师而言,这种透明度是无价的。

SPLADE的已知限制

池化策略

在SPLADE中切换到最大池化提高了其在MS MARCO和TREC数据集上的性能。然而,这表明基线SPLADE池化方法的潜在限制,暗示SPLADE的性能对池化策略的选择非常敏感​​。

文档和查询编码器

SPLADE模型变体使用最大池化的文档编码器,但没有查询编码器,达到了与先前SPLADE模型相同的性能水平。这表明查询编码器的必要性可能存在限制,可能影响模型的效率。

其他稀疏向量方法

SPLADE 不是创建稀疏向量的唯一方法。

本质上,稀疏向量是 TF-IDF 和 BM25 的超集,这两者是最流行的文本检索方法。换句话说,您可以使用词频和逆文档频率 (TF-IDF) 创建稀疏向量,以精确复现 BM25 分数。

此外,来自句子变换器的注意力权重可以用来创建稀疏向量。 这种方法保持了查询确切单词和短语的能力,但避免了在SPLADE中使用的查询扩展所带来的计算开销。

我们将在未来的文章中详细介绍这些方法。

Qdrant 支持稀疏向量的单独索引。这使您可以在同一个集合中同时使用稠密向量和稀疏向量。Qdrant 中的每个“点”都可以同时具有稠密向量和稀疏向量。

但让我们先看看您如何在Qdrant中使用稀疏向量。

在Python中的实际实现

让我们通过一个例子深入了解Qdrant如何处理稀疏向量。我们将涵盖以下内容:

  1. 设置 Qdrant 客户端:首先,我们使用 QdrantClient 建立与 Qdrant 的连接。这个设置对于后续操作至关重要。

  2. 创建一个支持稀疏向量的集合:在Qdrant中,集合是您的向量的容器。在这里,我们创建一个专为支持稀疏向量而设计的集合。这是通过使用create_collection方法完成的,我们在此定义稀疏向量的参数,例如设置索引配置。

  3. 插入稀疏向量:一旦集合设置完成,我们就可以将稀疏向量插入其中。这涉及到使用它的索引和数值定义稀疏向量,然后将这个点插入到集合中。

  4. 使用稀疏向量查询:要进行搜索,我们首先准备一个查询向量。这涉及从查询文本计算向量并提取其索引和值。然后,我们使用这些细节在我们的集合中构建查询。

  5. 检索和解释结果:搜索操作返回的结果包括匹配文档的id、其分数和其他相关细节。分数是一个关键方面,反映了查询与集合中的文档之间的相似性。

1. 设置

# Qdrant client setup
client = QdrantClient(":memory:")

# Define collection name
COLLECTION_NAME = "example_collection"

# Insert sparse vector into Qdrant collection
point_id = 1  # Assign a unique ID for the point

2. 创建一个支持稀疏向量的集合

client.create_collection(
    collection_name=COLLECTION_NAME,
    vectors_config={},
    sparse_vectors_config={
        "text": models.SparseVectorParams(
            index=models.SparseIndexParams(
                on_disk=False,
            )
        )
    },
)

3. 插入稀疏向量

在这里,我们看到将稀疏向量插入Qdrant集合的过程。这一步对于构建一个可以在检索过程的第一阶段快速检索的数据集至关重要,利用稀疏向量的效率。由于这是为了演示的目的,我们仅插入一个稀疏向量的点,而没有稠密向量。

client.upsert(
    collection_name=COLLECTION_NAME,
    points=[
        models.PointStruct(
            id=point_id,
            payload={},  # Add any additional payload if necessary
            vector={
                "text": models.SparseVector(
                    indices=indices.tolist(), values=values.tolist()
                )
            },
        )
    ],
)

通过使用稀疏向量插入点,我们为快速的第一阶段检索准备了数据集,为后续使用密集向量进行详细分析打下基础。注意,我们使用“text”来表示稀疏向量的名称。

熟悉 Qdrant API 的人会注意到,为了与现有的命名向量 API 一致而采取的额外措施 - 这样可以更容易地在现有代码库中使用稀疏向量。与以往一样,您可以 应用有效载荷过滤器、分片键,以及您期待的其他高级功能。为了让您更轻松,索引和值不必在插入之前进行排序。Qdrant 会在索引持久化时对其进行排序,例如在磁盘上。

4. 使用稀疏向量查询

我们使用相同的过程来准备查询向量。这涉及从查询文本计算向量并提取其索引和值。然后,我们使用这些细节构建针对我们集合的查询。

# Preparing a query vector

query_text = "Who was Arthur Ashe?"
query_vec, query_tokens = compute_vector(query_text)
query_vec.shape

query_indices = query_vec.nonzero().numpy().flatten()
query_values = query_vec.detach().numpy()[query_indices]

在这个例子中,我们对文档和查询使用相同的模型。这不是一个要求,但这是一个更简单的方法。

5. 获取和解释结果

在设置集合并插入稀疏向量之后,下一步关键是检索和解释结果。这个过程涉及执行搜索查询,然后分析返回的结果。

# Searching for similar documents
result = client.search(
    collection_name=COLLECTION_NAME,
    query_vector=models.NamedSparseVector(
        name="text",
        vector=models.SparseVector(
            indices=query_indices,
            values=query_values,
        ),
    ),
    with_vectors=True,
)

result

在上述代码中,我们使用准备好的稀疏向量查询对我们的集合进行搜索。client.search 方法将集合名称和查询向量作为输入。查询向量是使用 models.NamedSparseVector 构造的,其中包括来自查询文本的索引和值。这是在高效检索相关文档中的关键步骤。

ScoredPoint(
    id=1,
    version=0,
    score=3.4292831420898438,
    payload={},
    vector={
        "text": SparseVector(
            indices=[2001, 2002, 2010, 2018, 2032, ...],
            values=[
                1.0660614967346191,
                1.391068458557129,
                0.8903818726539612,
                0.2502821087837219,
                ...,
            ],
        )
    },
)

上面显示的结果是一个 ScoredPoint 对象,包含检索到的文档的ID、版本、相似度分数和稀疏向量。分数是一个关键元素,因为它量化了查询与文档之间的相似度,基于它们各自的向量。

要理解这个评分是如何工作的,我们使用熟悉的点积方法:

相似度(查询, 文档) = ∑i ∈ I 查询i × 文档i

这个公式通过将查询向量和文档向量的相应元素相乘并求和来计算相似度分数。该方法在稀疏向量中尤其有效,其中许多元素为零,从而导致计算过程高效。分数越高,查询与文档之间的相似度就越大,这使其成为评估检索文档相关性的宝贵指标。

混合搜索:结合稀疏向量和密集向量

通过结合稠密向量和稀疏向量的搜索结果,您可以实现一种高效且准确的混合搜索。 稀疏向量的结果将保证返回所有包含所需关键字的结果, 而稠密向量将涵盖语义上相似的结果。

稠密和稀疏结果的混合可以直接呈现给用户,或用作两阶段检索过程的第一阶段。

让我们看看如何在Qdrant中进行混合搜索查询。

首先,您需要创建一个包含稠密和稀疏向量的集合:

client.create_collection(
    collection_name=COLLECTION_NAME,
    vectors_config={
        "text-dense": models.VectorParams(
            size=1536,  # OpenAI Embeddings
            distance=models.Distance.COSINE,
        )
    },
    sparse_vectors_config={
        "text-sparse": models.SparseVectorParams(
            index=models.SparseIndexParams(
                on_disk=False,
            )
        )
    },
)

然后,假设您已经插入了稠密和稀疏向量,您可以一起查询它们:

query_text = "Who was Arthur Ashe?"

# Compute sparse and dense vectors
query_indices, query_values = compute_sparse_vector(query_text)
query_dense_vector = compute_dense_vector(query_text)


client.search_batch(
    collection_name=COLLECTION_NAME,
    requests=[
        models.SearchRequest(
            vector=models.NamedVector(
                name="text-dense",
                vector=query_dense_vector,
            ),
            limit=10,
        ),
        models.SearchRequest(
            vector=models.NamedSparseVector(
                name="text-sparse",
                vector=models.SparseVector(
                    indices=query_indices,
                    values=query_values,
                ),
            ),
            limit=10,
        ),
    ],
)

结果将是一个结果列表的配对,一个用于稠密向量,一个用于稀疏向量。

拥有这些结果,有几种方式可以将它们组合起来:

混合或融合

您可以根据它们的相对得分混合稠密和稀疏向量的结果。这是一个简单有效的方法,但它没有考虑结果之间的语义相似性。流行的混合方法包括:

- Reciprocal Ranked Fusion (RRF)
- Relative Score Fusion (RSF)
- Distribution-Based Score Fusion (DBSF)
Relative Score Fusion

相对评分融合

Ranx 是一个很好的库,用于混合来自不同来源的结果。

重排序

您可以将获得的结果用作两阶段检索过程的第一阶段。在第二阶段,您可以使用更复杂的模型(例如 交叉编码器 或像 Cohere Rerank 这样的服务)对第一阶段的结果进行重新排序。

就是这样!您已成功使用 Qdrant 实现混合搜索!

附加资源

对于那些想要深入了解的人,这里是关于该主题的顶尖论文,大多数都有可用的代码:

  1. 问题动机: 稀疏过完备词向量表示
  2. SPLADE v2: 稀疏词汇和扩展模型用于信息检索
  3. SPLADE: 稀疏词汇和扩展模型用于初始阶段排名
  4. 晚期互动 - ColBERTv2: 通过轻量级晚期互动实现有效和高效的检索
  5. SparseEmbed: 利用上下文嵌入学习稀疏词汇表示以用于检索

为什么仅仅阅读而不尝试一下呢?

我们为您准备了一个易于使用的Colab,介绍如何制作稀疏向量:稀疏向量单编码器演示。运行它,尝试一下,开始在您的项目中看到魔法的展开。我们迫不及待想听到您是如何使用它的!

结论

好的,大家,咱们来总结一下。更好的搜索不是“可有可无”的,而是改变游戏规则的因素,而Qdrant可以帮助你实现这一点。

有问题吗?我们的Discord社区充满了答案。

如果您喜欢阅读这个,为什么不注册我们的新闻通讯以保持领先地位呢。

当然,特别感谢你们,我们的读者,推动我们使排名更好,造福每个人。

这个页面有用吗?

感谢您的反馈!🙏

我们很遗憾听到这个消息。 😔 你可以 编辑 这个页面在 GitHub上,或者 create 一个 GitHub 问题。