文档下线三天了,客服机器人还在引用它
9 月中旬出了个不大不小的故障。运营下架了一份过期的《海外退换货政策 V1》,三天后还有用户在客服机器人里问到"海外订单 30 天可退",而新政策是 15 天。用户截图投诉到了客服主管那里。
我查了一圈,问题出在存储架构上。我们当时的 RAG 是这样的:
MySQL ──存文档元数据、状态、权限──> 应用
Milvus ──存向量──────────────────> 应用(通过同步任务每晚全量重建)
文档状态在 MySQL 里改成 deleted 了,但向量库是全量重建的,要等第二天凌晨。中间这十几个小时,检索还能把已删除的文档捞出来。
为什么会有两套存储
回头看,当初选型挺随意的。2024 年初做第一个 RAG 原型时,团队里没人有向量库经验,网上搜到的教程基本都是 Milvus 或者 standalone 的 FAISS,于是就按教程来了。MySQL 存元数据是沿用现成的,没多想。
到出故障的时候,我们的知识库规模是:
- 文档 12 万篇,切分后 chunk 约 47 万条
- 768 维向量,单条大概 3KB(含原文、元数据)
- 向量总数据量 47 万 × 768 × 4B ≈ 1.4 GB
说白了,这个量级根本用不上分布式向量数据库。用 Milvus 属于杀鸡用牛刀,还带来了一堆同步问题。
换成 PGVector 一体化
我们最终把向量搬进了 PostgreSQL 16 + pgvector 0.7。元数据、原文、向量在一张表里,删文档就是一个事务。
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE kb_chunk (
id BIGSERIAL PRIMARY KEY,
doc_id BIGINT NOT NULL,
tenant_id INT NOT NULL DEFAULT 0,
status SMALLINT NOT NULL DEFAULT 1, -- 1 正常 0 删除
chunk_index INT NOT NULL,
content TEXT NOT NULL,
token_count INT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
embedding VECTOR(768) NOT NULL
);
CREATE INDEX idx_kb_chunk_doc ON kb_chunk(doc_id);
CREATE INDEX idx_kb_chunk_tenant_status ON kb_chunk(tenant_id, status);
-- HNSW 索引
CREATE INDEX idx_kb_chunk_emb ON kb_chunk
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
改完之后,下线文档变成了一个简单的事务:
@Transactional
public void offline(long docId) {
// 元数据和向量在同一张表,一个事务搞定,没有一致性窗口
jdbc.update("UPDATE kb_chunk SET status = 0, updated_at = now() WHERE doc_id = ?", docId);
jdbc.update("UPDATE kb_doc SET status = 'DELETED' WHERE id = ?", docId);
}
故障就此消失。这是我觉得 PGVector 最大的价值——它不是一个"性能更好的向量库",而是把一致性问题的复杂度降为零。
如果必须用独立向量库怎么办
不是所有场景都能一体化。我们的另一个项目(商品语义搜索,2300 万条向量)还在用 Milvus,那种规模 PG 扛不住。这种时候我用 Transactional Outbox 保证双写一致:
@Transactional
public void updateChunk(Chunk c) {
chunkMapper.update(c); // 业务库
outboxMapper.insert(OutboxEvent.of( // 同一事务写事件表
"kb_chunk", c.id(), "UPSERT", c.embedding()));
}
@Scheduled(fixedDelay = 1000)
public void relay() {
List<OutboxEvent> events = outboxMapper.lockPending(200);
for (OutboxEvent e : events) {
try {
milvusClient.upsert(e.toMilvusRow());
outboxMapper.markSent(e.id());
} catch (Exception ex) {
outboxMapper.bumpRetry(e.id(), ex.getMessage());
}
}
}
要点是事件和业务数据在同一个事务里落库,靠一个独立的 relay 线程去推。这样"业务数据改了但事件没发"的情况不会出现。代价是最终一致,延迟在 1 秒量级,对知识库场景够用。
三种查询模式与索引选择
pgvector 有两种索引,选错会很惨:
| 索引 | 构建时间(47万) | 索引大小 | QPS(ef_search=40) | 召回率@10 |
|---|---|---|---|---|
| IVFFlat (lists=1000) | 48 s | 1.6 GB | 210 | 0.93 |
| HNSW (m=16, ef_construction=64) | 6 min 20 s | 3.7 GB | 640 | 0.985 |
结论是 HNSW 查询快三倍、召回高五个点,但构建慢八倍、空间大一倍多。我们的知识库每周全量重建一次,构建慢可以接受,选了 HNSW。如果是那种频繁增删的实时场景,IVFFlat 更合适。
模式一:纯向量检索
SELECT id, content, embedding <=> ?::vector AS distance
FROM kb_chunk
ORDER BY embedding <=> ?::vector
LIMIT 10;
模式二:带过滤的向量检索(我们 90% 的查询)
SELECT id, content, embedding <=> ?::vector AS distance
FROM kb_chunk
WHERE tenant_id = 10086 AND status = 1
ORDER BY embedding <=> ?::vector
LIMIT 10;
这里有个大坑:pgvector 的 HNSW 过滤是后过滤。它是先在图上找到 ef_search 个最近邻,再套 WHERE 条件。如果过滤条件筛掉了 90% 的数据,那 40 个候选里可能只剩 4 个,召回率断崖式下跌。
我们线上就遇到过:一个租户只有 3000 条数据,但全库 47 万条,按 tenant_id 过滤后召回率只有 0.61。解决办法有两个:
- 调大
ef_search:SET hnsw.ef_search = 200;召回回到 0.94,但 QPS 从 640 掉到 180。 - 按租户做分区表。大租户单独分区走全局索引,小租户合并到一个分区。这是我们现在用的方案。
模式三:混合检索(向量 + 全文)
纯向量对专有名词、型号、错误码很弱。用户搜"ER-2024-0903 报错",向量检索经常给出语义相关但不是这个错误码的文档。我们加了 PostgreSQL 的全文检索做补充:
ALTER TABLE kb_chunk ADD COLUMN tsv TSVECTOR
GENERATED ALWAYS AS (to_tsvector('simple', content)) STORED;
CREATE INDEX idx_kb_chunk_tsv ON kb_chunk USING gin(tsv);
WITH vec AS (
SELECT id, embedding <=> ?::vector AS dist,
row_number() OVER (ORDER BY embedding <=> ?::vector) AS vrank
FROM kb_chunk WHERE tenant_id = ? AND status = 1 LIMIT 50
),
txt AS (
SELECT id, ts_rank(tsv, plainto_tsquery('simple', ?)) AS score,
row_number() OVER (ORDER BY ts_rank(tsv, plainto_tsquery('simple', ?)) DESC) AS trank
FROM kb_chunk
WHERE tenant_id = ? AND status = 1 AND tsv @@ plainto_tsquery('simple', ?) LIMIT 50
)
SELECT id, SUM(1.0 / (60 + rank)) AS rrf_score
FROM (
SELECT id, vrank AS rank FROM vec
UNION ALL
SELECT id, trank AS rank FROM txt
) t
GROUP BY id
ORDER BY rrf_score DESC
LIMIT 10;
这是 RRF(Reciprocal Rank Fusion)融合,k=60 是经验值。加上全文之后,错误码类问题的准确率从 52% 提到 89%。
灌数据的吞吐问题
47 万条数据第一次灌库时,我用普通的 INSERT 逐条写,跑了 52 分钟。改成 COPY 之后降到 3 分 20 秒,差了 15 倍。
COPY kb_chunk(doc_id, tenant_id, chunk_index, content, token_count, embedding)
FROM STDIN WITH (FORMAT csv, DELIMITER E'\t', QUOTE E'\b');
Java 侧用 PgConnection 的 CopyManager:
CopyManager cm = conn.unwrap(PgConnection.class).getCopyAPI();
try (Writer w = new BufferedWriter(cm.copyIn(sql), 1 << 20)) {
for (Chunk c : chunks) {
w.write(STR."\{c.docId()}\t\{c.tenantId()}\t\{c.idx()}\t");
w.write(escape(c.content()));
w.write("\t" + c.tokens() + "\t" + toVectorLiteral(c.embedding()) + "\n");
}
}
向量字面量的格式是 '[0.12,-0.34,...]',用字符串拼比用 PreparedStatement 快得多。另外一个关键点:先灌数据再建索引。反过来(先建索引再插)会慢 6 倍,因为每插一条都要更新 HNSW 图。
| 方式 | 47 万条耗时 |
|---|---|
| 逐条 INSERT(有索引) | 52 min |
| COPY(有索引) | 21 min |
| COPY(先灌后建索引) | 3 min 20 s + 6 min 20 s 建索引 |
模型换了的重新灌库
Embedding 模型一换,所有向量都得重算。我们准备了双表切换:新数据写 kb_chunk_v2,建好索引后用视图或改表名切换,出问题能立刻切回去。第一次换模型时没做这个,直接在原表上 UPDATE,结果中间状态检索出来的结果是新旧向量混在一起的,准确率一度跌到 40%。
连接池和并发参数
向量检索比普通查询吃资源,混在一个库里要注意别把主库拖垮。我们做的隔离:
- 向量查询走单独的只读副本,主库只负责写入。
- 应用层给向量查询配了独立的连接池(HikariCP,maximumPoolSize=30),跟业务查询的池分开,避免向量慢查询把业务连接占满。
- 设了
statement_timeout = 5s,超过就杀掉。有一次手写了个没带 limit 的 SQL,扫了全表,靠这个参数保住了主库。
什么时候不该用 PGVector
一体化不是万能的。我的判断线是:
- 千万级向量以上:单表太大,索引构建时间和内存都吃不消,用 Milvus 或 Elasticsearch。
- QPS 超过 2000:PostgreSQL 是单机扩展,读多副本能缓解,但成本不如专用向量库。
- 需要 GPU 加速或者特殊索引(比如标量量化、磁盘索引):pgvector 现在还比较朴素。
- 向量和主业务库负载冲突:如果主库本身压力就大,别把向量查询塞进去,一个慢查询可能拖垮整个实例。
留个问题
关于《AI 应用中的数据存储选型:关系型与向量的协同》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。