• Qdrant 文章
  • 什么是稀疏向量?如何实现基于向量的混合搜索
返回向量搜索手册

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

Nirant Kasliwal

·

2023年12月9日

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

想象一下一个拥有庞大索引卡片系统的图书馆。每本书(文档)在海量的词汇集中,只有少数几个关键词被标记出来(即稀疏向量)。这就是稀疏向量为文本所带来的能力。

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

稀疏向量就像数据界的“近藤麻理惠”(Marie Kondo)——只保留那些能带来愉悦感(或相关性,在本例中)的内容。

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

在这个例子中:我们假设它只从每个文档中选择2个单词或词元(Token)。

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']

它们在信息检索中至关重要,特别是在排序和搜索系统中。由 Elasticsearch 等搜索引擎使用的标准排序函数 BM25 就是一个典型的例子。BM25 计算文档与给定搜索查询之间的相关性。

BM25 的能力已得到公认,但它也有局限性。

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

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

理解稀疏向量

稀疏向量是一种表示方式,其中每个维度对应一个单词或子词,这极大地有助于解释文档排序。这种清晰度是稀疏向量在现代搜索和推荐系统中必不可少的原因,它补充了含义丰富的嵌入(Embedding)或稠密向量。

来自 OpenAI Ada-002 或 Sentence Transformers 等模型的稠密向量包含每个元素的非零值。相比之下,稀疏向量专注于每个文档的相对词权重,大多数值为零。这带来了一个更高效且可解释的系统,尤其是在搜索等文本密集型应用中。

稀疏向量在存在许多罕见关键词或专业术语的领域和场景中表现亮眼。例如,在医学领域,许多罕见术语并未出现在通用词汇表中,因此通用稠密向量无法捕捉到该领域的细微差别。

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

那么稀疏向量在哪里会失败呢?它们在捕捉单词之间的微妙关系方面表现不佳。例如,它们无法像稠密向量那样很好地捕捉“国王”(king)和“王后”(queen)之间的关系。

SPLADE

让我们看看 SPLADE,这是一种生成稀疏向量的出色方法。首先看一些数字。数值越高越好。

模型MRR@10 (MS MARCO 开发集)类型
BM250.184稀疏向量 (Sparse)
TCT-ColBERT0.359稠密
doc2query-T5 链接0.277稀疏向量 (Sparse)
SPLADE0.322稀疏向量 (Sparse)
SPLADE-max0.340稀疏向量 (Sparse)
SPLADE-doc0.322稀疏向量 (Sparse)
DistilSPLADE-max0.368稀疏向量 (Sparse)

所有数字均来自 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 个排序后的词元。它已经扩展到包含原始文本中不存在的词元。这就是我们将要讨论的词项扩展。

以下是一些添加的术语:“Berlin”(柏林)和“founder”(创始人)——尽管文本中并未提及 Arthur 的种族(这导致了 Owen 的柏林胜利)以及他作为 Arthur Ashe 城市健康研究所创始人的工作。以下是权重超过 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 为何有效:词项扩展

考虑一个查询“太阳能优势”(solar energy advantages)。SPLADE 可能会将其扩展为包含“可再生的”(renewable)、“可持续的”(sustainable)和“光伏的”(photovoltaic)等词项,这些词项在语境上是相关的,但未被显式提及。这个过程称为词项扩展,它是 SPLADE 的一个关键组件。

SPLADE 学习查询/文档扩展,以包含其他相关词项。与那些仅包含精确单词却完全忽略语境相关词的其他稀疏方法相比,这是一个关键优势。

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

例如,假设有一个包含 100 万个文档的语料库。假设我们为每个文档使用 100 个稀疏词元 ID + 权重。相应地,稠密 BERT 向量将是 7.68 亿个浮点数,OpenAI 嵌入将是 15.36 亿个浮点数,而稀疏向量最多是 1 亿个整数 + 1 亿个浮点数。这意味着内存使用量减少了 10 倍,这对大规模系统来说是一个巨大的胜利。

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

SPLADE 的工作原理:利用 BERT

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

Transformer 主干网络输出的 Logits 是 SPLADE 构建的基础。Transformer 架构可以是像 BERT 这样熟悉的架构。SPLADE 不会产生稠密的概率分布,而是利用这些 Logits 来构建稀疏向量——将它们想象成词元的提炼精华,其中每个维度对应词汇表中的一个词项,及其在给定文档或查询语境下的相关权重。

这种稀疏性至关重要;它映射了典型的 掩码语言建模 任务中的概率分布,但经过调整以实现检索有效性,强调了以下两点:

  1. 语境相关性:能够很好地代表文档的词项应被赋予更高的权重。
  2. 文档间的判别力:文档有而其他文档没有的词项,应被赋予更高的权重。

在标准 Transformer 模型中预期的词元级分布现在在 SPLADE 中被转换为词元级重要性分数。这些分数反映了每个词项在文档或查询语境下的重要性,引导模型为那些对检索目的更具意义的词项分配更多权重。

生成的稀疏向量不仅内存高效,而且专为像 Qdrant 这样的搜索引擎的高维空间中的精确匹配而定制。

解释 SPLADE

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

SPLADE 重要性评估可以提供对文档与查询相关性背后“原因”的洞察。通过揭示哪些词元对检索分数的贡献最大,SPLADE 在提供性能的同时提供了一定程度的可解释性,这在神经信息检索(Neural IR)领域是难能可贵的。对于从事搜索工作的工程师来说,这种透明度非常宝贵。

SPLADE 的已知局限性

池化策略

转向 SPLADE 中的最大池化(max pooling)提高了它在 MS MARCO 和 TREC 数据集上的性能。然而,这也表明了基线 SPLADE 池化方法的潜在限制,暗示 SPLADE 的性能对池化策略的选择很敏感。

文档和查询编码器

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

其他稀疏向量方法

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

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

此外,来自 Sentence Transformers 的注意力权重也可以用于创建稀疏向量。这种方法保留了查询精确单词和短语的能力,但避免了 SPLADE 中使用的查询扩展的计算开销。

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

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

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

Python 中的实际实现

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

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

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

  3. 插入稀疏向量:一旦集合设置完成,我们就可以向其中插入稀疏向量。这涉及定义带有索引和值的稀疏向量,然后将该点 upsert(插入/更新)到集合中。

  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()
                )
            },
        )
    ],
)

通过 upsert 带有稀疏向量的点,我们为快速的第一阶段检索准备好了数据集,为后续使用稠密向量进行详细分析奠定了基础。请注意,我们使用“text”来命名稀疏向量。

熟悉 Qdrant API 的人会注意到,我们非常小心地与现有的命名向量 API 保持一致——这是为了让在现有代码库中使用稀疏向量变得更容易。一如既往,您可以应用有效负载过滤器、分片键以及您所期望的 Qdrant 的其他高级功能。为了方便起见,索引和值不需要在 upsert 之前进行排序。当索引被持久化(例如在磁盘上)时,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、版本、相似度分数和稀疏向量。分数是一个关键要素,因为它根据各自的向量量化了查询和文档之间的相似度。

为了理解这种评分是如何工作的,我们使用熟悉的点积(dot product)方法:

$$\text{Similarity}(\text{Query}, \text{Document}) = \sum_{i \in I} \text{Query}_i \times \text{Document}_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,
            )
        )
    },
)

然后,假设您已经 upsert 了稠密和稀疏向量,就可以将它们一起查询。

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

相对分数融合(Relative Score Fusion)

Ranx 是一个用于混合不同来源结果的优秀库。

重排序(Re-ranking)

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

就是这样!您已经成功在 Qdrant 中实现了混合搜索!

其他资源

对于那些想要深入研究的人,这里有关于该主题的最重要的论文,其中大多数都提供了代码:

  1. 问题动机:稀疏超完备词向量表示
  2. SPLADE v2:用于信息检索的稀疏词汇和扩展模型
  3. SPLADE:用于第一阶段排序的稀疏词汇和扩展模型
  4. 延迟交互 - ColBERTv2:通过轻量级延迟交互实现有效且高效的检索
  5. SparseEmbed:学习用于检索的带有语境嵌入的稀疏词汇表示

为什么只读而不亲自尝试呢?

我们为您整理了一个简单易用的 Colab,介绍了如何制作稀疏向量:稀疏向量单编码器演示。运行它,摆弄它,并开始见证在您的项目中展现的神奇效果。我们迫不及待地想听到您如何使用它!

结论

好了,伙计们,让我们总结一下。更好的搜索不是“锦上添花”,它是游戏规则的改变者,而 Qdrant 可以助您实现这一目标。

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

如果您喜欢阅读这篇文章,何不注册我们的 时事通讯 以保持领先地位。

当然,还要非常感谢我们的读者,是你们推动我们为每个人打造更好的排序体验。

此页面有用吗?

感谢您的反馈!🙏

很遗憾听到这个消息。😔 您可以在 GitHub 上 编辑 此页面,或 创建 一个 GitHub 问题。