向量化这两年被 RAG、语义搜索、AI 应用反复带火,但很多人一上来就奔着“向量数据库”去了,装个 Milvus 或者 Qdrant,连数据都还没整理明白,最后发现自己只是想要一个能跑起来的小型向量库。我自己折腾过不少类似的场景:做植物识别笔记的相似检索、给公司内部文档做语义去重、给冷门商品做“换句话也能搜到”的搜索入口,真正花时间的从来不是数据库选型,而是先把文本、图片、甚至音频变成一组有意义的数字,也就是向量化这一步。
这篇文章我会用 Python 从零搭一个“够用”的向量库。注意这个说法:我刻意说“向量库”,不是“向量数据库”。两者的差别后面会讲,但核心思路是先把数据向量化、存储、检索这条链路跑通。你只需要有 Python 基础,看得懂列表和字典,就能跟着读完、跑起来。等你能用自己的数据构建出第一个能检索的向量库,再去看各种重型方案,会发现它们本质上也只是帮你把“存向量”和“算相似度”这两件事做得更精致而已。
1. 先搞明白:你想解决的到底是什么问题
1.1 语义搜索离我们并不远
我刚开始接触向量化的时候,最大的误区是把它当成一种“高级搜索技术”。实际上,向量化最朴素的动机,是解决关键词匹配解决不了的问题。
举个我实际遇到过的例子。当时我在整理一套烹饪笔记,里面有条笔记写的“土豆切丝泡水去淀粉”,另一条写“红烧肉一定要煸到表面微焦”,我想搜“处理土豆”相关的内容,关键词搜法只能匹配到包含“土豆”字样的句子,那条“把切好的马铃薯泡凉水”就漏掉了。后来换成向量检索,问题立刻简单了:模型把“土豆”和“马铃薯”映射到语义空间里相近的位置,搜“土豆处理”也能召回到“马铃薯泡水”这条记录。
向量化的本质就是把一段文字、一张图片或者任意对象,转换成一个固定长度的数字列表。比如[0.12, -0.34, 0.56, ...],这个列表就是“向量”。两个向量在多维空间里离得越近,语义就越接近。所谓“向量库”,就是专门存放这些向量的容器,负责让你可以快速回答“哪些向量和这个向量最接近”这个问题。
1.2 向量库与向量数据库别搞混
这是我在各种技术社区里看人吵得最多的地方。很多人把“向量库”直接等同于 Milvus、Chroma、Weaviate 那一类向量数据库,实际上它们是不同层级的东西。
向量库的概念更底层,它只关心向量集合的存储和相似度计算。在 Python 里,你用numpy数组存一堆向量,写一个函数返回最近邻的 Top-K 结果,这已经是一个向量库了。向量数据库则是一个完整的系统,它管理数据的增删改查、事务、持久化、分片、副本、备份,以及索引结构(比如 HNSW、IVF),通常以独立服务的方式部署。
用个生活化的类比:向量库像是你手里的一摞卡片,向量数据库则是一套带检索目录、防火保险柜和借阅登记制度的图书馆。这篇文章先带你整理好“卡片”,等你发现自己需要管理几百万张卡片的时候,再去考虑要不要升级成“图书馆”。
1.3 为什么用 Python 搭第一个库是合理的
有人可能会问,既然市面上有现成的库和数据库,为什么还要自己搭?我的观点是:搭第一个向量库的过程,是你理解所有上层工具的最好途径。
当你自己写一遍嵌入生成、距离计算、Top-K 排序这些步骤,再去看 FAISS 的官方文档,就能立刻读懂它为什么要求你先归一化、为什么默认用内积而不是余弦相似度、为什么索引类型和查询模式有关。没有这个基础,直接上手 Milvus 会感觉处处“莫名其妙”,出了问题也不知道该查哪个方向。
Python 恰好是适合干这个事的语言,它的科学计算生态太成熟了。numpy帮你高效做矩阵运算,sentence-transformers几行就能加载嵌入模型,faiss-cpu随时能无缝替换手写检索逻辑。我用它做完第一个向量库之后,最大的感受是:原来那些“高大上”的架构,拆开来看就是“存一堆矩阵 + 做点矩阵运算”。
2. 前置准备与嵌入模型的选择
2.1 最小依赖清单
在动手之前,先把环境和依赖搞定。我建议你新建一个独立的虚拟环境,避免跟系统 Python 或者其它项目的依赖打架。如果你平时用 VS Code,这一步可以直接在终端里操作。
python -m venv .vector_env source .vector_env/bin/activate # Windows 下用 .vector_env\Scripts\activate然后安装四个库,这是我在多种场景下验证过的组合,足够覆盖本文所有示例:
pip install numpy sentence-transformers scikit-learn faiss-cpunumpy:向量存储与矩阵运算的基础,没有它寸步难行。sentence-transformers:加载预训练嵌入模型,读取文本并生成向量。这是目前 Python 生态里最顺手的嵌入工具之一。scikit-learn:我们用它的cosine_similarity、KMeans等工具做验证和聚类,比自己手写更稳。faiss-cpu:Facebook 开源的相似度检索库,到文章后半部分处理大规模向量时用,CPU 版足够学习。
如果你对 torch 的安装有顾虑,sentence-transformers会自动拉取对应版本。初次下载模型权重会花点时间,后面的过程就顺畅了。
2.2 模型从哪来,选哪一个
embedding 模型的选择决定了向量化的效果,这是我反复强调的一点:同样的代码,换一个模型,检索效果可能天差地别。模型是把“文字”映射成“数字”的核心,不同的模型有不同的擅长领域和维度。
我最早用的是all-MiniLM-L6-v2,一个体积很小、速度很快的英文模型,向量维度是 384 维。如果只处理中文,我会更推荐BAAI/bge-small-zh-v1.5,它对中文语义的理解明显更细腻,维度是 512 维。BGE 系列是北京智源发布的中文嵌入模型,在中文社区里测试反馈普遍不错。
下载和使用模型很简单:
from sentence_transformers import SentenceTransformer # 英文场景 model = SentenceTransformer("sentence-transformers/all-MiniLM-L6-v2") # 中文场景,只需要换一行 # model = SentenceTransformer("BAAI/bge-small-zh-v1.5")有一点容易踩坑:模型默认从 Hugging Face Hub 下载,国内网络经常超时。我的做法是先用huggingface-cli或者浏览器镜像站,把模型文件手动下载到本地目录,然后加载本地路径:
model = SentenceTransformer("/my/models/bge-small-zh-v1.5")加载本地路径时,目录里需要包含config.json、pytorch_model.bin(或model.safetensors)、tokenizer.json等完整的模型文件。如果你不确定文件是否齐全,最简单的方式是先用一行代码触发自动下载,它下载完成后缓存到本地,之后加载就不需要联网了。
2.3 多模态向量化与 SigLIP2 这类模型
“向量化”这个词不限于文本。热搜里经常出现siglip2向量化这种说法,我在这里顺手提一下。SigLIP2 是谷歌开源的多模态模型,可以把图片和文本对齐到同一个向量空间。你用一张猫的图片得到一组向量,用“a cat sitting on a chair”这段文字也能得到一组向量,这两个向量在空间里距离会很近,这就实现了“图文互搜”。
搭多模态向量库的思路其实一样,只是把“嵌入模型”从文本模型换成多模态模型罢了。代码层面没有本质差异,都是把对象经模型转换为向量,然后存进向量库。唯一的区别是输入预处理:文本要tokenizer,图片要processor转成模型能接受的张量格式。我实测下来,SigLIP2 对图片的理解比早期的 CLIP 更细,但模型体积和推理耗时也更大,做 demo 时先拿小模型试跑会舒服很多。
3. 自己动手把“库”建起来
3.1 数据准备与第一条嵌入
我建议用一个真实但不复杂的小数据集来练手。下面这个例子,我给几段产品描述加上编号,再让模型把它们变成向量:
sentences = [ "无线蓝牙耳机,支持主动降噪", "头戴式游戏耳机,带麦克风", "真无线耳机,防水适合运动", "机械键盘,青轴手感清脆", "静音鼠标,适合办公", ]然后生成向量:
from sentence_transformers import SentenceTransformer model = SentenceTransformer("BAAI/bge-small-zh-v1.5") embeddings = model.encode(sentences, normalize_embeddings=True) print(embeddings.shape) # (5, 512)normalize_embeddings=True这个参数我建议每次都加上,它会把向量长度归一化成 1。后面算相似度的时候你会发现,归一化之后的向量之间,内积等于余弦相似度,省去一整套额外的数学公式,也让不同模型产出的向量在数值范围上更一致。
3.2 向量化存储结构的设计
向量库不是把向量丢进一个 list 就完事,你需要同时保存原始文本和向量的对应关系,否则检索到相似向量后,你根本不知道它对应哪条记录。
我习惯用两个核心数据结构:
import numpy as np # 向量矩阵,每一行是一个样本的向量 vector_matrix = np.array(embeddings) # 元信息列表,与向量矩阵的行一一对应 meta_list = sentencesvector_matrix[i]和meta_list[i]永远保持位置对应。这是最朴素也最不容易出错的方案。假如你要存图片,meta_list里放图片路径就行;要存文档,里面放文档 ID 和标题。
如果数据本身有主键,我通常会把它也塞进元信息里:
meta_list = [ {"id": "p001", "content": "无线蓝牙耳机,支持主动降噪", "price": 199}, {"id": "p002", "content": "头戴式游戏耳机,带麦克风", "price": 399}, # ... ]这样,当向量库返回一个索引idx,你可以通过meta_list[idx]找回完整信息。这个“索引对齐”的思路会在后续所有代码里反复出现。
3.3 验证向量库的基本指标
建好库之后,先别急着写检索逻辑,验证一下向量库的质量更重要。
先看自相似度。让向量库返回每条数据与所有数据的相似度矩阵,对角线上的值应该接近 1(经过归一化的话是 1.0)。
from sklearn.metrics.pairwise import cosine_similarity sim_matrix = cosine_similarity(vector_matrix) print(sim_matrix)看到sim_matrix[0][0]接近 1,说明向量本身没问题。再看不同句子之间的相似度,耳机相关的句子之间应该比耳机和机械键盘之间的相似度更高。这是一个非常粗糙但有效的“向量质量嗅觉测试”。
这一步能帮你发现很多问题,比如模型是否加载错语言版本,文本是否被倒序截断,或者normalize_embeddings是否漏掉。我见过不少人直接跳到检索,发现结果很离谱,回头查了半天,最后发现只是模型选错了。
4. 距离计算:向量检索的心脏
4.1 三种常见距离到底选哪个
向量建好了,检索的本质就是算距离。三种距离最常被提到:欧氏距离(L2)、曼哈顿距离(L1)、余弦相似度。
- 欧氏距离:向量在高维空间里的直线距离,数值越小越相似。
- 曼哈顿距离:各维度差的绝对值之和,受维度值差异影响大。
- 余弦相似度:只看两个向量的方向差异,数值越接近 1 越相似。
在做语义检索时,我绝大多数场景用余弦相似度,因为它对向量“长度”不敏感。举个直观例子:一篇短标题和一个长描述想表达同一个意思,它们的向量可能长度差异很大,但方向接近。余弦相似度依然能识别出它们语义相近,欧氏距离反而会因为长度差异给出一个偏大的“距离”。这就是为什么很多 embedding 模型在发布时就强调应该用余弦相似度。
用 sklearn 一行就能算两两相似度:
from sklearn.metrics.pairwise import cosine_similarity query_vec = model.encode(["降噪耳机适合通勤"], normalize_embeddings=True) sims = cosine_similarity(query_vec, vector_matrix)[0] # 形状 (1, n) 变为 (n,) print(sims)输出会是一个列表,对应每一条记录的相似度分数。
4.2 归一化为何是分水岭
我在前面的代码里反复加了normalize_embeddings=True,这里是关键原因。
对一个向量做 L2 归一化,本质是把它的模长变成 1。经过归一化之后,两个向量的余弦相似度可以直接用向量内积等价计算:
cosine_similarity(a, b) = a · b (当 ||a|| = ||b|| = 1 时)这个性质有多重要?我在小型向量库里可能感觉不明显,但一旦数据量上万,用矩阵内积代替逐对计算,速度会快一个数量级。cosine_similarity内部其实也做了归一化,但如果你直接对归一化向量做np.dot,性能更高。
下面是高效检索的核心代码,查询向量和检索库都归一化之后,全部数据只需要一次矩阵乘法:
def top_k_by_dot(query_vec, vector_matrix, meta_list, k=3): # query_vec: 已归一化,形状 (1, dim) scores = np.dot(vector_matrix, query_vec.T).flatten() top_idx = np.argsort(scores)[::-1][:k] return [(int(i), scores[i], meta_list[i]) for i in top_idx] query_vec = model.encode(["降噪耳机适合通勤"], normalize_embeddings=True) results = top_k_by_dot(query_vec, vector_matrix, meta_list, k=3) for idx, score, meta in results: print(score, meta)这里np.argsort从小到大排序,加[::-1]变成从大到小,再取前 k 个,就是 Top-K。整个过程没有任何循环,全部交给 numpy 底层的 C 实现,速度非常快。
4.3 从零实现 top-k 检索
如果你还是想“看穿”矩阵乘法背后的原理,我可以拆得更细一点。手动计算余弦相似度的公式是:
def cosine_similarity_manual(a, b): dot = sum(x * y for x, y in zip(a, b)) norm_a = sum(x * x for x in a) ** 0.5 norm_b = sum(y * y for y in b) ** 0.5 return dot / (norm_a * norm_b)这个版本清晰展示了余弦相似度的定义:两个向量点积,再除以各自的模长。但纯 Python 循环跑 10 万条数据时会慢到怀疑人生。numpy加速的原理是牺牲可读性换取向量化运算——顺便说一句,这里“向量化”指的是 numpy 层面的批量数组操作,跟“向量化 embedding”是两个层面的概念,但底层思想都是“批量代替循环”。
我把两种实现放在一起对比,不是为了让你手写公式,而是为了让你理解为什么np.dot能替代手写循环:numpy 把循环下沉到了编译好的 C 代码里,所以几百万次浮点乘加只在极短的时间内完成。这就是向量库从“能用”变成“好用”的起点。
5. 从玩具到可用:性能、持久化与避坑
5.1 用 numpy 批量计算带来的性能飞越
小数据量上,np.dot和循环看不出区别。我把数据量放大到 5 万条之后,情况完全不同。
假设你现在有 5 万条文本向量,维度 512。用纯 Python 循环计算每个查询与 5 万条向量的余弦相似度,至少几百毫秒,用户体验已经明显卡顿。而用np.dot,一次矩阵乘法在普通笔记本上只需要十几到几十毫秒。如果再加上faiss,它的 CPU 索引能把查询压到几毫秒以内。
import faiss # 把向量矩阵转换为 float32,faiss 的要求 dim = vector_matrix.shape[1] index = faiss.IndexFlatIP(dim) # Inner Product,归一化后等价于余弦相似度 index.add(vector_matrix.astype(np.float32)) # 查询 query_vec_np = query_vec.astype(np.float32) scores, indices = index.search(query_vec_np, k=3) print(indices) print(scores)IndexFlatIP是暴力检索索引,它内部做了高度优化的矩阵运算。当数据量几万级别时,它已经比手写 numpy 快不少;当数据量到百万级,就需要换 HNSW、IVF 这类近似最近邻索引了。
5.2 切到 FAISS 前要知道的边界
FAISS 虽然强大,但也有几条明显的边界,提前知道能少踩坑。
第一,FAISS 不支持动态增删改查中的“改”。你添加向量之后,如果某条数据变了,通常的操作是把这条向量删掉再重新添加。IndexFlatIP这类索引支持remove_ids,但必须先给每条向量设置独立的 ID,否则删除操作会很麻烦。
第二,FAISS 默认处理float32数据。你如果用float64喂进去,它会直接报类型错误。我建议从最开始就把向量矩阵统一存成float32,省得后来还要转换。
第三,FAISS 本身不负责管理“原始文本”。它只管向量和 ID 的映射。ID 到内容的对应关系,仍然需要你自己维护。在工程上,这正好对应你前面设计的meta_list的职责。
# 维护 ID 映射的例子 index = faiss.IndexFlatIP(dim) faiss_ids = np.arange(len(vector_matrix)) index.add_with_ids(vector_matrix.astype(np.float32), faiss_ids)5.3 持久化只是开始
内存里的向量库,机器一关就没了。如果你想要一个能长期使用的工具,持久化是必须考虑的问题。最简单的做法是把向量矩阵和元信息分别保存成文件。
np.save("vector_matrix.npy", vector_matrix) import json with open("meta_list.json", "w", encoding="utf-8") as f: json.dump(meta_list, f, ensure_ascii=False, indent=2)重新启动时加载回来:
vector_matrix = np.load("vector_matrix.npy") meta_list = json.load(open("meta_list.json", "r", encoding="utf-8"))这个方法完全可以用在日常小工具里。比如我给自己笔记做检索时,就是启动时加载一次,之后所有查询都在内存里秒回。直到数据量增长到几十万条开始觉得内存吃紧,或者需要多人并发访问,再转向 Chroma、Milvus 这类真正的向量数据库也不迟。
6. 向量库不只是搜索:去重、聚类与推荐
6.1 文本/图片去重的一个可复现例子
向量库最常见的两个用途是搜索和 RAG,但去重同样是很实际的需求。文本去重的思路是:把所有文本向量化,两两计算相似度,相似度高于某个阈值的两条记录保留一条。
sim_matrix = cosine_similarity(vector_matrix) # 简单去重:如果两条内容相似度超过 0.9,保留先出现的那条 threshold = 0.9 duplicate_groups = [] for i in range(len(sentences)): for j in range(i + 1, len(sentences)): if sim_matrix[i][j] > threshold: duplicate_groups.append((sentences[i], sentences[j]))真实场景下,阈值需要根据你的数据反复调。我用产品描述做过一次去重,阈值 0.85 会把“不同规格同款商品”误杀成重复,阈值 0.95 又会漏掉大量近似重复的描述。后来我的策略是先聚类,再在聚类结果内部观察相似度分布,找到每个簇的“断裂点”再定阈值。这个方法比拍脑袋选阈值靠谱得多。
6.2 聚类的实用套路
聚类是向量库的另一个自然应用方向。KMeans可以直接跑在向量矩阵上:
from sklearn.cluster import KMeans kmeans = KMeans(n_clusters=3, random_state=42, n_init="auto") labels = kmeans.fit_predict(vector_matrix) for i, label in enumerate(labels): print(f"第{i}条 -> 簇{label}")聚类可以帮你自动整理混乱的数据集。比如我把一批杂乱的商品标题聚类之后,发现簇 0 全是耳机相关,簇 1 全是鼠标键盘,簇 2 是音箱设备。一个原本需要人工标注的分类任务,通过向量化 + 聚类几分钟就有了雏形。
为什么聚类好使?因为 embedding 模型本身就倾向于把语义相近的对象映射到空间中接近的位置,KMeans 恰恰是在空间里找“抱团”的点。这两者配合得相当自然。
6.3 接下来能扩展成什么
当你手里有了一个稳定的向量库,往上扩展的方向就变得很清晰了。
- RAG(检索增强生成):把知识库文档切块、向量化、存库,回答问题时先检索最相关的几个片段,再交给大模型生成回答。
- 推荐系统:把用户行为序列向量化,找历史上相似用户的行为,或者给当前用户推荐“最相似的物品”。
- 异常检测:正常样本的向量会在空间里聚成一团,离这团太远的样本往往就是异常样本。
- 多模态搜索:把图片、文本、音视频统一嵌入,实现跨模态检索,这也是 SigLIP2 等模型的用武之地。
我自己的项目就是从一个电子书笔记检索工具开始的,后来不断给它加东西:把图片笔记里的文字抠出来一起向量化,把收藏的网页正文也塞进去,最终变成了一个私人的“第二大脑”雏形。整个过程中向量库始终是底座,上面爬什么应用都行。
如果你打算继续向生产环境走,我的建议是:先别急着买服务器部署重型数据库。用本文的方式把数据量测试到位,确认模型效果和查询延迟符合预期,再考虑迁移到 FAISS、Chroma 或者云服务商的向量检索产品。我做过的几次项目迁移里,花在“选型对比”上的时间,远没有花在“数据预处理和模型调优”上的时间多。
最后分享一个个人体会:向量化的效果模型说了算,工程上做得再花哨也弥补不了模型选错的损失。所以当你第一次跑通检索却发现结果乱七八糟,不要怀疑自己的代码,先换一个更合适、更大的预训练模型试试。很多时候,一句话的事,效果就完全不一样了。