读者搜“事务”,数据库里明明有《数据库隔离级别》,结果却是空的。工程师给正文加了 GIN 索引,搜索还是找不到。另一个页面用 ILIKE '%事务%' 能返回文章,但数据增长后开始扫描整张表。这两个现象很容易被归到“PostgreSQL 搜索不好用”,实际上,前者没有生成查询需要的词项,后者缺少能缩小扫描范围的索引条件。
先把这两件事分开。索引帮助数据库少读数据,匹配规则决定哪些数据应该返回。换索引不会让连续中文自动变成词,也不会让“事务”与“交易”成为同义词。对一个技术博客,能够解释这些差异的搜索,往往比叠上几种分数却说不清排序原因的方案更容易维护。
下面从七条记录做起,再用三万条标题观察访问路径。实验在独立的 PostgreSQL 18.6、aarch64 Alpine 容器中执行,没有连接业务数据库。表、标题和英文正文都是测试数据;文中的行数与计划来自这次运行,耗时只说明这个实验发生了什么,不作为生产容量承诺。
七条记录先把需求分开
这个语料故意包含几种会混淆结果的情况:标题直接包含中文词、正文包含但标题没有、英文技术词、通配符,以及一篇不能公开的草稿。
CREATE TABLE posts (
id integer PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
status text NOT NULL
);
INSERT INTO posts VALUES
(1, '事务提交以后发生了什么',
'PostgreSQL transaction commit and rollback', 'PUBLISHED'),
(2, '数据库隔离级别',
'数据库事务隔离级别', 'PUBLISHED'),
(3, '交易记录的重复请求',
'Use an idempotency key after a timeout', 'PUBLISHED'),
(4, '100%_可靠! 的说法',
'Literal wildcard example', 'PUBLISHED'),
(5, 'transaction recovery',
'Recover a transaction after commit', 'PUBLISHED'),
(6, '事务草稿', 'secret draft', 'DRAFT'),
(7, '交易和事务不是同一个词',
'A transaction is not a trade', 'PUBLISHED');
建好表后,先写下三条验收要求。搜“事务”,标题路径返回 1、7,正文路径再补 2;6 是草稿,任何公开路径都不能返回它。搜 transactoin,系统可以提示 transaction,但不要假装用户一定想搜这个词。搜 100%_可靠!,百分号和下划线应按字面匹配,只返回 4。
这份期望结果很小,却能区分匹配、授权、纠错和转义四类问题。以后换索引或调整排序,都先跑这几条断言。否则一次“召回率提高”可能只是把草稿也算进来了。
这里没有承诺自然语言问答。“数据库提交后还能撤回吗”需要同义表达、领域知识或更复杂的检索策略,不能从前述三个测试通过推导出来。第一版明确支持什么,比一个无所不能的搜索框更容易让读者建立预期。
先看分词器实际产出了什么
执行下面这条查询,不建索引也能看见问题:
SELECT alias, token, lexemes
FROM ts_debug('simple', '数据库事务隔离级别');
本次环境只返回一行:
alias: word
token: 数据库事务隔离级别
lexemes: {数据库事务隔离级别}
正文产生的词项是整段连续文字;查询“事务”产生的是另一个词项。于是下面的全文条件没有命中 2,实验结果为零行:
SELECT id, title
FROM posts
WHERE status = 'PUBLISHED'
AND to_tsvector('simple', body)
@@ plainto_tsquery('simple', '事务');
这不是索引丢记录。即使逐行计算,两个词项也不相等。把同一表达式放进 GIN,只能更快得到同样的空集合。要实现中文按词搜索,接入方必须选择中文切词流程,同时处理文档与查询;还要决定“隔离级别”是否保留成词,“MVCC”与中英文混排怎么处理。
切词会改变结果语义。词典更新之后,同一篇文章可能产生另一组词项,必须明确重新计算的是文档词项还是索引,不能把两者统称为“重建”。对文章不多、标题中的技术关键词较完整的博客,可以先提供字面包含,暂缓引入分词依赖。这是一个范围明确的产品选择,代价是不能靠它解决中文同义表达。
英文全文检索可以独立加入。下面将标题权重设为 A、正文设为 B,明确选择 english 配置:
ALTER TABLE posts ADD COLUMN search_en tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english'::regconfig, title), 'A') ||
setweight(to_tsvector('english'::regconfig, body), 'B')
) STORED;
CREATE INDEX posts_search_en
ON posts USING GIN (search_en)
WHERE status = 'PUBLISHED';
SELECT id, title
FROM posts
WHERE status = 'PUBLISHED'
AND search_en @@ websearch_to_tsquery('english', 'transaction')
ORDER BY id;
本次得到 1、5、7,符合三条记录中的英文内容。正式接口将查询文字作为驱动参数传入。websearch_to_tsquery 解析搜索表达式,不替代 SQL 参数绑定;英文词干处理也不会把中文问题自动译成英文。字段权重和查询构造的接口细节见 PostgreSQL 全文检索控制文档。
这里的 search_en 是存储生成列,表里已经保存了计算后的词项。假设以后更换词典,让某个术语从一个词项变成两个词项,旧记录不会因为配置文件变了就自动获得新结果。只对 search_en 上的 GIN 执行 REINDEX,读取的仍是列中已经保存的向量;索引可以重建成功,搜索却继续使用旧切词。这与直接以 to_tsvector 表达式建索引的维护对象不同,不能把一种重建步骤照搬给另一种结构。
对存储向量,迁移方案需要让历史文档重新经过目标配置,随后让查询端使用同一套规则。例如先建立独立命名的新配置和新向量列,计算历史记录,为新列建索引,再比较固定查询的结果;确认后切换读取路径。这样可以保留旧结果用于对照,也能知道某次召回变化来自配置还是文章更新。这个过程会产生写入和索引维护工作,不能把它描述成零成本的配置切换;本文没有执行这项迁移,也没有测量其耗时。
验证迁移不应只看新列非空。可以取一篇明确含目标术语的文档,分别检查旧、新向量,再检查查询端产生的词项。若文档已经按新规则拆开,查询仍产生旧词项,候选就可能消失;反过来也一样。迁移验收至少需要“目标词能够召回、原有重要词没有意外丢失、草稿仍被过滤”这三类结果。只有索引大小或建立成功日志,不能证明搜索语义已经完成迁移。
一个实用的调试入口可以显示“标题包含”“正文包含”“英文词项”这样的匹配原因。它不必面向普通读者长期展示,却能让维护者区分:文章没被召回,还是被召回后排在第二页。
百分号是用户文字,也可能是查询语言
如果搜索框默认提供字面包含,必须处理 %、_ 和选定的转义字符。绑定参数能防止 SQL 注入,却不会阻止 % 扩大匹配范围。二者承担不同职责。
SELECT id, title
FROM posts
WHERE status = 'PUBLISHED'
AND title ILIKE '%100!%!_可靠!!%' ESCAPE '!'
ORDER BY id;
实验只返回 4。应用层先把 ! 变成 !!,再把 % 变成 !%、_ 变成 !_,最后加两端百分号,通过参数提交。替换顺序要固定,否则新加入的转义字符可能被再次处理。
有些产品允许高级用户自己写通配符,那是另一份契约。此时需要标清模式搜索、限制复杂度和执行时间,不能让普通输入框在没有提示的情况下切换语义。用户粘贴错误码或文件名时,下划线是正常字符,搜索接口不应偷偷把它解释成任意一个字符。
空输入也应在应用侧处理。把空字符串拼成 %% 等于匹配所有标题;这可能是文章列表的合法行为,却不一定适合搜索接口。应明确返回初始提示还是返回列表,不要依赖数据库自然给出结果。
两个汉字能返回结果,不代表能用索引缩小范围
给标题增加 trigram 索引:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX posts_title_trgm
ON posts USING GIN (title gin_trgm_ops)
WHERE status = 'PUBLISHED';
七条记录不足以讨论性能。另建一张 scale_posts,生成三万条标题,每一千条插入一个 数据库事务_编号,其余为 engineering note 编号。测试表上建立完整标题 trigram 索引并执行 ANALYZE,然后对两个包含条件分别运行 EXPLAIN (ANALYZE, BUFFERS)。
CREATE TABLE scale_posts AS
SELECT n AS id,
CASE WHEN n % 1000 = 0
THEN '数据库事务_' || n
ELSE 'engineering note ' || n
END AS title
FROM generate_series(1, 30000) n;
CREATE INDEX scale_title
ON scale_posts USING GIN (title gin_trgm_ops);
ANALYZE scale_posts;
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM scale_posts WHERE title ILIKE '%事务%';
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM scale_posts WHERE title ILIKE '%数据库事务%';
两次查询都返回 30 条记录,但访问路径不同:
| 条件 | 本次计划 | 返回行 | 额外观察 |
|---|---|---|---|
title ILIKE '%事务%' |
Seq Scan | 30 | 过滤掉 29,970 行 |
title ILIKE '%数据库事务%' |
Bitmap Index Scan + Bitmap Heap Scan | 30 | 访问 30 个堆页面 |
第一条计划的执行时间为 6.893 毫秒,第二条为 0.073 毫秒,均为单次本地记录。不能把这两个数字当成“优化了九十多倍”:两条查询文字不同,只是在这份特殊语料里碰巧得到同一集合。真正值得观察的是,短词没有足够的可用三元组帮助缩小候选,而较长模式走了索引。
强行关闭顺序扫描不会改变短词缺少筛选信息的事实。优化器可能在顺序扫描和代价很高的索引扫描之间选择,不能看到 Seq Scan 就判定数据库失误。小表顺序扫描本来就可能更便宜。若业务要求一个字也搜正文,团队必须接受对应成本,或提供明确的搜索范围限制。
这次合成表的数据文件为 2,048 kB,标题索引为 1,184 kB。真实标题更长、字符更分散、写入更多时,比例会变化。若还给长正文建同类索引,应该单独测量索引体积、写入时间与维护成本,不能把标题实验推广过去。trigram 对短模式的限制及 GIN、GiST 的能力边界见 pg_trgm 文档。
对于这个博客式样例,我会先让一两个字只搜索标题,输入更长关键词后才扩展正文,并在界面说明范围。另一个可行选择是全范围查询但设置超时;超时就明确告诉读者查询未完成,不能截取一部分候选却展示“共找到这些文章”。限制应出现在产品契约中,而不是隐藏在某个 LIMIT 后面。
纠错先去小词表里找候选
把错拼词和一整篇正文计算相似度,解释起来很困难。先用术语表寻找建议更可控:
CREATE TABLE terms (term text PRIMARY KEY);
INSERT INTO terms VALUES
('transaction'), ('transport'), ('translation'),
('commit'), ('rollback');
CREATE INDEX terms_distance
ON terms USING GiST (term gist_trgm_ops);
SELECT term, similarity(term, 'transactoin') AS score
FROM terms
ORDER BY term <-> 'transactoin', term
LIMIT 3;
本次结果是 transaction=0.5000、transport=0.2941、translation=0.2632。最接近的建议很合理,但这仍然不是用户意图的证明。界面可以提供“是否搜索 transaction”,保留原输入和原结果;不要无声地把查询替换掉。
这张小表不需要索引才能跑得快,建 GiST 是为了展示距离排序的访问方式。真实术语表可从标题、技术标签或经人工确认的词汇生成。词表来源决定建议质量:把未经筛选的正文碎片全部塞进去,会产生数量很多、业务上没用的近似词。
还要考虑新词进入和旧词退出。发布一篇介绍新框架的文章后,纠错词表若几天后才更新,系统可能持续把正确的新词改成旧词。词表与文章可以异步维护,但更新延迟应可观察。删除文章不一定立即删除对应术语;只有没有来源或失去用途的词才需要清理。
先让排序规则可解释,再谈更精细的相关性
第一版可以按三级排列:标题完全相等、标题包含、正文包含。同一层按固定 ID 倒序,用一个 CASE 为每篇文章分配唯一层级,避免分别 UNION 三路结果后出现重复文章。
下面给出可在 psql 中执行的参数化入口。先在独立测试库按前文建好 posts 表;若使用原实验创建的 searchlab 模式,连接后先执行 SET search_path=searchlab,public;。整个 PREPARE 到 DEALLOCATE 片段在同一会话运行,参数由 EXECUTE 提供,不能单独执行其中带 $1 的 SELECT。这里补全的是调用方式,未重新运行实验;后面的既有结果仍来自原实验的常量输入。
PREPARE search_posts(text) AS
WITH input AS (
SELECT $1::text AS q,
'%' || replace(replace(replace($1, '!', '!!'),
'%', '!%'), '_', '!_') || '%' AS pattern
), candidates AS (
SELECT p.id, p.title,
CASE WHEN p.title = i.q THEN 0
WHEN p.title ILIKE i.pattern ESCAPE '!' THEN 1
ELSE 2 END AS tier
FROM posts p CROSS JOIN input i
WHERE p.status = 'PUBLISHED'
AND (p.title ILIKE i.pattern ESCAPE '!'
OR p.body ILIKE i.pattern ESCAPE '!')
)
SELECT id, title, tier
FROM candidates
ORDER BY tier, id DESC
LIMIT 20;
EXECUTE search_posts('事务');
EXECUTE search_posts('100%_可靠!');
DEALLOCATE search_posts;
在应用驱动中,提交的 SQL 是上面的 WITH 到 LIMIT 部分,参数数组包含原始搜索文字;不需要把 PREPARE 和 EXECUTE 字符串拼进请求。转义在 input 中完成一次,因此调用方传入“100%_可靠!”即可,不应先传入带 ! 的 LIKE 模式再让 SQL 重复转义。绑定参数解决文字与 SQL 结构的分离,模式转义解决用户字符与通配符的分离,二者仍然各做各的工作。
这个例子将“标题完全相等”定义为等号比较,而包含路径采用 ILIKE。英文大小写变化可能命中包含层,却没有进入完全相等层;这不是去重失败,而是两层比较规则不同。若产品要求忽略大小写的完全相等,应另行定义归一化方式并同步修改相关性样例,不能凭一次中文查询通过就认定所有语言的层级都符合预期。
实验将输入固定为“事务”,得到 7、1、2,前两条来自标题,最后一条来自正文。这个排序没有宣称 7 的知识质量高于 1,只说明同层使用 ID 打破并列。若文章发布时间比 ID 更符合读者习惯,可以换成发布时间和 ID,但必须同时调整分页游标。
深页分页还要固定查询条件。游标至少绑定查询文字、搜索范围、排序版本和末条记录;查询改了就从第一页开始。文章在两页之间更新内容,可能从正文层移到标题层,因此稳定排序不等于跨请求快照。普通阅读列表可以接受内容变化,固定名单导出则需要另一套一致性方案。
给每个查询写下“为什么相关”
七条记录足以检查机制,却不足以证明搜索质量。可以在这份语料上继续做人工判断:用户输入“事务”,如果需求只是寻找出现这个字符串的公开文章,那么 1、2、7 都应保留,标题优先也容易解释;如果用户想学习事务提交,1 的主题更直接,7 虽然命中标题,仍可能只是概念辨析。此时 7 排在 1 前面暴露的是同层按 ID 排序的限制,不是数据库漏查,也不意味着应把 7 永久排除。
再看记录 3 的“交易记录的重复请求”。它没有字面上的“事务”,按当前中文路径不应召回。编辑可能认为重复请求与事务处理相关,但这是一项扩展语义的决定,需要新的查询样例和人工标注支持。把“交易”直接列为“事务”的同义词,会同时改变其他读者搜索金融交易的结果。词典里的一个映射,可能跨越了原先分开的两个主题,不能只凭多返回一篇文章判断改善。
纠错也应单独评价。对错拼 transactoin,术语表把 transaction 排第一,说明候选生成对这个例子有用;它没有证明建议适合所有英文输入,更没有证明点击建议后的文章排序有用。用户接受建议后,可以进入已实测的英文 FTS 路径得到 1、5、7,再由标题与正文内容判断哪篇回答问题。候选词准确和文档相关是两个检查点,合在一个成功率里会掩盖失败位置。
实际记录判断时,每条样例至少保留查询文字、搜索范围、期望文档及理由。例如“事务/标题范围”期望 1、7,“事务/标题和正文范围”还应有 2;这两份集合不能共用一个漏检结论。给定人工相关集合后,再计算返回结果中相关项的比例,以及相关项被找回的比例。语料很小时直接逐项列出比百分比更直观,也能避免把一个样例的成绩写成整体搜索质量。
这些判断属于基于现有内容的编辑分析,没有新增数据库实测。它们提供的是下一次调整排序的对照问题:保留哪些结果、改变哪些位置、为什么改变。如果一条规则让“事务提交”更好,却让错误码搜索因为字符归一化而失准,就应分别记录得失;一个平均分不能替代这些具体的产品选择。
英文 FTS 和纠错候选不要直接混成一个总分。先把每条路径的结果及相关性样本保存下来,弄清谁补了漏检、谁带来噪声,再决定是否融合。否则一条很高的 similarity 可能压过正文真正回答问题的文章,维护者还解释不出分数代表什么。
最后一个容易漏掉的出口是高亮。正文存进数据库并不等于可信 HTML;高亮器生成的片段也不能直接交给 innerHTML。小站可以先显示纯文本摘要,确有需要再使用受控标记并清洗输出。搜索已经找到正确文章,不应该在展示摘要时引入新的安全问题。
这套实现到这里完成了几个具体承诺:中文按字面查、英文按词项查、错拼给出建议,草稿不进入公开集合,结果能说明来自哪里。下一次读者报告“找不到”时,可以带着他的查询逐层核对词项、候选与排序。缺少的如果是中文同义表达,再评估中文分词或独立检索服务;不要先换引擎,再重新发现同一批未定义的需求。
原文来自 daodao.ee,本文保留原发布时间。











