Administrator
发布于 2024-09-28 / 2261 阅读
62

AI 应用中的数据存储选型:关系型与向量的协同

文档下线三天了,客服机器人还在引用它

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 s1.6 GB2100.93
HNSW (m=16, ef_construction=64)6 min 20 s3.7 GB6400.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。解决办法有两个:

  1. 调大 ef_searchSET hnsw.ef_search = 200; 召回回到 0.94,但 QPS 从 640 掉到 180。
  2. 按租户做分区表。大租户单独分区走全局索引,小租户合并到一个分区。这是我们现在用的方案。

模式三:混合检索(向量 + 全文)

纯向量对专有名词、型号、错误码很弱。用户搜"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 应用中的数据存储选型:关系型与向量的协同》里这个坑,你当时是怎么处理的?欢迎在评论区聊聊你踩过的类似情况。

参考