Why PostgreSQL FTS For Agent Projects

上一篇我聊了向量搜索,有朋友接着问我:那全文检索呢?做 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 的全文检索几乎天然就是:

1
2
3
4
5
6
WHERE 
  AND 
  AND 
  AND 
ORDER BY relevance
LIMIT k

这时候,过滤和排序不是两个独立系统里的两段逻辑,而应该是同一条执行链路里的两部分

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 / permissions
  • WHERE 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 搜索需求已经绰绰有余:

  • tsvector
  • tsquery / websearch_to_tsquery / plainto_tsquery
  • GIN 索引
  • phrase / prefix / boolean query
  • 高亮
  • 字典和 stemmer

对于“找出包含关键词、短语、语义上相近词形的文档,并且带业务过滤”的场景,它并不弱。

4. 需要更现代的排序时,可以继续留在 PostgreSQL 里升级

这是我最看重的一点。

很多团队的思路是:

基础检索用 PostgreSQL,想要 BM25 或更强排序,就切 ES。

但现在不一定要这么跳了。

你完全可以走这条升级路径:

1
2
3
4
PostgreSQL built-in FTS
    -> pg_tokenizer.rs
    -> VectorChord-BM25
    -> 混合检索(BM25 + 向量搜索)

也就是说,从基础全文检索到现代 sparse retrieval,你可以始终留在 PostgreSQL 体系里。

这对 Agent 项目非常关键,因为它意味着:

  • 架构不分裂
  • 数据不分裂
  • 运维不分裂
  • 认知负担也不分裂

PostgreSQL 全文检索的三层路线

如果按能力演进来分,我会把 PostgreSQL 里的全文检索分成三层。

第一层:内置 FTS

最经典的路线:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
ALTER TABLE documents
ADD COLUMN fts tsvector;

UPDATE documents
SET fts = to_tsvector('english', title || ' ' || body);

CREATE INDEX documents_fts_gin
ON documents USING gin (fts);

SELECT id, title
FROM documents
WHERE fts @@ websearch_to_tsquery('english', 'postgresql search')
ORDER BY ts_rank(fts, websearch_to_tsquery('english', 'postgresql search')) DESC
LIMIT 10;

这一层的优点是:

  • 原生
  • 成熟
  • 性能很好
  • 和业务 SQL 深度集成

如果你的 Agent 项目主要是英文、简单分词、基础排名,这一层已经足够。

第二层:pg_tokenizer.rs

当你遇到这些问题时,内置 FTS 就开始不够了:

  • 中文、日文这类非空格语言
  • 需要更灵活的分词器和停用词
  • 需要自定义词表
  • 想把 token id 作为 sparse 检索输入

这就是 pg_tokenizer.rs 的位置。

它不是简单“给 Postgres 加个 tokenizer”,而是把文本处理管线拆成可配置模块:

1
2
3
4
5
6
text
  -> character filters
  -> pre-tokenizer
  -> token filters
  -> model
  -> INT[]

支持的核心组件:

  • character filter:to_lowercaseunicode_normalization
  • pre-tokenizer:regexunicode_segmentationjieba
  • token filter:stopwordsstemmersynonympg_dictngram
  • 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 串起来看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
写入侧
──────

原始文本
  │
  │ 1. pg_tokenizer.rs
  ▼
Text Analyzer
  character_filters
  -> pre_tokenizer
  -> token_filters
  │
  ▼
tokens: ["postgresql", "search", "bm25", ...]
  │
  │ 2. model
  ▼
token ids: [1012, 3899, 4248, ...]   -- INT[]
  │
  │ 3. cast / 聚合词频
  ▼
bm25vector
  例: {1012:2, 3899:1, 4248:1}
  │
  │ 4. 存表
  ▼
documents(id, passage, embedding bm25vector)
  │
  │ 5. CREATE INDEX ... USING bm25
  ▼
建索引
  - 统计 doc_cnt
  - 统计 sum_of_document_lengths / avgdl
  - 统计每个 term 的 df
  - 为每个 term 建 posting list
  - 每 128 docs 切 block
  - 为每个 block 预存 upper bound
  ▼
BM25 索引文件
  meta
  documents/payload
  tokens
  summaries
  blocks


查询侧
──────

查询文本 "PostgreSQL search"
  │
  │ 1. 同一个 tokenizer
  ▼
query token ids: [1012, 3899]
  │
  ▼
query bm25vector
  │
  │ 2. to_bm25query(index, vector)
  ▼
bm25query
  │
  │ 3. SQL
  │    ORDER BY embedding <&> bm25query LIMIT k
  ▼
Index Scan
  - 读 token posting
  - 用 token upper bound 找 pivot
  - 用 block upper bound 跳过无望 block
  - 必要时精算真实 BM25
  - 可选 prefilter 先过 WHERE
  ▼
Top-k rows

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

它不是普通文本,而是稀疏词袋表示:

1
{term_id: tf}

这让 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/blimit、索引参数这些,会更自然地收敛到 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"系列的第二篇。上一篇聊向量搜索,下一篇聊事务。