向量数据库
向量数据库
一、工程案例:用 pgvector 与专用向量库各建一个索引,对比召回与延迟
场景:一个企业知识库已经切好 20 万条片段,每条有一个 768 维向量,另有一个 dept(部门)过滤字段,取值刻意做成偏斜——tech 只占 1%。团队在选型:继续用已有的 PostgreSQL 加 pgvector,还是单独部署一个专用向量库。
为什么必须实测:两边的宣传页都会写「毫秒级」「高召回」,但召回率和延迟是一对互相拉扯的数字——把 ef_search 调到 1000,任何库都能接近 100% 召回,代价是延迟翻几倍。只比其中一个数字,等于什么都没比。
做法:同一批向量灌进两个库,用同一批 200 条查询,以暴力检索的结果为真值,测三件事——Recall@10、p50/p95 延迟、带过滤时的召回。三个数字一起看,才有选型结论。
规模说明:用 20 万条 × 768 维,是因为它能在单机上跑完整个流程(向量约 614 MB)。维度只影响常数因子,不影响方法;换成 1536 维,把下面所有内存数字乘 2 即可。
1.1 环境
# docker-compose.yml
services:
pg:
image: pgvector/pgvector:pg16
environment:
POSTGRES_PASSWORD: postgres
POSTGRES_DB: bench
ports: ["5432:5432"]
shm_size: 2gb # 并行建索引要用动态共享内存,别只按 shared_buffers 算
command: >
postgres -c shared_buffers=2GB -c maintenance_work_mem=1GB
-c max_parallel_maintenance_workers=2
qdrant:
image: qdrant/qdrant:latest
ports: ["6333:6333"]
volumes: ["./qdrant_storage:/qdrant/storage"]docker compose up -d
docker compose exec pg psql -U postgres -d bench -c "CREATE EXTENSION IF NOT EXISTS vector;"
curl -s http://localhost:6333/collections # 返回 JSON 即 Qdrant 就绪python -m venv .venv && source .venv/bin/activate
pip install numpy "psycopg[binary]" pgvector qdrant-client镜像 tag 只是锚点:pg16 用于复现,Qdrant 用 latest。两者的具体行为、以及客户端 API 的命名,都以官方当前稳定版为准——近几个版本的 qdrant-client 就改过查询入口的名字。
maintenance_work_mem 直接决定 HNSW 建索引的速度:默认 64MB 时,20 万条要建很久,调到 1GB 后差距是数量级的。这个参数只影响建索引与 VACUUM,不影响查询,建完可以调回去。
1.2 数据与真值
# 01_gen_data.py
import numpy as np
N, DIM, NQ = 200_000, 768, 200
rng = np.random.default_rng(42)
def unit(n: int, dim: int) -> np.ndarray:
v = rng.standard_normal((n, dim), dtype=np.float32)
return v / np.linalg.norm(v, axis=1, keepdims=True)
DOCS = unit(N, DIM) # 文档向量
QUERIES = unit(NQ, DIM) # 查询向量
np.save("docs.npy", DOCS)
np.save("queries.npy", QUERIES)
# 部门分布刻意做偏斜:tech 只占 1%,用来测过滤
depts = rng.choice(["tech", "hr", "fin", "ops"], size=N, p=[0.01, 0.29, 0.30, 0.40])
np.save("depts.npy", depts)
print(DOCS.nbytes / 1e6, "MB", DOCS.shape)⚠️ 随机向量只能测索引机制,不能代表真实效果。高维随机向量彼此近似正交,最近邻与次近邻的分数几乎一样,这是 ANN 最难受的分布;真实文本嵌入有明显的簇结构,通常更容易。所以这张表上的绝对召回率偏低是正常的,两个库之间的相对差异才有意义。要评估真实效果,把 DOCS 换成真实嵌入,其余代码不变。
真值用暴力算,分块避免一次吃掉几百 MB:
# 02_ground_truth.py
import numpy as np
DOCS = np.load("docs.npy")
QUERIES = np.load("queries.npy")
K, CHUNK = 10, 20_000
truth = np.empty((len(QUERIES), K), dtype=np.int64)
for qi, q in enumerate(QUERIES):
best = np.full(K, -np.inf, dtype=np.float32)
best_idx = np.zeros(K, dtype=np.int64)
for s in range(0, len(DOCS), CHUNK):
part = DOCS[s:s + CHUNK] @ q
cand = np.concatenate([best, part])
idx = np.concatenate([best_idx, np.arange(s, s + len(part))])
top = np.argpartition(-cand, K - 1)[:K]
best, best_idx = cand[top], idx[top]
truth[qi] = best_idx[np.argsort(-best)]
np.save("truth.npy", truth)
print(truth.shape)跑 python 01_gen_data.py && python 02_ground_truth.py,应得到 docs.npy、queries.npy、depts.npy、truth.npy 四个文件,truth.shape 是 (200, 10)。
分块是必须的:QUERIES @ DOCS.T 一次性要 200 × 200000 × 4 字节 = 160 MB,看着不大,但 argpartition 的中间数组会再翻几倍。内存估算永远要按峰值算,不是按结果算。
1.3 pgvector:灌数、建索引、查询
-- schema.sql
CREATE TABLE items (
id BIGSERIAL PRIMARY KEY,
ext_id INT NOT NULL UNIQUE,
dept TEXT NOT NULL,
embedding vector(768) NOT NULL
);docker compose exec -T pg psql -U postgres -d bench -f - < schema.sql
python 03_load_pg.py# 03_load_pg.py
import numpy as np, psycopg
from pgvector.psycopg import register_vector
DOCS, DEPTS = np.load("docs.npy"), np.load("depts.npy")
conn = psycopg.connect("postgresql://postgres:postgres@localhost:5432/bench")
register_vector(conn) # 不注册,numpy 数组没法作为 vector 参数或 COPY 值传进去
with conn.cursor() as cur:
cur.execute("TRUNCATE items RESTART IDENTITY")
with cur.copy("COPY items (ext_id, dept, embedding) FROM STDIN") as copy:
for i in range(len(DOCS)):
copy.write_row((i, str(DEPTS[i]), DOCS[i]))
conn.commit()
print("rows:", conn.execute("SELECT count(*) FROM items").fetchone()[0])COPY 比 executemany 快一个数量级,因为省掉了逐条解析 SQL。这里用默认的文本格式:二进制 COPY 要求每个类型都有二进制 dumper,pgvector 的 psycopg 适配器只提供文本形式,写 FORMAT BINARY 会直接报错。先灌完数据再建索引——顺序反了,每插一行都要维护图结构,20 万条会慢到无法接受。
-- 建索引(灌完数据之后)
CREATE INDEX items_emb_idx ON items
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
CREATE INDEX items_dept_idx ON items (dept); -- 过滤列建议有 B-tree:选择性高时能用上,没有就只能顺序扫
ANALYZE items; -- 让优化器知道数据分布(含 dept 的偏斜)# query_pg.py (单独成模块,供 1.5 / 1.6 的脚本 import)
import numpy as np, psycopg
from pgvector.psycopg import register_vector
conn = psycopg.connect("postgresql://postgres:postgres@localhost:5432/bench")
register_vector(conn)
def search_pg(q: np.ndarray, k: int = 10, dept: str | None = None,
ef: int = 40) -> list[int]:
conn.execute(f"SET hnsw.ef_search = {ef}") # 只影响当前会话
if dept:
rows = conn.execute(
"SELECT ext_id FROM items WHERE dept = %s"
" ORDER BY embedding <=> %s LIMIT %s", (dept, q, k)).fetchall()
else:
rows = conn.execute(
"SELECT ext_id FROM items ORDER BY embedding <=> %s LIMIT %s",
(q, k)).fetchall()
return [r[0] for r in rows]三个必须记住的点:<=> 是余弦距离(升序,越小越近);ORDER BY 里必须直接写操作符表达式;索引的 vector_cosine_ops 必须与 <=> 配对,写成 vector_l2_ops 就用不上索引。
1.4 Qdrant:同一批数据
# 05_load_qdrant.py
import numpy as np
from qdrant_client import QdrantClient, models
DOCS, DEPTS = np.load("docs.npy"), np.load("depts.npy")
qd = QdrantClient(url="http://localhost:6333")
if qd.collection_exists("items"):
qd.delete_collection("items")
qd.create_collection(
"items",
vectors_config=models.VectorParams(size=DOCS.shape[1],
distance=models.Distance.COSINE),
hnsw_config=models.HnswConfigDiff(m=16, ef_construct=64))
qd.create_payload_index("items", "dept",
field_schema=models.PayloadSchemaType.KEYWORD)
B = 2000
for s in range(0, len(DOCS), B):
last = s + B >= len(DOCS)
qd.upsert("items", wait=last, points=[ # 最后一批等落盘
models.PointStruct(id=i, vector=DOCS[i].tolist(),
payload={"dept": str(DEPTS[i]), "ext_id": i})
for i in range(s, min(s + B, len(DOCS)))])
print(qd.count("items").count)跑 python 05_load_qdrant.py,最后打印的计数应等于 200000;小于这个数说明有批次没写完。
# query_qdrant.py (单独成模块,供 1.5 / 1.6 的脚本 import)
import numpy as np
from qdrant_client import QdrantClient, models
qd = QdrantClient(url="http://localhost:6333")
def search_qd(q: np.ndarray, k: int = 10, dept: str | None = None,
ef: int = 40) -> list[int]:
flt = None
if dept:
flt = models.Filter(must=[models.FieldCondition(
key="dept", match=models.MatchValue(value=dept))])
res = qd.query_points(
"items", query=q.tolist(), limit=k, query_filter=flt,
search_params=models.SearchParams(hnsw_ef=ef, exact=False))
return [p.payload["ext_id"] for p in res.points]query_points 是新版客户端的统一入口;旧版是 client.search(...),参数名也不同。客户端版本迭代很快,以装到的那个版本的文档为准,不要照抄博客里的调用。
1.5 召回与延迟
# metric.py
import numpy as np
def recall_at_k(got: list[list[int]], truth: np.ndarray, k: int = 10) -> float:
return float(np.mean([len(set(g[:k]) & set(truth[i][:k].tolist())) / k
for i, g in enumerate(got)]))# 07_bench.py
import time
import numpy as np
from metric import recall_at_k
from query_pg import search_pg
from query_qdrant import search_qd
QUERIES, TRUTH = np.load("queries.npy"), np.load("truth.npy")
def run(fn, ef: int, dept: str | None = None) -> dict:
for q in QUERIES[:50]: # 预热 50 条,排除首次连接与页缓存影响
fn(q, 10, dept, ef)
lat, got = [], []
for q in QUERIES:
t0 = time.perf_counter()
got.append(fn(q, 10, dept, ef))
lat.append((time.perf_counter() - t0) * 1000)
lat = np.array(lat)
return {"recall": round(recall_at_k(got, TRUTH), 4),
"p50": round(float(np.percentile(lat, 50)), 2),
"p95": round(float(np.percentile(lat, 95)), 2)}
for ef in (16, 40, 100, 200):
print("pg", ef, run(search_pg, ef))
print("qd", ef, run(search_qd, ef))跑 python 07_bench.py,把输出逐行填进下表。
| 库 | ef | Recall@10 | p50 (ms) | p95 (ms) |
|---|---|---|---|---|
| pgvector | 16 | <实测> | <实测> | <实测> |
| pgvector | 40 | <实测> | <实测> | <实测> |
| pgvector | 100 | <实测> | <实测> | <实测> |
| pgvector | 200 | <实测> | <实测> | <实测> |
| Qdrant | 16 | <实测> | <实测> | <实测> |
| Qdrant | 40 | <实测> | <实测> | <实测> |
| Qdrant | 100 | <实测> | <实测> | <实测> |
| Qdrant | 200 | <实测> | <实测> | <实测> |
<实测> 是待填值,必须在同一台机器、同一个 ef 网格上跑完再填,不能跨机器拼数字。另外两边都有传输开销,但 Qdrant 多一层 HTTP/JSON 序列化——要么统一报告「客户端看到的延迟」,要么在结论里写明这个偏差,别把它算进引擎本身。
读表方法:先定一个可接受的 Recall(比如 0.95),再看达到它的那一行延迟。两个库在同等召回下的延迟才有可比性——A 库 Recall 0.90 用 2ms、B 库 Recall 0.98 用 4ms,不能说 A 更快。
ef 的扫描同时告诉你一件事:召回对参数有多敏感。如果 ef 从 16 到 200,Recall 只从 0.93 涨到 0.96,说明默认参数已经很够,没必要加延迟。
1.6 带过滤的召回
tech 只占 1%,这是最容易出问题的地方:先取 Top-10 再过滤,很可能一条都不剩。
# 08_filtered.py
import numpy as np
from metric import recall_at_k
from query_pg import search_pg
from query_qdrant import search_qd
DEPTS = np.load("depts.npy")
mask = DEPTS == "tech"
def filtered_truth(k: int = 10) -> np.ndarray:
"""只在 tech 子集里算真值。"""
docs, qs = np.load("docs.npy")[mask], np.load("queries.npy")
out = np.empty((len(qs), k), dtype=np.int64)
sub = np.flatnonzero(mask)
for i, q in enumerate(qs):
out[i] = sub[np.argsort(-(docs @ q))[:k]]
return out
FT = filtered_truth()
for name, fn in (("pgvector", search_pg), ("qdrant", search_qd)):
for ef in (40, 100, 200):
got = [fn(q, 10, "tech", ef) for q in QUERIES]
print(name, ef, round(recall_at_k(got, FT), 4))跑 python 08_filtered.py。这里 recall_at_k 与查询都复用前面的模块,别把 FT 换成不带过滤的真值,否则测的是两件事。
| 库 | ef=40 | ef=100 | ef=200 | 说明 |
|---|---|---|---|---|
| pgvector | <实测> | <实测> | <实测> | 优化器可能改走 dept 索引再排序 |
| Qdrant | <实测> | <实测> | <实测> | 过滤在遍历中生效,受 payload 索引影响 |
两个库在这里的行为不一样,而且都不是「调大 ef 就一定好」:pgvector 由查询规划器决定走向量索引还是 dept 索引,选择率 1% 时它很可能选择先过滤;Qdrant 把过滤下推进 HNSW 遍历,但过滤条件越苛刻,可达的候选越少,需要更大的 hnsw_ef 才能凑够 K 条。必须用 EXPLAIN ANALYZE 看 pgvector 到底走了哪条路。
1.7 验收
| # | 检查项 | 命令或检查 | 通过标准 |
|---|---|---|---|
| 1 | 索引存在且类型正确 | \d items | 出现 hnsw (embedding vector_cosine_ops) |
| 2 | 查询真的走索引 | EXPLAIN ANALYZE ... | 计划里是 Index Scan 或 Bitmap Index Scan,不是 Seq Scan |
| 3 | 操作符与 ops 匹配 | 索引定义 vs ORDER BY | vector_cosine_ops ↔ <=> |
| 4 | 默认参数召回 | 1.5 的表 | 真实嵌入 ≥ 0.95;随机向量只看相对值 |
| 5 | 延迟 | p95 | < 20 ms(20 万条、单机) |
| 6 | 过滤召回 | dept='tech'(1%) | 真实嵌入 ≥ 0.90,且返回条数 = K |
| 7 | 过滤列有索引 | \d items / Qdrant payload index | 两边都有 |
| 8 | 灌数顺序 | 建索引语句在 COPY 之后 | 是 |
| 9 | 维度一致 | vector(768) vs 向量长度 | 相等 |
| 10 | 删除能回收 | VACUUM FULL / REINDEX;专用库等 optimizer 合并完 | 体积回落或不再增长 |
| 11 | 备份可恢复 | pg_dump 恢复到一个空库 | 行数一致、索引重建成功 |
| 12 | 压测可复现 | 固定 seed、固定 ef | 两次结果差异 < 2% |
第 6 项要同时看「召回」和「条数」。返回 8 条但召回 0.95,和返回 10 条但召回 0.90,是两个不同的问题:前者是候选池不够,后者是候选不够准。
1.8 结论怎么写
20 万条 × 768 维、200 条查询、单机 4 核 16G:
pgvector HNSW(ef_search=40):Recall@10 = A,p95 = B ms,含 dept 过滤召回 = C
Qdrant HNSW(hnsw_ef=40): Recall@10 = D,p95 = E ms,含 dept 过滤召回 = F
达到同等召回(0.95)时:pgvector 需要 ef=X、Qdrant 需要 ef=Y,延迟分别为 ...
→ 数据量 < 百万且已在用 PostgreSQL:pgvector 胜在少一个组件,性能差距不足以抵消运维成本
→ 过滤条件苛刻、或需要多副本与分片:专用向量库的过滤与扩展能力更值钱选型结论必须带规模与条件。同一组数字在 20 万条上成立,到 2000 万条可能完全反过来——HNSW 的图会大到装不进内存,那时比的是分片与量化能力,不是单机延迟。
二、高频问答:会卡住你的地方,也是面试最常问的
2.1 索引建了,查询还是几百毫秒
现场
EXPLAIN ANALYZE 看计划节点是 Index Scan 还是 Seq Scan:
-- ❌ 包了一层计算,索引用不上
ORDER BY 1 - (embedding <=> '[...]') DESC
-- ✅ 直接写操作符
ORDER BY embedding <=> '[...]'要点
- 索引在不在,
\d一看就知道;问题在用不用,重建索引只会浪费时间。 - 三种最常见失效:
ORDER BY里包了一层计算、操作符与ops不匹配、表太小优化器主动选择全表扫描。 - 先
SET enable_seqscan = off强制走索引:这时变快说明是优化器主动放弃,通常是表小或统计信息过期;还是慢说明索引根本没被匹配上。
追问
- 为什么重建索引没用?→ 写法没变,优化器给的计划也不会变。
- 怎么确认
ops配对?→vector_cosine_ops配<=>,vector_l2_ops配<->。 - 表小为什么不用索引?→ 顺序扫更便宜,优化器的选择是理性的。
2.2 加了过滤条件后,结果不足 K 条
现场
去掉过滤再查一次,看 Top-K 里符合条件的有几条;再算一下选择率:
SELECT count(*) FROM items WHERE dept = 'tech'; -- 1% 的选择率要点
- 「先检索后过滤」:先取 Top-10,再筛掉不符合条件的,筛完只剩两三条,过滤越苛刻越明显。
- 数据没丢,是候选池太小——Top-10 里本来就只有 0.1 条属于 tech,怎么筛都不够。
- 过滤后剩 1% 时,要凑够 10 条候选至少取 1000 条,还要留余量。
追问
- 怎么改?→ 放大
LIMIT/hnsw.ef_search再过滤,或把过滤下推到索引遍历里。 - 返回 8 条但召回 0.95 是什么问题?→ 候选池不够,不是排序不准。
- 哪些库支持过滤下推?→ Qdrant、Milvus 的常见做法,pgvector 要看版本与计划。
2.3 空表上建 ivfflat,召回率极低
现场
看建索引时的行数;空表建完后插入数据,再测召回:
-- ❌ 顺序反了:表还是空的就建索引
CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
INSERT INTO items ...; -- 数据进来时,簇心早已按空表定死
-- ✅ 先灌数据(条数要远多于 lists,否则簇心没有代表性),再建索引
INSERT INTO items ...;
CREATE INDEX ON items USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);要点
- ivfflat 需要训练:建索引时对现有数据跑 K-Means 得到
lists个簇心。 - 空表没有数据可训练,簇心为空或随机,之后插入的数据照样被分到这些无效簇上。
- 不是算法优劣,是使用顺序错了;同样的 ivfflat,先有数据再建,召回能正常。
追问
- 为什么重建就能好?→ 建索引时终于有数据可训练簇心。
lists怎么取?→ 百万行以内取行数/1000,更大取sqrt(行数)。- HNSW 也要先灌数据吗?→ HNSW 支持增量构建,但批量导入仍然是先灌后建更快。
2.4 插入报维度不匹配
现场
看报错里的两个数字,再查代码里的 model 参数是否一致:
ERROR: expected 768 dimensions, not 1536要点
- 建表写的是
vector(768),送进来的是 1536 维:换了模型,或者两处代码用了不同模型。 - 这个报错是好事,它把「模型混用」这种隐性错误挡在了入库阶段;真正危险的是不校验维度的库。
- 换模型必须全量重算,改列类型不算;代码里只能有一个地方决定模型。
追问
- 只把列类型改大行不行?→ 维度对了语义也不对,旧向量在新空间里是随机数。
- 怎么防止再犯?→ 只留一处模型配置,启动时校验维度与库表一致。
- 报错是不是库或驱动坏了?→ 不是,这是类型检查在正常工作。
2.5 Milvus 插入成功,但查不到
现场
先看 get_load_state 是否 Loaded;再把这次查询的 consistency_level 提到 Strong 试一次:
col.load() # 搜索前集合必须处于 Loaded 状态
col.insert(rows)
col.flush() # 进 sealed segment:索引与 num_entities 才跟上要点
- 两个独立原因:集合没
load()到查询节点;或者一致性级别低于写入确认,刚写的行对这次查询还不可见。 - 重插会产生重复数据,问题在可见性不在写入;按主键
get一次就能分清。 num_entities统计的是已 flush 的数据,未 flush 时会偏小,不能用来判断刚插入的行是否可查。
追问
- 怎么区分是没写进去还是读不到?→ 按主键
get,能取到就是搜索侧的加载或一致性配置。 - 为什么不能每条都 flush?→ 写入会变得极慢,flush 要放在批次边界。
- 默认一致性是什么?→ 各版本有差异,以官方当前稳定版为准,别照抄博客。
2.6 删除数据后,内存和磁盘都没降
现场
对比 count(*) 与索引/数据目录体积,再看是否有 pending 的合并任务:
DELETE FROM items WHERE dept = 'ops';
VACUUM FULL items; -- 真正重写表并回收空间,会锁表要点
- 向量索引的删除大多是标记删除(tombstone),被删的向量仍占着图节点或段文件,要等合并/清理才真正释放。
count(*)降了说明删除生效了,没降的是物理空间;重复删除只会多写一遍 WAL。- pgvector 的 HNSW 索引在大量删除后需要
REINDEX才能收紧;专用向量库一般有compact或 optimizer 任务。
追问
- 怎么量化回收效果?→ 看
pg_relation_size('items')前后对比。 VACUUM FULL有什么代价?→ 锁表重写,生产要挑窗口。- 无效节点多了会影响查询吗?→ 会,遍历到大量已删节点会变慢。
2.7 建 HNSW 索引跑了几个小时
现场
看建索引时的行数、maintenance_work_mem 当前值、CPU 利用率:
SET maintenance_work_mem = '2GB';
SET max_parallel_maintenance_workers = 2;
CREATE INDEX ... USING hnsw (...);要点
- 三个常见原因:边插数据边建索引、
maintenance_work_mem太小导致落盘排序、max_parallel_maintenance_workers为 0 没用上并行。 - HNSW 构建是 CPU + 内存密集型,加机器之前先把这两个参数调对,收益通常是数倍。
maintenance_work_mem只影响建索引与 VACUUM,不影响查询,建完可以调回去。
追问
- 数据边写边建,加机器能救吗?→ 救不了,顺序错了,先灌完再建。
- 怎么验证是参数问题?→ 5 万条小样本上分别用默认值和调参后各建一次。
- 并行 worker 开多大?→ 不超过可用核数,还要和
shm_size对得上。
2.8 并发一上来,查询延迟就崩
现场
固定 QPS 逐步加压,看 p95 随并发的曲线拐点;同时看 CPU 是否打满。
要点
- 单查询快不等于能扛并发;HNSW 查询是 CPU 密集的,每个查询都要遍历图。
- 连接数超过核数后,排队时间会盖过计算时间,曲线在某个点突然上翘。
- 这不是索引的缺陷,是容量规划的问题:要么加核,要么降
ef,要么加副本。
追问
- 怎么定需要几台?→ 并发从 1 加到 2、4、8、16 记录 p95,用拐点倒推。
- 加副本能提召回吗?→ 不能,只提吞吐和可用性。
- 降
ef的代价是什么?→ 召回下降,要和评测集一起看。
2.9 按「向量总大小」买内存,结果 OOM
现场
对比进程 RSS 与 行数 × 维度 × 4:
堆表里的向量:20 万 × 768 × 4 B ≈ 614 MB
HNSW 索引:1.3~1.6 倍 ≈ 0.8~1.0 GB(索引自带一份向量 + 连接)
两副本就再乘 2要点
- 只算了向量本身,没算索引结构:HNSW 除了连接列表还存了一份向量,加上页填充,整体通常比裸向量大 30%~60%。
- 还要算连接缓冲、副本与重建时的临时空间,
REINDEX要能同时放下新旧两份。 - 泄漏是「持续增长不回」,OOM 是「一开始就不够」,看 RSS 曲线是平的还是爬升的就能分开。
追问
- 怎么估得准?→ 先用 1/10 数据实测每百万条的实际占用,再线性外推。
- 为什么索引比向量还大?→ 自带一份向量 + 连接列表 + 8 KB 页里的填充。
- 调大
m会怎样?→ 内存线性涨,召回提升变小。
2.10 换了 Embedding 模型,表结构锁死
现场
取几条旧向量与新模型的查询向量算相似度,看是否接近随机:
-- ❌ 原地改类型:旧数据维度不符会直接报错,改成功也不代表语义兼容
ALTER TABLE items ALTER COLUMN embedding TYPE vector(1024);
-- ✅ 新表 + 全量重算 + 评测 + 切流量
CREATE TABLE items_v2 (
id BIGSERIAL PRIMARY KEY,
ext_id INT NOT NULL UNIQUE,
dept TEXT NOT NULL,
embedding vector(1024) NOT NULL
);要点
- 维度可以改,但向量空间不兼容;旧向量在新模型的空间里是无意义的坐标。
- 改列类型只是让它们「能存进去」,把错误推迟到检索阶段;真正的迁移是重建。
- 正确顺序:新表 + 全量重算 + 同一评测集对比 + 双写切流量。
追问
- 同维度换模型也要重建吗?→ 要,空间不同,相似度接近随机。
- 怎么切流量?→ 新表灌全量、对比达标后双写,观察一段再删旧表。
- 旧表能直接 ALTER 吗?→ 不能指望旧数据还能用。
2.11 召回率测出来很好,上线却很差
现场
检查真值是不是暴力算的、查询是否来自真实分布、是否与文档集重叠;再随机抽 20 条查询人工看 Top-10。
要点
- 这是测量错误,不是分布差异;用近似结果当真值,测的是「两个索引像不像」,不是「召回高不高」。
- 常见四种:用文档向量当查询(自己查自己)、只测 Top-1、真值用同一个近似索引算、拿随机向量的绝对召回当业务指标。
- 人工判断和脚本算出的召回差很多,就是评测代码的问题。
追问
- 用另一个 ANN 当真值会怎样?→ 两个都错时还可能显示 100%。
- 为什么自己查自己必然准?→ 查询就是文档本身,最近邻一定是它。
- 离线指标和线上还背离怎么办?→ 留一个小的线上 A/B,看点击和任务成功率。
2.12 3072 维的模型,索引直接建不出来
现场
\d+ items 先确认列类型与维度;看建索引报错里是否出现维度上限:
-- ❌ 3072 维直接建 HNSW:超过 vector 的索引维度上限
CREATE INDEX ON items USING hnsw (embedding vector_cosine_ops);
-- ✅ 换 halfvec:索引上限 4000 维,内存还省一半
ALTER TABLE items ADD COLUMN embedding_h halfvec(3072);
CREATE INDEX items_emb_idx ON items USING hnsw (embedding_h halfvec_cosine_ops);要点
- pgvector 的
vector列能存到 16000 维,但 HNSW/IVFFlat 索引对vector的上限是 2000 维。 - 1536 维刚好在界内,3072 维的模型一建索引就报维度超限;这不是内存或算法问题,是类型上限。
- 两条出路:换
halfvec,或按 Matryoshka 截断到 2000 维以内再建。
追问
- 加大
maintenance_work_mem有用吗?→ 没用,只会让它更慢地失败。 halfvec的代价是什么?→ fp16 有精度损失,旧列数据要转换过去。- 1536 维会受影响吗?→ 不会,在界内。
三、核心知识:从零开始要建立的东西
3.1 向量检索的计算代价
暴力检索的复杂度是 O(N·d):N 条向量,每条 d 维,每个查询都要全部比一遍。
20 万 × 768 维 × 4 字节 ≈ 614 MB 每次查询都要读一遍
100 万 × 1536 维 × 4 字节 ≈ 6.1 GB 已经超过多数机器的有效缓存关键不在浮点运算次数,而在内存带宽:数据必须从内存搬到 CPU,带宽是固定的,所以耗时基本随 N 线性增长。这也是为什么「加 CPU 核」对单查询没用——瓶颈不在算,在搬。推论有三条:数据量小时暴力检索完全够用(几万条以内);上了百万级必须有索引;索引的本质是少读数据,不是算得更快。
3.2 精确检索与近似检索
| 精确(暴力) | 近似(ANN) | |
|---|---|---|
| 召回率 | 100% | 可调,通常 90%~99% |
| 复杂度 | O(N·d) | 接近 O(log N) 或 O(候选数·d) |
| 结果稳定性 | 完全确定 | 受参数与数据分布影响 |
| 适用 | 几万条以内、需要精确 | 十万条以上 |
ANN 的合法性来自一个事实:检索的下游通常只需要「足够好」的 Top-K。RAG 取 Top-5 喂给模型,第 3 名和第 5 名换个位置,最终答案往往不变;人眼看搜索结果,前 10 条里少一条相关项也很难察觉。但它有一个不能越过的边界:当「找全」本身是业务要求时,ANN 不合适。去重、法务取证、风控名单匹配这类场景,漏一条就是事故,这时要么用精确检索,要么用 ANN 召回后再精确复核。
3.3 召回率的定义与测量
Recall@K = |ANN 返回的 Top-K ∩ 真实 Top-K| / K这个定义有一个容易忽略的前提:真值必须是精确算出来的。用另一个 ANN 的结果当真值,测出来的是「两个索引的一致性」,两个都错的时候还会显示 100%。
测量的四个纪律:
| 纪律 | 原因 |
|---|---|
| 真值用暴力算 | 否则测的不是召回 |
| 查询来自真实分布 | 用文档自身当查询会虚高 |
| 报告 K 值 | Recall@1 和 Recall@100 是两件事 |
| 固定随机种子与参数 | 否则两次结果不可比 |
还有一个更贴近业务的指标:端到端命中率——把检索结果交给下游(人或大模型),看最终任务成功率。召回率涨了但答案质量没涨,说明瓶颈不在检索。
3.4 HNSW 的图结构
HNSW 是一张多层图:上层稀疏、跨度大,下层稠密、连接近邻。查询从顶层入口开始,贪心地往离目标更近的邻居走,走到局部最优就下沉一层,直到最底层。
这个结构和跳表同源:用少量长边换取「不用遍历全部节点」。代价有两块:每个节点要额外存连接列表,索引里还要再存一份向量。两块加起来,索引通常是向量数据的 1.3~1.6 倍(3.11 有估算)。插入是增量的:新节点随机分配层数,然后在各层找邻居连边。所以 HNSW 支持边写边查,但写入会触发邻居列表的调整,批量导入时先建表后建索引比边写边建快得多。
面试:常问「HNSW 为什么查得快,代价是什么?」答:多层图用长边跳过大多数节点,代价是内存里多存一份向量和连接。
3.5 HNSW 的三个参数
| 参数 | 作用时机 | 调大的后果 | 调小的后果 |
|---|---|---|---|
m | 建索引 | 连接多,召回高,内存大 | 内存小,长程连通性差 |
ef_construction | 建索引 | 图质量高,建得慢 | 建得快,图质量差 |
ef_search | 查询 | 召回高,延迟高 | 快,召回低 |
调优顺序只有一条:召回不够,先加 ef_search——它是查询时参数,随时可改,不用重建索引;还不够,再重建索引加 m 和 ef_construction。
ef_search 要明显大于 K。设成 10 却要 Top-10,候选池刚好只有 10 个,图走错一步就没有备选,召回会明显偏低;各实现对 ef < K 的兜底并不一致,别依赖它。参数名也各不相同:pgvector 是 hnsw.ef_search,Qdrant 是 hnsw_ef,Milvus 的搜索参数写作 ef。m 的经验区间是 16~64:超过 64 之后召回提升很小,内存却线性涨;低于 8 时图容易断开,表现为「某些查询的召回突然很差」。
3.6 IVF 的粗量化与 nprobe
IVF 先用 K-Means 把向量聚成 nlist 个簇,每个向量记录自己属于哪个簇;查询时只算查询到各簇心的距离,选最近的 nprobe 个簇,在簇内暴力比。
pgvector 里这两个参数叫 lists 与 ivfflat.probes(会话级 SET ivfflat.probes = 10),Milvus 沿用 nlist/nprobe;名字不同,含义一样。
计算量 ≈ (nlist × d) + (nprobe / nlist) × N × d
nlist=1000、nprobe=10 时,第二项只有全量的 1%| 参数 | 含义 | 经验值 |
|---|---|---|
nlist | 簇数 | 百万行以内 行数/1000,更大用 sqrt(行数) |
nprobe | 查询几个簇 | 1~32,越大越准越慢 |
IVF 的软肋在边界:查询落在两个簇的交界处时,真正的近邻可能在没被选中的簇里,这时只调 nprobe 只能缓解,不能消除系统性误差。数据分布越均匀,这个问题越轻。它比 HNSW 省内存(不存图),但需要训练、增量插入效果差,适合「数据量大、内存紧、更新少」的场景,比如离线构建的商品库。
在这个库里建哪种索引,按三个问题走:
这张图只回答「建哪种索引」,回答不了「选哪个库」——那是 3.15 的事。
3.7 量化:PQ 与标量量化
量化的思路是把 float32 换成更小的表示,用精度换内存。
| 方式 | 做法 | 压缩比 | 代价 |
|---|---|---|---|
| 标量量化(int8) | 每维从 4 字节压到 1 字节 | 4 倍 | 精度损失小 |
| 乘积量化(PQ) | 切段,每段用码本索引表示 | 可达几十倍 | 召回明显下降 |
PQ 的直觉:把 768 维切成 96 段,每段 8 维,对每段训练一个 256 项的码本,原始 8 个 float32(32 字节)变成一个字节的索引。压缩比很大,但距离变成了近似距离,通常要和原始向量一起用(先粗排再精排)来补回精度。
各家给的菜单不一样:pgvector 提供 halfvec(fp16)与 bit,没有 int8 码本量化;Qdrant 与 Milvus 才有标量量化、乘积量化。别把「量化」当成通用开关,先看手上的库支持哪一种。
边界:量化不是免费的降维。降维改变的是「用多少维表示语义」,量化改变的是「每一维用多少位表示」,后者更容易伤到细粒度区分。召回要求 0.95 以上时,先试 int8(库支持的话)或 halfvec,别一上来就 PQ。
3.8 距离度量与操作符类
| 度量 | pgvector 操作符 | 操作符类 | 排序 |
|---|---|---|---|
| 余弦距离 | <=> | vector_cosine_ops | 升序 |
| 欧氏距离 | <-> | vector_l2_ops | 升序 |
| 负内积 | <#> | vector_ip_ops | 升序 |
三者都按「越小越近」排序,所以 ORDER BY 一律升序。余弦相似度是 1 - 距离,要展示给用户时再转换。
必须配对:索引用什么 ops,查询就必须用对应的操作符。用 vector_cosine_ops 建的索引,配 <-> 查询,索引直接失效,退化成全表扫描,而且不报错——这是最难发现的一类性能问题。
归一化的影响:向量已归一化时,余弦、内积、欧氏的排序完全一致。这时选哪个只看库的支持程度;未归一化时三者会给出不同结果,必须先明确业务要的是「方向像」还是「绝对位置近」。
3.9 过滤与检索的执行顺序
三种策略,差别在「什么时候筛掉不相关的数据」:
| 策略 | 优点 | 风险 | 适用 |
|---|---|---|---|
| 先过滤后检索 | 结果完整 | 过滤后行数少时退化为全表扫描 | 选择率 < 10% |
| 先检索后过滤 | 稳定走索引 | 可能凑不够 K 条 | 选择率 > 50% |
| 遍历中过滤(下推) | 两者兼顾 | 实现复杂,各库行为不同 | Qdrant/Milvus 的常见做法 |
新版 pgvector 还给了另一条路:hnsw.iterative_scan(strict_order / relaxed_order),过滤后不足 K 时继续扫图而不是直接停。它把「先检索后过滤」的召回补回来,代价是延迟随过滤严格程度上升;是否可用、默认值是什么,以官方当前稳定版为准。
工程做法是两条腿走路:主路径让数据库自己选(pgvector 看统计信息,Qdrant 下推),应用层再兜一层——结果不足 K 条时自动放宽过滤或加大 ef 重查。兜底逻辑不优雅,但它是唯一能保证「永远返回 K 条」的东西。
面试:常问「过滤和向量检索谁先执行?」答:先过滤可能放弃索引,先检索可能不足 K,下推最好但要实测。
3.10 索引构建与写入的顺序
| 顺序 | 建索引耗时 | 写入耗时 | 结论 |
|---|---|---|---|
| 先建索引再灌数 | 每行都维护图,极慢 | 慢 | ❌ |
| 先灌数再建索引 | 一次性批量构建,快 | 快 | ✅ |
批量导入的标准流程:建表(不建向量索引)→ COPY/批量 upsert → 建索引 → ANALYZE。这条只对「从零一次性构建」成立,是 pgvector 的要求;Milvus 与 Qdrant 的索引跟着 segment 增量构建,通常先声明索引参数再灌数据,两种顺序都行,但灌完要确认索引已追平。
增量写入是另一回事:日常的小批量更新必须能走索引,否则每次都要重建。HNSW 支持增量插入,代价是图质量略低于一次性构建——这就是为什么「全量重建」偶尔还是要做一次。
3.11 内存模型与容量估算
总内存 ≈ 向量数据 × (1 + 索引倍数 1.3~1.6) + 连接与临时缓冲| 规模(768 维) | 向量(堆表) | HNSW 索引 1.3~1.6× | 合计 |
|---|---|---|---|
| 20 万 | 0.61 GB | 0.8~1.0 GB | 1.4~1.6 GB |
| 100 万 | 3.07 GB | 4.0~4.9 GB | 7~8 GB |
| 1000 万 | 30.7 GB | 40~49 GB | 70~80 GB |
索引为什么比向量还大:它自己存了一份向量,768 维的索引项约 3 KB,一个 8 KB 页只放得下两条,剩下约四分之一是页内填充;再叠加连接列表,1.3~1.6 倍是常态。这也是 2.9 里「按向量大小买内存」会翻车的原因。
三个常被漏掉的项:副本(两副本就是两倍)、查询时的临时缓冲(大 K 值、大 ef 都会放大)、重建与合并的临时空间(REINDEX 时要能放下新旧两份)。估算的正确姿势是先按 1/10 数据实测,再线性外推:1.3~1.6 是区间不是常数,m 从 16 调到 64,倍率还会往上走。
3.12 更新、删除与空间回收
| 操作 | 实际发生的事 | 后续动作 |
|---|---|---|
| 更新向量 | 旧向量标记删除,新向量插入 | 定期合并/清理 |
| 删除 | 标记删除(tombstone) | VACUUM / compact / REINDEX |
| 批量替换 | 大量 tombstone 堆积 | 重建索引最干净 |
为什么删除不立即释放:HNSW 的图是双向连接,删一个节点要修所有指向它的边,代价太高。所以先标记,等合并任务或重建时统一处理。这带来两个可观测的后果:count(*) 降了但磁盘没降;删除比例高时查询变慢(遍历到大量无效节点)。监控里要有一个「无效比例」指标,超过 20% 就该安排重建。
3.13 分片、副本与一致性
| 概念 | 解决什么 | 代价 |
|---|---|---|
| 分片(shard) | 单机装不下、单机算不过来 | 查询要广播到所有分片再归并 |
| 副本(replica) | 读吞吐、高可用 | 内存翻倍,写入要同步 |
| 一致性级别 | 写完能不能立刻读到 | 强一致牺牲延迟与可用性 |
分片对召回有隐性影响:每个分片各自返回 Top-K,归并后取全局 Top-K,每个分片都要返回至少 K 条,否则全局结果会缺。归并侧的候选量是 分片数 × K,分片越多,网络与归并开销越大;每个分片自己的检索压力不变,变大的是总成本。多租户系统还有一种用法:按租户做 shard key,让一次查询只落到一个分片,而不是广播。
副本对召回没有帮助,只提升吞吐和可用性。把副本当成「提高召回」是常见的误解——它只是让同样的查询能并发更多次。
一致性是第三个旋钮:向量检索绝大多数时候不需要强一致,写后立刻读有秒级延迟通常能接受;但换成「下单后马上查库存」这类场景,弱一致就是 bug。写完能不能立刻读到,要在设计时定,不是上线后调。
3.14 混合检索的落点
向量检索解决「意思相近」,关键词检索解决「字面精确」。两者在向量库里可以共存:
| 方案 | 做法 | 适用 |
|---|---|---|
| 库内混合 | 同一引擎同时建倒排与向量索引,查询时融合 | 已在用支持混合的引擎 |
| 库外融合 | 两路各查一次,应用层做 RRF | 任意组合,最灵活 |
| 向量 + 元数据 | 过滤字段承担「精确条件」 | 结构化条件为主 |
融合时不要直接加权原始分数:BM25 是无界正数,余弦在 [-1, 1],量纲不同。RRF 只用名次,score = Σ 1/(k + rank),k 常取 60,几乎不用调参。
先测再加:如果关键词那一路的召回只有向量的一半,融合的收益可能被噪声吃掉。混合检索是手段,不是标配。
3.15 选型的四个维度
不要按「谁的性能表更好」选,按这四个维度:
| 维度 | 问题 | 影响 |
|---|---|---|
| 数据规模 | 百万以内还是上亿 | 决定要不要分布式 |
| 现有技术栈 | 已经在用 PostgreSQL / ES 吗 | 少一个组件就是少一套运维 |
| 过滤需求 | 是否需要复杂条件、多租户 | 决定过滤能力是不是硬指标 |
| 团队能力 | 有没有人能维护集群 | 决定「能私有化」是不是真优势 |
一个实用判断:数据量在百万以内、且已在用 PostgreSQL,pgvector 几乎总是对的。它的性能差距在这个量级不足以抵消「多一个组件」的成本——备份、监控、权限、故障演练,每一样都要重来一遍。
反过来,当过滤条件复杂、需要多副本、或者数据量确实到了千万级,专用向量库的能力才开始值钱。选型的转折点由规模与团队决定,不由基准测试决定。
面试:常问「百万以内选 pgvector 还是专用向量库?」答:已在用 PostgreSQL 就 pgvector,少一个组件的收益大于单机性能差。
四、冷门知识汇总
每一条都能用一个配置项、一句 SQL 或一次日志确认,查到就改,不必重建系统。
| 知识点 | 为什么冷门 | 用到时的关键词 |
|---|---|---|
hnsw.iterative_scan | 过滤后不足 K 的官方解法,藏在索引文档里 | strict_order、relaxed_order |
halfvec 存向量 | 都知道要量化,不知道 pgvector 有 fp16 类型 | halfvec(1536)、vector vs halfvec |
| 部分索引承载热数据 | 以为一张表只能建一个向量索引 | CREATE INDEX ... WHERE |
Qdrant 的 score_threshold 方向随度量变 | COSINE 越大越好,EUCLID 越小越好,抄错就全空 | score_threshold、Distance.EUCLID |
Qdrant 的 on_disk 与 memmap | 默认走内存,数据一大就 OOM | on_disk=True、memmap_threshold |
Milvus 的 consistency_level | 写完立刻查不到,容易误判成丢数据 | Strong、Bounded |
| 多租户漏过滤 | 测试环境只有单租户,测不出来 | tenant_id、行级安全 |
ANALYZE 的时机 | 大批量变更后忘跑,计划悄悄退化 | 统计信息、pg_statistic |
| 逻辑备份恢复会重建索引 | 备份快、恢复慢,容易当成故障 | pg_dump、pg_restore -j |
ef_search 是会话级 | 连接池一换连接就回到默认值 | SET LOCAL、连接池 reset |