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

本文涉及的产品
阿里云百炼推荐规格 ADB PostgreSQL,4核16GB 100GB 1个月
云原生数据仓库AnalyticDB MySQL版,基础版 8ACU 100GB 1个月
简介: 本文介绍了如何使用ClickHouse进行向量搜索。总体来说,本文通俗易懂地介绍了如何使用ClickHouse进行向量搜索,包括概念、实现、高级功能和应用示例,对使用ClickHouse进行向量搜索提供了很好的概述。


介绍

这篇博客文章是关于向量搜索系列的续篇,基于前一篇文章,我们为向量搜索提供了一个概览,探讨了它与历史上基于倒排索引的方法的关系,目前有价值的可能用例,以及一些高级的实现方法。在这篇文章中,我们将通过实际的例子详细探讨ClickHouse与向量搜索的关系,并回答"什么时候应该使用ClickHouse进行向量搜索?"的问题。

在我们的示例中,我们使用了一个ClickHouse Cloud集群,每个节点有60核CPU和240GB的RAM。



我应该什么时候使用ClickHouse进行向量搜索?

ClickHouse是一个实时的OLAP数据库,支持完整的SQL,并提供了一系列的功能来帮助用户编写分析查询。其中一些函数和数据结构执行向量之间的距离操作,使ClickHouse可以被用作一个向量数据库。

由于完全并行化的查询管道,ClickHouse可以非常快速地处理向量搜索操作,特别是在通过线性扫描所有行进行精确匹配时,提供了与专用向量数据库相当的处理速度。

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

计算两个向量之间的距离的能力只是另一个SQL函数,并且可以有效地与更传统的SQL过滤和聚合能力结合起来。这允许向量与元数据一起存储和查询,甚至与丰富的文本一起,从而支持各种各样的用例和应用。

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

总之,当符合以下场景之一的,ClickHouse都是一个有效的向量搜索平台:

1. 你希望将向量匹配与元数据过滤和/或聚合或连接能力结合起来。

2. 你需要在非常大的向量数据集上执行线性距离匹配,并希望将这项工作并行化和分布在许多CPU核心上,而不需要任何额外的工作或配置。

3. 您需要匹配某个大小的向量数据集,而由于成本或硬件可用性的原因,仅依赖内存索引是不可用行的。

4. 当查询向量时,你将受益于完全的SQL支持。

5. 你已经有一个生成向量的嵌入生成管道,并且不需要这个能力原生支持存储引擎。

6. 你已经在ClickHouse中有相关的数据,而不希望为几百万个向量产生学习另一个工具的开销和成本。

7. 你主要需要快速并行化精确匹配你的向量,并不需要一个生产实现的ANN。

8. 你是一个经验丰富或好奇的ClickHouse用户,并相信我们会改善我们的向量匹配能力,并希望成为这个旅程的一部分。

虽然这涵盖了广泛的用例,但在某些情况下,ClickHouse可能作为一个向量存储引擎不太合适,你可能希望考虑其他替代方案,如Faiss,或者一个专用的向量数据库。如果有以下场景,ClickHouse可能不太适合:

1. 你的向量数据集很小,并且很容易适应内存。虽然ClickHouse可以轻松地完成小数据集的向量搜索,但它可能比实际需要的更强大。

2. 你没有向量的额外元数据,只需要距离匹配和排序。如果不需要将向量搜索结果与其他元数据连接,而你的数据集很小,那么如上所述,ClickHouse可能比你真正需要的更强大。

3. 你有一个非常高的QPS,大于每秒几千。通常,对于这些用例,数据集将适应内存,并且需要几毫秒的匹配时间。虽然ClickHouse可以为这些用例提供服务,但一个简单的内存索引可能就足够了。

4. 你需要一个包括嵌入生成能力的解决方案,其中模型在插入和查询时都被集成。向量数据库,如Weaviate,是专门为这个用例设计的,并且可能更适合这些需求。

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



设置一个示例


LAION数据集

正如我们在前一篇文章中所讨论的,向量搜索在嵌入上进行操作 - 代表上下文含义的向量。通过将原始内容(如图像或文本)通过预先训练的机器学习模型进行传递,生成嵌入。

对于这篇文章,我们使用了一个名为LAION 5十亿测试集的预先准备好的嵌入,这是公开可下载的。我们选择了这个数据集,因为我们认为在撰写这篇文章时,这是最大的可供测试的预先计算的嵌入数据集。它由公共互联网上的几十亿张公共图片及其标题的嵌入组成,这些嵌入是通过公开爬取互联网生成的。为了测试大规模的向量搜索,它还包括了元数据,这有助于说明如何将ClickHouse的通用分析能力与向量搜索结合起来。

在LAION数据集中,每个图像及其相关标题都生成了嵌入 - 这给了我们每个对象两个嵌入。对于这篇文章,我们只关注英语子集,它包含了22亿个对象。尽管这些对象每个都有两个嵌入,分别是图像和标题,但我们在ClickHouse中将每对作为一个单独的行存储,这给了我们总共约22亿行,44亿向量。对于每一行,我们包括元数据作为列,这捕获了诸如图像尺寸,图像和标题嵌入的相似性等信息。这种相似性,一个余弦距离,使我们能够识别出标题和图像不概念对齐的对象,在查询中可能过滤掉这些对象。

我们希望承认原始作者整理这个数据集的努力,并为公共使用生成嵌入。我们建议阅读生成这个数据集的完整过程,它克服了一些具有挑战性的数据工程挑战,例如在合理的时间内有效地下载和调整数十亿张图片的大小,并在可接受的成本下。


使用CLIP模型生成嵌入

这些LAION嵌入是使用ViT-L/14模型生成的,这个模型是由LAION使用openCLIP训练的,openCLIP是OpenAI开发的CLIP模型的一个开源实现。这不是一个便宜的过程!对400百万张图片,这花了大约30天,并需要592个V100 GPU。

CLIP(Contrastive Language-Image Pre-training)是一个多模态模型,这意味着它被设计来训练多种相关类型的数据,如图像和相关的文本。CLIP在OCR、地理定位和动作识别方面都有很好的效果。对于图像的编码,CLIP的作者使用了Resnet50和视觉变换器(ViT),对于文本的编码,使用了类似于GPT-2的变换器。生成的嵌入表示为两个独立的向量集。

训练过程的关键结果是,这两种数据类型的嵌入是可以比较的 - 如果图像和标题的向量是接近的,那么它们可以被认为在概念上是相似的。一个好的模型,如CLIP,会产生接近的嵌入,距离上接近,或者余弦相似度的值接近1,对于一个图像向量及其相关的标题向量。这在下面的图像中得到了说明,其中T1是第一幅图像标题的嵌入表示,I1是图像本身的编码。这意味着我们希望在训练过程中最大化这个矩阵的对角线,在我们的图像和文本重合时。

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

图片来源: https://openai.com/research/clip


为加载做好数据准备

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

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

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

buntu@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/s-l1600_16_-_Copy_110x110@2x.jpg?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数据集,用户可以下载并使用它来重现示例。



在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) - 这给了我们两个向量之间的余弦相似性。更具体地说,这度量了两个向量之间的角的余弦,即点积除以长度。这产生了一个在-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,这个脚本会利用它。这可以极大地减少生成时间 - 当在2021年的Mac M1上进行测试时,为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

为了进一步展示这些模型的实用性,我们可以选择使用一张睡觉的狗的照片作为搜索起点,而不是使用文本。我们生成代表这张照片的输入向量,然后搜索概念上相似的结果。

为此,我们重复上面的查询,但使用 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

在这两个例子中,我们都匹配了不同模式的嵌入,即从图像输入中得到的嵌入与 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"


使用元数据进行聚合

除了过滤,我们还可以对元数据进行聚合操作。作为一个面向列的数据库,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.)

显然,如果您下次想去野生动物园,Canon应该是您的相机首选。请注意,在这里,我们仅使用前1000个结果。与余弦距离不同,余弦距离没有界限,而欧几里得距离没有上限,这使得设置阈值变得具有挑战性。


使用倒排索引

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

ClickHouse的实验性次级索引功能在处理向量时也可能非常有用。

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

如果没有倒排索引,我们的搜索可能如下所示。在此,我们使用以下图片的嵌入并针对1亿个向量进行搜索。

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')"



高级功能


近似最近邻 (Annoy)

注意:ClickHouse中的Annoy索引仍处于高度实验性阶段。

Annoy索引旨在提高大规模最近邻向量搜索的效率。它涉及到精度和计算效率之间的权衡。

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

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

图像由Annoy分割的超平面

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

下面,我们展示了带有Annoy索引的LAION数据集的模式,每个嵌入字段都有一个索引。我们使用默认的索引为表填充100m行。

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索引的开销是很大的,上述插入大约需要27分钟来插入1亿行数据。与此相比,不带这些索引的表格需要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

在ClickHouse中,重要的是要注意Annoy索引可以用来加速查询,这些查询要么利用 ORDER BY DistanceFunction(Column, vector) 排序,要么利用 WHERE DistanceFunction(Column, Point) < MaxDistance 进行过滤,但不能同时使用两者。查询必须设置一个LIMIT来返回前N个匹配项。为了返回前面的匹配项,使用基于优先队列的缓冲区来收集匹配的向量。一旦缓冲区满了,收集就停止,然后对缓冲区进行排序。此缓冲区的大小由 max_limit_for_ann_queries 的设置限制(默认值为1000000)。


用户定义函数(UDFs)

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

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

我们可以使用UDFs来完成这项任务。下面定义的UDFs分别叫做 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()

这个 embed_text.py 脚本然后可以通过自定义函数embedText暴露出来。以下配置可以放在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实例池,准备接收输入。对于我们的函数,这是有益的,因为它减少了第一次执行后的模型加载时间。这允许后续调用更快。有关如何控制池大小和其他配置参数的更多详细信息,可以在此处找到。

现在两个UDFs都配置好了,我们可以进行如下查询:

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的bloat16类型适用于机器学习用例,只需要截断32位浮点数的最后16位,前提是后者使用IEE-754编码。

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

尽管bfloat16目前不是ClickHouse的原生功能,但它可以很容易地用其他函数复制。我们在下面为 image_embeddingtext_embedding 列执行此操作。

为此,从表 laion_100m (包含100m行)中选择所有行,并使用 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的二进制序列,并执行与值4294901760bitAnd 操作。这个后者的值是二进制序列000000000000000001111111111111111。因此,这个操作将尾随的16位归零,执行一个有效的截断。然后重新解释生成的二进制值作为float32。

我们在下面示范了这个过程:

INSERT INTO default.laiINSERT 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_1mon_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_modelFROM laion_1m

如下所示,这样做有效地将我们的压缩数据减少了超过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的影响较小。如下所示,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 │
└────────────────────────────┴─────────────────┴─────────────────┴───────────────────┴───────┘

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

Table

Encoding

Cold (secs)

Hot (secs)

laion_10m

Float32

12.851s

2.406s

laion_10m

bloat16

7.285s

1.554s

laion_100m

Float32

111.857s

24.444s

laion_100m

bloat16

71.362s

16.271s

我们在这里线性扫描的速度上取得了显著的提升,使用bfloat16变体在100m行数据集的冷查询上将我们的性能从111秒提高到了71秒。

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

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

尽管对于这次搜索没有明显的搜索质量降低,但这很可能需要在更广泛的查询样本上进行相关性测试。用户需要在他们特定的模型和数据集上测试这种精度降低技术,结果可能会因情况而异。


额外的向量乐趣

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

例如,假设我们有 BerlinGermanyUnited KingdomBridge 的词嵌入。我们可以对它们各自的向量执行以下数学操作。

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

如果我们逻辑上减去并添加上述概念,我们可以假设结果会表示伦敦的一座桥。

为了测试这个想法,我们增强了我们的简单 search.py 脚本,以支持一个可以接受与上面类似输入的基本解析器。这个解析器支持 +-*  和  / 操作,以及 ' 来表示多项输入,并通过一个 concept_math 命令公开。

感谢伟大的 pyparsing 库,为这种语法构建一个解析器是很简单的。总之,上面的短语会被解析成以下的语法树:

我们可以反过来递归地计算上面树中文本项(叶子)的向量。然后,可以使用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函数来进行我们的逐点加法和减法(考虑支持 +- 操作符作为逐点操作)。

我们在下面展示了这些结果,匹配10m行的样本:

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

太酷了!它真的有效!请注意,文本中没有提到伦敦桥 - 第一张图片是Claude Monet Waterloo桥画作系列的一部分。

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

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

这再次展示了组合向量的另一个有趣的可能性。毫无疑问,这种基本的向量数学在其他情况下可能很有用。我们很想听到任何例子!



结论

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




云数据库 ClickHouse 版是阿里云提供的全托管 ClickHouse服务,是国内唯一和 ClickHouse 原厂达成战略合作并一方提供企业版内核服务的云产品。 企业版较社区版 ClickHouse 增强支持实时update&delete,云原生存算分离及Serverless 能力,整体成本可降低50%以上,现已开启邀测,欢迎申请体验(链接:https://www.aliyun.com/product/apsaradb/clickhouse

产品介绍(https://www.aliyun.com/product/apsaradb/clickhouse

技术交流群:

image.png

ClickHouse官方公众号:

image.png

相关文章
|
存储 自然语言处理 算法
使用ClickHouse进行矢量搜索 - 第一部分
本文介绍了向量搜索的概念,即使用数学向量来存储和检索数据。向量可以捕捉数据的语义关系,提高搜索效率。文章还提到了向量搜索在推荐、问题回答、图像/视频搜索等方面的应用。向量搜索可以应用于文本数据、图像数据、音频数据等不同类型的数据。最后,文章总结了向量搜索的挑战和现有技术,并展望了未来的研究方向。
47778 26
|
5月前
|
存储 关系型数据库 数据库
【DDIA笔记】【ch2】 数据模型和查询语言 -- 多对一和多对多
【6月更文挑战第7天】该文探讨数据模型,比较了“多对一”和“多对多”关系。通过使用ID而不是纯文本(如region_id代替&quot;Greater Seattle Area&quot;),可以实现统一、避免歧义、简化修改、支持本地化及优化搜索。在数据库设计中,需权衡冗余和范式。文档型数据库适合一对多但处理多对多复杂,若无Join,需应用程序处理。关系型数据库则通过外键和JOIN处理这些关系。文章还提及文档模型与70年代层次模型的相似性,层次模型以树形结构限制了多对多关系处理。为克服层次模型局限,发展出了关系模型和网状模型。
59 6
|
5月前
|
XML NoSQL 数据库
【DDIA笔记】【ch2】 数据模型和查询语言 -- 概念 + 数据模型
【6月更文挑战第5天】本文探讨了数据模型的分析,关注点包括数据元素、关系及不同类型的模型(关系、文档、图)与Schema模式。查询语言的考量涉及与数据模型的关联及声明式与命令式编程。数据模型从应用开发者到硬件工程师的各抽象层次中起着简化复杂性的关键作用,理想模型应具备简洁直观和可组合性。
39 2
|
5月前
|
SQL 人工智能 关系型数据库
【DDIA笔记】【ch2】 数据模型和查询语言 -- 文档模型中Schema的灵活性
【6月更文挑战第8天】网状模型是层次模型的扩展,允许节点有多重父节点,但导航复杂,需要预知数据库结构。关系模型将数据组织为元组和关系,强调声明式查询,解耦查询语句与执行路径,简化了访问并通过查询优化器提高效率。文档型数据库适合树形结构数据,提供弱模式灵活性,但在Join支持和访问局部性上不如关系型。关系型数据库通过外键和Join处理多对多关系,适合高度关联数据。文档型数据库的模式灵活性体现在schema-on-read,写入时不校验,读取时解析,牺牲性能换取灵活性。适用于不同类型或结构变化的数据场景。
49 0
|
5月前
|
SQL JSON NoSQL
【DDIA笔记】【ch2】 数据模型和查询语言 -- 关系模型与文档模型
【6月更文挑战第6天】关系模型是主流数据库模型,以二维表形式展示数据,支持关系算子。分为事务型、分析型和混合型。尽管有其他模型挑战,如网状和层次模型,但关系模型仍占主导。然而,随着大数据增长和NoSQL的出现(如MongoDB、Redis),强调伸缩性、专业化查询和表达力,关系模型的局限性显现。面向对象编程与SQL的不匹配导致“阻抗不匹配”问题,ORM框架缓解但未完全解决。文档模型(如JSON)提供更自然的嵌套结构,适合表示复杂关系,具备模式灵活性和更好的数据局部性。
53 0
|
5月前
|
敏捷开发 存储 缓存
【DDIA笔记】【ch1】 可靠性、可扩展性和可维护性 -- 可维护性
【6月更文挑战第4天】本文探讨了Twitter面临的一次发推文引发的巨大写入压力问题,指出用户粉丝数分布是决定系统扩展性的关键因素。为解决此问题,Twitter采用混合策略,大部分用户推文扇出至粉丝主页时间线,而少数名人推文则单独处理。性能指标包括吞吐量、响应时间和延迟,其中高百分位响应时间对用户体验至关重要。应对负载的方法分为纵向和横向扩展,以及自动和手动调整。文章强调了可维护性的重要性,包括可操作性、简单性和可演化性,以减轻维护负担和适应变化。此外,良好设计应减少复杂性,提供预测性行为,并支持未来改动。
59 0
|
5月前
|
缓存 关系型数据库 数据库
【DDIA笔记】【ch1】 可靠性、可扩展性和可维护性 -- 可扩展性
【6月更文挑战第3天】可扩展性关乎系统应对负载增长的能力,但在产品初期过度设计可能导致失败。理解基本概念以应对可能的负载增长是必要的。衡量负载的关键指标包括日活、请求频率、数据库读写比例等。推特的扩展性挑战在于&quot;扇出&quot;,即用户关注网络的广度。两种策略包括拉取(按需查询数据库)和推送(预计算feed流)。推送方法在推特案例中更为有效,因为它减少了高流量时的实时计算压力。
53 0
|
5月前
|
存储 消息中间件 缓存
【DDIA笔记】【ch1】 可靠性、可扩展性和可维护性 -- part1 可靠性
【6月更文挑战第2天】本书探讨现代数据系统,阐述其在信息社会中的关键作用,包括数据库、缓存、搜索引擎、流处理、批处理和消息队列等组成部分。随着技术发展,工具如Kafka、Spark和Redis等多功能组件使得系统设计更为复杂。面对可靠性、可扩展性和可维护性的挑战,书中强调了容错和韧性的重要性,区分了硬件故障、软件错误和人为错误,并提出了应对措施。可靠性关乎用户数据、企业声誉和生存,因此是系统设计的核心考量。
53 0
硬件开发笔记(十): 硬件开发基本流程,制作一个USB转RS232的模块(九):创建CH340G/MAX232封装库sop-16并关联原理图元器件
有了原理图,可以设计硬件PCB,在设计PCB之间还有一个协同优先动作,就是映射封装,原理图库的元器件我们是自己设计的。为了更好的表述封装设计过程,本文描述了CH340G和MAX232芯片封装创建(SOP-16),并将原理图的元器件关联引脚封装。
硬件开发笔记(十): 硬件开发基本流程,制作一个USB转RS232的模块(九):创建CH340G/MAX232封装库sop-16并关联原理图元器件

热门文章

最新文章