SSD Nodes Learn 🎉 VPS $5.50/月起
指南 Matt Connor作者: Matt Connor

VPS上运行向量数据库:pgvector、Qdrant还是暴力扫描?

应用和索引在同一台VPS上时,网络延迟几乎为零。本文比较pgvector、Qdrant、Chroma与暴力扫描,并用向量数量、维度和每元素4字节估算RAM。

VPS 上运行向量数据库的实际成本

在 VPS(虚拟专用服务器)上运行向量数据库,可以消除所有托管服务商试图帮您解决的问题。应用和索引位于同一台机器上,因此搜索请求通过回环套接字传输,而不必经过网络。剩下的成本才一直是核心成本:将文本转换为向量。此外还有两项成本:构建索引所需的时间,以及索引在提供服务期间持续占用的 RAM。

这会改变真正重要的决策。您不再需要关注区域和端点往返时间。您需要关注向量数量、维度数与 four bytes 的乘积,因为它决定了索引是否能放入您每月租用的内存中。

单台服务器上,毫秒实际消耗在哪里

跟踪一次相似度查询在自托管技术栈中的完整过程。

  1. 嵌入模型将查询文本转换为向量。短字符串在 CPU 上通常需要几十到几百毫秒。在 GPU 上则只需个位数毫秒。
  2. 向量被发送到向量存储。通过回环 TCP 或 Unix 域套接字传输时,只需不到 1 毫秒。
  3. 向量存储遍历索引并返回最近的记录。
  4. 您的代码读取匹配的文本并组装提示词。

第 1 步通常是列表中耗时最长的步骤。托管服务商主要在第 2 步展开竞争,但在单台服务器上,这部分耗时几乎不存在。不要猜测耗时分布。请在自己的服务器上测量两端的耗时。

curl http://127.0.0.1:11434/api/embed -s -o /dev/null \
  -w 'embed: %{time_total}s\n' \
  -d '{"model": "nomic-embed-text", "input": "how do I rotate the api key"}'

然后在执行搜索查询前,在 psql 中运行 \timing on。如果第一个命令输出 embed: 0.184312s,且 psql 返回 Time: 4.201 ms,那么优化索引就是错误的方向:延迟来自嵌入模型。使用 Ollama 在本地运行嵌入模型会将第 1 步放在与第 2 步和第 3 步相同的 CPU 上,因此两部分会争用相同的 CPU 核心和 RAM。围绕此存储构建的数据导入和检索循环,请参阅自托管 RAG 流程指南。RAG 是检索增强生成:搜索您自己的文档,并将最匹配的内容粘贴到提示词中。

十万左右的向量以内:扫描全部向量

穷举扫描会将查询向量与每个已存储向量进行比较。召回率按定义是完美的。它不需要索引,也不需要构建步骤,并且不会因为数据更新而过期。

通过计算即可判断它何时不再适用。每次查询需要读取 n * d * 4 字节,其中 n 是向量数量,d 是维度。对于 100,000 个 768 维向量,每次查询需要读取 307 MB;现代 CPU 可以在几十毫秒内完成顺序读取。对于 5 million 个向量,每次查询需要读取 15 GB,这已经不能算作查询了。

因此,将向量存储在 SQLite 中,并使用 NumPy 执行比较。

import sqlite3, numpy as np

db = sqlite3.connect("docs.db")
db.execute("CREATE TABLE IF NOT EXISTS docs (id INTEGER PRIMARY KEY, body TEXT, vec BLOB)")

def add(body, vec):
    v = np.asarray(vec, dtype=np.float32)
    v /= np.linalg.norm(v)
    db.execute("INSERT INTO docs (body, vec) VALUES (?, ?)", (body, v.tobytes()))
    db.commit()

def search(query_vec, k=5):
    rows = db.execute("SELECT id, body, vec FROM docs").fetchall()
    mat = np.frombuffer(b"".join(r[2] for r in rows), dtype=np.float32).reshape(len(rows), -1)
    q = np.asarray(query_vec, dtype=np.float32)
    q /= np.linalg.norm(q)
    scores = mat @ q
    return [(rows[i][0], rows[i][1], float(scores[i])) for i in np.argsort(-scores)[:k]]

两侧都已缩放到单位长度,因此点积就是余弦相似度,分数越高表示匹配越接近。在启动时加载一次 mat,而不是每次查询都加载,这样 SQLite 读取就完全不在热路径上了。

在否定这种方案之前,先在自己的机器上测试耗时。

import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")

需要明确的限制是:这是一个将整个矩阵保存在 RAM 中的单进程方案,并且不支持元数据过滤,也没有并发写入方案。如果其中任何一点正是问题所在,就应当更换方案。SQLite 本身是成熟的服务端存储,SQLite 生产环境指南对此有详细介绍;如果你的实际工作负载是扫描列而不是提供行查询,那么DuckDB 与 SQLite 的比较更值得阅读。

已有 Postgres 时使用 pgvector

如果应用已经使用 Postgres 数据库,pgvector 引入的新维护面最少。它是扩展,而不是独立服务。向量存储在描述对应数据行的普通表中,因此带筛选条件的搜索只需使用 WHERE 子句,不需要维护第二套必须保持同步的系统。

Ubuntu 24.04 在 universe 组件中提供该扩展。

sudo apt update
sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d yourdb -c 'CREATE EXTENSION vector;'

截至 2026 年 8 月,该软件包中的 pgvector 版本为 0.6.0,明显落后于上游版本。尤其是迭代式索引扫描需要 0.8,因此应从 PostgreSQL 项目自己的软件包仓库获取。

sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17-pgvector

17 替换为服务器的主版本号。sudo -u postgres psql -tAc 'SHOW server_version' 可以输出该版本号。如果安装了为错误主版本构建的扩展软件包,CREATE EXTENSION 会失败,因为 Postgres 只会在当前运行版本的 share 目录中查找:

ERROR:  could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directory

该架构使用普通 SQL,只增加了一种新类型。

CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    bigint NOT NULL,
  body      text   NOT NULL,
  embedding vector(768)
);

SELECT id, body FROM chunks
ORDER BY embedding <=> '[0.013, -0.021, 0.004]'
LIMIT 5;

<=> 表示余弦距离,<-> 表示 L2(欧几里得)距离,<#> 表示负内积。使用嵌入模型训练时采用的距离类型。如果选择错误,查询不会报错,但结果会在不易察觉的情况下变差。

没有索引时,该查询会扫描每一行并执行精确搜索。这相当于 Postgres 版本的上述暴力搜索,召回率同样是完美的。提高 max_parallel_workers_per_gather 可以让更多 CPU 核心参与计算。先执行这一步,之后再创建索引,因为这样可以先得到一个用于评估索引的召回率基线。

Qdrant:索引规模超过数据库承载能力时

Qdrant 是一个使用 Rust 编写的专用向量存储。索引规模足够大时,您可能不希望索引构建与应用使用的 Postgres 争用资源;或者,您需要 pgvector 不提供的 payload 过滤和量化功能。此时,Qdrant 才适合使用。

docker run -d --name qdrant \
  -p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 \
  -e QDRANT__SERVICE__API_KEY="$(openssl rand -hex 32)" \
  -v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
  qdrant/qdrant

6333 端口提供 REST(表述性状态转移)API,并在 /dashboard 提供管理面板;6334 端口提供 gRPC。在公网 VPS 上运行时,有两点需要注意。Qdrant 官方文档指出,该服务默认“没有加密或身份验证”;快速入门中的 -p 6333:6333 会绑定所有网络接口。Docker 会自行写入转发规则,因此即使配置了 ufw 规则,端口仍可能被发布到公网。请绑定到 127.0.0.1,并设置 API key。可通过公网 IP 访问且未设置 key 的 Qdrant 实例,等同于公开暴露您的文档副本。

curl -s http://127.0.0.1:6333/collections -H "api-key: $QDRANT_API_KEY"

正常响应应类似 {"result":{"collections":[]},"status":"ok","time":0.00002}。如果返回 {"status":{"error":"Unauthorized"}},说明请求头名称或 key 不正确;如果完全没有返回内容,说明容器未运行,或绑定到了其他地址。该容器是否适合您的服务器,属于普通的有状态服务部署问题,因此Docker 与主机数据库的比较同样适用于此处。

Chroma 及其用途

Chroma 是从零开始构建可用检索演示的最短路径。

pip install chromadb
chroma run --path /srv/chroma

该服务监听 8000 端口,chromadb.HttpClient(host="localhost", port=8000) 会连接到它。Chroma 自带默认嵌入函数,因此第一个原型完全不需要单独的模型服务器。

需要明确其中的权衡。Chroma 使用方便,因为它隐藏了本指南要讨论的决策:选择哪种距离指标,以及结果需要占用多少 RAM。对于原型而言,这样做是合适的;但对于需要由您负责处理告警的生产系统,则不合适。如果数据已经存储在 Postgres 中,将其迁移到 Chroma 会增加一个进程和同步问题,而 pgvector 本身并不存在这些问题。

索引需要多少 RAM

从原始向量开始计算,因为它们决定了内存下限,任何调优都无法改变这一点。

bytes = number_of_vectors * dimensions * 4

每个维度使用 4 个字节,即一个 32 位浮点数。Qdrant 的容量规划文档会额外乘以 1.5,用于元数据和优化期间生成的临时 segment:

memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5

下面将该公式应用于一百万个向量,并列出实际 embedding 模型常见的维度。

ChartRAM for 1 million vectors, by embedding dimension
The data behind this chart
[
  {
    "label": "384 dims",
    "raw_gib": 1.43,
    "with_overhead_gib": 2.15
  },
  {
    "label": "768 dims",
    "raw_gib": 2.86,
    "with_overhead_gib": 4.29
  },
  {
    "label": "1024 dims",
    "raw_gib": 3.81,
    "with_overhead_gib": 5.72
  },
  {
    "label": "1536 dims",
    "raw_gib": 5.72,
    "with_overhead_gib": 8.58
  },
  {
    "label": "3072 dims",
    "raw_gib": 11.44,
    "with_overhead_gib": 17.17
  }
]

这些是公式计算出的 gibibyte (GiB) 数值,不是实测结果。应将其理解为必须预留的内存空间。以 nomic-embed-text 为例,768 维模型处理一百万个 chunk 时需要约 4.29 GiB,这在 8 GB 方案中仍可为 Postgres 留出空间。同一批数据使用 3072 维时需要 17.17 GiB,因此无法满足。

真正应调整的是表格的第一列,而不是最后一列。维度减半后,后续所有环节的字节数都会永久减半。一个在公开排行榜上得分略低的 768 维模型,在 VPS 上通常是更好的工程选择。随后,pgvector 的 halfvec 类型会存储 16 位浮点数,使字节数再次减半,并通过表达式建立索引:

CREATE INDEX ON chunks USING hnsw ((embedding::halfvec(768)) halfvec_cosine_ops);

选择模型前,还需要了解一个限制。pgvector 的 vector 类型最多接受 16,000 个维度,但其 HNSW 和 IVFFlat 索引最多只支持 2,000 个维度。超过该限制时,可以转换为 halfvec 类型后建立索引,最多支持 4,000 个维度;否则就不能建立索引。

构建时 m 和 ef_construction 的成本

HNSW(分层可导航小世界)是 pgvector 和 Qdrant 都采用的索引结构。它是一种分层图。每个向量都是一个节点,并连接到附近的节点。搜索时,系统沿这些连接逐步靠近查询向量,而不是读取所有向量。

m表示每个节点保留的连接数。Faiss 文档将 HNSW 的内存占用表示为每个向量 (d * 4 + m * 2 * 4) 字节,并建议将 m 保持在 4 到 64 之间。以 768 维、1000000 个向量为例运行计算。

ChartCost of raising m at 768 dimensions, 1 million vectors
The data behind this chart
[
  {
    "label": "m = 8",
    "link_bytes_per_vector": 64,
    "total_gib": 2.92
  },
  {
    "label": "m = 16 (default)",
    "link_bytes_per_vector": 128,
    "total_gib": 2.98
  },
  {
    "label": "m = 32",
    "link_bytes_per_vector": 256,
    "total_gib": 3.1
  },
  {
    "label": "m = 64",
    "link_bytes_per_vector": 512,
    "total_gib": 3.34
  }
]

这个差异的实际大小值得注意。将默认值 m = 16 提高到 m = 64 后,每个向量会增加 512 字节的连接数据,而每个向量数据占用 3072 字节。因此总占用会从 2.98 GiB 增加到 3.34 GiB。增幅约为 12%。在这种维度下,m 并不是内存的主要消耗项。向量才是。

m真正增加的是构建时间和插入时间,因为添加节点时需要查找并连接这么多邻居。ef_construction表示构建器为每个节点选择位置时考虑的候选列表大小。提高该值可以生成更好的图,但会降低构建速度,而且完全不会改变最终索引的大小。

SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 7;
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

maintenance_work_mem决定构建需要几分钟还是几小时,因为 pgvector 会在图可以放入内存时直接在内存中构建图。当内存不足时,pgvector 会显示以下信息:

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.
HINT:  Increase maintenance_work_mem to speed up builds.

这条提示是 pgvector 输出中最有用的一行。它表示构建已经进入慢得多的路径。此时应取消构建,将该设置提高到超过上面计算出的 RAM 数值,然后重新开始。可以从另一个会话监控进度:

SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;

HNSW 会先报告 initializing,然后报告 loading tuples。如果磁盘不繁忙,而构建长时间停留在较低百分比,这通常是 maintenance_work_mem 问题,而不是查询卡住。

有两个事实需要纳入规划。pgvector 的 README 表示,与 IVFFlat 相比,HNSW“构建速度更慢且占用更多内存”,但可以换取更好的速度与召回率平衡。HNSW 可以在空表上创建,而 IVFFlat 必须先基于代表性数据运行 k-means,因此在空表上构建 IVFFlat 会导致召回率较低。对于全新的 schema,HNSW 是可以预先创建的索引。

mef_construction 会固化在索引中。ef_search 不会。它决定搜索遍历图时保留多少候选项,您可以按会话或按查询修改它。

SET hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 5;

默认值为 40。提高该值可以提升召回率,但延迟也会增加。降低该值后,两者都会下降。这是唯一无需重建索引即可调整的召回率参数。因此,应使用一组已知正确答案的固定查询进行调优,并在召回率不再提升时停止。

需要注意一个问题。ef_search 与选择性较强的 WHERE 子句配合时效果不佳,因为索引会返回固定数量的候选项,之后才应用过滤条件。如果过滤条件排除大多数行,那么即使表中存在匹配行,最终结果也可能少于 LIMIT 条。pgvector 0.8 通过迭代扫描解决了这一问题:

SET hnsw.iterative_scan = relaxed_order;

随后,索引会重新扫描并获取更多候选项,直到满足限制条件,最多获取 hnsw.max_scan_tuples 个候选项;其默认值为 20000。strict_order 会保留精确的距离排序,但开销更高。Ubuntu 0.6.0 软件包不支持此功能;在过滤条件下出现结果缺失时,您就会发现这一点。

为什么索引必须装入 RAM

HNSW 搜索是在图上遍历。每次跳转都会读取一个与上一个节点位置无关的节点,因此访问模式接近随机访问,预读无法发挥作用。图位于 RAM 中时,每次跳转都是一次内存访问。图不在 RAM 中后,每次跳转都可能变成一次磁盘读取。一次搜索如果访问几百个节点,就可能产生几百次读取。

Qdrant 文档给出了一个明确的关系:“如果 RAM 中存储的向量数量减少一半,搜索延迟大致会翻倍。”请据此进行规划。

如果索引确实无法装入 RAM,每个选项都存在取舍,应有意识地选择。

  • 使用内存映射存储向量,让操作系统缓存热点页面,并将冷数据保留在磁盘上。底层需要使用速度足够快的 NVMe(非易失性内存表达)存储,性能才可以接受。
  • 进行量化,将每个维度从 4 字节存储为 1 字节。这样可将向量占用的字节数减少 4 倍,但召回率会有小幅且可测量的损失。
  • 在 pgvector 中转换为 halfvec,这样可将字节数减少一半,召回率损失小于使用 1 字节量化。
  • 使用更小的模型生成嵌入。这是成本最低的修复方式,但很多人会跳过,因为这意味着需要重新为整个语料库生成嵌入。

故障模式与您将看到的字符串

could not open extension control file未安装与当前运行的 Postgres 主版本匹配的 pgvector 软件包。使用 sudo -u postgres psql -tAc 'SHOW server_version' 输出版本,然后安装匹配的 postgresql-NN-pgvector

ERROR: expected 768 dimensions, not 1536列类型与模型不一致。您更换了 embedding 模型,但没有重新生成 embedding。这里不存在局部修复方案,因为两个不同模型生成的向量完全不可比较,因此必须重新生成每一行数据。

查询速度很慢,并且 EXPLAIN 显示进行了顺序扫描。索引操作符类与查询操作符不匹配。vector_cosine_ops 只适用于 <=>。对查询运行 EXPLAIN ANALYZE,并查找 Index Scan using ... on chunks。如果看到的是 Seq Scan on chunks,请使用与实际查询操作符匹配的操作符类重建索引。

行数少于 LIMIT,并且存在 WHERE 子句。这是上文所述的过滤陷阱。提高 hnsw.ef_search,或者升级到 pgvector 0.8 并设置 hnsw.iterative_scan

索引构建结束时进程消失,并且 psql 中没有错误。maintenance_work_mem 被设置为占用机器大部分内存,而 shared_buffers 和您的应用也需要内存时,最终会触发内核的 out-of-memory killer。sudo dmesg -T | grep -i 'killed process' 会显示包含 postgres 名称的行。降低该设置,或者在配置更大的计划上构建索引,然后恢复 dump。

选择方案

如果您已经运行 Postgres,且向量数量少于几百万个,请使用 pgvector。索引与数据位于同一处,过滤条件就是一个 WHERE 子句,现有备份也会涵盖它。如果索引规模大到需要单独设置内存上限,或您需要进行大量载荷过滤,请在旁边运行 Qdrant,但要接受需要运维第二个服务。

向量数量大约低于 100000 个时,请先测量暴力扫描的性能,再安装任何组件。在这个规模下,具备完美召回率且无需构建步骤的穷举搜索并不是妥协,而是正确方案。此时改用近似索引,只会为了原本并未消耗的几毫秒,额外承担调优工作和 RAM 压力。

FAQ

我需要专用的向量数据库,还是 Postgres 就够了?

如果数据已经存储在 Postgres 中,pgvector 的适用范围通常比大多数对比文章所说的更广。它将向量存储在普通列中,因此带过滤条件的搜索就是一个 WHERE 子句,现有备份也会覆盖索引。当向量工作负载需要独立的内存上限,或需要 pgvector 不提供的负载过滤和量化时,再迁移到 Qdrant 等专用存储。

一台 VPS 可以存储多少个向量?

使用 number_of_vectors * dimensions * 4 bytes * 1.5 计算,不要凭猜测判断。1,000,000 个 768 维向量约占 4.3 GiB,因此 8 GB 方案在为 Postgres 留出空间后仍可容纳这些向量。1,000,000 个 3072 维向量约占 17 GiB,需要更大的方案。影响这一数值最大的因素是嵌入模型的维度,因此选择模型时应考虑内存成本。

为什么索引和应用在同一台机器上,向量搜索仍然很慢?

在同一台机器上,网络不是问题,因此应检查真正影响性能的两点。首先单独测量嵌入调用的耗时,因为在 CPU 上生成查询向量通常比搜索本身耗时更长。其次确认索引位于 RAM 中。HNSW 搜索会在图中随机跳转,因此图溢出到磁盘后,每次跳转都可能变成一次磁盘读取;Qdrant 官方建议指出,RAM 中可容纳的向量数量减半后,搜索延迟大致会翻倍。

我是否应该构建 HNSW 索引?

向量数量低于约 100,000 个时,不应构建。穷举扫描每个查询会读取 n * d * 4 字节;对于 100,000 个 768 维向量,这相当于 307 MB。现代 CPU 可在几十毫秒内完成连续读取,同时获得完美召回率,也无需构建步骤。先在自己的硬件上测量扫描耗时。只有在实测扫描确实过慢时才构建索引,不要仅因为某篇基准测试文章这样建议就构建。

提高 m 实际会带来什么成本?

主要增加构建时间和插入时间,而不是内存占用。在 768 维的情况下,将默认值 m = 16 提高到 m = 64,每个向量会增加 512 字节的图链接;相比 3072 字节的向量数据,总内存约增加 12%。但每次插入都必须查找并连接数量多 4 倍的邻居。应先调整 ef_search,因为修改它无需额外成本,也不需要重建索引。

#vector-database#rag#pgvector#qdrant#自托管