1. 为什么需要“一张表”:AI 应用里被拆散的记忆
1.1 RAG 和 Agent 系统里的常规双库架构
做过 AI 应用开发的都知道,最头疼的往往不是模型本身,而是记忆和知识怎么管。早期我做 RAG 系统的时候,架构几乎是标准模板:文档切块、跑 embedding、塞进向量数据库,业务数据则继续留在 PostgreSQL 或者 MySQL 里。于是每个知识片段都形成了两份存在——一份是结构化的业务记录,包含标题、作者、时间、标签、权限;另一份是向量库里的 chunk,只有文本内容和浮点数组。
这种双库架构在 demo 阶段完全没问题,但一旦进入真实业务,问题就全都冒出来了。我接过一个企业知识库项目,里面的文档每天都在更新,有些文档还会被删除、合并。当时负责同步的程序员写了一个定时任务,从业务库拉取变更,再重新生成 embedding 更新到向量库。听起来很合理,但线上跑起来之后,向量库里总有旧数据残留,有些删掉的文档还能被语义检索捞出来,用户一问“为什么这个方案已经作废了你还能搜到”,整个团队就得去翻同步日志。
这就是我后来坚定转向“PostgreSQL 一张表”的起因。PostgreSQL 加上 pgvector 扩展之后,向量不再是独立数据库里的特殊数据类型,它就是一个普通的列。关系查询和语义检索可以同时作用于同一张表,同一条 SQL 里既有WHERE又允许你按向量相似度排序。这样从源头消灭了“写两遍”的问题——同一个知识片段,业务字段和向量在同一个事务里写入,同一个事务里更新,同一个事务里删除。
1.2 “写两遍”的代价:一致性、事务和同步脚本
先别急着说“双库也行”,我们仔细算一下“写两遍”的真实成本。
第一笔成本是事务一致性。业务库更新一个文档标题后,向量库里对应的 chunk 文本通常没有变化,因为标题可能不参与向量化;但如果正文改了,向量必须重新生成。这就有个时间窗口:业务库已经提交了新版本,向量库里还是旧版本。任何用户在窗口期内做语义检索,拿到的都是过期内容。数据库本身无法保证跨库事务,你只能靠消息队列、定时任务或者 CDC 管道来弥补。
第二笔成本是同步代码的复杂度。我见过不少项目,最初的同步逻辑就是把“新增/修改/删除”各写一个函数,看起来挺简单。可是后来加了权限字段、加了文档状态、加了版本号之后,同步逻辑很快膨胀到几百行。再后来出现脏数据、漏同步、重复插入,排查起来要在两个系统的日志里来回跳。
第三笔成本是查询。双库架构下的语义检索通常分两步:先从向量库召回 top-N,拿回主键 ID 列表,再去业务库二次查询补全字段、做权限过滤。如果业务库有 500 条记录,向量召回 50 条,二次查询还好说;但如果向量库本来就按多租户隔离、在向量库里也要做过滤,那你就得在向量库里冗余存一份租户 ID、状态、时间。冗余越多,同步链路越长,出错概率越大。
所以别小看标题里说的“记忆写两遍”,这几乎是所有 RAG 系统从原型走向生产时都要撞上的一面墙。pgvector 提供的不是“又一个向量数据库”,而是让你可以回到关系数据库的思维模型里,用一套事务、一套查询、一张表完成所有事情。
1.3 pgvector 是怎么把两种能力合并的
pgvector 本质上是一个 PostgreSQL 扩展,它没有重写数据库内核,而是增加了一个vector数据类型和几个配套的索引访问方法。这意味着你不需要迁移数据,不需要引入额外的服务端组件,在你已有的 PostgreSQL 实例上执行CREATE EXTENSION vector就拥有了语义检索能力。
它解决了三个层面的问题。存储层面,向量和其他业务字段在同一个行里,不再有跨系统的 ID 映射;事务层面,向量和业务数据一起提交、一起回滚;查询层面,优化器可以在同一个 SQL 里综合处理结构化条件和向量条件,甚至通过 JOIN 把权限表、订单表、标签表全部串起来。
接下来我会从原理讲到实操,给出我自己在项目里验证过的建表方式、混合查询 SQL、索引调参经验,以及踩过的几个比较隐蔽的坑。
2. 本质先搞懂:向量在 PostgreSQL 里到底是什么
2.1 一个扩展,一种数据类型
从使用者的角度看,pgvector 做的事情特别简单——给你加了一个vector(n)类型,n 表示维度。比如 embedding 模型输出 768 维向量,建表的时候就写embedding vector(768)。插入数据时,向量的存储格式是一个浮点数组,PostgreSQL 会做序列化和压缩,和其他数据类型一样走 WAL、走流复制、走备份恢复。
这个设计最聪明的点在于:它没有发明新的查询语言,也没有让你单独建一个 collection。你建的表,既可以按WHERE title = 'xxx'精确匹配,也可以按ORDER BY embedding <=> $vec LIMIT 10做相似度检索。甚至可以在一次查询里同时用上这两者,这就是“关系查询和语义检索装进一张表”的底气。
我在生产环境里见过很多第一次接触 pgvector 的同事,他们常问:这个和 ES 的 dense vector 有什么区别?最大的区别在于,ES 的向量字段和文档字段虽然也在同一个文档里,但它的聚合、过滤能力和关系数据库不在一个量级。你在 ES 里要按用户的标签、组织架构、时间范围做复杂过滤,写 Query DSL 就像在拼积木;在 PostgreSQL 里,这就是WHERE和JOIN的常规操作。
2.2 三种相似度计算方式怎么选
pgvector 支持三种距离运算符:
<->表示 L2 欧氏距离,数值越小越相似;<#>表示负内积,数值越小表示内积越大、越相似;<=>表示余弦距离,数值越小表示余弦相似度越高、越相似。
我在实际业务中,90% 的场景用余弦距离。原因是主流的 embedding 模型(OpenAI 的 text-embedding-3 系列、开源的 bge 系列等)在训练时就以余弦相似度作为对齐目标,大家约定俗成也用余弦。需要注意的坑是:有些模型输出的向量已经做了归一化,有些没有。如果向量已经归一化,那么 L2 距离和余弦距离排序结果是一致的,用哪个都行;如果没有归一化,余弦距离更稳定,不会因为向量模长不同而产生偏差。
另外,如果你追求极致的检索速度,且模型输出已经归一化,可以考虑用内积。内积计算比余弦少做一次模长除法,在百万级数据量下索引扫描会有微弱优势。不过实话讲,现在的硬件条件下这点差异很小,我更建议优先保证语义效果,选余弦距离,后续有性能瓶颈再回来改。
2.3 环境准备与安装要点
安装 pgvector 并不难,但有几个版本匹配的细节会影响后续使用,尤其是你想用 HNSW 索引、半精度向量这些新特性的时候。
我建议 PostgreSQL 直接用 15 或更高版本,当前常见发行版里 15/16/17 都很稳定。pgvector 扩展本身,Debian/Ubuntu 系统可以直接apt install postgresql-15-pgvector,CentOS/Rocky 可以装pgvector_15之类的 RPM 包,源码编译也只是常规的make && make install。如果你在 Docker 里跑,官方镜像pgvector/pgvector里面已经预装了扩展。
装完之后,先别急着建表,CREATE EXTENSION vector;执行一下,确认输出没有报错,再用下面这条 SQL 验证:
SELECT '[1,2,3]'::vector;能看到数组输出,说明类型可用。我曾经在一个项目里卡了半小时,原因是用户连到了另外一个数据库实例,扩展装在了 A 库,B 库里根本没有这个类型,创建任何带 vector 列的表都报“type does not exist”。这是新手最容易忽略的点:扩展是 per-database 的,每个需要用到的库都要单独CREATE EXTENSION。
3. 混合查询实战:关系条件和向量条件写在同一条 SQL
3.1 最基本的“过滤 + 排序”混合查询
建一张最简单的知识库表,包含业务字段和向量字段:
CREATE TABLE document_chunks ( id BIGSERIAL PRIMARY KEY, doc_id BIGINT NOT NULL, title TEXT, content TEXT, author TEXT, status TEXT DEFAULT 'active', created_at TIMESTAMPTZ DEFAULT now(), embedding vector(768) ); CREATE INDEX ON document_chunks USING hnsw (embedding vector_cosine_ops);查询时就非常自然了,比如只搜某个作者创建的有效文档,同时按语义相似度取 Top 10:
SELECT id, title, content, 1 - (embedding <=> $query_vector) AS similarity FROM document_chunks WHERE author = '张三' AND status = 'active' ORDER BY embedding <=> $query_vector LIMIT 10;这看起来像普通的 SQL,但执行计划里同时包含了条件过滤和向量索引扫描。PostgreSQL 会先走author或status相关的索引,缩小候选集,然后对候选集做向量排序;或者先走向量索引取一批相似数据,再对这批数据做条件过滤。两种策略的代价差别很大,后面我详细讲。
但至少从语义上看,业务方要的“搜到张三最近写的、和某个问题相关的材料”,一条 SQL 就完成了,不需要再写代码拼接两个系统的结果。
3.2 权限控制与向量检索放在一起
这是我觉得最能体现“一张表”价值的地方。
在前司做内部知识助手时,最麻烦的需求就是权限隔离。不同的知识库分属不同部门,有的文档只能项目经理看,有的全员可见。传统的双库方案里,你得先在向量库里给每条 chunk 打上权限标签,检索时把用户的权限标签带进 filter。一旦权限模型是动态的、基于角色的,这套同步逻辑就特别容易出 bug。
用 pgvector 之后,权限过滤可以完全交给关系查询:
SELECT c.id, c.title, c.content, 1 - (c.embedding <=> $query_vector) AS similarity FROM document_chunks c JOIN doc_permissions p ON p.doc_id = c.doc_id JOIN user_roles ur ON ur.role_id = p.role_id WHERE ur.user_id = $current_user AND c.status = 'published' ORDER BY c.embedding <=> $query_vector LIMIT 20;权限表、用户角色表都还是普通的关系表,与向量检索的 JOIN 不需要任何额外的应用层代码。用户能看到什么,数据库层面就决定了。有一次审计问我们“某个用户能不能搜到机密文档”,我直接跑一条 SQL 就能验证,不需要去 ES 里翻 mapping,也不需要看同步脚本有没有漏标权限。
如果你把权限也冗余进 chunk 表,比如加一个allowed_roles text[],检索的时候用&&数组操作符过滤,速度会更快,但 REVOKE 权限时需要同步更新数组。我更推荐先 JOIN 权限表,等确认性能瓶颈之后再做冗余优化。
3.3 JOIN 让结构化知识参与语义召回
RAG 系统经常遇到一个问题:用户问“我们上个月和 A 公司签的合同里,关于违约金是怎么约定的”。如果只做纯向量检索,合同全文里提到违约金的片段很多,但“上个月”“A 公司”这些结构化属性,向量模型理解得不一定准。
传统做法是走查询改写,把“上个月”转成日期范围,再单独过滤。但在 PostgreSQL 里,这个需求可以被一个 JOIN 搞定:
SELECT cc.id, cc.content, 1 - (cc.embedding <=> $query_vector) AS similarity FROM contract_chunks cc JOIN contracts ct ON ct.id = cc.contract_id WHERE ct.partner_name = 'A公司' AND ct.sign_date >= date_trunc('month', now()) - interval '1 month' AND ct.sign_date < date_trunc('month', now()) ORDER BY cc.embedding <=> $query_vector LIMIT 10;这个写法的好处是全链路都可解释。向量部分负责“违约金”的语义,关系部分负责“A 公司、上个月”的精确匹配。两者不是串行执行的,而是由 PostgreSQL 的优化器统一调度,比应用层两段式查询要优雅得多。
3.4 带聚合和分组的向量检索
还有些更进阶的玩法,比如“每个部门各返回一条最相关的知识”。正常在向量数据库里做这种 group-wise top-N 会非常痛苦,得循环查询或者在应用层逐组处理。PostgreSQL 里可以用DISTINCT ON或窗口函数,一次算出每组的 Top 1:
SELECT DISTINCT ON (department_id) department_id, title, content, 1 - (embedding <=> $query_vector) AS similarity FROM document_chunks WHERE status = 'active' ORDER BY department_id, embedding <=> $query_vector;这条 SQL 的意思是:按部门分组,每组内按向量相似度排序,取每组的第一行。整个查询完全在数据库内完成,数据量不大时性能非常理想。如果你需要在每组取 Top N,换成窗口函数ROW_NUMBER() OVER (PARTITION BY department_id ORDER BY embedding <=> $query_vector)就行。
这种能力是纯向量数据库很难做到的。向量数据库的单查延迟做得好,但你要在里面模拟关系语义、做分组聚合,往往要写复杂脚本,而且数据一致性还是悬空的。
4. 索引选型和性能调优:这不是普通 B-Tree
4.1 IVFFlat 和 HNSW 的机制与取舍
pgvector 的索引不是默认自动建的。你要是不建任何索引,每次查询就是全表扫描,把每一行的向量都和查询向量算一遍距离。几十万条数据还能扛,百万条以上延迟就飙到几百毫秒甚至秒级。所以必须建索引。
pgvector 提供两种索引算法:IVFFlat 和 HNSW。
IVFFlat 的思路是先对全量数据做聚类,把向量空间划分成若干个区域,查询时只扫描离查询向量最近的几个区域。它的特点是建索引速度快、内存占用低,但有两个限制:一是建索引前最好有足够的数据,让聚类中心更准确;二是如果数据持续大量写入,聚类中心可能跟不上分布变化,导致召回率下降。
HNSW 的思路是构建一个多层图,每个节点连接若干邻居,查询时从顶层开始逐层向下搜索。它的特点是召回率更稳定、查询延迟更平滑,不需要预先训练聚类中心,新增数据随时插入都能保持较好的检索质量。代价是构建速度相对慢、索引体积更大、内存占用更高。
我个人的选型建议比较直接:
- 数据量在 10 万到 100 万之间,且会持续写入,用 HNSW,因为省心稳定;
- 数据量在千万级以上,且分布相对稳定,可以考虑 IVFFlat,配合定期重建索引;
- 数据集很小(万级别以下),甚至可以不建向量索引,直接全表扫描,简单还不用调参。
表格化对比一下:
| 对比项 | IVFFlat | HNSW |
|---|---|---|
| 建索引速度 | 快 | 慢 |
| 查询延迟 | 快,受 probes 参数影响 | 更快且更稳定 |
| 召回率 | 受 lists 和聚类质量影响 | 更高更稳定 |
| 是否需要预训练 | 需要,建索引前最好有一定数据量 | 不需要 |
| 索引体积 | 小 | 大 |
| 适合场景 | 大规模、数据分布稳定 | 持续写入、高召回要求 |
4.2 混合查询中的“预过滤 vs 后过滤”陷阱
这是我在实际项目中踩得最深的坑,也是混合查询最容易翻车的地方。
当你同时写WHERE和ORDER BY embedding <=> $query_vector时,PostgreSQL 有两种执行策略:
- 先向量检索,得到 Top-N 候选,再应用 WHERE 条件过滤。我称之为“后过滤”。这种策略的风险在于:如果 WHERE 条件非常严格,Top-N 里几乎全部被过滤掉,最后可能什么都没剩下。而且向量索引扫描的 N 不受 LIMIT 影响,需要额外配置扫描范围。
- 先应用 WHERE 条件,缩小候选集,再对候选集做向量排序。我称之为“预过滤”。这种策略更安全,但如果你过滤后的候选集仍然有几十万行,对每行计算向量距离可能很慢,向量索引就派不上用场,变成顺序扫描加逐行计算。
那怎么判断哪种策略被采用?看执行计划。
EXPLAIN (ANALYZE, BUFFERS) SELECT ... FROM document_chunks WHERE status = 'active' ORDER BY embedding <=> $query_vector LIMIT 10;如果计划里出现Index Scan using ... ORDER BY,说明走的是向量索引的后过滤路径;如果出现Seq Scan加Sort,说明是先过滤再全量排序。前者召回率高但可能漏数据,后者准确但可能慢。
我的经验是:当过滤条件能把候选集压到十万条以内时,不要迷信向量索引,直接让 PostgreSQL 先过滤再算距离,配合略高一点的LIMIT反而效果更好。当过滤条件很弱(比如只按状态过滤,能捞回几百万行)时,才应该依赖向量索引做 ANN 检索,把过滤条件留到索引扫描之后。
4.3 用 EXPLAIN 判断执行路径
多花一分钟看执行计划,能省下大量调优时间。我常见的排查方法论是这样的:
第一步,不带 WHERE 直接跑一次向量检索,确认向量索引生效:
EXPLAIN SELECT * FROM document_chunks ORDER BY embedding <=> $query_vector LIMIT 10;正常情况下应该看到Index Scan using document_chunks_embedding_idx,而不是Sort。如果出现了 Sort,说明索引没建成功,或者查询写法匹配不上索引运算符。
第二步,加上重要度不同的过滤条件,观察候选集估算行数。rows=预估行数是个关键指标。如果预估行数远小于总行数,说明过滤条件有选择性,此时优先考虑“先过滤再算距离”的路径。如果预估行数接近总行数,说明这是个弱过滤,向量索引扫描是更好的选择。
第三步,实际跑几个不同过滤条件的查询,结合EXPLAIN ANALYZE看真实耗时。我遇到过预估和实际差异很大的情况,比如一张表统计信息没更新,PostgreSQL 完全误判了行数,执行计划选了一条很差的路径。这时候跑一次ANALYZE table_name;往往就解决了。
还有一个小细节:如果你用 HNSW 索引,可以在查询里通过SET hnsw.ef_search = 100;控制搜索宽度。默认值是 40,调大之后召回率提升、延迟增加。在混合查询里,我通常会把ef_search调到 100 以上,因为后续还要做一次过滤,预召回基数大一点,最后 TOP-K 的覆盖率才更高。
5. 实战项目:AI 记忆表是怎么设计的
5.1 agent_memory 表结构
标题里说的是“AI 的记忆”,这部分我拿自己做过的一个 AI 助手的记忆模块来举例。
这个助手的核心需求是:用户在多轮对话中提到的事实、偏好、项目背景,助手必须记住,并在后续对话里自主召回。构建记忆表时我把它设计成了一张“既能按关系过滤,又能按语义召回”的表:
CREATE TABLE agent_memory ( id BIGSERIAL PRIMARY KEY, user_id BIGINT NOT NULL, session_id BIGINT, memory_type TEXT NOT NULL, -- fact / preference / event / task subject TEXT, -- 记忆主体,比如项目名称 content TEXT NOT NULL, importance REAL DEFAULT 0.5, source TEXT, -- 记忆来源,比如 chat / email / doc meta JSONB, -- 扩展属性 created_at TIMESTAMPTZ DEFAULT now(), last_accessed_at TIMESTAMPTZ DEFAULT now(), embedding vector(768) ); CREATE INDEX ON agent_memory USING hnsw (embedding vector_cosine_ops); CREATE INDEX ON agent_memory (user_id, created_at);这张表的设计哲学是:向量负责“语义相似”,其他普通的 B-Tree 索引负责“时间、用户、类型”。两者互不干扰,又能在同一条 SQL 里配合。
5.2 多轮对话中的语义记忆召回
当用户问“我们上次说的那个封面方案,后来定下来了吗”,助手需要从记忆里找到和“封面方案”语义相关的记录,同时如果会话跨度大,还要尽量带上最近被提到过的记忆。
我用的召回 SQL 是两段式。第一段先做候选召回,让向量索引工作:
SELECT id, content, memory_type, created_at, 1 - (embedding <=> $query_vector) AS similarity FROM agent_memory WHERE user_id = $user_id AND memory_type IN ('event', 'task') ORDER BY embedding <=> $query_vector LIMIT 100;第二段在应用层做一个加权重排:相似度分 + 时间衰减分 + 重要程度分,最后取 Top 10 塞进大模型的 system prompt。为什么不放在 SQL 里一次算完?因为时间衰减和重要程度的权重需要经常调整,每次调整都要改 SQL,不如在代码里维护一个几十行的重排函数灵活。
如果你希望数据库直接出结果,也可以用子查询把时间权重带进来:
SELECT * FROM ( SELECT content, memory_type, created_at, 1 - (embedding <=> $query_vector) AS similarity FROM agent_memory WHERE user_id = $user_id ORDER BY embedding <=> $query_vector LIMIT 200 ) t ORDER BY similarity * 0.8 + CASE WHEN created_at > now() - interval '7 days' THEN 0.2 ELSE 0 END DESC LIMIT 10;子查询先让向量索引召回 200 条候选,外层再按业务规则排序。候选集足够大,不会因为时间过滤把最相关的记忆丢掉;外层排序足够灵活,加字段、调权重都很方便。
5.3 权限隔离和跨会话召回
有一些记忆属于某个部门/项目组共享,助手需要根据当前用户的权限来决定能不能召回。我在设计时没有在记忆表里冗余权限,而是通过关联表:
SELECT m.content, m.subject, m.created_at, 1 - (m.embedding <=> $query_vector) AS similarity FROM agent_memory m JOIN memory_grants mg ON mg.memory_id = m.id WHERE mg.user_id = $current_user_id ORDER BY m.embedding <=> $query_vector LIMIT 20;这样用户换了个会话,只要权限没变,助手的记忆依然能跨会话召回。而一旦用户被移出授权组,JOIN 的结果立刻变空,不需要额外写删除逻辑去清理向量库里的旧数据——因为向量数据没变,是权限关系变了。这个思路在双库架构里很难做到,在关系数据库里反而成了最自然的设计。
5.4 重复记忆的检测
AI 记性太好也麻烦,如果用户在两轮对话里都提到“我喜欢简洁风的设计”,记忆表里可能会存两条几乎一样的记忆,后面的重排很难消除重复。
我处理的方法是:在写入新记忆前,先做一次语义相似度检查,把相似度高于 0.85 的记录找出来,选择保留最新一条或合并一条:
SELECT id, content, 1 - (embedding <=> $new_embedding) AS similarity FROM agent_memory WHERE user_id = $user_id AND memory_type = 'preference' ORDER BY embedding <=> $new_embedding LIMIT 5;如果相似度超过阈值,就放弃插入,或者更新原记录的last_accessed_at。这个去重逻辑跑在数据库内部,没有额外的查询开销,检索速度非常快。这也是“一张表”带来的优势:去重检查本质上就是一次带 WHERE 条件的向量查询,不需要维护两套数据。
6. 我踩过的坑和一些进阶想法
6.1 embedding 模型换不得
这是所有用向量检索的人迟早要面对的问题。你辛辛苦苦把 100 万条数据都向量化了,某天发现一个更强的 embedding 模型,想着“升级一下,效果更好”,结果一换模型,向量维度变了、向量语义空间也变了,新旧向量混在一起做相似度计算毫无意义,全部历史数据都得重新向量化。
所以我的建议是:embedding 模型的选择在项目初期就要严肃对待,至少要评估它在你业务数据上的实际效果,而不是只看榜单分数。升级模型的代价远不止计算成本,还包括历史数据的全量重算同步,这个时间窗口内检索质量会很差。如果确实要升级,建议做成双模型并行——旧模型继续服务老数据,新模型只处理增量数据,等老数据逐步重算完成再切换。
6.2 维度越高不一定越准
很多 embedding 模型支持输出 3072 维向量,看起来“信息更多”,但实际检索效果提升有限,计算开销和存储开销却成倍增加。我在一次客服知识库项目里对比过 768 维和 1536 维的效果,发现 Top-10 准确率几乎没有差别,但查询延迟从 40ms 涨到了 120ms,索引体积翻了一番。
如果你的 embedding 模型支持降维输出,或者你能通过 PCA 把向量降到 256/512 维,我建议做一轮测评再决定。pgvector 本身对向量的维度支持有上限,具体上限看版本,但实际项目中 768 或 1024 是性价比很高的选择。另外,pgvector 0.7.0 之后逐步支持半精度向量存储,可以降低一半的存储占用,如果你的精度损失能接受,值得一试。
6.3 阈值别拍脑袋定
在“相似度多高才算相关”这件事上,很多人的第一反应是定一个阈值,比如 0.8 以上就是相关,否则过滤掉。但这个阈值不能脱离具体模型和数据。同一个模型,在某一批数据上相似度普遍偏高,换一批数据可能整体偏低。更科学的做法是评估检索集的分布,统计相似度的分位数,再根据业务容忍度选择阈值。
我踩过的一个具体坑是:某个模型输出的相似度普遍在 0.85 以上,我当时以为“这么高肯定就是同一个意思”,结果把一个完全不相关的知识片段硬塞进了上下文,导致 AI 回答被带偏。后来统计了 1000 条随机配对的相似度分布,发现中位数就有 0.78,说明这个模型的相似度基线整体偏高。阈值这种东西,必须结合负样本分布来定,不能凭空拍。
6.4 后续可以往哪走
等到 PostgreSQL + pgvector 这套方案在你项目里跑稳之后,下一步优先级最高的事是监控和容量规划。向量索引的膨胀、表的膨胀、重建索引的频率,这些和普通关系表不太一样。我的做法是每月跑一次索引大小统计,观察增长速率,提前预估存储容量。
另外一个方向是把 PostgreSQL 的物化视图和向量检索结合起来。如果某个知识库经常被高并发查询,而且数据变更不频繁,可以物化一份精简版的向量结果集,把字段裁剪到最少,减少扫描代价。物化视图刷新时走 PostgreSQL 自身的机制,比在应用层写缓存要可靠得多。
说回我个人的体会。最开始我也被“专业向量数据库性能更好”的说法带偏过,觉得 PostgreSQL 做向量检索不如专用系统。但真正让我回头的不是性能,而是“一致性”和“简单性”。当产品经理问“为什么文档更新了搜到的还是旧内容”,当老板问“为什么用户没有权限却能搜到某个文档”,答案不再是一堆同步脚本,而是一句“因为数据和权限都在同一个事务里”。这就是一张表带来的底气,也是我最终选择这套方案的原因。