向量数据库:FAISS 与 Pinecone

向量数据库是 RAG 流水线里的存储层:把 embedding 之后的向量存起来,支持按相似度找出最近的若干条。

它接在 RAG 的 Indexing 和 Retrieval 之间——文档切块、向量化之后存进来;用户提问时转成向量,从这里捞出最近的几个块。

有一个区分要先说,因为它解释了后面几乎所有行为差异:

Faiss 是「库」,不是「数据库」。

这不是抠字眼。库和数据库能做的事差得很远,而你踩的坑基本都在这个差别里。

1. 先分清「库」和「数据库」

库(library) 数据库(database / server)
形态 进程内的一个数据结构 独立进程,或托管服务
访问方式 函数调用 网络 API
元数据 没有(只管向量) 有自己的元数据存储
命名空间 / 权限 / 备份 没有 有
例子 Faiss、Annoy、hnswlib Milvus、Weaviate、Pinecone、Qdrant

Faiss 里没有「记录」这个概念——它只有向量和一个 id。

这句话能推出三个你可能已经遇到过的问题:

一、为什么 Faiss 原生不支持过滤? 因为 Faiss 里根本没有元数据这个东西。原文、页码、作者这些,是你(或 LangChain)在它外面另外存的一份。

二、为什么 Faiss 的过滤要「先多取再筛」? 过滤发生在 Faiss 之外。所以要先把更多的候选从 Faiss 里取出来,再在外面筛。

三、为什么 Pinecone 的过滤是原生的? 因为元数据是它自己索引的一部分,可以在检索的同一趟里做。

按这个标准重新看常见的那个三分法:

分类 例子 说明
本地文件 Faiss 就是「库」——数据落在本地文件,靠函数调用检索
本地部署 + API Milvus、Weaviate 是真正的数据库,只是可以自己部署
云端托管 API Pinecone、腾讯云 VectorDB 数据库,但运维交给别人

这个划分里有一个放错位置的:Annoy 不是「本地部署 API 向量数据库」。

Annoy(Approximate Nearest Neighbors Oh Yeah)是 Spotify 开源的库——跟 Faiss 同一类。它把索引建成文件、建好之后只读,没有服务进程、没有网络 API。所以它该待在第一行。

另外这个三分法的边界也在变模糊:Milvus Lite 让 Milvus 能当本地库跑;反过来,Faiss 也有第三方把它包装成服务的。所以分类是帮你建立直觉的,不是严格的。

各自的特点

特点 优势 局限
Faiss Meta(原 Facebook AI Research)开源,专注高性能相似性搜索,适合大规模静态数据集 检索速度快,支持多种索引类型(IVF / HNSW / PQ……) 偏静态:更新和删除都麻烦,没有元数据、没有服务
Milvus 开源,支持分布式架构和动态数据更新 扩展性强,数据管理功能灵活 要自己运维(或另找托管版)
Pinecone 托管的云原生向量数据库,支持高性能向量搜索 完全托管,部署简单,适合大规模生产环境 数据要出网,按量付费;本地调试不方便

「静态」还是「动态」是这里最实质的一条分界线:Faiss 假设你建一次库、然后一直查;Milvus 和 Pinecone 假设数据一直在变。

还会听到的几个

一句话
Chroma 最轻的一个,能跑在进程里、也能起服务;LangChain 教程里默认用的就是它
Qdrant Rust 写的,自托管友好,带原生过滤
pgvector PostgreSQL 的扩展——如果你的数据本来就在 PG 里,加它比引入一个新数据库划算得多

最后那个值得单独记一笔:很多时候你不需要一个新的数据库,只需要给已有的数据库加一个扩展。

上手路径

有个很好用的比喻:把向量数据库当成一个 Excel 电子表格。 按这个顺序学:

1
安装 → 写入数据 → 查找数据 → 删除数据 → 更新数据 → 保存数据

它比 SQL 数据库好学,因为功能集小得多——没有 JOIN、没有子查询、没有复杂的聚合,甚至没有事务。

但清单里那个「更新数据」要打个星号:按下一节讲的,向量数据库基本只支持「删除后重新新增」。这一步得拆成两步做。

2. 一个共同的设计:不会改,只会删了重加

向量数据库普遍支持这些操作:

新增数据、检索数据、带得分检索、带条件筛选检索、删除数据。

但几乎都不支持直接修改数据。想改一条,做法是:删除后重新新增。

这不是偷懒,是索引结构决定的。

向量检索靠的是近似最近邻(ANN)索引——HNSW 的一张图、IVF 的一组聚类中心、PQ 的一套量化码本。这些东西都是建索引的时候算出来的:

  • 插入一个新点很便宜:HNSW 只要给它连几条边,IVF 只要归到最近的簇
  • 修改一个已有点的位置很贵:HNSW 里它周围的边要重连(删一个点会让图局部不连通),IVF 里它可能该换簇了

也就是说,改一个点的代价跟重建一小片索引差不多。如果允许频繁修改,维护索引的开销会盖过检索本身带来的收益。

顺带一个观察:这也解释了为什么大多数向量数据库不提供传统意义上跨多行的多语句事务。它的写入模型天然就是「追加 + 删除标记」,没有「改一批行、要么全成要么全滚」这个场景。

3. LangChain 里用 Faiss:写入、检索、删除、持久化

装

1
pip install -U langchain-community faiss-cpu langchain-openai tiktoken

Faiss 本体是 C++ 写的,Python 只是一层接口,所以要装编译好的轮子:

1
2
3
pip install faiss-cpu          # CPU

pip install faiss-gpu # GPU(需要先装好 CUDA)

它支持 Linux / macOS / Windows。官方给出的数据是:百万级向量的相似性检索可以做到 <10ms——代价是搜的是近似结果,不是精确结果(这是所有 ANN 索引共同的交换)。

写入

LangChain 提供了两个通用入口,from_texts 和 from_documents。因为向量库存的是向量,所以必须把嵌入模型传进去,让它把文本转成向量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import dotenv
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings

dotenv.load_dotenv()

embedding = OpenAIEmbeddings(model="text-embedding-3-small")

db = FAISS.from_texts([
"笨笨是一只很喜欢睡觉的猫咪",
"我喜欢在夜晚听音乐,这让我感到放松。",
"猫咪在窗台上打盹,看起来非常可爱。",
# ...
], embedding)

print(db.index.ntotal) # 库里现在有多少条向量

db.index.ntotal 是 Faiss 自己的属性——index 就是那个底层的 Faiss 索引对象。从 LangChain 的封装里伸手拿到它,后面删除和计数都会用到。

核心流程就一步:

1
文本 → Embedding → 向量 → FAISS Index

检索

1
print(db.similarity_search("我养了一只猫,叫笨笨"))

4. 相关性评分:那个 √2 是怎么来的

这一节值得慢慢看,因为「莫名其妙的计算结果」基本都是在这里出的。

LangChain 有两个看起来很像的检索方法,返回的东西完全不同:

方法 返回 分数的含义
similarity_search_with_score 原始距离 越小越相关
similarity_search_with_relevance_scores 相关性分数 越大越相关

第二个方法向你承诺的是一个「分数」,也就意味着它内部做了一次转换。而转换函数取决于两件事:用的哪种距离度量,以及向量有没有归一化。

LangChain 内置了三种(欧氏 / 余弦 / 内积),按距离策略自动挑。欧氏那个是:

1
2
3
4
@staticmethod
def _euclidean_relevance_score_fn(distance: float) -> float:
"""Return a similarity score on a scale [0, 1]."""
return 1.0 - distance / math.sqrt(2)

为什么除的是 √2? 因为它假设向量是单位长度(归一化过)的。归一化之后,L2 距离和余弦相似度之间有一个固定的换算关系:

1
distance = √(2 − 2·cos)

代进去看边界:

两个向量 cos distance 分数
完全一样 1 0 1.0
毫不相关 0 √2 0.0
完全相反 −1 2 负数

所以 √2 就是「归一化向量在欧氏距离下的最大值」。 除掉它,刚好把 [0, √2] 映射到 [0, 1]——这就是它为什么可以叫「相关性分数」,而不只是一个距离。

这一切成立的前提是向量归一化了。OpenAI 的 embedding 是单位长度的,所以对得上。如果换一个不归一化的 embedding 模型,这个除 √2 就没有意义——你会拿到负数,或者大于 1 的分。

笔记里那个 1/(1+d) 是另一种走法

1
relevance_score_fn=lambda distance: 1.0 / (1.0 + distance)

它跟 1 − d/√2 的区别在于:它不需要知道距离的上界。 d ≥ 0,所以分数永远落在 (0, 1],单调递减,不会为负。

笔记里那句直觉是对的——「在 N 维向量空间下,两个点最大距离会随着维数变化」。N 维空间里 L2 距离没有固定上界(距离随维数增长,这就是维数灾难的一个侧面),所以你不能简单地写 1 − d / 最大距离,因为那个「最大距离」事先算不出来。

两条路其实在解决同一件事,只是解法不同:

  • 1 − d/√2 靠「假设归一化」把上界消掉了 —— 归一化之后最大值就是 √2
  • 1/(1+d) 靠「用一个有界函数」把上界绕过去了 —— 反正函数本身压在 (0, 1]

但两者的刻度是不一样的。 1/(1+d) 排序是对的、范围是安全的,可它不等于余弦相似度,它只是另一个刻度。

那个「Important」警告,实例化一下

笔记里提醒:「一定要注意测试和校验文本嵌入模型生成向量的数值范围,避免出现明显错误」,以及 LangChain 封装了大量第三方组件、可能没针对每种情况测过。

这不是空话,有一个可以直接拿来讲的真实案例:

1
2
3
4
5
LangChain 0.3 之前    similarity_search_with_relevance_scores 返回原始距离
→ 越小越相关,范围约 [0, 2]

LangChain 0.3 之后 改成 1 − d/√2
→ 越大越相关,最高就是 1.0

于是:按旧版调的 SCORE_THRESHOLD = 2.0,升级之后一条都检索不出来。 因为新分数最高才 1.0,永远到不了 2.0——它不会报错,只会返回空结果,附带一句「没有文档达到阈值」。

这就是「莫名其妙的计算结果」长什么样。

所以那句提醒应该再往前走一步:

把评分函数和阈值绑在一起测,别分开调。

抄来的阈值(比如 0.7)换一个评分函数、换一个 embedding 模型、甚至换一个 LangChain 版本,含义就变了。分数能用来排序,但不能跨配置比较。

5. 带过滤的检索:Faiss 要变通,Pinecone 原生

元数据

大部分向量数据库除了存向量,还能存对应的元数据:原文、扩展信息、页码、归属文档 ID、作者、创建时间……所以一条记录是:

1
记录 = 向量(vector) + 元数据(metadata) + id

召回之后过滤(按文档、按时间、按权限)靠的就是它。

Faiss:先多取,再筛

Faiss 原生并不支持过滤——§1 说过原因,它只存向量。所以 LangChain 的封装在外面补了一层:

1
2
3
4
5
先取 fetch_k 个结果(默认 20)
↓
按 metadata 过滤
↓
取 k 条结果
1
2
3
4
5
6
7
8
9
10
11
12
13
db = FAISS.from_texts(
texts,
embedding,
metadatas,
relevance_score_fn=lambda distance: 1.0 / (1.0 + distance),
)

print(
db.similarity_search_with_relevance_scores(
query="我养了一只猫,叫笨笨",
filter=lambda x: x["page"] > 5
)
)

filter 可以传两种东西:元数据字典,或者函数——函数的参数是 metadata 字典,返回布尔值。

但这里有个语义陷阱,比代码本身重要得多。

filter 只作用于已经召回的 fetch_k 条,不是在全库上筛。

也就是说,上面这段的语义不是「在所有 page > 5 的文档里检索」,而是:

先检索出 20 条,再从这 20 条里挑出 page > 5 的。

如果 page > 5 的文档在库里占比很低,这 20 条里可能一条都不满足,你会拿到空结果——而全库里明明有大量符合条件的数据。

推论:用 Faiss 做过滤时,fetch_k 要按筛选比例放大。 你觉得符合条件的大概占 10%,那 fetch_k 至少得是 k 的十倍——还不够保险,所以更要紧的是先测一遍「筛完之后还剩几条」。

这是「库 vs 数据库」那个差别最实际的后果:在 Pinecone 里过滤是一等公民,在 Faiss 里它是事后补救。

Pinecone:原生支持

Pinecone 的过滤器就是一个 JSON:

1
2
3
4
resp = db.similarity_search_with_relevance_scores(
"我养了一只猫,叫笨笨",
filter={"page": {"$lte": 5}} # less than or equal,小于等于 5
)

比较操作符:$eq $ne $gt $gte $lt $lte $in $nin $exists,逻辑操作符 $and $or $not,可以嵌套。

几条容易踩的规则:

  • 顶层只能放字段名或逻辑操作符。 多个字段默认隐式 AND;但你不能把一个裸的 $gt 放在顶层,它必须藏在字段下面
  • 类型严格。 字符串 "2024" 不匹配数字 2024
  • $in / $nin 最多 10000 个值,超了直接失败
  • 否定查询更慢。 $ne、$nin 这种「排除法」比正向查询代价高——尽量用正向条件表达
1
{"genre": "action", "year": 2020, "length_hrs": 1.5}
1
{"color": "blue", "price": 29.99, "is_jeans": true}

6. Pinecone:托管数据库长什么样

Pinecone 是托管的云原生向量数据库,极简的 API,不用本地部署,每个账户还有一定免费额度,开发阶段可以直接上手。

它和 Faiss 的架构差别很大,层级是:

1
2
3
4
5
6
7
8
9
Organization
↓
Project
↓
Index
↓
Namespace
↓
Record

Namespace 是这里最值得理解的一层——一个 Index 下可以挂多个:

1
2
3
4
Index1
├─ Namespace1 Record1 / Record2 / Record3
├─ Namespace2 Record1 / Record2 / Record3
└─ Namespace3 Record1 / Record2 / Record3

它提供的是逻辑隔离:不同数据集、不同租户、不同环境的向量放不同 namespace,检索时指定一个就行,互不干扰。相比开多个 Index,namespace 成本低得多(Index 是重资源)。

1
2
3
4
5
6
7
8
9
10
11
from langchain_pinecone import PineconeVectorStore

db = PineconeVectorStore(
index_name="llmops",
embedding=embedding,
namespace="dataset",
)

db.add_texts(texts, metadatas, namespace="dataset")

print(db.similarity_search_with_relevance_scores("我养了一只猫,叫笨笨"))

注意 API 的形状和 Faiss 不一样:Faiss 是 from_texts 建库,Pinecone 是先连上一个已经存在的 Index,再 add_texts 往里写。 因为一个是本地对象,一个是远端资源——又是「库 vs 数据库」的差别。

7. 删除和持久化

删除:只能按 id

Faiss 目前只支持传 id 删除,不支持条件筛选。 想按条件删:

1
条件搜索 → 找到符合条件的数据 → 拿到 ID → 按 ID 删除
1
2
3
4
5
6
print("删除前数量:", db.index.ntotal)

# 获取向量数据库的索引 id
db.delete([db.index_to_docstore_id[0]])

print("删除后数量:", db.index.ntotal)
1
2
删除前数量:10
删除后数量:9

index_to_docstore_id 是一张映射表,把 Faiss 内部的向量 id 对应到 LangChain docstore 里的文档 id。delete() 要的是后者,所以你得从这张表里取。

为什么不支持条件删除? 还是那个原因——条件在元数据上,而 Faiss 不知道元数据。条件筛选这件事,库里没有。

持久化:save_local / load_local

Faiss 是本地文件向量数据库,整个库可以落到本地,也能从本地加载,省掉每次重新建库:

1
db.save_local("faiss_index")
1
2
3
4
5
6
7
new_db = FAISS.load_local(
"faiss_index",
embeddings,
allow_dangerous_deserialization=True,
)

docs = new_db.similarity_search("我养了一只猫,叫笨笨")

那个 allow_dangerous_deserialization=True 不是可选装饰。 新版本 LangChain 默认是 False,不加这个参数会直接报错。

原因是 load_local 读的是 pickle 文件,而 pickle 反序列化可以执行任意代码——一个被替换过的 index.pkl 就是一个可执行的恶意载荷。LangChain 让你显式写这一行,是为了让「我知道我在加载自己建的索引」变成一个需要你点头的决定。

所以:只对你自己生成、且没被别人碰过的索引开这个开关。 用户上传的、网上下的索引文件,不要开。

搜索结果的形状是这样的:

1
2
3
4
笨笨是一只很喜欢睡觉的猫咪
猫咪在窗台上打盹,看起来非常可爱
我的狗喜欢追逐球,看起来非常开心
我的手机突然关机了,让我有些焦虑

——注意最后两条。向量检索总能返回 k 条结果,哪怕库里根本没有相关的。它找的是「最近的邻居」,不是「相关的文档」。

这个特性在 Faiss 里没办法关掉(Faiss 不知道元数据,也就没有阈值可用)。后端拼上「是否真的相关」这一步,得靠评分阈值或者 reranker——这就是 RAG §5 讲的精排。

8. 小结

一、Faiss 是「库」,不是「数据库」。 它只有向量和 id,没有元数据、没有服务、没有命名空间。这一条推导出了后面几乎所有差异:为什么它原生不支持过滤、为什么过滤要 fetch_k 再筛、为什么删除只能按 id。Annoy 也是库,把它归到「本地部署 API 向量数据库」是分类放错了。

二、上游的划分是「本地文件 / 本地部署 API / 云端托管」,但边界在变模糊。 选型时真正该问的是:这个数据量下,我需要的是一个进程内的库,还是一个要运维的数据库?如果你的数据本来就在 PostgreSQL 里,pgvector 往往比引入一个新数据库划算。

三、几乎所有的都不支持修改数据,只会「删除后重新新增」。 因为在 HNSW / IVF 这类 ANN 索引里,插入一个点很便宜,改一个点约等于重建一小片索引。这也是为什么大多数向量数据库没有多语句事务。

四、similarity_search_with_relevance_scores 的分数是换算出来的,换算公式取决于距离度量和是否归一化。 LangChain 欧氏那个默认是 1 − d/√2,√2 来自「归一化向量在欧氏距离下的最大值」(因为 d = √(2−2cos))。不归一化的 embedding 会让它算出负数或者超过 1 的分。

五、那个 1/(1+d) 是另一种刻度,不是同一个分。 它靠有界函数绕过了「N 维空间距离没有上界」的问题,排序对、范围安全,但跟余弦相似度不是一个刻度。

六、评分函数和阈值必须绑在一起测。 LangChain 0.3 把返回从「原始距离」改成「1 − d/√2」,方向反了、量纲也变了——按旧版调的阈值升级后一条都检索不回来,不报错,只返回空。这就是笔记里那句「会出现莫名其妙的计算结果」的真实长相。

七、过滤在 Faiss 上是事后补救,在 Pinecone 上是原生的。 Faiss 的 filter 只作用于已召回的 fetch_k 条,所以 fetch_k 要按筛选比例放大,否则你会拿到「全库明明有数据、却筛出空」的结果。Pinecone 的过滤是 JSON、支持 $lte 这类操作符,但要记住顶层只能放字段名或逻辑操作符、类型严格、否定查询更慢。

八、load_local 要显式开 allow_dangerous_deserialization=True。 pickle 能执行任意代码,只对你自己建的索引开。另外向量检索总能返回 k 条——它找的是最近的邻居,不是相关的文档。

最后,这一串串起来就是 RAG 里的那段:

1
2
3
4
5
6
7
8
9
10
11
12
13
Chunk
↓
Embedding
↓
FAISS / Pinecone
↓
Query Embedding
↓
相似度检索
↓
TopK 文档
↓
LLM

参考

向量数据库:FAISS 与 Pinecone

https://wywzixin.github.io/agent/vector-database/

作者

wangyawei

发布于

2026-09-15

更新于

2026-09-15

许可协议