为什么我在 Agent 项目里只认 PostgreSQL:全文检索篇

上一篇我聊了向量搜索,有朋友接着问我:那全文检索呢?做 Agent 项目,关键词搜索是不是还是应该上 Elasticsearch?
我的回答还是一样:大多数 Agent 项目里,我依然优先选 PostgreSQL。
这句话听起来有点反直觉。毕竟一提到全文检索,很多人的第一反应就是 ES,仿佛 PostgreSQL 的 FTS 只是个“能用但不专业”的附属功能。
但在我看来,这个判断放在传统搜索引擎场景里也许成立,放在 Agent 项目里,结论往往正好反过来。
因为 Agent 项目里的全文检索,和“做一个站内搜索引擎”不是一回事。
Agent 时代,全文检索真正要解决什么?
先别急着聊 BM25、倒排索引、分词器。先看 Agent 项目里的真实需求。
1. 检索对象不是一份静态文档库
Agent 要搜的东西通常不只是一批产品文档。
它可能同时要搜:
- 用户上传的 PDF、Markdown、网页快照
- 历史对话
- 长期记忆
- 待办、任务状态、工具执行日志
- 业务系统里的结构化字段和半结构化字段
这些数据有几个特点:
- 更新频繁
- 权限边界复杂
- 和业务主数据强相关
- 需要和事务一致性保持同步
这和“每天离线同步一份文档到 ES,然后做搜索页”是两种问题。
2. Agent 的搜索几乎总是带过滤条件
传统搜索常常是:
给我搜一下所有文档里和 query 最相关的 10 条。
Agent 的搜索更像:
在“这个用户有权限访问的知识库”里,限定最近 30 天、限定 workspace、限定文档类型、限定项目 ID,再找和 query 最相关的结果。
也就是说,Agent 的全文检索几乎天然就是:
|
|
这时候,过滤和排序不是两个独立系统里的两段逻辑,而应该是同一条执行链路里的两部分。
3. Agent 更怕一致性问题,不怕“没榨干最后一点 QPS”
Agent 项目真正让人痛的,往往不是“查询再快 20%”,而是:
- 用户刚上传文档,搜不到
- 数据库删掉了记录,ES 里还有脏数据
- 权限撤销了,搜索结果还在泄露
- 业务表更新了标题/状态/归属,搜索索引没同步
在这种系统里,一致性、简化架构、降低运维面,通常比“专用搜索系统的理论峰值性能”更重要。
为什么我不优先选 Elasticsearch
先说清楚:我不是说 ES 不好。
Elasticsearch 在这些场景里依然非常强:
- 超大规模公开搜索站点
- 日志检索和可观测性
- 复杂聚合分析
- 成熟的搜索团队已经围绕它有大量经验沉淀
但对于大多数 Agent 项目,它有四个很现实的问题。
| 问题 | 在 Agent 项目里的具体表现 |
|---|---|
| 双写与同步 | 主数据在 PostgreSQL,搜索在 ES,天然要做 CDC、异步同步、补偿、重放 |
| 一致性复杂 | 新增、删除、权限变更、重建索引都要考虑延迟和脏读 |
| 过滤逻辑分裂 | 业务条件一部分在 DB,一部分在 ES,最终逻辑容易漂移 |
| 运维成本高 | 多一套集群、多一套备份、多一套监控、多一套性能调优 |
尤其是第一条,经常被低估。
很多团队上 ES 时,只想着“搜索更专业”,但没认真算过后面的工程账:
- 需要同步链路
- 需要 mapping 管理
- 需要 analyzer 管理
- 需要重建索引策略
- 需要冷热分层和资源规划
- 需要处理 schema 演进
而 Agent 项目往往还在快速试错阶段。你最不需要的,就是在“记忆、检索、状态管理”这条主链路上再额外引入一套复杂系统。
为什么 PostgreSQL FTS 更适合 Agent 项目
在 Agent 场景里,PostgreSQL 的优势不只是“也能做全文检索”,而是它刚好踩中了 Agent 系统最看重的点。
1. 业务数据和搜索数据在同一套事务里
这是最大优势。
如果你的文档表、对话表、记忆表本来就在 PostgreSQL 里,那么全文检索天然可以和主数据放在一起:
- 插入文档时同时写入检索字段
- 更新标题、状态、权限时同事务生效
- 删除记录时不会留一份“平行宇宙里的索引残影”
对 Agent 项目来说,这种“一个系统说了算”的感觉非常重要。
2. 过滤、排序、Join 可以在一个执行计划里完成
Agent 检索很少是裸搜。
你经常需要把全文检索和这些操作混在一起:
JOIN users / projects / permissionsWHERE tenant_id = ?WHERE created_at > now() - interval '30 days'WHERE source_type IN (...)ORDER BY rank
PostgreSQL 的价值在这里很直接:这些不是“搜索后再二次处理”,而是 SQL 原生能力。
如果你已经接受了“Agent 的数据底座就是 PostgreSQL”,那全文检索继续放在 PostgreSQL 里,整体复杂度会明显更低。
3. 内置 FTS 已经比很多人想象的强得多
很多人对 PostgreSQL FTS 的印象还停留在很多年前:
- 分词简单
- 排序一般
- 性能一般
但实际上,只要用法对,内置 FTS 解决大量 Agent 搜索需求已经绰绰有余:
tsvectortsquery / websearch_to_tsquery / plainto_tsquery- GIN 索引
- phrase / prefix / boolean query
- 高亮
- 字典和 stemmer
对于“找出包含关键词、短语、语义上相近词形的文档,并且带业务过滤”的场景,它并不弱。
4. 需要更现代的排序时,可以继续留在 PostgreSQL 里升级
这是我最看重的一点。
很多团队的思路是:
基础检索用 PostgreSQL,想要 BM25 或更强排序,就切 ES。
但现在不一定要这么跳了。
你完全可以走这条升级路径:
|
|
也就是说,从基础全文检索到现代 sparse retrieval,你可以始终留在 PostgreSQL 体系里。
这对 Agent 项目非常关键,因为它意味着:
- 架构不分裂
- 数据不分裂
- 运维不分裂
- 认知负担也不分裂
PostgreSQL 全文检索的三层路线
如果按能力演进来分,我会把 PostgreSQL 里的全文检索分成三层。
第一层:内置 FTS
最经典的路线:
|
|
这一层的优点是:
- 原生
- 成熟
- 性能很好
- 和业务 SQL 深度集成
如果你的 Agent 项目主要是英文、简单分词、基础排名,这一层已经足够。
第二层:pg_tokenizer.rs
当你遇到这些问题时,内置 FTS 就开始不够了:
- 中文、日文这类非空格语言
- 需要更灵活的分词器和停用词
- 需要自定义词表
- 想把 token id 作为 sparse 检索输入
这就是 pg_tokenizer.rs 的位置。
它不是简单“给 Postgres 加个 tokenizer”,而是把文本处理管线拆成可配置模块:
|
|
支持的核心组件:
- character filter:
to_lowercase、unicode_normalization - pre-tokenizer:
regex、unicode_segmentation、jieba - token filter:
stopwords、stemmer、synonym、pg_dict、ngram - model:builtin / Hugging Face / Lindera / custom model
它的价值不是“替代 PostgreSQL FTS”,而是把文本清洗、切词、词表映射这层做成了数据库内可配置基础设施。
第三层:VectorChord-BM25
当你不只想“匹配到”,而是想要:
- 更现代的 relevance ranking
- 原生 BM25
- top-k sparse retrieval
- 为混合检索做 sparse 分支
就可以继续往上接 VectorChord-BM25。
它的核心思路是:
- 用
pg_tokenizer.rs把文本变成 token id - token id 聚合成
bm25vector - 在 PostgreSQL 内部建立 BM25 倒排索引
- 用 Block-WeakAnd 做 top-k 剪枝
端到端链路图
这张图可以直接把 pg_tokenizer.rs -> bm25vector -> VectorChord-BM25 串起来看:
|
|
pg_tokenizer.rs 为什么重要
很多人看到 BM25,会直接把注意力放在倒排索引和排序函数上。
但在我看来,Agent 项目里真正决定检索质量下限的,常常不是 BM25 本身,而是 tokenization pipeline。
原因很简单:
- 你怎么切词,决定了词项空间长什么样
- 你怎么做 normalize,决定了同义词和变体会不会被打散
- 你怎么做 stopwords 和 stemmer,决定了噪音会不会淹没有效信号
- 你怎么维护 custom model,决定了领域术语会不会被保留下来
pg_tokenizer.rs 的强点就在于,它把这些都拉回到了 PostgreSQL 体系里。
它最实用的三个能力
1. 可组合 analyzer
你可以单独定义 text_analyzer,也可以在 tokenizer 里 inline 写。
这意味着不同数据类型可以有不同的处理链:
- 对话历史一套 analyzer
- 技术文档一套 analyzer
- 中文知识库一套 analyzer
- 代码片段甚至可以单独配 regex + ngram
2. custom model
它支持基于你的语料表生成数据库内词表,并且增量维护。
这对于 Agent 特别有价值,因为 Agent 的词项空间经常高度领域化:
- 内部项目名
- 代码仓库名
- 用户自定义术语
- 企业内部缩写
你不一定总能指望通用 tokenizer 做得刚刚好。
3. preload model
模型可以在 PostgreSQL 启动时预加载,避免首次 query 卡顿。
代价是会多占内存,但对交互式 Agent 来说,这种取舍非常合理。
VectorChord-BM25 解决的不是“能搜”,而是“搜得更像搜索引擎”
如果说 PostgreSQL 内置 FTS 已经解决了“能搜”,那 VectorChord-BM25 解决的是:
我希望 PostgreSQL 里的 sparse retrieval,也能有现代搜索引擎级别的 BM25 排序和 top-k 性能。
它的核心能力有三层:
1. bm25vector
它不是普通文本,而是稀疏词袋表示:
|
|
这让 PostgreSQL 可以直接把“文档向量”当成一种原生类型来处理。
2. 原生 BM25 倒排索引
建索引时会统计:
- 文档总数
doc_cnt - 总词数与平均文档长度
avgdl - 每个 term 的文档频率
df - 每个 term 的 posting list
也就是说,它不是“查询时临时算一下相关度”,而是把 BM25 真正落进了索引结构。
3. WeakAND / Block-WAND
这是真正让 top-k 检索快起来的关键。
BM25 决定“如何打分”,WeakAND 决定“哪些候选根本不用打分”。
它的思想可以压缩成一句话:
如果某个 posting block 的理论最高分,连当前 top-k 门槛都过不了,就整块跳过。
所以性能的核心不是“算得更快”,而是“少算很多”。
为什么这比 ES 更像 Agent 项目要的东西
把前面这些拼起来,你会发现这条链路非常符合 Agent 项目的真实需求。
| 维度 | PostgreSQL FTS 路线 | Elasticsearch 路线 |
|---|---|---|
| 主数据一致性 | 同库同事务 | 需要同步链路 |
| 权限过滤 | SQL 原生 | 需要额外建模或二次过滤 |
| 文本处理 | 内置 FTS + pg_tokenizer.rs |
analyzer 强,但系统独立 |
| 排序升级 | ts_rank -> BM25 扩展 |
原生强 |
| 混合检索 | 可和向量检索同库共存 | 往往再接一套向量系统 |
| 运维复杂度 | 一套 PostgreSQL | 额外一套 ES 集群 |
| 适合 Agent 早期迭代 | 非常适合 | 成本偏高 |
在 Agent 项目里,我更看重的是:
- 架构简单
- 一致性强
- 数据不分裂
- 可以渐进升级
而 PostgreSQL 路线刚好满足这四点。
版本演进:为什么我看好这条路线
这条路线不是静态的,它还在快速演进。
pg_tokenizer.rs:把文本处理层补齐
这是 PostgreSQL 内置 FTS 的一个重要补位。
很多时候,问题不在于 Postgres 不会查,而在于它缺一个更灵活的 tokenizer/analyzer 体系。pg_tokenizer.rs 正好把这层补上了。
VectorChord-BM25 当前稳定版:功能完整的一代实现
当前主线版本的特点是:
- 已经把 BM25 倒排、评分、Block-WeakAnd 跑通
- 支持 build / insert / scan / vacuum
- 有自己的 page / segment / posting / growing segment 结构
也就是说,它是一版“能跑、能增量写入、能维护”的完整系统。
PR #100 rewrite:面向长期维护的二代重构
但我们也在重写第二代内核。
这次 rewrite 的方向很清晰:
- 把 BM25 核心抽成独立库
- 用统一的 relation/page 抽象替代旧的自定义 page/segment 体系
- 改成 tuple 化、显式版本化的磁盘格式
- 把 build/search 主路径重构得更清晰
- 引入可配置
k1/b - 把部分配置从旧 GUC 迁到新 reloption / GUC 体系
这一版更值得关注的,不是“换了一套写法”,而是它明确了我们下一版本的优化方向。
我们会在下一版本重点推进这几件事:
- 把存储格式做得更清晰、更稳定:从原来偏工程内部实现的 page/segment 组织,走向 tuple 化、显式版本化的磁盘格式。这样后续做索引升级、兼容和迁移时,路径会更明确。
- 把 build/search 主路径继续标准化:把 BM25 的 build 和 search 核心抽出来,和 PostgreSQL 的适配层解耦。这样后面优化检索逻辑、调参数、扩展扫描策略时,改动面会更小。
- 把配置从“全局开关思维”改成“索引级配置思维”:像
k1/b、limit、索引参数这些,会更自然地收敛到 reloption 和新的 GUC 体系里。这样不同索引可以按业务场景做差异化配置。 - 把后续在线维护能力做得更统一:包括增量写入、删除维护、VACUUM 相关路径,最终都落在同一套 relation/page 抽象上,而不是继续沿用旧版里较重的自定义存储层。
- 把长期性能优化的落点提前铺好:比如更清晰的 block summary、token upper bound、scan 路径抽象,本质上都是在为后续 top-k 检索、prefilter 和混合检索优化留接口。
换句话说,这一轮 rewrite 的价值,不只是“代码更整洁”,而是我们会在下一版本把 BM25 从一套能跑的实现,推进成一套更容易演进、更容易迁移、更适合长期维护的 PostgreSQL 检索内核。
所以我对版本的建议会更务实一些:
生产如何选
如果你现在就要上线:
- 优先用当前稳定 release / 主线版本
- 特别是有在线写入、删除、VACUUM 诉求时
如果你在关注未来迁移:
- 下一版本会需要一次
REINDEX - 一部分 GUC 和索引参数会更规范化
- 磁盘格式版本化之后,迁移和升级路径会更清楚
也就是说,这条路线不是“今天已经完美了”,而是“现在已经足够实用,而且未来方向是对的”。
我的实践建议
如果你在做 Agent 项目,我会这样选:
规模小、需求简单
- 直接上 PostgreSQL 内置 FTS
tsvector + GIN足够
需要更灵活的分词和多语言支持
- 接
pg_tokenizer.rs - 尤其是中文、日文、领域术语场景
需要更强的 relevance ranking
- 上
VectorChord-BM25 - 用 BM25 替代单纯
ts_rank
需要混合检索
- 向量检索走 VectorChord
- 关键词检索走 VectorChord-BM25
- 都留在 PostgreSQL 里
这条路线的最大优势,是你不需要一开始就把系统切成很多块。
你可以从最简单的 SQL 原生能力开始,随着需求增长再逐层升级。
Thoughts
Agent 项目里的全文检索,本质上不是“做一个搜索引擎”,而是“给 Agent 做一个一致、可控、可扩展的检索底座”。
这个目标和 ES 最擅长的目标,并不是完全重合的。
ES 很强,但它强在“搜索系统本身”。
而 PostgreSQL 更强的地方是:
- 它已经是你的主数据底座
- 它已经是你的事务边界
- 它已经是你的权限和业务逻辑中心
当全文检索只是 Agent 数据底座的一部分时,把它继续留在 PostgreSQL 里,很多时候反而是更高级的工程判断。
Next Step
这条路线我还会继续往下写。
后面我准备继续聊:
- ACID 事务:为什么 Agent 状态管理离不开事务
- JSONB:为什么我偏好 JSONB 而不是单独引入 MongoDB
- 混合检索:向量 + BM25 在 PostgreSQL 里怎么拼
- 长期记忆:为什么 Agent Memory 最终会回到数据库问题
这是"为什么我在 Agent 项目里只认 PostgreSQL"系列的第二篇。上一篇聊向量搜索,下一篇聊事务。