博客 / 工程

ClickHouse 向量搜索 - 第二部分

author avatar
Dale McDiarmid
2023 年 5 月 31 日 - 49 分钟阅读

立即开始使用 ClickHouse Cloud,并获得 300 美元信用额度。要了解有关我们基于用量的折扣的更多信息,请联系我们或访问我们的定价页面

简介

这篇博文继续我们关于向量搜索的系列,建立在上一篇博文的基础上,我们在其中概述了什么是向量搜索,它与历史悠久的基于倒排索引方法的关系,它目前为其交付价值的可能用例,以及一些高层次的实现方法。在这篇文章中,我们将通过实际示例详细探讨与 ClickHouse 相关的向量搜索,并回答问题:“何时应使用 ClickHouse 进行向量搜索?”

对于我们的示例,我们使用了 ClickHouse Cloud 集群,每个节点总共有 60 个核心和 240GB 的 RAM。然而,这些示例应该可以在同等大小的自管理集群上重现。或者,立即启动您的 ClickHouse Cloud 集群,并获得 300 美元的信用额度。让我们来担心基础设施,您只需进行查询!

ClickHouse 是一个实时的 OLAP 数据库,具有完整的 SQL 支持和广泛的函数,可以帮助用户编写分析查询。其中一些函数和数据结构执行向量之间的距离运算,使 ClickHouse 可以用作向量数据库。

由于完全并行的查询管道,ClickHouse 可以非常快速地处理向量搜索操作,尤其是在通过对所有行进行线性扫描来执行精确匹配时,其处理速度可与专用向量数据库相媲美。

高压缩级别(可通过自定义压缩编解码器进行调整)使您可以存储和查询非常大的数据集。ClickHouse 不受内存限制,允许查询包含嵌入向量的多 TB 数据集。

计算两个向量之间距离的功能只是另一个 SQL 函数,可以有效地与更传统的 SQL 过滤和聚合功能相结合。这允许向量与元数据甚至富文本一起存储和查询,从而实现广泛的用例和应用。

最后,实验性的 ClickHouse 功能(如近似最近邻 (ANN) 索引)支持更快的向量近似匹配,并为进一步增强 ClickHouse 的向量匹配功能提供了有希望的发展方向。

总而言之,当以下任何一项为真时,ClickHouse 是一个有效的向量搜索平台

  • 您希望将向量匹配与元数据过滤和/或聚合或连接功能相结合
  • 您需要对非常大的向量数据集执行线性距离匹配,并希望在许多 CPU 核心上并行化和分发这项工作,而无需额外的工作或配置
  • 您需要在向量数据集的大小上进行匹配,在这种情况下,仅依赖内存索引在成本或硬件可用性方面是不可行的
  • 在查询向量时,您将受益于完整的 SQL 支持
  • 您拥有现有的嵌入向量生成管道来生成向量,并且不需要此功能成为存储引擎的原生功能
  • 您已经在 ClickHouse 中拥有相关数据,并且不希望因为几百万个向量而承担学习另一种工具的开销和成本
  • 您主要需要对向量进行快速并行化的精确匹配,并且(目前!)不需要 ANN 的生产实现
  • 您是一位经验丰富或好奇的 ClickHouse 用户,并且相信我们会改进我们的向量匹配功能,并希望参与到这个旅程中来

虽然这涵盖了广泛的用例,但在某些情况下,ClickHouse 可能不太适合作为向量存储引擎,您可能希望考虑诸如 Faiss 或专用向量数据库之类的替代方案。如果出现以下情况,则如今 ClickHouse 作为向量搜索引擎可能提供的优势较少

  • 您的向量数据集很小且易于放入内存。虽然 ClickHouse 可以轻松完成小型数据集的向量搜索,但在这种情况下,它可能过于强大而显得多余。
  • 您的向量没有额外的元数据,只需要距离匹配和排序。如果将向量搜索结果与其他元数据连接起来没有用处,并且您的数据集很小,那么如上所述,ClickHouse 可能比您真正需要的更强大。
  • 您的 QPS 非常高,每秒超过数千次。通常,对于这些用例,数据集将适合内存,并且需要几毫秒的匹配时间。虽然 ClickHouse 可以满足这些用例,但简单的内存索引可能就足够了。
  • 您需要一个开箱即用的解决方案,其中包括嵌入向量生成功能,其中模型在插入和查询时集成。诸如 Weaviate 之类的向量数据库是专门为此用例设计的,并且可能更适合这些需求。

考虑到这一点,让我们探索 ClickHouse 的向量功能。

设置示例

LAION 数据集

正如我们在上一篇文章中讨论的那样,向量搜索在嵌入向量上运行——嵌入向量代表上下文含义。嵌入向量是通过将原始内容(如图像或文本)传递到预训练的机器学习模型来生成的。

对于这篇文章,我们使用了准备好的嵌入向量集,可公开下载,称为 LAION 50 亿测试集。我们选择这个数据集是因为我们认为在撰写本文时,这是可用于测试的最大的预计算嵌入向量数据集。它包含 768 维的嵌入向量,用于互联网上的数十亿张公共图像及其标题,这些图像是通过对互联网进行公开抓取而生成的。它的创建明确目的是为了大规模测试向量搜索,并且它还包括元数据,这些元数据反过来有助于说明如何将 ClickHouse 的通用分析功能与向量搜索相结合。

在 LAION 数据集中,已为每个图像及其关联的标题生成了嵌入向量——为我们提供了每个对象的两个嵌入向量。对于这篇文章,我们只关注英语子集,它由减少后的 22 亿个对象组成。虽然这些对象中的每一个都有两个嵌入向量,分别是图像和标题的嵌入向量,但我们将每对向量存储为 ClickHouse 中的单行,总共为我们提供了近 22 亿行和 44 亿个向量。对于每一行,我们都包含元数据作为列,这些列捕获了诸如图像尺寸、图像相似度以及标题嵌入向量相似度之类的信息。这种相似度(余弦距离)使我们能够识别标题和图像在概念上不一致的对象,从而可能在查询中过滤掉这些对象。

我们要感谢原始作者为整理此数据集并生成嵌入向量以供公众使用所付出的努力。我们建议阅读完整的生成过程,该过程克服了许多具有挑战性的数据工程难题,例如在合理的时间和可接受的成本内高效地下载和调整数十亿张图像的大小。

使用 CLIP 模型生成嵌入向量

这些 LAION 嵌入向量是使用 ViT-L/14 模型生成的,该模型由 LAION 使用 openCLIP 训练,openCLIP 是 OpenAI 开发的 CLIP 模型的开源实现。这不是一个廉价的过程!在 4 亿张图像上,这花费了大约 30 天,需要 592 个 V100 GPU(在 AWS 按需实例上约为 100 万美元)。

CLIP(对比语言-图像预训练)是一种多模态模型,这意味着它旨在训练多种相关类型的数据,例如图像和关联文本。CLIP 已被证明在学习文本的视觉表示方面非常有效,并在 OCR、地理定位和动作识别方面取得了可喜的成果。对于图像的编码,CLIP 的作者使用了 Resnet50 和 Vision Transformer (ViT),对于文本的编码,则使用了类似于 GPT-2 的 Transformer。生成的嵌入向量表示为两组独立的向量。

训练过程的关键结果是两种数据类型的嵌入向量具有可比性——如果图像和标题的向量接近,则可以认为它们在概念上相似。对于图像向量及其关联的标题向量,像 CLIP 这样的优秀模型将产生在距离方面接近的嵌入向量,或者对于余弦相似度,产生接近 1 的高值。如下图所示,其中 T1 是第 1 张图像的标题的嵌入表示,I1 是图像本身的编码。这意味着我们希望在训练过程中最大化此矩阵的对角线,其中我们的图像和文本重合。

作为后处理步骤,作者丢弃了文本标题余弦相似度小于 0.28 的图像,从而过滤掉标题和图像不一致的潜在低质量结果。通过图像大小、标题长度、可能的非法性以及删除重复项进行进一步过滤,将数据集总数从超过 50 亿减少到 22 亿。

clip_pretrain.png 来源:https://openai.com/research/clip

准备要加载的数据

LAION 数据集可从多个来源下载。选择英语子集,我们使用了 Hugging Face 托管的版本。此服务依赖于 Git Large File Storage (LFS),这需要安装客户端才能下载文件。安装后,下载数据只需要一个命令。为此,请确保您至少有 20TB 的可用磁盘空间。

git lfs install
git clone https://huggingface.co/datasets/laion/laion2b-en-vit-l-14-embeddings

下载包含三个文件夹;其中两个文件夹包含 npy 格式(实际上是多维数组格式)的图像和标题的嵌入向量,第三个目录包含 Parquet 文件,其中包含每个图像和标题对的元数据。

ubuntu@ip-172-31-2-70:/data$ ls -l ./laion2b-en-vit-l-14-embeddings
total 456
drwxrwxr-x 2 ubuntu ubuntu  77824 May 16 12:28 img_emb
drwxrwxr-x 2 ubuntu ubuntu 110592 May 16 12:27 metadata
drwxrwxr-x 2 ubuntu ubuntu 270336 May 16 12:28 text_emb

为了将此数据加载到 ClickHouse 中,我们希望为每个嵌入向量对生成一行,并使用元数据进行丰富。这将需要一个过程,将每个对象的相应嵌入向量和元数据合并。考虑到 ClickHouse 中的向量可以表示为 Float 数组,因此此过程生成的 JSON 行可能如下所示

{
 "key": "196060024",
 "url": "https://cdn.shopify.com/s/files/1/1194/1070/products/[email protected]?v=1477414012",
 "caption": "MERCEDES BENZ G65 RIDE-ON TOY CAR WITH PARENTAL REMOTE |  CHERRY",
 "similarity": 0.33110910654067993,
 "width": "220",
 "height": "147",
 "original_width": "220",
 "original_height": "147",
 "status": "success",
 "NSFW": "UNLIKELY",
 "exif": {
   "Image Orientation": "Horizontal (normal)",
   "Image XResolution": "72",
   "Image YResolution": "72",
   "Image ResolutionUnit": "Pixels/Inch",
   "Image YCbCrPositioning": "Centered",
   "Image ExifOffset": "102",
   "EXIF ExifVersion": "0210",
   "EXIF ComponentsConfiguration": "YCbCr",
   "EXIF FlashPixVersion": "0100",
   "EXIF ColorSpace": "Uncalibrated",
   "EXIF ExifImageWidth": "220",
   "EXIF ExifImageLength": "147"
 },
 "text_embedding": [
   0.025299072265625,
   ...
   -0.031829833984375
 ],
 "image_embedding": [
   0.0302276611328125,
   ...
   -0.00667572021484375
 ]
}

预处理数据集的完整代码可以在此处找到。最终生成的 2313 个 Parquet 文件消耗大约 5.9TB 的磁盘空间。我们已将这些文件组合起来,生成了一个 6TB 的 Parquet 数据集,我们的用户可以 s3://datasets-documentation/laion/ 并使用它来重现示例。

在 ClickHouse 中存储向量

将生成的 Parquet 文件加载到 ClickHouse 中需要几个简单的步骤。

模式和加载过程

以下显示了我们的表模式,其中嵌入向量存储为 Array(Float32) 列。

CREATE TABLE laion
(
	`_file` LowCardinality(String),
	`key` String,
	`url` String,
	`caption` String,
	`similarity` Float64,
	`width` Int64,
	`height` Int64,
	`original_width` Int64,
	`original_height` Int64,
	`status` LowCardinality(String),
	`NSFW` LowCardinality(String),
	`exif` Map(String, String),
	`text_embedding` Array(Float32),
	`image_embedding` Array(Float32),
	`orientation` String DEFAULT exif['Image Orientation'],
	`software` String DEFAULT exif['Image Software'],
	`copyright` String DEFAULT exif['Image Copyright'],
	`image_make` String DEFAULT exif['Image Make'],
	`image_model` String DEFAULT exif['Image Model']
)
ENGINE = MergeTree
ORDER BY (height, width, similarity)

exif 列包含元数据,我们稍后可以使用它进行过滤和聚合。为了灵活性和模式简洁性,我们已将其映射为 Map(String,String)。此列包含超过 100,000 个唯一的元标签。访问子键需要从列中加载所有键,这可能会减慢某些查询的速度,因此我们已使用 DEFAULT 语法将五个感兴趣的属性提取到根目录以供后续分析。对于对可用元属性完整列表感兴趣的用户,可以使用以下查询来识别可用的 Map 键及其频率

SELECT
	arrayJoin(mapKeys(exif)) AS keys,
	count() AS c
FROM laion
GROUP BY keys
ORDER BY c DESC
LIMIT 10

我们的模式还包括一个 _file 列,表示生成此数据的原始 Parquet 文件。如果特定文件加载在插入到 ClickHouse 期间失败,这允许我们重新启动该文件加载。

为了将来使用,我们将此数据加载到公共 S3 存储桶中。要将此数据插入到 ClickHouse 中,用户可以执行以下查询

INSERT INTO laion SELECT * FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/laion/*.parquet')

这是要加载的大量数据,未优化的加载需要几个小时。我们建议用户批量加载过程,以避免网络连接问题等中断。用户可以使用 glob 模式定位特定子集,例如 s3(https://datasets-documentation.s3.eu-west-3.amazonaws.com/laion/00*.parquet)_file 列可用于通过确认 ClickHouse 中的计数与原始 Parquet 文件中的计数来协调任何加载问题。

对于下面的示例,我们创建了各种大小的表,后缀表示行数;例如,laion_100m 包含 1 亿行。这些是使用适当的 glob 模式创建的。

INSERT INTO laion_sample (_file, key, url, caption, similarity, width, height, original_width, original_height, status, NSFW, exif, text_embedding, image_embedding) SELECT
    _file,
    key,
    url,
    caption,
    similarity,
    width,
    height,
    original_width,
    original_height,
    status,
    NSFW,
    exif,
    text_embedding,
    image_embedding
FROM s3('https://datasets-documentation.s3.eu-west-3.amazonaws.com/laion/*.parquet')

压缩的存储性能

ClickHouse 的面向列的结构意味着列的值按顺序排序和写入。磁盘上相同和相似值的聚类通常会导致高压缩率。ClickHouse 甚至提供了多种 模式和编解码器,允许用户根据其数据的属性调整其配置。对于浮点数数组,由于嵌入向量的值没有可利用的领域无关属性,因此更难实现高压缩。使用了完整的 32 位范围,并且对于大多数编解码器,嵌入向量中相邻值之间的关系是随机的。因此,我们建议使用 ZSTD 编解码器来压缩嵌入向量。下面我们展示了四个大小递增的表(1m、10m、100m 和 2b 行)中向量列的压缩率。

SELECT
	table,
	name,
	formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
	formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
	round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE (table IN ('laion_100m', 'laion_1m', 'laion_10m', 'laion_2b')) AND (name IN ('text_embedding', 'image_embedding'))
GROUP BY
	table,
	name
ORDER BY table DESC

┌─table──────┬─name────────────┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ laion_1m   │ text_embedding  │ 1.60 GiB    	│ 2.50 GiB      	 │  1.56 │
│ laion_1m   │ image_embedding │ 1.61 GiB    	│ 2.50 GiB      	 │  1.55 │
│ laion_10m  │ text_embedding  │ 18.36 GiB   	│ 28.59 GiB     	 │  1.56 │
│ laion_10m  │ image_embedding │ 18.36 GiB   	│ 28.59 GiB     	 │  1.56 │
│ laion_100m │ text_embedding  │ 181.64 GiB  	│ 286.43 GiB    	 │  1.58 │
│ laion_100m │ image_embedding │ 182.29 GiB  	│ 286.43 GiB    	 │  1.57 │
│ laion_1b   │ image_embedding │ 1.81 TiB    	│ 2.81 TiB      	 │  1.55 │
│ laion_1b   │ text_embedding  │ 1.81 TiB    	│ 2.81 TiB      	 │  1.55 │
└────────────┴─────────────────┴─────────────────┴───────────────────┴───────┘

6 rows in set. Elapsed: 0.006 sec.

虽然压缩率通常可以受到主键选择的影响,但 1.56 的恒定压缩率不太可能受到数据排序方式的影响。ZSTD 编解码器的压缩级别可以从 ClickHouse Cloud 中的默认值 1 提高。这提供了大约 10% 的改进,在 1000 万行样本上将我们的数据压缩了 1.71

SELECT
	table,
	name,
	formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
	formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
	round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE (table IN ('laion_10m_zstd_3')) AND (name IN ('text_embedding', 'image_embedding'))
GROUP BY
	table,
	name
ORDER BY table DESC

┌─table────────────┬─name────────────┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ laion_10m_zstd_3 │ text_embedding  │ 16.68 GiB   	   │ 28.56 GiB     	   │  1.71 │
│ laion_10m_zstd_3 │ image_embedding │ 16.72 GiB   	   │ 28.56 GiB     	   │  1.71 │
└──────────────────┴─────────────────┴─────────────────┴───────────────────┴───────┘

2 rows in set. Elapsed: 0.026 sec.

请注意,ZSTD 的较高值会减慢压缩和数据插入速度,但解压缩速度应保持合理稳定(大约 20% 的差异)。

浮点数的压缩是一个研究领域,基于量化的几种有损候选项(例如 SZ 算法)可能是 ClickHouse 的可能的补充。其他选项包括将浮点数的精度降低到 16 位。我们将在下面的“改进压缩”部分中讨论这一点。

在 ClickHouse 中搜索向量

正如我们在本系列第 1 部分中介绍的那样,执行向量搜索意味着将输入向量与向量存储库进行比较,以找到最接近的匹配项。

输入向量表示感兴趣的概念。在我们的例子中,它是编码的图像或标题。向量存储库表示我们希望与之比较的其他图像及其标题。

执行搜索时,会比较向量的邻近度或距离。距离接近的两个向量表示相似的概念。距离最近的两个向量是集合中最相似的。

选择距离函数

鉴于向量的高维度,有很多方法可以比较距离。这些不同的机制称为距离函数

ClickHouse 支持广泛的距离函数 - 您可以根据您的用例选择最合适的函数。对于这篇文章,我们重点介绍向量搜索中非常常用的两个函数

  • 余弦距离 - cosineDistance(vector1, vector2) - 这为我们提供了 2 个向量之间的余弦距离(1 - 余弦相似度)。更具体地说,这测量两个向量之间角度的余弦,即点积除以长度。这产生一个介于 -1 和 1 之间的值,其中 1 表示两个嵌入向量是成比例的,因此在概念上是相同的。可以解析列名和输入嵌入向量以进行向量搜索。如果向量尚未归一化,并且提供可用于过滤的有界范围,则此函数尤其相关。
  • L2 距离- L2Distance(vector1, vector2) - 这测量 2 个点之间的 L2 距离。实际上,这是两个输入向量之间的欧几里得距离,即向量表示的点之间线的长度。距离越小,源对象在概念上越相似。

这两个函数都计算一个用于比较向量嵌入向量的分数。对于我们预训练的 CLIP 模型,根据 官方示例中使用的内部评分,L2 距离代表最合适的距离函数。

有关可用距离函数和向量归一化函数的完整列表,请参见此处。我们很乐意听到您如何利用这些函数来搜索您的嵌入向量!

生成输入向量

现在我们已经确定了我们将要使用的距离函数,我们需要将输入(我们要搜索的图像或标题)转换为向量嵌入向量。

这需要我们调用 CLIP 模型。这可以通过一个简单的 Python 脚本轻松实现。有关安装此脚本依赖项的说明,请参见 此处。我们在下面展示了这个脚本

#!/usr/bin/python3
import argparse
from PIL import Image
import clip
import torch

if __name__ == '__main__':
	parser = argparse.ArgumentParser(
    	prog='generate',
    	description='Generate CLIP embeddings for images or text')
	group = parser.add_mutually_exclusive_group(required=True)
	group.add_argument('--text', required=False)
	group.add_argument('--image', required=False)
	parser.add_argument('--limit', default=1)
	parser.add_argument('--table', default='laion_1m')
	args = parser.parse_args()
	device = "cuda" if torch.cuda.is_available() else "cpu"
	print(f"using {device}")
	device = torch.device(device)
	model, preprocess = clip.load("ViT-L/14")
	model.to(device)
	images = []
	if args.text:
    	inputs = clip.tokenize(args.text)
    	with torch.no_grad():
        	print(model.encode_text(inputs)[0].tolist())
	elif args.image:
    	image = preprocess(Image.open(args.image)).unsqueeze(0).to(device)
    	with torch.no_grad():
        	print(model.encode_image(image)[0].tolist())

此版本的脚本接受文本或图像路径作为输入,并将嵌入向量输出到命令行。请注意,如果存在 CUDA 启用的 GPU,这将加以利用。这可以显着改变生成时间 - 在 Mac M1 2021 上测试时,生成 100 个标题的时间约为 6 秒,而在具有 1 个 GPU 核心的 p3.2xlarge 上则为 1 秒。

例如,让我们将文本“a sleepy ridgeback dog”转换为嵌入向量。为了简洁起见,我们裁剪了完整的嵌入向量结果,可以在 此处找到。

python generate.py --text "a sleepy ridgeback dog"

[0.5736801028251648, 0.2516217529773712, ...,  -0.6825592517852783]

我们现在有一个向量嵌入向量,它表示文本“a sleepy ridgeback dog”。这是我们的搜索输入向量。我们现在可以将此输入向量与我们的向量嵌入向量存储库进行比较,以查找在概念上表示相似事物的图像及其标题。

整合在一起

以下查询搜索概念上相似的嵌入向量,并按距离对其进行排序。嵌入向量存储在 image_embedding 列中。距离存储为 similarity。我们过滤掉任何大于 0.2 的距离以减少噪声。

SELECT
	url,
	caption,
	L2Distance(image_embedding, [0.5736801028251648, 0.2516217529773712, ...,  -0.6825592517852783]) AS score
FROM laion_10m WHERE similarity >= 0.2
ORDER BY score ASC
LIMIT 2
FORMAT Vertical

Row 1:
──────
url: 	https://thumb9.shutterstock.com/image-photo/stock-photo-front-view-of-a-cute-little-young-thoroughbred-african-rhodesian-ridgeback-hound-dog-puppy-lying-in-450w-62136922.jpg
caption: Front view of a cute little young thoroughbred African Rhodesian Ridgeback hound dog puppy lying in the woods outdoors and staring.
score:   12.262665434714496

Row 2:
──────
url: 	https://m.psecn.photoshelter.com/img-get2/I0000_1Vigovbi4o/fit=180x180/fill=/g=G0000x325fvoXUls/I0000_1Vigovbi4o.jpg
caption: SHOT 1/1/08 3:15:27 PM - Images of Tanner a three year-old male Vizsla sleeping in the sun on the couch in his home in Denver, Co. The Hungarian Vizsla, is a dog breed originating in Hungary. Vizslas are known as excellent hunting dogs, and also have a level personality making them suited for families. The Vizsla is a medium-sized hunting dog of distinguished appearance and bearing. Robust but rather lightly built, they are lean dogs, have defined muscles, and are similar to a Weimaraner but smaller in size. The breed standard calls for the tail to be docked to two-thirds of its original length in smooth Vizslas and to three-fourths in Wirehaired Vizslas..(Photo by Marc Piscotty/ (c) 2007)
score:   12.265194306913513

2 rows in set. Elapsed: 1.595 sec. Processed 9.92 million rows, 32.52 GB (6.22 million rows/s., 20.38 GB/s.)

结果表明,我们的输入向量“a sleepy ridgeback dog”在概念上与数据集中的非洲罗德西亚脊背犬的照片最相似,并且在概念上也非常类似于睡着的猎犬的图像。

kibo.png

我的狗 Kibo

为了进一步证明这些模型的实用性,作为文本搜索的替代方法,我们可以从 睡着的狗的图像开始,并以这种方式搜索相似的图像。我们生成一个表示此照片的输入向量,并搜索概念上相似的结果。

为此,我们使用 text_embedding 列重复上述查询。完整的嵌入向量可以在 此处找到。

python generate.py --image images/ridgeback.jpg

[0.17179889976978302, 0.6171532273292542, ...,  -0.21313616633415222]
SELECT
	url,
	caption,
	L2Distance(text_embedding, [0.17179889976978302, ..., -0.21313616633415222]
) AS score
FROM laion_10m WHERE similarity >= 0.2
ORDER BY score ASC
LIMIT 2
FORMAT Vertical

Row 1:
──────
url: 	https://i.pinimg.com/236x/ab/85/4c/ab854cca81a3e19ae231c63f57ed6cfe--submissive--year-olds.jpg
caption: Lenny is a 2 to 3 year old male hound cross, about 25 pounds and much too thin. He has either been neglected or on his own for a while. He is very friendly if a little submissive, he ducked his head and tucked his tail a couple of times when I...
score:   17.903361349936052

Row 2:
──────
url: 	https://d1n3ar4lqtlydb.cloudfront.net/c/a/4/2246967.jpg
caption: American Pit Bull Terrier/Rhodesian Ridgeback Mix Dog for adoption in San Clemente, California - MARCUS = Quite A Friendly Guy!
score:   17.90681696075351

2 rows in set. Elapsed: 1.516 sec. Processed 9.92 million rows, 32.52 GB (6.54 million rows/s., 21.45 GB/s.)

为了方便起见,我们提供了一个简单的结果生成器 search.py,它编码传递的图像或文本并执行查询,将查询结果呈现为本地 html 文件。然后,此文件会自动在本地浏览器中打开。以下显示了上述查询的结果文件

python search.py search --image images/ridgeback.jpg --table laion_10m

ridgebacks.png

在所有这些示例中,我们都匹配了来自不同模态的嵌入向量,即来自图像输入的嵌入向量与 text_embedding 列进行匹配,反之亦然。这与之前描述的原始模型训练保持一致,并且是预期的应用。虽然已经探索了针对相同类型匹配输入嵌入向量,但之前的尝试已导致混合结果

SQL 的优势

通常,在向量搜索的实践中,我们不仅仅跨嵌入向量进行搜索。通常,将搜索与元数据过滤或聚合相结合具有额外的实用价值。

使用元数据进行过滤

例如,假设我们希望对非版权图像执行向量搜索。这种查询会将向量搜索与基于版权元数据的过滤相结合。

再举一个例子,假设我们只想将搜索限制为大图像 - 至少 300px*500px,并且标题相似度满足更高的 0.3 余弦相似度分数。对于此示例,让我们从搜索“great animal migrations”开始。幸运的是,将其制定为 SQL 查询很简单。下面,我们对 1 亿张图像执行此查询。

SELECT
	url,
	caption,
	L2Distance(image_embedding, [<embedding>]) AS score
FROM laion_100m
WHERE (width >= 300) AND (height >= 500) AND (copyright = '') AND similarity > 0.3
ORDER BY score ASC
LIMIT 10
FORMAT Vertical


Row 1:
──────
url: 	https://aentcdn.azureedge.net/graphics/items/sdimages/a/500/3/6/5/4/1744563.jpg
caption: Great Migrations
width:   366
height:  500
score:   16.242750635008512

Row 2:
──────
url: 	https://naturefamiliesdotorg.files.wordpress.com/2017/01/on-the-move.jpg?w=418&h=557
caption: on-the-move
width:   384
height:  512
score:   16.26983713529263

10 rows in set. Elapsed: 2.010 sec. Processed 6.82 million rows, 22.52 GB (3.39 million rows/s., 11.20 GB/s.)

这说明了使用 SQL 和元数据将向量比较限制为子集的好处。在此特定案例中,我们查询了超过 1 亿个向量,但由于我们的元数据,将实际距离匹配减少到不到 700 万个。

为了方便起见,我们还在我们的 search.py 中添加了传递额外过滤器的功能,这使我们能够验证上述匹配的质量

python search.py search --filter "(width >= 300) AND (height >= 500) AND (copyright = '') AND simularity > 0.3" --text "great animal migrations"

migrations.png

使用元数据进行聚合

除了过滤之外,我们还可以对元数据执行聚合。作为面向列的数据库,ClickHouse 非常适合此任务。

例如,假设我们想识别用于“safari pictures”的主要相机型号。我们在此处执行该搜索

WITH results AS
	(
    	SELECT
        	image_make,
        	image_model,
        	L2Distance(image_embedding, [<embedding>]) AS score
    	FROM laion_100m
    	WHERE (image_make != '') AND (image_model != '')
    	ORDER BY score ASC
    	LIMIT 1000
	)
SELECT
	image_make,
	image_model,
	count() AS c
FROM results
GROUP BY
	image_make,
	image_model
ORDER BY c DESC
LIMIT 10

┌─image_make────────┬─image_model───────────┬──c─┐
│ Canon         	│ Canon EOS 7D      	│ 64 │
│ Canon         	│ Canon EOS-1D X    	│ 51 │
│ Canon         	│ Canon EOS 5D Mark III │ 49 │
│ NIKON CORPORATION │ NIKON D700        	│ 26 │
│ NIKON CORPORATION │ NIKON D800        	│ 24 │
│ Canon         	│ Canon EOS 5D Mark II  │ 23 │
│ NIKON CORPORATION │ NIKON D810        	│ 23 │
│ NIKON CORPORATION │ NIKON D7000       	│ 21 │
│ Canon         	│ Canon EOS 40D     	│ 18 │
│ Canon         	│ Canon EOS 60D     	│ 17 │
└───────────────────┴───────────────────────┴────┘

10 rows in set. Elapsed: 23.897 sec. Processed 100.00 million rows, 286.70 GB (4.18 million rows/s., 12.00 GB/s.)

显然,Canon(佳能)应该是您下次狩猎旅行的首选相机。请注意,这里我们仅使用了前 1000 个结果。与无界的余弦距离不同,欧几里得距离没有上限 - 这使得设定阈值具有挑战性。

使用倒排索引

注意:倒排索引是 ClickHouse 中的一项实验性功能。

ClickHouse 的实验性二级索引功能也可以证明在向量处理中非常有用。

例如,我们可能希望强制执行一个过滤器,将我们的狩猎旅行照片限制为包含狮子的照片。为此,我们可以施加令牌约束 - 要求 caption 列包含字符串 lions

如果没有倒排索引,我们的搜索可能看起来像下面这样。在这里,我们使用了 embedding(嵌入)用于 以下图片,并针对 1 亿个向量进行搜索。

safari.png

SELECT url, caption, L2Distance(text_embedding, [<embedding>]) AS score FROM laion_10m WHERE SELECT
	url,
	caption,
	L2Distance(text_embedding, [-0.17659325897693634, …, 0.05511629953980446]) AS score
FROM laion_100m
WHERE hasToken(lower(caption), 'lions')
ORDER BY score ASC
LIMIT 10
FORMAT Vertical

Row 1:
──────
url: 	https://static.wixstatic.com/media/c571fa_25ec3694e6e04a39a395d07d63ae58fc~mv2.jpg/v1/fill/w_420,h_280,al_c,q_80,usm_0.66_1.00_0.01/Mont%20Blanc.jpg
caption: Travel on a safari to Tanzania, to the rolling plains of the Serengeti, the wildlife-filled caldera of the Ngorongoro Crater and the lions and baobabs of Tarangire; Tanzania will impress you like few other countries will.  This tailor-made luxury safari will take you to three very different parks in northern Tanzania, each with their own scenery and resident wildlife.   As with all our private tours, this sample itinerary can be completely tailored to create the perfect journey of discovery for you.
score:   18.960329963316692

Row 2:
──────
url: 	https://thumbs.dreamstime.com/t/jeepsafari-ngorongoro-tourists-photographers-watching-wild-lions-who-walk-jeeps-79635001.jpg
caption: Jeep safari in Ngorongoro3. Tourists and photographers are watching wild lions, who walk between the jeeps Stock Image
score:   18.988379350742093
hasToken(lower(caption), 'lions') ORDER BY score ASC LIMIT 10 FORMAT Vertical

10 rows in set. Elapsed: 6.194 sec. Processed 93.82 million rows, 79.00 GB (15.15 million rows/s., 12.75 GB/s.)

为了加速这种元数据查询,我们可以利用倒排索引,并为 caption 列添加一个倒排索引。

SET allow_experimental_inverted_index=1
ALTER TABLE laion_100m ADD INDEX caption_idx(lower(caption)) TYPE inverted;
ALTER TABLE laion_100m MATERIALIZE INDEX caption_idx;

重复之前的查询,我们可以看到这在查询时间上带来了显著的改进。倒排索引可以用于将我们的距离比较的行数限制为 3000 万行,从而将时间从 6 秒减少到 3 秒。

SELECT url, caption, L2Distance(text_embedding, [<embedding>]) AS score FROM laion_10m WHERE SELECT
	url,
	caption,
	L2Distance(text_embedding, [-0.17659325897693634, ..., 0.05511629953980446]) AS score
FROM laion_100m
WHERE hasToken(lower(caption), 'lions')
ORDER BY score ASC
LIMIT 10
FORMAT Vertical

Row 1:
──────
url: 	https://static.wixstatic.com/media/c571fa_25ec3694e6e04a39a395d07d63ae58fc~mv2.jpg/v1/fill/w_420,h_280,al_c,q_80,usm_0.66_1.00_0.01/Mont%20Blanc.jpg
caption: Travel on a safari to Tanzania, to the rolling plains of the Serengeti, the wildlife-filled caldera of the Ngorongoro Crater and the lions and baobabs of Tarangire; Tanzania will impress you like few other countries will.  This tailor-made luxury safari will take you to three very different parks in northern Tanzania, each with their own scenery and resident wildlife.   As with all our private tours, this sample itinerary can be completely tailored to create the perfect journey of discovery for you.
score:   18.960329963316692

Row 2:
──────
url: 	https://thumbs.dreamstime.com/t/jeepsafari-ngorongoro-tourists-photographers-watching-wild-lions-who-walk-jeeps-79635001.jpg
caption: Jeep safari in Ngorongoro3. Tourists and photographers are watching wild lions, who walk between the jeeps Stock Image
score:   18.988379350742093

10 rows in set. Elapsed: 3.554 sec. Processed 32.96 million rows, 74.11 GB (9.27 million rows/s., 20.85 GB/s.)

此查询的结果如下

python search.py search --image ./images/safari.jpg --table laion_100m --filter "hasToken(lower(caption), 'lions')"

lion_safari.png

高级功能

近似最近邻 (Annoy)

注意:Annoy 索引在 ClickHouse 中是高度实验性的。

Annoy 索引旨在提高大规模最近邻向量搜索的效率。它在准确性和计算效率之间进行了权衡。

具体来说,Annoy 索引是一种用于在高维空间中查找近似最近邻的数据结构。Annoy 的工作原理是将向量组织成树结构。它使用随机超平面(二维空间中的线,三维空间中的平面等)将高维空间划分为分区。这些超平面将空间分割成更小的区域,每个区域仅包含数据点的子集。这些分区反过来又用于构建树结构(通常是二叉树),其中每个节点代表一个超平面,子节点代表分割平面的区域。树的叶节点包含实际的数据点。平衡和优化技术,例如随机化插入和使用启发式方法来确定用于分区的最佳超平面,确保树是高效且平衡的。

一旦构建了 Annoy 索引,它就可以用于搜索。在提供向量时,可以通过将每个向量与每个内部节点的超平面进行比较来遍历树。在树的每一层,Annoy 估计查询向量与子节点表示的区域之间的距离。距离度量决定了要进一步探索哪个子节点。到达根节点或指定的节点后,将返回它遇到的节点集。结果是一个近似的结果集,与线性扫描相比,搜索时间可能快得多。

annoy.png Annoy 分割超平面的图像

在为 ClickHouse 创建 Annoy 索引时,我们可以同时指定 NumTree 和 DistanceName。后者表示使用的距离函数,默认为 L2Distance,适用于我们的 LAION 数据集。前者表示算法将创建的树的数量。树越大,其工作速度越慢(在 CREATE 和 SELECT 请求中都是如此),但您获得的准确性越高(针对随机性进行调整)。默认情况下,NumTree 设置为 100。

下面,我们展示了 LAION 数据集的模式,其中每个嵌入字段都有一个 Annoy 索引。我们对索引使用默认值,并用 1 亿行数据填充表。

SET allow_experimental_annoy_index = 1

CREATE TABLE default.laion_100m_annoy
(
   `_file` LowCardinality(String),
   `key` String,
   `url` String,
   `caption` String,
   `similarity` Float64,
   `width` Int64,
   `height` Int64,
   `original_width` Int64,
   `original_height` Int64,
   `status` LowCardinality(String),
   `NSFW` LowCardinality(String),
   `exif` Map(String, String),
   `text_embedding` Array(Float32),
   `image_embedding` Array(Float32),
   `orientation` String DEFAULT exif['Image Orientation'],
   `software` String DEFAULT exif['Image Software'],
   `copyright` String DEFAULT exif['Image Copyright'],
   `image_make` String DEFAULT exif['Image Make'],
   `image_model` String DEFAULT exif['Image Model'],
   INDEX annoy_image image_embedding TYPE annoy(1000) GRANULARITY 1000,
   INDEX annoy_text text_embedding TYPE annoy(1000) GRANULARITY 1000
)
ENGINE = MergeTree
ORDER BY (height, width, similarity)

INSERT INTO laion_100m_annoy SELECT * FROM laion_100m

0 rows in set. Elapsed: 1596.941 sec. Processed 100.00 million rows, 663.68 GB (62.62 thousand rows/s., 415.59 MB/s.)

如图所示,Annoy 索引的开销在插入时非常显著,上述插入 1 亿行数据大约需要 27 分钟。相比之下,没有这些索引的表需要 10 分钟。下面,我们重复了我们的早期查询,该查询大约花费了 24 秒(热启动)。

SELECT
	url,
	caption,
	L2Distance(image_embedding, [embedding]) AS score
FROM laion_100m_annoy
ORDER BY score ASC
LIMIT 10 FORMAT Vertical

Row 1:
──────
url: 	https://i.dailymail.co.uk/i/pix/2012/04/26/article-2135380-12C5ADBC000005DC-90_634x213.jpg
caption: Pampered pets: This hammock-style dog bed offers equal levels of pet comfort
score:   12.313203570174357

Row 2:
──────
url: 	https://i.pinimg.com/originals/15/c2/11/15c2118a862fcd0c4f9f6c960d2638a0.jpg
caption: rhodesian ridgeback lab mix puppy
score:   12.333195649580162

10 rows in set. Elapsed: 1.456 sec. Processed 115.88 thousand rows, 379.06 MB (79.56 thousand rows/s., 260.27 MB/s.)

Annoy 索引在查询性能方面带来了显著的改进,此查询花费 1 到 2 秒之间,但牺牲了一些搜索质量。

这里的测试嵌入代表了我们的“a sleepy ridgeback dog”文本。我们可以在下面看到图像结果。

python search.py search --text "a sleepy ridgeback dog" --table laion_100m_annoy

ridgeback_annoy.png

在 ClickHouse 中,需要注意的是,Annoy 索引可以用于加速以下查询:利用 ORDER BY DistanceFunction(Column, vector)WHERE DistanceFunction(Column, Point) < MaxDistance,但不能同时使用两者。查询必须施加 LIMIT,以返回前 N 个匹配项。为了返回最佳匹配项,使用基于优先级队列的缓冲区来收集匹配的向量。一旦填满,收集停止,缓冲区将被排序。此缓冲区的大小受设置 max_limit_for_ann_queries 限制(默认为 1000000)。

用户定义函数 (UDF)

ClickHouse 的用户自定义函数,或 UDF,允许用户通过创建可以利用 SQL 构造和函数的 lambda 表达式来扩展 ClickHouse 的行为。这些函数可以像查询中的任何内置函数一样使用。

到目前为止,我们一直依赖于在 ClickHouse 之外执行向量生成,并在查询时从我们的 search.py 脚本传递生成的嵌入。虽然这已经足够了,但如果我们可以直接在 SQL 查询中简单地传递文本或图像路径(甚至 URL!),那就更好了。

我们可以使用 UDF 来完成此任务。下面定义的 UDF 分别称为 embedTextembedImage

SELECT
	url,
	caption,
	L2Distance(image_embedding, embedText('a sleepy ridgeback dog')) AS score
FROM laion_10m
ORDER BY score ASC
LIMIT 10

SELECT
	url,
	caption,
	L2Distance(text_embedding, embedImage("https://dogpictures.com/ridgeback.jpg")) as score
FROM laion_100m
ORDER BY score ASC
LIMIT 10

为了定义 embedText UDF,我们首先调整我们早期的 generate.py,用于生成嵌入,使其成为下面的 embed_text.py

注意:这应该保存在 ClickHouse 的 user_scripts 文件夹中。

#!/usr/bin/python3
import clip
import torch
import sys

device = "cuda" if torch.cuda.is_available() else "cpu"
model, preprocess = clip.load("ViT-L/14", device=device)

if __name__ == '__main__':
	for text in sys.stdin:
    	inputs = clip.tokenize(text)
    	with torch.no_grad():
        	text_features = []
        	text_features = model.encode_text(inputs)[0].tolist()
        	print(text_features)
        	sys.stdout.flush()

然后可以通过自定义函数 embedText 公开此 embed_text.py 脚本。以下配置可以放在 ClickHouse 配置目录(默认 /etc/clickhouse-server/)下,名称为 embed_text__function.xml

注意:用户应确保已为 clickhouse 用户安装此脚本的依赖项 - 有关步骤,请参阅 此处

<functions>
	<function>
    	<type>executable</type>
    	<name>embedText</name>
    	<return_type>Array(Float32)</return_type>
    	<argument>
        	<type>String</type>
        	<name>text</name>
    	</argument>
    	<format>TabSeparated</format>
    	<command>embed_text.py</command>
    	<command_read_timeout>1000000</command_read_timeout>
	</function>
</functions>

注册该函数后,我们现在可以像之前的示例中所示那样使用它

SELECT
	url,
	caption,
	L2Distance(image_embedding, embedText('a sleepy ridgeback dog')) AS score
FROM laion_10m
ORDER BY score ASC
LIMIT 10

对于我们类似的 embedImage 函数,我们基于以下 python 脚本 embed_image.py 添加了另一个 UDF。

#!/usr/bin/python3
from io import BytesIO
from PIL import Image
import requests
import clip
import torch
import sys

device = "cuda" if torch.cuda.is_available() else "cpu"
model, preprocess = clip.load("ViT-L/14", device=device)

if __name__ == '__main__':
	for url in sys.stdin:
    	response = requests.get(url.strip())
    	response.raise_for_status()
    	image = preprocess(Image.open(BytesIO(response.content))).unsqueeze(0).to(device)
    	with torch.no_grad():
        	print(model.encode_image(image)[0].tolist())
        	sys.stdout.flush()
<functions>
	<function>
    	<type>executable_pool</type>
    	<name>embedImage</name>
    	<return_type>Array(Float32)</return_type>
    	<argument>
   	 <type>String</type>
    	</argument>
    <format>TabSeparated</format>
    	<command>embed_image.py</command>
    	<command_read_timeout>1000000</command_read_timeout>
	</function>
</functions>

当 UDF 设置为 executable_pool 类型时,ClickHouse 维护一个预加载的 python 实例池,准备接收输入。对于我们的函数,这很有益,因为它减少了首次执行后的模型加载时间。这使得后续调用速度更快。有关如何控制池大小和其他配置参数的更多详细信息,请参阅 此处

现在两个 UDF 都已配置,我们可以按如下方式查询

SELECT embedImage('https://cdn.britannica.com/12/236912-050-B39F82AF/Rhodesian-Ridgeback-dog.jpg')
...
1 row in set. Elapsed: 13.421 sec.

SELECT embedImage('https://cdn.britannica.com/12/236912-050-B39F82AF/Rhodesian-Ridgeback-dog.jpg')
...
1 row in set. Elapsed: 0.317 sec.

SELECT
	url,
	caption,
	L2Distance(image_embedding, embedImage('https://cdn.britannica.com/12/236912-050-B39F82AF/Rhodesian-Ridgeback-dog.jpg')) AS score
FROM laion_10m
ORDER BY score ASC
LIMIT 10

完成此操作后,我们可以通过 embed_concept.py 脚本和函数 embedConcept 公开我们早期的概念数学能力。

select embedConcept('(berlin - germany) + (uk + bridge)')

SELECT
	url,
	caption,
	L2Distance(image_embedding, embedConcept('(berlin - germany) + (uk + bridge)')) AS score
FROM laion_10m
ORDER BY score ASC
LIMIT 10

请注意,以上示例不包括错误处理和输入验证。我们将其作为读者的练习。希望这些示例为结合用户自定义函数、嵌入模型和向量搜索提供了一些灵感!

改进压缩

增强的压缩技术可能有助于整体数据大小和存储需求。例如,我们之前的模式和生成的压缩统计信息是基于将我们的向量存储为 Array(Float32) 类型。但是,对于某些模型,不需要 32 位浮点精度,并且可以通过将其降低到 16 位来实现类似的匹配质量。

虽然 ClickHouse 没有原生的 16 位浮点类型,但我们仍然可以将精度降低到 16 位并重用 Float32 类型,每个值都简单地用零填充。这些零将通过 ZSTD 编解码器(ClickHouse Cloud 中的标准)有效地压缩,从而减少我们的压缩存储需求。

为了实现这一点,我们需要确保正确完成 16 位浮点值的编码。幸运的是,Google 的 bfloat16 类型 对于机器学习用例效果很好,并且仅需要截断 32 位浮点数的最后 16 位,假设后者使用 IEE-754 编码。

Bfloat16.png 来源:https://cloud.google.com/tpu/docs/bfloat16

虽然 bfloat16 目前不是 ClickHouse 原生的,但可以使用其他函数轻松复制。我们在下面对 image_embeddingtext_embedding 列执行此操作。

为此,从表 laion_100m(包含 1 亿行)中选择所有行,并使用 INSERT INTO SELECT 子句将其插入到表 laion_100m_bfloat16 中。在 SELECT 期间,我们将嵌入中的值转换为 BFloat16 表示形式。

此 bfloat16 转换是使用 arrayMap 函数实现的,即 arrayMap(x -> reinterpretAsFloat32(bitAnd(reinterpretAsUInt32(x), 4294901760)), image_embedding)

这会迭代向量嵌入中的每个值 x,执行转换 reinterpretAsFloat32(bitAnd(reinterpretAsUInt32(x), 4294901760)) - 这使用函数 reinterpretAsUInt32 将二进制序列解释为 Int32,并使用值 4294901760 执行 bitAnd。后一个值是二进制序列 000000000000000001111111111111111。因此,此操作将末尾 16 位清零,从而执行有效的截断。然后将生成的二进制值重新解释为 float32。

我们在下面说明此过程

INSERT INTO default.laion_1m_bfloat16 SELECT
	_file,
	key,
	url,
	caption,
	similarity,
	width,
	height,
	original_width,
	original_height,
	status,
	NSFW,
	exif,
	arrayMap(x -> reinterpretAsFloat32(bitAnd(reinterpretAsUInt32(x), 4294901760)), text_embedding) AS text_embedding,
	arrayMap(x -> reinterpretAsFloat32(bitAnd(reinterpretAsUInt32(x), 4294901760)), image_embedding) AS image_embedding,
	orientation,
	software,
	copyright,
	image_make,
	image_model
FROM laion_1m

bfloat16_process.png

如下所示,这具有将我们的压缩数据减少超过 35% 的效果 - 0s 压缩效果非常好。

SELECT
   table,
   name,
   formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
   formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
   round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE (table IN ('laion_100m', 'laion_100m_bfloat16', 'laion_10m', 'laion_10m_bfloat16')) AND (name IN ('text_embedding', 'image_embedding'))
GROUP BY
   table,
   name
ORDER BY table DESC

┌─table───────────────┬─name────────────┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ laion_10m_bfloat16  │ text_embedding  │ 13.51 GiB       │ 28.46 GiB         │  2.11 │
│ laion_10m_bfloat16  │ image_embedding │ 13.47 GiB       │ 28.46 GiB         │  2.11 │
│ laion_10m           │ text_embedding  │ 18.36 GiB       │ 28.59 GiB         │  1.56 │
│ laion_10m           │ image_embedding │ 18.36 GiB       │ 28.59 GiB         │  1.56 │
│ laion_100m_bfloat16 │ image_embedding │ 134.02 GiB      │ 286.75 GiB        │  2.14 │
│ laion_100m_bfloat16 │ text_embedding  │ 134.82 GiB      │ 286.75 GiB        │  2.13 │
│ laion_100m          │ text_embedding  │ 181.64 GiB      │ 286.43 GiB        │  1.58 │
│ laion_100m          │ image_embedding │ 182.29 GiB      │ 286.43 GiB        │  1.57 │
└─────────────────────┴─────────────────┴─────────────────┴───────────────────┴───────┘

8 rows in set. Elapsed: 0.009 sec.

由于我们的精度降低到 16 位,因此 ZSTD 压缩级别的进一步提高对我们的压缩 bfloat16 的影响将小于对我们的 32 位表示的影响。如下所示,ZSTD(3) 对我们的压缩 bfloat16 几乎没有影响。

SELECT
	table,
	name,
	formatReadableSize(sum(data_compressed_bytes)) AS compressed_size,
	formatReadableSize(sum(data_uncompressed_bytes)) AS uncompressed_size,
	round(sum(data_uncompressed_bytes) / sum(data_compressed_bytes), 2) AS ratio
FROM system.columns
WHERE (table IN ('laion_100m_bfloat16', 'laion_100m_bfloat16_zstd_3')) AND (name IN ('text_embedding', 'image_embedding'))
GROUP BY
	table,
	name
ORDER BY table DESC

┌─table──────────────────────┬─name────────────┬─compressed_size─┬─uncompressed_size─┬─ratio─┐
│ laion_100m_bfloat16_zstd_3 │ text_embedding  │ 128.12 GiB  	 │ 286.85 GiB    	 │  2.24 │
│ laion_100m_bfloat16_zstd_3 │ image_embedding │ 127.28 GiB   	 │ 286.85 GiB    	 │  2.25 │
│ laion_100m_bfloat16    	 │ image_embedding  │ 133.80 GiB  	 │ 286.75 GiB    	 │  2.14 │
│ laion_100m_bfloat16    	 │ text_embedding   │ 134.59 GiB  	 │ 286.75 GiB    	 │  2.13 │
└────────────────────────────┴─────────────────┴─────────────────┴───────────────────┴───────┘

除了减少磁盘空间外,压缩的增加还具有其他潜在的好处。我们通过包含 1000 万行和 1 亿行的表的查询性能来演示这些好处,使用编码为 float32 和 bfloat16 的嵌入。这些结果基于 之前使用的相同查询

编码冷启动 (秒)热启动 (秒)
laion_10mFloat3212.851 秒2.406 秒
laion_10mbloat16 (bfloat16)7.285 秒1.554 秒
laion_100mFloat32111.857 秒24.444 秒
laion_100mbloat16 (bfloat16)71.362 秒16.271 秒

我们线性扫描速度的提升是显而易见的,对于 1 亿行数据集的冷启动查询,bfloat16 变体将我们的性能从 111 秒提高到 71 秒。

一个显而易见的问题可能是这种精度降低如何影响我们在向量中表示概念的能力,以及是否会导致搜索质量降低。毕竟,我们已经减少了在高维空间中编码的信息,并有效地将我们的向量“更紧密地”压缩在一起。下面我们展示了使用我们新的 laion_100m_v2 表和我们的 search.py 脚本进行的早期“a sleepy ridgeback dog”查询的结果。

python search.py search --text "a sleepy ridgeback dog" --table laion_100m_bfloat16

bfloat16_ridgebacks.png

虽然此搜索的搜索质量没有明显的降低,但这可能需要在更广泛的查询样本中进行相关性测试。用户需要在其特定模型和数据集上测试此精度降低技术,结果可能会因案例而异。

额外的向量乐趣

在阅读了一篇关于向量数学如何用于在高维度空间中移动的有趣博客文章后,我们认为看看相同的概念是否可以应用于我们 CLIP 生成的嵌入可能会很有趣。

例如,假设我们有单词 BerlinGermanyUnited KingdomBridge 的嵌入。可以对其各自的向量执行以下数学运算。

(berlin - germany) + ('united kingdom' + bridge)

如果我们逻辑上减去和加上上述概念,我们可以假设结果将代表伦敦的一座桥梁。

为了测试这个想法,我们增强了我们简单的 search.py 脚本,以支持一个基本解析器,该解析器可以接受类似于上述的输入。此解析器支持运算 +-*/,以及 ' 以表示多词输入,并通过 concept_math 命令公开。

感谢出色的 pyparsing 库,构建此语法的解析器非常简单。总之,上面的短语将被解析为以下语法树

merging_concepts.png

反过来,我们可以递归计算上述树中文本词项(叶子节点)的向量。然后可以使用 ClickHouse 中指定的数学运算符的等效向量函数来组合分支。此过程以深度优先的方式执行,将整个树解析为单个查询(应代表等效概念)。

最后,使用与标准搜索相同的过程,在 image_embedding 列上匹配此函数。因此,以上内容将解析为以下查询

SELECT url, caption,
L2Distance(image_embedding,
	arrayMap((x,y) -> x+y,
    	arrayMap((x,y) -> x-y, [berlin embedding], [germany embedding]),
    	arrayMap((x,y) -> x+y, ['united kingdom' embedding], [bridge embedding])
	)
) AS score FROM laion_10m ORDER BY score ASC LIMIT 10

请注意,我们使用 arrayMap 函数来下推我们的逐点加法和减法(对作为逐点运算的 +- 运算符的支持正在考虑中)。

我们在下面展示了此结果,在 1000 万行样本上进行匹配

python search.py concept_math —-text "(berlin - germany) + ('united kingdom' + bridge)"

london_bridges.png

太酷了!它奏效了!请注意,文本中没有提到伦敦桥 - 第一张图片是 克劳德·莫奈滑铁卢桥系列画作的一部分。

最后,我们认为增强语法解析器以支持整数常量可能很有用。具体来说,我们想看看两个对比鲜明的概念之间的中点会产生什么有趣的东西。例如,cubism(立体主义)和 surrealism(超现实主义)概念之间的艺术可能代表什么?这可以用数学方式表示为 (cubism+surrealism)/2。执行此搜索实际上产生了一些有趣的东西

cubism_surrealism.png

我们将其留给我们的读者中的艺术家来评论这里的相关性和准确性。

这展示了结合向量的另一种有趣的可能。毫无疑问,在其他情况下,这种基本的向量数学也可能有用。我们很乐意听到任何示例!

结论

在这篇博客文章中,我们展示了如何将 20 亿行的向量数据集转换为 Parquet 格式并加载到 ClickHouse 中。我们已经证明,它的压缩效果很好,线性搜索可以通过 CPU 进行扩展,并可以使用元数据进行完整的基于 SQL 的分析进行补充。最后,我们展示了 ClickHouse 的一些较新的 ANN 功能,并探讨了如何使用 UDF 来提供用于生成嵌入的优雅函数。

立即开始使用 ClickHouse Cloud,并获得 300 美元的积分。在 30 天试用期结束时,继续使用按需付费计划,或联系我们以了解有关我们基于用量的折扣的更多信息。访问我们的定价页面了解详情。

分享这篇文章

订阅我们的新闻通讯

随时了解功能发布、产品路线图、支持和云产品!
正在加载表单...
关注我们
X imageSlack imageGitHub image
Telegram imageMeetup imageRss image
©2025ClickHouse, Inc. 总部位于加利福尼亚州湾区和荷兰阿姆斯特丹。