23 不用搜索引擎,也能搜索
给帮助中心加搜索框有两种做法。相同的 50,000 篇文章、相同的GIN索引、搜索“failed payment”得到相同的 4,206 个匹配项、排名最前的 20 个结果也相同。其中一种不到 5 ms 就能返回,另一种却要 170 ms,因为它索引的是表达式而不是存储列,而且每个匹配项都要再次解析才能排名。
Postgres 中的搜索就是这样:每个部分都很好,但只有测量时才会看到它的代价。大多数产品根本不需要搜索引擎;确实需要的产品也能说清楚原因。
B-树之外已经介绍了基础知识:一个tsvector列、一个 GIN 索引,以及一个 @@ 匹配操作。这里我们在一个包含 50,000 篇文章的合成帮助中心上构建文本和向量搜索(实验脚本是 22-search.sh)。其中的嵌入向量并非来自任何模型,因此这里的召回率数字用于展示取舍,不是对你的数据的预测。
文本会变成词素
全文搜索从不把查询与原文直接比较。它比较的是规范化词与规范化词。to_tsvector 会解析文档,并保留带位置的词素,也就是规范化后的词:
“Deploys”和“deployment”都会变成 deploy;“the”、“were”、“so”和“we”都会被去掉。'english' 参数是一种文本搜索配置:它由解析器以及每类词元所使用的词典组成。
停用词和词干提取有两面性
词干提取依靠后缀规则,而不是理解含义:在 english 中,“organization”会匹配“organ”。搜索乐队 The Who 时,english 会把整个查询当作停用词丢掉。词干提取有利于普通文章,却会伤害名称和代码,因此应为不同内容提供不同配置:文本使用 english,标题、标签和错误代码使用只做小写转换的 simple。
配置是索引的一部分
始终传入配置:单参数形式 to_tsvector(body) 依赖会话设置,因此索引会拒绝它(functions in index expression must be marked IMMUTABLE)。
重音符号需要自己的配置。english 会把“Zürich”保留为 zürich,因此搜索“zurich”什么也找不到。应把 contrib 中的 unaccent 词典加入配置副本,而不是把它作为函数调用。我们的配置名是 app_english,to_tsvector('app_english', 'Requests from Zürich and Montréal') 会得到 'montreal':5 'request':1 'zurich':3。
深入阅读:解析器内部,以及为什么 unaccent() 不能建立索引
english_stem 是带有 127 个停用词的 Snowball 词干提取器,因此 plainto_tsquery('english', 'to be or not to be') 会返回空结果。unaccent() 函数只有 stable 属性(它的规则文件可能变化),所以索引会拒绝 to_tsvector('english', unaccent(body)),却接受 to_tsvector('app_english', body),因为它信任该配置。你要手动兑现这个承诺:修改 app_english 后,旧行会继续保留旧向量,直到你重写它们。至于内置解析器无法拆分成词的日语或中文,见PGroonga。
动手验证 · lab 22-search.sh
存储 tsvector;不要索引表达式
把向量存储在生成列中,再为该列建立索引。直接索引表达式看起来更节省空间,却会让每次搜索都付出代价。
标题词的权重高于正文词。作为比较,第二张表 docs_expr 不保存该列,而是在相同表达式上建立 GIN 索引。这里写成 stored:在 PostgreSQL 18 中,生成列默认是虚拟列,而虚拟列不能建立索引(类型是一份契约)。
| 堆页 | GIN 索引 | 加载时的 WAL | |
|---|---|---|---|
| 表达式索引 | 3,347 | 9,064 KB | 70 MB |
| 存储列 | 6,766 | 9,360 KB | 95 MB |
存储列让堆的大小翻倍:带位置信息的 tsvector 平均为 511 字节,而它所来源的正文只有 380 字节。
这就是代价。对匹配项排名时,收益就出现了。存储版本在我们的机器上耗时 4.6 ms;表达式版本耗时 170 ms。两个 GIN 索引都能在不到一毫秒内找到 4,206 个匹配项,但排名需要每个匹配项的 tsvector,而表达式版本必须再次解析全部 4,206 个文档,才能为每个匹配项取得这个向量。
把堆和 WAL 的代价在写入时一次付清,而不是每次搜索都让解析器重新工作。
深入阅读:证明堆、WAL 和再次解析的代价
表达式版本为什么会输:tsvector 上的 GIN 索引只存储词素和行指针,除此之外什么也没有,因此它能说哪些行匹配,却不能把向量交给 ts_rank 打分。没有列可以读取向量时,排名过程会对每个匹配项的标题和正文再次调用 to_tsvector,每次搜索共 4,206 次。两个 GIN 索引的大小大致相同;存储列额外增加的是向量本身,它存在于堆中,也存在于记录每行写入的 WAL 中。
动手验证 · lab 22-search.sh
表达式索引只有在你永远不需要排名或高亮时才会胜出。
websearch_to_tsquery 从不抛出语法错误
永远不要把用户输入的内容直接交给 to_tsquery:它要求 tsquery 语法,因此 'failed payment' 会产生语法错误。websearch_to_tsquery(PostgreSQL 11+)接受搜索框语法,从不抛出语法错误:'"roll back" -flags or rollback' 会变成 'roll' <-> 'back' & !'flag' | 'rollback',而像 'payment & ) ( !' 这样的垃圾输入会变成 'payment'。
短语需要位置信息
引号会变成 <->,也就是“紧跟其后”,然后根据存储的位置进行检查:“roll”和“back”都出现在 7,849 篇文章中,但两词紧挨着作为短语出现的文章只有 4,194 篇。
深入阅读:停用词仍然会占用位置
phraseto_tsquery('english', 'roll back the deploy') 会产生 'roll' <-> 'back' <2> 'deploy',因此“roll back a deploy”也能匹配。如果你只是想做搜索,这样已经足够;如果你期待精确字符串,就会觉得意外。
动手验证 · lab 22-search.sh
按邻近程度排名,而不是按频率
匹配只有是或否;排名则负责给这些匹配排序。使用 ts_rank_cd:它计算覆盖密度,也就是查询词彼此靠得有多近,因此短语“failed payment”会胜过相隔数句才出现的两个词。(ts_rank 只计算它们出现的频率。)
深入阅读:三个文档,三种排名
对于查询“failed payment”:
norm 2 列是 ts_rank 使用规范化值 2 的结果。
ts_rank 略微偏好那篇短文的标题匹配;ts_rank_cd 偏好四个相邻命中;两者都会把第三个结果压到后面。默认情况下,标题命中(权重 A)计为正文命中(B)的 2.5 倍。规范化位会把排名得分除以文档某个属性对应的值:2 表示除以文档长度,让长文章的得分降到短文章的四分之一。不存在正确的唯一设置,只有一个得到你的用户认可的设置。
动手验证 · lab 22-search.sh
排名会读取每个匹配项
order by rank limit 20 看起来应该只读取 20 行,但它做不到。只有给每个匹配项都排名之后,才能知道排名最前的 20 个:
索引查找只需 13 个页;其余页用于取出全部 4,206 个匹配项,总计仍是 3,283 个页,和没有 limit 时相同。(这里的 8 ms 包含 EXPLAIN ANALYZE 自身的开销;直接运行时,同样的搜索耗时就是前面引用的 4.6 ms。)“deploy”出现在 19,507 篇文章中,对它排名会读取 97% 的表。GIN 知道哪些行包含某个词素,却不知道它出现了多少次、位于哪里,因此无法先返回最佳结果。这就是内置全文搜索的扩展性上限。
所以要在排名前先缩小范围。在这份语料中,一个租户总共约拥有 50 篇文章,因此租户过滤只留下少量候选,而整个语料库会有 4,206 个候选。给过滤条件建立索引,让规划器可以把它和 GIN 扫描组合起来:在 account_id 上建立 B-树以得到 BitmapAnd,或者通过 btree_gin 扩展建立一个组合 GIN 索引。在多租户应用中,这通常就是完整的修复。
动手验证 · lab 22-search.sh
ts_headline 会再次解析文档
最后再计算摘要,而且只为你展示的行计算。ts_headline 每次调用都会再次解析整篇原文。显示 20 行的页面,无论计算摘要与否都约需 5 ms,因为规划器会在限制行数之后才计算摘要。为全部 4,206 个匹配项生成摘要约需 150 ms;当 API 或视图为每个匹配项都生成摘要时,这就是它要支付的账单。
文档说明,它不能防御跨站脚本攻击。渲染前先转义结果,或者把 StartSel 和 StopSel 设置为特殊标记,让模板先转义结果,再把这些标记转换成标签。
深入阅读:ts_headline 在计划中的位置
对大多数应用来说,Postgres 的搜索已经够用
Postgres 搜索的是你自己的内容,写入与搜索处于同一个事务中,使用相同的权限和租户过滤条件,不需要同步。只有在能明确说出缺少什么时,才考虑更强的工具:把相关性当作产品能力(知道某个词在整个语料中有多稀有的BM25评分、权重提升、同义词),长篇文本的拼写容错(pg_trgm 覆盖短字符串),海量结果集上的分面和计数,或一个字段中包含多种语言。
独立的搜索引擎会带来一条有延迟且会漂移的同步管线、每条权限规则的第二份副本,以及两个系统之间无法共享事务。先从 Postgres 开始。只有当你能展示某个搜索引擎可以回答、而 Postgres 无法回答的查询时,再迁移出去。
搜索扩展把 BM25 放进索引
应该像评价搜索引擎一样评价一个能提供搜索引擎式排名的扩展:使用你的查询和语料,而不是看功能表。话虽如此,BM25 已经不再要求你离开 Postgres。较新的扩展会把词频和语料统计信息保存在索引中,因此无需取出每个匹配项,就能返回最好的 k 个结果。截至 2026 年 10 月,有四个扩展值得了解(我们的实验没有运行其中任何一个;以下内容来自各项目自己的文档):
| 扩展 | 短语、模糊 | 许可证 | 运行位置 |
|---|---|---|---|
| pg_search(ParadeDB) | 是,是 | AGPL-3.0;Enterprise 为商业版 | 自托管、ParadeDB Cloud |
| pg_textsearch(Timescale) | 是,否 | PostgreSQL | 自托管、PostgreSQL 17 和 18 |
| VectorChord-bm25(TensorChord) | 否,否 | AGPL-3.0 或 ELv2 | 自托管 |
| TIN(PlanetScale) | 是,是 | 未发布 | 仅 PlanetScale |
在测试其中一个之前,先确认你的托管平台提供它、它的索引能否经受故障切换,以及查询语法有多大可移植性(只有 pg_textsearch 保留 tsquery)。PlanetScale 的基准测试把 TIN 排在很前面;但这是厂商在测量自己的产品。
深入阅读:四个扩展,各用一段介绍
pg_search(0.26.0,PostgreSQL 15+)在 Tantivy 上建立日志结构合并索引;Tantivy 是一个采用 Rust 编写的 Lucene 风格库。其文档称开源版不会复制搜索索引,因此搜索只在主库上运行;副本功能只有商业版提供。
pg_textsearch(1.5.1)基于本章的配置实现 BM25,并按 order by content <@> 'failed payment' 排名。其 README 指出,任何共享 BM25 索引都有一个限制:语料统计会计算被行级安全隐藏的行,因此一个租户可以推断出其他租户数据的某些信息(不要信任任何租户)。
VectorChord-bm25(0.3.0,2025 年 12 月)对词元计数的稀疏向量排名:没有位置信息,因此不支持短语;按照其 README,它只在英语上测试过。
TIN(2026 年 9 月 16 日正式发布)使用行的ctid作为文档 ID,因此倒排列表会按页、再按元组组织成位图进行压缩。我们没有找到已发布的源代码或许可证;Lead(AGPL-3.0)是一个供开发使用的替代品,自称“有意不适合生产环境”。我们没有复现厂商的基准测试。
向量搜索会找到临近的点,不在乎文字是什么
嵌入模型会把文本转换为浮点数组,并把相似输入放在彼此附近。“找出与这个问题相似的文章”就变成“寻找最近的点”,而不论它们使用什么词。pgvector(实验运行 0.8.7)增加了 vector 类型和距离运算符:<=> 表示余弦距离,这是大多数文本模型需要的距离;<-> 表示欧几里得(L2)距离,<#> 表示负内积,<+> 表示 L1 距离。
一个 1,536 维向量会存进 TOAST
一个常见的 1,536 维嵌入向量有 6,152 字节(每个维度 4 字节,再加 8),占页的四分之三,远远超过约 2 KB 的阈值;Postgres 超过这个阈值后会把值移到行外的 TOAST 中(大值会先压缩,再移出行外)。存储 2,000 个向量时,表有 15 个只保存 ID 和指针的堆页,另有 2,000 个 TOAST 页。任何跳过向量的操作都很便宜:count(*) 读取 15 个页。精确搜索会逐块读取每个向量:12,024 个缓冲区,每个向量约 6 个。
深入阅读:普通存储、halfvec 和 bits:向量存在哪里
把列设置为 storage plain 后,2,000 个向量会内联存储,每个占一个堆页;同样的搜索读取 2,003 个缓冲区。只有在行能装进一个页时这才有效(2,100 个维度会被拒绝:“row is too big”)。halfvec(1536)(pgvector 0.7+)为 3,080 字节,并使用 1,000 个 TOAST 页;经过 binary_quantize() 的 bit(1536) 在堆中只占 200 字节,而且表根本不会有 TOAST 表。两者都是近似形式(halfvec 可以缩小索引,召回率没有可测量的损失会测量它们的召回率)。
动手验证 · lab 22-search.sh
精确搜索是顺序扫描
不要先去使用近似索引。从这里开始,嵌入向量的维度是 384,存放在单独的 doc_embeddings 表中,这样文章列表就不会把它们带进缓存。没有索引时,order by embedding <=> $1 limit 10 会扫描全部 10,000 个页,在这里约需 20 ms。它是精确的,不需要调参;对于几万个向量,或单个租户对应的那份数据,速度已经足够快。
HNSW 会贪心地遍历图,也可能漏掉结果
HNSW(Hierarchical Navigable Small World,分层可导航小世界)是一张图。每个向量都会连接到一些临近邻居;其中少数向量还会出现在更稀疏、连接更长的上层,就像地铁线路上的快车站:
搜索从顶层进入,向查询点靠近,再下降一层;它只接触几百个节点,而不是全部节点,因此可能漏掉贪心遍历无法到达的结果。要给构建过程足够的内存和工作进程:默认 64 MB 的 maintenance_work_mem 且没有并行工作进程时需要 57 秒,256 MB 和两个工作进程时需要 8 秒。索引为 98 MB,比 78 MB 的表更大。
ef_search 用延迟换召回率
查询时有一个设置很重要:hnsw.ef_search,它控制候选列表的大小(默认 40)。Recall@10 是真实的十个最近邻中最终返回的比例:
hnsw.ef_search | Recall@10 | 每次查询毫秒数(我们的测量) | 缓冲区,单次查询 |
|---|---|---|---|
| 40(默认) | 0.969 | 0.7 | 722 |
| 400 | 0.995 | 4.0 | 3,925 |
| 精确搜索(无索引) | 1.000 | 21 | 10,035 |
在默认设置下,只需十四分之一的页数,就能得到真实邻居的 97%。HNSW 构建过程带有随机性,因此每次构建的召回率会相差几个百分点:在我们的多次运行中,默认设置得到 0.92 到 0.99,ef_search 400 得到 0.98 到完整的 1.0。这就是“近似”的含义,也是你要在自己的数据上测量召回率的原因。对于高风险查询,按查询提高它(set local hnsw.ef_search = 100),并将真实查询样本与精确搜索比较,在自己的数据上测量召回率。
深入阅读:构建设置,以及不同构建之间召回率变化多少
在 64 MB 下,构建会警告 hnsw graph no longer fits into maintenance_work_mem after 28369 tuples。我们的计时从串行构建开始(max_parallel_maintenance_workers = 0;服务器默认允许两个工作进程)。仅增加内存(256 MB)就把耗时从 57 秒降到 23 秒;仅增加两个并行工作进程则降到 17 秒;两者都增加时降到 8 秒。并行构建会把图保存在共享内存中,因此在 Docker 中应给容器更大的 /dev/shm。
这轮参数测试还测得 10(0.956,340 个缓冲区)、20(0.968)、100(0.986,1,684 个缓冲区)和 200(0.986)。构建不是确定性的:pgvector 会随机选择每个向量的图层级(setseed 不能影响它),而并行构建每次插入顺序都不同。在我们的多次运行中,默认设置的召回率范围为 0.923 到 0.992,因此实验检查的是 0.9 或更高,而不是某一次运行的数字。
动手验证 · lab 22-search.sh
IVFFlat 的默认一次探测是陷阱
IVFFlat 在构建时把向量聚成 lists 个组,并且只搜索距离查询点最近的 probes 个组。它的构建耗时不到 2 秒,而 HNSW 要 8 秒;它的默认设置却是陷阱:50 个组且 ivfflat.probes = 1 时,recall@10 只有 0.636。5 次探测达到 1.000。应从 sqrt(lists) 开始,并在加载数据后构建,因为这些组来自当时已经存在的行。我们的默认选择是 HNSW。
halfvec 可以缩小索引,召回率没有可测量的损失
表可以继续保存 4 字节浮点数,而索引保存更小的形式:using hnsw ((embedding::halfvec(384)) halfvec_cosine_ops) 把索引从 98 MB 缩小到 56 MB,召回率没有可测量的损失。二值量化每个维度只使用一位,生成了一个 17 MB 的索引,找回真实近邻的比例为 0.303;按完整向量对 400 个候选重新排名后,比例为 0.915(先用位向量取 400 个最近邻,再进行精确重排)。先在自己的嵌入向量上测量。
深入阅读:对二值候选重新排名
位索引是 using hnsw ((binary_quantize(embedding)::bit(384)) bit_hamming_ops),而且 hnsw.ef_search 必须至少等于内层查询的行数上限。对 100 个候选重新排名得到 0.747。高斯噪声是单比特的困难案例;真实模型(其中一些就是为此训练的)表现会好得多。在这次构建中,半精度的 0.980 胜过全精度的 0.969:这是建图运气。
动手验证 · lab 22-search.sh
过滤条件与近似索引不能混用
使用近似索引时,where 会在索引扫描之后运行:HNSW 找到 ef_search 个最佳候选,Postgres 丢弃不满足过滤条件的候选,剩下的才是答案。kind = 'faq' 每十篇文章中匹配一篇。请求十篇:
结果只有四行,100 次查询平均只返回 3.7 行,有些查询一行也没有。你的应用请求十行,却只拿到四行,而且没有报错,这是最糟糕的一种错误。
对于宽泛过滤条件,打开 pgvector 0.8 的迭代索引扫描(set hnsw.iterative_scan = relaxed_order),它会持续获取候选,直到得到足够多符合过滤条件的结果:每次都返回十行,但页数超过原来的三倍。对于高选择性的过滤条件,在过滤列上建立 B-树:对于某个租户的 46 篇文章,规划器使用该索引并计算 46 个精确距离,读取 83 个页,不使用 HNSW。每个值一个部分 HNSW 索引,或分区,可以覆盖剩余情况。
深入阅读:迭代扫描、顺序和租户计划
使用 relaxed_order 时,一次查询读取 2,529 个页,而未开启迭代扫描时读取 708 个页;它会扫描最多 hnsw.max_scan_tuples(20,000)个候选。结果可能并不严格按距离排序;strict_order 会保持顺序,但召回率会降低。连接表上的过滤条件无法在扫描期间应用,这就是 account_id 和 kind 会被复制到 doc_embeddings 上的原因。
动手验证 · lab 22-search.sh
关键词和向量的失败方式不同,所以要融合
全文搜索能精准命中确切词语(错误代码、“SAML”),却会漏掉释义相同的表达:“card declined”在我们的语料中什么也匹配不到。向量能找到这种释义,却会模糊确切词语。
它们的分数没有共同尺度,因此应改为组合排名位置:倒数排名融合(RRF)会从每个返回该文档的列表中按 1 / (60 + rank) 给它一个分数,再按总和排序。两张列表都找到的文章总会胜出:每张列表返回 50 个结果时,两个至少为 1/110 的分数之和,会超过最好的单个分数 1/61。对于“card declined”,向量一侧会填满全部十个位置。
把查询向量作为参数传入:如果查询向量来自连接所得行中的某一列,距离计算就无法使用 HNSW 索引。
深入阅读:把混合搜索写成一个 SQL 函数
以计费主题的中心作为查询嵌入向量时,前十名会交错来自两个列表。在一次索引构建中,排名第一的结果在文本列表中排第 35、在向量列表中排第 19;另一次构建中,没有文章同时进入两个列表的前 50。HNSW 图每次构建都不同,因此向量列表也不同。常数 60 来自 RRF 论文,该论文称它为 k;我们函数中的 k 只是结果数量。
动手验证 · lab 22-search.sh
向量数据库需要一个说得出的理由
把属性说清楚:数亿个向量、繁忙主库无法满足的延迟、迭代扫描读取过多页的过滤搜索。若需求还没达到这些程度,让向量和行放在一起更有优势:一份备份、一套权限模型,以及与文本在同一事务中更新的嵌入向量。
本章要点
- 大多数产品根本不需要搜索引擎。只有为了一个说得出的属性才迁移:BM25 相关性、拼写容错、规模化分面。
- 到处传入文本搜索配置。
- 存储
tsvector:通过表达式索引排名耗时 170 ms,而存储列只需 4.6 ms。 - 对用户输入的一切内容使用
websearch_to_tsquery。 - 排名会读取每个匹配项。先按租户或日期缩小范围。
- 只为展示的行计算
ts_headline,并对其进行转义。 - 1,536 维的
vector会存进 TOAST:跳过它不增加开销,但扫描每个向量要访问 6 个缓冲区。 - 先做精确搜索。默认 HNSW 的召回率为 0.92 到 0.99,只需十四分之一的页数。
- 过滤条件在近似扫描之后运行:打开
hnsw.iterative_scan,或用 B-树对高选择性过滤后的数据进行精确搜索。
延伸阅读
- Full Text Search,PostgreSQL 文档。整个子系统。
- Controlling Text Search,PostgreSQL 文档。排名、规范化以及
ts_headline的代价和 XSS 警告。 - ParadeDB architecture,ParadeDB 文档。Postgres 内部的 Tantivy 索引,以及哪些内容会复制。
- pg_textsearch,源代码仓库。基于 Postgres 配置的 BM25,以及它的限制。
- Introducing TIN,PlanetScale 博客,2026 年 9 月。以
ctid作为文档 ID 的设计。 - pgvector,pgvector 项目。README 就是手册。
- Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs,Yu. A. Malkov 和 D. A. Yashunin。HNSW 论文。
- Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods,Gordon V. Cormack、Charles L. A. Clarke 和 Stefan Büttcher,SIGIR 2009。介绍 RRF 的两页论文。