news 2026/10/17 8:46:14

用Python从零搭建一个够用的向量库:从向量化到检索实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
用Python从零搭建一个够用的向量库:从向量化到检索实践

向量化这两年被 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-cpu
  • numpy:向量存储与矩阵运算的基础,没有它寸步难行。
  • 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 = sentences

vector_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 或者云服务商的向量检索产品。我做过的几次项目迁移里,花在“选型对比”上的时间,远没有花在“数据预处理和模型调优”上的时间多。

最后分享一个个人体会:向量化的效果模型说了算,工程上做得再花哨也弥补不了模型选错的损失。所以当你第一次跑通检索却发现结果乱七八糟,不要怀疑自己的代码,先换一个更合适、更大的预训练模型试试。很多时候,一句话的事,效果就完全不一样了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 8:46:13

JavaWeb项目从创建到部署:Tomcat、Servlet、MySQL与war包发布全攻略

带新人的时候我经常发现一个奇怪的现象:明明代码逻辑都写对了,数据库也建好了,但项目到了“跑起来”这一步就卡壳。点开运行按钮,浏览器一片404;或者Tomcat启动了,页面却报500;再不然就是项目能…

作者头像 李华
网站建设 2026/10/17 8:45:21

鸿蒙RN实战:自封装Modal实现底部抽屉及手势动画

做跨端开发的同行应该都有感触,近两年最绕不开的话题就是鸿蒙系统接入React Native。尤其当业务方忽然丢过来一句“我们的RN应用要上鸿蒙”的时候,第一个要面对的问题是:那些在iOS和Android上跑得好好的组件,在鸿蒙上到底还能不能…

作者头像 李华
网站建设 2026/10/17 8:45:20

无人直播零基础实操:木偶云系统拆解与数据复盘

很多人第一次听到“无人直播”这四个字,第一反应基本都是:还能这么玩?屏幕里放着视频素材,人不用出镜,直播间依然有人看、有人问、甚至有人下单。我之前也是这么想的,直到自己用木偶云系统跑了好几场真实直…

作者头像 李华
网站建设 2026/10/17 8:45:19

客户端性能测试

一.什么是客户端性能测试 关于客户端性能测试,没有一个严格统一的定义。这里仅根据个人的理解来进行定义。 客户端性能测试是通过模拟用户在客户端实际使用过程中的各种操作场景;对应用程序在运行过程中的响应速度、资源消耗、运行稳定性等方面进行测试…

作者头像 李华
网站建设 2026/10/17 8:44:33

OpenCV实现极验滑动拼图识别的完整工程方案

1. 为什么极验滑动拼图是OpenCV实战的“试金石”我第一次在某电商后台系统里看到极验滑动验证码时,下意识点开开发者工具——不是为了绕过,而是被它背后那套视觉逻辑勾住了。它不像传统数字验证码那样靠OCR就能硬刚,而是把“人眼识别连续性”…

作者头像 李华
网站建设 2026/10/17 8:44:32

C#闭包陷阱详解:foreach已修复,for循环仍需警惕

闭包陷阱这四个字,凡是写过几年 C# 的人应该都不陌生。我第一次真正被它咬到,是在一次批量绑定按钮点击事件的时候:循环里写了十几行绑定代码,运行起来却发现每个按钮的回调拿到的都是最后一个数据。当时的我还以为是事件机制出了…

作者头像 李华