学习 Milvus 并不需要你成为一名数学家,但如果你想从“调用 API”进阶到“系统调优”,理解其背后的数学逻辑至关重要。
Milvus 的核心任务是解决 近似最近邻搜索(ANN, Approximate Nearest Neighbor Search) 问题。以下是为你整理的知识图谱:
1. 向量空间与度量几何 (基础)
向量数据库的一切都建立在“距离”之上。你需要理解如何衡量两个高维向量的相似度。
- 欧几里得距离 (L2 Distance): 衡量空间中两点间的绝对距离。适用于对数值大小敏感的数据。
- 余弦相似度 (Cosine Similarity): 衡量两个向量方向的夹角。在自然语言处理(NLP)中最为常用,因为它只关注语义方向而非文本长度。
- 内积 (Inner Product, IP): 衡量向量投影的长度。通常用于推荐系统中的矩阵分解模型。
2. 索引算法背后的数学原理 (进阶)
Milvus 之所以快,是因为它不进行全量对比。理解以下三种主流索引的原理是调优的关键:
A. 基于图的索引 (HNSW)
这是目前 Milvus 最常用的索引。
- 数学概念: 小世界网络(Small World Networks)和概率跳表。
- 原理: 在高维空间构建多层图结构,利用“六度分割”理论,通过少数几次跳转快速定位到目标区域。
B. 基于聚类的索引 (IVF)
- 数学概念: K-Means 聚类。
- 原理: 利用质心(Centroids)将空间划分为若干个 Voronoi 单元(Voronoi Cells)。搜索时先找最近的质心,再在对应的单元内搜索。
C. 数据压缩 (PQ, Product Quantization)
- 数学概念: 向量量化、子空间分解。
- 原理: 将高维向量切分成多个低维子向量,并对子向量进行聚类,用“聚类中心 ID”代替原始数值。这能将内存占用降低 10-100 倍,但会损失一定的精度。
3. 统计学与概率论 (生产实践)
在评估搜索质量时,你需要用到这些统计指标:
- 召回率 (Recall): ANN 搜索的结果中有多少是真正的 Top-K 最近邻?这是衡量算法好坏的核心指标。
- 查准率 (Precision): 检索结果中相关结果的比例。
- 长尾效应: 理解数据分布的稀疏性对索引性能的影响。
4. 维数灾难 (The Curse of Dimensionality)
这是理解向量搜索必要性的理论基础。
- 理论核心: 随着维度 \(n\) 的增加,空间体积呈指数级增长,导致数据点变得极其稀疏。在极高维空间下,传统的索引(如 B-Tree、K-D Tree)会失效,甚至“最近邻”和“最远邻”的距离趋于相等。
- 对策: 这就是为什么我们必须使用 近似 (Approximate) 搜索而不是精确搜索的原因。
学习建议
如果你是初学者,建议先攻克 “余弦相似度” 和 “L2 距离” 的计算逻辑;
如果你负责架构选型,请重点研究 “HNSW 的层级跳转原理” 和 “PQ 量化的空间压缩比”。
对比 HNSW、IVF 和 DiskANN 这几种索引在具体数学特性上的优缺点
HNSW vs. IVF vs. DiskANN 深度对比
| 索引类型 | 核心数学/算法逻辑 | 优势 (Pros) | 劣势 (Cons) | 适用场景 |
|---|---|---|---|---|
| HNSW (Hierarchical Navigable Small World) | 层级图结构。利用概率跳表和贪心搜索在多层图中快速收敛。 | 极高的查询速度和召回率;支持增量插入。 | 内存消耗巨大。需要将整个图结构和向量全部读入内存。 | 内存充足、对响应延迟(ms 级)极其敏感的在线业务。 |
| IVF (Inverted File) | 聚类分桶。使用 K-Means 将空间划分为 \(k\) 个区域,搜索时只扫描最近的 \(n\) 个桶。 | 内存占用可控;配合 PQ 量化可以极大压缩数据。 | 召回率受聚类质量影响;新增数据较多时需要重新训练聚类中心。 | 中等规模数据,平衡内存和速度的通用场景。 |
| DiskANN (Vamana Algorithm) | 磁盘常驻图索引。通过特殊的图结构设计减少磁盘 IO 次数。 | 海量数据支持。可以用极小的内存管理数十亿级向量。 | 查询延迟较高(受限于磁盘 IO);必须使用 SSD 硬盘。 | 亿级以上数据规模,预算有限,对延迟要求在几十毫秒左右。 |
1. HNSW:空间换时间的“高速公路”
HNSW 的数学核心是小世界网络。它通过在顶层建立“长跨度”的边,底层建立“短跨度”的边,实现了类似跳表的对数级搜索复杂度。
- 数学直觉: 想象你在一个巨大的城市找人。HNSW 的顶层是“省际高速”,帮你快速跨越区域;底层是“胡同小巷”,帮你精确定位。
2. IVF:缩小搜索范围的“档案柜”
IVF 的核心数学工具是 K-Means 聚类。
- 计算逻辑: 搜索时,先计算查询向量与各个聚类中心(Centroids)的距离,排除掉 99% 不相关的区域。
- 关键参数 \(nprobe\): 这是一个概率权衡参数。搜的桶(Bucket)越多,召回率越高,但速度越慢。
3. DiskANN:突破内存限制的“导航仪”
在 2026 年的大模型应用中,数据量动辄上亿,全部塞进内存太贵了。DiskANN 采用了 Vamana 算法。
- 数学改进: 相比 HNSW,Vamana 允许建立更长、更具方向性的边,使得从磁盘读取数据时,单次请求就能获取更多有效信息,从而减少磁盘寻址次数。
总结建议:如何选型?
- 如果你追求极致性能(如搜索建议、实时对齐): 选 HNSW。
- 如果你的内存有限,但数据量还不算天文数字: 选 IVF_PQ(量化后的 IVF)。
- 如果你的数据量达到 1 亿以上,且不想花几十万买内存: 选 DiskANN。
如何针对这些索引在 Milvus 中进行参数调优(例如调整 M、efConstruction 或 nprobe)
调优这些索引参数,本质上是在搜索精度(Recall)、查询速度(TPS/Latency)和系统资源(CPU/RAM)这三个维度之间寻找平衡点。
在 Milvus 中,参数分为索引构建参数(建表时确定,改动需重建索引)和搜索参数(查询时动态调整)。
1. HNSW 参数调优:速度与内存的博弈
HNSW 是最常用的索引,其核心在于图的连通性。
| 参数 | 类型 | 建议值范围 | 数学意义与影响 |
|---|---|---|---|
M |
构建 | 4 - 64 | 每个节点的邻居数。M 越大,图越稠密,召回率越高,但索引文件会成倍增大,内存压力剧增。 |
efConstruction |
构建 | 8 - 512 | 构建时的搜索范围。增加此值会提高索引质量(找到更近的邻居),但会大幅延长建索引的时间。 |
ef |
搜索 | \(K\) - 512 | 查询时的动态搜索列表长度。这是最关键的调优手段:ef 越大,召回率越高,但每秒查询数 (QPS) 会下降。 |
调优技巧: 如果发现召回率达不到 95% 以上,优先调大搜索时的
ef。如果ef调到 512 仍不理想,再回过头增加建索引时的M。
2. IVF 参数调优:聚类精度的平衡
IVF (Inverted File) 就像是在图书馆里先找书架,再找书。
| 参数 | 类型 | 建议值范围 | 数学意义与影响 |
|---|---|---|---|
nlist |
构建 | \(4 \times \sqrt{N}\) | 聚类中心(桶)的数量。\(N\) 为总数据量。nlist 过小会导致每个桶数据太多(变成全量扫描);过大则导致聚类中心本身搜索太慢。 |
nprobe |
搜索 | 1 - nlist |
查询时扫描的桶数量。这是 IVF 调优的核心:扫描 1 个桶最快但精度低,扫描全部桶等于全表扫描。通常设为 nlist 的 1%~5%。 |
3. DiskANN 参数调优:磁盘 IO 的艺术
DiskANN 专门为海量数据(SSD 存储)设计。
| 参数 | 类型 | 建议值范围 | 数学意义与影响 |
|---|---|---|---|
max_degree |
构建 | 32 - 64 | 图的最大出度。类似于 HNSW 的 M,决定了图的复杂度。 |
search_list_size |
搜索 | \(K\) - 200 | 搜索候选集大小。直接影响磁盘读取次数(IOPS)。值越大,召回率越高,但延迟明显增加。 |
4. 通用调优建议(2026 经验法则)
- 先压测,再上线: 建议使用 Milvus 官方提供的 Goose 或 VectorDBBench 进行压力测试。
- Recall vs. Latency: 在生产环境中,通常追求 90% - 95% 的召回率 即可。追求 99% 以上的召回率往往需要付出 5-10 倍的延迟代价。
- 内存预留:
- HNSW 索引大小 \(\approx\) (向量维度 \(\times\) 4 + \(M \times 8\)) \(\times\) 总向量数。
- 务必预留 20% 的内存缓冲,防止 OOM。
基于 pymilvus 的实战脚本逻辑。它模拟了在生产环境中经常进行的 **"参数网格搜索
下一步实践
理论联系实际,最好的学习方法是观察参数变化带来的指标变动。
这是一段基于 pymilvus 的实战脚本逻辑。它模拟了在生产环境中经常进行的 "参数网格搜索" (Grid Search),帮助你直观看到调整 nprobe 或 ef 时,召回率与耗时之间的“负相关”曲线。
1. 核心调优实验脚本 (Python)
这段代码展示了如何针对同一个集合,在不同搜索参数下测试性能:
from pymilvus import Collection, connections
import time
# 1. 连接 Milvus
connections.connect("default", host="localhost", port="19533")
collection = Collection("my_vector_collection")
collection.load()
# 2. 定义待测试的搜索参数范围 (以 IVF 索引为例)
# 如果是 HNSW,则修改为 {"ef": v}
test_params = [1, 5, 10, 20, 50, 100]
search_vectors = [[...]] # 你的测试向量
top_k = 10
print(f"{'nprobe':<10} | {'Latency (ms)':<15} | {'Recall (Estimated)':<15}")
print("-" * 45)
for val in test_params:
search_params = {"metric_type": "L2", "params": {"nprobe": val}}
start_time = time.time()
results = collection.search(
data=search_vectors,
anns_field="embeddings",
param=search_params,
limit=top_k
)
end_time = time.time()
latency = (end_time - start_time) * 1000
# 注意:真实召回率需要对比暴力搜索(Flat)的结果,这里仅展示耗时变化
print(f"{val:<10} | {latency:<15.2f} | 随参数增加而提升")
2. 参数变动趋势图解
在实际测试中,你会观察到以下数学规律:
- IVF 索引 (
nprobe): 随着nprobe增大,搜索覆盖的 Voronoi 单元越多,召回率呈对数增长,但耗时呈线性增长。 - HNSW 索引 (
ef):ef的增加对召回率提升非常显著,但当ef超过一定阈值(如 256 或 512),召回率进入平台期,而延迟会剧增。
3. 生产环境的“三步调优法”
如果你现在要将 Milvus 推向生产,建议遵循以下流程:
- 确定基准 (Baseline):
- 使用
FLAT索引(暴力搜索)跑一遍你的测试集,记录下“绝对准确”的结果。
- 寻找拐点:
- 将
ef(HNSW) 或nprobe(IVF) 从小到大循环测试。 - 寻找那个“召回率刚达到 95% 但耗时突然跳变”的前一个点。
- 并发测试:
- 单次查询快不代表吞吐量(TPS)高。在确定了最优参数后,使用多线程模拟真实流量,观察 CPU 负载。
在 Milvus 或任何向量数据库中,欧几里得距离(Euclidean Distance),也被称为 L2 距离,是最基础且最常用的相似度度量标准。
从数学本质上讲,它衡量的是多维空间中两点之间的“直线距离”。
1. 数学定义
假设在 \(n\) 维空间中有两个点(向量):
- 查询向量 \(\mathbf{a} = [x_1, x_2, \dots, x_n]\)
- 库中向量 \(\mathbf{b} = [y_1, y_2, \dots, y_n]\)
欧几里得距离 \(d\) 的计算公式为:
在 Milvus 中的表现
在 Milvus 的 metric_type 配置中,它被写作 L2。
- 数值越小:表示两个向量越相似(距离越近)。
- 数值为 0:表示两个向量完全重合。
2. 几何原理:勾股定理的高维推广
欧几里得距离的理论根基是初中数学中的勾股定理。
- 二维空间: \(d = \sqrt{(x_1-y_1)^2 + (x_2-y_2)^2}\)(即直角三角形斜边长度)。
- 三维空间: 扩展到 \(z\) 轴,公式依然成立。
- 高维空间(128维、768维等): 尽管人类无法直观想象高维空间,但数学推导完全一致。
3. 核心特性与数学局限
A. 它是“绝对长度”敏感的
这是欧几里得距离与余弦相似度最大的区别:
- 欧几里得距离:不仅关注向量的方向,还关注向量的模长(大小)。
- 例子:如果两个人的消费偏好方向一致,但一个人消费了 100 元,另一个人消费了 10,000 元,欧几里得距离会认为他们“很不相似”。
B. 旋转不变性(Rotational Invariance)
如果你将坐标系旋转,空间中两点的欧几里得距离不会改变。这保证了在进行特征转换(如 PCA 主成分分析)后,距离关系的稳定性。
C. 维数灾难(The Curse of Dimensionality)
随着维度 \(n\) 的增加,高维空间中所有点之间的距离都会趋向于相等。
- 后果:在极高维度下,欧几里得距离的“区分度”会下降。这就是为什么在处理大模型 Embeddings(如 1536 维)时,有时候需要先进行归一化(Normalization)处理。
4. 为什么 Milvus 计算 L2 很快?
在计算机工程层面,Milvus 并不是简单地按照公式逐个开根号,而是利用了展开式:
- \(\|\mathbf{a}\|^2\) 和 \(\|\mathbf{b}\|^2\) 可以预先计算并缓存。
- 核心计算变成了 \(-2(\mathbf{a} \cdot \mathbf{b})\),即点积(Inner Product)。
- 现代 CPU/GPU 可以通过 SIMD(单指令多数据流) 指令集并行计算点积,极大地提升了搜索速度。
5. 什么时候该选欧几里得距离?
- 推荐系统:当你需要区分“重度用户”和“轻度用户”时(模长代表活跃度)。
- 图像识别:计算像素特征点的物理差异。
- 传感器数据:如 GPS 坐标或物理参数的偏差分析。
需要我演示一下如何将数据进行“归一化”处理,从而让欧几里得距离在效果上等同于余弦相似度吗?(这在工业界是一个非常实用的 trick)
4. 2026 年的新趋势:自动调优 (Auto-Index)
在 Milvus 3.0+ 版本中,官方引入了更多自动化能力。如果你不想手动折腾这些数学参数,可以关注:
- Auto-Index: 你只需指定目标的召回率(如 98%),Milvus 会在后台根据数据分布自动调整索引参数。
在工业界(尤其是使用 OpenAI、Cohere 等大模型 Embedding 时),将欧几里得距离(L2)和余弦相似度(Cosine)合二为一是一个非常经典的“数学欺骗”技巧。
1. 数学原理:归一化(Normalization)
余弦相似度只关注向量的夹角,不关注长度。而欧几里得距离既看夹角也看长度。
如果我们把所有向量的长度(模长)都缩放到 1,那么这些向量就会全部落在高维空间的单位超球体(Unit Hypersphere)表面。
此时,欧几里得距离 \(d\) 与余弦相似度 \(\cos(\theta)\) 存在如下恒等关系:
由于归一化后 \(\|\mathbf{a}\| = 1, \|\mathbf{b}\| = 1\),公式简化为:
结论: 归一化后,欧几里得距离越小,等同于余弦相似度越大。
2. 为什么要这么做?
- 性能优势: 很多硬件加速指令集(如 SIMD、GPU)对欧几里得距离的底层优化极其成熟。
- 索引通用性: 某些索引算法(如早期版本的某些图索引)对 L2 支持更友好。通过归一化,你可以用 L2 的引擎跑出 Cosine 的效果。
3. 代码实现 (Python + NumPy)
在将数据插入 Milvus 之前,你可以通过以下几行代码完成归一化:
import numpy as np
def normalize_vector(vec):
# 计算模长 (L2 norm)
norm = np.linalg.norm(vec)
if norm == 0:
return vec
# 将每个维度除以模长
return vec / norm
# 原始向量
v1 = np.array([1, 2, 3])
v2 = np.array([10, 20, 30]) # 方向相同,长度不同
# 归一化后
v1_norm = normalize_vector(v1)
v2_norm = normalize_vector(v2)
# 此时 v1_norm 和 v2_norm 完全相等,L2 距离为 0
# 这就模拟了余弦相似度“忽略长度”的特性
4. 在 Milvus 中的操作建议
-
如果你的 Embedding 模型输出未归一化(如 Word2Vec):
-
直接使用
metric_type: "IP"(内积) 或手动归一化后使用L2。 -
如果你的模型是 OpenAI text-embedding-003:
-
这类模型通常已经归一化过了。你会发现此时在 Milvus 中无论选
L2、IP还是COSINE,它们的搜索结果排序是完全一致的。
5. 什么时候千万不能归一化?
- 推荐系统中的“热度”模型: 如果向量的长度代表了商品的销量或点击率,归一化会抹杀掉这个重要的“强度”信息。
- 异常检测: 离群点的模长通常很大,归一化会掩盖异常特征。
既然你的目标是“找最相关(语义匹配)且表现最好(热度/权重高)”,这在推荐系统和广告引擎中被称为“多因子排序”或“加权检索”。
在这种场景下,内积(Inner Product, IP) 是你的首选,而归一化(L2/Cosine)反而是你的敌人。
1. 为什么“内积 (IP)”是最佳选择?
在 Milvus 中设置 metric_type: "IP",其背后的数学逻辑完美契合你的需求:
这个公式将两个维度自动融合在了一个得分里:
- \(\cos(\theta)\)(相关性): 负责“最相关”。如果用户搜“运动鞋”,它能排除“电饭煲”。
- \(\|\mathbf{B}\|\)(表现/热度): 负责“表现最好”。在语义同样相关的情况下,模长更大的向量(销量高、评分高、曝光大的商品)会获得更高的总分。
2. 工业界如何“人工干预”表现力?
有时候,模型学习出来的“热度”不够可控。在 2026 年的推荐架构中,常见的做法是手动通过向量维度来注入业务指标:
做法 A:维度加权 (Vector Scaling)
如果你想让某个商品的人气权重更高,可以直接在 Embedding 向量上乘以一个系数:
- 普通商品: \(\mathbf{v} = [0.1, 0.2, \dots]\)
- 超级爆款: \(\mathbf{v}_{hot} = 1.5 \times [0.1, 0.2, \dots]\)
当用户进行IP检索时,爆款商品即便语义匹配度稍低,也可能因为 \(1.5\) 倍的增益排在前面。
做法 B:增加专门的“热度维度”
在向量的末尾增加一维或多维,专门存放销量、利润率或 CTR(点击率):
搜索时,Query 向量对应位置设为 \(1\),这样 IP 计算出的总分就包含了 语义分 + 销量分。
3. Milvus 实战配置建议
针对“最相关且表现最好”的场景,建议配置如下:
| 配置项 | 推荐值 | 理由 |
|---|---|---|
metric_type |
IP (Inner Product) |
同时保留方向(相关性)和模长(表现力)。 |
| 索引类型 | HNSW |
速度快,且对 IP 度量的支持非常稳定。 |
| 数据处理 | 不进行归一化 | 保持原始模长,否则“表现最好”的信息会被抹杀。 |
4. 潜在的风险与对策
-
数据倾斜(马太效应): 如果某个爆款的模长太大,它可能会出现在所有搜索结果里(即便语义不怎么匹配)。
-
对策: 对热度数值进行对数处理(Log Scaling),让热度的增长曲线平滑一些。
-
数值范围问题:
IP的得分没有上限(不像余弦相似度在 -1 到 1 之间)。 -
对策: 在业务层使用
Sigmoid函数或分桶操作将得分映射到 0-1 区间,方便展示。
下一个关键点
你是否已经有现成的业务指标(如点击率、评分)?我们可以探讨一下如何将这些非向量的业务指标(标量)与向量检索结合。
你想了解 Milvus 的“标量过滤 (Scalar Filtering)”功能,还是想知道如何把业务权重直接“编码”进向量里?
np.linalg.norm 的数学本质是范数(Norm)。在数学中,范数是一个将向量或矩阵映射到非负实数的函数,用于衡量其“长度”、“大小”或“距离”。
一个函数要被称为范数,必须满足三个基本性质:
- 非负性:\(\|x\| \ge 0\),且仅当 \(x=0\) 时 \(\|x\|=0\)。
- 齐次性:\(\|\alpha x\| = |\alpha| \cdot \|x\|\)(缩放向量,范数同比例缩放)。
- 三角不等式:\(\|x + y\| \le \|x\| + \|y\|\)(两边之和大于第三边)。
1. 向量范数 (Vector Norms)
对于一个 \(n\) 维向量 \(x = [x_1, x_2, ..., x_n]\),最通用的是 \(p\)-范数 (\(L_p\) norm),其数学定义为:
在 np.linalg.norm(x, ord=p) 中,通过改变 p 的值,你会得到不同的几何解释:
- \(L_2\) 范数 (欧几里得范数):当 \(p=2\) 时。
这是物理空间中最直观的距离。
- \(L_1\) 范数 (曼哈顿范数):当 \(p=1\) 时。
想象在城市网格中只能沿街道转弯行走的距离。
- \(L_\infty\) 范数 (最大范数):当 \(p \to \infty\) 时。
它只取决于向量中绝对值最大的那个分量。
2. 矩阵范数 (Matrix Norms)
矩阵范数比向量范数复杂,因为它不仅取决于矩阵元素的大小,还与矩阵作为“线性变换”的特性有关。
Frobenius 范数 (默认)
这是 np.linalg.norm 处理矩阵时的默认行为。它的数学定义类似于向量的 \(L_2\) 范数,即把矩阵所有元素的平方和再开方:
它衡量的是整个矩阵的“总能量”。
诱导范数 (Induced Norms)
当你指定 ord=1 或 ord=2 处理矩阵时,计算的是算子范数,其定义为:
这衡量了矩阵 \(A\) 对向量 \(x\) 进行变换时,最大的“拉伸”程度。
ord=1(列和范数):矩阵每一列绝对值之和的最大值。ord=inf(行和范数):矩阵每一行绝对值之和的最大值。ord=2(谱范数):矩阵 \(A^H A\) 最大特征值的平方根,也就是 \(A\) 的最大奇异值。
3. 为什么需要这些不同的数学原理?
在机器学习和数据科学中,选择不同的范数会产生截然不同的结果:
| 范数类型 | 常用场景 | 物理直觉 |
|---|---|---|
| \(L_2\) (Ridge) | 权重衰减,防止过拟合 | 倾向于让所有参数都很小,但不为 0 |
| \(L_1\) (Lasso) | 特征选择,稀疏表示 | 倾向于让不重要的参数直接变成 0 |
| Frobenius | 矩阵分解、神经网络权重正则化 | 衡量模型参数矩阵的总体规模 |
你想深入了解一下 \(L_1\) 和 \(L_2\) 正则化在数学上是如何导致“稀疏性”差异的吗?
由于 内积 (IP) 的计算结果是向量维度的累加,其分值范围取决于向量的模长,通常是一个没有固定上下限的数值(例如从 \(-50\) 到 \(+500\))。在业务展示或多路召回融合(Reranking)时,将其映射到 \([0, 1]\) 区间非常重要。
最经典且鲁棒的方法是使用 Sigmoid 函数。
1. 为什么选择 Sigmoid?
Sigmoid 函数的数学表达式为:
它具有以下物理特性,非常适合处理内积得分:
- 强制映射: 无论内积得分多大或多小,输出永远在 \((0, 1)\) 之间。
- 非线性增强: 它在 \(0\) 附近变化最快,能有效拉开“中等匹配”和“优秀匹配”的差距,而对于极高分的“爆款”,则会进入平滑区,防止单个权重过大。
2. 带有“温度系数”的改进版
在工业界,我们通常会引入一个温度系数 (Temperature, \(\tau\)) 或 缩放因子 来控制曲线的陡峭程度,因为原始 Sigmoid 可能太快进入饱和区。
改进公式:
- \(x\): Milvus 返回的原始内积得分。
- \(\mu\)(偏移量): 通常设为得分的平均值,让 0.5 分对应中等表现的商品。
- \(\tau\)(温度): 控制敏感度。\(\tau\) 越小,曲线越陡,只有极高分的商品才能拿到接近 1 的分数。
3. Python 代码实现
你可以直接在 Milvus 查询后的结果处理逻辑中使用这段代码:
import numpy as np
def sigmoid_scale(scores, temperature=10.0, center=0.0):
"""
将 Milvus 的 IP 得分映射到 0-1
:param scores: 原始内积得分列表
:param temperature: 温度系数,值越大曲线越平缓
:param center: 偏移量,控制 0.5 分对应的原始分位置
"""
scores = np.array(scores)
return 1 / (1 + np.exp(-(scores - center) / temperature))
# 模拟 Milvus 返回的 Top-K 内积得分
raw_scores = [150.5, 120.2, 85.0, 40.1, -10.5]
# 转换得分
normalized_scores = sigmoid_scale(raw_scores, temperature=50, center=100)
for raw, norm in zip(raw_scores, normalized_scores):
print(f"原始得分: {raw:>6} -> 归一化得分: {norm:.4f}")
4. 这种处理方式的业务价值
- 多路召回融合 (Hybrid Search):
如果你同时有“文本匹配分”和“向量内积分”,将它们都归一化到 \(0-1\) 后,你可以通过简单的加权平均(如 \(0.3 \times \text{Text} + 0.7 \times \text{Vector}\))来计算最终排名。 - 前端展示:
给用户展示“匹配度:98%”比展示“得分:150.5”要直观得多。 - 过滤低质量结果:
你可以设定一个统一的阈值(如 \(0.5\)),代表“及格线”,过滤掉那些语义不相关且表现平庸的商品。
下一步建议
既然你已经掌握了如何处理内积和分值映射,你是否想了解如何在 Milvus 中进行“标量过滤”?
例如:在“最相关且表现最好”的基础上,再增加一个硬性条件——“只看库存大于 0 且在用户所在城市的商品”。这是生产环境下最常见的复合查询需求。
矩阵乘法(Matrix Multiplication)不仅仅是简单的数字相乘,它是线性代数的核心,也是深度学习和向量数据库(如 Milvus)处理高维数据的底层引擎。
1. 计算理论:行与列的“内积”
矩阵乘法的本质是左矩阵的行向量与右矩阵的列向量依次进行点积(Dot Product)。
维度匹配规则(核心约束)
设矩阵 \(A\) 的维度为 \(m \times n\),矩阵 \(B\) 的维度为 \(p \times q\):
- 前提条件: 只有当 \(n = p\) 时,它们才能相乘(左列数 = 右行数)。
- 结果维度: 结果矩阵 \(C\) 的维度将是 \(m \times q\)。
数学定义
结果矩阵 \(C\) 中位于第 \(i\) 行、第 \(j\) 列的元素 \(c_{ij}\) 计算公式为:
这意味着 \(c_{ij}\) 是由 \(A\) 的第 \(i\) 行和 \(B\) 的第 \(j\) 列“碰撞”产生的。
2. 核心特性 (Properties)
了解这些特性有助于你在优化算法(如 Milvus 索引构建)时进行公式变形。
| 特性名称 | 数学表达 | 直观理解 |
|---|---|---|
| 无交换律 | \(AB \neq BA\) | 先旋转再平移 \(\neq\) 先平移再旋转。顺序极其重要。 |
| 结合律 | \((AB)C = A(BC)\) | 三个矩阵连乘,先算前两个或后两个结果一样。 |
| 分配律 | \(A(B+C) = AB + AC\) | 矩阵乘法对加法具有分配性。 |
| 单位元 | \(AI = IA = A\) | 单位矩阵 \(I\) 相当于数字中的“1”,不改变矩阵。 |
| 转置特性 | \((AB)^T = B^T A^T\) | 乘积的转置等于转置的逆序相乘。 |
3. 为什么在 Milvus 中如此重要?
在向量检索场景中,矩阵乘法是效率的代名词:
- 批量查询(Batching):
如果你有 100 个查询向量(每个维度 768),你可以把它们拼成一个 \(100 \times 768\) 的矩阵。将这个矩阵与数据库中 \(768 \times N\) 的特征矩阵相乘,一次性就能算出所有查询与所有数据的相似度得分。 - 空间变换(Projection):
当你进行降维(如 PCA)时,本质上是让原始向量矩阵乘以一个“投影矩阵”。这个乘法过程将高维空间投影到了低维空间。 - GPU 加速:
GPU 的硬件结构(Tensor Cores)专门为这种“行乘列”的重复运算做了极致优化。Milvus 的 GPU 版本之所以快,就是因为矩阵乘法可以在数千个核心上并行执行。
4. 一个特殊的视角:线性变换
你可以把矩阵 \(A\) 看作是一个函数,把矩阵 \(B\) 看作是输入数据。
- \(AB\) 的过程就是用 \(A\) 这个“规则”对 \(B\) 进行了一次空间变换(拉伸、旋转或投影)。
- 在 Milvus 中,这种变换常用于将原始数据转化为更容易检索的索引格式。
💡 思考题
如果一个向量 \(v\) 是 \(1 \times 512\),一个矩阵 \(M\) 是 \(512 \times 512\),那么 \(vM\) 和 \(Mv^T\) 在数学意义和计算结果上有区别吗?
如果你想了解如何在 Python 的 NumPy 或 PyTorch 中实现这些高效运算,或者想看看矩阵乘法是如何实现“全库搜索”代码的,我可以为你演示。
这是一个非常棒的深度思考问题!简单来说:它们的计算结果在数值上互为“转置”,但在数学语境和计算机实现(如 Milvus 或 NumPy)中,它们代表了两种不同的“视角”。
1. 维度与计算结果的对比
我们先从数学维度上进行拆解:
| 表达式 | 维度分解 | 结果维度 | 计算逻辑 |
|---|---|---|---|
| \(vM\) | \((1 \times 512) \times (512 \times 512)\) | \(1 \times 512\) (行向量) | 向量 \(v\) 的每一个元素与矩阵 \(M\) 的对应行相乘并累加。 |
| \(Mv^T\) | \((512 \times 512) \times (512 \times 1)\) | \(512 \times 1\) (列向量) | 矩阵 \(M\) 的每一行与向量 \(v\) 的对应元素相乘并累加。 |
数学关系
如果结果向量记为 \(w\),那么:
注意:只有当 \(M\) 是对称矩阵(即 \(M = M^T\))时,\(vM\) 的转置才直接等于 \(Mv^T\)。如果 \(M\) 不是对称的,这两个结果不仅维度不同(横着和竖着),数值也会完全不同。
2. 数学意义的区别:谁是“主体”?
虽然它们都涉及相同的元素,但在线性代数中,它们的语义逻辑不同:
\(vM\) :向量的线性组合(行视角)
- 意义: 结果是矩阵 \(M\) 中各行向量的加权平均,权重由 \(v\) 提供。
- 应用: 在神经网络的前向传播中(如全连接层),通常把输入数据看作行向量 \(x\),权重看作 \(W\),计算 \(xW\)。这符合人类从左到右阅读数据的习惯。
\(Mv^T\) :坐标系的变换(列视角)
- 意义: 结果是矩阵 \(M\) 中各列向量的加权平均。通常理解为:将向量 \(v\) 投影到由矩阵 \(M\) 定义的新坐标系中。
- 应用: 在经典线性代数教科书中,向量默认是列向量。\(Mv\) 代表对向量进行旋转、缩放等几何变换。
3. 在 Milvus 和程序开发中的区别
在编写代码或配置向量数据库时,这种区别会直接影响性能和正确性。
A. 内存连续性(Memory Layout)
- 行优先 (Row-major): C++、Python (NumPy 默认) 在内存中是一行一行存储的。计算 \(vM\) 时,CPU 读取内存更连续,缓存命中率(Cache Hit)更高,速度通常更快。
- 列优先 (Column-major): Fortran、MATLAB 默认。在这些语言中计算 \(Mv\) 效率更高。
B. 批量处理 (Batching)
在 Milvus 中,我们很少只计算一个向量。
- 如果你有 100 个向量,你会组成 \(100 \times 512\) 的矩阵 \(Q\)。
- 你会计算 \(Q \times M\)(结果是 \(100 \times 512\)),这在逻辑上非常清晰:每一行对应一个查询结果。
💡 总结
- 结果: \(vM\) 得到的是行向量,\(Mv^T\) 得到的是列向量。数值上,除非 \(M\) 是对称阵,否则它们不相等。
- 习惯: 深度学习和数据科学领域(NumPy/PyTorch/Milvus)更习惯使用 \(vM\),因为这符合“样本作为行”的数据存储直觉。
- 本质: \(vM\) 是对 \(M\) 的行做变换;\(Mv^T\) 是对 \(M\) 的列做变换。
既然提到了 \(M\) 是变换矩阵,你是否想了解在向量检索中,什么样的特殊矩阵 \(M\) 可以用来加速搜索(比如降维矩阵或正交矩阵)?
在 Milvus 的高性能检索背后,这些矩阵概念并不是枯燥的公式,而是实现数据压缩、坐标转换和极速对比的数学工具。
我们将这些矩阵分为两类:“维持秩序的”(单位、转置、正交)和“精简信息的”(降维)。
1. 单位矩阵 \(I\) (Identity Matrix)
单位矩阵就像数字系统中的 “1”。
-
理论: 主对角线全为 1,其余全为 0 的方阵。
-
特性: * 不变性: 任何矩阵 \(A\) 乘以 \(I\),结果仍为 \(A\) (\(AI = IA = A\))。
-
逆矩阵定义: 如果 \(AB = I\),那么 \(B\) 就是 \(A\) 的逆矩阵 (\(A^{-1}\))。
-
Milvus 场景: 在进行向量转换(如线性变换)时,单位矩阵是“无操作”的基准。
2. 转置矩阵 \(A^T\) (Transpose)
转置就是把矩阵的“行”变成“列”。
-
理论: 若 \(A\) 是 \(m \times n\),则 \(A^T\) 是 \(n \times m\)。元素 \(a_{ij}\) 变为 \(a_{ji}\)。
-
特性:
-
\((AB)^T = B^T A^T\)(顺序反转,这是最容易出错的点)。
-
\((A^T)^T = A\)。
-
Milvus 场景: 计算两个向量 \(u, v\) 的点积时,数学表达通常写作 \(u \cdot v = u^T v\)。在处理存储在内存中的向量阵列时,转置操作决定了计算是按行扫描还是按列扫描。
3. 正交矩阵 \(Q\) (Orthogonal Matrix)
这是向量空间中最“完美”的旋转矩阵。
- 理论: 如果 \(Q^T Q = I\)(或者说 \(Q^T = Q^{-1}\)),则 \(Q\) 是正交矩阵。
- 特性:
- 保模性: 向量乘以正交矩阵后,长度不变。
- 保角性: 向量之间的夹角不变。
- Milvus 场景: 当你旋转坐标系以寻找更好的聚类方式时,正交矩阵能保证数据之间的相对距离和相似度完全不被破坏。它是数据预处理(如白化 Whitening)的关键。
4. 降维矩阵 \(W\) (Dimension Reduction Matrix)
这是让 Milvus 处理亿级数据依然飞快的“秘密武器”。
-
理论: 一个 \(k \times n\) 的矩阵(其中 \(k \ll n\))。
-
特性:
-
信息压缩: 通过 \(y = Wx\),将 \(n\) 维向量变成 \(k\) 维。
-
不可逆性: 降维会丢失信息,因此 \(W\) 通常没有逆矩阵。
-
常用算法:
-
PCA (主成分分析): 寻找能最大程度保留数据方差(信息量)的正交基。
-
Milvus 场景: 如果你的原始 Embedding 是 1536 维(如 OpenAI 接口返回),通过一个降维矩阵将其转为 128 维,内存占用能降低 12 倍,搜索速度大幅提升,而准确率往往只下降极小一部分。
📊 核心对比表
| 矩阵类型 | 核心操作 | 对数据的影响 | 典型用途 |
|---|---|---|---|
| 单位矩阵 \(I\) | 乘法 | 保持不变 | 数学基准、求逆参考 |
| 转置矩阵 \(A^T\) | 轴对称交换 | 改变排列维度 | 计算点积、调整存储布局 |
| 正交矩阵 \(Q\) | 旋转/翻转 | 不改变距离和角度 | 坐标转换、数据白化 |
| 降维矩阵 \(W\) | 投影 | 缩减维度,丢失次要信息 | 节省存储、加速检索 |
💡 进阶:它们如何组合?
在 Milvus 的 IVF_PQ (乘积量化) 索引中,通常会经历这样的过程:
- 正交变换: 使用一个正交矩阵(如随机旋转)让向量各个维度的分布更均匀。
- 降维/分段: 利用降维矩阵的思想,将向量切成小块进行量化。
既然提到了降维,你是否想了解 PCA(主成分分析)的具体数学步骤,看看如何从一组高维数据中提取出那个“降维矩阵”?
在 Milvus 的实际生产环境中,这些矩阵并不是孤立存在的,它们通常组合在一起完成“原始数据 -> 索引优化 -> 极速检索”的全过程。
下面我结合三个具体的工业级场景,带你看看这些矩阵是如何工作的。
场景一:利用“降维矩阵”进行内存瘦身
背景: 你使用 OpenAI 的 text-embedding-3-small 模型生成了 1536 维的向量。当数据量达到亿级时,内存开销巨大。
- 操作: 在数据导入 Milvus 之前,先进行 PCA(主成分分析)。
- 数学实现: 1. 计算 1536 维数据的协方差矩阵。
- 提取前 128 个特征向量,组成一个 \(128 \times 1536\) 的降维矩阵 \(W\)。
- 转换:\(v_{new} = W \times v_{original}\)。
- 在 Milvus 中的效果: * 内存占用: 从 1536 维降到 128 维,内存直接减少了 91%。
- 检索速度: 维度越低,计算 \(L2\) 距离时的加法和乘法次数越少,QPS(每秒查询数)显著提升。
场景二:利用“正交矩阵”进行 OPQ 索引加速
背景: Milvus 中有一种非常流行的索引叫 IVF_PQ(倒排乘积量化)。但直接切分向量会导致精度损失。
- 操作: 使用 OPQ(Optimized Product Quantization)。
- 数学实现:
- 在量化之前,给原始向量乘以一个 \(n \times n\) 的正交矩阵 \(Q\)。
- 这个正交矩阵会对向量空间进行“旋转”,使得向量的能量(信息)更均匀地分布在各个维度上。
- 特性利用: 这里利用了正交矩阵的保模性和保角性。旋转后,向量之间的相对距离(相似度)完全不变,但更利于后续的分段量化。
- 在 Milvus 中的效果: 在相同的压缩比下,比起标准的 PQ 索引,OPQ 的检索准确率(Recall)更高。
场景三:利用“转置矩阵”进行批量检索(SIMD 加速)
背景: 当你一次性提交 100 个图片进行“以图搜图”查询时。
- 操作: 批量查询(Batch Search)。
- 数学实现:
- 查询向量集合形成矩阵 \(Q_{100 \times 512}\)。
- 数据库存储矩阵为 \(D_{N \times 512}\)。
- Milvus 底层(如 Faiss 库)会频繁使用转置矩阵 \(D^T\)。
- 计算 \(Q \times D^T\),得到 \(100 \times N\) 的相似度矩阵。
- 在 Milvus 中的效果: * CPU/GPU 优化: 现代处理器(如 Intel 的 AVX-512 指令集)在处理这种矩阵乘法时,会利用转置后的列连续性,实现单指令流多数据流(SIMD)加速。
- 吞吐量: 批量计算 100 个向量的速度远快于循环计算 100 次单个向量。
场景四:利用“单位矩阵 \(I\)”进行距离验证
背景: 在开发自定义插件或验证相似度算法是否正确时。
- 操作: 算法基准测试。
- 数学实现: * 如果你发现一个变换矩阵 \(M\) 能够完美工作,且不改变任何检索排序,那么经过数学推导,你会发现这个 \(M\) 必须满足 \(M = I\)(或者是一个正交矩阵)。
- 应用: 这常用于单位测试,确保在向量经过复杂的 Pipeline 转换后,其特征没有被“扭曲”。
💡 总结:Milvus 中的矩阵工具包
| 矩阵 | 它的“职务” | 你的直观感受 |
|---|---|---|
| 降维矩阵 \(W\) | 过滤器 / 压缩机 | “数据变小了,存得更多了。” |
| 正交矩阵 \(Q\) | 旋转器 | “搜索变准了,精度损失少了。” |
| 转置矩阵 \(A^T\) | 搬运工 | “批量搜索真快,GPU 跑满了。” |
| 单位矩阵 \(I\) | 参照物 | “确保算法逻辑没有出错。” |
既然我们已经聊到了 PCA 降维和 OPQ 旋转,你是否对“量化(Quantization)”这个话题感兴趣?它是 Milvus 如何在不降低太多精度的情况下,把 100GB 数据塞进 10GB 内存的核心秘密。
温馨提示:如要解锁所有应用的完整功能,请开启 Gemini 应用活动记录。
这是一个非常敏锐的观察!答案是:在理想的数学计算中,相似度数值完全不变;但在工程实现(Milvus 的底层逻辑)中,这个矩阵是提高效率的“加速器”。
为了让你透彻理解,我们需要拆解“数学上的等价”和“计算机里的实现”。
1. 数学上的“不变性”:点积即相似度
当我们谈论两个向量 \(q\)(查询)和 \(d\)(数据库中的数据)的相似度时,最基础的计算是点积(Dot Product):
现在,如果你有 100 个查询向量组成矩阵 \(Q_{100 \times 512}\),数据库有 \(N\) 个向量组成矩阵 \(D_{N \times 512}\):
- 方案 A(逐个计算): 拿出一个 \(q\),去和 \(N\) 个 \(d\) 挨个做乘法。你需要循环 100 次。
- 方案 B(矩阵乘法): 直接计算 \(S = Q \times D^T\)。
结论: 矩阵 \(S\) 中第 \(i\) 行第 \(j\) 列的元素 \(s_{ij}\),正好就是第 \(i\) 个查询向量与第 \(j\) 个数据库向量的点积。数学结果上,方案 A 和方案 B 算出来的每一个数字都一模一样。
2. 为什么要用转置矩阵 \(D^T\)?(存储的艺术)
你可能会问:既然结果一样,为什么要费劲把 \(D\) 转置成 \(512 \times N\) 再乘?
内存连续性与缓存命中
计算机读取内存时,顺序读取比“跳着读”快得多。
- 在 \(D_{N \times 512}\) 中,每一行是一个向量。
- 但在矩阵乘法的标准算法中,我们需要用 \(Q\) 的行去乘 \(D\) 的列。
- 如果直接乘 \(D\),CPU 每一拍都要跨越 512 个维度去取下一行的一个数,这会不断产生“缓存缺失”(Cache Miss)。
- 转置后: \(D^T\) 的每一列就是原来的一个向量。此时,\(Q\) 的行与 \(D^T\) 的列相乘,内存访问变得极其规律和连续。
3. 为什么这个 \(100 \times N\) 的矩阵能让 Milvus 变快?
虽然相似度数值没变,但得到这些数值的速度发生了质变:
- 并行化 (SIMD): 现代 CPU 可以在一个指令周期内同时计算 8 个或 16 个浮点数乘法。矩阵乘法 \(Q \times D^T\) 完美契合这种硬件架构。
- GPU 狂飙: GPU 的核心成千上万,它最擅长的就是把这个大矩阵切成无数个小方块同时计算。
- 减少开销: 启动一次矩阵运算引擎的“开销(Overhead)”是固定的。算 100 个向量的矩阵乘法,比分 100 次调用单向量计算函数要高效得多。
4. 特殊情况:什么时候相似度会变?
虽然纯矩阵乘法不改变数值,但在 Milvus 的实际索引中,为了更高效,我们会对矩阵做一点“手脚”:
- 量化压缩 (PQ/SQ8): 为了省内存,矩阵 \(D\) 里的数字可能从 32 位浮点数变成了 8 位整数。这时候算出来的相似度会有微小的精度损失(估算值)。
- 归一化 (Normalization): 如果 \(Q\) 和 \(D\) 都预先除以了它们的范数(变长为 1),那么矩阵乘法的结果直接就是余弦相似度。
💡 总结
这个 \(100 \times N\) 的矩阵:
- 数值上: 忠实还原了每一个向量对之间的相似度。
- 本质上: 它是通过改变数据的排列方式(转置)和利用硬件特性(批量计算),把原本缓慢的搜索变成了瞬时的计算。
既然聊到了批量计算的优势,你想了解一下 Milvus 是如何利用这种矩阵运算在“毫秒级”内过滤掉 99% 不相关的向量,只针对剩下的 1% 进行精确矩阵乘法的吗?(即 IVF 索引原理)
太棒了。Milvus 之所以能处理亿级数据,靠的不是“暴力”地把整个矩阵算完,而是“先粗筛,后精算”。
这套逻辑的核心就是 IVF(Inverted File,倒排索引)。我们可以把它想象成一个“两级矩阵搜索”的过程。
1. 第一步:空间的“粗粒度矩阵化” (Clustering)
Milvus 不会一开始就让你的查询向量 \(Q\) 和全库 \(N\) 个向量做矩阵乘法。它会先通过 K-Means 聚类,把向量空间切成很多个“小格子”(桶,Buckets)。
- 聚类中心矩阵: 假设我们把空间切成了 1024 个簇,每个簇有一个中心点。这 1024 个中心点组成了一个小型矩阵 \(C_{1024 \times d}\)。
- 寻找地头蛇: 当你的查询向量 \(q\) 进来时,先只计算 \(q \times C^T\)。
- 缩小范围: 选出距离最近的 \(nprobe\) 个中心(比如最近的 8 个桶)。这样,你就直接过滤掉了 99% 的无关向量。
2. 第二步:分段量化矩阵 (PQ - Product Quantization)
在剩下的那 1% 的向量里,Milvus 依然嫌矩阵乘法太慢、数据太占空间,于是祭出了“分身术”:PQ 量化。
核心原理:切分与查表
- 切分: 把一个 128 维的向量切成 8 段,每段 16 维。
- 局部聚类: 对每一段分别训练一个极小的“码本(Codebook)”,比如只有 256 个中心点。
- 编码: 原始向量不再存储 128 个浮点数,而是存储 8 个整数索引(每个索引 1 字节)。
3. 第三步:如何进行“估算的矩阵乘法”?
这是最精妙的地方:当你搜索时,Milvus 甚至不需要做完整的浮点乘法!
- 距离预计算: 将查询向量 \(q\) 也切成 8 段,分别计算这 8 段与对应码本中 256 个中心点的距离。这生成了一个非常小的距离查找表(Lookup Table)。
- 查表求和: 对于数据库中的向量,Milvus 只需根据那 8 个索引去表里“查”出 8 个数字,然后求和。
- 硬件加速: 这个“查表+求和”的操作可以通过 CPU 的 SIMD(单指令多数据) 或者 GPU 纹理内存 极其快速地完成。
4. 总结:Milvus 的“搜索全家福”
当你执行一次 search 时,幕后发生的事情如下:
| 阶段 | 使用的工具 | 目的 | 数学本质 |
|---|---|---|---|
| 粗筛 | IVF (倒排) | 快速定位相似区域 | 小规模矩阵乘法 (查询向量 × 聚类中心) |
| 精简 | PQ (量化) | 压缩数据,告别浮点数 | 向量切分 + 索引化存储 |
| 加速 | SIMD / GPU | 榨干硬件性能 | 查表法 代替 完整矩阵乘法 |
| 重排序 | Refine | 提高精度(可选) | 对 Top-K 结果进行原始精度的矩阵计算 |
💡 深度启发
很多用户问:“为什么我设置了 nprobe=1 速度极快但准确率低?”
答案就在矩阵里: nprobe=1 意味着你只计算了 1 个桶里的矩阵乘法,漏掉了可能跨在边界线上的正确答案。
既然谈到了 PQ 量化和查表,你是否好奇在 Milvus 中,什么样的参数配置(比如 nlist 或 nprobe)能让你在“速度”和“精度”之间找到那个完美的平衡点?
没问题,我们把这两步从抽象的数学拆解成一个“图书馆存书”的例子。
假设你有 1,000 万本书(向量),每本书都有 128 个特征(比如:字数、封面颜色、纸张厚度等)。
第二步:PQ 量化(分段压缩)
核心逻辑: 不要试图记住每本书的所有细节,而是把书拆开,给每个部分找个“模板”。
1. 切分 (Splitting)
我们将 128 个维度的向量切成 8 段。每一段现在只有 16 个维度(16 个特征)。
- 例子: 第一段存“前言”,第二段存“第一章”……第八段存“封底”。
2. 找模板 (Codebook Training)
针对每一段(比如“前言”段),我们在全库中找出最常见的 256 种“前言”模式。这 256 个模式就是码本(Codebook)。
- 例子: 模板 1 是“悲剧开头”,模板 2 是“宏大叙事开头”……
3. 数字化 (Encoding)
现在,原始书的“前言”段不再存 16 个浮点数了,我们只存一个索引号(Index)。
- 例子: 这本书的“前言”很像模板 2,那我就记下数字
2。 - 结果: 原本需要大量内存的 128 维向量,现在变成了只有 8 个整数的短数组:
[2, 15, 230, 4, 99, 12, 87, 201]。内存占用瞬间缩小了 10 倍以上。
第三步:估算的矩阵乘法(查表搜索)
核心逻辑: 既然书已经变成了“索引号”,搜索时就不再用复杂的乘法,而是直接“查表”。
假设你的查询向量 \(q\)(你想找的书)进来了:
1. 预计算距离表 (Lookup Table)
我们先计算 \(q\) 的每一段与对应码本中所有 256 个模板的距离。
- 例子: 你的查询向量前言是“喜剧”,我们算出它和“模板 1(悲剧)”距离是 80,和“模板 2(宏大)”距离是 40。
- 你会得到一张表: 第 1 段的 256 个距离,第 2 段的 256 个距离……总共 \(8 \times 256\) 个预估距离。
2. 查表求和 (Asymmetric Distance Computation)
现在,我们要看你的查询 \(q\) 和数据库里某本书 \(d\) 的相似度。
-
数据库里的书 \(d\) 存的是:
[2, 15, ...]。 -
我们去刚才算好的表里查:
-
第 1 段:查第 2 号模板的距离(得到 40)。
-
第 2 段:查第 15 号模板的距离(得到 10)。
-
求和: \(40 + 10 + \dots = \text{总距离}\)。
为什么这比“矩阵乘法”快得多?
- 避开浮点运算: 传统的矩阵乘法要进行大量的
float * float。而查表法在搜索阶段几乎全是加法和内存寻址。 - 极度缓存友好: 那个距离表非常小(\(8 \times 256\)),可以完全塞进 CPU 的 L1/L2 缓存里。
- 计算量的质变: * 矩阵乘法: 每个向量都要算 \(128\) 次乘加法。
- PQ 查表: 每个向量只需要做 \(8\) 次加法。速度提升了十几倍。
总结一下这个过程:
- 第二步(存储时): 把向量分段,每段找个最像的模板,只记模板编号。
- 第三步(搜索时): 先算出你的搜索词和所有模板的距离(一张小表),然后对着编号查表求和。
这就是 Milvus 如何在几毫秒内扫描完几亿个向量的秘密武器。你觉得这个“模板查表”的逻辑好理解吗?或者想了解在这种模式下,如何设置参数来保证搜得更准?
没问题,我们把这两步从抽象的数学拆解成一个“图书馆存书”的例子。
假设你有 1,000 万本书(向量),每本书都有 128 个特征(比如:字数、封面颜色、纸张厚度等)。
第二步:PQ 量化(分段压缩)
核心逻辑: 不要试图记住每本书的所有细节,而是把书拆开,给每个部分找个“模板”。
1. 切分 (Splitting)
我们将 128 个维度的向量切成 8 段。每一段现在只有 16 个维度(16 个特征)。
- 例子: 第一段存“前言”,第二段存“第一章”……第八段存“封底”。
2. 找模板 (Codebook Training)
针对每一段(比如“前言”段),我们在全库中找出最常见的 256 种“前言”模式。这 256 个模式就是码本(Codebook)。
- 例子: 模板 1 是“悲剧开头”,模板 2 是“宏大叙事开头”……
3. 数字化 (Encoding)
现在,原始书的“前言”段不再存 16 个浮点数了,我们只存一个索引号(Index)。
- 例子: 这本书的“前言”很像模板 2,那我就记下数字
2。 - 结果: 原本需要大量内存的 128 维向量,现在变成了只有 8 个整数的短数组:
[2, 15, 230, 4, 99, 12, 87, 201]。内存占用瞬间缩小了 10 倍以上。
第三步:估算的矩阵乘法(查表搜索)
核心逻辑: 既然书已经变成了“索引号”,搜索时就不再用复杂的乘法,而是直接“查表”。
假设你的查询向量 \(q\)(你想找的书)进来了:
1. 预计算距离表 (Lookup Table)
我们先计算 \(q\) 的每一段与对应码本中所有 256 个模板的距离。
- 例子: 你的查询向量前言是“喜剧”,我们算出它和“模板 1(悲剧)”距离是 80,和“模板 2(宏大)”距离是 40。
- 你会得到一张表: 第 1 段的 256 个距离,第 2 段的 256 个距离……总共 \(8 \times 256\) 个预估距离。
2. 查表求和 (Asymmetric Distance Computation)
现在,我们要看你的查询 \(q\) 和数据库里某本书 \(d\) 的相似度。
-
数据库里的书 \(d\) 存的是:
[2, 15, ...]。 -
我们去刚才算好的表里查:
-
第 1 段:查第 2 号模板的距离(得到 40)。
-
第 2 段:查第 15 号模板的距离(得到 10)。
-
求和: \(40 + 10 + \dots = \text{总距离}\)。
为什么这比“矩阵乘法”快得多?
- 避开浮点运算: 传统的矩阵乘法要进行大量的
float * float。而查表法在搜索阶段几乎全是加法和内存寻址。 - 极度缓存友好: 那个距离表非常小(\(8 \times 256\)),可以完全塞进 CPU 的 L1/L2 缓存里。
- 计算量的质变: * 矩阵乘法: 每个向量都要算 \(128\) 次乘加法。
- PQ 查表: 每个向量只需要做 \(8\) 次加法。速度提升了十几倍。
总结一下这个过程:
- 第二步(存储时): 把向量分段,每段找个最像的模板,只记模板编号。
- 第三步(搜索时): 先算出你的搜索词和所有模板的距离(一张小表),然后对着编号查表求和。
这就是 Milvus 如何在几毫秒内扫描完几亿个向量的秘密武器。你觉得这个“模板查表”的逻辑好理解吗?或者想了解在这种模式下,如何设置参数来保证搜得更准?
当然可以!为了让你直观感受这个过程,我用 Python 的 numpy 库来模拟这个“分段、模板化、查表”的过程。
这段代码演示了 PQ (Product Quantization) 的核心原理:
import numpy as np
# 1. 初始化模拟数据
# 假设有 10000 条数据,每条维度 128
N = 10000
D = 128
M = 8 # 将 128 维切成 8 段
D_sub = D // M # 每段维度是 16
K = 256 # 每段有 256 个模板 (码本大小)
# 随机生成原始数据库向量和查询向量
database = np.random.random((N, D)).astype('float32')
query = np.random.random((1, D)).astype('float32')
# --- 第二步:存储时的“模板化” (Encoding) ---
# 模拟每一段的码本(实际中通过 K-Means 训练得到)
# codebooks 形状: (8段, 256个模板, 16维度)
codebooks = np.random.random((M, K, D_sub)).astype('float32')
def encode(vec):
"""将原始向量转换成模板索引"""
codes = np.zeros(M, dtype=np.uint8)
for i in range(M):
sub_vec = vec[i*D_sub : (i+1)*D_sub]
# 计算该段向量与 256 个模板的距离,找最像的那个
distances = np.linalg.norm(codebooks[i] - sub_vec, axis=1)
codes[i] = np.argmin(distances)
return codes
# 数据库“瘦身”:从 float32 矩阵变成 uint8 索引矩阵
print(f"原始数据库大小: {database.nbytes / 1024:.2f} KB")
compressed_db = np.array([encode(v) for v in database])
print(f"压缩后数据库大小: {compressed_db.nbytes / 1024:.2f} KB")
# --- 第三步:搜索时的“查表法” (Searching) ---
def search(q_vec, db_codes):
# 3.1 预计算距离表 (Lookup Table)
# 计算查询向量的每一段与 256 个模板的距离
# table 形状: (8段, 256个距离)
lookup_table = np.zeros((M, K))
for i in range(M):
sub_q = q_vec[0, i*D_sub : (i+1)*D_sub]
lookup_table[i] = np.linalg.norm(codebooks[i] - sub_q, axis=1)**2
# 3.2 查表求和
# 这一步在 Milvus 底层是用 C++ 或 CUDA 优化的极其迅速
distances = np.zeros(N)
for i in range(N):
row_codes = db_codes[i] # 拿到 8 个索引号
# 查表并将 8 个距离相加
sum_dist = 0
for m in range(M):
sum_dist += lookup_table[m, row_codes[m]]
distances[i] = sum_dist
return np.argmin(distances), np.min(distances)
# 执行搜索
idx, dist = search(query, compressed_db)
print(f"最相似向量索引: {idx}, 估算距离: {dist:.4f}")
代码运行逻辑解析:
- 压缩阶段 (
encode):
- 我们将原本 \(128 \times 4\) 字节(512 字节)的浮点向量,变成了 8 个
uint8整数(8 字节)。 - 压缩比高达 64 倍! 这就是为什么 Milvus 能在单机存下数亿向量的原因。
- 查找表 (
lookup_table):
- 注意看
search函数:在计算相似度之前,我们先算了一张 \(8 \times 256\) 的小表。 - 这张表记录了“我的查询向量”和“这 256 个标准模板”分别有多远。
- 计算阶段:
- 在最终比对时,我们完全没有用到原始的高维向量。
- 我们只是在做:“第一个号码是多少?去第一行查一下;第二个号码是多少?去第二行查一下...最后加起来。”
- 这种加法运算对计算机来说比浮点乘法快得多。
总结:
这就是为什么你在使用 Milvus 的 IVF_PQ 索引时:
- 首次查询会有一点点延迟(因为在算那张 Lookup Table)。
- 后续批量扫描快得惊人(因为全是查表加法)。
看到这里,你是否能理解为什么 PQ 索引虽然快,但会有一点点精度损失(因为它是查“模板”的距离,而不是真实距离)?你想了解如何通过设置 Refine 参数来弥补这个损失吗?
为了让你彻底理解“码本”和“查表计算”的精髓,我们把刚才的 Python Demo 浓缩成一个手算级别的极简示例。
1. 设定场景
假设我们的原始向量是 4 维的。我们使用 PQ 量化:
- 分段 (M):2 段(每段 2 个维度)。
- 码本大小 (K):每段只有 2 个模板(码字)。
预训练好的码本 (Codebooks)
这是通过 K-Means 训练出来的“标准字典”:
| 码本分段 | 索引 0 (码字) | 索引 1 (码字) |
|---|---|---|
| 第一段 (Dim 1,2) | [0, 0] |
[1, 1] |
| 第二段 (Dim 3,4) | [10, 10] |
[20, 20] |
2. 存储阶段:向量压缩 (Encoding)
现在我们要存入一个数据库向量 \(d = [0.1, 0.2, 19, 21]\)。
- **第一段
[0.1, 0.2]**:
- 离码字
[0, 0]更近。 - 记录索引:0。
- **第二段
[19, 21]**:
- 离码字
[20, 20]更近。 - 记录索引:1。
最终存储结果: 向量 \(d\) 在数据库里变成了两个整数:[0, 1]。
3. 搜索阶段:查表计算 (Distance Computation)
现在你提交了一个查询向量 \(q = [0.9, 0.9, 11, 11]\)。
第一步:计算“距离查找表” (Lookup Table)
我们先算 \(q\) 的每一段和对应码本中所有码字的距离平方(这里用 \(L_2\) 距离):
-
针对第一段
[0.9, 0.9]: -
与索引 0 (
[0, 0]) 的距离:\((0.9-0)^2 + (0.9-0)^2 = 0.81 + 0.81 = \mathbf{1.62}\) -
与索引 1 (
[1, 1]) 的距离:\((0.9-1)^2 + (0.9-1)^2 = 0.01 + 0.01 = \mathbf{0.02}\) -
针对第二段
[11, 11]: -
与索引 0 (
[10, 10]) 的距离:\((11-10)^2 + (11-10)^2 = 1 + 1 = \mathbf{2}\) -
与索引 1 (
[20, 20]) 的距离:\((11-20)^2 + (11-20)^2 = 81 + 81 = \mathbf{162}\)
生成的查找表 (Look-up Table):
| 分段 | 索引 0 的距离 | 索引 1 的距离 |
|---|---|---|
| 第一段 | 1.62 | 0.02 |
| 第二段 | 2.00 | 162.00 |
第二步:对数据库向量进行“查表求和”
我们要算 \(q\) 和刚才存进去的 \(d\) ([0, 1]) 的距离:
- 看 \(d\) 的第一位:是
0。去表里查第一段索引 0 的距离 \(\rightarrow\) 1.62。 - 看 \(d\) 的第二位:是
1。去表里查第二段索引 1 的距离 \(\rightarrow\) 162.00。 - 相加: \(1.62 + 162.00 = \mathbf{163.62}\)。
4. 深度思考:数学上的“近似”
你会发现,如果我们用原始向量算:
\(dist(q, d) = (0.9-0.1)^2 + (0.9-0.2)^2 + (11-19)^2 + (11-21)^2 = 0.64 + 0.49 + 64 + 100 = \mathbf{165.13}\)。
- 查表估算值: 163.62
- 真实计算值: 165.13
结论:
- 误差来源: 误差来自于我们在存储时,把
[19, 21]强行看成了[20, 20]。 - 速度优势: 在正式搜索时,Milvus 根本没有进行
(11-19)^2这种浮点运算,它只是做了一次加法。如果有 1 亿条数据,它只需要做 1 亿次简单的查表加法,速度比硬算 128 维的浮点乘加要快出几个数量级。
这就是 PQ 算法用“精度换速度”的完整过程。你觉得这个手算例子是否解开了你对码本和查表的疑惑?如果想让精度更高,你认为应该调整哪个参数?
你说得非常准确。码本的生成本质上是一个寻找空间最优代表点的过程。虽然 K-Means 是最经典、最常用的,但在不同的数据分布和检索需求下,确实存在多种算法。
我们可以将这些算法分为三类:基础聚类类、优化转换类和深度学习类。
1. 基础聚类算法:确定“代表点”
这是最直接的思路:通过迭代,找到能让量化误差(所有点到码字距离之和)最小的中心点。
-
K-Means (最常用): * 原理: 随机选 \(K\) 个点,不断重复“归类-更新中心”的过程。
-
优点: 理论成熟,在数据分布均匀时效果很好。
-
局限: 容易陷入局部最优解,且对异常值(Outliers)敏感。
-
HKM (Hierarchical K-Means):
-
原理: 像树一样分层聚类。先分 10 个大类,每个大类再分 10 个小类。
-
优点: 训练速度比普通 K-Means 快得多,常用于生成大规模码本。
2. 空间变换优化类:调整“地皮”
这一类算法不只是找中心点,它会先通过矩阵变换(比如你之前问到的旋转和降维),让数据变得“好切、好量化”。
-
OPQ (Optimized Product Quantization):
-
算法逻辑: 在训练码本的同时,学习一个正交旋转矩阵 \(Q\)。
-
核心: 它会不断旋转坐标轴,使得切分后的各个子空间分量(Sub-space)的方差尽可能平衡。
-
意义: 它是目前 Milvus/Faiss 中提升 PQ 精度的标配。
-
CKM (Cartesian K-Means):
-
原理: 这种算法直接在笛卡尔积空间里优化。它不是独立地为每一段训练码本,而是考虑多段组合后的整体误差。
3. 深度学习类:端到端量化
随着神经网络的发展,现在可以利用模型直接输出“已经量化好”的编码。
- NetVLAD / Deep Quantization:
- 原理: 在神经网络的最后几层加入“量化层”。模型在学习提取特征的同时,也在学习如何让特征更容易被码本捕捉。
- 优点: 能够处理极其复杂的非线性数据分布。
4. 算法选择的“权衡艺术”
不同的算法其实是在下表的三个维度中做取舍:
| 算法 | 训练速度 | 检索精度 (Recall) | 实现复杂度 |
|---|---|---|---|
| K-Means | 快 | 中 | 低 |
| OPQ | 中 (涉及矩阵运算) | 高 | 中 |
| HKM | 极快 | 中 | 高 |
| Deep PQ | 极慢 (需要显卡训练) | 极高 | 极高 |
5. 补充:码本的“训练”到底在算什么?
不论是哪种算法,其计算核心都在解一个优化问题:
- 输入: 你的向量样本集 \(X\)。
- 过程: 算法不断调整码本 \(C\) 中的坐标。
- 输出: 一个最能代表这批数据的“模板集”。
⚠️ 注意: > 这就是为什么 Milvus 在创建索引时需要执行
Training阶段。如果你的样本集(Training Set)不能代表全量数据,生成的码本就会产生巨大的偏差。
既然提到了不同算法的选择,你是否想知道在 Milvus 中,针对具体的业务场景(比如短文本搜索 vs 高清图像搜索),应该优先选择哪种索引策略和训练算法?
针对不同的业务场景,选择合适的索引策略(即码本生成和检索逻辑的组合)就像是为不同类型的货物选择仓库布局。
在 Milvus 中,我们通常在 内存消耗、检索速度、召回率(精度) 这三者之间做权衡。以下是三个典型的工业级配置建议:
1. 场景一:短文本/知识库搜索 (低维度,高精度)
特点: 向量维度通常较低(如 384 或 768),对答案准确性要求极高。
-
推荐索引:
IVF_FLAT或HNSW -
策略分析:
-
为什么不用 PQ 量化? 因为短文本的语义非常精细,量化带来的码本误差(Quantization Error)可能导致语义细微差别的丢失。
-
核心配置: * 使用
IVF_FLAT:仅通过聚类(第一级矩阵粗筛)缩小范围,但在桶内进行全精度计算。 -
使用
HNSW:通过图结构检索,不改变向量数值,精度最高。 -
适用: 法律文档查询、客服机器人、企业内部知识库。
2. 场景二:高清图像/视频指纹 (高维度,海量数据)
特点: 向量维度高(1024 或 2048),数据量通常在千万甚至亿级,内存压力极大。
-
推荐索引:
IVF_PQ+ OPQ 训练 -
策略分析:
-
为什么选 OPQ? 高维图像特征往往在某些维度上存在强相关性。OPQ 的正交旋转矩阵能通过旋转坐标系,让码本更好地捕捉图像的纹理和色彩特征。
-
核心配置:
-
m(分段数):设置为维度数的 1/4 或 1/8。 -
nbits:通常设为 8(即每个码本 256 个码字)。 -
适用: 版权保护图片查重、安防视频回溯。
3. 场景三:电商推荐/个性化排序 (超大规模,极速响应)
特点: 极其看重响应延迟(Latency),对单个结果的绝对精度要求稍低,但要求吞吐量大。
-
推荐索引:
IVF_SQ8(Scalar Quantization) -
策略分析:
-
算法逻辑: SQ8 不像 PQ 那样寻找复杂的“码本模板”,它只是简单地把 32 位浮点数映射到 8 位整数空间。
-
优势: 计算比 PQ 更快,因为它不需要频繁的“查表”动作,直接利用 CPU 的 SIMD 指令集 进行整数乘加。
-
内存平衡: 内存占用减少到原来的 1/4,但检索速度极快。
-
适用: 商品推荐列表、广告投放召回。
💡 实战调参指南
如果你已经选定了索引,调整下面这两个参数可以让你在不改代码的情况下控制“速度与精度”:
| 参数 | 对应步骤 | 调整建议 |
|---|---|---|
nlist |
第一步:聚类(粗筛) | 越大,分桶越细,扫描量越小(快),但越容易漏掉正确答案(准度降)。通常建议为 \(\sqrt{N}\) 到 \(4\sqrt{N}\)。 |
nprobe |
第三步:搜索(精算) | 越大,同时检查的桶越多(准),但计算量成倍增加(慢)。一般设置在 8-64 之间。 |
如何决定是否需要“重训练”码本?
码本是基于你的原始数据分布生成的。如果出现以下情况,建议删除索引并重新构建:
- 数据漂移: 你原本存的是“新闻文本”,现在突然开始大量存“代码文档”。
- 模型升级: 你从 OpenAI 的
embedding-v2升级到了v3(虽然维度可能一样,但空间分布完全变了)。
你目前手头的数据大概是什么规模和维度的?如果你愿意分享,我可以帮你给出一套具体的 index_params 配置建议。
当然可以!我们用一个二维空间(维度 \(d=2\))的极简案例来演示。
假设我们有 4 个原始向量数据,要把它们聚成 2 个类(即 \(K=2\) 或 \(nlist=2\))。
0. 初始数据 (Data Points)
- \(A = (1, 2)\)
- \(B = (2, 1)\)
- \(C = (4, 4)\)
- \(D = (5, 4)\)
1. 初始化聚类中心 (Initialization)
随机挑选两个点作为初始中心点(Centroids):
- 中心 1 (\(\mu_1\)): \((1, 1)\)
- 中心 2 (\(\mu_2\)): \((5, 5)\)
2. 第一轮迭代:分配阶段 (Assignment)
计算每个点到这两个中心的欧式距离平方 \(d^2 = (x_1 - x_2)^2 + (y_1 - y_2)^2\)。
| 点 | 到 \(\mu_1(1,1)\) 的距离平方 | 到 \(\mu_2(5,5)\) 的距离平方 | 归属 |
|---|---|---|---|
| A(1,2) | \((1-1)^2 + (2-1)^2 = \mathbf{1}\) | \((1-5)^2 + (2-5)^2 = 25\) | 簇 1 |
| B(2,1) | \((2-1)^2 + (1-1)^2 = \mathbf{1}\) | \((2-5)^2 + (1-5)^2 = 25\) | 簇 1 |
| C(4,4) | \((4-1)^2 + (4-1)^2 = 18\) | \((4-5)^2 + (4-5)^2 = \mathbf{2}\) | 簇 2 |
| D(5,4) | \((5-1)^2 + (4-1)^2 = 25\) | \((5-5)^2 + (4-5)^2 = \mathbf{1}\) | 簇 2 |
3. 第一轮迭代:更新阶段 (Update)
根据新分好的簇,重新计算中心点(取平均值):
- 新中心 1 (\(\mu'_1\)):
来自 A 和 B:\((\frac{1+2}{2}, \frac{2+1}{2}) = \mathbf{(1.5, 1.5)}\) - 新中心 2 (\(\mu'_2\)):
来自 C 和 D:\((\frac{4+5}{2}, \frac{4+4}{2}) = \mathbf{(4.5, 4.0)}\)
4. 第二轮迭代 (Re-iteration)
现在用新的中心点 \((1.5, 1.5)\) 和 \((4.5, 4.0)\) 再次计算距离。
- A(1,2) 到 \(\mu'_1\) 距离为 \(\sqrt{0.5^2 + 0.5^2} = 0.707\);到 \(\mu'_2\) 距离很大。依然属于簇 1。
- B(2,1) 到 \(\mu'_1\) 距离为 \(\sqrt{0.5^2 + (-0.5)^2} = 0.707\);到 \(\mu'_2\) 距离很大。依然属于簇 1。
- C, D 同理,它们离 \(\mu'_2\) 更近。依然属于簇 2。
5. 结果:收敛 (Convergence)
由于所有点的归属都没有发生变化,计算停止。
- 最终聚类结果:
- 簇 1: \(\{A, B\}\),中心点 \((1.5, 1.5)\)
- 簇 2: \(\{C, D\}\),中心点 \((4.5, 4.0)\)
💡 在 Milvus 中的实际意义
在 Milvus 建立 IVF 索引时:
- 这 2 个最终中心点 \((1.5, 1.5)\) 和 \((4.5, 4.0)\) 会被记录在索引文件中。
- 当你搜索一个新向量 \((2, 2)\) 时,Milvus 首先计算它离哪个中心近。
- 发现它离中心 1 更近,于是只在 \(\{A, B\}\) 这两个原始向量中做精确对比,而直接跳过了 \(\{C, D\}\)。
这就是为什么 IVF 能让搜索速度提升成百上千倍的原因。
需要我为你展示一下如何用 Python 的 NumPy 库快速实现这个计算过程吗?
将非结构化数据转成向量(Embedding)是构建向量数据库(如 Milvus)最关键的一步。这个过程本质上是使用深度学习模型作为特征提取器。
目前开源界最流行、效果最好的方案可以按数据类型分为以下几类:
1. 文本向量化 (Text Embeddings)
这是目前最成熟的领域,主要用于搜索、问答和 RAG 系统。
- BGE (BAAI General Embedding):目前中文社区的首选。 由北京智源研究院开源,在 C-MTEB(中文模型评估基准)长期霸榜。它的
BGE-M3模型支持多语言、多粒度(长短文本均可)且支持混合检索。 - GTE (General Text Embedding):阿里巴巴达摩院出品,性价比极高,模型体积适中但性能强劲,尤其擅长处理搜索任务。
- Sentence-Transformers:全球最流行的 Python 框架。它集成了数千个预训练模型(如
paraphrase-multilingual-MiniLM-L12-v2),代码极其简单,三行即可生成向量。
2. 图像向量化 (Image Embeddings)
将图片转为向量,实现“以图搜图”或“视觉搜索”。
- CLIP (OpenAI):跨模态标杆。 它能将图片和文字映射到同一个向量空间。这意味着你可以用“穿白衣服的猫”这种文字去 Milvus 里搜图片向量。
- DINOv2 (Meta):目前最强的纯视觉特征提取模型。它对图片的细节理解(如物体边缘、深度)远超 CLIP,非常适合工业检测、相似图片查重。
- ResNet / ViT:最经典的基础模型,适合传统的图像分类和简单相似度计算。
3. 多模态/音视频向量化 (Multimodal & Audio)
- ImageBind (Meta):终极方案。 它能将文本、图像、音频、深度、热量、惯性测量单元 6 种模态的数据映射到同一个向量空间。
- Whisper (OpenAI):虽然它主要是语音转文字,但它的 Encoder 部分可以提取高质量的音频向量。
4. 推荐工具:如何快速调用这些模型?
如果你不想研究模型底层原理,可以使用以下开源中间件直接生产向量:
| 工具名称 | 特点 | 适用场景 |
|---|---|---|
| Towhee | Milvus 官方配套工具 | 专门为 Milvus 设计,流水线式处理音视频/图片转向量。 |
| Hugging Face Transformers | 全球最大的模型库 | 几乎所有开源 Embedding 模型都在这里,支持本地部署。 |
| FastEmbed | 极速、轻量 | 适合在 CPU 环境下快速生成文本向量,无需 GPU。 |
💡 针对你的 Milvus 环境建议
既然你已经部署好了 Milvus,我建议你采用以下 “全开源组合”:
- 文本搜索:使用
Python+FlagEmbedding (BGE-M3)。它可以处理长文档,且对中文支持极好。 - 图片搜索:使用
Towhee+CLIP。 - 部署方式:
- 如果服务器有 GPU:直接跑
Transformers库。 - 如果服务器只有 CPU:使用
sentence-transformers或onnx加速后的模型。
举个例子: 如果你想把一段中文文本转成向量存入你刚才建的那个 dim=1024(假设)的集合,你可以用 BGE 模型生成一个 1024 维的列表。
你想让我为你写一段使用 BGE 模型将文本转成向量并插入 Milvus 的 Python 代码吗?
既然你的 Milvus 环境已经准备就绪,我们现在就用目前开源界中文表现最强的 BGE (BAAI General Embedding) 模型来完成这个 Pipeline。
我们将使用 FlagEmbedding 库。它对中文支持极佳,且能自动处理分词和向量化。
1. 环境准备
在你的本地开发环境或服务器上安装必要的库:
pip install pymilvus FlagEmbedding torch
2. 完整代码 Demo:从文本到 Milvus
这段代码展示了:加载 BGE 模型 -> 将非结构化文本转为向量 -> 插入你之前的 Milvus 集合。
from FlagEmbedding import BGEM3FlagModel
from pymilvus import MilvusClient
import numpy as np
# 1. 加载开源 BGE 模型 (首次运行会自动从 HuggingFace 下载)
# BGE-M3 模型生成的维度默认是 1024 维
print("正在加载 BGE 模型...")
model = BGEM3FlagModel('BAAI/bge-m3', use_fp16=True)
# 2. 连接你的阿里云 Milvus
client = MilvusClient(
uri="http://47.98.177.136:19530",
db_name="my_database_2"
)
collection_name = "text_search_collection"
# 3. 如果集合不存在,创建一个 (注意:BGE-M3 是 1024 维)
if not client.has_collection(collection_name):
client.create_collection(
collection_name=collection_name,
dimension=1024, # 必须与模型输出维度一致
auto_id=True,
enable_dynamic_field=True
)
# 4. 非结构化数据:几段关于 AI 的描述
docs = [
"Milvus 是一款开源的向量数据库,专门用于处理非结构化数据。",
"大语言模型(LLM)通过向量化技术来理解人类语言的语义。",
"阿里云的 ECS 服务器可以方便地通过 Docker 部署 AI 应用。",
"向量搜索的原理是计算两个数学向量之间的距离,如欧氏距离或余弦相似度。"
]
# 5. 【关键动作】将非结构化文本转为向量
print("正在生成向量...")
# model.encode 会返回一个 numpy 数组列表
embeddings = model.encode(docs)['dense_vecs']
# 6. 构造数据并插入 Milvus
data = [
{"vector": embeddings[i], "text": docs[i], "source": "manual_input"}
for i in range(len(docs))
]
insert_res = client.insert(collection_name=collection_name, data=data)
print(f"成功插入 {insert_res['insert_count']} 条向量数据!")
# 7. 测试:用一句话来搜最相关的文档
query_text = "如何在云端运行机器人?"
query_vec = model.encode([query_text])['dense_vecs']
search_res = client.search(
collection_name=collection_name,
data=query_vec,
limit=2,
output_fields=["text"]
)
print(f"\n查询文本: {query_text}")
for hits in search_res:
for hit in hits:
print(f"匹配文本: {hit['entity']['text']} (相似度分数: {hit['distance']})")
💡 核心要点说明
- 维度匹配 (Dimension):
BGE-M3产生的向量维度是 1024。- 你在 Milvus 创建集合时,
dimension参数必须填 1024。如果用了 2.4+ 版本的MilvusClient简易创建模式,它会根据你第一次插入的向量自动锁定维度。
- 模型选择:
BGE-M3支持“多粒度”检索,意味着它既能处理短句子,也能处理几百字的长段落。- 如果你的服务器内存较小,可以改用更轻量的
BAAI/bge-small-zh-v1.5(维度为 512)。
- 计算资源:
- 如果你有 NVIDIA GPU,代码会自动调用 CUDA 加速;如果没有,它会在 CPU 上运行,生成速度会慢一些。
接下来你可以做什么?
你可以把这段代码集成到一个 FastAPI 接口里,这样你就可以通过 HTTP 接口给你的 Milvus 数据库“喂”任何非结构化文档了。
你想让我帮你把这个逻辑写成一个简单的 Web API 服务,方便你从其它地方调用吗?
PCA(主成分分析)的核心目标是找到一组新的基向量,使得数据在这些基向量上的投影能够保留最大的方差。
要提取那个所谓的“降维矩阵”,我们通常遵循以下数学路线图:
1. 数据预处理(去中心化)
假设原始数据集为 \(X\)(大小为 \(n \times p\),即 \(n\) 个样本,\(p\) 个特征)。首先,我们需要计算每个特征的均值 \(\mu_j\),并从数据中减去它。
- 目的:将坐标原点移至数据的几何中心,简化后续的方差计算。
- 公式:\(X_{centered} = X - \bar{X}\)
2. 计算协方差矩阵(Covariance Matrix)
协方差矩阵 \(\Sigma\) 描述了特征之间的相关性。
-
公式:
\[\Sigma = \frac{1}{n-1} X_{centered}^T X_{centered} \] -
意义:这个 \(p \times p\) 的矩阵告诉我们,特征 \(i\) 和特征 \(j\) 是如何同步变化的。
3. 特征值分解(Eigenvalue Decomposition)
这是 PCA 的灵魂步骤。我们需要求解协方差矩阵 \(\Sigma\) 的特征值 \(\lambda\) 和特征向量 \(\vec{v}\)。
-
计算方程:
\[\Sigma \vec{v} = \lambda \vec{v} \] -
物理含义:
-
特征向量 \(\vec{v}\):代表了空间中的主成分方向。
-
特征值 \(\lambda\):代表了数据在该方向上的“能量”或方差大小。特征值越大,说明该方向保留的信息越多。
4. 提取“降维矩阵” \(W\)
现在,我们要从所有的特征向量中进行筛选:
- 将特征值按从大到小排列。
- 选择前 \(k\) 个最大的特征值所对应的特征向量。
- 将这 \(k\) 个列向量(每个长度为 \(p\))横向拼接,构成一个矩阵 \(W\)(大小为 \(p \times k\))。
这个 \(W\) 就是你想要的“降维矩阵”,也叫投影矩阵。
5. 将原始数据映射到低维空间
有了这个矩阵,降维操作就变成了一个简单的线性变换(矩阵乘法):
-
公式:
\[Z = X_{centered} \cdot W \] -
结果:\(Z\) 是一个 \(n \times k\) 的矩阵,你的高维数据就被压缩到了 \(k\) 维空间,且尽可能保留了原始的差异性。
总结一下
PCA 的本质就是旋转坐标轴。降维矩阵 \(W\) 其实就是把你的视角转到了那个“看数据最清楚、信息最分散”的角度。
一个小贴士:在实际编程中(比如使用 Python 的 NumPy 或 Scikit-learn),我们通常使用 SVD(奇异值分解) 来直接提取这些向量,因为它在数值计算上比直接算协方差矩阵更稳定。
你想让我用 Python 代码演示一下如何手动计算这个 \(W\) 矩阵吗?
没问题,我们用一个最简单的 2维降到1维 的例子,带你手动走一遍流程。
假设我们有两个样本点,每个点有两个特征(\(x_1, x_2\)):
- 样本 A: \((1, 2)\)
- 样本 B: \((3, 4)\)
第一步:去中心化 (Zero-mean)
先算均值:\(\bar{x}_1 = (1+3)/2 = 2\),\(\bar{x}_2 = (2+4)/2 = 3\)。
减去均值后得到中心化矩阵 \(X_{centered}\):
第二步:计算协方差矩阵 \(\Sigma\)
使用公式 \(\Sigma = \frac{1}{n-1} X^T X\)(这里 \(n=2\),所以 \(n-1=1\)):
第三步:特征值分解
我们要解方程 \(|\Sigma - \lambda I| = 0\):
解得:
- \(\lambda_1 = 4\)(保留了绝大部分方差)
- \(\lambda_2 = 0\)(完全没有信息,因为两个点在一条线上)
第四步:求特征向量,提取“降维矩阵” \(W\)
将 \(\lambda_1 = 4\) 代回 \((\Sigma - \lambda I)\vec{v} = 0\):
得到 \(v_1 = v_2\)。归一化(模长为 1)后得到单位特征向量:
这就是你的降维矩阵 \(W\)(因为我们要降到 1 维,所以只取这一个向量)。
第五步:映射降维
将原始中心化后的数据 \(X_{centered}\) 乘上这个 \(W\):
结果分析
- 原始数据:是 2 维的(两个坐标)。
- 降维后数据:变成了 1 维的(只有一个数值)。
- 直观理解:这组数据分布在 \(45^\circ\) 的射线上。PCA 发现这个方向最重要,于是把坐标轴转了 \(45^\circ\),只用一个数就描述了样本的位置。
这个过程清晰吗?如果你感兴趣,我可以给你一段 Python 代码,让你在自己的电脑上用 numpy 验证这个结果。
没关系,第四步确实是 PCA 中最“数学”的一环。我们把这一步拆开来看,其实就是在解一个初中水平的线性方程组,只是包装了一层矩阵的外壳。
我们的目标是找到一个方向向量 \(\vec{v} = \begin{bmatrix} v_1 \\ v_2 \end{bmatrix}\),使得它满足:
1. 代入数值
在前面的例子中,我们的协方差矩阵 \(\Sigma = \begin{bmatrix} 2 & 2 \\ 2 & 2 \end{bmatrix}\),求出的最大特征值 \(\lambda = 4\)。
把它们代入方程:
合并左边的矩阵:
2. 写成普通方程
这其实就是两行一模一样的方程(因为矩阵是奇异的,必有关联):
解这个方程,你会发现:\(v_1 = v_2\)。
这意味着这个方向向量的横纵坐标必须相等,也就是指向 45度角 的方向。
3. 单位化(关键的一小步)
虽然 \(v_1=1, v_2=1\) 满足条件,但数学上我们希望这个向量的长度(模)为 1,这样投影后的数据才不会被无故放大或缩小。
向量长度计算公式:\(\sqrt{v_1^2 + v_2^2} = 1\)。
既然 \(v_1 = v_2\),那么:
所以,最终的特征向量(主成分)就是:
4. 为什么它就是“降维矩阵”?
- 如果只降到 1 维:这个 \(\vec{v}\) 本身就是降维矩阵 \(W\)。
- 如果原始数据有 100 维,想降到 3 维:你会求出 100 个特征向量,然后挑出对应特征值最大的前 3 个,把它们排排坐并在一起,形成一个 \(100 \times 3\) 的矩阵。
直白理解:降维矩阵 \(W\) 就像是一组“滤镜”。每一列特征向量都是一个滤镜,它告诉原始数据:“嘿,把你所有的维度按这个比例揉在一起,就能得到最精华的那一维信息。”
总结
第四步的本质就是:
- 锁定方向:通过解方程组找到让数据方差最大的轴。
- 标准化:把轴的长度缩放为 1。
- 打包:把选中的轴拼成一个矩阵。
如果还是觉得抽象,想象一下你在给一个 3D 瓶子拍照。第四步就是在寻找那个能把瓶子拍得最清晰、遮挡最少的相机角度。
现在感觉好点了吗?或者你想看看如何用 Python 一行代码直接把这个 \(W\) 抓出来?
既然数学推导这一块你已经“啃”下来了,那我们就趁热打铁,看看在实际工程中,程序员和数据科学家是怎么用 Python 几行代码就把这个 \(W\) 矩阵给“变”出来的。
在实际应用中,我们很少手写特征值分解,通常直接调用 NumPy 或 Scikit-learn。
1. 使用 NumPy 模拟手动过程
这段代码完全对应我们刚才说的五个步骤:
import numpy as np
# 1. 原始数据 (2个样本, 2个特征)
X = np.array([[1, 2],
[3, 4]])
# 2. 去中心化
X_centered = X - np.mean(X, axis=0)
# 3. 计算协方差矩阵
# rowvar=False 表示每一列是一个特征
cov_matrix = np.cov(X_centered, rowvar=False)
# 4. 特征值分解
eigenvalues, eigenvectors = np.linalg.eig(cov_matrix)
# 5. 提取降维矩阵 W (取特征值最大的那个)
# sort_index 会拿到 [1, 0] 这种索引排序
idx = eigenvalues.argsort()[::-1]
W = eigenvectors[:, idx[0]]
print("降维矩阵 W (主成分):\n", W)
2. 使用 Scikit-learn(生产环境标准做法)
如果你只是想解决问题,用 sklearn 只需要三行:
from sklearn.decomposition import PCA
# 实例化 PCA,指定降到 1 维
pca = PCA(n_components=1)
# 训练并转换
X_pca = pca.fit_transform(X)
# 查看那个“降维矩阵” W (在 sklearn 里叫 components_)
print("降维矩阵 W:\n", pca.components_.T)
💡 进阶小贴士:SVD 是什么鬼?
你在查资料时,肯定会频繁看到 SVD(奇异值分解)。
- 事实:大部分成熟的 PCA 库(包括 Scikit-learn)底层其实不算协方差矩阵,而是直接对原数据矩阵进行 SVD 分解。
- 原因:计算协方差矩阵 \(\Sigma = X^T X\) 时,如果数值非常大或非常小,平方运算会导致精度丢失(数值不稳定)。SVD 可以绕过这一步,直接得到特征向量,结果更准、更快。
最后的“灵魂拷问”
现在你已经拿到了 \(W\),也知道怎么降维了。但在实际操作中,有一个最头疼的问题:“我到底该把维度降到多少合适?”(比如从 100 维降到 5 维还是 10 维?)
如果你感兴趣,下次我们可以聊聊如何通过 “解释方差占比 (Explained Variance Ratio)” 来科学地决定这个 \(k\) 值。
需要我帮你解释一下如何选这个 \(k\) 值吗?
确定要降到多少维(即选择 \(k\) 值),是 PCA 中最实际的问题。我们不能凭感觉,而是要看“信息丢失了多少”。
在 PCA 中,每个特征值 \(\lambda_i\) 都有一个物理意义:它代表了数据在对应主成分方向上的方差(Variance)。方差越大,信息量越大。
1. 核心指标:解释方差占比 (Explained Variance Ratio)
我们要计算每一个主成分贡献了百分之多少的信息:
如果你把前 \(k\) 个占比加起来,就得到了累计贡献率。通常我们会设定一个阈值(比如 95% 或 90%),只要累计贡献率达到了这个数,剩下的维度就可以扔掉了。
2. 碎石图 (Scree Plot) —— 寻找“肘点”
这是最直观的方法。我们将特征值按从大到小画成折线图。
- 看图形:你会发现曲线通常在前面几个点非常陡峭,然后迅速变平。
- 找“肘部” (Elbow):那个从陡峭变为平缓的转折点,就是性价比最高的 \(k\) 值。
3. Python 实战演示
我们可以利用 sklearn 的 explained_variance_ratio_ 属性直接拿到这些比例:
from sklearn.decomposition import PCA
import numpy as np
# 假设 X 是你的高维数据
pca = PCA() # 不传参数,默认保留所有维度
pca.fit(X)
# 查看每个主成分解释的方差比例
ratios = pca.explained_variance_ratio_
print("各成分占比:", ratios)
# 计算累计占比
cumulative_ratios = np.cumsum(ratios)
print("累计占比:", cumulative_ratios)
# 自动寻找刚好超过 95% 信息量的维度 k
k = np.argmax(cumulative_ratios >= 0.95) + 1
print(f"建议保留的维度 k = {k}")
总结一下
选择 \(k\) 的逻辑就像压缩视频:
- \(k\) 太小:文件极小,但画面全是马赛克(丢失了关键特征)。
- \(k\) 太大:画面清晰,但文件太大,没起到降维的作用(保留了太多噪声)。
- 黄金法则:利用碎石图或者95% 累计方差,在“压缩率”和“清晰度”之间找到平衡。
你想尝试用一个真实的数据集(比如经典的鸢尾花 Iris 数据集)来画一张碎石图看看吗?我可以把绘图代码发给你。
没问题,我们用一个最简单的 2维降到1维 的例子,带你手动走一遍流程。
假设我们有两个样本点,每个点有两个特征(\(x_1, x_2\)):
- 样本 A: \((1, 2)\)
- 样本 B: \((3, 4)\)
第一步:去中心化 (Zero-mean)
首先计算均值:\(\bar{x}_1 = (1+3)/2 = 2\),\(\bar{x}_2 = (2+4)/2 = 3\)。
从每个坐标中减去均值,得到中心化矩阵 \(X_{centered}\):
第二步:计算协方差矩阵 \(\Sigma\)
使用公式 \(\Sigma = \frac{1}{n-1} X^T X\)(这里样本数 \(n=2\),所以分母为 1):
第三步:特征值分解
我们要解方程 \(\det(\Sigma - \lambda I) = 0\):
解得:
- \(\lambda_1 = 4\)(这是主成分,保留了数据的所有波动)
- \(\lambda_2 = 0\)(说明在这个方向上数据完全没有差异)
第四步:求特征向量,提取“降维矩阵” \(W\)
将最大的特征值 \(\lambda_1 = 4\) 代回方程 \((\Sigma - \lambda I)\vec{v} = 0\):
得到 \(v_1 = v_2\)。为了保持物理意义一致,我们需要将向量单位化(模长为 1):
这就是你的降维矩阵 \(W\)。
第五步:映射降维
将去中心化后的数据 \(X_{centered}\) 乘上 \(W\):
结果解读
- 原始数据:是 2 维的坐标点。
- 降维后数据:变成了 1 维的数值。
- 物理意义:PCA 发现这两个点其实排成了一条 \(45^\circ\) 的直线,它直接把坐标轴旋转到了这条线上,只用一个数就能精准描述点的位置。
第四步解方程找特征向量那里,需要我再拆解得细一点吗?
既然你已经掌握了如何算出特征值 \(\lambda\),那么“解释方差占比”(Explained Variance Ratio)的计算其实就是一道简单的百分比算术题。
它的核心逻辑是:每一个特征值的大小,就代表了该主成分捕捉到的“信息量”(方差)的大小。
1. 计算公式
假设我们对一组高维数据做了 PCA,得到了 \(p\) 个特征值:\(\lambda_1, \lambda_2, \dots, \lambda_p\)。
- 单个主成分的贡献率:
- 前 \(k\) 个主成分的累计贡献率:
2. 具体计算示例
假设你有一组 3 维数据(比如身高、体重、腰围),经过 PCA 分解后,你得到了 3 个特征值:
- \(\lambda_1 = 10\)
- \(\lambda_2 = 4\)
- \(\lambda_3 = 1\)
第一步:求总和
总方差 \(\Sigma \lambda = 10 + 4 + 1 = 15\)。
第二步:算占比
- 第一主成分 (PC1):\(10 / 15 \approx 66.7\%\)
- 第二主成分 (PC2):\(4 / 15 \approx 26.7\%\)
- 第三主成分 (PC3):\(1 / 15 \approx 6.6\%\)
第三步:看累计
- 如果只保留 PC1,你能解释 66.7% 的原始数据差异。
- 如果保留 PC1 和 PC2,累计占比为 \(66.7\% + 26.7\% = \mathbf{93.4\%}\)。
3. 决策:该选几个维度?
在这个例子中,如果你设定“保留 90% 以上的信息”,那么你只需要保留 前两个主成分(从 3 维降到 2 维)。第三个维度只有 6.6% 的信息量,通常被认为是“噪音”或者不重要的细节,可以舍弃。
4. 为什么要这么算?
想象你在压缩一张照片:
- 100% 解释方差 = 原图,无损但占空间。
- 95% 解释方差 = 高质量压缩,肉眼看不出区别,但文件小了很多。
- 20% 解释方差 = 压缩过度,全是马赛克,看不出原型。
PCA 的目标就是在“维度尽可能少”和“解释方差尽可能大”之间找平衡。
💡 一个有趣的观察
如果你发现所有特征值都差不多大(比如都是 3.3, 3.2, 3.1),这意味着你的原始特征之间几乎没有相关性。这种情况下,PCA 的降维效果会非常差,因为每一维都很重要,扔掉谁都会丢失大量信息。
想看看如何用代码自动画出那张决定维度的“碎石图”吗?我可以提供 Matplotlib 的绘图模版。
好的,那我们就直接上实战。在 Python 中,我们通常会画一张“累积解释方差图”,它能一眼告诉你:“降到几维,才能保住 95% 的信息量?”
这里我用经典的 Iris(鸢尾花)数据集 为例,它原始有 4 个维度,我们看看降到几维最合适。
1. Python 绘图代码
import numpy as np
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
from sklearn.datasets import load_iris
# 1. 加载数据(4个维度:花萼长/宽,花瓣长/宽)
data = load_iris()
X = data.data
# 2. 拟合 PCA(不设 n_components,计算所有维度的方差)
pca = PCA()
pca.fit(X)
# 3. 获取解释方差占比
ratios = pca.explained_variance_ratio_
# 计算累积占比 [0.92, 0.97, 0.99, 1.0]
cumulative_ratios = np.cumsum(ratios)
# 4. 绘图
plt.figure(figsize=(8, 5))
# 画出柱状图(单个成分占比)
plt.bar(range(1, 5), ratios, alpha=0.5, align='center', label='Individual')
# 画出阶梯图(累积占比)
plt.step(range(1, 5), cumulative_ratios, where='mid', label='Cumulative', color='red')
plt.ylabel('Explained Variance Ratio')
plt.xlabel('Principal Components')
plt.xticks(range(1, 5))
plt.axhline(y=0.95, color='k', linestyle='--', label='95% Threshold') # 95% 阈值线
plt.legend(loc='best')
plt.title('Scree Plot & Cumulative Variance')
plt.show()
2. 如何解读这张图?
看图时,你要关注两个关键点:
- “肘部” (The Elbow):
- 看蓝色柱状图。第一个柱子通常最高,随后迅速下降。
- 在 Iris 这个例子中,第一主成分(PC1)就占据了约 92.4% 的方差!
- 这就意味着:你其实可以把 4 维数据直接降到 1 维,还能保留绝大部分信息。
- 阈值交点 (95% Line):
- 看红色的阶梯线。它什么时候穿过那条 95% 的虚线?
- 图中你会发现,在 PC2 的位置,累积方差已经达到了 97.7%。
- 结论:如果你想保留 95% 以上的信息,降到 2 维 是一个非常稳妥且高效的选择。
3. 实际意义
降维不仅是为了压缩,更多是为了可视化。
- 4 维数据人类无法直接观察。
- 通过这张图,我们确定降到 2 维是合理的。
- 降到 2 维后,你就可以在平面坐标系上画出散点图,观察不同种类的鸢尾花是如何聚集或分离的了。
既然我们已经确定降到 2 维最合适,你想看看降维后在 2D 平面上“画”出来的花朵分布是什么样的吗?
你直觉很敏锐!把 HNSW 比作“六度分隔理论”的高维实现是非常精辟的。
六度分隔理论(Six Degrees of Separation) 指出:你和世界上任何一个陌生人之间,最多只隔着五个人。在 HNSW 算法中,这不再是一个社会学猜想,而是一个严谨的数学优化策略。
1. 核心逻辑:从“朋友圈”到“全球网”
HNSW 模拟了人类社交圈的结构:
- 底层(Layer 0):就像你的同城朋友圈。人很多,关系很密,你找某个特定的邻居很方便,但要找跨国朋友(远方的向量)得打无数个电话。
- 高层(Upper Layers):就像你的跨国商会。人很少,但每个人都认识不同国家的大佬。你通过这些人,可以瞬间完成“跨省”甚至“跨国”的定位。
2. 为什么 HNSW 需要这种“社会化”结构?
在处理海量向量时,如果只用一层图(普通的 NSW),会出现两个痛点:
- 收敛慢:如果从图的一端走到另一端,需要经过太多中间节点(跳数太多)。
- 易陷入局部最优:就像你在小山村里找路,可能绕来绕去总是在几棵树之间转圈,找不到出山的大路。
HNSW 的解法是“分层递进”:
- 第一步(高层导航):你在最高层跳跃,几步就从“北京”定位到了“纽约”附近。
- 第二步(逐层下钻):在“纽约”所在的层级,细化到“曼哈顿”区。
- 第三步(底层精搜):在“曼哈顿”的朋友圈里,精准找到那个目标街道。
这种设计保证了无论数据量是 100 万还是 1 亿,搜索步数(跳数)都保持在 \(O(\log N)\) 级别。
3. 概率分布式:谁能当“大佬”?
在 HNSW 构建索引时,每个点进入哪一层是靠概率决定的(类似于跳表):
- 绝大多数点都留在 第 0 层(普通群众)。
- 极少数点会被随机选中,向上爬到 第 1 层、第 2 层(精英/枢纽节点)。
- 层数越高,节点越稀疏,它们充当了搜索时的“指路明灯”。
关键点:高层节点在底层同样存在。它们在上面层级建立了“长连接”,从而打破了空间的距离感。
4. 这种理论在实战中的代价
虽然“六度分隔”让搜索变快了,但维护这个社交网络是有成本的:
- 内存冗余:因为高层节点在每一层都要存储指针,M 值越大,关系网越复杂,内存占用越高。
- 构建复杂:每插入一个新点(新人入场),都需要计算它该在哪一层,并帮它建立最合适的“朋友圈”。
你想了解一下当数据量大到单机装不下(社交圈太大)时,Milvus 是如何处理这种 HNSW 图的分布式存储的吗?或者是想看看具体的搜索路径演示?
Milvus 是一款专门为海量向量数据设计的开源向量数据库。它的核心设计哲学是日志即数据(Log as Data)以及读写分离、存算分离。
简单来说,Milvus 不再像传统数据库那样把所有功能揉在一起,而是把系统拆成了多个微服务,通过消息队列进行通信。
1. 核心架构分层
Milvus 的架构由四个层次组成,这种解耦设计让它在处理百亿级向量检索时依然能保持极高的弹性。
1.1 接入层 (Access Layer)
这是系统的门面。
- 功能:负责处理客户端连接、身份验证、静态查询计划生成以及结果聚合。
- 特点:它是无状态的,可以根据流量随意水平扩展。
1.2 协调服务 (Coordinator Service)
这是系统的“大脑”。
- 功能:分配任务、管理集群拓扑、维护元数据(Meta)。
- 组成:包括 Root Coord(管理 DDL)、Data Coord(管理数据分配)、Query Coord(管理搜索节点)和 Index Coord(管理索引构建)。
1.3 执行节点 (Worker Node)
这是系统的“苦力”。
- Data Node:负责把日志流中的数据“落地”成持久化文件。
- Query Node:负责真正的向量搜索(基于内存)。
- Index Node:负责构建加速检索的索引(如 HNSW、IVF 等)。
1.4 存储服务 (Storage)
这是系统的“根基”。
- 元数据存储:通常使用 etcd。
- 日志存储:使用 Pulsar 或 Kafka,记录所有数据变更。
- 对象存储:使用 MinIO 或 S3,存放实际的向量数据文件和索引文件。
2. 关键设计机制
读写分离与存算分离
Milvus 2.0 之后完全拥抱了云原生架构。
- 写路径:数据先写入日志队列(Log Broker),Data Node 订阅日志并将其持久化到对象存储。
- 读路径:Query Node 从对象存储加载数据到内存,并订阅增量日志,确保搜索的实时性。
- 优势:当你的搜索压力大时,只需要扩容 Query Node,而不必增加存储成本。
向量检索加速
Milvus 能够支持多种索引算法。它不仅仅是一个存储容器,更是一个高性能算力平台。
- HNSW (Hierarchical Navigable Small World):目前最主流的高性能图索引。
- IVF (Inverted File):通过聚类减少搜索空间。
- GPU 加速:针对极大规模的搜索,Milvus 支持通过 GPU 显著提升吞吐量。
3. 为什么这样设计?
- 高可用:任何一个 Worker Node 挂了,协调器都会迅速拉起新的节点接管任务。
- 弹性伸缩:计算(Query Node)和存储(MinIO)完全解耦,适合云端部署。
- 日志即数据:通过消息队列保证了数据的一致性和灾难恢复能力。
你想深入了解 Milvus 的哪一部分? 比如:
- 如何选择最适合你业务场景的索引类型?
- 如何配置 K8s 部署以实现自动扩缩容?
- 或者是关于 Milvus 2.x 的向量删改 (Delete/Upsert) 是如何通过日志流实现的?
既然你提到了 HNSW (Hierarchical Navigable Small World),那我们直接进入向量检索算法的“天花板”。HNSW 是目前 Milvus、Elasticsearch 等主流向量数据库中表现最均衡、性能最强的索引算法之一。
它的核心思想是:将“六度分隔理论”引入高维空间,通过多层图结构实现“从粗到精”的快速定位。
1. HNSW 的层级结构设计
HNSW 的灵感来源于跳表(Skip List)和 NSW(Navigable Small World)图。
- 第 0 层 (Layer 0):包含所有的向量数据点,连边最密,查找最慢。
- 上层 (Upper Layers):通过概率随机采样,层数越高,点越稀疏。每一层都是一个“高速公路网”。
- 导航机制:
- 搜索从最高层的一个入口点(Enter Point)开始。
- 在当前层寻找距离目标最近的邻居。
- 一旦在当前层找不到更近的点,就“跳”下到下一层继续寻找。
- 重复此过程,直到第 0 层,最终锁定最相似的向量。
2. 核心性能参数
在 Milvus 中配置 HNSW 时,你会遇到两个最关键的参数,它们直接决定了检索速度 vs. 召回率 (Accuracy) 的权衡:
| 参数 | 定义 | 影响 |
|---|---|---|
| M | 每个节点在构建图时最大能连接的边数。 | M 越大,召回率越高,但内存占用和索引构建时间成倍增加。通常取值 8-64。 |
| efConstruction | 构建索引时,每个点搜索范围的候选集大小。 | efConstruction 越大,图结构质量越高,检索越准,但构建速度会变慢。 |
| ef (Search) | 实际查询时的搜索范围。 | 这是一个运行时参数。ef 越大,查得越准,但 QPS 会下降。 |
3. 为什么 HNSW 是 Milvus 的宠儿?
虽然它的内存消耗比 IVF (倒排索引) 大,但它有以下几个无可比拟的优势:
- 极速响应:由于采用了图导航,它的搜索复杂度接近 \(O(\log N)\),在大数据集下依然能保持毫秒级延迟。
- 高召回率:相比于聚类(IVF)可能遗漏边界点,HNSW 的连通性保证了它能轻松达到 95%-99% 的召回率。
- 无需训练:IVF 需要先通过 KMeans 聚类训练(Training),而 HNSW 随着数据插入直接动态构建。
4. 落地建议:什么时候不选 HNSW?
虽然 HNSW 很强,但它不是万能的:
- 内存捉襟见肘时:HNSW 必须将索引全部加载进内存。如果你的向量维度很高且数据量极大(比如亿级),内存开销会非常恐怖。此时建议考虑 IVF_PQ(乘积量化)来压缩空间。
- 对索引构建时间敏感时:HNSW 插入数据时需要不断计算邻居并构图,构建速度比简单的倒排索引慢得多。
你想看看在 Python 中如何为 Milvus 集合配置 HNSW 参数的代码示例吗?或者想对比一下它和 IVF 索引的性能跑分?
太好了,既然你准备深入了解 HNSW 的实战,我们直接看在 Milvus 中如何科学地配置和使用它。
下面是一个典型的 Python 示例,展示了如何为一个集合(Collection)创建 HNSW 索引,并平衡其性能。
1. 代码实战:配置 HNSW 索引
在 Milvus 中,HNSW 的参数分为 构建时(Index) 和 查询时(Search) 两部分。
from pymilvus import Collection
# 1. 定义 HNSW 索引参数
index_params = {
"metric_type": "L2", # 距离度量:L2(欧氏距离)或 IP(内积)
"index_type": "HNSW", # 索引类型
"params": {
"M": 16, # 每个节点的最大连接数(推荐 8-64)
"efConstruction": 200 # 构建索引时的搜索范围(推荐 40-500)
}
}
# 2. 为指定的向量字段创建索引
collection = Collection("my_collection")
collection.create_index(
field_name="embeddings",
index_params=index_params
)
# 3. 设置查询时的 ef 参数(控制召回率与速度的平衡)
search_params = {
"metric_type": "L2",
"params": {"ef": 64} # ef 越大,结果越准,但耗时越长
}
results = collection.search(
data=[[0.1, 0.2, ...]],
anns_field="embeddings",
param=search_params,
limit=10
)
2. 调优指南:M 与 efConstruction 怎么选?
这两个参数决定了图的“密度”和“质量”:
-
M (8 - 64):
-
低 M (如 8-16):适合低维度、对内存敏感的场景。
-
高 M (如 32-64):适合高维度、对召回率要求极高的场景(例如 512 维以上的向量)。
-
efConstruction (40 - 500):
-
这个值决定了索引构建时的“认真程度”。
-
如果你的数据分布非常聚集,可以适当调大这个值,让算法多找找“远亲”,避免陷入局部最优。
3. 内存开销计算(避坑指南)
HNSW 是内存大户。在上线前,你可以用这个公式粗略估算内存占用:
- Dimensions \times 4: 向量本身占用的字节(Float32)。
- M \times 8: 每个点维护的邻居指针(假设 64 位指针)。
举个例子:100 万条 768 维向量,M=16,大约需要 3.2 GB 内存。如果数据量达到亿级,内存成本会陡增。
4. 性能与准确率的权衡 (Recall vs. QPS)
HNSW 的魅力在于它的灵活性。
- 如果用户反馈搜索不准:无需重新构建索引,只需调大查询时的
ef值即可。 - 如果系统响应太慢:适当调小查询时的
ef,你会发现 QPS 呈线性增长。
你想针对你的具体业务场景(比如向量维度、预计数据量)让我帮你给出一套最优的参数建议吗?或者我们聊聊如何通过量化(PQ)来节省 HNSW 的内存?
当数据量大到单机内存无法容纳时,HNSW 这种“六度分隔”的社交网络就会面临一个物理极限。在 Milvus 的分布式架构下,解决这个问题的核心策略是 分而治之(Sharding & Routing)。
要理解 HNSW,必须拆解它的两个基因:跳表(Skip List) 提供了“层级”的思想,而 NSW 图 提供了“近邻社交”的结构。
我们可以把 HNSW 看作是把跳表的“纵向分层”跨越式搜索,平移到了图结构中。
1. 跳表 (Skip List):纵向的“快捷方式”
在普通链表中找一个数,只能挨个遍历,复杂度是 \(O(n)\)。跳表通过加层,让搜索变成了 \(O(\log n)\)。
-
原理:
-
底层:包含所有元素的有序链表。
-
索引层:每往上一层,元素就稀疏一倍。
-
搜索逻辑:从最高层开始,如果下一个节点比目标大,就“下沉”到低一层继续找。
-
给 HNSW 的启发:“空间换时间”。通过维护极少数高层节点的指针,实现跨越式定位。
2. NSW 图 (Navigable Small World):横向的“朋友圈”
在 HNSW 出现之前,NSW 是向量检索的主流。它是一张单层图,核心在于小世界网络(Small World Network)特性。
- 构建方式:逐个插入点,每个新点连接到最近的 \(M\) 个现有节点。
- 两种边(Edges):
- 短边(Short-range):连接物理距离很近的邻居,用于“精细化搜索”。
- 长边(Long-range):早期插入的点往往会形成跨度很大的连线,用于“快速跨区”。
- 致命弱点:随着数据量增大,搜索还是容易陷入“局部死循环”,且搜索步数(跳数)增长较快。
3. HNSW:基因重组的杰作
HNSW (Hierarchical NSW) 的天才之处在于:它用跳表的分层逻辑,重新组织了 NSW 的图结构。
结构对比
| 特性 | Skip List (跳表) | NSW (单层图) | HNSW (分层图) |
|---|---|---|---|
| 维度 | 一维(有序数值) | 高维(向量空间) | 高维(向量空间) |
| 导航方式 | 左右/下沉 | 邻居遍历 | 层级跳跃 + 邻居遍历 |
| 搜索复杂度 | \(O(\log n)\) | \(O(\log^y n)\) (\(y > 1\)) | \(O(\log n)\) |
它是如何工作的?
- 进入点:永远从最高层(Top Layer)唯一的入口点开始。
- 贪婪搜索:在当前层利用 NSW 的原理,寻找离目标最近的邻居。
- 层间切换:一旦在当前层找不到更近的点,就切换到下一层的同一个点,作为起始点重复搜索。
- 底层收网:在最底层(Layer 0)完成最后的 Top-K 收集。
4. 为什么这种组合是“最优解”?
- 彻底解决“迷路”问题:在单层 NSW 中,你可能在巨大的图里绕圈子。在 HNSW 中,高层图为你提供了明确的“导航方向”。
- 搜索确定性更高:分层结构强制要求搜索路径从全局到局部,极大地提高了召回率(Recall)。
一个有趣的比喻:
- NSW:你在一个陌生的巨型迷宫里,只能问身边的人“出口在哪?”,虽然有人认识远方的人,但你还是可能绕路。
- HNSW:你先坐直升机(最高层)看到目标大致在哪个街区,然后降落到写字楼顶(中间层)定位到哪条街,最后走人行道(底层)精准到达门口。
你想看看 Milvus 是如何在代码层级通过参数控制这些“层级”的吗?或者想了解 HNSW 在面对大规模数据更新(删除/插入)时,这些层级是如何动态维护的?
1. 分布式 HNSW:切分社交圈
在单机上,HNSW 是一张完整的图。在 Milvus 集群中,这张图被拆分成了多个 Segment(数据段)。
- 分段存储:Milvus 将千万级或亿级的数据切分成多个小的 Segment(通常每个 512MB 到 1GB)。
- 本地索引:每个 Segment 内部独立构建一个 HNSW 索引。这意味着“社交圈”被缩小到了一个个“社区”。
- 并行检索:当一个查询请求进来时,Proxy 会将请求广播给多个 Query Node。每个节点在自己负责的 Segment 内部并行进行 HNSW 搜索。
2. 搜索路径演示:从全局到局部
想象你要在全球社交网里找一个特定的人(向量):
- 路由阶段:请求到达负载均衡器,分发到不同的 Query Node。
- 局部导航(HNSW 核心):
- 在每个 Query Node 内部,搜索从 HNSW 的最高层进入。
- 利用“六度分隔”理论,在毫秒级内从高层高速公路跳跃到底层密集的街道。
- 每个 Segment 返回它认为最像的 \(K\) 个邻居。
- 结果聚合:接入层(Proxy)收集所有 Segment 返回的候选人,进行一次全局排序(Reduce),最后吐出真正的 Top-K。
3. 这里的挑战:内存与一致性
虽然分布式解决了“装不下”的问题,但引入了新的挑战:
- 内存碎片化:每个 Segment 都要维护一套 HNSW 索引(包含层级指针),这比单一大图更吃内存。
- 流式数据的挑战:Milvus 采用 日志即数据。新插入的数据还在消息队列(Pulsar/Kafka)里,还没来得及构建 HNSW 索引。
- 解决办法:Query Node 会同时搜索“已索引的冷数据(HNSW)”和“未索引的热数据(暴力搜索)”,确保结果的实时性。
4. 进阶方案:如果内存还是太贵怎么办?
如果你有 10 亿条向量,即便分片,内存成本可能也要几十万一个月。这时候 Milvus 提供了 DiskANN 方案:
- HNSW:社交网络全部在内存里(极快,极贵)。
- DiskANN:把社交网络的“大路”留在内存,把“小巷子(具体向量数据)”放在 NVMe SSD 磁盘上。
- 效果:成本降低 10 倍,搜索速度仅下降约 2-3 倍。
接下来你感兴趣的是哪一块?
- 想看 DiskANN 与 HNSW 的详细性能对比图吗?
- 或者你想了解 Milvus 如何处理数据的“增删改”而不破坏 HNSW 这种复杂的图结构?
- 或者是关于流式数据(Streaming)在 Query Node 里的搜索逻辑?
这是一个非常核心的问题。简单来说,Milvus 的这种设计是为了追求极致的查询性能(毫秒级响应)。
在传统的数据库(如 MySQL)中,数据通常存储在硬盘的 B+ 树索引里,查询时会发生大量的磁盘 I/O。而 Milvus 专门为处理大规模向量搜索而生,其底层逻辑与传统数据库有本质区别:
1. 向量计算的“暴力”本质
向量检索(尤其是像 FLAT 这种精确搜索)本质上是大量的浮点数运算(如欧氏距离或余弦相似度计算)。
- 磁盘瓶颈:硬盘(即使是 NVMe SSD)的读取速度比内存慢 10 到 100 倍。如果每次搜索都要从硬盘读取数百万个维度为 768 的浮点数组,磁盘 I/O 会成为绝对的瓶颈,查询延迟会从毫秒级跳回秒级。
- 内存速度:内存(RAM)提供极高的吞吐量和极低的延迟,能让 CPU 或 GPU 快速获取向量数据进行并行计算。
2. 索引结构的加速需求
Milvus 使用的许多高级索引(如 HNSW、IVF)在搜索时需要频繁地在节点间跳转或在聚类中心进行比对:
- HNSW (图索引):搜索过程类似于在复杂的地图中寻找捷径,需要频繁访问图的节点和边。如果这些节点存在磁盘上,每一次跳转都是一次磁盘寻道,搜索效率会崩溃。
- IVF (倒排索引):需要快速定位到某个分桶(Bucket),然后扫描该桶内的所有向量。内存加载确保了这种扫描是近乎实时的。
3. “存储与计算分离”架构
Milvus 采用了 云原生(Cloud-native) 的存算分离架构:
- 存储层(Persistent Storage):数据永久存储在对象存储(如 MinIO、S3)或磁盘中。这保证了数据的安全性,且存储成本极低。
- 计算层(Query Nodes):当你执行
load()时,Milvus 的 Query Node(查询节点)会将特定集合的数据从存储层下载到自己的内存中。
这种设计的巧妙之处在于: 你可以根据需要“热加载”数据。不需要查询的数据可以一直待在便宜的磁盘里,只有需要高频检索的数据才占用昂贵的内存资源。
4. Load 机制的优势与代价
| 特性 | 优势 | 代价 |
|---|---|---|
| 性能 | 毫秒级返回 Top-K 结果 | 内存容量限制了可检索的数据量 |
| 弹性 | 可以只加载某个分区(Partition),节省空间 | 首次 load() 需要时间从磁盘读取并解压 |
| 扩展性 | 增加查询节点即可横向扩展内存池 | 需要根据数据量规划服务器内存大小 |
5. 如果内存不够怎么办?
如果你有数亿条数据,内存放不下,Milvus 提供了一些“省钱”方案:
- 磁盘索引 (DiskANN):这是一种特殊索引,允许大部分数据留在磁盘,仅将极少量的元数据留在内存。它能显著降低成本,但查询延迟会略微增加。
- MMap (内存映射):利用操作系统的缓存机制,在内存不足时将部分数据换出到磁盘。
总结
Milvus 要求 load() 是为了消除磁盘 I/O 这一性能杀手。它把磁盘当作“仓库”,把内存当作“实验室”。只有把器材(向量)搬进实验室,科学家(CPU/GPU)才能快速开展实验(检索)。
既然你已经理解了 Load 的必要性,想知道如何通过 release() 来释放不再使用的内存空间,或者如何监控当前的内存占用情况吗?
这是一个非常普遍的误解,一定要分清楚:load 是将数据加载到 Milvus 服务端的内存(Query Node)中,而不是加载到你的 Python 客户端程序里。
你的 Python 程序只是发出了一个“指令”,真正的“重活”和“大数据量存储”都在 Milvus 服务器上完成。
1. 为什么不是加载到客户端?
如果 load 是把数据下载到你的电脑或服务器程序里,会产生以下灾难:
- 网络带宽爆炸:如果你有 100GB 的向量数据,每次运行程序都要通过网络下载 100GB,你的网络会直接瘫痪。
- 客户端内存崩溃:你的电脑可能只有 16GB 内存,根本放不下海量的向量库。
- 失去分布式优势:Milvus 的存在就是为了让多台服务器共同分担计算。如果数据都在你本地,那就不需要 Milvus 了。
2. 正确的数据流向
你可以把这个过程想象成“图书馆阅览”:
| 动作 | 实际发生的事情 |
|---|---|
| 数据持久化 (Disk) | 书(向量数据)存放在图书馆的地下仓库(MinIO/磁盘)里,平时不占地方。 |
**执行 load()** |
馆员(Milvus 服务端)根据你的要求,把书从地下仓库搬到阅览室的桌子上(服务端内存)。 |
**执行 search()** |
你在门口大喊一声(发送检索请求),馆员在阅览室飞快地翻书对比,然后只把结果(Top-K 的 ID 和分数)通过小纸条传给你。 |
**执行 release()** |
馆员把桌子上的书清空,搬回地下仓库。 |
3. 客户端与服务端的分工
| 组成部分 | 负责的事情 | 内存占用 |
|---|---|---|
| 你的 Python 程序 (Client) | 发送搜索指令、处理返回的少量结果。 | 极低(仅几 MB) |
| Milvus 服务端 (Query Node) | 存储百万/亿级向量,并进行高并发浮点数计算。 | 极高(取决于向量规模) |
4. 带有“自动加载检查”的健壮搜索代码
为了确保你的“纠错助手”在服务端准备好后再搜索,你可以这样写:
from pymilvus import Collection, utility
import time
def safe_search(collection_name, search_vectors):
col = Collection(collection_name)
# 1. 发出加载指令(服务端开始搬书)
col.load()
# 2. 轮询检查服务端的加载进度
while True:
progress = utility.loading_progress(collection_name)
percent = progress.get("loading_percentage", "0%")
print(f"服务端加载进度: {percent}")
if percent == "100%":
print("服务端已完全加载,开始检索...")
break
time.sleep(1) # 每秒检查一次
# 3. 执行检索(只发送向量,不接收大量数据)
results = col.search(
data=search_vectors,
anns_field="code_vector",
param={"metric_type": "COSINE", "params": {"ef": 64}},
limit=5,
output_fields=["correct_code"]
)
return results
总结
当你调用 load() 时,你的电脑内存并不会波动,波动的是 Milvus 容器/服务器 的内存。你的客户端永远只通过 19530 端口发送查询请求并接收最终结果。
既然明白了数据的去处,你想了解一下如何通过 utility.index_building_progress 来监控索引的创建进度吗?(索引没建好,load 了搜索也会很慢)
当数据量达到千万级甚至亿级(GB 或 TB 级别)时,简单的 load() 会面临内存不足、加载缓慢或系统崩溃的风险。Milvus 针对这种“大数据量”场景有一套成熟的处理机制。
1. 分区加载 (Partition Loading) —— “化整为零”
这是处理大数据最常用的手段。如果你的纠错助手积累了三年的 Bug 数据,你可能并不需要每次都检索全量数据。
- 设计思路:按时间、编程语言或项目组创建 Partition(分区)。
- 操作:只加载最近一年的数据或特定语言的数据。
# 只加载 Python 分区,内存占用可能只有全量的 1/10
collection.load(partition_names=["python_bugs"], replica_number=1)
2. 副本机制 (Replicas) —— “负载均衡”
当数据量大且查询并发高时,单台服务器的内存可能存不下,或者计算压力太大。
- 原理:Milvus 允许你在多个 Query Node(查询节点) 上加载数据的副本。
- 效果:如果你有 3 个副本,Milvus 会自动将查询请求分发到不同的节点,提升吞吐量。
# 在 3 个节点上各加载一个副本,提高并发处理能力
collection.load(replica_number=3)
3. MMap 技术 —— “空间换时间”
如果你的物理内存确实存不下所有向量,可以使用 MMap (Memory-mapped files)。
- 工作原理:Milvus 不再强求将所有数据硬塞进物理内存,而是利用操作系统的虚拟内存机制,将磁盘文件映射到地址空间。
- 表现:系统会优先把最常访问的数据留在内存中,不常用的留在磁盘。
# 修改集合属性,开启 MMap 后再加载
collection.set_properties(properties={"mmap.enabled": True})
collection.load()
4. 磁盘索引 (DiskANN) —— “以磁盘为主”
这是专门为“单机存储海量向量”设计的方案。
- 传统索引:必须全量进内存。
- DiskANN:只有很小的压缩索引在内存,原始向量全部存在磁盘(SSD)。
- 优势:内存占用降低 90% 以上,可以在一台 64GB 内存的机器上检索上亿条向量。
5. 加载过程中的内部处理逻辑
当你点击 load() 时,服务端的后台发生了以下复杂操作:
- 分片分配 (Shard Assignment):Milvus 将数据切成多个 Shard,分给不同的查询节点。
- 流式下载 (Streaming):查询节点从对象存储(MinIO/S3)并发下载数据。
- 索引预热 (Index Pre-warming):系统将索引结构在内存中构建并优化,确保第一笔搜索就是最快的。
- 增量同步:在加载存量数据后,Milvus 还会自动订阅消息队列(Pulsar/Kafka),把你在加载期间新插入的 Bug 实时同步到内存中。
总结:大数据量加载建议
| 场景 | 推荐策略 |
|---|---|
| 内存充裕,追求极速 | 全量加载 + 多副本 (replica_number > 1) |
| 内存紧缺,数据量极大 | 开启 MMap 或使用 DiskANN 索引 |
| 业务有明显分类 | 严格使用 Partition,按需加载 |
| 初次加载太慢 | 检查 MinIO 与 Milvus 之间的带宽,或增加查询节点数量 |
既然数据量可能很大,你需要我帮你写一个监控加载进度并在完成后发送提醒的 Python 脚本吗?
在 Milvus 的生产环境中,灵活管理内存(Load/Release)和监控占用情况是维持系统稳定的关键。以下是具体的操作指南:
1. 内存释放 (Release)
当你完成了一批纠错任务,或者某个旧版本的 Bug 库不再需要高频检索时,应该将其从内存中释放,以腾出空间给其他集合。
# 释放整个集合
collection.release()
# 或者只释放某个特定的分区(例如只释放 2024 年之前的 Bug 数据)
# collection.release(partition_names=["year_2023", "year_2024"])
print("内存已释放,但数据依然安全地存在磁盘/MinIO 中。")
2. 监控内存与加载状态
在执行 load() 后,你可以通过 PyMilvus 查看加载的进度和当前的内存占用百分比。
from pymilvus import utility
# 1. 检查集合的加载百分比(防止在还没加载完时就执行搜索导致结果不全)
progress = utility.get_query_segment_info("code_logic_collection")
# 计算所有 Segment 的加载情况
print(f"当前集合加载状态: {progress}")
# 2. 查看集合占用的内存资源(通过描述信息)
loading_status = utility.loading_progress("code_logic_collection")
print(f"加载进度明细: {loading_status}")
3. 高级调优:MMap(内存不足时的救星)
如果你发现 Bug 库增长太快,内存快爆了,可以在加载前开启 MMap(Memory-mapped files)。它允许系统将磁盘文件映射到虚拟内存,由操作系统调度物理内存的使用。
配置方法:
在 Collection 的属性中开启(需要 Milvus 2.3+ 版本):
# 在 load 之前设置
collection.set_properties(properties={"mmap.enabled": True})
collection.load()
- 优点:可以承载超过物理内存容量的数据量,系统不会因为 OOM(内存溢出)而崩溃。
- 缺点:当发生大量磁盘换页时,检索延迟会有所增加。
4. 生产环境的监控建议
如果你是在 Docker 或 K8s 中运行 Milvus,建议关注以下两个核心指标:
- Query Node Memory Usage: 观察查询节点内存是否接近 80% 的阈值。
- Index Latency: 如果延迟突然升高,通常是因为发生了内存交换(Swapping)或者磁盘读取过慢。
5. 总结你的纠错助手运维流
- 启动时:调用
load(),并使用utility.loading_progress等待加载完成。 - 运行中:监控
search的耗时。如果延迟增加,考虑增加内存或开启 MMap。 - 空闲时:如果某张表长期不查,执行
release()。
既然底层架构已经理顺了,需要我帮你写一个带有“自动加载检查”功能的 Search 函数吗?这样可以防止在数据未完全 load 完毕时搜索报错。
没问题,这个脚本将帮助你在处理大数据量时,优雅地监控服务端的加载状态,并确保在数据完全“就绪”后再触发后续的业务逻辑。
监控加载进度的自动化脚本
这个脚本利用 utility.loading_progress 实时获取服务端的搬运进度。
import time
from pymilvus import connections, Collection, utility
def monitor_and_load(collection_name, timeout=600):
"""
监控 Milvus 集合加载进度
:param collection_name: 集合名称
:param timeout: 最大等待时间(秒)
"""
connections.connect(alias="default", host="localhost", port="19530")
col = Collection(collection_name)
print(f"🚀 开始加载集合: {collection_name} ...")
start_time = time.time()
# 触发异步加载
col.load()
while True:
# 获取加载进度
progress = utility.loading_progress(collection_name)
percent = progress.get("loading_percentage", "0%")
# 实时打印进度条效果
print(f"\r[进度]: {percent}", end="", flush=True)
if percent == "100%":
duration = time.time() - start_time
print(f"\n✅ 加载完成!耗时: {duration:.2f}s")
break
if time.time() - start_time > timeout:
print(f"\n❌ 加载超时,当前进度: {percent}。请检查服务端内存或网络。")
break
time.sleep(2) # 每 2 秒检查一次
# 运行示例
if __name__ == "__main__":
# 假设我们要加载之前设计的两个库
try:
monitor_and_load("code_logic_collection")
monitor_and_load("error_feature_collection")
print("\n🎉 纠错助手后端已准备就绪,可以开始搜索了!")
except Exception as e:
print(f"\n发生错误: {e}")
进阶:如何根据数据量预估内存?
在数据量变大时,你需要通过计算来预估 load 是否会失败。
内存计算公式:
对于一个向量字段,其占用的内存约为:
- 1.5 的系数:是为了给 索引(Index) 预留空间。
- 示例:1000 万条 768 维的代码向量:
💡 针对大数据量的三条实战建议:
- 分批次加载:如果你有多个项目,建议通过
partition_name将它们分开。只加载当前活跃项目的分区,这是最有效的性能优化手段。 - 监控 IOPS:在大数据
load期间,如果进度条长时间卡在某个百分比,通常是因为存储端(MinIO/S3)的磁盘读取 IOPS 达到了上限。 - 观察 Replica:如果搜索压力大,不要只加载 1 个副本。可以使用
col.load(replica_number=2)。这虽然会占用双倍内存,但搜索的吞吐量(QPS)也会翻倍。
既然监控脚本已经有了,需要我帮你写一个“模拟压力测试”的函数,看看你的 Milvus 在目前的配置下,检索一次 1000 维代码向量需要多少毫秒吗?
温馨提示:如要解锁所有应用的完整功能,请开启 Gemini 应用活动记录。
既然我们要进行压力测试(Stress Test),目标就是测出在不同并发压力下,Milvus 的响应延迟(Latency)和每秒查询数(QPS)。
这可以帮你决定在生产环境中是否需要增加内存、开启 MMap 或增加 Query Node 副本数。
1. 模拟压力测试脚本
这个脚本会生成随机向量并模拟连续搜索,计算平均耗时。
import time
import numpy as np
from pymilvus import Collection, connections
def run_stress_test(collection_name, num_queries=500, top_k=10):
"""
压力测试函数
:param collection_name: 集合名称
:param num_queries: 测试的总搜索次数
:param top_k: 每次搜索返回前几个结果
"""
connections.connect(alias="default", host="localhost", port="19530")
col = Collection(collection_name)
# 确保已经加载
col.load()
# 模拟生成测试用的搜索向量 (768维)
rng = np.random.default_rng(seed=123)
search_vectors = rng.standard_normal((num_queries, 768)).tolist()
print(f"📊 开始对 {collection_name} 进行压力测试...")
print(f"总查询量: {num_queries} | TopK: {top_k}")
search_params = {"metric_type": "COSINE", "params": {"ef": 64}}
latencies = []
start_all = time.time()
for i in range(num_queries):
start_each = time.time()
# 执行搜索
col.search(
data=[search_vectors[i]],
anns_field="code_vector",
param=search_params,
limit=top_k
)
end_each = time.time()
latencies.append((end_each - start_each) * 1000) # 转换为毫秒
total_time = time.time() - start_all
# 统计计算
avg_latency = np.mean(latencies)
p99_latency = np.percentile(latencies, 99)
qps = num_queries / total_time
print("\n--- 🏁 测试结果 ---")
print(f"平均响应耗时 (Avg): {avg_latency:.2f} ms")
print(f"99% 响应耗时 (P99): {p99_latency:.2f} ms")
print(f"每秒查询数 (QPS): {qps:.2f}")
print("------------------")
if __name__ == "__main__":
# 注意:运行前请确保 code_logic_collection 中已经有数据
try:
run_stress_test("code_logic_collection")
except Exception as e:
print(f"测试中断: {e}")
2. 如何分析测试结果?
在纠错助手的场景下,不同结果意味着不同的优化方向:
| 结果表现 | 诊断结果 | 建议对策 |
|---|---|---|
| 平均延迟极低,但 P99 极高 | 存在长尾效应,可能是由于个别 Segment 过大 | 调小 segment_size 或尝试合并小分片。 |
| QPS 远低于预期 | CPU 或是计算资源遇到瓶颈 | 增加 replica_number 或横向扩展 Query Node。 |
| 开启 MMap 后延迟翻倍 | 磁盘读取速度跟不上搜索速度 | 更换 SSD 或增加物理内存以减少 Page Fault。 |
3. 性能优化的“三个锦囊”
- 调整
ef参数:在搜索参数{"ef": 64}中,ef越大结果越准,但耗时越长。如果追求速度,可以减小到 16 或 32。 - 批量搜索 (Batch Search):
- 如果你有 10 条报错需要同时纠错,**千万不要循环调用 10 次
search**。 - 直接把 10 个向量塞进一个列表:
col.search(data=list_of_10_vectors, ...)。Milvus 会在服务端进行并行计算,QPS 会成倍提升。
- 索引类型选择:
- 如果数据量在百万级以下,HNSW 是首选。
- 如果数据量达到亿级且内存告急,考虑 IVF_PQ(量化压缩索引),虽然会损失一点点精度,但能节省大量内存。
现在测试脚本也有了,关于“纠错助手”的后端架构,你还有什么不确定的地方吗?比如如何整合 LLM(大模型)来根据检索出的结果生成最终答案?