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

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

Nirant Kasliwal

·

2023年12月09日

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

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

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

稀疏向量就像数据界的近藤麻理惠——只保留那些引发喜悦(或在本例中,是相关性)的内容。

考虑一个简化示例,有两篇文档,每篇包含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或Sentence Transformers等模型的密集向量每个元素都有非零值。相比之下,稀疏向量关注每篇文档的相对词语权重,其中大多数值都为零。这使得系统更高效且更易于解释,尤其是在搜索等文本密集型应用中。

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

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

那么稀疏向量在哪方面不足呢?它们不擅长捕捉词语之间细微的关系。例如,它们无法像密集向量那样很好地捕捉“国王”和“王后”之间的关系。

SPLADE

让我们看看 SPLADE,一种生成稀疏向量的极好方法。我们先看一些数据。数值越高越好。

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

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

SPLADE作为一种方法非常灵活,可以通过调整正则化旋钮来获得 不同的模型

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

首先,我们来看看如何创建稀疏向量。然后,我们将探讨SPLADE背后的概念。

创建稀疏向量

我们将探讨两种创建稀疏向量的不同方法。性能更高的方法是使用专用的文档编码器和查询编码器来创建稀疏向量。我们将研究一种更简单的方法——这里我们将对文档和查询使用相同的模型。我们将获得样本文本(代表文档)的标记ID及其对应权重的字典。

如果您想跟着做,这里有一个包含所有代码的 Colab Notebook备用链接

设置

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工作原理:词项扩展

考虑查询“太阳能的优势”。SPLADE可能会将其扩展到包括“可再生”、“可持续”和“光伏”等词项,这些词项在语境上相关但未明确提及。这个过程称为词项扩展,它是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在提供性能的同时也提供了一定程度的可解释性,这在神经IR系统领域是罕见的成就。对于从事搜索工作的工程师来说,这种透明度是无价的。

SPLADE已知局限性

池化策略

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

文档和查询编码器

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

其他稀疏向量方法

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

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

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

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

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

但我们首先来看看如何在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保持一致——这是为了更容易在现有代码库中使用稀疏向量。一如既往,您可以应用payload过滤器、分片键以及您所期望的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、版本、相似性得分以及稀疏向量。得分是一个关键元素,因为它根据查询和文档各自的向量量化了它们之间的相似性。

为了理解这种评分的工作原理,我们使用熟悉的点积方法:

$$\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

相对得分融合

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

重排序

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

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

更多资源

对于希望深入了解的用户,以下是该主题的顶级论文,其中大多数提供了可用的代码:

  1. 问题动机:稀疏过完备词向量表示
  2. SPLADE v2: Sparse Lexical and Expansion Model for Information Retrieval
  3. SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking
  4. 后期交互 - ColBERTv2: 通过轻量级后期交互实现高效检索
  5. SparseEmbed: Learning Sparse Lexical Representations with Contextual Embeddings for Retrieval

何不亲自尝试一下呢?

我们为您准备了一个易于使用的Colab,教您如何创建稀疏向量:稀疏向量单编码器演示。运行它,修修补补,开始在您的项目中看到奇迹的发生。我们迫不及待地想听听您是如何使用它的!

结论

好了,各位,让我们总结一下。更好的搜索不是“锦上添花”,而是“颠覆者”,而Qdrant可以帮助您实现它。

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

如果您喜欢阅读本文,不妨订阅我们的 时事通讯,以便保持领先。

当然,还要非常感谢您,我们的读者,是您推动我们为所有人改进排名。

本页面有用吗?

感谢您的反馈!🙏

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