数据库是企业中用于管理业务数据的基础设施。随着过去两年大模型的快速发展,越来越多开发者和企业开始利用大模型技术来优化、增强或启动新的业务。在这一过程中产生的数据,被称为非结构化数据。对这类数据的管理需求不断增长,也使数据库系统在多个方面面临更高要求。传统数据库已经无法满足这类场景下的数据管理需求;关注点也不再只是关系型数据库的四个关键特性,即 Atomicity、Consistency、Isolation 和 Durability(ACID,原子性、一致性、隔离性和持久性),而是数据库能否在大数据量和高并发条件下可靠运行。这正是 Milvus 向量数据库诞生的推动力。
在本书中,我们将认识一种新型数据库——向量数据库。作为全球最受欢迎、功能强大的向量数据库,Milvus 将成为我们的学习重点。我们将从 Milvus 的基础介绍开始,学习如何在 Milvus 中创建和设置数据库,如何用 Milvus 构建简单的 RAG 应用,随后深入 Milvus 架构、索引策略、部署模式,以及如何使用 Milvus 扩展应用。
但在本章开启旅程时,我们首先会聚焦于什么是向量数据库,它提供哪些优势,以及它如何帮助我们处理非结构化数据。最后,我们将介绍 Milvus 向量数据库,帮助你入门。
这里,我们将讨论以下主题:
- 理解向量数据库
- 介绍 Milvus
技术要求
本章涉及的实践案例只需要一些非常基础且免费的工具,包括以下内容:
- Google Colab:一个免费的云端 Jupyter Notebook 环境,允许你在浏览器中编写和执行 Python 代码。
- VS Code:用作代码编辑器。我们建议使用最新稳定版 VS Code,以获得最佳体验,并受益于最新功能和 bug 修复。你可以从官方网站下载:
https://code.visualstudio.com/download - Docker:简化代码运行环境的安装,并且易于使用。对于本书,我们建议使用最新稳定版 Docker Desktop,因为它为在 Windows、macOS 和 Linux 上运行容器提供了完整且用户友好的环境。你可以从 Docker 官方网站下载 Docker Desktop:
https://www.docker.com/products/docker-desktop/
本章使用的代码可以在我们的 GitHub 仓库中找到:
https://github.com/PacktPublishing/The-Architecture-Handbook-for-Milvus-Vector-Database
本章中的代码示例既可以使用 Google Colab 运行,也可以使用本地 Python Notebook 环境运行。我们强烈推荐使用 Google Colab,因为它能让你更高效地练习本章内容,同时提供更愉快、更有参与感的学习体验。
理解向量数据库
如果你是刚刚进入向量数据库这个神奇世界的初学者,不用担心,本节会一步步介绍它。只要具备一些基础计算机科学知识就足够了。如果你已经熟悉向量数据库,可以跳过本节。本节讨论的核心内容包括:数据库类型、非结构化数据、编码过程,以及相似性搜索算法。
让我们开始吧。
数据库类型
数据库对大多数人来说都是一个熟悉的概念;简单来说,它们是为存储和检索数据而设计的系统。数据库通常分为两类:关系型数据库和非关系型数据库,后者通常被称为 NoSQL。二者的一个关键区别在于对数据一致性的处理方式。关系型数据库被设计为严格遵循 ACID 特性,以确保可靠的事务处理。
另一方面,非关系型数据库通常会优先考虑可扩展性和性能,而不是严格遵守 ACID。虽然许多 NoSQL 数据库并不保证所有操作都具备完整 ACID 特性,但值得注意的是,在某些非关系型数据库中,根据其设计和具体用例,在特定场景或上下文下仍然可以实现 ACID 特性。这种取舍使 NoSQL 数据库能够在处理大规模、多样化数据时提供更高灵活性和水平扩展能力。
常见的关系型数据库包括 MySQL、Oracle 和 Microsoft SQL Server,而流行的非关系型数据库包括 MongoDB、Cassandra 和 Redis。
数据库的核心功能是数据管理,因为数据代表着每个企业的一种虚拟财富,尤其是用户数据。这些数据需要根据需要新增或更新,同时要确保不会丢失,也不会发生泄露或篡改。日常管理的信息通常以固定格式表示,例如用户信息可能包括用户名、年龄、职业和地址。然而,现实生活中还存在大量无法用固定格式组织的信息,例如文档、图像、视频和音频文件。后一类信息构成了我们生活的核心,因为前一类大多是个人或企业处理后的原始信息,只保留与其应用相关的数据。
多数情况下,你需要处理的是各种形式和格式的非结构化数据。下面我们来理解如何处理这类数据,以及相关挑战。
非结构化数据
非结构化数据指文档、图像、视频和音频等类型的信息。与简单文本相比,管理非结构化数据面临更大挑战。第一个挑战是存储;这类信息在存储到磁盘或光盘等介质之前,需要先进行编码。检索数据时,还必须解码,以便计算机能按照特定规则显示它。
不同场景需要不同存储方式。例如,当个人拍摄照片时,对清晰度的要求可能不高。相比之下,电影对清晰度要求非常高。除了存储之外,检索这类信息也是重大挑战。无论采用哪种文本检索方法,最终过程都涉及字母匹配。然而,处理非结构化数据可能非常困难。
考虑这个例子:有 100 张图片,一个人已经全部看过,并且清楚记得其中内容。如果你问这个人关于这些图片的问题,例如有没有植物图片,或者让他找出一张动物图片,他可以很快回答。能够快速得到答案的关键,在于人的记忆如何对图像信息进行编码,本质上就是从这些图像中提取特征。有了这种编码基础,我们再从问题本身中提取关键信息。随后,这些关键信息会被用于检索包含相关特征的历史数据,并最终提供答案。这可以类比数据库的工作方式:计算机的角色是清楚记住所有数据,并在查询时快速检索答案。
为了处理非结构化数据,必须先对其进行编码,这一过程也称为 embedding。它会把数据转换成固定长度的数值数组,也就是 vector,向量,其中包含非结构化数据的特征。然而,这些特征对人类来说并不可读。编码过程通常需要特定模型,很多模型可以在 Hugging Face 或 GitHub 等平台找到。如果找不到合适模型,可能需要基于现有数据训练新模型,而这项任务可能耗时且资源密集。
那么,编码过程到底是什么?
探索编码过程
为了理解编码过程,我们先关注文本编码。将一段文本转换为向量有两种方法:one-hot encoding 和 TF-IDF encoding。
让我们理解这两种方法背后的基本原理。
One-hot 编码
在 one-hot encoding 中,文本中的每个单词都表示为一个二进制向量。向量长度等于词表中唯一单词总数。对于每个单词,向量中对应该单词的位置为 1,其他所有位置为 0。这种方法简单直观,但无法捕捉单词之间的任何语义关系。
下面是 Python 中 one-hot 编码的示例代码:
import numpy as np
def one_hot_encode(words, vocab):
vector = np.zeros(len(vocab))
for word in words:
index = vocab.index(word)
vector[index] = 1
return vector
vocab = ["apple", "banana", "cherry", "orange", "kiwi", "melon", "mango"]
words = ["apple", "cherry", "kiwi", "mango", "apple"]
encoded_vector = one_hot_encode(words, vocab)
print(encoded_vector)
核心方法是 one_hot_encode,它会创建一个长度与 vocab 相同的一维数组,然后遍历 words。如果某个单词存在于 vocab 中,就将对应位置设置为 1,最后返回该数组。
在 Google Colab 中运行前面的代码,你将得到以下结果。图 1.1 展示最终运行结果。
图 1.1:One-hot 编码结果
如截图所示,正确输出如下:
[1. 0. 1. 0. 1. 0. 1.]
另一种流行的文本编码方法是 TF-IDF 编码。
TF-IDF 编码
TF-IDF 会根据某个词在特定文档中的频率,以及它在所有文档中的出现频率,为每个词分配权重。term frequency(TF,词频)衡量某个词在特定文档中出现的频繁程度;inverse document frequency(IDF,逆文档频率)评估某个词在更大文档集合中有多常见或多稀有。为了更深入理解,你可以访问这个资源,它详细解释了这些概念:
https://milvus.io/ai-quick-reference/what-is-tfidf-and-how-is-it-calculated
通过结合这两个指标,TF-IDF 会突出对某篇文档重要的词,同时降低常见词的权重。这些常见词通常被称为 stop words,也就是停用词,通常包括冠词、介词和连词,例如 the、a、an、is、and、but 和 or。因为这些词几乎在文档集合中的所有文档里都频繁出现,TF-IDF 会给它们非常低的权重,从而使它们在判断文档独特内容或主题时不那么重要。
现在我们已经对 one-hot 和 TF-IDF 编码有了基本理解,接下来使用这两种方法把一段示例文本转换为向量。
下面是 TF-IDF 编码示例代码。这些示例使用 Python 第三方库 gensim,它是一个易用的 Python 库,可以创建高效的文本语义向量。下面的示例相对简单,直接使用 gensim 的示例。
我们将运行这个 demo,体验这个过程的原理。
执行必要导入,引入 corpora 和 tfidf model:
from gensim import corpora, models, similarities
创建待分析文本文件列表:
text_corpus = [
"the cat in the hat",
"the cat sat on the mat",
"the dog sat on the log",
"dogs and cats are great pets"
]
将文档预处理为小写并分词:
texts = [doc.lower().split() for doc in text_corpus]
将每个唯一词映射到整数 ID:
dictionary = corpora.Dictionary(texts)
corpus = [dictionary.doc2bow(text) for text in texts]
将每篇文档转换为 bag-of-words 格式,也就是每篇文档对应一个 (word_id, word_frequency) 元组列表:
tfidf = models.TfidfModel(corpus)
计算 TF-IDF 分数,并构建稀疏矩阵相似性索引,用于高效相似度计算:
index = similarities.SparseMatrixSimilarity(
tfidf[corpus], num_features=len(dictionary))
定义查询文档为单词列表,并使用字典将查询转换为 bag-of-words 格式:
query_document = 'cat hat'.split()
query_bow = dictionary.doc2bow(query_document)
计算查询与语料中所有文档之间的相似度分数:
sims = index[tfidf[query_bow]]
按降序排序并打印相似度分数,用于排名:
for document_number, score in sorted(
enumerate(sims), key=lambda x: x[1], reverse=True):
print(document_number, score)
借助我们前面的 Python 环境,运行起来非常方便。记得运行前安装 gensim 第三方库。下面是运行结果。
图 1.2:TF-IDF 编码结果
如上图所示,正确输出如下:
0 0.71836466
1 0.16127957
2 0.0
3 0.0
下一步是使用相似性搜索算法。
相似性搜索算法
得到向量之后,我们如何使用它们进行检索?这个过程称为 similarity search,也就是相似性搜索,它涉及 approximate nearest neighbor search(ANNS,近似最近邻搜索)算法。ANNS 是一种用于在高维空间中快速找到与给定查询点相似的数据点的方法。
需要注意的是,approximate nearest 并不意味着 exact nearest。例如,在一个二维坐标系中有很多点,如果我们输入一个随机坐标,希望找到最近的点,典型做法是遍历所有已有点,计算输入坐标到每个点的距离,并选择距离最小的点。这被称为 exact nearest neighbor search,即精确最近邻搜索。然而,如果有数万甚至更多点,找到这个精确点会非常耗时。
相比之下,ANN 算法会优化速度和效率,通常牺牲一些精度来获得更快响应,这在处理大规模数据时尤其关键。
ANN 算法有很多种,我们先讨论 locally sensitive hashing(LSH,局部敏感哈希)算法。
LSH
LSH 在原理上类似 hash map,因此更容易让我们从 ANN 入门。LSH 的一个关键特征是它不同于传统哈希函数。传统哈希函数目标是尽量减少哈希冲突,而 LSH 会有意提高冲突概率,以便将相似项分到一起。通过把相似数据点映射到同一个 bucket,LSH 可以加速搜索过程。
LSH 使用哈希函数把高维数据映射到低维空间,确保相似数据点在哈希表中发生碰撞的概率更高。这使得我们只需要在同一个 bucket 内搜索,而不是遍历整个数据集。
LSH 算法的基本步骤如下:
- 选择哈希函数:选择适合数据特征的哈希函数。在我们的例子中,我们将使用坐标点,并用欧氏距离计算距离,同时采用随机投影作为哈希函数。如果你想了解更多,可以访问:
https://en.wikipedia.org/wiki/Locality-sensitive_hashing#Random_projection - 构建哈希表:使用哈希函数将数据点映射到不同 bucket。
- 查询:使用同一个哈希函数将查询点映射到相应 bucket,并在该 bucket 内搜索最近邻,这可能需要距离计算。
- 返回结果:返回找到的近似最近邻。
随机投影哈希
Hashing 是一个将任意大小数据映射为固定大小值的过程,方便快速检索和比较数据。
Random projection hashing 是一种通过随机矩阵将数据投影到低维空间、从而降低数据维度的技术,并且能以高概率保留点之间距离。
让我们把这些知识付诸实践:
初始化:以 n_hashes(哈希函数数量)和 n_buckets(bucket 数量)为参数,并初始化多个空哈希表和随机向量,用于后续哈希计算:
import numpy as np
class LSH:
def __init__(self, n_hashes, n_buckets):
self.n_hashes = n_hashes
self.n_buckets = n_buckets
self.hash_tables = [{} for _ in range(n_hashes)]
np.random.seed(0)
self.random_vectors = np.random.randn(n_hashes, 2)
哈希函数:通过计算随机向量与输入点的点积,为输入点计算二进制哈希值。如果点积大于零,对应 bit 设置为 1;否则设置为 0,并返回一个元组作为哈希值:
def _pairwise_distances(self, X, Y=None):
X = np.array(X)
Y = np.array(Y)
dist_matrix = np.zeros((X.shape[0], Y.shape[0]))
for i in range(X.shape[0]):
for j in range(Y.shape[0]):
dist_matrix[i, j] = np.linalg.norm(X[i] - Y[j])
return dist_matrix
def _hash(self, point):
bits = (
np.dot(self.random_vectors, np.array(point)) > 0
).astype(int)
return [
int(''.join(map(str, bits[i:i+1])), 2) % self.n_buckets
for i in range(self.n_hashes)
]
插入数据:对每个输入点计算其哈希值,并将其存储到对应哈希表中。如果哈希值不存在,就初始化一个 bucket,并将该点加入 bucket:
def insert(self, point):
bucket_ids = self._hash(point)
for i in range(self.n_hashes):
bucket_id = bucket_ids[i]
if bucket_id not in self.hash_tables[i]:
self.hash_tables[i][bucket_id] = []
self.hash_tables[i][bucket_id].append(tuple(point))
查询并返回结果:为查询点计算哈希值,并从哈希表中取出候选点。如果存在候选点,就计算它们到查询点的距离,并返回最近的一个;如果没有候选点,则返回 None:
def query(self, query_point):
candidates = set()
bucket_ids = self._hash(query_point)
for i in range(self.n_hashes):
bucket_id = bucket_ids[i]
if bucket_id in self.hash_tables[i]:
candidates.update(self.hash_tables[i][bucket_id])
candidates = np.array(
[np.array(candidate) for candidate in candidates])
if candidates.size == 0:
return None
distances = self._pairwise_distances(
candidates, [query_point])
nearest_index = np.argmin(distances)
return candidates[nearest_index]
最后,下面是一个简单代码示例,展示如何使用 LSH 类插入数据并执行查询:
data = [[1, 2], [3, 4], [5, 6], [7, 8]]
query_point = [4, 5]
lsh = LSH(n_hashes=5, n_buckets=10)
for point in data:
lsh.insert(point)
result = lsh.query(query_point)
print("lsh query result:", result)
既然我们已经理解算法步骤,我相信阅读整个代码流程会更容易。下面是代码运行结果。让我们一起运行它。
图 1.3:LSH 结果
如图 1.3 所示,正确输出如下:
lsh query result: [3 4]
LSH 算法找到了 [3, 4] 作为查询点 [4, 5] 的最近邻,它们之间的欧氏距离为 √2 ≈ 1.41。这展示了 LSH 通过将相似点哈希到同一 bucket 中,快速找到近似最近邻的能力。
LSH 的优点如下:
- 高维空间中的效率:LSH 在处理高维数据时尤其高效,可以显著降低查询时间。
- 可扩展性:它能够有效处理大规模数据集。
但它也有一些缺点:
- 哈希函数选择:LSH 的性能会受到哈希函数和参数选择影响,需要仔细调优。
- 近似结果:返回结果是近似的,不一定总是最准确的最近邻。
除了 LSH,其他常见 ANNS 算法还包括 inverted file(IVF)和 hierarchical navigable small world(HNSW)。还有各种策略可用于降低高维向量计算负载,例如 product quantization(PQ)。许多实现都可以在开源库中找到,例如 FAISS,它是高效相似性搜索中非常值得推荐的选择。
如果你不熟悉基于 ANNS 的算法,可以参考本书第 18 章,那里会更深入讲解。
在本节中,你已经对数据库和向量数据库有了初步理解。下一节,让我们探索本书的重点:Milvus。
介绍 Milvus
到现在你可能已经猜到了,Milvus 毫无疑问是一个向量数据库,它是专门为处理和管理大规模向量数据而设计的开源项目。
作为全球最受欢迎、功能强大的向量数据库,Milvus 拥有许多优势。下面是每个优势的简要概览,这将帮助我们后续更深入理解 Milvus:
-
高速:Milvus 通过分布式架构实现快速性能,该架构分离读写操作,并配备强大的索引引擎和高效数据存储方法。
-
可扩展性和高性能:系统设计包含多种节点类型,每种节点承担特定功能。多个 coordinator 角色通过服务发现来促进任务和资源调度。
-
多种部署模式:包括 Lite(Python library)、standalone(单机 server 部署)和 Distributed(部署在 Kubernetes 集群上)。
-
丰富 SDK 支持:Milvus 为主流编程语言提供 SDK,包括 Python、Go、Java 和 Node.js,并且全部由官方维护。
-
支持多种数据类型和索引方法:这种灵活性扩展到处理多样化数据类型,例如整数(int)、浮点数(float)、布尔值(bool)、字符串(str),甚至包括机器学习中关键的专用 dense vectors 和 sparse vectors。为了高效管理和检索这些数据,Milvus 提供多种索引策略,包括 IVF_PQ、HNSW 和 DISKANN,每种都针对不同性能需求和规模进行优化。
-
灵活的数据一致性:Milvus 提供多种一致性保证,允许用户根据具体业务场景选择。
-
开发和运维工具:Milvus 包含多种管理和监控工具:
- Attu,用于 GUI 管理:
https://github.com/zilliztech/attu - Birdwatcher,用于查看 metadata:
https://github.com/milvus-io/birdwatcher - 与 Prometheus 和 Grafana 集成,用于监控 metrics 和 logs
- Milvus Backup 工具:
https://github.com/zilliztech/milvus-backup - Milvus CDC,用于增量数据迁移:
https://github.com/zilliztech/milvus-cdc
- Attu,用于 GUI 管理:
如果想更深入了解 Milvus 的功能,可以参考 Milvus overview:
https://milvus.io/docs/overview.md
Milvus 被用于多个领域,包括大模型中的 retrieval-augmented generation(RAG)、文本与多模态搜索、图像搜索和推荐系统。你可以在 Milvus Demos and Tutorials 中查看相关 demo 案例:
https://github.com/milvus-io/milvus#demos-and-tutorials
虽然 Milvus 有很多优势,但也面临一些挑战,包括:
- 依赖多个服务,使部署复杂。
- Milvus 消耗较高计算资源。
- Milvus 自愈能力有限,在错误恢复方面仍有改进空间。
目前,为了应对这些挑战,Milvus 正在持续演进,具体包括:
- 通过开发自有组件减少依赖。
- 探索 DiskANN 等高效索引方法,并优化任务调度。
- 通过错误预防、恢复和检测增强系统稳健性。
- 基于资源使用情况实现动态请求限流。
- 改进升级流程,以支持不中断服务的 rolling upgrades。
以上是对向量数据库和 Milvus 作为向量数据库所提供功能的简要概览,也展示了 Milvus 如何持续演进,以应对挑战并提升能力。
在本书中,我们将聚焦于如何配置并与 Milvus 交互,理解 Milvus 架构,向 Milvus 执行写入和读取,并探索其底层机制。我们还会覆盖 Milvus 中的向量索引和搜索等高级概念,并更深入理解如何评估性能以及如何扩展 Milvus 操作。敬请期待!
小结
本章中,我们探索了向量数据的概念,通过两个 Python 示例展示了如何获得向量,并用另一个 Python 示例解释了向量数据库核心功能——向量搜索——是如何工作的。在此基础上,我们介绍了本书主要关注的 Milvus,详细说明了 Milvus 是什么、它的各种部署模式,并突出展示了它的众多优势。我相信你已经迫不及待想更深入学习 Milvus。
下一章中,我们将带你了解部署 Milvus 的各种方式。