向量数据库选型与LangChain VectorStore集成
引言
去年我负责一个企业级 RAG(检索增强生成)平台的重构。项目初期,团队为了"快速跑通 Demo",直接把所有文档向量塞进了 FAISS 的本地索引文件,配合 LangChain 的 FAISS 封装,两周就上线了内测。结果一进入生产环境,问题接踵而至:
- 多租户隔离失效:不同客户的数据混在同一个索引里,
similarity_search一查就串数据;
- 增量更新灾难:每来一批新文档,就要全量重建索引,10 万条向量重建一次要 8 分钟,业务方直接炸锅;
- 元数据过滤形同虚设:想按"部门=财务"过滤,
FAISS只能先召回再 Python 层过滤,召回率被稀释得惨不忍睹;
- 扩容无门:单机内存顶到 64GB 后,索引再也放不下,团队开始怀疑人生。
这四个问题,恰好对应了向量数据库选型的四个核心维度:多租户/命名空间、增量写能力、原生元数据过滤、水平扩展。而这篇文章,我想从一个架构师的视角,把"选型"和"LangChain VectorStore 集成"这两件事讲透——不只是 API 怎么调,而是为什么这么设计、源码里到底发生了什么、什么时候该换方案。
如果你正在做 RAG、Agent 记忆、推荐召回,或者只是被"到底选 Milvus 还是 pgvector"折磨过,这篇文章应该能帮你少走几个月的弯路。
核心概念:把向量检索想象成"图书馆找书"
先建立一个生活化类比,不然后面源码会很难啃。
传统数据库像图书馆的卡片目录:你报一个书名(精确 key),管理员直接按字母顺序翻到那一格,取出书。查询是"精确匹配 + 范围扫描"。
向量数据库像图书馆的主题导览员:你没有书名,只有一句模糊的需求——"我想找一本讲'如何优雅地处理分布式事务'的书"。导览员不会去翻卡片,而是把所有书按照"主题坐标"摆在一个巨大的多维空间里,你站在某个坐标点,他把你附近最近的 10 本书递给你。这个"坐标"就是向量(Embedding),"附近"就是相似度(余弦/内积/欧氏)。
这个类比的三个关键延伸:
- 坐标的维度:一本书可能被拆成 768 个特征("技术深度""幽默感""篇幅"……),这就是 embedding 维度。维度越高,语义刻画越细,但计算和存储成本也越高。
- "附近"怎么找:暴力遍历所有书太慢,于是有了 ANN(近似最近邻) 算法——像图书馆先按大类分书架(聚类),再在书架内细找。这就是 HNSW、IVF、DiskANN 等索引结构的本质。
- "只看财务类的书":这就是元数据过滤(metadata filtering)。传统图书馆可以先去"财务"书架再找,而很多朴素向量库只能"先找最近的 100 本,再从中挑财务的"——召回质量天差地别。
从技术定义看,一个生产级向量数据库必须同时具备:
| 能力 | 说明 | 典型实现 |
|---|---|---|
| 向量索引 | ANN 检索,平衡召回率与延迟 | HNSW / IVF-PQ / DiskANN |
| 元数据存储 | 结构化字段,支持过滤 | 内置 KV / 列存 |
| 标量+向量混合查询 | filter 下推,避免后过滤 | Milvus / Qdrant / pgvector |
| 命名空间/多租户 | 逻辑隔离 | collection / namespace / partition |
| 增删改 | 动态插入、删除、更新 | 支持 upsert 的存储引擎 |
| 持久化与扩展 | 副本、分片 | 分布式协调(etcd 等) |
LangChain 的 VectorStore 抽象,则是把这些能力统一成一个接口。它的定位很像 JDBC:你写 similarity_search(query, k, filter),底下接的是 FAISS 还是 Milvus,业务代码不用改。但——JDBC 的教训告诉我们,抽象永远不会完全抹平差异。后面源码分析会看到,filter 在不同后端的行为差异,就是最大的坑。
源码/原理深度分析
1. LangChain VectorStore 的抽象骨架
先看 langchain_core.vectorstores.VectorStore 的核心方法(简化后):
class VectorStore(ABC):
@abstractmethod
def add_texts(self, texts, metadatas=None, ids=None, **kwargs) -> List[str]:
"""写入文本,返回 id 列表"""
...
@abstractmethod
def similarity_search(self, query: str, k: int = 4, **kwargs) -> List[Document]:
"""返回 Document 列表(只含 page_content + metadata)"""
...
def similarity_search_with_score(self, query, k=4, **kwargs):
"""返回 (Document, score) 列表"""
docs = self.similarity_search_with_score_by_vector(
self._embed_query(query), k=k, **kwargs
)
return docs
@abstractmethod
def similarity_search_with_score_by_vector(self, embedding, k=4, **kwargs):
...这里有一个极其重要的设计细节:similarity_search 默认返回的是 Document,丢掉了原始向量和底层 id 的部分语义。这导致两个后果:
- 你想做"MMR 去重"或"自定义重排"时,往往需要重新取向量,多一次 IO;
- 不同后端的
score语义不统一(FAISS 返回 L2 距离,Chroma 返回距离,Milvus 可返回 COSINE/IP/L2),直接比较 score 会出错。
2. filter 的三种命运:后过滤、下推、混合
这是选型时最容易被忽略、生产上最致命的一点。我们用 mermaid 画一下三种过滤策略:
- 后过滤(Post-filter):FAISS、早期 Chroma 的做法。LangChain 的
FAISS.similarity_search在传入filter时,就是先多召回,再本地过滤。源码大致是:
# langchain_community/vectorstores/faiss.py (简化)
def similarity_search_with_score_by_vector(self, embedding, k=4, filter=None, fetch_k=20):
# 注意 fetch_k:为了过滤后还能剩 k 条,先多取
if filter is not None:
# 多召回 10 倍,再过滤
scores, indices = self.index.search(np.array([embedding]), k * 10)
else:
scores, indices = self.index.search(np.array([embedding]), k)
docs = []
for score, idx in zip(scores[0], indices[0]):
doc = self.docstore.search(self.index_to_docstore_id[idx])
if filter is None or filter(doc.metadata):
docs.append((doc, score))
return docs[:k]看到没?fetch_k 默认就是 k*10 甚至更多。如果你的 filter 选择性极高(比如只占 1%),召回 10 倍根本不够,结果就是返回条数远小于 k,或者干脆为空。这就是"财务部门文档查不出来"的根因。
- 下推(Push-down):Milvus、Qdrant、Weaviate 的做法。filter 表达式被翻译成底层引擎的谓词,ANN 遍历时只考虑满足条件的候选。Qdrant 的 payload index、Milvus 的 scalar field index(如
INVERTED INDEX)就是为此而生。
- 混合(Hybrid):pgvector 结合 PostgreSQL 的 B-tree/GIN 索引,优化器会根据 filter 选择性决定"先走标量索引再算向量距离"还是"先 ANN 再过滤"。这是关系型数据库的老本行,也是 pgvector 在"结构化+向量"混合场景下最优雅的地方。
结论:选型时,如果你的 RAG 场景一定有强元数据过滤(多租户、时间范围、权限),请优先选择支持 filter 下推的库。否则你会被迫用 fetch_k 硬扛,延迟和成本双双爆炸。
3. HNSW 索引:为什么它成了事实标准
几乎所有主流向量库默认都用 HNSW(Hierarchical Navigable Small World)。理解它,才能理解"为什么删除和更新这么贵"。
HNSW 是一张多层跳表式的图:
- 顶层稀疏,像高速公路,用于快速"跳到大致区域";
- 底层稠密,像城市街道,用于精确逼近最近邻;
- 每个节点维护
M个邻居(通常 16~64),构建时通过ef_construction控制搜索广度。
查询时从顶层入口点开始,贪心地向"离目标更近的邻居"移动,逐层下降。复杂度约为 O(log N)。
关键代价:HNSW 是图结构,删除一个节点需要修复所有指向它的边(否则图会断裂、召回率下降)。所以:
- Milvus 的删除是软删除(标记 tombstone)+ 后台 compaction;
- Qdrant 支持真正的删除,但会周期性重建受影响的子图;
- 很多库的"更新"本质是"删旧 + 插新"。
这解释了为什么高频更新的场景,HNSW 不一定是最优解——此时 IVF-PQ 或 DiskANN 的写入吞吐可能更合适。选型时一定要问自己:读写比是多少?
实战代码
下面三个示例,覆盖三种典型后端,全部可直接运行(依赖见注释)。
示例一:FAISS + LangChain,理解后过滤的真实行为
# pip install langchain langchain-community langchain-openai faiss-cpu numpy
import numpy as np
from langchain_community.vectorstores import FAISS
from langchain_core.embeddings import Embeddings
from langchain_core.documents import Document
# ---- 用一个确定性假 embedding,方便复现,不依赖外部 API ----
class FakeEmbeddings(Embeddings):
def __init__(self, dim: int = 64):
self.dim = dim
def _vec(self, text: str) -> list[float]:
# 用字符 hash 生成稳定向量,仅用于演示
rng = np.random.default_rng(abs(hash(text)) % (2**32))
return rng.random(self.dim).tolist()
def embed_documents(self, texts):
return [self._vec(t) for t in texts]
def embed_query(self, text):
return self._vec(text)
emb = FakeEmbeddings()
# ---- 构造 1000 条文档,其中只有 20 条 dept=finance ----
texts, metas = [], []
for i in range(1000):
dept = "finance" if i % 50 == 0 else "engineering"
texts.append(f"文档编号 {i},内容涉及 {dept} 领域的知识")
metas.append({"dept": dept, "doc_id": i})
store = FAISS.from_texts(texts, emb, metadatas=metas)
# ---- 关键实验:高选择性 filter 下的召回率 ----
query = "文档编号 500 的内容"
k = 5
# 1) 不传 filter,正常召回
raw = store.similarity_search(query, k=k)
print("无 filter 返回条数:", len(raw))
# 2) 传 filter:只有 1/50 命中,观察返回条数
filtered = store.similarity_search(
query, k=k,
filter=lambda md: md["dept"] == "finance" # 后过滤
)
print("带 filter 返回条数:", len(filtered)) # 大概率 < 5,甚至为 0
# 3) 手动放大 fetch_k,缓解后过滤问题
# 注意:FAISS 封装里 fetch_k 通过 kwargs 透传给底层
filtered2 = store.similarity_search(
query, k=k, fetch_k=500,
filter=lambda md: md["dept"] == "finance"
)
print("放大 fetch_k 后返回条数:", len(filtered2)) # 明显改善,但延迟上升运行后你会直观看到:第 2 步返回条数经常小于 k,甚至为空。这就是后过滤的"召回塌陷"。生产上如果你无法换成支持下推的后端,必须显式调大 fetch_k,并监控返回条数。
示例二:Qdrant,体验原生 filter 下推
# pip install langchain-qdrant qdrant-client
# 本地起服务:docker run -p 6333:6333 qdrant/qdrant
from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams
from langchain_qdrant import QdrantVectorStore
from langchain_core.embeddings import Embeddings
import numpy as np
class FakeEmbeddings(Embeddings):
def __init__(self, dim=64):
self.dim = dim
def _vec(self, text):
rng = np.random.default_rng(abs(hash(text)) % (2**32))
return rng.random(self.dim).tolist()
def embed_documents(self, texts):
return [self._vec(t) for t in texts]
def embed_query(self, text):
return self._vec(text)
DIM = 64
client = QdrantClient(url="http://localhost:6333")
# 若 collection 不存在则创建
if not client.collection_exists("demo"):
client.create_collection(
collection_name="demo",
vectors_config=VectorParams(size=DIM, distance=Distance.COSINE),
)
emb = FakeEmbeddings(DIM)
store = QdrantVectorStore(
client=client,
collection_name="demo",
embedding=emb,
)
texts, metas, ids = [], [], []
for i in range(1000):
dept = "finance" if i % 50 == 0 else "engineering"
texts.append(f"文档编号 {i},内容涉及 {dept} 领域的知识")
metas.append({"dept": dept, "doc_id": i})
ids.append(f"doc-{i}")
# 幂等写入(重复运行不会重复插)
store.add_texts(texts, metadatas=metas, ids=ids)
# ---- 关键:filter 用 Qdrant 原生表达式,下推到引擎 ----
from qdrant_client.models import Filter, FieldCondition, MatchValue
qfilter = Filter(
must=[FieldCondition(key="metadata.dept", match=MatchValue(value="finance"))]
)
results = store.similarity_search(
"文档编号 500 的内容", k=5, filter=qfilter
)
print("Qdrant 下推 filter 返回条数:", len(results))
for d in results:
print(" ->", d.metadata)对比示例一:即使 dept=finance 只占 2%,Qdrant 依然能稳定返回 5 条,因为过滤发生在 ANN 遍历内部。这就是下推 vs 后过滤的本质差距。
注意:
langchain_qdrant的filter参数接受的是qdrant_client.models.Filter,不是 Python 函数。这个接口差异是迁移时最常见的坑。
示例三:pgvector + 混合查询,关系型选手的优雅
# pip install langchain-postgres psycopg[binary] pgvector
# 需 PostgreSQL 15+ 并安装 pgvector 扩展:CREATE EXTENSION vector;
from langchain_postgres import PGVector
from langchain_core.embeddings import Embeddings
import numpy as np
class FakeEmbeddings(Embeddings):
def __init__(self, dim=64):
self.dim = dim
def _vec(self, text):
rng = np.random.default_rng(abs(hash(text)) % (2**32))
return rng.random(self.dim).tolist()
def embed_documents(self, texts):
return [self._vec(t) for t in texts]
def embed_query(self, text):
return self._vec(text)
CONNECTION = "postgresql+psycopg://postgres:postgres@localhost:5432/postgres"
COLLECTION = "demo_pg"
store = PGVector(
embeddings=FakeEmbeddings(64),
collection_name=COLLECTION,
connection=CONNECTION,
use_jsonb=True, # metadata 存 jsonb,可建 GIN 索引
)
texts, metas, ids = [], [], []
for i in range(1000):
dept = "finance" if i % 50 == 0 else "engineering"
texts.append(f"文档编号 {i},内容涉及 {dept} 领域的知识")
metas.append({"dept": dept, "doc_id": i})
ids.append(f"doc-{i}")
# 幂等写入
store.add_texts(texts, metadatas=metas, ids=ids)
# ---- LangChain 的 filter 是 dict 语法,会被翻译成 SQL WHERE ----
results = store.similarity_search(
"文档编号 500 的内容", k=5,
filter={"dept": "finance"} # 翻译为 metadata->>'dept' = 'finance'
)
print("pgvector 混合查询返回条数:", len(results))
for d in results:
print(" ->", d.metadata)
# ---- 生产建议:为过滤字段建 GIN 索引,让优化器选择执行路径 ----
# 在 psql 中执行:
# CREATE INDEX ON langchain_pg_embedding USING gin ((embedding.cmetadata -> 'dept'));pgvector 的精髓在于优化器:当 dept='finance' 选择性极高时,Postgres 可能先走 GIN 索引过滤,再算向量距离;选择性低时则先 ANN。这是"混合查询"最省心的实现,代价是向量检索性能不如专用库,且大规模下需要调 hnsw.ef_search、ivfflat.probes 等参数。
方案对比
| 维度 | FAISS | Chroma | Qdrant | Milvus | pgvector | Pinecone(云) |
|---|---|---|---|---|---|---|
| 部署形态 | 库/本地 | 库/轻量服务 | 服务 | 分布式服务 | PG 扩展 | 全托管 |
| filter 下推 | ❌ 后过滤 | 部分 | ✅ 原生 | ✅ 原生 | ✅ SQL 优化器 | ✅ |
| 多租户 | 手动 | collection | collection/payload | database/collection/partition | schema/表 | namespace |
| 增量写 | 需重建/合并 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 水平扩展 | ❌ | 有限 | ✅ | ✅ 强 | 靠 PG 分片 | 自动 |
| 运维成本 | 极低 | 低 | 中 | 高 | 低(已有 PG) | 无 |
| 适用场景 | Demo/小数据 | 本地原型 | 中小规模生产 | 大规模生产 | 已有 PG 的业务 | 不想运维 |
选型决策树(简化):
最佳实践与避坑指南
- 永远为 filter 字段建索引。Qdrant 的 payload index、Milvus 的 scalar index、pgvector 的 GIN,缺了它们,下推会退化成全表扫描。
- 不要跨后端比较 score。FAISS 默认 L2,Qdrant COSINE 返回相似度(越大越相似),Milvus 可配。做重排前,先确认 score 语义,否则阈值过滤会全错。
- 批量写入优于逐条写入。HNSW 构建是 CPU 密集的,
add_texts一次传几百条远比循环调用快。Milvus 有bulk_insert,Qdrant 有upload_points。
- 更新 = 删除 + 插入,注意 compaction。高频更新场景要监控 tombstone 比例,Milvus 需定期
compact(),否则查询会越来越慢。
- embedding 模型与维度锁定后不要轻易换。换模型意味着全量重嵌入+重建索引,这是 RAG 系统最大的迁移成本。建议在 collection 上记录模型版本。
k不要盲目调大。RAG 里k=20往往比k=4效果更好,但会把噪声塞进 context,反而降低 LLM 回答质量。正确姿势是 召回多(k=20~50)+ 重排(Rerank)取 3~5。
- 多租户用 partition/namespace,不要用 filter 硬扛。Milvus 的 partition、Qdrant 的 collection 是物理级隔离,比 filter 更快更安全。
- 监控三个指标:召回延迟 P99、返回条数是否 < k(后过滤塌陷信号)、索引内存占用。前两个是 RAG 质量的先行指标。
- LangChain 的封装会滞后于底层。新特性(如 Qdrant 的量化、Milvus 的稀疏向量)往往要等
langchain-xxx更新。生产关键路径上,建议直接用原生 SDK,LangChain 只做编排层。
- 测试时用真实 embedding 分布。随机向量和真实 embedding 的聚类特性完全不同,用假向量压测出来的召回率没有参考价值。
总结
回到开头的四个生产事故,现在可以给出清晰的答案:
- 多租户隔离:用 partition/namespace,别用 filter;
- 增量更新:选支持 upsert + compaction 的库,FAISS 只适合静态数据;
- 元数据过滤:必须选支持 filter 下推的后端,否则
fetch_k会把你拖垮;
- 水平扩展:千万级以上直接上 Milvus 或云托管,别硬扛单机。
LangChain 的 VectorStore 抽象是双刃剑:它让 Demo 变得极其简单,也让"不同后端语义差异"被隐藏到生产才爆发。作为架构师,你要做的不是拒绝抽象,而是看穿抽象之下的实现——知道 filter 是函数还是表达式、score 是距离还是相似度、fetch_k 什么时候会救你、什么时候会害你。
最后留一个延伸思考:随着 稀疏向量(SPLADE)、多向量(ColBERT)、混合检索(Dense+Sparse+RRF) 的普及,下一代向量库的竞争焦点正在从"ANN 快不快"转向"混合检索的表达力强不强"。Milvus 2.4+ 原生支持 sparse vector,Qdrant 支持 hybrid query,pgvector 也能配合 tsvector 做混合。选型时,把"混合检索能力"纳入评估清单,会是你比同行早半步的地方。
技术选型没有银弹,只有对场景的诚实。想清楚你的读写比、数据量、过滤选择性、运维预算,答案自然浮现。