DoubleCloud 即将停止运营。利用限时免费迁移服务迁移到 ClickHouse。立即联系我们 ->->

博客 / 工程

使用 ClickHouse 进行向量搜索 - 第 2 部分

author avatar
Dale McDiarmid
2023 年 5 月 31 日

立即开始使用 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 使用openCLIPOpenAI 开发的 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 大型文件存储 (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 中的向量可以表示为浮点数数组,因此由此过程产生的 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 编解码器来压缩嵌入。下面我们显示了四个大小不断增加的表(100 万行、1000 万行、1 亿行和 20 亿行)中向量列的压缩率。

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) - 这给出了两个向量之间的余弦距离(1 - 余弦相似度)。更具体地说,这测量了两个向量之间角度的余弦,即点积除以长度。这会产生一个介于 -1 和 1 之间的值,其中 1 表示两个嵌入是成比例的,因此在概念上是相同的。可以解析列名和输入嵌入以进行向量搜索。如果向量未被归一化,则此函数特别相关,并且可以提供一个有用的有界范围以进行过滤。
  • L2 距离- L2Distance(vector1, vector2) - 这测量了两个点之间的 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 秒。

例如,让我们将文本“一只困倦的罗得西亚脊背犬”转换为嵌入。为简洁起见,我们已裁剪了完整的嵌入结果,可以在此处找到。

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

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

现在我们有了表示文本“一只困倦的罗得西亚脊背犬”的向量嵌入。这是我们的搜索输入向量。现在,我们可以将此输入向量与我们的向量嵌入存储库进行比较,以查找在概念上表示相似事物的图像及其标题。

将所有内容整合在一起

以下查询搜索概念上相似的嵌入,并按距离排序。嵌入存储在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.)

结果表明,我们的输入向量“一只困倦的罗得西亚脊背犬”在概念上最类似于数据集中非洲罗得西亚脊背猎犬的照片,并且在概念上也非常类似于一张正在睡觉的猎犬的图片。

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的更高余弦相似度得分。对于此示例,让我们从搜索“大型动物迁徙”开始。幸运的是,将其表述为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非常适合此任务。

例如,假设我们想识别用于“野生动物园照片”的主要相机型号。我们在这里执行该搜索

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.)

显然,对于您的下次野生动物园之旅,佳能应该是您的首选相机。请注意,这里我们只使用前1000个结果。与无界的余弦距离不同,欧几里得距离没有上限,这使得施加阈值具有挑战性。

使用倒排索引

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

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

例如,我们可能希望强制执行一个过滤器,将我们的野生动物园图片限制为包含狮子的图片。为此,我们可以施加一个令牌限制 - 要求caption列包含字符串lions

在没有倒排索引的情况下,我们的搜索可能如下所示。在这里,我们利用嵌入用于以下图片并搜索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秒,但以牺牲一些搜索质量为代价。

此处的测试嵌入表示我们的“一只困倦的罗得西亚脊背犬”文本。我们可以在下面看到图像结果。

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个匹配项。为了返回前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% - 0的压缩效果非常好。

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位后,与我们的32位表示相比,进一步提高ZSTD压缩级别将产生的影响较小。如下所示,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.851s2.406s
laion_10mbfloat167.285s1.554s
laion_100mFloat32Float32111.857s
laion_100mbfloat1624.444sbfloat16

我们在线性扫描速度方面的收益是可观的,对于冷查询,bfloat16变体将1亿行数据集的性能从111秒提高到71秒。

一个显而易见的问题可能是这种精度降低如何影响我们表示向量中概念的能力,以及是否会导致搜索质量下降。毕竟,我们减少了在多维空间中编码的信息,并有效地将我们的向量“压缩”得更近。下面我们展示了使用新的laion_100m_v2表和search.py脚本对早期“一只困倦的脊背犬”查询的结果。

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

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

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

cubism_surrealism.png

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

这证明了结合向量的另一种有趣的可能性。毫无疑问,还有其他一些基本向量数学可以派上用场的情况。我们很乐意听到任何例子!

结论

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

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

分享此文章

订阅我们的时事通讯

随时了解功能发布、产品路线图、支持和云产品!
加载表单…
关注我们
Twitter imageSlack imageGitHub image
Telegram imageMeetup imageRss image