向量数据库选型与LangChain VectorStore集成

引言

去年我负责一个企业级 RAG(检索增强生成)平台的重构。项目初期,团队为了"快速跑通 Demo",直接把所有文档向量塞进了 FAISS 的本地索引文件,配合 LangChain 的 FAISS 封装,两周就上线了内测。结果一进入生产环境,问题接踵而至:

  • 多租户隔离失效:不同客户的数据混在同一个索引里,similarity_search 一查就串数据;
  • 增量更新灾难:每来一批新文档,就要全量重建索引,10 万条向量重建一次要 8 分钟,业务方直接炸锅;
  • 元数据过滤形同虚设:想按"部门=财务"过滤,FAISS 只能先召回再 Python 层过滤,召回率被稀释得惨不忍睹;
  • 扩容无门:单机内存顶到 64GB 后,索引再也放不下,团队开始怀疑人生。

这四个问题,恰好对应了向量数据库选型的四个核心维度:多租户/命名空间、增量写能力、原生元数据过滤、水平扩展。而这篇文章,我想从一个架构师的视角,把"选型"和"LangChain VectorStore 集成"这两件事讲透——不只是 API 怎么调,而是为什么这么设计、源码里到底发生了什么、什么时候该换方案。

如果你正在做 RAG、Agent 记忆、推荐召回,或者只是被"到底选 Milvus 还是 pgvector"折磨过,这篇文章应该能帮你少走几个月的弯路。


核心概念:把向量检索想象成"图书馆找书"

先建立一个生活化类比,不然后面源码会很难啃。

传统数据库像图书馆的卡片目录:你报一个书名(精确 key),管理员直接按字母顺序翻到那一格,取出书。查询是"精确匹配 + 范围扫描"。

向量数据库像图书馆的主题导览员:你没有书名,只有一句模糊的需求——"我想找一本讲'如何优雅地处理分布式事务'的书"。导览员不会去翻卡片,而是把所有书按照"主题坐标"摆在一个巨大的多维空间里,你站在某个坐标点,他把你附近最近的 10 本书递给你。这个"坐标"就是向量(Embedding),"附近"就是相似度(余弦/内积/欧氏)。

这个类比的三个关键延伸:

  1. 坐标的维度:一本书可能被拆成 768 个特征("技术深度""幽默感""篇幅"……),这就是 embedding 维度。维度越高,语义刻画越细,但计算和存储成本也越高。
  2. "附近"怎么找:暴力遍历所有书太慢,于是有了 ANN(近似最近邻) 算法——像图书馆先按大类分书架(聚类),再在书架内细找。这就是 HNSW、IVF、DiskANN 等索引结构的本质。
  3. "只看财务类的书":这就是元数据过滤(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 画一下三种过滤策略:

graph TD A[Query + Filter] --> B{后端策略} B -->|后过滤 Post-filter| C[先 ANN 召回 Top-K*10] C --> D[Python/DB 层按 metadata 过滤] D --> E[返回不足 k 条, 召回率崩] B -->|下推 Push-down| F[ANN 检索时同步下推 filter] F --> G[索引层只遍历满足 filter 的候选] G --> H[返回精确 k 条] B -->|混合 Hybrid| I[标量索引 + 向量索引联合执行] I --> J[按代价选择执行顺序] J --> H
  • 后过滤(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 的业务 不想运维

选型决策树(简化):

graph TD A[需要向量检索] --> B{数据量级} B -->|< 10万 & 单机| C{是否已有 PG} C -->|是| D[pgvector] C -->|否| E[Chroma / FAISS] B -->|10万~千万| F{是否强过滤多租户} F -->|是| G[Qdrant / Milvus] F -->|否| H[Qdrant] B -->|> 千万| I[Milvus / 云托管] I --> J{运维能力} J -->|强| K[Milvus 自建] J -->|弱| L[Pinecone / Zilliz Cloud]

最佳实践与避坑指南

  1. 永远为 filter 字段建索引。Qdrant 的 payload index、Milvus 的 scalar index、pgvector 的 GIN,缺了它们,下推会退化成全表扫描。
  1. 不要跨后端比较 score。FAISS 默认 L2,Qdrant COSINE 返回相似度(越大越相似),Milvus 可配。做重排前,先确认 score 语义,否则阈值过滤会全错。
  1. 批量写入优于逐条写入。HNSW 构建是 CPU 密集的,add_texts 一次传几百条远比循环调用快。Milvus 有 bulk_insert,Qdrant 有 upload_points。
  1. 更新 = 删除 + 插入,注意 compaction。高频更新场景要监控 tombstone 比例,Milvus 需定期 compact(),否则查询会越来越慢。
  1. embedding 模型与维度锁定后不要轻易换。换模型意味着全量重嵌入+重建索引,这是 RAG 系统最大的迁移成本。建议在 collection 上记录模型版本。
  1. k 不要盲目调大。RAG 里 k=20 往往比 k=4 效果更好,但会把噪声塞进 context,反而降低 LLM 回答质量。正确姿势是 召回多(k=20~50)+ 重排(Rerank)取 3~5。
  1. 多租户用 partition/namespace,不要用 filter 硬扛。Milvus 的 partition、Qdrant 的 collection 是物理级隔离,比 filter 更快更安全。
  1. 监控三个指标:召回延迟 P99、返回条数是否 < k(后过滤塌陷信号)、索引内存占用。前两个是 RAG 质量的先行指标。
  1. LangChain 的封装会滞后于底层。新特性(如 Qdrant 的量化、Milvus 的稀疏向量)往往要等 langchain-xxx 更新。生产关键路径上,建议直接用原生 SDK,LangChain 只做编排层。
  1. 测试时用真实 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 做混合。选型时,把"混合检索能力"纳入评估清单,会是你比同行早半步的地方。

技术选型没有银弹,只有对场景的诚实。想清楚你的读写比、数据量、过滤选择性、运维预算,答案自然浮现。