在 PostgreSQL 生态中,文本搜索的实现路径长期由两条技术主线构成:其一是内置的全文检索(tsvector/tsrank),其二是扩展生态中的 trigram 模糊匹配(pg_trgm)。Timescale 推出的 pg_textsearch(原 pg_search)扩展引入了原生 BM25 排名算法,使 PostgreSQL 第一次具备了与 Elasticsearch 相近的相关性排序能力。本文将从索引结构、排序质量、查询延迟三个维度对比 pg_trgm 与倒排索引配合 BM25 的工程差异,并给出具体场景下的选型建议。

索引结构层面的根本差异

pg_trgm 的核心机制是将文本切分为三元组(trigram),在 GIN 或 GiST 索引中存储每个三元组对应的文档 posting list。查询时,系统通过计算查询词与文档的 trigram 重叠度来评估相似性,典型场景下使用 similarity () 函数或 % 运算符。这种设计的优势在于对前缀匹配和模糊查询天然友好 —— 即使查询词有拼写错误,只要共享足够数量的 trigram 仍能命中目标。然而,trigram 本质上是一种字符级别的相似度度量,它无法区分词项的语义权重,也无法感知文档长度对相关性的影响。

pg_textsearch 则采用倒排索引(Inverted Index)配合 BM25 评分模型。创建索引时,系统会对文本进行分词(支持多种语言配置如 english、chinese),构建「词项→文档」的映射结构,同时记录每个词项在文档中的出现频次(TF)以及该词项在整个语料库中的逆文档频率(IDF)。查询时,BM25 算法综合考虑词项频次、文档长度归一化以及词项饱和效应(term saturation),计算出每篇文档相对于查询的相关性得分。这意味着相同查询词在不同长度的文档中将获得差异化的排序位置,而非简单的布尔匹配。

两种索引的存储开销也存在显著差异。pg_trgm 的 GIN 索引在处理高基数字段时可能出现索引体积膨胀,因为每个唯一的三元组都需要单独存储;而 BM25 倒排索引的体积更紧凑,索引大小通常与文档数量和平均词项数的对数成正比。对于百万级文档库,pg_trgm 索引可能是原始数据大小的数倍,而 pg_textsearch 的倒排索引通常控制在原始大小的 30%–50% 区间(取决于压缩配置)。

相关性排序质量的实践对比

在搜索质量层面,BM25 与 trigram 的差异尤为明显。以一个产品评论搜索场景为例:假设用户输入查询「手机电池续航」,一篇仅在正文末尾出现一次「手机」和一次「续航」的文章,按 trigram 匹配逻辑可能因字符重叠度不足而排名靠后;但 BM25 会因为这两个词在语料库中的 IDF 值较高(假设它们相对少见),即使出现频次低也能获得可观的排名权重。反过来,如果一篇文章频繁重复出现「手机」这个词(例如每段都提及),BM25 的词项饱和机制会抑制其权重加成,避免因关键词堆砌而过度排名。

对于短文本搜索场景(如用户名、商品编码、标签),trigram 往往表现更稳。因为短文本本身词项有限,BM25 的 IDF 信号几乎失效,此时字符级别的相似度反而更贴近用户预期的「模糊匹配」效果。这也是为什么许多产品在搜索框的即时补全(autocomplete)场景中仍然依赖 pg_trgm:用户输入几个字符时,BM25 可能因缺乏足够的词项统计而退化为近乎随机的排序。

从工程实践角度看,BM25 的评分参数(k1、b)通常需要根据业务数据分布调优。k1 控制词项频次的饱和曲线,默认为 1.2;b 控制文档长度归一化力度,默认为 0.75。如果业务语料库的文档长度差异极大(例如同时存在 50 字的短评和 5000 字的长文),可能需要将 b 调低至 0.5 以降低长度偏差。pg_trgm 的可调参数则相对有限,主要通过设置 gin_trgm_ops 操作类或调整 similarity 阈值来控制匹配粒度。

查询延迟与吞吐量对比

在查询延迟方面,两种方案的绝对耗时均能控制在毫秒级(取决于数据规模和硬件),但性能曲线的斜率不同。pg_trgm 的查询成本与 posting list 长度直接相关:当查询词对应的 trigram 在大量文档中出现时,索引扫描需要遍历大量的候选集,后续的 similarity 计算成本随之线性增长。对于热点词(如常用字、标点),trigram 索引可能出现严重的缓存失效,导致查询延迟抖动。

pg_textsearch 的 BM25 查询优化空间更大。首先,倒排索引天然支持 postings list 的有序遍历,可以结合 skip list 结构快速跳过低分候选;其次,Timescale 的实现支持并行索引构建和查询计划优化,在多核机器上可通过 parallel seq scan 加速评分计算。官方测试数据表明,在 2000 万文档规模下,BM25 查询的 P95 延迟约为 15–30 毫秒,而同等规模的 trigram 查询(带 similarity 排序)通常在 40–80 毫秒区间。

但需要注意一个关键差异:pg_trgm 支持 <@> 包含运算符的流式评分(即边扫描边评分),可以在得到首批结果后立即返回,适合实现「top-k 截断」的分页查询。pg_textsearch 的 BM25 评分则需要在完整的候选集上执行排序操作,若不指定 LIMIT 则可能触发全表扫描。实践中应始终结合 LIMIT 子句或使用 ORDER BY score LIMIT 20 之类的模式,强制优化器采用索引覆盖的排序路径。

混合策略的工程实践

鉴于两种技术的互补性,越来越多的系统采用两阶段混合策略:第一阶段使用 pg_trgm 的 GIN 索引做快速的候选过滤(例如通过 % query% 或 similarity > 0.3 筛选出前 1000 条潜在匹配),第二阶段在候选集上应用 pg_textsearch 的 BM25 评分进行精细排序。这种方式的理论依据在于:trigram 过滤可以快速缩小搜索空间,使 BM25 评分计算仅需处理少量文档,从而兼具 trigram 的召回能力和 BM25 的排序质量。

实现两阶段混合查询的典型 SQL 模式如下:首先通过 subquery 或 CTE 获取 trigram 候选,例如 SELECT * FROM articles WHERE content % 'query' ORDER BY similarity(content, 'query') DESC LIMIT 500,随后在外部查询中对这 500 条记录执行 BM25 排序 SELECT *, content <@> 'query' AS bm25_score FROM (...) AS candidates ORDER BY bm25_score DESC LIMIT 20。这种嵌套结构在 PostgreSQL 12 之后的版本中可以借助 LATERAL JOIN 实现流式管道化,避免物化中间结果。

混合策略的调优点主要包括:trigram 候选集的 size 上限(不宜超过 1000,否则第二阶段 BM25 评分成本陡增)、similarity 阈值(建议从 0.2 开始迭代测试)、以及是否对第一阶段结果进行去重(如果文档可能因多个字段匹配而被重复召回)。另一个优化方向是在第一阶段加入成本更低的 GiST 索引替代 GIN:GiST 的插入吞吐量更高,适合写多读少的场景,虽然查询性能略逊于 GIN,但配合后续 BM25 评分仍可接受。

索引选型的决策清单

基于上述分析,以下决策清单可作为工程选型的起点。当业务满足以下条件时,优先考虑 pg_textsearch 的 BM25 方案:查询词长度通常在 3 个词以上、文档内容长度差异显著、对搜索结果的相关性排序有明确质量要求、且愿意投入调参成本优化 k1/b 参数。当业务以短词模糊匹配为主(如搜索系统的前缀补全、用户名去重、代码片段匹配)、对拼写容错要求极高、或希望最小化运维复杂度时,pg_trgm 配合 GIN 索引仍是稳健的默认选项。

值得注意的是,两种方案并非互斥。可以在同一张表上同时创建 pg_trgm 的 GIN 索引和 pg_textsearch 的 BM25 索引,由查询优化器根据查询特征自动选择执行路径。在混合云或有多租户隔离需求的场景下,建议将两种索引分别置于不同的表空间,以便独立管理存储配额和备份策略。

资料来源

本文技术细节参考 Timescale 官方博客关于 pg_textsearch 实现原理的阐述(https://www.tigerdata.com/blog/introducing-pg_textsearch-true-bm25-ranking-hybrid-retrieval-postgres)。