上周有人丢给我一个问题:手上堆了几百万条 1536 维的 embedding,究竟该塞进哪个库。聊天工具里聊了半小时,我给的答案还是那套:在 Linux 机器上拉一个 Docker 镜像,镜像名pgvector/pgvector:pg16,把pgvector扩展建起来,剩下的读写全交给 SQL。这套组合我从做语义检索的小项目一路用到几十亿 token 的语料库,中间换过机器、换过云、换过存储引擎,唯独这个底座一直没动过。
这篇文章讲的就是这件事:在 Linux 环境下用 Docker 安装 PostgreSQL 16 加 pgvector 向量扩展,从镜像怎么选、容器怎么起、参数怎么调,到表怎么建、索引怎么选、查询为什么没走索引,一条线写下来。它解决的核心问题是——不想额外维护一套专用向量数据库,又需要在一个熟悉的关系库里做近邻检索。适合三类人看:刚接触向量检索、手里只有几万条数据的新手;已经在跑 PostgreSQL、想原地加一个向量能力的后端;以及被专用向量库的运维成本劝退、想回退到单一存储的团队。
我把踩过的坑基本都写进去了,包括几个官方文档里一句话带过、但实际能让人卡半天的地方。
1. 先把选型讲透:为什么是 pgvector,而不是另一个专用库
1.1 三类向量存储方案的真实差距
向量检索这个领域,方案大致分三层。最上面是专用向量数据库,天生为 ANN 检索设计,分片、副本、量化压缩一应俱全;中间是搜索引擎派的实现,靠倒排加向量字段拼出来;最下面就是关系库扩展,典型代表就是 PostgreSQL 加pgvector。
很多人默认认为“专用的一定强”,这句话只在数据量到千万级、QPS 到几千、还需要多副本横向扩展的时候成立。真实项目里,绝大多数场景的向量规模在十万到几百万之间,QPS 几十到几百,还要求跟业务数据做 join、做事务、做条件过滤。这种负载下,上一套专用库意味着你要多维护一个集群、多写一套数据同步逻辑、多处理一次一致性对账。多出来的复杂度是实打实的成本,而性能收益可能只有几十毫秒。
pgvector的定位非常清晰:让 Postgres 具备 ANN 检索能力,代价是接受它在极端规模下不如专用库。它把向量当成一种普通列类型,索引走 HNSW 或 IVFFlat,查询就是一条带ORDER BY的 SQL。事务、备份、权限、CDC、BI 工具这些生态能力全部自动继承,这才是它最大的价值——不是快,是省事。
1.2 pgvector 的能力边界在哪里
用之前得知道它哪里不行,不然一定会在某个深夜踩坑。三个限制最值得记:
- 可建索引的维度有上限。
vector类型走 HNSW / IVFFlat 索引时最多 2000 维;halfvec(半精度)能到 4000 维;bit类型能到 64000 维。主流 embedding 模型输出 768、1024、1536、3072 维,1536 没问题,3072 就得转halfvec或者降到 2000 维以下。这一点很多人第一次撞上会懵,因为建表时不报错,建索引时才报。 - 索引构建是内存密集型操作。HNSW 建索引会把图结构往内存里堆,几百万行数据配上
maintenance_work_mem太小,直接 OOM 或者慢到怀疑人生。 - 过滤加检索的组合需要动脑子。带
WHERE条件的近邻查询,Postgres 有两种执行路径:先过滤再排序,或者先 ANN 再过滤。后者会导致召回数量不足,你得用iterative scan(0.8 版本引入)或者调大hnsw.ef_search来兜。
这三条如果事先不知道,后面一定会返工。我在第二个项目上就因为 3072 维的模型直接建 HNSW 索引失败,白等了一小时。
1.3 为什么用 Docker 装,而不是 apt 装
apt install postgresql-16再自己编译pgvector当然可行,我早期也这么干过。问题是编译需要postgresql-server-dev-16、build-essential,还得保证pg_config指向正确的版本,机器上如果同时装了 PG 14/15/16,编译出来的.so很容易装错目录,然后CREATE EXTENSION报一句could not access file "$libdir/vector",排查半小时。
Docker 方案把这些问题一次性抹平。官方维护的pgvector/pgvector镜像里,PostgreSQL 和扩展都是预编译好、路径对齐的,版本组合固定。换机器、换系统、迁移到另一台测试机,拉同一个 tag 就是同一个环境。另外,容器里的 PG 你可以随便折腾参数、随便删库重建,不污染宿主机。
代价是数据持久化和资源限制要自己做,后面几节会讲怎么处理。总体算下来,省的时间远多于花的时间。
2. 环境准备与镜像 tag 的门道
2.1 宿主机的最低要求和检查清单
先明确硬件底线。跑pgvector我建议至少 2 核 4G 起步,如果数据量超过 500 万条向量,内存直接按 16G 起,原因在参数那一节讲。磁盘方面,向量是浮点数组,1536 维的vector大约占 6KB 左右(每维 4 字节加头部开销),一千万条就是 60GB 裸数据,索引还要再翻一倍左右,所以磁盘规划要留余量。
开始之前先在宿主机上确认几件事:
# 确认 docker 和 compose 都在 docker --version docker compose version # 确认内核的共享内存够用(后面 --shm-size 依赖) df -h /dev/shm # 确认 5432 端口没被占用 ss -tlnp | grep 5432 # 确认数据目录所在磁盘的剩余空间 df -h /data提醒:如果宿主机上已经有别的 PostgreSQL 在跑,要么换端口映射,要么改容器内监听端口。直接
-p 5432:5432会起不来,而且报错信息里只写bind: address already in use,不看日志不容易反应过来。
2.2 镜像 tag 到底该怎么选
pgvector/pgvector这个仓库的 tag 命名规则是<pgvector版本>-pg<PG大版本>或者直接pg<PG大版本>。比如pg16表示跟随 PostgreSQL 16 的最新补丁版、内置当前最新的pgvector;0.8.0-pg16这种则把扩展版本也钉死。
我的建议分两种场景:
| 场景 | 推荐 tag | 理由 |
|---|---|---|
| 学习、测试、内部 demo | pg16 | 跟随最新补丁,省心 |
| 生产环境 | 0.8.0-pg16这类钉死版本 | 避免某次docker pull之后扩展行为变化 |
| 需要特定系统库 | pg16-bookworm | 基于 Debian 12,glibc 版本较新 |
| 极小镜像 | pg16-alpine | 体积小,但部分扩展和字符集支持受限 |
alpine版本要谨慎,某些依赖 ICU 排序规则或者需要编译额外扩展的场景会出问题。我第一次用 alpine 时,中文排序行为和 bookworm 不一致,查了半天才发现是 locale 差异。生产环境我一般直接选默认的 Debian 版本,稳。
2.3 拉取镜像与本地验证
docker pull pgvector/pgvector:pg16拉完之后别急着起容器,先确认里面确实带扩展:
docker run --rm pgvector/pgvector:pg16 \ bash -c "ls /usr/share/postgresql/16/extension/ | grep vector"正常能看到vector.control、vector--0.8.0.sql之类的文件。如果这一行什么都没有,说明拉错了镜像——大概率是拉成了官方的postgres:16,那个镜像里没有pgvector,后面CREATE EXTENSION vector会直接告诉你extension "vector" is not available。
顺带看一眼容器里的 PG 版本和扩展版本:
docker run --rm pgvector/pgvector:pg16 \ bash -c "postgres --version; ls /usr/share/postgresql/16/extension/vector.control"记下这个版本号,后面写文档、排查兼容性问题都用得上。
3. 容器启动:从一条命令到可复现的编排
3.1 一条能跑起来的 docker run 命令拆解
先给能直接用的版本,再逐个参数说原因:
mkdir -p /data/pgvector/{pgdata,init} docker run -d \ --name pgvector16 \ --restart unless-stopped \ -p 5432:5432 \ -e POSTGRES_USER=vecadmin \ -e POSTGRES_PASSWORD='换成你自己的强密码' \ -e POSTGRES_DB=vectordb \ -e TZ=Asia/Shanghai \ -e PGDATA=/var/lib/postgresql/data/pgdata \ -v /data/pgvector/pgdata:/var/lib/postgresql/data \ -v /data/pgvector/init:/docker-entrypoint-initdb.d:ro \ --shm-size=1g \ --memory=8g \ pgvector/pgvector:pg16逐个说关键点:
-v /data/pgvector/pgdata:/var/lib/postgresql/data是数据持久化,这条不写等于白装,容器一删数据全没。PGDATA指向挂载目录下的子目录,是官方镜像推荐的写法。原因是挂载点本身被 Docker 接管后权限和lost+found目录会带来麻烦,放进子目录更干净。这一点在升级 PG 大版本时尤其重要。--shm-size=1g是最容易被漏掉的一条。PostgreSQL 并行查询、建索引都用共享内存,容器默认/dev/shm只有 64MB,跑大表建索引时会报could not resize shared memory segment ... No space left on device。这个报错跟磁盘无关,纯粹是 shm 太小,我第一次遇到时对着磁盘剩余空间看了半天。--memory=8g是给容器设硬上限,防止某个失控查询把宿主机拖死。TZ设时区,不设的话时间戳全按 UTC 存,日志和业务时间对不上,排查问题会很难受。
3.2 用 compose 固化配置,别手敲命令
docker run适合试水,长期用一定得上 compose。理由很实际:参数记不住、迁移时容易漏、团队里每个人的命令不一样。
services: pgvector: image: pgvector/pgvector:pg16 container_name: pgvector16 restart: unless-stopped environment: POSTGRES_USER: vecadmin POSTGRES_PASSWORD: ${PG_PASSWORD} POSTGRES_DB: vectordb TZ: Asia/Shanghai PGDATA: /var/lib/postgresql/data/pgdata ports: - "5432:5432" volumes: - ./pgdata:/var/lib/postgresql/data - ./init:/docker-entrypoint-initdb.d:ro - ./conf/postgresql.conf:/etc/postgresql/postgresql.conf:ro shm_size: 1g mem_limit: 8g command: - postgres - -c - config_file=/etc/postgresql/postgresql.conf healthcheck: test: ["CMD-SHELL", "pg_isready -U vecadmin -d vectordb"] interval: 10s timeout: 5s retries: 5密码放在同目录的.env里,PG_PASSWORD=xxx,别写进 yaml 提交到仓库。healthcheck加上之后,docker compose ps能直接看到healthy状态,脚本里等依赖服务启动时很好用。
3.3 初始化脚本让扩展自动就位
/docker-entrypoint-initdb.d这个目录里的.sql、.sh文件,只会在数据目录为空时执行一次。这个特性特别适合做扩展自举:
-- init/01-init.sql CREATE EXTENSION IF NOT EXISTS vector; CREATE EXTENSION IF NOT EXISTS pg_trgm; CREATE EXTENSION IF NOT EXISTS pg_stat_statements;这里有两个坑得说清楚:
第一个坑,初始化脚本只在首次启动生效。你的pgdata目录里一旦有了数据,改init/01-init.sql重启也不会执行。想重新触发,只能把pgdata清空——生产环境千万别这么干。后续要加扩展,老老实实手动连上去执行CREATE EXTENSION。
第二个坑,初始化脚本里如果写了CREATE DATABASE或者复杂的建表逻辑,出错会导致容器启动失败,而且日志藏在docker logs里。建议初始化脚本保持极简,只放扩展创建,业务建表交给应用侧的迁移工具。
启动之后验证一下:
docker compose up -d docker compose logs -f pgvector # 连进去看扩展 docker exec -it pgvector16 psql -U vecadmin -d vectordb -c "\dx" docker exec -it pgvector16 psql -U vecadmin -d vectordb -c "SELECT extversion FROM pg_extension WHERE extname='vector';"看到vector那一行,基础环境就算成了。
4. 数据库参数调优:让向量检索真的跑得起来
4.1 内存类参数怎么算
默认的postgresql.conf是按小机器配的,直接拿来跑向量检索会很憋屈。核心几个参数按下面这个逻辑算:
| 参数 | 建议值 | 计算依据 |
|---|---|---|
shared_buffers | 物理内存 25%,上限 8GB | 再大热数据命中率提升有限 |
effective_cache_size | 物理内存 50%~75% | 给优化器参考,不实际分配 |
work_mem | 64MB~256MB | 排序、哈希、召回过程中的临时内存 |
maintenance_work_mem | 2GB~8GB | 建索引专用,直接影响 HNSW 构建 |
max_parallel_workers_per_gather | 2~4 | 并行扫描 |
max_connections | 100~200 | 别设太大,每连接有固定开销 |
举例:一台 16GB 内存、专门跑向量库的机器,可以用这套:
shared_buffers = 4GB effective_cache_size = 10GB work_mem = 128MB maintenance_work_mem = 4GB max_parallel_workers_per_gather = 4 max_parallel_maintenance_workers = 4 random_page_cost = 1.1random_page_cost改成 1.1 是因为现在基本都是 SSD,默认的 4.0 会让优化器过度回避索引扫描,向量查询特别容易因此走到全表扫描。
一个反直觉的点:
work_mem不是越大越好。它是每个排序/哈希操作单独分配的,一条复杂查询里可能同时开好几个。设成 1GB 配上 100 个连接,理论峰值能吃掉 100GB。宁可按需在会话里SET work_mem,也别全局设太大。
4.2 建索引期间的临时参数
HNSW 建索引这一步是整个流程里最吃资源的。索引构建的内存需求大致跟行数 × (维度 × 4字节 + m × 2 × 8字节)成正比,粗算下来一百万行 1536 维、m=16,光图结构就要 1.5GB 以上,还不算排序和临时文件。
实际做法不是把全局maintenance_work_mem调得极高,而是针对性调整:
-- 建索引前 SET maintenance_work_mem = '8GB'; SET max_parallel_maintenance_workers = 4; CREATE INDEX CONCURRENTLY idx_items_embedding_hnsw ON items USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 128); -- 建完恢复 RESET maintenance_work_mem; RESET max_parallel_maintenance_workers;CONCURRENTLY的好处是不锁表,代价是构建时间更长、内存占用更高。线上表已经有人在读的时候必须加这个关键字,否则建索引期间写入全被阻塞。
4.3 连接数与运维侧的参数
容器环境下还有一个常被忽视的参数:listen_addresses。官方镜像默认是*,但如果你挂载了自定义postgresql.conf,记得确认这一项,不然容器起来了外部连不进去,docker logs里一片安静。
日志方面建议开log_min_duration_statement = 1000,慢查询超过 1 秒就记下来。向量检索的慢查询和普通 SQL 的慢查询形态不一样,前者往往是“索引没命中”或者“过滤后召回不足”,看执行计划比看耗时更有用。
另外把pg_stat_statements加上,采样向量查询的真实分布。这玩意儿在建扩展那一步顺手就建了,shared_preload_libraries里加上pg_stat_statements即可。注意这个参数必须重启容器才生效,reload不管用。
5. 建表、建索引与查询实战
5.1 向量类型的选型表
从 0.7 版本起,pgvector提供四种向量类型,选错了要么精度掉、要么索引建不上:
| 类型 | 单维占用 | 可建索引维度上限 | 适用场景 |
|---|---|---|---|
vector(n) | 4 字节(float4) | 2000 | 常规 embedding,精度要求高 |
halfvec(n) | 2 字节(float2) | 4000 | 高维模型、省内存省磁盘 |
bit(n) | 1 bit | 64000 | 二值化 embedding、哈希签名 |
sparsevec(n) | 非零元素存储 | 1000 非零项 | 稀疏检索(如 BM25 向量化) |
建表示例,用vector存 1536 维,同时保留业务字段和过滤字段:
CREATE TABLE IF NOT EXISTS doc_chunks ( id BIGSERIAL PRIMARY KEY, doc_id BIGINT NOT NULL, chunk_no INT NOT NULL, content TEXT NOT NULL, metadata JSONB DEFAULT '{}'::jsonb, embedding vector(1536), created_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX idx_doc_chunks_doc_id ON doc_chunks (doc_id); CREATE INDEX idx_doc_chunks_created ON doc_chunks (created_at DESC);注意embedding没有加NOT NULL,因为向量入库常常是异步的:先落文本,再补向量。如果你的流程里向量一定存在,加上NOT NULL能让优化器有更多信息。
5.2 HNSW 和 IVFFlat 到底选哪个
这是最高频的问题,直接上对比:
| 维度 | HNSW | IVFFlat |
|---|---|---|
| 构建速度 | 慢 | 快 |
| 索引体积 | 大(约数据量 1.5~2 倍) | 小 |
| 查询速度 | 快,召回高 | 中,依赖 probes |
| 增量插入 | 友好,直接插 | 会退化,需重建 |
| 内存需求 | 高 | 低 |
| 参数 | m、ef_construction、ef_search | lists、probes |
| 空表能建吗 | 能 | 不能(需要数据) |
结论很直白:数据会持续增量写入的,选 HNSW;数据一次性导入、之后只读的,IVFFlat 更省资源。
HNSW 参数怎么定:
m:每个节点的最大连接数,默认 16。召回要求高就调到 32,索引体积和构建时间都会跟着涨。一般 16~32 之间。ef_construction:构建时的候选队列长度,默认 64。调到 128 或 200 能明显提升召回,代价是构建变慢。100 万行以内建议 128 起步。ef_search:查询期参数,默认 40。这个可以运行时改,不用重建索引。
-- 会话级调高召回 SET hnsw.ef_search = 100;IVFFlat 的lists有个经验公式:行数小于 100 万时lists = rows / 1000,大于 100 万时lists = sqrt(rows)。查询时probes从sqrt(lists)起调。
5.3 写入与检索的完整示例
先看纯 SQL 的写入和近邻查询:
-- 写入(向量用字符串字面量传) INSERT INTO doc_chunks (doc_id, chunk_no, content, embedding) VALUES (1001, 0, '示例文本内容', '[0.0123,-0.0456,0.0789,...]'); -- 余弦距离近邻查询,取前 10 SELECT id, doc_id, content, 1 - (embedding <=> '[0.0123,-0.0456,0.0789,...]') AS similarity FROM doc_chunks WHERE embedding IS NOT NULL ORDER BY embedding <=> '[0.0123,-0.0456,0.0789,...]' LIMIT 10;距离算子必须记牢,用错算子索引直接失效:
| 算子 | 含义 | 配套索引 opclass |
|---|---|---|
<-> | 欧氏距离(L2) | vector_l2_ops |
<#> | 负内积 | vector_ip_ops |
<=> | 余弦距离 | vector_cosine_ops |
<+> | L1 距离 | vector_l1_ops |
关键点:ORDER BY里的算子必须和建索引时的 opclass 一致,否则索引不生效。比如索引建的是vector_cosine_ops,查询却用<->,优化器只能老老实实全表扫。
Python 侧的完整例子,用 psycopg3:
import psycopg from pgvector.psycopg import register_vector DSN = "postgresql://vecadmin:password@127.0.0.1:5432/vectordb" def search(vec, topk=10, threshold=0.7): with psycopg.connect(DSN) as conn: register_vector(conn) with conn.cursor() as cur: cur.execute("SET hnsw.ef_search = 100") cur.execute( """ SELECT id, content, 1 - (embedding <=> %s) AS score FROM doc_chunks WHERE embedding IS NOT NULL ORDER BY embedding <=> %s LIMIT %s """, (vec, vec, topk), ) return [(r[0], r[1], r[2]) for r in cur.fetchall() if r[2] >= threshold]register_vector这一步不能漏。它负责把 numpy 数组或者 Python list 转成 PG 能识别的向量字面量,不注册的话要么报类型转换错,要么被当成字符串存进去。
5.4 执行计划怎么看,索引到底有没有用上
验证索引是否生效,唯一可靠的方式是EXPLAIN ANALYZE:
EXPLAIN (ANALYZE, BUFFERS) SELECT id FROM doc_chunks WHERE embedding IS NOT NULL ORDER BY embedding <=> '[...]' LIMIT 10;看输出里有没有这一行:
Index Scan using idx_items_embedding_hnsw on doc_chunks ...如果出现的是Seq Scan,说明索引没用上。常见原因有四个:表太小优化器觉得全表扫更快、opclass 和算子不匹配、ORDER BY里做了表达式运算导致无法走索引、查询里带了WHERE过滤条件导致优化器选择先过滤。
还有一个隐蔽的情况:查询里写了ORDER BY embedding <=> $1 LIMIT 10但同时有WHERE doc_id = 123,如果过滤后行数很少,优化器会先走doc_id索引过滤再排序,结果就是向量索引完全没参与。这种场景要么建组合策略,要么用 0.8 版本之后的迭代扫描。
6. 踩坑实录与排查手册
6.1 扩展装不上、连不上、启动就挂
报错extension "vector" is not available八成是镜像用错了,拉成了postgres:16。检查docker inspect <容器> | grep -i image,确认镜像名是pgvector/pgvector:pg16。也可能是PGDATA指向了一个旧数据目录,那个目录是在没有扩展的环境下初始化的,CREATE EXTENSION找不到控制文件——这种情况检查/usr/share/postgresql/16/extension/vector.control是否存在,存在的话直接连上去手建扩展即可。
报错could not access file "$libdir/vector"扩展的控制文件在,但.so二进制不在或者路径不对。基本只发生在自己编译安装的场景,Docker 镜像不会有这个错。确认用了pg_config --pkglibdir指的目录。
容器起来就退出,docker logs只有一行initdb: error最常见的原因是挂载目录权限不对。容器内 postgres 用户 UID 是 999,宿主机目录如果是 root 拥有且 700 权限,initdb 写不进去。处理办法是先chown -R 999:999 /data/pgvector/pgdata,或者干脆让容器自己创建目录(去掉宿主机上的预先mkdir,让 Docker 生成)。
报错FATAL: password authentication failedPOSTGRES_PASSWORD只在那次初始化时生效。数据目录已经有数据的情况下改这个环境变量完全没用,你得连进去ALTER USER改密码。
| 现象 | 大概率原因 | 处理方式 |
|---|---|---|
| 启动后立刻退出 | 数据目录权限 / 端口占用 | 看docker logs,调整属主或端口 |
| 外部连不上 | listen_addresses未开 / 端口未映射 | 查 conf 和-p参数 |
| 扩展不可用 | 镜像错误 | 换pgvector/pgvector镜像 |
| 改密码无效 | 数据库已初始化 | 连进去ALTER USER |
6.2 索引相关的坑,一个比一个隐蔽
建索引时被 OOM 干掉报错类似server process was terminated by signal 9: Killed。这就是内存不够,被内核 OOM Killer 杀了。处理顺序:先加maintenance_work_mem,不行就把容器的--memory和--shm-size一起调大,还是不行就分批建——先建部分索引,再合并,或者干脆降维。
维度不匹配:expected 1536 dimensions, not 768换了 embedding 模型但没改表结构。vector的维度是强类型的一部分,改模型必须ALTER TABLE或者重建列。我的建议是建表时把维度写死在表定义里,同时在一张配置表里记录当前使用的模型名和维度,两处对照,换模型时不会漏。
建了 IVFFlat 索引但表是空的IVFFlat 需要在有数据的基础上做聚类,空表建索引会警告并且索引无效。正确顺序是:先灌数据,再建索引。HNSW 没有这个限制,空表也能建。
索引体积比数据大好几倍HNSW 就是这个特性,正常。如果磁盘吃紧,可以考虑halfvec存储,索引体积能砍一半,召回损失通常在 1% 以内,很多场景完全可接受。
6.3 容器层的那些坑
重启后数据全没了-v没挂或者挂载路径写错。检查docker inspect pgvector16 | grep -A5 Mounts,看Source是不是你预期的宿主机路径。
建索引时No space left on device,但df -h显示磁盘还有空间大概率是/dev/shm满了,不是磁盘满了。这就是前面说的--shm-size。用df -h /dev/shm在容器内确认一下:docker exec pgvector16 df -h /dev/shm。
docker compose restart之后参数没生效restart只是重启进程,不会重新读挂载的配置文件之外的东西。如果是改了postgresql.conf,restart能读到;但改了shared_preload_libraries这类需要重载动态库的,必须docker compose down && docker compose up -d真正重建容器。
备份恢复了但向量类型丢了pg_dump会导出CREATE EXTENSION语句,但目标库必须先有扩展可用。恢复顺序是:先在目标库CREATE EXTENSION vector,再pg_restore。反过来会报type "vector" does not exist。
6.4 一个我踩了很久的坑:过滤条件下的召回不足
这个坑值得单独拎出来说。假设查询是这样的:
SELECT id FROM doc_chunks WHERE metadata @> '{"lang":"zh"}' ORDER BY embedding <=> '[...]' LIMIT 10;如果lang=zh的行只占全表 1%,优化器会让 HNSW 索引先扫ef_search个候选,然后过滤,最后可能只剩 1~2 条,达不到LIMIT 10。表现出来就是"明明有数据,查出来却很少"。
三个应对办法,按推荐度排:
- 升级到支持
iterative scan的版本,让扫描自动扩大候选范围:SET hnsw.iterative_scan = relaxed_order; - 手动调大
hnsw.ef_search,代价是查询变慢。 - 建部分索引,把过滤条件写进索引定义:
CREATE INDEX idx_zh_hnsw ON doc_chunks USING hnsw (embedding vector_cosine_ops) WHERE metadata @> '{"lang":"zh"}';
第三个办法效果最好,但索引会变多,写入开销上升。我在中英混合语料的项目上就是给中文和英文各建了一个部分索引,查询性能提升很明显。
7. 我个人的几条使用建议
跑了一段时间下来,有几个习惯我觉得值得养成。
第一,容器里的 PG 参数一律走挂载的postgresql.conf,不要在容器里ALTER SYSTEM。容器是可替换的,配置放在宿主机文件里才能跟着版本控制走,换机器时复制目录就完事。
第二,向量表单独放一个表空间或者至少单独一张表,别跟业务主表混。原因是向量表动不动几十上百 GB,备份策略、清理策略、索引重建周期都跟业务表不一样,混在一起彼此拖累。
第三,ef_search这类查询期参数,在连接池层面设默认值,别指望每个查询都记得写SET。很多驱动支持连接初始化 SQL,把SET hnsw.ef_search = 100塞进去,后面写查询就不用心疼了。
第四,定期跑一次REINDEX检查。HNSW 索引在大量删除之后会留下无效节点,召回下降。可以用pg_stat_user_indexes观察索引大小和扫描次数的变化趋势,尺寸异常膨胀就重建。
第五,别在一个容器里塞全部数据。千万级向量的时候,单机单实例的瓶颈往往出现在建索引的内存峰值和磁盘 IO 上。真到那一步,分区表加多个 HNSW 索引,比硬扛一个超大索引要靠谱得多。
这套环境我从三年前的 0.4 版本一路用到现在,中间经历过两次大版本升级、一次磁盘故障恢复、无数次参数重调。回头看,最难的不是装,是搞清楚它在什么情况下会不按预期工作。把上面这些边界和坑摸清楚之后,pgvector加 Docker 这个组合的稳定性其实相当够用——至少对于绝大多数业务场景来说,它省下来的运维复杂度,远比那几十毫秒的检索差距值钱。