PostgreSQL 全文检索能满足许多应用。用一个简单的决策规则、起始查询和索引清单来判断何时需要引入搜索引擎。

大多数人不会直接说要“全文检索”。他们要的是一个感觉很快且能在首页找到用户想要结果的搜索框。如果结果慢、噪声多或排序奇怪,用户不会在意你是用了 PostgreSQL full-text search 还是单独的引擎——他们只会不再信任搜索。
这是一个决策:把搜索维持在 Postgres 内,还是添加专用的搜索引擎。目标并不是完美的相关性,而是一个易于上线、易于运维、对实际使用来说“足够好”的基线。
对于许多应用,PostgreSQL 全文检索在很长一段时间内都是足够的。如果你只有几个文本字段(标题、描述、备注)、基础排序,以及一两个过滤条件(状态、分类、租户),Postgres 就能在不增加额外基础设施的情况下处理这些需求。你会得到更少的组件、更简单的备份,以及更少的“为什么搜索挂了但应用还在”的事件。
“足够”通常意味着你能同时达到三项目标:
一个具体例子:一个 SaaS 仪表盘,用户按项目名和备注搜索。如果像“onboarding checklist”这样的查询能在不到一秒内把正确的项目排到前五,并且你不需要不断调优分析器或频繁重建索引,那就是“足够”。当你无法在不增加复杂性的情况下满足这些目标时,才需认真考虑“内置搜索与搜索引擎”的选择。
团队常常用功能来描述搜索,而不是用结果来描述。关键是把每个功能翻译为构建、调优和保持可靠所需付出的成本。
早期的需求通常听起来像:容错拼写、分面与过滤、高亮、“智能”排序和自动补全。对于首个版本,把必需项和可选项分开。一个基础的搜索框通常只需找到相关项、处理常见词形(复数、时态)、遵守简单过滤,并在表规模增长时保持快速。这正是 PostgreSQL 全文检索常常适合的场景。
当内容存在于普通文本字段并且你希望搜索与数据靠得很近时,Postgres 很擅长:帮助文章、博客、工单、内部文档、产品标题与描述,或客户记录上的备注。这些大多是“找到正确记录”的问题,而不是“构建搜索产品”。
可选项通常会带来复杂度。高质量的容错拼写和丰富的自动补全通常把你推向额外工具。分面在 Postgres 中是可行的,但如果你需要大量分面、深度分析和跨海量数据的即时计数,专用引擎就更有吸引力了。
隐藏成本很少是许可费,而是第二套系统。一旦你添加了搜索引擎,就必须处理数据同步与回填(以及由此产生的 bug)、监控与升级、“为什么搜索显示旧数据?”的支持工作,以及两套相关性的调参开关。
如果不确定,先从 Postgres 开始,发布一个简单实现,只有在确有无法满足的需求时再添加引擎。
使用三项检查规则。如果三项都通过,就继续使用 PostgreSQL 全文检索。如果其中一项严重不通过,考虑专用搜索引擎。
相关性需求: 是否可以接受“足够好”的结果,还是需要在很多边缘情况(拼写、同义词、“人们也搜索”、个性化结果)上达到近乎完美?如果可以容忍偶尔的不完美排序,Postgres 通常可行。
查询量与延迟: 峰值时每秒预期多少次搜索,实际的延迟预算是多少?如果搜索只是流量的一小部分,并且通过适当索引能保持查询快速,Postgres 就没问题。如果搜索成为主要负载并与核心读写竞争,那就是危险信号。
复杂度: 你是在搜索一两个文本字段,还是要合并许多信号(标签、过滤、时间衰减、流行度、权限)并支持多语言?逻辑越复杂,在 SQL 内部感到阻力就越大。
一个安全的起点是简单:在 Postgres 中发布一个基线,记录慢查询和“无结果”搜索,然后再决定。许多应用永远不会超出它,且避免过早运行与同步第二套系统。
通常指向专用引擎的红旗:
继续使用 Postgres 的绿旗:
PostgreSQL 全文检索是将文本转成数据库能快速搜索的形式的内置方法,而不是扫描每一行。当你的内容已经存在于 Postgres 中并希望获得快速、可靠的搜索时它效果最佳。
有三部分值得了解:
ts_rank(或 ts_rank_cd)把更相关的行排在前面。语言配置很重要,因为它影响 Postgres 如何处理词语。正确的配置下,“running” 与 “run” 可以匹配(词干化),常见的填充词也会被忽略(停用词)。配置错误会导致正常用户措辞无法匹配索引的内容,从而让搜索感觉失灵。
前缀匹配是人们常用来实现“输入提示”行为的功能,例如把“dev”匹配到“developer”。在 Postgres FTS 中,通常通过前缀操作符(例如 term:*)实现。它可以提升感知质量,但通常会增加每次查询的工作量,所以把它当作可选升级而非默认。
需要明确的是:Postgres 不是要成为一个包含所有功能的完整搜索平台。如果你需要模糊拼写纠正、先进的自动补全、learning-to-rank、每字段不同的复杂分析器,或跨多节点的分布式索引,那就超出了内置功能的舒适区。但对许多应用来说,PostgreSQL 全文检索能以更少的移动部件满足大部分用户预期。
下面是一个针对要搜索的内容的简洁、现实的形态:
-- Minimal example table
CREATE TABLE articles (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
PostgreSQL 全文检索的一个良好基线是:从用户输入构建查询,尽量先过滤行(如果可行),然后对剩余匹配项做排序。
-- $1 = user search text, $2 = limit, $3 = offset
WITH q AS (
SELECT websearch_to_tsquery('english', $1) AS query
)
SELECT
a.id,
a.title,
a.updated_at,
ts_rank_cd(
setweight(to_tsvector('english', coalesce(a.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(a.body, '')), 'B'),
q.query
) AS rank
FROM articles a
CROSS JOIN q
WHERE
a.updated_at >= now() - interval '2 years' -- example safe filter
AND (
setweight(to_tsvector('english', coalesce(a.title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(a.body, '')), 'B')
) @@ q.query
ORDER BY rank DESC, a.updated_at DESC, a.id DESC
LIMIT $2 OFFSET $3;
一些能节省时间的细节:
WHERE 中(status、tenant_id、日期范围)。你对更少的行做排名,这样更快。ORDER BY 中加上 tiebreaker(比如 updated_at,然后 id)。当许多结果排名相同时,这能保持分页稳定。websearch_to_tsquery 处理用户输入。它能以用户期望的方式处理引号和简单操作符。当这个基线工作良好后,把 to_tsvector(...) 表达式移到存储列中。这样可以避免在每次查询时重新计算,并且便于建立索引。
大多数“PostgreSQL 全文检索很慢”的故事归结于一件事:数据库在每次查询时都在构建搜索文档。首先修复这一点:存储预构建的 tsvector 并为其建立索引。
tsvector:生成列还是触发器?当你的搜索文档由同一行内的列构成时,生成列是最简单的选项。它会在更新时自动保持正确,也不容易被忘记。
当文档依赖相关表(比如把产品行与其分类名合并),或你需要无法用单个生成表达式表示的自定义逻辑时,使用触发器维护 tsvector。触发器会增加可变部件,所以保持精简并做好测试。
在 tsvector 列上创建 GIN 索引。这是让 PostgreSQL 全文检索在典型应用搜索中感觉“瞬时”的基线配置。
适合很多应用的做法:
tsvector 放在最常搜索的表中。tsvector 上添加 GIN 索引。tsvector 使用 @@,而不是在查询时调用 to_tsvector(...)。VACUUM (ANALYZE),让优化器理解新索引。把向量保存在同一表通常更快且更简单。如果基础表写入非常频繁,或你要索引跨多表的组合文档并希望按自己的节奏更新它,单独的搜索表可能有意义。
当你只搜索子集行(例如 status = 'active'、单租户或特定语言)时,部分索引可以帮助减少索引大小并加速搜索,但前提是你的查询始终包含相同的过滤条件。
如果你保持相关性规则简单且可预测,PostgreSQL 全文检索能带来意外不错的结果。
最容易的改进是字段加权:标题中的匹配应比正文中的匹配更重要。构建一个合并的 tsvector,将 title 权重设高于 description,然后用 ts_rank 或 ts_rank_cd 排序。
如果你想让“新鲜”或“流行”的项上浮,要谨慎。可以小幅提升,但不要让其完全覆盖文本相关性。一个实用模式是:先按文本排序,然后用新鲜度打破平局,或加上有上限的加分,避免一个与查询无关的新条目击败一个完美匹配的旧条目。
同义词与短语匹配往往会导致预期差异。同义词不是自动的;只有在你添加了同义词表或自定义词典,或在查询端扩展了搜索词(例如把“auth”当作“authentication”)时才会生效。短语匹配也不是默认行为:普通查询会匹配词语出现在任意位置,而不是“完全短语”。如果用户输入带引号的短语或较长的问题,考虑使用 phraseto_tsquery 或 websearch_to_tsquery 来更好地匹配搜索行为。
混合语言内容需要做出决策。如果每条文档都知道其语言,存储该信息并用相应配置生成 tsvector(English、Russian 等)是最优的。如果不知道,安全的折中是使用 simple 配置(不做词干化),或同时保持两个向量:一个在知道语言时使用的语言特定向量,一个对所有内容都适用的 simple 向量。
验证相关性的做法要小而具体:
对于像“模板”、“文档”或“项目”等应用搜索,这通常足够让 PostgreSQL 全文检索满足需求。
大多数“PostgreSQL 全文检索慢或不相关”的情况来自几个可避免的错误。修复它们通常比添加新搜索系统要简单。
一个常见陷阱是把 tsvector 当做一个不需要维护的计算值。如果你把 tsvector 存在列里却没有在每次插入和更新时更新它,结果就会显得随机,因为索引与文本不再匹配。如果你在查询时即时计算 to_tsvector(...),结果可能正确但会更慢,并且失去了专用索引带来的优势。
另一个容易导致性能问题的做法是先做排序再缩小候选集。ts_rank 很有用,但通常应在 Postgres 使用索引找到匹配行之后再计算。如果你对表的大部分行都计算 rank(或者先 join 其它表),就可能把一次快速搜索变成全表扫描。
人们还期望“包含”搜索像 LIKE '%term%' 那样工作。前导通配符与全文搜索的词基机制不匹配。如果你确实需要针对产品代码或部分 ID 的子串搜索,用专门工具(如 trigram 索引)来处理,不要责怪 FTS。
性能问题常常来自结果处理,而不是匹配本身。需要注意的两个模式:
OFFSET 分页,会让 Postgres 随着翻页跳过越来越多的行。运维问题也很重要。频繁更新后索引膨胀(bloat)会累积,等到问题严重再重建索引代价很大。在改动前后测量真实查询时间(并查看 EXPLAIN ANALYZE)。没有数据,很容易在“修复” Postgres FTS 时引入别的性能问题。
在把问题归咎于 PostgreSQL 全文检索之前,先做这些检查。大多数“Postgres 搜索慢或不相关”的问题源于基础没做好,而不是功能本身。
构建真实的 tsvector:将其存储为生成列或维护列(而不是在每次查询时计算),使用正确的语言配置(english、simple 等),并在混合字段时应用权重(title > subtitle > body)。
规范化你索引的内容:把噪声字段(IDs、模板文本、导航文本)排除在 tsvector 之外,若用户从不搜索巨大的 blob 内容就不要索引它们。
创建合适的索引:在 tsvector 列上添加 GIN 索引并通过 EXPLAIN 确认它被使用。如果只有子集可搜索(例如 status = 'published'),部分索引能减小大小并加速读取。
保持表健康:死元组会减慢索引扫描。频繁更新的内容要定期 vacuum。
制定重建索引计划:在大规模迁移或索引膨胀时需要安排可控的重建窗口。
当数据与索引都看起来正常后,关注查询形态。PostgreSQL 全文检索在能尽早缩小候选集时速度最快。
先过滤再排序:在排名前应用严格过滤(tenant、language、published、category)。对成千上万行计算排名但最终丢弃它们是浪费。
使用稳定的排序:按 rank 排序并加上 updated_at 或 id 作为 tiebreaker,避免刷新时结果跳动。
避免“查询做所有事”:若你需要模糊匹配或容错拼写,要有意为之并测量其影响。不要无意间强制顺序扫描。
测试真实查询:收集前 20 个搜索,手动检查相关性,维持一个小的预期结果列表以捕捉回归。
关注慢路径:记录慢查询,查看 EXPLAIN (ANALYZE, BUFFERS),监控索引大小与缓存命中率,以便在增长改变行为时及时发现。
SaaS 帮助中心是个好起点,因为目标简单:帮用户找到解答问题的那篇文章。你有几千篇文章,每篇都有标题、简短摘要和正文。大多数访客输入 2 到 5 个词,比如“reset password”或“billing invoice”。
用 PostgreSQL 全文检索,这可以非常快地完成。你为组合字段存储 tsvector,添加 GIN 索引,并按相关性排序。成功的标准可能是:结果在 100 ms 内出现,前三个结果通常正确,你不用频繁照看系统。
然后产品成长了。支持团队希望按产品区域、平台(web、iOS、Android)和套餐(free、pro、business)过滤。文档作者想要同义词、“你是不是想找”,以及更好的拼写容错。市场团队想要“零结果的热门搜索”等分析。流量上升,搜索成为繁忙的端点之一。
这些信号表明专用引擎可能值得投入:
一个实用的迁移路径是:即便添加了搜索引擎,也让 Postgres 保持为事实来源。先记录搜索查询与无结果案例,然后运行一个异步同步任务,把可搜索字段复制到新索引。先并行运行一段时间再逐步切换,而不是一开始就全部押注迁移当天。
PostgreSQL 全文检索在能同时满足这三项时就是“足够”的:
如果你可以通过存储 tsvector 并添加一个 GIN 索引来满足这些条件,通常就是一个很好的选择。
默认先选 PostgreSQL 全文检索。它能更快交付,把数据与搜索放在同一个地方,避免构建和维护单独的索引流水线。
当你遇到 Postgres 无法很好满足的明确需求时,再迁移到专用引擎(例如需要高质量的容错拼写纠正、丰富的自动补全、大量分面等)。
一个简单规则是:如果你在这三项检查中都通过,就继续使用 Postgres:
若其中一项严重不满足(尤其是对拼写/自动补全的高质量需求,或搜索流量过大),就考虑专用引擎。
当你的搜索主要是“找到正确的记录”,并且只在少数字段(title/body/notes)上搜索,结合简单过滤(tenant、status、category)时,Postgres FTS 很合适。
适用场景包括帮助中心、内部文档、工单、文章搜索,以及按项目名称和备注搜索的 SaaS 仪表盘。
一个好的基线查询通常具备:
websearch_to_tsquery 解析用户输入。tsvector 使用 匹配。存储预构建的 tsvector 并添加 GIN 索引,这样可以避免每次请求都重新计算 to_tsvector(...)。
实践配置:
tsvector 放在与查询表相同的表里。当搜索文档只由同一行内的列组成时,优先使用生成列(generated column),它自动保持正确且不易出错。
当文档依赖相关表(例如把产品与其分类名合并)或需要自定义复杂逻辑时,使用触发器维护 tsvector。触发器会增加复杂度,所以保持简洁并充分测试。
从可预测的相关性入手:
用一小组真实用户查询和期望结果来验证调整效果,避免过度工程化。
FTS 基于词(词元),不是基于任意子串,因此不会像 LIKE '%term%' 那样工作。
如果需要子串搜索(例如产品代码或部分 ID),请为此使用其他工具(如 trigram 索引),不要强迫 FTS 做它不适合的工作。
通常表明你已超出 Postgres 能力的信号:
现实做法是保持 Postgres 为事实来源,只有在明确需求出现时再做异步索引到专用引擎。
@@ts_rank/ts_rank_cd 排序,并加上稳定的 tiebreaker(如 updated_at, id)。这能保证结果既相关又快速,且分页稳定。
tsvector_column @@ tsquery。这是搜索变慢时最常见且最有效的修复方法。